Access Token과 Refresh Token: 보안과 UX 사이의 안전한 인증 전략

크리스는 로그인 기능을 구현하면서 가장 쉬운 길을 택했다.
서버에서 받은 토큰을 localStorage에 저장하고, API 요청을 보낼 때마다 꺼내서 헤더에 실어 보냈다.
// ❌ 크리스의 위험한 코드
localStorage.setItem('token', 'secret-token-123');
// 누군가 콘솔창에 localStorage.getItem('token')만 쳐도 털린다.어느 날 보안 감사에서 "XSS(크로스 사이트 스크립팅) 공격에 취약함"이라는 경고를 받았다. 해커가 자바스크립트 코드를 심어서 사용자의 토큰을 탈취할 수 있다는 것이다.
그렇다면 토큰을 어디에 숨겨야 할까? 그리고 왜 토큰을 두 개나 써야 할까?
오늘은 프론트엔드 개발자가 반드시 알아야 할 보안과 사용자 경험(UX) 사이의 줄타기, 인증 토큰 전략을 파헤쳐 본다.
1. 왜 토큰을 두 개로 나눌까?
인증 시스템의 딜레마는 다음과 같다.
이 두 마리 토끼를 잡기 위해 역할을 분담했다.
Access Token (출입증)
Refresh Token (재발급권)
2. 어디에 저장해야 안전할까? (Storage Wars)
가장 논쟁이 뜨거운 주제다. 결론부터 말하면 "Refresh Token은 HttpOnly Cookie에, Access Token은 메모리(변수)에" 저장하는 것이 가장 안전하다.
❌ LocalStorage / SessionStorage
✅ HttpOnly Cookie
3. 이상적인 인증 흐름 (Best Practice)
크리스가 채택해야 할 안전한 로그인 시나리오는 다음과 같다.
1. 로그인 성공
서버는 두 가지를 응답으로 보낸다.
// 클라이언트는 accessToken만 받아서 변수에 저장한다.
let accessToken = response.data.accessToken;
// refreshToken은 브라우저가 알아서 쿠키 깊숙한 곳에 저장한다.2. API 요청 (Access Token 사용)
클라이언트는 메모리에 있는 accessToken을 Authorization 헤더에 담아 요청한다.
headers: {
Authorization: `Bearer ${accessToken}`
}3. 토큰 만료 (401 Error)
Access Token의 수명(30분)이 다했다. 서버는 401 Unauthorized 에러를 보낸다.
이때 사용자를 로그아웃 시키면 안 된다. 사용자 모르게(Silent Refresh) 토큰을 갱신해야 한다.
4. 토큰 재발급 (Refresh)
클라이언트는 /refresh 엔드포인트로 요청을 보낸다.
이때 브라우저가 자동으로 쿠키에 있는 Refresh Token을 실어 보낸다. (우리가 코드로 넣을 필요 없음)
서버는 쿠키를 확인하고, 새로운 Access Token을 발급해준다.
4. 보안 디테일 챙기기
RTR (Refresh Token Rotation)
Refresh Token조차 탈취당하면 어떡할까? 이를 막기 위해 RTR을 사용한다.
Refresh Token을 한 번 쓰면 무조건 폐기하고 새로 발급받는 방식이다.
만약 해커가 훔친 Refresh Token을 사용하면? 이미 사용된 토큰이므로 서버는 이를 감지하고 모든 토큰을 즉시 차단해버린다.
새로고침 문제 해결
Access Token을 메모리(변수)에 저장하면, 페이지를 새로고침 할 때마다 날아간다.
그래서 앱이 초기화될 때(Mount), 가장 먼저 /refresh API를 찔러서 Access Token을 받아오는 과정이 필요하다.
핵심 정리
개념은 완벽하다. 하지만 이것을 매번 API 요청할 때마다 if (tokenExpired) refresh() 코드를 짤 수는 없다.
이 복잡한 갱신 로직을 단 한 곳에서 자동으로 처리해 주는 문지기, Interceptors를 구현할 차례다.
"Axios Interceptor를 통한 토큰 갱신과 토큰 블랙리스트, 화이트리스트 처리" 편에서 계속된다.