동시성 이슈 해결기: 비관적 락 + 재시도 전략

조제·2025년 9월 26일
0

서비스 개발 중 "최신 상태"를 관리하는 테이블에서 다수의 요청이 동시에 insert/update 를 수행하면서 동시성 이슈가 발생했습니다.
서비스 환경이 redisson 분산락을 사용할 수 없는 환경이라서 DB와 Retry 전략으로 해결해봤습니다.

문제 상황

  • latest 테이블에는 특정 리소스(userHealthInfoId)별로 단 하나의 row만 존재해야 합니다.
  • 하지만 유니크 인덱스가 없었고, 동시에 여러 요청이 들어오면 중복 생성 시도가 발생했습니다.
  • 낙관적 락(버전 필드 기반)을 적용해 충돌 시 OptimisticLockException 을 던지도록 했습니다.

1차 시도: 낙관적 락 + 재시도 루프

시도한 코드

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);
        }
    }
}

한계

  • 재시도 3회만으로는 충분하지 않았습니다.
  • 동시에 갱신되는 양이 많을 경우 여전히 예외가 발생했습니다.
  • 코드가 복잡했습니다.

2차 시도: 비관적 락 + 유니크 인덱스 + Spring Retry

개선 전략

  1. 유니크 인덱스 적용
    → 중복 row가 생성되지 않도록 DB 레벨에서 강제.
  2. 비관적 락(Pessimistic Lock) 적용
    → 특정 리소스의 최신 상태 row를 조회할 때 select ... for update 로 잠금.
  3. 트랜잭션 READ_COMMITTED 적용
    READ_COMMITTED에서는 해당 row만 잠그기 때문에 충돌 최소화
  4. Spring Retry 적용
    → 충돌 예외(DataIntegrityViolationException, ObjectOptimisticLockingFailureException) 발생 시 최대 10회까지 자동 재시도.
    → 재시도 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);
}

장점

  • DB 유니크 인덱스로 데이터 무결성 보장
  • 비관적 락으로 동시 수정 충돌 방지
  • Spring Retry로 재시도 처리 간소화 (코드 가독성 ↑)

READ_COMMITTED를 적용한 이유

Spring @Transactional 기본 격리 수준은 DB 기본값은 REPEATABLE_READ입니다.

하지만 REPEATABLE_READ는 불필요한 갭락(gap lock)을 발생시켜 데드락 가능성을 높이고, 성능 저하를 유발할 수 있습니다.

반면 READ_COMMITTED에서는 해당 row만 잠금을 걸어 충돌 범위를 최소화할 수 있습니다.

즉, 최신 데이터만 필요하고 row-level 충돌만 제어하면 되는 상황에서는 READ_COMMITTED가 더 적합했습니다.

비관적 락(SELECT ... FOR UPDATE)과 궁합이 잘 맞고, 불필요한 락 범위를 줄일 수 있었기 때문입니다.


Redisson 분산락

처음에는 분산락(Redisson 등) 도 고려했습니다.
하지만 운영 환경에서는기술 스택상 Redisson 같은 외부 분산락을 도입하기 어려웠습니다.

그리고 단일 DB 환경에서만 동시성이슈가 발생하는 상황이어서 DB 락으로 해결이 가능할 것 같았습니다.

그래서 애플리케이션 레벨의 분산락 대신 DB 레벨 제어를 선택했습니다.


최종 선택: 유니크 인덱스 + 비관적 락

  1. 유니크 인덱스
    • 특정 리소스(resourceId)에 대해 row가 하나만 존재하도록 DB 차원에서 강제.
    • 중복 insert 시 DB 예외가 발생하므로, 애플리케이션에서 재시도 전략만 세우면 됨.
  2. 비관적 락
    • 해당 row를 조회할 때 select ... for update로 row-level 잠금.
    • 동시에 수정하는 경우에도 한 요청만 잠금을 획득 → 충돌 최소화.
  3. Spring Retry
    • DB 예외 발생 시 최대 10회까지 재시도.
    • 코드가 간단해지고, 예외 처리 로직을 서비스 코드에 직접 반복하지 않아도 됨.
profile
조제

0개의 댓글