[NextJS] Next.js AccessControl > middleWare로 적용하기

2024. 3. 23. 13:04

 

요즘 Next.js 파헤치기를 시도하고 있다. 만들어놓은 많은 좋은 기능들을 제대로 알고 쓰기란 항상 어려운 것 같다.

이번에는 Next.js 프로젝트에서 AccessControl을 관리하는 부분을 개선할 필요성이 있어 middleWare를 적용해보았다. 필요성 외에도 실질적인 문제 상황이 존재했다.

 

Problem

내가 작업한 페이지는 결제 페이지였다. 결제 페이지는 아래와 같은 로직을 가지고 있다.

 

  1. 결제 페이지에 진입하기 전에 로그인 여부를 체크한다. → 로그인이 되어 있지 않으면 로그인 페이지로 리다이렉션한다.
  2. 로그인이 되어 있으면 유저 정보를 불러온다. → 휴대폰 인증 여부를 확인한다.
  3. 인증이 되어 있지 않으면 → 휴대폰 인증 페이지로 리다이렉션 한다.
  4. 휴대폰 인증이 끝나면 → 다시 결제 페이지로 이동하여 원래의 플로우로 돌아간다.

 

하지만 기존에는 AccessContorl 라는 파일에서 이러한 동작들을 관리하고 있으며, 클라이언트 사이드에서 이뤄지고 있어 이 로직을 거치는 과정에서 데이터가 보장되지 않아 로직이 불규칙적으로 실행하는 오류가 생겼다. 또한 로직을 추가할 때마다 라우터를 하드코딩하여 추가하고 그 안에 로직들이 정리되지 않은 채로 정의되어 있어 개선의 필요성을 느꼈다. 마지막으로 결제 페이지는 네이티브 앱들도 거치는 과정이므로 많은 고려가 필요했다. 우선 미들웨어를 사용하기 전에 정리를 한 번 했다.

 

Next.js MiddleWare

Next.js의 미들웨어를 사용하면 요청이 완료되기 전이나 특정 경로 처리기에 도달하기 전에 코드를 실행할 수 있다. 이를 통해 요청이나 응답 수정, 사용자 리디렉션, 오류 처리 등 다양한 작업을 수행할 수 있다. 미들웨어는 Next.js 프로젝트에 정의된 순서에 따라 순차적으로 실행된다. Next.js의 middleware를 간단히 정리하자면 아래와 같다.

 

  1. Execution Order (실행 순서): 미들웨어는 Next.js에서 정의한 특정 순서로 호출된다. 이 순서에는 헤더 처리, 리디렉션, 사용자 정의 미들웨어 논리 및 동적 경로 처리가 포함된다.
  2. Functionality (기능): 미들웨어는 URL 재작성, 요청 또는 응답 헤더 수정, 사용자를 다른 페이지로 리디렉션, 인증 처리, 로깅 등과 같은 다양한 작업을 수행할 수 있다.
  3. Configuration (구성): 'next.config.js' 파일을 사용하여 Next.js 프로젝트에서 미들웨어 동작을 구성할 수 있다. 헤더, 리디렉션 및 재작성에 대한 사용자 지정 논리를 정의하고, 미들웨어를 적용할 경로를 지정하고, 실행 조건을 설정할 수 있다.
  4. Customization (사용자 정의): Next.js를 사용하면 사용자 정의 일치자 및 조건문을 사용하여 미들웨어 동작을 사용자 정의할 수 있다. 이러한 유연성을 통해 미들웨어 실행을 위한 특정 경로나 패턴을 정의할 수 있으므로 애플리케이션의 라우팅 및 동작을 세밀하게 제어할 수 있다.
  5. Dynamic Routes (동적 경로): 미들웨어는 Next.js의 동적 경로에도 적용된다. 이는 요청이 처리되기 전에 미들웨어를 사용하여 데이터를 전처리하거나 특정 동적 경로에 특정한 작업을 수행할 수 있다.
  6. Error Handling (오류 처리): 미들웨어를 오류 처리에 사용할 수도 있다. 오류 처리 미들웨어를 정의하여 요청 수명 주기 동안 발생하는 오류를 포착하고 처리함으로써 애플리케이션 전체에서 오류를 관리하기 위한 중앙 집중식 메커니즘을 제공할 수 있다.

 

전반적으로 Next.js의 미들웨어는 요청이 최종 목적지에 도달하기 전에 요청을 가로채고 처리할 수 있도록 하여 애플리케이션의 유연성과 기능을 향상시키는 강력한 기능이다.

 

 

Next.js middleWare 사용하기

이제 정의는 알아보았으니 Next.js에서 어떻게 사용하는지 살펴보자.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
	console.log('middleware 시작!');
  return NextResponse.redirect(new URL('/home', request.url))
}
 
export const config = {
  matcher: '/about/:path*',
}

 

우선 프로젝트 파일 내에 상위 디렉토리(src/app/page)와 같은 레벨 안에 middleware.js (혹은 middleware.ts) 파일을 생성한다. 여기서부터 시작이다. 이제 위의 예시처럼 미들웨어를 선언하면 된다. 그럼 우선 미들웨어 정의는 끝! 만약 여기서 console.log를 찍는다면 서버 사이드에서 실행되는 코드이므로 서버가 실행되고 있는 터미널에서 볼 수 있다. (만약에 나오지 않는다면 서버를 재실행하고 시도해보자.)

 

  • 참고
    next.js version 12 이전에는 middleware를 각각의 디렉토리에서 정의할 수 있었다고 하나, 이제는 하나의 middleware 파일로 관리할 수 있게 되었다. (MiddleWare Upgrade Guide)

 

그럼 이제부터는 미들웨어로 각 라우터에 대한 처리할 정의를 하면 된다. 어떤 라우터에 대해서 처리할 지는 config를 통하여 정의하면 된다. 이 방법이 개선의 필요성을 느낀 방법 중 하나인데, 기존에는 아래와 같이 정의되어 있었다.

// app.ts

return (
	<>
	<AccessControl>
		<App/>
		...
	</AccessControl/>
	</>
);

 

모든 페이지가 아닌 로그인 후 유저가 사용하는 페이지 /user Router Path에 해당하는 곳만 체크가 필요했다.

이와 같은 구조로 정의되어 있기 때문에 AccessControl이 필요 없는 모든 다른 페이지에 대해서도 AccessControl의 로직을 거치게 되는 것이 비효율을 발생시키고 있다는 생각이 들었다. 이런 점을 Next.js의 config 에서 matcher를 정의하여 해결해 줄 수 있었다.

 

Matcher

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

 

또한 Matcher Path에 실행 우선순위가 있다는 점이다.

  1. next.config.js에서 헤더
  2. next.config.js에서 리디렉션
  3. 미들웨어 (rewrite, redirect 등)
  4. next.config.js의 beforeFiles (rewrite)
  5. file system routes (public/, _next/static/, pages/, app/ 등)
  6. next.config.js의 afterFiles (rewrite)
  7. 동적 경로 (/blog/[slug])
  8. next.config.js의 fallback (rewrite)

 

Matcher는 아래와 같은 규칙을 지켜 구성해야 한다.

  1. /로 시작해야 한다.
  2. 이름이 지정된 매개변수여야 한다.
    ex) /about/:path/about/a/about/b 는 경로 일치, /about/a/c 는 일치 X
  3. 정의된 매개변수에는 콜론(:) 접두사가 붙은 수정자를 가질 수 있다.
    • :   0개 이상의 항목과 일치하는 것을 의미 ex) /about/:path*는 /about/a/b/c와 일치
    • ?:  0개 또는 1개의 항목과 일치
    • +:  하나 이상의 항목과 일치
  4. 괄호 안의 정규식으로 일치자를 정의할 수 있다.
    ex) /about/(.*)/about/:path*와 동일

 

이렇게 구성된 매처는 Next.js 애플리케이션에서 경로와 매개변수를 정의하는 유연성을 제공한다. URL 패턴을 기반으로 동적 라우팅을 허용하고 추가 처리 또는 라우팅 논리를 위해 URL에서 매개변수를 추출함으로써 많은 것을 할 수 있다.

 

그리고 이러한 matcher를 사용하는 방법과 더불어 미들웨어가 실행될 경로를 정의하는 두 가지 방법이 있다.

 

  1. Custom matcher config
export const config = {
	matcher: '/user/:path*',
}
  1. Conditional statements
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/user/join')) {
			// 실행될 로직...
	  }
}

 

나는 케이스에 따라 두 가지를 모두 썼다.

 

우선 /user 페이지 전반에 걸쳐서 로그인 여부를 처리해야 했고, /user 페이지 중에 /join이라는 특정 결제 페이지에 대한 로직 (휴대폰 인증 로직)을 처리해야 했으므로 두 가지를 혼용하였다. 우선은 결제 페이지의 로직만 적용했다.

 

적용해야 할 로직은 <결제 페이지 진입 시 유저 정보 체크 후, 휴대폰 인증 페이지로 리다이렉션 함>이다.

이 동작을 하기 위해서는 요청을 처리하기 위한 것들이 필요했다. NextResponse를 사용해야 한다.

 

 

NextResponse

Next.js의 NextResponse API를 사용하면 다음을 수행할 수 있다.

  1. 들어오는 요청을 다른 URL로 리디렉션한다.
  2. 지정된 URL을 표시하여 응답을 재작성
  3. API 라우트, getServerSideProps 및 재작성 대상에 대한 요청 헤더를 설정
  4. 응답 쿠키를 설정
  5. 응답 헤더를 설정

 

그리고 여기서 나는 1번의 동작을 하고자 했다.

이 동작을 하기 위해서는 아래의 두 방법이 있었는데, 나는 NextResponse를 직접 처리하는 방식을 이용했다.

  1. rewrite to a route (Page or Route Handler) that produces a response
  2. return a NextResponse directly. See Producing a Response

 

우선 최종적으로 작성한 middleWare 코드는 아래와 같다.

// middleware.ts
import { NextFetchEvent, type NextRequest, NextResponse } from 'next/server';
...

export async function middleware(request: NextRequest, event: NextFetchEvent) {
  const path = request.nextUrl.pathname;
  // 해당 path를 가져올 수 있다. ex) /user/join/uuid****

  if (path.includes(BASE_URLS.JOIN)) {
	  // 접속한 해당 페이지가 처리가 필요한 <결제 페이지>라면
    const hasPhoneNumber = await checkVerifyPhoneNo(request);
    // 유저 정보에 휴대폰 번호가 있는지 확인한다.

    if (!hasPhoneNumber) {
    // 번호가 없다면 휴대폰 인증 페이지로 이동한다.
      return NextResponse.redirect(
        new URL(
          `${ROUTES.USER_VERIFY_PHONE}?&redirectUrl=${path}`,
          request.url,
        ),
      );
    }
  }

// 그 이외에는 원래 페이지로 로드한다.
  return NextResponse.next();
}

export const config = {
// 현재는 결제 페이지 로직밖에 없어서 아래와 같이 추가해 두었지만, 
// 다른 라우터에 대해서도 처리가 필요할 것 같아 정의해 두었다.
  matcher: ['/user/membership/join/:path*'],
};

 

 

이와 같이 middleWare에서 checkVerifyPhoneNo를 이용하여 유저 정보를 읽어와서 휴대폰 번호가 있는지 확인하는 함수를 통하여 해당 페이지로 리다이렉션 하는 로직을 처리할 수 있었다. 참고로 checkVerifyPhoneNo는 아래와 같았다.

 

 

// middleWare/checkVerifyPhoneNo.ts

import { type NextRequest } from 'next/server';

export async function getUserProfile(req: NextRequest) {
  const response = await fetch(
    `/api/profile`,
    {
      method: 'GET',
      mode: 'cors',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
        cookie: `loginToken=${req.cookies.get('loginToken')?.value}`,
      },
    },
  );

  return response.json();
}

export default async function checkVerifyPhoneNo(request: NextRequest) {
  const response = await getUserProfile(request);
  const hasPhoneNumber = !!response?.body?.phoneNumber;

  return hasPhoneNumber;
}

 

 

이 모든 로직을 middleWare.ts 파일 안에 넣으면 복잡할 것 같아 처리하는 로직을 별도의 함수로 빼두었다.

getUserProfile의 정보는 브라우저에 가지고 있는 쿠키로 유저를 인증하고 정보를 가져와야 하므로, req.cookies.get를 통해 토큰을 가져와서 요청을 보내어 올바른 유저 정보를 확인하여 체크할 수 있었다.

 

+) Using Cookies

쿠키는 일반적인 헤더이다. 요청에서는 Cookie 헤더에 저장되고 응답에서는 Set-Cookie 헤더에 저장된다. Next.js는 NextRequest 및 NextResponse의 cookies 확장을 통해 이러한 쿠키에 쉽게 액세스하고 조작할 수 있는 편리한 방법을 제공한다.

  • 제공하는 메서드 : get, getAll, set 및 delete cookies

 

Another Problem

짠! 여기까지 해서 이전의 AccessControl로 처리할 때와는 달리 결과를 보장받고, 오류도 사라지고, 코드도 직관적으로 짤 수 있어서 좋았다. 하지만 또 하나의 문제가 발생했다. 앱과의 처리를 함께 해야 하기 때문에 고려 사항이 또 있었다.

 

계속 말하지만 결제 페이지 진입 → 휴대폰 인증을 하고 → 다시 결제 페이지로 돌아가는 로직이었다.

 

하지만 웹에서는 이 redirectUrlsessionStorage에 저장하고 있었는데 이 동작을 <가입하기> 버튼을 누를 경우에 set 해주고 있어서, 네이티브 앱에서 사파리 브라우저를 열어 넘어갈 때에는 Setting을 해 줄 방법이 없었다. 나는 고민에 빠졌다. 이걸 미들웨어에서 처리할 수 있는 방법이 없나? 응, 없었다.

 

middleWare는 서버 사이드에서 실행되는 것이기 때문에 브라우저에서 사용하는 session은 당연히 사용할 수 없었다. 그래서 조금은 고전적인 방법이 될 수도 있는 방법을 사용했다. 다행히도 이미 앱에서는 웹을 열어 호출하는 url이 아래와 같은 구조였다.

 

https://[결제 페이지 url]/token=[token]/redirectUrl=””

 

그래서 단순히 휴대폰 인증을 완료한 페이지에서 useSearchParam(구 router.query.param)을 사용하여 redirectUrl을 router에서 get 해오는 방식을 사용하여 처리를 해주었다. 사실 웹에서는 간단한 일이었지만 앱까지 고려하니 위의 방법이 적절해 보였다. 아무튼 이렇게 해서 결제 페이지에 진입하기 전에 실행하는 로직을 처리할 수 있었다!

 

 

-

 

이번 기회에 Next.js 문서를 좀 더 제대로 볼 수 있었다. 앞으로도 개선할 것들이 없나 계속 눈여겨 봐야지.

내가 사용했던 방법 이외에도 다양한 기능을 제공하고 있으므로 적절하게 잘 활용 방안을 좀 더 생각해 봐야겠다.

 

 

참고 글

 

[Next JS] Middleware로 로그인 여부 확인 중복 코드 줄이기

Next JS Middleware를 알아보고 로그인 여부를 확인하는 공통 로직을 중앙에서 처리해봅니다.

velog.io

 

문서보며 알아보는 nextjs 미들웨어

공식문서를 활용해 nextjs(13v 기준)의 middleware에 대해 알아보자Middleware allows you to run code before a request is completed, then based on the incoming request, you

velog.io

 

Middleware Upgrade Guide

As we work on improving Middleware for General Availability (GA), we've made some changes to the Middleware APIs (and how you define Middleware in your application) based on your feedback. This upgrade guide will help you understand the changes, why they w

nextjs.org

 

Functions: NextResponse | Next.js

API Reference for NextResponse.

nextjs.org

 

BELATED ARTICLES

more