[트러블 슈팅] 로그아웃 API 구현 과정과 개선 기록

2025. 12. 6. 23:41·스파르타 자바 심화 4기 RushCrew Project
RushDeal 프로젝트에서 로그아웃 API를 구현하던 중, 예상보다 흥미로운 고민이 생겼다.
겉보기엔 단순한 기능이지만, 실제로는 "토큰을 검증해야 하는가, 아니면 멱등성을 보장하는 것이 맞는가"
라는 선택의 순간이 있었다.
이 글은 그 과정에서 했던 판단과 선택의 이유를 과정을 공유합니다.

 

1. 프로젝트 상황 및 초기 목표

Framework: Spring Boot 

Language: Java 21 

 

RushDeal 프로젝트의 인증·인가 시스템을 구성하면서, 그 중 한 기능으로 로그아웃 API를 설계하게 되었다.
겉으로 보기엔 단순해 보이는 기능이었지만, 실제 구현 과정에서는 생각보다 많은 고민을 던져준 지점이었다.

  

1-1. 초기 구현

처음 로그아웃 API는 JWT 발급 구조에 맞춰, 토큰을 검증한 뒤 블랙리스트에 추가하는 방식으로 구현했다.

// Controller
@PostMapping("/logout")
public ResponseEntity<Void> logOut(
    @RequestHeader("Authorization") String authorization,
    HttpServletRequest request
) {
    String accessToken = tokenUtils.extractAccessToken(authorization);
    String refreshToken = tokenUtils.extractRefreshToken(request);

    LogoutCommand command = new LogoutCommand(accessToken, refreshToken);

    authService.logOut(command);

    ResponseCookie expiredCookie = tokenUtils.createExpiredRefreshTokenCookie();

    return ResponseEntity.noContent()
        .header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
        .build();
}
// Service
@Transactional
public LogoutResult logout(LogoutCommand command) {
    String accessToken = command.accessToken();
    String refreshToken = command.refreshToken();

    // 🔴 토큰 유효성 검증
    accessTokenProvider.validateToken(accessToken);

    // Refresh Token 삭제
    Optional.ofNullable(refreshToken)
        .ifPresent(refreshTokenRepository::deleteByToken);

    // Access Token 블랙리스트 추가
    LocalDateTime expiryDate = accessTokenProvider.getExpiryDate(
        accessToken
    );
    tokenBlacklistService.blacklistAccessToken(accessToken, expiryDate);
}

얼핏 보면 문제가 없어 보인다.

 

하지만 실제로는 심각한 결함이 숨어있었다..


2. 이슈

2-1. 문제점

시나리오 1: 만료된 토큰으로 로그아웃

1. 사용자가 30분 전 로그인 (Access Token 만료 시간: 30분)
2. 31분 후, 로그아웃 버튼 클릭
3. accessTokenProvider.validateToken(accessToken) 호출
4. ❌ "유효하지 않은 토큰입니다" 예외 발생
5. 블랙리스트 추가 로직이 실행되지 않음
6. 사용자는 로그아웃 실패로 인식

 

시나리오 2: 잘못된 형식의 토큰

1. 네트워크 오류 등으로 토큰이 손상됨
2. validateToken() 파싱 실패
3. ❌ 예외 발생
4. 로그아웃 실패

두 경우 모두 결과는 동일했다.
토큰의 상태와 관계없이 성공해야 하는 로그아웃이, 오히려 검증 때문에 실패하고 있었다.

사용자는 로그아웃이 되지 않았다고 느끼게 된다.

 

2-2. 고민

문제는 결국 이 질문에 닿았다.

로그아웃은 “토큰을 검증해야 하는 작업인가?”
아니면
“ 항상 성공해야 하는 멱등적 작업’인가? ”

 

방식 장점 단점
검증한다 인증 정책의 일관성, 보안 강도 유지 만료·손상 토큰 → 로그아웃 실패
검증하지 않는다 어떤 상태에서도 성공, 멱등성 보장, UX 개선 무효 토큰도 처리 대상? 정책 모호성

 

이 시점에서 인증 흐름을 다시 생각해야 했다.

 

2-3. 인사이트

로그인의 목적을 생각하면 결론은 의외로 단순했다.

 

목적은 토큰을 “판별”하는 것이 아니라,
더 이상 사용할 수 없도록 “세션을 종료”하는 것이다.

 

결론

  • 이미 만료된 토큰은 설계적으로 아무 의미가 없다
  • 손상된 토큰도 API 호출에 사용할 수 없다
  • 그렇다면 로그아웃은 언제나 성공해야 하는 작업이다

즉,

로그아웃의 본질은 멱등성(Idempotency) 이다.


3. 해결 방안

문제의 원인은 결국 “로그아웃 과정에서 토큰을 굳이 검증하려 했던 것”이었다.
그래서 개선은 두 단계로 진행했다.

 

3-1. 1차 개선: 토큰 검증 제거

@Transactional
public LogoutResult logout(LogoutCommand command) {
    String accessToken = command.accessToken();
    String refreshToken = command.refreshToken();

    // ❌ 검증 제거!
    // accessTokenProvider.validateToken(accessToken);  // 삭제

    // Refresh Token 삭제
    Optional.ofNullable(refreshToken)
        .ifPresent(refreshTokenRepository::deleteByToken);

    // 만료 시간을 추출
    LocalDateTime expiryDate = accessTokenProvider.getExpiryDate(
        accessToken
    );
    
    // Access Token 블랙리스트 추가
    tokenBlacklistService.blacklistAccessToken(accessToken, expiryDate);
}

 

하지만 문제는 남아 있었다.
getExpiryDate() 자체가 토큰 파싱을 수행하기 때문에,
만료된 토큰·손상된 토큰은 여전히 예외를 유발했다.

 

3-2. 2차 개선: 안전한 실패 처리 (try-catch)

try-catch 를 적용해 만료·손상된 토큰은 안전하게 흡수하도록 예외 처리를 추가했다.

@Transactional
public void logOut(String accessToken, String refreshToken) {
    // 1. Refresh Token 삭제
    Optional.ofNullable(refreshToken).ifPresent(
        refreshTokenRepository::deleteByToken
    );

    // ✅ try catch 추가
    // 2. 만료 시간을 추출
    LocalDateTime expiryDate;
    try {
        expiryDate = accessTokenProvider.getExpiryDate(accessToken);
    } catch (Exception e) {
         // 이미 만료되었거나 깨진 토큰 → 블랙리스트 처리 불필요
        return;
    }

    // 3. 블랙리스트 처리
    tokenBlacklistService.blacklistAccessToken(accessToken, expiryDate);
}

 

3-3. 전체 흐름


4. 결론

로그아웃 API를 설계하면서 느낀 점은,
기능의 성격에 따라 우선해야 할 기준이 달라진다는 것이었다.
처음에는 “인증 관련 API니까 당연히 토큰 검증이 먼저”라고 생각했지만,
막상 로그아웃의 본질을 다시 바라보니 검증보다 사용자가 언제든 성공할 수 있어야 한다는 특성,
즉 멱등성이 더 중요한 경우가 있었다.

이 경험 덕분에, 기능을 구현하기 전에
“이 기능이 실제로 해결해야 하는 문제는 무엇인가?”를 먼저 고민하는 습관이 생겼다.
어떻게 구현할지는 그다음 문제였다.

'스파르타 자바 심화 4기 RushCrew Project' 카테고리의 다른 글

[의사결정] MSA 설계 시 동기·비동기 통신 선택 가이드  (0) 2025.12.15
[트러블 슈팅] Java 21 업그레이드 후 Eureka DNS 해석 실패  (1) 2025.12.02
[기획] 이벤트 스토밍 도입기  (0) 2025.11.28
'스파르타 자바 심화 4기 RushCrew Project' 카테고리의 다른 글
  • [의사결정] MSA 설계 시 동기·비동기 통신 선택 가이드
  • [트러블 슈팅] Java 21 업그레이드 후 Eureka DNS 해석 실패
  • [기획] 이벤트 스토밍 도입기
코드피터
코드피터
능동적으로 배우고, 적극적으로 해결하며, 성실하게 성장
  • 코드피터
    코드 읽어주는 피터
    코드피터
  • 전체
    오늘
    어제
    • 분류 전체보기 (10)
      • 이슈 (1)
      • 트러블 슈팅 (1)
      • 아키텍처 (3)
      • Backend (1)
      • 스파르타 자바 심화 4기 RushCrew Proj.. (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    멱등성
    JEP 418
    트러블 슈팅
    MSA
    #DDD
    RestClient
    HHH000104
    분산트랜잭션
    springboot
    Java 21 Features
    backend
    SagaPattern
    이벤트스토밍
    외부 API 연동
    fetch join
    Spring Boot
    SystemDesign
    N+1
    Monolithic
    Netflix Eureka
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
코드피터
[트러블 슈팅] 로그아웃 API 구현 과정과 개선 기록
상단으로

티스토리툴바