이벤트 루프와 완료 순서
180분 안팎
학습 목표
동기 실행·Promise·타이머의 순서를 구분합니다.
개념
이벤트 루프와 완료 순서
지역 행사 검색창에서 책을 입력하고 곧바로 책 모임으로 바꿨다고 생각합니다. JavaScript가 한 번에 한 콜백을 실행하더라도 두 검색의 네트워크 대기는 겹칠 수 있습니다. 실행 중인 코드의 순서와 외부 작업이 끝나는 순서는 다른 문제입니다. 이벤트 루프를 이해하면 왜 검색 버튼을 두 번 눌렀을 때 이전 결과가 마지막에 나타나는지 원인을 설명할 수 있습니다.
이 모듈은 앞 단계의 Node 18 이상 환경을 이어 씁니다. browser 실습은 표준 입력으로 JSON 한 값을 받고 JSON 한 줄만 출력합니다. DOM과 실제 네트워크는 사용하지 않습니다. local zip은 풀린 폴더에서 npm ci 다음 npm test로 검사합니다. 외부 패키지는 없으며 테스트 파일을 바꾸지 않고 TODO를 완성합니다. 미션 starter에는 m05 미션 solution의 파일을 이미 넣었습니다.
콜 스택은 지금 실행하는 함수 호출을 담습니다. 검색 제출 콜백이 시작되면 그 안의 동기 문장은 순서대로 실행됩니다. Promise 콜백이나 타이머를 등록했다고 현재 함수를 중간에 멈추고 그 콜백부터 실행하지 않습니다. 현재 호출이 반환하고 실행 스택이 비워질 때 예약된 작업을 처리할 기회가 생깁니다. 함수 호출과 콜백 등록을 구분하는 것이 첫 출발점입니다.
Promise의 then은 값이 준비된 뒤 실행할 반응을 등록합니다. 이미 이행된 Promise라도 then의 콜백이 그 줄에서 동기 실행되지는 않습니다. 이번 예제는 Promise.resolve로 준비된 값만 사용하므로 마이크로태스크의 등록 순서를 눈으로 추적할 수 있습니다. 아직 대기 중인 네트워크 Promise는 응답이 와야 반응을 예약할 수 있으므로 요청을 등록한 순서만으로 완료 순서를 예측하지 않습니다.
setTimeout의 지연 값은 콜백을 즉시 실행하라는 명령이 아닙니다. 0을 주어도 현재 동기 작업과 처리할 마이크로태스크가 앞에 있습니다. 타이머 콜백은 나중 태스크에서 실행합니다. 실제 브라우저에서는 다른 작업이나 백그라운드 탭 정책 때문에 더 늦을 수 있습니다. 따라서 250ms를 기다렸으니 응답이 왔을 것이라는 가정으로 화면을 바꾸지 않고 완료 Promise를 기다립니다.
마이크로태스크 체크포인트에서는 예약된 마이크로태스크를 처리하고, 그 처리 중 추가된 마이크로태스크도 큐가 빌 때까지 이어서 처리합니다. then 안에서 또 then을 등록하면 첫 콜백보다 뒤에 예약됩니다. 이 규칙은 짧은 상태 정리에 유용하지만 긴 반복 계산을 Promise로 계속 이어 붙이면 다음 태스크와 화면 갱신이 밀릴 수 있습니다. 마이크로태스크가 비동기라는 이유로 계산 비용이 사라지지는 않습니다.
async 함수는 호출 직후 첫 await 전까지 동기 실행합니다. await를 만나면 해당 함수의 나머지는 기다리는 값의 완료 이후에 재개됩니다. 호출한 코드 전체나 브라우저 전체가 멈추는 것은 아닙니다. 로딩 표시를 바꾼 뒤 await request를 쓰면 그 사이 다른 입력을 받을 수 있습니다. 이 시간 동안 새 검색이 시작될 수 있다는 사실이 다음 레슨의 요청 번호가 필요한 이유입니다.
await Promise.resolve는 함수 재개를 마이크로태스크로 넘깁니다. 이것만으로 브라우저 페인트 한 번을 보장하지 않습니다. DOM의 textContent를 바꿨다는 사실과 사용자가 그 중간 화면을 봤다는 사실은 구분합니다. 이번 실습은 로그 순서를 확인하며 실제 렌더링 시점은 측정하지 않습니다. 화면이 멈춘 문제는 Network 응답 시간과 Performance의 긴 실행 구간을 함께 보면서 좁힙니다.
네트워크 요청 A를 먼저 보냈다는 것은 시작 의도가 먼저였다는 뜻입니다. 서버가 A를 늦게 처리하거나 B가 캐시에서 빨리 반환되면 B의 완료 처리가 앞설 수 있습니다. 이벤트 루프는 도착한 작업을 실행할 기회를 제공할 뿐 사용자에게 어떤 검색이 최신인지 판단하지 않습니다. 최신 조건을 결정하는 정책은 애플리케이션이 따로 구현합니다. 요청을 순차로 기다리도록 바꾸면 입력 반응이 느려질 수도 있습니다.
로그를 분석할 때 각 줄을 동기·마이크로태스크·타이머 중 하나로 표시합니다. 먼저 동기 줄을 전부 적고, 이미 준비된 Promise 반응을 등록 순서대로 적습니다. 그 다음 타이머를 적습니다. 이 레슨의 모든 타이머는 같은 태스크에서 같은 0ms 값으로 등록되며 콜백에서는 로그만 남깁니다. 서로 다른 네트워크 완료나 Node 고유 API가 섞인 코드를 이 작은 모델로 일반화하지 않습니다.
브라우저 과제의 입력은 type과 label을 가진 이벤트 배열입니다. sync는 그 자리에서 label을 기록하고 micro는 이행된 Promise의 then에 기록을 예약하며 timer는 0ms 타이머에 기록을 예약합니다. 모든 예약 작업이 끝난 뒤 전체 로그 배열을 JSON으로 출력합니다. 타입은 이 세 종류만 제공하고 label은 문자열입니다. 빈 입력 배열은 빈 출력 배열이며 같은 label도 지우지 않고 횟수대로 기록합니다.
Promise.all에 타이머 완료 Promise들을 모으면 마지막 완료 뒤 출력할 수 있습니다. 타이머가 없는 경우에도 await의 재개 전에 이미 예약된 micro 콜백이 실행됩니다. Promise.all이 로그를 입력 순서로 정렬해 주는 것은 아닙니다. 이 실습의 로그는 각 콜백이 실행할 때 push한 배열입니다. 결과 배열의 순서와 Promise.all이 반환하는 값 배열의 순서를 혼동하지 않는지 확인합니다.
흔한 실수는 timer를 등록하자마자 로그를 출력하는 것입니다. 타이머가 아직 실행되지 않아 결과에 timer label이 빠집니다. 또 then 대신 log.push를 직접 인자로 넘기면 등록할 때 이미 실행되어 동기 로그가 됩니다. 테스트에서 Expected와 Actual의 첫 다른 위치를 찾아 어떤 줄을 예약해야 했는지 확인합니다. SyntaxError: Unexpected token은 실행 순서 문제보다 입력 JSON 문법이나 코드 괄호를 먼저 확인합니다.
따라하기 코드는 각각 별도의 order.cjs 파일에 저장하고 node order.cjs로 실행합니다. 출력에는 시간 수치를 넣지 않으므로 컴퓨터 속도가 달라도 순서 계약을 비교할 수 있습니다. 코드에 새로운 지연 시간을 넣어 결과를 맞추려 하지 않습니다. 입력이 없는 예제와 표준 입력을 읽는 채점 코드를 구분하고, 채점 코드에는 설명 로그나 안내 문구를 추가하지 않습니다.
선임에게 설명할 때는 JavaScript가 단일 실행 흐름이라 안전하다는 말 대신 검색 제출·대기·재개의 경계를 표시합니다. A 요청의 await 사이에 B가 시작할 수 있고 B가 먼저 끝날 수 있다고 구체적으로 말합니다. 이 설명은 늦은 결과를 버리는 정책으로 이어집니다. 실행 모델의 추가 사례는 더 읽기의 이벤트 루프 장에서 확인하고 여기서는 행사 탐색의 완료 순서에 집중합니다.
마이크로태스크 처리 경계는 MDN 마이크로태스크 안내로 확인합니다. 여기서 관찰하는 것은 콜백 로그의 순서이며 실제 브라우저가 중간 상태를 그렸는지는 별도 관찰로 남깁니다.
따라하기
현재 실행과 예약을 나눕니다
코드를 order.cjs에 저장하고 node order.cjs로 실행합니다. 동기 구간을 먼저 표시합니다.
console.log('검색 시작');setTimeout(()=>console.log('타이머'),0);Promise.resolve().then(()=>console.log('조건 준비'));console.log('제출 완료');실행 결과
검색 시작 제출 완료 조건 준비 타이머
await 전후를 비교합니다
await 앞은 즉시 실행하고 이후는 재개를 기다립니다.
async function search(){console.log('요청 등록');await Promise.resolve();console.log('결과 처리');}search();console.log('다음 입력');실행 결과
요청 등록 다음 입력 결과 처리
추가 마이크로태스크를 추적합니다
한 콜백이 추가한 작업은 기존에 등록된 작업 뒤에서 실행됩니다.
Promise.resolve().then(()=>{console.log('조건');Promise.resolve().then(()=>console.log('조건 후처리'));});Promise.resolve().then(()=>console.log('목록'));setTimeout(()=>console.log('타이머 완료'),0);실행 결과
조건 목록 조건 후처리 타이머 완료
확인 문제
실습
type(sync·micro·timer), label 이벤트 배열을 읽고 실제 예약 규칙대로 기록한 label 배열을 출력합니다. 같은 label을 제거하지 않습니다. 모든 콜백 완료 뒤 한 번 출력합니다.
모범 답안
const events=JSON.parse(require('fs').readFileSync(0,'utf8'));
(async()=>{const log=[],pending=[];for(const e of events){if(e.type==='sync')log.push(e.label);else if(e.type==='micro')Promise.resolve().then(()=>log.push(e.label));else if(e.type==='timer')pending.push(new Promise(r=>setTimeout(()=>{log.push(e.label);r();},0)));}await Promise.all(pending);console.log(JSON.stringify(log));})();더 읽기
면접 질문
- 검색 요청이 순서와 다르게 도착할 때의 처리 방식을 설명합니다.