[선착순 상품 구매 프로젝트] Pessimistic Lock(비관적 락, 선점 잠금), Optimistic Lock(낙관적 락, 비선점 잠금)으로 동시성제어하기

2024. 4. 13. 04:46Project/예약구매

 

특정 시간대에 집중된 주문 요청이 발생하는 [선착순 상품 구매 프로젝트]를 진행하면서 동시성 문제를 맞땋드렸습니다. synchronized 키워드 활용, 낙관적 락, 비관적 락을 활용해 동시성 제어를 통합 테스트로 확인해보았습니다. 

 

두 포스팅으로 나눠서 어떤 이유로 해당 방법을 사용했는지 저만의 문제 접근 방식을 기술해 보도록 하겠습니다. 

1. [선착순 상품 구매 프로젝트] 자바 synchronized 키워드 적용으로 동시성 제어하기

2. [선착순 상품 구매 프로젝트] Pessimistic Lock(비관적 락, 선점 잠금), Optimistic Lock(낙관적 락, 비선점 잠금)으로 동시성제어하기

 

목차

문제 상황

문제 분석

해결 방법

구현 및 테스트 결과

아쉬운 점 및 한계점

향후 학습 


문제 상황

주문/결제 요청 시 재고 수량보다 많은 결제가 처리되는 문제가 발생하였습니다.

 

문제 분석

1. (기존) synchronized 키워드 적용한 재고 증가, 감소 코드 

더보기
더보기
더보기

[재고 증가 요청]

//@Transactional
public StockResponseDto increaseProductStock(StockRequestDto requestDto) {
    if (requestDto.stock() <= 0) {
        throw new CustomException(ErrorCode.INVALID_STOCK_QUANTITY);
    }

    synchronized(this) {
        Stock stock = stockRepository.findById(requestDto.productId())
                .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));

        Long newStockQuantity = stock.getStock() + requestDto.stock();

        Stock updatedStock = stockRepository.save(Stock.builder()
                .productId(stock.getProductId())
                .stock(newStockQuantity)
                .build());

        return new StockResponseDto(updatedStock.getProductId(), updatedStock.getStock());
    }
}

 

[재고 감소 요청]

//@Transactional
public synchronized StockResponseDto decreaseProductStock(StockRequestDto requestDto) {
    Stock stock = stockRepository.findById(requestDto.productId())
            .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));

    Long newStockQuantity = stock.getStock() - requestDto.stock();

    if(newStockQuantity < 0) {
        throw new CustomException(ErrorCode.STOCK_NOT_ENOUGH);
    }

    Stock updatedStock = stockRepository.save(Stock.builder()
            .productId(stock.getProductId())
            .stock(newStockQuantity)
            .build());

    return new StockResponseDto(updatedStock.getProductId(), updatedStock.getStock());
}

2. 통합테스트 통과

 

3. 한계

자바의 synchronized 키워드를 사용한 동시성 제어는 멀티 서버 환경에서 보장되지 않는다는 한계점이 존재합니다. 

 

해결 방법

다양한 동기화 전략을 통해 주문/결제 시스템의 동시성 문제를 해결할 수 있습니다. 

 

대안

1. 데이터베이스 잠금

- 동작 방식: 데이터베이스의 내장 잠금 메커니즘을 이용하여 데이터의 동시 수정을 방지합니다.

- 장점: 별도의 도구 없이 사용 가능하며, 데이터베이스 수준에서 일관성과 무결성을 보장합니다.

- 단점: 비관적 잠금은 자원을 장시간 점유할 수 있어 시스템의 성능 저하를 일으킬 수 있고, 낙관적 잠금은 충돌 해결 로직이 추가로 필요합니다.

 

2. 분산 잠금(Distributed Locking)

- 동작 방식: Apache ZooKeeper나 Redis를 이용하여 분산 서버 간의 자원 접근을 동기화합니다.

- 장점: 대규모 분산 시스템에서도 일관성, 높은 가용성 및 확장성을 유지할 수 있습니다. 

- 단점: 네트워크 지연과 같은 외부 요인에 의해 성능이 영향을 받을 수 있습니다.

 

3. 메시지 큐를 이용

- 동작 방식: 메시지 큐에 작업 요청을 순차적으로 저장하고 서버가 하나씩 처리합니다.

- 장점: 처리 순서를 명확하게 하여 동시성 문제를 방지합니다. 

- 단점: 메시지 큐 시스템의 관리와 모니터링이 필요합니다.

 

4. 트랜잭션 그룹화 관리

- 동작 방식: 여러 데이터 변경 작업을 그룹화하고 모든 작업이 완료될 때만 커밋합니다.

- 장점: 데이터의 일관성을 보장합니다.

- 단점: 롤백 로직이 복잡하고 성능 저하가 발생할 수 있습니다.

 

선택 : 데이터베이스 잠금 

- 비관적 락

- 낙관적 락

 

데이터베이스 잠금 선택의 이유

데이터베이스 잠금 방식을 선택한 주된 이유는 기존의 데이터베이스 관리 시스템(DBMS)에 이미 내장된 동기화 및 락 관리 기능을 활용하여 추가적인 도구 설치나 복잡한 설정 없이도 데이터베이스 수준에서 일관성과 무결성을 보장할 수 있기때문입니다. 추가적인 이점으로는 다음과 같습니다. 
1. 비용 효율성:

이미 구축된 DBMS의 기능을 활용함으로써 추가적인 비용 없이 동시성 문제를 해결할 수 있습니다. 

* 추가적인 비용의 예: 구축 비용과 인프라 관리비용


2. 기술 복잡성 최소화, 검증된 기술로 안정성 보장 ➡ 빠른 구현 가능

새로운 기술이나 도구를 도입하는 것은 기술적인 복잡성을 증가시킵니다. 반면, 데이터베이스의 잠금 메커니즘은 검증된 기술로 안정성이 보장됩니다. 우리 팀이 이미 잘 알고 있는 도구를 사용하면 빠른 구현이 가능합니다. 

* [ 향후 학습  ] 다른 방법들도 순차적으로 학습하고 필요에 따라 적용할 예정입니다.

 

구현 및 테스트 결과

첫 번째, 비관적 락 적용

실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법입니다. 데이터에 독점 잠금(exclusive lock)을 설정하여 다른 트랜잭션에서는 잠금이 해제될 때까지 데이터에 접근할 수 없습니다. 데드락 발생 가능성이 있으므로 이 기법을 사용할 때는 주의가 필요합니다.

* 데이터에 Lock을 걸어 정합성을 맞춘다는 것은 일관성과 정확성을 보장하기 위해 특정 데이터 또는 자원에 대한 접근을 제한하는 것을 말합니다.

 

PESSIMISTIC_READ

트랜잭션이 완료될 때까지 다른 트랜잭션이 해당 데이터를 업데이트하지 못하도록 하면서도 조회는 가능하게 하는 락입니다.

 

테스트 결과, 실패

@Test
@DisplayName("예약 상품 100개에 대해 80개 감소 요청 시 정확한 재고(20개) 반영 검증")
void whenConcurrentlyDecreasing80_OutOf100Stock_ThenRemainingStockShouldBe20() throws InterruptedException {}

// 결과
Expected: 20
Actual  : 91

 

원인 분석

PESSIMISTIC_READ는 다른 트랜잭션이 쓰기 락을 획득하는 것을 방지하지만, 읽기는 허용하기 때문에 재고 수량을 조정하는 경우 충분한 동시성 제어를 제공하지 못합니다.

 

해결 방안 PESSIMISTIC_WRITE

이 접근법은 다른 트랜잭션이 데이터 항목을 읽거나 쓰는 것을 모두 방지합니다. 그러나 데이터를 동시에 읽을 수 있도록 허용하고 싶어 CQRS(Command Query Responsibility Segregation) 패턴을 적용하였습니다. 또한, 재고 감소 및 증가 로직을 도메인 로직으로 캡슐화하여 안정성을 강화하였습니다.

 

CQRS 패턴 적용

더보기
더보기
더보기

[ 쓰기 작업 ]

public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.productId = :productId")
    Optional<Stock> findByIdForUpdate(@Param("productId") Long productId);
}

 

[읽기 작업]

public interface StockQueryRepository extends JpaRepository<Stock, Long> {
    @Query("SELECT s FROM Stock s WHERE s.productId = :productId")
    Optional<Stock> findByProductId(Long productId);
}

기존 서비스 계층에서 구현된 decreaseStock 메서드와 increaseStock 메서드 ➡ 도메인 로직으로 캡슐화

더보기
더보기
더보기

@Getter
@NoArgsConstructor
@Entity
public class Stock{
    @Id
    @Column(name = "product_id")
    private Long productId;

    private Long stock;

    public Stock(Long productId, Long stock) {
        this.productId = productId;
        this.stock = stock;
    }
    
    // 해당 부분
    public void decreaseStock(Long quantity) {
        if (this.stock < quantity) {
            throw new CustomException(ErrorCode.STOCK_NOT_ENOUGH);
        }
        this.stock -= quantity;
    }

// 해당 부분
    public void increaseStock(Long quantity) {
        if (quantity <= 0) {
            throw new CustomException(ErrorCode.INVALID_STOCK_QUANTITY);
        }
        this.stock += quantity;
    }

}

 

발생 가능한 문제1 : 데드락

해결 방법: 대기 시간 설정( @QueryHints )으로 데드락 방지

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="2000")})
@Query("select s from Stock s where s.productId = :productId")
Optional<Stock> findByIdForUpdate(@Param("productId") Long productId);

 

최종 코드 링크 [ feat: 비관적 쓰기락과 CQRS 적용으로 동시성 제어 및 관련 테스트 코드 추가 ]

 

발생 가능한 문제2: 데이터 불일치
데이터를 조회하는 동안 다른 곳에서 같은 데이터가 변경되면 문제가 발생할 수 있습니다. 예를 들어, 한 스레드가 주문 정보를 확인하고 있을 때, 다른 스레드가 그 주문의 상태를 변경하면, 최초의 스레드는 잘못된 정보를 가지고 작업을 계속하게 됩니다. 이런 상황은 데이터를 확인하고 있는 동안 그 데이터가 바뀌어 버리기 때문에 발생합니다.

해결 방법:
낙관적 락을 사용하여 데이터 버전 관리를 통해 이러한 문제를 해결할 수 있습니다.

 

두 번째, 낙관적 락 적용

실제로 데이터에 락을 설정하지 않고 데이터의 버전을 사용하여 정합성을 유지하는 방법입니다. 데이터를 처음 읽은 후, 데이터를 업데이트할 때 현재 내가 읽은 데이터의 버전이 최신인지 확인합니다. 업데이트를 진행할 때 해당 데이터의 버전을 하나씩 증가시킵니다. 만약 내가 읽은 버전에서 다른 사용자에 의해 변경이 발생했다면, 애플리케이션에서 데이터를 다시 읽고 업데이트를 수행해야 합니다. 

public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("select s from Stock s where s.productId = :productId")
    Optional<Stock> findAndForceIncrementVersionById (@Param("productId") Long productId);
}

 

발생한 문제 : 클래스 수준에서 @Builder 사용

- 에러 내용: '@Builder 애너테이션이 적절한 생성자를 필요로 합니다.'

- 원인: JPA의 @Version 필드와 같이 JPA에 의해 관리되어야 하는 필드는 빌더 패턴에서 제외되어야 합니다.
- 해결 방안:
버전 필드를 포함하지 않는 사용자 정의 builder() 메서드를 생성합니다. 생성된 빌더 클래스는 version 필드를 제외하고, productId와 stock 필드에 대한 빌더 메서드만을 제공하여 결과적으로 JPA의 낙관적 락 기능과의 충돌을 방지합니다.

 

해결 방안 적용한 코드

더보기
더보기
더보기

@Getter
@NoArgsConstructor
@Entity
public class Stock{
    @Id
    @Column(name = "product_id")
    private Long productId;

    private Long stock;

    @Version
    private Long version;

    public Stock(Long productId, Long stock) {
        this.productId = productId;
        this.stock = stock;
    }

   ....


    // 사용자 정의 빌더 클래스
    @Builder
    public static class StockBuilder {
        private Long productId;
        private Long stock;

        public StockBuilder productId(Long productId) {
            this.productId = productId;
            return this;
        }

        public StockBuilder stock(Long stock) {
            this.stock = stock;
            return this;
        }

        public Stock build() {
            Stock stock = new Stock();
            stock.productId = this.productId;
            stock.stock = this.stock;
            return stock;
        }
    }

}

낙관적 락의 재시도 로직 구현
낙관적 락을 사용하면 충돌이 발생할 경우, 모든 요청이 완료될 때까지 재시도를 수행해야 합니다. 예를 들어, 1000개의 동시 수정 요청이 발생한다면, 이 모든 요청이 성공할 때까지 반복적으로 재시도가 이루어집니다. 

구현마다 다르겠지만 성공할때까지 시도한다고 가정했을 때 데이터베이스에 굉장히 많은 요청을 보내게 될 것입니다. 

재시도 로직의 설계 고려 사항
특정한 재시도 로직이 다양한 서비스나 컴포넌트에서 공통적으로 사용되어야 하고, 실행 지점이 여러 곳일 경우, AOP(Aspect-Oriented Programming)가 적합할 수 있습니다. 반면, 재시도 로직이 특정 비즈니스 프로세스에 국한되어 있고, 서비스 레이어의 일부로서 명확하게 관리되어야 할 경우에는 Facade 패턴을 통한 접근이 더 적합할 수 있습니다. 따라서 저는 이번 프로젝트에서는 재시도 로직을 Facade 패턴을 적용하여 구현해보았습니다.

 

코드 구현

프로젝트 구조

stock_service
├── src
    ├──main/**
    │     ├── client
    │           ├── controller
    │           ├── dto
    │     ├── common
    │           ├── config
    │           ├── dto
    │           ├── entity
    │           ├── handler
    │     ├── stock
    │           ├── controller
    │           ├── entity
    │           ├── facade     // 해당 부분
    │                 ├── OptimisticLockStockFacade
    │           ├── repository
    │           ├── service
    │
    ├──test/**
          ├── stock
            ├── service

 

OptimisticLockStockFacade 클래스

낙관적 락을 활용하여 재고 관리 작업을 수행하고, 낙관적 락 실패 시 자동으로 재시도하는 로직입니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class OptimisticLockStockFacade {

    private final StockService stockService;

    @Value("${stock.retry.maxAttempts:3}")
    private int maxAttempts;

    @Value("${stock.retry.initialDelay:50}")
    private long initialDelay;

    public void increaseProductStock(StockRequestDto requestDto) throws InterruptedException {
        executeWithRetry(() -> stockService.increaseProductStock(requestDto));
    }

    public void decreaseProductStock(StockRequestDto requestDto) throws InterruptedException {
        executeWithRetry(() -> stockService.decreaseProductStock(requestDto));
    }

    private void executeWithRetry(Runnable stockOperation) throws InterruptedException {
        int attempt = 0;
        long delay = initialDelay;
        while (attempt < maxAttempts) {
            try {
                stockOperation.run();
                return;
            } catch (OptimisticLockingFailureException e) {
                handleRetry(attempt, e);
                Thread.sleep(delay);
                delay *= 2;
                attempt++;
            } catch (CustomException e) {
                if (e.getErrorCode() == ErrorCode.PRODUCT_NOT_FOUND || e.getErrorCode() == ErrorCode.INVALID_STOCK_QUANTITY) {
                    throw e;
                }
            }
        }
        throw new CustomException(ErrorCode.MAX_RETRY_ATTEMPTS_EXCEEDED);
    }

    private void handleRetry(int attempt, Exception e) {
        log.warn("낙관적 락 실패 - 시도 {}/{}: {}", attempt + 1, maxAttempts, e.getMessage());
        if (attempt >= maxAttempts - 1) {
            log.error("최대 재시도 횟수 도달. 작업 실패.");
        }
    }

}

 

재시도 코드 구현시 고려한 점

1. 최대 재시도 횟수 설정: 예외가 무한히 발생하는 경우를 방지하기 위해 최대 재시도 횟수(maxAttempts)를 도입합니다.

2. 지수 백오프 전략 구현: 일정 시간을 기다리는 대신 지수 백오프(delay *= 2)를 구현합니다. 이는 분산 시스템에서 더 효과적이며, 썬더링 허드 문제를 예방할 수 있습니다.

* 지수 백오프 전략
재시도 간의 대기 시간을 점진적으로 늘려가는 재시도 로직입니다. 주로 네트워크 통신이나 데이터베이스 연산 등 실패할 가능성이 있는 연산에 적용됩니다.
재시도가 실패할 때마다 대기 시간이 지수적으로 증가합니다. 재시도 사이에 긴 대기 시간을 설정함으로써, 문제의 원인이 되는 요소가 해소될 시간을 제공합니다. 해당 코드에서는 초기 대기 시간은 50밀리 초로 시작하여, 각 실패 후에 이를 2배씩 늘려나가며, 최대 재시도 횟수(maxAttempts)에 도달하면 더 이상 재시도하지 않고 예외를 다시 던집니다.

** 지수 백오프 전략을 통해 썬더링 허드 문제를 예방
많은 클라이언트가 동시에 실패했을 때, 일정한 시간 간격(예: 모두 50ms마다)으로 재시도하면, 모든 클라이언트가 거의 동시에 재시도를 시도하게 되어 서버에 갑작스러운 부하가 가중됩니다. 지수 백오프 전략을 사용하면, 재시도하는 시간 간격이 각기 다르게 되어(50ms, 100ms, 200ms 등으로 증가) 모든 클라이언트가 동시에 재시도하는 것을 방지하여 시스템에 대한 동시 요청의 수를 분산시키고, 결과적으로 서버에 대한 부하를 줄이는 효과가 있습니다.

 

3. 특정 예외만 처리: 모든 Exception 타입을 잡는 대신, 낙관적 락 실패를 나타내는 특정 예외만 처리합니다.
4. 로깅 추가: 각 재시도 시도와 재시도 한계에 도달했을 때 로깅을 추가하여 디버깅과 모니터링을 용이하게 합니다.

5. 재시도 매개변수 외부 설정 및 재시도 로직을 전용 메서드로 이동: 지연 시간과 최대 재시도 횟수와 같은 재시도 매개변수를 외부에서 설정할 수 있게 함으로써 코드 변경 없이 수정을 쉽게 할 수 있고 재시도 로직을 전용 메소드로 이동함으로써 코드 중복을 방지합니다.

최종 코드 링크 [feat: 낙관적 락 적용으로 동시성 제어 및 관련 테스트 코드 추가]

 

비관적 락과 낙관적 락 방법 비교

Pessimistic Lock

✔ 장점
- 충돌이 빈번할 경우 Optimistic Lock보다 성능이 좋을 수 있음
- 락을 통해 update를 제어하므로 데이터 정합성 보장
단점
- 별도의 락을 잡기 때문에 성능 저하 가능성

Optimistic Lock

✔ 장점
- 별도의 락을 잡지 않아 성능상 이점
단점
- 업데이트 시 재시도 로직을 개발자가 직접 작성해야 하며, 이로 인한 시간 부담

 

최종 선택과 이유

비관적 락을 선택했습니다. 선착순 상품 구매 프로젝트 특성 상 충돌이 잦을 것으로 예상되었고, 낙관적 락을 채택할 경우 재시도 로직을 직접 작성해야 하는 부담과 시간 성능상의 문제가 있어 비관적 락이 더 적합하다고 판단했습니다. 추가적으로, Java의 Lock 인터페이스를 활용하여 더욱 세밀하고 효율적인 락 관리를 도입할 계획입니다.

 

아쉬운 점 및 한계점

비관적 락 방식은 충분한 데이터 정합성을 제공하지만, 고성능 요구 사항에는 한계가 있습니다. 대량의 트랜잭션이 발생하는 환경에서는 락으로 인한 병목 현상이 발생할 수 있습니다. 이러한 문제를 해결하기 위해 분산 락이나 메시지 큐 같은 다른 기술을 도입하는 것이 효과적일 수 있습니다. 분산 락은 여러 서버에서 작업을 조정할 때, 메시지 큐를 사용하면 요청을 순차적으로 처리하여 서버의 부하를 분산시킬 수 있다는 장점이 있습니다.  

향후 학습 

1. 추가 학습 할 개념

스프링이 제공하는 TransactionTemplate 

스레드 풀 생성 기준

 

2.  Java Locks 인터페이스, Redis 분산락


 

 

참고

https://code-lab1.tistory.com/269

https://ojt90902.tistory.com/865