티스토리 뷰
1. 여러 사용자가 동시에 서버에 요청을 보낸다면, 서버는 모든 요청을 어떻게 순차적으로 처리할 수 있을까?
웹 서비스 이용자가 많고, 특정 서비스에 대한 요청이 동시에 몰리게 되면, 서버에서 적절한 처리를 하지 않는 경우 일부 요청이 유실되는 문제가 발생할 수 있다. 예를 들어, 두 사용자가 동시에 물건을 구매했을 때 재고가 100개라면 정상적으로는 98개가 되어야 하지만, 첫 번째 사용자의 요청이 유실되면 DB에는 99개로 잘못 저장될 수 있다.
서버는 여러 사용자가 동시에 동일한 데이터에 접근하더라도 모든 요청을 순차적으로 처리할 수 있도록 관리해야 한다. 하지만 모든 요청을 완벽히 처리하려고 하면 서비스 속도가 느려질 수 있는 한계가 있다.
MySQL에서는 배타적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 제공한다. 배타적 락은 조회하는 데이터에 Lock을 걸어, 다른 트랜잭션이 해당 데이터에 접근하려 할 때 대기하게 하는 방법이다.
반면, 낙관적 락은 Lock 대신 Version을 사용하여, 데이터 조회 시점과 수정 시점의 버전이 다르면 다른 트랜잭션이 해당 행을 수정한 것으로 간주해 작업을 실패로 처리한다. 이 경우 개발자는 실패한 작업을 다시 시도할 수 있도록 처리해야 한다.
2. Optimistic Lock(낙관적 락)
낙관적 락은 데이터 충돌이 발생하지 않을 것으로 예상하고 동시성을 제어하는 방식이다. 낙관적 락은 실제 Lock을 사용하지 않고, Version 필드를 활용하여 동시성을 제어하므로, 락을 사용하는 방법보다 성능상 이점이 있다. 그러나 충돌이 발생하지 않을 것으로 가정하기 때문에, 실제로 충돌이 자주 발생하는 경우에는 락을 사용하는 방식보다 성능이 떨어질 수 있다.
JPA에서는 엔티티에 @Version 필드를 추가하여 낙관적 락을 쉽게 구현할 수 있다. 하지만 @Version을 사용할 때는 몇 가지 주의할 점이 있다.
- 엔티티는 하나의 버전 필드만 가질 수 있다.
- @Version 필드는 int, Integer, Long, short, Short, java.sql.Timestamp 타입 중 하나여야 한다.
Entity
@Version 애너테이션을 사용하여 Version 정보를 관리한다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
Repository
@Lock 애너테이션은 JPA Repository에서 메서드 수준에 적용할 수 있으며, 해당 메서드가 어떤 락 모드를 사용할지를 정의하는 데 사용된다. @Lock은 LockModeType을 통해 다양한 낙관적 락 모드를 제공한다.
- OPTIMISTIC : 데이터 조회와 수정 시 다른 트랜잭션의 동시 접근을 허용한다. 단, 수정 시에만 Version 값이 증가하며, 업데이트할 때 조회 시점의 Version 값과 현재 Version 값이 다르면 충돌로 간주하여 예외를 발생시킨다. 예를 들어, 트랜잭션 A가 레코드 A를 작업 중이더라도 트랜잭션 B는 레코드 A에 동시 접근이 가능하다.
- 단순히 데이터를 조회하는 시점에는 Version 값이 증가하지 않는다.
- OPTIMISTIC_FORCE_INCREMENT : OPTIMISTIC과 달리 데이터를 단순 조회할 때도 Version 값이 증가한다. 즉, 동일 데이터에 대한 동시 접근 시 충돌을 더욱 엄격하게 관리하므로, 데이터 충돌 가능성이 높아진다. 예를 들어, 트랜잭션 A가 레코드 A를 읽고 작업 중일 때 트랜잭션 B가 레코드 A를 읽는다면 Version 값이 증가하여, 트랜잭션 A가 데이터를 수정하려 할 때 충돌이 발생해 예외가 발생할 수 있다.
public interface StockRepository extends JpaRepository<Stock, Long> {
// Optimistic Lock
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
Service
JPA의 경우 1차 캐시의 스냅샷을 기반으로 데이터 변경 사항을 트랜잭션 커밋 시점에 자동으로 수정 쿼리를 발생시킨다. 아래 예제코드도 굳이 save()를 호출할 필요는 없으나, 좀 더 명확하게 코드를 보여주고자 작성하였다.
findByIdWithOptimisticLock() 메서드는 Stock 정보를 조회할 때 Stock 테이블의 Version 값도 함께 가져온다.
이후, 조회한 Stock 객체를 기반으로 decrease() 메서드를 호출해서 재고를 감소시키고, 감소된 재고 수량을 DB에 저장한다.
@Transactional이 적용되어 있어서, 스프링 AOP에 의해 트랜잭션 커밋이 마지막에 발생한다.
이때 처음 조회한 Version 값과 DB에 커밋 직전의 Version 값이 다를 경우, 데이터 충돌이 발생한 것으로 간주하고 org.springframework.orm.ObjectOptimisticLockingFailureException 예외가 발생한다.
@Service
public class OptimisticLockStockService {
@Autowired
private StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
Service 예외 처리
낙관적 락은 Version 필드 불일치로 데이터 충돌이 발생할 경우 OptimisticLockException을 발생시킨다. 이 예외가 발생하면 개발자가 직접 예외를 처리해서 작업을 재시도하도록 해야 한다.
OptimisticLockStockFacade 클래스는 OptimisticLockStockService#decrease() 메서드에서 데이터 충돌로 예외가 발생할 경우, 작업에 성공할 때까지 50ms 간격으로 다시 요청을 보낸다.
@Component
public class OptimisticLockStockFacade {
@Autowired
private OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break; // 수량 감소에 성공했다면 종료
} catch (Exception e) { // 수량 감소 실패 시, 50ms 후에 재시도
// 예외 클래스 출력
System.out.println("예외 클래스 : " + e.getClass().getName());
Thread.sleep(50);
}
}
}
}
Test
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@Autowired
private OptimisticLockStockFacade optimisticLockStockFacade;
@BeforeEach
public void beforeEach() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void afterEach() {
stockRepository.deleteAll();
}
@Test
public void 동시예_100개의_요청() throws InterruptedException {
int threadCount = 100; // 스레드 100개 생성
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// 각 스레드는 재고를 1개 감소시킨다.
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
optimisticLockStockFacade.decrease(1L, 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0, stock.getQuantity());
}
}
사용자 100명이 동시에 재고를 1개 감소시킬 때, 낙관적 락을 사용하여 모든 요청을 처리할 수 있도록 하였다.
낙관적 락은 데이터 충돌이 발생하지 않을 것으로 간주하고 동시성을 제어하는 방식인데, 100개의 요청을 동시에 처리하는 과정에서 Version 불일치로 인한 ObjectOptimisticLockingFailureException 예외가 374번 발생했음을 확인할 수 있다. 이 경우에는 낙관적 락을 사용하는 것보단 다른 방법을 통해 동시성 문제를 제어하는 것이 좋을 것이다.
참고로 ObjectOptimisticLockingFailureException 예외는 스프링 프레임워크에서 제공하는 예외로, JPA의 낙관적 락에서 발생한 예외를 스프링이 처리하는 과정에서 변환하여 발생시킨다.
좀 더 구체적으로 설명하자면, JPA에서는 낙관적 락 충돌이 발생하면 OptimisticLockException이 발생한다. 스프링은 다양한 데이터 접근 기술에 종속되지 않고 일관된 예외 계층 구조를 제공하기 위해, OptimisticLockException(JPA 예외)을 ObjectOptimisticLockingFailureException(스프링 예외)로 변환한다.
즉, OptimisticLockException은 JPA에 종속된 예외 클래스인데, 이 예외를 직접 처리하면 코드가 JPA에 종속적이 된다. 스프링의 예외 변환 기능을 활용하면 특정 데이터 접근 기술에 종속되지 않고 스프링 예외로만 처리할 수 있어, 코드의 재사용성이 높아지고 특정 기술에 대한 의존성이 줄어든다.
3. 비관적 락(Pessimistic Lock)
비관적 락은 데이터 충돌이 발생할 가능성이 높다고 가정하고, 트랜잭션이 수행되는 동안 다른 트랜잭션의 접근을 제한하여 동시성을 제어하는 방법이다.
낙관적 락과 달리, 비관적 락은 실제 Lock을 사용하여 동시성을 제어하기 때문에, 데이터 충돌이 빈번히 발생하는 환경에서 안전하게 데이터 일관성을 보장할 수 있다. 다만, Lock을 사용하는 방식은 Version을 사용하는 방식보다 성능이 저하될 수 있다.
Lock을 사용하는 방식에서 성능 저하가 발생하는 이유
비관적 락은 데이터베이스에서 실제로 Lock을 사용해 다른 트랜잭션의 접근을 제한하기 때문에 성능에 영향을 준다. 구체적인 이유는 다음과 같다.
- 락이 걸린 데이터에 대한 접근 제한
- 락이 걸린 데이터에는 다른 트랜잭션이 접근할 수 없다. 예를 들어, 트랜잭션 A가 데이터에 락을 걸면, 트랜잭션 B는 트랜잭션 A가 작업을 완료하고 락을 해제할 때까지 해당 데이터에 접근할 수 없다.
- 이는 병렬 처리가 제한되어 여러 요청이 동시에 처리될 수 없고, 트랜잭션 지연이 발생하게 된다.
반면에 낙관적 락은 Version 필드를 사용해 충돌을 제어하므로 트랜잭션 간 대기가 발생하지 않는다. 락을 사용하지 않기 때문에 데이터에 동시 접근이 가능하여, 충돌 가능성이 낮은 환경에서는 락을 사용하는 비관적 락보다 성능이 좋다.
다만, 낙관적 락은 충돌이 발생하지 않을 것이라 가정하고 데이터 동시 접근을 허용하는 방식인데, 데이터 충돌이 빈번하게 발생한다면 비관적 락보다 성능이 떨어질 수 있다. 이는 데이터 충돌 시 OptimisticLockException이 발생하고, 개발자가 이 예외를 처리하여 작업을 재시도해야 하기 때문에 충돌 처리 비용이 쌓여 비관적 락보다 성능이 저하되는 결과를 초래할 수 있다.
Entity
낙관적 락과 달리 Version 필드를 사용하지 않는다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
public Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
Repository
낙관적 락과 마찬가지로 @Lock 애너테이션을 사용하여 비관적 락을 설정할 수 있다. 비관적 락도 여러 가지 LockModeType을 제공한다.
- PESSIMISTIC_READ : 트랜잭션 A가 데이터를 조회할 경우, 트랜잭션 B는 동일한 데이터를 조회할 수 있지만, 데이터 변경은 불가능하다. → 트랜잭션 A는 공유 잠금(Shared Lock)을 획득한다.
- PESSIMISTIC_READ를 사용하여 락이 걸린 데이터는 다른 트랜잭션에서 읽기 작업만 허용되고, 쓰기 작업은 차단된다.
- 즉, 락이 걸린 데이터에 대해 다른 트랜잭션이 읽기는 가능하지만, 쓰기를 시도하면 락이 해제될 때까지 대기해야 한다.
- SELECT FOR SHARE를 통해 공유 락을 설정할 수 있다.
- PESSIMISTIC_WRITE : 트랜잭션 A가 데이터를 조회하면 트랜잭션 B는 동일한 데이터를 읽거나 수정하는 것이 모두 불가능하다. → 트랜잭션 A는 배타적 락(Exclusive Lock)을 획득한다.
- SELECT FOR UPDATE를 통해 배타적 락을 설정할 수 있다.
- PESSIMISTIC_FORCE_INCREMENT : 비관적 락을 사용하면서 동시에 Version 값을 증가시켜 다른 트랜잭션과의 충돌을 방지한다.
public interface StockRepository extends JpaRepository<Stock, Long> {
// Pessimistic Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
Service
비관적 락에서는 Lock을 사용하여 Lock이 걸린 데이터에 접근 시에 단순히 Lock이 반납될 때까지 대기하는 것이므로 별도의 예외 처리가 필요하지 않다.
@Service
public class PessimisticLockStockService {
@Autowired
private StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
Test
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@Autowired
private PessimisticLockStockService pessimisticLockStockService;
@BeforeEach
public void beforeEach() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void afterEach() {
stockRepository.deleteAll();
}
@Test
public void 동시예_100개의_요청() throws InterruptedException {
int threadCount = 100; // 스레드 100개 생성
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// 각 스레드는 재고를 1개 감소시킨다.
for(int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
pessimisticLockStockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0, stock.getQuantity());
}
}
Lock을 가진 트랜잭션부터 작업을 우선 처리하고, Lock을 얻지 못한 트랜잭션은 Lock이 반납될 때까지 대기한다.
100개의 요청이 동시에 발생했을 때 비관적 락을 통해 동시성을 제어할 수 있다.
4. 두 번의 갱실 문제
낙관적 락에서 두 번의 갱신 분실 문제 방지
낙관적 락은 Version 필드를 통해 충돌을 감지하고 예외를 발생시키는 방식으로 동작한다. 두 개의 트랜잭션이 동시에 동일한 데이터를 조회하여 동일한 버전으로 작업을 시작하더라도, 먼저 커밋한 트랜잭션이 Version을 증가시키고 DB에 저장한다. 이후 두 번째 트랜잭션이 커밋을 시도할 때, 버전이 달라진 것을 감지하여 OptimisticLockException이 발생한다. 스프링 프레임워크를 사용하면 예외가 ObjectOptimisticLockingFailureException으로 변환된다.
이 예외를 처리하여 두 번째 트랜잭션에서 작업을 다시 시도하도록 함으로써 두 번의 갱신 분실 문제를 방지할 수 있다. 즉, OptimisticLockException이 발생한 두 번째 트랜잭션은 작업을 처음부터 다시 시도하게 되므로, 두 번의 갱신 분실 문제가 발생하지 않는다.
비관적 락에서 PESSIMISTIC_READ 사용 시 두 번의 갱신 분실 문제 발생
PESSIMISTIC_READ는 데이터를 읽기 위해 락을 걸지만, 동일한 데이터에 대한 “읽기”는 여러 트랜잭션에서 허용한다.
예를 들어, 트랜잭션 A가 데이터를 읽고 수정하기 위해 PESSIMISTIC_READ로 락을 걸고 작업을 진행하는 동안, 트랜잭션 B도 동일한 데이터를 읽을 수 있다. 문제는 두 트랜잭션이 동일한 데이터를 읽고 작업을 수행할 때 발생한다. 트랜잭션 B가 트랜잭션 A가 완료되기 전에 수정을 시도하면, 락이 걸려 대기해야 한다. 그러나 트랜잭션 A가 먼저 완료되면, 트랜잭션 B는 트랜잭션 A가 수정하기 전의 데이터를 수정하게 된다.
이 경우, 트랜잭션 B의 작업이 커밋되면서 트랜잭션 A의 작업이 유실되는 문제가 발생한다.
비관적 락에서 PESSIMISTIC_WRITE 사용 시 두 번의 갱신 분실 문제 방지
PESSIMISTIC_WRITE는 해당 데이터에 쓰기 락을 걸어 다른 트랜잭션의 모든 접근(읽기와 쓰기)을 차단한다. 따라서 트랜잭션 A가 PESSIMISTIC_WRITE 락을 걸고 데이터를 수정하고 있다면, 트랜잭션 B는 락이 해제될 때까지 해당 데이터를 읽는 것조차 불가능하다. 이로 인해 트랜잭션 B가 동일한 데이터를 읽고 업데이트할 기회가 없어, 두 번의 갱신 분실 문제가 발생하지 않는다.
결론
- 낙관적 락은 두 번의 갱신 분실 문제를 방지한다.
- Version 필드를 사용하여 충돌을 감지하고 예외를 발생시킨다. 예외 처리 과정에서 트랜잭션이 작업을 다시 시도하므로 두 번의 갱신 분실 문제가 발생하지 않는다.
- 비관적 락에서 PESSIMISTIC_READ는 두 번의 갱신 분실 문제가 발생할 수 있다.
- PESSIMISTIC_READ는 락이 걸린 데이터의 읽기를 허용하므로, 두 개의 트랜잭션이 동일한 데이터에 대해 작업을 수행할 경우 먼저 수행된 트랜잭션의 변경 사항이 유실될 수 있다.
- 비관적 락에서 PESSIMISTIC_WRITE는 두 번의 갱신 분실 문제가 발생하지 않는다.
- PESSIMISTIC_WRITE는 락이 걸린 데이터의 읽기조차 금지하기 때문에, 첫 번째 트랜잭션이 작업을 마치고 락을 해제해야 두 번째 트랜잭션이 락을 획득하고 데이터에 접근할 수 있다.