ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Database] 주문 생성 API 아키텍처 - 6
    업무 자동화/Database 2025. 12. 19. 14:05

    1. 전체 구조 한 번에 보기

    1-1. 한 주문 생성 요청으로 일어나는 일

    1. 클라이언트가 /orders 엔드포인트로 주문 요청을 보낸다.
    2. API 서버는
      • 요청 데이터를 검증하고,
      • 유저/상품을 조회하고,
      • 재고/상품 상태를 확인한 뒤,
      • MariaDB 트랜잭션 안에서 주문/주문아이템/재고를 갱신하고,
      • 커밋이 끝나면 MongoDB에 주문 이벤트/고객 요약을 기록한다.
    3. 에러 상황에 따라 400/409/500 등 서로 다른 HTTP 코드를 반환한다.

    1-2. 데이터베이스 역할 분리

    • MariaDB (관계형, 트랜잭션 중심)
      • users, products, orders, order_items 테이블
      • 재고, 주문 금액, 상태 등 “틀리면 안 되는 숫자/상태”
      • 강한 정합성이 필요한 핵심 비즈니스 데이터
    • MongoDB (문서 지향, 로그/분석 중심)
      • order_events 컬렉션
      • customer_order_summary 컬렉션
      • 주문 이벤트 히스토리, 고객별 주문 요약 데이터 등
      • 통계/분석/추천에 사용할 부가 데이터
      실무 포인트
      • 돈/재고/정산 → RDB 트랜잭션
      • 로그/이벤트/요약/통계 → NoSQL/문서 DB
        라는 역할 분리를 코드 안에서 자연스럽게 구현한다.

    2. 도메인 모델과 테이블/컬렉션 설계

    2-1. MariaDB – 핵심 엔티티

    핵심 테이블 구조는 대략 다음과 같다.

    CREATE TABLE users (
        id INT PRIMARY KEY AUTO_INCREMENT,
        email VARCHAR(255) NOT NULL UNIQUE,
        name VARCHAR(100) NOT NULL,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    );
    
    CREATE TABLE products (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255) NOT NULL,
        price DECIMAL(10,2) NOT NULL,
        stock_qty INT NOT NULL DEFAULT 0,
        is_active TINYINT NOT NULL DEFAULT 1
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT NOT NULL,
        total_price DECIMAL(10,2) NOT NULL,
        status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users(id)
    );
    
    CREATE TABLE order_items (
        id INT PRIMARY KEY AUTO_INCREMENT,
        order_id INT NOT NULL,
        product_id INT NOT NULL,
        quantity INT NOT NULL,
        unit_price DECIMAL(10,2) NOT NULL,
        FOREIGN KEY (order_id) REFERENCES orders(id),
        FOREIGN KEY (product_id) REFERENCES products(id)
    );

    특징

    • 주문 시점의 상품 가격을 order_items.unit_price 에 스냅샷으로 저장
    • orders.total_price는 주문 전체 금액 캐시 역할
    • products.stock_qty 는 현재 재고 수량
    • orders.status는 주문 상태 (CREATED, PAID, CANCELLED 등 확장 가능)

    2-2. MongoDB – 이벤트/요약 문서

    1) 주문 이벤트 컬렉션

    {
      "_id": "ObjectId(...)",
      "order_id": 123,
      "user_id": 1,
      "total_price": 59000,
      "status": "CREATED",
      "items": [
        { "product_id": 10, "quantity": 1, "unit_price": 29000 },
        { "product_id": 11, "quantity": 1, "unit_price": 30000 }
      ],
      "event_type": "ORDER_CREATED",
      "created_at": "2025-12-08T10:00:00Z",
      "event_at": "2025-12-08T10:00:01Z"
    }

    2) 고객 주문 요약 컬렉션

    {
      "_id": "ObjectId(...)",
      "user_id": 1,
      "order_count": 5,
      "total_spent": 245000,
      "last_order_at": "2025-12-08T10:00:00Z",
      "created_at": "2025-11-01T12:00:00Z",
      "updated_at": "2025-12-08T10:00:01Z"
    }
    • order_count, total_spent, last_order_at 등을 한 도큐먼트에 갖고 있는 구조
    • 새로운 주문이 들어올 때마다 $inc, $max, $set 등으로 upsert

    3. API 레벨에서의 흐름 설계

    3-1. 요청/응답 스키마 정의

    요청(JSON)

    {
      "user_id": 1,
      "items": [
        { "product_id": 10, "quantity": 1 },
        { "product_id": 11, "quantity": 1 }
      ]
    }

    응답(JSON)

    {
      "order_id": 123,
      "user_id": 1,
      "total_price": 59000,
      "status": "CREATED",
      "items": [
        { "product_id": 10, "quantity": 1, "unit_price": 29000 },
        { "product_id": 11, "quantity": 1, "unit_price": 30000 }
      ],
      "created_at": "2025-12-08T10:00:00Z"
    }

    Pydantic 기준 스키마는 대략 다음과 같이 구성한다.

    class OrderItemCreate(BaseModel):
        product_id: int = Field(..., gt=0)
        quantity: conint(gt=0, le=1000)
    
    
    class OrderCreate(BaseModel):
        user_id: int = Field(..., gt=0)
        items: List[OrderItemCreate]

    3-2. 비즈니스 로직 단계

    1. 기본 검증
      • user_id 존재 여부
      • items 비어 있는지 여부
    2. 상품/재고 검증
      • product_id 유효성
      • is_active 여부
      • stock_qty 충분 여부
    3. 주문 금액 계산
      • sum(product.price * quantity)
    4. 트랜잭션 시작
      • orders INSERT
      • order_items INSERT
      • products.stock_qty 감소
    5. MongoDB 이벤트 기록
      • order_events 에 주문 이벤트 insert
      • customer_order_summary 에 upsert
    6. 응답 반환

    4. 핵심 코드 패턴: 트랜잭션과 이벤트 분리

    4-1. RDB 트랜잭션 블록

    Python + SQLAlchemy 비동기 기준 예시는 다음과 같다.

    async with db.begin():
        # 1) 주문 생성
        order = Order(
            user_id=payload.user_id,
            total_price=total_price,
            status="CREATED",
        )
        db.add(order)
        await db.flush()  # order.id 확보
    
        # 2) 재고 차감 + 주문 아이템 생성
        for item in payload.items:
            product = product_map[item.product_id]
    
            # 재고 차감
            product.stock_qty -= item.quantity
    
            order_item = OrderItem(
                order_id=order.id,
                product_id=product.id,
                quantity=item.quantity,
                unit_price=product.price,
            )
            db.add(order_item)
    • 이 블록 안에서 예외가 발생하면 자동으로 롤백
    • 성공하면 자동으로 커밋

    4-2. 트랜잭션 이후의 MongoDB 처리

    created_at = datetime.utcnow()
    order_doc = {
        "order_id": order.id,
        "user_id": payload.user_id,
        "total_price": total_price,
        "status": order.status,
        "created_at": created_at,
        "items": [
            {
                "product_id": oi.product_id,
                "quantity": oi.quantity,
                "unit_price": float(oi.unit_price),
            }
            for oi in order_items
        ],
    }
    
    try:
        await order_events_coll.insert_one(order_doc)
        await customer_summary_coll.update_one(
            {"user_id": order_doc["user_id"]},
            {
                "$inc": {
                    "order_count": 1,
                    "total_spent": float(order_doc["total_price"]),
                },
                "$set": {
                    "last_order_at": order_doc["created_at"],
                    "updated_at": datetime.utcnow(),
                },
                "$setOnInsert": {
                    "created_at": datetime.utcnow(),
                },
            },
            upsert=True,
        )
    except Exception:
        # 주문 자체는 성공 → DB 롤백은 하지 않음
        # 대신 로깅/모니터링 대상으로 남김
        pass

    실무 개념 포인트

    • 주문/재고는 완전한 트랜잭션이 필요하지만,
      이벤트/요약 데이터는 “최대한 기록하면 좋은” best effort 성격이다.
    • 두 가지를 같은 트랜잭션으로 묶으려 하면, 시스템이 과도하게 복잡해진다.

    5. 에러 코드/상태 설계

    5-1. HTTP 상태 코드 분리

    • 400 Bad Request
      • 없는 user_id, 없는 product_id
      • 잘못된 quantity 값 등
    • 409 Conflict
      • 재고 부족
      • 비활성 상품
    • 500 Internal Server Error
      • 트랜잭션 내부 예기치 않은 오류
      • DB 연결 문제 등

    5-2. 클라이언트 입장에서의 의미

    • 400 → 요청 자체를 수정해야 한다 (유저/상품/파라미터 문제)
    • 409 → 비즈니스 조건이 맞지 않는다 (재고 부족, 상태 충돌)
    • 500 → 서버/시스템 문제 (나중에 재시도 가능)
    • 튜토리얼에서는 보통 모든 에러를 400/500 정도로만 처리하지만,
      실무에서는 클라이언트가 “어떤 종류의 실패인지”를 구분할 수 있도록 설계한다.
Designed by Tistory.