옛 리액트 코드 마이그레이션
Class component / Pages Router / Redux-only / fetch-on-mount 같은 옛 스타일 코드를 이 책의 modern 스타일로 옮기는 가이드.
34장으로 이 책의 본문이 모두 마무리됐습니다. 본 부록은 본문이 의도적으로 다루지 않은 한 영역, 옛 리액트 코드를 이 책의 modern 스타일로 옮기는 절차를 한곳에 묶었습니다.
이 책의 본문 1~34장은 처음부터 한 가지 스타일만 가르칩니다. function component + hooks, App Router, RSC + Server Actions, TypeScript first. 책 안에서 옛 스타일 (Class component, componentDidMount, Pages Router, Redux-only, fetch-on-mount, PropTypes)은 거의 등장하지 않습니다. 한 권의 입문서가 두 스타일을 동시에 가르치면 누구도 둘 다 익히지 못한다는 판단입니다.
그렇지만 현실의 코드베이스에는 옛 스타일이 살아 있습니다. 본 부록은 그런 코드와 마주한 독자에게 한 페이지 분량의 지도가 되는 것이 목표입니다. 옛 → modern의 한 줄 한 줄 매핑과 대규모 코드베이스에서 깨지지 않게 옮기는 절차 두 가지를 정리하겠습니다.
옛 리액트 사용자의 진입점 — 본 부록은 옛 코드를 손에 들고 책의 어디부터 읽어야 할지 가늠하실 때 가장 먼저 들러 주실 곳입니다. 본문 매핑 표에서 본인 코드가 어느 챕터와 만나는지 확인하시면, 이 책의 어느 장부터 펼치는 게 좋은지 쉽게 파악할 수 있습니다.
옛 → 이 책 매핑 표 #
옛 스타일에서 자주 보는 패턴이 이 책의 어느 챕터에서 다뤄지는지 한 페이지에 정리해 두겠습니다.
| 옛 스타일 | 이 책 챕터 |
|---|---|
class Foo extends React.Component | 4장 (컴포넌트와 props) + 7장 (state) |
this.state / this.setState | 7장 (useState) |
componentDidMount / componentDidUpdate / componentWillUnmount | 11장 (useEffect) |
componentDidCatch | 11장 (ErrorBoundary 절) |
| HOC / render props | 12장 (useContext) + 13장 (커스텀 훅) |
pages/foo.tsx (Pages Router) | 23장 (App Router) |
getServerSideProps / getStaticProps | 25장 (RSC 데이터 페칭) |
_app.tsx / _document.tsx | 23장 (app/layout.tsx) |
next/router | 23장 (next/navigation) |
useEffect(() => { fetch(...) }) | 25장 (RSC 안 직접 fetch) |
| Redux store / reducer / saga | 24장 (RSC) + 27장 (Server Actions) + 12장 (Context) |
PropTypes.string.isRequired | 16~17장 (TypeScript) |
| styled-components / emotion | 책 본문에서 단정하지 않음 (CSS Modules / Tailwind를 쓰는 경우) |
fetch 응답을 그대로 신뢰 | 21장 (fetch와 API 응답 타이핑) |
이 매핑이 본 부록의 뼈대입니다. 아래 각 절에서 차례로 풀어 가겠습니다.
Class component → function + hooks #
가장 자주 만나는 변환입니다. 한 컴포넌트 단위로 옮길 수 있어 점진적 마이그레이션의 시작점으로 가장 좋습니다.
this.state + this.setState → useState
#
class Counter extends React.Component {
state = { count: 0 };
increment = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<button onClick={this.increment}>
클릭: {this.state.count}
</button>
);
}
}function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
클릭: {count}
</button>
);
}핵심 차이는 setState({ count: this.state.count + 1 })의 함정입니다. 옛 코드는 this.state.count의 stale 값을 잡을 위험이 있어 this.setState((prev) => ...) 콜백 형식이 권장됐습니다. useState에서는 setCount((c) => c + 1) 함수 업데이트가 동일한 역할을 합니다. 7장에서 다룬 패턴입니다.
componentDidMount → useEffect(() => {...}, [])
#
componentDidMount() {
fetchUser(this.props.userId).then((u) => this.setState({ user: u }));
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
fetchUser(this.props.userId).then((u) => this.setState({ user: u }));
}
}
componentWillUnmount() {
this.cancelled = true;
}useEffect(() => {
let cancelled = false;
fetchUser(userId).then((u) => {
if (!cancelled) setUser(u);
});
return () => { cancelled = true; };
}, [userId]);세 라이프사이클이 한 hook으로 모입니다. 의존성 배열에 userId를 넣으면 mount / update가 한 흐름이 되고, return 한 cleanup 함수가 unmount + 의존성 변경 시점 모두 호출됩니다. 11장 (useEffect)에서 다룬 모델 그대로입니다.
다만 이 책의 강조점은 useEffect 안 fetch를 피하라는 쪽이라는 점도 같이 기억해 주세요. 위 변환은 1:1 매핑일 뿐, 더 좋은 곳은 RSC의 server 컴포넌트 안 직접 fetch입니다. 본 부록 §“fetch-on-mount” 절에서 다시 다루겠습니다.
componentDidCatch → ErrorBoundary 컴포넌트
#
componentDidCatch는 흥미롭게도 함수형으로 변환할 수 없는 유일한 라이프사이클입니다. React가 hook으로 동등물을 제공하지 않습니다. 따라서 ErrorBoundary만큼은 여전히 class component로 적습니다.
class ErrorBoundary extends React.Component<
{ fallback: React.ReactNode; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('Caught by boundary:', error, info);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}이 책의 modern 코드 안에서도 ErrorBoundary 한 컴포넌트만큼은 class로 한 번 적어 두고 끝납니다. App Router에서는 app/error.tsx가 라우트 단위 ErrorBoundary 역할을 자동으로 해 주니, 직접 적을 일이 점점 줄어드는 흐름입니다.
HOC / render props → custom hook #
옛 코드의 두 패턴이 modern의 custom hook 한 가지로 수렴합니다.
const withUser = (Component) => (props) => {
const [user, setUser] = useState(null);
useEffect(() => { fetchCurrentUser().then(setUser); }, []);
return <Component {...props} user={user} />;
};
const Profile = withUser(ProfileBase);function UserProvider({ render }) {
const [user, setUser] = useState(null);
useEffect(() => { fetchCurrentUser().then(setUser); }, []);
return render(user);
}
<UserProvider render={(user) => <Profile user={user} />} />function useCurrentUser() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetchCurrentUser().then(setUser); }, []);
return user;
}
function Profile() {
const user = useCurrentUser();
// ...
}14장 (custom hook)에서 다룬 정확한 패턴입니다. HOC의 wrapper hell도, render props의 callback 중첩도 사라집니다.
Pages Router → App Router #
옛 코드베이스 마이그레이션의 가장 큰 영역이 보통 여기입니다. 다행히 Next.js는 /app과 /pages의 공존을 공식 지원해, 한 번에 다 옮기지 않아도 됩니다.
디렉토리 매핑 #
| Pages Router | App Router |
|---|---|
pages/index.tsx | app/page.tsx |
pages/todos/[id].tsx | app/todos/[id]/page.tsx |
pages/_app.tsx | app/layout.tsx (root) |
pages/_document.tsx | app/layout.tsx 안의 <html> / <body> |
pages/api/foo.ts | app/api/foo/route.ts |
pages/_error.tsx / pages/404.tsx | app/error.tsx / app/not-found.tsx |
가장 큰 정신적 도약은 파일 한 개 = 라우트 한 개에서 폴더 한 개 = 라우트 한 개 + 특수 파일들로 바뀌는 부분입니다. page.tsx / layout.tsx / loading.tsx / error.tsx / not-found.tsx가 한 폴더 안에 함께 사는 모델입니다. 23장에서 다룬 그림 그대로입니다.
getServerSideProps / getStaticProps → RSC 안 직접 fetch
#
export async function getServerSideProps(context) {
const { id } = context.params;
const todo = await db.todos.findById(id);
return { props: { todo } };
}
export default function TodoPage({ todo }) {
return <article>{todo.title}</article>;
}export default async function TodoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const todo = await db.todos.findById(id);
return <article>{todo.title}</article>;
}페이지 컴포넌트 자체가 async가 되고, 데이터 페칭 코드가 컴포넌트 안으로 들어옵니다. 별도 export 함수가 사라지고, props의 직렬화 경계도 사라집니다. 함수가 두 개에서 한 개로 줄었다가 변환의 가장 직관적인 묘사입니다. 25장에서 다룬 모델입니다.
getStaticProps의 정적 빌드는 App Router에서 fetch의 cache: 'force-cache' 또는 export const revalidate = N로 표현됩니다. ISR (Incremental Static Regeneration)도 같은 옵션의 변주입니다.
_app.tsx / _document.tsx → app/layout.tsx
#
전역 layout이 단순해집니다.
function MyApp({ Component, pageProps }) {
return (
<Provider store={store}>
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
</Provider>
);
}export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const theme = (await cookies()).get('theme')?.value ?? 'light';
return (
<html lang="ko" data-theme={theme}>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}_app.tsx가 Component와 pageProps를 받아 wrapping 하던 식의 마법이 사라지고, 그냥 React 컴포넌트 한 개가 됩니다. server 컴포넌트라 첫 응답부터 테마 / 세션 등을 알고 렌더합니다.
_document.tsx의 <Html> / <Head> / <Main> / <NextScript>도 사라집니다. <html> / <body>가 root layout 안에서 평범하게 적힙니다. <head> 안 메타데이터는 metadata export 또는 generateMetadata 함수로 옮깁니다.
next/router → next/navigation
#
import { useRouter } from 'next/router';
function Foo() {
const router = useRouter();
router.push('/login');
const { id } = router.query;
}import { useRouter, useParams, useSearchParams } from 'next/navigation';
function Foo() {
const router = useRouter();
router.push('/login');
const params = useParams();
const searchParams = useSearchParams();
}가장 자주 만나는 함정이 router.query가 사라지고 두 hook (useParams / useSearchParams)으로 분리됐다는 점입니다. 옛 코드는 동적 라우트 파라미터와 query string을 한 객체 안에 섞어 받았지만, App Router는 두 종류를 명시적으로 구분합니다.
점진적 공존 — /app과 /pages
#
Next.js는 두 라우터의 공존을 공식 지원합니다. 같은 path의 충돌이 없다면, pages/old-page.tsx와 app/new-page/page.tsx가 한 빌드 안에 함께 살 수 있습니다.
다만 함정이 둘 있습니다.
- client 측 navigation 비호환 —
/pages의next/link로 클릭하면 SPA navigation으로/app라우트로 못 갑니다. 전체 페이지 reload가 일어납니다. 옮기는 중에는 이를 감수합니다. - API Route의 행동 차이 —
/pages/api/foo.ts와/app/api/foo/route.ts가 다른 모델입니다. 한꺼번에 옮길 때는 한 API만 옮기지 말고 한 도메인 단위 (예:/api/auth/*전체)로 옮기는 게 안전합니다.
옮기는 순서는 보통 leaf 페이지부터 layout으로, 읽기 전용부터 변경 라우트로의 두 갈래로 진행합니다.
Redux-only → RSC + Server Actions + 소형 client store #
옛 코드베이스의 Redux 사용은 보통 모든 상태를 store에 넣는 식이었습니다. 사용자 정보, 서버 데이터, UI 상태가 한 store 안에 섞여 있습니다.
modern의 출발점은 다음 질문입니다.
이 상태가 server 상태인지, client 상태인지, 그리고 client 상태라면 정말 전역인지 — 이 질문을 먼저 던집니다.
이 세 갈래로 분리하면 Redux의 쓰임이 빠르게 줄어듭니다.
server 상태는 RSC + Server Actions로 #
옛 코드의 절반 이상이 보통 server 상태입니다. Todo 목록, 사용자 프로필, 댓글, 게시글 등. 이건 store에 둘 일이 아니라 server가 다시 가져오면 되는 데이터입니다.
// store/todos.ts
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], loading: false },
reducers: {
setItems: (state, action) => { state.items = action.payload; },
},
});
// 어딘가의 컴포넌트
useEffect(() => {
dispatch(fetchTodos());
}, []);
const todos = useSelector((s) => s.todos.items);// app/todos/page.tsx
export default async function TodosPage() {
const items = await db.todos.findAll();
return <TodoList items={items} />;
}store, slice, action, selector, useEffect가 모두 사라집니다. server가 진실의 출처라는 24장의 모델이 코드 양을 8~10배 줄입니다.
진짜 client 상태는 작은 도구로 #
다음 정도가 진짜 client 상태입니다.
- 다크 모드 (단, 이 책은 Cookie로 SSR 친화)
- 모달 / 드롭다운 열림 닫힘
- 다단계 폼의 현재 단계
- 사이드바 접힘 / 펼침
이런 상태는 두 가지 곳에 넣습니다.
- 컴포넌트 가까이 (
useState) — 가장 흔한 출발점이 됩니다. 한 화면 안에서만 쓰면 여기에 두면 됩니다. - Context 한 개 (12장) — 여러 컴포넌트가 공유해야 하면 작은 Context.
Zustand / Jotai 같은 작은 store 도구는 Context가 부족할 때 (특히 잦은 업데이트로 re-render가 부담될 때)에 어울립니다. Redux Toolkit의 무게는 거의 항상 과합니다.
Redux를 유지해야 하는 경계 케이스 #
다음의 경우 Redux가 여전히 가장 자연스럽습니다.
- 복잡한 undo / redo가 필수 (도형 편집기, 코드 에디터)
- 시간 여행 디버깅이 가치 있는 정도의 복잡도
- 수십 개의 비동기 action이 명시적인 state machine으로 표현되어야 하는 도메인 (saga가 필요한 경우)
위 세 가지 중 어느 것도 해당하지 않으면, modern stack으로 옮기는 동안 Redux의 95%가 자연스럽게 사라집니다. 남는 5%가 정말 Redux가 필요한 영역입니다.
마이그레이션 절차 #
대규모 코드베이스의 일반적 순서를 적어 두겠습니다.
1. server 상태 식별 (Todo / User / Comment 등) 후 RSC + Server Action으로 이전
2. 옅은 client 상태 식별 후 useState로 컴포넌트 가까이
3. 진짜 전역 client 상태만 Context (또는 Zustand)로 분리
4. 남은 Redux store의 크기 점검 — 작아져 있어야 함
5. 작아진 store도 필요 없다면 제거, 필요하다면 그대로 유지한꺼번에 옮기지 않습니다. 도메인 한 개씩 (예: “todos 슬라이스만 RSC로”) 끊어 갑니다.
fetch-on-mount → RSC data fetching #
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error />;
return <article>{user.name}</article>;
}import { Suspense } from 'react';
export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<Spinner />}>
<Profile params={params} />
</Suspense>
);
}
async function Profile({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await db.users.findById(id);
return <article>{user.name}</article>;
}옛 코드의 useState 3개, useEffect, fetch, JSON 파싱, error / loading 상태가 한 줄의 await db.users.findById(id)로 줄어듭니다. error는 app/error.tsx로, loading은 <Suspense fallback> 또는 app/loading.tsx로 위임됩니다.
여전히 client에서 fetch가 필요한 경우 #
100%가 server로 옮겨가지 않습니다. 다음의 경우 client fetch가 자연스럽습니다.
- 실시간 — SSE / WebSocket으로 변하는 데이터.
- mutation 후 즉시 갱신 — 다만 이 책은 Server Action +
revalidatePath로 대부분 해결되니, 이쪽을 먼저 시도해 봅니다. - 사용자 동작 (스크롤 / 검색)에 따른 점진 로드 — TanStack Query 같은 도구의 영역.
이 책의 본문은 TanStack Query를 다루지 않았습니다. RSC + Server Actions가 90% 이상의 경우를 덮기 때문입니다. 남는 10%의 client fetch가 진짜 필요한 곳에서 TanStack Query의 가치가 살아납니다.
PropTypes → TypeScript #
옛 코드의 PropTypes는 런타임 검증이고, TypeScript는 컴파일 타임 검증입니다. 두 도구의 역할이 다르지만, 실제로 PropTypes가 막아 주는 버그의 거의 모든 것을 TypeScript가 더 일찍 잡습니다.
import PropTypes from 'prop-types';
function Avatar({ src, size, alt }) {
return <img src={src} width={size} height={size} alt={alt} />;
}
Avatar.propTypes = {
src: PropTypes.string.isRequired,
size: PropTypes.number,
alt: PropTypes.string,
};
Avatar.defaultProps = { size: 40 };type AvatarProps = {
src: string;
size?: number;
alt?: string;
};
export function Avatar({ src, size = 40, alt = '' }: AvatarProps) {
return <img src={src} width={size} height={size} alt={alt} />;
}기본값이 함수 매개변수의 분할 할당으로 옮겨갑니다. defaultProps는 React 19부터 함수형에서 deprecated되어, 더 이상 권장되지 않습니다 (28장에서 다룬 변경 사항).
codemod의 한계 #
react-codemod의 PropTypes → TypeScript 변환은 시작점은 잡아 줍니다. 다만 다음의 경우 손이 가야 합니다.
PropTypes.oneOfType같은 union은 TS의 union으로 옮겨지긴 하지만 의도가 명확하지 않게 자동 변환됩니다.- PropTypes가 실제와 달랐던 부분 — 코드는 PropTypes에
string으로 적혀 있는데 실제로는null도 흐르고 있던 경우가 자주 있습니다. 자동 변환 후 TS가 빨갛게 그어 주니, 그 빨간 줄을 따라 한 번씩 다시 보는 것이 변환의 진짜 가치입니다.
PropTypes 변환은 보통 TypeScript 도입의 마지막 단계입니다. 먼저 새 코드부터 TS로 쓰고, 기존 PropTypes는 한동안 같이 살게 두는 게 안전합니다.
CSS-in-JS — styled-components / emotion #
이 책의 본문은 CSS 도구에 단정을 두지 않았습니다. 다만 styled-components와 emotion은 RSC 호환성에서 마찰이 있다는 점만 짚어 두겠습니다.
두 라이브러리 모두 런타임 스타일 생성에 의존하니, server component 안에서 직접 쓰기가 까다롭습니다. Next.js의 App Router 위에서 쓰려면 'use client' 경계 안으로 한 번 가둬야 하고, 이 자체가 RSC의 가치를 줄이는 방향이 됩니다.
modern의 일반적인 출발점은 CSS Modules 또는 Tailwind CSS입니다. 둘 다 빌드 타임에 클래스명을 결정하니 server / client 어디서든 같은 모양으로 동작합니다.
마이그레이션의 일반적인 흐름은 다음 두 갈래입니다.
- 점진적 — styled-components가 살아 있는 컴포넌트는 client component로 유지, 새 코드 / RSC 인 컴포넌트만 CSS Modules / Tailwind로.
- 한 번에 — 자동 변환 도구 (
twin.macro등) 또는 손으로 옮김. 컴포넌트 개수가 많지 않을 때.
선택은 코드 규모와 우선순위에 달려 있습니다. 이 책의 본문이 단정을 두지 않은 까닭도 동일합니다.
자주 만나는 함정 #
마이그레이션 도중 자주 부딪히는 함정 몇 가지를 정리해 두겠습니다.
'use client'의 전염
#
'use client' 한 파일을 만들면, 그 안에서 import 하는 모든 컴포넌트가 client로 따라 들어옵니다. server 컴포넌트의 가치 (DB 직접 쿼리, 시크릿 안전)가 사라지지 않게 client 경계를 작게 유지 합니다. 24장에서 다룬 모델입니다.
useEffect 안 fetch의 유혹
#
옛 코드를 가져온 직후엔 그냥 useEffect + fetch를 그대로 두고 싶어집니다. 동작은 합니다. 다만 RSC의 가치를 받지 못합니다. 한 번에 다 못 옮기면 leaf부터 옮기는 것이 보통 안전한 순서입니다.
Redux의 잔재로 남은 Context #
Redux를 걷어내면서 Context로 옮긴 부분이 그대로 “전역 store"처럼 부풀어 오르는 경우가 있습니다. Context는 12장에서 다룬 대로 작은 단위로 분리 하는 게 옳습니다. 하나의 거대한 AppContext는 Redux의 작은 복제판이 됩니다.
Pages Router의 _app.tsx 안 Provider가 둘 다 깜빡임
#
/app과 /pages 공존 시기에 ThemeProvider / Redux Provider 등이 두 라우터에서 각각 동작합니다. 라우트가 바뀔 때 깜빡임이나 state 초기화가 일어나면 보통 이 원인입니다. 한 도메인 안에서는 한 라우터로 통일하는 것이 가장 안전합니다.
TypeScript any의 누적 #
옛 코드를 옮기는 동안 as any를 임시로 박아 두면, 그 임시가 영원이 됩니다. as any마다 TODO 코멘트 + GitHub issue를 같이 만들어 두는 습관을 권장합니다. 마이그레이션 끝에 grep으로 한 번에 청소합니다.
대규모 코드베이스용 마이그레이션 절차 #
수백 컴포넌트 / 수십 라우트의 코드베이스를 이 책의 스타일로 옮기는 작업은 며칠로 끝나지 않습니다. 분기 단위 또는 반기 단위의 계획이 됩니다. 다음 순서가 가장 사고가 적은 흐름입니다.
1. TypeScript 도입 — 안전망. 가장 먼저, 가장 천천히.
2. Class → function + hooks — 한 컴포넌트씩. 회귀 위험이 가장 적은 변환.
3. App Router 일부 도입 — /(marketing) 같이 격리된 영역부터.
4. 점진적 RSC 전환 — 새 라우트는 모두 RSC, 옛 라우트는 그대로 유지.
5. Redux 의존성 축소 — server 상태부터 RSC로 이전.
6. 마무리 — Pages Router 제거 — 마지막. 모든 라우트가 /app으로 옮겨진 다음.각 단계마다 자세한 권장 사항을 적어 두겠습니다.
1단계 — TypeScript 도입 #
가장 먼저, 가장 천천히. 한 번에 strict로 가지 않습니다. allowJs: true + strict: false로 시작해 .ts / .tsx 파일을 점진적으로 늘립니다. 16장 (TypeScript 셋업)에서 다룬 출발점입니다.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "ES2022"],
"allowJs": true,
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
"strict": false,
"noUncheckedIndexedAccess": false,
"skipLibCheck": true
}
}strict: true로의 점진 전환은 컴파일러 옵션을 한 개씩 켜는 식으로 진행합니다. noImplicitAny만 먼저, 그다음 strictNullChecks, 그다음 나머지의 순서가 일반적입니다.
2단계 — Class → function + hooks #
한 컴포넌트 단위라 가장 안전합니다. 위 §“Class component → function + hooks” 절의 매핑을 그대로 적용합니다. 한 PR에 한 ~ 두 컴포넌트씩 점진 변환합니다. 회귀 테스트가 작동한다면 자동 변환 도구도 고려할 수 있지만, 보통은 손으로 옮기는 편이 작은 정리까지 함께 챙겨집니다.
3단계 — App Router 일부 도입 #
전체 페이지를 옮기지 않고 격리된 영역 하나 (예: 마케팅 / 로그인 / 새로운 기능)만 /app으로 만듭니다. 두 라우터의 공존 모드에서 안정성을 확인합니다.
4단계 — 점진적 RSC 전환 #
새로 만드는 페이지는 모두 RSC. 기존 페이지는 그대로 유지. RSC의 가치 (server 상 데이터 페칭, 시크릿 안전, 첫 페인트 빠름)가 한 페이지에서 드러나면, 다른 페이지의 마이그레이션 동기가 자연스럽게 생깁니다.
5단계 — Redux 의존성 축소 #
§“Redux-only” 절의 절차를 그대로 따릅니다. 도메인 한 개씩, server 상태부터.
6단계 — Pages Router 제거 #
마지막. /pages 안에 남은 라우트가 0이 되면 폴더를 삭제하고, next.config.ts에서 임시로 켜 뒀던 호환성 플래그들을 정리합니다.
마이그레이션 도중 사이트가 안 깨지는 절차 #
각 단계 안에서 사이트가 살아 있는 채로 변환하려면 몇 가지 습관이 필요합니다.
- PR 단위 크기 작게 — 한 컴포넌트 / 한 라우트 단위. revert가 쉬워야 합니다.
- feature flag로 노출 제어 — 새 라우트는 일부 사용자에게만 먼저 노출 (PostHog flag, 환경변수). 33장에서 다룬 패턴.
- 두 모델의 데이터 호환성 확인 — Redux도 RSC도 같은 백엔드 API를 보고 있다면 데이터 경로의 일관성을 PR마다 확인.
- E2E 한 시나리오 유지 — 30장의 Playwright 시나리오를 마이그레이션 전후에 동일하게 도리는지 확인. 사용자 동선의 회귀를 가장 일찍 잡는 안전망입니다.
직접 해보기 #
본인이 손에 들고 있는 옛 리액트 코드 (있다면) 또는 한 작은 샘플 프로젝트를 골라 다음을 한 번씩 해 보세요.
- 한 Class component 골라 function + hooks로 변환.
componentDidMount/componentDidUpdate/componentWillUnmount가 있는 컴포넌트를 골라야 변환의 묘미가 살아납니다. 11장의 useEffect 한 번 더 확인. - 한 페이지 골라 Pages Router → App Router.
getServerSideProps가 있는 페이지를 골라 RSC로 옮겨 보세요. props 직렬화가 사라지고 함수가 한 개로 줄어드는 것을 확인합니다. - 한 Redux slice 골라 RSC + Server Action으로. server 상태인 slice를 골라 store를 비우고 RSC 안 직접 fetch + Server Action으로 옮깁니다. 옛 코드의 줄 수와 modern 코드의 줄 수를 한 번 세어 보세요.
- PropTypes 한 파일 → TypeScript. codemod 자동 변환 후 빨간 줄이 그어진 부분만 한 번 손으로 다시 확인합니다. “PropTypes가 실제와 달랐던 부분"이 보일 수 있습니다.
네 가지를 다 끝내고 나면, 본인 코드베이스 전체를 옮기는 그림이 머릿속에 잡힙니다.
연습문제 #
- 라이프사이클 매핑. 다음 옛 코드를 hook 모델로 한 줄씩 옮겨 보세요. (a)
componentDidMount에서 fetch + state set, (b)componentDidUpdate에서 prop 비교 후 재 fetch, (c)componentWillUnmount에서 subscribe 해제. 결과 코드의 useEffect 안 의존성 배열과 cleanup 함수가 어떻게 구성되는지 답합니다. - server 상태 vs client 상태. 다음 다섯 가지가 server 상태인지 client 상태인지, client 라면 진짜 전역인지를 분류하세요. (a) 로그인된 사용자 정보, (b) 다크 모드, (c) 현재 열린 모달의 ID, (d) 장바구니 항목, (e) 화면 폭 (반응형 분기용). 각각이 이 책에서 어느 도구가 맡는지 한 줄씩 적습니다.
- 마이그레이션 순서 짜기. 다음 항목들을 가상의 코드베이스에서 어떤 순서로 옮길지 답하세요. (a) Class component 200개, (b) Pages Router 50 라우트, (c) Redux store 30 slice, (d) PropTypes 100 파일, (e) styled-components 800 컴포넌트. 본 부록의 6단계 절차를 참고하되 본인 우선순위 (서비스 안정성 vs 신속한 modern 이전)도 함께 적습니다.
한 줄 요약: 옛 리액트 코드는 한 줄 한 줄의 매핑 (Class → function + hooks, Pages → App, Redux server 상태 → RSC + Server Action, fetch-on-mount → RSC fetch, PropTypes → TS)으로 이 책의 modern 스타일과 1:1 연결되고, 대규모 코드베이스의 변환은 TypeScript 도입 → function 화 → App Router 일부 도입 → 점진적 RSC → Redux 축소 → Pages Router 제거의 6단계가 사고가 가장 적은 흐름이다. 한 PR 단위 크기를 작게, feature flag로 노출을 제어하고, E2E 한 시나리오로 회귀를 잡는 습관이 사이트가 살아 있는 채로 옮기는 안전망이다.
책의 마무리 #
본 부록을 끝으로 《리액트》의 본문 + 부록이 모두 마무리됩니다.
이 책이 약속한 것을 다시 한 번 정리하겠습니다. 2026 시점의 리액트 표준(function + hooks, App Router, RSC + Server Actions, TypeScript first)을 처음부터 한 스타일로 가르치는 것, 그리고 입문에서 풀스택까지 단절 없는 곡선을 한 권으로 잇는 것. 1~34장 + 본 부록을 끝낸 분은 이 두 약속이 목표 지점에 도달한 곳에 서 있습니다.
이 책 안에서 다루지 않은 영역(React Native, Remix, TanStack Start, 디자인 시스템, 애니메이션, WebGL)은 다른 책에서 다룹니다. 이 책이 그 영역으로 가는 출발점이 됐다면 이 책의 역할은 충분히 다한 것입니다. 즐거운 코딩 부탁드립니다.