React 19 Server Components 도입기 — 레거시 SPA를 점진적으로 마이그레이션하기

게시일: 2025년 4월 1일 · 16분 읽기

3년 된 SPA를 한 번에 갈아엎자는 건 자살행위다. 우리 팀은 React 19 Server Components를 6개월에 걸쳐 점진적으로 도입했다. 지난 회의에서 한 CTO의 말이 기억난다:

"번들 사이즈 50% 감소? 초기 로딩 3배 빨라짐? 이게 진짜냐?"

진짜였다. 이 경험을 공유한다.

문제: 레거시 SPA의 한계

우리의 음성 채팅 웹앱은:

문제는 이것이었다:

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.4MB1.2MB50%
First Contentful Paint4.2s1.4s67%
Time to Interactive6.1s2.8s54%
API 요청 수 (홈페이지)80100%

배운 점

1. 모든 데이터 페칭을 클라이언트에서 할 필요는 없다
2. 정적 콘텐츠를 서버에서 렌더링하면 번들이 줄어든다
3. 보안: API 키를 서버에만 두고 클라이언트에 노출 안 함
4. 점진적 마이그레이션이 가능하고, 혼합해서 쓸 수 있다

결론

Server Components는 혁신이다. 하지만 마이그레이션은 신중해야 한다. 우리는 6개월에 걸쳐 50개 라우트를 옮겼다. 급하게 할 필요는 없다. 점진적이 답이다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.