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 오류가 발생하게 됨을 체크