들어가며
GEONIQ(SEO/GEO(AI Engine Optimization)를 분석해주는 서비스) 개발에 저도 같이 참여하게 되었는데 프론트엔드는 Next.js의 App Router, 백엔드는 FastAPI로 구성되어 있어 프론트엔드에서 백엔드 API를 호출할 일이 많았습니다.
처음에는 프론트엔드에서 백엔드를 직접 호출하는 단순한 구조로 시작했지만, 개발을 진행하면서 CORS 문제라든지, 작업 환경에 따라 URL이 각각 바뀌는 것에 의해 필연적으로 ”중간에 서버인 Proxy 서버 ”가 필요해 졌습니다.
이 글은 Next.js API Routes를 활용해 API 프록시 레이어를 만들게 된 과정과, 만들고 나서 느낀 점을 이야기해 보려고 합니다
프론트엔드가 백엔드를 직접 호출하면 안 되나?
실제로 많은 프로젝트가 프론트엔드에서 백엔드 API를 직접 호출합니다. 하지만 직접 호출로는 해결하기 어려운 문제들이 하나둘 생기기 시작했습니다.
문제 1: CORS
프론트엔드(localhost:3000)에서 백엔드(localhost:8001)를 직접 호출하면 CORS 설정이 필요합니다. 백엔드에서 “Access-Control-Allow-Origin:*”와 같이 추가해서 관리하면 되지만, 환경이 늘어날 때마다 양쪽을 맞춰줘야 합니다. (별거 아닌 것 같지만, 개발 중에 CORS 오류로 시간을 쓰지 않아도 된다는 건 생각보다 편합니다 :-))
만들어진 구조
이런 필요들이 모여서 만들어진 API 라우트 구조는 이렇습니다.
app/api/
├── [...path]/route.ts ← catch-all 프록시
└── auth/
├── set-tokens/route.ts ← 토큰 저장 (쿠키 관리)
├── refresh/route.ts ← 토큰 갱신
├── me/route.ts ← 사용자 정보 조회
└── logout/route.ts ← 로그아웃
async function proxyRequest(
request: NextRequest,
params: Promise<{ path: string[] }>,
method: string,
) {
const { path } = await params;
const accessToken = request.cookies.get("access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 경로 조합: /api/example/123 → 백엔드의 /test-api/example/123
const { searchParams } = new URL(request.url);
const queryString = searchParams.toString();
const apiPath = path.join("/");
const url = `${API_BASE}/admin/${apiPath}${queryString ? `?${queryString}` : ""}`;
const body = method !== "GET" && method !== "DELETE" ? await request.text() : undefined;
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body,
signal: controller.signal,
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}
// 모든 HTTP 메서드를 동일한 함수로 처리
export async function GET(req, { params }) {
return proxyRequest(req, params, "GET");
}
export async function POST(req, { params }) {
return proxyRequest(req, params, "POST");
}
- httpOnly 쿠키에서 access_token을 꺼낸다.
- 요청 경로 앞에 /test-api/을 붙여 백엔드 URL을 조합한다.
- Authorization 헤더에 토큰을 실어 백엔드로 전달한다.
문제 2: 백엔드 URL이 환경마다 다르다
관리자 백엔드는 localhost:8001(개발)과 프로덕션 서버가 다릅니다. 클라이언트에서 직접 호출하면 이 URL이 브라우저에 그대로 노출되는데 관리자 API의 주소가 외부에 드러나는 건 보안상 좋지 않아 서버 사이드에서 URL을 결정하면, 보안상의 문제도 해결할 수 있습니다.
// 환경변수로 백엔드 URL 결정 (서버 사이드에서만 접근)
const API_BASE =
process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
"http://localhost:8001";
문제 3: 인증 토큰을 안전하게 관리해야 한다
관리자 사이트는 Google OAuth로 로그인합니다. 백엔드에서 받은 JWT 토큰을 어디에 저장할 것인가의 문제가 있었습니다. localStorage에 넣으면 XSS에 취약하고, 일반 쿠키는 JavaScript에서 접근 가능합니다.
결국 httpOnly 쿠키를 사용하게 되었는데 이 쿠키는 브라우저 JavaScript에서 읽을 수 없으므로, 서버에서 꺼내 백엔드로 전달하는 중간 레이어가 필요해졌습니다. 이것이 API 프록시 레이어가 필요한 가장 직접적인 이유였습니다.
프록시 서버를 이용한 장점 1 : 코드가 깔끔해진다.
클라이언트 코드의 단순함입니다. 백엔드 URL도 모르고, 토큰 관리도 신경 쓸 필요가 없습니다.토큰 전달도 없이 그냥 /api/로 호출하여 간단히 작성했습니다.
export const getTextAPI = async (params): Promise<TTextListResponse> => {
const response = await fetch(`/test-api/test?${searchParams.toString()}`, {
credentials: "include", // 쿠키 자동 전송
});
return response.json();
};
export const patchTextAPI = async (id, data) => {
const response = await fetch(`/test-api/test/${id}/`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return response.json();
};
/api/test → catch-all이 /test-api/test로 변환 → 쿠키에서 토큰 꺼내서 Authorization 헤더에 주입 → 백엔드로 전달. 이 모든 과정이 프록시 레이어 안에서 자동으로 일어납니다. 새 기능이 추가될 때도 클라이언트에서 /api/test2를 호출하는 함수만 추가하면 되고 프록시 쪽은 건드릴 필요가 없어 좋습니다.
프록시 서버를 이용한 장점 2 : OAuth 콜백 흐름
인증 전체 흐름을 보면 프록시 레이어의 역할이 좀 더 명확졌던 것 같습니다.
- 사용자가 "Google 로그인" 클릭
- 백엔드(admin-api)의 OAuth URL로 이동
- Google 인증 완료 → 백엔드가 콜백 처리
- 백엔드가 프론트엔드로 리다이렉트 (URL에 토큰 포함)
→ /auth/callback/google?access_token=xxx&refresh_token=yyy - 콜백 페이지에서 /test-api/test-auth/tokens 호출 (BFF)
→ httpOnly 쿠키에 토큰 저장 - /test-api/test-auth/is-admin 호출해서 관리자 권한 확인
- 대시보드로 이동
토큰이 URL 파라미터로 잠깐 노출되긴 하지만, 곧바로 httpOnly 쿠키에 저장되고 URL에서 사라집니다. 이후 모든 요청은 쿠키가 자동 전송되므로, 클라이언트 코드에서 토큰을 직접 다룰 일이 없습니다.
마치며
사실 이 API 프록시 레이어는 "좋은 아키텍처를 설계하겠다"는 의도보다, 보안이나 CORS 같은 실무적인 문제를 풀기 위해 자연스럽게 만들어진 결과물이었습니다.
Next.js App Router를 사용하고 계신다면, API Routes가 단순한 서버리스 함수가 아니라 프론트엔드와 백엔드 사이의 유용한 중간 레이어가 될 수 있다는 점을 한번 고려해 보시면 좋겠습니다. 특히 [...path] catch-all 라우트 하나로 일반 프록시를 처리하고, 보안이 필요한 라우트만 개별로 분리하는 패턴은 코드 양 대비 꽤 실용적이었다고 생각합니다.
읽어주셔서 감사합니다.

