Pessimisitic Lock과 Optimisitic Lock
Pessimisitic Lock : 비관적 잠금
비관적 잠금은 여러 트랜잭션이 동일한 데이터에 동시에 접근하고 수정하는 것을 방지하는 잠금 매커니즘입니다. 이 잠금에서의 트랜잭션은 특정 데이터 항목을 읽거나 수정하기 전에 해당 데이터에 대한 잠금을 요청하고 트랜잭션이 완료될 때 잠금을 해지합니다.
이렇게 한 번에 하나의 트랜잭션만이 데이터에 접근하고 수정할 수 있으므로 데이터 불일치 및 데이터 충돌 문제를 해소할 수 있습니다. 하지만 교착 상태에 빠질 우려가 있으니 주의가 필요합니다.
💡 비관적 잠금의 과정
💡 비관적 잠금의 교착 상태
아래 순서에 따르면 스레드 A는 영원히 스레드 B에 대한 비관적 잠금을 구할 수 없습니다. 그 이유는 스레드 B가 B 에그리게이트에 대해 잠금을 이미 선점하고 있기 때문입니다. 이렇게 두 스레드는 계속하여 서로를 기다리게 되며, 결국 교착상태에 빠지게 됩니다.
비관적 잠금의 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높으며, 사용자가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가합니다. 이러한 문제가 발생하지 않도록 하기 위해서는 잠금을 구할 때 최대 대기 시간을 지정해야 합니다.
상황
- 스레드 A : A 에그리게이트에 대한 비관적 잠금 구함
- 스레드 B : B 에그리게이트에 대한 비관적 잠금 구함
- 스레드 A : B 에그리게이트에 대한 비관적 잠금 시도
- 스레드 B : A 에그리게이트에 대한 비관적 잠금 시도
Type
💡 배타적 락(Exclusive Lock, Write Lock)
- 데이터를 변경할 때 사용하는 Lock이며, A라는 사람이 데이터에 접근하고 있다면 다른 사람은 해당 데이터에 대해 접근 및 쓰기, 수정이 불가능 합니다.
- 데이터에 공유 락이 하나라도 걸려있으면 배타적 락을 걸 수 없습니다.
💡 공유 락(Shared Lock, Read Lock)
- 데이터를 읽을 때 사용하는 Lock이며, A라는 사람이 데이터에 접근하고 있다면 다른 사람은 해당 데이터에 접근은 할 수 있지만 변경 작업은 불가능 합니다.
- 데이터에 해당 공유 락이 걸려있으면 배타적 락을 걸 수 없습니다.
Mode
💡 PESSIMISTIC_READ
- 공유 락(Shared Lock)을 얻을 수 있으며, 데이터에 대해 읽을 수는 있지만 변경 작업은 불가능 합니다.
- 쿼리문에 select ... for share 구문이 추가됩니다.
💡 PESSIMISTIC_WRITE
- 배타적 락(Exclusive Lock)을 얻을 수 있으며, 사용중인 데이터에 대해 읽거나 변경 작업을 할 수 없습니다.
- 쿼리문에 select ... for update 구문이 추가됩니다.
💡 PESSIMISTIC_FORCE_INCREMENT
- 낙관적 락처럼 버전 정보를 사용하며, 버전 정보를 강제로 증가시킵니다. 하이버네이트는 nowait를 지원하는 데이터 베이스에 대해
for update nowait 옵션을 적용합니다.
Lock Scope
💡 EXTENDED
- 연관된 엔티티 객체들도 Lock이 걸립니다.
💡 NORMAL
- 해당 엔티티만 Lock이 걸립니다.
코드로 알아보기
💡 Lock을 사용하지 않은 코드
- Lock을 사용하지 않고 상품의 재고를 차감하였을 경우 race condition이 발생하여 상품의 재고가 제대로 감소하지 않는 상황이 발생합니다. 그렇기 때문에 테스트는 실패를 하게 됩니다.
@Getter
@Entity
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long idx;
@Comment(value = "재고")
@Column(name = "stock", nullable = false)
private Integer stock;
public void decreaseStock(Integer productBuyQuantity) {
if (productBuyQuantity == null || this.stock < productBuyQuantity) {
throw new IllegalArgumentException();
}
this.stock -= productBuyQuantity;
}
}
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {
private final JPAQueryFactory queryFactory;
public Product findByIdx(Long productIdx) {
return queryFactory
.selectFrom(product)
.where(
product.idx.eq(productIdx)
)
.fetchOne();
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class ProductService {
private final ProductDao productDao;
public void decreaseStock(int stock) {
Product product = productDao.findByIdx(1L);
product.decreaseStock(stock);
}
}
// 테스트 실행시 실패
@Test
void 동시에_재고_차감() throws InterruptedException {
int threadCount = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
CompletableFuture.runAsync(() -> {
try {
productService.decreaseStock(10);
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = productDao.findByIdx(1L);
assertThat(product.getStock()).isEqualTo(0);
}
💡 PESSIMISTIC_READ LOCK를 사용한 테스트
- RESSIMISTIC_READ 모드로 작동을 하면 해당 데이터에 대해 읽기 작업은 가능하나 변경 작업은 불가능하기에 그럼 각 스레드마다 읽었다가 변경할때는 이전 스레드가 완료되면 기다렸다가 변경 작업이 들어가지 않을까? 라고 생각을 했었는데 실상은 그렇게 동작을
하지 않았습니다. N개의 스레드가 동시에 접근하므로 함께 읽었다가 몇 개의 스레드만 커밋이 해제된 이후에 우연치 않게 접근을 하게되어 재고가 차감되었습니다. 그렇기 때문에 테스트는 실패하게 되었습니다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {
private final JPAQueryFactory queryFactory;
public Product findByIdx(Long productIdx) {
return queryFactory
.selectFrom(product)
.where(
product.idx.eq(productIdx)
)
.setLockMode(LockModeType.PESSIMISTIC_READ) // << 변경된 부분
.fetchOne();
}
}
@Test
void 동시에_재고_차감() throws InterruptedException {
int threadCount = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
CompletableFuture.runAsync(() -> {
try {
productService.decreaseStock(10);
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = productDao.findByIdx(1L);
assertThat(product.getStock()).isEqualTo(0);
}
💡 PESSIMISTIC_WRITE LOCK를 사용한 테스트
- PESSIMISITIC_WRITE 모드로 작동을 하면 하나의 스레드가 데이터를 얻으면 잠금이 걸려 다른 스레드는 블로킹 상태가되어 접근을 할 수 없으므로 정상적으로 재고를 차감시킬 수 있습니다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {
private final JPAQueryFactory queryFactory;
public Product findByIdx(Long productIdx) {
return queryFactory
.selectFrom(product)
.where(
product.idx.eq(productIdx)
)
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // << 변경된 부분
.fetchOne();
}
}
@Test
void 동시에_재고_차감() throws InterruptedException {
int threadCount = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
CompletableFuture.runAsync(() -> {
try {
productService.decreaseStock(10);
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = productDao.findByIdx(1L);
assertThat(product.getStock()).isEqualTo(0);
}
💡 OPTIMISTIC_FORCE_INCREMENT를 사용한 테스트
- 엔티티에 버전 정보를 알려주는 @Version 어노테이션을 추가하였습니다.@Version 어노테이션이 붙은 필드의 엔티티는 자동으로 낙관적 락이 적용된다고 합니다.
public class Product {
@Version
private Long versionNo; // << 변경된 부분
}
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {
private final JPAQueryFactory queryFactory;
public Product findByIdx(Long productIdx) {
return queryFactory
.selectFrom(product)
.where(
product.idx.eq(productIdx)
)
.setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT) // << 변경된 부분
.fetchOne();
}
}
// 테스트 실행시 실패
@Test
void 동시에_재고_차감() throws InterruptedException {
int threadCount = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
CompletableFuture.runAsync(() -> {
try {
productService.decreaseStock(10);
} catch (ObjectOptimisticLockingFailureException e) {
System.out.println("충돌 감지"); // << 변경된 부분
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = productDao.findByIdx(1L);
assertThat(product.getStock()).isEqualTo(0);
}
비관적락의 장단점
💡 비관적 락의 장점
- 트랜잭션이 시작된 순간부터 충돌을 방지할 수 있습니다.
💡 비관적 락의 단점
- 잠금 시간이 길면 성능에 대한 문제 및 교착 상태가 발생할 수 있습니다.
- 애플리케이션의 확장성에 영향을 미칩니다.
- 모든 데이터 베이스가 지원하지 않으므로 사전에 파악해야 합니다.
Optimisitic Lock : 낙관적 잠금
낙관적 잠금이란 데이터에 대해 잠금을 사용하지 않는 방법입니다. version이라는 속성 값을 두어 commit할 때 버전 체크를 하여 검증합니다. JPA에서는 @Version 어노테이션을 사용할 수 있으며 엔티티 클래스에는 하나의 @Version 어노테이션만 있어야하며, 여러 테이블에 매핑된 엔티티의 경우 기본 테이블에 배치되어야 합니다. 또한 버전은 int, Integer, long, Long, short, Short, java.sql.Timestamp 중 하나의 타입이어야 합니다.
Mode
💡 NONE
- 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용됩니다.
💡 OPTIMISTIC
- @Version만 적용했을 경우 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크합니다.
이것을 사용하면 한 번 조회한 엔티티는 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장합니다.
💡 OPTIMISTIC_FORCE_INCREMENT
- 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킵니다.
낙관적락의 장단점
💡 낙관적 락의 장점
- 락이 발생하지 않을거라 생각하므로 잠금이 필요하지 않습니다.
- 락을 사용하지 않으므로 교착상태가 발생하지 않습니다. 그렇기 때문에 성능에 영향을 미치지 않습니다.
💡 낙관적 락의 단점
- 버전 또는 타임스탬프를 유지 관리 해야합니다.
- 동시성 이슈 발생시 수동적으로 처리해야 합니다.
참고자료
- https://akasai.space/db/about_lock/
- https://effectivesquid.tistory.com/entry/Optimistic-Lock%EA%B3%BC-Pessimistic-Lock
- https://cult.honeypot.io/reads/optimistic-vs-pessimistic-concurrency/
- https://junhyunny.github.io/spring-boot/jpa/junit/jpa-optimistic-lock/