React 19 Server Components 도입기 — 레거시 SPA를 점진적으로 마이그레이션하기
3년 된 SPA를 한 번에 갈아엎자는 건 자살행위다. 우리 팀은 React 19 Server Components를 6개월에 걸쳐 점진적으로 도입했다. 지난 회의에서 한 CTO의 말이 기억난다:
"번들 사이즈 50% 감소? 초기 로딩 3배 빨라짐? 이게 진짜냐?"
진짜였다. 이 경험을 공유한다.
문제: 레거시 SPA의 한계
우리의 음성 채팅 웹앱은:
- Next.js 12 + React 17
- 모든 페이지가 client-side 렌더링
- 초기 번들 크기 2.4MB
- 초기 로딩 시간 4.2초
- SEO 매우 나쁨
문제는 이것이었다:
1. 모든 데이터 페칭 로직이 useEffect에 있음 → waterfall requests
2. 모든 npm 패키지가 클라이언트에 번들됨 → 무거움
3. 보안: API 키가 클라이언트 코드에 노출
4. SEO: 동적 메타태그가 작동 안 함
Step 1: Next.js 14 업그레이드 (App Router 도입)
먼저 Pages Router에서 App Router로 옮겼다. 이 과정은 사실 Server Components 도입의 준비였다.
이전 구조 (Pages Router):
// pages/calls/[id].tsx
export default function CallPage({ call }: Props) {
const [messages, setMessages] = useState([]);
useEffect(() => {
fetch(`/api/calls/${id}/messages`)
.then(r => r.json())
.then(setMessages);
}, [id]);
return (
<div>
{messages.map(m => <Message key={m.id} msg={m} />)}
</div>
);
}
새로운 구조 (App Router):
// app/calls/[id]/page.tsx
export default async function CallPage({ params }) {
const messages = await db.query.callMessages.findMany({
where: { callId: params.id }
});
return (
<div>
{messages.map(m => <Message key={m.id} msg={m} />)}
</div>
);
}
이게 정말 큰 차이다. 클라이언트에서 API 호출 대신, 서버에서 데이터를 직접 가져온다.
Step 2: Client Components로 상호작용 분리
모든 걸 Server Component로 할 순 없다. 상호작용이 필요한 부분은 Client Component여야 한다.
좋은 분리:
// app/calls/[id]/page.tsx (Server Component)
export default async function CallPage({ params }) {
const call = await db.query.calls.findOne(params.id);
const messages = await db.query.callMessages.findMany({
where: { callId: params.id }
});
return (
<>
<div>
<h1>{call.title}</h1>
<p>Started at {call.startedAt}</p>
</div>
{/* Client Component: 상호작용 있음 */}
<MessagesContainer initialMessages={messages} callId={params.id} />
</>
);
}
// components/MessagesContainer.tsx (Client Component)
'use client';
export function MessagesContainer({ initialMessages, callId }) {
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState('');
const sendMessage = async () => {
const response = await fetch(`/api/calls/${callId}/messages`, {
method: 'POST',
body: JSON.stringify({ text: input }),
});
const newMessage = await response.json();
setMessages(m => [...m, newMessage]);
setInput('');
};
return (
<div>
{messages.map(m => <Message key={m.id} msg={m} />)}
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={sendMessage}>Send</button>
</div>
);
}
이 분리가 성능의 핵심이다. 정적인 콘텐츠(call 제목)는 서버에서 렌더링되고, 상호작용 부분(메시지 입력)만 클라이언트에 간다.
Step 3: 라우트 레벨 분할
점진적 마이그레이션을 위해 라우트별로 나눴다:
주 1회 1-2개 라우트씩 Server Components로 전환
마이그레이션 순서:
Week 1: /home (정적, 데이터 없음)
Week 2: /docs (정적, 마크다운)
Week 3: /calls/[id] (동적이지만 읽기만)
Week 4: /settings (사용자 정보, 업데이트 필요)
...
Step 4: 데이터 페칭 패턴 변경
Server Components에서 데이터를 가져오는 방식:
// ✅ 좋음: 병렬 페칭
export default async function CallPage({ params }) {
// 이 두 요청은 병렬로 실행됨
const [call, participants, messages] = await Promise.all([
db.calls.findOne(params.id),
db.participants.findMany({ callId: params.id }),
db.messages.findMany({ callId: params.id }),
]);
return <CallView call={call} participants={participants} messages={messages} />;
}
비교: 클라이언트에서 (waterfall):
// ❌ 나쁨: 직렬 페칭
useEffect(() => {
fetch(`/api/calls/${id}`)
.then(r => r.json())
.then(call => {
fetch(`/api/calls/${id}/participants`) // ← call 로드 후에야 시작
.then(r => r.json())
.then(setParticipants);
});
}, [id]);
서버에서는 모두 동시에 시작된다.
Step 5: Hydration 문제 해결
처음 몇 페이지 마이그레이션 후 hydration mismatch 에러가 터졌다:
Hydration failed because the initial UI does not match what was rendered on the server.
원인: Client Component에서 초기 상태가 서버 렌더링과 다르면 발생한다.
나쁜 예:
// ❌ Hydration mismatch 발생
'use client';
export function Timestamp() {
const [time, setTime] = useState('');
useEffect(() => {
setTime(new Date().toISOString());
}, []);
return <p>{time}</p> // 초기값: ''(서버), 1초 후: '2024-08-08T...'(클라이언트)
}
좋은 예:
// ✅ 일치함
'use client';
interface TimestampProps {
initialTime: string; // 서버에서 받음
}
export function Timestamp({ initialTime }: TimestampProps) {
const [time, setTime] = useState(initialTime);
useEffect(() => {
setTime(new Date().toISOString());
}, []);
return <p>{time}</p> // 초기값: initialTime(일치)
}
또는 suppressHydrationWarning을 쓸 수 있지만, 이건 임시방편이다:
// 임시방편
<p suppressHydrationWarning>{time}</p>
Step 6: API 전략 변경
Server Components에서는 데이터베이스에 직접 접근할 수 있다. API routes가 필요 없어진다.
이전:
// pages/api/calls/[id].ts
export default async function handler(req, res) {
const call = await db.calls.findOne(req.query.id);
res.json(call);
}
// pages/calls/[id].tsx
const [call, setCall] = useState(null);
useEffect(() => {
fetch(`/api/calls/${id}`)
.then(r => r.json())
.then(setCall);
}, [id]);
이후:
// app/calls/[id]/page.tsx (Server Component)
const call = await db.calls.findOne(id);
// API routes는 동적 요청(POST 등)에만 필요
// pages/api/calls/[id]/messages.ts (POST 메시지 생성)
성능 개선 결과
| 메트릭 | 이전 (SPA) | 이후 (Server Components) | 개선 |
|---|---|---|---|
| 초기 JS 번들 | 2.4MB | 1.2MB | 50% |
| First Contentful Paint | 4.2s | 1.4s | 67% |
| Time to Interactive | 6.1s | 2.8s | 54% |
| API 요청 수 (홈페이지) | 8 | 0 | 100% |
배운 점
1. 모든 데이터 페칭을 클라이언트에서 할 필요는 없다
2. 정적 콘텐츠를 서버에서 렌더링하면 번들이 줄어든다
3. 보안: API 키를 서버에만 두고 클라이언트에 노출 안 함
4. 점진적 마이그레이션이 가능하고, 혼합해서 쓸 수 있다
결론
Server Components는 혁신이다. 하지만 마이그레이션은 신중해야 한다. 우리는 6개월에 걸쳐 50개 라우트를 옮겼다. 급하게 할 필요는 없다. 점진적이 답이다.