React의 렌더링 규칙

리렌더링 조건과 오해를 기준으로 React 렌더링 다시 보기

작성일: 2026. 06. 0515 min

들어가며

React를 쓰다 보면 렌더링이라는 말을 정말 자주 만난다. 상태가 바뀌면 리렌더링된다거나, 부모가 렌더링되면 자식도 렌더링된다는 설명은 익숙하다. 여기에 props, Context, React.memo 같은 이야기까지 붙기 시작하면, 어느 순간 렌더링이라는 단어가 꽤 넓은 의미로 쓰이고 있다는 걸 느끼게 된다.

그런데 막상 하나씩 따져보면 렌더링이라는 단어 안에 여러 단계가 섞여 있다. 컴포넌트 함수가 다시 호출되는 것인지, React 엘리먼트가 새로 만들어지는 것인지, 이전 결과와 비교하는 것인지, 실제 DOM이 바뀌는 것인지, 브라우저가 다시 페인팅하는 것인지가 한 단어로 뭉쳐 있다.

처음에는 렌더링을 단순히 "화면이 다시 그려지는 일" 정도로 생각했다. 하지만 그렇게 이해하면 React의 동작이 계속 헷갈린다. 분명 컴포넌트 함수는 다시 실행됐는데 DOM은 바뀌지 않을 수 있고, 화면에 보이는 변화가 없어도 렌더링 로그는 다시 찍힐 수 있다. 반대로 props가 달라진 것처럼 보이는데도 내가 생각한 방식으로 컴포넌트가 다시 실행되지 않는 경우도 있다.

그래서 이 글에서는 React가 언제 UI를 다시 계산하고 어떤 규칙을 전제로 동작하는지 정리해보려고 한다. 핵심은 "리렌더링을 어떻게 줄일까"보다 먼저, "React에서 렌더링이 발생한다는 말이 정확히 무엇을 뜻하는가"를 분리해서 보는 데 있다.

렌더링 과정 다시 보기

React는 선언형 UI 라이브러리다. 개발자가 직접 DOM을 찾아서 바꾸는 대신, 현재 상태에서 UI가 어떻게 보여야 하는지를 컴포넌트로 표현한다. 그러면 React는 상태 변화가 생겼을 때 컴포넌트를 다시 호출하고, 새로 계산한 UI와 이전 UI를 비교한 뒤, 필요한 변경만 DOM에 반영한다.

React 공식 문서의 Render and Commit에서는 이 흐름을 크게 Trigger, Render, Commit 세 단계로 나눠 설명한다.

이 글에서는 "렌더링"을 주로 React가 컴포넌트를 호출해 UI를 계산하는 Render 단계의 의미로 사용한다. 다만 실제로는 DOM 반영이나 브라우저 페인팅까지 포함해 "화면이 렌더링됐다"고 말하는 경우도 많다.

이 구조에서 렌더링은 화면을 칠하는 일만이 아니라 현재 입력을 기준으로 다음 UI를 계산하는 과정이다.

txt
UI = Component(props, state, context)

물론 실제 React 내부가 이 수식 하나로 끝나는 것은 아니다. 그래도 렌더링을 이해할 때 이 모델은 유용하다. 컴포넌트는 props, state, context를 입력으로 받아 UI를 반환한다. React는 이 계산을 다시 수행하면서 다음 화면이 어떻게 생겨야 하는지 알아낸다.

문제는 이 계산이 아무 때나, 아무 방식으로나 일어나지 않는다. React는 어떤 변경이 생겼을 때 렌더링을 예약하고, 렌더링 과정에서 컴포넌트들을 호출한다. 그리고 이 과정이 안전하려면 컴포넌트는 같은 입력에 대해 같은 결과를 낼 수 있어야 한다.

그래서 렌더링 규칙은 단순한 성능 팁이 아니다. React가 컴포넌트를 여러 번 호출하거나, 렌더링을 중단했다가 다시 시도하거나, 이전 계산 결과를 재사용할 수 있으려면 렌더링이 예측 가능해야 한다. 이 예측 가능성을 깨는 코드가 들어오면 리렌더링 횟수보다 더 근본적인 문제가 생긴다.

렌더링은 언제 발생하는가

React에서 렌더링이 발생하는 조건은 여러 방식으로 설명할 수 있지만, 크게 보면 다음 네 가지 흐름으로 정리할 수 있다.

  1. 애플리케이션이 처음 로드될 때
  2. 컴포넌트 내부 state가 변경될 때
  3. 부모 컴포넌트가 리렌더링될 때
  4. 컴포넌트가 구독하는 context 값이 변경될 때

각 조건은 익숙해 보이지만, 조금씩 다른 의미를 가진다.

애플리케이션이 처음 로드될 때

처음 렌더링은 React 애플리케이션이 루트 컴포넌트를 화면에 올리는 과정이다.

tsx
import { createRoot } from "react-dom/client";

import App from "./App";

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

이 시점에 React는 루트 컴포넌트부터 호출하기 시작한다. App이 어떤 컴포넌트를 반환하면 그 컴포넌트도 렌더링하고, 다시 그 컴포넌트가 다른 컴포넌트를 반환하면 트리를 따라 내려간다. 이렇게 React는 화면에 무엇을 보여줘야 하는지 계산한다.

초기 렌더링에서는 비교할 이전 UI가 없다. 그래서 React는 계산된 결과를 DOM에 반영한다. 이때가 우리가 흔히 "앱이 처음 그려진다"고 말하는 순간이다.

컴포넌트 내부 state가 변경될 때

가장 익숙한 리렌더링 조건은 state 변경이다.

tsx
function Counter() {
  const [count, setCount] = useState(0);

  console.log("Counter render", count);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

버튼을 누르면 setCount가 호출된다. 이 호출은 DOM을 직접 바꾸는 명령이 아니다. React에게 "이 컴포넌트의 상태가 바뀌었으니 다음 UI를 다시 계산해달라"고 요청하는 일에 가깝다.

그래서 setCount를 호출한 직후 현재 렌더링 안의 count 값이 바로 바뀌지는 않는다. 한 번 렌더링이 시작되면 그 안에서 읽는 state 값은 고정되어 있다. setState를 호출해도 현재 렌더링의 state를 그 자리에서 고치는 것이 아니라, 다음 렌더링에서 새로운 값으로 들어온다.

이때의 state는 일반 변수처럼 계속 변하는 값이 아니라, React가 이번 렌더링에 넘겨준 값에 가깝다. 이벤트 핸들러도 그 렌더링 시점의 state를 기준으로 만들어진다. 그래서 업데이트를 요청한 뒤에도 현재 핸들러 안에서 읽는 값은 그대로 남아 있다. 이런 의미에서 React 공식 문서는 state를 "스냅샷"으로 설명한다.

tsx
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }

  return <button onClick={handleClick}>{count}</button>;
}

위 코드에서 console.log(count)는 업데이트된 값이 아니라 현재 렌더링이 기억하고 있는 count를 출력한다. 새로운 값은 다음 렌더링에서 들어온다. 이 관점이 중요하다. 상태 업데이트는 기존 렌더링 안의 값을 즉시 고치는 일이 아니라, 새로운 렌더링을 예약하는 일이다.

부모 컴포넌트가 리렌더링될 때

부모가 렌더링되면 자식도 렌더링된다는 말은 React를 공부할 때 자주 듣는다. 대체로 맞지만, 이 문장을 DOM 변경까지 포함해서 이해하면 헷갈린다.

tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <Child />
    </section>
  );
}

function Child() {
  console.log("Child render");
  return <p>child</p>;
}

Parent의 state가 바뀌면 Parent가 다시 호출된다. 그리고 Parent가 반환하는 JSX 안에 Child가 있으므로 Child도 렌더링 흐름에 들어간다. Childcount를 props로 받지 않아도 마찬가지다.

Child 함수가 다시 호출되더라도, 화면의 Child 부분이 매번 실제로 바뀌는 것은 아니다. React는 렌더링 결과를 비교하고, 변경이 필요한 부분만 DOM에 커밋한다. Child가 다시 호출됐더라도 결과가 이전과 같다면 실제 DOM 변경은 없을 수 있다.

따라서 부모 리렌더링을 볼 때는 두 가지를 나눠야 한다.

  • 자식 컴포넌트 함수가 다시 호출되는가
  • 그 결과 실제 DOM 변경이 발생하는가

이 둘은 같은 말이 아니다.

Context 값이 변경될 때

Context는 props를 여러 단계로 전달하지 않고도 하위 컴포넌트에서 값을 읽을 수 있게 해준다. 하지만 Context를 읽는 컴포넌트는 해당 Context 값이 바뀌면 다시 렌더링된다.

tsx
const ThemeContext = createContext("light");

function ThemeLabel() {
  const theme = useContext(ThemeContext);
  console.log("ThemeLabel render");

  return <p>{theme}</p>;
}

ThemeLabel은 props로 theme을 받지 않는다. 그래도 ThemeContext의 값이 바뀌면 다시 렌더링된다. Context도 컴포넌트 렌더링의 입력이기 때문이다.

그래서 Context는 편하지만, 변경 범위를 넓게 만들 수 있다. 하나의 Context에 자주 바뀌는 값과 거의 바뀌지 않는 값을 함께 넣으면, 일부 값만 필요한 컴포넌트도 같은 Context 변경에 반응할 수 있다. 이 문제는 성능 최적화 글에서 자주 다루지만, 렌더링 규칙 관점에서는 더 단순하게 볼 수 있다.

Context를 읽는다는 것은 그 값을 렌더링 입력으로 삼는다는 뜻이다. 입력이 바뀌면 React는 다시 계산해야 한다.

리렌더링에 대한 오해

문제는 조건 자체보다, 그 조건을 너무 단순하게 외울 때 생긴다. props와 children이 대표적이다. 둘 다 props라는 말로 묶이지만, 실제 렌더링 흐름에서는 조금 더 조심해서 봐야 한다.

props가 바뀌면 리렌더링된다?

먼저 표현부터 조금 정확히 잡아야 한다. props가 리렌더링되는 것은 아니다. props는 자식 컴포넌트에 전달되는 입력이고, 다시 렌더링되는 것은 그 props를 받은 컴포넌트다.

또 props는 자식 컴포넌트 안에서 혼자 바뀌지 않는다. 보통 부모가 다시 렌더링되면서 자식에게 props를 다시 전달한다.

예를 들어 아래 코드는 Child에게 항상 같은 name prop을 넘긴다.

tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
      <Child name="react" />
    </section>
  );
}

function Child({ name }: { name: string }) {
  console.log("Child render");
  return <p>{name}</p>;
}

버튼을 누르면 count가 바뀌고 Parent가 다시 렌더링된다. 이때 name prop은 여전히 "react"로 같지만, Parent가 다시 실행되면서 <Child name="react" />도 다시 만들어진다. 그래서 Child도 다시 렌더링된다. 중요한 점은 Child의 props 값이 달라져서 렌더링된 것이 아니라는 점이다. 부모가 다시 렌더링되었고, 그 과정에서 자식도 렌더링 흐름에 들어간 것이다.

그럼 memo를 쓰면 어떻게 될까?

tsx
const Child = memo(function Child({ name }: { name: string }) {
  console.log("Child render");
  return <p>{name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
      <Child name="react" />
    </section>
  );
}

이제 Parentcount가 바뀌어도 Child의 props는 이전과 같다. React는 memo로 감싼 Child의 이전 props와 다음 props를 비교하고, 같다고 판단하면 Child 렌더링을 건너뛸 수 있다.

memo는 리렌더링의 해결책이 아니다. 렌더링 규칙을 바꾸는 도구도 아니다. 부모가 다시 렌더링되었을 때 자식의 props가 이전과 같으면, 자식 렌더링을 건너뛸 수 있게 해주는 최적화 도구다.

그래서 memo를 써도 props가 실제로 달라지면 다시 렌더링된다.

tsx
const Child = memo(function Child({ user }: { user: { name: string } }) {
  console.log("Child render");
  return <p>{user.name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);

  const user = { name: "react" };

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>Parent count: {count}</button>
      <Child user={user} />
    </section>
  );
}

위 코드에서 user는 렌더링할 때마다 새로 만들어지는 객체다. 내용은 매번 { name: "react" }로 같아 보이지만, 이전 렌더링의 user와 다음 렌더링의 user는 서로 다른 객체다. memo는 props를 비교할 때 객체의 내용까지 깊게 비교하지 않고, 기본적으로 이전 값과 다음 값이 같은 참조인지 확인한다.

이런 경우에는 memo가 있어도 렌더링을 건너뛰지 못한다. 그래서 React.memo를 이해할 때는 "memo를 쓰면 리렌더링이 막힌다"가 아니라, "memo는 props가 같다고 판단되는 경우에만 렌더링을 건너뛴다"고 보는 편이 더 정확하다.

props.children은 항상 다시 렌더링될까

children도 props다. 하지만 Parent가 다시 렌더링된다고 해서 children으로 받은 내용이 항상 다시 렌더링되는 것은 아니다.

먼저 이 구조를 보자.

tsx
function Parent({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);

  console.log("Parent rendered");

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <h1>Count: {count}</h1>
      {children}
    </div>
  );
}

function SlotContent() {
  console.log("SlotContent rendered");
  return <div>children으로 전달된 내용</div>;
}

function App() {
  return (
    <Parent>
      <SlotContent />
    </Parent>
  );
}

Parent 안에서는 1초마다 count가 바뀐다. 그래서 Parent는 계속 다시 렌더링된다. 하지만 SlotContent는 계속 다시 렌더링되지 않는다.

왜 그럴까?

<SlotContent />Parent 안에서 만든 JSX가 아니다. App<Parent>...</Parent>를 렌더링할 때 만들어서 Parent에게 children으로 넘긴 값이다.

tsx
<Parent>
  <SlotContent />
</Parent>

이 코드는 대략 이렇게 볼 수 있다.

tsx
<Parent children={<SlotContent />} />

childrenParent가 매번 새로 만드는 내용이 아니라, App에서 만들어서 전달받은 값이다. 그래서 Parent의 state만 바뀌어 Parent만 다시 렌더링될 때는, children으로 받은 <SlotContent />가 새로 만들어지지 않는다.

반대로 Parent 안에 직접 자식을 쓰면 결과가 달라진다.

tsx
function InlineChild() {
  console.log("InlineChild rendered");
  return <div>Parent 안에 직접 선언된 자식</div>;
}

function Parent({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);

  console.log("Parent rendered");

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <h1>Count: {count}</h1>
      <InlineChild />
      {children}
    </div>
  );
}

<InlineChild />Parent의 반환문 안에 직접 있다. 그래서 Parent가 다시 실행될 때마다 이 JSX도 다시 만들어지고, InlineChild도 다시 렌더링된다.

정리하면 차이는 이것이다.

구분어디에서 만들어지는가Parent만 다시 렌더링될 때
<InlineChild />Parent 안에서 만들어진다다시 만들어지고, InlineChild도 다시 렌더링된다
{children}App에서 만들어져 Parent로 전달된다새로 만들어지지 않으므로, SlotContent는 다시 렌더링되지 않을 수 있다

그래서 children은 항상 다시 렌더링되지 않는다고 외우면 안 된다. 만약 App도 다시 렌더링되면 <SlotContent />도 다시 만들어질 수 있다. 핵심은 children인지 아닌지가 아니라, 그 JSX를 누가 다시 만들고 있는가다.

React 렌더링 규칙

렌더링 조건과 오해를 정리하고 나면, 결국 더 중요한 질문이 남는다.

렌더링 중인 컴포넌트는 어떤 규칙을 지켜야 할까?

React 공식 문서도 컴포넌트와 훅은 순수해야 한다고 설명한다. 여기서 순수하다는 말은 단순히 함수형 프로그래밍을 멋있게 쓰자는 말이 아니다. React가 렌더링을 안전하게 반복하고, 중단하고, 비교하고, 최적화하려면 컴포넌트가 예측 가능해야 한다는 뜻이다.

부수 효과를 렌더링 밖으로 빼기

렌더링은 UI를 계산하는 단계다. 따라서 렌더링 중에는 외부 세계를 바꾸는 일을 하지 않아야 한다.

나쁜 예를 단순하게 만들면 이런 코드다.

tsx
let renderCount = 0;

function CounterLabel() {
  renderCount += 1;

  return <p>{renderCount}번째 렌더링</p>;
}

이 컴포넌트는 렌더링될 때마다 컴포넌트 바깥의 값을 변경한다. 같은 props와 state로 렌더링해도 호출 횟수에 따라 결과가 달라진다. 이제 이 컴포넌트의 출력은 입력만으로 설명되지 않는다.

조금 더 실제 코드에 가까운 예도 있다.

tsx
function Page({ title }: { title: string }) {
  document.title = title;

  return <h1>{title}</h1>;
}

문서 제목을 바꾸는 일 자체가 문제는 아니다. 문제는 그 일을 렌더링 중에 하고 있다는 점이다. 이 컴포넌트는 호출되기만 해도 document.title을 바꾼다. React가 렌더링을 다시 시도하거나 개발 환경에서 Strict Mode로 컴포넌트를 한 번 더 호출하면, 의도하지 않은 타이밍에 문서 제목이 바뀔 수 있다.

이런 코드는 렌더링 밖으로 빼야 한다.

tsx
function Page({ title }: { title: string }) {
  useEffect(() => {
    document.title = title;
  }, [title]);

  return <h1>{title}</h1>;
}

이제 렌더링은 title을 기준으로 JSX를 계산하는 일만 한다. 문서 제목을 바꾸는 부수 효과는 렌더링 이후에 실행된다.

렌더링 중에 피해야 할 일은 대략 이런 것들이다.

  • props, state, context를 직접 변경하기
  • 컴포넌트 바깥의 변수나 객체를 변경하기
  • DOM을 직접 조작하기
  • 네트워크 요청, 구독 등록, 타이머 설정 같은 부수 효과 실행하기
  • new Date(), Math.random()처럼 같은 입력에서도 매번 다른 결과를 만드는 값을 렌더링 결과에 직접 섞기

이 규칙의 목적은 React가 컴포넌트를 마음 놓고 다시 호출할 수 있게 만드는 것이다. 렌더링이 순수하다면 여러 번 호출되어도 같은 입력에 대해 같은 결과가 나온다. 그러면 React는 렌더링을 재시도하거나 최적화하기 쉬워진다.

멱등성 보장하기

순수성에서 특히 중요한 개념은 멱등성이다. 같은 입력으로 여러 번 실행해도 같은 결과가 나와야 한다는 뜻이다.

tsx
function Greeting({ name }: { name: string }) {
  return <p>Hello, {name}</p>;
}

이 컴포넌트는 name이 같다면 여러 번 호출해도 같은 JSX를 반환한다. React가 개발 모드에서 한 번 더 호출하든, 렌더링을 다시 시도하든 결과가 흔들리지 않는다.

반대로 이런 컴포넌트는 멱등적이지 않다.

tsx
function Clock() {
  return <p>{new Date().toLocaleTimeString()}</p>;
}

Clock은 props나 state가 바뀌지 않아도 호출 시점에 따라 다른 결과를 낸다. 시간이 바뀌는 UI가 필요하다면 시간 값을 state로 관리하고, 타이머는 effect에서 설정하는 식으로 렌더링 밖에서 다뤄야 한다.

tsx
function Clock() {
  const [time, setTime] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return <p>{time.toLocaleTimeString()}</p>;
}

이제 렌더링은 현재 state인 time을 화면으로 표현하는 일만 한다. 시간이 흐른다는 외부 변화는 effect가 state 업데이트로 React에 알려준다.

JSX로 넘긴 값은 나중에 바꾸지 않기

React 렌더링 규칙에서 자주 놓치는 부분이 있다. JSX에 전달한 값을 이후에 변경하지 않아야 한다는 점이다.

tsx
function Page({ color }: { color: string }) {
  const styles = { color, fontSize: 20 };

  const title = <Title styles={styles} />;

  styles.fontSize = 12;

  const description = <Description styles={styles} />;

  return (
    <>
      {title}
      {description}
    </>
  );
}

이 코드는 얼핏 보면 Title에는 큰 글씨를, Description에는 작은 글씨를 주려는 코드처럼 보인다. 하지만 styles 객체 하나를 JSX에 넘긴 뒤 다시 변경하고 있다. React 엘리먼트는 이미 그 객체 참조를 props로 받은 상태다. 이후에 같은 객체를 바꾸면 어떤 컴포넌트가 어떤 값을 기준으로 렌더링되는지 추론하기 어려워진다.

이럴 때는 값을 나눠서 만들어야 한다.

tsx
function Page({ color }: { color: string }) {
  const titleStyles = { color, fontSize: 20 };
  const descriptionStyles = { color, fontSize: 12 };

  return (
    <>
      <Title styles={titleStyles} />
      <Description styles={descriptionStyles} />
    </>
  );
}

React에서 props와 state를 불변으로 다뤄야 한다는 말은 단순히 객체를 수정하지 말자는 취향 문제가 아니다. React는 값이 언제 어떻게 만들어졌는지, 이전 렌더링과 다음 렌더링이 어떻게 다른지 비교하면서 UI를 갱신한다. 이미 JSX에 넘긴 값을 나중에 바꾸면 이 비교와 추론이 어려워진다.

렌더링 중에 객체를 새로 만들거나, 지역 배열에 값을 push하는 것이 항상 나쁜 것은 아니다. 중요한 기준은 그 값이 렌더링 안에서 새로 만들어졌고, 외부에 기억되지 않으며, JSX에 넘긴 뒤 다시 변경되지 않는가다. 지역적인 계산은 괜찮지만, React가 이미 입력으로 받은 값을 뒤늦게 흔드는 것은 피해야 한다.

memoization은 렌더링 규칙을 바꾸지 않는다

렌더링 조건을 공부하다 보면 자연스럽게 React.memo, useMemo, useCallback 같은 도구로 넘어가게 된다. 하지만 memoization은 렌더링 규칙을 바꾸는 도구가 아니다. 렌더링 규칙을 이해한 뒤, 특정 계산을 건너뛰거나 재사용하기 위해 쓰는 도구다.

tsx
const Child = memo(function Child({ name }: { name: string }) {
  console.log("Child render");
  return <p>{name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <Child name="react" />
    </section>
  );
}

Parentcount가 바뀌면 Parent는 다시 렌더링된다. 하지만 Child의 props인 name은 계속 같다. 이때 memo는 이전 props와 다음 props를 비교해서 같다고 판단되면 Child 렌더링을 건너뛸 수 있게 한다.

여기서 전제는 Child가 같은 props에 대해 같은 결과를 낸다는 것이다. 컴포넌트가 순수하지 않다면 이전 렌더링 결과를 재사용하는 것이 안전하지 않다. 그래서 memoization의 기반도 결국 렌더링 순수성이다.

또 하나 조심할 부분은 객체, 배열, 함수 props다.

tsx
const Child = memo(function Child({ user }: { user: { name: string } }) {
  console.log("Child render");
  return <p>{user.name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);

  const user = { name: "react" };

  return (
    <section>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <Child user={user} />
    </section>
  );
}

user 객체는 렌더링할 때마다 새로 만들어진다. 내용은 같아 보여도 참조는 다르다. Childmemo로 감싸도 props가 바뀐 것으로 판단될 수 있다.

이런 경우에 useMemouseCallback을 고려할 수 있다. 다만 순서는 여전히 같다. 먼저 렌더링이 왜 발생하는지 이해하고, 그 렌더링 비용이 실제로 문제인지 확인한 다음, 필요한 곳에만 memoization을 적용해야 한다. 리렌더링 로그가 보인다고 바로 모든 곳에 memoization을 붙이는 것은 좋은 출발점이 아니다.

리렌더링보다 먼저 봐야 할 것

React를 공부하다 보면 리렌더링을 나쁜 것처럼 느끼게 된다. DevTools에서 컴포넌트가 번쩍이는 걸 보면 뭔가 잘못된 것 같고, 콘솔에 render 로그가 여러 번 찍히면 최적화가 필요해 보인다.

하지만 리렌더링 자체는 React의 정상적인 작업 방식이다. 상태가 바뀌었을 때 UI를 다시 계산하는 것은 React가 해야 할 일이다. 오히려 상태가 바뀌었는데도 UI가 다시 계산되지 않는다면 그게 더 이상하다.

문제는 모든 리렌더링이 아니라, 의미 없는 비용이 반복되는 경우다. 예를 들어 렌더링 중에 무거운 계산을 매번 수행하거나, props가 매번 새 참조로 만들어져 memoization이 계속 깨지거나, 렌더링 과정에서 부수 효과가 실행되어 예측하기 어려운 동작을 만드는 경우다.

그래서 렌더링을 볼 때는 질문을 나눠야 한다.

  • 이 컴포넌트가 다시 렌더링되는 것은 React 규칙상 자연스러운가?
  • 어떤 입력이 바뀌어서 렌더링이 발생했는가?
  • 렌더링 결과가 실제 DOM 변경으로 이어지는가?
  • 렌더링 비용이 실제로 문제가 될 만큼 큰가?
  • 렌더링 중에 하면 안 되는 일을 하고 있지는 않은가?

개인적으로는 마지막 질문이 가장 먼저라고 생각한다. 불필요한 렌더링을 줄이는 것도 중요하지만, 렌더링 중에 부수 효과를 넣어두면 최적화 이전에 React의 기본 가정이 깨진다. React가 컴포넌트를 여러 번 호출하거나, 렌더링을 중단했다가 다시 시작하거나, 일부 작업을 건너뛰는 최적화를 적용하려면 렌더링이 순수하다는 전제가 필요하다.

정리

  • 렌더링은 단순히 화면을 다시 그리는 일이 아니라, React가 현재 props, state, context를 기준으로 다음 UI를 계산하는 과정이다.

  • DOM 반영과 브라우저 페인팅은 렌더링 이후의 단계로 나누어 볼 수 있다.

  • 렌더링은 애플리케이션이 처음 로드될 때 시작되고, state 변경, 부모 컴포넌트 리렌더링, context 값 변경에 의해 다시 발생할 수 있다.

  • props 변경은 보통 부모 렌더링의 결과로 봐야 한다. 자식의 props가 혼자 바뀌는 것이 아니라, 부모가 다시 렌더링되면서 새 props를 전달하는 흐름에 가깝다.

  • props.childrenParent가 직접 만든 JSX가 아니라, 상위 컴포넌트에서 만들어 전달된 값일 수 있다. 그래서 핵심은 "children인가 아닌가"보다, 그 JSX를 누가 다시 만들고 있는가에 있다.

  • 렌더링 규칙의 중심에는 순수성이 있다. 컴포넌트는 같은 입력에 대해 같은 결과를 내야 한다.

  • 렌더링 중에 외부 값을 변경하거나 DOM을 조작하면 React의 예측 가능성이 깨진다. JSX에 전달한 값도 이후에 변경하지 않는 편이 안전하다.

  • memoization은 렌더링 규칙을 대체하지 않는다. memo는 props가 같다고 판단될 때 렌더링을 건너뛰는 최적화 도구다.

  • props가 실제로 달라졌다고 판단되면 memo가 있어도 다시 렌더링된다. 따라서 먼저 렌더링이 왜 발생했는지 이해한 뒤에 최적화를 고민해야 한다.