Middleware로 할 수 있는 것과 하면 안 되는 것
Article
이 글은 Next.js 15 이전 기준이다.
Next.js 16에서
middleware.ts는 deprecated 됐고proxy.ts로 이름이 바뀌었다. 내보내는 함수 이름도proxy이고, 기본 런타임이 Edge가 아니라 Node.js다. 아래에서 "Edge Runtime의 벽"이라 부른 제약 중 일부는 이제 해당하지 않는다. 옛 코드는npx @next/codemod@canary middleware-to-proxy .로 옮긴다.최신 기준으로 정리한 강의는 이쪽이다. proxy.ts 와 라우트 핸들러, 그리고 배포
다만 이 글의 결론은 지금도 유효하다. 거기에 너무 많은 걸 넣지 말라는 것. 공식 문서도 이 기능을 최후의 수단으로만 쓰라고 권한다.
Middleware에 너무 많은 걸 넣었다
Next.js의 Middleware를 처음 발견했을 때 만능 도구처럼 보였다. 모든 요청을 가로챌 수 있고 리다이렉트할 수 있고 헤더도 수정할 수 있으니, 그러면 인증도 여기서 하고 로깅도 여기서 하고 데이터 검증도 여기서 하면 되겠다고 생각했다.
그렇게 로직을 몰아넣었다가 Edge Runtime의 벽에 부딪혔다.
Middleware의 기본 동작
middleware.ts 파일 하나로 동작한다. 프로젝트 루트나 src/에 둔다.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
console.log('요청:', request.nextUrl.pathname);
return NextResponse.next();
}
// 어떤 경로에서 실행할지 지정
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
실행 시점은 요청이 들어올 때, 라우트 핸들러나 페이지가 실행되기 전이다. 실행 환경은 Edge Runtime이다. 일반 Node.js가 아니라는 것, 이 글의 대부분이 여기서 파생된다.
할 수 있는 것들
1. 인증 체크 & 리다이렉트
가장 흔한 사용 사례다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const { pathname } = request.nextUrl;
// 인증이 필요한 경로
if (pathname.startsWith('/dashboard')) {
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
}
// 이미 로그인한 사용자가 로그인 페이지 접근 시
if (pathname === '/login' && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
여기서 확인하는 건 토큰의 존재 여부까지다. 유효성 검증, 그러니까 DB 조회 같은 건 Server Component나 API Route에서 한다.
2. 리다이렉트 & 리라이트
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 오래된 URL 리다이렉트
if (pathname === '/old-blog') {
return NextResponse.redirect(new URL('/blog', request.url));
}
// URL은 유지하면서 다른 페이지 렌더링 (리라이트)
if (pathname === '/docs') {
return NextResponse.rewrite(new URL('/documentation/latest', request.url));
}
return NextResponse.next();
}
3. 헤더 추가/수정
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 보안 헤더 추가
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
// 요청 헤더에 정보 추가 (Server Component에서 읽기 위해)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-pathname', request.nextUrl.pathname);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
4. i18n 라우팅
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 이미 로케일이 있으면 패스
if (pathname.startsWith('/ko') || pathname.startsWith('/en')) {
return NextResponse.next();
}
// Accept-Language 헤더에서 선호 언어 감지
const locale = request.headers.get('accept-language')?.includes('ko')
? 'ko'
: 'en';
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
5. A/B 테스트
export function middleware(request: NextRequest) {
const bucket = request.cookies.get('ab-bucket')?.value;
const response = NextResponse.next();
if (!bucket) {
// 50/50 분배
const newBucket = Math.random() < 0.5 ? 'A' : 'B';
response.cookies.set('ab-bucket', newBucket);
}
return response;
}
하면 안 되는 것들
1. 무거운 인증 검증
// ❌ Middleware에서 하면 안 되는 것
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
// DB 조회 → Edge Runtime에서 대부분의 ORM이 작동하지 않음
const user = await prisma.user.findUnique({ where: { token } });
// JWT 검증 → 일부 crypto API가 Edge에서 지원 안 됨
const decoded = jwt.verify(token, process.env.SECRET);
}
Edge Runtime은 Node.js API를 일부만 지원한다. fs, net, child_process는 못 쓰고 일부 crypto 함수도 없다. Prisma나 TypeORM 같은 ORM은 대부분 작동하지 않는다.
그래서 Middleware에서는 토큰이 있는지만 보고, 상세 검증은 아래로 내린다.
// middleware.ts — 가볍게
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// app/(protected)/layout.tsx — 상세 검증
export default async function ProtectedLayout({ children }) {
const session = await verifySession(); // DB 조회, JWT 검증 등
if (!session) redirect('/login');
return <>{children}</>;
}
2. 데이터 가져오기 (fetch 남용)
// ❌ Middleware에서 외부 API 호출
export async function middleware(request: NextRequest) {
const config = await fetch('https://api.example.com/config');
const features = await fetch('https://api.example.com/features');
// ...
}
이게 위험한 건 실행 횟수 때문이다. 모든 요청마다 실행된다. 이미지, CSS, JS 요청에도. 외부 API가 느려지면 사이트 전체가 같이 느려진다. matcher를 걸어도 불필요한 fetch는 그대로 성능을 잡아먹는다.
3. 복잡한 비즈니스 로직
// ❌ Middleware에서 비즈니스 로직 처리
export async function middleware(request: NextRequest) {
const user = getUserFromToken(request);
// 구독 상태 확인
if (user.subscription === 'free' && request.nextUrl.pathname.startsWith('/premium')) {
return NextResponse.redirect(new URL('/upgrade', request.url));
}
// 사용량 제한
if (user.apiCalls > user.limit) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 });
}
}
구독 상태나 사용량 제한은 Server Component나 Route Handler의 일이다. Middleware가 내려야 할 결정은 라우팅 수준까지다.
역할 분담 정리
| 역할 | Middleware | Server Component | Route Handler |
|---|---|---|---|
| 토큰 존재 확인 | ✅ | - | - |
| 토큰 유효성 검증 | ❌ | ✅ | ✅ |
| 리다이렉트 | ✅ | ✅ (redirect) | ✅ |
| 헤더 추가 | ✅ | - | ✅ |
| DB 조회 | ❌ | ✅ | ✅ |
| 외부 API 호출 | ⚠️ (최소한) | ✅ | ✅ |
| 쿠키 설정 | ✅ | ✅ | ✅ |
| i18n 감지 | ✅ | - | - |
| 비즈니스 로직 | ❌ | ✅ | ✅ |
matcher 설정 팁
config.matcher를 제대로 설정하지 않으면 정적 파일 요청까지 포함해서 모든 요청에 Middleware가 실행된다.
export const config = {
matcher: [
// 정적 파일과 내부 Next.js 경로 제외
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
또는 필요한 경로만 명시적으로 지정한다.
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*', '/login', '/signup'],
};
경험상 명시적으로 지정하는 쪽이 낫다. "왜 이 요청에 Middleware가 실행되지?"라는 질문 자체가 줄어든다.
정리
Middleware는 문지기에 가깝다. 출입증이 있는지 확인하고 방향을 안내하는 정도. 출입증이 진짜인지 감별하거나, 방문자의 업무를 대신 처리하거나, 신상을 조회하는 건 다른 자리에서 할 일이다.
처음에 이 경계를 몰라서 한참 헤맸다. 로직을 몰아넣을수록 편해지는 게 아니라 Edge Runtime 에러 메시지를 읽는 시간만 길어졌다.