[Next.js 전환] 기존 Backend/CMS를 Next.js로 바꿀 때 앱 API 경계 유지하는 방법
이래저래 서비스를 운영하다 보면, 기존 Laravel(PHP)로 되어 있는 백엔드나 CMS 프로젝트를 Next.js로 전환하고 싶을 때가 있습니다.
저의 경우도 최근 관리자 CMS 화면을 Next.js로 바꾸고 앞단에 BFF(Backend For Frontend)를 두는 구조를 검토했었는데요. 이때 가장 헷갈리고 조심해야 하는 부분이 바로 화면과 앱 API 사이의 경계를 어디서 나눌지 정하는 일입니다.
화면 바꾸는 김에 API 구조도 싹 정리하면 깔끔할 것 같지만, 실제 운영해보면 화면 전환과 앱 API 계약 변경은 완전히 다른 문제입니다. 이미 배포된 모바일 앱은 사용자들이 바로 업데이트를 안 하기 때문에, 응답 필드 하나만 잘못 바꿔도 구버전 앱에서 바로 크래시가 나거나 장애로 이어집니다.
기존 앱 API 계약을 깨지 않고 안전하게 Next.js로 전환하기 위해 제가 실무에서 쓰고 있는 기준들을 정리해 둡니다. 자꾸 까먹어서 기록용으로 남깁니다.
전환 과정에서 흔히 겪는 문제들
급하게 화면과 API를 같이 넘어가다 보면 보통 아래와 같은 지점에서 문제가 터집니다.
- CMS 화면 개편하다가 앱 API 응답 형식까지 같이 바뀌어서 구버전 앱이 깨짐
- Next.js Route Handler가 편하다고 내부 DB 모델이나 Upstream 응답을 그대로 return 해버림
- 관리자 세션 인증이랑 앱 토큰 인증 로직이 한 곳에 섞임
- 웹 화면용 SSR/ISR 캐시랑 앱 API의 Cache-Control 정책이 혼동되어 꼬임
- BFF 레이어에서 내부 서버 비공개 URL이나 토큰이 클라이언트에 노출됨
내가 정의한 API 경계 기준
전환 작업을 시작하기 전에 무엇이 고정되어야 하는 계약(Boundary)이고, 무엇이 변경 가능한 내부 구현인지 명확하게 선을 그어야 합니다.
| 구분 | API 경계에 포함되는가? | 예시 |
| Endpoint Path | 포함 | /api/v1/notices |
| Request Params | 포함 | page, limit, keyword |
| Response DTO | 포함 | id, title, publishedAt |
| Error Format | 포함 | code, message |
| Auth Policy | 포함 | Public, Bearer Token, Session |
| Internal DB Column | 제외 | admin_memo, deleted_at |
| CMS 화면 컴포넌트 | 제외 | 관리자 테이블 UI |
| 내부 서버 URL | 제외 | 비공개 Upstream URL |
핵심은 간단합니다. 화면 UI를 어떻게 바꾸든, 표에서 **'포함'**에 해당되는 영역은 기존 앱을 위해 그대로 유지되어야 합니다.
도입 방식별 주의할 점
Next.js를 도입하는 방식에 따라 신경 써야 하는 주의점이 조금씩 다릅니다.
- CMS 화면만 Next.js로 전환할 때
- 관리자 UI 개선은 정말 편해지지만, 기존 API 인프라와 관리자 인증 세션을 연동하는 처리가 매끄러워야 합니다.
- Next.js BFF 레이어를 추가할 때
- 프론트엔드 입맛에 맞게 데이터를 조립하기 좋습니다. 다만, 여기서 만들어진 응답이 기존 앱 API 계약을 침범하지 않도록 철저히 분리해야 합니다.
- 일부 API를 Route Handler로 이전할 때
- Node.js 기반으로 API를 가볍게 관리할 수 있어 좋지만, 기존 PHP/Laravel 레거시 API 파트와 Versioning 규칙을 맞춰야 혼선이 없습니다.
해결 방법: DTO와 Adapter로 내부 구현 숨기기
Upstream(기존 Laravel API 등)에서 오는 데이터 구조가 바뀌거나 DB 스키마가 변경되더라도, 앱이 받는 최종 응답은 DTO(Data Transfer Object) 형태로 꽁꽁 묶어서 고정해야 합니다.
아래는 Node.js 24 기준으로 제가 작성해서 쓰고 있는 Route Handler와 DTO 변환 어댑터 예시입니다.
1. Type 정의 및 Adapter 로직
// 내부 Upstream 스키마 (언제든 바뀔 수 있는 영역)
type UpstreamNotice = {
id: number;
title?: string;
summary?: string | null;
published_at?: string | null;
admin_memo?: string | null; // 앱에 나가면 안 되는 관리자 메모
};
// 앱 클라이언트와 약속한 고정 DTO
type NoticeDto = {
id: number;
title: string;
summary: string;
publishedAt: string | null;
};
// 가공 로직을 어댑터로 분리
function toNoticeDto(item: UpstreamNotice): NoticeDto {
return {
id: item.id,
title: item.title ?? '',
summary: item.summary ?? '',
publishedAt: item.published_at ?? null, // 카멜케이스 전환 및 null 안정성 확보
};
}
2. Next.js Route Handler 적용 (Node.js 24)
// app/api/v1/notices/route.ts
import { NextResponse } from 'next/server';
type ApiResponse<T> = {
data: T;
meta?: Record<string, unknown>;
};
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 20)));
// 내부 API 호출 (실제 URL이나 토큰은 환경변수로 숨김)
const upstreamItems: UpstreamNotice[] = await fetchNoticesFromInternalApi({ page, limit });
// 약속된 DTO 구조로 포맷팅
const body: ApiResponse<NoticeDto[]> = {
data: upstreamItems.map(toNoticeDto),
meta: {
page,
limit,
hasNext: upstreamItems.length > limit,
},
};
return NextResponse.json(body, {
headers: {
'Cache-Control': 'public, max-age=60', // 앱 API 전용 캐시 설정
},
});
}
async function fetchNoticesFromInternalApi(params: { page: number; limit: number }) {
// 비공개 Upstream 통신 로직 진행...
return [] as UpstreamNotice[];
}
처음에는 그냥 내부 응답을 Spread 연산자(...item)로 대충 내려주는 게 속편하고 빠르게 느껴집니다. 하지만 나중에 admin_memo 같은 내부 필드가 앱 응답에 한 번 섞여 들어가면, 나중에는 찝찝해서 필드를 지우지도 못하는 상황이 발생합니다. 귀찮아도 중간에 어댑터 함수 하나 두고 명시적으로 매핑하는 게 훨씬 안전합니다.
실무에서 놓치기 쉬운 3가지 운영 포인트
1. 인증 경계 확실하게 긋기
관리자 CMS 렌더링에 쓰는 세션/쿠키 인증 방식과 앱 API가 사용하는 토큰(Bearer) 인증 방식은 완전히 격리되어야 합니다. 특히 인증 실패 시, 관리자용 Route의 영향으로 HTML 로그인 폼 페이지가 JSON 응답 대신 내려가는 불상사가 없도록 에러 핸들러를 체크해야 합니다. 앱 단에서 HTML 파싱 에러 나면서 터집니다.
2. 캐시 정책 분리하기
Next.js의 Server Component에서 쓰는 fetch cache나 웹 페이지용 ISR(Incremental Static Regeneration) 캐시 주기를 조정한다고 해서, 앱 API의 Cache-Control까지 알아서 맞춰지는 게 아닙니다. 화면을 그리기 위한 캐시와, 앱 클라이언트/CDN이 바라보는 캐시 정책은 별개로 관리해야 부하 관리가 됩니다.
3. 변경 위험도에 따른 Versioning 판단
만약 불가피하게 필드를 삭제하거나 타입을 바꿔야 한다면, v1 endpoint를 건드리지 말고 무조건 /api/v2/로 올려야 합니다.
- 새로운 Optional 필드 추가: 위험도 [낮음] $\rightarrow$ v1 유지 가능
- 기존 필드 삭제 / 타입 변경 / 에러 포맷 변경: 위험도 [높음] $\rightarrow$ 무조건 v2 분리
- 내부 로직만 PHP에서 Node로 변경 (DTO 동일): 위험도 [낮음] $\rightarrow$ 회귀 테스트 후 교체 가능
전환 전 최종 체크리스트
오픈하고 나서 허둥지둥하지 않으려면 릴리즈 전에 아래 리스트는 꼭 확인해보는 것이 좋습니다.
- [ ] 기존 앱 API endpoint와 응답 계약(JSON 구조)이 문서화되어 있는가?
- [ ] Next.js BFF 레이어에서 내부 DB 스키마를 그대로 노출하지 않고 DTO로 거르는가?
- [ ] 관리자 CMS 진입 Route와 앱 API Route가 완전히 분리되었는가?
- [ ] 환경 변수 점검을 통해 비공개 Upstream URL이나 마스터 토큰이 브라우저에 노출되지 않는가?
- [ ] breaking change가 발생하는 시점에 맞춰 API versioning 계획을 세웠는가?
- [ ] 구버전 앱을 켜서 새 백엔드 환경에서도 정상 동작하는지 회귀 테스트를 마쳤는가?
요약
새로운 기술(Next.js)을 도입해서 구조를 깔끔하게 바꾸는 것도 좋지만, 서비스를 운영하는 입장에서는 기존 사용자의 환경을 깨뜨리지 않는 것이 훨씬 중요합니다. 화면은 화려하게 바꾸더라도 앱이 바라보는 API 경계(Endpoint, DTO, 캐시, 인증)는 굳건하게 지켜주면서 단계적으로 마이그레이션하는 편이 가장 안전합니다.