본문으로 건너뛰기

테이블 설계

테이블 설계는 업무 데이터를 어떤 단위로 저장하고, 어떤 기준으로 이력을 남기며, 장애가 났을 때 어디서 추적할지 정하는 작업이다. 유통 시스템은 주문 한 건이 재고, 출고, 배송, 반품, 정산, 외부 연동까지 이어지기 때문에 테이블을 너무 크게 합치면 변경과 추적이 어려워진다.

실무 팁

조회가 편하다는 이유로 하나의 테이블에 모든 상태와 수량을 넣으면 초기 개발은 빠르지만, 부분 출고와 반품이 들어오는 순간 수정 비용이 커진다. 현재 상태 테이블과 이력 테이블을 분리하는 습관이 중요하다.

개념 정의

유통 시스템 테이블은 보통 다음 기준으로 나눈다.

구분설명예시
기준정보상품, 거래처, 창고, 로케이션처럼 업무의 기준이 되는 데이터products, partners, warehouses
업무 문서주문서, 출고서, 반품서처럼 업무 단위를 표현하는 데이터orders, outbound_orders, returns
라인 데이터상품별 수량과 금액을 가진 상세 행order_lines, outbound_lines
이력 데이터상태 변경, 재고 증감, 인터페이스 처리 이력status_history, stock_ledger
연동 데이터외부 시스템 송수신 메시지와 처리 결과interface_messages, api_request_logs

전체 구조 예시

이 ERD는 실제 시스템의 축약 예시다. 중요한 점은 주문, 출고, 배송, 반품을 한 테이블에 넣지 않고 각 도메인의 책임에 맞게 나눈다는 것이다.

실제 업무 흐름

  1. 주문이 접수되면 orders, order_lines가 생성된다.
  2. 출고 요청이 만들어지면 outbound_orders, outbound_lines가 생성되고 원주문과 연결된다.
  3. 피킹, 검수, 패킹 과정에서 출고 상태와 작업 이력이 쌓인다.
  4. 출고 확정 시 stock_ledger에 재고 차감 이력이 기록되고, stock_snapshots의 현재고가 갱신된다.
  5. 배송 지시 후 deliveries에 송장과 배송 상태가 저장된다.
  6. 반품이 들어오면 returns, return_lines, return_pickups가 원주문과 연결된다.
  7. 외부 연동은 interface_messages와 로그 테이블에 송수신 결과를 남긴다.

화면/기능 관점 설명

화면은 보통 여러 테이블을 조합해 보여준다.

화면주요 테이블설계 포인트
주문 상세orders, order_lines, outbound_orders주문 상태와 출고 진행률을 함께 표시
출고 상세outbound_orders, outbound_lines, picking_tasks작업 상태와 확정 수량을 분리
재고 상세stock_snapshots, stock_ledger현재고와 증감 원인을 같이 추적
배송 상세deliveries, delivery_tracking_logs송장 생성과 배송 추적 이벤트를 분리
반품 상세returns, return_lines, stock_ledger판정 결과가 재고 복구와 연결됨
연동 모니터링interface_messages, interface_message_logs실패 메시지와 재처리 횟수를 표시

백엔드 API 관점 설명

API는 테이블을 직접 드러내기보다 업무 행위를 기준으로 만든다.

API내부 처리
POST /orders주문 헤더와 라인 생성, 중복 주문 검증
POST /orders/{id}/request-outbound출고 헤더와 라인 생성, 재고 예약
POST /outbounds/{id}/confirm출고 확정, 재고 원장 기록, 배송 준비
POST /deliveries/{id}/invoice송장 생성, 택배사 연동 로그 저장
POST /returns반품 헤더와 라인 생성, 원주문 연결
POST /interfaces/{id}/retry실패 메시지 재처리, 재시도 횟수 증가

하나의 API가 여러 테이블을 갱신할 때는 트랜잭션 경계를 명확히 해야 한다. 외부 API 호출까지 한 트랜잭션에 묶기보다는 내부 데이터 저장 후 outbox나 인터페이스 메시지로 비동기 처리하는 방식이 안정적이다.

데이터베이스 테이블 관점 설명

주요 테이블 예시는 다음과 같다.

테이블주요 컬럼설명
ordersid, order_no, order_status, customer_id, ordered_at주문 헤더
order_linesid, order_id, product_id, ordered_qty, canceled_qty주문 상품 라인
outbound_ordersid, order_id, outbound_status, warehouse_id출고 업무 헤더
outbound_linesid, outbound_id, product_id, requested_qty, confirmed_qty출고 상품 라인
stock_snapshotsproduct_id, warehouse_id, location_id, stock_qty현재고 스냅샷
stock_ledgerid, product_id, movement_type, qty, source_type, source_id재고 증감 이력
deliveriesid, outbound_id, invoice_no, delivery_status배송 정보
returnsid, order_id, return_status, reason_code반품 헤더
interface_messagesid, source_system, target_system, status, idempotency_key연동 메시지

상태값 예시

상태 컬럼은 도메인별로 나눈다.

테이블상태 컬럼상태 예시
ordersorder_statusRECEIVED, CONFIRMED, CANCELED
outbound_ordersoutbound_statusREQUESTED, PICKING, PACKED, CONFIRMED
deliveriesdelivery_statusINVOICED, SHIPPED, DELIVERED
returnsreturn_statusREQUESTED, ARRIVED, INSPECTED, REFUNDED
interface_messagesstatusPENDING, PROCESSING, SUCCESS, FAILED

하나의 status 이름을 모든 테이블에 재사용하면 의미가 흐려진다. CONFIRMED가 주문 확정인지, 출고 확정인지, 정산 확정인지 분명하지 않기 때문이다.

예외 상황

예외테이블 설계 대응
주문이 부분 취소됨order_lines.canceled_qty와 상태 이력을 함께 기록
부분 출고 발생outbound_lines.confirmed_qty를 요청 수량과 분리
출고 확정 후 취소원장 삭제 대신 반대 방향 재고 원장 기록
송장 중복 생성deliveries.invoice_no unique 또는 idempotency key 적용
외부 연동 실패interface_messages.status, error_message, retry_count 기록
재고 수량 불일치stock_snapshotsstock_ledger 합계 비교 가능하게 설계

실무에서 자주 생기는 문제

  • 주문 테이블에 출고, 배송, 정산 컬럼이 계속 추가되어 테이블 의미가 흐려진다.
  • 수량 컬럼 이름이 qty 하나뿐이라 주문 수량인지 확정 수량인지 알기 어렵다.
  • 현재고만 저장하고 원장을 남기지 않아 재고가 왜 바뀌었는지 추적하지 못한다.
  • 상태 변경 이력이 없어 운영자가 장애 원인을 찾지 못한다.
  • 외부 연동 요청과 응답 로그가 없어 택배사, ERP, WMS 중 어디서 실패했는지 알 수 없다.

설계 시 주의사항

테이블을 나눌 때는 "같은 생명주기를 갖는 데이터인가"를 기준으로 판단한다. 주문과 출고는 연결되어 있지만 생명주기가 다르다. 주문은 취소될 수 있고, 출고는 창고 작업 단위로 쪼개질 수 있으며, 배송은 송장 단위로 다시 나뉠 수 있다.

삭제보다 취소 상태를 우선한다. 이미 출고, 재고, 정산, 연동에 영향을 준 데이터는 물리 삭제하면 추적이 끊긴다. 운영 화면에서는 숨기더라도 데이터는 상태와 이력으로 남겨야 한다.

간단한 예시 테이블

CREATE TABLE stock_ledger (
id BIGINT PRIMARY KEY,
product_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NULL,
movement_type VARCHAR(30) NOT NULL,
qty DECIMAL(18, 3) NOT NULL,
source_type VARCHAR(30) NOT NULL,
source_id BIGINT NOT NULL,
reason_code VARCHAR(30) NULL,
created_at TIMESTAMP NOT NULL
);

CREATE TABLE interface_messages (
id BIGINT PRIMARY KEY,
source_system VARCHAR(30) NOT NULL,
target_system VARCHAR(30) NOT NULL,
message_type VARCHAR(50) NOT NULL,
idempotency_key VARCHAR(100) NOT NULL,
status VARCHAR(30) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP NULL,
UNIQUE (target_system, message_type, idempotency_key)
);

관련 문서 링크