-
[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;문제는, 동시에 여러 사람이 같은 상품을 주문할 때다.
- 사용자 A, B가 거의 동시에
SELECT stock_qty를 실행 - 둘 다
stock_qty >= qty라고 판단 - 둘 다
UPDATE실행 - 결국 재고가 음수가 되거나, 기대와 다른 값이 된다.
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. 무엇을 트랜잭션 안에 넣을 것인가?
주문 생성 기준으로 보면, 트랜잭션 안에 들어가야 하는 것은:
- 재고 조회 + 잠금 (
WITH FOR UPDATE) - 재고 차감 (
UPDATE products SET stock_qty = stock_qty - ...) - 주문/주문아이템 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로 확장되는 첫 단계가 된다.
7. 정리
- 재고/계좌/포인트 등 동시성이 민감한 데이터는
SELECT ... FOR UPDATE/with_for_update()+ 트랜잭션으로 다루는 것이 일반적이다.
- 트랜잭션 안에는
- 재고 차감, 주문 생성, 상태 변경 등 핵심 정합성 로직만 넣는다.
- 트랜잭션 밖에는
- 로그, 이벤트, 알림, 요약/통계 등 best effort 처리를 둔다.
- 이렇게 “트랜잭션 경계”를 고민하는 과정이
튜토리얼에서는 잘 안 보이는, 실무와 학생 사이의 깊이 차이를 메워주는 핵심 포인트다.
이 시리즈를 기반으로,
- 실제 프로젝트의 주문/재고 로직을 리뷰해 보거나,
- 개인 프로젝트에서도 일부러
with_for_update/ 트랜잭션을 써 보는 연습을 해 보면 좋다.
'업무 자동화 > Database' 카테고리의 다른 글
[Database] 주문 생성 API 아키텍처 - 6 (0) 2025.12.19 [Database] 실무 체크리스트 & 마이그레이션 (MongoDB ↔ MariaDB) - 5 (0) 2025.12.18 [Database] MongoDB vs MariaDB - 4 (0) 2025.12.17 [Database] MongoDB vs MariaDB, 설계 관점 비교 - 3 (0) 2025.12.15 [Database] MongoDB 기본 개념과 용어 정리 - 2 (0) 2025.12.11 - 사용자 A, B가 거의 동시에