Practice

Next.js Middleware

Yelihi 2023. 4. 24. 16:48

로그인 여부에 따라 페이지를 리다이렉트 하는 방법은 저번 포스팅에서 한번 다뤄본적이 있다.

 

문득 모든 페이지마다 getServerSideProps 를 통해서 redirect 를 지정하는 것이 아니라, 한번에 처리할 수 있는 방법이 있지 않을까 하여 찾다보니 Next.js 에서의 Middleware 에 대해서 알게 되었고, 실험적으로 사용해본 과정과 능숙하게 사용을 하지 못해서 느껴진 한계점을 한번 포스팅해보고자 한다.


Next.js 의 middleware

미들웨어는 기존 express 에서 다루었던 것과 같이, 모든 요청과 응답 사이에서 작용을 한다. 미들웨어를 활용하면 응답을 받기 전 그 응답에 대하여 마음대로 커스터마이징을 할 수 있다. 캐쉬된 페이지를 로드하는것보다도 더 먼저 실행되기 때문에, 대표적으로 인증에 따른 페이지 접근 권한에서 사용될 수 있다. 

 

공식 홈페이지에 나와있는 예시 코드는 기본적인 redirect 기능을 수행하는 미들웨어에 대해서 보여주고 있다.

// 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*',
}

미들웨어 파일은 `page` 폴더와 동등한 루트에 생성하면 된다. 즉 next.config.ts 와 같이 있다고 생각하면 된다. 

 

파일이름을 middleware.ts 로 생성한 다음, NextResponse.redirect 를 통해 설정해둔 path 에 대하여 모두 '/about-2' path 로 redirect 해준다. 즉 request path 에 대해서 redirect 된 response 를 받게되는 것이다. 아주 심플하다!

 

여기서 미리 설정해둔 path를 봤을 때, 어떻게 설정이 가능한가에 대해 궁금할 수 있다. 이는 Matcher 를 통해 가능하다. request.url 중에서 설정해둔 matcher 에 해당하는 url 만 redirect 할 수 있도록 matcher 를 위 코드처럼 설정하고 있는 모습을 볼 수 있다. config 로 설정하게 되면 request 는 자동적으로 matcher 가 적용이 된다.

 

공식문서에서는 matcher 작성 시 주의할 점에 대해서 다음과 같이 설명한다

 

  • 반드시 시작은 `/` 로 해야한다.
  • `/about/:path` 로 되어있다면, 이는 `/about/a` 를 나타내는 것이지 `/about/a/b` 를 나타내는것이 아니다.
  • 위처럼 뒤에 모든 path에 대해서 적용하고 싶다면, `/about/:path*` 로 표현하면 된다.
  • `/about/:path*` 는 `/about/(.*)` 과 같이 정규표현식으로도 작성 가능하다.

아마 작성을 하게 된다면 보통 다음과 같은 모양새가 될 것이다.

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

하나가 아닌 여러개의 path 역시 설정이 가능하다. 즉 위 matcher 에 의하면 시작 path 가 /about 과 /dashboard 라면 응답 전에  middleware 를 거치겠다는 의미다.

 

만약 matcher 를 사용하지 않으려면 조건식을 활용하는 방법도 존재한다. 아래 코드처럼 조건식을 활용하면 된다.

// middleware.ts

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))
  }
}

좀 더 직관적이라 쉽게 이해할 수 있을 것이다. 조건에 맞는 path에 따라 각각 redirect 시킨다.


cookie 마저 다룰수 있음을 이용하면?

next.js 의 미들웨어는 header 의 cookie 역시 손쉽게 접근하고 수정할 수 있는 기능이 있다. 각각 request 와 response 는 NextResponse, NextRequest 에 의해 쿠키에 대한 get, getAll, set, delete 메서드를 사용할 수 있다. (request 는 여기에 더해 has 와 clear 를 사용할 수 있다.)

 

공식 문서에서는 아래 코드를 통해 기본적인 사용법을 보여주고 있다.

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: '/test',
  })
  cookie = response.cookies.get('vercel')
  console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/test' }
  // The outgoing response will have a `Set-Cookie:vercel=fast;path=/test` header.

  return response
}

 기본적으로 get 을 통해서 존재하는 쿠키에 접근할 수 있으며, has 를 통해 boolean 값으로 쿠키여부를 파악할 수 있다. 삭제 역시 가능하며, 새로운 쿠키를 생성할 수도 있다. 위 코드를 살펴보면 기존에 존재하는 next.js 쿠키를 삭제하고 새로운 쿠키인 vercel 을 추가하고 있다. 미들웨어가 매 라우팅 전에 실행된다는 점을 활용한다면 조건문으로 쿠키를 활용할 수 있겠다.


현재 적용중인 프로젝트에 middleware 를 적용해보자

작성중인 프로젝트에서 로그인의 과정은 서버에서 session id 를 쿠키로 클라이언트에 전송하고, 이후 클라이언트에서 서버에 매번 요청을 보낼때마다 쿠키 내 session id 를 전달하면서 서버 session storage 에 존재하는 id 인지 확인하는 것으로 인증절차를 진행하고 있다. 즉 로그인 인증의 핵심은 쿠키라고 할 수 있겠다.

 

우선 머리속으로 그려본 과정을 설명해보자면..

 

  • 로그인이 성공하면 cookie 가 생성이 된다. (connect.sid)
  • 브라우저가 쿠키 connect.sid 를 가지고 있다면, 로그인 페이지에 접근하지 못하도록 한다.
  • 로그아웃을 하면 브라우저의 쿠키 connect.sid 를 삭제한다.
  • 브라우저가 쿠키 connect.sid 를 가지고 있지 않다면, 모든 페이지의 접근 시 로그인 페이지로 redirect 한다.

심플한 과정이라 생각은 들었고 실제 middleware.ts 파일을 생성하여 코드를 작성해보았다. 그리고 막혀버렸다..

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

export async function middleware(request: NextRequest, ev: NextFetchEvent) {

  if (request.cookies.has('connect.sid')) {
    return NextResponse.next();
  }
  return NextResponse.redirect(new URL('/userlogin', request.url));
}

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

미들웨어에서 쿠키에 접근할 수 있다는 점을 응용하여, 로그인 시 생성되는 connect.sid 가 존재한다면 그냥 matcher 의 path 대로 next 하며, 아니라면 matcher 의 path 를 모두 /userlogin 으로 redirect 하는 코드이다. matcher 는 로그인을 제외한 나머지 페이지에 대해서 설정을 해두었다.

 

문제가 딱히 있는 코드는 아니지만, 미들웨어가 작동하는 조건에서 문제가 발생하였다.

 

우선, 로그아웃을 하였다고 해서 브라우저 내 쿠키가 삭제되지는 않는다는 점을 간과하였다. 이 부분때문에 로그아웃을 하여도 여전히 redirect 하지 않는 문제가 발생하였다. 단순하게 생각해서 쿠키만 삭제하면 되겠지 라고 생각했는데, 생각해보니 자바스크립트로 쿠키에 접근하는 것 자체가 막혀있고, 이에 대한 httpOnly 설정을 false 로 서버에서 설정하여야 가능하다는 사실을 알게 되었다. 

 

두번째는 matcher 에 /userlogin 을 설정하게 되면 무한 리다이렉트가 발생한다는 점에 있었다. 계획대로라면 쿠키가 있으면 다른 페이지들, 아니라면 로그인 페이지로 리다이렉트 시키는 것이었고 아래처럼 코드를 작성하였었다.

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

export async function middleware(request: NextRequest, ev: NextFetchEvent) {
  if (request.cookies.has('connect.sid')) {
  	if (request.nextUrl.pathname.startsWith('/userlogin')){
    return NextResponse.redirect(new URL('/closet/overview', request.url);
    }
  }else{
    return NextResponse.redirect(new URL('/userlogin', request.url));
  }
}

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

하지만 결과는 만일 쿠키가 로그아웃을 통해 삭제되었다고 했을 때, reducer 에 의해 userlogin 으로 router 가 발생하게 되는데, 이 경우 NextResponse.redirect(new URL('/userlogin', '/userlogin') 이 발동되게 되어 무한 리다이렉트가 발생하게 된다. 

 

그래서 어떻게 해야할지 고민을 이어가다가, 미들웨어에서는 /userlogin 으로 redirect 하는 부분만을 다루는 것이 좋겠다고 판단하였고, 쿠키를 일단 삭제할 수 있어야 하기에 서버에서 httpOnly : false 로 설정한 뒤, reducer 에서 로그아웃 시 쿠키의 만료기간을 0초 로 변경하는 로직을 작성했다.

      // user reducer 내
      
      case t.LOGOUT_SUCCESE: {
        document.cookie = 'connect.sid=; max-age=-1; path=/';
        draft.logOutLoading = false;
        draft.logOutDone = true;
        draft.logOutError = null;
        draft.me = action.data;
        Router.push('/userlogin');
        alert(`로그아웃 되셨습니다.`);
        break;
      }

로그아웃 시 쿠키는 삭제되며, 이후 로그인이 되지 않은 상태에서 다른 페이지에 접근하려 한다면, 미들웨어에 의해 다시 로그인 페이지로 리다이렉트 되도록 설정되었고, 실제로 그렇게 됨을 확인하였다.

 

로그인 됬을 시 /userlogin 으로 접근할 때 redirect 를 처리하는 부분은 userlogin 페이지에서 getServerSideProps 에서 처리해주었다.

export const getServerSideProps = wrapper.getServerSideProps(store => async (context: GetServerSidePropsContext) => {
  const cookie = context.req ? context.req.headers.cookie : '';
  axios.defaults.headers.Cookie = '';
  if (context.req && cookie) {
    axios.defaults.headers.Cookie = cookie;
  }
  store.dispatch({
    type: t.LOAD_TO_MY_INFO_REQUEST,
  });

  store.dispatch(END);
  await (store as SagaStore).sagaTask?.toPromise();
  if (store.getState().user.me) {
    return {
      redirect: {
        destination: '/closet/overview',
        permanent: false,
      },
    };
  }
  return {
    props: {},
  };
});

전체 페이지로의 리다이렉트라는 것이 좀 한계가 느껴지지만, 우선 목표인 로그인페이지로의 이동은 막았다.


그래서 이대로 배포 후에도 적용하였는가?

미들웨어를 활용하는 시도는 나쁘지 않았다고 생각하지만, 더 나은 코드가 떠오르지 않는 한 미들웨어를 활용하지는 않을 것 같다. 실제 배포했을 때 조금은 귀찮지만 모든 페이지마다 redirect 를 설정하였다. 왜 그럴 수밖에 없었냐면...

 

쿠키를 자바스크립트로 수정할 수 있다는 점에서 보안에 문제가 된다는 점이 걸렸다. httpOnly 가 디폴트 값으로 true 라는 점은 운영할 웹페이지를 해킹에 대비하여 안전하게 보호하기 위한 절차임을 알 수 있고, 그렇기에 쿠키를 수정하는것은 시도하지 않는것이 좋다고 판단하였다. 따라서 로그아웃 시 쿠키를 삭제하는 로직을 사용하지 않기로 하였다. 아직 적용하지는 못하고 있지만 서버에서 로그아웃 시 만료일자가 0초인 쿠키를 전달하는 방법? 같은 부분을 좀 더 생각해보려고 한다.

 

쿠키를 삭제하지 않는다면, 쿠키의 여부로 판단하는 미들웨어 역시 제대로 동작할 리 없다. 그렇기에 모든 페이지에 getServerSideProps 를 통해서 redirect 를 설정하는것으로 일단 마무리 하였다. 아래는 하나의 페이지 예시이다.

export const getServerSideProps = wrapper.getServerSideProps(store => async (context: GetServerSidePropsContext) => {
  const cookie = context.req ? context.req.headers.cookie : '';
  axios.defaults.headers.Cookie = '';
  if (context.req && cookie) {
    axios.defaults.headers.Cookie = cookie;
  }
  store.dispatch({
    // store에서 dispatch 하는 api
    type: t.LOAD_TO_MY_INFO_REQUEST,
  });

  store.dispatch({
    type: t.LOAD_ITEM_REQUEST,
    data: { clothId: context.params?.id },
  });

  store.dispatch(END);
  await (store as SagaStore).sagaTask?.toPromise();
  if (!store.getState().user.me) {
    return {
      redirect: {
        destination: '/userlogin',
        permanent: false,
      },
    };
  }
  return {
    props: {},
  };
});

 

결론적으로 next.js 의 middleware 를 사용하지는 못하였지만, 그럼에도 추후 더 좋은 방안이 떠오른다면 언제든 활용할 수 있는 기능을 공부하게 되어 의미있는 경험이었다 생각한다. 이 외에도 next.js 에서 제공하는 기능들이 많으니 더 좋은것들이 있는지도 살펴봐야 겠다.