Refresh Token 회전(삭제+발급) 원자성 보장 및 입력 정규화

gminnimk·2025년 9월 20일
0

문제 해결

목록 보기
14/18

Refresh Token 탈취를 막는 가장 간단한 방법: @Transactional

Spring Security 환경에서 JWT Refresh Token을 사용할 때 발생할 수 있는 치명적인 동시성 이슈와 이를 @Transactional 어노테이션 하나로 간단하게 해결하는 방법을 알아보고자 합니다.



토큰 회전 전략, 완벽할까?

보안을 강화하기 위해 토큰 회전 전략을 사용합니다.
사용자가 Refresh Token으로 새로운 Access Token을 요청할 때마다, 기존 Refresh Token을 무효화하고 새로운 Refresh Token을 함께 발급하는 방식입니다.

아래는 일반적인 토큰 재발급 로직입니다.

public Optional<JwtResponseDto> refreshTokens(String token) {
    return refreshTokenRepository.findByToken(token)
            .map(refreshToken -> {
                // 1. 토큰 유효기간 검증
                verifyExpiration(refreshToken);
                User user = refreshToken.getUser();

                // 2. 사용된 토큰은 삭제
                refreshTokenRepository.delete(refreshToken);

                // 3. 새로운 토큰 쌍 발급
                String newAccessToken = jwtUtil.generateAccessToken(UserDetailsImpl.build(user));
                String newRefreshToken = createRefreshToken(user).getToken();
                return new JwtResponseDto(newAccessToken, newRefreshToken);
            });
}

위 코드에서는 사용된 Refresh Token은 즉시 삭제되니 재사용이 불가능할 것 같습니다.

그러나, 거의 동시에 두 개의 요청이 들어오는 상황 을 보겠습니다.



문제점: 경쟁 상태

공격자가 사용자의 Refresh Token을 탈취했다고 가정하겠습니다.
공격자는 이 토큰을 사용해서 아주 짧은 시간 안에 여러 번의 재발급 요청을 동시에 보낼 수 있습니다.
이때 경쟁 상태가 발생하며, 다음과 같은 심각한 보안 문제로 이어집니다.

  1. [요청 A] 가 서버에 도착하여 DB에서 Refresh Token (ABC)를 조회합니다. (조회 성공)
  2. [요청 A] 가 토큰을 삭제하기 전, 거의 동시에 [요청 B] 가 도착하여 DB에서 동일한 Refresh Token (ABC)를 조회합니다. (조회 성공)
  3. [요청 A] 는 로직을 계속 진행하여 기존 토큰 (ABC)를 삭제하고, 새로운 토큰 쌍 (Access_A, Refresh_A)을 발급하여 응답합니다.
  4. [요청 B] 도 로직을 계속 진행하여 새로운 토큰 쌍 (Access_B, Refresh_B)를 발급하여 응답합니다.

결과: 하나의 Refresh Token으로 두 개의 유효한 토큰 쌍이 발급되었습니다.

공격자와 실제 사용자 모두 유효한 토큰을 가지게 되며, 공격자는 탈취한 토큰으로 사용자의 계정에 접근할 수 있게 됩니다.



해결책 1: @Transactional로 원자성 보장하기

이 경쟁 상태를 막는 가장 간단하고 강력한 방법은 @Transactional 어노테이션을 사용하는 것입니다.

@Transactional
public Optional<JwtResponseDto> refreshTokens(String token) {
    // ...
}

@Transactional은 메서드 내에서 일어나는 모든 데이터베이스 작업을 하나의 트랜잭션으로 묶어줍니다.

트랜잭션은 원자성을 보장하므로, 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 모든 작업이 없던 일이 됩니다.

  1. [요청 A] 가 먼저 @Transactional 메서드를 실행하면, 데이터베이스에 Locks이 걸리게 됩니다.
  2. [요청 B] 가 거의 동시에 도착하더라도, [요청 A] 의 트랜잭션이 끝날 때까지 기다려야 합니다.
  3. [요청 A] 가 로직을 모두 수행하고 Refresh Token을 삭제한 뒤 트랜잭션을 종료(Commit)합니다.
  4. 이제 [요청 B] 가 실행될 차례입니다. DB를 조회해 보면, Refresh Token은 이미 [요청 A] 에 의해 삭제되었으므로 토큰을 찾지 못하고 재발급에 실패합니다.

단 하나의 어노테이션으로 토큰의 이중 사용 가능성을 원천적으로 차단한 것입니다.


해결책 2: 입력값 정규화로 안정성 높이기

추가로, 코드의 안정성을 높이기 위한 리팩토링도 적용해 보겠습니다.

만약 null이나 공백(" ")이 토큰 값으로 들어온다면, 불필요한 DB 조회가 발생할 수 있습니다.

Guard Clause 패턴을 사용해 이런 비정상적인 입력을 미리 차단할 수 있습니다.

@Transactional
public Optional<JwtResponseDto> refreshTokens(String token) {
    // 1. 입력값 정규화 (앞뒤 공백 제거)
    String normalizedToken = token == null ? "" : token.trim();
    // 2. 정규화된 토큰이 비어있으면 즉시 반환 (불필요한 DB 조회 방지)
    if (normalizedToken.isEmpty()) {
        return Optional.empty();
    }

    return refreshTokenRepository.findByToken(normalizedToken)
            .map(refreshToken -> {
                // ... (이하 동일)
            });
}

trim()으로 공백을 제거하고, isEmpty()null이나 빈 문자열을 검사하여 불필요한 DB 접근을 막아 성능과 안정성을 모두 개선할 수 있습니다.

최종 개선 코드

@Transactional
public Optional<JwtResponseDto> refreshTokens(String token) {
    String normalizedToken = token == null ? "" : token.trim();
    if (normalizedToken.isEmpty()) {
        return Optional.empty();
    }

    return refreshTokenRepository.findByToken(normalizedToken)
            .map(refreshToken -> {
                verifyExpiration(refreshToken);
                User user = refreshToken.getUser();

                refreshTokenRepository.delete(refreshToken);

                String newAccessToken = jwtUtil.generateAccessToken(UserDetailsImpl.build(user));
                String newRefreshToken = createRefreshToken(user).getToken();
                return new JwtResponseDto(newAccessToken, newRefreshToken);
            });
}

정리

  • 토큰 회전은 중요한 보안 전략이지만 경쟁 상태에 취약할 수 있습니다.
  • @Transactional은 토큰 조회, 삭제, 생성을 하나의 원자적 단위로 묶어 동시성 문제를 간단하게 해결해 줍니다.
  • 항상 외부 입력값은 사용하기 전에 정규화하고 검증하는 습관을 들여 코드의 안정성을 높이는 것이 좋습니다.

0개의 댓글