Spring Boot + Redis + JWT 기반 인증 시스템 구현기록
Spring Boot 기반 캡스톤 프로젝트에서 회원가입 / 로그인 / 로그아웃 / 토큰 재발급 기능을 구현했다.
단순한 세션 기반 인증 대신 JWT + Redis 조합을 선택했고, 대학생 서비스 특성상 학교 이메일(@ac.kr) 인증을 필수로 적용했다.
0. 기술 스택
| 프레임워크 | Spring Boot, Spring Security |
| DB | JPA / MySQL |
| 캐시 | Redis |
| 인증 | JWT (JJWT 라이브러리) |
1. 회원가입
이메일 인증코드 발송 → 인증코드 확인 → 회원가입
회원가입 전 반드시 학교 이메일(@ac.kr) 인증을 완료해야 한다.
| POST | /api/users/email/send | 인증코드 이메일 발송 |
| POST | /api/users/email/verify | 인증코드 검증 |
| POST | /api/users/signup | 회원가입 |
이메일 인증 구조
// 1. 인증코드 발송
public void sendVerificationCode(EmailSendRequestDTO request) {
// @ac.kr 도메인 검증
// SecureRandom으로 6자리 코드 생성
// Redis에 저장 (email:code:{email}, TTL: 5분)
// @Async로 비동기 메일 전송
}
// 2. 인증코드 검증
public void verifyCode(EmailVerifyRequestDTO request) {
// Redis에서 코드 조회 및 비교
// 일치 시 email:verified:{email} 저장 (TTL: 30분)
// 인증코드 삭제
}
회원가입 로직
public UserResponseDTO signUp(UserRequestDTO request) {
// Redis에 email:verified:{email} 존재 확인 (인증 완료 여부)
// 중복 이메일 확인
// BCrypt로 비밀번호 암호화
// DB 저장
// Redis 인증 키 삭제
}
Redis 키 설계
| email:code:{email} | 6자리 인증코드 | 5분 |
| email:verified:{email} | "true" | 30분 |
2. 로그인
POST /api/users/login
로직
public LoginResponseDTO login(LoginRequestDTO request) {
// 이메일로 사용자 조회
// passwordEncoder.matches()로 비밀번호 검증
// 실패 시 AUTHENTICATION_FAILED(401) 예외
// Access Token + Refresh Token 생성
// Redis에 refresh:token:{userId} 저장 (TTL: 24시간)
// 토큰 + 사용자 정보 반환
}
3. 로그아웃
로그아웃 시 두 가지 처리를 수행한다.
- Redis에서 Refresh Token 즉시 삭제
- Access Token을 블랙리스트에 등록 (남은 유효시간만큼 TTL 설정)
POST /api/users/logout
Authorization: Bearer {accessToken}
로직
public void logout(String accessToken) {
// Bearer 제거 및 토큰 정규화
// Access Token 유효성 검증
// userId 추출
// Redis에서 refresh:token:{userId} 삭제
// 남은 유효시간 계산 (만료시간 - 현재시간)
// Redis에 blacklist:access:{token} 저장 (TTL: 남은 유효시간)
}
⭐TTL을 토큰 남은 유효시간으로 설정하면, 만료된 토큰은 블랙리스트에서도 자동 삭제되어 Redis 메모리를 불필요하게 점유하지 않는다.
Redis 키 설계
| blacklist:access:{token} | "logout" | 토큰 남은 유효시간 |
4. JWT 토큰
jwt:
secret: ${JWT_SECRET} # Base64 인코딩 비밀키
access-token-expire-ms: 3600000 # 1시간
refresh-token-expire-ms: 86400000 # 24시간
클레임 구조
- memberId : Long
- email : String
- type : "access" | "refresh"
- iat : 발급 시간
- exp : 만료 시간
type 클레임을 포함한 이유는 Access Token으로 Refresh 엔드포인트를 호출하거나, Refresh Token으로 일반 API를 직접 호출하는 것을 방지하기 위함이다.
JwtProvider 주요 메서드
createAccessToken(Long memberId, String email) // Access Token 생성
createRefreshToken(Long memberId, String email) // Refresh Token 생성
validateAccessToken(String token) // 유효성 + type 확인
validateRefreshToken(String token) // 유효성 + type 확인
getMemberId(String token) // userId 추출
getExpiration(String token) // 만료 시간 반환
JwtAuthenticationFilter
모든 요청에서 JWT를 검증하는 필터로, UsernamePasswordAuthenticationFilter 이전에 실행된다.
protected void doFilterInternal(...) {
// Authorization 헤더에서 Bearer 토큰 추출
// Redis 블랙리스트 확인 (blacklist:access:{token})
// 토큰 유효성 검증
// 유효하면 SecurityContext에 AuthPrincipal(memberId, email) 등록
}
5. 토큰 재발급 (Token Rotation)
POST /api/users/refresh
Request: { "refreshToken": "..." }
Response: { "accessToken": "...", "refreshToken": "...", ... }
원자적 Token Rotation (Lua 스크립트)
토큰 재발급 시 Race Condition 문제를 방지하기 위해 Redis Lua 스크립트로 원자적으로 처리했다.
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3])
return 1
else
return 0
end
- Refresh Token 유효성 검증
- Redis에 저장된 토큰과 일치 확인 + 새 토큰으로 교체를 원자적으로 처리
- 일치하지 않으면 재발급 거부 (TOKEN_INVALID)
- 새 Access Token + Refresh Token 반환
Token Rotation을 적용한 이유는 Refresh Token이 탈취되더라도, 정상 사용자가 먼저 재발급받으면 기존 토큰이 무효화되므로 공격자가 사용할 수 없기 때문이다.
// 세션 사용 안 함 (JWT 기반 Stateless)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 공개 엔드포인트
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/users/signup",
"/api/users/email/**",
"/api/users/login",
"/api/users/refresh"
).permitAll()
.anyRequest().authenticated())
// JWT 필터 등록
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class)
7. 예외 처리
| USER_NOT_FOUND | 404 | 사용자 없음 |
| USER_ALREADY_EXISTS | 409 | 이미 존재하는 이메일 |
| AUTHENTICATION_FAILED | 401 | 이메일/비밀번호 불일치 |
| USER_EMAIL_NOT_VERIFIED | 400 | 이메일 인증 미완료 |
| EMAIL_CODE_INVALID | 400 | 인증코드 오류/만료 |
| EMAIL_DOMAIN_INVALID | 400 | @ac.kr 아님 |
| TOKEN_INVALID | 401 | 유효하지 않거나 만료된 토큰 |
| UNAUTHORIZED_USER | 403 | 접근 권한 없음 |
전체 Redis 키
| email:code:{email} | 이메일 인증코드 | 5분 |
| email:verified:{email} | 인증 완료 상태 | 30분 |
| refresh:token:{userId} | Refresh Token | 24시간 |
| blacklist:access:{token} | 로그아웃된 Access Token | 토큰 남은 유효시간 |
⭐마무리⭐
구현하면서 신경 쓴 포인트 세 가지를 정리하면 다음과 같다.
1. 블랙리스트 TTL 최적화 만료된 토큰은 어차피 검증 단계에서 걸러지므로, Redis에는 남은 유효시간만큼만 보관하도록 TTL을 설정했다. 불필요한 메모리 점유를 방지할 수 있다.
2. Token Rotation 원자성 보장 Lua 스크립트를 활용해 조회 + 교체를 하나의 트랜잭션처럼 처리했다. 동시에 여러 재발급 요청이 들어오는 Race Condition 상황을 방어할 수 있다.
3. 학교 이메일 검증 @ac.kr 도메인 필수 적용으로 서비스 대상을 재학생으로 한정했다. 별도 학교 API 연동 없이 도메인 검증만으로 간단하게 구현했다.
'{Lecture} > Capstone design' 카테고리의 다른 글
| [캡스톤디자인] Spring 서버 Https 설정 및 DNS 연결 - GCP (0) | 2026.05.20 |
|---|---|
| [캡스톤디자인] 구글 SMTP 설정하기 2026 (0) | 2026.05.02 |