Loading the catalog…
Loading the catalog…
이벤트루프란 JavaScript의 런타임 모델은 코드 실행, 이벤트 수집과 처리, 큐에 대기 중인 하위 작업을 처리하는 이벤트 루프 에 기반하고 있어요. 이벤트 루프의 구조부터 살펴볼게요. 핵심은 이벤트루프가 콜스택이 비었을 때만 큐에서 콜백을 가져온다는 점입니다. 꺼내는 순서는 마이크로태스크큐가 가장 먼저 에요. Promise.then이나 queueMicrotask 콜백이 여기에 들어가요. 이들은 큐가 완전히 빌 때까지 전부 실행 돼요. 그다음 매크로태스크큐(이제부터 태스크큐라고 할게요)에서 setTimeout이나 이벤트 콜백을 딱 하나만 꺼내 실행 해요. 그 사이 브라우저가 렌더링 기회를 가진 뒤 처음부터 반복해요. 실행순서 예측해보기1 개념을 가볍게 잡았으니 코드로 실행순서를 예측해볼게요. console.log('1'); setTimeout(() => log('2'), 0); Promise.resolve().then(() => log('3')); console.log('4'); 먼저 동기코드가 1순위로 실행돼요. 그럼 1과 4가 가장 먼저 출력되겠죠. 두 번째로는 마이크로태스크큐를 끝까지 비워요. Promise.then 안에 있는 코드가 실행될테니 3이 출력돼요. 마지막으로 태스크큐에서 딱 하나만 실행해요. 태스크큐에는 setTimeout하나만 있으니 그걸 꺼내어 실행하면 2가 출력돼요. 헷갈리는 지점 바로 setTimeout의 0ms 부분인데요. 0ms면 지연없이 바로 실행되는 게 아닌가 생각하실 수도 있지만, 여기서 0ms는 "0ms 뒤 실행이 아니에요. 최소 0ms뒤에 태스크큐에 넣기 라는 뜻으로, 동기적 코드와 마이크로태스크가 다 끝날 때까지 기다려요. 여기서 기억할 점은 Promise가 setTimeout 뒤에 있지만, 큐의 우선순위로 Promise가 먼저 실행된다는 점이에요. 실행순서 예측해보기2 async function foo() { console.log('A'); await bar(); console.log('B'); } async function bar() { console.log('C'); } console.log('1'); setTimeout(() => console.log('2'), 0); foo(); Promise.resolve().then(() => console.log('3')); console.log('4'); 1을 출력해요. setTimeout 콜백이 태스크큐로 들어가요. foo()를 호출해 A를 출력해요. await bar()에서 bar()가 실행되면서 C를 출력해요. await 때문에 foo()의 마지막 부분 ( .log('B') )가 마이크로태스크큐에 들어가고, foo는 빠져나와요. Promise의 log('3') 이 마이크로태스크큐에 들어가요. (마이크로태스크큐 상태: [B, 3]) 4를 출력하고 콜스택이 비어요. 마이크로태스크큐를 들어온 순서대로 처리해서 B,3을 출력해요. 마지막으로 매크로큐에서 2를 꺼내어 출력해요. 출력결과: 1 → A → C → 4 → B → 3 → 2 여기서 헷갈리는 부분은 await bar가 왜 바로 실행되느냐인데요. await이 기다리는 건 bar()의 결과일 뿐이라서, bar()를 호출하는 것은 즉시 일어나요. 추가로 await이 없는 async 함수는 동기 함수처럼 실행되고, 반환값만 Promise로 감싸져요. 왜 이벤트루프가 React에서 중요할까 React는 브라우저의 메인 스레드 하나에서 돌아가요. JS실행, React렌더링, 브라우저 layout과 paint, 사용자 입력 처리가 모두 같은 이벤트루프를 통해 처리돼요. 그래서 React는 이벤트루프 안에서 언제 일하고 언제 양보할지 를 세심하게 처리해요. 1. 같은 태스크 안의 setState는 한 번만 렌더링된다 setState(count+1); console.log(count); // 아직 이전값 setState 는 상태를 즉시 바꾸지 않아요. 업데이트를 큐에 쌓아두고, 렌더링은 나중에 예약해둡니다. 같은 동기 실행 구간에서 호출한 setState 여러 개는 한 번의 렌더링으로 묶이는데, 이걸 bathing 이라고 해요. React17까지는 이벤트 핸들러 안에서만 batching이 됐고, 이로 인해 setTimeout이나 promise 콜백 안에서는 setState마다 렌더링이 일어났어요. 그 결과 같은 콜백에서 상태를 N번 바꾸면 렌더링도 N번 실행되어 성능이 낭비됐고, 렌더링과 렌더링 사이 "일부만 바뀐 상태"가 코드에 노출됐어요. 참고로 사용자 화면에는 변함이 없었어요. 이 모든 과정이 하나의 Task 안에서 끝났기 때문에 브라우저가 페인트할 틈이 없었어요. React18에서 Auto batching 이 생기면서 어디서든 호출해도 하나의 렌더링으로 묶어주기 시작했어요. 17에서는 비동기 콜백 안에서 setState 17번이 일어나면 렌더링이 17번 일어나지만, 18은 1번만 일어나요. 렌더링 비용이 비싼 이유 setState 한 번에 많은 과정을 거치는데요. 상태가 바뀐 컴포넌트와 그 하위 트리의 함수를 다시 호출 (memo로 막지 않으면) 새 결과를 이전 Fiber 트리와 비교 (reconciliation) 바뀐 부분을 실제 DOM에 반영 (commit) 의존성이 바뀐 effect의 클린업과 재실행을 처리 React17에서는 이 과정들이 N번 실행되니 중간 결과들은 필요가 없지만 순수하게 낭비되고 있었던거죠. 특히 useLayoutEffect 에서 DOM 크기를 읽는 코드가 있으면, 렌더링마다 브라우저가 레이아웃을 강제 계산해 비용이 더 커졌어요. 비용을 줄인 React18 setState 호출 시 바로 렌더링하지 않아요. 업데이트를 해당 컴포넌트의 업데이트 큐에 넣어요. "이 루트를 렌더해야 한다"는 작업을 예약해요. 이미 같은 우선순위의 작업이 예약돼 있으면 새로 만들지 않고 그걸 재사용해요. setTimeout(() => { setCount(c => c + 1); // 큐에 넣고 렌더 작업 예약 setFlag(f => !f); // 큐에 넣고, 예약은 이미 있으니 그대로 }, 1000); 여기서 클릭 같은 급한 업데이트는 마이크로태스크큐로, 일반 업데이트는 Scheduler의 Task(MessageChannel)로 예약돼요. 2. 우선순위에 따라 다른 큐를 사용 브라우저가 개발자에게 주는 선택지는 두 가지뿐이에요. 마이크로태스크 큐: 지금 하던 일이 끝나자마자 바로 실행 매크로태스크 큐: 브라우저가 화면을 그리거나 입력을 처리할 틈을 준 뒤 실행 HTML 스펙은 태스크 큐를 여러 개 두는 것을 허용해서, Chrome은 사용자 입력 Task를 다른 Task보다 먼저 꺼내기도 해요. 하지만 그건 브라우저에서 하는 일이고, 개발자는 "지금 바로인지, 다음인지"만 고를 수 있어요. React 순서를 제어하기 위해 자신만의 우선순위 큐를 하나 더 만들었어요. React Scheduler의 큐 Scheduler는 작업마다 마감시간을 붙여요. 급한 작업일수록 마감이 짧아요. 우선순위 마감 시간 Immediate 즉시 UserBlocking 250ms Normal 5초 Low 10초 Idle 없음 마감이 가장 가까운 작업부터 꺼내 처리해요. 이러면 급한 작업이 자연스럽게 먼저 처리되고, 오래 밀려 있던 작업도 마감이 다가오면 차례가 와서 처리돼요. 급한 일만 처리돼서 덜 급한 일이 처리되지 않는 것을 방지하는거죠. 왜 MessageChannel일까 마이크로태스크 큐가 빌 때까지 연달아 실행돼요. 그래서 그 사이에 브라우저가 화면을 그릴 틈이 없어요. setTimeout 0ms로 걸어도 중첩 호출이 5번을 넘으면 브라우저가 최소 4ms를 기다리게 해요. 5ms 일하고 4ms 쉬면 시간의 절반 가까이 버리게 돼요. requestAnimationFrame 다음 화면을 그리기 지전에만 실행돼서, 최대 한 프레임(16ms) 기다려야 해요. requestIdleCallback 브라우저가 한가할 때만 실행돼서 언제 돌 지 예측이 안 돼요. Safari는 지원하지 않아요. MessageChannel postMessage()를 호출하면 지연 없이 바로 다음 Task로 콜백이 예약돼요. 매크로태스크라서 그 사이 브라우저가 화면을 그릴 수 있어요. 3. 타임슬라이싱 타임슬라이싱은 긴 렌더링 작업을 잘게 쪼개서, 조각 사이마다 브라우저에게 차례를 넘겨줘요. JS는 한 번에 하나의 일만 할 수 있기 때문에, 렌더링 도중 아무것도 못 하는 상황을 방지하기 위해서 5ms마다 브라우저에게 차례를 넘기는 거에요. 모든 렌더링이 쪼개지진 않아요. startTransition 이나 useDeferredValue 로 표시한 업데이트만 타임슬라이싱으로 처리돼요. 클릭이나 입력처럼 즉시 보여야 하는 업데이트는 쪼개지 않고 한 번에 처리해요. 정리 React가 이벤트루프를 다루는 방식은 세 가지로 정리할 수 있어요. Automatic Batching : 같은 태스크 안에서 일어난 setState를 모아 한 번만 렌더링. 우선순위별 스케줄링 : 급한 업뎃은 마이크로태스크로, 일반 업데이트는 Scheduler의 큐에 넣어 MessageChannel로 처리. 타임슬라이싱 : 급하지 않은 렌더링은 5ms 단위로 쪼개서, 조각 사이마다 브라우저가 처리. 참고 https://github.com/reactwg/react-18/discussions/21 https://react.dev/blog/2022/03/29/react-v18#new-feature-automatic-batching
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
React가 이벤트루프를 다루는 방법. 이벤트루프란 JavaScript의 런타임 모델은 코드 실행, 이벤트 수집과 처리, 큐에 대기 중인 하위 작업을 처리하는 이벤트 루프 에 기반하고 있어요. 이벤트 루프의 구조부터 살펴볼게요. 핵심은 이벤트루프가 콜스택이 비었을 때만 큐에서 콜백을 가져온다는 점입니다. 꺼내는 순서는 마이크로태스크큐가 가장 먼저 에요. Promise.then이나 queueMicrotask 콜백이 여기에 들어가요. 이들은 큐가 완전히 빌 때까지 전부 실행 돼요. 그다음 매크로태스크큐(이제부터 태스크큐라고 할게요)에서 setTimeout이나 이벤트 콜백을 딱 하나만 꺼내 실행 해요. 그 사이 브라우저가 렌더링 기회를 가진 뒤 처음부터 반복해요. 실행순서 예측해보기1 개념을 가볍게 잡았으니…
Open source