본문으로 건너뛰기

안동민 개발노트

본문 시작

API Proxy

Next.js 16 Proxy의 실행 위치와 matcher를 이해하고 인증·공통 헤더·요청 차단 경계를 설계합니다.

Next.js 16의 Proxy는 요청이 페이지나 라우트 핸들러에 도달하기 전에 실행되는 코드입니다.

공통 헤더 추가, 경로 변경, 간단한 인증 확인처럼 여러 경로에 반복되는 진입 규칙을 처리할 수 있습니다.

Proxy가 모든 업무 로직을 대신하는 것은 아닙니다.

데이터베이스를 조회해야 하는 소유권 판단과 실제 데이터 변경 권한은 라우트 핸들러에서 다시 확인합니다.

Next.js 16의 요청 전처리 파일은 proxy.ts다

프로젝트 루트 또는 src 아래에서 app과 같은 수준에 한 파일을 둔다.

  1. proxy.ts Next.js 16

    찾는 단일 진입 파일

  2. config.matcher 실행할 URL 범위

    제한한다.

  3. App Router 통과한 요청

    page 또는 route가 처리한다.


Proxy의 역할

Proxy는 요청 URL과 쿠키, 헤더를 읽고 다음 동작을 선택합니다.

  • 요청을 다음 처리 단계로 보냅니다.
  • 다른 URL로 리다이렉트합니다.
  • 브라우저 주소는 유지한 채 내부 대상을 다시 씁니다.
  • 오류나 JSON 응답을 즉시 반환합니다.

실행 범위가 넓을수록 모든 요청의 비용이 늘어납니다.

따라서 필요한 경로만 matcher로 선택합니다.


기본 Proxy 작성

프로젝트 루트의 src/proxy.ts 파일에서 요청을 받아 응답을 반환합니다.

src/proxy.ts
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-request-path', request.nextUrl.pathname);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: ['/api/:path*'],
};

이 Proxy는 /api/로 시작하는 요청에만 실행됩니다.

요청 헤더를 변경한 복사본을 다음 라우트 핸들러로 전달합니다.

응답 헤더와 요청 헤더는 용도가 다르므로 어느 방향에 값을 추가하는지 구분해야 합니다.

Proxy에서 바꾼 요청 header는 명시적으로 전달한다

원본 Headers를 복제하고 수정한 값을 다음 서버 경계로 넘긴다.

  1. request.headers 들어온 header

    읽는다.

  2. new Headers 원본

    변경하지 않고 복제한다.

  3. set 필요한 내부 header만 추가한다
  4. NextResponse.next

    request.headers 옵션으로 전달한다.


matcher 범위 설계

matcher는 Proxy가 실행될 경로를 정합니다.

export const config = {
  matcher: ['/dashboard/:path*', '/api/admin/:path*'],
};

/dashboard/:path*는 대시보드 아래의 모든 페이지를 포함합니다.

/api/admin/:path*는 관리자 API 아래의 모든 라우트를 포함합니다.

이미지와 빌드 산출물까지 모든 요청을 가로채면 불필요한 비용과 예외 처리가 늘어납니다.

보호할 URL 경계를 먼저 정한 뒤 가장 좁은 패턴을 사용합니다.

matcher는 Proxy의 실행 범위를 먼저 줄인다

API·정적·공개 자산을 matcher에서 제외해 Proxy의 실행 범위를 줄인다.

  1. URL 요청 pathname

    matcher와 비교한다.

  2. 대상 경

    Proxy의 가벼운 인증·분기 로직을 실행한다.

  3. API·정적·공개 경

    API는 Proxy를 건너뛰고 Route Handler가 직접 인증·인가한다.


Auth.js Proxy 연결

인증이 필요한 여러 경로에는 src/auth.ts에서 만든 auth를 Proxy로 내보냅니다.

src/proxy.ts
export { auth as proxy } from '@/auth';

export const config = {
  matcher: ['/dashboard/:path*'],
};

허용 여부는 중앙 Auth.js 설정의 authorized 콜백에서 판단합니다.

src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
  session: { strategy: 'jwt' },
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id ?? token.sub!;
        token.role = user.role ?? 'member';
      }

      return token;
    },
    session({ session, token }) {
      session.user.id = token.id;
      session.user.role = token.role;
      return session;
    },
    authorized({ auth }) {
      return Boolean(auth?.user);
    },
  },
});

로그인하지 않은 페이지 요청은 로그인 화면으로 이동할 수 있습니다.

API 요청은 리다이렉트보다 명확한 401 또는 403 JSON 응답이 필요한 경우가 많습니다.

따라서 이 예제는 관리자 API를 Proxy matcher에 넣지 않고, 라우트 핸들러에서 auth로 요청을 감싸 응답을 직접 구분합니다.

Auth.js auth를 Proxy 진입 함수로 내보낼 수 있다

auth.ts의 같은 세션 계약을 요청 앞단에서도 재사용한다.

  1. auth.ts Provider

    authorized 규칙을 정의한다.

  2. auth as proxy.ts

    Auth.js 경계를 재사용한다.

  3. authorized 세션

    URL로 통과 여부를 정한다.

  4. next · redirect 요청

    계속하거나 로그인으로 보낸다.


라우트 핸들러에서 최종 검사

Proxy를 통과했다는 사실만 믿고 중요한 API의 검사를 생략하지 않습니다.

먼저 예제를 독립적으로 실행할 수 있도록 신고 게시물 조회 계약을 가진 저장소 대역을 만듭니다.

src/repositories/posts.ts
export type ReportedPostSummary = {
  id: string;
  title: string;
  reportCount: number;
};

const reportedPosts: ReportedPostSummary[] = [
  { id: 'post-1', title: '검토가 필요한 게시물', reportCount: 3 },
];

export async function findReportedPosts(): Promise<ReportedPostSummary[]> {
  return reportedPosts;
}

운영 코드에서는 같은 반환 계약을 유지한 채 메모리 배열을 데이터베이스 조회로 교체합니다.

src/app/api/admin/posts/route.ts
import { auth } from '@/auth';
import { findReportedPosts } from '@/repositories/posts';

export const GET = auth(async (request) => {
  const user = request.auth?.user;

  if (!user) {
    return Response.json({ message: '로그인이 필요합니다.' }, { status: 401 });
  }

  if (user.role !== 'admin') {
    return Response.json({ message: '접근 권한이 없습니다.' }, { status: 403 });
  }

  const posts = await findReportedPosts();
  return Response.json(posts);
});

이 구조는 Proxy 범위가 바뀌더라도 API 자체의 권한 규칙을 지킵니다.

또한 인증 실패와 권한 부족을 서로 다른 상태 코드로 표현합니다.

Route Handler가 API 권한의 최종 판정을 맡는다

API가 Proxy matcher에서 제외되어도 Route Handler가 인증과 인가를 직접 수행한다.

  1. Route Request auth()

    정책으로 호출자를 판정한다.

  2. 세션 없음 401 응답
  3. 권한 없음 403 응답
  4. 허용 업무 처리 후 2xx

    응답


리다이렉트와 rewrite

NextResponse.redirect()는 브라우저의 URL을 실제로 바꿉니다.

로그인하지 않은 사용자를 로그인 페이지로 보낼 때 사용할 수 있습니다.

NextResponse.rewrite()는 브라우저 주소를 유지하고 내부에서 다른 페이지나 핸들러를 실행합니다.

점검 화면이나 지역별 콘텐츠를 내부적으로 선택할 때 유용합니다.

API 오류를 로그인 HTML로 rewrite하면 클라이언트가 응답 형식을 오해할 수 있습니다.

API는 상태 코드와 JSON 계약을 유지하는 편이 안전합니다.

redirect와 rewrite는 브라우저 주소 변화가 다르다

사용자를 다른 URL로 보낼지, 주소를 유지한 채 내부 처리만 바꿀지 결정한다.

  1. redirect 새 URL

    응답해 브라우저 주소도 바꾼다.

  2. rewrite 보이

    주소를 유지하고 내부 목적지만 바꾼다.


Proxy 운영 기준

Proxy에서는 긴 데이터베이스 조회와 외부 API 호출을 피합니다.

실행 경로는 matcher로 좁게 제한합니다.

인증 여부처럼 빠른 진입 판단만 수행하고 복잡한 업무 권한은 데이터 경계에서 확인합니다.

로그에 쿠키와 토큰 원문을 남기지 않습니다.

라우트 핸들러는 Proxy와 독립적으로 401, 403, 입력 오류를 반환할 수 있어야 합니다.

Proxy는 공통 진입 규칙을 단순하게 유지할 때 가장 효과적입니다.

Proxy는 운영 비용과 우회 경로를 함께 점검한다

앞단 분기가 빨라도 범위, 반복 이동, 최종 권한이 틀리면 안전하지 않다.

  1. Scope API·정적·public 자산

    matcher에서 제외했는가

  2. Cost DB 같

    무거운 작업을 피했는가

  3. Loop redirect

    rewrite가 순환하지 않는가

  4. Final auth Route Handler

    401·403을 최종 판정하는가