March 27, 20264 minutes
리액트로 애플리케이션을 만들다 보면 어느 순간 화면이 묵직해지거나 반응이 느려지는 경험을 하게 되는데요.
대부분의 개발자는 이럴 때 가장 먼저 ‘useMemo’나 ‘useCallback’ 혹은 ‘React.memo’를 코드 곳곳에 뿌리기 시작합니다.
물론 이런 도구들이 도움이 될 때도 있지만 사실 성능 저하의 본질은 리액트 엔진 자체가 아니라 우리가 짠 코드의 구조에 있는 경우가 훨씬 많거든요.
단순히 메모이제이션에 의존하기 전에 우리가 놓치고 있는 진짜 성능 최적화 포인트가 무엇인지 심도 있게 살펴보겠습니다.
성능이 떨어질 때 ‘memo’를 붙이는 것은 마치 물이 새는 배의 구멍을 테이프로 막는 것과 비슷한데요.
근본적으로 배에 너무 많은 짐이 실려 있거나 엔진 설계가 잘못되었다면 테이프 몇 장으로는 상황을 바꿀 수 없습니다.
실제로 프로파일링을 해보면 성능 저하의 주범은 ‘불필요하게 높은 곳에 위치한 상태’인 경우가 굉장히 많거든요.
상태가 너무 상단에 있으면 아주 작은 변화에도 하위의 수많은 컴포넌트가 동시에 재렌더링되는 ‘렌더링 폭포’ 현상이 발생합니다.
이럴 때는 메모이제이션을 고민하기보다 상태를 최대한 사용하는 곳과 가까운 하위 컴포넌트로 내려보내는 것이 우선입니다.
만약 여러분의 앱에 수만 개의 아이템을 보여줘야 하는 리스트가 있다면 일반적인 렌더링 방식으로는 한계가 올 수밖에 없는데요.
브라우저가 수십만 개의 DOM 노드를 한꺼번에 계산하고 그리는 작업은 메모리와 CPU에 엄청난 부담을 주기 때문입니다.
이럴 때 ‘가상화’라는 개념을 도입하면 성능을 비약적으로 끌어올릴 수 있습니다.
가상화는 사용자의 화면에 보이는 영역과 그 주변의 극히 일부 아이템만 실제로 렌더링하는 기법을 말하는데요.
쉽게 말해 10,000개의 데이터가 있어도 실제 DOM에는 20~30개 정도만 유지하면서 스크롤 위치에 따라 내용만 바꿔주는 겁니다.
이렇게 하면 렌더링 복잡도를 데이터의 개수인 ‘O(n)‘에서 화면 크기에 비례하는 ‘O(1)‘로 고정할 수 있습니다.
가상화를 구현할 때는 단순히 라이브러리를 쓰는 것보다 그 내부 원리를 이해하는 것이 중요한데요.
가장 핵심은 ‘안정적인 키’를 사용하는 것과 ‘오버스캔’의 범위를 적절히 조절하는 것입니다.
인덱스를 키로 사용하면 아이템이 재사용될 때 리액트가 혼란을 느껴 성능 이점이 사라질 수 있거든요.
또한 사용자가 빠르게 스크롤할 때 흰 화면이 보이지 않도록 화면 밖의 아이템을 몇 개나 미리 그려둘지도 세밀하게 튜닝해야 합니다.
최근에는 ‘TanStack Virtual’ 같은 훌륭한 도구들이 잘 나와 있어서 복잡한 로직을 직접 구현하지 않고도 고성능 리스트를 만들 수 있습니다.
많은 분이 자바스크립트의 연산 속도가 느려서 앱이 버벅거린다고 오해하시곤 하는데요.
사실 현대의 자바스크립트 엔진은 몇 밀리초 안에 수만 번의 연산을 처리할 수 있을 만큼 매우 강력합니다.
진짜 문제는 계산 자체가 아니라 그 계산이 ‘언제’ 그리고 ‘얼마나 자주’ 일어나느냐에 달려 있거든요.
매 렌더링마다 복잡한 데이터 변환이나 필터링을 반복하고 있다면 이는 분명히 개선해야 할 지점입니다.
하지만 데이터가 수백 건 수준이라면 이를 최적화하려고 ‘useMemo’를 남발하는 것이 오히려 메모리 점유율을 높이는 역효과를 낼 수도 있습니다.
상태 관리 라이브러리를 사용한다면 ‘메모이징된 셀렉터’를 활용하는 것이 아주 현명한 선택인데요.
컴포넌트가 전체 상태 객체를 구독하는 대신 필요한 조각만 골라서 구독하게 만들면 불필요한 재렌더링을 원천 봉쇄할 수 있습니다.
예를 들어 ‘Zustand’나 ‘Redux’를 쓸 때 객체 전체를 가져오지 말고 ID 배열만 가져온 뒤 각 아이템 컴포넌트에서 자신의 데이터를 직접 조회하게 해보세요.
이런 방식은 데이터 접근을 원자화하여 부모가 변해도 자식은 영향을 받지 않는 탄탄한 구조를 만들어줍니다.
리액트 커뮤니티에서는 재렌더링 횟수를 줄이는 것에 거의 집착에 가까운 관심을 보이곤 하는데요.
하지만 냉정하게 말해서 렌더링 한 번의 비용이 매우 저렴하다면 서너 번 더 렌더링되는 것은 사용자 경험에 아무런 지장을 주지 않습니다.
오히려 렌더링 횟수를 줄이겠다고 코드 여기저기에 ‘memo’ 레이어를 씌우는 행위 자체가 자바스크립트 실행 오버헤드를 늘릴 수 있거든요.
진정한 고수는 재렌더링을 막기보다 ‘렌더링 로직 자체를 가볍게’ 만드는 것에 더 집중합니다.
무거운 레이아웃 계산을 피하고 DOM 구조를 단순하게 유지하면 리액트의 재조정 과정은 생각보다 훨씬 빠르게 끝납니다.
감에 의존해서 코드를 수정하는 것만큼 위험하고 비효율적인 일도 없는데요.
리액트 개발자 도구에 포함된 ‘Profiler’ 탭을 열어보면 어떤 컴포넌트가 왜 렌더링되었는지 명확한 증거를 찾을 수 있습니다.
‘Highlight updates’ 옵션을 켜두면 화면에서 어떤 영역이 실시간으로 번쩍이며 재렌더링되는지 한눈에 파악할 수 있거든요.
여기서 우리가 주목해야 할 것은 ‘Commit 시간’과 ‘각 컴포넌트의 렌더링 사유’입니다.
단순히 props가 변해서 렌더링된 것인지 아니면 부모가 변해서 강제로 렌더링된 것인지를 구분하는 것만으로도 해결책은 절반 이상 나온 셈입니다.
성능 최적화의 여정에서 가장 경계해야 할 것은 ‘무분별한 패턴의 적용’이라고 할 수 있는데요.
어떤 기술이나 라이브러리를 도입하기 전에 “지금 이 작업이 정말 필요한가?“라는 질문을 스스로에게 던져봐야 합니다.
데이터 구조를 단순화하고 상태의 소유권을 명확히 정의하는 것만으로도 대부분의 성능 문제는 마법처럼 사라지곤 하거든요.
‘memo’는 모든 구조적 개선이 끝난 뒤에 마지막으로 찍는 마침표 같은 존재여야 합니다.
탄탄한 기본기 위에 적재적소의 도구를 활용할 때 비로소 우리는 진정으로 매끄러운 사용자 경험을 선사할 수 있습니다.