Spring Security 6로 JWT 인증 시스템 설계하기
왜 JWT인가
개인 블로그 서비스를 만들면서 인증 방식을 결정해야 했다. 선택지는 크게 두 가지였다.
세션 기반 인증은 서버가 사용자 상태를 메모리나 DB에 저장하고, 클라이언트는 세션 ID만 쿠키로 주고받는 방식이다. 구현이 단순하고 즉시 무효화가 쉽다는 장점이 있다.
**토큰 기반 인증(JWT)**은 서버가 상태를 저장하지 않고, 토큰 자체에 사용자 정보를 담아 클라이언트가 보관하는 방식이다.
JWT를 선택한 이유는 프론트엔드와 백엔드가 완전히 분리된 구조였기 때문이다. 프론트엔드는 Vercel에, 백엔드는 AWS EC2에 각각 배포되어 도메인이 다르다. 세션 기반 인증은 쿠키의 Same-Site 정책 때문에 크로스 도메인 환경에서 설정이 복잡해진다. JWT는 Authorization 헤더로 토큰을 전달하므로 이런 제약에서 자유롭다.
또한 향후 서버를 수평 확장할 가능성을 고려했다. 세션 방식은 여러 서버가 세션 저장소를 공유해야 하지만, JWT는 각 서버가 독립적으로 토큰을 검증할 수 있다.
다만 JWT를 선택하면서 감수해야 할 트레이드오프도 있었다. 토큰이 세션 ID보다 크기가 크고, 한번 발급된 토큰은 만료 전까지 서버에서 즉시 무효화하기 어렵다. 이 문제는 별도의 블랙리스트 메커니즘으로 해결해야 했다.
이 글에서 다루지 않는 것: OAuth2 소셜 로그인 연동, URL별 권한 설정, Redis 캐싱 전략의 상세 구현은 다루지 않는다.
전체 인증 구조

인증 흐름은 다음과 같다.
- 사용자가 로그인하면 Access Token과 Refresh Token을 발급한다
- 클라이언트는 Access Token을 Authorization 헤더에 담아 요청한다
- JwtAuthenticationFilter가 매 요청마다 토큰을 검증한다
- Access Token이 만료되면 Refresh Token으로 새 토큰을 발급받는다
- 로그아웃 시 Access Token을 블랙리스트에 등록하고, Refresh Token을 삭제한다
Access Token과 Refresh Token 분리
단일 토큰 방식의 문제는 유효기간 설정이 딜레마라는 점이다.
- 짧게 설정하면(예: 15분) 사용자가 자주 재로그인해야 해서 UX가 나빠진다
- 길게 설정하면(예: 7일) 토큰 탈취 시 피해 기간이 길어진다
이중 토큰 구조로 이 문제를 해결했다.
| 토큰 | 유효기간 | 용도 | 저장 위치 |
|---|---|---|---|
| Access Token | 1시간 | API 요청 인증 | 클라이언트 메모리 |
| Refresh Token | 14일 | Access Token 재발급 | Redis (서버) |
Access Token 1시간으로 설정한 이유는 블로그 특성상 한 번 접속하면 여러 글을 읽는 패턴이 많아서다. 15분은 너무 짧고, 하루는 탈취 시 위험 노출 시간이 길었다.
Refresh Token 14일은 "2주에 한 번 로그인"이 사용자에게 합리적인 빈도라고 판단했다.
토큰 페이로드 설계
Access Token Claims:
{
"sub": "user@example.com", // 사용자 식별자
"role": "ADMIN", // 권한 (API 접근 제어용)
"iat": 1705589400, // 발급 시간
"exp": 1705593000 // 만료 시간
}
Refresh Token Claims:
{
"sub": "user@example.com",
"iat": 1705589400,
"exp": 1707199200
}
Refresh Token에는 role을 담지 않았다. Refresh Token은 오직 새 Access Token 발급에만 사용되고, 발급 시점에 DB에서 최신 권한을 조회하기 때문이다. 이렇게 하면 관리자가 사용자 권한을 변경했을 때 다음 토큰 갱신부터 반영된다.
Secret Key 관리
application.yml:
jwt:
secret: ${JWT_SECRET}
Secret Key는 환경변수로 주입한다. 코드에 하드코딩하지 않고, 배포 환경별로 다른 키를 사용할 수 있다.
키 생성 시 주의할 점은 충분한 엔트로피다. HS512는 최소 512비트(64바이트) 이상의 키를 권장한다. 단순한 문자열이 아닌 암호학적으로 안전한 난수를 Base64 인코딩해서 사용했다.
Refresh Token 저장소로 Redis를 선택한 이유
Refresh Token을 어디에 저장할지 고민했다. 선택지는 세 가지였다.
1. 클라이언트만 보관 (Stateless)
- 장점: 서버 저장소 불필요
- 단점: 토큰 무효화 불가능 (로그아웃해도 토큰 유효)
2. RDB(MySQL) 저장
- 장점: 영구 저장, 복잡한 쿼리 가능
- 단점: 토큰 검증마다 DB 조회, 만료 토큰 정리 필요
3. Redis 저장
- 장점: 빠른 조회, TTL로 자동 만료
- 단점: 휘발성 (재시작 시 데이터 손실 가능)
Redis를 선택했다. 가장 큰 이유는 TTL(Time To Live) 기능이다. Refresh Token은 14일 후 만료되어야 하는데, Redis는 키 저장 시 TTL을 설정하면 자동으로 삭제된다. RDB라면 배치 작업으로 만료 토큰을 주기적으로 정리해야 한다.
Key: refresh:{email}
Value: {refresh_token}
TTL: 14일
키를 refresh:{email} 형태로 설계한 이유는 사용자당 하나의 Refresh Token만 유효하게 만들기 위해서다. 새로 로그인하면 기존 토큰을 덮어쓴다. 이 방식의 단점은 다중 디바이스 로그인을 지원하지 못한다는 것이다. 한 기기에서 로그인하면 다른 기기는 로그아웃된다. 개인 블로그 관리자 계정이라 이 제약을 수용했다.
로그아웃과 블랙리스트
JWT의 본질적 한계는 한번 발급된 토큰을 서버에서 "취소"할 수 없다는 점이다. 토큰 자체에 만료 시간이 내장되어 있고, 서버는 서명만 검증하기 때문이다.
로그아웃 기능을 구현하려면 별도의 무효화 메커니즘이 필요했다. 검토한 방식은 두 가지다.
1. 토큰 버전 관리
- 사용자별 토큰 버전을 DB에 저장
- 토큰에 버전을 포함하고, 검증 시 DB 버전과 비교
- 로그아웃 시 버전을 증가시켜 기존 토큰 무효화
- 단점: 매 요청마다 DB 조회 필요
2. 블랙리스트 방식
- 로그아웃된 토큰을 블랙리스트에 등록
- 검증 시 블랙리스트 확인
- TTL을 토큰 잔여 만료 시간으로 설정해 자동 정리
- 단점: 매 요청마다 블랙리스트 조회 필요
블랙리스트 방식을 선택했다. 어차피 매 요청마다 조회가 필요하다면 Redis가 DB보다 빠르고, 토큰 버전 방식은 모든 사용자의 버전을 영구 저장해야 해서 관리가 번거롭다.
Key: blacklist:{access_token}
Value: "logout"
TTL: Access Token의 남은 만료 시간
겪었던 문제들
Redis 연결 실패 시 전체 인증 불가
Redis를 Refresh Token과 블랙리스트에 사용하면서 Redis가 단일 장애점이 되었다. Redis가 다운되면 로그인, 로그아웃, 토큰 갱신이 모두 불가능해진다.
현재는 Redis 연결 실패 시 예외를 그대로 던지고 있다. 개인 프로젝트라 Redis 장애가 드물고, 장애 시 빠르게 인지하고 복구하는 게 낫다고 판단했다. 서비스 규모가 커지면 Redis Sentinel이나 Cluster 구성을 고려해야 한다.
현재 구조의 장단점
장점
- Stateless에 가까움: Redis 의존성이 있지만, 대부분의 검증은 토큰 자체로 수행
- 수평 확장 용이: 여러 서버가 같은 Secret Key만 공유하면 독립적으로 검증 가능
- 구현 단순성: 이중 토큰 + 블랙리스트라는 검증된 패턴 사용
단점
- Redis 의존성: 완전한 Stateless가 아님, Redis 장애 시 인증 불가
- 다중 디바이스 미지원: 사용자당 하나의 세션만 유지
- 키 로테이션 미구현: Secret Key 유출 시 모든 토큰 재발급 필요
개인 블로그 규모에서는 이 정도 트레이드오프가 적절하다고 판단했다. 다중 디바이스 지원이 필요해지면 Refresh Token 키를 refresh:{email}:{deviceId} 형태로 변경하고, 키 로테이션은 비대칭키 전환과 함께 고려할 예정이다.