-
[Database] 주문 생성 API 아키텍처 - 6업무 자동화/Database 2025. 12. 19. 14:05
1. 전체 구조 한 번에 보기
1-1. 한 주문 생성 요청으로 일어나는 일
- 클라이언트가
/orders엔드포인트로 주문 요청을 보낸다. - API 서버는
- 요청 데이터를 검증하고,
- 유저/상품을 조회하고,
- 재고/상품 상태를 확인한 뒤,
- MariaDB 트랜잭션 안에서 주문/주문아이템/재고를 갱신하고,
- 커밋이 끝나면 MongoDB에 주문 이벤트/고객 요약을 기록한다.
- 에러 상황에 따라 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. 비즈니스 로직 단계
- 기본 검증
- user_id 존재 여부
- items 비어 있는지 여부
- 상품/재고 검증
- product_id 유효성
- is_active 여부
- stock_qty 충분 여부
- 주문 금액 계산
sum(product.price * quantity)
- 트랜잭션 시작
ordersINSERTorder_itemsINSERTproducts.stock_qty감소
- MongoDB 이벤트 기록
order_events에 주문 이벤트 insertcustomer_order_summary에 upsert
- 응답 반환
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 정도로만 처리하지만,
실무에서는 클라이언트가 “어떤 종류의 실패인지”를 구분할 수 있도록 설계한다.
'업무 자동화 > Database' 카테고리의 다른 글
[Database] 트랜잭션 & 동시성 제어 패턴 (재고 차감, with_for_update) - 7 (0) 2025.12.20 [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 - 클라이언트가