피키토키 프로젝트를 진행하면서 회원가입 기능을 맡게 되었다. 따로 회원가입을 위한 아이디와 비밀번호 입력 폼을 구현할 필요는 없었고, 카카오 로그인 로직 하나만 구현하면 됐다. Next.js 프레임워크를 사용하기로 했기 때문에, 해당 프레임워크의 인증 라이브러리인 NextAuth를 사용해보고자 했다. 클라이언트, 서버 사이드에서 모두 인증 관리를 편하게 처리할 수 있을 것 같았다.
처음 공식문서를 보고 생소한 코드들이 많아 이해하는데 꽤 많은 시간이 소요됐다. providers, callbacks, jwt 등등.. 이번 기회에 정리하면서 확실히 알고 넘어가보자.
providers: [
Kakao({
clientId: process.env.KAKAO_CLIENT_ID as string,
clientSecret: process.env.KAKAO_CLIENT_SECRET as string
})
],
providers는 필수적인 항목이다. 현재는 Kakao만 인증 공급자로 표기되어 있지만, Google, Github 등 다양하게 제공하고 있다.
다음에 오는 callbacks 부분부터 많은 시간이 걸렸다. callbacks는 어떤 이벤트가 발생했을 때 실행되는 각 핸들러들을 지정할 수 있다.
callbacks: {
jwt: async ({ token, account }) => {
if (account) {
try {
const { data: tokenData } = await axios.get(`${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/kakao/exchange`, {
headers: {
Authorization: `Bearer ${account.access_token}`,
"Content-Type": "application/json"
}
})
token.expiredAccessToken = tokenData.body.expiredAccessToken
token.accessToken = tokenData.body.accessToken
token.refreshToken = tokenData.body.refreshToken
token.userName = tokenData.body.userName
token.channelCount = tokenData.body.channelCount
token.channelId = tokenData.body.channelId
} catch (error) {
console.error("카카오 또는 DB 요청 실패:", error)
}
}
const nowTime = Math.round(Date.now() / 1000)
const shouldRefreshTime = (token.expiredAccessToken as number) - 5 * 60 - nowTime
if (shouldRefreshTime > 0) {
return token
}
return refreshAccessToken(token)
},
session: async ({ session, token }: { session: Session; token: JWT }) => {
session.user.accessToken = token.accessToken
session.user.refreshToken = token.refreshToken
session.user.userName = token.userName
session.user.channelCount = token.channelCount
session.user.channelId = token.channelId
session.user.expiredAccessToken = token.expiredAccessToken
if (token.logout) {
session.user.logout = true
}else {
session.user.logout = false
}
return session
}
},
callbacks의 jwt부터 살펴보자. OAuth 로그인에 성공하게 되면, JWT를 생성하게 되고 token과 account 매개변수에 각 데이터들이 저장이 된다. 참고로, account는 첫 로그인 시에만 존재한다. 이때 account에는 카카오 API에 대한 정보가 담기게 된다. 하지만, 나는 백엔드로부터 카카오 accessToken을 전달해 새로 발급된 accessToken과 다른 데이터들을 받아올 수 있도록 했다. 이 받아온 데이터들을 token 값에 할당하여 저장해줬다.
이어 나오는 callbacks.session도 중요한 역할을 한다. 앞서 token에 할당한 값들을 전달받게 되는 곳이다. 세션이 확인될 때마다 호출이 되며, 반환 값은 클라이언트에서도 사용이 가능하다. 서버 사이드 및 클라이언트 모두 사용이 가능하기 때문에 필요한 정보들을 모두 session에 할당했다.
위 코드들은 모두 app/api/auth/[...nextauth]/auth.ts 내부에 authOptions라는 변수에 객체 타입으로 구현을 했다. 이렇게 구현된 authOptions 변수를 app/api/auth/[...nextauth]/route.ts 내부에서 인증 관련 요청을 자동으로 관리할 수가 있도록 인증 핸들러를 생성하도록 했다.
import NextAuth from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
그럼 서버 컴포넌트와 클라이언트 컴포넌트에서 session을 어떻게 사용할까?
먼저 클라이언트에서 사용하기 위해서는 children을 wrapper한 SessionProvider props로 session을 전달해줘야 한다.
나는 아래 코드를 AuthProvider라는 클라이언트 컴포넌트를 만들어주며, layout.tsx에 위치시켰다.
<SessionProvider session={session}>
{children}
</SessionProvider>
이제 클라이언트 사이드에서 useSession 훅을 사용하여 session에 대한 정보를 불러올 수가 있게 된다.
const { data: session } = useSession()
반면 서버 컴포넌트에서 session에 대한 값들을 가져오고 싶다면, getServerSession 함수를 호출해보자.
나는 루트 페이지(서버 컴포넌트)에서 기존 회원 여부, 채널 개수에 따라 redirect 처리를 해 줄 필요가 있었다. 이때 getServerSession을 통해서 session에 대한 정보를 가져온 다음, 공용 닉네임이 존재하는지, 채널 개수가 1개보다 많은지 또는 채널 개수가 존재하지 않거나 1개일 때에 따라 로그인 성공 후 서로 다른 경로로 redirect 처리를 해주었다.
사실 여기까지는 크게 어려움없이 구현을 했다. 문제는 refreshToken 로직부터였다. 로그인 담당을 맡으신 백엔드 개발자분도 처음 경험하시다보니, 서로 많은 대화가 필요했다. 같이 배우는 입장이고, 열심히 해주셔서 시간이 좀 걸려도 해결할 수 있었던 것 같다.
RefreshToken.. NextAuth 자체 버그?
refresh 로직을 구현하면서 정말 많은 버그들을 마주하게 되었다. refresh api 요청을 기존 refreshToken을 가지고 재발급까지 성공했지만, 다시 이전 refreshToken 값으로 전달을 하는 현상이었다. 각종 문서, 블로그, 외국유튜브 총 동원하여 해결하려 했지만, 해결하지 못했고 같은 팀원분이 다행히 현직자이셔서 여쭤봤었다. 현직자분도 NextAuth를 사용할 때 동일한 버그를 겪었다고 말씀해 주셔서, 다른 방안을 찾아 나섰다.
문제는 토큰을 재발급 받아오면, jwt 콜백이 실행되면서 새로 갱신이 되고 session 콜백에도 최신 토큰들이 전달되어야 하는데!
마지막 갱신되기 직전 토큰을 가지고 jwt 콜백을 한번 더 호출하는 것이다. (이미 만료된 시점)
이미 만료가 된 토큰을 가지고 API 요청을 보내면서 에러가 발생하게 된 것이었다.
이대로 재발급 요청을 끝내고, 로그아웃 처리를 해버리는 건 말도 안 된다고 생각을 했다. 사용자 입장에서 얼마나 불편할까..
그래서 내가 구현한 방법은 아래와 같다.
해결 방법
jwt 콜백 함수 내부에 아래와 같은 코드를 구현했다.
const nowTime = Math.round(Date.now() / 1000)
const shouldRefreshTime = (token.expiredAccessToken as number) - 5 * 60 - nowTime
if (shouldRefreshTime > 0) {
return token
}
return refreshAccessToken(token)
현재 시간과 accessToken의 만료시간을 구하고, 만료 5분 전 경우 미리 재발급 요청을 보내도록 했다. 즉, 5분 전일 경우 refreshAccessToken 함수가 호출이 된다. 설정한 shouldRefreshTime이 5분 이내에 웹 토큰이 실행되거나 업데이트될 때, 만료시간을 확인하고, 새로운 토큰을 받아올 수 있다.
async function refreshAccessToken(token: JWT) {
try {
const { data: refreshTokenData } = await axios.post(`${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/refresh`, null, {
headers: {
Authorization: `Bearer ${token.refreshToken}`,
"Content-Type": "application/json"
}
})
if (refreshTokenData.result.resultCode !== 200) {
throw refreshTokenData
}
return {
...token,
accessToken: refreshTokenData.body.accessToken,
expiredAccessToken: refreshTokenData.body.expiredAccessToken,
refreshToken: refreshTokenData.body.refreshToken ?? token.refreshToken
}
} catch (error) {
console.error("refreshAccessToken-error", error)
return {
...token,
logout: true
}
}
}
'/auth/refresh/' 로 기존 refreshToken으로 요청을 보내게 되면 새로운 'accessToken', 'expiredAccessToken', 'refreshToken'을 반환받게 된다. 이 반환 값들을 기존 token에서 업데이트를 해주었다.
이렇게 구현하고 나니 또 다른 이슈에 직면했다. 서비스를 이용하면서 설정한 5분 이내에 어떤 행동을 취할 때, 해당 로직이 트리거가 되면서 새로운 accessToken으로 업데이트가 잘 되는 것 같았지만, 서비스를 켜놓기만 하고 이미 만료된 시점에 어떤 행동을 취했을 때는 refreshAccessToken 함수 내에서 api 에러가 발생해 catch문이 실행되는 것을 확인할 수 있었다.
서비스 이용 중 , 설정한 시간 내 어떤 동작을 하지 않아 토큰 만료가 되었을 때 이슈 해결 - 첫 번째 시도
NextAuth에서 제공하는 SessionProvider의 refetchInterval을 활용할 수 있었다. SessionProvider의 props로 refetchInterval을 설정해 주면, 명시적으로 refetch 기능을 수행할 수가 있게 된다. 나는 초기값으로 10000을 설정해주며, RefreshTokenHandler 컴포넌트 내부에서 토큰 만료기간과 현재 시간을 비교해 3분 이내일 경우 0으로 상태값 변경을 해줌으로써 refetch가 실행되도록 구현했다. 이렇게 해서 3분 전에 클라이언트 사이드에서 세션을 refetch 하면서 로그인 유지를 할 수 있게 되었다.
export const AuthProvider = ({ session, children }: ProvidersProps) => {
const [sessionRefetchInterval, setSessionRefetchInterval] = useState(10000)
return (
<SessionProvider session={session} refetchInterval={sessionRefetchInterval}>
<RefreshTokenHandler setSessionRefetchInterval={setSessionRefetchInterval} />
{children}
</SessionProvider>
)
}
import { signOut, useSession } from "next-auth/react"
import { Dispatch, SetStateAction, useEffect } from "react"
interface Props {
setSessionRefetchInterval: Dispatch<SetStateAction<number>>
}
const RefreshTokenHandler = ({ setSessionRefetchInterval }: Props) => {
const { data: session } = useSession()
useEffect(() => {
if (!!session) {
const nowTime = Math.round(Date.now() / 1000)
const timeRemaining = (session.user.expiredAccessToken as number) - 3 * 60 - nowTime
setSessionRefetchInterval(timeRemaining > 0 ? timeRemaining : 0)
}
}, [session, setSessionRefetchInterval])
return null
}
export default RefreshTokenHandler
정리하면, accessToken 만료 5분 전에 토큰을 재발급할 수 있는 조건을 갖추게 되고, 어떤 행동을 취했을 때 갱신이 이뤄진다. 만약, 어떤 행동도 취하지 않았을 경우에는 만료 3분 전에 refetch를 함으로써 로그인 유지를 할 수 있게 된다.
두 번째 시도
refetch를 하는 방법도 있지만 조금 과한 방법이라는 생각이 들었고, 좀 더 리팩토링 해본 결과 useSession 혹의 update를 호출해서 session을 갱신해주는 방법도 가능하다. update를 호출하게 되면 jwt 콜백이 호출되면서 갱신이 정상적으로 가능해지므로, 이에따라 session 콜백도 호출이 되면서 session 데이터들도 갱신이 가능해진다.
useEffect(() => {
if (interval.current) {
clearInterval(interval.current)
}
const watchAndUpdateIfExpire = () => {
if (!!session) {
const nowTime = Math.round(Date.now() / 1000)
const timeRemaining = (session.user.expiredAccessToken as number)- nowTime
console.log("timeRemaining", timeRemaining)
if (timeRemaining <= 0) update()
}
}
interval.current = setInterval(watchAndUpdateIfExpire, 1000 * 10)
return () => {
if (interval.current) {
clearInterval(interval.current)
}
}
}, [session, update])
이미 만료된 상태에서 서비스를 실행했을 때 이슈
다음날 서비스를 개발 모드로 다시 실행해보니, 만료된 토큰을 계속 가지고 호출하고 있는 문제를 발견했다. 이번 프로젝트를 통해서 에러 처리에 대해 깊게 생각하고, 처리해줄 필요성을 배울 수 있었다. 해당 이슈에 대해서는 로그아웃 처리를 해주도록 하고, 첫 화면으로 이동하는 로직으로 구현하기로 했다. 이미 서비스를 이용하지 않은 상태로 긴 시간 동안 접속하지 않았기 때문에, 세션을 모두 지우고 로그아웃 처리를 하며 바로 로그인할 수 있게끔 첫 화면으로 이동하는 게 적절할 거라고 판단했다.
refreshAccesToken 함수 내에서 refresh 요청 실패 시 catch 문이 실행된다고 앞서 언급한 적이 있다. 이때 catch문에 내에서 logout이라는 값을 boolean 형태로 추가했다. logout이 true가 되면 아래 구현된 RefreshTokenHandler 컴포넌트 내에서 logout 상태 여부를 판단한 다음, NextAuth의 signOut 함수를 호출하게 된다. auth.ts에서 signOut 함수를 호출하고 싶었으나, signOut은 클라이언트 컴포넌트 내에서만 호출이 가능하기 때문에, 해당 컴포넌트 내에서 구현을 하게 되었다.
//RefreshTokenHandler.tsx
// logout 값이 true가 되면, 로그아웃과 동시에 '/'로 이동
useEffect(() => {
if (session?.user?.logout) {
signOut({ callbackUrl: "/" })
}
}, [session?.user?.logout])