리렌더링 지옥 탈출하기: 실제 프로젝트로 본 React 최적화 전략
2025. 9. 10.
안녕하세요, 컨시언스파트너스 프론트엔드 개발자 Dean입니다😃
이번 글에서는 제가 마주했던 고민들을 바탕으로, React 최적화 과정에서 떠올랐던 의문들을 하나하나 짚어보고 공부하며 얻은 인사이트를 정리해 보려고 해요. 완벽한 결론이라기보다는, 비슷한 고민을 하는 분들께 작은 힌트나 출발점이 되었으면 해요.
리액트를 공부하다 보면 메모이제이션을 자연스럽게 접하게 돼요. 보통 React.memo
는 컴포넌트, useMemo
는 값, useCallback
은 함수를 메모이제이션해 최적화한다는 건 알고 있지만, 막상 “어디에 적용해야 효과적일까?”는 애매해요.
비싼 연산의 기준은 무엇일까?
리렌더링이 잦은 컴포넌트는 어떻게 찾을까?
메모이제이션 자체 비용은 얼마나 들까?
헷갈리는 지점을 정리해서 실무에서 바로 쓸 수 있는 기준을 잡아보려고 해요.
리액트 렌더링 과정
먼저, 리액트가 화면을 어떻게 그리고 언제 리렌더링되는지 간단히 알아볼게요.
1. JSX 코드를 Babel로 트랜스파일링
JSX는 표준 문법이 아니에요. 브라우저의 자바스크립트 엔진은 JSX를 바로 이해하지 못하므로, Babel 같은 도구를 사용해 트랜스파일링해줘야 해요.

이런 JSX 코드는 리액트 프로젝트에 포함된 빌드 도구의 빌드 과정에서 자동으로 아래 코드로 변환돼요.

2. React 엘리먼트를 Fiber 노드로 변환
Fiber 노드는 React 16부터 도입된 자료구조예요. React 16 이전의 Stack Reconciler에서는 재조정이 동기적으로만 이루어져, 렌더링이 시작되면 끝날 때까지 멈출 수 없었고 모든 업데이트가 같은 우선순위로 처리됐어요. 하지만 React 16에서 Fiber Reconciler가 도입되면서 비동기 렌더링을 지원하고, 중요한 작업이 있을 때 렌더링을 중단하거나 업데이트에 우선순위를 부여하는 등 다양한 기능을 활용할 수 있게 되었어요.
간단히 말해, Fiber 노드는 실제 DOM과 연결되며 리액트 내부의 재조정 과정에 필요한 정보들을 담고 있는 자료구조라고 보면 돼요.

3. Render Phase - Virtual DOM 생성
이후 React는 Fiber 노드를 활용해 Virtual DOM 트리를 구성하고 저장해둬요.

https://www.youtube.com/watch?v=XWqs1mMPJ30&list=PLBh_4TgylO6CI4Ezq3OLRRzg2NAn3FLPB&index=6
초기 렌더링 시에는 그대로 화면에 그려져요. 이후 컴포넌트의 상태나 props가 바뀌면 트리 전체를 다시 그리는 대신, 이전 Virtual DOM과 변경된 Virtual DOM을 비교해 달라진 부분만 교체해요. 이 과정을 Diffing이라고 하고, 이렇게 찾아낸 변경점을 새로운 Virtual DOM에 반영하는 과정을 재조정(Reconciliation)이라고 해요.
Virtual DOM이 항상 좋을까? Virtual DOM은 메모리를 사용하고, 변경 사항을 찾는 diffing 과정에서 CPU 자원도 활용해요. 그래서 DOM 트리가 복잡하고 상태 변경이 자주 발생하는 대규모 애플리케이션에서는 변경된 부분만 찾아 적용하는 Virtual DOM이 더 효율적이지만, 단순한 애플리케이션에서는 오히려 오버헤드가 생길 수 있어요.
4. Commit Phase
렌더 단계에서 계산된 변경사항(Virtual DOM 트리)을 실제 DOM에 적용한다.
최적화에 드는 비용
1. React.memo
컴포넌트는 다음과 같은 경우에 리렌더링이 발생해요.
props가 변경될 때
state가 변경될 때
부모 컴포넌트가 리렌더링될 때
state나 props가 변경되는 경우는 리렌더링이 당연히 필요한 상황이므로 memo를 적용할 필요가 없어요.
하지만 부모 컴포넌트가 리렌더링되었는데 자식에게 전달되는 props가 바뀌지 않았다면, 자식 컴포넌트는 변화가 없음에도 불필요하게 리렌더링돼요. 이런 경우 React.memo
를 사용해 최적화를 고려할 수 있어요.
그렇다면 memo를 사용할 때 발생하는 비용은 무엇일까요?
props를 메모리에 저장하는 비용
얕은 비교(shallow compare)를 통해 props 변경 여부를 확인하는 비용
컴포넌트를 메모이제이션하는 비용
메모이제이션은 이전 렌더링 결과를 저장해 두었다가, 리렌더링이 필요하지 않으면 그 결과를 그대로 재사용하는 방식이에요.
그런데 이 과정, 어디서 본 것 같지 않나요?
맞아요, 바로 앞에서 설명한 재조정 알고리즘이에요. React는 기본적으로 이전 렌더링 결과를 저장하고 있으니, memo가 실제로 추가로 지불하는 비용은 props에 대한 얕은 비교뿐이라고 할 수 있어요.
물론 props가 크고 복잡해진다면 이 비용도 늘어나겠지만, diffing을 포함한 전체 렌더링 과정에서 발생하는 비용이나 자식 컴포넌트가 매번 리렌더링되는 비용에 비하면 훨씬 작은 편이에요.
2. useMemo, useCallback
useCallback
은 개념적으로 useMemo
와 동일한 방식으로 동작한다고 보면 돼요. (예: useCallback(fn, deps) ≈ useMemo(() => fn, deps)예요). 둘 다 값을/함수를 캐시하기 때문에 값·함수 자체를 저장하는 비용, 의존성 배열을 저장하는 비용, 그리고 매 렌더마다 의존성 배열을 비교하는 비용이 들어가요.
useMemo는 계산 비용이 큰 값을 미리 계산해두거나 객체의 참조를 안정적으로 유지해야 할 때 쓰면 좋아요. useCallback은 함수의 참조를 안정적으로 유지해야 할 때, 예를 들어 자식 컴포넌트의 메모화나 의존성이 함수인 효과/이벤트 핸들러에서 유용해요. 아래에서 자세히 살펴봐요.
최적화가 필요한 경우
1. React.memo - 상위 컴포넌트가 리렌더링 되었을 때 하위 컴포넌트에 전달되는 props가 변경되지 않은 경우


부모와 자식 컴포넌트는 리렌더링되고 있지만, 손자 컴포넌트는 변경 사항이 없음에도 함께 리렌더링되고 있어요. 이런 상황에서는 손자 컴포넌트에 React.memo
를 적용해주면 돼요.

Context를 사용할 때도 마찬가지예요. 자식이 Context를 구독하고 있다면 Context가 변경될 때 자식 컴포넌트는 리렌더링돼요. 그런데 손자가 Context를 사용하지 않더라도 함께 리렌더링될 수 있어요. 이 경우에도 손자 컴포넌트에 React.memo
를 적용하면 불필요한 리렌더링을 방지할 수 있어요.
2. useMemo - 계산 비용이 많이 드는 연산

useMemo는 컴포넌트가 리렌더링되더라도 의존성 배열의 값이 바뀌지 않으면 함수를 다시 실행하지 않고, 이전에 계산한 값을 그대로 사용해요. 따라서 계산 비용이 크고 의존성이 자주 바뀌지 않는 경우에 활용하면 효과적이에요.

3. useMemo / useCallback - 참조 동일성이 유지되어야 할 때
자바스크립트에서 기본 타입(number, string, boolean, null, undefined)을 제외한 모든 값은 객체로 취급돼요. 함수 역시 객체로 표현되고, 객체의 연산은 실제 값이 아니라 참조값을 기준으로 처리돼요.
즉, 기존 객체나 함수와 새로 생성된 객체나 함수의 내용이 완전히 동일하더라도 자바스크립트는 이를 서로 다른 값으로 인식해요. 그래서 객체나 함수를 props로 전달하거나 useEffect의 의존성 배열에 넣을 경우, 불필요한 리렌더링이나 effect 실행이 발생할 수 있어요.

위의 경우에는 객체를 하위 컴포넌트로 전달할 때 useMemo를 사용해 불필요한 리렌더링을 막아줘요.

따라서 객체를 직접 전달하기보다는 분해해서 전달하는 것이 더 좋은 패턴이에요. 필요한 속성만 개별적으로 전달하면 useMemo를 사용할 필요 없이 불필요한 리렌더링을 줄일 수 있고, 컴포넌트의 의존성이 더 명확해지며 타입 정의도 깔끔해져요.


위 코드는 useEffect의 의존성 배열에 함수를 포함하고 있어요. 이때 useCallback을 적용하지 않으면 다음과 같은 흐름이 발생해요.
상위 컴포넌트 리렌더링
상위 컴포넌트에서 새 props를 받아 하위 컴포넌트 리렌더링
첫 번째 useEffect 실행
함수 참조가 변경되면서 의존성 배열의 변화 감지
두 번째 useEffect 실행
그 결과 Effect가 두 번 실행돼요. 위 예시에서는 단순히 카운트만 증가하지만, 만약 Effect 내부에서 setState처럼 상태를 변경하는 로직이 있다면 실제로 컴포넌트가 한 번 더 리렌더링되죠. 성능 저하나 예기치 못한 동작을 막으려면, 함수를 의존성 배열에 넣을 때는 반드시 useCallback으로 메모이제이션해주는 것이 좋아요.

4. DOM에 접근할 땐 State 대신 Ref로 접근하기
메모이제이션과는 별개지만, 실무에서 자주 놓치는 포인트예요. 앞서 말했듯 컴포넌트 리렌더링은 props나 state가 바뀔 때 발생해요. 그런데 requestAnimationFrame 으로 프레임마다 애니메이션 값을 갱신해야 하는 상황에서 그 값을 state로 관리하면 어떻게 될까요? 초당 60번 상태가 바뀌면서 그만큼 리렌더가 일어나고, 매 프레임마다 Virtual DOM을 생성·비교해야 해요.



이런 경우에는 state 대신 ref로 DOM을 직접 업데이트해 React 렌더링 사이클을 우회하는 게 좋아요.



애니메이션처럼 프레임 단위로 바뀌는 경우 말고도 ref로 최적화할 장면은 많아요. 예를 들어 드래그 앤 드롭에서는 마우스 좌표를 매번 state로 올릴 필요가 없고, 스크롤 처리나 차트 갱신처럼 빈번한 인터랙션도 ref로 DOM을 직접 조작하면 React 렌더링 사이클을 거치지 않아 즉시 반영돼요. 또한 D3.js, Three.js 같은 외부 라이브러리와 통합할 때도 ref를 쓰면 라이브러리가 DOM을 직접 제어할 수 있어 충돌 없이 부드럽게 동작해요.’
메모이제이션이 불필요한 경우
당연한 이야기지만, 자주 리렌더링되는 작은 컴포넌트나 계산 비용이 낮은 연산에는 메모이제이션을 적용하는 것이 오히려 메모리 사용과 비교 연산으로 인한 불필요한 비용을 늘릴 수 있어요.
불필요한 메모이제이션을 제거하기
1. Children Prop으로 자식 컴포넌트 전달하기



메모이제이션을 적용하지 않아도 ParentWrapper의 상태가 바뀌었을 때 Child는 리렌더링되지 않아요.
2. 로컬 State를 사용하고, State를 상위 컴포넌트로 올리는 것 지양하기




form 같은 일시적인 state는 상위 컴포넌트나 전역 상태 라이브러리에 두지 말아야 해요.
3. State를 업데이트하는 불필요한 useEffect 피하기
React 앱에서 발생하는 대부분의 성능 문제는 Effect로 인해 불필요하게 컴포넌트가 반복 렌더링되는 과정에서 비롯돼요. 이런 문제를 피하려면 Effect가 필요하지 않은 두 가지 대표적인 경우를 알아두면 좋아요.
* 렌더링을 위해 데이터를 변환하는 경우
예를 들어, 필터링된 목록을 화면에 보여주려 한다고 해볼게요. 흔히 목록이 변경될 때 state를 업데이트하는 Effect를 작성하곤 하지만, 이렇게 하면 state가 바뀌면서 컴포넌트가 다시 호출되고 DOM 업데이트가 일어난 뒤 Effect가 실행돼요. 그리고 Effect 안에서 다시 state를 업데이트하면 같은 과정이 반복되죠. 매우 비효율적인 방식이에요.
이럴 땐 컴포넌트 내부에서 Effect를 쓰기보다, 상위 컴포넌트에서 필터링된 목록을 내려주는 방식으로 불필요한 렌더링을 막는 게 좋아요.
* 사용자 이벤트를 처리하는 경우
사용자가 구매 버튼을 클릭했을 때 /api/buy
에 POST 요청을 보내고 알림을 띄우는 상황을 생각해볼게요. 이런 로직은 이벤트 핸들러 안에서 처리해야 해요. Effect는 “렌더링 결과에 따라 실행되는 로직”을 다루는 곳이지, 사용자가 어떤 버튼을 클릭했는지 같은 이벤트 정보를 알 수 없어요. 따라서 사용자 이벤트 처리까지 Effect에 넣으면 의도치 않은 동작과 성능 문제가 생기게 돼요.
4. Effect의 불필요한 의존성 제거하기
메모이제이션을 사용하는 대신, 객체나 함수를 아예 컴포넌트 바깥으로 분리하는 것이 더 나은 경우도 있어요.
Effect를 작성할 때는 코드 변경을 반영하기 위해 의존성을 넣는 것이 정말 의도한 바인지 확인해야 해요. 의존성 값이 바뀔 때마다 Effect가 다시 실행되도록 설정했더라도, 실제로는 재실행이 필요 없는 상황에서 의도치 않게 Effect가 반복 실행될 수 있기 때문이에요.
* 조건에따라 Effect 안의 다른 부분을 재실행하고 싶은 경우
예를 들어, country props에 따라 해당 country의 cities를 가져와 state에 저장하고, 사용자가 특정 city를 선택하면 그 city의 areas를 가져오는 로직을 생각해볼 수 있어요.

Effect의 의존성에 country와 city를 모두 넣는 것이 맞아 보일 수 있지만, 이렇게 하면 country는 그대로인데 city만 바뀌어도 cities를 다시 불러오는 문제가 생겨요. 이 코드의 핵심 문제는 서로 다른 두 로직을 하나의 Effect에 함께 넣었다는 점이에요. 따라서 이를 분리해서 작성해야 해요.

각 Effect는 독립적인 동기화 과정을 나타내도록 분리해야 해요. 하나의 Effect를 제거하더라도 다른 Effect의 로직이 깨지지 않아야 하죠. 만약 중복이 걱정된다면, 반복되는 로직을 커스텀 훅으로 추출해 코드를 개선할 수 있어요.
* 변화에 반응하는 대신 의존성의 최신값을 읽기만 하고 싶은 경우
예를 들어, isMuted가 true가 아닐 때 사용자가 새 메시지를 받으면 사운드를 재생하고 싶은 상황을 생각해볼 수 있어요.

이 경우 isMuted가 바뀔 때마다 Effect 전체가 다시 실행돼요. 이런 상황에서는 앞에서 설명한 것처럼 useRef를 사용해 isMuted를 관리할 수 있어요.

* 의존성이 객체 또는 함수여서 의도하지 않게 너무 자주 변경되는 경우
앞에서는 함수가 의존성일 때 useCallback을 사용해 불필요한 Effect 실행을 막는 방법을 살펴봤어요. 이번 예제에서는 message state가 바뀔 때마다 options가 새로 생성되고, Effect는 options가 변경된 것으로 인식해 내부 로직을 실행하게 돼요.

이럴 땐 객체 options를 Effect 내부에서 선언해주면, 의존성 배열에서 options를 제외할 수 있어요.

이제 message state가 바뀌어도 불필요하게 Effect가 실행되지 않아요.
마무리
예전에 프로젝트를 진행하면서 최적화를 적용한 적이 있었어요. 그 당시에는 뭔가 개선했다고 생각했는데, 지금 돌아보니 사실은 처음부터 당연히 했어야 할 일을 하지 않고서 ‘개선’이라고 했던 것 같아요.😢
useCallback과 useRef는 언제 사용해야 하는지가 비교적 명확해요. 또 메모이제이션 없이도 최적화할 수 있는 방법들을 다뤘고요. useMemo와 React.memo에 대해서는, 의존성 배열이나 props를 저장하고 비교하는 데 드는 리소스보다 불필요한 연산이나 리렌더링 비용이 훨씬 크다는 결론을 내릴 수 있을 것 같아요.
물론 가장 좋은 건 모든 상황에서 테스트를 거쳐 최적화를 적용하는 거겠지만, 그럴 여유가 없다면 의심되는 부분에 일단 전부 최적화를 적용해두는 것도 괜찮지 않을까 싶어요.
참고
https://ko.legacy.reactjs.org/docs/introducing-jsx.html