본문으로 건너뛰기
Tech Blog

리액트 최적화가 달라진 진짜 이유

글 복사 완료!

useMemo, useCallback, memo를 일일이 거는 게 맞나 싶을 때 보세요.

·9분·

새 컴포넌트를 만들 때마다 손이 먼저 나가요. 함수는 useCallback으로 감싸고, 값은 useMemo로 감싸고, 자식은 memo로 감싸고요. 정작 느린지 측정은 안 해봤는데 말이죠. 근데 2025년 10월 React Compiler가 1.0으로 안정화되면서 이 손버릇의 전제가 흔들렸어요. 메모이제이션은 이제 우리가 손으로 거는 기술이 아니라, 컴파일러가 알아서 걸어주도록 리액트의 규칙에 맞게 코드를 쓰는 문제로 바뀌었거든요.

세 도구는 사실 한 팀이었다

useMemo, useCallback, memo를 따로따로 배우면 셋이 무슨 관계인지 잘 안 잡혀요. 근데 실제로는 한 가지 문제를 같이 푸는 한 팀이에요. 그 문제는 "부모가 다시 그려져도 자식은 안 그리고 싶다"예요.

memo로 감싼 컴포넌트는 props가 안 바뀌면 부모가 리렌더돼도 자기는 건너뛰어요. 문제는 이 비교가 Object.is 기반의 얕은 비교라는 거예요. 객체나 배열, 함수는 내용이 같아도 매 렌더마다 새로 만들어지면 다른 값으로 취급돼요. 부모가 다시 그려질 때 자식까지 따라 그려지는 조건은 지난 글에서 정리했는데, 여기서 핵심은 참조가 매번 바뀐다는 점이에요.

그래서 자식에 넘기는 함수를 useCallback으로, 값을 useMemo로 고정해줘야 memo가 비로소 일을 해요. 셋 중 하나만 빠져도 최적화가 통째로 무너지는 구조죠.

콘솔을 열고 부모 증가 버튼을 눌러보세요. Child 렌더링 로그가 안 찍혀요. handleClick에서 useCallback을 빼보면 그때부터 부모를 누를 때마다 자식이 같이 그려져요. memo 혼자서는 아무것도 못 막는다는 게 눈에 보이죠.

그래서 손으로 거는 게 왜 피곤했나

이 삼각관계를 머리로 알고 나면 다음 함정이 기다려요. "그럼 헷갈리지 말고 일단 다 감싸자"는 생각이에요. 저도 한동안 그랬어요. 근데 공식 문서는 정반대로 말해요.

useMemo가 의미 있는 경우는 사실 세 가지뿐이에요. 계산이 눈에 띄게 느린데 의존성이 거의 안 바뀔 때, 결과를 memo 자식에 넘길 때, 그리고 다른 Hook의 의존성으로 쓸 때예요. 이 셋에 안 걸리면 대부분 그냥 빼는 게 나아요.

"You should only rely on useMemo as a performance optimization. If your code doesn't work without it, find the underlying problem and fix it first." - react.dev useMemo

useMemo는 성능 최적화 수단으로만 기대야 해요. 이게 없으면 안 돌아가는 코드라면, useMemo로 덮을 게 아니라 그 밑에 깔린 진짜 문제부터 찾아야 한다는 뜻이에요.

memo도 마찬가지예요. 렌더링 도중에 만든 객체나 함수를 그대로 넘기면 props가 항상 달라지니까 memo는 완전히 무용지물이 돼요. memo만 딱 달면 빨라질 거라는 기대가 가장 자주 빗나가는 지점이에요.

"memo is completely useless if the props passed to your component are always different, such as if you pass an object or a plain function defined during rendering." - react.dev memo

props가 매번 다르면 memo를 달아도 0에서 한 걸음도 못 나가요. 그래서 useMemo, useCallback과 짝을 지어야만 효과가 생기는 거고요.

무차별로 감싸면 코드는 의존성 배열로 뒤덮이고, 읽기는 점점 힘들어져요. 동작이 빨라지지도 않으면서요. useMemo 하나만 놓고 언제 쓰고 언제 빼는지 더 파고든 이야기는 따로 정리한 글에 있어요.

React Compiler가 뒤집은 것

여기까지가 손으로 메모이제이션을 걸던 시절의 풍경이에요. React Compiler는 이 일을 빌드 타임에 자동으로 해줘요. 코드를 컴파일하는 단계에서 어떤 값과 함수를 캐시해야 하는지 컴파일러가 분석해서, useMemo나 useCallback, React.memo를 우리가 직접 안 써도 같은 효과를 내요.

가장 좋은 점은 코드를 다시 쓸 필요가 없다는 거예요. 평범하게 작성한 컴포넌트와 Hook을 그대로 두면 컴파일러가 알아서 최적화해요. 위의 데모 같은 코드도 useCallback 없이 그냥 함수를 정의하면, 컴파일러가 참조를 고정해주는 식이죠.

"React Compiler works on both React and React Native, and automatically optimizes components and hooks without requiring rewrites. The compiler has been battle tested on major apps at Meta and is fully production-ready." - react.dev React Compiler 1.0

리액트와 리액트 네이티브 양쪽에서 동작하고, 코드 재작성 없이 자동으로 최적화해요. Meta의 대규모 앱에서 검증을 마쳐서 프로덕션에 바로 써도 되는 상태라는 뜻이에요.

숫자로도 근거가 있어요. Meta의 Quest Store에서는 초기 로드와 페이지 이동이 최대 12% 빨라졌고, 일부 상호작용은 2.5배 이상 빨라졌어요. 한 번에 전부 켤 필요도 없어요. 파일이나 디렉토리 단위로 점진적으로 도입할 수 있고, Next.jsVite, Expo 같은 프레임워크는 템플릿에서 기본으로 챙겨주거든요.

그래도 사라지지 않는 것

그럼 이제 손 놓고 컴파일러만 믿으면 될까요. 여기에 함정이 하나 있어요. 자동 최적화에는 전제 조건이 붙어요. 코드가 Rules of React, 그러니까 리액트가 정한 작성 규칙을 지켜야 컴파일러가 안전하게 최적화할 수 있어요. 렌더링 중에 외부 값을 함부로 바꾸거나, 컴포넌트를 순수하지 않게 짜면 컴파일러는 그 부분의 최적화를 그냥 건너뛰어요.

그래서 무게중심이 옮겨갔어요. 예전엔 useMemo를 어디에 거느냐가 실력이었다면, 이제는 컴파일러가 일할 수 있도록 규칙에 맞는 코드를 쓰는 게 핵심이에요. 이 규칙을 사람이 일일이 지키긴 어려우니까, eslint-plugin-react-hooks와 컴파일러용 react-compiler 린트 규칙이 대신 잡아줘요. 1.0부터는 이 린트가 권장 프리셋에 들어가 있어서, 컴파일러를 안 깔아도 규칙 위반을 미리 경고받을 수 있어요.

컴파일러 시대의 마인드셋

☐ 일단 useMemo, useCallback부터 감쌀까 → 일단 순수하게 짜고 측정부터
☐ memo를 어디에 달지 고민 → 참조가 왜 매번 바뀌는지부터 점검
☐ 최적화 코드를 손으로 관리 → 린트로 규칙 위반을 자동으로 차단

도구를 능숙하게 거는 사람보다, 규칙을 어기지 않는 사람이 더 빠른 앱을 만드는 시대가 된 거예요. 참조 안정성이 왜 그렇게 중요한지 한 걸음 더 보고 싶으면 Context를 쪼개는 이야기도 같이 읽어보면 좋아요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

새 코드는 컴파일러에 맡기고 굳이 새로 감싸지 않아도 돼요. 다만 기존에 써둔 것을 한꺼번에 지우는 건 급하지 않아요. 컴파일러가 같은 일을 해주니 중복일 뿐 해롭진 않거든요. 점진적으로 정리하면 됩니다.
코드가 Rules of React를 어길 때예요. 렌더링 중에 외부 상태를 바꾸거나 컴포넌트가 순수하지 않으면, 컴파일러는 안전을 위해 그 부분을 최적화하지 않아요. 그래서 린트로 규칙 준수를 확인하는 게 중요해요.
memo는 props가 같을 때만 리렌더를 건너뛰는데, 렌더링 중에 만든 객체나 함수를 넘기면 props가 매번 달라져요. 그래서 useMemo, useCallback으로 참조를 먼저 고정해줘야 memo가 효과를 내요.

참고 자료

관련 글