🏅오늘의 목표
- Access Token 블랙리스트 구현
✅ 진행한 작업
- Access Token 블랙리스트 구현
📃 개발내용
AccessToken 블랙리스트
블랙리스트란?
- 로그아웃 이후에도 유효 기간이 남은 AccessToken 을 서버에서 차단하는 기능
왜 필요할까?
- JWT 는 stateless 구조라 서버는 토큰을 기억하지 않음
- 로그아웃을 해도 AccessToken 은 유효 기간이 끝날 때까지 계속 사용 가능해서 문제 발생
- 예시
- 로그인 → Access Token 탈취
- 로그아웃 요청
- Refresh Token 만료 → 유효시간 남아있는 Access Token 악용 가능
프론트에서 AccessToken 삭제가 불충분한 이유
- 프론트에서 제거 → 내 브라우저에서는 안 씀 → 서버에선 여전히 쓸 수 있는 토큰
- 서버에서 차단하지 않으면 보안적으로 충분하지 않음
구현
Access Token 에 jti(UUID) 추가
블랙리스트의 key 를 토큰 전체 문자열로 저장하지 않고, 짧고 명확한 식별자로 관리하기 위해 jti 를 추가
private String createJWT(JWTUserDto user, Long expiration) {
String jti = UUID.randomUUID().toString();
Date now = new Date();
Date expirationDate = new Date(now.getTime() + expiration);
String token = Jwts.builder()
.id(jti)
.subject(String.valueOf(user.userUuid()))
.claim("email", user.email())
.claim("name", user.name())
.claim("role", user.role().name())
.issuedAt(now)
.expiration(expirationDate)
.signWith(secretKey)
.compact();
log.info("JWT 토큰 생성 완료 - email: {} , name: {}, role: {}, expirationDate: {}",
user.email(), user.name(), user.role(), expirationDate);
return token;
}
- jti
- 토큰 문자열 전체를 Redis Key 로 사용하지 않기 위해
- 블랙리스트 key 를 짧고 명확하게 관리
블랙리스트 저장/조회 서비스 - JwtBlacklistService
@Service
@RequiredArgsConstructor
@Slf4j
public class JwtBlacklistService {
private final JWTUtil jwtUtil;
private final RedisTemplate<String, String> redisTemplate;
private final String PREFIX = "blacklist:access:";
// 블랙리스트에 등록
public void blacklist(String accessToken,long expirationMillis){
String jti = jwtUtil.getJti(accessToken);
redisTemplate.opsForValue().set(
PREFIX+jti,"logout",expirationMillis, TimeUnit.MILLISECONDS
);
}
// 블랙리스트에 있는지 확인
public boolean isBlacklisted(String accessToken){
try{
String jti = jwtUtil.getJti(accessToken);
return Boolean.TRUE.equals(redisTemplate.hasKey(PREFIX + jti));
}catch(Exception e){
log.warn("만료/위조 토큰이여서 블랙리스트 체크 불가 : {}", e.getMessage());
return false;
}
}
}
blacklist() - 블랙리스트 등록
- AccessToken 에서 jti 추출
- Redis 에 블랙리스트 등록
- key : blacklist:access:{jti}
- value : logout 의미 있는 값이 아니라 존재 여부만 중요
- TTL : Access Token 의 남은 유효 시간
isBlacklisted() - 블랙리스트에 있는지 확인
- AccessToken 에서 jti 추출
- Redis 에 블랙리스트 key 존재 여부 확인
- hasKey 는 null 가능성 방어
- jwtUtil.getJti() 내부
- 만료토큰, 위조토큰 → 블랙리스트 책임 아님
로그아웃 - AuthController
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@AuthenticationPrincipal CustomUserDetails customUserDetails,
HttpServletRequest request
){
// 리프레시 토큰 삭제
jwtRefreshTokenService.delete(customUserDetails.getUserUuid());
// 액세스 토큰 추출
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String accessToken = header.substring(7);
try {
long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
if (remainingTime > 0) {
jwtBlacklistService.blacklist(accessToken, remainingTime);
}
} catch (Exception e) {
log.debug("logout accessToken 처리 중 예외: {}", e.getMessage());
}
}
return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
}
흐름
- Refresh Token 삭제
- 재발급 경로 차단
- Authorization 헤더에서 Access Token 추출
- Bearer 형식 검증
- 남은 만료 시간 계산
- 남은 유효 시간 동안만 블랙리스트 유지
- 유효한 경우에만 블랙리스트 등록
- 예외 발생
- Access Token 이 이미 만료 됨
- 토큰이 위조/깨짐
- 잘못된 형식
- 예외를 던지지 않는 이유
- Refresh Token 삭제 : 로그아웃의 핵심 목적
- Access Token 문제 있어도 재발급 불가
getRemainingExpiration() , getJti() - JwtUtil
public String getJti(String token) {
return getClaims(token).getId();
}
public long getRemainingExpiration(String token) {
Date expiration = getClaims(token).getExpiration();
return expiration.getTime() - System.currentTimeMillis();
}
- Jti 추출 메서드
- 남은 만료 시간 계산 메서드
블랙리스트 확인 - JwtAuthenticationFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Authorization 헤더에서 JWT 토큰 추출
String authorization = request.getHeader("Authorization");
//Authorization 헤더 검증 : JWT 헤더가 없을 경우 다음 필터로 넘김
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.debug("JWT Token 을 request headres 에서 찾을 수 없음");
filterChain.doFilter(request, response);
return;
}
// Bearer 접두사 제거해 토큰 값 추출
String token = authorization.substring(7);
// Token 이 blacklist 인지 검증
if(jwtBlacklistService.isBlacklisted(token)) {
log.debug("JWT Token 이 Blacklist");
sendErrorResponse(response,"JWT Token 이 Blacklist");
return;
}
try{
// JWT 유효성 검증
if (!jwtUtil.validateToken(token)) {
log.warn("JWT token 유효성 검증 실패");
sendErrorResponse(response, "JWT token 유효성 실패");
return;
}
// JWT 유효성 검증 성공 후
Claims claims = jwtUtil.getClaims(token);
String email = jwtUtil.getEmail(claims);
log.debug("JWT token 인증 유저 : {}",email);
// 데이터베이스에서 사용자 정보 조회
UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
// 사용자가 존재 하지 않는 경우
if (userDetails == null) {
log.warn("사용자가 존재하지 않음 : {}", email);
sendErrorResponse(response, "사용자가 존재하지 않음");
return;
}
// security 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
// 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
}catch (Exception e) {
log.error("JWT Authentication 실패 : {}",e.getMessage());
sendErrorResponse(response, "Authentication 실패");
return;
}
filterChain.doFilter(request, response);
}
흐름
- Authorization 헤더에서 토큰 추출
- 헤더가 없거나 Bearer 형식이 아니면 필터 패스
- 모든 요청이 JWT 를 필요로 하지 않음
- Bearer 접두사 제거 후 토큰 추출
- 블랙리스트 여부 체크
- 블랙리스트에 등록된 토큰은 차단해야 함
- JWT 파싱, DB 조회 같은 비용을 쓰기 전에 차단
- JWT 유효성 검증
- 검증 : 서명, 만료, 형식
- Claims 파싱 후 사용자 식별 정보 추출
- 토큰이 유효하면 클레임 꺼낼 수 있음
- DB 에서 사용자 정보 조회
- JWT 가 유효하더라도 실제 사용자 상태가 바뀌었을 수 있음
- 사용자 탈퇴,차단 or 권한 변경 등
- 토큰은 유효하지만 사용자가 없다면, 401 처리 후 종료
- Authentication 생성 후 SecurityContext 에 등록
- 인증 과정 실패 시 401 후 종료
- 모든 검증을 통과하면 다음 필터로 진행
📝 테스트

참고자료
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 18. 회원 프로필 업로드 (0) | 2026.01.12 |
|---|---|
| [런닝 코스 공유 서비스] - 17. Oauth 로그인 (1) | 2026.01.12 |
| [런닝 코스 공유 서비스] - 15. Refresh Token (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 14. 회원가입 전, 이메일 인증 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 13. 회원 정보 조회 및 수정 (0) | 2026.01.11 |