
자바스크립트에서 setTimeout을 0밀리초로 설정했는데 바로 실행되지 않거나, Promise 콜백이 타이머보다 먼저 실행되는 현상은 초보자뿐 아니라 실무 개발자도 혼동하기 쉽습니다. 자바스크립트 코드는 기본적으로 하나의 호출 스택에서 실행되며, 브라우저나 Node.js가 비동기 작업을 맡은 뒤 완료된 콜백을 큐에 넣습니다. 이벤트 루프는 스택이 비었을 때 실행할 작업을 선택합니다. 비동기 코드를 안정적으로 작성하려면 문법보다 작업이 어느 큐에 들어가고 언제 제어권을 돌려주는지 이해해야 합니다.
호출 스택과 실행 완료 규칙
함수를 호출하면 스택에 실행 프레임이 쌓이고 반환되면 제거됩니다. 한 번 시작한 동기 코드는 중간에 타이머 콜백이 끼어들지 않습니다. 긴 반복문이나 무거운 JSON 처리가 실행 중이면 화면 클릭과 렌더링도 기다려야 합니다. 이를 실행 완료 규칙이라고 볼 수 있습니다. 비동기 API를 사용해도 콜백 안에서 CPU 작업을 오래 수행하면 메인 스레드는 다시 멈춥니다.
매크로태스크와 마이크로태스크
타이머와 사용자 이벤트는 일반적으로 태스크 큐에서 대기합니다. Promise의 then, catch, finally와 queueMicrotask는 마이크로태스크 큐에 들어갑니다. 현재 동기 코드가 끝나면 다음 태스크로 넘어가기 전에 마이크로태스크를 모두 처리합니다. 그래서 해결된 Promise의 then은 0밀리초 setTimeout보다 먼저 실행되는 경우가 일반적입니다.
마이크로태스크가 계속 새 마이크로태스크를 등록하면 브라우저가 렌더링과 사용자 이벤트를 처리할 기회를 얻지 못할 수 있습니다. 작은 작업이라는 이름만 믿고 무한히 연결하지 말아야 합니다. 많은 계산을 나눌 때는 일정 구간마다 태스크나 전용 Worker로 제어권을 넘기는 방법을 고려합니다.
핵심 원칙: setTimeout의 지연 시간은 그 시간이 지난 뒤 실행이 보장된다는 뜻이 아니라, 실행 후보가 될 수 있는 최소 대기 시간입니다.
async와 await가 실제로 하는 일
async 함수는 항상 Promise를 반환합니다. await는 전체 프로그램을 멈추는 것이 아니라 현재 async 함수의 나머지 부분을 나중에 이어서 실행하도록 양보합니다. await 뒤의 코드는 Promise가 처리된 후 마이크로태스크로 이어집니다. 서로 의존하지 않는 두 요청을 연속으로 await하면 불필요하게 직렬 실행되므로 먼저 두 Promise를 시작하고 Promise.all로 기다릴 수 있습니다.
병렬 실행 전 확인할 항목
- 두 작업이 서로의 결과에 의존하지 않는지 확인합니다.
- 동시에 보낼 수 있는 요청 수를 제한합니다.
- 하나의 실패가 전체 실패여야 하는지 결정합니다.
- 중복 실행돼도 안전한 작업인지 검토합니다.
- 취소와 타임아웃 정책을 함께 구현합니다.
오류는 Promise 체인 전체에서 처리하기
await를 try/catch로 감싸면 거부된 Promise를 처리할 수 있습니다. 이벤트 핸들러에서 async 함수를 호출하고 반환된 Promise를 기다리지 않으면 오류가 처리되지 않은 거부로 남을 수 있습니다. 의도적으로 기다리지 않는 작업이라도 void task().catch(...)처럼 실패 경로를 명시합니다. catch에서 로그만 남기고 정상 값처럼 계속 진행하면 이후 코드가 불완전한 데이터를 사용할 수 있으므로 복구할 수 없는 오류는 다시 전달합니다.
Promise.all과 allSettled 선택하기
Promise.all은 모든 작업이 성공해야 결과를 반환하며 하나가 거부되면 즉시 전체가 거부됩니다. 그렇다고 나머지 네트워크 요청이 자동 취소되는 것은 아닙니다. 필요하면 AbortController를 공유해 남은 작업을 취소합니다. 여러 이미지 업로드처럼 일부 실패 결과도 모두 확인해야 한다면 Promise.allSettled로 각 결과의 상태를 검사합니다. 성공한 작업을 되돌려야 하는 업무라면 보상 로직과 멱등성까지 설계해야 합니다.
UI 응답성을 지키는 방법
대량 데이터 정렬, 이미지 변환, 암호화처럼 CPU를 오래 사용하는 작업은 Promise로 감싼다고 다른 스레드에서 실행되지 않습니다. 브라우저에서는 Web Worker로 계산을 옮기고 메시지로 데이터를 주고받을 수 있습니다. 큰 객체를 반복 복사하면 전송 비용이 커지므로 Transferable 객체나 작업 단위 분할을 검토합니다. 짧은 화면 업데이트는 프레임 예산을 고려하고 DOM 읽기와 쓰기를 불필요하게 반복하지 않습니다.
Node.js에서 주의할 차이
Node.js도 이벤트 루프를 사용하지만 브라우저와 단계 및 API가 완전히 같지는 않습니다. 파일과 네트워크 I/O는 효율적으로 처리하지만 CPU 집약 작업은 이벤트 루프를 막습니다. 서버에서 한 요청의 무거운 계산이 다른 사용자의 응답까지 늦출 수 있으므로 Worker Threads나 별도 작업 프로세스로 분리합니다. process.nextTick을 과도하게 연속 호출하면 I/O 처리가 지연될 수 있습니다.
비동기 코드 테스트 순서
- 동기 로그, Promise, 타이머의 실행 순서를 작은 예제로 확인합니다.
- 느린 응답이 빠른 최신 응답을 덮는지 시험합니다.
- 요청 취소와 제한 시간을 테스트합니다.
- 동시 실행 중 일부 실패 결과를 확인합니다.
- 중복 클릭과 자동 재시도 상황을 재현합니다.
- 긴 계산 중 화면과 서버 응답성이 유지되는지 측정합니다.
- 처리되지 않은 Promise 거부를 테스트 실패로 감지합니다.
타이머가 포함된 테스트는 실제 시간을 오래 기다리기보다 가짜 타이머를 사용할 수 있지만 Promise 마이크로태스크 처리 순서까지 테스트 도구의 사용법을 확인해야 합니다. 구현 내부의 정확한 로그 순서만 고정하면 리팩터링이 어려워질 수 있으므로 사용자에게 보이는 최종 상태와 취소, 오류 처리를 중심으로 검증합니다.
이벤트 루프는 비동기를 병렬로 만들어 주는 마법이 아닙니다. 동기 코드가 실행을 양보하고 외부 작업이 완료되면 정해진 큐 순서에 따라 후속 코드를 실행하는 조정 장치입니다. 호출 스택, 마이크로태스크, 태스크, CPU 작업의 차이를 알면 간헐적인 순서 버그와 멈춘 화면을 훨씬 빠르게 진단할 수 있습니다.
한 줄 요약: 자바스크립트 비동기 안정성은 Promise 문법뿐 아니라 이벤트 루프의 큐 순서, 취소, 오류 및 CPU 작업 분리를 함께 이해할 때 확보됩니다.