Next.js에서 middleware는 요청(Request)과 응답(Response) 사이에 실행되는 코드로, 요청을 가로채어 특정 작업을 수행할 수 있습니다.
이를 활용하면 인증(Authentication) 및 권한 부여(Authorization), 로깅(Logging), 리다이렉트 등의 기능을 효율적으로 구현할 수 있습니다.특히, 인가 처리에 활용하면 특정 페이지에 대한 접근 권한을 제어할 수 있습니다.
예를 들어, 로그인된 사용자만 접근할 수 있도록 설정하거나, 특정 역할(Role)을 가진 사용자에게만 특정 페이지를 허용하는 방식으로 사용할 수 있습니다.Next.js에서는 프로젝트의 middleware 파일을 통해 이러한 로직을 구현할 수 있습니다.
이 미들웨어는 요청이 들어올 때 실행되며, 사용자의 인증 상태를 확인하여 허가되지 않은 경우 로그인 페이지로 리다이렉트하거나, 접근을 제한하는 역할을 합니다.
또한, 특정 경로에 대해서만 실행되도록 설정할 수도 있어, 보호가 필요한 페이지에만 적용할 수 있습니다.결론적으로, Next.js의 middleware를 사용하면 페이지마다 별도로 인증 로직을 구현할 필요 없이 일관된 방식으로 인가 처리를 적용할 수 있으며, 보안성을 강화하는 데 도움이 됩니다.
app 또는 pages와 같은 레벨, 혹은 src 아래 루트)에 middleware.js 또는 middleware.ts 파일을 만들면 된다.proxy 파일 컨벤션으로 옮겨가는 중이며, 기존 middleware는 deprecated 예정이다.GET /dashboard 요청middleware를 호출middleware(request) 안에서NextResponse.next() 반환NextResponse.redirect()NextResponse.rewrite()new NextResponse("Blocked", { status: 403 })최상단 예시
// middleware.ts
import { NextResponse, NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const isLoggedIn = Boolean(request.cookies.get('auth_token')?.value);
// 로그인 안 된 사용자가 /dashboard 쪽으로 접근할 경우 로그인 페이지로 리다이렉트
if (!isLoggedIn && request.nextUrl.pathname.startsWith('/dashboard')) {
const loginUrl = new URL('/login', request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
NextRequest: URL, 헤더, 쿠키 등 요청 정보에 접근할 수 있는 객체NextResponse: next(), redirect(), rewrite(), json(), 쿠키/헤더 설정 등을 제공matcher로 특정 경로에만 적용
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
config.matcher를 사용하면, 해당 패턴에 맞는 경로에만 Middleware가 실행된다.'/((?!_next|static|.*\\..*).*)'처럼 정적 파일이나 _next 등을 제외하고 전부 적용['/dashboard/:path*', '/profile/:path*']처럼 보호가 필요한 경로만 적용NextRequest, NextResponse는 Proxy(전 Middleware) 함수 안에서 쓰는 요청·응답 객체이다.
NextRequestNextResponsenext(), redirect(), rewrite() 같은 헬퍼와, 헤더/쿠키 설정 기능을 제공한다.next/server에서 가져온다.import { NextRequest, NextResponse } from "next/server";export function middleware(req: NextRequest) {
const url = req.nextUrl; // NextURL 객체
const pathname = req.pathname; // "/dashboard"
const searchParams = url.searchParams; // URLSearchParams
const method = req.method; // "GET", "POST" 등
const headers = req.headers; // Headers 객체
const cookie = req.cookies.get("auth_token")?.value; // 쿠키 읽기
}nextUrlURL과 비슷하지만, Next.js 전용 속성(basePath, locale 등)을 가진 객체pathname, origin, searchParams 등을 편하게 다룰 수 있다.methodGET, POST 등)headersHeaders 객체get, set 같은 메서드는 있지만, Proxy에서는 주로 get만 사용한다.cookiesreq.cookies.get("name")로 읽을 수 있는 쿠키 컬렉션nextUrl을 복사(clone)해서 수정하는 패턴이 아주 자주 나온다.export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
if (url.pathname === "/") {
url.pathname = "/home";
return NextResponse.redirect(url); // 조작한 url로 redirect
}
return NextResponse.next();
}clone()을 쓰는 이유: 원본 nextUrl을 그대로 두고, 새로운 URL로 redirect/rewrite할 때 실수를 줄이기 위해서쿠키 읽기
const token = req.cookies.get("auth_token")?.value;
if (!token) {
// 로그인 안 된 상태
}
cookies.get(name) → { name: string; value: string } | undefinedNextResponse 쪽에서 함)NextResponse.next()export function middleware(req: NextRequest) {
// 아무 변경 없이 라우트/캐시로 넘김
return NextResponse.next();
}NextResponse.redirect()export function middleware(req: NextRequest) {
if (!req.cookies.get("auth_token")) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("from", req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}NextResponse.rewrite()export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
if (url.pathname.startsWith("/blog")) {
url.pathname = url.pathname.replace("/blog", "/news");
return NextResponse.rewrite(url);
}
return NextResponse.next();
}new NextReponse(), NextResponse.json()export function middleware() {
return new NextResponse("Forbidden", { status: 403 });
// 또는
// return NextResponse.json({ message: "Forbidden" }, { status: 403 });
}export function middleware(req: NextRequest) {
const res = NextResponse.next();
// 헤더 추가
res.headers.set("x-request-id", crypto.randomUUID());
// 쿠키 설정
res.cookies.set("experiment_group", "A", {
path: "/",
maxAge: 60 * 60 * 24, // 1일
httpOnly: true,
});
return res;
} res.headersHeaders 객체 (set, delete 등 가능)res.cookiesset(name, value, options)으로 응답 쿠키 설정delete(name)로 제거도 가능export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === "/") {
return NextResponse.redirect(new URL('/home', request.url));
}
return NextResponse.next();
}
/ → /home 리다이렉트/en, /ko 등)export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
if (url.pathname.startsWith('/blog')) {
url.pathname = url.pathname.replace('/blog', '/news');
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
/blog/...는 유지하면서 실제 처리는 /news/...에서 하는 식export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 응답 헤더 추가
response.headers.set('x-custom-header', 'my-value');
// 응답 쿠키 설정
response.cookies.set('experiment_group', 'A', {
path: '/',
maxAge: 60 * 60 * 24,
});
return response;
}
Content-Security-Policy, X-Frame-Options 등)를 통합 관리하거나,request.headers.get('x-forwarded-host'), request.nextUrl.hostname 등을 이용해 호스트에 따라 분기/login으로 보내기" 같은 로직에 최적화되어 있다./ → /ko 또는 /en 같은 언어 서브 경로로 리다이렉트request.nextUrl.locale을 사용한 리다이렉트/리라이트/pricing → /pricing-a 또는 /pricing-b로 rewrite/redirect이렇게 하면 클라이언트 코드 수정 없이도 서버 라우팅 레벨에서 실험 분기를 만들 수 있다.
미들웨어로 인가(authorization)를 한다는 건 결국 "요청이 라우트에 도달하기 전에, 이 사용자가 이 경로에 들어와도 되는지 미리 걸러낸다"라는 뜻이다.
sub(user id), role(권한), 만료 시간 등을 넣어둔다.middleware가 가장 먼저 실행된다./admin, /dashboard 등)와 유저 role을 비교해서NextResponse.next()로 통과/login으로 redirect/403 또는 메인으로 redirect// 예시: lib/access-control.ts
export const PUBLIC_PATHS = ["/", "/login", "/signup", "/about"];
export const ROLE_RULES = [
{ pattern: /^\/admin(\/|$)/, allowed: ["ADMIN"] },
{ pattern: /^\/dashboard(\/|$)/, allowed: ["USER", "ADMIN"] },
];PUBLIC_PATHS: 누구나 접근 가능ROLE_RULES: 정규식을 사용해서 /admin/**은 ADMIN만, /dashboard/**는 USER 이상 접근 가능JWT 검증 유틸 (Edge 환경 기준)
// lib/auth-token.ts
import { JWTPayload, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // 문자열을 Uint8Array(바이너리)로 변환
export type UserTokenPayload = JWTPayload & {
sub: string; // user id
role: "USER" | "ADMIN";
};
// 토큰을 검증해서 payload를 돌려주고, 실패하면 null을 리턴
export async function verifyAuthToken(
token: string
): Promise<UserTokenPayload | null> {
try {
const { payload } = await jwtVerify(token, secret);
return payload as UserTokenPayload;
} catch {
return null;
}
}
jose는 Edge 런타임에서도 잘 쓰이는 JWT 라이브러리이다.JWTPayloadiss, sub, aud, exp, iat 같은 표준 필드들을 포함하고 있는 타입jwtVerifyjwtVerify(token, secret) 형식으로 사용경로에 따른 role 매칭 유틸
// lib/access-control.ts
export const PUBLIC_PATHS = ["/", "/login", "/signup", "/forgot-password"];
export const ROLE_RULES = [
{ pattern: /^\/admin(\/|$)/, allowed: ["ADMIN"] },
{ pattern: /^\/dashboard(\/|$)/, allowed: ["USER", "ADMIN"] },
];
export function isPublicPath(pathname: string) {
return PUBLIC_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`)
);
}
export function getRequiredRoles(pathname: string): string[] | null {
const rule = ROLE_RUELS.find((r) => r.pattern.test(pathname));
return rule?.allowed ?? null;
}
middleware.ts에서 인가 처리
// middleware.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { verifyAuthToken } from "@/lib/auth-token";
import { isPublicPath, getRequiredRoles } from "@/lib/access-control";
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// 1) 공개 경로는 바로 통과
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// 2) auth 토큰 조회
const token = req.cookies.get("auth_token")?.value;
// 토큰 자체가 없으면 비로그인 → 로그인 페이지로 리다이렉트
if (!token) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("from", pathname); // 돌아올 위치
return NextResponse.redirect(loginUrl);
}
// 3) 토큰 검증
const user = await verifyAuthToken(token);
// 토큰이 만료되었거나 위조되었으면 다시 로그인 유도
if (!user) {
const loginUrl = new URL("/login", req.url);
return NextResponse.redirect(loginUrl);
}
// 4) 요청 경로에 필요한 role 확인
const requiredRoles = getRequiredRoles(pathname);
if (requiredRoles && !requiredRoles.includes(String(user.role))) {
// 권한 없음 → 403 페이지 등으로
const forbiddenUrl = new URL("/403", req.url);
return NextResponse.redirect(forbiddenUrl);
}
// 5) 모든 체크 통과 → 라우트로 진행
return NextResponse.next();
}
// 6) 어떤 경로에 미들웨어를 적용할지 설정
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
| 구분 | Middleware | Route Handler(route.ts) / API Route |
|---|---|---|
| 실행 시점 | 라우트 매칭 이전 | 특정 라우트로 매칭된 후 |
| 위치 | 루트의 middleware.ts | app/**/route.ts, pages/api/** |
| 주 목적 | 필터링, 리다이렉트, 가드 ,헤더·쿠키 수정 | 실제 비즈니스 로직, 데이터 CRUD |
| 응답 생성 | 가능하지만, 보통 간단 응답에 사용 | 주로 여기서 실제 JSON/HTML 반환 |
| 적용 범위 | matcher로 지정한 여러 경로에 공통 적용 | 각 라우트별로 개별 적용 |
복잡한 API는 Route Handler에게 맡기고, 그 전에 "이 요청이 들어와도 되는지?"를 판단하는 건 Middleware에서 처리하는 패턴이 가장 깔끔하다.
export function middleware(req: NextRequest) {
// 여기서 검사
return NextResponse.next();
}axiosInstance.interceptors.request.use((config) => {
// 여기서 검사
return config;
});axios.get("/api/...") 할 때axios.post("https://...") 할 때<a> 클릭 같은 건 전혀 관여하지 못한다.export async function middleware(req: NextRequest) {
const token = req.cookies.get("auth_token")?.value;
const { pathname } = req.nextUrl;
// 로그인 필요 페이지
if (!token && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}/dashboard URL에 들어오는 모든 요청(페이지, 데이터, SSR 등) 앞에서 쿠키를 보고 아예 /login으로 보내버릴 수 있다./login으로 바뀐다. ➡️ 완전한 라우팅 제어axiosInstance.interceptors.request.use((config) => {
const token = getTokenFromStorage();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});/dashboard 페이지에 그냥 들어오는 것은 막지 못하고,요약 비교
| 항목 | Next.js Middleware(Proxy) | axios 인터셉터 |
|---|---|---|
| 동작 위치 | 서버/프록시 레이어 (요청-응답 경계) | 클라이언트(또는 Node) 코드 안 |
| 대상 | 브라우저가 보내는 모든 HTTP 요청 (matcher 범위 내) | axios로 보낸 요청만 |
| 주 용도 | 라우팅 제어, 인가 가드, 리다이렉트, 헤더/쿠키 조작 | 공통 헤더 삽입, 공통 에러 처리, 로깅 |
| URL 변경 가능? | 가능 (redirect/rewrite) | 불가능 (요청 config만 수정) |
| 페이지 접근 차단 | URL 단위로 직접 막을 수 있음 | 직접 막진 못하고, API 실패로 간접 제어 |
| 이미지/정적 파일도? | matcher에 따라 포함될 수 있음 | 아예 대상 아님 |
matcher에 명시하고, 정적 자원 경로(/_next, /static, 이미지 경로 등)는 명시적으로 제외한다.fs 모듈 직접 사용, 특정 Node 서버 라이브러리 사용 등middleware.ts → proxy.tsexport function middleware → export function proxy기존 코드: middleware.ts
import { NextResponse } from "next/server";
export function middleware() {
return NextResponse.next();
}
변경된 코드: proxy.ts
import { NextResponse } from "next/server";
export function proxy() {
return NextResponse.next();
}
middleware.ts → proxy.tsmiddleware → proxynext.config.js에 실험 옵션 쓰고 있었다면) middleware* 관련 옵션 이름 → proxy*로 변경NextResponse, NextRequest 쓰는 방법export const config = { matcher: ... } 구조NextResponse.next / redirect / rewrite / json 등➡️ 그래서 내가 지금 쓰던 인증/로그/리다이렉트 로직은 파일/함수 이름만 맞게 바꾸면 그대로 동작한다고 생각하면 된다.
export const config = { runtime: 'edge' } 같이 runtime 옵션을 쓸 수 없다.middleware에서는 Edge runtime이 기본이고, 설정으로 Node를 선택하는 패턴도 있었는데, Proxy는 아예 runtime 선택을 막고 Node로 고정해 버렸다.middleware.ts → proxy.tsexport function middleware → export function proxyconfig.matcher 그대로runtime 설정 있었다면 제거