본문 바로가기

개발 & IT/프론트엔드

서버가 호출하는 API와 브라우저가 호출하는 API는 다르다 : NEXTJS

들어가며

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가 이를 기본 제공하지 않는 것이 아니라, 인증과 응답 가공 같은 요구사항에 따라 방식을 선택하도록 열어둔 것이다.

반응형