트랜잭션 동시성 트러블 슈팅

박태현·2025년 3월 5일

기타

목록 보기
12/12

org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction

(or unsaved-value mapping was incorrect): [com.example.crawling.jwt.Refresh#130]


만료된 Access Token을 Refresh Token을 이용해 재발급하는 과정에서, Refresh Token 삭제 요청이 여러 번 발생하여 충돌이 발생한 문제
→ 클라이언트에서의 중복 요청을 해결했지만, 서버단에서도 동시성 문제를 해결할 필요가 있음

StaleObjectStateException란?

JPA / Hibernate에서 같은 엔터티를 여러 개의 트랜잭션이 수정하려 할 때 발생하는 오류

하나의 트랜잭션에서 특정 엔터티를 읽어왔는데, 다른 트랜잭션에서 해당 엔터티가 삭제되거나 변경되면 충돌이 발생

즉, 같은 Refresh Token을 여러 요청이 동시에 삭제하려고 하면서 발생하는 문제 !

# 문제를 일으키는 곳
if (refreshRepository.existsByRefresh(refresh)) {
  refreshRepository.deleteByRefresh(refresh);
} else {
  log.info("refresh 토큰이 DB에 존재하지 않습니다.");
}

⇒ 낙관적 락 방식을 이용하여 해결

# refresh 엔티티

@Version
private Integer version; // 필드 추가
@Slf4j
@RequiredArgsConstructor
@Service
public class ReissueService {

    private final JwtUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    public ResponseEntity<?> reissue(String refreshAuthorization, HttpServletRequest request, HttpServletResponse response) {

        String refresh = refreshAuthorization.substring(7);
        
        ... 생략

        deleteRefreshToken(refresh);

        ... 생략
    }

    **@Transactional
    public void deleteRefreshToken(String refresh) {
        try {
            refreshRepository.findByRefresh(refresh)
                    .ifPresentOrElse(refreshRepository::delete,
                            () -> log.info("Refresh 토큰이 DB에 존재하지 않습니다."));

        } catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
            log.warn("낙관적 락 충돌 발생! 다른 요청이 먼저 삭제 되었습니다.");
            throw new CustomException(HttpStatus.CONFLICT, ErrorCode.OPTIMISTICLOCKING, "다른 요청이 먼저 Refresh 토큰을 삭제했습니다. 다시 시도하세요.");
        }
    }**

    ... 생략
}

refresh 토큰을 삭제하기 전에 findByRefresh(refresh)를 하여 @Version이 적용되게 하고 이를 통해 delete를 실행하면 version 값이 변경되었는지 확인하여 비교, 동시성 충돌을 감지할 경우 에러를 발생시키지 않고 log를 띄움

+) refreshRepository.existsByRefresh을 통해 @Version을 활성화 할 수 있지 않냐 ?
: existsByRefresh는 엔티티를 조회하지 않기 때문에 @Version이 활성화 되지 않음

  • 에러 발생 테스트 코드
    @DisplayName("낙관적 락을 활용한 Refresh 토큰 동시 삭제 테스트")
    @SpringBootTest
    class ReissueServiceTest {
    
        @Autowired
        private RefreshRepository refreshRepository;
    
        @Test
        void 낙관적락_refresh_삭제_테스트() throws InterruptedException {
            // Refresh 토큰 1개 생성
            Refresh savedRefresh = refresh_토큰_생성();
    
            // 동시에 3개의 스레드가 삭제 요청을 보냄
            int numberOfThreads = 3;
    
            ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
    
            Future<?> future1 = executorService.submit(
                    () -> refreshRepository.deleteByRefresh(savedRefresh.getRefresh()));
            Future<?> future2 = executorService.submit(
                    () -> refreshRepository.deleteByRefresh(savedRefresh.getRefresh()));
            Future<?> future3 = executorService.submit(
                    () -> refreshRepository.deleteByRefresh(savedRefresh.getRefresh()));
    
            Exception result = new Exception();
    
            try {
                future1.get();
                future2.get();
                future3.get();
            } catch (ExecutionException e) {
                result = (Exception) e.getCause();
            }
    
            // OptimisticLockingFailureException 발생 확인
            assertTrue(result instanceof OptimisticLockingFailureException);
        }
    
        private Refresh refresh_토큰_생성() {
            Refresh refresh = Refresh.builder()
                    .username("testUser")
                    .refresh("sample_refresh_token")
                    .expiration("2024-12-31")
                    .build();
    
            return refreshRepository.save(refresh);
        }
    }
    
    OptimisticLockingFailureException 오류가 발생하게 됨을 체크
profile
꾸준하게

0개의 댓글