공식문서를 활용해 nextjs(13v 기준)의 middleware에 대해 알아보자
Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly.
nextjs에서 페이지를 렌더링하기 전에 서버 측에서 실행되는 함수이다.
즉 특정 요청 전에 무언가를 수행할 수 있게 해주는 기능이다.
Middleware
에서는 Request
객체와 Response
객체에 접근할 수 있으며
이를 활용해 요청 정보를 받아와 부가적인 처리를 하고 응답객체에 무언가를 추가하거나 응답을 변경할 수 있다.
페이지 렌더링 전에 인증을 확인하거나 요청을 확인한다.
요청 데이터를 사전에 처리하거나 특정 API요청을 수행하거나 캐시를 관리한다.
요청에 대한 응답을 변환하거나 에러를 처리할 수 있다.
12.2.0
버전에서 안정화 되었고 13.0.0
버전에서 요청/응답 헤더와 응답을 변경할 수 있게 추가되었다고 한다.
현재 13.1.0
이후 버전을 사용하고 있어 해당 문서를 토대로 적용하기에는 전혀 문제가 없어보인다.
Middleware runs before cached content, so you can personalize static files and pages. Common examples of Middleware would be authentication, A/B testing, localized pages, bot protection, and more. Regarding localized pages, you can start with i18n routing and implement Middleware for more advanced use cases.
캐시된 페이지보다 먼저 수행되어 정적 파일과 페이지를 개인화할 수 있어 위와 같은 사례에서 활용된다고 한다.
Vercel
공식문서에 따르면 미들웨어는 웹사이트에서 요청이 처리되기 전에 실행되며
Vercel
환경에서는 특이하게도 캐시보다 먼저 수행된다고 한다.
이를 통해 누구나 접근할 수 없는 개인화된 콘텐츠(예: 특정 사용자의 SSR페이지)를 CDN에 캐싱하여 빠른 응답을 의도하여도 개인화하여 제공할 수 있으며 그런 방식에 효과적이라고 설명하고 있다.
(1) nextjs 설치 (12.2.0 이하면 업그레이드가 권장된다고 함)
npm install next@latest
(2) middleware.ts
파일을 pages
경로와 같은 위치에 만들기
이 위치에 두지 않으면 13.2.1
버전 기준으로 경고를 보여준다.
(3) 미들웨어 파일을 만들고 export하기
// middleware.ts
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('/about-2', request.url))
}
// See "Matching Paths" below to learn more
export const config = {
matcher: '/about/:path*',
}
공식문서의 예시를 그대로 가져왔다.
middleware
의 파라미터로 NextRequest
와 NextResponse
를 사용할 수 있어 요청 응답을 핸들링 할 수 있다.
노드를 별로 안써봐서 잘은 모르겠으나 해당 객체는 일반적인 요청/응답 객체의 내용물들을 사용함에 있어서는 노드의 것과 별반 다르지 않아보였다.
이러면 알아서 페이지 요청 시 미들웨어가 동작한다.
단 주의해야할 것이 있는데 코드 예시 가장 아래의 matcher
설정이다.
미들웨어는 기본적으로 모든 라우트에 대해 다음과 같이 순차적으로 동작하는데 다음과 같다.
(1) next.config.js의 `headers`, `redirects`
(2) Middleware
(3) nextjs.config.js의 `beforeFiles`
(4) 파일 시스템의 모든 파일 (`public`, `_next/static/, 페이지들)
(5) nextjs.config.js의 `afterFiles`
(6) Dynamic 라우트들 (ex: `/card/[cardId]`)
(7) nextjs.config.js의 `fallback`
솔직히 위에서 nextjs.config의 무언가들은 사용을 아직 안해봐서 잘 모르겠는데
기본적으로 모든 파일시스템의 정적인 콘텐츠들에 대한 요청에서도 동작한다.
예를 들면 아래와 같이 /
루트 페이지만 요청했는데도 수많은 요청들이 미들웨어를 거치는 것을 볼 수 있다.
따라서 matcher
라는 것을 설정에 적용해줘야 한다.
문서에 따르면 다음 두 가지에 대한 정의를 통해 원하는 경로에 미들웨어를 적용할 수 있다고 한다.
(1) 커스텀 `matcher`를 config에 설정한다.
(2) 미들웨어 내에서의 condition으로 분기한다.
조건문으로 분기해서 하라는 것 보니 다양한 페이지에 다양한 미들웨어를 적용하고 싶어도 pages
와 같은 경로에 미들웨어를 하나만 두라는 의도 같았다.
config
의 matcher
를 설정하여 원하는 경로에 미들웨어를 적용한다.
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).*)',
],
}
만약 모든 page route
에 미들웨어를 적용해야 한다면 위와 같이 설정하라고 예시를 보여주고 있다.
또 matcher
는 빌드 시 정적으로 분석되도록 상수여야 하기 때문에 동적 값은 사용할 수 없다고 한다.
matcher
에 적용해야 되는 경로의 규칙은 다음과 같다.
(1) MUST start with /
(2) Can include named parameters: /about/:path matches /about/a and /about/b but not /about/a/c
(3) Can have modifiers on named parameters (starting with :): /about/:path* matches /about/a/b/c because * is zero or more. ? is zero or one and + one or more
(4) Can use regular expression enclosed in parenthesis: /about/(.*) is the same as /about/:path*
예를 들어 /about/:path
면
/about/a
는 되지만 /about/a/b
는 안된다.
예를 들어 /about/:path*
이면
/about/a/b
도 된다.
redirect
redirect
시킨다.return NextResponse.redirect(new URL('/', request.nextUrl.origin))
rewrite
오는 요청에 대한 경로는 유지하고 rewrite
하는 URL의 응답을 보여준다.
return NextResponse.rewrite(new URL('/me/categories', request.nextUrl.origin))
set Header
API routes, getServerSideProps, rewrite될 페이지의 request header를 설정할 수 있다.
response header, cookie를 설정할 수 있다.
// 쿠키 설정
response.cookies.set('vercel', 'fast')
response.cookies.set({
name: 'vercel',
value: 'fast',
path: '/test',
});
// 쿠키 가져오기
const cookie = request.cookies.get('nextjs')?.value // 해당 쿠키 key의 value를 가져옴
request.cookies.has('nextjs') // => true
request.cookies.delete('nextjs')
request.cookies.has('nextjs') // => false
// Clone the request headers and set a new header `x-hello-from-middleware1`
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-hello-from-middleware1', 'hello')
// You can also set request headers in NextResponse.rewrite
const response = NextResponse.next({
request: {
// New request headers
headers: requestHeaders,
},
})
// Set a new response header `x-hello-from-middleware2`
response.headers.set('x-hello-from-middleware2', 'hello')
return response
새로운 응답 리턴
export function middleware(request: NextRequest) {
// Call our authentication function to check the request
if (!isAuthenticated(request)) {
// Respond with JSON indicating an error message
return new NextResponse(
JSON.stringify({ success: false, message: 'authentication failed' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
}
위에서 얻은 내용들을 바탕으로 jwt 쿠키를 검사해 페이지 라우팅을 처리하는 미들웨어를 구성해보았다.
일단 임시적인 구현이고 리팩터링,수정,확장 가능성이 높기 때문에 작성한 의도대로 동작하는지 테스트코드를 작성했다.
현재의 요구기능은 다음과 같다.
- 로그인을 했던 사용자에게는 메인페이지 접근 시 본인 카테고리 목록 조회 페이지를 보여줘야한다.
- 인증된 사용자만 접근할 수 있는 페이지에 토큰이 없는 사용자가 접근할 경우 메인페이지로 redirect시킨다.
- 그 외에는 요청에 대해 그대로 응답한다.
구현코드
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { cookies } = request;
const hasCookie = cookies.has('accessToken');
if (!hasCookie && request.nextUrl.pathname !== '/') {
return NextResponse.redirect(new URL('/', request.nextUrl.origin));
}
if (hasCookie && request.nextUrl.pathname === '/') {
return NextResponse.rewrite(new URL('/me/categories', request.nextUrl.origin));
}
return NextResponse.next();
}
export const config = {
matcher: ['/me/:path*', '/write/:path*', '/'],
};
테스트코드
/**
* @jest-environment node
*/
import { middleware } from '../../middleware';
import { NextRequest, NextResponse } from 'next/server';
describe('middleware', () => {
const mockNextRequest = {
cookies: {
has: jest.fn(),
},
nextUrl: {
pathname: '/',
origin: 'http://localhost:3000',
},
} as unknown as NextRequest;
afterEach(() => {
jest.clearAllMocks();
});
it('특정한 경로(인가가 필요한 페이지)로 요청했을 때 쿠키에 토큰이 없으면 루트로 redirect 되어야 한다.', () => {
mockNextRequest.nextUrl.pathname = '/me/categories/12314';
(mockNextRequest.cookies.has as jest.Mock).mockReturnValue(false);
const response = middleware(mockNextRequest);
expect(response).toBeInstanceOf(NextResponse);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toEqual('http://localhost:3000/');
});
it('로그인했던 사용자가 루트로 접근 시 /me/categories로 rewrite 되어야 한다.', () => {
(mockNextRequest.cookies.has as jest.Mock).mockReturnValue(true);
mockNextRequest.nextUrl.pathname = '/';
const response = middleware(mockNextRequest);
expect(response).toBeInstanceOf(NextResponse);
expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-next')).toBeNull();
expect(response.headers.get('x-middleware-rewrite')).toBe('http://localhost:3000/me/categories');
});
it('특정한 경로(인가가 필요한 페이지)로 요청했을 때 쿠키에 토큰이 있으면 그대로 응답한다.', () => {
mockNextRequest.nextUrl.pathname = '/me/categories/12314';
(mockNextRequest.cookies.has as jest.Mock).mockReturnValue(true);
const response = middleware(mockNextRequest);
expect(response).toBeInstanceOf(NextResponse);
expect(response.headers.get('x-middleware-next')).toBe('1');
});
});
jest에서 기본적으로 jsdom
으로 브라우저 환경을 가정하고 테스트하도록 설정했기 때문에
노드환경인 미들웨어를 테스트하기 위해 @jest-environment node
를 선언해주었다.
그리고 NextRequest
를 mocking 하여 임의로 쿠키나 경로 등의 값을 설정해주고
응답을 assertion
하는 방식으로 세 가지 상황에 대해 테스트했다.
글 잘 봤습니다! 많은 영감이 되었습니다 :)