리액트는 언제 다시 그리나요
state, props, 부모 중 무엇이 트리거였는지 헷갈릴 때 보세요.
리액트로 앱을 만들다 보면 "이 컴포넌트가 왜 또 그려지지?" 하는 순간이 찾아와요. 저도 프로파일러를 켜놓고 한참을 들여다본 적이 있거든요. props가 안 바뀐 것 같은데 자식이 계속 반짝거리고, state를 갱신했는데 화면은 그대로인 경우도 있었어요. 리렌더가 어디서 시작되고 어디까지 번지는지 감이 잡히면 이런 상황이 훨씬 덜 당황스러워져요.
리렌더가 시작되는 순간
리액트 공식 문서는 렌더링을 Trigger, Render, Commit 세 단계로 설명해요. 여기서 핵심은 Trigger예요. 최초 마운트를 제외하면, 리액트가 컴포넌트를 다시 그리기로 결정하는 트리거는 크게 두 종류뿐이에요.
컴포넌트 자신의 state 업데이트
setState 계열 setter가 호출되면 해당 컴포넌트가 리렌더 대상으로 큐에 들어가요.
조상 컴포넌트의 리렌더
부모가 다시 그려지면 그 아래 자식들도 기본적으로 같이 그려져요. props가 바뀌지 않아도 해당돼요.
props 변경이 별도 트리거처럼 보이지만, 사실 "부모가 리렌더됐기 때문에 자식이 새로운 props를 받는 것"이에요. 그래서 공식 문서는 props를 독립 트리거로 세지 않아요. 이 구분이 나중에 React.memo의 동작을 이해할 때 도움이 돼요.
참조 동등성으로 판단해요
setter를 호출했다고 무조건 리렌더가 일어나진 않아요. 리액트는 새 값과 이전 값을 Object.is로 비교하고, 결과가 같다면 해당 컴포넌트의 리렌더를 건너뛰어요. 이걸 bailout이라고 불러요.
문제는 객체나 배열이에요. 내용이 바뀌어도 참조가 같으면 리액트는 "변화 없음"으로 판단해요.
왼쪽 버튼을 누르면 배열에 항목을 push한 뒤 같은 참조를 setTodos에 넘겨요. 리액트는 Object.is(prev, next)가 true라고 판단해서 리렌더를 건너뛰고, 화면에 새 항목이 안 나타나요. 오른쪽 버튼은 새 배열을 만들어서 넘기니까 참조가 달라지고, 리렌더가 일어나요. 불변 업데이트가 관례가 된 이유가 여기에 있어요.
부모가 그리면 자식도 그려져요
리렌더에서 가장 자주 놓치는 부분이에요. 부모 컴포넌트가 다시 그려지면, props가 안 바뀌었더라도 자식은 기본적으로 리렌더돼요. 리액트가 자동으로 props를 비교해서 자식을 스킵해주지 않아요.
"React will normally re-render a component whenever its parent re-renders." - react.dev, memo
부모가 그려지면 자식도 그려진다는 게 기본 동작이에요. 이 전제를 깔고 나서 memo를 얹어야 얘기가 맞아요.
React.memo로 감싸면 props를 얕게 비교해서 동일하면 리렌더를 건너뛰어요. 이때 주의할 점은 props 중에 함수나 객체 리터럴이 섞여 있으면 매번 새 참조가 만들어져서 memo가 사실상 무력해진다는 거예요. 그래서 useCallback과 useMemo가 함께 등장하는 경우가 많아요. 이 조합이 언제 의미 있는지는 useMemo가 필요한 순간에서 한번 정리한 적이 있어요.
Context가 내려주는 리렌더
Context는 조금 다른 경로를 타요. useContext로 값을 구독한 컴포넌트는 Provider의 value가 이전과 달라지면 중간 트리에 React.memo가 끼어 있더라도 리렌더돼요. memo는 자기 props만 비교하지, 구독 중인 context의 변화를 막진 못하거든요.
그래서 Provider value를 매 렌더마다 새 객체 리터럴로 넘기면 구독자 전원이 같이 그려지는 상황이 생겨요.
Provider가 리렌더될 때마다 value는 새 객체라서, 실제 user 데이터가 안 바뀌어도 NameViewer가 같이 그려져요. 값과 setter를 분리해서 Provider를 쪼개거나, value를 useMemo로 감싸면 이 연쇄를 끊을 수 있어요. 비슷한 맥락을 Context 값과 dispatch를 왜 쪼개나에서 다뤘어요.
같은 값으로 setState하면
마지막으로 자주 헷갈리는 엣지 케이스 하나. setState(sameValue)처럼 Object.is 기준 동일한 값으로 업데이트하면 리액트가 해당 컴포넌트의 리렌더를 bail out한다고 했잖아요. 그래서 카운터에 setCount(count)를 걸면 아무 일도 안 일어나요.
bail out은 "항상 스킵"은 아니에요. 이미 부모에서 리렌더가 진행 중이라면, 자식 단계에서 같은 값으로 setState를 호출해도 자식까지 한 번은 렌더 함수가 돌 수 있어요. 공식 문서도 이걸 "React may still need to render that specific component before bailing out"이라고 적어요.
실무에서 이걸 의존할 일은 드물어요. 다만 디버깅 중에 "같은 값 넣었는데 왜 렌더가 돌지?" 하는 의문이 생기면 이 규칙을 떠올려보면 돼요. 리렌더를 막고 싶다면 Object.is가 true가 되도록 값을 그대로 유지하거나, 아예 상태를 끌어올려서 조건 분기로 처리하는 게 더 예측 가능해요.
이렇게 리렌더 조건을 한 번 정리해두면, 프로파일러에서 빨간 막대가 길게 찍혔을 때 "이게 state 트리거인지, 부모 전파인지, context 전파인지"를 빠르게 분류할 수 있어요. 원인을 분류하고 나야 memo, useMemo, context 분리 중 어떤 도구를 꺼낼지 판단이 서요.
참고 자료
- React - Render and Commit
리렌더를 Trigger, Render, Commit 세 단계로 설명한 공식 가이드
- React - Queueing a Series of State Updates
state 변경 여부를 Object.is로 비교하고 bailout하는 규칙
- React - memo
부모가 리렌더될 때 자식도 기본 리렌더되며 memo가 props 얕은 비교로 스킵하는 동작
- React - Passing Data Deeply with Context
Provider value가 바뀌면 memo로 감싸도 구독 컴포넌트가 리렌더되는 경로