빌드노트
Node exit 리스너에 await를 넣어도 파일이 저장되지 않는 이유
Node exit 리스너 안의 await는 비동기 저장 완료를 보장하지 않는다. 동기 표시만 남는 예제와 같은 저장을 main에서 기다린 결과를 비교하고, Promise.resolve와 beforeExit를 일반 해법으로 삼을 수 없는 조건을 확인한다.
Node.js의 process.on('exit', async ...) 안에서 정리 파일을 저장하면, 프로세스는 정상 종료했는데 파일은 없을 수 있다. 완료가 필요한 비동기 저장은 종료 이벤트가 발생하기 전에 정상 실행 흐름에서 기다려야 한다. exit 리스너 안의 await를 추가하는 것만으로는 해결되지 않는다.
먼저 자기 코드에서 저장 함수가 어디서 호출되는지 본다. 마지막 호출이 exit 리스너 안에만 있다면, 그 호출을 main() 같은 실행 함수로 옮길 대상이다. Node의 exit 이벤트 문서 (새 탭에서 열림)는 리스너에서 동기 작업만 수행하도록 명시한다. 이 단계에서는 새로 예약한 타이머를 기다리도록 종료를 미룰 수 없다.
리스너는 호출됐지만 저장은 끝나지 않았다
Windows, PowerShell 7.6.6, Node.js 22.23.2에서 짧은 파일 저장을 별도로 재현했다. 실제 서비스의 장애 기록이 아니라, 저장 시점을 비교하는 합성 예제다. Node.js 외에 설치할 패키지는 없다.
리스너 호출과 저장 완료를 따로 확인하려면, 새 빈 폴더에 다음 내용을 save.cjs로 저장한다. exit-seen.txt는 동기 표시이고, saved.txt가 확인하려는 저장 결과다.
const { writeFileSync } = require('node:fs');
const { writeFile } = require('node:fs/promises');
const { setTimeout: delay } = require('node:timers/promises');
async function save() {
await delay(30);
await writeFile('saved.txt', 'saved\n', 'utf8');
}
process.on('exit', async (code) => {
writeFileSync('exit-seen.txt', `exit=${code}\n`);
await save();
});
30ms 대기는 저장에 앞서 다음 타이머 실행이 필요한 상황을 만들기 위한 입력이다. 실제 디스크 저장 시간을 측정하는 값은 아니다. 이 코드에는 process.exit() 호출도 없다. 일반 실행이 끝나면서 발생하는 exit만으로 비교한다.
파일이 있는 폴더에서 아래 두 명령을 차례로 실행한다. 두 번째 명령은 앞선 Node가 끝난 뒤 별도 프로세스에서 파일 내용과 누락을 읽는다. 이전 실행의 saved.txt가 남아 있으면 비교가 무효이므로 첫 실행은 반드시 빈 폴더에서 시작한다.
node save.cjs
node -e "const fs=require('node:fs'); for (const p of ['exit-seen.txt','saved.txt']) console.log(p+': '+(fs.existsSync(p)?fs.readFileSync(p,'utf8').trim():'MISSING'));"
직접 실행했을 때 첫 명령의 종료 코드는 0이었지만, 조회 결과는 다음과 같았다.
exit-seen.txt: exit=0
saved.txt: MISSING
동기 표시가 있으므로 리스너 자체가 누락된 것은 아니다. 이 코드에서는 save()가 타이머를 기다리는 지점까지 들어갔지만, 그 뒤의 파일 쓰기에는 도달하지 못했다. 종료 코드 0도 저장 완료의 증거가 되지 않았다. exit-seen.txt까지 없다면 같은 결과로 해석하지 말고 실행 오류, 작업 폴더와 쓰기 권한부터 확인한다.
async 리스너가 반환하는 것은 Promise다. EventEmitter 문서 (새 탭에서 열림)는 이벤트 리스너를 동기 호출하고 반환값을 버린다고 설명한다. 따라서 리스너 내부의 await save()는 그 함수 안의 순서를 표현할 뿐, 이벤트를 발생시킨 Node가 저장 완료를 기다린다는 계약을 만들지 않는다.
같은 저장을 main에서 기다린다
위 파일의 import와 save()는 그대로 두고, 마지막 process.on('exit', ...) 부분을 아래 코드로 교체한다. 저장은 main()에서 기다리고, 종료 리스너에는 비교용 동기 표시만 남긴다.
process.on('exit', (code) => {
writeFileSync('exit-seen.txt', `exit=${code}\n`);
});
async function main() {
await save();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
앞의 첫 실행에서 saved.txt: MISSING을 확인한 같은 폴더에서 두 명령을 다시 실행했다. 이번에도 종료 코드는 0이었고, 저장 내용까지 읽혔다.
exit-seen.txt: exit=0
saved.txt: saved
저장 함수와 입력은 같고 호출 위치가 달라졌다. 정상 흐름에서는 타이머와 파일 쓰기가 완료된 뒤 main()이 끝났다. 실제 스크립트에도 완료가 필요한 저장을 작업 흐름에 포함하고, 저장 실패가 호출자에게 전달되도록 만든다. 별도 실패 대조에서 saved.txt 자리에 디렉터리를 미리 만들자, 위 catch가 EISDIR 오류를 출력하고 프로세스는 종료 코드 1로 끝났다.
저장 함수를 호출한 직후 process.exit(0)을 실행하는 코드도 함께 확인한다. process.exit 문서 (새 탭에서 열림)는 미완료 비동기 작업이 있어도 종료를 강제할 수 있다고 설명한다. 위 수정안은 저장을 기다린 뒤 자연 종료하고, 실패 시에는 process.exitCode로 결과를 전달한다. exitCode 설정만으로 exit 리스너의 비동기 저장을 기다리게 되는 것은 아니다.
Promise 하나와 beforeExit로 일반화하지 않는다
await Promise.resolve() 뒤의 코드가 실행됐다는 짧은 예제만으로 파일 저장도 안전하다고 판단하면 안 된다. 같은 버전의 추가 대조에서, 이미 이행된 Promise를 기다린 뒤 동기로 쓰는 표시는 자연 종료 때 남았지만 process.exit(0)으로 종료할 때는 남지 않았다. 이는 해당 조건의 관찰이다. 다음 타이머 실행을 기다리는 앞의 저장과 같은 동작이 아니며, exit에서 비동기 정리를 보장하는 근거로 쓸 수 없다.
beforeExit로 이벤트 이름만 바꾸는 것도 기본 해법으로 삼기 어렵다. 공식 beforeExit 문서 (새 탭에서 열림)에 따르면 이벤트 루프에 남은 일이 없을 때 발생하며, 여기서 비동기 일을 예약하면 실행이 이어질 수 있다. process.exit()에 의한 종료에서는 발생하지 않는다.
별도 대조에서는 첫 beforeExit에서만 같은 저장을 예약하고 이후에는 예약하지 않도록 제한했다. 파일에는 before=1 → save=done → before=2 → exit=0 순서가 남았다. 저장이 끝나 루프가 다시 비면 이벤트도 다시 들어왔다. 매번 새 타이머를 만드는 리스너라면 이런 재진입을 고려해야 한다. 명시적으로 process.exit(0)을 호출한 대조에서는 beforeExit 표시와 저장 파일 모두 없었다.
여기서 확인한 것은 Windows의 자식 Node 프로세스에서 자연 종료와 명시적 종료를 비교한 결과다. Linux·macOS, 종료 신호 처리, OS 충돌·강제 종료 때의 저장은 검증하지 않았다. 파일을 종료 후 읽었다는 관찰로 전원 장애 때의 디스크 영속성까지 보장하지도 않는다. 이 글의 수정 경로는 스크립트가 정상 실행 흐름을 마칠 수 있을 때, 필요한 비동기 저장을 먼저 기다리도록 하는 데 해당한다.