2022년 4월 25일

마이크로태스크

프라미스 핸들러 .then/catch/finally는 항상 비동기적으로 실행됩니다.

프라미스가 즉시 이행되더라도 .then/catch/finally 아래에 있는 코드는 이 핸들러들이 실행되기 전에 실행됩니다.

예시:

let promise = Promise.resolve();

promise.then(() => alert("프라미스 성공!"));

alert("코드 종료"); // 얼럿 창이 가장 먼저 뜹니다.

예시를 실행하면 '코드 종료’가 먼저, '프라미스 성공!'이 나중에 출력되는 것을 볼 수 있습니다.

프라미스는 즉시 이행상태가 되었는데도 말이죠. 뭔가 이상하네요.

.then이 나중에 트리거 되었을까요? 그 이유에 대해 알아봅시다.

마이크로태스크 큐

비동기 작업을 처리하려면 적절한 관리가 필요합니다. 이를 위해 ECMA에선 PromiseJobs라는 내부 큐(internal queue)를 명시합니다. V8 엔진에선 이를 '마이크로태스크 큐(microtask queue)'라고 부르기 때문에 이 용어가 좀 더 선호됩니다.

명세서의 설명을 살펴봅시다.

  • 마이크로태스크 큐는 먼저 들어온 작업을 먼저 실행합니다(FIFO, first-in-first-out).
  • 실행할 것이 아무것도 남아있지 않을 때만 마이크로태스크 큐에 있는 작업이 실행되기 시작합니다.

요약하자면, 어떤 프라미스가 준비되었을 때 이 프라미스의 .then/catch/finally 핸들러가 큐에 들어간다고 생각하시면 됩니다. 이때 핸들러들은 여전히 실행되지 않습니다. 현재 코드에서 자유로운 상태가 되었을 때에서야 자바스크립트 엔진은 큐에서 작업을 꺼내 실행합니다.

위 예시에서 '코드 종료’가 먼저 출력되는 이유가 여기에 있습니다.

프라미스 핸들러는 항상 내부 큐를 통과하게 됩니다.

여러 개의 .then/catch/finally를 사용해 만든 체인의 경우, 각 핸들러는 비동기적으로 실행됩니다. 큐에 들어간 핸들러 각각은 현재 코드가 완료되고, 큐에 적체된 이전 핸들러의 실행이 완료되었을 때 실행됩니다.

그렇다면 '프라미스 성공!'을 먼저, '코드 종료’를 나중에 출력되게 하려면 어떻게 해야 할까요? 실행 순서가 중요한 경우엔 이런 요구사항이 충족되도록 코드를 작성해야 합니다.

방법은 아주 쉽습니다. .then을 사용해 큐에 넣으면 됩니다.

Promise.resolve()
  .then(() => alert("프라미스 성공!"))
  .then(() => alert("코드 종료"));

이제 의도한 대로 순서가 변경되었습니다.

처리되지 못한 거부

프라미스와 에러 핸들링에서 학습한 unhandledrejection 이벤트를 기억하고 계시나요?

이제 자바스크립트 엔진이 어떻게 처리되지 못한 거부(unhandled rejection)를 찾는지 정확히 알 수 있습니다.

'처리되지 못한 거부’는 마이크로태스크 큐 끝에서 프라미스 에러가 처리되지 못할 때 발생합니다.

정상적인 경우라면 개발자는 에러가 생길 것을 대비하여 프라미스 체인에 .catch를 추가해 에러를 처리합니다.

let promise = Promise.reject(new Error("프라미스 실패!"));
promise.catch(err => alert('잡았다!'));

// 에러가 잘 처리되었으므로 실행되지 않습니다.
window.addEventListener('unhandledrejection', event => alert(event.reason));

그런데 .catch를 추가해주는 걸 잊은 경우, 엔진은 마이크로태스크 큐가 빈 이후에 unhandledrejection 이벤트를 트리거 합니다.

let promise = Promise.reject(new Error("프라미스 실패!"));

// 프라미스 실패!
window.addEventListener('unhandledrejection', event => alert(event.reason));

그런데 만약 아래와 같이 setTimeout을 이용해 에러를 나중에 처리하면 어떤 일이 생길까요?

let promise = Promise.reject(new Error("프라미스 실패!"));
setTimeout(() => promise.catch(err => alert('잡았다!')), 1000);

// Error: 프라미스 실패!
window.addEventListener('unhandledrejection', event => alert(event.reason));

예시를 실행하면 프라미스 실패!가 먼저, 잡았다!가 나중에 출력되는 걸 확인할 수 있습니다.

마이크로태스크 큐에 대해 몰랐다면 "에러를 잡았는데도 왜 unhandledrejection 핸들러가 실행되는 거지?"라는 의문을 가졌을 겁니다.

unhandledrejection은 마이크로태스크 큐에 있는 작업 모두가 완료되었을 때 생성됩니다. 엔진은 프라미스들을 검사하고 이 중 하나라도 ‘거부(rejected)’ 상태이면 unhandledrejection 핸들러를 트리거 하죠. 이로써 앞선 의문이 자연스레 해결되었습니다.

위 예시를 실행하면 setTimeout을 사용해 추가한 .catch 역시 트리거 됩니다. 다만 .catchunhandledrejection이 발생한 이후에 트리거 되므로 프라미스 실패!가 출력됩니다.

요약

모든 프라미스 동작은 ‘마이크로태스크 큐’(ES8 용어)라 불리는 내부 ‘프라미스 잡(promise job)’ 큐에 들어가서 처리되기 때문에 프라미스 핸들링은 항상 비동기로 처리됩니다.

따라서 .then/catch/finally 핸들러는 항상 현재 코드가 종료되고 난 후에 호출됩니다.

어떤 코드 조각을 .then/catch/finally가 호출된 이후에 실행하고 싶다면 .then을 체인에 추가하고 이 안에 코드 조각을 넣으면 됩니다.

브라우저와 Node.js를 포함한 대부분의 자바스크립트 엔진에선 마이크로태스크가 '이벤트 루프(event loop)'와 '매크로태스크(macrotask)'와 깊은 연관 관계를 맺습니다. 이 둘은 프라미스와는 직접적인 연관성이 없기 때문에, 이벤트 루프와 매크로태스크, 마이크로태스크에서 따로 다루도록 하겠습니다.

튜토리얼 지도