0. 서론
동시성을 제어하는 방법은 각 상황에 따라 여러 가지가 존재합니다.
1. Java Application Level
- Synchronized
- Atomic
- ReentrantLock
- ConcurrentHashMap
2. Database Level
- Pessismistic Lock
- Optimistic Lock
- Named Lock
3. 분산 시스템
- Redis
- Zookeeper
이번 글에서는 Database Level 의 Lock을 Spring JPA 환경에서 어떻게 사용할 수 있는지 알아보도록 하겠습니다.
들어가기에 앞서 공통으로 사용될 코드들을 먼저 작성해 보겠습니다.
< Stock Entity >
재고의 정보를 저장할 간단한 Entity 입니다.
재고의 양을 quantity에 저장하고 있으며, decreaseQuantity(Long quantity) 메소드로 재고를 감소합니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Builder
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decreaseQuantity(Long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
< LockModeType >
JpaRepository에서 Lock을 제어하기 위해 사용되는 enum Class입니다.
각 LockModeType 별 설명은 아래와 같습니다.
타입 | 락 모드 | 설명 |
PESSIMISTIC_READ | 비관적 락 | 비관적 락, 읽기 락 사용 |
PESSIMISTIC_WRITE | 비관적 락 | 비관적 락, 쓰기 락 사용 |
PESSIMISTIC_FORCE_INCREMENT | 비관적 락 | 비관적 락 + 버전정보를 강제로 증가 |
OPTIMISTIC | 낙관적 락 | 낙관적 락 사용 |
OPTIMISTIC_FORCE_INCREMENT | 낙관적 락 | 낙관적 락 + 버전정보를 강제로 증가 |
READ | 기타 | JPA 1.0 호화 기능 (OPTIMISTIC과 같다) |
WRITE | 기타 | JPA 1.0 호화 기능 (OPTIMISTIC_FORCE_INCREMENT와 같다) |
NONE | 기타 | 락을 걸지 않는다. |
이번 글에서 사용할 LockModeType 은 PESSIMISTIC_WRITE, OPTIMISTIC입니다.
1. Pessimistic Lock
비관적 락이며 트랜잭션의 충돌이 자주 발생한다고 가정하고 사용하는 Lock입니다.
< 특징 >
- Lock 획득 및 잠금
- 특정 데이터나 테이블에 대해 트랜잭션이 작업을 수행하기 전에 미리 잠금(Lock)을 획득합니다.
- 다른 트랜잭션이 해당 데이터에 동시에 접근하는 것을 방지합니다.
- 단위
- Row 또는 Table 단위로 적용될 수 있습니다.
- Lock 관리 방법
- SQL의 FOR UPDATE 구문을 통해 비관적 락을 관리할 수 있습니다.
- PESSIMISTIC_WRITE는 배타락(Exclusive Lock)을 사용합니다.
※ 공유락(Shared Lock) & 배타락(Exclusive Lock)
- 공유락(Shared Lock)
- 공유락이 걸린 데이터는 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 할 수 없습니다.
- 여러 트랜잭션이 동시에 데이터를 읽는 것을 허용합니다.
- 배타락(Exclusive Lock)
- 배타락이 걸린 데이터는 해당 락을 건 트랜잭션만이 읽거나 쓸 수 있습니다.
- 다른 트랜잭션은 이러한 작업을 수행할 수 없습니다.
< 장점 >
- 데이터 정합성 보장
- Lock을 통해 데이터 업데이트가 엄격하게 제어되기 때문에, 데이터의 정합성이 보장됩니다.
- 빈번한 충돌 상황에서의 성능
- 충돌이 자주 발생하는 환경에서는 Optimistic Lock 보다 Pessimistic Lock이 더 나은 성능을 보일 수 있습니다.
< 단점 >
- 성능 감소
- Lock을 획득하고 관리하는 과정에서 추가적인 시간이 소요되므로, 전체적인 시스템의 성능 저하로 이어질 수 있습니다.
< 코드 구현 >
- LockModeType.PESSIMISTIC_WRITE
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
< 테스트 코드 >
@SpringBootTest
class StockServiceTest {
@Autowired
private StockRepository stockRepository;
@Autowired
private StockService stockService;
@Test
@DisplayName("Pessimistic Lock으로 재고의 동시성을 제어하여 100개 중 50개의 재고가 감소한다.")
void decreaseStockWithPessimisticLock() throws InterruptedException {
// given
// 동시에 실행될 스레드의 수를 50으로 설정합니다.
int threadCount = 50;
// 스레드풀을 25개의 스레드로 제한합니다.
ExecutorService executorService = Executors.newFixedThreadPool(25);
// 동기화 보조 도구로, 모든 스레드 작업이 완료될 때까지 대기합니다.
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// 초기 재고 상태를 설정합니다.
Stock save = stockRepository.save(Stock.builder()
.productId(1L)
.quantity(100L) // 초기 재고량 100
.build());
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
// 재고 감소 서비스를 호출합니다.
stockService.decreaseStockWithPessimisticLock(save.getId(), 1L);
} finally {
// 작업이 완료되면 countDown을 호출하여 latch의 카운트를 감소시킵니다.
countDownLatch.countDown();
}
});
}
// 모든 스레드의 작업이 완료될 때까지 기다립니다.
countDownLatch.await();
// then
// 업데이트된 재고 정보를 조회합니다.
Stock updateStock = stockRepository.findById(save.getId()).get();
// 재고 수량이 50으로 감소했는지 확인합니다.
assertThat(updateStock.getQuantity()).isEqualTo(50);
}
}
< 핵심 Query >
Pessimistic Lock 은 for update를 통해서 Lock을 관리하게 됩니다.
2. Optimistic Lock
낙관적 락이며 트랜잭션의 충돌이 발생하지 않는다고 가정하고 실제는 Lock을 사용하지 않는 방법입니다.
< 특징 >
- 버전 관리
- version 필드를 정의하여 버전을 관리합니다.
- @Version 애너테이션을 사용하여 구현됩니다.
- 재시도 로직
- 업데이트가 실패했을 때 (다른 트랜잭션이 먼저 데이터를 업데이트한 경우), while(true) 루프를 사용하여 성공할 때까지 재시도할 수 있습니다.
< 장점 >
- 성능 향상
- 별도의 락을 사용하지 않기 때문에, Pessimistic Lock보다 시스템의 성능이 향상될 수 있습니다.
- 데이터 접근 시 오버헤드가 적기 때문에 더 빠른 응답 시간을 제공할 수 있습니다.
< 단점 >
- 재시도 로직 필요
- 업데이트 충돌이 발생했을 때, 개발자가 재시도 로직을 직접 구현해야 하는 번거로움이 있습니다.
- 충돌 발생 시 처리
- 충돌이 빈번하게 일어나는 환경에서는 Pessimistic Lock이 더 적합할 수 있습니다.
< 코드 구현 >
- Entity에 Version 추가
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
@Builder
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decreaseQuantity(Long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
- LockModeType.OPTIMISITIC
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
}
- Facade Class
@Component
@RequiredArgsConstructor
public class OptimisticLockFacade {
private final StockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
stockService.decreaseStockWithOptimisticLock(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
※ Facade(퍼사드) 란?
- 퍼사드(Facade) 클래스는 복잡한 시스템을 단순화된 인터페이스를 통해 접근할 수 있게 해주는 디자인 패턴입니다.
- 이 패턴의 핵심 목적은 시스템의 복잡성을 줄이고 클라이언트가 시스템을 더 쉽게 사용할 수 있도록 하는 것입니다.
- 퍼사드는 "건물의 정면"이라는 뜻을 가지고 있으며, 클라이언트가 시스템의 내부 복잡성을 직접 다루지 않고, 퍼사드를 통해 간접적으로 시스템과 상호작용한다는 개념을 시각적으로 표현합니다.
< 테스트 코드 >
@SpringBootTest
class StockServiceTest {
@Autowired
private StockRepository stockRepository;
@Autowired
private OptimisticLockFacade optimisticLockFacade;
@Test
@DisplayName("Optimistic Lock으로 재고의 동시성을 제어하여 100개 중 50개의 재고가 감소한다.")
void decreaseStockWithOptimisticLock() throws InterruptedException {
// given
// 동시에 실행될 스레드의 수를 50으로 설정합니다.
int threadCount = 50;
// 스레드풀을 25개의 스레드로 제한합니다.
ExecutorService executorService = Executors.newFixedThreadPool(25);
// 동기화 보조 도구로, 모든 스레드 작업이 완료될 때까지 대기합니다.
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// 초기 재고 상태를 설정합니다.
Stock save = stockRepository.save(Stock.builder()
.productId(1L)
.quantity(100L) // 초기 재고량 100
.build());
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
// Facade 패턴이 적용된 재고감소 서비스를 호출합니다.
optimisticLockFacade.decrease(save.getId(), 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 작업이 완료되면 countDown을 호출하여 latch의 카운트를 감소시킵니다.
countDownLatch.countDown();
}
});
}
// 모든 스레드의 작업이 완료될 때까지 기다립니다.
countDownLatch.await();
// then
// 업데이트된 재고 정보를 조회합니다.
Stock updateStock = stockRepository.findById(save.getId()).get();
// 재고 수량이 50으로 감소했는지 확인합니다.
assertThat(updateStock.getQuantity()).isEqualTo(50);
}
}
< 핵심 Query >
Optimistic Lock은 실제로 Lock을 사용하지 않고, version을 비교하여 처음 조회한 버전과 일치하면 수정됩니다.
실패하면 성공할 때까지 while문을 돌면서 재시도합니다.
3. Named Lock
네임드 락은 특정한 이름을 가진 메타데이터 기반의 Lock입니다.
이 락은 데이터베이스나 다른 시스템 자원에 대한 동시성 제어를 이름을 통해 관리하는 방법을 제공합니다.
특히, MySQL에서 GET_LOCK과 RELEASE_LOCK 함수를 사용하여 구현할 수 있습니다.
< 특징 >
- 명시적 락 관리
- 네임드 락은 이름을 통해 명시적으로 락을 획득하고 해제할 수 있습니다.
- 특정 자원에 대한 접근을 제어하는 데 사용됩니다.
- 메타데이터 기반 락
- 락은 메타데이터 형태로 관리되며, 물리적인 데이터가 아닌 락 자체에 이름을 부여하여 관리합니다.
- 트랜잭션과의 독립성
- 네임드 락은 트랜잭션이 종료되어도 자동으로 해제되지 않습니다.
- 락의 선점 시간이 만료되거나 RELEASE_LOCK 같은 명령어로 직접 해제해야 합니다.
- 부모 트랜잭션과 독립적으로 동작하기 위해 Propagation.REQUIRES_NEW와 같은 트랜잭션 전파 옵션을 사용해야 합니다.
- 자원의 독립적 관리 권장
- 데이터 소스를 분리하여 사용하는 것이 좋습니다.
- 커넥션 풀의 부족 문제를 방지하기 위해, 추가적인 커넥션 풀을 활용하는 것이 좋습니다.
< 장점 >
- 타임아웃 구현의 용이성
- 비관적 락(pessimistic lock)은 타임아웃 구현이 어려울 수 있지만, 네임드 락은 타임아웃을 손쉽게 설정할 수 있습니다. (락의 선점 시간을 제어할 수 있기 때문)
- 데이터 삽입 시 정합성 보장
- 데이터를 삽입하는 과정에서 정합성을 유지해야 할 때 네임드 락을 활용할 수 있습니다.
- 동시에 같은 작업을 수행하는 다른 트랜잭션으로부터 데이터를 보호할 수 있습니다.
< 단점 >
- 트랜잭션과 락 관리의 복잡성
- 네임드 락은 트랜잭션 종료 시 자동으로 해제되지 않으므로, 사용자가 직접 세션과 락을 관리해야 합니다.
- 구현 방법이 복잡할 수 있습니다.
< 코드 구현 >
- getLock, releaseLock
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
getLock : Lock을 얻기 위해 최대 3000ms의 시간 동안 대기합니다.
- Facade Class
@Component
@RequiredArgsConstructor
public class NamedLockFacade {
private final StockService stockService;
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
try {
stockRepository.getLock(id.toString());
stockService.decreaseStockWithNamedLock(id, quantity);
} finally {
stockRepository.releaseLock(id.toString());
}
}
}
getLock을 통해 해당 이름을 가진 Lock을 가져온 후 작업을 진행합니다.
작업이 완료되면 Lock을 반환해 줍니다.
- Propagation.REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decreaseStockWithNamedLock(Long id, Long quantity) {
Stock stock = stockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 상품이 존재하지 않습니다."));
stock.decreaseQuantity(quantity);
}
※ Propagation.REQUIRES_NEW
- 새로운 트랜잭션 시작
- 현재 진행 중인 트랜잭션이 있더라도 무시하고, 어노테이션이 붙은 메소드를 실행할 새로운 트랜잭션을 시작합니다.
- 만약 현재 진행 중인 트랜잭션이 있다면, 그 트랜잭션은 일시 중단되고, @Transactional 어노테이션이 붙은 메소드가 완료될 때까지 대기 상태가 됩니다.
- 독립적인 커밋과 롤백
- REQUIRES_NEW로 시작된 트랜잭션은 호출한 트랜잭션과 독립적으로 커밋되거나 롤백됩니다.
- 즉, 이 메소드 내에서 발생한 변경 사항은 해당 메소드의 실행 성공 여부에만 영향을 받으며, 외부 트랜잭션의 결과에 영향을 받지 않습니다.
< 테스트 코드 >
@SpringBootTest
class StockServiceTest {
@Autowired
private StockRepository stockRepository;
@Autowired
private NamedLockFacade namedLockFacade;
@Test
@DisplayName("Optimistic Lock으로 재고의 동시성을 제어하여 100개 중 50개의 재고가 감소한다.")
void decreaseStockWithOptimisticLock() throws InterruptedException {
// given
// 동시에 실행될 스레드의 수를 50으로 설정합니다.
int threadCount = 50;
// 스레드풀을 25개의 스레드로 제한합니다.
ExecutorService executorService = Executors.newFixedThreadPool(25);
// 동기화 보조 도구로, 모든 스레드 작업이 완료될 때까지 대기합니다.
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// 초기 재고 상태를 설정합니다.
Stock save = stockRepository.save(Stock.builder()
.productId(1L)
.quantity(100L) // 초기 재고량 100
.build());
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
// Facade 패턴이 적용된 재고감소 서비스를 호출합니다.
namedLockFacade.decrease(save.getId(), 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 작업이 완료되면 countDown을 호출하여 latch의 카운트를 감소시킵니다.
countDownLatch.countDown();
}
});
}
// 모든 스레드의 작업이 완료될 때까지 기다립니다.
countDownLatch.await();
// then
// 업데이트된 재고 정보를 조회합니다.
Stock updateStock = stockRepository.findById(save.getId()).get();
// 재고 수량이 50으로 감소했는지 확인합니다.
assertThat(updateStock.getQuantity()).isEqualTo(50);
}
}
< 핵심 Query >
Named Lock 은 get_lock을 통해서 Lock을 획득한 후 업데이트를 합니다.
업데이트가 완료되면 Lock을 반환해 줍니다.
4. 정리
Pessimistic Lock | Optimistic Lock | Named Lock | |
특징 | - 충돌이 빈번하다고 가정 - row or table 단위 적용 - for update 명령어 사용 |
- 충돌이 드물다고 가정 - 버전 관리로 충돌 감지 - 충돌 시 재시도 로직 |
- 메타데이터에 Locking - 부모 트랜잭션과 독립 - get, release lock 사용 |
장점 | - 데이터 정합성 보장 - 빈번한 충돌 시 성능 이점 |
- 락을 사용하지 않음 - 더 빠른 응답 속도 |
- 타임아웃 구현 용이 - Insert 시 정합성 유지 |
단점 | - 성능 저하 가능성 - Lock 관리로 복잡성 증가 |
- 충돌이 빈번하면 성능 저하 - 재시도 로직 필요 |
- Lock 해제 관리 필요 - 리소스 사용량 증가 |
'Spring Boot' 카테고리의 다른 글
[Spring Cloud] Feign Client 성능 최적화: 다중 호출에서 일괄 처리로 (1) | 2024.02.18 |
---|---|
[Spring Boot] @RequiredArgsConstructor를 통한 의존성 주입(DI) 과정 (0) | 2024.02.04 |