*/Spring

[Spring Boot] 동시성 제어 (3) - Database

sssbin 2024. 11. 11. 19:38

** 본 글은 인프런 <재고시스템으로 알아보는 동시성이슈 해결방법> 을 수강한 후 작성한 글입니다.

동시성 문제 해결 방법 2. Database

MySQL 활용

  1. Pessimistic Lock
    • 실제로 데이터에 Lock을 걸어 정합성을 맞추는 방법
    • 데드락이 걸릴 수 있다.
  2. Optimistic Lock
    • 실제로 Lock을 사용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
    • 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트
  3. Named Lock
    • 이름을 가진 metadata locking
    • 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 Lock을 획득할 수 없도록 한다.
    • 트랜잭션이 종료될 때 Lock이 자동으로 해제되지 않기 때문에 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다.

Pessimistic Lock

  1. 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을 구현할 수 있다.

  2. 서비스 로직 작성

     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);
         }
     }
  3. 테스트

     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을 걸고 데이터를 가져오는 부분

  4. 장단점

    • 장점
      • 충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋을 수 있다.
      • Lock을 통해 업데이트를 제어하기 때문에 데이터 정합성이 보장된다.
    • 단점
      • 별도의 Lock을 잡기 때문에 성능 감소가 있을 수 있다.

Optimistic Lock

  1. 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;
         }
     }
    
  2. repository → Optimistic Lock을 활용하여 데이터를 가져오는 메서드 작성

     @Lock(LockModeType.OPTIMISTIC)
     @Query("select s from Stock s where s.id = :id")
     Optional<Stock> findByIdWithOptimisticLock(Long id);
  3. 서비스 로직 작성

     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);
         }
     }
  4. 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 → 실패했을 때 재시도

  5. 테스트

     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개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.

  6. 장단점

    • 장점
      • 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능이 좋다.
    • 단점
      • 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 하는 번거로움이 있다.

Named Lock

  1. 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);
     }
  2. 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 해제
             }
         }
     }
  3. StockService → 부모의 트랜잭션과 별도로 수행해야 하기 때문에 propagation 수정

     @Transactional(propagation = Propagation.REQUIRES_NEW) // 부모의 트랜잭션과 별도로 수행
     public void decrease(Long id, Long quantity) {
         ...
     }
  4. 같은 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
  5. 테스트

     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개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.

  6. 장단점

    • 장점
      • 주로 분산 락을 구현할 때 사용한다. 또한, 데이터 삽입 시 정합성을 맞춰야 하는 경우에도 많이 사용한다.
      • PessimisticLock과 달리 손쉽게 time-out 을 구현할 수 있다.
    • 단점
      • 트랜잭션 종료 시 락을 해제해줘야 한다.
      • 구현 방법이 복잡하다.