Promise.all을 await한 코드가 catch에 들어갔다. 이어서 finally에서 연결을 닫았는데, 조금 뒤 다른 작업이 “이미 닫힌 자원” 오류를 낸다. 처음 실패한 작업만 고쳐서는 이 문제가 사라지지 않을 수 있다. 묶음의 실패를 알게 된 시점과, 그 묶음이 쓰던 자원을 정리해도 되는 시점이 서로 다르기 때문이다.
이 글은 Promise.all의 실패 뒤에 공유 자원을 언제 닫아야 하는가를 다룬다. 타임아웃 구현이나 부분 결과 화면을 다시 만드는 대신, 첫 오류 이후에도 진행 중인 작업을 확인하고 정리 순서를 검사한다. 예제의 자원은 메모리 객체다. 실제 DB 연결, 파일 핸들, 외부 API를 사용하지 않는다.
Promise.all이 실패해도 finally가 기다려 주는 것은 아니다
일반적인 Promise 배열을 전달하면 Promise.all은 각 입력의 결과를 관측한다. 하나가 거부되면 반환한 Promise도 그 이유로 거부되며, 호출자의 await는 예외 경로로 진행한다. 이때 나머지 입력을 중단하거나 모두 끝날 때까지 기다리는 절차는 없다. TC39의 PerformPromiseAll 알고리즘은 각 입력에 성공 처리 함수와 묶음의 거부 함수를 연결한다.
따라서 try에서 await한 대상이 Promise.all의 반환값 하나라면, finally에 도착했다는 사실만으로 모든 입력 작업이 끝났다고 판단할 수 없다. catch에서 오류를 처리했어도 자원을 사용 중인 다른 작업은 남아 있을 수 있다. 여기서 finally는 JavaScript의 try/catch/finally 구문을 말한다.
특히 finally에서 닫는 자원을 여러 작업이 함께 쓰고 있다면 첫 실패가 두 번째 오류의 원인을 만들 수 있다. 아래 실험에서 A는 원래 실패한 작업이고, B의 실패는 자원을 너무 일찍 닫아서 생긴 오류다. 두 오류를 같은 원인으로 묶으면 수정할 위치를 잘못 짚게 된다.
재현: 먼저 닫는 코드와 모두 끝난 뒤 닫는 코드
다음 코드를 promise-all-cleanup.mjs로 저장한다. 이 글에서 검증한 Node.js v24.22.1에서는 아래 명령으로 실행할 수 있다. 추가 패키지나 인증 정보는 필요 없다. strict 옵션은 처리되지 않은 Promise 거부가 있으면 실행을 실패시키기 위한 검사 조건이다.
node --unhandled-rejections=strict promise-all-cleanup.mjs
gate는 B가 자원을 쓰는 시점을 직접 제어한다. 몇 밀리초 늦게 실행될 것이라는 추정에 의존하지 않기 위해 넣었다. 두 실행의 차이는 Promise.allSettled(jobs)를 자원을 닫기 전에 기다리는지뿐이다.
import assert from 'node:assert/strict';
function gate() {
let release;
const promise = new Promise(resolve => { release = resolve; });
return { promise, release };
}
async function run(drainBeforeClose) {
const trace = [];
const later = gate();
let open = true;
let firstError;
let settled;
// 실제 파일/DB 대신, 열림 상태만 가진 가상 공유 자원이다.
const resource = {
write() {
if (!open) throw new Error('B: resource already closed');
trace.push('B: write');
return 'B saved';
},
close() {
open = false;
trace.push('resource: close');
},
};
const jobs = [
Promise.reject(new Error('A: failed')),
(async () => {
await later.promise;
return resource.write();
})(),
];
try {
await Promise.all(jobs);
} catch (error) {
firstError = error;
trace.push(`aggregate: ${error.message}`);
} finally {
// 테스트가 B를 진행시킨다. 실제 코드에는 이 gate가 없다.
later.release();
if (drainBeforeClose) settled = await Promise.allSettled(jobs);
resource.close();
}
settled ??= await Promise.allSettled(jobs);
return { trace, firstError, settled };
}
const early = await run(false);
assert.equal(early.firstError.message, 'A: failed');
assert.equal(early.settled[1].reason.message,
'B: resource already closed');
assert.deepEqual(early.trace, ['aggregate: A: failed', 'resource: close']);
console.log('early:', JSON.stringify(early.trace));
console.log('late error:', early.settled[1].reason.message);
const drained = await run(true);
assert.equal(drained.firstError.message, 'A: failed');
assert.equal(drained.settled[1].status, 'fulfilled');
assert.deepEqual(drained.trace,
['aggregate: A: failed', 'B: write', 'resource: close']);
console.log('drained:', JSON.stringify(drained.trace));
console.log('PASS');
2026년 11월 7일 KST에 Linux 6.18.57, Node.js v24.22.1에서 실행해 다음 출력을 확인했다. 모든 assert가 통과했고 프로세스 종료 코드는 0이었다.
early: ["aggregate: A: failed","resource: close"]
late error: B: resource already closed
drained: ["aggregate: A: failed","B: write","resource: close"]
PASS
첫 실행은 A의 실패를 받은 뒤 자원을 닫는다. finally에서 gate를 해제했어도 B의 await 다음 코드는 즉시 끼어들어 실행되지 않는다. finally가 자원을 닫은 뒤 B가 이어서 실행되므로 B도 실패한다. 묶음의 첫 오류는 여전히 A: failed이고, 뒤늦은 B 오류는 개별 작업 결과를 확인해야 드러난다.
두 번째 실행은 gate를 해제한 다음 이미 만들어 둔 jobs의 종료를 기다린다. 그래서 B: write가 resource: close보다 먼저 기록된다. A의 실패는 사라지지 않았지만, 조기 정리로 생기던 B의 오류는 발생하지 않았다. 이 실험에서 바꾼 것은 성공 판정이 아니라 정리 순서다.
진단할 때는 첫 오류와 정리 로그를 나란히 본다
1. 실패를 알린 Promise를 찾는다. Promise.all을 await하다 발생한 오류를 catch에서 받은 것인지, 각 작업의 오류를 개별 처리한 것인지 확인한다. “전체 실패” 로그 하나만 남기면 어떤 작업이 먼저 실패했고 어떤 작업이 나중에 실패했는지 구분하기 어렵다. 묶음 ID와 작업 ID를 함께 기록한다.
2. 자원을 누가 닫는지 찾는다. finally, 요청 종료 콜백, 연결 반환 함수, 임시 작업 공간 정리 코드를 살핀다. 자원을 만든 코드와 닫는 코드 사이에 그 자원을 빌려 쓰는 작업이 몇 개 있는지 확인한다. B의 오류 직전에 close 로그가 있는지가 이 글의 핵심 검사다.
3. 이미 시작한 작업의 Promise를 보관한다. 실패 뒤 결과를 확인하려고 작업 함수를 다시 호출하면 첫 실행을 관측하는 대신 새 작업을 시작한다. 실험에서 allSettled에 전달한 jobs는 all에 전달한 배열과 같다. 쓰기 작업을 진단하면서 재실행을 섞으면 중복 쓰기가 생길 수 있다.
4. 각 Promise가 실제 작업의 끝을 나타내는지 확인한다. 함수가 백그라운드 작업을 시작하고 곧바로 반환하거나, 내부 Promise를 return/await하지 않으면 바깥 Promise의 완료만으로 자원의 사용 종료를 알 수 없다. allSettled로 배열 전체를 기다려도 추적하지 않은 작업까지 기다려 주지는 않는다. 자원에 접근하는 마지막 동작이 끝날 때 반환 Promise도 끝나도록 계약을 맞춰야 한다.
5. 자원 정리를 마지막 사용 뒤로 옮겨 검증한다. 각 작업이 반드시 끝나고, 반환 Promise가 실제 사용 종료를 나타내며, 묶음이 그 자원의 정리를 책임진다는 조건에서 종료 대기를 close 앞에 둔다. 첫 오류, 각 작업의 최종 상태, close 시점을 함께 확인한다. 성공 개수만 비교해서는 정리 순서가 고쳐졌는지 알 수 없다.
실패·취소·부분 결과는 서로 다른 판단이다
이 실험의 두 번째 실행에서도 묶음은 실패했다. B가 정상적으로 자원을 썼다는 사실이 A의 실패를 없애 주지는 않는다. 호출자에게는 원래 실패를 전달하면서, 내부적으로는 남은 작업의 종료를 기다려 자원을 안전하게 닫을 수 있다. 예제는 결과 비교를 위해 첫 오류를 변수에 담았으며, 실제 업무 함수의 성공 응답으로 간주하면 안 된다.
취소가 필요하다면 작업이 지원하는 취소 수단을 별도로 호출해야 한다. WHATWG DOM 표준은 Promise 자체에 내장된 중단 기능이 없으며, AbortSignal을 받는 API가 그 상태에 반응하도록 설명한다. signal을 전달하지 않았거나 작업이 이를 처리하지 않으면 controller.abort()만 호출해도 실제 작업이 멈추지는 않는다.
취소 요청과 자원의 사용 종료 역시 따로 확인한다. 작업의 취소 처리나 정리 코드가 끝나기 전에 공유 자원을 닫아도 안전한지는 해당 API의 계약에 달려 있다. 특히 외부 시스템에 이미 반영된 쓰기가 취소 요청으로 되돌아간다고 가정해서는 안 된다. 이 글의 메모리 실험은 원격 작업 중단이나 롤백을 검증하지 않는다.
부분 결과는 어느 성공을 업무 결과로 사용할지 결정하는 문제다. 여기서 allSettled는 그 결정을 하기 위한 화면 구성이 아니라, 자원 사용이 끝났는지 확인하는 대기 지점으로 쓰였다. B의 성공을 사용자에게 보여 주지 않는 정책이어도 B의 종료를 기다린 뒤 자원을 닫아야 할 수 있다.
종료를 기다리는 수정에도 조건이 있다
끝나지 않는 작업이 있으면 정리도 지연된다. allSettled로 기다린다고 작업의 실행 시간이 제한되지는 않는다. 종료가 보장되지 않는 시스템에서는 취소 지원, 작업별 시간 제한, 격리된 자원 사용을 함께 설계해야 한다. 시간 제한이 지났다는 이유만으로 아직 쓰는 자원을 닫으면 같은 문제가 다시 생긴다. 진단용 예제에는 두 작업이 반드시 끝나도록 제어된 gate만 넣었다.
공유 범위를 확인해야 한다. 여러 요청이 사용하는 풀 전체를 한 요청의 finally에서 닫으면 안 된다. 이 글의 close는 해당 묶음이 소유한 가상 자원의 종료다. 실제 풀 반환, 트랜잭션 종료, 파일 닫기는 각각의 수명과 사용 규칙에 따라 판단해야 한다. 하나의 연결을 동시에 써도 되는지 여부도 라이브러리마다 별도 확인 사항이다.
정리 실패로 원래 오류를 덮지 않도록 한다. finally에서 close가 예외를 던지면 호출자에게 전달되던 오류가 달라질 수 있다. 첫 작업 오류와 정리 오류를 별도로 보존하고, 무엇을 대표 오류로 반환할지 정한다. 예제의 close는 예외를 던지지 않으므로 이 경우까지 시험한 것은 아니다.
테스트에서 strict 모드가 통과했다는 사실도 “모든 오류가 사용자에게 보고됐다”는 뜻은 아니다. 이번 배열의 입력에는 Promise.all이 거부 처리 함수를 연결했다. 뒤의 B 오류가 첫 오류를 대체하지 않았을 뿐이다. 분리해서 만든 Promise, 내부에서 놓친 Promise, 처리되지 않은 파생 Promise까지 안전하다는 결론으로 넓힐 수 없다.
앞선 글과 함께 읽는 순서
Promise.race 타임아웃과 AbortSignal 글은 기다림을 그만두는 것과 작업을 중단하는 것의 차이를 다룬다. 제한 시간이 먼저 끝나는 경로를 점검할 때 함께 읽으면 된다. 이 글은 타임아웃 없이도 첫 실패 뒤 finally에서 공유 자원을 너무 일찍 닫을 수 있다는 경로를 재현했다.
Promise.allSettled 부분 실패 집계 글은 개별 작업 결과를 수집하고 다루는 경우에 연결된다. 여기서는 그 결과 배열의 활용보다 자원을 닫는 위치에 초점을 맞췄다. 업무상 전체 실패로 처리하더라도, 남은 작업의 수명을 책임지는 코드는 따로 필요하다.
다음 장애에서 catch 이후 “이미 닫힘” 오류가 보인다면 첫 실패의 원인만 읽지 말고 close가 찍힌 위치를 확인한다. 처음 실패한 작업을 고치는 일과, 남은 작업이 쓰는 자원을 보존하는 일은 서로 다른 수정일 수 있다.
확인 기준: 2026년 11월 7일 KST. 공식 근거는 TC39 온라인 명세(열람 당시 ECMAScript 2027 표기)의 Promise.all/PerformPromiseAll과 WHATWG DOM Living Standard의 중단 모델이다. 갱신되는 표준 문서의 표제 연도를 런타임 구현 버전과 동일시하지 않는다. 실제 검증 범위는 위 Node.js 합성 실험의 두 정리 순서이며, 브라우저·실제 DB·파일·네트워크·MCP 서버의 실행이나 성능은 측정하지 않았다.