서비스 개발 중 "최신 상태"를 관리하는 테이블에서 다수의 요청이 동시에 insert/update 를 수행하면서 동시성 이슈가 발생했습니다.
서비스 환경이 redisson 분산락을 사용할 수 없는 환경이라서 DB와 Retry 전략으로 해결해봤습니다.
latest 테이블에는 특정 리소스(userHealthInfoId)별로 단 하나의 row만 존재해야 합니다.OptimisticLockException 을 던지도록 했습니다.int maxRetries = 3;
int attempt = 0;
while (true) {
try {
LatestEntity entity = latestRepository.findByResourceId(resourceId)
.orElseGet(() -> {
ResourceEntity resource = resourceRepository.findById(resourceId)
.orElseThrow(() -> new RuntimeException("리소스를 찾을 수 없음"));
return latestRepository.save(new LatestEntity(resource));
});
entity.updateField(dataType, value);
latestRepository.saveAndFlush(entity);
break; // 성공 시 루프 탈출
} catch (OptimisticLockException e) {
attempt++;
if (attempt >= maxRetries) {
throw new RuntimeException("최대 재시도 횟수 초과");
}
try {
Thread.sleep(100L); // 잠시 대기 후 재시도
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
select ... for update 로 잠금.READ_COMMITTED에서는 해당 row만 잠그기 때문에 충돌 최소화DataIntegrityViolationException, ObjectOptimisticLockingFailureException) 발생 시 최대 10회까지 자동 재시도.@Retryable(
retryFor = {DataIntegrityViolationException.class, ObjectOptimisticLockingFailureException.class},
maxAttempts = 10,
backoff = @Backoff(delay = 500)
)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void upsertLatest(Long resourceId, DataType dataType, Object value) {
LatestEntity entity = latestRepository.findByResourceIdWithLock(resourceId)
.orElseGet(() -> {
ResourceEntity resource = resourceRepository.findById(resourceId)
.orElseThrow(() -> new RuntimeException("리소스를 찾을 수 없음"));
return latestRepository.save(new LatestEntity(resource));
});
entity.updateField(dataType, value);
latestRepository.save(entity);
}
Spring @Transactional 기본 격리 수준은 DB 기본값은 REPEATABLE_READ입니다.
하지만 REPEATABLE_READ는 불필요한 갭락(gap lock)을 발생시켜 데드락 가능성을 높이고, 성능 저하를 유발할 수 있습니다.
반면 READ_COMMITTED에서는 해당 row만 잠금을 걸어 충돌 범위를 최소화할 수 있습니다.
즉, 최신 데이터만 필요하고 row-level 충돌만 제어하면 되는 상황에서는 READ_COMMITTED가 더 적합했습니다.
→ 비관적 락(SELECT ... FOR UPDATE)과 궁합이 잘 맞고, 불필요한 락 범위를 줄일 수 있었기 때문입니다.
처음에는 분산락(Redisson 등) 도 고려했습니다.
하지만 운영 환경에서는기술 스택상 Redisson 같은 외부 분산락을 도입하기 어려웠습니다.
그리고 단일 DB 환경에서만 동시성이슈가 발생하는 상황이어서 DB 락으로 해결이 가능할 것 같았습니다.
그래서 애플리케이션 레벨의 분산락 대신 DB 레벨 제어를 선택했습니다.
resourceId)에 대해 row가 하나만 존재하도록 DB 차원에서 강제.select ... for update로 row-level 잠금.