로그인 검사를 빠뜨린 페이지가 열렸다: 미들웨어와 프록시 이해하기

X Facebook

Written by

in

로그인하지 않은 사용자가 /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 다. 둘을 가르는 기준은 하나다. 이걸 하고 나면 사용자 주소창에 무엇이 남는가.

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를 먼저 본다. 어떤 주소가 여기를 지나가는지가 정해지고 나면, 이 파일이 게이트인지 중계자인지는 그다음에 읽힌다.

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다