Promise.then()은 언제 microtask queue에 쌓이나요?
Promise가 객체고, fulfilled - rejected - pending 상태를 가지며 then() 콜백이 microtask queue에 적재되며 JS 엔진의 call stack이 전부 비워지면 microtask queue부터 순차적으로 실행된다는 것까지는 알고 있었다
근데 가장 중요한 Promise.then()이 microtask queue에 적재되는 시점은 그 어디에서도 설명이 나와있지 않았다. 심지어 Promise MDN 공식문서에서도 명확하게 나와있지 않았고 빌어먹을 "일시 중단"된다는 모호한 설명밖에 없었다. "일시 중단"이라는 단어로는 실행 컨텍스트에서 Promise를 마주쳤을 때 Call stack이 어떻게 관리되고 비동기 작업들이 정확히 어떤 순서대로 실행되는지 설명할 수 없었다. 가장 큰 문제는 Promise에 관한 90%의 글은 이 과정을 "일시 중단"으로 뭉개버리고 앵무새마냥 참고한 글들을 반복하기 바빴다.
그래서 이 글은 회사 업무를 진행하면서 Promise와 await을 마주쳤을 때 이후 코드가 어떤 순서대로 실행되는지에 관한 글임.
문제
const handleSave = async () => {
try {
setIsAPICalled(true);
const rows = await grid.getRows();
await onSubmit({
cashFlows: rows.map(convertGridRowToCashFlow),
});
} catch (error) {
console.error(error);
} finally {
setIsAPICalled(false);
}
};
그리드에서 수정 중에 셀 포커스를 풀지 않은 채로 저장버튼을 눌렀을 때 수정이 모두 반영된 후의 값이 onSubmit()으로 넘겨져 서버에 날아가야하는데, 화면에는 반영된 값이 업데이트되었지만 계속 반영 전의 값이 onSubmit()에 태워져 수정 전 값으로 뒤집어 씌워지는 현상이 반복됐음
시나리오
- 시나리오를 위한 기초지식
- 이벤트루프:
Promise는 microtask queue,Timer API는 (macro)task queue - 실행컨텍스트: JS 엔진 call stack에 쌓이고 위부터 비워짐
- (micro)task queue는 call stack이 비었을 때 하나씩 불러져 처리된다(micro - macro 순)
async함수는 함수내용을Promise로 랩핑하여 새Promise객체를 반환한다- 흔히
await가async함수의 실행을 ‘일시 중단’한다고 표현하는데, 이 ‘일시 중단’은 이벤트루프를 중단시키고await이후를 실행한다는 뜻이 아니라await이후 코드를 새로운 자식Promise로 생성해 저장하고await에 걸려있는Promise이후에 실행될 수 있도록 순서를 보장해준다는 차원에서 ‘일시 중단’된 것처럼 보인다는 뜻 await키워드 혹은Promise이후의 코드는 새로운Promise로 감싸져 PromiseFulfillReactions 슬롯에 자식Promise로 저장되고, 부모Promise가resolve되었을 때 microtask queue에 비로소 적재된다Promise실행순서Promise생성자의 매개변수인 executor 함수는 즉시 콜스택에 적재되고 즉시 실행된다then()메서드는Promise객체에서 호출돼 새로운Promise를 반환한다.then()이 가진 callback은 새로 생성된Promise에 내장돼있으며, 실행되지 않고 일단 기다린다. 또한then()을 실행한Promise객체(부모)는 자신의 PromiseFulfillReactions 슬롯에then()으로 생성한 자식promise를 저장한다.- executor가 이행(
resolveorreject)되면Promise의 상태는settled(fulfilledorrejected)로 설정된다. 이후 부모Promise는 PromiseFulfill/RejectReactions에 등록한 리액션들을 차례대로 실행한다. 이때 이Promise의 완료를 기다리는 자식Promise는 microtask queue에 자신의 callback을 전달한다. - b ~ c 단계는 여러번 반복될 수 있고, 이것을 Promise chaining이라 한다. 에러가 발생할 경우 보통 가장 마지막에 위치한
catch문에 전파되어 핸들링된다. - call stack에 있는 작업들이 모두 수행돼 비워졌다면 이벤트 루프는 microtask queue에 있는 작업을 가져와 수행시킨다. 이 때
Promise의 callback이resolve될 수 있으며 그 즉시 이Promise가 이행되길 기다리던 연결된Promise객체가 자신의 callback을 microtask queue에 전달한다. - call stack이 비워지고, microtask queue의 작업이 call stack으로 올라와 순차적으로 수행되는 과정이 반복된다.
- microtask queue에 있는 모든 작업이 완료되면 해당
async함수가 값을 반환하면서Promise가resolve된다.
- 이벤트루프:
-
저장버튼 클릭 →
handleSave호출.async함수이므로 그 즉시Promise객체를 반환하며, 이Promise의 callback인async함수 내부는 즉시 실행된다const handleSave = async () => { try { setIsAPICalled(true); const rows = await grid.getRows(); // 아래 코드들은 새로운 Promise로 감싸져 grid.getRows() 실행 후 이어 실행된다 await onSubmit({ cashFlows: rows.map(convertGridRowToCashFlow), }); } catch (error) { console.error(error); } finally { setIsAPICalled(false); } }; -
await grid.getRows()을 만남.grid.getRows()도async함수이므로 executor를 즉시 실행하고await이후 코드는then()처럼 취급해 새로운Promise로 감싸PromiseFulfillReactions에 저장해둔다. -
getRows()내부 실행.stopEditing()→ Grid Event에 의해onCellValueChanged()호출const getRows = useCallback(async () => { /** * editing 모드일 수도 있기땜시롱 getRows 시점에 editing 모드를 종료해준다 (수정 중 그리드 밖 클릭 등) * 수정 된 값이 반영되기를 기대할 것 */ const editingCells = apiInstance?.getEditingCells(); if (editingCells?.length) { apiInstance?.stopEditing(); } -
onCellValueChanged()는changeQueues에 변경 셀 푸시,setTimeoutTimer 브라우저 API 호출하고 종료된다.setTimeout의 콜백인() => processQueue({ api: e.api })함수는 부모 실행컨텍스트 실행하는 동안 비동기적으로 0~0.5초 후 (macro)task queue에 쌓임.const onCellValueChanged = useCallback( (e: CellValueChangedEvent<T>) => { const source = e.source || "unknown"; // 큐에 변경 추가 changeQueues.current.push({ rowIndex: e.rowIndex ?? -1, field: uncoverFieldName(e.colDef.field || "") as Extract< keyof T["cells"], string >, value: e.newValue, source, timestamp: Date.now(), }); // 타이머 초기화 (디바운싱) if (queueTimeoutRef.current) { clearTimeout(queueTimeoutRef.current); } // 일정 시간 후 큐 처리 queueTimeoutRef.current = setTimeout( () => { processQueue({ api: e.api }); // 0~0.5초 후 (macro)task queue에 쌓임 }, source === "paste" ? 500 : 0 ); }, [processQueue] ); -
다시
getRows()컨텍스트로 돌아와서await processQueueListenerPromise()실행.processQueueListenerPromise는Promise를 반환하는 함수. executor는 즉시 실행되고await이후 코드는 마찬가지로processQueueListenerPromise.PromiseFulfillReactions에 새로운Promise로 랩핑돼 저장됨.const getRows = useCallback(async () => { /** * editing 모드일 수도 있기땜시롱 getRows 시점에 editing 모드를 종료해준다 (수정 중 그리드 밖 클릭 등) * 수정 된 값이 반영되기를 기대할 것 */ const editingCells = apiInstance?.getEditingCells(); if (editingCells?.length) { apiInstance?.stopEditing(); } await processQueueListenerPromise(gridId); -
내부 코드 실행.
processQueueRunningByGridId를 확인해 동작하고 있는 queue가 있다면 다음 내용을 수행하고 queue가 비어있다면resolve하고Promise종료const processQueueListenerPromise = (gridId: string) => new Promise<void>((resolve) => { const isRunning = processQueueRunningByGridId.get(gridId); if (!isRunning) { resolve(); return; } const handler = (e: Event) => { const customEvent = e as CustomEvent<GridEventMap[GridEventType]>; if (customEvent.detail.gridId === gridId) { cleanup(); resolve(); } }; const completedRef = addGridEventListener( GridEvents.PROCESS_QUEUE_COMPLETED, handler ); const cancelledRef = addGridEventListener( GridEvents.PROCESS_QUEUE_CANCELLED, handler ); const cleanup = () => { removeGridEventListener(GridEvents.PROCESS_QUEUE_COMPLETED, completedRef); removeGridEventListener(GridEvents.PROCESS_QUEUE_CANCELLED, cancelledRef); }; // setTimeout cleanup도 필요 // processQueue에서 문제 발생한 경우 무한 대기 타임아웃 안전장치 setTimeout(() => { cleanup(); resolve(); }, 10000); });
-
isRunning조건문이 있다면 7. 동작중인grid가 없어(processQueue()가 아직 task queue에 머물러있으므로) 즉시resolve시킴.resolve되었으므로Promise가 종료되었다고 판단해PromiseFulfillReactions에 저장돼있던 자식Promise을 실행시킴. 아직 macrotask queue에 있는processQueue()는 call stack이 비어있지 않아 실행되지 않은 상태. 변경사항이 미반영된node.data를rows에 담아 반환해줌const rows: T[] = []; apiInstance?.forEachNodeAfterFilterAndSort((node) => { if (!node.data) return; rows.push(node.data); }); return rows; }-
getRows()가rows를 반환하면서resolve되어getRows()의 자식Promise가 실행됨. -
미반영된 값들을
onSubmit()에 담아 제출.await onSubmit()이 실행되고 내부에서fetch를 만나면 이후 코드가Promise로 묶이면서(fetch는Promise를 반환하므로) call stack이 드디어 비게 됨(fetch가 브라우저 API를 통해 비동기적으로 실행되기 때문에). 이 때 task queue에 있던processQueue()가 실행되는 것임
-
-
isRunning조건문이 없다면 7.isRunning조건문이 없다면handler가 각각COMPLETED/CANCELLED그리드 이벤트 리스너에 붙고,setTimeout이 호출되면서(10초동안 실행되고 끝난 후에 콜백은 task queue에 적재)processQueueListenerPromise의 executor가 종료됨.-
Promise가 완결되지 않은 상태로 call stack에서 제거되어(Promise는 남아있는 상태,resolve되지 않아 microtask queue에는 적재되지 않은 상태) 남아있는 태스크가 없으므로 task queue에 쌓여있던 태스크들이 하나씩 소비됨. 드디어processQueue({ api: e.api })가 실행됨 -
processQueue가 호출되고 마찬가지로async함수이므로 즉시Promise객체를 반환하고, executor 함수를 실행함.// 큐 처리 함수 const processQueue = useCallback( async ({ api }: { api: GridApi<T> }) => { if (changeQueues.current.length === 0) { // 큐가 비어있으면 아무 작업도 하지 않음 return; } // processQueue <실행 중> 플래그 설정 processQueueRunningByGridId.set(gridId, true); emitGridEvent(GridEvents.PROCESSING_STARTED, { gridId }); try { ... for (const item of changeQueues.current) { ... const changes: Partial<ExtractRowValues<T>> = (await changeEffects[item.field]?.( rowValues, row, item.source )) ?? {}; } ... changeQueues.current = []; // changeQueues 비워진 이후 completed 커스텀 이벤트 발생 processQueueRunningByGridId.set(gridId, false); emitGridEvent(GridEvents.PROCESS_QUEUE_COMPLETED, { gridId }); } catch (error) { console.error("[GRID - processQueue] Failed to process queue:", error); // 큐 비우기 changeQueues.current = []; processQueueRunningByGridId.set(gridId, false); emitGridEvent(GridEvents.PROCESS_QUEUE_CANCELLED, { gridId }); } }, [changeEffects, transforms, gridId] ); -
processQueue의 비동기 작업들이 모두 끝나고 자식Promise까지 모두 완료됨. 이 과정에서 우리가 심어놨던emitGridEvent()에 의해 그리드 이벤트가 촉발되고, 아까resolve되지 않고 남아있던processQueueListenerPromise가 이벤트리스너에 의해 촉발되면서cleanup()+resolve()가 실행됨. -
드디어
processQueueListenerPromise가resolve되면서 자식Promise가 실행됨.processQueue를 통해 정상적으로 업데이트한 gridApi의node.data를 성공적으로 반환함 -
getRows가 값을 반환하면서resolve되고 자식Promise가 실행됨.onSubmit이 호출되면서 서버로 정상 업데이트 값을 전송.
-
원인
원인은 isRunning 조건에 걸려있던 processQueueRunningByGrid
// 큐 처리 함수
const processQueue = useCallback(
async ({ api }: { api: GridApi<T> }) => {
if (changeQueues.current.length === 0) {
// 큐가 비어있으면 아무 작업도 하지 않음
return;
}
// processQueue <실행 중> 플래그 설정
processQueueRunningByGridId.set(gridId, true);
emitGridEvent(GridEvents.PROCESSING_STARTED, { gridId });
try {
...
changeQueues.current = [];
// changeQueues 비워진 이후 completed 커스텀 이벤트 발생
processQueueRunningByGridId.set(gridId, false);
emitGridEvent(GridEvents.PROCESS_QUEUE_COMPLETED, { gridId });
} catch (error) {
console.error("[GRID - processQueue] Failed to process queue:", error);
// 큐 비우기
changeQueues.current = [];
processQueueRunningByGridId.set(gridId, false);
emitGridEvent(GridEvents.PROCESS_QUEUE_CANCELLED, { gridId });
}
},
[changeEffects, transforms, gridId]
);
~~위 맵 객체는 언뜻보면 processQueue의 시작과 종료에 맞춰 업데이트되는 듯 보이지만, 실제로는 비동기 처리 사이클을 반영하고 있지 않아 어긋나있었음~~
제대로 보니 processQueueRunningByGridId는 문제가 없었다. 단지 돌아가는 processQueue가 없으면 resolve 시켜주는 로직이 잘못된 타이밍에 호출되고 있던 것.
결론 및 교훈
Promise를 믿지 못하고 명시적으로 resolve 조건을 추가한 우리의 잘못이었다 낄낄
- Promise 혹은 await을 만나면 Promise 객체가 생성되며, 해당 함수는 executor로써 즉시 콜스택에서 실행된다
- Promise 혹은 await 이후의 코드는 전부 새 Promise로 감싸져 자식 Promise로 앞서 생성된 Promise 객체의 PromiseFulfillReactions에 '수납'된다
- Promise 객체는 resolve(혹은 rejejct)되어야 종료된다. executor 안에서 코드들이 실행될 때 resolve가 비동기적으로 감싸져있다면(e.g. fetch, setTimeout, Promise 등) 해당 Promise는 종료되지 않고 resolve되는지 관찰하며 JS 엔진(?)에서 표류한다.
- resolve 되었을 때 비로소 Promise 객체가 제거되면서 다음 Promise였던 자식 Promise를 실행한다. 끝날 때까지 이 과정을 반복하는거임
참고
- 비동기처리 문법에 대한 고찰 ( callback - Promise - async/await ) ★ 이 글에서 가장 도움을 많이 받음
- Language design question: Why do promise.then() callbacks go through the microtask queue, rather than being called as soon as their promise is fulfilled or rejected?
- Tasks, microtasks, queues and schedules
- How is async/await implemented under the hood?