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을 탈취했다고 가정하겠습니다.
공격자는 이 토큰을 사용해서 아주 짧은 시간 안에 여러 번의 재발급 요청을 동시에 보낼 수 있습니다.
이때 경쟁 상태가 발생하며, 다음과 같은 심각한 보안 문제로 이어집니다.
(ABC)
를 조회합니다. (조회 성공)(ABC)
를 조회합니다. (조회 성공)(ABC)
를 삭제하고, 새로운 토큰 쌍 (Access_A, Refresh_A)
을 발급하여 응답합니다.(Access_B, Refresh_B)
를 발급하여 응답합니다.결과: 하나의 Refresh Token으로 두 개의 유효한 토큰 쌍이 발급되었습니다.
공격자와 실제 사용자 모두 유효한 토큰을 가지게 되며, 공격자는 탈취한 토큰으로 사용자의 계정에 접근할 수 있게 됩니다.
@Transactional
로 원자성 보장하기이 경쟁 상태를 막는 가장 간단하고 강력한 방법은 @Transactional
어노테이션을 사용하는 것입니다.
@Transactional
public Optional<JwtResponseDto> refreshTokens(String token) {
// ...
}
@Transactional
은 메서드 내에서 일어나는 모든 데이터베이스 작업을 하나의 트랜잭션으로 묶어줍니다.
트랜잭션은 원자성을 보장하므로, 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 모든 작업이 없던 일이 됩니다.
@Transactional
메서드를 실행하면, 데이터베이스에 Locks이 걸리게 됩니다.단 하나의 어노테이션으로 토큰의 이중 사용 가능성을 원천적으로 차단한 것입니다.
추가로, 코드의 안정성을 높이기 위한 리팩토링도 적용해 보겠습니다.
만약 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
은 토큰 조회, 삭제, 생성을 하나의 원자적 단위로 묶어 동시성 문제를 간단하게 해결해 줍니다.