-
FE: 카카오 소셜로그인 구현(서버검증)React 2025. 1. 26. 20:35
서론
이 기록은 소셜로그인을 구현하며 가진 고민들과 구현방식들에 대해 다룬다.
전제 - Next.js
1. 현재 진행하고있는 프로젝트는 소셜로그인만을 구현한다.
2. 인증플로우는 백엔드에서 프로세스 전체를 제어한다.
3. 인증이 완료될 시, Home으로 리프레시토큰과 함께 리다이렉트 된다.
4. 3번까지의 과정을 베이스로 코드를 구현한다.
Options
- HttpOnly: ✓ (브라우저 JS에서 접근 불가)
- Secure: ✓ (HTTPS만 사용)
- SameSite: Lax (크로스사이트 요청 제한)
- Priority: Medium (브라우저의 쿠키 우선순위)
Flow
user > FE :로그인 버튼 클릭 > BE : /auth/kakao > Kakao로그인페이지 > 사용자 인증 > BE: 인증처리 > 자체 토큰 생성 >
(refrsh_token)Fe: Home
(리다이렉트)>
(postReissue)BE: 토큰 재발급 >
(access_token)FE:로그인 과업
리프레시토큰과 함께 홈으로 리다이렉트 된 후, 렌더링이 되기 전 리프레시토큰으로 다시 재발급을 요청하여 새로운 엑세스토큰과 리프레시토큰을 응답받는다. 그 후, 엑세스토큰 클라이언트에서 상태로 관리하고, 엑세스토큰을 디코드하여 유저의 상태를 저장한다.
1. HTTP 클라이언트 라이브러리 셋팅 - axios
2. 전역상태관리 라이브러리 셋팅 - zustand
3. 미들웨어 셋팅
4. decode유틸함수 셋팅
5. 프로세스 확립
의사결정 과정
해당 플로우에 대하여 심히 많은 고민을 하였다. 결과적으로 총 3번의 코드를 엎었으며 이 또한 배움이라고 생각한다.
사실 처음 모든과정을 넥스트 미들웨어로 수행하려했다. 인증과정을 통일하고, 관련 프로세스를 한 곳에서 관리하기 떄문에 유지보수에 유리하다고 생각하였다. 그러나 현재 프로젝트에선 로그인이 필수기능은 아니다. 유저의 입장에서 글을 열람하고 조회하는 것은 인증없이도 수행할 수 있다. 따라, 미들웨어는 아주가벼운 동작만을 수행하기위하여 라우트 보호만을 수행하기로 하고 인증이 필요한 곳은 api호출 시, 검증하기로 한다.
접근 케이스 분류
리프레시 토큰 메모리 상태(유저정보) 상태 렌더링 case 1 x x 첫 방문 및 비로그인 상태 - 비로그인 상태로 UI 렌더링 case 2 o x 소셜 로그인 완료 후 리다이렉트된 상태 - 리프레시 토큰으로 액세스 토큰 재발급 요청
- 성공 시 메모리에 유저 정보 저장case 2-1 o x 일반 새로고침/재접속 상태 - 리프레시 토큰으로 액세스 토큰 재발급 요청
- 성공 시 메모리에 유저 정보 저장case 3 x o 비정상 상태 - 메모리 상태 초기화
- 비로그인 상태로 UI 렌더링case 4 o o 정상적인 인증 상태 - 저장된 상태로 UI 렌더링 기존 실패한(?) 방법
'use client'; import { createContext, useContext, useEffect, useState } from 'react'; import { useUserStore } from '@/store/userStore'; import { userApi } from '@/service/auth/api'; type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; interface AuthContextType { status: AuthStatus; } const AuthContext = createContext<AuthContextType | undefined>(undefined); // providers/AuthProvider.tsx export function AuthProvider({ children }: { children: React.ReactNode }) { const [status, setStatus] = useState<AuthStatus>('loading'); const { isAuthenticated } = useUserStore(); useEffect(() => { const checkAuth = async () => { try { // document.cookie 체크 대신 리프레시 API 직접 호출 await userApi.postReissue(); // 성공하면 리프레시 토큰이 있다는 의미 setStatus('authenticated'); } catch { setStatus('unauthenticated'); } }; if (!isAuthenticated) { checkAuth(); } else { setStatus('authenticated'); } }, [isAuthenticated]); return <AuthContext.Provider value={{ status }}>{children}</AuthContext.Provider>; } export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; };
다음과 같은 이유가 있었다. 한 상태관리 라이브러리는 하나의 플로우만 담당하고싶었다. zustand는 엑세스토큰과 유저의 정보만을 관리하고싶었고 인증 프로세스에 관한것은 contextAPI로 분리하여 따로 담당하려했었고 위에 보이는 결과물이 나왔었다. 그러나 이런 방식은 상태 관리의 복잡성도 증가되고, 추후 코드의 유지보수에도 어려움이 생길 것이며 로직이 분산되어 일관성이 유지되지 않을 것 같았다
그리고 로직에선 토큰의 유무를 파악과 동시에 재발급을 받는 과정을 하나의 호출로 설정했었다. 이는 클라이언트 사이드에서 JS로 접근하지못하는 점을 위하여, 토큰의 유무자체를 호출로 명확히 수행하려했고 토큰이 없으면 인증x 토큰이 있으면 그대로 엑세스토큰 발급이였다. 이 방법은 무조건적인 호출을 진행하기 떄문에, 또한 효율적이지 않았다. 그러나 장점은 인증상태의 유무를 오류없이 명백하게 잡을 수 있다는 점 하나였다.
import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; export async function GET() { const cookieStore = await cookies(); const refreshToken = cookieStore.get('refresh_token'); return NextResponse.json({ isAuthenticated: !!refreshToken }); }
그렇다면 무조건적인 호출을 방지하기 위하여 next 내장 api를 활용하여 리프레시토큰의 유무를 검증하는 코드를 추가해서 서버에서 확인하고, 유무에 따른 호출을 진행하려했을 때 오히려 한번의 검증을 추가로 진행하여 두번의 호출로 처리되는 추가적인 비용에 대한 또 고민이 생겨버렸다.
여기서 깊은 고민에 빠졌다. 사실 구현보다는 점점더 산으로 가는 느낌이 들었다.
따라 반드시 필요한 것을 설정하고 이를 기반으로 다시 코드를 짜는 시간을 가졌다.
엑세스토큰은 반드시 클라이언트 상태로 존재해야한다. 이말은 클라이언트 사이드에서 리프레시토큰을 활용한 Reissue를 진행해야 한다라고 정리할 수 있을 것 같다. 만약 서버에서 토큰을 재발급하고 유저에 관한 데이터를 prop으로 내린다고해도, 엑세스토큰 자체는 prop으로 전달 할 수 없기 때문이다. 물론 전달은 되겠지만 다음과 같은 문제점이 존재한다.
- XSS 공격 시 React DevTools를 통해 props 값이 노출될 수 있음
- props는 React 컴포넌트 트리를 따라 전달되므로, 중간 컴포넌트들에서 토큰이 노출될 위험이 있음
- props는 React의 상태로 관리되어 브라우저 메모리에 저장되므로, 메모리 덤프를 통한 토큰 탈취 가능성이 있음
- 물론 zustand와 같은 클라이언트 상태관리 라이브러리도 결국엔 브라우저 메모리를 사용하므로 메모리 덤프로 정보 탈취가 가능하지만, 엑세스토큰은 어차피 브라우저 메모리에 저장할 수 밖에없다. 그럼에도 props로 전달하는 것보다 상태관리 라이브러리를 사용하는것에 대한 장점으론 컴포넌트 트리 전체 노출방지와, devtools에서 직접적인 노출이 감소된다.
따라 유저의 상태만을 넘긴다고 가정하자. 그럼에도 문제가 존재한다. 엑세스토큰이다.
이제 인증이 필수적인 어떠한 요청을 보낼 때 내 요청헤더에 엑세스토큰을 담아 보내야한다. 이때 엑세스토큰은 어디서 가져올 것인가? -> 여기서 클라이언트 사이드에서 토큰 재발급을 수행하는 조건으로 코드를 짰다.
자 이제 다음 고민은 렌더링을 수행할 때, 토큰의 검증을 서버에서 수행하냐 클라이언트에서 수행하냐 이다.
서버에서 수행한다면 초기 상태를 보장할 것이고 이에 따라 렌더링이 수행되니 초기 깜박임도 존재하지 않을 것이다.
그러나 서버에서 수행하는 역할이니 당연히 소모적인 부분이 존재할 것이고, 클라이언트에서 수행한다면 빠른 초기 렌더링이 수행되지만 단점으론 초기상태로 인한 깜박임이 존재할 것이다. 따라 이 모든 조건들을 감안하고 서버에서 검증을 수행하기로 하였다.
다음은 관련 모든 코드이다.
Layout.tsx
export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { async function getAuthStatus() { const cookieStore = await cookies(); const refreshToken = cookieStore.get('refresh_token'); return !!refreshToken; } const authStatus = await getAuthStatus(); return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <ReactQueryProvider> <InitializeAuth isAuthenticated={authStatus} /> <HeaderLayout isAuthenticated={authStatus} /> {children} <Footer /> </ReactQueryProvider> </body> </html> ); }
레이아웃에서 먼저 쿠키의 존재 유무를 파악한다. 그리고 boolean으로 InitializeAuth에 전달한다.
InitializeAuth.tsx
'use client'; import { useEffect } from 'react'; import { useUserStore } from '@/store/userStore'; import { userApi } from '@/service/auth/api'; interface InitializeAuthProps { isAuthenticated: boolean; } export default function InitializeAuth({ isAuthenticated }: InitializeAuthProps) { const { initialize } = useUserStore(); useEffect(() => { const initAuth = async () => { if (isAuthenticated) { try { // 토큰이 있으면 재발급 시도해서 유저 정보 받아오기 await userApi.postReissue(); } catch (error) { initialize(false); } } else { initialize(false); } }; initAuth(); }, [isAuthenticated, initialize]); return null; }
토큰 유무 상태에 따라 토큰재발급 요청을 하고 없을시 또는 실패시 initialize에 false를 전달한다.
useUserStore.ts
import { create } from 'zustand'; import { jwtDecode } from 'jwt-decode'; interface UserState { accessToken: string | null; userId: number | null; nickname: string | null; userUrl: string | null; isAuthenticated: boolean; status: 'loading' | 'authenticated' | 'unauthenticated'; setUser: (token: string) => void; clearUser: () => void; setStatus: (status: 'loading' | 'authenticated' | 'unauthenticated') => void; initialize: (isAuthenticated: boolean) => void; } interface DecodedToken { user_id: number; user_url: string; user_nickname: string; sub: string; iat: number; exp: number; } export const useUserStore = create<UserState>((set) => ({ accessToken: null, userId: null, nickname: null, userUrl: null, isAuthenticated: false, status: 'loading', setUser: (token: string) => { const decoded = jwtDecode<DecodedToken>(token); set({ accessToken: token, userId: decoded.user_id, nickname: decoded.user_nickname, userUrl: decoded.user_url, isAuthenticated: true, status: 'authenticated', }); }, clearUser: () => { set({ accessToken: null, userId: null, nickname: null, userUrl: null, isAuthenticated: false, status: 'unauthenticated', }); }, setStatus: (status) => { set({ status }); }, initialize: (isAuthenticated: boolean) => { set({ status: isAuthenticated ? 'authenticated' : 'unauthenticated', isAuthenticated, }); }, }));
userApi.ts
export const userApi = { getKakaoLogin: () => { return `${baseURL}${ENDPOINTS.AUTH.KAKAO}`; }, postReissue: async (): Promise<ReissueResponse> => { try { const response = await basicAPI.post<ReissueResponse>(ENDPOINTS.AUTH.REISSUE, {}); if (response.data.access_token) { useUserStore.getState().setUser(response.data.access_token); } return response.data; } catch (error) { console.error('Reissue API error:', error); throw error; } }, logout: async (): Promise<LogoutResponse> => { try { const response = await authAPI.post<LogoutResponse>(ENDPOINTS.AUTH.LOGOUT, {}); useUserStore.getState().clearUser(); return response.data; } catch (error) { console.error('Logout API error:', error); throw error; } }, };
최종 플로우
- 페이지 접근
- 미들웨어 실행
- 보호된 라우트 체크
- 리프레시 토큰 존재 유무 확인
- 필요한 리다이렉션 처리
- 서버 레이아웃 실행
- 리프레시토큰 존재 유무를 확인하고 InitializeAuth프로바이더에 propd으로 현재 토큰 존재 유무를 boolean값으로 내림
- 리프레시 토큰이 존재하면 postReissue를 날려 리프레시 엑세스 토큰 발급
- 레프레시 토큰 존재하지 않으면 initialize false로 설정
구현
이로서 최종적으로 구현되었다. 이렇게 구현된 프로세스는, 렌더링 전 서버에서 유저의 인증 유무를 파악하고 렌더링할 수 있게 된다.
export default function InitializeAuth({ isAuthenticated }: InitializeAuthProps) { const { initialize } = useUserStore(); console.log(isAuthenticated); <----- 콘솔확인 useEffect(() => { const initAuth = async () => { if (isAuthenticated) { try { // 토큰이 있으면 재발급 시도해서 유저 정보 받아오기 await userApi.postReissue(); } catch (error) { initialize(false); } } else { initialize(false); } }; initAuth(); }, [isAuthenticated, initialize]); return null; }
이렇게 확인해보면 첫 렌더링시,
서버에서 유무를 파악할 수 있게 된다.
(아래의 영상은 비로그인시 새로고침과 로그인시 새로고침의 차이)
GIF 수정해야할 점
1. LayoutShift가 존재.
2. 서버에서 확인한 유무를 클라이언트에 어떻게 통일성 있게 전달할지(현재 집중화x, prop 드릴링존재)