동시성 문제?
- 여러 스레드가 동시에 같은 리소스에 접근할 때 발생할 수 있는 문제를 의미한다.
Spring Boot(Java) 환경 에서 발생하는 이유
- Java는 멀티 스레드 환경이므로 여러개의 요청이 재고의 값을 변경하려고 할 때, 정상적으로 변경되지 않는 문제가 발생한다.
@SpringBootTest
@ActiveProfiles(profiles = "test")
class ProductStockServiceTest {
@Autowired
private ProductStockService stockService;
@Autowired
private ProductStockRepository stockRepository;
@Autowired
private ProductRepository productRepository;
@BeforeEach
public void createStock(){
Product product = productRepository.save(Product.builder()
.name("product 1")
.price(1000)
.description("product description")
.build());
stockRepository.saveAndFlush(ProductStock.builder()
.product(product)
.stock(100)
.build());
}
@AfterEach
public void deleteAll(){
stockRepository.deleteAll();
}
@Test
void decrementStock() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
ProductStockDecrementServiceRequest request = new ProductStockDecrementServiceRequest(1L, 1);
for (int i = 0; i < threadCount; i++) {
executorService.submit(()->{
try{
stockService.decrementStock(request);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
ProductStock stock = stockRepository.findById(1L).orElseThrow();
Assertions.assertThat(stock.getStock()).isEqualTo(0);
}
}
100개의 재고를 100개의 스레드에서 1번씩 감소시키는 테스트 코드이다.
일반적으로 0개가 될 것이라고 예상하지만 실제 테스트 결과는 아래와 같다.

결론
선택한 해결 방법 : Redis(Redisson)를 활용한 분산락(Distribute Lock)
선택한 이유
1. 현재 프로젝트에 Redis를 적용하고 있으므로 추가적인 비용을 들이지 않고 적용할 수 있다.
2. Redisson은 분산 환경에서 동작한다.
3. Redisson은 DB Lock에 비해 DB 부하 없이 확장이 용이하다.
4. Redisson은 Lettuce와 다르게 락 해제 등의 작업을 자동으로 처리해주는 기능이 존재한다.
해결 방안
Syncronized
Syncronized
개념 : Synchronized는 Java에서 제공하는 동기화 메커니즘으로, 여러 스레드가 동시에 같은 리소스에 접근하는 것을 방지한다. 메소드나 블록에 synchronized 키워드를 사용하면, 한 번에 하나의 스레드만 해당 코드를 실행할 수 있다.
Syncronized Service 구현 코드
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public synchronized void decrease(Long id, int quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
}
문제점 : Syncronized는 단일 JVM 내에서 동작하므로 서버가 분산 배포된 환경에서는 동시성 문제를 해결할 수 없다.
또한 한 번에 하나의 스레드만 접근할 수 있어, 다른 스레드들은 대기해야 하며 이는 전체적인 시스템 성능을 저하시킬 수 있다.
DB Lock
Pessimistic Lock (비관적 락)
개념 : 실제 데이터에 lock을 걸어 정합성을 맞춘다.
exclusive lock을 걸게 되면 다른 트랜잭션에서 lock이 해제되기 전까지 데이터를 가져갈 수 없게 된다.
구현한 코드 내용은 아래와 같다.
Product Stock Service
@Service
@RequiredArgsConstructor
public class ProductStockService {
private final ProductStockRepository productStockRepository;
@Transactional
public void stockDecrementPessimisticLock(ProductStockDecrementServiceRequest request){
ProductStock productStock = productStockRepository.findByIdWithPessimisticLock(request.productId());
productStock.decrement(request.quantity());
productStockRepository.save(productStock);
}
}
Product Stock Repository
@Repository
public interface ProductStockRepository extends JpaRepository<ProductStock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT ps FROM ProductStock ps WHERE ps.id = :id")
ProductStock findByIdWithPessimisticLock(@Param("id") Long id);
}

그렇다면 비관적 락을 동시성 문제에 무작정 사용해도 문제가 없을까?
Deadlock이 발생하는 조건은 아래와 같다.
1. 순환적 대기 조건 (Circular Wait Condition)
: 여러 트랜잭션이 자원을 잠금상태로 만들면서 서로 다른 자원을 기다릴 때 발생한다.
(예: 트랜잭션 A에서 자원 1을 잠그고 자원 2를 기다린다. 동시에 트랜잭션 B가 자원 2를 잠그고 자원 1을 기다린다.)
2. 상호 배제 조건 (Mutual Exclusion Condition)
: 자원은 한 번에한 프로세스만 사용할 수 있어야 한다. 하나의 트랜잭션이 자원을 사용하고 있는 동안 다른 트랜잭션은 해당 자원에 접근 할 수 없어서 대기가 발생한다.
3. 점유와 대기 조건 (Hold and Wait Condition)
: 트랜잭션이 이미 적어도 하나의 자원을 점유하며 추가 자원을 기다리는 상황
4. 비선점 조건 (No Preemption Condition)
: 이미 잠긴 자원은 그 자원을 사용하는 트랜잭션이 해제할 때까지 강제로 빼앗을 수 없다.
Pessimistic lock은 자원을 점유하는 동안 다른 트랜잭션의 접근을 차단하기 때문에 위 조건들이 쉽게 충족될 수 있다. 따라서 추가적으로 데드락을 회피하는 로직을 구현해야한다.
Optimistic Lock (낙관적 락)
개념 : 실제로 Lock을 이용하지 않고 Version을 이용하여 정합성을 맞추는 방법이다.
먼저 데이터를 읽은 후 update를 수행할 때 현재 내가 읽은 Version이 맞는지 확인 후 Update를 진행.
읽은 Version에서 변동 사항이 생겼다면 application에서 다시 읽은 후 작업을 수행한다.
Entity에 Version 추가
@Entity
public class ProductStock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
|
@Version
private Long version;
}
Facade package를 만들고 재고 감소 로직을 구현
@Component
@RequiredArgsConstructor
public class OptimisticLockProductStockFacade {
private final ProductStockService productStockService;
public void decrease(ProductStockDecrementServiceRequest request) throws InterruptedException {
while (true){
try {
productStockService.stockDecrementOptimisticLock(request);
break;
} catch (Exception e){
Thread.sleep(50);
}
}
}
}
Repository
@Repository
public interface ProductStockRepository extends JpaRepository<ProductStock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT ps FROM ProductStock ps WHERE ps.id = :id")
ProductStock findByIdWithOptimisticLock(@Param("id") Long id);
}

예시 코드는 version이 적절할 때 까지 시도하므로 실제 구현에서는 성능을 고려하여 로직을 구현해야 하며
이러한 특성으로 인해 읽기 작업이 쓰기 작업보다 많은 경우에 사용하기 적절하다.
재시도 로직에서 구현을 잘못하면 데드락이 발생할 수 있다.
Named Lock (네임드 락)
개념 : 이름을 가진 metadata lock. 이름을 가진 lock을 획득 후 해제할 때까지 다른 세션은 이 lock을 획득할 수 없게 한다. 주의할 점은 transaction이 종료될 때 lock이 자동으로 해제되지 않는다. 따라서 별도의 로직 또는 명령어로 해제를 수행하거나 선점시간이 끝나야 해제된다.
*만약 H2로 테스트를 진행하고 있다면 NamedLock은 지원하지 않기 때문에 별도 구현을 하거나 MySQL을 사용
NamedLock Interface
public interface NamedLockRepository {
void getLock(String productId);
void releaseLock(String productId);
}
NamedLock 구현체
@Repository
@RequiredArgsConstructor
public class LockRepository implements NamedLockRepository {
private final EntityManager entityManager;
@Override
public void getLock(String productId) {
entityManager.createNativeQuery("SELECT GET_LOCK(?, 3000)")
.setParameter(1, productId)
.getSingleResult();
}
@Override
public void releaseLock(String productId) {
entityManager.createNativeQuery("SELECT RELEASE_LOCK(?)")
.setParameter(1, productId)
.getSingleResult();
}
}
NamedLock Facade
@Component
@RequiredArgsConstructor
public class NamedLockProductStockFacade {
private final LockRepository lockRepository;
private final ProductStockService stockService;
@Transactional
public void decrease(final Long id, final int quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrementStock(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
Service
@Service
@RequiredArgsConstructor
public class ProductStockService {
private final ProductStockRepository productStockRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrementStock(Long id, int quantity){
ProductStock productStock = productStockRepository.findByProductId(id);
productStock.decrement(quantity);
productStockRepository.save(productStock);
}
}

NamedLock을 적용했을 때 문제점은 무엇이 있을까?
Lock 해제를 보장하지 않는 코드는 영구적인 DeadLock을 유발한다.
많은 요청이 동시에 들어온다면 Lock 대기시간이 길어지며 응답시간이 늘어나며 성능 저하를 초래한다.
Rediss Lock
Lettuce
개념 : Redis를 사용하여 락을 관리한다. 락을 획득하면 다른 프로세스의 접근을 차단한다.
Redis Repository
@Component
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key){
return redisTemplate
.opsForValue()
.setIfAbsent(generated(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key){
return redisTemplate.delete(generated(key));
}
private String generated(Long key) {
return key.toString();
}
}
Lettuce Facade
@Component
@RequiredArgsConstructor
public class LettuceLockProductStockFacade {
private final RedisLockRepository redisLockRepository;
private final ProductStockService stockService;
public void decrease(Long key, int quantity) throws InterruptedException{
while (Boolean.FALSE.equals(redisLockRepository.lock(key))){
Thread.sleep(10);
}
stockService.decrease(key, quantity);
redisLockRepository.unlock(key);
}
}

Lettuce를 구현하였을 때 문제점?
락 획득 시도가 실패할 경우 적절한 재시도 메커니즘이 필요하다. 락을 얻기 위해 재시도를 끝없이 반복한다면 시스템 리소스를 낭비할 수 있다.
Redission
개념 : 분산 락(동시에 하나의 프로세스만 리소스에 접근할 수 있도록 보장) 구현이 가능하며, Lock 을 자동으로 해제할 수 있고 동일 스레드에서 중첩 락 획득이 가능하다.
Redisson Config
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
return Redisson.create(config);
}
}
Redisson Facade
@Component
@RequiredArgsConstructor
public class RedissonLockProductStockFacade {
private final RedissonClient redissonClient;
private final ProductStockService productStockService;
public void decrease(Long productId, int quantity) {
RLock lock = redissonClient.getLock("product_stock_" + productId);
try {
boolean isLocked = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!isLocked) {
throw new RuntimeException("Failed to acquire lock for product " + productId);
}
productStockService.decrease(productId, quantity);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock acquisition was interrupted", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

참고 링크
- Sync, DB Lock 참조
https://everydayyy.tistory.com/167
- Deadlock 발생 조건 참조
https://velog.io/@dl_edge/%EC%9A%B4%EC%98%81%EC%B2%B4%EC%A0%9C-%EA%B5%90%EC%B0%A9%EC%83%81%ED%83%9C-4%EA%B0%80%EC%A7%80-%EC%A1%B0%EA%B1%B4
'Spring Boot' 카테고리의 다른 글
| Spring Gateway 적용 (1) | 2024.07.22 |
|---|