프론트엔드에서는 서버의 응답을 기다리고, 데이터를 가공하고, 그 결과를 화면에 반영하는 코드를 자주 작성한다. Promise와 async, await는 이런 흐름을 표현하는 도구다. 먼저 세 가지가 각각 무엇을 의미하고 어떻게 연결되는지 살펴본 뒤, Chromium 안에서 실제로 실행되는 과정을 따라가 보자.
Promise는 작업의 성공 결과나 실패 이유를 나타내는 객체다. 서버에 데이터를 요청하는 fetch(url)을 호출하면 응답 데이터 자체가 즉시 반환되는 대신, 응답을 기다릴 수 있는 Promise가 반환된다. 결과를 기다리는 동안에는 pending(대기), 성공 결과가 확정되면 fulfilled(이행), 실패하면 rejected(거부) 상태가 된다. 한 번 fulfilled 또는 rejected가 된 Promise의 상태와 결과는 다시 바뀌지 않는다. 이미 결과가 준비된 Promise도 있으므로, Promise가 반드시 진행 중인 작업만 나타내는 것은 아니다.
Promise의 성공 결과는 .then()으로, 실패는 .catch()로 처리할 수 있다. 여기에 결과를 처리할 함수인 콜백을 전달하면, 각 호출은 후속 처리의 결과를 나타내는 새로운 Promise를 반환한다. 콜백이 일반 값을 반환하면 다음 Promise의 성공 결과가 되고, 예외를 던지면 다음 Promise의 실패 이유가 된다. 다른 Promise를 반환하면 다음 Promise는 그 결과를 따른다. 이처럼 후속 처리를 이어 붙이는 것이 Promise 체인이다. 데이터를 가져온 뒤 가공하고 오류를 처리하는 흐름을 하나로 연결할 수 있다. Promise의 콜백과 결과 전달 구현
Promise를 직접 만들 때는 new Promise()에 함수를 전달한다. 이 함수는 결과를 전달할 resolve와 실패 이유를 전달할 reject를 인자로 받는다. resolve(42)는 성공 결과를 42로 정하고, reject(error)는 실패 이유를 전달한다. 다만 resolve(otherPromise)처럼 다른 Promise를 전달하면 그 Promise의 결과를 따르게 되므로, resolve()를 호출했다는 사실만으로 성공 결과가 확정되었다고 볼 수는 없다.
async는 함수가 호출 결과를 Promise로 제공하도록 하는 키워드다. 예를 들어 async function getValue() { return 42; }를 호출하면 숫자 42 자체가 아니라, 42를 성공 결과로 갖는 Promise를 받는다. 함수 안에서 처리하지 않은 예외가 발생하면 반환된 Promise가 rejected 상태가 된다. 함수가 다른 Promise를 반환하면 반환된 Promise는 그 결과를 따른다. 따라서 호출자는 함수 내부의 처리 과정과 관계없이 Promise를 통해 결과를 받을 수 있다. JavaScript 표준의 async 함수 실행 정의
await는 기다린 결과를 이후 코드에서 사용할 수 있게 하는 문법이다. async 함수 안에서 const value = await promise를 실행하면 해당 함수는 결과를 기다렸다가, Promise가 fulfilled이면 그 값을 value에 넣고 이어서 실행한다. rejected이면 await 위치에서 예외가 발생한 것처럼 처리되므로 try/catch로 실패를 처리할 수 있다. 처리하지 않으면 그 async 함수가 반환한 Promise의 실패로 이어진다. 이때 기다리는 것은 해당 함수의 이후 실행이며, 함수를 호출한 쪽과 브라우저는 다른 코드를 진행할 수 있다. JavaScript 표준의 Await 정의
앞의 getValue()를 예로 들면, 호출 결과를 그대로 받았을 때는 Promise를 얻고, async 함수 안에서 await getValue()를 사용하면 이후 코드에서 숫자 42를 다룰 수 있다. Promise가 결과를 표현하고 .then()이 후속 함수를 연결한다면, async와 await는 그 결과를 함수의 반환과 순차적인 코드로 표현하게 해 준다. async 함수가 Promise를 반환한다는 점은 await로 작성한 코드에서도 그대로 유지된다.
여기서 실행 시점을 구분해야 한다. new Promise()에 전달한 함수는 호출한 자리에서 바로 실행된다. async 함수의 본문 역시 호출되면 실행을 시작하고, await에 도달하면 해당 함수의 실행을 잠시 멈추고 호출한 쪽에 제어를 돌려준다. 반면 .then() 콜백과 await 이후의 코드는 나중에 실행된다. 이미 결과가 준비된 Promise를 기다리는 경우에도 await 다음 줄을 즉시 이어서 실행하지는 않는다. Promise를 만들거나 함수에 async를 붙였다고 해서 그 안의 코드 전체가 나중에 실행되는 것은 아니다.
예를 들어 new Promise()에 전달한 함수 안에서 resolve(42)를 호출한 다음 다른 코드를 실행하면, 그 코드까지 계속 진행된다. .then() 콜백은 이후에 실행된다. 또한 그 함수 안에서 긴 계산을 하면 계산이 끝날 때까지 현재 실행 흐름을 사용한다. 결과를 전달하는 시점과 그 결과를 처리할 코드가 실행되는 시점이 다르다는 점이 Promise 실행 순서를 이해하는 핵심이다.
이 동작을 Chromium 구현과 연결해 보자. Chromium은 Chrome 등의 브라우저 기반이 되는 프로젝트이고, 그 안에서 JavaScript를 실행하는 엔진이 V8이다. V8은 Promise의 상태와 결과, 후속 실행을 구현한다. Blink는 웹 문서와 여러 브라우저 기능을 구현하며, 웹 API의 결과를 V8의 Promise에 전달하고 브라우저의 실행 흐름과 연결한다. V8의 Promise 생성자 구현에서도 new Promise()에 전달받은 함수를 직접 호출하는 모습을 확인할 수 있다. V8의 Promise 생성자 구현
V8은 .then()을 처리할 때 Promise가 아직 pending이면 후속 처리 정보를 저장해 두었다가 결과가 준비될 때 실행할 작업을 만든다. 이미 결과가 준비되어 있으면 .then()을 호출할 때 작업을 만든다. 이 작업을 마이크로태스크라고 하며, 실행을 기다리는 대기열인 마이크로태스크 큐에 넣는다. 즉, .then()을 호출한 순서만으로 실행 순서를 판단할 수는 없다. pending Promise에 먼저 콜백을 연결했더라도 결과가 늦게 준비되면 실행할 작업도 나중에 큐에 들어간다. V8의 후속 처리 등록 구현
큐에 대기 중인 마이크로태스크를 처리하는 절차가 마이크로태스크 체크포인트다. 브라우저의 일반 작업 단위인 태스크(task)가 끝난 뒤 등이 대표적인 수행 시점이다. Chromium에서는 Blink의 EventLoop::PerformMicrotaskCheckpoint()가 V8에 처리를 요청한다. 체크포인트에서는 큐의 작업을 하나씩 실행하고, 실행 중 추가된 마이크로태스크도 이어서 처리한다. 따라서 체크포인트가 시작할 때 있던 작업만 실행하고 끝내는 것은 아니다. Blink와 V8의 체크포인트 연결, V8의 마이크로태스크 실행 루프
예를 들어 Promise.resolve().then(A).then(B)를 실행한 뒤 queueMicrotask(C)로 C도 등록한다고 하자. A, B, C가 단순히 자신의 이름만 출력한다면 실행 순서는 A, C, B다. 처음 큐에 들어가는 것은 A와 C이며, B는 A가 끝나고 첫 번째 .then()이 반환한 Promise가 이행될 때 큐의 뒤에 추가된다. 같은 실행 흐름에서 예약한 setTimeout(..., 0)의 콜백은 별도 task로 실행되므로, 이 예제에서는 마이크로태스크들이 처리된 다음 실행된다.
await 이후의 실행도 마이크로태스크를 통해 재개된다. 이것이 이미 완료된 Promise를 기다리는 await Promise.resolve()에서도 다음 줄이 나중에 실행되는 이유다. V8은 현재 함수의 실행을 재개하는 데 필요한 정보를 보관하고 이후 실행을 예약한다. 기다리는 동안 JavaScript 실행 전체를 멈춰 놓는 방식은 아니다. V8의 await 구현
이제 실제 요청과 연결해 보자. fetch()를 호출하면 Blink의 요청 처리 코드로 이어지고, 일반적인 네트워크 요청 경로에서는 Network Service가 통신을 수행한다. 응답을 처리할 수 있게 되면 Blink는 응답 정보를 나타내는 Response 객체를 준비하고, ScriptPromiseResolver라는 연결 코드를 통해 V8의 Promise에 전달한다. 이후 .then()이나 await 뒤의 JavaScript는 마이크로태스크 처리 흐름에서 실행된다. 네트워크 응답의 도착, Promise의 결과 전달, 사용자의 코드 실행은 서로 이어지는 별개의 단계다. Network Service 설명, Blink에서 V8 Promise로 결과를 전달하는 구현
await fetch(url)이 끝나도 응답 본문 전체가 아직 준비되지 않았을 수 있다. 그래서 await response.json()으로 본문을 읽고 JavaScript 값으로 변환한 결과를 다시 기다린다. 이 두 Promise는 각각 응답 객체를 받는 단계와 본문을 읽어 변환하는 단계의 결과를 나타낸다. Promise는 이런 서로 다른 작업의 결과를 같은 방식으로 처리할 수 있게 해 준다.
프론트엔드에서는 여기에 화면 갱신 시점까지 구분해야 한다. 페이지의 텍스트를 “계산 중”으로 바꾸고 await Promise.resolve()를 수행한 다음 긴 계산을 시작하면, 사용자가 그 문구를 보기 전에 계산이 시작될 수 있다. 문서의 데이터는 변경되었지만, await 이후의 마이크로태스크가 실제 화면 갱신보다 먼저 실행될 수 있기 때문이다. await로 함수의 실행을 나누는 것만으로 화면을 그릴 기회가 보장되지는 않는다. HTML 표준의 이벤트 루프 처리 모델
큰 배열을 순회하면서 매번 await Promise.resolve()를 넣는 경우에도 재개 작업이 계속 추가되면 같은 체크포인트가 오래 이어질 수 있다. 계산량이 많은 작업은 작은 단위로 나누어 다른 task가 실행될 기회를 제공하거나, 페이지 문서를 직접 다룰 필요가 없다면 별도의 실행 환경인 Web Worker로 옮기는 방법을 검토해야 한다. Promise와 await의 사용 여부에 더해, 한 번에 실행하는 코드가 얼마나 오래 브라우저를 점유하는지 살펴봐야 한다.
본문의 실행 흐름을 더 살펴볼 때 참고할 용어들을 정리해 보자. 동기 실행은 현재 코드가 끝나야 같은 실행 흐름의 다음 코드로 넘어가는 방식이다. 비동기 처리는 작업의 완료를 기다리는 동안 호출한 쪽이 다른 코드를 진행하고, 결과가 준비되면 후속 처리를 수행할 수 있는 방식이다. 비동기 처리가 반드시 여러 계산을 동시에 수행한다는 뜻은 아니다. JavaScript의 문법과 동작을 정의하는 표준은 ECMAScript이고, 브라우저의 이벤트 루프와 여러 웹 API의 동작은 HTML 표준 등에서 정의한다.
객체는 관련된 데이터와 동작을 함께 다루는 JavaScript의 값이며, 객체에 연결해 호출하는 함수는 메서드라고 한다. 콜백은 필요한 때 호출하도록 다른 코드에 전달하는 함수다. 예외는 정상적인 코드 진행을 중단하고 문제를 전달하는 방식으로, throw로 발생시킬 수 있다. try/catch는 try 안에서 발생한 예외를 catch에서 처리하는 문법이다. Promise.resolve()는 전달한 값을 Promise로 다룰 수 있게 하는 메서드이며, 본문의 인자 없는 호출은 이미 이행된 Promise를 만든다. queueMicrotask()는 콜백을 마이크로태스크 큐에 직접 등록하고, setTimeout()은 지정한 시간이 지난 뒤 별도 task로 콜백을 실행할 수 있도록 예약한다. 시간에 0을 전달해도 현재 코드를 중단하고 즉시 실행한다는 뜻은 아니다.
이벤트 루프는 task를 실행하고, 정해진 시점에 마이크로태스크를 처리하며, 화면 갱신 기회가 있는지도 살피는 반복적인 실행 흐름이다. 스케줄러는 대기 중인 작업의 실행 순서와 우선순위를 조정하는 구성 요소다. Chromium에서는 Blink의 스케줄러와 그 아래의 작업 실행 기반이 관여한다. 큐는 작업을 순서대로 보관하는 대기열이지만, 브라우저 전체가 하나의 큐만 사용하는 것은 아니다. 또한 마이크로태스크라는 이름이 실행 시간이 짧다는 보장을 뜻하지도 않는다.
체크포인트는 task 완료 시점 외에도 수행된다. 실행할 JavaScript 코드인 스크립트의 실행을 정리하면서 실행 컨텍스트 스택이 비었을 때가 그 예다. 실행 컨텍스트는 JavaScript 실행에 필요한 상태이고, 스택은 현재 실행 중이거나 중첩 호출이 끝나기를 기다리는 실행 상태를 쌓아 두는 구조다. 일반적인 함수 호출에서는 호출할 때 쌓이고 반환할 때 빠진다. 안쪽 함수가 끝나도 바깥 함수가 실행 중이면 스택은 비어 있지 않으므로, 함수가 반환할 때마다 Promise 콜백이 끼어드는 것으로 이해해서는 안 된다. HTML 표준의 스크립트 실행 정리
스레드는 프로그램의 코드가 순서대로 실행되는 흐름이다. 페이지의 메인 스레드는 JavaScript 실행, 사용자 입력 처리, 화면 갱신에 필요한 여러 작업을 맡는다. Web Worker는 메인 스레드와 분리된 실행 환경에서 JavaScript를 실행하는 브라우저 기능이며, 페이지의 DOM을 직접 조작할 수 없다. DOM은 문서의 요소와 텍스트를 부모·자식 관계의 객체 구조로 표현한 것이다. 웹 API는 fetch()처럼 JavaScript에서 호출할 수 있도록 브라우저가 제공하는 기능이다. Response는 응답의 상태와 부가 정보, 본문을 읽는 기능을 제공하는 객체다. response.json()은 서버가 보낸 실제 데이터인 본문을 읽고, 텍스트로 데이터를 표현하는 형식인 JSON을 JavaScript 값으로 변환한다.
네트워크 처리에도 여러 구성 요소가 참여한다. Network Service가 이용하는 net은 연결과 데이터 송수신 등을 구현한 코드 모음이다. Mojo는 Chromium의 구성 요소들이 메시지를 주고받고 기능을 호출하도록 연결하는 통신 기반이다. content는 웹 콘텐츠의 실행 환경을 구성하는 계층이며, 네트워크 서비스의 시작과 연결에도 관여한다. 이들은 Promise 자체의 상태를 관리하는 V8과 역할을 나누어 실제 작업의 결과가 JavaScript에 전달되도록 한다.
문서의 변경을 실제 화면으로 만드는 과정을 렌더링이라고 한다. 요소에 적용할 모양을 정하는 스타일 계산, 크기와 위치를 정하는 레이아웃, 그릴 내용을 기록하는 페인트, 그 결과를 모아 화면을 구성하는 합성 등이 필요에 따라 수행된다. 컴포지터는 합성을 담당하는 구성 요소이며, Chromium의 Viz도 그리기 결과들을 모아 최종 화면에 표시하는 역할에 관여한다. 따라서 Promise 콜백이 끝났다는 사실과 화면 갱신이 끝났다는 사실은 구분해야 한다. Chromium의 렌더링·합성 구조
구현 설명은 Chromium de670a7740cd1과 V8 96fcbb71d274 기준이다. 이 값들은 코드의 특정 변경 이력을 식별하는 리비전이며, 소스 링크는 해당 시점의 코드에 고정했다.
'개발' 카테고리의 다른 글
| HTML에서 JavaScript를 불러오는 방법과 차이점 (1) | 2026.09.13 |
|---|---|
| ‘Discord로 로그인’을 누르면 무슨 일이 일어날까? (0) | 2026.09.06 |
| LLDB을 이용한 C++ 디버깅 원리 (1) | 2026.08.30 |
| 부동소수점은 왜 필요할까? (0) | 2026.08.23 |
| vscode에서 파이썬 프로그램을 디버깅 하는 방법 (0) | 2026.08.16 |