1,056줄 Express 커스텀 서버를 Next.js API Routes로 옮긴 과정
들어가며
레거시 코드를 리팩토링할 때 가장 어려운 점은 “왜 이렇게 만들어졌을까?”를 이해하는 것이다. 내가 개발하고 있는 WMS(Warehouse Management System) 프로젝트도 마찬가지였다. Next.js 기반 프로젝트임에도 불구하고 별도의 Express 커스텀 서버를 운영하고 있었는데, 초기에는 API 프록시 기능을 빠르게 구현하기 위한 선택이었을 것이다.
하지만 시간이 지나며 이 선택은 기술 부채로 쌓여갔고, 결국 1,056줄의 거대한 단일 파일이 되어 유지보수의 발목을 잡았다.
결과: 1,056줄짜리 Express 서버를 Next.js API Routes와 Middleware로 옮겨 프록시 코드를 약 550줄(6개 모듈)로 절반 가까이 줄이고, express·multer 의존성을 제거했다(package-lock.json 645줄 감소). 실행 방식도
next start로 표준화했다.
문제 상황
1,056줄짜리 Express 서버의 한계
WMS 프론트엔드 프로젝트는 Next.js 기반이지만, API 프록시를 위해 별도의 Express 커스텀 서버(server/index.js)를 사용하고 있었다. 이 파일은 1,056줄에 달했고, 다음과 같은 문제를 안고 있었다.
- Next.js의 내장 기능을 활용하지 못함: Next.js는 API Routes, Middleware 등 서버 사이드 기능을 자체 제공하지만, Express 커스텀 서버를 사용하면 이 기능들을 온전히 쓸 수 없다. Hot Module Replacement(HMR)도 제대로 작동하지 않아 개발 경험도 좋지 않았다.
- 동일한 코드의 반복: GET, POST, PUT, PATCH, DELETE 각 HTTP 메서드마다 거의 동일한 프록시 로직이 복사-붙여넣기 수준으로 반복되고 있었다. 하나의 버그를 수정하려면 5곳을 모두 찾아 고쳐야 했다.
- 프레임워크 이중 의존: Express와 Next.js 두 프레임워크를 동시에 관리해야 했고, express, multer 등 Express 전용 의존성이 불필요하게 포함되어 있었다.
- 디버깅의 어려움: 특정 api endpoint들을 ‘/excel’과 같은 하나의 경로로 프록시하여 경로를 가로채 처리하는 경우 때문에, 어떤 요청이 어디로 가는지 추적하기 어려웠다.
- 배포 복잡도 증가: Dockerfile에서 server/ 디렉토리를 별도로 복사해야 했고, 실행 명령어도 node server/index.js로 Next.js 표준과 달랐다. 신규 팀원이 합류할 때마다 “왜 Next.js인데 Express 서버를 띄워야 하나요?”라는 질문을 받았다.
// 기존: server/index.js (1,056줄)
const express = require("express");
const next = require("next");
const multer = require("multer");
app.prepare().then(() => {
const server = express();
// GET, POST, PUT, PATCH, DELETE마다 거의 동일한 프록시 코드가 반복...
server.post("/api/v2/*", async (req, res) => { /* ... */ });
server.patch("/api/v2/*", async (req, res) => { /* ... */ });
server.put("/api/v2/*", async (req, res) => { /* ... */ });
server.delete("/api/v2/*", async (req, res) => { /* ... */ });
server.get("/api/*", async (req, res) => { /* ... */ });
// ...이런 패턴이 수백 줄 계속됨
});
해결 방안
역할 분석부터 시작
리팩토링의 첫 걸음은 현재 시스템이 실제로 무엇을 하고 있는지 파악하는 것이었다. Express 커스텀 서버가 수행하던 역할을 분석하면 크게 세 가지였다:
- API 프록시: 프론트엔드의 API 요청을 백엔드 서버로 전달
- 세션 체크: 쿠키 기반 인증 확인
- 헬스 체크: 컨테이너 상태 확인 엔드포인트
복잡해 보였던 1,056줄의 코드가 결국 이 세 가지 기능만 수행하고 있었고, 이는 모두 Next.js의 네이티브 기능으로 대체 가능했다.
| 기존 (Express) | 변경 후 (Next.js) |
|---|---|
| server/index.js (1,056줄) | pages/api/ 디렉토리의 API Routes |
| Express 미들웨어로 세션 체크 | middleware.ts |
| server.get(“/health-check”) | pages/api/health-check.ts |
| multer로 파일 업로드 처리 | formidable + lib/api/fileUploadUtils.ts |
| node server/index.js | next start |
이 방안을 선택한 이유
1. 프레임워크 일관성 확보
Next.js 프로젝트에서 Express를 별도로 운영하는 것은 불필요한 복잡성을 추가한다. Next.js가 제공하는 API Routes는 서버리스 함수처럼 동작하며, 별도의 서버 설정 없이도 API 엔드포인트를 구축할 수 있다.
- Next.js의 파일 기반 라우팅 시스템을 일관되게 사용
- 프론트엔드와 백엔드 로직을 동일한 빌드 파이프라인에서 관리
- Vercel 등 서버리스 플랫폼으로의 배포 옵션 확보
2. 코드 중복 제거와 유지보수성 향상
기존 Express 서버에서는 HTTP 메서드별로 거의 동일한 프록시 로직이 반복되고 있었다. 팩토리 패턴을 적용한 createProxyHandler 함수를 도입함으로써:
- 1,056줄의 코드를 약 550줄(6개 모듈)로 축소
- 공통 로직을 재사용 가능한 모듈로 분리
- 새로운 프록시 엔드포인트 추가 시 설정 객체만 작성하면 되는 구조
3. Next.js 에코시스템과의 통합
Next.js 13+ 버전에서 도입된 Middleware는 Edge Runtime에서 실행되어 빠른 응답이 가능하다. 세션 체크 같은 인증 로직을 Middleware로 구현하면:
- 모든 페이지 요청에 일관된 인증 로직 적용
- 클라이언트 요청이 페이지 렌더링까지 도달하기 전에 조기 차단
- Edge에서 실행되어 낮은 레이턴시
4. 배포 및 운영 단순화
표준 Next.js 구조로 전환하면서:
- Dockerfile이 단순해져 유지보수 부담 감소
- next start 명령어로 표준화된 실행 방식
- Next.js 공식 문서와 커뮤니티 자료 활용 가능
5. 의존성 최소화
Express와 관련 라이브러리를 제거함으로써:
- package-lock.json에서 645줄 감소
- 보안 취약점 관리 대상 감소
- 번들 사이즈 최적화
실행 과정
Step 1: 공통 유틸리티 모듈화
가장 먼저 한 일은 반복되는 코드를 찾아내는 것이었다. GET, POST, PUT, PATCH, DELETE 각 메서드마다 거의 동일한 로직이 5번 반복되고 있었다. 이를 역할별로 분리하여 lib/api/ 디렉토리에 모듈화했다.
lib/api/
├── proxyHandler.ts # 프록시 핸들러 팩토리 (312줄)
├── headerBuilder.ts # 요청 헤더 빌드
├── fileUploadUtils.ts # multipart/form-data 처리
├── responseFilter.ts # 응답 필터링
├── responseUtils.ts # 응답 유틸리티
└── serverUtils.ts # URL 빌드, 헤더 정리
핵심은 팩토리 패턴을 활용한 createProxyHandler 함수다. 이 함수는 설정 객체를 받아서 API Route 핸들러를 생성한다.
예를 들어:
- /api/v2/* 경로는 GET, POST, PUT, PATCH, DELETE를 모두 지원
- /api/basic/* 경로는 GET, POST만 지원
- 파일 업로드가 필요한 경로는 multipart 옵션 활성화
기존에는 이런 차이마다 별도의 라우트 핸들러를 작성해야 했지만, 이제는 설정만 바꿔주면 된다.
// lib/api/proxyHandler.ts
export const createProxyHandler = (config: ProxyConfig) => {
return async (req: NextApiRequest, res: NextApiResponse) => {
const method = req.method || "GET";
const pathString = extractPath(req);
const backendUrl = buildBackendUrl(url);
const headers = cleanHeaders(req.headers);
// GET, POST, PUT, PATCH, DELETE를 하나의 핸들러에서 처리
if (method === "GET") { /* ... */ }
else if (method === "POST") { /* ... */ }
// ...
};
};
Step 2: API Routes로 엔드포인트 전환
Express 라우트를 Next.js의 Dynamic API Routes로 전환했다. […path].ts 패턴으로 하위 경로를 모두 캐치한다.
// pages/api/v2/[...path].ts
import { createProxyHandler, apiConfig } from "lib/api/proxyHandler";
export const config = apiConfig; // bodyParser: false
export default createProxyHandler({
pathPrefix: "/api/v2",
logPrefix: "[api/v2]",
allowedMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
multipart: true,
});
// pages/api/basic/[...path].ts
import { createProxyHandler, apiConfig } from "lib/api/proxyHandler";
export const config = apiConfig;
export default createProxyHandler({
pathPrefix: "/api/basic",
logPrefix: "[api/basic]",
allowedMethods: ["GET", "POST"],
multipart: true,
});
단 2개의 파일로 기존 Express 서버의 수백 줄 라우팅 코드를 대체했다. 새로운 프록시 경로가 필요하다면 설정 객체만 작성하여 새 파일을 만들면 된다. 10줄 이내의 코드로 완전히 동작하는 프록시가 완성된다.
Step 3: Middleware로 세션 체크
Next.js Middleware를 활용해 페이지 접근 시 세션 쿠키를 확인하도록 했다.
// middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 로그인 페이지는 통과
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
// 세션 쿠키가 없으면 로그인 페이지로 리다이렉트
const cookieHeader = request.headers.get("cookie") || "";
if (!cookieHeader.includes("CSESSIONID=")) {
const loginUrl = new URL("/auth/login", request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
Step 4: 호환성을 위한 rewrites 설정
기존에 /health-check, /session-check 경로로 직접 접근하던 클라이언트/인프라 코드가 있으므로, next.config.js에 rewrites를 추가하여 기존 경로를 유지했다.
// next.config.js
async rewrites() {
return [
{ source: "/health-check", destination: "/api/health-check" },
{ source: "/session-check", destination: "/api/session-check" },
];
},
Step 5: Webpack 설정 수정
기존에 서버/클라이언트 빌드를 구분하지 않고 splitChunks 최적화를 적용하고 있었는데, Express 서버가 사라지면서 서버 빌드에서 브라우저 전용 라이브러리(self 참조)가 포함되어 오류가 발생했다. 클라이언트 빌드에서만 적용하도록 수정했다.
// 변경 전
if (!dev) {
config.optimization = { ... };
}
// 변경 후
if (!dev && !isServer) {
config.optimization = { ... };
}
Step 6: 실행 스크립트 전환
// package.json
{
"scripts": {
"dev": "NODE_ENV=development next dev", // 변경: node server/index.js → next dev
"start": "NODE_ENV=production PORT=8080 next start -p 8080" // 변경: node server/index.js → next start
}
}
Step 7: Dockerfile 및 의존성 정리
# 변경 전
COPY --from=build /app/server ./server # Express 서버 디렉토리 복사
CMD ["npm", "start"] # node server/index.js 실행
# 변경 후
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/next.config.js . # rewrites 등 런타임에 필요
CMD ["npm", "start"] # next start 실행
불필요해진 의존성도 제거했다:
- express 제거
- multer 제거 → formidable로 대체
package-lock.json에서 645줄이 줄었다.
배포 후 발생한 문제와 대처
마이그레이션 코드를 머지하고 배포한 뒤, 예상치 못한 문제가 발생했다. 로컬 환경에서는 완벽하게 동작했지만, 프로덕션에서는 컨테이너가 정상적으로 시작되지 않았다.
health-check 엔드포인트 접근 불가
증상: 컨테이너 배포 후 health-check가 실패하여 서비스가 정상 가동되지 않았다.
원인 분석: middleware.ts에서 세션 쿠키가 없는 요청을 로그인 페이지로 리다이렉트하는데, /health-check 경로가 middleware matcher에 걸리고 있었다. health-check 요청은 쿠키 없이 들어오므로 매번 리다이렉트되어 200 응답을 받지 못했다.
로컬 개발 환경에서는 health-check를 테스트하지 않았기 때문에 이 문제를 발견하지 못했고, 프로덕션 배포 후에야 알게 되었다.
수정: middleware의 matcher 패턴에 health-check를 제외 경로로 추가했다.
// middleware.ts
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|images|fonts|service-worker.js|manifest.json|health-check).*)",
// ^^^^^^^^^^^^^ 추가
],
};
또한 Dockerfile에서 next.config.js를 프로덕션 런타임에 포함하도록 추가했다. rewrites 설정이 런타임에 필요한데 빌드 시점에만 포함되어 있었다.
COPY --from=build /app/next.config.js .
- 인프라 레벨의 엔드포인트(health-check, metrics 등)는 인증 로직에서 명시적으로 제외해야 한다.
- 프로덕션 환경과 동일한 조건에서 테스트하는 것이 중요하다. 특히 Docker 컨테이너로 배포한다면 로컬에서도 컨테이너로 테스트해야 한다.
결과 요약
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 서버 아키텍처 | Express 커스텀 서버 | Next.js 네이티브 |
| 프록시 코드량 | 1,056줄 (단일 파일) | ~550줄 (6개 모듈) |
| HTTP 메서드별 코드 | 복사-붙여넣기 반복 | 팩토리 패턴으로 통합 |
| 파일 업로드 | multer (Express 전용) | formidable (프레임워크 무관) |
| 실행 방식 | node server/index.js | next start |
| 제거된 의존성 | - | express, multer |
| package-lock.json | - | 645줄 감소 |
| 배포 후 핫픽스 | - | health-check 정상 동작 |
마치며
1. 코드의 명확성
- 1,056줄의 단일 파일 → 역할별로 분리된 6개 모듈
- “이 코드가 뭐하는 거지?”라는 질문이 사라졌다
2. 개발자 경험 개선
- Next.js HMR이 정상적으로 작동
- 표준 Next.js 명령어로 통일 (next dev, next start)
- 신규 팀원 온보딩 시 설명해야 할 내용이 줄었다
3. 유지보수성 향상
- 새로운 프록시 엔드포인트 추가: 10줄 이내로 가능
- 버그 수정: 한 곳만 고치면 모든 메서드에 반영
- Next.js 업그레이드 시 호환성 걱정 감소
4. 기술 부채 해소
- 불필요한 의존성 645줄 제거
- 프레임워크 이중 의존 문제 해결
- Dockerfile 단순화
레거시 코드를 리팩토링하는 것은 항상 두려운 일이다.
“혹시 내가 모르는 중요한 로직이 있는 건 아닐까?”, “배포 후 문제가 생기면 어떡하지?” 같은 막연한 걱정이 앞선다.
하지만 이번 경험을 통해 배운 것은 현재 시스템이 실제로 무엇을 하는지 정확히 파악하면 생각보다 단순하게 개선할 수 있다는 것이다. 1,056줄의 코드가 사실은 “프록시, 세션 체크, 헬스 체크” 세 가지만 하고 있었고, 이는 모두 Next.js로 더 간결하게 구현 가능했다. 프레임워크가 제공하는 기능을 최대한 활용하는 것이 장기적으로 유지보수 가능한 코드를 만드는 지름길이라는 것을 다시 한번 느꼈다.
