안녕하세요. J4J입니다.
이번 포스팅은 Next.js 16에서 middleware.ts가 proxy.ts로 바뀐 배경과, 달라진 네트워크 경계를 실제 코드로 확인해보는 시간을 가져보려고 합니다.
middleware.ts가 proxy.ts로 바뀐 이유, Next.js 16 네트워크 경계 재정의
Next.js 15까지는 요청이 라우트 핸들러나 페이지에 도달하기 전에 가로채는 진입점으로 middleware.ts 파일을 사용했습니다.
인증 체크, 리다이렉트, 헤더 조작처럼 애플리케이션 앞단에서 처리해야 하는 로직을 이 파일 하나에 모아둘 수 있었습니다.
다만 middleware라는 이름은 Express.js 같은 서버 프레임워크의 middleware 개념과 혼동을 일으키기 쉬웠습니다.
Express의 middleware는 요청 처리 파이프라인 어디에나 여러 개를 자유롭게 연결할 수 있는 함수인 반면, Next.js의 middleware.ts는 애플리케이션당 하나만 존재하며 애플리케이션 앞단에서만 동작하는 전혀 다른 역할이었기 때문입니다.
이런 이유로 Next.js 16은 이 파일의 이름을 proxy.ts로 바꾸면서, 애플리케이션 앞단의 네트워크 경계라는 역할을 이름 자체로 드러내도록 했습니다.
처음 이 변경 소식을 접했을 때는 단순한 리네이밍으로 생각했지만, 실제로는 실행 런타임 자체가 함께 바뀐 더 큰 변화였습니다.
다음으로는 proxy.ts를 실제로 어떻게 작성하는지 살펴보겠습니다.
proxy.ts 기본 사용법
proxy.ts는 middleware.ts와 마찬가지로 프로젝트 루트, src 디렉토리를 사용한다면 src 최상단에 app 폴더와 같은 위치에 놓습니다.
export 방식도 동일한 자유도를 가지고 있어서 default export와 named export인 proxy 둘 다 사용할 수 있습니다.
다만 공식 문서는 어떤 방식을 선택하더라도 함수 이름 자체는 proxy로 맞출 것을 권장합니다.
가장 단순한 형태로 요청 경로를 로그로 남기는 proxy.ts부터 작성해 보겠습니다.
// src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request) => {
console.log(`요청 경로: ${request.nextUrl.pathname}`)
return NextResponse.next()
}
export const config = {
matcher: ['/protected/:path*'],
}
NextProxy는 proxy.ts를 위해 새로 추가된 타입으로, request와 event 파라미터의 타입을 직접 지정하지 않아도 자동으로 추론해 줍니다.
matcher를 담은 config export 방식은 middleware.ts와 완전히 동일해서, 특정 경로에서만 proxy가 실행되도록 그대로 재사용할 수 있습니다.
matcher를 아예 지정하지 않으면 정적 파일과 이미지 최적화 경로를 포함한 모든 요청에서 proxy가 실행된다는 점도 middleware.ts와 같습니다.
다음으로는 이 proxy.ts가 Next.js 16에서 어떤 런타임 위에서 동작하는지 살펴보겠습니다.
proxy.ts의 Node.js 런타임에서 가능해진 것
middleware.ts는 v15.5 이전까지 오랫동안 Edge 런타임만 사용할 수 있었고, v15.5부터는 config에서 runtime을 nodejs로 직접 지정해야만 Node.js 런타임을 선택적으로 쓸 수 있었습니다.
Edge 런타임은 Node.js의 일부 내장 모듈과 네이티브 애드온을 지원하지 않기 때문에, DB 클라이언트나 특정 인증 라이브러리를 middleware.ts 안에서 그대로 쓰지 못하는 경우가 많았습니다.
proxy.ts는 별도 설정 없이도 Node.js 런타임을 기본값이자 유일한 런타임으로 사용합니다.
체감할 수 있는 것 중 하나는, jsonwebtoken처럼 Node.js 환경을 전제로 만들어진 라이브러리를 별도의 우회 없이 proxy.ts 안에서 바로 import할 수 있다는 점입니다.
다만 runtime 옵션 자체는 proxy.ts에서 지원하지 않는다는 점을 알아두는 것이 좋습니다.
// ❌ proxy.ts에서 runtime을 직접 지정하려는 경우
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request) => {
return NextResponse.next()
}
export const config = {
runtime: 'edge',
matcher: ['/protected/:path*'],
}
이렇게 config에 runtime을 지정하면 에러가 발생합니다.
// ✅ runtime 옵션 없이 Node.js 런타임을 그대로 사용하는 경우
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request) => {
return NextResponse.next()
}
export const config = {
matcher: ['/protected/:path*'],
}
그러므로 Edge 런타임이 꼭 필요한 경우가 아니라면 runtime 옵션 자체를 생략하고 Node.js 런타임을 그대로 사용하는 것이 좋습니다.
Edge 런타임이 반드시 필요한 상황이라면 proxy.ts 대신 이후 살펴볼 middleware.ts를 계속 유지하는 방법도 있습니다.
이제 Node.js 런타임에서 jsonwebtoken을 사용하는 인증 체크 예시로, 로그인하지 않은 사용자를 보호된 페이지에서 걸러내는 proxy.ts를 작성해 보겠습니다.
// terminal
$ npm install jsonwebtoken
$ npm install --save-dev @types/jsonwebtoken
// src/lib/auth.ts
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET ?? 'nextjs-16-proxy-demo-secret'
export function signAuthToken(username: string) {
return jwt.sign({ username }, JWT_SECRET, { expiresIn: '1h' })
}
export function verifyAuthToken(token: string) {
try {
return jwt.verify(token, JWT_SECRET) as { username: string }
} catch {
return null
}
}
// src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
import { verifyAuthToken } from '@/lib/auth'
export const proxy: NextProxy = (request) => {
const token = request.cookies.get('auth-token')?.value
const payload = token ? verifyAuthToken(token) : null
if (!payload) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/protected/:path*'],
}
auth-token 쿠키가 없거나 검증에 실패하면 /login으로 리다이렉트하고, 검증에 성공하면 요청을 그대로 통과시킵니다.
jsonwebtoken이 Node.js의 crypto 모듈에 의존하는 라이브러리라는 점을 생각하면, 여전히 Edge 런타임이 기본값이던 시절의 middleware.ts였다면 이 코드를 그대로 쓰기 어려웠을 것입니다.
이 auth-token 쿠키를 실제로 발급하는 로그인 화면도 함께 작성해 보겠습니다.
// src/app/login/actions.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { signAuthToken } from '@/lib/auth'
export async function login(formData: FormData) {
const username = formData.get('username') as string
const token = signAuthToken(username)
const cookieStore = await cookies()
cookieStore.set('auth-token', token, { httpOnly: true, path: '/' })
redirect('/protected')
}
login 함수는 formData를 인자로 받아 서버에서 직접 실행되는 Server Action입니다.
Server Action의 기본 개념과 활용법은 React Server Action, API Route 없이 서버 함수를 호출하는 방법에서 자세히 다뤘습니다.
React Server Action, API Route 없이 서버 함수를 호출하는 방법
안녕하세요. J4J입니다. 이번 포스팅은 React Server Action이 무엇이고, 실무에서 어떻게 활용할 수 있는지에 대해 알아보는 시간을 가져보려고 합니다.
jforj.tistory.com
// src/app/login/page.tsx
import { login } from './actions'
export default function LoginPage() {
return (
<main className="flex flex-col gap-4 p-6">
<h1 className="text-xl font-bold">로그인</h1>
<form action={login} className="flex gap-2">
<input
name="username"
placeholder="아이디"
className="rounded border border-gray-300 px-2 py-1 text-sm"
required
/>
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
type="submit"
>
로그인
</button>
</form>
</main>
)
}
// src/app/protected/actions.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function logout() {
const cookieStore = await cookies()
cookieStore.delete('auth-token')
redirect('/login')
}
// src/app/protected/page.tsx
import { logout } from './actions'
export default function ProtectedPage() {
return (
<main className="flex flex-col gap-4 p-6">
<h1 className="text-xl font-bold">보호된 페이지</h1>
<p>proxy가 auth-token 쿠키를 확인해야만 볼 수 있는 화면입니다.</p>
<form action={logout}>
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
type="submit"
>
로그아웃
</button>
</form>
</main>
)
}
로그인 없이 /protected에 접근하면 proxy가 곧바로 /login으로 리다이렉트하고, 로그인 폼을 제출하면 auth-token 쿠키가 발급되어 보호된 페이지에 접근할 수 있습니다.
다음으로는 리다이렉트와 함께 자주 쓰이는 rewrite를 살펴보겠습니다.
proxy.ts의 리다이렉트와 rewrite로 요청 가로채기
NextResponse.redirect, NextResponse.rewrite, NextResponse.next는 middleware.ts에서 쓰던 것과 동일한 API로 proxy.ts에서도 그대로 사용할 수 있습니다.
request.cookies와 response.cookies의 get, set, delete 같은 메서드도 이름이나 동작 방식이 바뀌지 않았습니다.
다만 proxy에서 만든 헤더를 이후 서버 컴포넌트까지 전달하려고 할 때 자주 놓치는 부분이 있습니다.
// ❌ 헤더를 얹어도 업스트림에 전달되지 않는 경우
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request) => {
return NextResponse.next({
headers: { 'x-user-region': 'kr' },
})
}
이렇게 NextResponse.next에 headers를 직접 넘기면 x-user-region 헤더는 클라이언트로 가는 응답에만 붙고, 이 요청을 이어받는 서버 컴포넌트에서는 이 헤더를 읽을 수 없습니다.
// ✅ 업스트림 서버 컴포넌트까지 헤더를 전달하는 경우
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request) => {
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-region', 'kr')
return NextResponse.next({
request: { headers: requestHeaders },
})
}
그러므로 proxy에서 설정한 헤더를 서버 컴포넌트까지 전달하려면 headers 대신 request.headers 형태로 감싸는 것이 좋습니다.
이 방식은 rewrite와 함께 사용할 때도 동일하게 적용됩니다.
/old-info로 들어온 요청을 /new-info로 rewrite하면서, 함께 헤더까지 전달하는 예시를 작성해 보겠습니다.
// src/app/new-info/page.tsx
import { headers } from 'next/headers'
export default async function NewInfoPage() {
const headerList = await headers()
const region = headerList.get('x-user-region')
return (
<main className="flex flex-col gap-4 p-6">
<h1 className="text-xl font-bold">새 안내 페이지</h1>
<p>proxy가 전달한 x-user-region 헤더 값: {region}</p>
</main>
)
}
이제 지금까지 작성한 인증 체크와 rewrite를 하나의 proxy.ts로 합쳐 보겠습니다.
// src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextProxy } from 'next/server'
import { verifyAuthToken } from '@/lib/auth'
export const proxy: NextProxy = (request) => {
const { pathname } = request.nextUrl
if (pathname.startsWith('/protected')) {
const token = request.cookies.get('auth-token')?.value
const payload = token ? verifyAuthToken(token) : null
if (!payload) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
if (pathname === '/old-info') {
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-region', 'kr')
return NextResponse.rewrite(new URL('/new-info', request.url), {
request: { headers: requestHeaders },
})
}
return NextResponse.next()
}
export const config = {
matcher: ['/protected/:path*', '/old-info'],
}
/old-info 주소로 접근해도 브라우저 주소창은 그대로인 채 /new-info 페이지의 내용과 x-user-region 헤더 값이 그대로 표시됩니다.
다음으로는 이렇게 완성한 proxy.ts를 기존 middleware.ts와 함께 어떻게 정리해야 하는지 살펴보겠습니다.
기존 middleware.ts와 proxy.ts의 공존, 마이그레이션 전략
이미 middleware.ts를 쓰고 있던 프로젝트를 Next.js 16으로 올리는 경우, 이 파일을 당장 지우지 않아도 애플리케이션이 곧바로 깨지지는 않습니다.
다만 middleware.ts는 Edge 런타임 전용 용도로만 남겨진 deprecated 파일이며, 향후 버전에서 제거될 예정입니다.
공식 문서는 프로젝트당 proxy 파일을 하나만 지원한다고 안내하고 있을 뿐, middleware.ts와 proxy.ts가 동시에 존재할 때 정확히 어떤 우선순위나 에러가 발생하는지는 명시하지 않고 있습니다.
그러므로 마이그레이션 시점에는 두 파일을 함께 두기보다 middleware.ts를 proxy.ts로 완전히 옮기는 편이 안전합니다.
이 변환 작업은 공식 codemod로 자동화할 수 있습니다.
// terminal
$ npx @next/codemod@canary middleware-to-proxy .
이 명령을 실행하면 middleware.ts 파일명과 함수명이 proxy.ts, proxy로 자동 변경됩니다.
다만 codemod는 파일명과 함수명만 바꿔줄 뿐이므로, config에 runtime: 'edge' 같은 옵션이 남아있다면 직접 지우는 것이 좋습니다.
정리하면 다음과 같은 상황에서 proxy.ts로 전환하는 것이 좋습니다.
- DB 클라이언트나 jsonwebtoken처럼 Node.js 전용 라이브러리를 요청 가로채기 단계에서 바로 쓰고 싶은 경우
- Express.js 같은 서버 middleware와 이름이 혼동되어 팀 내에서 역할 설명이 반복적으로 필요했던 경우
- Next.js 16 이상으로 이미 업그레이드했고, 더 이상 Edge 런타임 특유의 콜드 스타트 이점이 필요하지 않은 경우
반대로 Edge 런타임에서만 얻을 수 있는 응답 속도나 배포 환경 제약이 있다면, deprecated 경고를 감수하고 middleware.ts를 당분간 유지하는 선택도 가능합니다.
이상으로 Next.js 16에서 middleware.ts가 proxy.ts로 바뀐 배경과 실제 활용 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > Next' 카테고리의 다른 글
| React Compiler와 Next.js 16: useMemo/useCallback이 사라지는 이유 (0) | 2026.08.02 |
|---|---|
| Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션 (0) | 2026.08.01 |
| Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리 (0) | 2026.07.26 |
| React Server Action, API Route 없이 서버 함수를 호출하는 방법 (0) | 2026.07.05 |
| [Next] Next13 이후로 MSW 사용하기 (3) - Storybook에서 사용하기 (1) | 2024.01.09 |
댓글