이 글에서 정리하는 내용
React Compiler 1.0을 적용한 뒤 useMemo와 useCallback을 어디까지 줄일 수 있는지, 새 코드와 기존 코드에서 판단 기준이 어떻게 달라지는지 실무 흐름에 맞춰 정리합니다.
- 이 글에서 정리하는 내용
- React Compiler 1.0이 바꾸는 기준
- useMemo는 어디까지 줄일 수 있을까
- useCallback은 어디까지 줄일 수 있을까
- 여전히 남겨야 하는 경우
- 정리
- 많이 받는 질문
- 같이 읽으면 좋은 글
React Compiler 1.0이 바꾸는 기준

처음 React Compiler 1.0을 도입해보면 가장 먼저 달라지는 것은 최적화 코드를 쓰는 순서입니다. 예전에는 계산값이 다시 만들어질 것 같으면 useMemo를, 함수 참조가 바뀔 것 같으면 useCallback을 먼저 붙이기 쉬웠습니다. 하지만 이제는 먼저 평범한 코드로 작성한 뒤 compiler가 자동으로 memoization할 수 있게 두고, 정말 제어가 필요한 지점만 수동으로 남기는 방식이 더 자연스럽습니다. 핵심은 useMemo와 useCallback을 전부 지운다는 뜻이 아니라, 습관적 사용을 줄이고 판단 기준을 바꾼다는 데 있습니다. 특히 새 코드와 기존 코드는 같은 기준으로 보면 안 됩니다. 새 코드는 compiler 우선으로 설계해도 되지만, 기존 코드는 이미 React.memo, custom hook, Effect dependency와 얽혀 있는 경우가 많아서 테스트 없이 한꺼번에 지우면 오히려 동작이나 성능 판단이 더 어려워질 수 있습니다.
먼저 평문으로 작성하고 필요할 때만 남기기
type Todo = {
id: string;
title: string;
done: boolean;
};
function TodoList({ todos, tab }: {
todos: Todo[];
tab: 'all' | 'done';
}) {
const visibleTodos = todos.filter((todo) => tab === 'all' || todo.done);
return (<ul>
{visibleTodos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
</ul>);
}
이 예시는 React Compiler 이후의 기본 사고방식을 보여줍니다. 렌더 과정에서 만들어지는 파생값이라고 해서 바로 useMemo로 감싸지 않고, 우선 읽기 쉬운 코드로 둡니다. 이 흐름은 코드량을 줄이고 의존성 배열을 관리하는 부담도 줄여줍니다. 다만 계산 비용이 매우 크거나 이 값이 다른 Hook의 의존성으로 직접 쓰이거나, 기존 코드에서 이미 세밀한 최적화 체인이 만들어져 있다면 그때는 다시 수동 memoization을 검토해야 합니다.
컴파일이 실제로 적용되는지 먼저 확인하기
function ProductCard({ product }: {
product: {
name: string;
};
}) {
"use memo";
return <div>{product.name}</div>;
}
여기서 한 가지 빠뜨리기 쉬운 전제가 있습니다. React Compiler 1.0은 build 단계에서 동작하므로, 실제로 해당 컴포넌트나 Hook이 컴파일 대상이 되어야 useMemo와 useCallback 감소 효과도 기대할 수 있습니다. 기본 compilationMode인 infer에서는 PascalCase 컴포넌트와 use로 시작하는 Hook을 기준으로 추론하고, annotation 모드에서는 “use memo” 지시어가 있는 함수만 최적화합니다. 그래서 도입 후 체감이 없다면 무조건 Hook을 더 지울지 고민하기보다, 먼저 이 함수가 정말 React Compiler 1.0의 적용 대상인지부터 확인하는 편이 정확합니다.
useMemo는 어디까지 줄일 수 있을까
실무에서 가장 많이 줄어드는 것은 렌더 중 파생값을 만들기 위해 습관처럼 붙여둔 useMemo입니다. 필터링, 정렬, map 결과 옵션 객체 생성처럼 비교적 흔한 패턴은 이제 먼저 평문으로 두고 시작해볼 수 있습니다. 중요한 점은 useMemo의 원래 역할이 계산 결과 캐시라는 데 있습니다. 따라서 정말로 줄이려면 “이 계산이 비싼가”보다 먼저 “이 계산을 꼭 내가 손으로 캐시해야 하는가”를 다시 물어봐야 합니다. React Compiler가 들어오면 이 질문의 답이 예전보다 훨씬 자주 “아니오” 쪽으로 바뀝니다.
계산값 캐시를 위해 습관적으로 넣은 useMemo
type Product = {
id: string;
name: string;
price: number;
};
function ProductList({ products, keyword }: {
products: Product[];
keyword: string;
}) {
const visibleProducts = products.filter((product) => product.name.includes(keyword));
return (<ul>
{visibleProducts.map((product) => <li key={product.id}>{product.name}</li>)}
</ul>);
}
이런 종류의 useMemo는 삭제 후보로 보기 좋습니다. 이유는 코드의 목적이 계산 자체보다 캐시에 더 끌려가 있었기 때문입니다. React Compiler 1.0 환경에서는 먼저 이렇게 단순하게 써보고, 실제로 병목이 관찰되는 경우에만 다시 수동 useMemo를 고려하는 편이 유지보수에 유리합니다. 특히 단순 파생값을 위해 의존성 배열을 계속 관리하는 구조는 시간이 지날수록 읽기 비용이 커지기 쉽습니다.
| 패턴 | 판단 |
|---|---|
| 필터링, 정렬, map 결과 단순 옵션 객체 | 새 코드에서는 우선 삭제 후보로 보기 좋습니다. |
| Effect 의존성, 기존 복잡한 hotspot 계산 | 바로 지우지 말고 유지한 뒤 테스트로 판단하는 편이 안전합니다. |
useCallback은 어디까지 줄일 수 있을까
useCallback은 보통 자식 컴포넌트에 넘기는 함수 참조를 고정하려고 많이 사용했습니다. 그래서 React.memo와 세트처럼 등장하는 경우가 많았습니다. React Compiler 1.0 이후에는 이 패턴도 상당수 줄일 수 있습니다. 이벤트 핸들러를 넘긴다는 이유만으로 무조건 useCallback을 붙이는 습관은 먼저 내려놓아도 됩니다. 특히 새 코드에서는 함수 참조를 안정화하려는 의도보다, 함수가 실제로 어떤 역할을 하는지 읽히는 편이 더 중요해졌습니다.
이벤트 핸들러를 넘긴다는 이유만으로 쓰던 useCallback
import { useState } from 'react';
function Row({ id, onSelect }: {
id: string;
onSelect: (id: string) => void;
}) {
return <button onClick={() => onSelect(id)}>선택</button>;
}
function ListPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
function handleSelect(id: string) {
setSelectedId(id);
}
return (<div>
<Row id="a1" onSelect={handleSelect}/>
<p>selected: {selectedId}</p>
</div>);
}
이 예시처럼 단순 상태 업데이트를 자식에게 넘기는 정도라면 useCallback을 먼저 제거 후보로 볼 수 있습니다. 예전에는 함수 참조가 바뀌면 자식 렌더링에 영향을 줄 수 있다는 이유로 방어적으로 useCallback을 넣는 경우가 많았지만, React Compiler 환경에서는 이런 패턴을 손으로 계속 관리할 필요가 줄어듭니다. 또한 자식 컴포넌트를 React.memo로 감싼 이유가 함수 참조 안정화 하나뿐이었다면, React Compiler 1.0 도입 후에는 useCallback과 React.memo를 함께 재검토할 여지도 생깁니다. 결과적으로 코드가 더 짧아지고, 의존성 배열 때문에 발생하던 실수도 함께 줄어들 수 있습니다.
여전히 남겨야 하는 경우

여기서부터가 실무 판단의 핵심입니다. React Compiler 1.0이 자동 memoization을 해준다고 해서 모든 useMemo와 useCallback이 사라지지는 않습니다. 가장 대표적인 예외는 Effect dependency를 안정적으로 유지해야 하는 경우입니다. 값이 의미 있게 바뀌지 않았는데도 객체나 함수 참조가 계속 달라져 Effect가 반복 실행되는 상황이라면, 수동 memoization이 여전히 명확한 해결책이 될 수 있습니다. 또한 기존 코드베이스에서는 이미 잘 동작하는 memoization을 바로 걷어내기보다, 실제 화면 반응과 렌더 횟수, 병목 구간을 확인하면서 조금씩 줄이는 접근이 더 안전합니다.
import { useEffect, useMemo } from 'react';
import { createConnection } from './chat';
function ChatRoom({ roomId }: {
roomId: string;
}) {
const options = useMemo(() => ({ roomId, serverUrl: 'https://api.example.com' }), [roomId]);
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
return <div>채팅방: {roomId}</div>;
}
createConnection은 프로젝트의 채팅 연결 함수이며 Chart는 별도 차트 컴포넌트입니다. 해당 모듈은 프로젝트 구현에 맞게 준비해야 합니다. useMemo는 성능 최적화 도구이므로 연결의 정확성을 캐시 유지에만 의존해서는 안 됩니다. 객체가 Effect 안에서만 필요하다면 Effect 내부에서 생성하고 roomId를 의존성으로 두는 방법도 먼저 검토하세요. 이 경우에는 단순히 “코드 수를 줄이자”보다 “Effect가 언제 다시 실행되어야 하는가”가 더 중요합니다. 그래서 React Compiler를 쓰더라도 Effect dependency 제어 목적의 useMemo와 useCallback은 남겨둘 가치가 충분합니다. 즉, 자동 최적화가 들어와도 제어가 필요한 경계면은 여전히 존재합니다.
기존 코드베이스는 삭제보다 검증이 먼저
import { useMemo } from 'react';
function Dashboard({ reports, tab }: {
reports: string[];
tab: string;
}) {
const visibleReports = useMemo(() => reports.filter((report) => report.includes(tab)), [reports, tab]);
return <section>{visibleReports.length}</section>;
}
이미 운영 중인 코드에서 useMemo와 useCallback을 한 번에 걷어내면, 기대와 다르게 성능이 아니라 판단 가능성이 떨어질 수 있습니다. 무엇이 실제로 병목이었는지, 어떤 조합이 의미 있던 최적화였는지 흐려지기 때문입니다. 그래서 기존 코드에서는 삭제보다 분류가 먼저입니다. 단순 파생값, 단순 이벤트 핸들러, Effect dependency, custom hook API, 무거운 계산 구간을 나눠서 보고 가장 안전한 곳부터 줄여가는 편이 실무적으로 맞습니다.
문제가 생기면 일시적으로 범위를 좁혀서 확인하기
import { Chart } from './Chart';
function LegacyChart({ data }: {
data: number[];
}) {
"use no memo";
return <Chart data={data}/>;
}
React Compiler 1.0 도입 후 특정 화면에서만 동작이 어색하다면, 원인을 막연히 추측하기보다 범위를 좁혀서 확인하는 편이 좋습니다. React 공식 문서에는 특정 함수를 일시적으로 최적화 대상에서 제외하는 “use no memo” 지시어가 안내되어 있습니다. 이 방식은 영구 해결책이라기보다 디버깅용 escape hatch에 가깝지만, 문제가 React Compiler 1.0 자체인지 원래 코드 구조인지 분리해서 보는 데는 도움이 됩니다.
정리
React Compiler 1.0을 도입한 뒤 useMemo와 useCallback을 얼마나 줄일 수 있느냐는 숫자 하나로 답하기 어렵습니다. 대신 기준은 분명해졌습니다. 새 코드에서는 먼저 평문으로 작성하고 compiler에 맡기며, 수동 memoization은 정밀 제어가 필요할 때만 씁니다. 반대로 기존 코드에서는 이미 들어가 있는 useMemo와 useCallback을 무조건 지우지 말고, 삭제 후보와 유지 후보를 나눈 뒤 테스트로 확인해야 합니다. 정리하면 가장 많이 줄어드는 것은 습관적으로 넣어둔 파생값 캐시와 단순 이벤트 핸들러 memoization이고, 마지막까지 남는 것은 Effect dependency 제어와 기존 복잡한 최적화 구간입니다.
많이 받는 질문
Q. React Compiler를 켜면 useMemo와 useCallback을 전부 삭제해도 되나요?
그렇게 접근하면 위험합니다. 새 코드는 compiler 우선으로 단순하게 작성해도 되지만, 기존 코드는 이미 최적화 구조가 얽혀 있을 수 있어서 삭제 전에 테스트가 필요합니다.
Q. useCallback과 함께 쓰던 React.memo도 대부분 지워도 되나요?
일괄 삭제하기보다 해당 컴포넌트에 Compiler가 적용되는지 확인한 뒤 판단하세요. React.memo는 컴포넌트의 불필요한 재렌더링을 줄이는 도구입니다. 기존 코드에서는 제거 전후 화면 동작과 성능을 비교하고, 사용자 정의 props 비교 함수가 있다면 그 역할도 함께 확인해야 합니다.
Q. React Compiler 1.0을 켰는데도 useMemo와 useCallback이 별로 줄지 않는 것 같아요.
그럴 수 있습니다. 먼저 build 설정이 제대로 되었는지, 현재 compilationMode가 무엇인지, 컴포넌트와 Hook 이름이 React Compiler가 추론할 수 있는 형태인지 확인하는 편이 좋습니다. 컴파일이 실제로 적용되지 않았다면, 수동 memoization을 줄여도 기대한 효과가 잘 보이지 않을 수 있습니다.
Q. 이 변화의 핵심을 한 문장으로 정리하면 무엇인가요?
이제는 먼저 useMemo와 useCallback을 쓰는 것이 아니라, 먼저 읽기 쉬운 코드로 작성한 뒤 정말 필요한 부분만 수동으로 남기는 방향으로 사고방식을 바꾸는 것입니다.
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
App.test.tsx
import { expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Results } from './App';
test.each([false, true])(
'manual=%s preserves filter behavior across rerenders',
(manual) => {
const { rerender } = render(<Results keyword=" react " manual={manual} />);
expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toEqual([
'React',
]);
rerender(<Results keyword="" manual={manual} />);
expect(screen.getAllByRole('listitem')).toHaveLength(3);
rerender(<Results keyword="missing" manual={manual} />);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
},
);
App.tsx
import { useMemo, useState } from 'react';
const items = ['React', 'TypeScript', 'Vite'];
export function Results({
keyword,
manual = false,
}: {
keyword: string;
manual?: boolean;
}) {
const plain = items.filter((x) =>
x.toLowerCase().includes(keyword.trim().toLowerCase()),
);
const cached = useMemo(
() => items.filter((x) => x.toLowerCase().includes(keyword.trim().toLowerCase())),
[keyword],
);
return (
<ul>
{(manual ? cached : plain).map((x) => (
<li key={x}>{x}</li>
))}
</ul>
);
}
export default function App() {
const [keyword, setKeyword] = useState('');
return (
<main>
<h1>메모이제이션 전후 동작 비교</h1>
<label>
검색
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</label>
<h2>평문</h2>
<Results keyword={keyword} />
<h2>수동 useMemo</h2>
<Results keyword={keyword} manual />
</main>
);
}
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
package.json
{
"name": "react-practice-2205",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"typescript": "7.0.2",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
test-setup.ts
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["*.ts", "*.tsx"]
}
vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'jsdom', setupFiles: ['./test-setup.ts'] },
});
같이 읽으면 좋은 글
- React 처음 배우는 순서: 컴포넌트부터 state, Zustand까지
- React state vs Zustand: 언제 전역 상태가 필요할까
- React useEffect 두 번 실행 문제 해결: Strict Mode에서 중복 호출 확인하기
공식 기준: React Compiler 소개 · useMemo · compilationMode
과정 마무리 실습
상품 목록을 컴포넌트로 나누고 검색 입력과 선택 상태를 추가하세요.
완료 기준: props 전달 방향, 상태 소유 위치, 목록 key, 빈 결과 처리를 설명하고 직접 확인합니다.
이어서 공부할 과정: TanStack Query 첫 글 · Zustand 첫 글 · Next.js 첫 글
시작·완성 예제 파일
ZIP에는 시작본(starter), 완성본(complete), 실행 안내가 들어 있습니다. 압축을 푼 뒤 README의 준비 사항과 실행 순서를 확인하세요.
Compiler 적용과 동작 검증을 따로 확인합니다
React 버전만 올렸다고 이 ZIP에서 Compiler가 자동 활성화되는 것은 아닙니다. 이 실습의 기본 Vite 설정은 Compiler를 사용하지 않습니다. 평문과 useMemo 버전이 동일한 검색 결과를 만드는지만 typecheck·test·build로 확인합니다. 두 계산을 함께 실행하는 비교 UI이므로 속도 벤치마크로 사용하지 마세요.
Compiler를 선택적으로 도입할 때는 공식 설치 문서에서 사용하는 빌드 도구 버전에 맞는 설정을 확인하세요. @vitejs/plugin-react 6 이상은 이전의 inline babel 옵션 대신 reactCompilerPreset과 @rolldown/plugin-babel 조합을 안내합니다. 아래 설정은 선택 적용용이며 이 ZIP의 검증 대상에는 포함하지 않았습니다.
// 선택 설치: npm install -D @vitejs/plugin-react @rolldown/plugin-babel babel-plugin-react-compiler
// 설치 버전을 package-lock.json으로 고정한 뒤 vite.config.ts에 적용
import { defineConfig } from 'vitest/config';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
test: { environment: 'jsdom', setupFiles: ['./test-setup.ts'] },
});
설정 후 다시 테스트·빌드하고 React DevTools의 Memo 표시나 변환 산출물의 메모이제이션 코드를 확인해야 적용 증거가 됩니다. 테스트 통과만으로 컴파일되었다고 말하지 않습니다. 성능은 같은 데이터·기기·프로덕션 빌드 조건에서 별도로 측정합니다. Effect의 올바른 동작을 캐시 유지에 의존시키지 말고, 기존 수동 메모이제이션은 영향 범위와 측정을 확인하며 점진적으로 변경하세요.
아래 ZIP은 이 글의 핵심 동작을 직접 확인하는 독립 실습입니다. 압축을 풀고 README의 순서대로 실행하세요. 실습 코드 ZIP 다운로드
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까 현재 글
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.