본문 바로가기
개발/Next.js

[Next.js] NextAuth를 활용한 우아한 유저 관리

by 핸디(Handy) 2024. 2. 25.

들어가며

이번 글은 NextAuth를 활용하여 실제 서비스에서 우아하게 유저를 관리한 경험에 대해 말씀드리는 글입니다.

NextAuth와 Middleware를 이용해서 인증, 인가를 어떻게 관리하였는가에 대한 주제를 다룹니다.

다루지 않는 내용

  • 해당 글에서는 NextAuth의 기본 사용법에 대해 상세히 알려주지 않습니다. (공식문서 아주 추천)
  • Next13 환경에서 구현된 예시지만, 다른 버전에서도 크게 상이하진 않습니다.

 

우아한 유저 관리

이전까지 NextAuth는 주로 구글인증기로만 써왔습니다. 정확히 말하자면 인증만 썼던 거죠.

하지만 실제 서비스를 하다 보면 인증으로만 끝나지 않고 해당 유저에게 권한까지 주는 인가 단계를 거쳐야 합니다.

이전까지 구현한 서비스들은 인증, 인가에 대한 큰 구별 없이 유저를 식별할 수 있으면 됐기에 NextAuth 통해 만들어진 user 정보와 token만 필요했었어요.

근데 이번 서비스에는 결제시스템이 붙게 되면서 유저를 조금 더 복잡하게 관리할 필요성이 있었습니다.

유저별 시나리오

[기존]
유저 로그인 > 유저 식별, 토큰 발급 > 데이터 조회 > 토큰으롤 식별하여 데이터 반환
[변경]
유저 로그인 > 30일 내 탈퇴 유저인지 판단, 처음 가입 유저인지 판단, 개인정보 입력한 유저인지 판단  > 각 유저 상황에 맞춰 라우딩 또는 api 업데이트 > 유저별 접근 페이지 체크 

30일내 탈퇴한 유저라면 로그인이 안되고 30일내 가입할 수 없다는 페이지로 리디렉션 되고

처음 가입한 유저라면 로그인이 된 채로 유저 정보 입력한 페이지로 간 후 입력이 완료되면 무료 크레디트를 주고

사용준비된 유저라면 대시보드 페이지로 리디렉션 되어야 하죠.

기존 가입된 유저(베타 서비스를 운영 중이었기에) 동의 페이지로 가서 유저 동의를 받고 데이터는 변경처리해 주는 작업도 필요했습니다.

네.. 참 귀찮지만 모든 서비스의 시작은 유저이기에 다 대응을 해야 했습니다.

그래서 어떻게 해당 기능을 구현할까 하다가 이번 기회에 NextAuth를 잘 활용해 보자는 결론이 이르게 되었습니다.

NextAuth와 나의 인연

누가 보면 컨트리뷰터라고 오해할 만한 제목이지만, 그런 건 아니고요 그냥 많이, 주로 썼었습니다.

ERP 홈페이지 외주를 받아서 작업을 할 때, 마지막에 갑작스럽게 인증을 넣어달라고 했을 때도 NextAuth로 후딱 넣어줄 때 사용했고요.

구글로그인뿐만 아니라 한국인답게 네이버, 카카오 로그인까지 대응해 달라고 했을 때도 사용했고요.

이메일 인증을 할 때 keycloak과 함께 간편하게 사용했습니다.

특정 시간마다 토큰을 갱신(물론 refresh 로직은 짜야하지만)하고 refetchOnFocus를 통해 유저가 화면에 들어올 때만 토큰 갱신을 해주는 NextAuth가 참 좋았습니다.

네 아무튼 그렇습니다. NextAuth 짱짱입니다.

시나리오별 구현

이번 단락에서는 위에서 언급한 4가지 시나리오 중 3가지에 대한 구현을 진행해 보겠습니다.

기존 가입된 유저와 사용준비된 유저의 차이점은 내부 api 콜을 하느냐의 문제라서 NextAuth 로직보다는 비즈니스로직에 가까워서 하나로 합쳤습니다. 이 경우는 일반 유저로 하겠습니다.

NextAuth callback

우선 시나리오별 구현하기 전에 NextAuth에서 Callback에 관한 사전지식이 있어야 합니다. (당연히 공식문서를 읽어보면 좋고요)

 

Callbacks | NextAuth.js

Callbacks are asynchronous functions you can use to control what happens when an action is performed.

next-auth.js.org

공식문서 읽기 귀찮으신 분을 위해 제가 설명드리자면

Callbacks are asynchronous functions you can use to control what happens when an action is performed.
- https://next-auth.js.org/configuration/callbacks 발췌

유저가 액션을 했을 때 컨트롤 가능한 비동기 함수라고 보시면 됩니다. 원래 Nextauth에서 다 해주는데, 그 사이사이에 네가 원하는 함수를 넣어줘라는 느낌으로 봐주시면 되겠습니다.

그래서 여기에서 로그인 거부도 할 수 있고, 리디렉션도 가능하고 또 session, jwt에 대한 컨트롤이 가능합니다.

...
  callbacks: {
    async signIn({ user, account, profile, email, credentials }) {
      return true
    },
    async redirect({ url, baseUrl }) {
      return baseUrl
    },
    async session({ session, user, token }) {
      return session
    },
    async jwt({ token, user, account, profile, isNewUser }) {
      return token
    }
...
}

예시 코드로는 이렇게 되어있습니다.

처음 가입한 유저

처음 가입한 유저는 유저 메타를 입력하는 페이지로 리디렉션 되어야 합니다.

이것을 위하여 NextAuth에서 체크하여 처음 가입한 유지인지 판단하고 그 후에 플래그값을 넘겨 middleware에서 리디렉션 하는 로직으로 구현해 봤습니다.

Callback 중에서 jwt 콜백에서 해당 로직을 구현해 보겠습니다.

처음 가입한 유저를 저는 isAnonymousUser라는 boolean 값으로 판별합니다.

그리고 그 로직은 서비스마다 달라서 여기서는 다루지 않습니다. 

isSignupDeniedUser라는 값도 마찬가지입니다. 다만 이 값은 30일 내 탈퇴한 유저를 체크할 때 필요한 값이겠네요.

// api/auth/[...nextauth].ts
// 그 중 jwt 콜백 부분만

async jwt({ token, account, user, trigger }) {
  // 로그인할때 user meta  체크
  if (trigger === "signIn" && account) {
    const { isAnonymousUser, isSignupDeniedUser, locale } =
      await checkUserMeta(account.access_token, token.email);

    if (isAnonymousUser) {
     // 로그인체크하여 처음 가입한 유저라면 유저 기본정보를 서버에 보내기
      const { firstName, lastName } = await getUserName(
        user.name,
        account.id_token
      );
      await upDateUserWhenIsNew(
        account.access_token,
        token.email,
        firstName,
        lastName
      );
    }

    // 그외에 유저 메타로 가져온 값을 전달
    token.isSignupDeniedUser = isSignupDeniedUser;
    token.isAnonymousUser = isAnonymousUser; // <- 해당 값을 얻는 것이 이번 로직의 목적
    token.locale = locale;
  }

  if (token?.isAnonymousUser) {
    // 익명유저가 로그인이후 메타를 업데이트했는지 체크
    const { isAnonymousUser, isSignupDeniedUser, locale } =
      await checkUserMeta(token.accessToken, token.email);

    token.isSignupDeniedUser = isSignupDeniedUser;
    token.isAnonymousUser = isAnonymousUser;  <- 해당 값을 얻는 것이 이번 로직의 목적
    token.locale = locale;
  }
  if (account) {
    // account에 있는 토큰정보를 token값에 사용가능한 상태로 넘겨주기
    token.accessToken = account.access_token;
    token.accessTokenExpires = account.expires_at * 1000;
    token.refreshToken = account.refresh_token;
    token.refreshTokenExpires =
      Date.now() + account.refresh_expires_in * 1000;
    token.user = user;
  }

  // 토큰이 살아있으면 그냥 통과
  if (Date.now() < (token as CustomToken).accessTokenExpires) {
    return token;
  }

  // 토큰이 만료되었다면 refreshToken으로 갱신하여 전달
  return refreshAccessToken(token as CustomToken);
},

그리고 이 값을 middleware에서 넘겨받아서 처리하면 되겠습니다.

// middleware.ts
import { NextResponse } from "next/server";
import { withAuth } from "next-auth/middleware";

import { RouterPath } from "./src/utils/router/usePathRouter";

export default withAuth(
  async function middleware(request) {
    const url = request.nextUrl.clone();
    const originalPathname = url.pathname;
    
    // nextauth의 token에 저장된 값을 가져옴
    const nextauth = (request as any).nextauth.token;

    if (!nextauth.user) {
      url.searchParams.set("callback", originalPathname);
      url.pathname = RouterPath.authLogin;
      return NextResponse.redirect(url);
    }
    
    // AnonymousUser일때 user 메타를 입력하는 url로 redirect
    if (nextauth.isAnonymousUser && url.pathname !== RouterPath.userInit) {
      url.searchParams.set("callback", originalPathname);
      url.pathname = RouterPath.userInit;
      return NextResponse.redirect(url);
    }

    return NextResponse.next();
  }
);

export const config = {
  matcher: [" 중략 "],
};

각 코드를 주석을 추가해서 설명해 봤습니다. 

코드를 살펴보시면 이상한 점이 보이실 텐데요. 

if (trigger === "signIn" && account) {... } 블록에서 checkUserMeta를 해서 token.isAnonymousUser를 업데이트하고 

바로 다음줄에서  if (token?. isAnonymousUser) {... } 블록에서 또 checkUserMeta를 해서 token.isAnonymousUser를 업데이트를 한다는 점입니다.

이는 NextAuth가 토큰을 갱신하거나, 유저가 브라우저를 조작했을 때 트리거 되는 처음 시작이 jwt 콜백부터이기 때문입니다.

맨 처음 로그인할 때 AnonymousUser라면 매번 화면 이동 때마다 값을 체크하고 있죠.

왜 나면 유저 메타를 입력하는 창에서 메타를 업데이트하고 다른 페이지로 갔을 때에 체크가 되어야 하기 때문입니다.

그래서 로직이 2번 반복되어 있는 것처럼 코드가 되어있습니다.

30일 내 탈퇴한 유저

현재 만드는 서비스에서는 첫 가입 시에 무료 크레디트을 발급합니다. 그래서 크레딧 사용 후 탈퇴, 재가입을 방지하기 위해 30일 이내 재가입 불가를 요건을 추가했는데요.

signIn 콜백의 경우 return 값으로 true, false, url string를 받을 수 있습니다.

true 라면 로그인이 되어 다음 단계로 넘어가는데 false 또는 string이 온다면 로그인 과정을 취소하고 해당 url로 리다이렉션을 해줍니다.

// api/auth/[...nextauth].ts
// 그 중 signIn 콜백 부분만

async signIn({ user, account }) {
  if (account) {
    const { isSignupDeniedUser } = await checkUserMeta(
      account.access_token,
      user.email
    );

    if (isSignupDeniedUser) {
      return "/auth/denied";
    }

    return true;
  }

  return "/error/singin";
},

그리고  /auth/denied 경로에 있는 페이지에 원하는 화면을 구현해 주면 됩니다.

아래 gif는 사내에서 기능 poc를 진행했을 때 화면 예시입니다.

구글 로그인을 했음에도 로그인이 완료되지 않고 auth/denied로 리디렉션 된 모습을 확인할 수 있습니다 ( 화면을 작게 찍었더니 url이 가려져있네요)

 

일반 유저

마지막으로 일반유저입니다.

실제로는 일반유저는 뭐 딱히 해줄 게 없습니다. 위의 케이스에 걸리지 않으면 제대로 로그인되어야겠죠.

마무리

이것으로 nextauth를 이용해서 다양한 유저 시나리오에 대한 코드를 작성해 보았습니다.

블로깅을 하면서 살펴보니 아직 리펙토링 할 요소가 있지만, 기능을 보여주는 데는 충분한 예시라고 생각합니다.

Nextauth에 대한 글은 이만 마치겠습니다.

우리 모두 편리한 Nextauth를 통해 편리한 프런트엔드 세상을 누려봅시다.

끝.

댓글