안녕하세요! 지난 2편에서는 서버의 부담을 획기적으로 줄여주는 마법의 기술, JWT(JSON Web Token)에 대해 알아보았습니다. 하지만 JWT에는 너무나도 치명적인 단점이 하나 있었죠. 바로 '한 번 발급된 토큰은 유효기간이 끝날 때까지 서버가 절대 통제할 수 없다'는 점이었습니다.
만약 해커가 제 JWT를 훔쳐 간다면, 서버는 그 토큰이 진짜 주인이 보낸 건지 해커가 보낸 건지 구분할 방법이 없기 때문에 속수무책으로 당할 수밖에 없습니다.
"그럼 토큰 유효기간을 5분으로 짧게 줄이면 되잖아요!" 맞습니다. 그런데 그렇게 하면 사용자는 5분마다 다시 아이디와 비밀번호를 치고 로그인을 해야 합니다. 화가 난 사용자는 당장 서비스를 탈퇴해 버리겠죠.
보안을 챙기자니 사용자가 불편하고, 사용자를 편하게 하자니 보안이 뚫리는 최악의 딜레마. 현업의 수많은 천재 개발자들은 이 문제를 어떻게 해결했을까요? 오늘 그 위대한 타협점인 '토큰 이원화 전략'에 대해 완벽하게 파헤쳐 보겠습니다.
1. 역할 분담의 미학: Access Token과 Refresh Token
해결책은 의외로 간단명료했습니다. 토큰을 두 개로 나누는 것입니다.
놀이공원에 비유해 볼까요? 여러분은 놀이공원 매표소에서 신분증을 보여주고 '1일 자유이용권' 팔찌를 받습니다. 놀이기구를 탈 때 직원은 여러분의 신분증을 일일이 검사하지 않고, 손목에 있는 팔찌(1일 자유이용권)만 확인하죠. 팔찌가 끊어지거나 하루가 지나면 그 팔찌는 휴지통에 버리고, 다시 매표소에 가서 신분증을 보여준 뒤 새 팔찌를 발급받아야 합니다.
여기서 '1일 자유이용권 팔찌'가 Access Token이고, '신분증'이 Refresh Token입니다.
- Access Token (접근 토큰): 실제로 서버의 API를 호출할 때 사용하는 토큰입니다. 해커에게 탈취당할 위험을 최소화하기 위해 수명을 아주 짧게 (보통 15분 ~ 30분) 설정합니다. 만약 해커가 이 토큰을 훔쳐 가더라도 15분 뒤면 쓰레기 데이터가 되어버리기 때문에 피해를 최소화할 수 있습니다.
- Refresh Token (재발급 토큰): Access Token의 수명이 다했을 때, 새로운 Access Token을 발급받기 위한 용도로만 사용되는 토큰입니다. 사용자가 매번 로그인하는 불편함을 없애기 위해 수명을 아주 길게 (보통 1주 ~ 2주) 설정합니다. 이 토큰은 평소에는 안전한 곳에 숨겨두었다가, 오직 토큰을 재발급받을 때만 서버로 전송됩니다.

2. 사용자는 모르게, 조용하고 우아하게: Silent Refresh 로직
자, 토큰을 두 개로 나눈 건 알겠습니다. 그런데 프론트엔드 입장에서는 머리가 아파지기 시작합니다. Access Token이 30분마다 만료되는데, 그때마다 사용자가 보고 있는 화면을 멈추고 "토큰을 재발급 중입니다..."라는 로딩 창을 띄울 수는 없잖아요?
그래서 최신 프론트엔드 환경(React, Vue 등)에서는 Silent Refresh(조용한 재발급)라는 기법을 사용합니다. 사용자는 자신의 토큰이 만료되었는지, 재발급이 되었는지 전혀 눈치채지 못하게 뒤에서 몰래 처리하는 마법 같은 로직이죠. 주로 Axios의 Interceptor(인터셉터) 기능을 활용하여 구현합니다.
[Silent Refresh 프로세스 흐름]
- 사용자가 게시글 작성 버튼을 누릅니다. (만료된 Access Token이 서버로 넘어감)
- 서버는 토큰을 검사한 뒤 "너 토큰 만료됐어! (401 Unauthorized 상태 코드)" 라고 에러를 뱉어냅니다.
- 여기서 프론트엔드 Interceptor가 찰나의 순간에 개입합니다! 사용자에게 에러 알림을 띄우는 대신, 에러를 가로챕니다.
- 프론트엔드는 몰래 보관하고 있던 Refresh Token을 꺼내 서버의 /refresh API로 보냅니다.
- 서버가 Refresh Token을 확인하고, "오케이, 너 진짜 유저 맞구나" 하며 새로운 Access Token을 줍니다.
- 프론트엔드는 새로 받은 Access Token으로, 아까 실패했던 '게시글 작성 요청'을 다시 조용히 재시도합니다.
이 모든 과정이 0.1초도 안 되는 찰나에 벌어집니다. 사용자는 그저 "게시글이 잘 등록되었네"라고만 생각할 뿐, 뒤에서 프론트엔드와 백엔드가 얼마나 치열하게 토큰을 주고받았는지는 알 길이 없습니다. 이것이 바로 완벽한 사용자 경험(UX)입니다.

3. 가장 무서운 시나리오: Refresh Token마저 털린다면?
여기까지 구현하셨다면 실무에서 1인분은 충분히 하시는 겁니다. 하지만 깐깐한 시니어 개발자나 면접관은 꼭 이 질문을 던집니다.
"Access Token 탈취는 짧은 수명으로 방어한다고 쳐요. 근데 만약 2주짜리 수명을 가진 Refresh Token 자체를 해커가 훔쳐 가면 어떻게 되나요? 해커가 그 토큰으로 평생 Access Token을 뽑아 먹으면 어떡하죠?"
정답입니다. 엄청난 대참사가 벌어집니다. Refresh Token은 수명이 길기 때문에 탈취당하면 2주 동안 해커가 내 계정을 마음대로 주무를 수 있습니다. 이 치명적인 약점을 완벽하게 쳐내기 위해 등장한 최신 보안 기법이 바로 RTR (Refresh Token Rotation) 입니다.
4. 궁극의 방어막, RTR (Refresh Token Rotation)의 원리
RTR의 핵심 개념은 "Refresh Token을 일회용으로 만들어 버리자!" 입니다. 한 번 Access Token을 재발급받을 때 사용한 Refresh Token은 가차 없이 폐기하고, 새로운 Refresh Token을 발급해 주는 방식이죠.
동작 원리는 다음과 같습니다.
- 정상적인 유저가 Refresh Token (버전 1)을 서버로 보내어 토큰 재발급을 요청합니다.
- 서버는 검증 후, 새로운 Access Token과 함께 새로운 Refresh Token (버전 2)을 발급해 줍니다. 그리고 DB에 기록해 둡니다. "버전 1은 이제 끝! 앞으로는 버전 2만 유효함."
- 자, 이제 해커가 등장합니다. 해커가 훔쳐 간 옛날 토큰인 Refresh Token (버전 1)을 들고 서버에 재발급을 요청합니다.
- 서버가 DB를 봅니다. "어? 잠깐만. 버전 1은 아까 정상 유저가 이미 쓰고 버린 토큰인데? 누군가 이걸 다시 쓴다고? 이건 100% 탈취당한 거다!"
- 해킹을 감지한 서버는 즉시 비상벨을 울리고, 해당 유저와 관련된 모든 Refresh Token (버전 2 포함)을 DB에서 완전히 삭제(Revoke)해 버립니다.
결과적으로 해커의 재발급 요청은 거절되고, 정상 유저 역시 다음번 API를 호출할 때 토큰이 만료되어 강제로 로그아웃 처리됩니다. 정상 유저 입장에서는 갑자기 로그아웃이 되니 귀찮겠지만, 해커에게 계정이 털리는 것보다는 수백 배 나은 조치입니다. RTR은 현재 가장 강력하고 안전한 토큰 탈취 방어 전략으로 평가받고 있습니다.

마치며: 남은 것은 '어디에 저장할 것인가'
오늘은 JWT의 단점을 메꾸기 위한 Access Token과 Refresh Token의 환상적인 콜라보레이션, 조용한 재발급(Silent Refresh), 그리고 해커를 좌절시키는 RTR 기법까지 깊숙하게 알아보았습니다.
이제 아키텍처는 완벽하게 설계되었습니다. 하지만 코드를 짜려고 에디터를 켜는 순간, 프론트엔드 개발자들을 패닉에 빠뜨리는 역사상 가장 치열한 논쟁거리가 등장합니다.
"잠깐, 발급받은 Access Token이랑 Refresh Token... 브라우저 어디에 저장해야 제일 안전한 거지? LocalStorage? 아니면 Cookie?"
개발자 커뮤니티에서 하루가 멀다 하고 싸움이 벌어지는 바로 그 주제! 다음 [4편] 프론트엔드 개발자의 최대 고민: JWT 토큰, 도대체 어디에 저장해야 할까? 편에서 이 지긋지긋한 논쟁의 종지부를 찍어드리겠습니다. 기대해 주세요!
[실무 웹 인증과 보안 완벽 가이드 #04] 프론트엔드 개발자의 최대 고민: JWT 토큰, 도대체 어디에
안녕하세요! 지난 3편에서는 JWT의 치명적인 단점을 보완하기 위해 Access Token과 Refresh Token을 나누고, 백그라운드에서 조용히 토큰을 갱신하는 'Silent Refresh' 로직까지 완벽하게 설계해 보았습니다.
code-bricks.tistory.com