본문 바로가기
개발/WEB

리액트는 어떻게 화면을 다시 그릴까? – 리액트 렌더링 원리와 실전 최적화

by Dahna 2025. 4. 23.

 

리액트는 상태가 바뀔 때마다 어떻게 화면을 다시 그릴까요? 오늘은 그 원리를 쉽게 풀어보고, 렌더링 성능을 높일 수 있는 실전 팁까지 함께 정리해보려 합니다.

 

이 글을 통해 아래와 같은 내용을 쉽게 이해할 수 있어요.

1. 리액트가 화면을 다시 그릴 때 내부에서 어떤 일이 일어나는지

  - 렌더링의 전체 흐름과 구조를 단계별로 살펴봅니다.

2. 리액트가 언제 렌더링을 다시 하게 되는지

  - setState, props, key 등 렌더링을 유발하는 다양한 원인을 정리합니다.

3. 불필요한 렌더링을 줄이고 성능을 높이는 실전 팁들

 - React.memo, 리액트의 동등 비교 이해 등 실무에서 바로 쓸 수 있는 최적화 전략을 소개합니다.

 

이 글은 아래의 독자를 위해 작성되었어요.

1. 리액트와 웹 브라우저 환경에서 개발해본 경험이 있어요.

2. 리액트의 렌더링 과정을 쉽게 이해하고 싶어요.

3. 리액트가 잘 최적화 해줄 수 있는 컴포넌트를 작성하고 싶어요.

 

이 글이 작성된 환경은 다음과 같습니다.

1. React 16 이상

  - fiber를 사용하는 환경을 기준으로 작성되었으며, 글의 세부적인 내용은 버전의 업데이트에 따라 변경될수 있습니다.

2. Functional Component

  - 함수형 컴포넌트를 기준으로 작성하고 있습니다. 클래스 컴포넌트의 동작 원리는 세부적으로 다를수 있으니 별도의 참고 자료를 확인하시는 것을 추천드립니다. 

3. 브라우저 환경

  - 브라우저 환경에서 웹을 그려내는 것을 기준으로 작성하고 있습니다. 리액트는 웹에 종속적인 라이브러리가 아니며 다양한 호스트 환경(React Native 등)에서 실행될 수 있습니다.

 

그러면 지금부터 본론으로 들어가 하나씩 살펴보도록 하겠습니다! 

리액트가 화면을 다시 그릴 때 내부에서 어떤 일이 일어나는지

리액트가 화면을 다시 그릴 때, 변경 사항은 브라우저에 반영되어야 시각적으로 표현됩니다. 그렇다면 리액트의 렌더링은 어떤 일을 하는 걸까요? 브라우저 렌더링 과정(특히 reflow)은 많은 연산을 필요로 하는 비싼 과정입니다. 이 비용은 고스란히 사용자에게 청구되어 심한 경우 지연을 체감되게 합니다. 따라서 브라우저의 렌더링 즉, DOM 재계산 요청 횟수를 효과적으로 최적화하는 것이 리액트 렌더링의 목적입니다. 이를 수행하기 위해서 리액트는 메모리에서 렌더링을 미리 수행하고 압축하여, 브라우저에 보내는 변경 요청을 관리합니다.

 

이렇게 메모리에서 수행되는 리액트의 렌더링 과정은 크게 Render Phase, Commit Phase로 나뉩니다. 

이제부터 리액트의 렌더링 과정을 단계별로 차근차근 살펴보도록 하겠습니다!

Render Phase

Render Phase를 간단히 요약하면, 우리가 원하는 UI 상태의 청사진을 만드는 것입니다. 리액트는 이 청사진을 가지고 재조정이라는 프로세스를 통해 주어진 브라우저 환경에서 현실로 만듭니다. 

리액트는 두가지 데이터 구조를 사용하여 현재 UI 상태의 청사진을 만들고 기존 화면과 비교하여 변경사항을 추적해냅니다. 바로 React Element와 Fiber 입니다.

 

React Element

React Element는 리액트 애플리케이션의 가장 작은 구성 블록으로 화면에 표시되어야 할 내용을 설명합니다. React.createElement를 실행하여 반환된 결과물로 얻어집니다. 각 엘리먼트는 관련 prop이나 속성과 함께 해당 엘리먼트가 나타내는 컴포넌트를 기술하는 평범한 자바스크립트 객체입니다. type, key, className, children 등의 정보를 서술합니다. 우리가 익숙하게 들어본 Virtual DOM이 바로 이 ReactElement로 구성된 트리입니다.

 

리액트 16 미만에서는 ReactElement 트리를 기반으로 모든 변경사항을 순차적으로 처리했습니다. 하지만 웹애플리케이션 환경이 점점 고도화되어가면서, 변경사항은 보다 다각화됩니다. 우리가 흔하게 접할수 있는 다음과 같은 시나리오를 떠올려봅니다.

 

1. 검색창에 검색어을 입력합니다.

2. 실시간으로 추천 검색어와 그에 따른 검색 결과가 표출됩니다.  

3. 검색창의 검색어는 실시간으로 변경할 수 있습니다.

4. 검색 결과가 실패하면 안내 메시지가 표출됩니다.

 

흔하게 발생하는 위와 같은 시나리오에서 모든 변경사항을 순차적으로 처리한다면 어떻게 될까요? 추천 검색어 및 검색 결과 호출 등의 비싼 계산으로 인해 검색창의 입력 지연이 발생할 수 있습니다. 이 시나리오에서 살펴본 것처럼, 변경사항에는 즉시 응답을 기대하는 변경사항과, 다소 지연이 발생해도 괜찮은 변경사항이 있습니다. 이처럼 리액트의 재조정자는 변경사항에 우선순위를 적용하여 즉시 응답을 보여주어야 하는 변경사항은 먼저 보여주고, 그렇지 않은 변경사항은 기다렸다가 순차적으로 보여주는 방식으로 진화하게 됩니다.

 

Fiber

Fiber 재조정자에는 조정자를 위한 작업 단위를 나타내는 Fiber라는 데이터 구조가 사용됩니다. Fiber와 React Element의 핵심적인 차이는 Fiber는 상태를 저장하고 수명이 긴 반면, React Element는 임시적이고 상태가 없다는 것입니다. Fiber는 컴포넌트와 1:1 매칭되며 컴포넌트 인스턴스와 그 상태를 표현합니다. 해당 컴포넌트에 대한 정보, props, 상태, 하위 컴포넌트, hook 등의 정보를 포함합니다. Fiber는 변경 가능한 인스턴스로 설계되었으며 조정 과정에서 필요에 따라 업데이트되고 재배치됩니다. Fiber 재조정에는 현재 Fiber 트리와 다음 Fiber 트리를 비교해 어느 노드를 업데이트, 추가, 제거할지 파악하는 작업이 포함됩니다. 또한 변경사항을 Fiber 단위로 관리하여 리액트는 렌더링 중 변경사항을 중단, 재실행, 폐기할 수 있게 되었습니다.

 

Fiber 트리는 alternate를 통해 workInProgress 트리를 1개 보유하고 있습니다.

 

Render Phase에 사용되는 두가지 데이터 구조를 살펴보았으니 Render Phase에서 수행되는 과정을 살펴봅니다.

 

beginWork

Render Phase가 실행되면 Fiber 트리의 루트부터 시작하여 biginWork를 수행합니다.

biginWork는 위에서 아래로 이동하며 컴포넌트를 '업데이트가 필요함'으로 표시합니다. 이 과정에서 함수 컴포넌트를 실행하여 React Element를 생성합니다. React Element 트리를 기반으로 기존 child Fiber와 비교하는 재조정 과정을 수행합니다. type, key, prop을 비교하여 기존 Fiber를 재사용하거나 재생성하며, 변경된 부분만 업데이트합니다.

biginWork는 자식 Fiber를 연결하며 Fiber 트리를 업데이트합니다. 자식이 있으면 자식으로 이동하여 biginWork를 수행하며 자식이 없으면 copleteWork를 수행하고 형제로 넘어갑니다. 

 

completeWork

completeWork는 작업 완료를 뜻하며, 다시 위로 이동하며 실제 DOM 노드 생성을 준비합니다. 브라우저에서 분리된 실제 DOM 엘리먼트의 트리를 메모리에 구성합니다. Fiber 트리에서 감지된 변경사항을 수집하여 DOM에 요청할 effect를 수집하여 effect list를 생성하고, 실제로 반영할 DOM 엘리먼트 트리를 준비하는 단계입니다. 루트에서 completeWork가 수행되면 Commit Phase로 넘어갑니다.

Commit Phase

Commit Phase는 Render Phase에서 메모리에 그려둔 변경사항을 실제 DOM에 반영합니다. 이 단계에서 새 가상 DOM 트리가 브라우저 환경에 커밋되고 작업용 트리가 현재 트리로 바뀝니다. 이 단계에서 WorkInProgress 트리가 current로 교체되고 DOM api를 통해 브라우저에 렌더링을 요청하게 됩니다. 

DOM이 수정되고 layout 계산이 발생한 후, paint가 일어나기 전에 useLayoutEffect가 실행됩니다. 따라서 useLayoutEffect는 스크롤 위치 계산 등 화면에 빠르게 반영되어야 할 처리를 수행해주면 좋습니다.

 

브라우저 렌더링도 간단하게 살펴보겠습니다.

브라우저 렌더링

리액트가 DOM api를 통해 실제 DOM을 조작하면, 이후의 렌더링 작업은 브라우저의 렌더링 엔진이 담당하게 됩니다.

  1. DOM 트리 재계산: 커밋된 DOM 트리를 기반으로 전체 문서 구조를 다시 분석합니다.
  2. CSSOM 생성: CSS 파일, style 태그, inline style 등을 분석해서 스타일 규칙 트리를 생성합니다.
  3. Render 트리 구성: DOM + CSSOM을 결합해서 실제로 브라우저에 표시될 요소들을 정리한 렌더 트리를 만듭니다.
  4. Layout(reflow): 각 요소의 위치와 크기를 계산합니다. 
  5. Paint: 각 요소를 픽셀로 변환하여 실제 화면에 그릴 데이터를 준비합니다. 색상, 텍스트, 그림자 등이 여기서 처리됩니다.
  6. Composite: 여러 개의 레이어가 있다면 이들을 합성하여 최종적으로 화면에 표시됩니다.

브라우저 업데이트가 완료된 이후, fiber에 저장된 useEffect 훅을 실행시켜 부수효과를 처리합니다.

 

지금까지 살펴본 내용을 도식화하면 아래와 같습니다.

 

 

 

리액트가 언제 렌더링을 다시 하게 되는지

지금까지 리액트가 화면을 다시 그릴때 어떤 과정을 거치는지 살펴보았습니다. 리액트의 렌더링 원리 만큼이나, 리액트가 언제 렌더링을 다시 하게 되는지 아는것은 중요합니다. 리액트의 리렌더링 발생 원인을 이해하는 것은 리액트가 UI에 반영하고자 관리하는 변경사항이 무엇인지 이해하는 것과 같습니다. 모든 변경사항이 리렌더링을 발생시키지는 않습니다!

  1. setState, useReducer의 dispatch 실행
    - useState와 useReducer의 상태값은 리액트에서 UI와 연결된 값을 가질수 있도록 제공하는 상태값입니다. 리액트는 상태 변경을 추적하여 리렌더링을 발생시킵니다.
  2. (특히 리스트 컴포넌트에서) key prop 변경
    - 리액트는 key가 달라지면 다른 컴포넌트로 인식합니다. 기존 컴포넌트를 삭제하고 새로 생성합니다.
  3. props 변경
    - 부모로부터 전달받는 값인 props가 변경되면 이를 사용하는 자식 컴포넌트에서도 변경이 필요하므로 리렌더링이 일어납니다.
  4. 부모 컴포넌트 리렌더링
    - 리액트는 부모 컴포넌트가 리렌더링되면 하위에 속한 모든 컴포넌트를 다시 그리도록 합니다.

불필요한 렌더링을 줄이고 성능을 높이는 실전 팁들

key prop 정의하기

앞서 살펴본 바와 같이 key prop은 특히 리스트 내부에서 기존 Fiber를 찾기 위한 식별자로 사용됩니다. key prop이 변경되면 리액트의 재조정자는 기존 Fiber를 찾을 수 없어 재생성하게되고, 재계산이 발생하게 됩니다. 따라서 인덱스와 같이 리스트와 함께 변경될 여지가 있는 값을 사용하게 되면 리액트에서 적용해주는 최적화의 혜택을 받을 수 없으므로 변경되지 않는 key 값을 제공해주는것이 좋습니다.

React.memo

컴포넌트의 불필요한 리렌더링을 최적화하기 위해서는 React.memo를 사용할 수 있습니다. React.memo로 감싸면 부모 컴포넌트의 리렌더링이 발생했다 할지라도, props, key를 비교하여 동일한 경우에는 리렌더링하지않고 기존 것을 그대로 사용할 수 있습니다.

리액트의 동등 비교 이해하기

리액트에서 props를 비교할때는 얕은 비교를 통해 동등 여부를 판단합니다. 얕은 비교는 객체의 첫 번째 깊이의 value 까지만 비교합니다. 이때 원시값은 값을, 객체값은 참조값을 비교합니다. 따라서 컴포넌트의 props는 객체 형태로 전달되기 때문에 단일 prop이 원시값이라면 값을 비교하고, 객체인 경우 참조값을 비교합니다. 따라서 props에 객체를 값으로 전달할 때에는 참조값이 같아야 동등하다고 판단됩니다.

이와 마찬가지로 prop으로 함수가 전달되는 경우에도, 함수 역시 객체 타입이기 때문에 useCallback으로 감싸서 객체의 참조투명성을 유지해주어야 동등한 것으로 간주합니다.

 

또다른 방법으로는 React.memo의 두번째 인자를 활용하여 객체 값이 동등하다고 판단하는 비교할 키를 지정해줄수도 있습니다.

useState의 게으른 초기화

useState에 초기값으로 복잡하거나 무거운 연산이 포함된 값을 주고 싶을때는 게으른 초기화를 사용하면 유용합니다. useState의 초기값을 선언하기 위해 값을 반환하는 함수를 넣어줄 수 있습니다. 이렇게 useState에 변수 대신 함수를 넘기는 것을 게으른 초기화라고 합니다. 게으른 초기화 함수는 오로지 state가 처음 만들어질때만 사용됩니다. 이후에 리렌더링이 발생된다면 이 함수의 실행은 무시됩니다. 함수 컴포넌트는 리렌더링시마다 실행되기때문에 비싼 작업이 들어가 있다면 계속해서 실행될 위험이 존재할 것입니다. 이럴 때에는 게으른 초기화를 사용해 효과적으로 최적화할 수 있습니다.

useEffect를 생명주기 메서드를 대신하여 사용하지 않기

아래 내용은 맞는 내용일까요?

  1. useEffect의 의존성 배열에 빈 배열을 넣으면 마운트 시에만 실행된다.
  2. useEffect는 클린업 함수를 반환할수 있는데 이 클린업 함수는 컴포넌트가 언마운트될때 실행된다.

useEffect는 동기적으로 부수효과를 만드는 매커니즘입니다. 렌더링 이후 useEffect가 실행되는 시점에서 의존성 배열을 확인하여 변경사항이 있으면 인수로 받은 콜백을 수행하는것이 useEffect의 정확한 동작입니다. 따라서 마운트/언마운트 시점을 대체하고자 사용한다면 정확한 동작을 보장받을 수 없습니다.

 

클린업 함수는 인수로 받은 콜백이 실행되기 전에, 클린업 함수가 선언됐던 환경(이전 상태)를 기준으로 실행됩니다. 즉, useEffect는 그 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행하도록 설계되어 있습니다. 따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것입니다. 이렇게 함으로써 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할수 있습니다. 이같은 특성을 이해하여 리액트 팀의 설계 의도에 맞게 사용하는것이 정확한 동작을 기대할 수 있는 방법입니다.

useEffect의 의존성 배열을 무시하는 린트 주석은 최대한 자제하기

이 주석을 사용하게 되면 이 부수효과가 실제로 관찰해서 실행되야 하는 값과는 별개로 작동할수 있는 위험을 내포하게 됩니다. useEffect에서 사용한 콜백 함수의 실행과 내부에서 사용한 값의 실제 변경 사이에 연결 고리가 끊어져있는 것입니다.

따라서 정말로 의존성으로 []가 필요하다면 최초에 함수 컴포넌트가 마운트됐을 시점에만 콜백 함수 실행이 필요한지를 다시 한번 되물어봐야합니다. 또한 의존성으로 []를 주는 콜백 내부에서 props 접근이 필요하다면, 해당 로직은 props를 전달하는 부모 컴포넌트에서 실행되는 것이 옳을지도 모릅니다. 부모 컴포넌트에서 컴포넌트가 렌더링되는 시점을 결정하고 이에 맞게 작업을 수행하는것이 올바른 사용일 수 있습니다. 정말로 useEffect의 부수 효과가 컴포넌트의 상태와 별개로 작동해야만 하는지, 혹은 여기서 호출하는게 최선인지 한번 더 검토해봐야 합니다.

함수를 인수로 받는 훅에 기명함수 전달하기

useEffect, useCallback 등 다양한 훅을 사용할 때, 콜백으로 함수를 제공하는 경우가 많습니다. 이때 기명함수를 전달해주면 해당 로직의 목적을 파악하기 쉬워집니다. 또한 디버거에서도 함수 이름을 확인할 수 있기 때문에 코드 작성자와 코드를 읽을 개발자 모두 해당 로직을 읽고 이해하는 데에 도움이 됩니다. 

 

마치며

지금까지 우리는 리액트가 렌더링을 어떻게 수행하고, 언제 다시 렌더링을 일으키는지, 그리고 성능을 높이기 위해 어떤 전략을 적용할 수 있는지를 단계별로 살펴봤습니다.

 

리액트의 렌더링 시스템은 꽤나 복잡해 보이는데 리액트는 왜 이렇게까지 하는 것일까요?

리액트 렌더링의 핵심은 모든 시나리오에서 가장 빠른 렌더링이 아니라, 대부분의 일반적인 시나리오에서 충분히 빠른 속도를 제공하는 렌더링 시스템을 제공하는 것입니다.

리액트는 이를 위해서 UI를 값으로 표현하고, 메모리에서 변경사항을 압축하고 스케쥴링하는 최적화를 개발자가 별도로 관리하지 않아도 알아서 수행합니다.


이를 가능하게 하는 핵심 구조인 React Element, Fiber, 그리고 Effect List를 이해하면, 리액트가 제공하는 최적화를 활용하며 효과적으로 웹애플리케이션을 개발할 수 있습니다.

궁극적으로 중요한 건 불필요한 렌더링을 피하는 것보다, '언제, 왜 리렌더링이 필요한지'를 이해하고 관리하는 능력입니다.
이 글을 통해 리액트의 렌더링 원리와 실전 팁에 대해 조금이라도 얻어가시는 부분이 있다면 너무 기쁠것 같습니다:)

 

레퍼런스

도서: 모던 리액트 Deep Dive

도서: 전문가를 위한 리액트

'개발 > WEB' 카테고리의 다른 글

NginX 웹서버 배포하기[Python+React+Gunicorn]  (0) 2022.11.13

댓글