본문 바로가기
빌드노트 검색

빌드노트

finally에서 기본값을 반환해도 Promise 실패는 복구되지 않는다

finally에서 반환한 기본값으로 실패를 복구할 수 없는 이유를 성공·실패 여덟 조건으로 비교한다. 정리 Promise의 대기와 오류 전파를 확인하고, 값 변환은 then, 기본값 복구는 catch에 둘 기준을 설명한다.

await task.finally(() => "fallback")에서 기본값을 돌려줬는데도 catch로 빠진다면, finally에 오류 복구를 넣었는지 확인한다. finally의 정리가 성공하면 새 Promise는 원래의 성공 값이나 거절 사유를 그대로 전달한다. 실패를 기본값으로 바꾸려면 catch, 성공 값을 변환하려면 then을 쓴다. finally에는 성공·실패 양쪽에서 필요한 정리를 둔다.

MDN의 finally 설명 (새 탭에서 열림)은 콜백의 일반 반환값과 반환한 Promise의 이행 값을 결과로 쓰지 않는다고 명시한다. 다만 콜백이 오류를 던지거나 거절되는 Promise를 반환하면, 그 오류가 새 체인의 거절 사유가 된다. 따라서 “finally의 반환값은 언제나 무시된다”라고 외우면 정리 실패를 놓친다.

기본값이 어디에서 반환됐는지 확인한다

다음 입력은 finally에서 같은 기본값을 반환해도 성공 값과 실패 사유가 각각 유지되는지 확인한다. Node.js가 있는 빈 폴더에 finally-demo.mjs로 저장하고 node finally-demo.mjs를 실행한다. 추가 패키지는 필요 없다. .mjs는 파일 최상위의 await를 사용하기 위한 확장자다. 아래 출력은 Windows의 Node.js v22.23.2에서 직접 얻었다.

console.log(await Promise.resolve("original").finally(() => "fallback"));

try {
  const value = await Promise.reject(new Error("load failed"))
    .finally(() => "fallback");
  console.log("value", value);
} catch (error) {
  console.log("caught", error.message);
}
original
caught load failed

실패 쪽에서는 value를 출력하는 줄에 도달하지 않았다. 기본값을 쓰려는 의도라면 자기 코드의 .finally(() => 기본값)을 찾아 catch로 옮긴다. 아래는 복구 값과 정리용 상태를 따로 확인하는 입력이다. 앞 코드 대신 파일 내용을 바꿔 같은 명령으로 실행한다.

let loading = true;
const value = await Promise.reject(new Error("load failed"))
  .catch(() => "fallback")
  .finally(() => { loading = false; });
console.log(value, loading);

console.log(await Promise.resolve("original").then(() => "replacement"));
fallback false
replacement

catch가 반환한 문자열은 새 Promise의 성공 값이 됐다. 마지막 finally는 loading만 바꾸고 그 값을 전달했다. 성공 값을 바꾸는 then도 반환한 문자열을 다음 결과로 넘겼다. 이 차이는 MDN의 catch 반환 규칙 (새 탭에서 열림)과 then 반환 규칙 (새 탭에서 열림)에 설명돼 있다.

위 catch는 비교를 위해 모든 오류를 기본값으로 바꾼다. 실제 코드에서는 기본값을 허용할 오류인지 판정하고, 허용하지 않는 오류는 다시 던진다. 오류를 로그만 남기고 정상 반환하는 것도 복구로 처리되므로, 호출자가 반드시 알아야 하는 실패까지 숨기지 않는지 확인한다. finally 콜백에는 성공 값이나 오류가 인자로 전달되지 않는다. 그 값을 검사해야 하는 로직도 then이나 catch에 둔다.

정리 중 실패하면 결과가 바뀐다

별도 비교에서는 원래 성공 값을 "original", 원래 오류를 Error("load failed")로 두고 아래 네 콜백을 각각 적용했다. 각 항목의 결과는 원래 성공 / 원래 실패 순서다.

  • () => "fallback": 성공 original / 거절 load failed.
  • () => Promise.resolve("fallback"): 성공 original / 거절 load failed.
  • () => { throw new Error("cleanup failed"); }: 거절 cleanup failed / 거절 cleanup failed.
  • () => Promise.reject(new Error("cleanup failed")): 거절 cleanup failed / 거절 cleanup failed.

이는 문서에서 예상한 값만 적은 목록이 아니라, 여덟 체인을 각각 실행한 결과다. 오류 메시지뿐 아니라 정상 정리 뒤에는 원래 오류 객체, 정리 실패 뒤에는 정리 오류 객체가 전달되는지도 확인했다. 원래 Promise를 수정하는 것은 아니며, 위 결과는 finally가 반환한 새 Promise를 기다린 결과다.

ECMAScript의 finally 알고리즘 (새 탭에서 열림)은 정리 콜백의 결과를 Promise로 처리한 뒤, 정리가 이행되면 보관한 원래 값을 반환하거나 원래 거절 사유를 다시 던지도록 정의한다. 정리 자체가 실패하면 그 전달 단계로 가지 못한다. 이 규칙이 정상 반환에서는 원래 결과가 남고 정리 실패에서는 새 오류가 보이는 이유다.

따라서 로딩 표시를 내리거나 자원을 해제하는 코드에서 예외가 나면, 원래 작업은 성공했어도 바깥 await가 실패할 수 있다. 원래 작업도 실패했다면 바깥에서는 정리 오류만 보일 수 있다. 원래 오류를 반드시 보존해야 하는 경우에는 정리 내부에서 정리 오류를 별도로 처리할 정책이 필요하다.

catch의 위치도 확인한다. .catch(() => "fallback").finally(cleanup)의 앞쪽 catch는 뒤에서 발생한 정리 오류를 처리하지 못한다. 실제로 먼저 기본값으로 복구한 뒤 finally에서 오류를 던지자 바깥 catch에는 cleanup failed가 도착했다. 반대로 정리 뒤의 catch는 정리 오류까지 받을 수 있으므로, 그곳에서 모든 오류를 기본값으로 바꾸면 정리 실패도 함께 숨긴다.

반환값을 쓰지 않아도 정리가 끝날 때까지 기다린다

정리 Promise의 성공 값을 버린다는 말은 기다리지 않는다는 뜻이 아니다. 다음 입력은 짧은 타이머로 정리 순서와 최종 값을 확인한다. 파일 내용을 이 코드로 바꾸고 같은 node finally-demo.mjs 명령을 실행한다.

const task = Promise.resolve("original").finally(() => {
  console.log("cleanup start");
  return new Promise(resolve => {
    setTimeout(() => {
      console.log("cleanup fulfilled");
      resolve("fallback");
    }, 20);
  });
});
console.log("after attach");
console.log("await fulfilled:", await task);
after attach
cleanup start
cleanup fulfilled
await fulfilled: original

원래 Promise가 이미 성공했어도 콜백은 현재 동기 코드 뒤에 실행됐다. await task는 정리 완료 뒤에 이어졌지만 값은 fallback이 아니라 original이었다. 별도 실행에서 원래 Promise를 거절시키고 정리는 성공시키자, 정리 완료 뒤에야 await에서 원래 load failed 오류를 받았다. 타이머가 돌려주는 Promise까지 거절시키면 원래 성공·실패 모두 정리 거절 뒤 cleanup failed를 받았다. 20ms는 타이머에 준 지연 값이며 정확한 경과 시간을 측정한 수치는 아니다.

정리가 끝나기 전에 다음 줄이 실행된다면 콜백의 return도 본다. 위 타이머를 시작하면서 return만 빼고 비교하자 cleanup start → await fulfilled: original → cleanup fulfilled 순서가 나왔다. finally가 알 수 있는 반환값은 undefined였기 때문이다. 자기 코드에서는 .finally(() => cleanup())처럼 정리 Promise를 반환하거나, async 콜백 안에서 await cleanup()으로 기다린다. 정리 함수 자체도 실제 정리 완료를 나타내는 Promise를 반환해야 한다.

첫 예제에서 출력이 다르면 중괄호 안의 return, catch의 위치, 실제로 기다리는 Promise가 finally의 반환값인지부터 확인한다. 최상위 await 문법 오류가 나면 .mjs 확장자와 node --version을 확인한다. node 명령을 찾지 못했다면 실행 환경을 먼저 준비해야 하며, 아직 반환 규칙을 비교한 결과는 아니다.

이번 실행은 내장 Promise, 합성 문자열·Error, 짧은 로컬 타이머로 반환값·오류 객체·실행 순서를 확인했다. 브라우저의 실제 화면 갱신, 외부 요청과 자원 해제, 사용자 정의 thenable이나 Promise 하위 클래스는 시험하지 않았다. 기본값을 선택할 위치는 catch, 성공 값을 바꿀 위치는 then, 양쪽에서 필요한 정리를 기다릴 위치는 finally다.

← 전체 글 보기