Saleor Core Codemap: Channel / Order / Warehouse
한국형 멀티테넌트 이커머스 플랫폼 포크를 위한 아키텍처 분석 문서 분석 대상: saleor-core (Django + GraphQL)
1. Channel 모듈 (멀티테넌시 핵심)
1.1 Channel 모델 필드 해설
| 필드 | 타입 | 용도 |
|---|---|---|
name | CharField(250) | 채널 표시명 (예: “Korea Store”) |
slug | SlugField(255, unique) | URL/API 식별자, 전역 고유 |
is_active | BooleanField | 채널 활성화 여부 |
currency_code | CharField | 채널 기본 통화 (예: KRW) |
default_country | CountryField | 채널 기본 국가 (예: KR) |
allocation_strategy | CharField | 재고 할당 전략 (정렬순 우선 / 재고량 우선) |
order_mark_as_paid_strategy | CharField | 수동 결제 처리 방식 (Transaction / Payment flow) |
default_transaction_flow_strategy | CharField | 결제 흐름 (인증만 / 즉시 청구) |
automatically_confirm_all_new_orders | BooleanField | 주문 자동 확인 여부 |
allow_unpaid_orders | BooleanField | 미결제 주문 허용 여부 |
automatically_fulfill_non_shippable_gift_card | BooleanField | 디지털 기프트카드 자동 이행 |
expire_orders_after | IntegerField(nullable) | 주문 자동 만료 시간 (분) |
delete_expired_orders_after | DurationField | 만료 주문 삭제 대기 기간 (기본 60일) |
include_draft_order_in_voucher_usage | BooleanField | 바우처 사용량에 초안주문 포함 여부 |
use_legacy_error_flow_for_checkout | BooleanField | 레거시 체크아웃 에러 흐름 사용 |
automatically_complete_fully_paid_checkouts | BooleanField | 완불 체크아웃 자동 완료 |
draft_order_line_price_freeze_period | PositiveIntegerField | 초안 주문 라인 가격 갱신 주기 (시간) |
상속:
ModelWithMetadata— private_metadata JSON 필드 포함
1.2 Channel 전략 Enum 정리
AllocationStrategy:
PRIORITIZE_SORTING_ORDER -- 채널 내 warehouse 순서대로 할당
PRIORITIZE_HIGH_STOCK -- 재고가 가장 많은 warehouse부터 할당
MarkAsPaidStrategy:
TRANSACTION_FLOW -- TransactionItem으로 결제 처리
PAYMENT_FLOW -- Payment 객체로 결제 처리
TransactionFlowStrategy:
AUTHORIZATION -- 인증만 (이후 수동 캡처)
CHARGE -- 즉시 청구
1.3 Channel Penetration Map (채널 참조 모델 전체)
Saleor에서 Channel은 거의 모든 비즈니스 도메인에 FK/M2M으로 침투해 있다 (Channel 상세). 아래는 models.py 기준 직접 참조 목록:
graph TD CH[Channel] subgraph "직접 FK/M2M 참조" O[Order] -->|FK, PROTECT| CH CK[Checkout] -->|FK, PROTECT| CH PCL[ProductChannelListing] -->|FK, CASCADE| CH PVCL[ProductVariantChannelListing] -->|FK, CASCADE| CH CCL[CollectionChannelListing] -->|FK, CASCADE| CH VCL[VoucherChannelListing] -->|FK, CASCADE| CH SMCL[ShippingMethodChannelListing] -->|FK, CASCADE| CH CW[ChannelWarehouse] -->|FK, CASCADE| CH WH[Warehouse] -->|M2M through CW| CH GRP[Group] -->|M2M| CH end subgraph "간접 참조 (ChannelListing 패턴)" P[Product] -.->|via PCL| CH PV[ProductVariant] -.->|via PVCL| CH COL[Collection] -.->|via CCL| CH V[Voucher] -.->|via VCL| CH SM[ShippingMethod] -.->|via SMCL| CH end
ChannelListing 모델 상세
| ChannelListing 모델 | 소속 모듈 | 주요 필드 | 역할 |
|---|---|---|---|
ProductChannelListing | product | visible_in_listings, available_for_purchase_at, discounted_price | 상품의 채널별 게시 상태/가격 |
ProductVariantChannelListing | product | price, cost_price, prior_price, discounted_price, preorder_quantity_threshold | 옵션(SKU)별 채널 가격 |
CollectionChannelListing | product | (PublishableModel 상속) | 컬렉션의 채널별 게시 상태 |
VoucherChannelListing | discount | discount_value, min_spent | 바우처의 채널별 할인액/최소주문 |
ShippingMethodChannelListing | shipping | price, minimum_order_price, maximum_order_price | 배송방법의 채널별 요금 |
VariantChannelListingPromotionRule | product | discount_amount | 프로모션 룰 적용 결과 (variant+channel 조합) |
1.4 채널 기반 필터링 메커니즘
Query 레벨: GraphQL resolver에서 get_user_accessible_channels()로 사용자가 접근 가능한 채널 목록을 조회한 뒤, channel_id__in= 조건으로 필터링.
# saleor/graphql/order/resolvers.py
accessible_channels = get_user_accessible_channels(info, info.context.user)
channel_ids = [channel.id for channel in accessible_channels]
qs = qs.filter(channel_id__in=channel_ids)Warehouse 레벨: WarehouseQueryset.for_channel(channel_id) — Channel-Warehouse M2M through 테이블 기준으로 필터.
Stock 레벨: StockQuerySet.for_channel_and_country() — Channel → Warehouse → ShippingZone 3중 조인으로 특정 채널+국가에 유효한 재고만 반환.
1.5 Permission Scoping by Channel
graph LR U[User] -->|belongs_to| G[Group] G -->|has| P[Permissions] G -->|M2M| CH[Channels] G -->|flag| R[restricted_access_to_channels] R -->|true| RESTRICTED["특정 채널만 접근"] R -->|false| FULL["전체 채널 접근"]
Group.restricted_access_to_channels = True이면Group.channelsM2M에 연결된 채널만 접근 가능False이면 모든 채널에 접근 가능- **App (API 클라이언트)**는 항상 전체 채널 접근 (
check_channel_permissions에서 App이면 바로 return) - Mutation 레벨에서
check_channel_permissions(info, [channel_id])로 채널 접근 권한 검증
2. Order 모듈
2.1 핵심 모델 관계도
erDiagram Order ||--o{ OrderLine : "lines (CASCADE)" Order ||--o{ Fulfillment : "fulfillments (CASCADE)" Order ||--o{ OrderEvent : "events (CASCADE)" Order ||--o{ OrderGrantedRefund : "granted_refunds (CASCADE)" Order }o--|| Channel : "channel (PROTECT)" Order }o--o| User : "user (SET_NULL)" Order }o--o| ShippingMethod : "shipping_method (SET_NULL)" Order }o--o| Warehouse : "collection_point (SET_NULL)" Order }o--o| Voucher : "voucher (SET_NULL)" Order }o--o{ GiftCard : "gift_cards (M2M)" OrderLine }o--o| ProductVariant : "variant (SET_NULL)" OrderLine ||--o{ FulfillmentLine : "fulfillment_lines (CASCADE)" OrderLine ||--o{ Allocation : "allocations (CASCADE)" OrderLine ||--o{ OrderGrantedRefundLine : "granted_refund_lines (CASCADE)" Fulfillment ||--o{ FulfillmentLine : "lines (CASCADE)" FulfillmentLine }o--o| Stock : "stock (SET_NULL)" OrderGrantedRefund ||--o{ OrderGrantedRefundLine : "lines (CASCADE)" OrderGrantedRefund }o--o| TransactionItem : "transaction_item (SET_NULL)"
2.2 Order 상태 머신
stateDiagram-v2 [*] --> DRAFT: staff가 수동 생성 [*] --> UNCONFIRMED: checkout 완료 (자동확인 꺼짐) [*] --> UNFULFILLED: checkout 완료 (자동확인 켜짐) DRAFT --> UNFULFILLED: place order DRAFT --> EXPIRED: expire_orders_after 초과 UNCONFIRMED --> UNFULFILLED: confirm order UNCONFIRMED --> CANCELED: cancel UNCONFIRMED --> EXPIRED: expire_orders_after 초과 UNFULFILLED --> PARTIALLY_FULFILLED: 일부 라인 fulfill UNFULFILLED --> FULFILLED: 전체 라인 fulfill UNFULFILLED --> CANCELED: cancel (fulfillment 없을 때만) PARTIALLY_FULFILLED --> FULFILLED: 나머지 라인 fulfill PARTIALLY_FULFILLED --> PARTIALLY_RETURNED: 일부 반품 PARTIALLY_FULFILLED --> CANCELED: cancel (active fulfillment 없을 때) FULFILLED --> PARTIALLY_RETURNED: 일부 반품 FULFILLED --> RETURNED: 전체 반품 PARTIALLY_RETURNED --> RETURNED: 나머지 반품 CANCELED --> [*] EXPIRED --> [*] RETURNED --> [*]
상태 결정 로직 (determine_order_status)
if quantity_fulfilled - quantity_awaiting_approval <= 0:
status = UNFULFILLED
elif 0 < quantity_returned < total_quantity:
status = PARTIALLY_RETURNED
elif quantity_returned == total_quantity:
status = RETURNED
elif quantity_fulfilled - quantity_awaiting_approval < total_quantity:
status = PARTIALLY_FULFILLED
else:
status = FULFILLED2.3 Fulfillment 상태
| 상태 | 의미 |
|---|---|
FULFILLED | 배송 완료 (또는 발송됨) |
REFUNDED | 환불 처리됨 |
RETURNED | 반품 처리됨 |
REFUNDED_AND_RETURNED | 환불 + 반품 |
REPLACED | 교환 처리됨 |
CANCELED | 이행 취소됨 |
WAITING_FOR_APPROVAL | 승인 대기 |
2.4 주문 생성 흐름 (Checkout → Order)
1. Checkout 완료 요청
└─ _create_order_from_checkout() [complete_checkout.py:1370]
├─ _create_order() -- Order 객체 생성 (channel FK 복사)
├─ _create_order_lines_from_checkout_lines() -- CheckoutLine → OrderLine 변환
├─ _create_order_discount() -- 바우처/프로모션 할인 복사
└─ order_created() [actions.py:286]
├─ user.number_of_orders += 1
├─ order_created_event 기록
├─ [[📒 Saleor Webhooks|webhook]]: ORDER_CREATED 발송
├─ 결제 상태에 따라 order_authorized / order_charged 호출
└─ channel.automatically_confirm_all_new_orders이면 order_confirmed 호출
2.5 주문 취소 흐름
cancel_order() [actions.py:421]
├─ order_canceled_event 기록
├─ deallocate_stock_for_orders() -- 미이행 라인의 재고 할당 해제
├─ order.status = CANCELED
├─ webhook: ORDER_CANCELLED, ORDER_UPDATED
└─ send_order_canceled_confirmation -- 고객 알림
취소 가능 조건 (Order.can_cancel()):
- active fulfillment이 없어야 함 (CANCELED, REFUNDED, RETURNED, REPLACED만 허용)
- 주문 상태가 CANCELED, DRAFT, EXPIRED가 아니어야 함
2.6 환불 흐름
order_refunded() [actions.py:455]
├─ payment_refunded_event 기록
├─ send_order_refunded_confirmation
├─ 총 환불액 계산 (Payment.transactions + TransactionItem.refunded_value)
├─ total_refunded >= order.total.gross이면 ORDER_FULLY_REFUNDED webhook
└─ webhook: ORDER_REFUNDED, ORDER_UPDATED
관련 모델:
OrderGrantedRefund -- 승인된 환불 (NONE → PENDING → SUCCESS/FAILURE)
OrderGrantedRefundLine -- 환불 대상 라인별 수량
2.7 결제 상태 시스템
Order는 fulfillment 상태와 별도로 **결제 상태**를 독립 추적:
| 필드 | 값 | 의미 |
|---|---|---|
authorize_status | NONE / PARTIAL / FULL | 인증(사전승인) 상태 |
charge_status | NONE / PARTIAL / FULL / OVERCHARGED | 실제 청구 상태 |
3. Warehouse / Stock 모듈
3.1 모델 관계도
erDiagram Warehouse ||--o{ Stock : "stock_set (CASCADE)" Warehouse }o--o{ Channel : "M2M through ChannelWarehouse" Warehouse }o--o{ ShippingZone : "M2M" Warehouse }o--|| Address : "address (PROTECT)" ChannelWarehouse }o--|| Channel : "channel (CASCADE)" ChannelWarehouse }o--|| Warehouse : "warehouse (CASCADE)" Stock }o--|| ProductVariant : "product_variant (CASCADE)" Stock ||--o{ Allocation : "allocations (CASCADE)" Stock ||--o{ Reservation : "reservations (CASCADE)" Allocation }o--|| OrderLine : "order_line (CASCADE)" Reservation }o--|| CheckoutLine : "checkout_line (CASCADE)" PreorderAllocation }o--|| OrderLine : "order_line (CASCADE)" PreorderAllocation }o--|| ProductVariantChannelListing : "(CASCADE)" PreorderReservation }o--|| CheckoutLine : "checkout_line (CASCADE)" PreorderReservation }o--|| ProductVariantChannelListing : "(CASCADE)"
3.2 재고 수량 계산
available_quantity = Stock.quantity - SUM(Allocation.quantity_allocated)
예약 포함:
available_quantity = Stock.quantity
- SUM(Allocation.quantity_allocated)
- SUM(Reservation.quantity_reserved WHERE reserved_until > now)
Stock.quantity: 물리적 재고량Stock.quantity_allocated: 비정규화된 할당 총량 (성능 최적화)Allocation: 주문 라인별 실제 할당 (주문 확정 시 생성)Reservation: 체크아웃 중 임시 예약 (만료 시간 있음)
3.3 재고 할당 흐름
allocate_stocks() [management.py:80]
├─ 재고추적 대상 라인만 필터링
├─ channel.slug 기준으로 Stock 조회
│ ├─ collection_point이면: for_channel_and_click_and_collect()
│ └─ 일반 배송이면: for_channel_and_country(channel_slug, country_code)
├─ 기존 allocation 수량 집계
├─ sort_stocks(channel.allocation_strategy, ...)
│ ├─ PRIORITIZE_SORTING_ORDER: ChannelWarehouse.sort_order 기준
│ └─ PRIORITIZE_HIGH_STOCK: 가용 재고량 내림차순
├─ variant별 stocks 매핑
├─ 라인별 할당 생성 (_create_allocations)
│ └─ 부족하면 InsufficientStock 예외
└─ Allocation bulk_create + Stock.quantity_allocated 갱신
3.4 Warehouse 모델 주요 필드
| 필드 | 타입 | 용도 |
|---|---|---|
id | UUID | PK |
name | CharField(250) | 창고명 |
slug | SlugField(255, unique) | URL 식별자 |
channels | M2M(Channel) through ChannelWarehouse | 채널 연결 |
shipping_zones | M2M(ShippingZone) | 배송 가능 지역 |
address | FK(Address, PROTECT) | 창고 물리 주소 |
click_and_collect_option | CharField | DISABLED / LOCAL_STOCK / ALL_WAREHOUSES |
is_private | BooleanField | 비공개 창고 여부 |
4. 멀티테넌트 격리 평가
4.1 현재 Saleor의 Channel = “Soft Tenant”
Saleor의 Channel은 **논리적 격리(logical isolation)**를 제공하지만, 물리적 테넌트 격리는 아니다 (포크 분석 참고):
| 측면 | 현재 상태 | 포크 시 고려사항 |
|---|---|---|
| DB 격리 | 단일 DB, channel_id FK로 필터링 | 테넌트 간 데이터 누출 위험 — 모든 쿼리에 channel 필터 필수 |
| 상품 카탈로그 | ChannelListing 패턴으로 채널별 가격/게시 | 상품 자체는 공유 — 테넌트별 완전 분리 시 Product에도 tenant FK 필요 |
| 사용자 | User는 channel과 무관 (전역) | 멀티테넌트면 tenant-user 매핑 테이블 추가 필요 |
| 재고 | Warehouse-Channel M2M으로 채널별 창고 할당 | 이미 채널별 분리 가능, 물류 공유도 지원 |
| 권한 | Group.restricted_access_to_channels로 채널별 접근 제한 | App은 전체 접근 — 테넌트별 App 분리 필요 |
| 결제 | Order.channel FK로 채널별 결제 설정 분리 | 한국 PG(토스, KG이니시스 등) 연동 시 채널별 PG 설정 필요 (Payment App 개발) |
| URL 라우팅 | channel slug 기반 | 도메인별 테넌트 라우팅 레이어 추가 필요 |
4.2 포크 시 멀티테넌시 강화 방안
방안 1: Channel = Tenant (현재 구조 활용)
- 장점: 기존 코드 변경 최소화
- 단점: 상품/사용자 공유 문제, 누출 위험
- 적합: B2B SaaS가 아닌 자사 멀티브랜드 운영
방안 2: Tenant 모델 신설 + Channel 하위 배치
- Tenant → Channel (1:N)
- 모든 root 모델에 tenant_id FK 추가
- Row-Level Security (PostgreSQL RLS) 활용
- 적합: 완전한 SaaS 멀티테넌시
방안 3: Schema-per-Tenant
- django-tenants 패키지 활용
- 가장 강력한 격리, 가장 큰 마이그레이션 비용
4.3 누락 포인트 (현재 Saleor에 없는 것)
- Row-Level Security: 없음 — 모든 격리가 애플리케이션 레벨
- Tenant-aware 캐시: 없음 — 채널 slug를 캐시 키에 수동 포함 필요
- Cross-tenant 방지 인덱스: unique_together에 channel이 빠진 모델들 존재 (예: Order.number는 전역 unique)
- 감사 로그 격리: OrderEvent에 channel FK 없음 (order를 통해 간접 추적)
5. 한국 배송 연동 포인트
5.1 배송사 통합 아키텍처
graph TB subgraph "Saleor 기존 구조" SM[ShippingMethod] SZ[ShippingZone] SMCL[ShippingMethodChannelListing] F[Fulfillment] FL[FulfillmentLine] end subgraph "한국 배송 확장 필요" KSP[KR Shipping Provider Plugin] TN[tracking_number 활용] WH_API[Webhook: FULFILLMENT_CREATED] end SM --> SZ SM --> SMCL F --> FL F -->|tracking_number| TN TN -->|CJ대한통운, 한진, 로젠 등| KSP WH_API -->|비동기 배송 접수| KSP
5.2 주요 커스터마이징 포인트
| 영역 | 현재 Saleor | 한국 커스텀 필요사항 |
|---|---|---|
| 배송비 계산 | ShippingMethodChannelListing.price (고정/무게) | 도서산간 추가금, 제주 별도 요금, 무료배송 임계값 |
| 배송 추적 | Fulfillment.tracking_number (단순 문자열) | 택배사 API 연동, 배송 상태 webhook 수신 |
| 반품/교환 | Fulfillment RETURNED/REPLACED 상태 | 반품 택배 접수, 수거 예약 API |
| 송장 출력 | Invoice 모델 존재 | 한국 세금계산서 형식, 현금영수증 발행 |
| 주문번호 | 전역 sequence (order_order_number_seq) | 테넌트별 주문번호 체계 필요 시 sequence 분리 |
| 우편번호 | ShippingZone.countries + PostalCodeRule | 한국 5자리 우편번호 체계, 행정구역 기반 배송권역 (Address 모델) |
| 배송 상태 | FulfillmentStatus 7개 | ”집하”, “간선상차”, “배송출발”, “배달완료” 등 세분화 필요 |
5.3 Fulfillment 확장 제안
# 한국 배송사 연동을 위한 Fulfillment 모델 확장
class Fulfillment(ModelWithMetadata):
# 기존 필드
tracking_number = CharField(max_length=255)
status = CharField(choices=FulfillmentStatus.CHOICES)
# 한국 배송 확장 필드 (제안)
shipping_carrier = CharField() # CJ, HANJIN, LOGEN, LOTTE 등
shipping_carrier_code = CharField() # 택배사 코드
estimated_delivery_date = DateField() # 예상 배송일
actual_delivery_date = DateField() # 실제 배송일
delivery_message = TextField() # 배송 메모 (부재 시 문앞 등)5.4 한국 PG 연동 포인트
Order의 결제 흐름은 Channel의 default_transaction_flow_strategy로 결정:
- AUTHORIZATION: 카드 인증 → 수동 캡처 (일반적이지 않음)
- CHARGE: 즉시 결제 (토스페이먼츠, KG이니시스 등 한국 PG 표준 흐름)
Payment 플러그인 시스템을 통해 한국 PG를 연동하되, Channel 설정에서 CHARGE 전략을 기본으로 설정하면 된다.
6. 주요 파일 경로 요약
| 파일 | 역할 |
|---|---|
saleor/channel/models.py | Channel 모델 정의 |
saleor/channel/__init__.py | AllocationStrategy, MarkAsPaidStrategy, TransactionFlowStrategy |
saleor/channel/utils.py | get_default_channel (레거시 단일채널 호환) |
saleor/order/models.py | Order, OrderLine, Fulfillment, FulfillmentLine, OrderEvent, OrderGrantedRefund |
saleor/order/__init__.py | OrderStatus, FulfillmentStatus, OrderEvents, OrderChargeStatus 등 enum |
saleor/order/actions.py | 주문 생명주기 액션 (created, confirmed, canceled, refunded, fulfilled 등) |
saleor/order/utils.py | update_order_status, determine_order_status, create_order_line |
saleor/checkout/complete_checkout.py | Checkout → Order 변환 로직 |
saleor/warehouse/models.py | Warehouse, Stock, Allocation, Reservation, ChannelWarehouse |
saleor/warehouse/management.py | allocate_stocks, deallocate_stock, decrease_stock |
saleor/product/models.py | ProductChannelListing, ProductVariantChannelListing, CollectionChannelListing |
saleor/discount/models.py | VoucherChannelListing |
saleor/shipping/models.py | ShippingMethodChannelListing |
saleor/account/models.py | Group (restricted_access_to_channels, channels M2M) |
saleor/graphql/core/mutations.py | check_channel_permissions — 채널별 권한 검증 |
saleor/graphql/order/resolvers.py | 주문 쿼리에서 accessible_channels 필터링 |
관련 문서
핵심 개념
- 📒 Saleor Core Concepts — Saleor 핵심 개념 개요
- 📒 Saleor Architecture — 전체 아키텍처
- 📒 Saleor GraphQL — GraphQL API 구조
- 📒 Saleor Data Modeling — 데이터 모델링
Channel / Order / Warehouse
- 📒 Saleor Channels / 📒 Saleor Channels Detail — 채널 개요 및 상세
- 📒 Saleor Orders / 📒 Saleor Order Detail — 주문 개요 및 상세
- 📒 Saleor Stock — 재고 관리
- 📒 Saleor Checkout / 📒 Saleor Checkout Detail — 체크아웃 흐름
결제 / 배송 / 할인
- 📒 Saleor Payments / 📒 Saleor Payments Detail — 결제 시스템
- 📒 Saleor Payment App Development — Payment App 개발
- 📒 Saleor Sync Events Payment — 결제 Sync 이벤트
- 📒 Saleor Shipping Detail — 배송 상세
- 📒 Saleor Discounts — 할인/바우처
- 📒 Saleor Price Calculation — 가격 계산 로직
- 📒 Saleor Taxes — 세금 처리
상품 / 사용자 / 권한
- 📒 Saleor Products / 📒 Saleor Products Detail — 상품 모델
- 📒 Saleor Users — 사용자 모델
- 📒 Saleor Permissions — 권한 체계
- 📒 Saleor Address — 주소 모델
- 📒 Saleor Metadata — 메타데이터
확장 / 커스터마이징
- 📒 Saleor Apps / 📒 Saleor App Architecture — 앱 시스템
- 📒 Saleor Webhooks / 📒 Saleor Async Events — 웹훅 및 비동기 이벤트
- 📒 Saleor Extending Overview — 확장 방법 개요
- 📒 Saleor Core Fork Analysis — 코어 포크 분석