NextJS13과 NextAuth.js를 활용한 OAuth 도입기

작성일: 2023.07.11

AI 요약

NextJS 13와 NextAuth를 사용해 OAuth를 통한 가입과 인증 구현하는 방법 소개. NextAuth로 두 가지 시도 후 결론 도출.

Contents

개요

요즘 서비스는 전통적인 로그인 방식을 아예 제하고 OAuth만 사용해서 가입, 인증을 구현하는 경우가 더러 있다.

이번에 새로 Blankit 프로젝트를 진행하면서 기획 단계에서 이메일 로그인을 과감히 삭제(예상되는 유저층이 모두 카카오, 구글, 네이버 중 하나 이상 가입 돼 있을 것이므로)하기로 됐다.

마침 프론트 웹을 NextJS13을 사용하기로 한 만큼 OAuth도 편하게 구현할 수 있는 NextAuth.js를 사용하기로 했다.

  • NextAuth.js란? NextAuth.js (그런데 와중에 Auth.js로 이름과 페이지가 업데이트됐다) 도와줘 ChatGPT! 230711-172434

환경

230711-172417

전체적인 구조는 간단하다.

Vercel로 배포된 NextJS서버와 AWS 상의 Spring 서버 그리고 DB.

이에 추가로 각 OAuth 서비스 운영자가 제공하는 Provider 서버가 있을 것이다.

과정

첫 번째 시도

구상

초기에는 찾아본 자료를 통해 일반적인 경우의 OAuth 로그인을 구현하고자 했다.

230711-172449

위 그림과 같이

  1. 클라이언트에서 NextAuth를 통해 서버(Next백엔드) - Provider 서버 간 통신 과정을 통해 user 정보와 auth server access token을 발급받는다.
  2. 백엔드(스프링) 서버에 로그인 요청을 하면서 auth server로 부터 발급받은 access token을 보낸다.
  3. 클라이언트는 백엔드(스프링) 서버로부터 유저 정보 및 api 요청에 사용될 token을 발급받는다.

의 과정이었다.

문제

  1. Auth Service Provider와의 요청 시에는 NextJS 자체 백엔드 서버를 거쳐서 발급받고 이후에는 그 정보를 클라이언트에서 스프링 백엔드 서버에 요청한다. 이 과정이 퍽 직관적이지 못하고, 꽤 오버헤드가 많이 발생한다고 생각한다.
  2. (auth provider로 부터 받은) access token을 로그인 요청시 spring 서버에 담아 보낸다. 이를 통해서 유저 정보를 spring 서버에서 auth provider에 재요청해서 DB에 저장하거나 활용할 수 있는데, 중복적으로 auth를 요청하는 상황이 불필요하며, 구현하기 복잡하다.
  3. 제일 중요한 점은 NextJS backend에 적응이 안된 내가 이를 번갈아 가며 사용하는 것에 어려움을 느끼고 있다.

두 번째 시도

구상

NextAuth.js에서 권장하는 백엔드와의 연결 및 아키텍쳐가 있었다. 그런데 NextJS13 관련한 자료도 흔치 않은데 이를 응용해 데이터베이스 어뎁터를 생성하는게 녹록치 않아 처음엔 넘어갔었다.

팀원 분의 큰 도움으로 대략적인 문서의 구현 및 세부 내용을 배울 수 있었다.

문서

일단 nextAuth 자체가 nextJS api 디렉터리에 [...nextAuth].js 명의 파일을 생성해 작성한 authOptions을 기반으로 작동한다.

nextAuth.js의 장점이 이 과정에서 간단한 설정만으로 가입, 로그인 로직을 직접 구현할 필요없이 추상화 된 메서드를 통해 유저 정보와 세션, 토큰을 얻을 수 있다는 것이다.

하지만 따로 백엔드 서버(우리의 spring 서버에 해당하는)를 통해 조금 더 커스텀한 로직을 구현하길 원하는 유저를 위해 nextAuth.js에서는 adapter를 구현해 지정해줄 수 있다.

구현

공식 document에서는 다음과 같은 예시를 제공한다.

export default function YourAdapter (config, options = {}) {
  return {
    async getAdapter (appOptions) {
      async createUser (profile) {
        return null
      },
      async getUser (id) {
        return null
      },
      async getUserByEmail (email) {
        return null
      },
      async getUserByProviderAccountId (
        providerId,
        providerAccountId
      ) {
        return null
      },
      async updateUser (user) {
        return null
      },
      async deleteUser (userId) {
        return null
      },
      async linkAccount (
        userId,
        providerId,
        providerType,
        providerAccountId,
        refreshToken,
        accessToken,
        accessTokenExpires
      ) {
        return null
      },
      async unlinkAccount (
        userId,
        providerId,
        providerAccountId
      ) {
        return null
      },
      async createSession (user) {
        return null
      },
      async getSession (sessionToken) {
        return null
      },
      async updateSession (
        session,
        force
      ) {
        return null
      },
      async deleteSession (sessionToken) {
        return null
      },
      async createVerificationRequest (
        identifier,
        url,
        token,
        secret,
        provider
      ) {
        return null
      },
      async getVerificationRequest (
        identifier,
        token,
        secret,
        provider
      ) {
        return null
      },
      async deleteVerificationRequest (
        identifier,
        token,
        secret,
        provider
      ) {
        return null
      }
    }
  }
}

이걸 보고 왜 처음에 넘어갔냐면, 예시만 있고 설명이 거의 없다시피했다. 문서가 그리 친절하진 않고… 구글링해도 사례를 찾기가 힘들다.

이후 팀원 분의 도움 덕에 다음과 같은 과정을 알 수 있었다.

먼저 adapter를 넘겨주면, 내부의 함수가 정해진 플로우대로 동작한다. 내부의 구현을 하나하나 정해줄 필요가 없다.

미가입 상태 로그인

  1. getUserByAccount를 호출하여 로그인한 Account가 reference하는 User가 있는지 확인 후 리턴
  2. 만약 없다면 getUserByEmail 호출하여 동일한 이메일로 가입된 User가 있는지 확인 후 리턴
    1. 만약 있다면 다른 제공업체로 가입한 것이므로 로그인 진행 멈추고 …/signin?error=OAuthAccountNotLinked로 이동됨 사용자에게 다른 로그인 방법으로 시도 하라는 메시지 띄어주면됨 (ex. google로 가입한 상태에서 같은 이메일인 github 로그인시 발생)
  3. createUser 호출하여 User 스키마에 데이터 등록 시키고 등록한 유저 정보리턴
  4. linkAccount 호출하여 위에 생성한 UserAccount를 데이터베이스상에서 연결

가입상태 로그인

  1. getUserByAccount를 호출하여 로그인한 Account가 reference하는 User가 있는지 확인 후 리턴 (여기서 리턴된 유저의 정보가 아래의 콜백에서 사용됨)

  2. 존재한다면 AuthOptions의 callbacks에 session함수 호출됨

    1에서 리턴한 유저 id가 token.sub에 담겨있는데 이를 활용해 getUserById와 같은 함수를 만들어 호출시킨후 client 에서 사용할 데이터를 담아서 리턴하면됨

이제 여기서 spring backend에서 수행할 몇 가지 api 구현을 요청하면 된다.

사전적으로 백엔드와 필요한 DB 스키마에 대한 합의가 선행되어야 한다.

NextAuth.js의 adapter 동작에 적합한 DB 모델은 마찬가지로 document에서 제안해주는 스키마를 따르도록 하자.

230711-172510

Account 스키마는 Auth Provider로부터 받는 정보들을 NextAuth에서 반환해준 정보이고, User 스키마에 기본적인 유저 정보 및 서비스에서 추가로 필요한 필드를 추가해주거나 빼주면 된다.

우리 서비스의 경우로 예를 들면 경력, 업종 등을 User에 추가해주면 될 것이다.

Adapter를 통해 spring 서버에 요청할 API는

  • User 생성
  • User와 Account의 link
  • ID를 통한 User 조회
  • Email을 통한 User 조회
  • Account를 통한 User 조회

가 있다.

중요한 점은 api 요청 시의 token을 어떻게 처리하냐인데, nextJS의 백엔드와 spring 간에 합의된 secret 키를 정의해두고 user next 백엔드 내부에서 user 인증이 통과된 경우에만 요청 가능하도록 했다.

230711-172519

결과적으로 위와 같이 로그인 처리와 api 요청 과정을 조금 더 간소화할 수 있다.

결론

nextJS 13의 api 폴더와 nextAuth.js에서 제공하는 강력한 auth 관리를 통해 쉽게 OAuth 로그인을 구현할 수 있다.

하지만 여러 상황에 따라 원하는 방식으로 커스텀화하는 과정이 공식 문서의 불친절함(에 더한 나의 부족한 영어실력 덕분에) 쉽지만은 않게 느껴졌다.

여러 문서와 동료분의 도움으로 로그인의 세부 과정(다른 플랫폼으로 가입한 동일 메일 처리 등)을 커스텀할 수 있었다.

앞으로 수정사항이 생길 것 같은데, 정리해서 업데이트 해야겠다.

태그: