Blog

테크

Next.js와 Auth.js 소셜 로그인 세션 유지를 위해 토큰 갱신 및 관리하기 (DB / JWT 방법)

#Next.js#Auth.js#Login

2025-01-17

Next.js와 Auth.js 소셜 로그인 세션 유지를 위해 토큰 갱신 및 관리하기 (DB / JWT 방법)
🗝️

Next.js와 Auth.js 소셜 로그인 세션 유지를 위해 토큰 갱신 및 관리하기 (DB / JWT 방법)

안녕하세요.
최근 바디닷 피트니스 웹의 사용자 경험을 개선하기 위해 Apple, Google, Kakao, Naver 소셜 로그인 기능을 구현했습니다. 로그인과 계정으로부터 사용자 정보 조회 기능을 구현하면 끝이라고 생각했지만 실제로는 OAuth 토큰 관리와 로그인 유지를 위한 로직 구현이 더 중요한 과제가 되어 작업했습니다.
이 작업 경험을 토대로 이번 글에서는 소셜 로그인 기능 구현 이후에 OAuth 토큰을 관리하는 방법과 로그인 유지방법을 공유해 보겠습니다.
notion image
 

소셜 로그인 기능 구현 이후 작업 목록

소셜 로그인 기능 구현한 후에 수동으로 해야 하는 작업은 아래와 같습니다.
  1. 토큰 관리 (DB or JWT 방식)
  1. 세션 유지 및 갱신 처리 (Refresh token)
  1. 세션 만료될 경우 처리
 
Next.js와 Auth.js JWT방식으로 소셜 로그인 세션 유지 및 토큰 갱신 흐름도
Next.js와 Auth.js JWT방식으로 소셜 로그인 세션 유지 및 토큰 갱신 흐름도
 
왜 저 작업들을 추가적으로 처리해야 할까요?
Auth.js 에서 제공하는 소셜 로그인은 OAuth2.0 기반으로 작동합니다. OAuth 작동 방식 때문인데요, 먼저 OAuth 개념부터 설명드리겠습니다.
 

OAuth 이란?

OAuth는 제3자 애플리케이션이 사용자 자격 증명을 공유하지 않고도 사용자의 데이터나 리소스에 안전하게 접근할 수 있도록 인증 및 권한 부여를 처리하는 오픈 표준 프로토콜입니다.
 
이는 호텔 카드 키로 객실 입장하는 상황으로 비유할 수 있을 것 같습니다. 호텔 카운터에서 다른 세부 정보를 말하지 않고 이름과 예약 번호를 말하면 직원은 객실 문을 열 수 있는 카드 키를 발급해줍니다. OAuth도 비슷한 방식으로 작동합니다. OAuth에서 애플리케이션 하나가 사용자의 자격 증명을 보내는 대신 다른 애플리케이션에 권한 부여 토큰을 보내 사용자에게 액세스 권한을 부여합니다.
 
Auth.js와 OAuth 인증 흐름도 | 출처 : https://authjs.dev/concepts/oauth
Auth.js와 OAuth 인증 흐름도 | 출처 : https://authjs.dev/concepts/oauth
 

로그인 세션 유지 및 토큰 갱신 작업을 해야 하는 이유

아래는 OAuth 응답 데이터 일부입니다.
{ "access_token": "로그인 유효성을 입증하는 키", "refresh_token": "액세스 토큰을 갱신할 때 쓰이는 키, 보통 만료 시간이 더 길거나, 만료되지 않음(서버 설정에 따라 다름)", "expires_at": "액세스 토큰 만료 시간 (ISO 형식)" }
 
OAuth 보안상의 이유로 accessToken이 짧은 시간(약 1시간)동안만 유효합니다. accessToken이 만료되면 현재 로그인이 유효하지 않으니 로그아웃 됩니다. 그러면 사용자가 이용 중에 로그인이 풀려 다시 로그인을 해야 하는 불편한 상황이 계속 발생합니다. 이러한 문제를 해결하기 위해 로그인 세션 유지 및 토큰 갱신 작업을 구현해야 합니다.
 
세션 유지 및 토큰 갱신은 사용자가 지속적으로 로그인 상태를 유지할 수 있도록 백그라운드에서 refreshToken을 활용하여, 만료된 accessToken을 갱신하는 작업을 말합니다.
 
  • 토큰(Token)은 사용자가 인증된 후, 서버가 발급하여 클라이언트에게 전달하는 작은 데이터 조각입니다. JWT(JSON Web Token) 형식으로 사용되며, 서버와 클라이언트 간의 인증 정보를 안전하게 전달하는데 사용됩니다.
 
  • 세션(Session)은 사용자가 웹 사이트와 상호작용하는 동안 서버에 사용자 정보를 저장하는 방식, 일반적으로 하나의 세션은 사용자가 웹 사이트에 방문해 활동을 시작하고 브라우저를 닫거나 세션이 만료될 때까지의 기간을 나타냅니다.
 

Refresh Token Rotation으로 세션 유지 및 토큰 관리하기

수시로 토큰 유효성을 확인하는 과정에서 refreshTokenaccessToken, expiresAt 와 같은 토큰 데이터가 사용됩니다. 이 데이터들은 소셜 로그인할 때만 응답 받기 때문에, 수시로 확인하기 위해서는 토큰 데이터를 저장해두어야 합니다.
 
저장한 토큰을 확인해 보니 만료가 되었다면 토큰 갱신을 합니다. 이때, accessToken뿐만 아니라refreshToken도 갱신할 수 있습니다. Auth.js에서는 세션 유지를 목적으로 토큰 갱신하는 방법을 ‘Refresh Token Rotation’라고 언급하며, JWT와 DB 각 구현 방식에 대해 예시를 다루고 있습니다.
 
Refresh Token Rotation(새로 고침 토큰 회전)은 만료된 accessToken을 갱신하기 위해 refreshToken을 사용하는 과정에서, 기존 refreshToken을 무효화하고 새로운 refreshToken을 발급하는 보안 강화 전략입니다. refreshToken이 한 번 유출되면 공격자는 이를 이용하여 계속해서 새로운 액세스 토큰을 발급받아 시스템에 무단 접근하는 문제를 방지합니다.
 

JWT 방식으로 토큰 관리 및 세션 유지하기

JWT 방식은 jwt 콜백을 사용하여 토큰 유효성을 확인하는 방법입니다.
 
  • Auth 설정에 session: { strategy: 'jwt' }를 추가하면 jwt 콜백을 사용할 수 있습니다.
  • jwt 콜백에서 토큰을 반환하면 session 콜백의 파라미터 token으로 받을 수 있습니다.
  • session 콜백에서 만료시간을 확인하여 시간이 지났다면 토큰 갱신합니다.
export const { handlers, signIn, signOut, auth } = NextAuth({ // jwt 설정 활성화 session: { strategy: 'jwt' } providers: [ Google({...}), ], callbacks: { // jwt에서 token 저장 async jwt({ account }) { token = account.accessToken; ... retrun token; }, async session({ session, token }) { // 만료시간 확인 if (token.expires_at * 1000 < Date.now()) { ... // 토큰 갱신 코드 } return session; } });
 
DB방식은 수시로 조회 쿼리를 사용해야 하고 DB 데이터 관리 비용 발생 및 유지보수 난이도를 낮추기 위해서 JWT 방식으로 선택하여 구현했습니다. 그 과정을 지금부터 설명드려보겠습니다.
 

1. Flow chart

다시 한 번 플로우 차트를 참고하시면 더욱 도움이 될 것 같습니다.
Next.js와 Auth.js JWT방식으로 소셜 로그인 세션 유지 및 토큰 갱신 흐름도
Next.js와 Auth.js JWT방식으로 소셜 로그인 세션 유지 및 토큰 갱신 흐름도

2. 타입 설정

코드 설정하기 전에 jwt 와 session 에서 추가로 쓸 타입을 추가합니다.
// types.d.ts declare module "next-auth" { interface Session { // 세션 연장 처리에 실패할 경우, 이를 통해 로그아웃을 처리함 error?: "SessionExpired" } } declare module "next-auth/jwt" { interface JWT { access_token: string expires_at: number refresh_token?: string // 세션 연장 처리에 실패할 경우, 실패 여부를 세션으로 넘기기 위해 추가 error?: "SessionExpired" } }

3. 응답 토큰 데이터

Auth.js에서는 소셜 로그인 기능을 구현할 수 있는 provider를 제공합니다.
// auth.ts export const { handlers, signIn, signOut, auth, unstable_update: update, } = NextAuth({ adapter: customPrismaAdapter(prisma), providers: [ Google({...}), Kakao({...}), Naver({...}), Apple({...}), ], callbacks: { ... }, });
provider를 설정하고 소셜 로그인을 성공하면 다음과 같이 응답 데이터를 받습니다.
{ "user": { "name": "사용자 이름", "email": "사용자 이메일", "image": "사용자 프로필 이미지" }, "access_token": "로그인 유효성을 입증하는 키", "refresh_token": "액세스 토큰을 갱신할 때 쓰이는 키, 보통 만료 시간이 더 길거나, 만료되지 않음(서버 설정에 따라 다름)", "expires_at": "액세스 토큰 만료 시간 (ISO 형식)" }

4. auth 설정

jwt 콜백의 account가 OAuth 응답 객체를 가지고 있고 로그인할 때만 값이 존재합니다.
// auth.ts export const { handlers, signIn, signOut, auth, unstable_update: update, } = NextAuth({ // jwt 사용할 수 있도록 설정하기 session: { strategy: 'jwt' }, // 로그인과 회원가입을 동시에 처리하기 위해 어댑터 추가함 jwt 방식을 사용하는데 있어서 어댑터 설정은 필수가 아님 // 프로젝트 로직 구현 중에 내장 함수의 호환 문제가 발생하여 기존 PrismaAdapter를 확장하여 사용함 adapter: customPrismaAdapter(prisma), providers: [ Google({...}), Kakao({...}), Naver({...}), Apple({...}), ], callbacks: { async jwt({ token, account }) { // 로그인할 때만 account 값이 존재함, 응답 받은 account 정보를 token에 저장 if (account) { token.provider = account.provider; token.providerAccountId = account.providerAccountId; token.refresh_token = account.refresh_token as string; token.access_token = account.access_token; token.expires_at = account.expires_at; } return token; }, async session({ session, token }) { // 토큰 만료 여부 확인 및 백그라운드 토큰 갱신 if (token.expires_at && Date.now() / 1000 >= Number(token.expires_at)) { await updateOauthToken(token); } return { ...session, error: token.error, }; }, }, });

5. Refresh Token Rotation 을 적용한 토큰 갱신 함수 작성

accessToken 또는 refreshToken도 만료될 경우, Refresh Token Rotation을 적용한 토큰 갱신하는 함수를 작성합니다.
 
  • 일부 제공자는 리프레시 토큰을 한 번만 발급하여 새로운 토큰을 받지 못한 경우가 있음, 그럴 경우에는 기존 토큰을 반환합니다.
  • 새로운 refreshToken을 발급받으면 기존 토큰은 무효화됩니다.
  • 발급받지 못한 경우에는 기존 refreshToken 토큰을 계속 사용할 수 있도록 합니다.
  • refreshToken 만료 여부에 따라 getNewTokens() 의 fetch 응답 데이터 refreshToken 여부가 결정됩니다.
// token.ts interface NewToken { refresh_token: string; access_token: string; expires_at: number; } interface ServiceEnvDict { [key: string]: { [key: string]: string; }; } const serviceEnvDict: ServiceEnvDict = { google: { endpoint: 'https://oauth2.googleapis.com/token', clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET, }, ... }; export async function getNewTokens(token: JWT): Promise<NewToken> { if (!token.provider || !token.refresh_token) { throw new Error('Missing token.provider or token.refresh_token'); } const response = await fetch(serviceEnvDict[token.provider].endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: serviceEnvDict[token.provider].clientId, client_secret: serviceEnvDict[token.provider].clientSecret, grant_type: 'refresh_token', refresh_token: token.refresh_token, }), }); const newTokens = await response.json(); if (!response.ok) throw newTokens; return { // 일부 제공자는 리프레시 토큰을 한 번만 발급하여 새로운 토큰을 받지 못한 경우가 있음, 그럴 경우에는 기존 토큰을 반환 refresh_token: newTokens.refresh_token ?? token.refresh_token, access_token: newTokens.access_token, expires_at: Math.floor(Date.now() / 1000) + Number(newTokens.expires_in), }; }

6. 토큰 갱신 함수 처리하는 함수 작성

토큰 갱신에 실패하면 여전히 토큰 만료되어있으니 로그아웃 처리해야 합니다. 토큰 갱신에 실패한 것을 알기 위해 token.error 에 값을 할당합니다.
// token.ts export async function updateOauthToken(token: JWT) { try { if (!token.email || !token.provider) { throw new Error('Missing token.email or token.provider'); } // 새로운 토큰 발급 const newTokens = await getNewOauthTokens(token); // 새로운 토큰 저장 token.access_token = newTokens.access_token; token.expires_at = newTokens.expires_at; if (newTokens.refresh_token) { token.refresh_token = newTokens.refresh_token; } return token; } catch (error) { // 새 토큰을 발급하고 저장하는 게 실패하면 토큰 만료로 로그아웃 처리하기 위함 console.error(error); token.error = 'SessionExpired'; return token; } }

7. 세션 만료 처리 및 로그아웃 로직 작성

// middleware.ts const { auth } = NextAuth(authConfig); export default auth(async req => { // 토큰 만료 되면 로그아웃 처리 if (req.auth?.error === 'SessionExpired') { req.nextUrl.pathname = '/users/error/nonexistence'; // 로그인 만료 안내 페이지 return NextResponse.next(); } });
 

DB 방식으로 토큰 관리 및 세션 유지하기

Auth.js 설정에서 jwt를 사용할 수 없는 상황이라면 DB 방식으로 구현할 수 있습니다.
DB방식Adapter를 연결하고 session 콜백 함수에서 조회 쿼리를 사용하여 토큰 유효성을 확인하는 방법입니다.
 
  • Auth.js의 Adapter는 인증 라이브러리와 데이터베이스 간의 상호작용을 담당하는 함수 집합입니다. 주요 함수로는 findUserByEmail, createUser, updateUser, deleteUser 등이 있으며, 각 함수는 사용자의 정보를 조회, 생성, 수정, 삭제하는 데 사용됩니다.
  • Adapter를 사용하려면 아래와 같이 계정의 토큰 정보(Account)를 저장할 수 있도록 DB 스키마도 세팅되어야 합니다.
  • Auth 설정에서 Adapter 연결합니다.
    • 로그인하면 자동으로 user를 찾아서 session 함수의 user 파라미터로 넘겨줍니다.
  • session 콜백에서 user.id를 가지고 user의 토큰 정보를 포함한 계정을 조회합니다.
  • session 콜백에서 만료 시간을 확인하여 시간이 지났다면 토큰 갱신합니다.
export const { handlers, signIn, signOut, auth } = NextAuth({ // 어댑터 연결 adapter: PrismaAdapter(prisma), providers: [ Google({...}), ], callbacks: { async session({ session, user }) { // 쿼리로 조회 const [googleAccount] = await prisma.account.findMany({ where: { userId: user.id, provider: "google" }, }); // 만료 시간 확인 if (googleAccount.expires_at * 1000 < Date.now()) { ... // 토큰 갱신 코드 } return session; } } }

마무리

As of today, there is no built-in solution for automatic Refresh Token rotation. This guide will help you to achieve this in your application. Our goal is to add zero-config support for built-in providers eventually. 
이처럼 현재 자동 Refresh Token 로테이션을 위한 내장 솔루션은 없어서 수동으로 갱신 로직을 추가해야 하지만, Auth.js에서 내장 공급자에 대한 구성 없이 업데이트할 의사가 있다고 하니 추후 업데이트를 기대해볼 만합니다. 그때가 되면 더욱 간단하게 세션 및 토큰 갱신을 설정할 수 있을 것 같습니다 🙌 
 
참고 문서

sokkanji

관련된 이야기

우리 센터 차별화를 위한 체형분석기, Bodydot Fitness 출시!

제품

우리 센터 차별화를 위한 체형분석기, Bodydot Fitness 출시!