JavaScript의 async/await 문법은 콜백 지옥으로부터 우리를 구해줄 것으로 기대되었습니다. 하지만 대신 더 조용하고 교활한 문제, 즉 코드는 올바르게 보이지만 예측 불가능하게 동작하는 문제를 불러왔습니다. 함수 본문에 await가 있는 것을 보고 모든 것이 한 줄씩 정중하게 멈출 것이라고 가정하곤 합니다. 하지만 실제로는 그렇지 않은 경우가 많습니다. 루프는 앞서 나가고, 단 하나의 요청 실패로 인해 전체 배치가 무너집니다. 엔트리 파일에는 아무 이유 없이 보기 싫은 async 래퍼들이 생겨납니다. 만약 이런 문제들을 겪어보셨다면, 다음 세 가지 패턴이 이를 바로잡아 줄 것입니다.
Stop Using await Inside forEach
겉보기에는 무해해 보이는 흔한 실수입니다:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
이를 실행하면, 단 하나의 응답도 돌아오기 전에 'All done!'이 출력됩니다. 왜일까요? forEach는 모든 요소에 대해 콜백을 즉시 실행합니다. 각 반복 내의 프로미스를 기다려주지 않습니다. async 키워드는 각 콜백을 프로미스로 바꾸지만, forEach는 이를 즉시 무시합니다. 루프는 마이크로초 만에 끝나버리고, 네트워크 요청은 제멋대로 떠돌아다닙니다. 에러를 순서대로 처리해야 하거나, 다음 요청이 시작되기 전에 이전 요청이 완료됨을 보장해야 한다면, 이 패턴은 두 가지 보장 사항을 모두 조용히 깨뜨립니다.
for...of 루프로 교체하세요:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
이제 루프는 각 await에서 실제로 멈춥니다. 두 번째 요청은 첫 번째 요청이 끝날 때까지 기다립니다. 'All done!'은 모든 작업이 완료된 후에만 출력됩니다.
순서가 중요할 때 for...of를 사용하세요. 예를 들어 속도 제한을 준수하기 위해 파일을 하나씩 업로드하거나, 특정 순서로 데이터베이스 행을 작성하거나, 다음 요청이 이전 응답의 데이터가 필요한 API 호출을 체이닝할 때 유용합니다. 만약 실제로 병렬 실행을 원한다면 forEach로 헤매지 마세요. 의도가 다음 개발자에게 명확히 보이도록 Promise.all을 명시적으로 사용하세요. 하지만 동기적인 동작을 기대하며 await와 forEach를 섞어서는 안 됩니다. 결코 그렇게 작동하지 않습니다.
Reach for Promise.allSettled When Zero Cannot Be the Answer
Promise.all은 의미론적으로 정직합니다. 프로미스 배열을 전달하면 결과 배열을 반환합니다. 문제는 단 하나의 프로미스라도 거부(reject)되는 순간, 전체가 즉시 거부된다는 점입니다. 다른 모든 대기 중인 프로미스는 각자 완료되도록 남겨지지만, 그 결과에는 접근할 수 없게 됩니다. 프로덕션 환경에서 이러한 '전부 아니면 전무(all-or-nothing)' 방식은 뼈아픈 결과를 초래합니다.
대시보드의 위젯을 트래픽 분석, 매출 데이터, 사용자 피드백, 서버 상태라는 네 개의 독립적인 서비스에서 가져오는 애플리케이션을 상상해 보세요. 매출 API에서 짧은 타임아웃이 발생합니다. Promise.all을 사용하면 대시보드 전체가 에러를 던집니다. 나머지 세 개의 정상적인 응답은 허공으로 사라집니다. 데이터의 4분의 1이 제대로 작동하지 않았다는 이유만으로 사용자는 스피너를 보다가 실패 화면을 마주하게 됩니다.
Promise.allSettled는 더 합리적인 방식을 제공합니다. 어떤 방식으로든 모든 프로미스가 완료될 때까지 기다립니다. 해결된 값(resolved value)은 각 결과를 설명하는 객체 배열입니다:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
어떤 응답도 버려지지 않습니다. 가능한 것들을 렌더링하고 실패를 격리합니다. 이 패턴은 대량 알림, 서드파티 웹훅 발송, 여러 CSV 스트림에서 레코드 가져오기 등 서로 관련 없는 작업을 다룰 때 매우 중요합니다. 여전히 중앙 집중식 에러 트래킹은 필요하지만, 애플리케이션은 안정성을 유지할 수 있습니다.
실무적인 참고 사항: allSettled는 전체 세트를 반환하므로, 결과를 살펴보고 "부분적 성공"이 해당 기능에서 무엇을 의미하는지 결정해야 합니다. 반환된 배열을 모두 성공한 데이터로 취급하지 마세요. 상태 레이어에 데이터를 밀어넣기 전에 반드시 status 필드를 확인하세요.
Declare Top-Level await and Kill the Wrapper IIFE
수년 동안 파일의 루트에서 무언가를 await 하려면, 즉시 실행되는 async 함수로 감싸야 했습니다:
(async () => {
const config = await loadConfig();
startServer(config);
})();
작동은 하지만, 불필요한 노이즈입니다. ES 모듈에서 기본적으로 지원되는 Top-level await를 사용하면 의례적인 보일러플레이트를 제거할 수 있습니다:
const config = await loadConfig();
startServer(config);
애플리케이션의 진입점이나, 다른 작업이 실행되기 전에 초기화가 반드시 완료되어야 하는 전용 설정 모듈에서 이를 사용하세요. 환경 파일 로드, 데이터베이스 연결 풀 구축, 원격 기능 플래그(feature flags) 가져오기 등이 모두 적합한 사례입니다. Top-level await는 모듈 그래프의 실행을 차단하므로(이 모듈을 임포트하는 다른 파일들은 프로미스가 해결될 때까지 기다립니다), 보장된 상태를 얻을 수 있습니다. 코드베이스의 나머지 부분은 db를 임포트하는 것만으로 연결이 이미 활성화되어 있음을 알 수 있습니다.
두 가지 주의사항이 있습니다. 첫째, 런타임 또는 번들러가 ES modules를 지원해야 합니다. Node.js의 경우, .mjs 확장자를 사용하거나 package.json에 "type": "module"을 설정해야 함을 의미합니다. 둘째, 모듈 수준에서의 대기(await)는 모든 임포터(importer)를 지연시키므로, 대기하는 작업의 범위를 좁게 유지해야 합니다. 빈번하게 임포트되는 유틸리티 파일 상단에서 무거운 순차적 fetch를 수행하면 애플리케이션 전체의 콜드 스타트(cold start)가 느려집니다. top-level await는 다른 모듈이 진정으로 의존하는 실제 부트스트랩(bootstrap) 작업에만 사용하십시오.
이러한 패턴을 채택했을 때 실제로 변하는 것들
첫 번째 이점은 예측 가능성입니다. for...of 루프를 읽을 때, 아래 블록이 언제 종료되는지 정확히 알 수 있습니다. 백그라운드에서 경주하는 유령 프로미스(ghost promises)도 없고, 에러 핸들러에서 분리되어 버리는 foreach 콜백도 없습니다. 제어 흐름이 화면에 보이는 코드의 형태와 일치하게 됩니다.
다음은 회복 탄력성(Resilience)입니다. Promise.allSettled를 사용하면 모든 외부 시스템이 완벽하기를 바라는 대신, 부분적인 실패에 대해 생각하게 됩니다. 프로덕션 소프트웨어는 이분법적이지 않습니다. 어떤 엔드포인트는 불안정할 것이고, 어떤 파일 읽기는 권한 오류를 일으킬 것입니다. 산재한 실패라는 현실에 맞춰 설계하면, 정당한 데이터를 은폐하지 않으면서도 애플리케이션을 안정적으로 유지할 수 있습니다.
명확성이 이 모든 것을 하나로 묶어줍니다. for...of는 마치 평이한 영어 문장이 진행되는 것처럼 읽힙니다. allSettled는 이름 자체에 그 의도를 담고 있습니다. top-level await는 난해한 IIFE 래퍼를 제거하여, 엔트리 파일이 문법적인 곡예 대신 비즈니스 로직으로 시작할 수 있게 해줍니다. 6개월 뒤의 당신이든, 마감에 쫓기는 팀원이든, 이 파일을 다음에 다룰 엔지니어는 당신에게 고마워할 것입니다.
실질적인 요점
async/await를 기존 코드 위에 뿌리는 만능 해결책으로 취급하지 마십시오. 현재 프로젝트에서 다음 세 가지 특정 안티 패턴(anti-patterns)이 있는지 점검하십시오. forEach 블록 내부의 await를 찾아 for...of 또는 의도적인 Promise.all로 교체하십시오. 외부 서비스와 통신하는 모든 Promise.all을 검토하고, 단 하나의 실패가 정말로 전체 작업을 중단시켜야 하는지 자문해 보십시오. 그렇지 않다면 Promise.allSettled로 전환하여 혼합된 결과를 처리하십시오. 마지막으로, ES 모듈 엔트리 포인트에서 async IIFE를 제거하고 top-level await가 부트스트랩 시퀀스를 직접 처리하도록 하십시오. 이는 작은 기계적 변화들이지만, 이들이 모여 취약한 비동기 스크립트를 실제로 신뢰할 수 있는 코드로 바꿔줍니다.
