본문 바로가기
자바

동시성 이슈

by 이상한나라의개발자 2023. 12. 11.

동시성 이슈는 여러 스레드나 프로세스가 동시에 실행될 때 발생하는 문제입니다. 이러한 문제는 주로 공유 자원에 대한 동시 접근, 경쟁 조건, 데드락 등과 관련이 있습니다.

 

1. 공유 자원에 대한 동시 접근 : 여러 스레드가 동시에 같은 자원(변수, 객체, 데이터)에 접근하려고 할 때 발생합니다. 이로 인해 예상치 못한 결과나 데이터의 무결성 문제가 발생할 수 있습니다.

 

2. 경재조건 (Race Condition) : 두 개 이상의 스레드가 특정 연산의 순서에 따라 결과가 달라지는 상황을 의미합니다. 예를 들어 두 스레드가 동시에 같은 변수를 증가 시키려고 할 때, 예상 값 보다 다른 값이 저장 될 수 있습니다.

 

3. 데드락 (Deadlock) : 두 개 이상의 스레드가 서로 다른 자원을 기다리며 무한히 대기하는 상태를 의미합니다. 이로 인해 프로그램은 멈추게 됩니다.

 

4. 라이브락 (Livelock) : 스레드개 무한히 작업을 반복하지만 실제로는 진행되지 않는 상태를 의미합니다.

 

5. 스타베이션(Starvation) : 특정 스레드가 자원에 접근할 수 없어 무한히 대기하는 상태를 의미합니다.

 

6. 메모리 가시성 (Memory Visibility) : 한 스레드에서 변경한 변수의 값이 다른 스레드에 즉시 반영되지 않는 문제입니다. 이는 자바의 메모리 모델과 관련이 있습니다.

 

아래는 동시성 이슈에 관한 예를 만들어 보았는데요. 

재고 관리에서 최초 100개의 재고가 있고 하나의 상품에 1개씩 100번 호출하여 재고를 감소하는 로직을 작성하도록 하겠습니다. 

예상 재고 수량은 0 입니다. 

 

샘플 소스는 SpringBoot, SpringDataJPA, H2 를 사용하였습니다.

 

Stock.java

 

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Builder
    private Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public static Stock createStock(Long productId, Long quantity) {
        return Stock.builder()
                .productId(productId)
                .quantity(quantity)
                .build();
    }

    public void decrease(Long quantity) {
        if ( this.quantity - quantity < 0 ) {
            throw new IllegalArgumentException("재고가 부족합니다.");
        }
        this.quantity -= quantity;
    }
}

 

 

StockRepository

 

public interface StockRepository extends JpaRepository<Stock, Long> {
}

 

StockService

 

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        // Stock 조회
        Stock stock = stockRepository.findById(id).orElseThrow();
        // 재고를 감소한 뒤
        stock.decrease(quantity);
        // 갱신된 값을 저장
        stockRepository.save(stock);
    }
}

 

StockServiceTest

 

@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void init() {
        Stock createStock = Stock.builder()
                .productId(1L)
                .quantity(100L)
                .build();
        stockRepository.save(createStock);
    }

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }

    @DisplayName("재고가 제대로 감소하는지 테스트 한다.")
    @Test
    void decrease() {
        // given
        Long id = 1L;
        Long quantity = 1L;

        // when
        stockService.decrease(id, quantity);
        Stock findStock = stockRepository.findById(id).orElseThrow();
        // then
        assertThat(findStock.getQuantity()).isEqualTo(99L);

    }

    @DisplayName("동시에 100개의 재고 감소 요청, 감소 1개씩 100번 이다.")
    @Test
    void multiThreadStock() throws InterruptedException {

        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for ( int i=0; i<threadCount; i++ ) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);

                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        // then
        Stock findStock = stockRepository.findById(1L).orElseThrow();
        assertThat(findStock.getQuantity()).isEqualTo(0L);

    }
}

 

위 코드를 작성하여 테스트를 실행을 하게 되면 예상치 못한 결과를 보게 됩니다. 바로 동시성 이슈 때문이죠

Thread-1 , Thread-2 ... Race Condition 이 발생하게 되는데요 

 

동시 자원 접근

 

그럼 여기서 자바에서 제공하는 Synchronized를 사용하면 한개의 메소드는 한개의 스레드만 접근이 가능하게 됩니다.

하지만 테스트는 깨지게 되어 있는데요 

 

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public synchronized void decrease(Long id, Long quantity) {
        // Stock 조회
        Stock stock = stockRepository.findById(id).orElseThrow();
        // 재고를 감소한 뒤
        stock.decrease(quantity);
        // 갱신된 값을 저장
        stockRepository.save(stock);
    }
}

 

Synchronized 사용하게 되면 위가 만든 클래스를 래핑한 다른 클래스를 생성해서 사용하게 됩니다.

간략하게 설명하면 Proxy 패턴과 비슷한데요. 아래 참조 코드를 보시면 이해가 쉬울 겁니다.

 

@RequiredArgsConstructor
public class TransactionStockService {

	
	private final StockService stockService;
    
    public void decrease(Long id, Long quantity) {
    	startTransaction();
        
        stockService.decrease(id, quantity);
        
        endTransaction();
    }
    
    private void startTransaction() {
    	log.info("start transaction");
    }
    
    private void endTransaction() {
    	log.info("end transaction");
    }
}

 

여기서 문제가 발생하게 되는데요. 실제 decrease가 완료되고 transaction이 종료되기 전에 다른 스레드가 decrease를 접근하게 되면 

갱신되기 이전에 데이터를 가져가게 되어 문제가 발생하게 됩니다.

 

이를 해결하기 위해서는 Transaction 어노테이션을 제거하게 되면 되는데요 이것은 추천하는 방법은 아닙니다.

해결을 위해 여러가지 방법이 있겠지만

 

데이터베이스 lock를 통한 방법으로 알아보도록 하겠습니다.

 

 

1. Pessimistic 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);
}

 

락의 해제 시점은 트랜잭션 종료와 관련이 있습니다. SpringDataJPA와 같은 ORM 프레임워크에서 사용 하는 락은 일반적으로 다음과 같은 시점에 해제 됩니다.

 

  • 커밋 : 트랜잭션이 성공적으로 커밋되면 해당 트랜잭션에 걸린 모든 락이 해제 됩니다.
  • 롤백 : 트랜잭션이 롤백 되면 해당 트랜잭션에서 걸린 모든락도 해제됩니다.
  • 타임아웃 : 데이터베이스나 JPA 프로바이더에 따라 설정된 락의 타임아웃 시간이 초과되면 락이 자동으로 해제 될 수 있습니다.

SpringDataJPA 와 같은 ORM 프레임워크에서는 @Transactional 어노테이션이 붙은 메서드의 시작과 종료 시점이 됩니다. 따라서 실행이 완료되고 트랜잭션이 커밋되거나 롤백되면 락도 함께 해제 됩니다.

 

흐름

 

  1. StartTransaction : 트랜잭션이 시작됩니다.
  2. Acquire Lock : 필요한 데이터의 락을 획득 합니다.
  3. Access Data : 락이 걸린 데이터에 접근하여 작업을 수행합니다.
  4. Release Lock : 작업이 완료되면 락을 해제 합니다.
  5. End Transaction : 트랜잭션이 종료되고, 모든 변경 사항이 커밋 되거나 롤백 됩니다.

 

 

2. Optimistic Lock (낙관적 락)

 

낙관적 락은 데이터를 수정할 때 다른 트랜잭션이 동시에 데이터를 수정 하지 않을 것 이라고 "낙관적" 으로 가정하는 방식입니다.

실제로 데이터를 수정하기 전에, 원래의 데이터와 현재의 데이터를 비교하여 변경 여부를 확인합니다.

 

  • 실제 락을 사용하지 않습니다. 대신 데이터의 버전 정보나 타임스탬프를 사용하여 변경 여부를 확인합니다.
  • 데이터가 변경되었을 경우 ( 즉, 충돌이 발생했을 경우 ), 트랜잭션을 롤백하거나 오류를 반환합니다.
  • 동시성 문제가 발생할 가능성이 낮은 상황에서 사용 됩니다.

 

만약 특정 데이터에 대해서 한 접근에 대해서 처리하고 재시도 처리가 없다면 아래와 같이 하면 됩니다.

 

@Getter
@Entity
@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 createStock(Long productId, Long quantity) {
        return Stock.builder()
                .productId(productId)
                .quantity(quantity)
                .build();
    }

    public void decrease(Long quantity) {
        if ( this.quantity - quantity < 0 ) {
            throw new IllegalArgumentException("재고가 부족합니다.");
        }
        this.quantity -= quantity;
    }
}

 

@Service
@Transactional
@RequiredArgsConstructor
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

    public void updateStock(Long id, Long productId) {
		Stock stock = productRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
		stock.setProductId(productId);
        // Any other business logic
    }
    
    @Transactional
    public void decrease(Long id, Long quantity) {
        // Stock 조회
        Stock stock = stockRepository.findByIdWithOptimisticLock(id);
        // 재고를 감소한 뒤
        stock.decrease(quantity);
        // 갱신된 값을 저장
        stockRepository.save(stock);
    }
}

 

@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);

 

DB 테이블에 version 필드가 만들어지고 버전을 비교함으로써 데이터가 최신인지 아닌지를 판단하게 됩니다. 처음 version = 1 이 였는데

수정할때 version = 2 라면 OptimisticLockException이 발생하게 됩니다.

 

흐름

 

  1. Read Entity : 데이터베이스에서 엔티티를 읽어옵니다.
  2. Check @Lock : @Lock(LockModeType.OPTIMISTIC) 어노테이션이 적용된 메서드를 실행합니다.
  3. Access @Version : @Version 어노테이션이 적용된 버전 필드의 값을 확인합니다.
  4. Modify Entity : 엔티티를 수정합니다.
  5. Commit Change : 변경 사항을 커밋합니다. 이때 , 버전 필드의 값이 변경되었는지 확인하고, 변경 되었다면 OptimsticLockException이 발생합니다.

 

만약에, 재처리를 해야 한다면 재고 100개 에서 - 100으로 감소 시키는데 있어서 로직의 검증과 재처리 작업 코드를 알아보겠습니다.

Service, Repository, Entity 는 기존 소스 그대로 이며 , fasada 클래스를 하나 생성하여 작업을 진행합니다.

 

@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 ( RuntimeException e ) {
                // 재시도
                Thread.sleep(50);
            }
        }
    }
}

 

@SpringBootTest
class OptimisticLockStockFacadeTest {

    @Autowired
    private OptimisticLockStockFacade stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void init() {
        Stock createStock = Stock.builder()
                .productId(1L)
                .quantity(100L)
                .build();
        stockRepository.save(createStock);
    }

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }


    @DisplayName("동시에 100개의 재고 감소 요청, 감소 1개씩 100번 이다.")
    @Test
    void multiThreadStock() throws InterruptedException {

        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for ( int i=0; i<threadCount; i++ ) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);

                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        // then
        Stock findStock = stockRepository.findById(1L).orElseThrow();
        assertThat(findStock.getQuantity()).isEqualTo(0L);

    }
}

 

낙관적 락은 실제로 데이터베이스에 락을 잡지 않기 때문에 다른 시스템에 대한 영향도가 적습니다. 다만, 데이터를 읽을때의 버전정보 변경할때의 버전정보를 비교하여 동시성 문제를 방지하기 때문에 성능면에 단점이 있습니다. 

그러므로 낙관적 락은 동시성 문제가 자주 발생하지 않는 시나리오에 적합합니다. 

동시성 문제가 자주 발생 한다면 매번 OptimisticLockException을 처리해야 하므로 시스템의 복잡성이 증가하게 됩니다.

 

 

3. Named Lock

 

Named Lock는 특정 이름을 가진 락입니다. 여러 트랜잭션에서 동일한 이름의 락을 사용하여 특정 리소스나 작업에 대한 동시 접근을 제어할 수 있습니다.

 

  • 특정 리소스나 작업에 대한 동시 접근을 제어하기 위해 사용 됩니다.
  • 이름을 기반흐로 하는 락이므로, 동일한 이름의 락을 요청하는 트랜잭션은 대기 상태가 될 수 있습니다.
  • 특정 리소스나 작업에 대해 동시 접근을 제어하려는 상황에서 사용 됩니다. 예를 들어, 특정 작업을 한 번에 하나의 트랙잭션만 실행되도록 제한하려는 경우에 사용 할 수 있습니다.

 

 

Redis 를 통한 락 문제 해결 

 

먼저 본인의 pc에 redis를 설치해야 합니다. macOS 기준으로 설명 드리겠습니다. docker를 통한 redis 설치 방법 입니다.

 

  • Homebrew 설치 확인 : brew --version
    • 설치가 되어 있지 않다면 Homebrew 공식 홈페이지나 google 검색하여 설치 하셔야 됩니다.
  • docker 설치 : brew install --cask docker
  • docker 상태 확인 : docker --version
  • redis 설치 : docker pull redis
  • redis 도커 컨테이너 실행 : docker run --name redis-container -p 6379:6379 -d redis
    • --name redis-container : 컨테이너의 이름을 redis-container 로 설정합니다.
    • -p 6379:6379 : 호스트의 6379 포트와 컨테이너의 6379 포트를 연결합니다.
    • -d : 컨테이너를 백그라운드에서 실행합니다.
    • redis : 실행할 이미지의 이름 

Redis 카테고리 페이지 Redis 설치 부분에 자세히 나와 있습니다.

'자바' 카테고리의 다른 글

예외  (0) 2023.12.11
캡슐화  (0) 2023.12.11
추상클래스와 인터페이스  (0) 2023.12.11
상속과 다형성 기본  (0) 2023.12.11
객체지향적으로 개발해야 하는 이유  (0) 2023.12.11