Devin.KR

비동기 완료와 오류

90분 안팎

학습 목표

Promise 완료를 기다리고 예외를 실패로 전달합니다.

개념

검사가 끝났다는 사실도 검증합니다

응답을 기다리는 코드가 시작되었다고 결과를 확인한 것은 아닙니다. 비동기 작업을 호출한 직후 성공을 출력하면 아직 본문이 도착하지 않았는데 테스트가 끝날 수 있습니다. QA 자동화의 완료 조건에는 올바른 비교뿐 아니라 그 비교가 실제로 끝났다는 사실도 들어갑니다. 이번 레슨은 Promise의 완료를 기다리고 오류가 호출자까지 전달되는 경로를 직접 확인합니다.

네트워크 없이 성공·거절·예외를 반환하는 합성 loader를 사용합니다. 실제 지연 시간의 숫자를 비교하지 않고 Promise가 해결되기 전에는 완료 표시가 나오지 않는지를 봅니다. 시간은 실행 환경마다 달라질 수 있어 10밀리초 뒤면 끝났다고 가정하지 않습니다. 작업이 끝났다는 신호를 기다리는 방식은 다음 UI 자동화의 상태 기반 대기로도 이어집니다.

Promise는 값 그 자체가 아닙니다

Promise는 나중에 얻을 값 또는 실패를 표현합니다. 대기 중인 pending, 값으로 완료한 fulfilled, 오류로 거절한 rejected 상태로 구분합니다. async 함수는 값을 return해도 Promise를 반환합니다. 따라서 const body = load()만으로는 body가 응답 객체라고 가정할 수 없습니다. await load()로 완료 값을 받은 뒤 이전 레슨의 검증 함수에 전달합니다.

await는 비동기 함수 안의 나머지 작업을 해당 결과 뒤로 미룹니다. 프로그램 전체를 멈추지는 않습니다. 같은 파일에서 호출 직후 실행되는 코드는 await 뒤의 문장보다 먼저 실행될 수 있습니다. 순서를 눈으로 확인하려면 호출 전, 함수 시작, 호출 직후, 완료 후처럼 위치를 이름으로 출력합니다. 타임스탬프보다 이 순서 표시가 처음에는 동작을 이해하기 쉽습니다.

실습 파일은 require를 사용하는 CommonJS이므로 최상위에 await를 바로 쓰지 않습니다. async function main()을 만들고 그 안에서 기다립니다. main().catch(...)로 최상위 오류도 처리합니다. await is only valid 같은 SyntaxError가 나오면 비동기 함수 밖에서 await를 썼는지 확인합니다. 모듈 방식의 차이를 제품 응답 오류로 해석하지 않습니다.

예외는 기다린 자리에서 읽습니다

throw new Error는 현재 작업을 정상 반환하지 않고 실패 경로로 이동시킵니다. async 함수 안에서 던진 오류는 거절한 Promise로 전달됩니다. try 안에서 await한 작업이 거절되면 catch가 해당 오류를 받습니다. catch에서 오류를 기록한 뒤 계속 실패로 전달해야 한다면 throw error를 사용합니다. 로그를 썼다는 이유로 성공 true를 반환하면 실패가 사라집니다.

응답이 BLANK_NICKNAME인 것은 정상적인 업무 거절이고, loader가 연결 오류로 reject한 것은 관찰 자체의 실패입니다. 전자는 expected가 같은 판정을 기대하면 통과할 수 있습니다. 후자는 가입 성공 여부를 확인하지 못했으므로 성공으로 집계하지 않습니다. 실습의 문자열 NETWORK는 가짜 loader가 던진 오류 이름이며 실제 외부 접속 장애를 관찰했다는 주장은 아닙니다.

오류를 다루는 책임도 함수별로 나눕니다. validateAsync는 완료를 기다리고 검증하며 오류를 원본 그대로 호출자에게 전달합니다. 사용자에게 여러 사례를 보여 주는 집계 함수는 사례별 catch에서 FAIL 행을 만듭니다. 집계가 오류를 행으로 바꿨다면 마지막 요약이 성공인지 실패인지 별도로 판정해야 합니다. 예외 전달 함수와 출력 집계 함수가 같은 반환 계약이라고 가정하지 않습니다.

여러 사례를 끝까지 모읍니다

첫 연습은 for...of 안에서 각 작업을 await하는 순차 실행입니다. 행마다 하나의 응답과 판정이 완성된 뒤 다음 행으로 갑니다. 로그인 뒤 얻은 세션으로 프로필을 조회하는 흐름처럼 앞 결과가 필요한 경우에는 순서가 의미를 가집니다. 반면 이미 분리한 합성 자료의 형태 검사에는 그런 의존이 없습니다. 실행 전략은 빠른 방법보다 데이터와 상태의 관계에서 정합니다.

items.forEach(async item => ...) 뒤에 console.log(results)를 놓으면 콜백이 반환한 Promise를 forEach가 기다리지 않아 빈 결과가 나올 수 있습니다. 콜백 안에서 await를 사용했다는 사실만으로 바깥 코드도 기다린 것은 아닙니다. 순차라면 for...of로 고치고, 독립 작업을 함께 시작한다면 map으로 Promise 배열을 만든 뒤 그 배열 전체를 기다립니다.

Promise.all은 묶은 작업 중 하나가 거절되면 거절 결과를 전달하지만 다른 작업을 자동으로 취소하지 않습니다. 다른 작업이 저장을 계속할 수 있어 공유 상태를 쓰는 테스트에 무심코 적용하지 않습니다. 이번 브라우저 실습은 입력된 독립 가짜 작업을 Promise.allSettled로 기다립니다. 각 작업의 성공과 거절을 모두 받은 뒤 입력 순서로 testId와 결과를 출력합니다.

allSettled의 각 결과에는 fulfilled 또는 rejected 상태가 있습니다. fulfilled에는 value, rejected에는 reason을 읽습니다. 모든 작업이 settled되었다고 모두 성공한 것은 아닙니다. rejected 행을 FAIL로 표시하지 않으면 거절이 요약에서 사라집니다. 결과 배열은 완료 속도와 무관하게 입력 순서에 대응하므로 인덱스로 작업 ID를 연결할 수 있습니다.

고정 대기 대신 완료 신호를 만듭니다

로컬 테스트에는 직접 resolve를 호출할 수 있는 Promise를 준비합니다. loader가 그 Promise를 반환하게 하고, 검증 작업을 시작한 뒤 아직 완료하지 않았는지 확인합니다. 그 다음 정상 본문으로 resolve하고 검증 결과를 await합니다. 이 과정은 몇 밀리초가 지났는지와 관계없어 느린 컴퓨터에서도 의도가 같습니다. 조기 성공을 반환하는 코드라면 resolve 전에 완료 표시가 바뀌어 실패합니다.

비동기 실패를 기대할 때는 await assert.rejects를 사용합니다. rejects가 반환한 Promise까지 기다려야 확인 작업도 테스트 수명 안에 들어옵니다. await가 없으면 테스트가 끝난 뒤 비동기 활동 경고가 나거나 처리되지 않은 거절로 실행이 실패할 수 있습니다. 그래서 누락이 항상 조용히 통과한다고 단정하지 않고 완료와 오류 전달이 보장되지 않는 문제로 읽습니다.

동기 실패 확인은 assert.throws, Promise 거절 확인은 assert.rejects로 구분합니다. async 함수를 throws로 감싸면 그 함수가 반환한 Promise의 나중 실패를 잡지 못합니다. Missing expected rejection 메시지는 거절을 기대했는데 해당 Promise가 성공으로 끝났음을 뜻합니다. catch에서 true를 반환했는지 또는 검사할 작업 대신 다른 Promise를 기다렸는지부터 확인합니다.

완료 요약을 검토합니다

브라우저 실습의 loader는 입력 항목 kind에 따라 true, false 또는 Error를 비동기로 만듭니다. true이면 PASS, false이면 FAIL MISMATCH, throw이면 FAIL 뒤에 message를 출력합니다. 항목 ID와 kind가 올바르고 throw의 message가 문자열인 JSON 배열을 입력 계약으로 사용합니다. 빈 배열이면 DONE 0 하나만 출력합니다. 실제 요청 전송을 흉내 낸 형식일 뿐 서버를 실행하지 않습니다.

결과는 항목별 한 줄 뒤에 DONE과 처리 수를 붙입니다. DONE은 종료한 작업 수이며 성공 수가 아닙니다. FAIL 행이 있으면 후속 러너에서는 실패 종료 코드로 연결해야 합니다. 모든 행이 나온 뒤에만 DONE을 출력하여 일찍 요약한 문제를 발견할 수 있습니다. 각 오류가 올바른 ID와 연결되어 있는지도 확인합니다. 이 구조로 실패를 숨기지 않는 테스트 러너를 다음 레슨에서 완성합니다.

따라하기

await 전후 순서

호출 직후와 완료 뒤 순서를 확인합니다. await 뒤의 출력보다 호출 직후 출력이 먼저 나옵니다.

async function load(){console.log('start');await Promise.resolve();console.log('completed');}
console.log('before');load();console.log('after call');

실행 결과

before
start
after call
completed

거절을 실패로 모으기

가짜 거절을 await한 자리에서 catch합니다. NETWORK는 실제 접속 결과가 아닌 합성 오류입니다.

async function main(){
 try{await Promise.reject(new Error('NETWORK'));}
 catch(e){console.log('ASYNC-01 FAIL '+e.message);}
}
main().catch(e=>{console.error(e);process.exitCode=1;});

실행 결과

ASYNC-01 FAIL NETWORK

전체 완료 뒤 집계

독립 Promise의 성공·거절을 모두 받은 뒤 DONE을 출력합니다. fulfilled 수와 전체 완료 수를 구별합니다.

async function main(){
 const r=await Promise.allSettled([Promise.resolve(true),Promise.reject(new Error('MISMATCH'))]);
 r.forEach((x,i)=>console.log('ASYNC-'+(i+1),x.status));
 console.log('DONE',r.length);
}
main().catch(e=>{console.error(e);process.exitCode=1;});

실행 결과

ASYNC-1 fulfilled
ASYNC-2 rejected
DONE 2

원본 오류 전달 확인

assert.rejects도 기다립니다. 동일한 오류 객체가 호출자까지 전달되는지 확인합니다.

const assert=require('node:assert/strict');
async function main(){
 const failure=new Error('NETWORK');
 async function check(){return await Promise.reject(failure);}
 await assert.rejects(()=>check(),e=>e===failure);
 console.log('rejection PASS');
}
main().catch(e=>{console.error(e);process.exitCode=1;});

실행 결과

rejection PASS

확인 문제

실습

入力は항목 배열입니다. 각 항목 id는 문자열, kind는 pass·mismatch·throw 중 하나이며 throw에는 message 문자열이 있습니다. 제공 load는 pass이면 true, mismatch이면 false, throw이면 Error로 거절합니다. load를 유지하고 모든 완료를 기다려 입력 순서로 id PASS 또는 id FAIL MISMATCH 또는 id FAIL 메시지를 출력합니다. 마지막 줄은 DONE 항목수입니다. 빈 배열은 DONE 0입니다. 가짜 작업만 사용합니다.

모범 답안
const fs=require('fs');
const value=JSON.parse(fs.readFileSync(0,'utf8'));
async function load(x){
 await Promise.resolve();
 if(x.kind==='throw')throw new Error(x.message);
 return x.kind==='pass';
}
async function main(){
 const results=await Promise.allSettled(value.map(load));
 results.forEach((r,i)=>console.log(value[i].id+' '+(r.status==='rejected'?'FAIL '+r.reason.message:r.value===true?'PASS':'FAIL MISMATCH')));
 console.log('DONE '+results.length);
}
main().catch(e=>{console.error(e);process.exitCode=1;});

더 읽기

면접 질문

  • 실행 순서에 따라 결과가 달라지는 테스트를 확인하는 방법을 설명해 주시면 됩니다.