
로그인하지 않은 사람이 주소창에 /dashboard 를 치면 로그인 페이지로 보내야 한다. 제일 먼저 떠오르는 방법은 페이지마다 검사 코드를 넣는 것이다. 페이지가 셋일 때는 이걸로 된다. 그런데 페이지가 스물이 되면 새로 만들 때마다 같은 세 줄을 붙여야 하고, 한 번 빠뜨린 페이지는 로그인 없이도 열린다.
로그인 확인을 공통으로 처리하는 방법을 찾다 보면 미들웨어와 프록시를 함께 접하게 된다.
두 개념의 역할을 구분하고 Next.js에서 각각 어떻게 사용하는지 정리했다.
1. 미들웨어가 실행되는 시점
브라우저에서 주소를 치면 그 주소가 서버로 간다. 이걸 요청이라고 한다. 서버는 그 요청을 받아서 어떤 페이지를 그릴지 정하고, 결과를 돌려준다. 여기까지가 기본 흐름이다.
그런데 요청이 서버에 들어와서 페이지를 그리기까지는 한 번에 끝나는 일이 아니다. 주소를 읽고, 어떤 라우트인지 찾고, 그 라우트의 코드를 돌린다. 이 순서를 요청 처리 파이프라인이라고 한다. 요청을 정해진 순서에 따라 여러 단계로 처리한다는 뜻이다.
미들웨어는 그 파이프라인 중간에 끼워 넣는 한 단계다. 요청이 페이지 코드에 닿기 전에 반드시 이 자리를 지나간다. 그래서 여기서 쿠키를 보고 돌려보내면, 페이지 코드는 손대지 않아도 된다.
2. 미들웨어와 프록시의 역할 구분
미들웨어와 프록시를 같이 놓고 "어느 쪽이 맞나" 를 따지게 되는데, 이게 애초에 성립하지 않는 질문이다. 둘은 같은 종류의 낱말이 아니다.
| 미들웨어 | 프록시 | |
|---|---|---|
| 무엇을 가리키는 말인가 | 어디에 코드가 놓이는지 | 그 코드가 무슨 일을 하는지 |
| 한 줄 정의 | 요청 처리 파이프라인 안의 한 단계 | 요청을 받아서 다른 서버로 넘기고, 그 답을 대신 돌려주는 중계자 |
| 반대말에 해당하는 것 | 라우트 핸들러, 페이지 코드 | 직접 처리(넘기지 않고 자기가 답한다) |
표 1. 미들웨어와 프록시가 각각 가리키는 것
미들웨어는 자리를 가리키고 프록시는 역할을 가리킨다. 그래서 미들웨어 안에서 프록시를 할 수도 있고, 미들웨어는 인증 검사만 하고 프록시는 다른 파일에서 할 수도 있다. 이름이 나란히 나오니 둘 중 하나를 고르는 것으로 읽히지만, 하나는 어디에 두느냐이고 다른 하나는 무엇을 시키느냐다.
프록시 쪽은 비유가 하나 도움이 된다. 대리인 같은 것이다. 본인이 직접 은행에 가지 않고, 서류를 챙긴 대리인이 대신 다녀와서 결과만 건네준다. 브라우저는 백엔드가 어디 있는지도 모르고, 대리인이 어떤 신분증을 들고 갔는지도 모른다.
3. middleware.ts의 위치와 적용 범위
Next.js 에서는 프로젝트 루트에 middleware.ts 파일 하나를 만든다. src/ 디렉터리를 쓰고 있으면 src/middleware.ts 다. app 폴더 안이 아니라 그 바깥, app 과 같은 높이다.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
읽을 것은 셋이다.
NextResponse.next() 는 "여기서 할 일은 없으니 그냥 통과시켜라" 는 뜻이다. 이걸 돌려주면 요청이 원래 가려던 페이지로 계속 간다.
NextResponse.redirect(...) 는 브라우저에게 "거기 말고 이쪽으로 다시 요청해라" 라고 답한다. 그래서 페이지 코드는 아예 돌지 않는다. 위 코드에서 next 쿼리에 원래 가려던 주소를 넣어 둔 것은, 로그인이 끝난 뒤 그 자리로 되돌려 보내기 위해서다.
config.matcher 는 이 미들웨어를 어떤 주소에만 걸지 정한다. 이게 없으면 이미지와 정적 파일 요청까지 전부 미들웨어를 지나간다. :path* 는 그 아래 모든 경로라는 뜻이라, /dashboard/:path* 는 /dashboard/orders/3 같은 주소도 잡는다.
반대로 "이것만 빼고 전부" 를 쓰고 싶으면 정규식의 부정 전방탐색을 쓴다. Next.js 문서가 예로 드는 형태가 이것이다.
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
matcher 값은 빌드할 때 정해져야 한다. 서버가 뜬 뒤에 변수로 계산해서 넣는 방식은 안 된다.
4. redirect와 rewrite의 차이
미들웨어에서 요청의 방향을 바꾸는 방법은 두 가지고, 이름이 비슷해서 헷갈린다. redirect 와 rewrite 다. 둘을 가르는 기준은 하나다. 이걸 하고 나면 사용자 주소창에 무엇이 남는가.
NextResponse.redirect | NextResponse.rewrite | |
|---|---|---|
| 브라우저 주소창 | 바뀐다 | 그대로다 |
| 브라우저가 요청을 한 번 더 하는가 | 한다 | 하지 않는다 |
| 서버가 그리는 페이지 | 새 주소의 페이지 | 다른 주소의 페이지인데 주소는 원래 것 |
표 2. redirect 와 rewrite 가 나뉘는 자리
로그인 페이지로 보내는 것은 redirect 다. 사용자가 지금 로그인 화면에 있다는 걸 주소로도 알아야 하고, 브라우저 뒤로 가기도 그 기준으로 동작해야 한다.
rewrite 는 주소는 그대로 두고 내용만 갈아 끼울 때 쓴다. /blog/hello 라는 주소를 유지한 채 실제로는 /posts/hello 를 그리게 한다든가, 나라별로 다른 페이지를 같은 주소로 보여 준다든가 하는 경우다. 사용자 쪽에서는 주소가 하나로 보인다.
5. 미들웨어에서 데이터베이스를 조회할 때의 부담
Next.js 미들웨어는 기본적으로 Edge 런타임에서 돈다. Node.js 가 아니다.
Edge 런타임은 Node.js API 를 주지 않는다. fs 로 파일을 읽을 수 없고, net 으로 TCP 연결을 열 수도 없다. 쓸 수 있는 것은 fetch, Request, Response, Web Crypto 같은 표준 웹 API 쪽이다.
이게 왜 걸리느냐면, 데이터베이스 드라이버 대부분이 TCP 소켓을 직접 연다. 그래서 미들웨어 안에서 SELECT 한 번 하려던 코드가 그냥 안 돌아간다. JWT 검증도 같은 이유로 막힌다. Node.js 의 crypto 를 쓰는 라이브러리는 여기서 못 쓰고, Web Crypto 위에서 도는 라이브러리(jose 같은 것)를 써야 한다.
성능 쪽 이유도 있다. 미들웨어는 matcher 에 걸린 모든 요청마다 돈다. 사용자가 화면에 안 보이는 링크를 미리 당겨 오는 prefetch 요청도 포함이다. 여기에 데이터베이스 왕복을 하나 넣으면 그 비용이 요청 수만큼 곱해진다.
여기까지 보면 미들웨어는 생각보다 좁은 도구다. 그래서 미들웨어에서 하는 인증 확인은 낙관적 확인으로만 쓰는 게 맞다. 쿠키가 있는지 없는지, 형식이 맞는지 정도만 보고 아니면 돌려보낸다. 진짜 권한 검사는 데이터를 실제로 꺼내는 자리에서 다시 한다. Next.js 공식 문서도 이 순서를 권한다. 미들웨어를 유일한 방어선으로 두면, 미들웨어가 우회되는 순간 그 뒤에 아무것도 없다.
Next.js 최신 버전에는 미들웨어를 Node.js 런타임에서 돌리는 선택지가 생긴 것으로 알고 있는데, 어느 버전에서 실험 단계를 벗어났는지는 확인하지 못했다. 쓸 거라면 지금 쓰는 버전의 문서를 직접 봐야 한다.
6. 프록시가 필요한 경우
미들웨어로 페이지를 다 막아도 아직 하나가 남는다. 브라우저가 백엔드 API 를 직접 부를 때 생기는 문제들이다.
첫째, CORS. 브라우저는 웹 페이지가 다른 출처의 주소를 함부로 부르지 못하게 막는다. 페이지는 myapp.com 인데 API 는 api.internal.com 이면, 백엔드가 허용 헤더를 붙여 주지 않는 한 요청이 막힌다. 백엔드를 고칠 수 없는 상황이면 여기서 진행이 안 된다.
둘째, 백엔드 주소가 그대로 보인다. 브라우저 개발자 도구의 네트워크 탭을 열면 실제 API 주소와 경로 구조가 전부 드러난다.
셋째, 토큰을 브라우저에 둬야 한다. 액세스 토큰을 HttpOnly 쿠키에 넣으면 브라우저 자바스크립트가 그 값을 읽지 못한다. 값을 못 읽으니 Authorization: Bearer ... 헤더를 만들 수가 없다. 그렇다고 읽을 수 있게 풀어 두면, 페이지에 끼어든 스크립트가 토큰을 가져갈 수 있다.
셋 다 같은 방식으로 풀린다. 브라우저는 내 서버만 부르고, 내 서버가 백엔드를 대신 부른다. Next.js 에서 이 자리는 라우트 핸들러다.
// app/api/orders/route.ts
import { cookies } from "next/headers";
export async function GET() {
const token = (await cookies()).get("access_token")?.value;
const upstream = await fetch(`${process.env.API_ORIGIN}/orders`, {
headers: { Authorization: `Bearer ${token}` },
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}
쿠키를 읽는 것은 서버에서만 일어난다. 브라우저는 /api/orders 만 알고, 그 뒤에 무엇이 있는지 모른다. cookies() 앞에 await 이 붙은 것은 Next.js 15 부터 이 함수가 비동기로 바뀌었기 때문이다. 그 이전 버전 코드에는 await 이 없다.
넘길 것이 많고 가공할 게 없으면 next.config 의 rewrites 로 한 번에 넘기는 방법도 있다. 다만 이건 주소만 갈아 끼우는 것이라, 서버에서 토큰을 붙이는 일은 못 한다.
// next.config.js
module.exports = {
async rewrites() {
return [
{ source: "/api/:path*", destination: "https://api.example.com/:path*" },
];
},
};
7. 미들웨어와 프록시를 함께 사용하는 예시
여기까지 오면 처음 질문이 왜 성립하지 않는지가 보인다. 흔한 구성은 이렇다.
미들웨어는 페이지 주소를 맡는다. /dashboard 아래로 오는 요청에 세션 쿠키가 있는지 보고, 없으면 로그인 페이지로 redirect 한다. 여기서는 백엔드를 부르지 않는다.
라우트 핸들러는 API 주소를 맡는다. /api 아래로 오는 요청을 받아서, 쿠키에서 토큰을 꺼내 헤더로 바꿔 붙이고 백엔드로 넘긴다. 이쪽이 프록시다.
그리고 미들웨어의 matcher 에서 /api 를 빼 둔다. 3절에서 본 부정 전방탐색이 그 일을 한다. API 요청까지 미들웨어를 지나가게 하면 검사가 두 번 되고, 미들웨어가 로그인 페이지로 redirect 를 돌려보내는 바람에 API 응답 자리에 HTML 이 오는 일이 생긴다.
미들웨어 안에서 백엔드로 직접 넘기는 구성도 가능하기는 하다. 그런데 5절의 Edge 런타임 제약이 그대로 따라온다. 재시도나 토큰 재발급처럼 단계가 여러 개인 처리를 넣을수록 그 제약에 걸리는 자리가 늘어난다.
구성에 따른 운영 부담
| 선택 | 얻는 것 | 감수하는 것 |
|---|---|---|
| 미들웨어로 인증 게이트를 만든다 | 페이지마다 검사 코드를 넣지 않아도 된다. 새 페이지를 추가해도 자동으로 걸린다 | matcher 에 걸린 모든 요청마다 돈다. prefetch 도 포함이라 무거운 처리를 넣으면 전체가 느려진다 |
| 막는 규칙이 한 파일에 모인다 | Edge 런타임이라 Node.js API 와 그걸 쓰는 라이브러리를 못 쓴다 | |
페이지 코드만 읽으면 누가 이 페이지를 막고 있는지 안 보인다. 신입이 들어오면 middleware.ts 를 따로 알려 줘야 한다 | ||
| 낙관적 확인이라 데이터 접근 지점에서 한 번 더 검사해야 한다. 검사가 두 군데가 된다 | ||
| 프록시를 서버에 둔다 | CORS 를 백엔드 수정 없이 넘어간다. 백엔드 주소가 안 보인다 | 요청이 한 번 더 거쳐 가니 지연이 늘고, 트래픽이 전부 내 서버를 지나간다 |
| 토큰이 서버 밖으로 안 나간다 | 파일 업로드, 스트리밍, 서버 전송 이벤트를 그대로 흘려보내려면 따로 손봐야 한다 | |
| 응답을 가공하거나 캐시할 자리가 생긴다 | 백엔드 API 가 늘 때마다 프록시에도 자리를 만들어야 한다. 귀찮다고 와일드카드로 전부 열면 주소를 숨긴 효과가 줄어든다 | |
| 오류가 났을 때 봐야 할 로그가 두 곳이 된다 |
표 3. 두 구성을 쓸 때 얻는 것과 감수하는 것
아직 확인하지 못한 사항
- 미들웨어를 Node.js 런타임에서 돌리는 선택지가 어느 버전에서 실험 단계를 벗어났는지 확인하지 못했다.
- 프록시를 한 단계 더 두면 응답이 실제로 얼마나 느려지는지 재보지 않았다. 백엔드가 어디에 있는지에 따라 달라진다.
middleware.ts를 여러 개 두거나 순서를 정하는 방법이 있는지는 찾지 못했다. 파일 하나만 본다는 것까지가 확인한 범위다.
요청 처리 목적에 따른 선택
middleware.ts를 읽을 때는 matcher로 적용 대상을 먼저 확인한다. 어떤 주소가 여기를 지나가는지가 정해지고 나면, 이 파일이 게이트인지 중계자인지는 그다음에 읽힌다.