
약 한 달간 기존 Express.js 기반의 레거시 서버를 Next.js 15 App Router 기반으로 전환하는 대규모 마이그레이션을 진행했습니다. 이 글에서는 모놀리식 구조에서 단일 프레임워크 아키텍처로의 전환 과정과, 그 과정에서 마주한 기술적 결정들을 다룹니다.
초기 프로젝트는 Next.js Page Router와 함께 Express.js 서버가 백엔드 API와의 통신을 중계하는 모놀리식 구조로 운영되고 있었습니다. 이 구조는 시간이 지남에 따라 몇 가지 문제점을 드러냈습니다. 첫째, Express 서버와 Next.js 애플리케이션이 분리되어 있어 개발 및 배포 환경 설정이 복잡했습니다. 둘째, 클라이언트에서 API를 호출할 때 Express 서버를 거쳐 백엔드로 전달하는 방식은 추가적인 오버헤드를 발생시켰고, 통합된 로깅이나 에러 처리가 어려웠습니다. 마지막으로, 사용자 인증 정보 관리가 클라이언트 사이드에서 주로 이루어져 초기 로딩 성능 저하와 보안 취약성 우려가 있었습니다.
이러한 문제들을 해결하고 애플리케이션의 성능, 유지보수성, 개발 편의성을 대폭 개선하기 위해 Next.js 15 App Router로의 마이그레이션을 결정했습니다. 목표는 기존 Express 서버를 완전히 제거하고, Next.js의 통합된 서버-클라이언트 아키텍처를 최대한 활용하여 단일 프레임워크 기반의 효율적인 개발 환경을 구축하는 것이었습니다.
새로운 아키텍처, Next.js App Router와 React Server Components
이번 마이그레이션의 핵심은 기존 pages 디렉토리 기반의 Page Router에서 app 디렉토리 기반의 App Router로 전환하는 것이었습니다. 단순히 파일 구조를 바꾸는 것이 아니라, 애플리케이션의 진입점과 데이터 흐름 자체를 재설계하는 작업이었습니다.
기존에는 server.js (Express 서버)와 pages/_app.js가 각각 별도의 진입점으로 동작했습니다. 모든 요청이 Express를 거쳐야 했고, 사용자 인증 같은 전역 로직은 _app.js의 componentDidMount에서 클라이언트 사이드로 처리되고 있었습니다. 이 구조에서는 서버와 클라이언트의 역할이 뒤섞여 있었고, 초기 로딩 시 빈 화면이 깜빡이는 문제도 있었습니다.
App Router로 전환하면서 이 흐름을 완전히 바꿨습니다. 이제 모든 요청은 middleware.ts를 거쳐 app/layout.js (Root Layout)로 흐르게 됩니다. app 디렉토리 내의 컴포넌트는 기본적으로 서버 컴포넌트로 동작하기 때문에, 데이터 페칭을 서버에서 먼저 처리하고 완성된 HTML을 클라이언트로 보낼 수 있게 되었습니다.
전환 초기에 가장 까다로웠던 부분은 서버 컴포넌트와 클라이언트 컴포넌트의 경계였습니다. 기존 코드에서 useState나 useEffect를 사용하던 컴포넌트를 그대로 app 디렉토리로 옮기면 빌드 에러가 발생했고, 어디에 "use client" 지시어를 넣어야 할지 판단하는 기준을 세우는 데 시간이 필요했습니다. 결국 "데이터 페칭과 초기 렌더링은 서버, 사용자 상호작용은 클라이언트"라는 원칙을 세우고, 이 기준에 따라 컴포넌트를 분리하는 방식으로 정리했습니다.
// app/layout.js (Root Layout - 서버 컴포넌트)
import { fetchUserDetail } from "app/api/user/auth/route";
import UserSessionProvider from "providers/UserSessionProvider";
export default async function RootLayout({ children }) {
let userInfo = null;
let error = "";
try {
// 서버 사이드에서 사용자 정보 페칭
userInfo = await fetchUserDetail();
} catch (err) {
error = "로그인이 필요합니다.";
}
return (
<html lang="ko">
<body>
<UserSessionProvider user={userInfo} error={error}>
{children}
</UserSessionProvider>
</body>
</html>
);
}이전에는 _app.js의 componentDidMount에서 API를 호출해 사용자 정보를 가져왔기 때문에, 페이지가 먼저 렌더링된 후에야 인증 상태를 알 수 있었습니다. 권한이 없는 사용자에게 잠깐이라도 화면이 노출되는 문제가 있었습니다. 이제는 RootLayout이 서버 컴포넌트로서 렌더링 전에 fetchUserDetail을 호출하므로, 권한이 없으면 화면 자체가 그려지지 않습니다. UserSessionProvider를 통해 서버에서 가져온 사용자 정보를 Context API로 하위 클라이언트 컴포넌트에 전달하는 구조입니다.
API 계층 현대화: Express에서 Next.js Route Handler로
기존 Express 서버의 가장 큰 문제는 모든 API 엔드포인트마다 쿠키 전달, 에러 처리, 로깅 같은 반복 로직이 복사-붙여넣기 되어 있다는 점이었습니다. 엔드포인트가 수십 개에 달하다 보니 하나의 패턴을 수정하려면 모든 라우트를 찾아 고쳐야 했습니다.
마이그레이션 과정에서 server.js와 Express 라우트 파일들을 완전히 제거하고, Next.js의 API Route Handler(app/api 디렉토리)로 대체했습니다. 이때 단순히 1:1 전환만 한 것이 아니라, 공통 API 유틸리티를 먼저 설계한 뒤 각 엔드포인트를 전환하는 순서로 진행했습니다.
// Before: routes.js (Express GET 라우트)
app.get("/api/data/list", (req, res) => {
httpClient
.get(`${API_BASE_URL}/data/list`, {
params: req.query,
headers: { cookie: req.headers.cookie }, // 쿠키 수동 전달
})
.then((response) => res.json(response.data))
.catch((err) => res.status(500).send(err));
});이전에는 위와 같이 각 라우트마다 쿠키를 수동으로 전달하고, 에러 처리도 .catch((err) => res.status(500).send(err)) 수준으로 일관성 없이 되어 있었습니다.
이를 해결하기 위해 먼저 fetchData라는 서버 측 API 호출 유틸리티를 만들었습니다. 핵심은 next/headers의 cookies() 함수를 활용해 인증 쿠키를 자동으로 포함시키는 것이었습니다. Page Router에서는 getServerSideProps의 context로 접근했지만 App Router에서는 cookies() 함수를 사용해야 했습니다.
// libs/api/server-apis.ts (서버 측 API 호출 유틸리티)
import { cookies } from "next/headers";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export async function fetchData<T>(
method: string,
url: string,
data?: any,
): Promise<T> {
const cookieHeader = cookies().toString(); // 서버에서 쿠키 자동 추출
const headers = {
"Content-Type": "application/json",
Cookie: cookieHeader,
};
const res = await fetch(url, {
method,
headers,
body: data ? JSON.stringify(data) : undefined,
cache: "no-store",
});
if (!res.ok) {
const errorText = await res.text();
throw new ApiError(res.status, errorText);
}
return (await res.json()) as T;
}그리고 Route Handler에서의 요청 파싱과 에러 응답을 표준화하는 fetchHandler를 만들어, 각 엔드포인트가 비즈니스 로직에만 집중할 수 있도록 했습니다.
// libs/api/server-apis.ts (API Route Handler용 래퍼)
export async function fetchHandler(
request: Request,
backendUrl: string,
handlerName: string,
) {
const method = request.method;
let data;
if (method === "POST" || method === "PUT") {
try {
data = await request.json();
} catch (error) {
return NextResponse.json(
{ message: "Invalid JSON body" },
{ status: 400 },
);
}
}
try {
const res = await fetchData(method, backendUrl, data);
return NextResponse.json(res);
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json(
{ message: error.message },
{ status: error.status },
);
}
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 },
);
}
}이 유틸리티들을 먼저 만들어둔 덕분에, 개별 Route Handler는 매우 간결해졌습니다.
// After: app/api/data/list/route.ts (Next.js Route Handler)
import { fetchData } from "@/libs/api/server-apis";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const result = await fetchData(
"GET",
`${API_BASE_URL}/data/list?${searchParams}`,
);
return NextResponse.json(result);
}Express 라우트 수십 개를 전환하는 작업 자체는 반복적이었지만, 공통 유틸리티가 쿠키 전달, 에러 처리, 로깅을 모두 담당하고 있었기 때문에 각 엔드포인트의 전환은 기계적으로 진행할 수 있었습니다.
SSR 기반 사용자 인증 및 전역 상태 관리 개선
기존 시스템의 인증 흐름에는 근본적인 문제가 있었습니다. pages/_app.js의 componentDidMount에서 클라이언트 사이드로 API를 호출해 사용자 정보를 가져왔기 때문에, 페이지가 먼저 렌더링된 뒤에야 인증 상태를 확인할 수 있었습니다. 결과적으로 권한이 없는 사용자에게도 화면이 잠깐 노출되었고, MobX 스토어 기반의 전역 상태 관리는 SSR 환경과 잘 맞지 않았습니다.
// Before: pages/_app.js (Client Side Auth)
class MyApp extends App {
componentDidMount() {
// 페이지 렌더링 후 실행되어 초기 로딩 지연 발생
httpClient
.get(`/api/auth/user`)
.then((res) => this.props.store.setUser(res.data))
.catch(() => (window.location.href = "/login"));
}
// ... render ...
}마이그레이션 후에는 사용자 인증을 app/layout.js 서버 컴포넌트로 옮기고, MobX 대신 React Context API 기반의 UserSessionProvider를 도입했습니다.
// After: providers/UserSessionProvider.js (Client Component)
"use client";
import { createContext, useContext, useEffect } from "react";
const UserContext = createContext();
export default function UserSessionProvider({ user, error, children }) {
useEffect(() => {
if (error) window.location.href = "/login";
}, [error]);
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
export const useUser = () => useContext(UserContext);이 과정에서 주의할 점이 있었습니다. 처음에는 app/layout.js 서버 컴포넌트 안에서 권한이 없는 사용자를 처리할 때 window.location.href로 리다이렉트하려 했는데, 서버 컴포넌트에서는 window 객체에 접근할 수 없어 에러가 발생했습니다. 이를 해결하기 위해 next/navigation의 redirect() 함수를 사용하여 서버 측에서 리다이렉트를 처리하고, 그 외의 에러(세션 만료 등)는 UserSessionProvider 클라이언트 컴포넌트에서 useEffect로 처리하는 방식으로 역할을 나누었습니다.
클라이언트 컴포넌트에서의 API 호출도 정리가 필요했습니다. 기존에는 각 컴포넌트마다 axios로 직접 호출하고 에러 처리도 제각각이었는데, useApi라는 커스텀 훅을 만들어 로딩 상태, 에러 처리, 응답 파싱을 한곳에서 관리하도록 했습니다.
// hooks/use-api.tsx (클라이언트 전용 API 훅)
"use client";
import { useState, useCallback } from "react";
import { ApiError } from "@/libs/api/server-apis";
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: ApiError | null;
request: (url: string, options?: RequestInit) => Promise<T>;
}
export const useApi = <T = any>(): UseApiResult<T> => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const request = useCallback(
async (url: string, options: RequestInit = {}): Promise<T> => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
const result = await response.json();
setData(result);
return result;
} catch (err: any) {
const apiError =
err instanceof ApiError ? err : new ApiError(500, err.message);
setError(apiError);
throw err;
} finally {
setLoading(false);
}
},
[],
);
return { data, loading, error, request };
};서버 측 유틸리티(fetchData)와 클라이언트 측 훅(useApi)이 같은 ApiError 클래스를 공유하도록 설계한 것이 결과적으로 좋은 선택이었습니다. 에러 타입이 통일되니 서버든 클라이언트든 동일한 방식으로 에러를 처리할 수 있게 되었습니다.
미들웨어와 라우팅 최적화
기존에는 Express 서버에서 처리하던 전역 요청 로직(인증 확인, 리다이렉션 등)을 Next.js middleware.ts로 이관했습니다. Express의 여러 미들웨어에 흩어져 있던 로직이 한 파일에 모이면서 요청 흐름을 한눈에 파악할 수 있게 되었습니다.
미들웨어에서 처리하는 핵심 로직은 다음과 같습니다:
- 인증 쿠키(
auth_token) 존재 여부를 확인하고, 없으면 로그인 페이지로 리다이렉트 - 루트 경로(
/) 접근 시/dashboard로 리다이렉트 /health경로에 대한 헬스 체크 응답
라우팅 쪽에서도 next/router에서 next/navigation(useRouter, useSearchParams, usePathname)으로 전환했습니다. 또한, 기존에 SSR을 끄기 위해 사용하던 dynamic import를 제거하고 정적 import로 변경하여 번들 사이즈 최적화를 도모했습니다. App Router에서는 "use client" 지시어로 클라이언트 컴포넌트를 명시하면 되기 때문에, dynamic(() => import(...), { ssr: false }) 같은 우회가 더 이상 필요하지 않았습니다.
// middleware.ts (Next.js 미들웨어)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// 1. 인증 쿠키 확인 및 리다이렉트
if (!request.cookies.has("auth_token")) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", request.url);
return NextResponse.redirect(loginUrl);
}
// 2. 루트 경로('/') 접근 시 대시보드로 이동
if (request.nextUrl.pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// 3. 헬스 체크
if (request.nextUrl.pathname === "/health") {
return NextResponse.json({ status: "ok" });
}
return NextResponse.next();
}Express에서는 인증 미들웨어, 라우팅 미들웨어, 헬스 체크 라우트가 각각 다른 파일에 흩어져 있었는데, 이제 middleware.ts 하나에서 요청 흐름 전체를 관리할 수 있게 되었습니다. config.matcher로 미들웨어가 적용될 경로를 제한하면 정적 파일 요청 등에서 불필요한 처리를 방지할 수 있습니다.
환경 변수 관리 간소화
Next.js는 .env 파일을 통한 환경 변수 관리를 내장하고 있습니다. 기존에는 dotenv.config()를 직접 호출하거나 package.json 스크립트에서 환경 변수를 명시적으로 설정하는 방식이었으나, 마이그레이션 이후에는 Next.js가 .env, .env.local, .env.development, .env.production 파일들을 자동으로 로드하도록 변경하여 환경 변수 관리를 훨씬 간소화했습니다.
특히, 클라이언트 사이드 코드에서 접근해야 하는 환경 변수에는 반드시 NEXT_PUBLIC_ 접두사를 붙여야만 번들링 과정에서 클라이언트에 노출됩니다. 이를 통해 개발, 테스트, 프로덕션 환경에 따라 유연하게 설정을 관리할 수 있게 되었으며, 서버 전용 환경 변수가 클라이언트에 노출되는 보안 위험도 방지할 수 있습니다. next.config.ts, Dockerfile, package.json 파일에서 이러한 변경 사항을 반영하여 환경 변수 설정 및 관리가 더욱 효율적으로 이루어지도록 했습니다.
CI/CD 파이프라인 고도화
이번 마이그레이션의 또 다른 중요한 목표는 개발 환경의 일관성을 확보하고, 배포의 안정성과 효율성을 높이는 것이었습니다. 이를 위해 기존의 배포 프로세스를 재점검하고, GitHub Actions를 활용하여 CI/CD 파이프라인을 전면적으로 재구성했습니다.
Express 서버가 제거됨에 따라 빌드 및 배포 체인이 단순화되었으며, 이를 기반으로 다음 단계들을 자동화하여 운영 효율성을 극대화했습니다.
- 도커 이미지 빌드: 단일 Next.js 프로젝트를 컨테이너 이미지로 빌드하여 환경 간 일관성을 보장합니다.
- AWS ECR 푸시: 빌드된 이미지를 AWS ECR(Elastic Container Registry)에 자동으로 업로드하여 버전 관리를 용이하게 했습니다.
- ECS 배포: AWS ECS(Elastic Container Service)의 태스크 정의를 갱신하여 최신 이미지를 무중단으로 배포합니다.
- 알림 자동화: 배포의 성공 및 실패 여부를 Slack 알림으로 실시간 전송하여, 개발팀이 배포 상태를 즉각적으로 파악하고 대응할 수 있도록 운영 가시성을 확보했습니다.
이러한 자동화된 파이프라인 덕분에 수동 배포 과정에서 발생할 수 있는 인적 오류를 획기적으로 줄였으며, 개발자는 인프라 관리나 배포 작업에 대한 부담 없이 코드 작성과 기능 구현에만 집중할 수 있는 환경이 조성되었습니다.
마이그레이션 결과 및 얻은 교훈
이번 Next.js 15 App Router 마이그레이션을 통해 다음과 같은 유의미한 개선을 이루었습니다.
빌드 및 개발 환경 성능 비교
Express 서버 번들링이 사라지고 App Router의 증분 빌드가 적용되면서 전반적인 개발 환경이 개선되었습니다.
| 항목 | Before (Page Router + Express) | After (App Router) |
|---|---|---|
| 프로덕션 빌드 | 50~60초 | 30~40초 |
| 개발 서버 기동 | — | 약 50% 단축 |
| HMR (코드 반영) | 3~5초 | 1초 이내 |
| 로컬 프로세스 수 | 2개 (Express + Next) | 1개 (Next 단일) |
가장 체감이 컸던 건 HMR입니다. Page Router에서는 _app.js를 경유하는 전체 리렌더링이 잦았지만, App Router에서는 변경된 서버 컴포넌트만 교체되기 때문입니다.
- 성능 향상: 서버 컴포넌트를 활용한 SSR과 최적화된 API 계층을 통해 초기 로딩 속도가 빨라졌습니다. 사용자 인증 정보도 서버에서 미리 가져오므로 클라이언트에서 대기하는 시간이 줄었습니다.
- 유지보수성 개선: Express 서버를 제거하고 Next.js App Router 기반의 단일 프레임워크 스택으로 통합되어 코드 베이스가 간결해지고 개발 복잡성이 감소했습니다. 공통 API 유틸리티와 미들웨어 도입으로 로직의 재사용성과 일관성이 향상되었습니다.
- 개발 경험 증진: TypeScript 적용과 App Router의 명확한 구조화 덕분에 개발자들이 코드의 의도를 파악하고 새로운 기능을 추가하기가 더 쉬워졌습니다.
물론, 마이그레이션 과정에서 예상치 못한 문제들도 많았습니다.
- 서버 컴포넌트에서
window접근 시도: 기존 코드에서window.location.href로 리다이렉트하던 로직을 서버 컴포넌트로 그대로 옮기면서 에러가 발생했습니다. 서버 컴포넌트에서는 브라우저 API에 접근할 수 없기 때문입니다.next/navigation의redirect()를 사용하는 것으로 해결했지만, 비슷한 실수가 다른 곳에서도 반복되었습니다. 결국 서버/클라이언트 경계를 의식적으로 확인하는 습관이 필요했습니다. - 쿠키 전파 누락 문제: 서버 컴포넌트에서 백엔드 API를 호출할 때
인증 쿠키가 전달되지 않아 401 에러가 발생하는 문제가 있었습니다. Page
Router에서는
getServerSideProps의context.req.cookies로 접근했지만, App Router에서는next/headers의cookies()함수를 사용해야 했습니다.fetchData유틸리티에 이 로직을 중앙화하여 해결했습니다.
이번 마이그레이션을 통해 Express 서버를 완전히 제거하고, 서버/클라이언트 경계가 명확한 단일 프레임워크 아키텍처를 확보했습니다. 향후 RSC 스트리밍, Partial Prerendering 등 App Router 고유 기능을 단계적으로 적용할 계획입니다.
참고 자료
Related Posts

HOC와 미들웨어로 구축하는 중앙집중식 오류 처리
Next.js SSR 환경에서 HOC 패턴과 미들웨어 헤더 전파를 활용하여 분산된 오류 처리를 중앙집중화하고 일관된 사용자 경험을 구현한 과정을 다룹니다.

대용량 엑셀 다운로드: 클라이언트에서 서버로
클라이언트 기반 엑셀 생성의 한계를 극복하고 SSE를 활용한 서버 사이드 아키텍처로 성능과 사용자 경험을 개선한 과정을 다룹니다.

외부 PG사 연동 시 리다이렉트 패턴 비교 - SDK vs Form POST vs GET
결제 시스템에서 다양한 외부 결제 서비스를 연동하면서 경험한 리다이렉트 패턴들의 차이점과 구현 방법을 정리합니다.