React 렌더링과 재렌더링의 차이

React 렌더링과 재렌더링의 차이

한눈에 보기

React에서 렌더링은 컴포넌트 함수를 호출해 다음 UI를 계산하는 과정이다. 커밋은 그 결과를 이전 결과와 비교해 필요한 DOM 변경을 반영하는 과정이다. 컴포넌트가 재렌더되었다고 DOM 전체가 다시 만들어지는 것은 아니며, 최적화는 렌더 횟수보다 실제 비용과 사용자 경험을 측정한 뒤 적용해야 한다.

목차

렌더링이라는 말이 여러 단계를 섞는다

React 화면이 느릴 때 흔히 “렌더가 너무 많이 일어난다”라고 말한다. 하지만 이 문장에는 서로 다른 현상이 섞여 있을 수 있다.

다음 컴포넌트에서 로그가 다시 찍혀도 <strong> DOM 노드가 매번 제거되고 새로 생성되는 것은 아니다.

function ProductCount({ count }: { count: number }) {
  console.log("ProductCount render");

  return <strong>{count}개 상품</strong>;
}

React는 함수의 반환값을 계산하고, 이전 결과와 달라진 부분만 DOM에 반영한다. count가 그대로라면 함수가 호출되더라도 텍스트 노드를 바꿀 이유가 없다.

용어 분리

이 글에서 렌더는 컴포넌트 호출과 UI 계산, 커밋은 DOM 변경 반영을 뜻한다. 브라우저의 layout·paint는 그 뒤에 이어지는 별도 비용이다.

React 업데이트의 세 단계

React 공식 문서는 화면 업데이트를 trigger, render, commit의 흐름으로 설명한다.

flowchart LR
    A[Trigger
초기 진입 또는 state 변경] --> B[Render
컴포넌트 호출] B --> C[다음 UI 계산] C --> D[Commit
필요한 DOM 변경] D --> E[Browser
layout / paint]

1. Trigger

첫 화면을 표시하거나 state setter를 호출하면 렌더가 예약된다.

function QuantityButton() {
  const [quantity, setQuantity] = useState(1);

  return (
    <button onClick={() => setQuantity((current) => current + 1)}>
      수량 {quantity}
    </button>
  );
}

버튼 클릭은 state update를 큐에 넣는다. 이미 실행 중인 이벤트 핸들러의 quantity 변수를 즉시 바꾸는 것이 아니다.

2. Render

React는 컴포넌트를 호출해 해당 state와 props에서 어떤 UI가 나와야 하는지 계산한다.

function QuantityButton() {
  const [quantity, setQuantity] = useState(1);

  const label = quantity >= 10 ? "대량 주문" : "일반 주문";

  return (
    <button onClick={() => setQuantity((value) => value + 1)}>
      {label}: {quantity}
    </button>
  );
}

렌더는 계산 단계다. React의 concurrent rendering에서는 계산이 중단되거나 다시 시작될 수 있으므로 여기서 외부 시스템을 변경하면 안 된다.

3. Commit

React는 계산 결과에서 필요한 변경만 실제 DOM에 반영한다.

이전 결과: <button>일반 주문: 1</button>
다음 결과: <button>일반 주문: 2</button>
커밋:       텍스트에서 1을 2로 변경

commit이 끝난 뒤 브라우저가 layout과 paint를 수행한다. useLayoutEffectuseEffect의 실행 시점 차이를 이해할 때도 render와 commit을 먼저 분리해야 한다.

부모가 렌더되면 자식은 어떻게 되는가

기본적으로 부모 컴포넌트가 렌더되면 그 안의 자식 컴포넌트도 렌더될 수 있다.

function ProductPage() {
  const [query, setQuery] = useState("");
  const [cartCount, setCartCount] = useState(0);

  return (
    <>
      <SearchInput value={query} onChange={setQuery} />
      <ProductList query={query} />
      <CartSummary count={cartCount} />
      <button onClick={() => setCartCount((count) => count + 1)}>
        장바구니 추가
      </button>
    </>
  );
}

cartCount가 바뀌면 ProductPage가 다시 호출되고 자식들의 JSX도 다시 계산될 수 있다. 그렇다고 상품 목록 DOM 전체가 다시 만들어지는 것은 아니다. query가 같고 반환 결과가 같다면 commit에서 변경할 DOM이 없을 수 있다.

function ProductList({ query }: { query: string }) {
  console.count("ProductList render");

  return (
    <ul>
      <li>{query || "전체 상품"}</li>
    </ul>
  );
}

console.count가 늘어나는 것은 계산이 다시 실행됐다는 증거일 뿐, 계산이나 DOM 변경 비용이 크다는 증거는 아니다.

children 합성으로 렌더 범위를 좁히기

구조를 어디에 두느냐에 따라 불필요한 전파를 줄일 수 있다.

function App() {
  return (
    <ResizablePanel>
      <ExpensiveProductTree />
    </ResizablePanel>
  );
}

function ResizablePanel({ children }: PropsWithChildren) {
  const [width, setWidth] = useState(320);

  return (
    <section style={{ width }}>
      <button onClick={() => setWidth((value) => value + 20)}>
        넓히기
      </button>
      {children}
    </section>
  );
}

ResizablePanel의 state가 바뀌어도 부모 App이 전달한 동일한 children element를 활용할 수 있다. 상태를 필요한 곳 가까이에 두고 합성을 이용하는 것이 모든 자식에 memo를 붙이는 것보다 구조적인 해결이 될 때가 많다.

재렌더되어도 DOM이 유지되는 과정

다음 예제는 초 단위 시각과 입력 폼을 함께 보여 준다.

function CheckoutForm({ now }: { now: Date }) {
  return (
    <section>
      <p>현재 시각: {now.toLocaleTimeString()}</p>
      <label>
        배송 메모
        <input defaultValue="문 앞에 놓아 주세요" />
      </label>
    </section>
  );
}

부모가 매초 now를 바꾸면 CheckoutForm은 계속 렌더된다. React는 <p>의 텍스트만 갱신하고 같은 위치와 같은 타입의 <input> DOM 노드는 유지한다. 사용자가 입력 중인 값과 focus도 일반적으로 그대로 남는다.

이전                     다음
section                  section         → 재사용
├─ p "10:00:01"          ├─ p "10:00:02"  → 텍스트 변경
└─ label                 └─ label          → 재사용
   └─ input                 └─ input        → 재사용

이 과정을 “virtual DOM이 무조건 빠르다”라고 단순화하면 안 된다. 큰 트리를 계산하는 render 비용은 여전히 있고, commit에서 대량 DOM 변경이 필요하면 비용도 커진다.

commit할 것이 없을 수 있다

결과가 이전과 같아 DOM 변경이 없을 수 있다는 뜻이지 컴포넌트 계산 비용이 0이라는 뜻은 아니다.

state는 트리의 위치에 연결된다

React는 UI 트리의 위치를 기준으로 state를 보존한다. 조건에 따라 같은 위치에 같은 컴포넌트 타입이 렌더되면 state가 이어진다.

function AccountPage({ compact }: { compact: boolean }) {
  return (
    <main>
      {compact ? (
        <ProfileEditor layout="compact" />
      ) : (
        <ProfileEditor layout="wide" />
      )}
    </main>
  );
}

두 분기 모두 main의 첫 번째 자리에 ProfileEditor가 있으므로 layout prop이 바뀌어 재렌더되더라도 내부 state는 보존될 수 있다.

반대로 같은 자리에 다른 타입이 오면 기존 subtree가 제거되고 state가 초기화된다.

function AccountPage({ editing }: { editing: boolean }) {
  return (
    <main>
      {editing ? <ProfileEditor /> : <ProfilePreview />}
    </main>
  );
}

이 차이는 “재렌더”와 “재마운트”를 구분하게 해 준다.

현상 함수 호출 기존 state DOM
재렌더 다시 호출됨 보존 가능한 한 재사용
재마운트 새 인스턴스 호출 초기화 subtree 교체 가능
commit 없음 호출될 수 있음 보존 변경 없음

key가 state 보존과 DOM 재사용에 미치는 영향

목록의 key는 형제 사이에서 항목의 정체성을 React에 알려 준다.

type CartItem = {
  id: string;
  name: string;
};

function Cart({ items }: { items: CartItem[] }) {
  return (
    <ul>
      {items.map((item) => (
        <CartRow key={item.id} item={item} />
      ))}
    </ul>
  );
}

중간에 항목 하나가 삽입되어도 안정적인 item.id가 있으면 기존 항목과 state를 대응할 수 있다.

배열 index를 key로 쓰면 정렬이나 삽입 시 위치가 바뀌어 잘못된 행에 입력 state가 붙을 수 있다.

{items.map((item, index) => (
  <CartRow key={index} item={item} />
))}

매 렌더마다 새 랜덤 key를 만드는 것은 더 나쁘다.

{items.map((item) => (
  <CartRow key={crypto.randomUUID()} item={item} />
))}

React는 항목을 매번 다른 컴포넌트로 인식해 unmount와 mount를 반복한다. input focus와 내부 state가 사라지고 DOM도 재생성될 수 있다.

key로 의도적으로 초기화하기

대상이 바뀔 때 편집 폼을 초기화하고 싶다면 key가 명확한 도구가 된다.

function UserEditorPage({ selectedUserId }: Props) {
  return (
    <UserEditor
      key={selectedUserId}
      userId={selectedUserId}
    />
  );
}

selectedUserId가 바뀌면 다른 정체성으로 판단해 이전 로컬 state를 폐기한다. Effect에서 모든 필드를 수동 초기화하는 것보다 의도를 잘 표현할 수 있다.

렌더 함수는 순수해야 한다

React는 같은 입력에 대해 같은 JSX를 계산할 수 있다고 가정한다. 렌더 중 외부 값을 변경하면 재시도나 중단 가능한 rendering에서 예측하기 어려운 버그가 생긴다.

let renderedCount = 0;

function ProductCard({ product }: { product: Product }) {
  renderedCount += 1;

  return <article>{product.name}</article>;
}

렌더 횟수를 비즈니스 노출 수로 사용하면 개발 Strict Mode나 버려진 render 때문에 실제 노출과 달라진다. 노출 측정은 commit 후 observer나 적절한 Effect, 분석 SDK로 처리한다.

렌더 중 네트워크 요청도 피한다.

function ProductDetail({ productId }: Props) {
  fetch(`/api/products/${productId}`);

  return <div>상품 {productId}</div>;
}

데이터 패칭 프레임워크, 이벤트 핸들러, Effect 등 사용하는 렌더링 방식에 맞는 경계로 옮긴다.

반면 순수한 계산은 렌더 안에서 수행해도 된다.

function Price({ amount, discountRate }: PriceProps) {
  const discounted = amount * (1 - discountRate / 100);
  return <strong>{formatCurrency(discounted)}</strong>;
}

파생 값을 다시 state로 저장하고 Effect로 동기화하면 렌더를 한 번 더 만들고 불일치 가능성만 늘릴 수 있다.

Strict Mode에서 로그가 두 번 찍히는 이유

개발 환경의 Strict Mode에서는 순수하지 않은 코드를 찾기 위해 컴포넌트 함수나 일부 callback을 추가로 호출할 수 있다.

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

다음 로그가 초기 화면에서 두 번 보일 수 있다.

function ProductCard({ product }: Props) {
  console.log("render", product.id);
  return <article>{product.name}</article>;
}

이를 해결하려고 Strict Mode를 제거하거나 전역 boolean을 두는 것은 보통 올바른 접근이 아니다. 컴포넌트가 여러 번 호출되어도 외부 결과가 달라지지 않도록 렌더를 순수하게 만들고, Effect cleanup이 setup을 정확히 되돌리는지 확인한다.

개발 로그와 운영 성능

개발 빌드의 호출 횟수를 운영 렌더 횟수로 해석하지 않는다. Strict Mode와 개발 도구가 추가 작업을 할 수 있고 개발 빌드는 production build와 비용도 다르다.

“운영에서는 한 번이니 괜찮다”도 충분하지 않다. React는 사용자에게 commit하지 않은 render를 시작했다가 버릴 수 있다. render 순수성은 개발 모드에만 필요한 규칙이 아니다.

재렌더를 줄여야 하는 경우

렌더가 발생했다는 사실만으로 memo, useMemo, useCallback을 붙이면 복잡성이 먼저 늘어난다.

다음 상황에서는 최적화 가치가 낮을 수 있다.

반대로 다음은 측정해 볼 후보다.

state 위치를 먼저 좁힌다

페이지 전체에 있는 modal state 때문에 큰 목록이 재렌더된다면 state를 modal 주변으로 옮긴다.

function ProductPage() {
  return (
    <>
      <ProductList />
      <AddProductDialogButton />
    </>
  );
}

function AddProductDialogButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>상품 추가</button>
      {open && <AddProductDialog onClose={() => setOpen(false)} />}
    </>
  );
}

state locality는 memoization 없이 렌더 영향을 줄인다.

memo는 props가 안정적일 때 효과가 있다

const ProductList = memo(function ProductList({
  products,
  onSelect,
}: ProductListProps) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <button onClick={() => onSelect(product.id)}>
            {product.name}
          </button>
        </li>
      ))}
    </ul>
  );
});

부모가 매번 새 배열과 새 함수를 만들면 얕은 props 비교에서 달라진다.

<ProductList
  products={products.filter((product) => product.visible)}
  onSelect={(id) => navigate(`/products/${id}`)}
/>

필요하고 측정으로 확인된 경우 계산과 callback을 안정화한다.

const visibleProducts = useMemo(
  () => products.filter((product) => product.visible),
  [products],
);

const handleSelect = useCallback(
  (id: string) => navigate(`/products/${id}`),
  [navigate],
);

useMemo도 render 중 함수를 실행하고 의존성을 비교하며 값을 보관한다. 공짜 캐시가 아니다.

Profiler로 실제 비용 측정하기

React DevTools Profiler에서는 commit별로 어떤 컴포넌트가 렌더됐고 얼마나 시간이 걸렸는지 볼 수 있다.

  1. production과 가까운 데이터 크기를 준비한다.
  2. 타이핑, 목록 정렬, modal 열기 같은 사용자 시나리오를 정한다.
  3. recording을 시작하고 시나리오를 수행한다.
  4. 느린 commit과 오래 걸린 subtree를 찾는다.
  5. 구조나 memoization을 바꾼 뒤 같은 시나리오를 다시 측정한다.

프로그램에서 특정 subtree를 측정하려면 <Profiler>를 사용할 수 있다.

import { Profiler, type ProfilerOnRenderCallback } from "react";

const handleRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
) => {
  performanceLogger.record({
    id,
    phase,
    actualDuration,
    baseDuration,
  });
};

function SearchPage() {
  return (
    <Profiler id="search-result" onRender={handleRender}>
      <SearchResult />
    </Profiler>
  );
}

actualDuration은 이번 update에서 subtree를 렌더한 시간이고, baseDuration은 memoization 없이 전체를 렌더할 때의 추정 비용을 이해하는 데 도움을 준다. Profiling 자체에 overhead가 있으므로 운영 전체를 무분별하게 계측하지 않는다.

React Profiler가 빠른데 화면은 느리다면 큰 이미지 decode, CSS layout, 많은 DOM 노드, 긴 event handler, network waterfall, paint 비용도 확인한다. React render를 최적화해도 다른 단계가 병목이면 사용자는 차이를 느끼지 못한다.

렌더링 문제를 찾는 체크리스트

재렌더를 발견했을 때

  1. 컴포넌트 함수 재호출인가, DOM 변경인가, 재마운트인가?
  2. 개발 Strict Mode의 추가 호출을 보고 있는가?
  3. 사용자 입력 지연이나 긴 commit이 실제로 있는가?
  4. state를 더 가까운 컴포넌트로 옮길 수 있는가?
  5. 랜덤 key나 index key가 재마운트를 만들고 있지 않은가?
  6. 렌더 중 부수 효과나 비싼 계산이 있는가?
  7. Profiler 전후 측정에서 개선이 확인되는가?

렌더 로그는 출발점으로는 유용하지만 결론이 아니다. 도구가 표시하는 모든 재렌더를 제거 목표로 삼지 않는다.

정리

React의 재렌더는 DOM 전체 재생성과 다르다. state나 props 변경으로 업데이트가 trigger되면 React는 컴포넌트를 호출해 다음 UI를 계산하고, commit에서 달라진 DOM만 반영한다.

재렌더를 무조건 제거하는 것이 목표가 아니라, UI 계산을 순수하고 예측 가능하게 만들고 사용자에게 영향을 주는 비싼 경로만 측정해 줄이는 것이 목표다.

관련 노트와 참고 자료