요즘 유행하는 Claude Code를 활용해서 개인 프로젝트의 보안을 강화해봤습니다. 이번 포스팅에서는 기존 JWT 단일 토큰 방식에서 Access Token + Refresh Token + Rotation 방식으로 전환한 과정과 그 이유를 정리해봤습니다.
1. 왜 JWT 토큰 방식을 선택했나?
Spring Security의 formLogin은 기본적으로 로그인 성공/실패 시 302 redirect로 페이지를 이동시킵니다. 물론 커스터마이징으로 해결할 수도 있지만, JWT 방식이 React와의 연동에 더 자연스럽고 이후 확장성도 좋아서 JWT 방식으로 전환했습니다.
JWT 토큰은 단순히 header.payload.signature 구조의 문자열입니다. 유저가 로그인하면 서버가 JWT 토큰을 발급해주고, 이후 API 요청마다 헤더에 이 토큰을 같이 보냅니다. 서버는 header + payload를 서버의 비밀키로 서명해서 signature가 일치하면 인증을 허가하는 구조입니다.

2. JWT의 진짜 장점은 스케일 아웃
처음에 JWT를 도입한 이유가 "DB 부하를 줄이기 위해서"라고 알고 있었는데, 직접 코드를 뜯어보니 Spring Security 구조상 매 요청마다 UserDetailsService를 통해 DB 조회가 일어나고 있었습니다.
결론적으로 JWT의 진짜 장점은 스케일 아웃에 유리하다는 점입니다. 세션 방식은 서버 여러 대를 운영할 때 세션을 공유해야 하는 문제가 생기는데, JWT는 서버마다 비밀키만 있으면 어느 서버에서든 검증이 가능합니다.
(이 부분은 지금 당장 해결하진 않았지만, 개선 포인트로 기억해두면 좋을 내용입니다.)
3. 그러면 JWT 단일 토큰의 문제점은?
JWT 토큰이 탈취되면 서버에서 막을 방법이 없습니다. 토큰 자체가 유효한 한 누구든 사용할 수 있거든요. 그렇다고 유효시간을 짧게 하면 사용자가 자주 로그인해야 하는 불편함이 생깁니다.
이 딜레마를 해결하기 위해 Access Token + Refresh Token 방식을 도입했습니다.
- Access Token: 유효시간 30분. 실제 API 요청에 사용
- Refresh Token: 유효시간 7일. Access Token 재발급용
Access Token이 만료되면 Refresh Token으로 새 Access Token을 받아오는 구조입니다. 사용자 입장에서는 이 과정이 보이지 않고 자동으로 처리됩니다.
4. 어디에 저장하나? 쿠키 vs localStorage
브라우저에는 두 가지 저장 공간이 있습니다.
- localStorage: JavaScript로 접근 가능 → XSS 공격에 취약
- 쿠키(HttpOnly): JavaScript로 접근 불가 → XSS 공격에 강함. API 요청마다 자동으로 포함됨
그래서 이렇게 결정했습니다.
- Access Token ➔ localStorage (유효시간이 짧아 탈취 피해 제한적)
- Refresh Token ➔ HttpOnly 쿠키 (탈취 위험을 최소화)
그리고 Refresh Token은 서버의 Redis에도 저장합니다. 탈취가 의심될 때 서버에서 강제로 무효화할 수 있게 하기 위해서입니다.


5. Rotation 방식이란?
Refresh Token도 탈취될 수 있습니다. 이를 최소화하기 위해 Rotation 방식을 도입했습니다.
Refresh Token으로 재발급 요청할 때마다 새로운 Refresh Token도 함께 발급하고, 기존 Refresh Token은 Redis에서 삭제합니다. 만약 이미 사용된 Refresh Token이 다시 들어오면 탈취로 간주하고 모든 세션을 무효화합니다.
전체 흐름 요약
로그인 → Access Token(30분) + Refresh Token(7일) 발급
└─ Redis에 Refresh Token 저장
└─ Refresh Token은 HttpOnly 쿠키로 전달
API 요청 → Access Token 검증
Access Token 만료 → 401 반환
└─ 프론트 axios interceptor가 감지
└─ /api/users/reissue 자동 호출
└─ 새 Access Token + 새 Refresh Token 발급 (Rotation)
└─ 원래 요청 재시도
로그아웃 → Redis에서 Refresh Token 삭제 + 쿠키 만료
Access Token은 왜 localStorage에 저장할까?
처음에는 의문이 생겼습니다. 쿠키는 모든 요청에 자동으로 붙어서 가니까, 오히려 Access Token도 쿠키에 넣는 게 더 간단하고 안전하지 않을까?
결론부터 말하면 Access Token도 HttpOnly 쿠키에 넣는 게 XSS 관점에서는 더 안전합니다. 하지만 그렇게 하면 새로운 문제가 생겨요.
CSRF 공격에 취약해집니다.
쿠키는 자동으로 요청에 붙어서 가는 특성 때문에, 악성 사이트에서 사용자 모르게 요청을 보내도 쿠키가 자동으로 포함돼요.
사용자가 악성 사이트 방문
→ 악성 사이트에서 거래소 서버로 요청 전송
→ 브라우저가 자동으로 쿠키(Access Token) 첨부
→ 서버는 쿠키가 있으니 정상 요청으로 인식
→ 사용자 모르게 거래 실행
반면 localStorage 방식은 JavaScript로 직접 헤더에 붙여야 하기 때문에, 악성 사이트에서는 접근 자체가 불가능해요.
그래서 Access Token을 쿠키에 넣으면 CSRF Token도 함께 구현해야 해요. 이게 복잡도가 올라가는 부분입니다.
결론적으로 현재 방식의 트레이드오프:
- Access Token → localStorage
- XSS에 취약하지만 유효시간이 30분으로 짧아 피해 제한적
- CSRF 걱정 없음
- Refresh Token → HttpOnly 쿠키
- XSS로 탈취 불가
- 유효시간이 7일로 길어서 보안이 더 중요
구현 복잡도와 보안의 균형을 맞춘 선택입니다. 더 안전하게 하려면 Access Token도 HttpOnly 쿠키에 넣고 CSRF Token을 함께 구현하는 방향으로 개선할 수 있어요.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| 이상 거래 탐지 시스템 설계 - 금융권은 어떻게 이상 거래를 막는가? (클로드 코드) (0) | 2026.04.24 |
|---|---|
| 클로드 코드로 구현하면서 겪은 트러블슈팅 (0) | 2026.04.22 |
| 복잡한 인프라 환경을 단 한 줄로 띄우다 (feat. Docker Compose) (0) | 2026.03.10 |
| 대용량 트래픽과 비동기 처리, 왜 RabbitMQ 대신 Kafka였을까? (1) | 2026.03.10 |
| 동시성 제어와 성능 최적화, 왜 Redis 분산 락이었을까? (0) | 2026.03.10 |