로그인 흐름
로그인 흐름 알아보기
프로젝트에서 로그인 기능을 담당했다. 로그인 자체는 단순한 기능처럼 보이지만, 실제 구현 과정에는 보안, 세션 유지, 토큰 만료 처리 등 고려할 요소가 많았다.
특히 사용자가 로그인 한 후
- 토큰이 만료되더라도 자동으로 재발급되어야하고
- 페이지를 새로고침해도 로그인 상태가 유지되어야한다.
이 글에서 내가 구현한 로그인 흐름과 함께, 겪었던 문제와 해결 방법을 정리해보려 한다.
인증 방식 선택
웹 인증 방식에는 여러 방식이 존재한다. 그중 대표적인 방식은 세션 기반 인증과 JWT 기반 인증이다.
이번 프로젝트에서는 React 클라이언트와 REST API 서버가 분리된 구조였기 때문에, 무상태 구조에 유리한 JWT 인증 방식을 선택했다. 이 방식은 다양한 클라이언트 대응, 서버 확장성 측면에서 적합한 구조이다.
전체적인 로그인 흐름
백엔드
사용자가 로그인하면 백엔드는 다음과 같은 응답을 반환한다.
{ "accessToken": "eyJh...." // 필요에 따라 사용자 정보 추가 }
- accessToken : API 인증에 사용하는 토큰 (React 앱에서 상태로 관리)
- refreshToken : 서버에서 HttpOnly 쿠키에 저장한다. 이는 클라이언트에서 접근 불가능한다.
refreshToken 을 응답 본문이 아닌 HttpOnly 쿠키에 넣는 이유는 보안상 XSS 공격으로부터 토큰을 보호하기 위함이다.
프론트엔드
React 에서는 로그인 성공 시 받은 사용자 정보를 앱 전체에 사용할 수 있도록 상태로 관리한다. 나는 Context API 를 사용하여 AuthProvider 을 구현해 아래와 같이 관리했다. (간략화 버전 코드입니다.)
const AuthProvider = ({ children }) => { const [token, setToken] = useState(); ... }; export const useAuth = () => { const authContext = useContext(AuthContext); return authContext; };
export default function SignInPage() { const { setToken } = useAuth(); const handleSubmit = async () => { const response = await 로그인요청(email, password); setToken(response.accessToken); }; return ( // 로그인 폼 ); }
로그인 요청을 완료하면 useAuth 에서 꺼낸 setToken 으로 토큰을 상태에 저장해준다.
- 다양한 컴포넌트에서 user 정보를 쉽게 사용할 수 있고
- API 요청 시 Authorization 헤더에 accessToken 을 포함할 수 있다.
이렇게 하면 사용자는 로그인된 상태로 서비스를 사용할 수 있다.
토큰이 만료가된다면?
로그인 이후 사용자는 게시글 작성, 페이지 이동 등 여러 행동을 하게 된다. 이 과정에서 시간이 지날 수 있고, 토큰이 만료될 수 있다. 일반적으로 accessToken 의 유효기단은 짧게(15분 정도) 설정된다.
즉, 사용자가 로그인 후 1시간 동안 사이트를 둘러보다가 API 요청을 하면 accessToken 이 이미 만료된 상태일 수 있다.
이때 서버는 "토큰이 유효하지 않다" 는 401 에러를 반환하고, 사용자는 갑작스럽게 로그아웃되거나 요청이 실패하는 경험을 하게 된다.
이 문제를 해결하기 위해 accessToken 이 만료되었을 때 자동으로 재발급하는 로직을 구현했다.
백엔드 : 토큰 재발급 API
백엔드는 다음과 같은 재발급 API 를 제공한다.
POST /api/refreshToken
- refreshToken 을 HttpOnly 쿠키에서 읽고
- refreshToken 을 검증하여 새로운 accessToken 을 발급한다.
프론트 : xios Interceptor로 자동 처리
프론트에서는 Axios Interceptor를 활용해 자동으로 재발급 요청을 보내고, 성공 시 이전 요청을 다시 시도한다.
useLayoutEffect(() => { const refreshInterceptor = api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if (error.response.status === 401) { try { const response = await 토큰재발급요청(); setToken(response.accessToken); originalRequest.headers.Authorization = `Bearer ${response.accessToken}`; originalRequest._retry = true; return api(originalRequest); // // 실패했던 요청 재시도 } catch { setToken(null); // 재발급 실패 시 로그아웃 처리 } } return Promise.reject(error); } ); return () => { api.interceptors.response.eject(refreshInterceptor); }; }, [token]);
이 흐름 덕분에 사용자는 accessToken 이 만료되었다는 사실을 인식하지 못하고 원활하게 요청을 이어갈 수 있다.
💡 참고: setTimeout을 사용하여 토큰 만료 전에 미리 재발급하는 방식도 있으나, 예측된 타이밍이 아닌 실제 만료 시점에만 동작하도록 구현하는 게 더 안정적이라 판단했다.
새로고침 했을 경우
자, 이제 accessToken 만료에 대한 처리는 끝났다! 😺 그런데 또 하나의 문제가 있다. 바로 새로고침 시 로그인 정보가 사라지는 문제다.
현재 accessToken을 Context
에만 상태로 저장하고 있기 때문에, 사용자가 페이지를 새로고침하거나 브라우저를 닫았다 열면 로그인 정보가 초기화된다.
즉, token
값이 날아가고, 사용자는 다시 로그인을 해야 하는 상황이 발생한다.
-> 사용자 경험이 떨어지게 된다.
localStorage에 저장하면 되지 않나?
많은 블로그 예시에서는 localStorage
에 accessToken을 저장해서 로그인 상태를 유지한다.
하지만 이 방법은 보안상 안전하지 않다.
localStorage
는 XSS 공격에 취약하다- accessToken이 탈취되면 인증이 뚫린다
그래서 나는 localStorage를 사용하지 않고도 로그인 상태를 유지하는 방법을 고민했다.
처음 시도한 방법 (실패한 접근)
처음에는 다음 흐름을 생각했다.
/api/me
API를 만들고- 앱이 마운트될 때
useEffect
로/api/me
를 호출하여 로그인 여부를 판단
하지만 문제는 다음과 같다
- 새로고침 직후에는
accessToken
이 없기 때문에/api/me
요청 시 401 오류가 발생함 - 토큰 재발급 조건에 401 가 있으므로, 401 오류를 감지해서 토큰 재발급 요청을 시도
- 로그인한 사용자는 refreshToken이 쿠키에 있으므로 재발급에 성공하고 정상 동작
- 하지만 로그인하지 않은 사용자도 재발급 요청을 보내게 됨 → refreshToken 없음 → 재발급 실패 → 다시 401 → 무한 루프 발생
즉, 로그인한 사용자가 아닌 경우에도 재발급 요청을 보내면서 문제가 발생했다.
토큰 없이 요청하면 401, 재발급 요청해도 실패, 다시 요청... 반복... 😇
최종 해결 방법
그래서 다음과 같은 흐름으로 구조를 바꿨다.
- 앱 마운트 시 → 토큰 재발급 API를 먼저 호출
- 재발급에 성공하면 → 새 accessToken 저장
- 새 accessToken으로
/api/me
요청 → 유저 정보 조회 - 실패 시 → 로그인 상태가 아닌 것으로 처리
이렇게 하면
- 로그인한 사용자는 새로고침 후에도 로그인 유지됨
- 로그인하지 않은 사용자는 불필요한 요청 없이 비로그인 상태 유지됨
- 무한 루프도 발생하지 않음
백엔드
/api/me
API를 요청해서 추가 구현- 현재 로그인된 유저의 정보를 accessToken을 통해 반환
프론트엔드
프론트는 아래와 같이 구현하였다.
const AuthProvider = ({ children }) => { const [token, setToken] = useState(); useEffect(() => { const initToken = async () => { try { const { accessToken } = await 토큰재발급(); setToken(accessToken); } catch { setToken(null); } finally { setIsLoading(false); } }; initToken(); }, []); useEffect(() => { if (!token) return; const fetchUser = async () => { try { const { user } = await authService.getMe(); setUser(user); } catch { setUser(null); } }; fetchUser(); }, [token]); ... }; export const useAuth = () => { const authContext = useContext(AuthContext); return authContext; };
- 페이지가 새로 마운트되면, 먼저 토큰 재발급 API를 호출한다.
- 서버의 refreshToken(HttpOnly 쿠키)가 유효하다면, 새로운 accessToken을 발급해준다.
- accessToken이 발급되면, /api/me 요청을 통해 현재 로그인한 사용자 정보를 조회한다.
- 그 결과를 user 상태로 저장해, 전역에서 로그인 상태를 유지할 수 있다.
흐름도
참고 코드 및 링크
자세한 코드는 해당 링크에서 확인 가능합니다.
더 좋은 방식이 있으면 공유부탁드립니다!