3 / 3

Step 07: 종합 비교 — Express SSR vs Next.js 의사결정 프레임워크

예상 시간: 10분

Step 07: 종합 비교 — Express SSR vs Next.js 의사결정 프레임워크

PM의 질문 (김도연)

김도연: "준혁님, 이제 Step 1부터 Step 6까지 모두 완료했는데요. 솔직히 머리가 복잡해요. 각 단계에서는 '아 이렇게 하는구나' 싶었는데, 지금까지 배운 걸 정리하면서 실제 프로젝트에서 어떤 걸 선택해야 할지 기준을 명확히 알고 싶어요."

이준혁: "좋은 질문이야. 지금까지 6단계의 배관 작업을 직접 해봤으니까, 이제 왜 Next.js가 존재하는지 완전히 이해했을 거야. 오늘은 전체를 조감하면서 의사결정 기준을 정리해보자."


1. 6단계 배관 작업 총정리

1.1 단계별 배관 코드량 비교

이준혁: "먼저 지금까지 우리가 한 작업을 숫자로 정리해보자."

영역Express 수동 배관Next.js 자동 배관수동 코드량
Step 1: SSR 구조renderToString + HTML 템플릿 수동 작성 + hydration 직접 연결page.tsx 생성하면 자동 실행~60줄 → 0줄
Step 2: ZustandFactory 패턴 + JSON.stringify + window.__STATE__ 수동 주입 + 격리 처리Props 전달 + useEffect 초기화~50줄 → 20줄
Step 3: TanStack Queryprefetch + dehydrate + serialize + hydrate + clear 모두 수동<HydrationBoundary> 자동 처리~80줄 → 30줄
Step 4: 인증passport + session + requireAuth 미들웨어 + state 직렬화 수동NextAuth + middleware.ts 자동~100줄 → 40줄
Step 5: 라우팅라우트 수동 등록 + SSR 보일러플레이트 반복파일시스템 자동 + layout.tsx라우트당 30줄 → 0줄
Step 6: 배포esbuild 4단계 + Docker + PM2 + 캐싱 설정next build + Vercel~100줄 → 5줄
합계~440줄+ 배관 코드~95줄78% 감소

김도연: "와... 숫자로 보니까 확실히 차이가 크네요. 특히 Step 5의 '라우트당 30줄'이 무섭게 느껴져요."

이준혁: "맞아. 라우트가 10개면 300줄, 20개면 600줄이 보일러플레이트로 쌓여. 이게 Express SSR의 가장 큰 부담이지."


1.2 배관 누적 시각화

이준혁: "시각적으로 보면 더 명확해."

Express SSR — 기능 추가할 때마다 배관이 누적됨
 
Step 1: ████████                           (기본 SSR, ~60줄)
Step 2: ████████████████                   (+Zustand 직렬화, ~50줄)
Step 3: ████████████████████████████       (+TanStack Query dehydrate, ~80줄)
Step 4: ████████████████████████████████████████   (+인증 session+passport, ~100줄)
Step 5: ████████████████████████████████████████████████████   (+라우트마다 반복, 라우트당 ~30줄)
Step 6: ████████████████████████████████████████████████████████████████   (+빌드/배포, ~100줄)
 
──────────────────────────────────────────────────────────────────────────
총 ~440줄+ (라우트 수에 비례해 증가)
 
 
Next.js — 기능 추가해도 배관이 거의 늘지 않음
 
Step 1: ██                                 (page.tsx 생성)
Step 2: ███                                (+Props 전달)
Step 3: █████                              (+HydrationBoundary)
Step 4: ███████                            (+middleware.ts)
Step 5: ███████                            (+파일 추가만, 코드 증가 없음)
Step 6: █████████                          (+next.config)
 
──────────────────────────────────────────────────────────────────────────
총 ~95줄 (라우트가 늘어도 증가량 미미)

김도연: "Express는 계단식으로 쌓이는데, Next.js는 거의 수평이네요."

이준혁: "정확해. Express는 선형 증가(O(n)), Next.js는 **상수 시간(O(1))**에 가까워. 이게 프레임워크의 힘이지."


2. 철학적 차이

2.1 핵심 비교 테이블

이준혁: "기술적 차이 말고, 철학적 차이도 이해해야 해."

측면Express SSRNext.js
철학"모든 걸 직접 제어""최선의 방법 제공"
비유수동 변속기 자동차자동 변속기 자동차
학습 가치매우 높음 (SSR 본질 이해)중간 (프레임워크 사용법)
투명성높음 (모든 배관이 보임)중간 (일부 추상화)
유연성매우 높음 (뭐든 커스텀 가능)규칙 내 자유
생산성낮음 (배관 작업 시간)높음 (자동화)
유지보수어려움 (커스텀 코드)쉬움 (표준 패턴)
에코시스템제한적풍부 (Vercel, plugins 등)
디버깅쉬움 (내 코드)어려움 (프레임워크 내부)
러닝커브가파름 (모든 걸 알아야 함)완만함 (가이드 따라가기)

김도연: "수동 변속기 vs 자동 변속기 비유가 딱이네요. 수동 변속기는 제어감은 있지만 운전이 피곤하고, 자동 변속기는 편한데 내부 동작은 모르겠고."

이준혁: "맞아. 그리고 중요한 건, 수동 변속기를 써본 사람은 자동 변속기의 내부 동작을 이해할 수 있다는 거야. 너는 지금 그 경험을 한 거고."


2.2 실제 사례로 보는 철학 차이

이준혁: "구체적인 사례로 보자. TanStack Query의 dehydrate를 기억해?"

Express SSR:

// server.js - 모든 게 명시적
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['user'], queryFn: fetchUser });
const dehydratedState = dehydrate(queryClient);
const serialized = JSON.stringify(dehydratedState);
const html = `<script>window.__REACT_QUERY_STATE__=${serialized}</script>`;
queryClient.clear(); // 요청별 격리를 위해 수동 정리

Next.js:

// app/page.tsx - 추상화됨
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['user'], queryFn: fetchUser });
return <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>
// 나머지는 프레임워크가 알아서 함

김도연: "Express는 JSON.stringify, <script> 태그, clear()까지 모두 보이는데, Next.js는 <HydrationBoundary>만 쓰면 끝이네요."

이준혁: "그래. Express는 투명하지만 번거롭고, Next.js는 편하지만 불투명해. 이게 트레이드오프야."


3. 의사결정 프레임워크

3.1 Express SSR을 선택해야 할 때

이준혁: "이제 실전 의사결정 기준을 알려줄게. 먼저 Express SSR을 선택해야 하는 경우."

✅ 1. 학습 목적

언제:

  • SSR의 본질과 내부 동작을 깊이 이해하고 싶을 때
  • renderToString, dehydrate/hydrate가 어떻게 작동하는지 배우고 싶을 때
  • "왜 Next.js가 필요한가"를 체감하고 싶을 때
  • 주니어 개발자 교육 목적

예시:

// 이 코드를 작성하면서 SSR의 핵심을 배움
const html = renderToString(<App />);
const state = JSON.stringify(store.getState());
const template = `
  <!DOCTYPE html>
  <html>
    <body>
      <div id="root">${html}</div>
      <script>window.__STATE__=${state}</script>
      <script src="/client.js"></script>
    </body>
  </html>
`;

김도연: "맞아요. 제가 Step 1에서 이 코드를 직접 작성하면서 'SSR은 서버에서 HTML을 만드는 것'을 완전히 이해했어요."


✅ 2. 특수한 요구사항

a) 기존 Express 서버에 SSR 부분 추가 (점진적 마이그레이션)

// 기존 Express API 서버에 SSR 라우트 추가
app.get('/api/*', apiRoutes);        // 기존 API 유지
app.get('/admin/*', adminRoutes);    // 기존 관리자 페이지 유지
app.get('/app/*', ssrReactRoutes);   // React SSR 점진적 추가

이준혁: "기존 서버를 Next.js로 완전히 갈아엎을 수 없을 때 유용해."


b) Multi-framework (React + Svelte + Vue 동시 사용)

// A003 프로젝트처럼 여러 프레임워크를 동시에 사용
app.get('/react/*', renderReactSSR);
app.get('/svelte/*', renderSvelteSSR);
app.get('/vue/*', renderVueSSR);

김도연: "아! 그래서 A003 프로젝트가 Express를 쓴 거군요. Next.js는 React 전용이니까."

이준혁: "정확해. Multi-framework는 Express SSR의 독보적인 강점이지."


c) Next.js의 파일시스템 라우팅 규칙이 맞지 않을 때

// 복잡한 동적 라우팅이 필요한 경우
app.get('/products/:category/:subcategory?/:id?', (req, res) => {
  // 유연한 매칭 로직
  if (req.params.id) return renderProductDetail(req, res);
  if (req.params.subcategory) return renderSubcategory(req, res);
  return renderCategory(req, res);
});

이준혁: "Next.js는 [...slug] 같은 패턴은 되지만, 완전히 자유로운 라우팅은 불가능해."


d) 빌드 파이프라인을 완전히 커스텀해야 할 때

// esbuild를 직접 제어해서 특수한 플러그인 사용
await esbuild.build({
  plugins: [
    myCustomPlugin(),           // 커스텀 플러그인
    specialTransformPlugin(),   // 특수한 변환 로직
  ],
  // 완전한 제어
});

✅ 3. 팀/프로젝트 특성

a) 소규모 프로젝트 (라우트 5개 이하)

이준혁: "라우트가 5개 정도면 Express SSR의 보일러플레이트 부담이 적어. 오히려 Next.js의 설정이 오버엔지니어링일 수 있지."

b) 팀이 SSR 내부 동작을 잘 이해

이준혁: "팀 전체가 renderToString, dehydrate 같은 개념을 이해하고 있다면, Express SSR의 투명성이 오히려 장점이야."

c) 이미 Express 인프라가 구축되어 있음

이준혁: "PM2, 로드밸런서, 모니터링 등이 Express 기반으로 이미 있다면, Next.js로 갈아타는 비용이 커."


3.2 Next.js를 선택해야 할 때

이준혁: "이제 Next.js를 선택해야 하는 경우. 사실 대부분의 프로젝트는 여기 해당해."

✅ 1. 생산성 우선

a) 빠른 개발/배포 필요

김도연: "MVP를 2주 안에 만들어야 한다면?"

이준혁: "무조건 Next.js야. Express SSR은 배관만 1주 걸려."

b) 배관 작업에 시간 쓰고 싶지 않음

// Express: 라우트마다 이 코드를 반복
app.get('/dashboard', async (req, res) => {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(...);
  const dehydrated = dehydrate(queryClient);
  const store = createStore();
  const html = renderToString(...);
  // ... 30줄 이상
});
 
// Next.js: 파일만 만들면 끝
// app/dashboard/page.tsx
export default async function Dashboard() {
  const data = await fetch(...);
  return <div>{data}</div>;
}

c) MVP/프로토타입 빠르게 만들어야 함

이준혁: "아이디어 검증이 목적이면, 배관보다 기능 개발에 집중해야지."


✅ 2. 팀/프로젝트 특성

a) 중대형 프로젝트 (라우트 10개+)

이준혁: "라우트가 10개 넘어가면 Express SSR의 보일러플레이트가 감당 안 돼."

Express SSR: 10개 라우트 × 30줄 = 300줄 보일러플레이트
Next.js:     10개 파일 생성 = 추가 코드 0줄

b) 팀원 간 일관된 패턴 필요

이준혁: "Express SSR은 각자 다르게 구현할 여지가 많아. Next.js는 하나의 방법만 제공해서 일관성이 보장돼."

c) 신규 팀원 온보딩 중요

이준혁: "신입이 들어왔을 때, Express SSR은 배관 코드 설명하는 데 3일 걸려. Next.js는 공식 문서 읽으면 바로 시작 가능해."


✅ 3. 기능 요구사항

a) ISR (Incremental Static Regeneration) 필요

// Next.js만 지원
export const revalidate = 60; // 60초마다 재생성
 
export default async function Page() {
  const data = await fetch(...);
  return <div>{data}</div>;
}

이준혁: "Express SSR로 ISR 구현하려면 Redis + 캐싱 로직을 직접 짜야 해. Next.js는 한 줄이고."

b) Image 최적화 중요

// Next.js: 자동 최적화
import Image from 'next/image';
<Image src="/hero.jpg" width={800} height={600} />
 
// Express: 직접 구현 또는 별도 서비스 사용 (Cloudinary 등)

c) SEO가 중요한 마케팅 페이지

이준혁: "Next.js는 metadata API, sitemap 생성, robots.txt 등이 다 자동화돼 있어."

// app/page.tsx
export const metadata = {
  title: '우리 서비스',
  description: 'SEO 최적화된 설명',
  openGraph: { ... },
};

d) Vercel 배포 활용

이준혁: "Vercel에 배포하면 자동 HTTPS, CDN, 프리뷰 배포가 다 제공돼. Express SSR은 인프라를 직접 구축해야 해."


4. 실제 비교: 동일 앱 구현

4.1 요구사항

김도연: "구체적인 예시를 보고 싶어요."

이준혁: "그럼 동일한 앱을 두 방식으로 구현해보자."

요구사항:

  • 인증된 사용자만 접근 가능한 대시보드
  • 서버에서 데이터 prefetch (TanStack Query)
  • Zustand로 클라이언트 상태 관리
  • 프로덕션 배포 가능한 빌드

4.2 Express SSR 구현 (~150줄)

// ============================================
// server.js (~60줄)
// ============================================
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { renderToString } from 'react-dom/server';
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { createStore } from './store';
 
const app = express();
 
// 1. 인증 설정 (~15줄)
app.use(session({ secret: 'secret', resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
 
passport.use(new LocalStrategy(...));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => User.findById(id, done));
 
// 2. requireAuth 미들웨어 (~5줄)
function requireAuth(req, res, next) {
  if (!req.isAuthenticated()) return res.redirect('/login');
  next();
}
 
// 3. SSR 라우트 (~40줄)
app.get('/dashboard', requireAuth, async (req, res) => {
  // TanStack Query prefetch
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: 60000 } },
  });
  await queryClient.prefetchQuery({
    queryKey: ['dashboard'],
    queryFn: () => fetch(`/api/dashboard?userId=${req.user.id}`).then(r => r.json()),
  });
  const dehydratedState = dehydrate(queryClient);
 
  // Zustand store 생성 및 초기화
  const store = createStore();
  store.getState().setUser(req.user);
 
  // React 렌더링
  const html = renderToString(
    <QueryClientProvider client={queryClient}>
      <StoreProvider store={store}>
        <Dashboard />
      </StoreProvider>
    </QueryClientProvider>
  );
 
  // HTML 생성
  const page = `
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__REACT_QUERY_STATE__ = ${JSON.stringify(dehydratedState)};
          window.__ZUSTAND_STATE__ = ${JSON.stringify(store.getState())};
        </script>
        <script src="/client.js"></script>
      </body>
    </html>
  `;
 
  // 정리
  queryClient.clear();
  res.send(page);
});
 
app.listen(3000);
 
 
// ============================================
// client/entry.jsx (~20줄)
// ============================================
import { hydrateRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider, Hydrate } from '@tanstack/react-query';
import { StoreProvider, createStore } from './store';
 
const queryClient = new QueryClient();
const store = createStore();
 
// Zustand 초기화
if (window.__ZUSTAND_STATE__) {
  store.setState(window.__ZUSTAND_STATE__);
}
 
hydrateRoot(
  document.getElementById('root'),
  <QueryClientProvider client={queryClient}>
    <Hydrate state={window.__REACT_QUERY_STATE__}>
      <StoreProvider store={store}>
        <Dashboard />
      </StoreProvider>
    </Hydrate>
  </QueryClientProvider>
);
 
 
// ============================================
// build.js (~60줄)
// ============================================
import esbuild from 'esbuild';
 
// 1. 클라이언트 빌드
await esbuild.build({
  entryPoints: ['client/entry.jsx'],
  bundle: true,
  outfile: 'dist/client.js',
  platform: 'browser',
  minify: true,
  sourcemap: true,
});
 
// 2. 서버 빌드
await esbuild.build({
  entryPoints: ['server.js'],
  bundle: true,
  outfile: 'dist/server.js',
  platform: 'node',
  packages: 'external',
});
 
// 3. React 빌드 (별도)
await esbuild.build({
  entryPoints: ['components/Dashboard.jsx'],
  bundle: true,
  outfile: 'dist/Dashboard.js',
  platform: 'neutral',
  format: 'esm',
});
 
// 4. 서버 전용 빌드 (SSR용)
await esbuild.build({
  entryPoints: ['server.js'],
  bundle: true,
  outfile: 'dist/server-ssr.js',
  platform: 'node',
  define: { 'process.env.SSR': 'true' },
});
 
 
// ============================================
// 총합: ~150줄 (라우트가 늘면 비례 증가)
// ============================================

4.3 Next.js 구현 (~40줄)

// ============================================
// middleware.ts (~5줄)
// ============================================
export { default } from 'next-auth/middleware';
export const config = { matcher: ['/dashboard'] };
 
 
// ============================================
// app/api/auth/[...nextauth]/route.ts (~10줄)
// ============================================
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
 
export const authOptions = {
  providers: [
    CredentialsProvider({
      credentials: { email: {}, password: {} },
      authorize: async (credentials) => {
        const user = await User.findOne({ email: credentials.email });
        if (user && await bcrypt.compare(credentials.password, user.password)) {
          return { id: user.id, email: user.email };
        }
        return null;
      },
    }),
  ],
};
 
export const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
 
 
// ============================================
// app/dashboard/page.tsx (~15줄)
// ============================================
import { getServerSession } from 'next-auth';
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { DashboardClient } from './DashboardClient';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
 
export default async function DashboardPage() {
  const session = await getServerSession(authOptions);
 
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['dashboard'],
    queryFn: () => fetch(`/api/dashboard?userId=${session.user.id}`).then(r => r.json()),
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DashboardClient user={session.user} />
    </HydrationBoundary>
  );
}
 
 
// ============================================
// app/dashboard/DashboardClient.tsx (~15줄)
// ============================================
'use client';
import { useSession } from 'next-auth/react';
import { useQuery } from '@tanstack/react-query';
import { useStore } from '@/store';
 
export function DashboardClient({ user }) {
  const { data: session } = useSession();
  const { data } = useQuery({ queryKey: ['dashboard'] });
  const store = useStore();
 
  // Zustand 초기화
  useEffect(() => {
    store.setUser(user);
  }, [user]);
 
  return <div>Dashboard: {data.name}</div>;
}
 
 
// ============================================
// app/layout.tsx (~5줄)
// ============================================
import { Providers } from './providers';
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
 
 
// ============================================
// 빌드/배포: 단일 명령어
// ============================================
// package.json
{
  "scripts": {
    "build": "next build",
    "start": "next start"
  }
}
 
 
// ============================================
// 총합: ~40줄 (라우트가 늘어도 증가량 미미)
// ============================================

4.4 코드량 비교

이준혁: "같은 기능인데 코드량이 거의 4배 차이나."

항목Express SSRNext.js
인증 설정~20줄 (passport + session)~15줄 (NextAuth)
SSR 라우트~40줄 (수동 배관)~15줄 (자동)
클라이언트 hydration~20줄 (수동 주입)~15줄 (자동)
빌드 설정~60줄 (4단계 esbuild)0줄 (자동)
미들웨어~10줄 (requireAuth)~5줄 (middleware.ts)
총합~150줄~40줄

김도연: "그리고 라우트가 늘어나면 Express는 40줄씩 증가하지만, Next.js는 파일만 추가하면 되니까 거의 증가하지 않는 거죠?"

이준혁: "정확해. 이게 스케일의 차이야."


5. 학습 여정 회고

5.1 단계별 깨달음

이준혁: "지금까지의 여정을 돌아보자."

Step 1: "아, SSR은 서버에서 HTML을 만드는 거구나"
        → renderToString()의 본질 이해
 
Step 2: "상태를 클라이언트로 보내려면 직렬화가 필요하구나"
        → window.__STATE__의 목적 이해
 
Step 3: "dehydrate/hydrate는 캐시 통조림이구나"
        → TanStack Query의 SSR 메커니즘 이해
 
Step 4: "인증 추가하면 배관이 급격히 복잡해지는구나"
        → session + passport의 복잡성 체감
 
Step 5: "라우트마다 보일러플레이트를 반복하는 게 진짜 문제구나"
        → 파일시스템 라우팅의 가치 이해
 
Step 6: "프로덕션에서는 빌드/배포까지 직접 해야 하는구나"
        → esbuild 4단계 파이프라인의 복잡성 체감
 
Step 7: "Express SSR은 학습용으로 최고, 프로덕션은 Next.js가 합리적"
        → 도구 선택의 기준 확립

김도연: "Step 1에서는 '이게 왜 필요해?'라고 생각했는데, Step 6까지 오니까 '그래서 Next.js가 있구나'가 됐어요."


5.2 핵심 개념 완전 이해

이준혁: "너는 이제 이 개념들을 완전히 이해한 거야."

1. renderToString의 본질

// 이게 단순히 문자열 변환이 아니라
const html = renderToString(<App />);
 
// "리액트 컴포넌트 → HTML 문자열" 변환을 통해
// 1) 초기 렌더링 속도 개선 (브라우저가 HTML을 즉시 표시)
// 2) SEO 최적화 (검색엔진이 내용 읽을 수 있음)
// 3) 자바스크립트 실패 시에도 기본 콘텐츠 표시
 
// 이 모든 걸 이해하고 있음

2. dehydrate/hydrate의 본질

// "통조림" 비유로 완벽히 이해
const dehydrated = dehydrate(queryClient);  // 서버에서 통조림 만들기
const serialized = JSON.stringify(dehydrated);  // 운송 준비
// window.__REACT_QUERY_STATE__ = ...;  // 브라우저로 운송
hydrate(queryClient, window.__REACT_QUERY_STATE__);  // 클라이언트에서 개봉

3. 요청별 격리의 중요성

// 이 코드가 왜 필요한지 완전히 이해
app.get('/dashboard', async (req, res) => {
  const queryClient = new QueryClient();  // 요청마다 새로 생성
  const store = createStore();            // 요청마다 새로 생성
  // ... SSR ...
  queryClient.clear();  // 메모리 누수 방지
});

이준혁: "이 개념들을 이해하고 있으면, Next.js를 쓸 때도 '내부에서 뭘 하고 있는지' 알 수 있어. 이게 진짜 실력이지."


6. PM의 최종 정리 (김도연)

김도연: "준혁님, 이제 확실히 이해했어요!

Express SSR로 배관 작업을 직접 해보니 SSR의 핵심 개념들을 완전히 이해했어요:

  • renderToString이 컴포넌트를 HTML 문자열로 바꾸는 것
  • dehydrate/hydrate가 서버 캐시를 클라이언트로 옮기는 '통조림' 과정
  • window.__STATE__를 통한 상태 직렬화의 필요성
  • 요청별 store 격리의 중요성 (메모리 누수 방지)
  • 왜 라우트마다 보일러플레이트가 반복되는지
  • 프로덕션 배포를 위한 빌드 파이프라인의 복잡성

하지만 실제 제품 개발에서는 이 배관 작업이 큰 부담이네요. 라우트 10개만 되도 300줄이 보일러플레이트로 쌓이고, 팀원들도 이 커스텀 배관 코드를 다 이해해야 하니까요.

앞으로의 전략:

  1. 학습/PoC: Express SSR로 개념 이해 ✅ (이미 완료!)
  2. 프로덕션 React 앱: Next.js로 빠른 개발
  3. Multi-framework/특수 요구: Express SSR 활용 (예: A003 프로젝트)
  4. 기존 Express 서버 확장: Custom Server 패턴 검토

그리고 Next.js를 쓰더라도, 내부에서 어떤 일이 일어나는지 알고 있으니까 디버깅할 때 자신감이 생길 것 같아요!"


7. 시니어의 마지막 조언 (이준혁)

이준혁: "마지막으로 하나만 기억해. 프레임워크는 도구일 뿐이야.

Express SSR을 해본 사람은 Next.js를 쓸 때도 '내부에서 뭘 하고 있는지' 알고 있어. 예를 들어:

// Next.js app/page.tsx
export default async function Page() {
  const data = await fetch(...);
  return <div>{data}</div>;
}

이 코드를 보면, Next.js만 쓴 사람은 '그냥 되네'라고 생각하지만, 너는 이제 알아:

  • 서버에서 renderToString(<Page />)이 실행되고 있다
  • fetch 결과가 dehydrate되어 클라이언트로 전달된다
  • 브라우저에서 hydrate로 이벤트 핸들러가 연결된다
  • 이 모든 게 자동화된 거다

반대로 Next.js만 쓴 사람은 문제가 생겼을 때 디버깅하기 어려워. 'HydrationBoundary가 왜 안 돼?'라고 물어보면, 내부 동작을 모르니까 해결 못 하지.

너는 이 튜토리얼을 통해 배관의 실체를 봤으니까, 이제 어떤 도구를 쓰든 자신감을 가져도 돼. 이해 위에 세운 편의성은 단순한 편의성과 다르거든.


실전 조언 3가지

1. 새 프로젝트는 Next.js로 시작해

  • 특별한 이유가 없으면 Next.js
  • 생산성이 압도적으로 높아
  • 팀 협업도 훨씬 쉬워

2. 하지만 내부 동작은 항상 기억해

  • Next.js가 renderToString을 언제 호출하는지
  • dehydrate/hydrate가 어디서 일어나는지
  • 이걸 알면 디버깅이 10배 빨라져

3. Express SSR은 특수한 경우에만

  • Multi-framework 필요할 때 (A003)
  • 기존 Express 서버 확장할 때
  • 학습 목적으로 SSR 본질 이해할 때

다음 학습 경로

이준혁: "이제 다음 단계로 넘어가자."

단기 (1-2주):

  • Next.js App Router 공식 튜토리얼 완료
  • Vercel에 실제 배포 경험
  • ISR, Image 최적화 등 Next.js 고유 기능 학습

중기 (1-3개월):

  • Next.js로 실제 프로젝트 하나 완성
  • Server Actions 학습 (폼 처리)
  • Parallel Routes, Intercepting Routes 같은 고급 패턴

장기 (3개월+):

  • Next.js 내부 소스코드 읽어보기
  • Custom Server 패턴 실험
  • Remix, Astro 같은 다른 메타프레임워크 비교 학습"

8. 최종 비교 체크리스트

김도연: "마지막으로 의사결정할 때 쓸 체크리스트 만들어주세요!"

이준혁: "좋아, 실전용 체크리스트 줄게."

🎯 Express SSR vs Next.js 의사결정 체크리스트

다음 중 하나라도 해당하면 Express SSR:

  • Multi-framework (React + Svelte + Vue 등) 동시 사용
  • 기존 Express 서버에 SSR 부분만 추가
  • SSR 학습이 주 목적 (교육/연구)
  • 라우트가 5개 이하인 소규모 프로젝트
  • 빌드 파이프라인을 완전히 커스텀해야 함
  • Next.js의 파일시스템 라우팅이 요구사항과 맞지 않음

다음 중 하나라도 해당하면 Next.js:

  • 프로덕션 앱 빠르게 개발해야 함
  • 라우트가 10개 이상
  • SEO 중요 (마케팅 페이지, 블로그 등)
  • Image 최적화 필요
  • ISR (Incremental Static Regeneration) 필요
  • Vercel 배포 활용
  • 팀 협업 (일관된 패턴 필요)
  • 신규 팀원 온보딩 중요

여전히 고민된다면:Next.js 선택 (의심스러우면 생산성 우선)


9. 진짜 마지막 정리

Express SSR의 가치

✅ 학습 가치: SSR 본질 완전 이해
✅ 투명성: 모든 배관이 명시적으로 보임
✅ 유연성: 뭐든 커스텀 가능
✅ Multi-framework: React + Svelte + Vue 동시 사용
 
❌ 생산성: 라우트마다 30줄 보일러플레이트
❌ 유지보수: 커스텀 배관 코드 관리 부담
❌ 협업: 팀원마다 다르게 구현할 여지
❌ 에코시스템: 제한적

Next.js의 가치

✅ 생산성: 배관 자동화로 개발 속도 4배
✅ 일관성: 하나의 방법 (파일시스템 라우팅)
✅ 에코시스템: Vercel, plugins, 풍부한 문서
✅ 기능: ISR, Image 최적화, SEO 자동화
 
❌ 학습: SSR 내부 동작 이해 부족
❌ 불투명성: 일부 추상화로 디버깅 어려움
❌ 규칙: 파일시스템 라우팅 규칙 따라야 함

최종 결론

이준혁: "결국 목적에 따라 도구를 선택하는 거야."

목적이 학습 → Express SSR
목적이 프로덕션 → Next.js
목적이 Multi-framework → Express SSR
목적이 MVP → Next.js

김도연: "명확해요! Express SSR로 배관을 직접 해보니까 Next.js의 가치를 완전히 이해했고, 이제 프로젝트 특성에 맞게 선택할 수 있을 것 같아요."

이준혁: "그래. 그리고 가장 중요한 건, 너는 이제 선택할 수 있는 사람이 됐다는 거야. Express SSR을 이해하지 못하면 Next.js밖에 못 쓰지만, 둘 다 이해하면 상황에 맞게 선택할 수 있거든.

이게 진짜 시니어의 무기야. 도구에 종속되지 않고, 도구를 선택하고 다룰 수 있는 능력. 이 튜토리얼을 끝까지 완료한 너는 이제 그 능력을 가진 거야."


10. 다음 단계

추천 학습 순서

  1. Next.js App Router 공식 튜토리얼 (1주)

  2. 실전 프로젝트 구현 (2-4주)

    • 블로그 또는 대시보드 앱
    • TanStack Query + Zustand 통합
    • Vercel 배포
  3. 고급 패턴 학습 (1-2주)

    • Server Actions
    • Parallel Routes, Intercepting Routes
    • Middleware 고급 활용
  4. 비교 학습 (선택)

    • Remix 학습 (다른 React 메타프레임워크)
    • Astro 학습 (정적 사이트 우선)
    • SvelteKit 학습 (Svelte 메타프레임워크)

참고 자료

Express SSR:

  • React 공식 문서: renderToString API
  • TanStack Query 공식 문서: SSR 가이드
  • 이 튜토리얼 Step 1-6 복습

Next.js:

  • Next.js 공식 문서: https://nextjs.org/docs
  • Vercel 블로그: 최신 패턴 및 베스트 프랙티스
  • Lee Robinson YouTube: Next.js 실전 영상

비교/심화:

  • "Remix vs Next.js" 비교 글
  • "When to use Astro vs Next.js"
  • "Understanding React Server Components"

마치며

김도연: "준혁님, 정말 감사합니다! Step 1에서 '이게 왜 필요해?'라고 생각했는데, Step 7까지 오니까 SSR의 본질부터 프레임워크 선택 기준까지 완전히 이해했어요.

특히 Express SSR로 배관을 직접 짜보면서 renderToString, dehydrate/hydrate, window.__STATE__ 같은 개념들을 손으로 익힌 게 정말 값진 경험이었어요. 이제 Next.js를 쓸 때도 '내부에서 뭘 하고 있는지' 알고 쓸 수 있을 것 같아요!"

이준혁: "그래, 잘했어. 기억해. 이해 위에 세운 편의성은 단순한 편의성과 다르다. 너는 이제 도구를 선택하고 다룰 수 있는 사람이 됐으니까, 자신감 가져도 돼.

앞으로 어떤 프로젝트를 하든, 이 튜토리얼에서 배운 원리들을 기억하면서 최선의 도구를 선택해. 그게 진짜 시니어의 길이야. 파이팅!"


🎓 Step 1-7 완료! Express SSR vs Next.js 전체 여정 마스터!

✅ Step 1: SSR 본질 이해 (renderToString, hydration)
✅ Step 2: Zustand 직렬화 (Factory 패턴, window.__STATE__)
✅ Step 3: TanStack Query dehydrate/hydrate (통조림 비유)
✅ Step 4: 인증 통합 (passport + session, 복잡성 급증)
✅ Step 5: 라우팅 스케일 (보일러플레이트 누적 문제)
✅ Step 6: 프로덕션 배포 (esbuild 4단계 vs next build)
✅ Step 7: 의사결정 프레임워크 (언제 뭘 선택할지)
 
→ 이제 어떤 프로젝트든 자신감 있게 도구를 선택할 수 있습니다!