dev.to

How React Actually Works Under the Hood (And Why Your Mental Model Might Be Wrong)

React는 내부에서 어떻게 동작할까요 — 상태 스냅샷과 Fiber를 중심으로

React에서 상태를 바꿔도 같은 이벤트 핸들러 안의 값은 갱신되지 않는 이유를 렌더별 상태 스냅샷과 클로저로 설명합니다. Fiber, Hook 호출 순서, 재조정, 키와 컴포넌트 재렌더링까지 연결해 React의 렌더·커밋 과정을 풀어냅니다.

AI 요약

React에서 setState 바로 뒤에 값을 출력했는데 이전 값이 찍히는 현상은 상태 갱신이 단순히 늦게 처리되기 때문만은 아닙니다. 이 글은 React를 DOM을 즉시 수정하는 도구로 보는 관점 대신, 업데이트를 예약하고 새 UI를 계산한 뒤 실제 DOM에 반영하는 시스템으로 설명합니다. Fiber 트리와 Hook 저장 방식, 클로저가 붙잡는 렌더별 상태를 살펴보며 자주 겪는 동작을 연결합니다.

업데이트는 예약, 렌더, 커밋 순서로 진행됩니다

setCount(count + 1)을 호출하면 현재 함수 안의 count가 즉시 바뀌지 않습니다. React는 컴포넌트의 내부 표현에 업데이트를 큐잉하고 작업을 예약합니다. 여러 업데이트가 한 이벤트 안에서 일어나면 React는 이를 묶어 처리할 수 있습니다.

렌더 단계에서 React는 컴포넌트 함수를 다시 호출합니다. 여기서 ‘렌더’는 화면의 픽셀을 그린다는 뜻이 아니라, JSX를 바탕으로 다음 UI를 계산하는 과정입니다. JSX는 컴파일 단계에서 React.createElement 같은 호출로 바뀌며, React는 UI를 설명하는 JavaScript 객체를 만듭니다. 새 객체 트리와 이전 트리를 비교하는 작업이 재조정(Reconciliation)입니다. React는 바뀐 부분을 계산한 뒤 커밋 단계에서 브라우저 DOM에 변경을 반영합니다. 글은 렌더 단계의 계산과 실제 DOM 변경을 구분하며, 커밋은 동기적으로 진행된다고 설명합니다.

Hook은 호출 순서로 상태를 찾습니다

React는 변수 이름인 name이나 age를 읽어 상태를 연결하지 않습니다. 글에서 설명하는 구현 방식에서는 Hook이 Fiber 노드의 memoizedState에 연결 리스트 형태로 저장됩니다. 컴포넌트가 다시 실행되면 React는 첫 Hook부터 호출 순서대로 상태를 읽습니다.

따라서 조건문이나 반복문 안에서 Hook을 호출하면 안 됩니다. 한 렌더에서 세 번째까지 호출하던 Hook을 다음 렌더에서 건너뛰면, 뒤에 있는 useState가 이전 렌더에서 다른 Hook이 차지했던 자리를 읽게 됩니다. 호출 순서를 렌더마다 일정하게 유지해야 상태와 효과가 올바른 Hook에 연결됩니다.

상태는 각 렌더의 스냅샷입니다

컴포넌트 함수가 실행될 때 props와 state는 그 실행 동안 고정된 값입니다. 핸들러도 해당 렌더에서 만들어지므로 그 렌더의 상태를 클로저로 붙잡습니다. 그래서 setCount(count + 1) 뒤에 같은 핸들러에서 count를 출력하면 이전 값이 나옵니다. setCount는 현재 함수의 지역 변수를 수정하는 대신, 다음 렌더에서 새 값을 사용하도록 React에 요청합니다.

같은 원리는 비동기 콜백에도 적용됩니다. 글의 예시에서는 message가 ‘Hello’인 렌더에서 만든 setTimeout 콜백이 그 값을 기억합니다. 사용자가 입력을 ‘Goodbye’로 바꿔도 이미 만들어진 콜백은 ‘Hello’를 출력합니다. 비동기 콜백에서 최신 값을 읽어야 하는 경우에는 useRef를 사용하는 방법을 제시합니다.

부모 렌더와 목록 키의 영향

글은 컴포넌트가 자체 상태나 구독 중인 Context가 바뀌거나 부모가 다시 렌더링될 때 렌더링된다고 설명합니다. 기본 설정에서는 부모가 다시 렌더링되면 자식 컴포넌트도 다시 실행됩니다. 불필요한 재렌더링을 줄이려고 모든 곳에 React.memo와 useCallback을 붙이기보다, 상태를 작은 컴포넌트로 옮기고 자식을 children으로 전달하는 컴포넌트 조합을 대안으로 소개합니다. 자식 요소가 부모의 재렌더링 바깥에서 만들어져 같은 객체 참조로 전달되는 예시에서는 자식이 다시 렌더링되지 않습니다.

목록에서 배열 인덱스를 key로 쓰면 항목을 삭제하거나 재정렬할 때 잘못된 DOM 노드가 재사용될 수 있습니다. 입력 요소의 DOM 상태가 다른 항목에 남는 사례처럼, key는 경고를 없애기 위한 값이 아니라 렌더 사이에서 항목의 정체성을 유지하는 기준입니다. 데이터마다 안정적이고 고유한 key를 지정하면 위치가 바뀌어도 올바른 항목과 컴포넌트 상태를 연결할 수 있습니다.

글 끝에는 Hook 값을 배열에 저장하고 호출 위치를 인덱스로 추적하는 간단한 MiniReact 예제가 나옵니다. 실제 React는 Fiber와 우선순위 큐, 동시성 스케줄링을 사용하므로 이 예제가 구현 전체를 그대로 재현하지는 않습니다. 다만 상태를 컴포넌트 함수 바깥에 보관하고, 렌더마다 같은 순서로 읽으며, 상태 변경 뒤 컴포넌트를 다시 실행한다는 설명의 뼈대를 보여줍니다.

dev.to 반응

  • @alexcodebytes — setState 호출 바로 뒤에 console.log를 쓰고 값이 왜 바뀌지 않았는지 고민하다가 자신의 진로 선택까지 의심하는 일은 개발자라면 누구나 겪는 통과의례입니다. React가 단순한 DOM 패치 마법이 아니라는 점을 이해하면 크게 깨닫게 됩니다.
    • @smtahosin — 맞습니다! setState 바로 뒤의 console.log는 거의 모든 React 개발자를 한 번쯤 혼란스럽게 했을 겁니다. 렌더 스냅샷과 핸들러가 특정 렌더의 값을 클로저로 붙잡는 방식을 이해하면, 같은 핸들러 안에서 값이 바뀌지 않는 이유가 비동기 처리나 마법처럼 느껴지지 않습니다. ‘React가 DOM을 패치한다’는 생각에서 렌더와 커밋 과정을 이해하는 쪽으로 관점을 바꾸면 동작을 훨씬 예측하기 쉬워집니다. 댓글 고맙습니다!

원문: dev.to / 번역·요약: Trawling