** 본 글은 인프런 <재고시스템으로 알아보는 동시성이슈 해결방법> 을 수강한 후 작성한 글입니다.
동시성 문제 해결 방법 2. Database
MySQL 활용
- Pessimistic Lock
- 실제로 데이터에 Lock을 걸어 정합성을 맞추는 방법
- 데드락이 걸릴 수 있다.
- Optimistic Lock
- 실제로 Lock을 사용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
- 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트
- Named Lock
- 이름을 가진 metadata locking
- 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 Lock을 획득할 수 없도록 한다.
- 트랜잭션이 종료될 때 Lock이 자동으로 해제되지 않기 때문에 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다.
Pessimistic Lock
repository → Lock을 걸고 데이터를 가져오는 메서드 작성
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from Stock s where s.id = :id") Stock findByIdWithPessimisticLock(Long id);
Spring Data JPA →
@Lock
을 통해 손쉽게 Pessimistic Lock을 구현할 수 있다.서비스 로직 작성
package com.example.stock.service; import com.example.stock.domain.Stock; import com.example.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class PessimisticLockStockService { private final StockRepository stockRepository; @Transactional public void decrease(Long id, Long quantity) { // Stock 조회 Stock stock = stockRepository.findByIdWithPessimisticLock(id) .orElseThrow(() -> new RuntimeException("Stock을 조회할 수 없습니다.")); // 재고 감소 stock.decrease(quantity); // 갱신된 값 저장 stockRepository.saveAndFlush(stock); } }
테스트
package com.example.stock.service; import com.example.stock.domain.Stock; import com.example.stock.repository.StockRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class PessimisticLockServiceTest { @Autowired private PessimisticLockStockService pessimisticLockStockService; @Autowired private StockRepository stockRepository; @BeforeEach void before() { stockRepository.saveAndFlush(Stock.of(1L, 100L)); } @AfterEach void after() { stockRepository.deleteAll(); } @Test void 동시에_100개의_요청() throws InterruptedException { // when int threadCount = 100; ExecutorService executorService = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); for (int i=0; i<threadCount; i++) { executorService.submit(() -> { try { pessimisticLockStockService.decrease(1L, 1L); } finally { latch.countDown(); } }); } latch.await(); // then Stock stock = stockRepository.findById(1L).get(); assertEquals(0, stock.getQuantity()); } }
동시에 100개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.
쿼리문의
for update
→ lock을 걸고 데이터를 가져오는 부분장단점
- 장점
- 충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋을 수 있다.
- Lock을 통해 업데이트를 제어하기 때문에 데이터 정합성이 보장된다.
- 단점
- 별도의 Lock을 잡기 때문에 성능 감소가 있을 수 있다.
- 장점
Optimistic Lock
Stock에 version 컬럼 추가
package com.example.stock.domain; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @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 private Stock(Long productId, Long quantity) { this.productId = productId; this.quantity = quantity; } public static Stock of(Long productId, Long quantity) { return builder() .productId(productId) .quantity(quantity) .build(); } public void decrease(Long quantity) { if (this.quantity - quantity < 0) { throw new RuntimeException("재고는 0개 이상이어야 합니다."); } this.quantity -= quantity; } }
repository → Optimistic Lock을 활용하여 데이터를 가져오는 메서드 작성
@Lock(LockModeType.OPTIMISTIC) @Query("select s from Stock s where s.id = :id") Optional<Stock> findByIdWithOptimisticLock(Long id);
서비스 로직 작성
package com.example.stock.service; import com.example.stock.domain.Stock; import com.example.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class OptimisticLockStockService { private final StockRepository stockRepository; @Transactional public void decrease(Long id, Long quantity) { // Stock 조회 Stock stock = stockRepository.findByIdWithOptimisticLock(id) .orElseThrow(() -> new RuntimeException("Stock을 조회할 수 없습니다.")); // 재고 감소 stock.decrease(quantity); // 갱신된 값 저장 stockRepository.saveAndFlush(stock); } }
facade → 업데이트 실패 시 재시도하는 로직 작성
package com.example.stock.facade; import com.example.stock.service.OptimisticLockStockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class OptimisticLockStockFacade { private final 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); } } } }
Optimistic Lock → 실패했을 때 재시도
테스트
package com.example.stock.facade; import com.example.stock.domain.Stock; import com.example.stock.repository.StockRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class OptimisticLockStockFacadeTest { @Autowired private OptimisticLockStockFacade optimisticLockStockFacade; @Autowired private StockRepository stockRepository; @BeforeEach void before() { stockRepository.saveAndFlush(Stock.of(1L, 100L)); } @AfterEach void after() { stockRepository.deleteAll(); } @Test void 동시에_100개의_요청() throws InterruptedException { // when int threadCount = 100; ExecutorService executorService = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); for (int i=0; i<threadCount; i++) { executorService.submit(() -> { try { try { optimisticLockStockFacade.decrease(1L, 1L); } catch (InterruptedException e) { throw new RuntimeException(e); } } finally { latch.countDown(); } }); } latch.await(); // then Stock stock = stockRepository.findById(1L).get(); assertEquals(0, stock.getQuantity()); } }
동시에 100개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.
장단점
- 장점
- 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능이 좋다.
- 단점
- 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 하는 번거로움이 있다.
- 장점
Named Lock
repository → lock을 얻고 해제하는 로직 작성
package com.example.stock.repository; import com.example.stock.domain.Stock; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; 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); }
facade → 실제 로직 전후로 lock 획득 및 해제하는 로직 작성
package com.example.stock.facade; import com.example.stock.repository.LockRepository; import com.example.stock.service.StockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor public class NamedLockStockFacade { private final LockRepository lockRepository; private final StockService stockService; @Transactional public void decrease(Long id, Long quantity) { try { lockRepository.getLock(id.toString()); // lock 획득 stockService.decrease(id, quantity); // 재고 감소 } finally { lockRepository.releaseLock(id.toString()); // lock 해제 } } }
StockService → 부모의 트랜잭션과 별도로 수행해야 하기 때문에 propagation 수정
@Transactional(propagation = Propagation.REQUIRES_NEW) // 부모의 트랜잭션과 별도로 수행 public void decrease(Long id, Long quantity) { ... }
같은 datasource를 사용하기 때문에 커넥션풀 사이즈 늘려주기
spring: jpa: hibernate: ddl-auto: create show-sql: true datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${DATABASE_URL} username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD} hikari: maximum-pool-size: 40
테스트
package com.example.stock.facade; import com.example.stock.domain.Stock; import com.example.stock.repository.StockRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class NamedLockStockFacadeTest { @Autowired private NamedLockStockFacade namedLockStockFacade; @Autowired private StockRepository stockRepository; @BeforeEach void before() { stockRepository.saveAndFlush(Stock.of(1L, 100L)); } @AfterEach void after() { stockRepository.deleteAll(); } @Test void 동시에_100개의_요청() throws InterruptedException { // when 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(); // then Stock stock = stockRepository.findById(1L).get(); assertEquals(0, stock.getQuantity()); } }
동시에 100개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.
장단점
- 장점
- 주로 분산 락을 구현할 때 사용한다. 또한, 데이터 삽입 시 정합성을 맞춰야 하는 경우에도 많이 사용한다.
- PessimisticLock과 달리 손쉽게 time-out 을 구현할 수 있다.
- 단점
- 트랜잭션 종료 시 락을 해제해줘야 한다.
- 구현 방법이 복잡하다.
- 장점
'* > Spring' 카테고리의 다른 글
[Spring Boot] 동시성 제어 (4) - Redis (1) | 2024.11.12 |
---|---|
[Spring Boot] 동시성 제어 (2) - Synchronized (0) | 2024.11.10 |
[Spring Boot] 동시성 제어 (1) - 동시성 문제, 재고 감소 시스템 구현 및 테스트 (1) | 2024.11.09 |
[JPA] 엔티티 equals 메서드 구현 시 주의할 점 (0) | 2024.11.08 |
[JPA] Join Fetch 시 MultipleBagFetchException (0) | 2024.11.07 |