1. 브라우저의 화면 그리기 (CRP: Critical Rendering Path)
브라우저가 HTML 파일을 받아서 화면에 픽셀을 뿌리기까지의 과정
- DOM/CSSOM 생성: HTML/CSS를 읽어 브라우저가 이해하는 트리 구조로 변환.
- Render Tree: 화면에 '진짜 보일' 것들만 골라낸 트리.
- Layout(Reflow): 각 요소의 위치와 크기 계산.
- Paint(Repaint): 실제 픽셀을 색칠하는 과정.
- Composite: 나누어진 레이어들을 합쳐 최종 화면 완성.
Repaint & Reflow & Composite
1. Reflow (Layout)
브라우저가 요소의 기하학적 구조(위치와 크기)를 계산하는 단계. 가장 비용이 많이 드는 '비싼' 작업!
- 발생 조건: 요소의 크기가 변하거나, 위치가 바뀌거나, 폰트 크기가 달라지는 등 주변 요소의 배치에 영향을 줄 때 발생!!
- 연쇄 반응: 한 요소의 크기가 커지면 그 옆이나 아래에 있는 요소들도 밀려나기 때문에 연쇄적으로 계산이 일어나 성능 부하가 크다
2. Repaint (Paint)
레이아웃은 변하지 않았지만, 요소의 시각적 스타일이 바뀌었을 때 발생한다.
- 발생 조건: 위치나 크기에는 영향이 없지만, 단순히 눈에 보이는 색상이나 스타일이 바뀔 때이다.
- 비교: Reflow보다는 가볍지만, 여전히 브라우저가 픽셀을 다시 그려야 하므로 비용이 발생한다.
- Reflow가 일어나면 Repaint는 무조건 따라온다. (설계도를 다시 그렸으니 당연히 다시 칠해야 하기 때문이라고 한다!)
3. Composite (합성)
현대 브라우저 성능 최적화의 핵심
화면을 여러 개의 레이어(Layer)로 쪼개서, 각 레이어를 따로 그린 뒤 마지막에 합치는 방식.
- 발생 조건: 레이아웃이나 페인트를 건드리지 않고, 이미 그려진 레이어의 변형(Transform)이나 투명도(Opacity)만 조절할 때 발생.
- 특징: CPU가 아닌 GPU(그래픽 카드)가 직접 처리한다. 이미 그려진 레이어를 종이 옮기듯 이동만 시키는 거라 엄청나게 빠르다!
'실전 최적화' 팁
- 애니메이션은 무조건 transform으로!
- top/left를 움직이면 매 프레임마다 Reflow가 일어난다. (CPU 과부하)
- transform: translate()를 쓰면 Composite만 일어난다. (GPU가 부드럽게 처리)
- display: none vs visibility: hidden
- display: none은 공간 자체를 없애므로 Reflow를 유발한다.
- visibility: hidden은 공간은 유지하고 모습만 감추므로 Repaint만 발생한다.
2. 브라우저의 운명: 16.6ms 프레임 예산 (Frame Budget)
V8 엔진과 CRP가 실제로 만나는 지점이 바로 여기이다.
16.6ms의 법칙 (60fps)
- 원리: 대부분의 모니터는 초당 60번 화면을 갱신한다(60Hz).
즉, 브라우저는 1초 / 60 = 약 16.6ms 안에 모든 작업(JS 실행 + Reflow + Repaint)을 마쳐야 한다. - 프레임 드랍(Frame Drop): 만약 V8이 복잡한 계산을 하느라 20ms를 써버리면? 그 프레임은 건너뛰게 되고, 사용자는 화면이 뚝뚝 끊기는 'Jank'를 느끼게 된다.
- 16.6ms마다 한 번씩 무조건 화면을 그려야(Paint) 사용자가 "오, 부드러운데?"라고 느낄 수 있다.
메인 스레드(Main Thread)의 병목 현상
- 브라우저의 메인 스레드는 아주 바쁜 친구이다..
- 자바스크립트 실행 (V8)
- 레이아웃 계산 (Reflow)
- 페인트 작업 (Repaint)
- 이 모든 게 한 줄로 서서 처리된다. 즉, JS가 너무 오래 걸리면(Long Task) 화면을 그릴 기회조차 얻지 못한다.
프레임 관점에서의 최적화 팁
- Long Task 피하기: 하나의 JS 실행이 50ms를 넘지 않도록 쪼개야 한다. (그래야 중간에 브라우저가 여유 시간을 얻어 화면을 그린다.)
- GC와 프레임: 오리노코 GC가 Incremental(증분) 방식을 쓰는 이유도 이 때문이다. 쓰레기를 한 번에 다 치우면 16.6ms를 훌쩍 넘기니까, 프레임 사이사이에 조금씩 치우게는 하는 것이다.
- rAF(requestAnimationFrame): setTimeout은 16.6ms 주기를 맞추지 못할 때가 많다. 브라우저의 렌더링 주기에 딱 맞춰 코드를 실행하고 싶다면 rAF를 쓰는 것이 정석이다!
rAF vs setTimeout (정확한 타이밍)
- setTimeout (예약 알람): "대충 10ms 뒤에 실행해줘!"라고 하면, 엔진은 10ms 뒤에 실행하려고 노력하지만, 그 시점이 화면을 그리기 직전인지 직후인지 관심이 없다. 운 나쁘게 화면을 다 그린 '직후'에 실행되면, 다음 화면이 나올 때까지 그 결과는 반영되지 않고 붕 뜨게 된다.
- rAF (전문 안내원): 브라우저가 "나 이제 화면 그리려고 준비 중인데, 그 직전에 할 일 있으면 지금 빨리 말해!"라고 신호를 주는 것이다 !
- 결과: rAF 안에 애니메이션 코드를 넣으면, [JS 계산 -> 화면 그리기]가 아주 칼같이 딱딱 맞아서 낭비 없이 부드러운 화면이 나온다.
setTimeout
- 문제점 1 (주기 불일치): 모니터가 60Hz(16.6ms 주기)라면, 16ms와 아주 미세하게 차이가 난다. 이 오차가 쌓이면 어느 프레임에서는 한 번도 실행이 안 되고, 어느 프레임에서는 두 번 실행되어 화면이 떨리는 현상(Jank)이 생긴다.
- 문제점 2 (비효율): 브라우저 탭이 백그라운드에 있어도(안 보여도) 계속 계산을 돌린다. 배터리와 CPU 낭비의 주범이다.
let position = 0;
const box = document.getElementById('box');
function move() {
position += 1;
box.style.transform = `translateX(${position}px)`;
// 16ms 뒤에 다시 실행 (60fps를 흉내 내려고 노력함)
setTimeout(move, 16);
}
move();
requestAnimationFrame
- 장점 1 (완벽한 싱크): 브라우저의 주사율(60Hz, 120Hz 등)에 자동으로 동기화된다. 144Hz 모니터라면 알아서 초당 144번 실행된다.
- 장점 2 (스마트한 절전): 탭이 보이지 않는 상태(백그라운드)가 되면 브라우저가 자동으로 중단시킨다. CPU가 쉴 수 있게 해줌!!
- 장점 3 (부드러움): JS 계산이 끝나자마자 바로 레이아웃과 페인트 단계가 이어지기 때문에 시각적으로 가장 부드럽다.
let position = 0;
const box = document.getElementById('box');
function move() {
position += 1;
box.style.transform = `translateX(${position}px)`;
// 브라우저가 다음 화면을 그리기 직전에 다시 실행해달라고 예약!
requestAnimationFrame(move);
}
// 첫 시작
requestAnimationFrame(move);
왜 rAF가 "정확한 타이밍"일까?
브라우저는 매 프레임마다 아래와 같은 루틴을 반복한다.
- Macrotask 실행 (setTimeout 등)
- Microtask 실행 (Promise 등)
- rAF 실행 (<-- 화면 그리기 직전!)
- Render Steps (Style -> Layout -> Paint -> Composite)
setTimeout은 1번 단계에서 실행된다.
만약 여기서 계산이 조금이라도 길어지면 4번 단계(화면 그리기)가 뒤로 밀려버린다.
반면 rAF는 4번 단계 바로 직전에 딱 붙어서 실행되기 때문에, 계산 결과가 바로 다음 화면에 즉각적으로 반영되는 것이다!
3. 이벤트 루프!
에ㅔ휴.. 생각좀하고 살걸
자바스크립트 엔진 자체는 싱글 스레드
- JS 엔진(예: V8)은 Call Stack(호출 스택) 하나만 갖고 있어서, 동시에 여러 JS 코드를 실행하지 못함
- 즉, JS 코드 실행 자체는 동시에 하나만 수행
console.log("A");
console.log("B");
// => 무조건 A 먼저, B 나중에 실행
하지만 브라우저/Node.js 런타임은 멀티스레드
- 브라우저나 Node.js는 JS 엔진 외에도 Web APIs / libuv 같은 백그라운드 스레드를 갖고 있음.
- 이들이 네트워크 요청, 타이머, 파일 읽기 같은 작업을 엔진 밖에서 실행시킴.
- 그러다 끝나면 이벤트 루프(Event Loop)를 통해 JS 엔진의 큐(Queue)에 콜백을 넣어줌.
- 맨날 그림에서 보던 그거..
동작 흐름
- JS 코드 실행 → 네트워크 요청, setTimeout 등을 호출.
- 해당 비동기 작업은 브라우저/Node의 백그라운드 스레드에서 처리.
- 완료 시 → 콜백을 Task Queue / Microtask Queue에 넣음.
- 이벤트 루프가 Call Stack이 빌 때마다 큐에서 콜백을 꺼내 실행.
console.log("시작");
setTimeout(() => {
console.log("타이머 끝");
}, 1000);
console.log("끝");
실행 순서
- "시작" 출력
- setTimeout 등록 → 타이머는 백그라운드 스레드가 담당
- "끝" 출력
- 1초 후 타이머 완료 → 콜백이 큐로 이동
- 이벤트 루프가 스택이 비는 순간 콜백 실행 → "타이머 끝" 출력
이벤트 루프?~?
이벤트 루프(Event Loop)는 자바스크립트가 비동기 작업을 싱글 스레드 환경에서 처리할 수 있게 해주는 핵심 메커니즘!
“Call Stack(실행 중인 코드)이 비었는지 확인하고,
Queue(대기 중인 콜백)가 있으면 꺼내서 실행하는 반복 동작”
“Call Stack(실행 중인 코드)이 비었는지 확인하고,
Queue(대기 중인 콜백)가 있으면 꺼내서 실행하는 반복 동작”
이벤트 루프 구성요소
┌─────────────────────────────┐
Call Stack ← 실행 중인 자바스크립트 코드
(console.log, 함수 실행)
└─────────────▲───────────────┘
│ (비면 이벤트 루프가 큐 확인)
│
┌─────────────┴───────────────┐
Event Loop ← "스택이 비었나? 큐에 할 일 있나?" 확인하는 관리자
└─────────────▲───────────────┘
│
┌──────────┴────────────┐
│ │
┌──┴────────────────┐ ┌──┴────────────────┐
Microtask Queue Task Queue
(Promise.then, (setTimeout, I/O,
async/await 등) 이벤트 핸들러 등)
└───────────────────┘ └────────────────────┘
↑ ↑
│ │
┌───────┴────────┐ ┌───────┴─────────┐
Web APIs Node.js 백그라운드
(타이머, fetch, (파일, 네트워크 I/O)
DOM 이벤트 등)
└────────────────┘ └─────────────────┘
Call Stack (호출 스택)
- 현재 실행 중인 자바스크립트 코드가 쌓이는 곳.
- 함수 호출이 들어오면 위로 push, 끝나면 pop.
- 항상 스택이 비어야 큐에서 새로운 작업을 가져올 수 있음.
Web APIs (브라우저) / 백그라운드 (Node.js의 libuv)
- setTimeout, setInterval, fetch, DOM 이벤트, Ajax 요청 같은 비동기 작업을 처리하는 영역.
- 자바스크립트 엔진이 직접 처리하지 않고, 런타임(브라우저/Node)이 맡아 수행함.
- 작업이 끝나면 큐(Queue) 에 콜백을 넣어줌.
Task Queue (또는 Macro Task Queue)
- Web APIs에서 완료된 콜백이 들어오는 일반 작업 대기열.
- setTimeout
- setInterval
- DOM 이벤트 콜백
- I/O 콜백 (파일, 네트워크)
Microtask Queue
- Task Queue보다 우선순위가 높은 작은 작업 대기열.
- Promise.then
- async/await (내부적으로 Promise)
- MutationObserver
- 이벤트 루프는 Call Stack이 비면 항상 Microtask Queue를 먼저 비움 → 그다음 Task Queue 실행.
Event Loop
- Call Stack이 비었는지 계속 감시 비었다면:
- Microtask Queue 먼저 실행 (전부 다 처리).
- 그다음 Task Queue에서 하나 꺼내서 실행.
- 이 과정을 무한 반복.
한 줄 요약
이벤트 루프는
- Call Stack (실행 중)
- Web APIs/백그라운드 (비동기 처리 담당)
- Microtask Queue (Promise/async 우선)
- Task Queue (setTimeout, 이벤트 등)
이 네 가지를 오가면서, 싱글 스레드 자바스크립트가 멀티태스킹처럼 보이게 해주는 구조!
이벤트 예시 코드
console.log("스크립트 시작");
// Task Queue 예제
setTimeout(() => {
console.log("setTimeout 콜백 실행 (Task Queue)");
}, 0);
// Microtask Queue 예제
Promise.resolve().then(() => {
console.log("Promise.then 콜백 실행 (Microtask Queue)");
});
// 동기 코드
console.log("스크립트 끝");
📌 예상 실행 과정
- console.log("스크립트 시작") → Call Stack 즉시 실행
- setTimeout 등록 → Web APIs에서 타이머 대기 → Task Queue로 이동
- Promise.then 등록 → Microtask Queue에 대기
- console.log("스크립트 끝") → Call Stack 즉시 실행
- Call Stack 비었음 → Event Loop가 Microtask Queue 먼저 실행 → "Promise.then..." 출력
- Microtask Queue 다 끝남 → Task Queue 실행 → "setTimeout..." 출력
실행 결과 (출력 순서)
스크립트 시작
스크립트 끝
Promise.then 콜백 실행 (Microtask Queue)
setTimeout 콜백 실행 (Task Queue)
이 코드 덕분에 알 수 있는 포인트:
- 동기 코드 > Microtask Queue > Task Queue 순으로 실행된다.
- 자바스크립트가 싱글스레드임에도 불구하고, 이벤트 루프 덕분에 여러 일이 동시에 진행되는 것처럼 보인다.
4. Promise/async/await 톺아보기!
Promise: "미래의 결과를 약속하는 객체"
Promise는 그 이름처럼 비동기 작업의 최종 결과(성공 또는 실패)를 나타내는 객체이다.
- 동기적 실행: new Promise((resolve) => { ... }) 내부의 코드는 즉시(Sync) 실행된다.
- 비동기적 처리: .then()이나 .catch()에 등록된 콜백은 마이크로태스크 큐(Microtask Queue)로 간다
많은 사람이 new Promise를 쓰면 그 안의 코드들이 자동으로 비동기 어딘가로 갈 거라고 생각하지만, 그렇지 않다
- Executor(실행자): new Promise((resolve, reject) => { ... })에서 이 괄호 안의 코드는 즉시 실행!
- 비동기인 부분: 오직 resolve()나 reject()가 호출된 이후의 .then(), .catch() 콜백들만 비동기(Microtask Queue)로 간다....!
- 아마 대부분 await axios.get().... 과 같은 코드로 인해 오해를 했을 것 같다. (나도 그랬음...)
우선 아래와 같이 익숙한 코드를 통해 기존 Promise가 어떻게 작동하는 것인지 알아보자 !
console.log(1);
new Promise((resolve) => {
console.log(2); // 이건 동기! 바로 실행!
resolve();
}).then(() => {
console.log(3); // 이건 비동기(Microtask)!
});
console.log(4);
// 1. console.log(1) : 콜스택 추가 -> 실행(출력: 1) -> 콜스택 제거
// 2. new Promise(executor) : 생성자 함수가 콜스택에 추가됨
// 3. console.log(2) (executor 내부) : 콜스택 추가 -> 실행(출력: 2) -> 콜스택 제거
// 4. resolve() 호출 : Promise의 상태를 fulfilled로 변경
// 5. .then() 등록 : 콜백 함수(log(3))를 **마이크로태스크 큐(Microtask Queue)**에 예약 전송
// 6. new Promise 생성 완료 : 생성자 함수가 콜스택에서 제거됨
// 7. console.log(4) : 콜스택 추가 -> 실행(출력: 4) -> 콜스택 제거
// 8. 전체 스크립트 실행 완료 : 콜스택이 완전히 비워짐 (이 시점이 중요!)
// 9. 이벤트 루프(Event Loop) 등판 : "콜스택 비었네? 마이크로태스크 큐에 뭐 있지?" 확인
// 10. console.log(3) 콜백 : 마이크로태스크 큐에서 콜스택으로 이동 -> 실행(출력: 3) -> 콜스택 제거
Promise가 async/await를 만나면 !?
async/await는 새로운 기술이 아니라 Promise를 기반으로 한 Syntactic Sugar 뿐이다.
하지만 동작 방식에는 아주 중요한 차이가 있다!
- async: 이 키워드가 붙은 함수는 무조건 Promise를 반환한다.
- 그냥 값을 리턴해도 엔진이 Promise.resolve()로 감싸버림
- await: Promise가 해결될 때까지 함수의 실행을 일시 정지시킨다!
- await는 비동기 작업의 '실제 진행 상황'을 감시하는 게 아니라, Promise가 Settled 상태를 대기하는 감시자일 뿐이다
어 그럼 promise가 바로 fullfield 상태면 콜스택에서 안빠져 나가는거 아냐 ?
결론적으로 await 뒤에 오는 Promise가 이미 성공한 상태(Fulfilled)이든, 기다려야 하는 상태(Pending)이든 상관없이, await 뒷줄부터의 코드는 무조건 마이크로태스크 큐(Microtask Queue)를 거쳐서 실행된다!!
아래 두 코드를 보면서 console.log의 결과를 살펴보면 결론적으로 똑같다 !
async function fastCase() {
console.log('2. 함수 진입');
await Promise.resolve(); // 이미 성공한 프라미스!
console.log('4. await 다음 줄'); // <--- 무조건 큐로 이동
}
console.log('1. 시작');
fastCase();
console.log('3. 끝');
// 1 -> 2 -> 3 -> 4
async function slowCase() {
console.log('2. 함수 진입');
await new Promise(res => setTimeout(res, 1000)); // 1초 대기
console.log('4. await 다음 줄'); // <--- 나중에 큐로 이동
}
console.log('1. 시작');
slowCase();
console.log('3. 끝');
// 1 -> 2 -> 3 -> (1초 뒤) 4
만약 프라미스의 상태(settled, pending)에 따라 실행 순서가 바뀌면 코드의 흐름을 예측할 수 없다!.
그래서 자바스크립트는 "이미 Settled된 Promise라도 마이크로 태스크 큐를 한번 거쳐라!"라는 일관된 규칙으로 코드의 흐름을 엄격하게 제어하기 위함이다
await를 만난 프라미스의 실행 흐름을 살펴보면 아래와 같다
- 생성자 코드(Executor)는 이 시점에 동기적으로 즉시 실행!
- await를 만남: 엔진이 뒤에 있는 Promise 상태를 확인한다.
- excutor가 모두 동기라면 fullfied일 것이고 안에 비동기(setTimeout or fetch)라면 pending!
- 함수 중단: 상태가 무엇이든, 현재 함수 실행을 멈추고 콜 스택에서 빠져나온다. (이게 핵심!)
- 큐 예약: 이미 해결됨: 즉시 await 다음 코드를 마이크로태스크 큐에 넣는다.
- 대기 중: 나중에 해결되는 순간 마이크로태스크 큐에 넣는다.
- 제어권 반환: 엔진은 콜 스택 아래에 남아있던 다른 동기 코드들을 마저 실행한다.
- 비동기 재개: 콜 스택이 텅 비면, 이벤트 루프가 큐에서 코드를 꺼내와 실행한다.
좀 더 깊은 이해를 위해 아래 코드를 살펴보자!
나는 사실 프로미스 생성자 함수 내부가 동기코드라는걸 받아들이는데 좀 오래걸렷다 ㅠㅠ..
async function slowCase() {
console.log('2. 함수 진입');
await new Promise((resolve) => {
console.log('3. 프라미스 생성자 내부 (동기!)');
setTimeout(() => {
resolve();
}, 1000);
});
console.log('5. await 다음 줄');
}
console.log('1. 시작');
slowCase();
console.log('4. 끝');
- console.log('1. 시작'): 콜스택 추가 -> 출력: 1 -> 제거
- slowCase() 호출: 콜스택에 추가되어 실행 시작
- console.log('2. 함수 진입'): 콜스택 추가 -> 출력: 2 -> 제거
- new Promise(executor) 실행:
- 중요: Promise 생성자 안의 코드는 즉시(동기) 실행!
- console.log('3. 내부'): 콜스택 추가 -> 출력: 3 -> 제거
- setTimeout을 브라우저(Web API)에 맡기고 resolve는 나중에 부르기로 약속함.
- await 등장:
- slowCase 함수를 일시 정지시키고 콜스택에서 통째로 탈출
- 제어권이 다시 전역 스크립트로 넘어간다.
- console.log('4. 끝'): 콜스택 추가 -> 출력: 4 -> 제거
- 1초 대기: 브라우저가 타이머를 돌림.
- Resolve 호출: 1초 뒤 resolve()가 실행되어 프라미스가 Fulfilled 상태가 된다.
- 마이크로태스크 큐 예약: 이제야 console.log('5') 부분이 마이크로태스크 큐에 들어간다.
- 최종 실행: 이벤트 루프가 큐에서 꺼내 콜스택에 올린다. -> 출력: 5
await의 핵심 : "함수 탈출"
await를 만나면 함수는 콜 스택에서 즉시 빠져나와 제어권을 브라우저에 넘긴다.
그리고 await 뒤에 남은 코드들은 전부 마이크로태스크 큐에 들어간다!!
아래 함수가 어떻게 콜스택과 작동하는지 살펴보자 !
async function a() {
console.log(1)
const p = await new Promise((resolve) => {
setTimeout(() => {
resolve(1); // 1초 후에 settled!
}, 0);
});
console.log(2)
}
a()
console.log('end')
// 1. console.log(1) 출력
// 2. setTimeout 실행 → 브라우저 API에 콜백 등록
// 3. Promise가 pending → await에서 a() 빠짐
// 4. console.log('end') 출력
// 5. 타이머 완료 (0ms라서 거의 즉시!)
// 6. setTimeout 콜백이 Task Queue로 감
// 7. Call Stack 비면 → 콜백 실행 → resolve(1) 호출
// 8. 재개 콜백이 Microtask Queue로 감
// 9. a() 재개 → console.log(2) 출력
+++ 리액트에서 배칭이 비동기를 만나면?
1. 과거의 리액트 (v17 이하): "절반의 배칭"
과거 리액트의 배칭은 브라우저의 기본 이벤트(onClick, onChange 등) 핸들러 내부에서만 제한적으로 작동했음.
- 원리: 리액트가 관리하는 호출 스택(Call Stack) 안에서 동기적으로 실행되는 코드들만 하나로 묶어 렌더링함.
- 문제: await나 setTimeout을 만나는 순간, 그 이후의 코드들은 리액트의 통제권을 벗어나 마이크로태스크 큐(Microtask Queue)로 넘어가 버림. 리액트는 이벤트 핸들러가 끝났다고 판단하고 배칭을 마감함.
// React v17 예시
const handleClick = async () => {
setCount(c => c + 1);
setFlag(f => !f);
// 여기까지는 동기 영역이라 1번만 렌더링 (Batching OK)
await fetchData();
setCount(c => c + 1); // 리렌더링 발생 (Batching X)
setFlag(f => !f); // 리렌더링 또 발생 (Batching X)
// 비동기 이후에는 호출마다 렌더링이 각각 일어남
};
2. 현재의 리액트 (v18 이상): "Automatic Batching"
리액트 18부터는 성능 보장을 위해 어떤 환경에서든 배칭이 가능하도록 설계가 변경됨.
- 원리: setState가 호출되는 위치를 따지지 않고, 이벤트 루프의 한 사이클(Task) 안에서 발생하는 업데이트들을 수집함.
- 동작 방식: 리액트 스케줄러가 업데이트를 바구니에 담아두었다가, 실행 스택이 비는 시점에 최종 상태값으로 단 한 번만 UI를 업데이트함.
import React, { useState } from 'react';
function BatchingExample() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
console.log("리액트가 화면을 그린다! (Render)");
const handleClick = async () => {
console.log("버튼 클릭!");
// 비동기 작업 (API 호출 등)
await new Promise((resolve) => setTimeout(resolve, 1000));
// --- 여기서부터 중요: await 이후의 작업은 마이크로태스크 큐에서 실행됨 ---
setCount(c => c + 1); // 1. 상태 업데이트 예약 (렌더링 안 함)
setFlag(f => !f); // 2. 상태 업데이트 예약 (렌더링 안 함)
console.log("✅ 모든 상태 변경 예약 완료");
// 이 함수(마이크로태스크)가 완전히 종료되는 시점에
// 리액트는 바구니에 담긴 변경사항을 확인하고 딱 한 번만 리더링을 수행함.
};
return (
<div>
<p>Count: {count}</p>
<p>Flag: {flag ? "True" : "False"}</p>
<button onClick={handleClick}>비동기 업데이트 시작</button>
</div>
);
}
3. 여전히 존재하는 부작용 (성능과 정합성)
자동 배칭이 만능은 아님. 자바스크립트 엔진의 비동기 메커니즘 때문에 발생하는 한계점들을 이해해야 함.
- 렌더링 지연 (Rendering Starvation):
- 현상: await 이후 마이크로태스크 큐에 무거운 연산이나 수만 번의 setState가 들어있을 때 발생.
- 원인: 이벤트 루프는 마이크로태스크 큐가 완전히 비워질 때까지 화면을 그리는(Paint) 단계로 넘어가지 않음. JS가 메인 스레드를 너무 오래 점유하면 화면이 뚝뚝 끊기는 Jank를 피할 수 없음.
- 스냅샷과 클로저 문제 (Stale Closure):
- 현상: await 이후 setState를 실행할 때, 참조하는 상태값이 업데이트 전의 '과거 값'인 경우.
- 원인: 배칭의 결함이 아니라 자바스크립트 클로저(Closure)의 특성임. await로 일시 정지되었다가 재개된 함수는 실행 컨텍스트가 생성될 당시의 스코프를 그대로 붙잡고 있기 때문임.
// Stale Closure 예시
const [count, setCount] = useState(0);
const handleAlert = async () => {
setCount(count + 1); // count는 0인 상태
await delay(1000);
// 1초 뒤, 이 함수가 재개될 때 'count' 변수는 여전히 0임 (생성 당시의 스냅샷)
console.log(count); // 출력: 0
// 해결책: 함수형 업데이트를 사용해야 함
setCount(prev => prev + 1);
};
'Javascript > Javascript 지식' 카테고리의 다른 글
| Promise에 대한 오해.., await/async 좀 더 알아보기 (0) | 2026.02.17 |
|---|---|
| Javascript 프로토타입 (feat.일급객체) (0) | 2026.02.16 |
| Javscript 엔진 톺아보기 (feat. v8, 파이프라인, 클로져 등등..) (0) | 2026.02.13 |
| 비동기 vs 병렬 (0) | 2025.09.18 |
| Javascript는 싱글 스레드인데 여러 일을 하네..? (이벤트 루프) (0) | 2025.09.18 |
주니어 개발자에욤
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!