동시성 이슈를 해결하는 방법은 여러가지가 있지만 이번에는 비관적 락, 낙관적 락, 분산 락으로 어떻게 해결하는지 코드와 함께 간단하게 정리해보았습니다!
Pessimistic Lock 활용하기
비관적 락(Pessimistic Lock)은 데이터의 일관성을 보장하기 위해 자주 사용하는 방법 중 하나입니다. 이 방법에서는 특정 데이터에 대한 Exclusive lock
을 걸어, 다른 트랜잭션이 해당 데이터에 접근할 수 없도록 만듭니다. 쉽게 말해, 데이터를 가져가려면 먼저 내가 작업을 마칠 때까지 기다리라고 하는 것이죠.
예를 들어, 아래의 코드처럼 JPA에서 @Lock
어노테이션을 활용해 비관적 락을 걸 수 있습니다.
public interface StockRepository extends JpaRepository<Stock, Long>{
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
위 코드를 통해 데이터베이스 쿼리를 실행하게 되면, Hibernate는 다음과 같은 SQL 쿼리를 실행합니다.
Hibernate: select s1_0.id,s1_0.product_id,s1_0.quantity from stock s1_0 where s1_0.id=? for update
여기서 for update
부분이 바로 락을 걸고 데이터를 가져오는 부분입니다. 만약 여러 트랜잭션이 동일한 데이터를 접근하려고 할 때, 이 락 덕분에 데이터 충돌을 방지할 수 있습니다.
비관적 락은 충돌이 자주 발생하는 경우, 낙관적 락보다 성능이 더 좋을 수 있습니다. 또한, 락을 통해 업데이트를 제어하기 때문에 데이터 정합성(Consistency)도 보장할 수 있죠.
Optimistic Lock 활용하기
낙관적 락(Optimistic Lock)은 비관적 락과는 다르게 실제로 데이터베이스에 락을 걸지 않고, 버전 관리를 통해 데이터의 정합성을 맞추는 방법입니다. 쉽게 말해, 내가 데이터를 읽었을 때와 수정하려는 순간 사이에 누군가 수정한 적이 있는지 확인하고, 수정이 있다면 다시 데이터를 읽어와야 합니다.
예를 들어, 아래와 같은 방식으로 서비스 레이어에서 낙관적 락을 활용할 수 있습니다.
@Service
public class OptimisticLockStockService {
private final StockRepository stockRepository;
public OptimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity){
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
여기서 엔티티 클래스에 @Version
어노테이션을 사용하여 버전을 추가해주면, JPA는 데이터 수정 시 자동으로 버전을 비교해 충돌 여부를 판단합니다.
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
낙관적 락은 충돌이 발생했을 때 재시도를 해야 하고 이 부분은 개발자가 직접 구현을 해주어야 합니다. 아래와 같은 Facade
패턴을 활용해 재시도를 관리할 수 있습니다.
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true){
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
이제, 이 코드를 아래 테스트 코드를 통해서 테스트해보겠습니다. 100개의 트랜잭션이 동시에 실행되도록 하고, 그 결과를 확인해보겠습니다.
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private OptimisticLockStockFacade optimisticLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
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();
// 100 - (1 * 100) => 0 이 되어야함
assertEquals(0, stock.getQuantity());
}
}
100개의 요청이 동시에 처리되었지만, 데이터 정합성이 유지되었습니다.
낙관적 락은 충돌이 빈번하지 않을 때 성능상의 이점이 있지만, 업데이트가 실패했을 때 직접 재시도 로직을 구현해야 하는 번거로움이 있습니다. 반면에, 충돌이 빈번하다면 비관적 락이 더 나은 선택이 될 수 있습니다.
Named Lock 활용하기
분산 시스템에서 데이터의 동시성을 제어하는 방법 중 하나로 Named Lock이 있습니다. Named Lock은 이름이 있는 메타데이터 락을 이용해, 특정 세션이 락을 획득한 동안 다른 세션이 해당 락을 획득하지 못하도록 하는 방식입니다. 즉, 같은 이름을 가진 락이 이미 걸려 있다면 다른 세션에서는 락이 해제될 때까지 대기해야 합니다.
하지만 이때 주의해야 할 점이 있습니다. Named Lock은 트랜잭션이 종료되더라도 자동으로 해제되지 않습니다. 락을 해제하려면 별도의 명령어를 사용하거나, 선점 시간이 종료될 때까지 기다려야 합니다.
MySQL에서 Named Lock 사용하기
MySQL에서는 get_lock
명령어로 Named Lock을 획득하고, release_lock
명령어로 락을 해제할 수 있습니다.
위에서 설명한 낙관적 락이 특정 자원(예: stock)에 직접 락을 거는 방식이었다면, Named Lock은 별도의 공간에 락을 걸게 됩니다. 예를 들어, session1
이 '1'이라는 이름의 락을 걸면, 다른 세션에서는 session1
이 해당 락을 해제하기 전까지는 '1'이라는 이름의 락을 획득할 수 없습니다.
이를 구현할 때는 JPA의 Native Query 기능을 활용할 수 있습니다. 하지만 실제로 사용한다면, 데이터 소스를 분리해서 사용하는 것을 권장합니다. 같은 데이터 소스를 사용하면 커넥션 풀이 부족해질 수 있으며, 이로 인해 다른 서비스에도 영향을 줄 수 있습니다. 실무에서는 반드시 데이터 소스를 분리하는 것이 좋습니다.
Named Lock 구현 예시
먼저, LockRepository
를 생성합니다. 여기서는 편의상 Stock
엔티티를 사용했지만, 실제로는 별도의 JDBC를 사용하는 것이 좋습니다.
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
다음으로, NamedLockStockFacade
클래스를 만들어 보겠습니다.
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
this.lockRepository = lockRepository;
this.stockService = stockService;
}
@Transactional
public void decrease(Long id, Long quantity){
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
StockService
클래스는 아래와 같이 수정해주었습니다.
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity){
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
그리고 application.yml
파일에서 hikari: maximum-pool-size
를 설정해주어 커넥션 풀의 크기를 관리할 수 있습니다.
spring:
jpa:
hibernate:
ddl-auto: create
show-sql: true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/stock_example
username: root
password: 1234
hikari:
maximum-pool-size: 40
Named Lock 테스트
다음은 Named Lock을 활용한 재고 감소 동작을 테스트하는 코드입니다. 100개의 요청이 동시에 들어오는 상황을 시뮬레이션합니다.
@SpringBootTest
class NamedLockStockFacadeTest {
@Autowired
private NamedLockStockFacade namedLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i = 0; i < threadCount; i++){
executorService.submit(() -> {
try {
namedLockStockFacade.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) => 0 이 되어야함
assertEquals(0, stock.getQuantity());
}
}
Named Lock은 분산락을 구현할 때 매우 유용합니다. 비관적 락은 타임아웃 구현이 어렵지만, 분산락은 타임아웃을 손쉽게 구현할 수 있다는 장점이 있습니다. 또한, 데이터 삽입 시 정합성을 보장해야 하는 경우에도 유용하게 사용할 수 있습니다.
다만, 이 방법을 사용할 때는 트랜잭션 종료 시 락 해제와 세션 관리를 신중하게 처리해야 합니다. 잘못하면 락이 해제되지 않아 시스템 전체에 문제가 발생할 수 있습니다. 실제로 Named Lock을 사용할 때는 구현 방법이 복잡해질 수 있으므로, 주의해서 사용해야 합니다.
궁금한점은 댓글에 남겨주세요!
'Spring' 카테고리의 다른 글
동시성 제어 (1편) (0) | 2024.09.01 |
---|---|
간단하게 알아보는 Spring Cloud Config (0) | 2024.09.01 |
Spring Security Architecture (Spring MVC 기반) (0) | 2024.08.25 |
@Bean과 @Component의 차이 (0) | 2024.08.25 |
기몽수의 FCM 웹 알림 서비스 (0) | 2024.08.17 |