API Proxy
Next.js 16 Proxy의 실행 위치와 matcher를 이해하고 인증·공통 헤더·요청 차단 경계를 설계합니다.
Next.js 16의 Proxy는 요청이 페이지나 Route Handler에 이르기 전에 실행되는 코드입니다. 공통 헤더 추가, 경로 변경, 간단한 인증 확인처럼 여러 경로에 반복되는 진입 규칙을 처리할 수 있습니다. 다만 Proxy가 모든 업무 로직을 대신하지는 않습니다. 데이터베이스를 조회해야 하는 소유권 판단과 실제 데이터 변경 권한은 Route Handler에서 다시 확인합니다. 이 글에서는 Proxy의 역할과 matcher 범위를 정하는 방법, Auth.js와 연결하는 방법을 알아봅니다.
Proxy의 역할
Proxy는 요청 URL과 쿠키, 헤더를 읽고 다음 동작을 고릅니다. 요청을 다음 처리 단계로 보내거나, 다른 URL로 리다이렉트하거나, 브라우저 주소는 그대로 둔 채 내부 대상을 바꾸어 쓰거나(rewrite), 오류나 JSON 응답을 곧바로 돌려줄 수 있습니다. 실행 범위가 넓을수록 모든 요청의 비용이 늘어나므로, 필요한 경로만 matcher로 고릅니다.
Next.js 16에서 이전의 Middleware는 Proxy로 이름이 바뀌었고, 파일 이름도 middleware.ts에서 proxy.ts로, 내보내는 함수 이름도 proxy로 바뀌었습니다. Proxy는 Node.js 런타임에서 실행됩니다.
기본 Proxy 작성
이 프로젝트처럼 src/app을 쓰면 같은 src 아래의 proxy.ts에서 요청을 처리하고, app을 루트에 두면 proxy.ts도 루트에 둡니다. Proxy의 진입 파일은 하나뿐이며, 다음 기본 예제와 뒤의 Auth.js 연결 예제는 서로를 대신하는 구현입니다.
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*'],
};:path*는 세그먼트가 없는 경우도 포함하므로, 이 Proxy는 /api와 그 아래 경로에서 실행됩니다.
NEXT · 11.3 · API Proxy
Proxy는 수정한 요청 헤더를 다음 서버 처리에 전달합니다
/api/:path* matcher로 선택한 요청에서 Headers를 복사하고 x-request-path를 pathname으로 설정합니다. NextResponse.next({ request: { headers } })가 다음 처리에 전달합니다.
x-request-path는 다음 서버가 받을 요청 헤더이며, 브라우저에 보낼 응답 헤더는 별도로 설정합니다. 그림은 기본 Proxy 구현의 전달 경계입니다. 뒤의 Auth.js Proxy 코드는 같은 진입 파일을 대체하는 다른 예제입니다.
위 그림은 Proxy가 수정한 요청 헤더를 다음 서버 처리에 넘기는 방향을 보여 줍니다. 브라우저가 /api 경로로 보낸 요청은 /api/:path* matcher에 걸려 src/proxy.ts를 거칩니다. Proxy는 Headers를 복사해 x-request-path를 pathname으로 설정하고, NextResponse.next({ request: { headers } })로 그 헤더를 담은 내부 요청을 Route Handler에 넘깁니다. 그림의 설명처럼 x-request-path는 다음 서버가 받을 요청 헤더이며, 브라우저에 보낼 응답 헤더는 따로 설정해야 합니다.
matcher 범위 설계
matcher는 Proxy가 실행될 경로를 정합니다. config를 내보내지 않으면 Proxy는 프로젝트의 모든 경로에서 실행됩니다. 아래처럼 배열에 패턴을 여러 개 적으면 그중 하나에라도 맞는 요청에서만 실행되고, 나머지 요청은 Proxy를 거치지 않습니다. matcher 값은 빌드할 때 정적으로 분석되므로 변수로 계산한 값을 넣지 말고 문자열 그대로 적습니다.
export const config = {
matcher: ['/dashboard/:path*', '/api/admin/:path*'],
};/dashboard/:path*는 /dashboard 자체와 그 아래 경로를, /api/admin/:path*는 /api/admin 자체와 그 아래 경로를 포함합니다. 이미지와 빌드 산출물까지 모든 요청을 가로채면 불필요한 비용과 예외 처리가 늘어나므로, 보호할 URL의 범위를 먼저 정한 뒤 가장 좁은 패턴을 씁니다.
더 깊이: 요청 처리 순서 안에서 Proxy가 놓이는 자리
Next.js 문서의 proxy.js는 요청이 처리되는 순서를 이렇게 정리합니다. 먼저 next.config의 headers와 redirects가 적용되고, 그다음 Proxy가 실행되며, 이어서 beforeFiles rewrite, public/과 _next/static/, app/ 같은 파일 시스템 라우트, afterFiles rewrite, 동적 라우트, fallback rewrite 순으로 진행됩니다. Proxy가 정적 파일보다 먼저 실행되므로, matcher 없이 인증 리다이렉트를 걸면 CSS, JavaScript, 이미지 요청까지 막혀 화면이 깨질 수 있습니다.
서버 액션은 이 순서에서 따로 떨어진 라우트가 아닙니다. 서버 액션은 그 액션을 쓰는 경로로 보내는 POST 요청이므로, matcher에서 어떤 경로를 빼면 그 경로의 서버 액션 호출도 Proxy를 거치지 않습니다. 그래서 matcher를 고치거나 서버 액션을 다른 경로로 옮기는 리팩터링만으로 Proxy의 검사가 소리 없이 빠질 수 있습니다. 또 RSC 요청을 처리할 때 Next.js는 rsc, next-router-state-tree, next-router-prefetch 같은 내부 헤더를 Proxy의 request.headers에서 지워, HTML 요청과 RSC 요청을 서로 다르게 처리하는 실수를 막습니다.
이름이 Middleware에서 Proxy로 바뀐 데도 이런 성격이 반영되어 있습니다. 공식 문서는 Express.js의 미들웨어와 혼동되기 쉬웠다는 점과, 이 기능을 다른 방법이 없을 때 쓰는 마지막 수단으로 권한다는 점을 이유로 듭니다. Proxy라는 이름은 애플리케이션 앞에 놓인 네트워크 관문이라는 뜻을 드러내며, Next.js 16의 Proxy는 Node.js 런타임에서만 실행되고 runtime 설정을 지정하면 오류가 납니다.
Auth.js Proxy 연결
인증이 필요한 여러 경로에는 src/auth.ts에서 만든 auth를 Proxy로 내보냅니다. 앞의 기본 예제처럼 proxy 함수를 직접 작성하는 대신, Auth.js의 auth를 as proxy로 이름만 바꿔 내보내면 요청마다 세션을 읽는 Proxy가 됩니다. 이 파일은 앞 절의 src/proxy.ts를 대신하는 구현이므로 둘을 함께 두지 않으며, 여기서는 matcher를 /dashboard 아래로만 좁혔습니다.
export { auth as proxy } from '@/auth';
export const config = {
matcher: ['/dashboard/:path*'],
};Proxy로 직접 내보낸 auth의 허용 여부는 중앙 설정의 authorized 콜백에서 판단합니다. 다음 역할 코드는 10장의 UserRole 타입 확장을 전제로 하며, 기존 설정에 사용자 지정 로그인 페이지 등이 있다면 그 설정도 그대로 합칩니다.
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);
},
},
});지금의 콜백은 로그인 여부만 검사합니다. GitHub의 기본 프로필에 애플리케이션의 관리자 역할이 생기지는 않으므로, 관리자를 시험하려면 서버가 믿을 수 있는 역할 부여 경로도 필요합니다.
로그인하지 않은 페이지 요청은 로그인 화면으로 보낼 수 있지만, API 요청은 리다이렉트보다 분명한 401이나 403 JSON 응답이 필요한 경우가 많습니다. 그래서 이 예제는 관리자 API를 Proxy의 matcher에 넣지 않고, Route Handler에서 auth로 요청을 감싸 응답을 직접 구분합니다.
Route Handler에서 최종 검사
Proxy를 통과했다는 사실만 믿고 중요한 API의 검사를 생략하지 않습니다. 신고된 게시물을 조회하는 부분은 다음 저장소 대역으로 구성하며, 인증 설정과 역할 타입 확장은 앞의 코드가 준비되어 있어야 합니다. 10장 4절에서 같은 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;
}운영 코드에서는 같은 반환 형식을 유지한 채 메모리 배열을 데이터베이스 조회로 바꿉니다. 지금은 배열을 바로 돌려주면서도 findReportedPosts를 async 함수로 만든 것은 이 때문입니다. 호출하는 쪽이 처음부터 await로 기다리므로, 나중에 조회 방식이 바뀌어도 Route Handler는 고치지 않아도 됩니다. 다음 Route Handler는 이 함수를 부르기 전에 세션이 있는지, 그리고 역할이 admin인지를 차례로 확인합니다.
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);
});지금처럼 불리언을 돌려주는 authorized 콜백과 auth(handler) 구성에서는, 래퍼가 제공한 request.auth를 핸들러가 검사해 401과 403을 구분합니다. 콜백이 직접 Response를 돌려주도록 바꾸면 그 응답이 먼저 적용될 수 있으므로, 바꾼 인증 설정과 API의 응답 형식을 함께 확인합니다.
리다이렉트와 rewrite
NextResponse.redirect()는 다른 URL을 가리키는 HTTP 응답을 만듭니다. 페이지 탐색에서 이를 따라가면 주소창도 바뀌지만, fetch가 리다이렉트를 따라가는 것만으로 현재 페이지의 주소가 바뀌지는 않습니다. 로그인하지 않은 사용자를 로그인 페이지로 보낼 때 쓸 수 있습니다.
NextResponse.rewrite()는 브라우저 주소를 그대로 두고 내부에서 다른 페이지나 핸들러를 실행합니다. 점검 화면이나 지역별 콘텐츠를 내부적으로 고를 때 유용합니다. 다만 API 오류를 로그인 HTML로 rewrite하면 클라이언트가 응답 형식을 잘못 이해할 수 있으므로, API는 상태 코드와 JSON 응답 형식을 유지하는 편이 안전합니다.
Proxy 운영 기준
Proxy에서는 오래 걸리는 데이터베이스 조회와 외부 API 호출을 피하고, 실행 경로는 matcher로 좁게 제한합니다. 인증 여부처럼 빠른 진입 판단만 하고, 복잡한 업무 권한은 데이터를 다루는 Route Handler나 서버 액션에서 확인합니다. 로그에는 쿠키와 토큰의 원문을 남기지 않습니다.
정리
- Next.js 16의 Proxy는 요청이 페이지나 Route Handler에 이르기 전에 실행되며,
src/proxy.ts하나에서proxy함수나auth를 내보냅니다. matcher의:path*는 세그먼트가 없는 경우도 포함하며, 정적 파일과 빌드 산출물까지 가로채지 않도록 가장 좁은 패턴을 씁니다.NextResponse.next({ request: { headers } })는 다음 서버 처리에 넘길 요청 헤더를 바꿀 뿐, 응답 헤더를 만들지 않습니다.- Auth.js의
auth를 Proxy로 쓰면authorized콜백이 허용 여부를 정하며, API는 Route Handler에서 401과 403을 직접 구분합니다. - redirect는 다른 URL을 가리키는 응답을 만들고, rewrite는 주소를 유지한 채 내부 대상을 바꾸므로, API 오류를 HTML로 rewrite하지 않습니다.
연습 문제
1. 요청 헤더의 방향과 진입 파일
기본 헤더 Proxy를 쓰고, /api/users?search=김 요청에 클라이언트가 x-request-path: /admin을 넣었다고 가정합니다. 다음 Route Handler가 받을 x-request-path에는 어떤 값이 들어갈까요? 이 코드만으로 브라우저가 받을 응답 헤더에도 같은 값이 생길까요? Auth.js를 쓰는 대체 Proxy와 함께 쓰려면 proxy.ts를 두 개 만들면 되는지도 설명해 보세요.
해설 보기
복사한 Headers의 같은 이름을 pathname으로 덮어쓰므로 값은 /api/users이며, search 쿼리는 pathname에 들어가지 않습니다. request.headers 옵션은 다음 서버 처리에 넘길 요청 헤더이므로, 응답 헤더가 저절로 더해지지는 않습니다.
Proxy의 진입 파일은 하나뿐이므로, 기본 구현과 auth 내보내기를 그대로 중복해 선언할 수 없습니다. 둘을 합치려면 인증과 헤더 전달을 하나의 진입 흐름에서 처리하도록 설계하고, API의 최종 권한 검사도 유지해야 합니다. 공식 Proxy 헤더 안내도 요청 헤더 전달과 응답 헤더 설정을 구별합니다.
2. matcher가 고르는 경로
matcher: ['/dashboard/:path*', '/api/admin/:path*']일 때 /dashboard, /dashboard/settings, /api/admin, /api/administrator 가운데 Proxy가 실행되는 경로는 어디일까요?
해설 보기
/dashboard, /dashboard/settings, /api/admin에서 실행됩니다. :path*는 세그먼트가 없는 경우도 포함하므로 /dashboard와 /api/admin 자체도 대상입니다. /api/administrator는 /api/admin 다음에 /로 나뉜 세그먼트가 오는 경로가 아니므로 대상이 아닙니다.
3. Proxy를 믿고 검사를 뺀 API
/api/admin/:path*를 Proxy의 matcher에 넣고 로그인 여부를 검사하게 했으니, /api/admin/posts의 Route Handler에서는 auth 검사를 빼도 된다고 생각했습니다. 이 판단에는 어떤 문제가 있을까요?
해설 보기
Proxy는 로그인 여부 같은 빠른 진입 판단만 하므로, 관리자 역할 같은 업무 권한은 확인하지 않습니다. 또한 나중에 matcher를 바꾸거나 Proxy 설정을 고치면 그 API가 아무 검사 없이 열릴 수 있습니다. 데이터를 다루는 Route Handler는 Proxy와 관계없이 스스로 인증과 역할을 검사해 401과 403을 구분해야 합니다.
다음 글 외부 API와의 통합에서는 Route Handler와 서버 컴포넌트에서 외부 API를 안전하게 호출하고 결과를 다루는 방법을 알아봅니다.