본문으로 건너뛰기

테이블 설계

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

실무 팁

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

개념 정의​

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

구분설명예시
기준정보상품, 거래처, 창고, 로케이션처럼 업무의 기준이 되는 데이터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_snapshots와 stock_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)
);

관련 문서 링크​