들어가며
Next.js로 개발하다 보면 이런 상황을 마주친다. 분명히 서버에서 API를 잘 호출하고 있는데, 같은 주소를 클라이언트 컴포넌트에서 쓰면 요청이 실패한다. 로컬에서는 둘 다 되던 코드가, 배포하고 나면 클라이언트 쪽만 망가진다.
이 글은 그 원인 — API를 누가 호출하느냐에 따라 동작이 달라지는 이유 — 을 설명한다.
핵심 개념: API 호출의 주체
Next.js 앱에서 API를 호출하는 주체는 두 가지다.
- 서버: Server Component, API Route, Server Action 등에서 fetch()를 실행할 때. 코드는 Next.js가 돌아가는 머신에서 실행된다.
- 브라우저: Client Component에서 fetch()를 실행하거나,
처럼 브라우저가 직접 리소스를 요청할 때. 코드는 사용자의 기기에서 실행된다.
이 둘은 실행 환경이 완전히 다르다. 그래서 같은 URL이라도 접근 가능 여부가 달라진다.
구체적인 예시
백엔드 서버가 http://internal-api:8080에서 돌고 있다고 하자 (Docker나 Kubernetes 같은 내부 네트워크 환경을 가정한 예시다). 이 주소는 서버 네트워크 내부에서만 접근 가능하고, 외부에서는 막혀 있다.
[사용자 브라우저] ──── https://myapp.com ────> [Next.js 서버]
│
http://internal-api:8080
│
[백엔드 서버]
.env에 이렇게 설정했다고 하자:
NEXT_PUBLIC_API_URL=http://internal-api:8080
서버에서 호출할 때:
// Server Action (서버에서 실행)
async function getProducts() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products`)
return res.json()
}
잘 된다. Next.js 서버와 백엔드 서버가 같은 네트워크 안에 있으므로 http://internal-api:8080이 유효하다.
브라우저에서 호출할 때:
// Client Component (브라우저에서 실행)
'use client'
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_API_URL}/products`) // ❌ 실패
}, [])
실패한다. 요청을 보내는 주체가 사용자의 브라우저이고, 브라우저 입장에서 http://internal-api:8080은 존재하지 않는 주소다.
NEXT_PUBLIC_ 변수가 이 문제를 어떻게 만드나
앞선 예시에서 서버 코드와 클라이언트 코드가 같은 NEXT_PUBLIC_API_URL을 쓰고 있었다. 왜 이게 문제가 될까?
Next.js는 NEXT_PUBLIC_ prefix가 붙은 환경변수를 프로덕션 빌드 시 클라이언트 번들에 리터럴 문자열로 치환한다.
// 작성한 코드
<img src={`${process.env.NEXT_PUBLIC_API_URL}/files/thumbnail.jpg`} />
// 프로덕션 빌드 후 브라우저가 실행하는 코드
<img src="http://internal-api:8080/files/thumbnail.jpg" />
서버가 이 변수를 쓸 때는 문제없다. 서버는 http://internal-api:8080에 접근할 수 있으니까. 그런데 이 값이 클라이언트 번들에 박히는 순간, 브라우저가 접근할 수 없는 주소를 브라우저 코드에 심어버린다.
로컬에서 문제가 보이지 않는 이유도 이 때문이다. 로컬 개발 환경에서는 Next.js 서버와 브라우저가 같은 머신에서 실행되기 때문에, 브라우저도 localhost로 백엔드에 접근할 수 있다. 배포하면 그 전제가 무너진다.
해결책: 브라우저가 호출할 URL과 서버가 호출할 URL을 분리하라
브라우저는 항상 접근 가능한 주소 — 즉 Next.js 서버의 주소 — 만 알면 된다. 내부 백엔드 주소는 서버만 알면 충분하다.
환경변수 분리
# .env
# 서버 전용: 내부 백엔드 주소 (브라우저 번들에 포함되지 않음)
BACKEND_URL=http://internal-api:8080
BACKEND_URL은 NEXT_PUBLIC_ prefix가 없으므로 서버 사이드에서만 읽히고, 클라이언트 번들에는 포함되지 않는다.
Next.js API Route로 프록시 생성
// app/api/files/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const path = req.nextUrl.searchParams.get('path')
if (!path) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
// 서버가 내부 백엔드를 호출
const res = await fetch(
`${process.env.BACKEND_URL}/files/${encodeURIComponent(path)}`,
{ cache: 'no-store' },
)
if (!res.ok) {
return new NextResponse(null, { status: res.status })
}
const contentType = res.headers.get('content-type') ?? 'application/octet-stream'
const buffer = await res.arrayBuffer()
return new NextResponse(buffer, {
headers: { 'Content-Type': contentType },
})
}
클라이언트 코드에서 상대 경로 사용
// 수정 전 — 브라우저가 내부 백엔드를 직접 호출
<img src={`${process.env.NEXT_PUBLIC_API_URL}/files/thumbnail.jpg`} />
// 수정 후 — 상대 경로로 Next.js 서버에 요청
<img src="/api/files?path=thumbnail.jpg" />
브라우저는 현재 접속한 호스트(https://myapp.com)를 기준으로 /api/files?path=...에 요청하고, Next.js가 내부 백엔드에서 파일을 가져와 응답한다.
basePath를 쓰는 경우: next.config.ts에 basePath를 설정했다면
같은 일반 HTML 태그에는 basePath가 자동으로 붙지 않는다. 이 경우 /api/files?path=... 대신 /your-base-path/api/files?path=...처럼 직접 명시해야 한다.
요청 흐름이 이렇게 바뀐다:
// 수정 전
브라우저 ──> http://internal-api:8080/files/thumbnail.jpg ❌
// 수정 후
브라우저 ──> https://myapp.com/api/files?path=thumbnail.jpg ✅
│
[Next.js API Route]
│
http://internal-api:8080/files/thumbnail.jpg ✅
정리
서버브라우저
| 실행 위치 | Next.js가 돌아가는 머신 | 사용자의 기기 |
|---|---|---|
| 내부 네트워크 접근 | ✅ 가능 | ❌ 불가능 |
| 사용할 환경변수 | BACKEND_URL (비공개) | 불필요 (상대 경로 사용) |
핵심 원칙: 브라우저가 직접 호출해야 하는 URL은 반드시 브라우저에서 접근 가능한 공개 주소여야 한다. NEXT_PUBLIC_ 변수는 프로덕션 빌드 시 클라이언트 번들에 박히므로, 내부 백엔드 주소를 담으면 안 된다. 내부 백엔드 주소는 서버 전용 변수로 관리하고, Next.js API Route를 프록시로 두자.
Next.js에서 프록시를 구현하는 두 가지 방법
Nuxt를 써본 독자라면 이런 생각이 들 수 있다. "Nuxt에서는 @nuxtjs/proxy 하나로 끝났는데, Next.js에서는 Route Handler를 직접 만들어야 하나?"
Next.js에도 rewrites라는 범용 프록시 설정이 있다.
// next.config.ts
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://internal-api:8080/api/:path*',
},
]
},
}
이렇게 하면 /api/... 경로로 들어오는 요청을 모두 백엔드로 투명하게 넘긴다. Route Handler를 하나씩 만들 필요가 없고, Nuxt의 프록시 모듈과 가장 비슷한 방식이다.
그러나 이 방식으로는 처리할 수 없는 것들이 있다.
- 인증 토큰 주입: 쿠키에서 accessToken을 읽어 Authorization 헤더에 담는 작업은 서버 코드에서만 가능하다. rewrites는 요청을 그대로 넘길 뿐, 헤더를 조작할 수 없다.
- 응답 가공: 백엔드 응답을 클라이언트에 맞게 변환하거나, 에러 처리를 통일하는 작업도 rewrites에서는 불가능하다.
이런 요구사항이 있다면 Route Handler가 적합하다. 요청과 응답 사이에 서버 로직을 자유롭게 끼워 넣을 수 있기 때문이다.
rewritesRoute Handler
| 설정 복잡도 | 낮음 | 높음 (파일 직접 작성) |
|---|---|---|
| 인증 토큰 주입 | ❌ | ✅ |
| 응답 가공 | ❌ | ✅ |
| 적합한 상황 | 단순 프록시 | 인증·가공이 필요한 경우 |
Nuxt의 @nuxtjs/proxy는 rewrites와 비슷한 범용 프록시에 가깝다. Next.js가 이를 기본 제공하지 않는 것이 아니라, 인증과 응답 가공 같은 요구사항에 따라 방식을 선택하도록 열어둔 것이다.
'개발 & IT > 프론트엔드' 카테고리의 다른 글
| Next.js 캐싱의 기본값과 동적 렌더링 전환 조건 (0) | 2026.04.27 |
|---|---|
| Next.js의 redirect() vs router.push() — nginx 리버스 프록시 + basePath 환경에서의 함정 (0) | 2026.04.06 |
| Vue 개발자가 Next.js를 배우며 겪은 혼란: Server Component와 Client Component 이해하기 (0) | 2026.01.22 |
| JavaScript 개발자가 Flutter의 copyWith를 이해하기까지 (1) | 2026.01.06 |
| Dart 메서드 vs Getter: 언제 무엇을 사용해야 할까? (0) | 2026.01.06 |