How to use Next.js as a backend for your frontend

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
20/79

Next.js는 "프론트엔드를 위한 백엔드(Backend for Frontend, 줄여서 BFF)" 패턴을 아주 잘 지원해요. 이 기능을 사용하면 HTTP 요청을 처리하고, 단순히 HTML뿐만 아니라 여러분이 원하는 어떤 형태의 콘텐츠든 반환할 수 있는 공개 엔드포인트(public endpoints)를 만들 수 있답니다. 당연히 데이터베이스 같은 데이터 소스에 접근하거나 원격 데이터를 업데이트하는 작업들도 처리할 수 있죠.

💡 강사의 부연 설명 & 실무 팁 1: BFF 패턴이 뭔가요?
실무에서 프론트엔드 화면을 그리다 보면, 백엔드 API에서 주는 데이터 형태가 우리 화면에 딱 맞지 않아서 데이터를 이리저리 가공해야 할 때가 많아요. 또 여러 개의 API를 호출해서 조합해야 할 때도 있죠.
클라이언트(브라우저)에서 이 모든 걸 처리하면 코드가 복잡해지고 느려집니다. 그래서 프론트엔드와 메인 백엔드 서버 사이에 '프론트엔드만을 위한 맞춤형 중간 서버(BFF)'를 두는 방식이 널리 쓰이게 되었어요. Next.js를 사용하면 이 BFF 서버를 따로 구축할 필요 없이 프론트엔드 프로젝트 안에서 한 번에 해결할 수 있다는 게 정말 강력한 장점입니다!

만약 새로운 프로젝트를 시작하신다면, create-next-app 명령어에 --api 플래그를 붙여보세요. 새로 생성된 프로젝트의 app/ 폴더 안에 API 엔드포인트를 어떻게 만드는지 보여주는 route.ts 예제 파일이 자동으로 포함된답니다.

pnpm create next-app --api
npx create-next-app@latest --api
yarn create next-app --api
bun create next-app --api

꼭 알아두면 좋은 점 (Good to know): Next.js의 백엔드 기능이 기존의 무거운 백엔드를 100% 완벽하게 대체하는 것은 아니에요. 이 기능은 다음과 같은 역할을 하는 'API 레이어'로써 동작합니다:

  • 외부에서 접근 가능한(publicly reachable) 엔드포인트를 제공해요.
  • 모든 종류의 HTTP 요청(GET, POST, PUT, DELETE 등)을 처리할 수 있어요.
  • 어떤 형식의 콘텐츠(JSON, XML, 이미지 등)든 반환할 수 있어요.

💡 강사의 실무 팁 2:
Next.js 백엔드는 가벼운 데이터 가공, 외부 API 키 숨기기, 웹훅 처리 등에는 최고예요. 하지만 웹소켓(WebSocket)을 활용한 실시간 채팅 서버를 만들거나, 백그라운드에서 오래 돌아가는 무거운 스케줄링(Cron) 작업 등에는 적합하지 않습니다. 이럴 때는 별도의 Node.js 서버나 Spring, Django 같은 본격적인 백엔드 서버를 두는 것이 좋습니다.

이 BFF 패턴을 프로젝트에 적용하려면 다음 기능들을 사용하시면 됩니다:


Public Endpoints (공개 엔드포인트)

Route Handlers(라우트 핸들러)는 누구나 접근할 수 있는 공개 HTTP 엔드포인트예요. 어떤 클라이언트든 이 주소로 요청을 보낼 수 있죠.

라우트 핸들러를 만들려면 파일명을 route.ts 또는 route.js로 지정해서 규칙을 따라주시면 됩니다:

//filename="/app/api/route.ts" switcher
export function GET(request: Request) {}
//filename="/app/api/route.js" switcher
export function GET(request) {}

이렇게 작성하면 /api 경로로 들어오는 GET 요청을 처리하게 됩니다. 참 쉽죠?

실행 중 오류(예외)가 발생할 수 있는 작업을 할 때는 반드시 try/catch 블록을 사용해서 안전하게 처리해주세요:

//filename="/app/api/route.ts" switcher
import { submit } from '@/lib/submit'

export async function POST(request: Request) {
  try {
    await submit(request)
    return new Response(null, { status: 204 })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected error'

    return new Response(message, { status: 500 })
  }
}
//filename="/app/api/route.js" switcher
import { submit } from '@/lib/submit'

export async function POST(request) {
  try {
    await submit(request)
    return new Response(null, { status: 204 })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected error'

    return new Response(message, { status: 500 })
  }
}

주의할 점: 클라이언트(브라우저)로 보내는 에러 메시지에 데이터베이스 비밀번호나 내부 시스템 구조 같은 민감한 정보가 노출되지 않도록 각별히 조심하셔야 해요.

아무나 접근하지 못하게 막고 싶다면, 인증(Authentication)과 인가(Authorization)를 구현해야 합니다. 자세한 내용은 Authentication (인증 가이드) 문서를 참고해주세요.


Content types (콘텐츠 타입 설정)

Route Handlers를 사용하면 일반적인 UI 화면뿐만 아니라 JSON, XML, 이미지, 파일, 일반 텍스트 등 UI가 아닌 응답도 내려줄 수 있어요.

Next.js는 자주 쓰이는 엔드포인트들을 위해 파일명 규칙을 미리 만들어 두었답니다:

물론 여러분이 원하는 커스텀 엔드포인트를 직접 정의할 수도 있죠. 예를 들면:

  • llms.txt
  • rss.xml
  • .well-known

예를 들어, app/rss.xml/route.ts 파일을 만들면 rss.xml에 대한 라우트 핸들러가 생성됩니다. 코드로 한 번 살펴볼까요?

//filename="/app/rss.xml/route.ts" switcher
export async function GET(request: Request) {
  const rssResponse = await fetch(/* rss endpoint */)
  const rssData = await rssResponse.json()

  const rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
 <title>${rssData.title}</title>
 <description>${rssData.description}</description>
 <link>${rssData.link}</link>
 <copyright>${rssData.copyright}</copyright>
 ${rssData.items.map((item) => {
   return `<item>
    <title>${item.title}</title>
    <description>${item.description}</description>
    <link>${item.link}</link>
    <pubDate>${item.publishDate}</pubDate>
    <guid isPermaLink="false">${item.guid}</guid>
 </item>`
 })}
</channel>
</rss>`

  const headers = new Headers({ 'content-type': 'application/xml' })

  return new Response(rssFeed, { headers })
}
//filename="/app/rss.xml/route.js" switcher
export async function GET(request) {
  const rssResponse = await fetch(/* rss endpoint */)
  const rssData = await rssResponse.json()

  const rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
 <title>${rssData.title}</title>
 <description>${rssData.description}</description>
 <link>${rssData.link}</link>
 <copyright>${rssData.copyright}</copyright>
 ${rssData.items.map((item) => {
   return `<item>
    <title>${item.title}</title>
    <description>${item.description}</description>
    <link>${item.link}</link>
    <pubDate>${item.publishDate}</pubDate>
    <guid isPermaLink="false">${item.guid}</guid>
 </item>`
 })}
</channel>
</rss>`

  const headers = new Headers({ 'content-type': 'application/xml' })

  return new Response(rssFeed, { headers })
}

💡 강사의 실무 팁 3:
XML이나 HTML 마크업을 동적으로 생성할 때는 데이터베이스나 외부 API에서 가져온 값이 그대로 삽입됩니다. 이때 악성 코드가 섞여 있을 수 있으니, 마크업을 생성하는 데 사용되는 모든 입력값은 반드시 살균(Sanitize) 처리를 거쳐야 한다는 점, 잊지 마세요! XSS(크로스 사이트 스크립팅) 공격을 방어하는 기본 중의 기본입니다.

Content negotiation (콘텐츠 협상)

요청(Request)의 Accept 헤더에 따라 동일한 URL에서 각기 다른 콘텐츠 타입을 제공하고 싶을 때가 있죠? 이때 헤더 매칭 기능과 함께 rewrites (재작성) 기능을 활용할 수 있습니다. 이를 콘텐츠 협상(Content negotiation)이라고 불러요.

예를 들어, 개발자 문서 사이트가 있다고 해볼게요. 일반 브라우저로 접속하면 예쁜 HTML 페이지를 보여주고, AI 에이전트가 접속하면 가공되지 않은 순수 마크다운(Markdown) 데이터를 동일한 /docs/… URL에서 제공할 수 있습니다.

1. Accept 헤더와 일치하는 rewrite 설정하기:

next.config.js 파일에서 아래와 같이 설정해줍니다.

//filename="next.config.js"
module.exports = {
  async rewrites() {
    return [
      {
        source: '/docs/:slug*',
        destination: '/docs/md/:slug*',
        has: [
          {
            type: 'header',
            key: 'accept',
            value: '(.*)text/markdown(.*)',
          },
        ],
      },
    ]
  },
}

이렇게 설정하면 /docs/getting-started로 오는 요청 중 Accept: text/markdown 헤더가 포함된 요청은 내부적으로 /docs/md/getting-started로 라우팅됩니다. 그럼 해당 경로의 라우트 핸들러가 마크다운 응답을 반환하게 되죠. Accept 헤더에 text/markdown이 없는 일반 클라이언트들은 원래대로 일반 HTML 페이지를 받게 됩니다.

2. 마크다운 응답을 위한 라우트 핸들러 생성하기:

//filename="app/docs/md/[...slug]/route.ts" switcher
import { getDocsMd, generateDocsStaticParams } from '@/lib/docs'

export async function generateStaticParams() {
  return generateDocsStaticParams()
}

export async function GET(_: Request, ctx: RouteContext<'/docs/md/[...slug]'>) {
  const { slug } = await ctx.params
  const mdDoc = await getDocsMd({ slug })

  if (mdDoc == null) {
    return new Response(null, { status: 404 })
  }

  return new Response(mdDoc, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      Vary: 'Accept',
    },
  })
}
//filename="app/docs/md/[...slug]/route.js" switcher
import { getDocsMd, generateDocsStaticParams } from '@/lib/docs'

export async function generateStaticParams() {
  return generateDocsStaticParams()
}

export async function GET(_, { params }) {
  const { slug } = await params
  const mdDoc = await getDocsMd({ slug })

  if (mdDoc == null) {
    return new Response(null, { status: 404 })
  }

  return new Response(mdDoc, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      Vary: 'Accept',
    },
  })
}

여기서 주목할 점은 응답 헤더에 Vary: Accept를 넣어주었다는 거예요! 이건 캐시 서버들에게 "이 응답 본문은 요청자의 Accept 헤더에 따라 달라지니 캐싱할 때 주의해!"라고 알려주는 역할을 해요. 이 헤더가 없으면 공용 캐시가 마크다운 응답을 일반 브라우저에 잘못 보여주는(또는 그 반대의) 대참사가 일어날 수 있습니다. 대부분의 호스팅 제공업체가 이미 캐시 키에 Accept 헤더를 포함하고 있긴 하지만, Vary를 명시적으로 설정해주면 모든 CDN과 프록시 캐시에서 100% 올바르게 동작함을 보장할 수 있습니다.

그리고 generateStaticParams를 사용하면 빌드 타임에 마크다운 변형들을 미리 렌더링(pre-render)해 둘 수 있어서, 매 요청마다 원본 서버를 건드리지 않고 엣지(Edge) 네트워크에서 초고속으로 데이터를 제공할 수 있습니다.

3. curl 명령어로 테스트해보기:

# 마크다운 형태를 반환합니다
curl -H "Accept: text/markdown" https://example.com/docs/getting-started

# 일반 HTML 페이지를 반환합니다
curl https://example.com/docs/getting-started

꼭 알아두면 좋은 점 (Good to know):

  • 위 설정에서 /docs/md/... 경로는 rewrite를 통하지 않더라도 브라우저에 직접 입력해서 접근할 수 있습니다. 만약 이 경로를 오직 rewrite를 통해서만 접근 가능하게 막고 싶다면, proxy 기능을 사용해서 예상되는 Accept 헤더가 없는 직접적인 요청을 차단하시면 됩니다.
  • 더 복잡하고 고급스러운 콘텐츠 협상 로직이 필요하다면, rewrite 대신 proxy를 사용하는 것이 훨씬 유연하고 좋습니다.

Consuming request payloads (요청 페이로드 데이터 읽기)

클라이언트가 보낸 데이터(Body)를 읽으려면 Request 인스턴스 메서드(instance methods).json(), .formData(), 혹은 .text() 등을 사용하시면 됩니다.

참고로 GETHEAD 요청은 Body(본문)를 가질 수 없다는 점 잊지 마세요!

//filename="/app/api/echo-body/route.ts" switcher
export async function POST(request: Request) {
  const res = await request.json()
  return Response.json({ res })
}
//filename="/app/api/echo-body/route.js" switcher
export async function POST(request) {
  const res = await request.json()
  return Response.json({ res })
}

꼭 알아두면 좋은 점 (Good to know): 넘어온 데이터를 다른 내부 시스템으로 전달하기 전에는 반드시 데이터가 올바른지 검증(Validate)해야 합니다! Zod 같은 라이브러리를 사용하시면 편해요.

//filename="/app/api/send-email/route.ts" switcher
import { sendMail, validateInputs } from '@/lib/email-transporter'

export async function POST(request: Request) {
  const formData = await request.formData()
  const email = formData.get('email')
  const contents = formData.get('contents')

  try {
    // 팁: 여기서 Zod 등을 활용해 이메일 형식 등을 철저히 검사하세요!
    await validateInputs({ email, contents })
    const info = await sendMail({ email, contents })

    return Response.json({ messageId: info.messageId })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}
//filename="/app/api/send-email/route.js" switcher
import { sendMail, validateInputs } from '@/lib/email-transporter'

export async function POST(request) {
  const formData = await request.formData()
  const email = formData.get('email')
  const contents = formData.get('contents')

  try {
    await validateInputs({ email, contents })
    const info = await sendMail({ email, contents })

    return Response.json({ messageId: info.messageId })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}

💡 강사의 강력 추천 팁 4: Request Body는 딱 한 번만 읽을 수 있어요!
이 부분은 실무에서 주니어 분들이 정말 많이 겪는 에러 중 하나입니다. request.json()이나 request.formData() 같은 메서드는 데이터를 스트림(Stream) 형태로 읽어들이기 때문에 한 번 읽고 나면 데이터가 소모되어 사라집니다. 만약 동일한 Body를 여러 번 읽어야 하는 상황이라면, 데이터를 읽기 전에 반드시 request.clone()을 사용해 복제해 두어야 해요. 공식 문서의 아래 코드를 꼭 눈여겨보세요!

//filename="/app/api/clone/route.ts" switcher
export async function POST(request: Request) {
  try {
    const clonedRequest = request.clone() // 복제!

    await request.body()
    await clonedRequest.body()
    await request.body() // 여기서 에러가 발생합니다 (이미 소모되었기 때문)

    return new Response(null, { status: 204 })
  } catch {
    return new Response(null, { status: 500 })
  }
}
//filename="/app/api/clone/route.js" switcher
export async function POST(request) {
  try {
    const clonedRequest = request.clone()

    await request.body()
    await clonedRequest.body()
    await request.body() // Throws error

    return new Response(null, { status: 204 })
  } catch {
    return new Response(null, { status: 500 })
  }
}

Manipulating data (데이터 조작하기)

라우트 핸들러를 사용하면 여러 데이터 소스에서 가져온 데이터를 변환(transform)하거나, 필터링(filter)하거나, 하나로 합치는(aggregate) 작업이 가능해요. 이 패턴을 사용하면 프론트엔드 코드에서 복잡한 로직을 제거할 수 있고, 백엔드 내부 시스템의 구조를 클라이언트에 노출하지 않아도 되니 보안상 매우 훌륭하죠.

또한, 무거운 계산 작업을 클라이언트 기기(스마트폰이나 브라우저) 대신 서버로 넘겨서 사용자의 배터리와 데이터 사용량을 절약해 줄 수도 있습니다.

//file="/app/api/weather/route.ts" switcher
import { parseWeatherData } from '@/lib/weather'

export async function POST(request: Request) {
  const body = await request.json()
  const searchParams = new URLSearchParams({ lat: body.lat, lng: body.lng })

  try {
    const weatherResponse = await fetch(`${weatherEndpoint}?${searchParams}`)

    if (!weatherResponse.ok) {
      /* 에러 처리 로직 */
    }

    const weatherData = await weatherResponse.text()
    // 프론트엔드에서 쓰기 편하게 서버에서 미리 가공해서 내려줍니다
    const payload = parseWeatherData.asJSON(weatherData)

    return new Response(payload, { status: 200 })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}
//file="/app/api/weather/route.js" switcher
import { parseWeatherData } from '@/lib/weather'

export async function POST(request) {
  const body = await request.json()
  const searchParams = new URLSearchParams({ lat: body.lat, lng: body.lng })

  try {
    const weatherResponse = await fetch(`${weatherEndpoint}?${searchParams}`)

    if (!weatherResponse.ok) {
      /* handle error */
    }

    const weatherData = await weatherResponse.text()
    const payload = parseWeatherData.asJSON(weatherData)

    return new Response(payload, { status: 200 })
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}

꼭 알아두면 좋은 점 (Good to know): 위 예제를 보면 사용자의 위치 정보(lat, lng)를 받아오는데 GET이 아닌 POST 메서드를 사용하고 있죠? 위도/경도 같은 지리적 위치 데이터가 URL에 포함되는 것을 막기 위함입니다. GET 요청은 URL에 파라미터가 노출되므로 캐시 서버나 서버 로그에 남아서 민감한 정보가 유출될 위험이 있거든요. 아주 좋은 보안 습관입니다!


Proxying to a backend (다른 백엔드로 프록시하기)

라우트 핸들러를 다른 백엔드 서버로 요청을 넘겨주는 프록시(proxy)처럼 사용할 수도 있어요. 실제 백엔드로 요청을 넘기기 전에 유효성 검사 로직을 추가할 수 있어서 아주 유용하죠.

//filename="/app/api/[...slug]/route.ts" switcher
import { isValidRequest } from '@/lib/utils'

export async function POST(request: Request, { params }) {
  const clonedRequest = request.clone()
  const isValid = await isValidRequest(clonedRequest)

  // 요청이 유효하지 않으면 실제 백엔드로 보내지 않고 여기서 차단합니다.
  if (!isValid) {
    return new Response(null, { status: 400, statusText: 'Bad Request' })
  }

  const { slug } = await params
  const pathname = slug.join('/')
  const proxyURL = new URL(pathname, 'https://nextjs.org')
  const proxyRequest = new Request(proxyURL, request)

  try {
    // 실제 목적지 서버로 요청을 대신 전달(Proxy)합니다.
    return fetch(proxyRequest)
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}
//filename="/app/api/[...slug]/route.js" switcher
import { isValidRequest } from '@/lib/utils'

export async function POST(request, { params }) {
  const clonedRequest = request.clone()
  const isValid = await isValidRequest(clonedRequest)

  if (!isValid) {
    return new Response(null, { status: 400, statusText: 'Bad Request' })
  }

  const { slug } = await params
  const pathname = slug.join('/')
  const proxyURL = new URL(pathname, 'https://nextjs.org')
  const proxyRequest = new Request(proxyURL, request)

  try {
    return fetch(proxyRequest)
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : 'Unexpected exception'

    return new Response(message, { status: 500 })
  }
}

또는 다음과 같은 방법을 쓸 수도 있어요:


NextRequest 와 NextResponse

Next.js는 웹 표준 API인 RequestResponse를 확장해서, 자주 쓰이는 작업들을 아주 쉽게 할 수 있도록 만들었어요. 이 확장된 객체들은 라우트 핸들러와 Proxy 파일 모두에서 사용할 수 있습니다.

이 둘은 쿠키를 읽고 조작하는 편리한 메서드들을 제공해요.

특히 NextRequest에는 nextUrl 이라는 유용한 속성이 포함되어 있습니다. 이 속성을 쓰면 들어오는 요청의 URL 값들이 예쁘게 파싱되어 있어서, 예를 들어 URL의 경로명(pathname)이나 쿼리 파라미터(search params)에 접근하기가 훨씬 수월해져요.

NextResponsenext(), json(), redirect(), rewrite() 같은 아주 유용한 헬퍼 함수들을 제공해 줍니다.

표준 Request 객체가 필요한 곳 어디에나 NextRequest를 넘겨줄 수 있고, 반대로 Response를 반환해야 하는 곳에서 NextResponse를 반환해도 전혀 문제가 없습니다. 호환성이 완벽하죠.

//filename="/app/echo-pathname/route.ts" switcher
import { type NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const nextUrl = request.nextUrl // 👈 이렇게 쉽게 URL 객체에 접근해요!

  if (nextUrl.searchParams.get('redirect')) {
    return NextResponse.redirect(new URL('/', request.url))
  }

  if (nextUrl.searchParams.get('rewrite')) {
    return NextResponse.rewrite(new URL('/', request.url))
  }

  return NextResponse.json({ pathname: nextUrl.pathname })
}
//filename="/app/echo-pathname/route.js" switcher
import { NextResponse } from 'next/server'

export async function GET(request) {
  const nextUrl = request.nextUrl

  if (nextUrl.searchParams.get('redirect')) {
    return NextResponse.redirect(new URL('/', request.url))
  }

  if (nextUrl.searchParams.get('rewrite')) {
    return NextResponse.rewrite(new URL('/', request.url))
  }

  return NextResponse.json({ pathname: nextUrl.pathname })
}

NextRequestNextResponse에 대해 더 자세히 알고 싶으시면 공식 API 문서를 확인해 보세요!


Webhooks and callback URLs (웹훅과 콜백 URL 처리)

서드파티 애플리케이션(외부 서비스)에서 발생하는 이벤트 알림을 받기 위해 라우트 핸들러를 사용할 수 있습니다.

가장 대표적인 예로, CMS(콘텐츠 관리 시스템, 예: Sanity, Contentful 등)에서 콘텐츠가 변경되었을 때 Next.js 화면을 갱신(revalidate)하는 경우예요. CMS 쪽에서 콘텐츠를 수정하면 이 특정 엔드포인트를 호출하도록 설정해 두는 거죠.

//filename="/app/webhook/route.ts" switcher
import { type NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  // 아무나 웹훅을 찌르면 안 되니까 토큰 검증은 필수!
  const token = request.nextUrl.searchParams.get('token')

  if (token !== process.env.REVALIDATE_SECRET_TOKEN) {
    return NextResponse.json({ success: false }, { status: 401 })
  }

  const tag = request.nextUrl.searchParams.get('tag')

  if (!tag) {
    return NextResponse.json({ success: false }, { status: 400 })
  }

  // 변경된 데이터를 새로고침 해주는 핵심 함수!
  revalidateTag(tag)

  return NextResponse.json({ success: true })
}
//filename="/app/webhook/route.js" switcher
import { NextResponse } from 'next/server'

export async function GET(request) {
  const token = request.nextUrl.searchParams.get('token')

  if (token !== process.env.REVALIDATE_SECRET_TOKEN) {
    return NextResponse.json({ success: false }, { status: 401 })
  }

  const tag = request.nextUrl.searchParams.get('tag')

  if (!tag) {
    return NextResponse.json({ success: false }, { status: 400 })
  }

  revalidateTag(tag)

  return NextResponse.json({ success: true })
}

또 다른 활용처는 콜백 URL(Callback URLs)입니다. 사용자가 외부 서비스(예: 소셜 로그인, 결제 시스템 등)에서 작업을 마치면, 해당 서비스가 사용자를 여러분이 지정한 콜백 URL로 돌려보냅니다. 이때 라우트 핸들러를 사용해서 응답이 정상인지 검증하고 사용자를 적절한 화면으로 리다이렉트 시키면 됩니다.

//filename="/app/auth/callback/route.ts" switcher
import { type NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const token = request.nextUrl.searchParams.get('session_token')
  const redirectUrl = request.nextUrl.searchParams.get('redirect_url')

  const response = NextResponse.redirect(new URL(redirectUrl, request.url))

  // 응답 객체에 직접 쿠키를 구워줍니다.
  response.cookies.set({
    value: token,
    name: '_token',
    path: '/',
    secure: true,
    httpOnly: true, // 프론트 자바스크립트에서 접근 불가하게 만들어 보안 강화!
    expires: undefined, // 세션 쿠키
  })

  return response
}
//filename="/app/auth/callback/route.js" switcher
import { NextResponse } from 'next/server'

export async function GET(request) {
  const token = request.nextUrl.searchParams.get('session_token')
  const redirectUrl = request.nextUrl.searchParams.get('redirect_url')

  const response = NextResponse.redirect(new URL(redirectUrl, request.url))

  response.cookies.set({
    value: token,
    name: '_token',
    path: '/',
    secure: true,
    httpOnly: true,
    expires: undefined, // session cookie
  })

  return response
}

Redirects (리다이렉트 처리)

다른 주소로 이동시키고 싶을 때는 next/navigation에서 제공하는 redirect 함수를 사용하면 아주 깔끔합니다.

//filename="app/api/route.ts" switcher
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
  redirect('https://nextjs.org/')
}
//filename="app/api/route.js" switcher
import { redirect } from 'next/navigation'

export async function GET(request) {
  redirect('https://nextjs.org/')
}

리다이렉트에 대한 더 자세한 정보는 redirectpermanentRedirect 문서에서 확인하실 수 있어요.


Proxy (프록시)

💡 강사의 부연 설명:
여기서 말하는 proxy는 보통 Next.js에서 이야기하는 middleware (미들웨어) 파일을 의미합니다. 요청이 실제 페이지나 라우트에 도달하기 전에 중간에서 가로채서 처리하는 문지기 역할을 하죠.

프로젝트당 단 하나의 proxy 파일만 허용됩니다. 특정 경로에서만 동작하게 하려면 config.matcher를 설정해 주세요. proxy에 대해 더 자세히 알아보세요.

요청이 실제 라우트 경로에 도달하기 전에 응답을 생성해 내고 싶을 때 proxy를 사용합니다.

//filename="proxy.ts" switcher
import { isAuthenticated } from '@lib/auth'

export const config = {
  matcher: '/api/:function*', // /api/ 로 시작하는 모든 요청에 적용
}

export function proxy(request: Request) {
  // 인증되지 않은 사용자라면 아예 라우트 핸들러로 넘기지도 않고 여기서 컷!
  if (!isAuthenticated(request)) {
    return Response.json(
      { success: false, message: 'authentication failed' },
      { status: 401 }
    )
  }
}
//filename="proxy.js" switcher
import { isAuthenticated } from '@lib/auth'

export const config = {
  matcher: '/api/:function*',
}

export function proxy(request) {
  if (!isAuthenticated(request)) {
    return Response.json(
      { success: false, message: 'authentication failed' },
      { status: 401 }
    )
  }
}

proxy를 사용해서 요청 경로를 뒤에서 몰래 바꿔칠(rewrite) 수도 있습니다:

//filename="proxy.ts" switcher
import { NextResponse } from 'next/server'

export function proxy(request: Request) {
  if (request.nextUrl.pathname === '/proxy-this-path') {
    const rewriteUrl = new URL('[https://nextjs.org](https://nextjs.org)')
    return NextResponse.rewrite(rewriteUrl)
  }
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'

export function proxy(request) {
  if (request.nextUrl.pathname === '/proxy-this-path') {
    const rewriteUrl = new URL('[https://nextjs.org](https://nextjs.org)')
    return NextResponse.rewrite(rewriteUrl)
  }
}

또한 proxy로 직접 리다이렉트 응답을 만들어낼 수도 있죠:

//filename="proxy.ts" switcher
import { NextResponse } from 'next/server'

export function proxy(request: Request) {
  if (request.nextUrl.pathname === '/v1/docs') {
    request.nextUrl.pathname = '/v2/docs' // 구버전 문서를 찾으면 신버전으로 이동!
    return NextResponse.redirect(request.nextUrl)
  }
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'

export function proxy(request) {
  if (request.nextUrl.pathname === '/v1/docs') {
    request.nextUrl.pathname = '/v2/docs'
    return NextResponse.redirect(request.nextUrl)
  }
}

Security (보안 전략)

Working with headers (헤더 다루기)

헤더가 어디로 가는지 항상 의식하면서 개발하셔야 합니다. 클라이언트에서 들어온 요청 헤더를 그대로 외부로 나가는 응답 헤더에 전달하는 것은 피하세요.

  • Upstream request headers (서버로 들어오는 요청 헤더): Proxy 파일에서 NextResponse.next({ request: { headers } })를 사용하면 여러분의 서버가 받는 헤더를 수정할 수 있으며, 이 정보는 클라이언트에게 노출되지 않습니다.
  • Response headers (클라이언트로 나가는 응답 헤더): new Response(..., { headers }), NextResponse.json(..., { headers }), NextResponse.next({ headers }), 또는 response.headers.set(...)를 사용하면 클라이언트(브라우저)로 헤더가 전송됩니다. 여기에 민감한 값을 실수로 넣으면 클라이언트가 그대로 볼 수 있으니 절대 주의하세요!

더 자세한 내용은 NextResponse headers in Proxy 문서에서 확인할 수 있습니다.

Rate limiting (요청 속도 제한)

Next.js 백엔드에 Rate limiting(사용자가 짧은 시간에 너무 많은 요청을 보내지 못하게 제한하는 기능)을 직접 구현할 수 있습니다. 코드 수준의 검사 외에도, 여러분이 사용하는 호스팅 서비스(예: Vercel, AWS WAF 등)에서 제공하는 속도 제한 기능을 활성화하는 것을 권장합니다.

//filename="/app/resource/route.ts" switcher
import { NextResponse } from 'next/server'
import { checkRateLimit } from '@/lib/rate-limit'

export async function POST(request: Request) {
  const { rateLimited } = await checkRateLimit(request)

  if (rateLimited) {
    return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
  }

  return new Response(null, { status: 204 })
}
//filename="/app/resource/route.js" switcher
import { NextResponse } from 'next/server'
import { checkRateLimit } from '@/lib/rate-limit'

export async function POST(request) {
  const { rateLimited } = await checkRateLimit(request)

  if (rateLimited) {
    return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
  }

  return new Response(null, { status: 204 })
}

Verify payloads (페이로드 데이터 검증하기)

외부에서 들어오는 요청 데이터는 절대 믿으시면 안 됩니다. 데이터가 사용되기 전에 콘텐츠 타입, 크기를 반드시 검증하고, XSS 공격을 막기 위해 살균(sanitize) 처리를 하세요.

또한 무자비한 남용을 막고 서버 자원을 보호하기 위해 적절한 타임아웃(timeout)을 설정해야 합니다.

사용자가 업로드하는 정적 에셋(이미지, 파일 등)은 전용 스토리지 서비스(AWS S3, Cloudflare R2 등)에 저장하세요. 가능하다면 브라우저에서 스토리지로 직접 업로드하도록 처리(Pre-signed URL 방식)하고, 여러분의 데이터베이스에는 업로드 완료 후 반환된 주소(URI)만 저장해서 Next.js 서버의 요청 부하를 줄이는 것이 최고의 설계 방식입니다.

Access to protected resources (보호된 리소스 접근 제어)

중요한 정보에 접근할 때는 항상 권한(credentials)을 확인하세요. Proxy(미들웨어)에만 의존해서 인증과 인가를 100% 처리하려고 하면 구멍이 생길 수 있습니다. (라우트 핸들러나 서버 컴포넌트 내부에서도 재차 확인하는 것이 안전합니다.)

응답 데이터나 백엔드 로그에는 민감하거나 불필요한 정보가 남지 않도록 모두 제거해 주세요.

마지막으로 권한 정보나 API 키는 주기적으로 교체(Rotate)하는 습관을 들이세요!


Preflight Requests (사전 요청 처리)

브라우저에서 CORS(교차 출처 리소스 공유) 정책에 의해 본 요청을 보내기 전, OPTIONS 메서드를 사용하여 서버에게 "이 출처(origin), 메서드, 헤더로 요청을 보내도 되나요?"라고 먼저 물어보는 과정이 있는데, 이것을 사전 요청(Preflight requests)이라고 합니다.

여러분이 직접 OPTIONS 핸들러를 정의하지 않아도 괜찮아요. Next.js가 다른 정의된 메서드들을 바탕으로 Allow 헤더를 설정하여 자동으로 OPTIONS 응답을 처리해 주니까요.


Library patterns (라이브러리 사용 패턴)

커뮤니티에서 제공하는 오픈소스 라이브러리들은 Route Handlers를 위해 '팩토리 패턴(factory pattern)'을 자주 사용합니다.

//filename="/app/api/[...path]/route.ts"
import { createHandler } from 'third-party-library'

const handler = createHandler({
  /* 라이브러리 전용 설정 옵션들 */
})

export const GET = handler
// 또는 이렇게 내보낼 수도 있습니다:
export { handler as POST }

이 방식은 GET이나 POST 요청 모두에 대해 공통된 핸들러 로직을 만들어 냅니다. 라이브러리 내부에서 요청의 methodpathname에 따라 동작을 알아서 커스터마이징 해주는 구조죠. (예: NextAuth.js 가 이런 방식을 사용합니다.)

라이브러리들은 proxy 생성을 위한 팩토리를 제공하기도 합니다.

//filename="proxy.ts"
import { createMiddleware } from 'third-party-library'

export default createMiddleware()

꼭 알아두면 좋은 점 (Good to know): 서드파티 라이브러리 문서들을 읽다 보면 proxy라는 단어 대신 middleware라고 부르는 경우가 여전히 많으니 혼동하지 마세요!


More examples (더 많은 예제 살펴보기)

Router Handlers 예제 모음proxy API 레퍼런스에서 더 다양한 활용 사례를 만나보세요.

Cookies (쿠키 다루기), Headers (헤더 다루기), Streaming (스트리밍), Proxy에서의 negative matching (특정 경로 제외하기) 등 유용하고 실용적인 코드 조각들이 많이 준비되어 있습니다.


Caveats (주의사항 및 한계점)

💡 강사의 팁: 새로운 기술을 배울 때는 장점보다 "어떨 때 쓰면 안 되는가(제약 사항)"를 정확히 아는 것이 훨씬 중요합니다! 집중해서 읽어주세요.

Server Components (서버 컴포넌트와의 관계)

서버 컴포넌트(Server Components)에서 데이터를 가져올 때는 Route Handlers를 거치지 말고 데이터베이스나 외부 API 소스에 직접 요청하세요.

빌드 타임(build time)에 사전 렌더링(pre-rendered)되는 서버 컴포넌트의 경우, 라우트 핸들러를 호출하려고 하면 빌드 에러가 납니다. 왜냐하면 빌드하는 그 순간에는 라우트 핸들러의 요청을 받아줄 서버가 아직 실행되지 않은 상태이기 때문이죠.

요청이 들어올 때마다 동적으로 렌더링(on demand)되는 서버 컴포넌트라 할지라도, 라우트 핸들러를 통해 데이터를 가져오면 속도가 느려집니다. 왜냐하면 렌더링 프로세스와 라우트 핸들러 사이에 추가적인 HTTP 네트워크 왕복(round trip) 비용이 발생하기 때문입니다.

서버 사이드에서 fetch 요청을 보낼 때는 절대 경로(absolute URLs)를 사용해야 합니다. 이는 외부 서버로 HTTP 요청이 한 번 더 왕복한다는 것을 뜻하죠. 개발 환경에서는 여러분의 로컬 개발 서버가 그 외부 서버 역할을 하게 됩니다. 빌드 타임에는 아예 서버 자체가 없고, 실제 서비스가 돌아갈 때(runtime)는 여러분의 실제 퍼블릭 도메인을 통해 서버에 접근하게 됩니다. 굳이 내 서버 안에서 다시 내 서버의 API를 HTTP로 호출할 필요 없이 함수로 직접 가져오는 게 훨씬 빠릅니다.

대부분의 데이터 페칭(fetching) 작업은 서버 컴포넌트로 해결이 가능합니다. 하지만 다음과 같은 경우에는 클라이언트 사이드에서 데이터를 가져오는 것이 여전히 필요할 수 있어요:

  • 클라이언트 브라우저에서만 쓸 수 있는 Web API에 의존하는 데이터일 때:
    • Geo-location API (사용자 현재 위치)
    • Storage API (로컬 스토리지 등)
    • Audio API
    • File API
  • 짧은 주기로 계속해서 새로고침(Polled) 되어야 하는 데이터일 때

이런 경우에는 swr이나 react-query 같은 훌륭한 커뮤니티 라이브러리를 클라이언트에서 사용하시면 됩니다.

Server Actions (서버 액션)

서버 액션(Server Actions)은 클라이언트 화면에서 직접 서버 측 코드를 실행할 수 있게 해주는 기능이에요. 이 기능의 가장 주된 목적은 프론트엔드 클라이언트에서 데이터를 변경(mutate, 예: 폼 제출, DB 업데이트)하는 것입니다.

중요한 점은 서버 액션은 큐(queue)에 들어가서 순차적으로 처리된다는 점이에요. 그래서 단순히 데이터를 조회(fetching)하는 용도로 서버 액션을 남용하면, 병렬로 빠르게 처리할 수 있는 것도 순차적으로 대기하게 되어 성능이 떨어질 수 있습니다. 조회는 서버 컴포넌트나 라우트 핸들러를 이용하세요!

export mode (정적 내보내기 모드)

export 모드는 런타임에 동작하는 Node.js 서버 없이 완전히 정적인 HTML, CSS, JS 파일들만 생성해 내는 모드입니다. 런타임 서버가 아예 없기 때문에, Next.js의 동적인 런타임 기능들은 자연스럽게 지원되지 않습니다.

export mode에서는 오직 GET 요청 라우트 핸들러만 사용할 수 있으며, 그마저도 라우트 세그먼트 설정(route segment config)인 dynamic 값을 'force-static'으로 설정해야만 동작합니다.

이 방식을 활용하면 빌드 타임에 정적인 HTML, JSON, TXT 같은 파일들을 쾅쾅 찍어낼 수 있죠.

//filename="app/hello-world/route.ts"
export const dynamic = 'force-static'

export function GET() {
  return new Response('Hello World', { status: 200 })
}

Deployment environment (배포 환경의 특수성)

Vercel이나 AWS Lambda 같은 일부 호스팅 환경은 라우트 핸들러를 '서버리스 람다 함수(lambda functions)' 형태로 배포합니다. 이게 무슨 의미냐면요:

  • 각각의 요청은 독립된 환경에서 실행되므로, 요청들 사이에 메모리 데이터를 계속해서 공유할 수 없습니다. (전역 변수로 뭔가 저장해두면 날아갈 수 있어요!)
  • 실행 환경에 따라 파일 시스템(File System)에 파일을 직접 쓰는 기능(Write)이 지원되지 않을 수 있습니다.
  • 무거운 작업을 돌려서 응답이 너무 오래 걸리면(Timeout), 핸들러가 강제로 종료될 수 있습니다.
  • 타임아웃이 발생하거나 응답이 한 번 생성되고 나면 연결이 바로 뚝 끊기기 때문에, 지속적인 연결이 필요한 WebSockets(웹소켓) 기능은 서버리스 환경에서 정상적으로 동작하지 않습니다.

API Reference (API 레퍼런스)

Route Handlers, Proxy, 그리고 Rewrites에 대해 더 깊이 공부하고 싶으시다면 아래 공식 레퍼런스 문서들을 꼭 읽어보시길 권장합니다.

  • route.js
    • route.js 특수 파일에 대한 공식 API 레퍼런스.
  • proxy.js
    • proxy.js 파일에 대한 공식 API 레퍼런스.
  • rewrites
    • Next.js 앱에 라우트 재작성(rewrites) 기능을 추가하는 방법.

모든 공식 문서의 체계적인 구조를 보고 싶으시다면 사이트맵 (sitemap.md)을 확인해주세요.

사용 가능한 전체 문서의 색인(index) 목록이 필요하시다면 llms.txt를 참고하시면 됩니다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글