관리 메뉴

너와 나의 스토리

Spring JPA 트랜잭션 관리 - 낙관적 락, 비관적 락 본문

개발/Spring Boot

Spring JPA 트랜잭션 관리 - 낙관적 락, 비관적 락

노는게제일좋아! 2025. 9. 28. 12:42
반응형

Spring JPA Transaction 관리

JPA에서는 보통 @Transactional 어노테이션으로 트랜잭션을 선언적으로 관리한다.

기본적으로 Srping은 AOP 방식으로 메서드를 감싸서, 진입 시 begin, 정상 종료 시 commit, 예외 발생 시 rollback을 수행한다.

 

예제: 디바이스 조회 -> 점유 상태 변경 -> 디바이스 저장

@Service
public class DeviceService {

    private final DeviceRepository deviceRepository;

    @Transactional
    public void occupyDevice(Long deviceId, Long userId) {
        Device device = deviceRepository.findById(deviceId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 디바이스"));

        if (device.isOccupied()) {
            throw new IllegalStateException("이미 점유 중");
        }

        device.occupy(userId); // 상태 변경
        deviceRepository.save(device); // flush 시점에 update
    }
}

 

 

JPA에서 제공하는 동시성 제어 전략

1) Optimistic Lock (낙관적 락)

  • 충돌이 드물다고 가정하고 동작
  • 엔티티에 버전(@Version)을 두고 업데이트 시점에 버전 비교로 충돌을 감지한다. 
    • 엔티티의 상태가 변경되어 update가 일어나면 version이 자동으로 증가함.
  • 충돌 발생 시 예외를 던지고 재시도하거나 사용자에게 실패를 알린다. 
@Entity
public class Device {
    @Id
    private Long id;

    private Boolean occupied;
    private Long occupiedBy;

    @Version
    private Long version; // JPA/Hibernate가 자동으로 관리

    public void occupy(Long userId) {
        if (Boolean.TRUE.equals(this.occupied)) {
            throw new IllegalStateException("이미 점유중");
        }
        this.occupied = true;
        this.occupiedBy = userId;
    }
}
@Service
public class DeviceService {
    private final DeviceRepository repo;

    public void occupyWithRetry(Long deviceId, Long userId) {
        final int maxAttempts = 3;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                tryOccupy(deviceId, userId); // 트랜잭션 단위 시도
                return;
            } catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
                if (attempt == maxAttempts) throw new ConcurrentModificationException("동시성 충돌 — 재시도 초과");
                // 짧은 backoff
                try { Thread.sleep(100L * attempt); } catch (InterruptedException ignored) {}
            }
        }
    }

    @Transactional
    protected void tryOccupy(Long deviceId, Long userId) {
        Device d = repo.findById(deviceId).orElseThrow();
        if (Boolean.TRUE.equals(d.getOccupied())) throw new IllegalStateException("이미 점유중");
        d.occupy(userId);
        repo.saveAndFlush(d);
    }
}
  • 시나리오
    1. T1과 T2가 동시에 device(버전=1)를 읽음.
    2. T1이 점유 후 commit → DB 업데이트, 버전 = 2.
    3. T2가 점유하려고 저장 시도 → 업데이트문이 WHERE id=? AND version=1로 동작 → 영향된 row가 0이면 JPA가 OptimisticLockException 발생.
    4. T2는 예외를 받아 재시도 로직을 실행하거나 사용자에 실패를 반환.
  • 장단점
    • 장점: 락을 걸지 않으므로 읽기 성능 유리, 확장성 좋음
    • 단점: 충돌이 잦으면 재시도로 효율 저하

 

 

2) Pessimistic Lock (비관적 락)

  • 충돌을 미리 막는다. 
  • DB 수준에서 해당 row를 SELECT ... FOR UPDATE처럼 잠궈서 다른 트랜잭션의 접근을 미리 차단(블락킹)
  • 충돌 가능성이 높은 경우 사용함.
public interface DeviceRepository extends JpaRepository<Device, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT d FROM Device d WHERE d.id = :id")
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")})
    Device findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public void occupyPessimistic(Long deviceId, Long userId) {
    try {
        Device d = repo.findByIdForUpdate(deviceId); // DB에서 FOR UPDATE (잠금)
        if (Boolean.TRUE.equals(d.getOccupied())) throw new IllegalStateException("이미 점유중");
        d.occupy(userId);
        repo.save(d);
    } catch (LockTimeoutException | PessimisticLockException e) {
        // 잠금 획득 실패 -> 재시도 혹은 사용자에게 알림
        throw new IllegalStateException("다른 요청이 처리 중입니다. 잠시 후 다시 시도하세요.");
    }
}
  • 시나리오
    1. T1이 SELECT ... FOR UPDATE로 row 잠금 획득.
    2. T2가 같은 row를 SELECT ... FOR UPDATE하려 하면 블록되거나(lock timeout 설정 시) 즉시 실패.
    3. T1이 commit/rollback을 하면 잠금 해제 → T2가 진행.
  • 장단점
    • 장점: 충돌을 미연에 차단하여 절대적으로 한 번에 한 명만 수행되도록 보장
    • 단점: 락 경함시 성능 저하&데드락 위험, 트랜잭션 짧게 유지해야함. 

 

 

반응형
Comments