서버구축 - 데이터베이스 심화

anvel·2025년 8월 10일

항해 플러스

목록 보기
34/39

항해 플러스 백엔드 - 데이터베이스 심화

서버구축 - 데이터베이스 심화

이번 주차는 전 주차에 설계 및 Index가 적용된 DB를 기반으로 동시성 문제가 발생하는 비즈니스 로직을 식별하고, 해당 로직에 대한 DB 관점의 Lock을 통한 동시성 제어를 구현하는 것이 목표였습니다.

동시성의 정의

동시성은 여러 작업이 독립적으로 실행되며, 그 실행이 논리적으로 동시에 일어나는 것처럼보이게 하는 프로그래밍 기법

동시성으로 인한 문제

1. 경쟁 상태, 경쟁 조건(Race condition)

  • 두 개 이상의 스레드가 동시에 공유자원에 접근할 때 발생
  • 스레드 실행 순서에 따라 결과가 달라짐
  • 예시
    • balance 에 대한 충전과 사용 요청이 동시에 접근하여, 아직 갱신되지 않은 잔액을 기준으로 서로 나중에 완료되는 요청으로 갱신이 될 수 있음
    • 이로 인해 분실 갱신이 발생하여 정합성이 깨지고 데이터 불일치가 발생함

2. 데드락(Deadlock)

  • 두 개의 스레드가 서로 상대방이 점유하고 있는 자원을 무한정 대기하는 상태
  • 발생 조건
    • 상호 배제: 자원은 하나의 프로세스만 점유 가능
    • 점유와 대기: 자원을 점유한 채로 다른 자원을 기다림
    • 비선점: 자원을 강제로 가져오지 못함
    • 순환 대기: 프로세스들이 서로 원형으로 대기함
  • 예시 1
    • 상품 > 쿠폰 > 잔액을 접근하는 주문 메서드를 호출한 스레드와
    • 쿠폰 > 상품을 접근하는 다른 메서드가 동시에 요청이 발생 했을 떄,
    • 각각의 요청이 상품과 쿠폰을 선점하고 서로 다른 자원을 기다리는 교착상태에 빠짐
  • 예시 2
    • 두 주문 요청이 발생하는 중, 상품 한 테이블에 대하여 PK에 대한 순서 정렬 없이 재고 차감을 하는 경우
    • 3 > 1 > 5 번 상품 재고를 차감하려는 주문 요청
    • 1 > 3 번 상품 재고를 차감하려는 주문의 경우
    • PK 기준으로 row에 락이 동작해 서로 다음 상품의 재고 차감을 하지 못하도 교착상태에 빠짐

3. 라이브락(Livelock)

  • 여러 스레드나 프로세스가 서로의 작업을 방해햐여 진행이 멈춘 상태
  • 데드락과 유사하지만, 데드락과 달리 스레드들이 작업을 지속적으로 시도함

4. 기아 상태(Starvation)

  • 특정 스레드나 프로세스가 자원에 대한 접근 권한을 지속적으로 얻지 못하고 무한정 대기하는 상태
  • 우선순위가 낮거나, 잘못된 자원할당 정책에서 발생할 수 있는 상황

5. 우선순위 역전(Priority Inversion)

  • 실시간 시스템에서 낮은 우선순위의 태스크가 높은 우선순위의 태스크보다 먼저 자원을 점유하여 높은 우선순위 태스크의 실행을 지연시키는 문제

E-commerce 서비스에서 예측되는 동시성 문제

1. 상품 재고 차감

  • 기능 설명: 주문 생성 중 상품의 재고를 차감하는 경우
  • 예측되는 동시성 문제: Race condition → 재고 음수 발생, 차감 누락
  • 발생 시나리오
    • 상품 A의 재고 5 일 때, 두 사용자의 요청으로 각각 수량 3을 주문
    • 두 트랜젝션이 모두 Product.stock >= quantity를 충족
    • 둘 다 차감하고 커밋하면 최종 재고 -1
    • 혹은 업데이트를 위해 조회한 5를 기준으로 나중의 것이 덮어쓰기 되어 재고 2
  • 비즈니스적 문제
    • 실제보다 많은 수량을 판매한 것처럼 기록되어 오배송, 품절, 환불 요청 등으로 재고 관리의 어려움 발생
    • 물류를 직접 다루는 도메인에서는 실패를 감내하기 어려움

2. 쿠폰 선착순 발급

  • 기능 설명: 쿠폰 정책별 잔여 수량에 대하여 사용자에게 쿠폰을 발급 하는 경우
  • 예측되는 동시성 문제: Race condition → 쿠폰 초과 발급
  • 발생 시나리오
    • remainingCount = 1 일 때, 두 사용자가 동시에 발급 요청
    • 두 요청 모두 remainingCount > 0 조건을 충족
    • 둘 다 반영되어 최종 잔여가 -1이 되거나, 사용자 각각의 잘못된 쿠폰이 초과 발급
  • 비즈니스적 문제
    • 예산을 초과하는 쿠폰 발급으로 인한 마케팅 비용에 손실이 있음
    • 공정성 문제가 발생하고, 사용자의 신뢰도가 하락함
    • 발생하지 않는 것이 제일 좋지만, 마케팅과 사후 안내를 통한 소량의 초과 발급분 까지 인정하고,
    • 회사의 손실보다 사용자의 이익이 강조되는 상황에선 역으로 마케팅이 가능하여 어느 정도 실패를 감내할 수 있음

3. 쿠폰 사용

  • 기능 설명: 주문 생성 중 쿠폰을 사용하는 경우
  • 예측되는 동시성 문제: Race condition → 쿠폰의 중복 사용
  • 발생 시나리오
    • 정상적으로 발급된 쿠폰 A에 대하여, 한 사용자가 두 번의 주문을 동시에 요청
    • 요청 시 둘 다 used == false 조건으로 사용 상태로 업데이트
    • 하나의 쿠폰이 두번 사용됨
  • 비즈니스적 문제
    • 쿠폰 관리 시스템에 대한 신뢰도 저하
    • 악용 사례로 인한 부수적인 피해 발생
    • 발급된 쿠폰에 대한 접근은 발급을 받은 사용자 한명에게만 유효하여 한 주문에 실패 정도는 감내 가능

4. 잔액 차감

  • 기능 설명: 주문 생성 중 잔액을 차감하는 경우
  • 예상되는 동시성 문제: Race condition → 잔액의 중복 차감, 덜 차감
  • 발생 시나리오
    • 사용자 A의 잔액이 5,000인 상태에서, 총액 3,000, 4,000의 주문 동시 발생
    • 조건 5,000 >= 주문금액을 모두 통과하고, 음수 잔액 -2,000 혹은, 두 개의 주문 총액보다 덜 차감됨
  • 비즈니스적 문제
    • 음수 잔액으로 인한 후속 결제 문제, 공급사가 별도로 있다면 정산에도 오류가 발생
    • 일부 월말 통합 결제, 미수금 결제 등 B2B 사업에서는 허용될 수 있으나,
    • 충전식으로 사용되는 e-commerce 서비스에서는 신뢰성이 크게 하락할수 있음.
    • 다만, 잔액은 사용자 단위의 자원이므로, 한 주문 요청을 실패시키는 것에 대한 감내 가능.

5. 잔액 충전 및 사용

  • 기능 설명: 잔액 충전 요청과 동시에 주문 생성을 통한 잔액차감 발생
  • 예상되는 동시성 문제: Race condition → 충전과 차감이 충돌하여 잔액 오류 발생
  • 발생 시나리오
    • 사용자 A가 잔액이 5,000이 있는 상태에서, 3,000의 주문과 3,000의 충전을 동시에 요청
    • 충전 및 사용에 조건이 동시에 통과해 동시에 잔액을 참조
    • 주문보다 충전이 늦게 완료되어 주문 금액이 무시되고, 충전 금액으로 덮어쓰기 됨
  • 비즈니스적 문제
    • 충전 후에도 잔액이 감소되어있거나, 반대로 사용했음에도 잔액이 남아있어 신뢰성이 무너짐
    • 다만, 잔액은 사용자 단위의 자원이므로, 한가지 요청을 실패시키는 것으로 감내 가능.

6. 주문 생성 전체 흐름

  • 기능 설명: 하나의 트랜잭션 내에서 3개의 자원(Product, Coupon, Balance)을 함께 처리
  • 예상 동시성 문제: Deadlock → 교착 상태 발생
  • 발생 시나리오 1
    • 주문 생성 트랜젝션: 상품 → 쿠폰 → 잔액 순으로 접근
    • 주문 생성 외 트랜젝션: 쿠폰 → 상품, 또는 잔액 → 상품 순서 처럼 역방향으로 접근
    • 서로 자원 점유 순서가 다르면 서로 다른 자원 점유 후 상대 자원 대기
    • 주문 생성 OrderFacade에서 Product > Coupon > Balance 순으로 프로세스 동작
    • 현재 시나리오 상으로는 발생 가능성이 낮음
  • 발생 시나리오 2
    • 주문 상품의 정렬 없이, 상품 목록 [1, 3, 5][3, 1]의 주문이 동시에 요청됨
    • PK 1과 3의 자원을 점유한 상태로 교착 상태에 빠짐ㅠㅠ
  • 비즈니스적 문제
    • 교착 상태로 인한 지속적인 주문 실패는 매우 크리티컬 하여, 서비스 전체적인 신뢰도가 하락함
    • 반복적인 재시도 및 CS 요청 처리가 지속적으로 발생
    • 교착 상태는 감내할 수 없는 치명적인 장애로, 반드시 배제할 수 있도록 예방 설계가 필요

DB락을 통한 동시성 문제 해결 방법

1. 상품 재고 차감

  • 문제: Race condition → 재고 음수 발생, 차감 누락

    • 경쟁의 강도: 높음 – 다수의 사용자가 같은 인기 상품에 동시에 접근
    • 예측 트래픽: 사용자 수에 비례 – 수천~수만 트랜잭션까지 병행 가능
    • 실패 감내: 불가 – 재고 차감 실패 시 물류, 환불, 품절 처리 등 직접적인 운영 비용 발생
  • 해결 방법: 비관적 락(PESSIMISTIC_WRITE)

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Product findWithLockById(@Param("id") Long id);
    • 상품 목록은 PK 기준으로 정렬하여 조회해야 교착 상태 예방 가능
    • 정렬 예시: WHERE p.id IN :ids ORDER BY p.id

2. 쿠폰 선착순 발급

  • 문제: Race condition → 쿠폰 초과 발급

    • 경쟁의 강도: 매우 높음 – 마케팅 오픈 순간 다수 유저가 동시 요청
    • 예측 트래픽: 폭발적 – 몇 초간 수천 건 이상의 발급 요청 가능
    • 실패 감내: 일정 수준 허용 가능 – 마케팅 이슈로 감내/보상 가능한 수준의 실패 수용 가능 ( or 감내 불가? )
  • 해결 방법: 비관적 락, 재시도를 포함한 낙관적 락, 또는 조건부 업데이트(Optimistic 방식) ✅ 기반 처리

    @Modifying
    @Query("""
        UPDATE CouponPolicy cp
           SET cp.remainingCount = cp.remainingCount - 1
         WHERE cp.id = :policyId
           AND cp.remainingCount > 0
    """)
    int decreaseRemainingCount(@Param("policyId") Long policyId);
    • 조건부 업데이트 쿼리를 직접 수행하여 쿠폰을 발급
    • DB 락 만으로는 "선착순"이라는 비즈니스 로직을 해결하기 어려울 것으로 예측함
    • 추후 Redis를 학습하면 보완 가능할 것으로 보임

3. 쿠폰 사용

  • 문제: Race condition → 쿠폰의 중복 사용

    • 경쟁의 강도: 낮음 – 동일한 쿠폰은 한 사용자에게만 할당되므로 한 사용자의 요정 내 경쟁 발생
    • 예측 트래픽: 낮음 – 동일 쿠폰을 한 사용자가 두 번 이상 요청할 경우에 한정
    • 실패 감내: 허용 가능 – 중복 사용 감지 시 하나의 주문만 실패해도 문제 없음
  • 해결 방법: 조건부 업데이트 (Optimistic 방식)

    @Modifying(clearAutomatically = true) // 자동 초기화
    @Query("""
        UPDATE Coupon c
           SET c.used = true
         WHERE c.id = :couponId
           AND c.user.id = :userId
           AND c.used = false
    """)
    int markCouponAsUsed(@Param("couponId") Long couponId, @Param("userId") Long userId);
    • used = false 여야 동작 가능. 1 또는 0으로 반환

4. 잔액 차감 - 주문 중복 요청

  • 문제: Race condition → 잔액의 중복 차감, 덜 차감

    • 경쟁의 강도: 중간 - 사용자 단위의 balance 자원은 충전 및 사용 동시 요청, 중복 요청 등으로 접근이 가능함
    • 예측 트래픽: 중간 - 악의적으로 혹은 전송의 오류로 인하여 여러 요청을 보낼 수 있음
    • 실패 감내: 허용 가능 - 한 주문의 통과하고, 그 외 일부 주문의 실패는 사용자 한명에게 치명적이지 않고, 오류 메세지로 충분히 반환 가능함
  • 해결 방법: 낙관적 락(Optimistic Lock), JPA @Version 활용

    @Entity
    @Table(name = "balance")
    public class Balance {
        /* ... */
    
        @Version
        private int version;
      
        /* ... */
    }
    • 일부 주문의 실패는 시스템 재시도 없이 사용자에게 실패 안내를 통한 재시도 요청으로 진행

5. 잔액 충전 및 사용 - 주문과 충전의 충돌, 충전 동시 요청

  • 문제: Race condition → 충전과 차감이 동시에 발생하며, 충전 또는 차감이 유실되거나 덮어써지는 문제
    • 경쟁의 강도: 중간 – 충전은 사용자에 의한 명시적 액션이지만, 사용 요청과 겹칠 수 있음
    • 예측 트래픽: 중간 – 보통 명시적 충전은 많지 않지만, 악의적, 간헐적으로 동시에 발생 가능
    • 실패 감내: 허용 가능 – 둘 중 하나만 반영되어도 다시 재시도 가능
  • 해결 방법: 낙관적 락(Optimistic Lock), JPA @Version 활용
    • 4번과 동일

6. 주문 생성 전체 흐름 - Product → Coupon → Balance

  • 문제: Deadlock – 여러 자원을 동시에 접근할 때 발생하는 교착 상태

    • 경쟁의 강도: 높음 – 인기 상품, 쿠폰, 잔액 차감이 동일한 타이밍에 발생 가능
    • 예측 트래픽: 높음 – 사용자 수와 동시에 증가, 상품 재고 차감과 동일한 수준
    • 실패 감내: 불가 – 전체 주문 실패는 CS 대응, 신뢰도 저하로 이어질 수 있음
  • 해결방법: 자원 접근 순서 고정 + 배열 자원 정렬 처리(상품 목록) - 이미 OrderFacade에 순서와 정렬은 구현됨

    • 접근 순서 일관화

      • 다른 트렌젝션을 포함하여 항상 Product → Coupon → Balance 순의 접근으로 프로세스 구현
    • 배열 자원 정렬

      • IN 절로 여러 상품을 조회하는 경우 반드시 PK 순으로 정렬

        @Query("SELECT p FROM Product p WHERE p.id IN :ids ORDER BY p.id")
        List<Product> findAllByIdWithLock(@Param("ids") List<Long> ids);
      • 혹은 주문 상품을 순회하여 재고를 차감하는 경우 배열을 정렬

        List<OrderItem> items = orderItems.stream()
            .sorted(Comparator.comparing(OrderItemCommand::getProductId)) // 상품 순서 정렬
            .map(command -> {
                Product product = productService.verifyAndDecreaseStock(command.getProductId(), command.getQuantity());
                return OrderItem.of(product, product.getPrice(), command.getQuantity(), 0);
            }) 
            .toList();

피드백

  • 조건부 업데이트 @Modifying 쿼리 사용의 적절성 양호함
  • 락이 적용되지 않았는데도, CannotAcquireLockException로 인해 동시성 문제가 발생하지 않는 경우 더 확인해보기

마치며

  • 동시성에 대해서 고민을 많이 해보지 않는 환경에서 작업을 하고 있다보니, 내가 부족했던 부분이 무엇 인 지 알 수 있는 과정이어서 좋았습니다.
  • DB락 대신 @Modifying 쿼리를 통한 더 성능이 좋은 동시성 제어를 사용하긴 했으나, 이게 사용성이 과연 좋을 까? 하는 생각에 최종적으로 테스트 결과에서 낙관적 락을 쓰는 것이 좋겠다고 작성하였습니다.
  • 이부분에 대해서 DB의 부하가 적은 방법으로 처리해서 BP를 주셨다고 하여, 살짝 부끄러운 결과를 도출했구나 또 하는 생각이 들었습니다.
  • 그래도 다양한 동시성 이슈와 제어 방법을 알게 되는 좋은 과정이었습니다.

0개의 댓글