Devin.KR

Promise와 오류 전파

150분 안팎

학습 목표

비동기 완료와 실패의 실행 순서를 설명합니다.

개념

먼저 실행한 줄과 먼저 완료된 일을 나눕니다

사용자가 검색을 제출하면 요청 시작은 즉시 기록되지만 행사 데이터는 나중에 도착합니다. 기다리는 동안 검색 폼과 브라우저는 다른 일을 할 수 있습니다. 요청 함수를 호출한 다음 줄에서 결과 배열이 준비됐다고 생각하면 빈 화면이나 undefined를 만들게 됩니다. 이번 레슨은 시간 숫자 대신 사건 이름으로 실행 순서를 설명합니다. 요청 시작·본문 준비·렌더링·실패 처리·정리 중 어떤 사건이 어떤 사건 뒤에 와야 하는지 확인합니다.

Promise는 나중에 정해지는 결과를 나타내는 객체입니다. pending은 아직 결론이 없고 fulfilled는 값으로 이행됐으며 rejected는 실패 이유로 거부됐다는 상태입니다. 하나의 Promise가 확정된 뒤 재시도한다고 원래 객체가 pending으로 돌아오지 않습니다. 재시도는 함수를 다시 호출해 새 Promise를 만드는 동작입니다. 화면 로딩 상태와 Promise 상태는 연결되지만 화면에는 빈 결과나 HTTP 오류처럼 더 구체적인 의미가 필요합니다.

Promise.resolve는 이미 준비된 값을 이행된 Promise로 다룹니다. 그래도 then에 등록한 콜백은 현재 동기 코드가 끝난 뒤에 실행됩니다. 여기서는 타이머와 여러 큐의 전체 규칙을 외우지 않고 동기 로그와 then 로그의 경계만 실험합니다. 다음 모듈에서 요청 도착 순서를 확장합니다. 지금 필요한 능력은 다음 단계가 앞 비동기 작업의 완료를 기다리는 체인을 만드는 것입니다.

then은 값을 반환하는 연결점입니다

행사 응답을 받은 뒤 변환 함수를 실행하려면 then 콜백에서 결과를 반환합니다. 콜백이 일반 값을 반환하면 다음 then이 그 값을 받습니다. Promise를 반환하면 그 작업의 결과가 정해질 때까지 다음 단계가 기다립니다. 중괄호가 있는 화살표 함수에서 return을 빼면 undefined가 다음 단계로 전달됩니다. 수행 순서를 보장하려던 네트워크 작업이 체인에서 떨어져 나갈 수 있으므로 호출 유무뿐 아니라 반환 여부를 확인합니다.

예를 들어 then 안에서 response.json()을 호출만 하고 반환하지 않으면 다음 렌더 함수는 본문을 받지 못합니다. return response.json() 또는 값이 표현식인 화살표 함수로 연결합니다. 비동기 작업을 함수로 감쌀 때도 호출자에게 Promise를 반환해야 테스트와 상위 오류 처리에서 기다릴 수 있습니다. 함수 이름이 load라서 자동으로 기다려 주는 것이 아닙니다. 반환 계약이 기다림의 경계를 결정합니다.

같은 흐름을 async 함수로 쓰면 await가 결과를 얻는 위치를 드러냅니다. async 함수는 값을 return해도 Promise를 반환합니다. await는 해당 함수의 이어지는 실행을 기다리게 하며 프로그램 전체의 스레드를 잠그는 문법이 아닙니다. 호출자도 await하거나 then으로 결과를 소비해야 합니다. async라고 선언했다는 사실만으로 내부에서 만든 모든 Promise가 자동 연결되지는 않습니다.

오류를 잡는 위치가 복구 정책입니다

앞 Promise가 거부되거나 then 콜백에서 throw하면 뒤의 성공 콜백은 건너뛰고 오류 처리로 흐릅니다. 행사 목록을 못 받았는데 카드 렌더링을 수행하지 않도록 하는 자연스러운 경로입니다. catch를 체인 뒤에 두면 그 앞 단계에서 발생한 실패를 받습니다. then의 두 번째 인자는 그 then의 성공 콜백 내부에서 새로 발생한 오류까지 처리하는 위치는 아니므로 오류 범위를 코드 순서로 판단합니다.

catch에서 값을 반환하면 이후 체인은 정상 경로로 이어집니다. 캐시된 행사 배열을 의도적으로 제공하는 정책이라면 복구를 설명할 수 있습니다. 하지만 모든 실패에 빈 배열을 반환하면 정상 0건과 서버 장애를 구분하지 못합니다. 이번 browser 실습에서는 recover라는 명시적인 입력이 있을 때만 recovered를 기록합니다. 복구 없이 오류를 다시 던지면 바깥 처리에서 failed를 기록하며 render 사건은 발생하지 않습니다.

오류 메시지를 로그에 남긴 뒤 throw하지 않으면 의도하지 않은 복구가 됩니다. 코드 리뷰에서는 catch가 어떤 값을 반환하는지, 호출자가 실패를 여전히 알 수 있는지 확인합니다. try 안에서 Promise를 만들고 await하지 않은 채 빠져나와도 뒤의 rejection을 그 try/catch가 기다려 처리하지 않습니다. 직접 실행한 예제로 await한 작업과 연결되지 않은 작업의 경계를 설명하는 것이 선언 위치를 외우는 것보다 도움이 됩니다.

finally는 성공 표시가 아닙니다

finally는 성공과 실패 경로 양쪽에서 정리 작업을 실행할 위치입니다. 로딩 표시를 해제하거나 임시 자원을 정리하는 책임을 둡니다. finally가 실행됐다고 행사 목록이 성공한 것은 아닙니다. render라는 성공 사건과 finish라는 처리 종료 사건을 분리해 기록합니다. finally에서 return하거나 throw하면 원래 결과를 바꿀 수 있으므로 정리 콜백에 불필요한 결과 변경을 넣지 않습니다.

이번 순서 실습은 실제 네트워크 대신 Promise.resolve로 완료 시점을 만듭니다. jobs 배열의 항목을 순서대로 체인에 붙이며 각 항목은 name과 outcome을 가집니다. outcome은 ok 또는 fail입니다. 실패하면 그 뒤 작업은 실행하지 않습니다. 입력은 제공된 계약을 지킨다고 가정하고 시간이나 운영 API를 사용하지 않습니다. 정답은 추측한 배열을 출력하는 대신 Promise 체인을 실행해 얻습니다.

출력 사건은 start, scheduled, request:이름, ok:이름, error:이름, recovered, render, failed, finish입니다. start와 scheduled는 체인 등록 전후의 동기 기록입니다. 작업 요청 기록은 then에서 실행되므로 scheduled 뒤에 옵니다. fail 작업은 ok를 남기지 않고 error를 남깁니다. recover가 true이면 recovered 다음 render, 아니면 failed가 이어집니다. 빈 jobs도 정상 완료해 render와 finish를 남깁니다.

실행 결과를 읽으며 체인을 고칩니다

Unhandled rejection이 보이면 catch로 연결되지 않은 Promise를 찾습니다. 로그에 undefined가 보이면 이전 then의 return과 async 함수의 반환값을 먼저 확인합니다. 앞 요청은 완료됐는데 뒤 작업이 먼저 실행됐다는 의심이 들면 두 작업의 시작 로그와 완료 로그를 나누어 기록합니다. 시간에 따라 바뀌는 출력보다 의미가 있는 사건 이름을 사용하면 테스트의 기대값을 읽고 실패 지점을 설명하기 쉽습니다.

작업 배열을 forEach로 돌리면서 async 콜백을 사용하면 forEach 자체는 콜백 Promise들을 기다리지 않습니다. 모든 일이 끝난 뒤 render를 원한다면 반환 Promise를 수집하거나 순서가 필요한 경우 체인을 이어야 합니다. 여기서는 요청 간 선후 관계를 보기 위해 순차 실행을 선택합니다. 서로 독립적인 실제 조회를 이 방식으로 고정하라는 지시는 아닙니다. 선택 근거와 실제 의존성을 먼저 설명합니다.

확인할 때는 성공 하나·성공 둘·첫 작업 실패·뒤 작업 실패·의도적 복구·빈 배열을 대조합니다. 오류가 난 뒤 request 사건이 계속 나오면 작업을 먼저 실행한 뒤 배열에 넣었는지 살펴봅니다. 체인에는 작업 결과가 아니라 작업을 호출하는 함수가 연결되어야 합니다. 더 읽기의 Promise 장에서는 조합 함수를 확장합니다. 여기서는 자신의 함수가 완료를 기다릴 수 있는 Promise를 반환하고 오류가 렌더를 막는지 입증합니다.

따라하기

동기 실행과 then 실행을 구분합니다

flow.cjs에 저장해 실행합니다. 이행된 Promise의 then도 현재 동기 실행 뒤에 옵니다.

console.log('등록 전');Promise.resolve('행사').then(value=>console.log('본문: '+value));console.log('등록 후');

실행 결과

등록 전
등록 후
본문: 행사

반환된 Promise를 따라갑니다

중괄호 내부에서 반환한 Promise의 값이 다음 단계로 이어집니다.

Promise.resolve(2).then(id=>{return Promise.resolve({id,name:'마을 책 모임'});}).then(e=>console.log(e.id,e.name));

실행 결과

2 마을 책 모임

복구와 오류 전파를 비교합니다

두 흐름을 순서대로 await해 실행합니다. 복구하지 않으면 render가 생략되며 정리는 양쪽에서 실행됩니다.

(async()=>{for(const recover of [false,true]){await Promise.reject(new Error('500')).catch(e=>{console.log('오류 '+e.message);if(recover)return [];throw e;}).then(rows=>console.log('render '+rows.length)).catch(()=>console.log('failed')).finally(()=>console.log('finish'));}})();

실행 결과

오류 500
failed
finish
오류 500
render 0
finish

작업 시나리오를 구현합니다

browser 실습의 fail TODO에서 Error(job.name)를 던집니다. jobs 빈 배열, 실패 후 작업 생략, recover=true의 출력 차이를 검사합니다. JSON은 한 줄만 출력하고 log 사건 외 디버깅 출력을 제거합니다. 제출 전에 then에서 Promise를 반환한 이유와 render가 생략되는 경로를 자신의 말로 설명합니다.

확인 문제

실습

표준 입력은 {"jobs":[{"name":"A","outcome":"ok"}],"recover":false} 형태입니다. jobs는 순차 실행할 작업 배열이며 outcome은 ok 또는 fail입니다. 실패 작업에서 Error(name)을 던지고 이후 작업을 생략합니다. recover=true일 때만 recovered 후 render로 복구하며 그렇지 않으면 failed를 남깁니다. 빈 jobs도 render와 finish를 기록합니다. start·scheduled는 동기 로그이며 요청 로그는 그 뒤입니다. 제공 체인을 유지해 사건 배열을 JSON 한 줄로 출력합니다.

모범 답안
const fs=require('node:fs');
const {jobs,recover}=JSON.parse(fs.readFileSync(0,'utf8'));
const log=['start'];
let chain=Promise.resolve();
for(const job of jobs){
 chain=chain.then(()=>{
  log.push('request:'+job.name);
  return Promise.resolve().then(()=>{
   if(job.outcome==='fail')throw new Error(job.name);
   log.push('ok:'+job.name);return job.name;
  });
 });
}
chain=chain.catch(e=>{log.push('error:'+e.message);if(recover){log.push('recovered');return [];}throw e;})
 .then(()=>log.push('render'))
 .catch(()=>log.push('failed'))
 .finally(()=>{log.push('finish');console.log(JSON.stringify(log));});
log.push('scheduled');

더 읽기

면접 질문

  • 로딩·오류·빈 결과 화면을 나눈 이유를 설명합니다.