ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Database] 트랜잭션 & 동시성 제어 패턴 (재고 차감, with_for_update) - 7
    업무 자동화/Database 2025. 12. 20. 14:15

    1. 재고 차감에서 흔히 발생하는 동시성 문제

    1-1. 단순한 코드

    아주 단순하게 생각하면 재고 차감 코드는 이렇게 보일 수 있다.

    SELECT stock_qty FROM products WHERE id = :product_id;
    
    -- 재고 체크 (앱단 또는 프로시저)
    IF stock_qty >= :qty THEN
        UPDATE products
        SET stock_qty = stock_qty - :qty
        WHERE id = :product_id;
    END IF;

    문제는, 동시에 여러 사람이 같은 상품을 주문할 때다.

    1. 사용자 A, B가 거의 동시에 SELECT stock_qty 를 실행
    2. 둘 다 stock_qty >= qty 라고 판단
    3. 둘 다 UPDATE 실행
    4. 결국 재고가 음수가 되거나, 기대와 다른 값이 된다.

    1-2. 왜 이런 일이 발생하는가?

    • 각 트랜잭션이 “자기 눈에 보이는 스냅샷” 만 보고 판단하기 때문
    • RDB는 기본적으로 동시성을 허용하고,
    • 개발자가 잠금(lock)이나 트랜잭션 레벨을 명시적으로 설계해야 한다.

    2. SELECT ... FOR UPDATE / with_for_update()

    2-1. 개념

    SELECT ... FOR UPDATE

    • 해당 행(row)을 수정하려는 의도가 있는 상태로 읽는다는 의미
    • 읽은 순간, 다른 트랜잭션이 그 행을 수정하지 못하게 잠근다.
    • 보통 재고 차감, 계좌 이체 등 동시성이 민감한 로직에 사용한다.

    MariaDB/MySQL 기준 예:

    SELECT *
    FROM products
    WHERE id = :product_id
    FOR UPDATE;

    2-2. SQLAlchemy 비동기 코드에서의 사용

    Python + SQLAlchemy async에서 with_for_update() 를 사용하면 된다.

    stmt = (
        select(Product)
        .where(Product.id.in_(product_ids))
        .with_for_update()
    )
    result = await db.execute(stmt)
    products = result.scalars().all()

    이렇게 조회된 products 들은

    • 해당 트랜잭션이 끝날 때까지(커밋/롤백)
    • 다른 트랜잭션에 의해 수정될 수 없다.
    • 개념상으로는 “잠금 잡고 읽는다” 정도로 이해하면 된다.

    3. 트랜잭션 경계 설계

    3-1. 무엇을 트랜잭션 안에 넣을 것인가?

    주문 생성 기준으로 보면, 트랜잭션 안에 들어가야 하는 것은:

    1. 재고 조회 + 잠금 (WITH FOR UPDATE)
    2. 재고 차감 (UPDATE products SET stock_qty = stock_qty - ...)
    3. 주문/주문아이템 INSERT
    async with db.begin():
        # 1) 상품 조회 + 잠금
        stmt = (
            select(Product)
            .where(Product.id.in_(product_ids))
            .with_for_update()
        )
        result = await db.execute(stmt)
        product_map = {p.id: p for p in result.scalars().all()}
    
        # 2) 재고 체크 + 차감
        for item in payload.items:
            product = product_map[item.product_id]
            if product.stock_qty < item.quantity:
                raise HTTPException(
                    status_code=409,
                    detail=f"Insufficient stock for product {product.id}",
                )
            product.stock_qty -= item.quantity
    
        # 3) 주문/주문아이템 생성
        order = Order(
            user_id=payload.user_id,
            total_price=total_price,
            status="CREATED",
        )
        db.add(order)
        await db.flush()
    
        for item in payload.items:
            product = product_map[item.product_id]
            order_item = OrderItem(
                order_id=order.id,
                product_id=product.id,
                quantity=item.quantity,
                unit_price=product.price,
            )
            db.add(order_item)

    이 블록 안에서 오류가 발생하면 전체가 롤백된다.

    3-2. 무엇을 트랜잭션 밖에 둘 것인가?

    • MongoDB 로그 기록
    • 고객 요약 upsert
    • 알림 발송(이메일/SMS/푸시)
    • 외부 시스템 연동(예: 슬랙 알림, 메시지 큐 등)

    이런 것들은 핵심 재고/주문 정합성과는 별도로 생각해야 한다.

    정리

    • 트랜잭션 안: 돈, 재고, 상태 같이 “틀리면 큰일 나는 것”
    • 트랜잭션 밖: 로그, 이벤트, 알림 같이 “나중에 보정 가능하고, 최대한 기록하면 좋은 것”

    4. 에러와 롤백, 그리고 재시도 전략

    4-1. 재고 부족 시 처리

    재고가 부족한 경우에는

    • 트랜잭션 안에서 예외를 발생시키고,
    • HTTP 409(Conflict)로 응답하는 것이 자연스럽다.
    if product.stock_qty < item.quantity:
        raise HTTPException(
            status_code=409,
            detail=f"Insufficient stock for product {product.id}",
        )

    이 예외가 발생하면

    • async with db.begin(): 블록이 롤백되고,
    • 주문/주문아이템/재고 변경은 모두 취소된다.

    4-2. DB 예외 vs 비즈니스 예외

    • 비즈니스 예외
      • 재고 부족, 비활성 상품, 잘못된 상태 전이 등
      • HTTP 400/409 등으로 명확하게 반환
    • DB 예외/시스템 예외
      • DB 연결 끊김, 타임아웃, 인덱스 문제 등
      • HTTP 500으로 캡슐화 + 로그/모니터링

    실무에서는

    • 비즈니스 예외는 사용자/클라이언트가 어떻게 행동해야 하는지를 알려주는 역할
    • 시스템 예외는 개발/운영팀이 대응해야 할 장애로 구분해서 관리한다.

    4-3. 재시도 전략

    • 클라이언트가 같은 요청을 재시도할 수 있는지 판단하기 위해
    • API를 멱등(idempotent) 하게 설계하는 것도 중요하다.

    예시

    • X-Idempotency-Key 같은 헤더로 중복 주문 방지를 구현하기도 한다.
    • 이 부분은 별도 글에서 심화로 다룰 수 있다.

    5. with_for_update 를 쓸 때 주의할 점

    5-1. 잠금 범위

    • with_for_update() 는 읽은 행에 대한 잠금을 건다.
    • 너무 많은 행을 한 번에 잠그면 경합이 심해져서 성능 문제가 생긴다.
      • 항상 “필요한 행만” 잠그도록 where 조건을 최대한 좁힐 것
      • 재고 차감 시, 해당 상품들만 in 조건으로 잠그는 식으로 설계

    5-2. 인덱스와 잠금

    • 조건에 맞는 행을 찾기 위해 전체 테이블 스캔이 일어나면,
    • 불필요한 범위에 잠금 영향이 갈 수 있다.
    • 인덱스 설계와 함께 고려해야 한다.

    5-3. 데드락(Deadlock) 가능성

    • 여러 트랜잭션이 복수의 자원을 서로 다른 순서로 잠그면 데드락이 발생할 수 있다.
    • 실무에서는
      • 잠글 자원의 순서를 통일하거나,
      • 최대한 잠금 범위를 줄이고,
      • 데드락 발생 시 재시도 로직을 넣는 전략을 사용한다.

    6. 트랜잭션 vs 이벤트, CQRS 로 확장하는 길

    현재 예제는

    • 쓰기(write): MariaDB 트랜잭션 + MongoDB 이벤트 기록
    • 읽기(read): MariaDB/MongoDB 혼용 가능

    이 구조를 더 확장하면 CQRS(Command Query Responsibility Segregation) 패턴으로도 이어질 수 있다.

    • Command (쓰기)
      • 주문 생성/취소/상태 변경 → RDB 기반 트랜잭션
    • Query (조회)
      • 고객별 주문 요약, 통계, 대시보드 → MongoDB 기반 읽기 최적화
      이 글에서는 CQRS 자체를 깊게 파고들지는 않지만,
      “트랜잭션 안/밖”을 나눠 생각하는 습관은 CQRS로 확장되는 첫 단계가 된다.

    7. 정리

    • 재고/계좌/포인트 등 동시성이 민감한 데이터
      • SELECT ... FOR UPDATE / with_for_update() + 트랜잭션으로 다루는 것이 일반적이다.
    • 트랜잭션 안에는
      • 재고 차감, 주문 생성, 상태 변경 등 핵심 정합성 로직만 넣는다.
    • 트랜잭션 밖에는
      • 로그, 이벤트, 알림, 요약/통계 등 best effort 처리를 둔다.
    • 이렇게 “트랜잭션 경계”를 고민하는 과정이
      튜토리얼에서는 잘 안 보이는, 실무와 학생 사이의 깊이 차이를 메워주는 핵심 포인트다.

    이 시리즈를 기반으로,

    • 실제 프로젝트의 주문/재고 로직을 리뷰해 보거나,
    • 개인 프로젝트에서도 일부러 with_for_update / 트랜잭션을 써 보는 연습을 해 보면 좋다.
Designed by Tistory.