본문 바로가기
개발진행목록/스터디카페 통합 조회 플랫폼

[studyhub] Redis 토큰 블랙리스트 구현

by o3oppp 2026. 9. 19.

해당 게시글에서는 Redis 컨테이너 구성과 연결 설정은 다루지 않는다.

로그아웃, 회원탈퇴 API 구현

@Transactional
public void withdraw(Long memberId, WithdrawRequest request, String header) {
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new BusinessException(MemberErrorCode.MEMBER_NOT_FOUND));

    if (member.isWithdrawn()) {
        throw new BusinessException(MemberErrorCode.ALREADY_WITHDRAWN);
    }

    boolean passwordMatches = passwordEncoder.matches(request.getPassword(), member.getPassword());
    if (!passwordMatches) {
        throw new BusinessException(MemberErrorCode.PASSWORD_MISMATCH);
    }

    if (reservationQueryPort.hasActiveReservation(memberId)) {
        throw new BusinessException(MemberErrorCode.ACTIVE_RESERVATION_EXISTS);
    }

    member.withdraw();
    member.clearRefreshToken();
    tokenInvalidator.invalidate(header);
}

탈퇴 시 비밀번호를 재확인하는 이유

  • 토큰만으로 탈퇴가 가능하다면 토큰이 탈취된 경우 계정이 삭제될 수 있다.
  • 로그아웃의 경우는 비밀번호를 재확인하지 않는다. 자기 세션을 끊는 것이라 토큰이 탈취된 경우에도 잃는 것이 없다.

검증 순서를 비밀번호 -> 예약조회로 둔 이유

  • 예약 조회를 먼저 하면 비밀번호를 모르는 사람도 그 회원이 예약을 갖고 있는지 알아낼 수 있다.
  • 아무 비밀번호로 탈퇴를 요청했을 때 ACTIVE_RESERVATION_EXISTS가 오면 예약이 있는 것이고, PASSWORD_MISMATCH가 오면 없는 것이다.
  • 본인 확인을 통과한 뒤에 계정 상태(예약 등)를 알려주는 것이 맞다고 판단하였다.
  • 끝나지 않은 예약이 있는 경우 실제 사용중인 자리를 비우지 않고 계정만 사라지는 상황을 방지하기 위해 탈퇴를 막았다.

블랙리스트 등록을 검증 이후에 둔 이유

  • 검증에서 예외가 발생하면 그 아래 코드는 실행되지 않는다.
  • 탈퇴가 실패하였는데 토큰이 무효화되는 상황을 방지하기 위함이다.

로그아웃, 회원탈퇴 이후 토큰이 살아있는 문제

10:00  로그인 → Access Token 발급 (만료 10:30)
10:05  로그아웃 → Refresh Token 제거
10:06  기존 Access Token으로 API 호출 → 정상 동작
10:30  토큰 만료 → 그제서야 차단
  • 로그아웃에서 Refresh Token을 지워도 Access Token은 만료까지 25분간 그대로 유효했다.
  • 세션 방식이라면 서버가 세션을 삭제하면 끝나지만 JWT는 해당되지 않는다.
  • 회원탈퇴도 마찬가지다. 탈퇴한 회원의 토큰으로 예약을 생성할 수 있다.

Access Token을 삭제하면 되는 것 아닌가?

// Member 엔티티
@Column(name = "refresh_token_hash")
private String refreshTokenHash;

@Column(name = "refresh_token_expired_at")
private LocalDateTime refreshTokenExpiredAt;
  • Refresh Token은 해시가 Member엔티티에 저장되어 있다.
  • 서버가 갖고 있으므로 clearRefreshToken()으로 컬럼을 null로 만들면 실제로 지워진다.
  • 그러나 Access Token은 서버 어디에도 저장되지 않는다. 발급해서 클라이언트에 준 뒤로는 서버가 그 존재 조차 모른다. 요청이 올 때마다 서명만 검증할 뿐 발급 기록을 조회하지 않는다.

그래서 무효화된 토큰 목록을 서버가 따로 들고 있어야 하며, 이것이 토큰 블랙리스트이다.

토큰 블랙리스트

삭제 대신 무효화를 기록

member.withdraw();
member.clearRefreshToken();
tokenInvalidator.invalidate(header);
  • 없는 것(Access Token)을 지울 수 없으니 반대로 "이 토큰은 무효다"를 서버가 기록해두고, 요청이 올 때마다 그 목록을 확인한다. 이것이 토큰 블랙리스트이다.
  • JWT의 장점은 서버가 상태를 갖고 있지 않아 매 요청마다 DB를 조회할 필요가 없다는 것이지만, 단점으로는 발급한 토큰을 회수할 수 없다는 것이다.
  • 블랙리스트는 이 단점을 일부 보완하는 장치이다.

블랙리스트 저장소와 키 선정

저장소를 Redis로 선택한 이유
무효화된 토큰 목록은 요청이 올 때마다 조회되는데, 어디에 둘지부터 정해야 했다.

방식 키 길이 비고
토큰 원문 수백 자 JWT 전체가 키가 됨
SHA-256 해시 64자 매 요청마다 해싱 연산
jti 36자 토큰에서 파싱만 하면 됨
  1. 로컬 메모리
  • 서버가 한 대일 때만 동작한다. 여러 대로 늘리는 경우 A 서버에서 로그아웃 한 토큰이 B 서버에서는 그대로 통과한다.
  • 서버를 재시작하면 목록이 사라지고, 만료된 항목을 지우는 로직을 구현해야 한다.
  1. DB 테이블
  • 모든 API가 인증을 진행하므로 매 요청마다 DB 조회를 조회해야 한다.
  • 블랙리스트 조회를 위한 커넥션 풀의 일부가 필요하여 실제 비즈니스 로직이 쓸 거넥션이 줄어든다.
  • 로그아웃한 토큰으로 요청이 오는 것은 드문 경우이므로 조회 결과의 대부분은 "없음"이다.
  1. Redis
  • 인메모리 조회가 빠르다.
  • 여러 서버 인스턴스가 같은 Redis를 바라보기 때문에 공유가 된다.
  • TTL로 만료가 자동 처리된다.

키가 필요한 이유
블랙리스트는 "이 토큰이 목록에 있는가"를 묻는 구조다. Redis는 key-value 저장소이므로 토큰 하나를 가리킬 키가 있어야 한다.

방식 키 길이 비고
토큰 원문 수백 자 JWT 전체가 키가 됨
SHA-256 해시 64자 매 요청마다 해싱 연산
jti 36자 토큰에서 파싱만 하면 됨

jti를 선택한 이유

// JwtProvider
private String createToken(String memberId, String role, long expirationMillis) {
    Date now = new Date();
    Date expiry = new Date(now.getTime() + expirationMillis);

    return Jwts.builder()
        .id(UUID.randomUUID().toString()) // jti
        .subject(memberId)
        .claim("role", role)
        .issuedAt(now)
        .expiration(expiry)
        .signWith(secretKey)
        .compact();
}
  • jti(JWT ID)는 JWT 표준에 정의된 클레임으로, 토큰마다 부여하는 고유 식별자이다.
  • 이미 토큰 안에 들어 있는 값이므로 별도 연산 없이 파싱만 하면 된다.
  • 같은 회원이 여러 기기에서 로그인해도 토큰마다 jti가 다르므로 나중에 특정 기기만 로그아웃 시키는 기능으로 확장이 가능하다.
  • Refresh Token에는 넣지 않았다. 서버가 현재 유효한 토큰의 해시 하나만 DB에 들고 있으므로, 들어온 토큰을 해시해서 비교하면 유효 여부를 알 수 있기 때문에 개별 토큰을 가리킬 식별자가 필요 없다.

토큰 만료 시간이 아니라 잔여 시간을 TTL로 둔 이유

Access Token 만료: 30분

로그인 직후 로그아웃   → TTL 30분
10분 쓰고 로그아웃     → TTL 20분
29분 쓰고 로그아웃     → TTL 1분
  • 블랙리스트는 그 토큰이 만료될 때까지만 필요하다. 만료 후에는 서명 검증에서 걸리므로 Redis에 남아 있어도 아무 역할을 하지 않는다.
  • access-token-expiration을 그대로 TTL로 주면 29분 쓰다 로그아웃한 토큰이 30분을 더 차지한다.

만료 처리를 직접 구현하지 않아도 되는 이유

  • DB 테이블로 만들었다면 만료된 레코드가 계속 쌓이며, 주기적으로 삭제하는 배치가 필요하다.
  • Redis는 TTL이 지나면 알아서 키를 삭제한다.

LocalDateTime 대신 epoch 밀리초로 계산한 이유

// JwtProvider
public long getRemainingMillis(String token) {
    Date expiration = parseClaims(token).getExpiration();
    return expiration.getTime() - System.currentTimeMillis();
}
  • 만료시각을 LocalDateTime으로 변환하는 경우 TTL로 쓰려면 다시 밀리초로 변환해야 했다.
  • Date.getTime()은 epoch 밀리초를 그대로 주기 때문에 시간대 변환이 아예 없다.

블랙리스트 기능 구현

@Component
@RequiredArgsConstructor
public class TokenBlacklist {

    private static final String KEY_PREFIX = "blacklist:";
    private final StringRedisTemplate redisTemplate;

    public void addBlacklist(String jti, long ttlMillis) {
        if (ttlMillis <= 0) {
            return;
        }
        redisTemplate.opsForValue().set(makeKey(jti), "", ttlMillis, TimeUnit.MILLISECONDS);
    }

    public boolean containsBlacklist(String jti) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(makeKey(jti)));
    }

    private String makeKey(String jti) {
        return KEY_PREFIX + jti;
    }
}

StringRedisTemplate를 쓴 이유

  • 저장하는 값이 단순 문자열이라 직렬화 설정이 필요 없다. Spring Boot가 bean을 자동으로 만들어 주기 때문에 RedisConfig도 아직 필요 없다.
  • 객체를 JSON으로 저장할 일이 생기면 그때 RedisTemplate와 serializer를 설정하면 된다.

ttlMillis가 0 이하면 등록을 건너뛰는 이유

  • 이미 만료된 토큰으로 로그아웃을 요청하면 expiration - now가 음수가 되고, Redis에서 음수 TTL을 주면 예외가 발생하거나 즉시 삭제된다.
  • 만료된 토큰은 어차피 서명 검증에서 걸리므로 블랙리스트에 없어도 통과하지 못한다.

Boolean.TRUE.equals()를 쓴 이유

  • hasKey()는 boolean이 아니라 Boolean을 반환한다.
  • Redis 연결에 문제가 있으면 null이 올 수 있고, 그대로 반호나하면 호출부에서 NPE가 발생한다.

필터에서의 검증

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtProvider jwtProvider;
    private final TokenBlacklist tokenBlacklist;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = resolveToken(request);
        if (token != null && jwtProvider.validateToken(token) && !isBlacklisted(token)) {
            String memberId = jwtProvider.getMemberId(token);
            String role = jwtProvider.getRole(token);

            if (role != null) {
                List<SimpleGrantedAuthority> authorities = List.of(
                    new SimpleGrantedAuthority("ROLE_" + role));

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    memberId, null, authorities);

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // 토큰이 없거나 유효하지 않아도 체인은 계속 진행
        // 최종적으로 막을지 말지는 SecurityConfig의 authorizeHttpRequests 규칙이 결정
        filterChain.doFilter(request, response);
    }

    private boolean isBlacklisted(String token) {
        String jti = jwtProvider.getJti(token);
        return tokenBlacklist.containsBlacklist(jti);
    }

    private String resolveToken(HttpServletRequest request) {
        String header = request.getHeader(AUTHORIZATION_HEADER);
        return jwtProvider.resolveToken(header);
    }
}

서명 검증을 블랙리스트 조회보다 먼저 한 이유

  • 자바의 &&는 왼쪽부터 평가하다가 거짓이 나오면 나머지를 실행하지 않는다.
  • 토큰이 없거나 서명이 유효하지 않으면 Redis 조회가 아예 일어나지 않는다.
  • 위조된 토큰으로 요청이 쏟아져도 Redis에 부담이 가지 않는다.

차단 시 예외를 던지지 않은 이유

  • 블랙리스트에 걸리면 인증을 설정하지 않고 체인을 계속 진행한다.
  • 최종적으로 막을지는 SecurityConfig의 규칙이 결정한다는 기존 필터 구조를 그대로 따랐다.

전체 흐름

로그아웃

  1. SecurityConfig가 인증을 요구하므로 토큰 없이는 도달하지 못한다.
  2. JwtAuthenticationFilter가 서명과 블랙리스트를 검증하고 SecurityContext에 인증을 설정한다.
  3. AuthController가 @AuthenticationPrincipal로 memberId를, @RequestHeader로 Authorization 헤더를 받아 AuthService.logout()을 호출한다.
  4. jwtProvider.resolvceToken()으로 Bearer 접두사를 제거하고 jti와 잔여 시간을 추출한다.
  5. member.clearRefreshToken()으로 Refresh Token을 제거한다.
  6. tokenBlacklist.addBlacklist(jti, ttlMillis)로 Access Token을 무효화한다.

회원탈퇴

  1. member.isWithdrawn()으로 이미 탈퇴한 회원인지 확인한다.
  2. passwordEncoder.matches()로 비밀번호를 검증한다.
  3. reservationQueryPort.hasActiveReservation()으로 진행 중인 예약을 확인한다.
  4. member.withdraw()로 상태를 WITHDRAWN으로 변경한다.
  5. member.clearRefreshToken()으로 Refresh Token을 제거한다.
  6. tokenInvalidator.invalidate(header)로 Access Token을 무효화한다.

로그아웃, 회원탈퇴 시 Redis 조회

  • 초기에는 blacklist 키가 존재하지 않는다.
  • 로그아웃, 회원탈퇴 시 조회되며 TTL이 나온다.

무효화된 토큰으로 API 호출

  1. JwtAuthenticationFilter가 resolveToken()으로 헤더에서 토큰을 꺼낸다.
  2. jwtProvider.validateToken()으로 서명과 만료를 검증한다.
  3. tokenBlacklist.contains(jti)로 블랙리스트를 조회한다.
  4. 인증을 설정하지 않고 필터 체인을 계속 진행한다.
  5. SecurityConfig의 anyRequest().authenticated()에 걸려 401 Unauthorized가 반환된다.