Next.js 13 master course - middleware

dante Yoon·2023년 6월 4일
26

nextjs13-master-course

목록 보기
7/11
post-thumbnail

안녕하세요, 단테입니다.

next.js 13 master course에 오신 여러분 환영합니다.

오늘은 아래의 주제에 대해 다룹니다. 재밌고 유익한 시간 되셨으면 좋겠습니다.

middleware

middleware
우리가 이전 강의에서 route handler를 통해 서버로 오는 요청을 처리했다면 middleware는 route handle를 거치기 전에 특정 역할을 수행하는 역할을 합니다.

우편메세지가 도착지에 도착하기 전 봉투에 넣어지는 등의 일을 하는 것과 마찬가지이죠.

message envelop

이 미들웨어를 통해 아래와 같은 일들을 할 수 있습니다.

  • 인증 (쿠키 처리
  • 국가별 페이지 처리
  • 크로스 브라우징 처리
  • 봇 탐지 및 접근 제한
  • 리다이렉트 / rewrite
  • A/B 테스트
  • 로깅

middleware.ts | middleware.ts

사용을 위해서는 app 디렉토리와 동일한 위치에 middleware.ts혹은 middleware.js 파일을 생성합니다. 미들웨어의 next.js의 special files의 일종입니다.

우리는 프로젝트 루트에 app 디렉토리에 있기 때문에 프로젝트 루트에 생성합니다.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
  return NextResponse.redirect(new URL('/home', request.url));
}
 
export const config = {
  matcher: '/about/:path*',
};

matcher

미들웨어는 next.js 서버가 처리하는 어떤 route를 대상으로도 동작합니다. 특정 path로만 해당 미들웨어가 동작하게 하고 싶다면 matcher를 사용할 수 있습니다.

앞서 봤던 예제 코드의 config가 해당 역할을 합니다.

export const config = {
  matcher: '/about/:path*',
};

다양한 path를 대상으로 matcher를 만들기 위해서는 배열 형식으로 선언합니다.
아래 선언한 matcher는 /about 이하 path와 /dashboard 이하 path로 항상 middleware가 동작하게 합니다.

export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'],
};

이 matcher는 정규분포식으로 작성될 수 있으며 역참조 문법또한 사용할 수 있습니다.

아래 문법은 route handler, 정적파일, favicon.ico를 제외하고는 미들웨어가 동작하게 합니다.

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

동적 matcher는 적용되지 않습니다.

한가지 주의해야 할 점은 matcher는 빌드 타임에 next.js 서버에 생성되어 적용되기 때문에 동적인 값은 사용할 수 없다는 것입니다. 동적인 match config는 무시됩니다.

조건문

만약 동적인 값으로 matcher를 작동시키고 싶거나 path가 동일하더라도 특정 조건에만 middleware를 작동시켜야 한다면 미들웨어 함수 내부에 조건문에서 이를 충족시킬 수 있습니다.

NextResponse

다음 예제 코드에서는 미들웨어에서 response를 생성해서 사용자에게 바로 응답을 전달합니다.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/about')) {
    return NextResponse.rewrite(new URL('/about-2', request.url));
  }
 
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.rewrite(new URL('/dashboard/user', request.url));
  }
}

NextResposne를 통해 사용자를 특정 url로 리다이렉트 시키거나 요청한 url은 그대로 노출하면서 특정 경로로 이동시키는 rewrite를 수행할 수 있습니다.

matcher, 조건문 실습

우리가 앞서 만들었던 /dashboard 경로의 페이지와 /dashboard/[slug] 경로의 동적 라우팅 페이지를 가지고 matcher와 조건문 실습을 해보겠습니다.

redirect/rewrite

/middleware.ts

import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.rewrite(new URL("/dashboard/1", request.url))
  }

  if (request.nextUrl.pathname.startsWith("/login")) {
    return NextResponse.redirect(new URL("/", request.url))
  }
}

// See "Matching Paths" below to learn more
export const config = {
  matcher: ["/about/:path*", "/dashboard/:path*"],
}

/dashboard -> /dashboard/1 rewrite

사이트 운영 사정상 dashboard들의 모든 데이터가 비어있고 첫번쨰 아이템만 존재하는 경우
/dashboard 이하 경로로 접속하는 모든 경로는 /dashboard/1로만 가게 변경할 수 있습니다.

이때 /dashboard/1로 유저에게 url을 노출하고 싶지 않습니다. 이미 동적 라우팅으로 페이지를 만들었지만
store is empty

/dashboard/1이 노출되면 첫번째 아이템만 있는 빈약한 페이지로 비춰지거나 특정 아이템의 유니크한 리소스 넘버가 유저에게 노출될 수 있기 때문입니다.

if (request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.rewrite(new URL("/dashboard/1", request.url))
  }

middleware 함수 내부를 보면 NextResponse를 이용해 rewrite 구문을 작성했습니다.

이전 강의에서 app/dashboard/[slug]/page.tsx 는 아래와 같이 작성했었습니다.

type PageParams = {
  slug: string
}
export default function page({ params }: { params: PageParams }) {
  return (
    <div>
      <div>[slug]: {params.slug}</div>
    </div>
  )
}

dashboard로 접속하면 /dashboard/1에 해당하는 페이지로 렌더링이 됩니다.
이떄 url은 그대로 사용자에게 /dashboard로 노출되는 것을 확인할 수 있습니다.

/login -> / redirect

authenticate signed

  if (request.nextUrl.pathname.startsWith("/login")) {
    return NextResponse.redirect(new URL("/", request.url))
  }

우리 페이지에서는 이미 로그인이 되어있다고 가정하고
login url로 들어갔을 때 NextResponse.redirect를 통해 홈페이지로 이동시키는 시나리오를 가정하려고 합니다.

따라서 위와같이 홈페이지로 리다이렉트 시켰습니다.

하지만 위와 같이 작성해도 middleware가 의도한 것대로 동작하지 않은데요, login 페이지로 이동해도 홈페이지로 리다이렉트 되지 않습니다.

그 이유는 그 아래 작성한 matcher config에 /login path가 존재하지 않기 때문입니다.


// See "Matching Paths" below to learn more
export const config = {
  matcher: ["/about/:path*", "/dashboard/:path*"],
}

단순히 /login path를 넣어줄 수도 있지만, 이럴 경우는 항상 page path를 matcher 배열에 넣어주어야 하기 때문에 matcher를 regex로 작성하겠습니다.

정적파일을 가져올때나 route handler로 들어가는 요청은 미들웨어를 거칠 필요가 없기 때문에 아래와 같이 regex를 작성해줍니다.

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

이제 의도한대로 앱이 동작하는지 확인해보겠습니다.

/login으로 접속했을때 자동으로 home으로 접속되며 다른 page들도 기존에 정의한대로 정상적으로 동작하고 있습니다.!

NextResponse.next로 middleware 실행 후 route handler도 실행시키기

앞서 봤던 NextResponse 클래스의 next메소드 호출을 통해 NextResponse 인스턴스를 생성할 수 있습니다.

다음 코드를 봅니다.
NextResponse class

export function middleware(request: NextRequest) {
  const newRequestHeaders = new Headers(request.headers)
    newRequestHeaders.set("some-thing", "something from headers")

  const response = NextResponse.next({
    request: {
        headers: newRequestHeaders,
      },
    })
  response.cookies.set({
    name: "hi",
    value: "bye",
    path: "/",
  })
  return response
}

예제 코드의 미들웨어에서 반환하는 response 객체가 NextResponse.next에서 생성된 NextResponse라면 route handler에서도 request 객체를 처리할 수 있습니다.

이 떄 next 메소드에서 받아들이는 인자로 header 값만 정의할 수 있기 때문에 middleware에서 route handler를 거치지 않고서는 response body를 설정할 수 없다는 특징이 있습니다.

앞선 예제 코드에서는 route handler에서 인자로 받을 request 객체의 header 값에 새로운 헤더 값을 설정합니다.

const newRequestHeaders = new Headers(request.headers)
    newRequestHeaders.set("some-thing", "something from headers")

const response = NextResponse.next({
  request: {
    headers: newRequestHeaders,
  },
})

newRequestHeaderssome-thing 이라는 헤더를 설정했습니다.

그리고 app/api/post/route.ts에서 헤더 값을 읽습니다.

import { headers } from "next/headers"
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
  const httpHeaders = headers()
  console.info("httpHeaders: ", httpHeaders)
  console.info("request.headers: ", request.headers)
  // console.info("hi this is GET")

next/server의 header()

httpHeaders:  HeadersList {
  cookies: null,
  [Symbol(headers map)]: Map(15) {
    'accept' => { name: 'accept', value: '*/*' },
    'accept-language' => { name: 'accept-language', value: '*' },
    'cache-control' => { name: 'cache-control', value: '' },
    'connection' => { name: 'connection', value: 'close' },
    ...
    'some-thing' => { name: 'some-thing', value: 'something from headers' },
    ...
  },

request.headers

request.headers:  HeadersList {
  cookies: null,
  [Symbol(headers map)]: Map(15) {
    'accept' => { name: 'accept', value: '*/*' },
    'accept-language' => { name: 'accept-language', value: '*' },
    'cache-control' => { name: 'cache-control', value: '' },
    'connection' => { name: 'connection', value: 'close' },
 ...
    'some-thing' => { name: 'some-thing', value: 'something from headers' },
 ...
  },

route handler에서 헤더를 읽는 방법은 인자로 전달된 request 객체의 headers를 참조하는 방법과 next/server에서 제공하는 header api를 사용하는 두 가지 방법이 있습니다.

두 방법을 통해 미들웨어에서 설정한 header를 읽을 수 있으며 헤더 내부 값은 동일합니다.

쿠키, nextUrl 예제

NextRequest는 Request 객체를 확장한 것이기 때문에 개발을 간편하게 해주는 속성들이 있습니다. 그리고 우리는 이것을 십분 활용할 수 있습니다.

앞선 예제에서도 봤듯이 request.url이 아닌 request.nextUrl을 사용했습니다.

// /home으로 접근했을때  pathname은 /home이 됩니다.
request.nextUrl.pathname;
//  /home?name=lee 으로 접근했을때  searchParams 은 { 'name': 'lee' }이 됩니다.
request.nextUrl.searchParams;

이 nextUrl 타입은 다음과 같으며 host, protocol과 같은 정보또한 쉽게 얻을 수 있습니다.

export declare class NextURL {
    private [Internal];
    constructor(input: string | URL, base?: string | URL, opts?: Options);
    constructor(input: string | URL, opts?: Options);
    private analyze;
    private formatPathname;
    private formatSearch;
    get buildId(): string | undefined;
    set buildId(buildId: string | undefined);
    get locale(): string;
    set locale(locale: string);
    get defaultLocale(): string | undefined;
    get domainLocale(): DomainLocale | undefined;
    get searchParams(): URLSearchParams;
    get host(): string;
    set host(value: string);
    get hostname(): string;
    set hostname(value: string);
    get port(): string;
    set port(value: string);
    get protocol(): string;
    set protocol(value: string);
    get href(): string;
    set href(url: string);
    get origin(): string;
    get pathname(): string;
    set pathname(value: string);
    get hash(): string;
    set hash(value: string);
    get search(): string;
    set search(value: string);
    get password(): string;
    set password(value: string);
    get username(): string;
    set username(value: string);
    get basePath(): string;
    set basePath(value: string);
    toString(): string;
    toJSON(): string;
    clone(): NextURL;
}

localhost:3000/post?id=1234로 접근했을 때

console.log("request.url: ", request.url)
// request.url:  http://localhost:3000/post?id=11234
  console.log("request.nextUrl.pathname: ", request.nextUrl.pathname)
// request.nextUrl.pathname:  /post

쿠키 설정

미들웨어에서 쿠키를 설정하는 방법은 간단합니다.

아래처럼 NextRequest 타입인 request 인자의 cookies를 속성을 통해 요청된 request의 쿠키 값을 읽을 수 있고

NextResponse 인스턴스를 생성하여 cookies.set 메소드를 통해 쿠키를 설정해줄 수 있습니다.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  // Assume a "Cookie:nextjs=fast" header to be present on the incoming request
  // Getting cookies from the request using the `RequestCookies` API
  let cookie = request.cookies.get('nextjs')?.value;
  console.log(cookie); // => 'fast'
  const allCookies = request.cookies.getAll();
  console.log(allCookies); // => [{ name: 'nextjs', value: 'fast' }]
 
  request.cookies.has('nextjs'); // => true
  request.cookies.delete('nextjs');
  request.cookies.has('nextjs'); // => false
 
  // Setting cookies on the response using the `ResponseCookies` API
  const response = NextResponse.next();
  response.cookies.set('vercel', 'fast');
  response.cookies.set({
    name: 'vercel',
    value: 'fast',
    path: '/',
  });
  
  cookie = response.cookies.get('vercel');
  console.log(cookie); // => { name: 'vercel', value: 'fast', Path: '/' }
  // The outgoing response will have a `Set-Cookie:vercel=fast;path=/test` header.
 
  return response;
}

수고하셨습니다.

오늘은 app directory의 미들웨어에 대해 알아보았습니다. middleware.ts는 edge function에서도 작동할 수 있도록 설계되어 pages directory의 middleware와는 다른 부분이 있습니다.

금일 내용 예제 코드와 함께 직접 실습해보시기 바랍니다.

감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

3개의 댓글

comment-user-thumbnail
2023년 6월 12일

항상 잘 보고 있어요 👍

답글 달기
comment-user-thumbnail
2023년 12월 6일

언제나 감사합니다! 최고입니다

답글 달기
comment-user-thumbnail
2024년 3월 24일

어려운 개념임에도 불구하고 설명을 정말 잘 하시는 것 같아요. 읽는 매 순간마다 정말 많은 것들을 얻어갑니다!

답글 달기