DB의 같은 테이블에서 동시에 접근을 하여 작업을 하는경우 (읽기만 하는 경우 제외) 문제가 발생한다. 데이터가 갱신되지 않거나 둘다 실패하거나 하는 일이 발생 할 수 있고, 이를 막기 위해 동시성을 제어하는 처리가 필요하다. 이게 정답이다! 라는 상황은 없고 데이터 특성이나 비즈니스 로직 상황에 맞게 골라서 처리를 해야 한다. 물론 대부분은 DBMS 가 처리를 해주나, 개발자가 수동으로 처리를 해야 할 때가 있다.
크게 2가지로 나눠볼 수 있다.
- 비관적 락
- 동시에 같은 데이터를 수정하는 일이 잦은 경우 (결제 처리, 쇼핑몰 구매로 인한 재고 감소 등)
- DB에서 처리 해준다 (후속 요청을 홀드 후 처리한다.) 때문에, DB에 부담이 될 수 있다.
- 낙관적 락
- 같은 데이터를 수정하는 일보다, 읽는 비율이 높은 경우 사용.
- 어플리케이션에서 처리 한다.
예제 코드를 통해 알아보자.
1. 엔티티
//lombok 을 이용한 자동 getter, setter 설정
@Entity
@getter
@setter
public class testEntity
{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int itemCount;
@Version // 낙관적 락 관리용 버전 컬럼
private int version;
}
- @Version 어노테이션이 붙어있는 엔티티의 경우, 기본적으로 낙관적 락으로 동작을 한다.
2. 리파지토리
public interface testRepository JpaRepository<testEntity, Long>
{
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<testEntity> findByIdPessimistic(Long id);
}
- @Lock 어노테이션 타입이 PESSIMISTIC_WRITE 인 메소드의 경우 비관적 락으로 동작 한다.
- 해당 메소드 호출시 커밋, 롤백하기 전까지 락이 걸린다.
3. 비관적 락 서비스 예
@Service
public class PessimisticService {
private final testRepository repository;
public PessimisticService(testRepository repository) {
this.repository = repository;
}
@Transactional
public void testQuery(Long productId) {
testEntity testItem = repository.findByIdPessimistic(productId);
if (testItem.getItemCount() <= 0) {
throw new RuntimeException("오류 발생");
}
testItem.setItemCount(testItem.getItemCount() - 1);
// commit 시점에 DB row lock 자동 해제
}
}
- 리파지토리의 @Lock 어노테이션 타입이 PESSIMISTIC_WRITE 인 메소드를 호출시 자동으로 lock이 걸린다.
- 오류가 발생하여 자동으로 롤백하거나, 처리가 완료되어 자동으로 commit 될 때 자동으로 lock을 해제 한다.
- 만일 A 트랜잭션이 작업중인데, B 트랜잭션이 똑같이 작업 하려 하면 B는 무조건 실패 하는가? 답은 아니다. DBMS 에서 B를 잠시 대기 시켜 놓은 후 처리를 한다. (물론 실패하는 경우도 있다. timeout에 걸리거나 데드락에 걸릴경우)
- 따라서 비관적 락의 경우 개발자가 보통 재시도 코드를 넣지 않아도 된다.
- 하지만 타임아웃이나 데드락 때문에 실패할 경우도 있으니, 재시도 코드를 넣는게 어떤가 싶다.
4. 낙관적 락 서비스 예
@Service
public class OptimisticService {
private final testRepository repository;
private static final int MAX_RETRIES = 3;
public OptimisticService(testRepository repository) {
this.repository = repository;
}
public void testQueryWithRetry(Long productId, int quantity) {
int attempt = 0;
boolean success = false;
while (!success && attempt < MAX_RETRIES) {
attempt++;
try {
testQuery(productId, quantity);
success = true;
} catch (OptimisticLockingFailureException e) {
System.out.println("버전 충돌 발생, 재시도 " + attempt);
// 잠시 대기 후 재시도 가능
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
}
}
if (!success) {
throw new RuntimeException("재시도 후에도 처리 실패");
}
}
@Transactional
public void testQuery(Long Id, int quantity) {
testEntity testItem = repository.findById(Id)
.orElseThrow(() -> new RuntimeException("해당 항목 없음"));
if (testItem.getItemCount() < quantity) {
throw new RuntimeException("오류 발생");
}
testItem.setItemCount(testItem.getItemCount() - quantity);
// 트랜잭션 커밋 시점에 JPA가 @Version 필드 기준으로 낙관적 락 충돌 체크
}
}
- 낙관적 락의 경우, 커밋을 하는 시점에 버전을 확인한다. 만일 버전이 다르다면 쿼리가 실패로 리턴되기 때문에 개발자가 재시도를 해주어야 한다.
- 비관적 락과 낙관적 락의 간단 차이점
- 비관적 락의 경우 A 트랜잭션 처리도중 B 트랜잭션이 들어오면 DBMS에서 B를 잠시 대기 -> A 완료시 B를 수행
- 낙관적 락의 경우 A 트랜잭션 처리도중 B 트랜잭션이 들어오면 락을 걸지 않음 -> B 트랜잭션 처리 시도시 버전이 달라졌다면 (A 트랜잭션 때문에) 실패를 반환 -> 개발자가 재시도 코드를 추가 해야함.
'DB' 카테고리의 다른 글
| 정규화, 역정규화 (0) | 2025.09.29 |
|---|---|
| DB 수동 Index (3) | 2025.08.27 |