-
인프런) 재고시스템으로 알아보는 동시성 이슈 해결 (1)JAVA 2024. 6. 4. 23:10
동시성 이슈 해결을 위한 인프런 강의를 듣고 실습해보았다.
학습 환경 구성
먼저 실습 환경을 구성한다.
실습은 mysql, spring web, jpa를 사용한다.
docker로 mysql을 띄우고 실습용 데이터 베이스를 생성한다.
# mysql 설치 및 실행 % docker pull mysql % docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=1234 --name mysql mysql # 실행 확인 % docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a96a8913042e mysql "docker-entrypoint.s…" 4 seconds ago Up 3 seconds 0.0.0.0:3306->3306/tcp, 33060/tcp mysql # mysql 접근 % docker exec -it mysql bash bash-4.4# mysql -u root -p Enter password: Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 10 Server version: 8.3.0 MySQL Community Server - GPL ## 만약 이미 mysql 이 실행중이어서 접근이 안된다면 다음과 같이 기존 프로세스 종료 후 실행 % ps aux | grep mysqld user 820 0.1 0.1 34844724 11292 ?? S 5:56AM 0:04.22 /usr/local/=... user 668 0.0 0.0 34145332 500 ?? S 5:56AM 0:00.08 /bin/sh /usr/... user 7017 0.0 0.0 34138768 728 s000 S+ 6:15AM 0:00.00 grep mysqld % lsof -i:3306 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME mysqld 820 user 20u IPv4 0xc1dd28647d3b6be5 0t0 TCP localhost:mysql (LISTEN) com.docke 6777 user 66u IPv6 0xc1dd286942e15aa5 0t0 TCP *:mysql (LISTEN) % killall -9 mysqld # database 생성 mysql> create database stock_example; Query OK, 1 row affected (0.01 sec) mysql> use stock_example; Database changed mysql> show databases; +--------------------+ | Database | +--------------------+ |stock_example | | sys | +--------------------+ 5 rows in set (0.00 sec)
프로젝트의 db 연결 정보를 넣어준다.
application.yaml
spring: jpa: hibernate: ddl-auto: create show-sql: true datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/stock_example username: root password: 1234 logging: level: org: hibernate: SQL: DEBUG type: descriptor: sql: BasicBinder: TRACE
💡 참고)
여기서 Access denied for user 'root'@'localhost'오류가 발생했는데, 이전에 3306 포트로 생성한 DB가 있어 volume 꼬여서 발생한 문제였다. 다음과 같이 포트를 다르게 열어서 연결해서 문제를 해결했다.
# docker 포트를 다르게 설정해서 열어주기 % docker run -d -p 33306:3306 -e MYSQL_ROOT_PASSWORD=root --name mysql mysql
## application.yaml spring: jpa: hibernate: ddl-auto: create show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:33306/stock_example username: root password: root logging: level: org: hibernate: SQL: DEBUG type: descriptor: sql: BasicBinder: TRACE
테스트를 위한 엔티티, 서비스 repository를 구성해준다.
@Entity public class StockEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long productId; private Long quantity; public StockEntity() { } public StockEntity(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 RuntimeException("재고를 0개 미만으로 설정할 수 없습니다."); } this.quantity -= quantity; } }
@Repository public interface StockRepository extends JpaRepository<StockEntity, Long> { }
@Service public class StockService { private final StockRepository stockRepository; public StockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional public void decrease(Long id, Long quantity) { StockEntity stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); } }
재고 감소 테스트 코드를 작성해준다.
@SpringBootTest class StockServiceTest { @Autowired private StockService stockService; @Autowired private StockRepository stockRepository; @BeforeEach public void setUp() { stockRepository.saveAndFlush(new StockEntity(1L, 100L)); } @AfterEach public void clear() { stockRepository.deleteAll(); } @Test public void 재고감소() { Long stockId = 1L; stockService.decrease(stockId, 1L); StockEntity stock = stockRepository.findById(stockId).orElseThrow(); //100 -1 = 1 assertEquals(99, stock.getQuantity()); } }
하나의 요청에 대해 처리한다면, 테스트 코드가 성공하는 것을 확인할 수 있다.
하지만, 동시에 여러개의 요청이 들어온다면 어떻게 될까?
@Test public void 동시에_100개의_요청() throws InterruptedException { Long stockId = 1L; int threadCount = 100; // 비동기 적으로 요청을 실행할 수 있는 thread service ExecutorService service = Executors.newFixedThreadPool(threadCount); // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능 CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i=0; i< threadCount; i++) { service.submit(() -> { try { stockService.decrease(stockId, 1L); } finally { countDownLatch.countDown(); } }); } countDownLatch.await(); StockEntity stock = stockRepository.findById(stockId).orElseThrow(); assertEquals(0, stock.getQuantity()); }
race condition이 일어나기 때문에 최소 재고는 0개가 아니다. race condition이란 둘이상의 스레드가 공유자원에 접근할 수 있고, 동시에 변경하고자 할때 발생하는 문제다. 이를 해결하기위해서는 하나의 스레드만 데이터에 변경액세스 할 수 있도록 제한해야 한다.
그렇다면, 이러한 상황을 해결하기 위해선 어떤 방법이 있을까?
1. synchronized
synchronized는 자바에서 지원하는 방법으로 하나의 스레드에서만 해당 메서드에 접근하도록 제한함으로서 동시성 이슈를 해결한다. synchronized를 메서드 선언부에 선언해준다.
package com.yunhalee.concurrency.service; import com.yunhalee.concurrency.domain.StockEntity; import com.yunhalee.concurrency.repository.StockRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class PessimisticStockService { private final StockRepository stockRepository; public PessimisticStockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional public synchronized void decrease(Long id, Long quantity) { StockEntity stock = stockRepository.findOneByIdWithPessimisticLock(id); stock.decrease(quantity); stockRepository.save(stock); } }
하지만 테스트 코드를 실행하면, 테스트 케이스가 실패하게 된다.
이는, transactional 어노테이션의 동작 방식 (AOP) 때문이다. transactional 어노테이션이 붙게 되면, 다음 예시 처럼 해당 메서드를 매핑한 새로운 클래스를 생성하게 하여 트랜잭션 종료시점에 데이터 베이스에 update 커밋을 날린다.
이때, synchronized 메서드가 종료된 이후에 데이터베이스에 commit을 날리게 되어 결국 조회 시점과 데이터 업데이트 시점의 데이터 불일치를 막지 못하게 되어 동일한 동시성 이슈가 발생하게 된다.
package com.yunhalee.concurrency.transaction; import com.yunhalee.concurrency.service.StockService; public class TransactionStockService { private StockService stockService; public TransactionStockService(StockService stockService) { this.stockService = stockService; } public void decrease(Long id, Long quantity) { startTransaction(); stockService.decrease(id, quantity); endTransaction(); } private void startTransaction() { System.out.println("start transaction"); } private void endTransaction() { System.out.println("end transaction"); } }
실제로 transcational 어노테이션을 주석처리하고 실행하면, 테스트 케이스는 성공하게된다.
java synchronized 는 하나의(각) 프로세스 안에서만 보장이된다. 서버가 1대일때는 되는듯싶으나 여러대의 서버가 동시에 데이터베이스에 접근하게 되면, 사용하지 않았을때와 동일한 문제가 발생된다.
인스턴스단위로 thread-safe 이 보장이 되고, 여러서버가 된다면 여러개의 인스턴스가 있는것과 동일하기 때문이다.
실제로는 두대 이상의 서버를 실무에서 사용하기 때문에 해당 방법으로는 동시성 이슈를 해결하기 어렵다.
2. Pessimistic Lock
이번부터는 데이터베이스를 이용하여 동시성 이슈를 해결하는 방법을 알아보자. Mysql을 이용해서 동시성 이슈를 해결하는 방법에는 세가지가 있다.
- Pessimistic Lock
- Optimistic Lock
- Named Lock
먼저 Pessimistic Lock은 실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법으로 exclusive lock 을 걸게되며 다른 트랜잭션에서는 lock 이 해제되기전에 데이터를 가져갈 수 없도록 제한한다. 단, 데드락이 걸릴 수 있기때문에 주의하여 사용해야 한다.
레포지토리와 이를 활용하는 서비스를 구현해보자.
lock 어노테이션을 이용해서 손쉽게 락을 구현할 수 있다.
package com.yunhalee.concurrency.repository; import com.yunhalee.concurrency.domain.StockEntity; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface StockRepository extends JpaRepository<StockEntity, Long> { // spring data jpa에서는 어노테이션으로 락을 구현할 수 있다. @Lock(LockModeType.PESSIMISTIC_WRITE) // named query를 작성해준다. @Query("select s from StockEntity s where s.id = :id") StockEntity findOneByIdWithPessimisticLock(Long id); }
package com.yunhalee.concurrency.service; import com.yunhalee.concurrency.domain.StockEntity; import com.yunhalee.concurrency.repository.StockRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class PessimisticStockService { private final StockRepository stockRepository; public PessimisticStockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional public void decrease(Long id, Long quantity) { StockEntity stock = stockRepository.findOneByIdWithPessimisticLock(id); stock.decrease(quantity); stockRepository.save(stock); } }
테스트 코드를 실행하면, 성공하는 것을 확인할 수 있다.
package com.yunhalee.concurrency.service; import com.yunhalee.concurrency.domain.StockEntity; import com.yunhalee.concurrency.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.assertEquals; @SpringBootTest class PessimisticStockServiceTest { @Autowired private PessimisticStockService stockService; @Autowired private StockRepository stockRepository; @BeforeEach public void setUp() { stockRepository.saveAndFlush(new StockEntity(1L, 100L)); } @AfterEach public void clear() { stockRepository.deleteAll(); } @Test public void 동시에_100개의_요청() throws InterruptedException { Long stockId = 1L; int threadCount = 100; // 비동기 적으로 요청을 실행할 수 있는 thread service ExecutorService service = Executors.newFixedThreadPool(threadCount); // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능 CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i=0; i< threadCount; i++) { service.submit(() -> { try { stockService.decrease(stockId, 1L); } finally { countDownLatch.countDown(); } }); } countDownLatch.await(); StockEntity stock = stockRepository.findById(stockId).orElseThrow(); assertEquals(0, stock.getQuantity()); } }
로그를 보면 for update 부분을 확인할 수 있는데, 이 부분이 pessimistic lock을 거는 부분이다.
Pessimistic lock의 경우 충돌이 빈번하게 발생한다면, optimistic lock보다 성능이 좋으며, lock을 통해서 update를 수행하기 때문에 데이터 정합성이 보장되지만, 데이터베이스의 별도 lock을 잡기 때문에 성능 감소가 있을 수 있다.
3. Optimistic Lock
Optimistic Lock은 실제로 락을 이용하지 않고 데이터 베이스에 저장을 할 때, 버전을 이용해서 데이터 정합성 맞추는 방법이다. 먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트한다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후에 작업을 수행해야 한다.
먼저 옵티미스틱 락을 사용하기 위해서 먼저 버전을 선언해주어야 한다.
💡 참고)
여기서 강의는 javax.persistence를 임포트하라고 해주셨는데, 존재하지 않아 찾아보니 스프링 버전 업그레이드에 의해 javax -> jakarta.persistence로 변경되었음을 알게되었다.
- 참고 문서 : https://mkyong.com/spring-boot/spring-boot-package-javax-persistence-does-not-exist/버전을 명시한 엔티티와 락을 사용하는 레파지토리를 만든다.
import jakarta.persistence.*; @Entity public class StockEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long productId; private Long quantity; @Version private Long version; public StockEntity() { } public StockEntity(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 RuntimeException("재고를 0개 미만으로 설정할 수 없습니다."); } this.quantity -= quantity; } }
package com.yunhalee.concurrency.repository; import com.yunhalee.concurrency.domain.StockEntity; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface StockRepository extends JpaRepository<StockEntity, Long> { @Lock(LockModeType.OPTIMISTIC) // named query를 작성해준다. @Query("select s from StockEntity s where s.id = :id") StockEntity findOneByIdWithOptimisticLock(Long id); }
이를 활용하는 서비스를 만든다.
import com.yunhalee.stock.domain.StockEntity; import com.yunhalee.stock.repository.StockRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OptimisticStockService { private final StockRepository stockRepository; public OptimisticStockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional public void decrease(Long id, Long quantity) { StockEntity stock = stockRepository.findOneByIdWithOptimisticLock(id); stock.decrease(quantity); stockRepository.save(stock); } }
optimistic lock은 실패했을 때 재시도를 해야하기 때문에 facade 패키지를 만들고 재시도 로직을 구현 해준다.
package com.yunhalee.stock.facade; import com.yunhalee.stock.service.OptimisticStockService; import org.springframework.stereotype.Component; @Component public class OptimisticLockStockFacade { private final OptimisticStockService optimisticStockService; public OptimisticLockStockFacade(OptimisticStockService optimisticStockService) { this.optimisticStockService = optimisticStockService; } public void decrease(Long id, Long quantity) throws InterruptedException { while (true) { try { optimisticStockService.decrease(id, quantity); break; } catch (Exception e) { Thread.sleep(50); } } } }
테스트 코드로 테스트 해보자.
import com.yunhalee.stock.domain.StockEntity; import com.yunhalee.stock.facade.OptimisticLockStockFacade; import com.yunhalee.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.assertEquals; @SpringBootTest class OptimisticStockServiceTest { @Autowired private OptimisticLockStockFacade optimisticLockStockFacade; @Autowired private StockRepository stockRepository; @BeforeEach public void setUp() { stockRepository.saveAndFlush(new StockEntity(1L, 100L)); } @AfterEach public void clear() { stockRepository.deleteAll(); } @Test public void 동시에_100개의_요청() throws InterruptedException { Long stockId = 1L; int threadCount = 100; // 비동기 적으로 요청을 실행할 수 있는 thread service ExecutorService service = Executors.newFixedThreadPool(threadCount); // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능 CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i=0; i< threadCount; i++) { service.submit(() -> { try { optimisticLockStockFacade.decrease(stockId, 1L); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { countDownLatch.countDown(); } }); } countDownLatch.await(); StockEntity stock = stockRepository.findById(stockId).orElseThrow(); assertEquals(0, stock.getQuantity()); } }
테스트가 재시도를 하느라 이전보다 오래걸리긴 했지만, 성공하는 것을 확인할 수 있다. 다음과 같이 debug를 확인하면, 쿼리를 실행하는 시점에 where 절에 version을 넣고 version 부분을 확인하고 업데이트 하는 것을 볼 수 있다.
2024-04-05T07:02:09.045+09:00 DEBUG 1731 --- [pool-2-thread-4] org.hibernate.SQL : select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? Hibernate: select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? 2024-04-05T07:02:09.059+09:00 DEBUG 1731 --- [pool-2-thread-7] org.hibernate.SQL : select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? Hibernate: select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? 2024-04-05T07:02:09.066+09:00 DEBUG 1731 --- [ool-2-thread-10] org.hibernate.SQL : select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? Hibernate: select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? 2024-04-05T07:02:09.066+09:00 DEBUG 1731 --- [pool-2-thread-4] org.hibernate.SQL : update stock_entity set product_id=?,quantity=?,version=? where id=? and version=? 2024-04-05T07:02:09.066+09:00 DEBUG 1731 --- [pool-2-thread-7] org.hibernate.SQL : update stock_entity set product_id=?,quantity=?,version=? where id=? and version=? Hibernate: update stock_entity set product_id=?,quantity=?,version=? where id=? and version=? Hibernate: update stock_entity set product_id=?,quantity=?,version=? where id=? and version=?
Optimistic lock의 장점으로는 별도의 락을 잡지 않아, pessimistic 락 보다는 성능상 이점이 있지만, 업데이트가 실패했을 때 재시도하는 로직을 개발자가 직접 만들어야 한다는 단점이 있다.
충돌이 빈번하게 일어난다면 pessimistic 락이 더 유용하고, 그렇지 않다면 optimistic lock이 좋다.
4. Named Lock (metadata locking)
네임드 락은 이름을 가진 메타데이터를 사용하는 방법으로 이름을 가진 lock을 획득한 후 해제할 때까지 다른 세션을 락을 획득할 수 없도록 한다. 단, 트랜잭션이 종료될 떄 자동으로 락이해제되지 않기 떄문에 별도의 명령어로 락을 해제해주거나, 선점 시간이 끝나야 해제하게 된다. Pessimistic lock과 비슷하지만, 이와 다르게 row나 테이블 단위가 아니라 metadata 단위로 락을 건다는 차이가 있다.
mysql에서는 getLock 명령어를 통해서 named lock을 획득할 수 있고, release 명령어를 통해 락을 해제할 수 있다.
현재는 같은 데이터베이스를쓰지만, 네임드 락이 같은 데이터 소스를 사용하면 커넥션 풀이 부족해지는 현상이 발생할 수 있다.
실무에서는 데이터 소스를 분리해서 써야한다.
현재는 Stock 엔티티를 사용하지만 실무에서는 별도의 Jdbc를 사용하거나 해야한다.
@Repository public interface LockRepository extends JpaRepository<StockEntity, 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); }
락을 획득하고 해제하는 파사드 클래스를 만들자.
import com.yunhalee.stock.repository.LockRepository; import com.yunhalee.stock.service.OptimisticStockService; import com.yunhalee.stock.service.StockService; import org.springframework.stereotype.Component; @Component public class NamedLockStockFacade { private final LockRepository lockRepository; private final StockService stockService; public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) { this.lockRepository = lockRepository; this.stockService = stockService; } public void decrease(Long id, Long quantity) { try { lockRepository.getLock(id.toString()); stockService.decrease(id, quantity); } finally { lockRepository.releaseLock(id.toString()); } } }
그리고 네임드 락에서는 트랜잭션이 다르게 실행되어야 하기때문에 stock service의 메소드에 트랜잭션 프로파게이션 설정을 바꿔준다.
import com.yunhalee.stock.domain.StockEntity; import com.yunhalee.stock.repository.StockRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service public class StockService { private final StockRepository stockRepository; public StockService(StockRepository stockRepository) { this.stockRepository = stockRepository; } // @Transactional @Transactional(propagation = Propagation.REQUIRES_NEW) public synchronized void decrease(Long id, Long quantity) { StockEntity stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); } }
같은 데이터 소스를 사용하기 때문에 커넥션 풀을 조절해준다.
spring: jpa: hibernate: ddl-auto: create show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:33306/stock_example username: root password: root hikari: maximum-pool-size: 40 logging: level: org: hibernate: SQL: DEBUG type: descriptor: sql: BasicBinder: TRACE
테스트 케이스를 작성하고 실행해보자.
import com.yunhalee.stock.domain.StockEntity; import com.yunhalee.stock.facade.NamedLockStockFacade; import com.yunhalee.stock.facade.OptimisticLockStockFacade; import com.yunhalee.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.assertEquals; @SpringBootTest class NamedLockStockFacadeTest { @Autowired private NamedLockStockFacade namedLockStockFacade; @Autowired private StockRepository stockRepository; @BeforeEach public void setUp() { stockRepository.saveAndFlush(new StockEntity(1L, 100L)); } @AfterEach public void clear() { stockRepository.deleteAll(); } @Test public void 동시에_100개의_요청() throws InterruptedException { Long stockId = 1L; int threadCount = 100; // 비동기 적으로 요청을 실행할 수 있는 thread service ExecutorService service = Executors.newFixedThreadPool(threadCount); // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능 CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i=0; i< threadCount; i++) { service.submit(() -> { try { namedLockStockFacade.decrease(stockId, 1L); } finally { countDownLatch.countDown(); } }); } countDownLatch.await(); StockEntity stock = stockRepository.findById(stockId).orElseThrow(); assertEquals(0, stock.getQuantity()); } }
debug 로그를 확인해보면 다음과 같이 락을 획득하고 쿼리 실행후 락을 해재하는 것을 확인할 수 있다.
2024-04-06T10:40:07.783+09:00 DEBUG 1489 --- [ool-2-thread-17] org.hibernate.SQL : select get_lock(?, 3000) Hibernate: select get_lock(?, 3000) Hibernate: select get_lock(?, 3000) 2024-04-06T10:40:07.999+09:00 DEBUG 1489 --- [ool-2-thread-19] org.hibernate.SQL : select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? Hibernate: select se1_0.id,se1_0.product_id,se1_0.quantity,se1_0.version from stock_entity se1_0 where se1_0.id=? 2024-04-06T10:40:08.027+09:00 DEBUG 1489 --- [ool-2-thread-19] org.hibernate.SQL : update stock_entity set product_id=?,quantity=?,version=? where id=? and version=? Hibernate: update stock_entity set product_id=?,quantity=?,version=? where id=? and version=? 2024-04-06T10:40:08.046+09:00 DEBUG 1489 --- [ool-2-thread-19] org.hibernate.SQL : select release_lock(?) Hibernate: select release_lock(?)
네임드락은 주로 분산락을 구현할때 사용된다.
Pessimistic Lock 은 주로 타임아웃을 구현하기 힘들지만, 네임드 락은 타임아웃을 손쉽게 구현할 수 있다.
데이터 삽입시에 데이터 정합성을 맞춰야 하는 경우에도 사용할 수 있다.
하지만, 트랜잭션 종료시에 락해제, 트랜잭션 관리를잘 해야해서 주의해서 사용해야한다.
'JAVA' 카테고리의 다른 글
인프런) 실습으로 배우는 선착순 이벤트 시스템 (1) 2024.06.05 인프런) 재고시스템으로 알아보는 동시성 이슈 해결 (2) (1) 2024.06.05 가비지 컬렉션 GC(Garbage Collection) (0) 2022.08.05 Enum Type ( 열거형 ) (0) 2021.10.26