본문으로 바로가기
KIM JUNHA Playful Logo Symbol

원천 4종을 하나의 통합재고로 연결하기: 공통 메모리 형식과 승인 매핑 설계

서로 다른 원천 재고 4종을 물리적으로 합치지 않고 공통 메모리 형식으로 정규화한 과정과, canonical snapshot 기반 fixture·row map·hash 설계를 정리합니다.

작성자 김준하
작성일수정일
원문 Markdown
밝은 배경에서 오프라인, 이커머스, 그리팅, 물류센터 재고가 공통 번역기와 검증·매핑을 거쳐 canonical 테이블로 연결되는 설명 도식

이 기능이 필요했던 배경

해당 서비스는 그리팅, 이커머스, 오프라인, 물류센터로 나뉘어 관리되는 재고 정보를 하나의 통합재고로 활용하는 서비스입니다. 각 시스템은 자신에게 필요한 방식으로 데이터를 저장하고 있었기 때문에, 같은 재고를 가리키더라도 컬럼명과 값의 기준이 서로 달랐습니다.

예를 들어 가용 수량 하나만 해도 오프라인은 available_stock_count, 이커머스는 sellable_quantity, 그리팅은 saleable_meal_count, 물류센터는 physical_available_qty라는 이름을 사용합니다. 어떤 원천에는 판매 가격이 있지만 물류센터에는 없고, 그리팅에는 식수 단위와 품절 플래그가 있으며, 물류센터에는 판매처에 배정되지 않은 공용 재고가 있습니다.

이 데이터를 원천 테이블별로 그대로 사용하면 통합재고를 조회하거나 동기화할 때마다 “어느 원천인가?”를 먼저 판단해야 합니다. 같은 의미의 값을 읽기 위해 기능마다 서로 다른 컬럼명을 알고 있어야 하고, 원천별 예외를 각 화면·서비스·배치에 반복해서 작성해야 합니다. 그 결과 새로운 원천이 추가되거나 컬럼 기준이 바뀔 때 수정해야 할 범위가 넓어지고, 잘못된 수량이나 가격을 canonical 업무 데이터에 반영할 위험도 커집니다.

그래서 이 작업에서는 네 원천 테이블을 물리적으로 하나로 합치는 대신, 각 원천의 현재 데이터를 우리 서비스가 이해할 수 있는 공통 형식으로 가공하고 정제하는 경계를 만들었습니다. 원천의 계약은 그대로 보존하고, 서비스가 사용하는 상품·SKU·가격·LOT·정책·재고 의미만 CanonicalInventoryRecord로 맞춘 뒤 기존 canonical 테이블과 연결합니다.

통합재고 기능을 설계하면서 가장 오래 붙잡은 문장은 “원천 데이터를 하나로 합친다”입니다. 이 표현만 보면 네 개의 원천 테이블을 하나의 거대한 테이블로 UNION ALL하는 그림이 먼저 떠오릅니다. 실제 구현은 다른 방향입니다.

네 원천 테이블은 그대로 둔 채, 각 원천의 현재값을 같은 의미를 가진 메모리 전용 record로 번역한 뒤 기존 canonical 테이블에 반영합니다. 원천의 차이는 숨기지 않고, 서비스가 읽는 의미만 한 곳에서 맞춥니다.

이 글에서는 통합재고 전체 기능이 아니라 원천 4종을 하나의 정식 데이터 모델로 연결하는 설계 과정만 다룹니다. 동기화 실행 버튼, 위험 재평가, 화면 조회 흐름은 다음 기록에서 이어서 정리할 예정입니다.

한 문장으로 요약하면

OFFLINE, ECOMMERCE, GREETING, WAREHOUSE의 current 행을 원천별 SQL projection으로 CanonicalInventoryRecord에 맞추고, 승인된 INVENTORY_SOURCE_ROW_MAP을 기준으로 PRODUCT, SKU, LOT, INVENTORY_BALANCE 같은 기존 행을 업데이트합니다.

통합 목표

서로 다른 원천을 같은 기준으로 연결합니다.

네 가지 원천 재고가 공통 번역기와 검증·매핑을 거쳐 PRODUCT, SKU, 가격, LOT, 정책, 재고 테이블로 연결되는 밝은 설명 도식
원천 테이블은 그대로 보존하고, 공통 번역기와 검증·매핑을 거쳐 canonical 업무 테이블에 연결합니다.

문제는 컬럼명이 아니라 책임의 경계였습니다

네 원천은 모두 “재고”를 말하지만 같은 언어를 사용하지 않습니다. 가용 수량만 해도 오프라인은 available_stock_count, 이커머스는 sellable_quantity, 그리팅은 saleable_meal_count, 물류센터는 physical_available_qty라는 이름을 씁니다.

이름만 다른 것이 아닙니다. 그리팅은 식수 단위와 품절 플래그를 가지고, 물류센터는 판매처가 없는 공용 재고를 가집니다. 이커머스에는 결제 수수료와 배송비가 있지만 물류센터에는 판매 가격이 없습니다. 공통 테이블 하나에 전부 욱여넣으면 컬럼은 많아지고, 어느 값이 필수인지도 흐려집니다.

그래서 설계 질문을 “테이블을 몇 개로 만들까?”에서 “한 행이 어떤 책임을 가지는가?”로 바꿉니다.

선택지장점문제가 되는 지점이번 설계
원천 4종을 공통 테이블 하나로 병합조회가 한 곳에서 시작됨원천별 필수값·상태·검증 책임이 사라지고 NULL 컬럼이 늘어남선택하지 않음
원천별 current는 유지하고 normalized staging 테이블을 영속화중간 결과를 재조회·감사하기 쉬움staging 스키마와 canonical 스키마가 함께 바뀌고, 중간 데이터의 수명·삭제 정책이 추가됨현재 범위에서는 선택하지 않음
원천별 current → 메모리 typed record → canonical target원천 계약을 보존하면서 publish 경계를 작게 유지한 번의 실행에서 buffer와 검증 흐름을 함께 관리해야 함선택

여기서 말하는 canonical은 “모든 데이터를 한 테이블에 저장한다”는 뜻이 아닙니다. 서비스가 상품, SKU, 가격, LOT, 정책, 재고를 어떤 의미로 다룰지 정한 정식 업무 모델입니다.

데이터 계층을 먼저 나눴습니다

처음부터 코드부터 읽으면 current, row map, source state, canonical이 한꺼번에 등장해 흐름을 놓치기 쉽습니다. 이 작업에서는 데이터의 수명과 책임을 기준으로 계층을 나눴습니다.

크롤링 파일 + 승인된 canonical snapshot
                │  (초기 fixture 생성 때만)
                ▼
원천 current 4종
  OFFLINE · ECOMMERCE · GREETING · WAREHOUSE
                │
                ├─ source_record_key로 현재 행을 식별
                ├─ INVENTORY_SOURCE_ROW_MAP으로 기존 PK 연결
                └─ record_hash / row_version으로 변경 기준 보관
                ▼
메모리 전용 CanonicalInventoryRecord
                │
                ├─ PRODUCT · SKU
                ├─ SKU_CHANNEL_PRICE · SKU_COST
                ├─ LOT · INVENTORY_POLICY
                └─ INVENTORY_BALANCE

데이터 계층

현재값, 연결 정보, 업무 데이터를 섞지 않습니다.

승인된 canonical snapshot에서 원천 current 네 종류, row map, CanonicalInventoryRecord, canonical 업무 테이블로 이어지는 데이터 계층과 실행 메타데이터의 관계
현재값·연결 정보·공통 record·canonical 업무 데이터·실행 메타데이터를 서로 다른 계층으로 나눕니다.

INVENTORY_SYNC_RUN 같은 실행 제어 테이블은 이 흐름에 포함되지만 재고 자체가 아닙니다. 실행 상태와 오류를 저장하는 운영 메타데이터입니다. 이런 데이터를 업무 테이블과 섞지 않아야 “현재 재고”와 “동기화가 실패했는가”를 서로 다른 질문으로 다룰 수 있습니다.

ERD와 Flyway를 함께 보면 범위가 세 겹으로 나뉩니다

DB 설계를 확인할 때 ERDCloud 그림만 보면 전체 스키마처럼 보이고, Flyway만 보면 통합재고와 관계없는 전략·예측 테이블까지 한꺼번에 들어옵니다. 저장소에 보관된 통합재고 ERD 스냅샷에는 이 기능에 직접 연결되는 22개 엔터티가 있고, Flyway V1~V24에는 서비스 전체 52개 테이블이 있습니다. 따라서 ERD에 없는 Flyway 테이블은 누락이라기보다 다른 기능의 범위입니다.

이번 글에서 다루는 DB 범위는 아래처럼 나뉩니다.

영역주요 테이블설계 책임
기준 업무 데이터PRODUCT, SKU, LOT, SKU_CHANNEL_PRICE, SKU_COST, INVENTORY_POLICY, INVENTORY_BALANCE통합재고가 최종적으로 사용하는 상품·가격·LOT·정책·수량의 정식 모델입니다.
원천 currentINVENTORY_SOURCE_OFFLINE, INVENTORY_SOURCE_ECOMMERCE, INVENTORY_SOURCE_GREETING, INVENTORY_SOURCE_WAREHOUSE네 시스템의 서로 다른 컬럼과 원천별 필수 조건을 그대로 보존합니다.
연결·검증INVENTORY_SOURCE_ROW_MAP, INVENTORY_SOURCE_STATE원천 키를 승인된 기준 PK에 연결하고 버전·checksum 기준을 기록합니다.
실행 운영INVENTORY_SYNC_MUTEX, INVENTORY_SYNC_RUN, INVENTORY_SYNC_RUN_SOURCE, INVENTORY_SYNC_ERROR중복 실행 방지, 실행 상태, 원천별 통계, 행 단위 오류를 분리합니다.
연계·보조RISK_ASSESSMENT, INVENTORY_DEMO_ADJUSTMENT재고 변경의 위험 평가 lineage와 시연용 원천 차감 이력을 보관합니다.
오프라인, 이커머스, 그리팅, 물류센터 원천 current가 승인 row map을 거쳐 상품, SKU, LOT, 가격, 원가, 정책, 재고 현황으로 연결되고 동기화 운영과 위험 평가가 분리된 DB 설계 범위
전체 ERD를 한 장에 욱여넣기보다 원천·연결·업무·운영 책임을 분리해 읽을 수 있게 구성했습니다.

이 범위는 BackEnd/docs/erd/통합재고조회-흐름배치-한글-snapshot.jsonBackEnd/docs/erd/flyway-v24-full-schema-mermaid.md를 기준으로 확인했습니다. V21~V24가 원천 current, durable sync, risk lineage, demo adjustment를 추가하고, V22에서 RISK_ASSESSMENT.inventory_sync_run_id를 연결하며, V24에서 SCHEDULED 트리거를 허용합니다. 최신 전체 마이그레이션이 궁금하다면 ERDCloud 통합재고조회와 저장소의 Flyway 카탈로그를 함께 보는 것이 안전합니다.

왜 원천 테이블을 네 개로 유지했는가

원천 current의 한 행은 특정 원천의 특정 SKU가 특정 위치·LOT에서 현재 어떤 수량과 가격·정책을 가지는가를 나타냅니다. 상품명과 SKU명이 LOT 행마다 반복되는 넓은 구조지만, 원천 전송 레코드에 가깝다는 목적에는 맞습니다.

오프라인은 지점과 판매 상태가 중요하고, 이커머스는 판매자 옵션과 결제·배송 비용이 필요합니다. 그리팅은 메뉴와 식수 단위를 보존해야 하며, 물류센터는 판매처에 배정되지 않은 공용 재고를 표현해야 합니다. 이 차이를 모두 공통 컬럼으로 만들면 “값이 없음”이 “이 원천에는 해당 개념이 없음”인지 “데이터가 누락됨”인지 구분하기 어려워집니다.

원천별 테이블은 각 계약을 DB 제약과 테스트로 확인할 수 있게 해줍니다. 예를 들어 물류센터 행의 assigned_sales_point_code는 현재 NULL이어야 합니다. 센터 공용 재고를 판매처 재고로 복제하면 통합재고 합계가 부풀기 때문입니다.

canonical 업무 테이블은 관계를 보존하도록 나눴습니다

정식 업무 데이터도 하나의 넓은 CANONICAL_INVENTORY 테이블로 만들지 않았습니다. 상품의 정체성, SKU의 판매 단위, LOT의 유통기한, 채널별 가격, 원가, 재고 정책, 실제 잔량은 변경 주기와 책임이 다르기 때문입니다. 관계를 분리해두면 원천 컬럼명이 바뀌어도 기존 상품·SKU·LOT의 의미를 다시 정의하지 않아도 됩니다.

관계핵심 컬럼이렇게 나눈 이유
상품 → SKU → LOTPRODUCT.product_idSKU.product_idLOT.sku_id상품, 판매 단위, 유통기한이 있는 물류 단위를 구분합니다. LOT가 바뀌어도 상품·SKU의 식별자는 유지됩니다.
SKU → 가격·원가SKU_CHANNEL_PRICE.sku_id, SKU_COST.sku_id판매처별 가격과 기간별 내부 원가를 분리합니다. 채널 가격의 PRODUCT_COST와 공통 원가의 UNIT_COST를 같은 의미로 덮어쓰지 않습니다.
SKU·센터·판매처 → 정책INVENTORY_POLICY.sku_id, warehouse_id, stock_sales_point_id안전재고·목표재고·보유비용은 위치와 판매처 조합의 정책입니다. 재고 수량 자체와 섞지 않습니다.
SKU·센터·판매처·LOT → 재고 현황INVENTORY_BALANCEon_hand_qty, reserved_qty, total_qty실제 보유량과 예약량을 분리하고 total_qty는 Oracle virtual column으로 계산합니다. 예약량을 다시 빼서 중복 차감하지 않습니다.
재고 현황 → 위험 평가RISK_ASSESSMENT.inventory_balance_id, inventory_sync_run_id어떤 재고와 어떤 동기화 실행을 근거로 위험도를 계산했는지 추적합니다. inventory_sync_run_id는 V22에서 추가되었습니다.
상품에서 SKU와 LOT로 이어지고 SKU의 가격과 원가, 센터와 판매처의 정책이 재고 현황으로 모인 뒤 위험 평가로 이어지는 canonical 업무 데이터 관계
업무 테이블은 원천별 컬럼을 담는 곳이 아니라, 서비스가 유지해야 하는 관계와 생명주기를 표현합니다.

공통 의미로 번역하는 방식

실제 필드 변환의 중심은 Java adapter보다 InventorySyncSourcePageMapper.xml의 SQL projection입니다. 원천별 SELECT가 서로 다른 컬럼을 같은 alias와 타입으로 맞추고, CanonicalInventoryRecord가 그 결과를 받습니다.

공통 의미OFFLINEECOMMERCEGREETINGWAREHOUSE
상품명item_namedisplay_product_titlemenu_namecenter_product_name
SKU명branch_option_nameseller_option_namegreeting_sku_namecenter_sku_name
판매 가격store_sale_price_amountdiscounted_sale_pricemember_price_amount없음
가용 수량available_stock_countsellable_quantitysaleable_meal_countphysical_available_qty
예약 수량reserved_stock_countordered_quantitycommitted_meal_countphysical_reserved_qty
안전재고safe_stock_countsafety_quantityminimum_stock_countcenter_safety_qty
소비기한use_by_date_textexpires_on_textconsume_by_textexpiry_date_text

SQL은 이름만 바꾸지 않습니다. 문자열 날짜를 TO_DATE(..., 'YYYY-MM-DD')로 바꾸고, 그리팅의 sold_out_flagsale_available_yn으로 반전하며, 물류센터처럼 판매 가격이 없는 원천에는 NULL을 명시합니다.

-- InventorySyncSourcePageMapper.xml 중 원천별 projection의 핵심
SELECT display_product_title AS product_name,
       option_enabled_flag AS sale_available_yn,
       seller_option_name AS sku_name,
       discounted_sale_price AS actual_price,
       TO_DATE(expires_on_text, 'YYYY-MM-DD') AS expiry_date,
       sellable_quantity AS on_hand_qty,
       ordered_quantity AS reserved_qty,
       record_hash
  FROM inventory_source_ecommerce
 WHERE active_yn = 'Y'
   AND is_deleted = 0

이 결과를 받는 CanonicalInventoryRecord는 DB staging table에 저장하지 않는 메모리 전용 record입니다. 생성자에서 productId, skuId, warehouseId, lotId, inventoryBalanceId가 양수인지, 수량이 음수가 아닌지, reservedQtyonHandQty보다 크지 않은지 검사합니다. 검증을 통과하지 못한 행은 임의의 기본값으로 publish하지 않습니다.

아래 코드는 실제 CanonicalInventoryRecord 전체 필드 중 이 글에서 설명하는 ID·수량 검증 부분만 줄여 옮긴 예시입니다. 실제 record에는 categoryId, salesPointId, 가격·원가·LOT·정책 필드와 원천 버전 정보가 함께 들어 있습니다.

public record CanonicalInventoryRecord(
    String sourceType,
    String sourceRecordKey,
    Long productId,
    Long skuId,
    Long warehouseId,
    Long lotId,
    Long inventoryBalanceId,
    BigDecimal onHandQty,
    BigDecimal reservedQty,
    String recordHash,
    long sourceVersion,
    long rowVersion
) {
    public CanonicalInventoryRecord {
        if (inventoryBalanceId == null || inventoryBalanceId <= 0) {
            throw new IllegalArgumentException("inventoryBalanceId must be positive");
        }
        if (onHandQty == null || onHandQty.signum() < 0) {
            throw new IllegalArgumentException("onHandQty must be non-negative");
        }
        if (reservedQty == null || reservedQty.signum() < 0) {
            throw new IllegalArgumentException("reservedQty must be non-negative");
        }
        if (reservedQty.compareTo(onHandQty) > 0) {
            throw new IllegalArgumentException("reservedQty must not exceed onHandQty");
        }
    }
}

여기서 adapter는 모든 변환을 다시 수행하는 거대한 클래스가 아닙니다. 현재 구현의 InventorySourceAdapter는 record의 sourceType이 자신이 담당하는 원천과 맞는지 확인하고 InventorySyncAttemptBuffer에 넣는 계약에 가깝습니다. 필드 변환은 SQL projection, 의미 검증은 typed record가 맡습니다.

필드 표준화

원천 컬럼명을 서비스가 이해하는 공통 필드로 번역합니다.

OFFLINE, ECOMMERCE, GREETING, WAREHOUSE의 서로 다른 컬럼을 SQL projection이 공통 alias로 바꾸고 CanonicalInventoryRecord가 수량과 ID를 검증하는 흐름
원천별 필드 변환은 SQL projection이 담당하고, record 생성자는 공통 의미의 최소 조건을 검사합니다.

원천 키와 canonical PK를 자동 추측하지 않았습니다

외부의 seller_option_no가 내부 sku_id와 같다고 가정하면 빠르게 연결할 수 있습니다. 하지만 이름이 바뀌거나 같은 이름의 SKU가 생기는 순간 잘못된 재고를 수정할 위험이 있습니다.

그래서 INVENTORY_SOURCE_ROW_MAP을 별도로 두고 원천 한 행을 기존 업무 행에 명시적으로 연결합니다.

source_type + source_record_key
  ├─ product_id
  ├─ sku_id
  ├─ sales_point_id (공용 센터 재고는 NULL)
  ├─ warehouse_id
  ├─ lot_id
  ├─ inventory_balance_id
  ├─ sku_channel_price_id
  └─ inventory_policy_id

승인 매핑

원천 키를 기존 canonical PK와 명시적으로 연결합니다.

source_record_key를 INVENTORY_SOURCE_ROW_MAP이 product, SKU, 판매처, 센터, LOT, inventory balance의 canonical PK와 연결하고 매핑 실패는 publish를 중단하는 흐름
원천 키와 canonical PK를 직접 비교하지 않고, 승인된 row map을 통해서만 publish 대상을 찾습니다.

INVENTORY_SOURCE_ROW_MAP의 컬럼은 단순한 외부 키 목록이 아닙니다. 원천 행의 식별자와 publish 대상의 여러 외래 키를 한 레코드에 함께 보관해, 동기화 코드가 이름 유사도나 순서에 의존하지 않도록 합니다.

컬럼 묶음대표 컬럼검증 포인트
원천 식별source_type, source_record_key같은 원천 안에서 한 행을 재현할 수 있어야 합니다.
기준 대상product_id, sku_id, sales_point_id, warehouse_id, lot_id실제 canonical 행이 존재하고 현재 source의 위치 의미와 맞아야 합니다.
반영 대상inventory_balance_id, sku_channel_price_id, sku_cost_id, inventory_policy_idpublish 시 갱신할 행을 명시적으로 결정합니다.
매핑 상태mapping_method, mapping_status, mapping_version, validated_at승인되지 않은 매핑을 운영 경로에서 사용하지 않도록 합니다.
감사 정보생성·수정 사용자와 시각누가 어떤 매핑을 승인했는지 역추적합니다.
원천 한 행의 source type과 source record key가 INVENTORY_SOURCE_ROW_MAP의 원천 키, 기준 ID, 매핑 상태, 매핑 버전을 거쳐 상품 SKU, LOT 가격, 재고 정책에 연결되는 도식
row map은 자동 추측 결과가 아니라 승인된 기준 ID와 매핑 상태를 함께 보관하는 연결 테이블입니다.

현재 초기 매핑은 mapping_method = INITIAL_EXACT, mapping_status = MAPPED 조합으로 기록합니다. 매핑이 없거나 필수 PK가 비어 있으면 해당 원천을 임의로 다른 상품에 연결하지 않고 fail-closed로 중단합니다. 데이터 통합에서 “일단 비슷한 이름으로 넣고 나중에 고친다”는 접근은 재고 수량과 가격을 함께 잘못 바꿀 수 있기 때문에 선택하지 않습니다.

동기화 실행과 원천별 상태를 별도 테이블로 나눴습니다

동기화가 한 번 실행되었다는 사실과 원천 current의 현재 상태는 같은 데이터가 아닙니다. INVENTORY_SYNC_RUN은 요청 하나의 생명주기를, INVENTORY_SYNC_RUN_SOURCE는 그 실행 안에서 원천별 처리량을, INVENTORY_SOURCE_STATE는 원천의 마지막 성공 기준선을, INVENTORY_SYNC_ERROR는 실패한 행의 맥락을 기록합니다. 네 책임을 한 테이블에 넣으면 “실행이 실패했지만 원천 상태는 마지막 성공 버전으로 유지되는” 상황을 표현하기 어려워집니다.

테이블대표 컬럼왜 필요한가
INVENTORY_SYNC_RUNclient_request_id, request_hash, run_status, current_phase, fencing_token, read_count, changed_count, error_count중복 요청을 멱등하게 묶고, 실행 전체의 상태와 통계를 남깁니다.
INVENTORY_SYNC_RUN_SOURCEsource_type, start_version, end_version, start_checksum, end_checksum, source_status같은 실행 안에서도 원천별 시작·종료 기준과 결과를 분리합니다.
INVENTORY_SOURCE_STATEcurrent_version, last_verified_checksum, last_success_sync_run_id, last_success_synced_at다음 실행이 비교할 원천별 기준선을 보관합니다.
INVENTORY_SYNC_ERRORsource_record_key, error_phase, field_name, error_code, error_message한 행의 어떤 필드가 projection·mapping·publish 중 실패했는지 기록합니다.
동기화 실행이 네 원천별 실행으로 나뉘고 읽은 행, 매핑된 행, 변경된 행, 오류 행 통계가 원천 상태와 오류 기록으로 모인 뒤 정상 반영으로 이어지는 운영 메타데이터 흐름
실행 메타데이터는 재고 업무 데이터와 분리되어 중복 실행·재시도·원천별 실패를 설명합니다.

초기 fixture는 운영 기능과 분리합니다

이 부분은 혼동하기 쉬워서 따로 강조합니다. dummy-data/scripts/generate_inventory_source_current.mjs는 운영 중 원천을 읽는 worker가 아니라 승인된 snapshot을 원천 current 모양으로 만드는 generator입니다.

방향은 런타임과 반대입니다.

fixture 생성: canonical snapshot → source current
실제 sync:    source current → CanonicalInventoryRecord → canonical

generator는 canonical_master_current.csv, canonical_price_current.csv, canonical_sku_cost_current.csv, canonical_lot_current.csv, canonical_inventory_policy_current.csv, canonical_inventory_balance_current.csv 여섯 snapshot을 읽습니다. baseline_manifest.json의 행 수와 checksum이 맞지 않으면 생성하지 않습니다.

INVENTORY_BALANCE 행은 allocated_sales_point_code 기준으로 정확히 하나의 원천에 배치합니다.

조건생성되는 원천
MODU_MATJIPECOMMERCE
GREETINGGREETING
HMART_ 또는 DEPT_ 접두사OFFLINE
할당 판매처 없음WAREHOUSE
알 수 없는 판매처생성 실패

이 규칙은 같은 balance를 채널 원천과 물류센터 원천에 동시에 복제하지 않게 합니다. 현재 승인 fixture 기준으로 OFFLINE 23,392, ECOMMERCE 325, GREETING 1,416, WAREHOUSE 8,225, 합계 33,358행이며 row map도 33,358건입니다.

초기 fixture

승인된 canonical snapshot에서 원천 입력을 만듭니다.

canonical snapshot 여섯 개를 generator가 키 기반으로 조인하고 원천 current 네 종류, row map, generation manifest, MERGE loader를 만드는 초기 fixture 생성 흐름
초기 fixture는 운영 sync의 입력을 만드는 별도 경로입니다. 생성 결과에는 source current, row map, manifest가 함께 남습니다.

generator는 원천별 serializer로 같은 값을 다른 컬럼명으로 표현합니다.

canonical on_hand_qty = 10
  → OFFLINE.available_stock_count = 10
  → ECOMMERCE.sellable_quantity = 10
  → GREETING.saleable_meal_count = 10
  → WAREHOUSE.physical_available_qty = 10

생성 후에는 각 행의 record_hash, row_version, fixture_generation_id를 붙이고, source CSV·row map·generated SQL의 checksum과 row count를 generation_manifest.json에 기록합니다. 같은 입력을 다시 넣어도 같은 결과가 나오는지 --verify로 확인합니다.

변경 검증

바뀐 행만 반영하고 첫 동기화 기준선을 확인합니다.

record_hash와 synced_record_hash를 비교해 변경된 행만 buffer와 MERGE로 보내고 같은 값이면 건너뛰어 첫 동기화 changed count 0을 확인하는 흐름
hash 비교는 변경 후보를 줄이는 단계이며, 실제 canonical 반영은 target별 비교와 transaction 안에서 수행합니다.

시연용 차감 테이블은 정식 정규화 경로와 분리합니다

ERD에서 INVENTORY_DEMO_ADJUSTMENT가 보이기 때문에 이 테이블이 원천 통합의 핵심이라고 오해하기 쉽습니다. V23 마이그레이션의 주석처럼 이 테이블은 local/demo 전용 원천 차감 audit이며, canonical 테이블을 직접 변경하지 않습니다. 기본 설정 app.inventory-sync.demo-enabled=false에서는 Controller 자체가 활성화되지 않습니다.

시연에서 특정 원천 행의 수량을 20에서 15로 바꾸고 싶을 때는 요청 hash와 원천 행 버전을 함께 기록합니다. 서비스는 source state와 source row를 잠근 뒤 current 수량·row version·hash를 바꾸고, INVENTORY_DEMO_ADJUSTMENT에 요청자·적용 시각·변경 전후 값을 남깁니다. 이후 실제 sync가 이 current 변경을 읽어 canonical publish 대상으로 판단합니다. 이렇게 하면 “시연을 위한 입력 변화”와 “정식 업무 데이터 반영”의 transaction을 혼동하지 않습니다.

구분INVENTORY_DEMO_ADJUSTMENTcanonical 업무 테이블
목적시연·검증을 위한 원천 current 변화의 audit서비스가 제공하는 상품·재고·위험 결과
변경 대상네 source current와 INVENTORY_SOURCE_STATE승인된 row map으로 연결된 기존 업무 행
안전장치request_hash, source row version 전후, rate limit, 중복 요청 방지typed record 검증, target 비교, publish transaction
운영 여부demo property가 켜진 로컬·검증 환경에서만 활성화실제 동기화 경로의 정식 데이터

따라서 이 테이블은 전체 DB 도식에서는 보조 영역으로 표시하고, 원천을 canonical로 번역하는 핵심 설계와 섞어 설명하지 않습니다.

MERGE와 no-op 기준선을 사용했는가

생성 SQL은 source record key를 기준으로 MERGE합니다. 같은 키가 있으면 값이 달라졌을 때만 갱신하고, 없으면 삽입합니다. 생성본에 없는 과거 generation 행은 물리 삭제하지 않고 논리 비활성화합니다. SQL 파일 자체에는 COMMIT을 넣지 않고 상위 seed entrypoint가 source current, row map, source state의 transaction 경계를 소유합니다.

초기 fixture를 canonical snapshot에서 역으로 만들었기 때문에 첫 sync는 업무적으로 아무 것도 바꾸지 않아야 합니다. 문서에 기록된 Oracle 실행 13은 원천 33,358행을 읽고 canonical 변경 0, 오류 0으로 끝난 실행입니다. 이 changed_count=0은 실패가 아니라 “생성한 원천과 정식 기준선이 일치한다”는 검증 결과입니다.

이 기준선이 있으면 이후 한 행의 수량을 바꾸었을 때 정말 변경된 행만 잡히는지 확인할 수 있습니다. 전체를 임의의 더미 값으로 채우고 첫 실행부터 대량 변경이 발생하면, generator가 틀린 것인지 publish가 틀린 것인지 분리하기 어려워집니다.

선택지를 비교하며 남긴 설계 결정

공통 staging 테이블을 만들지 않은 이유

중간 결과를 테이블로 저장하면 디버깅과 재조회는 편해집니다. 대신 staging의 schema, 보관 기간, 삭제, 권한, canonical과의 차이를 계속 관리해야 합니다. 현재 작업은 하나의 실행 안에서 projection·검증·buffer·publish를 끝내는 범위이고, 중간 결과를 장기 보관할 감사 요구도 없습니다. 그래서 typed record를 메모리에 두고 실행 이력과 예외만 영속화합니다.

자동 fuzzy matching을 넣지 않은 이유

상품명이나 SKU명을 기준으로 비슷한 행을 찾는 방법은 초기 데이터 생성량을 줄일 수 있습니다. 하지만 “비슷함”은 재고 데이터의 안전한 식별자가 아닙니다. 매핑이 틀리면 상품 속성뿐 아니라 LOT와 수량, 가격, 정책까지 함께 잘못 연결됩니다. 현재는 승인된 row map으로 범위를 제한하고, 신규 canonical row 자동 생성과 삭제 전파는 다음 설계 과제로 남깁니다.

전체 재적재 대신 changed-only 기준을 둔 이유

매번 모든 canonical 행을 덮어쓰면 구현은 단순해도 변경량과 잠금 시간이 커집니다. 반대로 record_hashsynced_record_hash가 다를 때만 buffer에 넣으면 같은 값은 건너뛸 수 있습니다. 다만 hash가 다르다고 canonical 값이 반드시 바뀌는 것은 아닙니다. 원천 전용 필드가 바뀌었거나 hash 사전이 확장된 경우도 있으므로, hash는 “검토할 후보”를 줄이는 기준이고 최종 publish는 target별 비교와 transaction이 담당합니다.

한 행의 흐름을 시퀀스로 그리면

아래는 런타임에서 원천 한 행을 공통 형식으로 바꾸는 부분만 단순화한 흐름입니다. 초기 fixture 생성은 이 방향의 반대입니다.

participant SourceCurrent as source current
participant PageMapper as InventorySyncSourcePageMapper
participant RowMap as INVENTORY_SOURCE_ROW_MAP
participant Record as CanonicalInventoryRecord
participant Buffer as InventorySyncAttemptBuffer
participant Target as canonical target row

SourceCurrent -> PageMapper: sourceType별 projection 조회
PageMapper -> RowMap: source_record_key로 기존 PK 조인
RowMap --> PageMapper: product / sku / lot / balance ID
PageMapper -> Record: 공통 alias + 날짜·수량 타입 전달
Record -> Record: 필수 ID·수량·상태 검증
Record -> Buffer: sourceType 일치 여부 확인 후 추가
Buffer -> Target: changed record만 publish transaction에서 반영

이 흐름에서 “하나로 합쳐졌다”는 말은 source current 테이블이 하나가 되었다는 뜻이 아닙니다. 같은 의미의 record가 잠시 하나의 메모리 경계에 모였다는 뜻입니다.

이번 설계에서 남은 한계

현재 구조는 기존 canonical 행을 안전하게 갱신하는 데 초점을 맞춥니다. 그래서 원천에만 새로 등장한 상품을 자동으로 PRODUCTSKU로 만들지 않고, canonical row가 없으면 매핑 검증에서 멈춥니다. 원천 삭제를 canonical 삭제로 전파하는 기능도 같은 이유로 범위 밖입니다.

또 하나의 경계는 hash 계약입니다. bootstrap generator는 source type별 필드 사전을 정규화해 SHA-256을 만들지만, demo 수량 조정 writer는 sourceRecordKey | 변경 후 수량 | 다음 rowVersion으로 변경 token을 만듭니다. 두 경로 모두 pending 행을 만드는 목적은 같지만 hash에 포함하는 값은 같지 않습니다. 운영 writer를 확장할 때 이 계약을 하나로 통일할지 결정해야 합니다.

이 한계를 남기는 이유는 구현을 덜 해서가 아니라, 자동 생성·삭제까지 포함하면 “원천 데이터의 권위”와 “canonical의 생명주기”를 새로 결정해야 하기 때문입니다. 현재 단계에서는 승인된 기존 행만 변경하는 편이 데이터 안전성과 검증 가능성이 높습니다.

마무리

이번 원천 통합에서 선택한 핵심은 테이블을 줄이는 것이 아닙니다. 서로 다른 시스템의 current 계약을 보존하고, 서비스가 이해할 공통 의미를 별도의 typed record로 정의한 뒤, 승인된 row map과 hash 기준으로 기존 canonical 행에 반영하는 경계를 정하는 일입니다.

정리하면 다음 구조입니다.

  • 원천 4종은 각자의 컬럼과 상태를 가진 current 테이블로 유지합니다.
  • InventorySyncSourcePageMapper.xml이 원천별 컬럼을 공통 alias와 타입으로 projection합니다.
  • CanonicalInventoryRecord가 ID·수량·상태의 최소 조건을 검사합니다.
  • INVENTORY_SOURCE_ROW_MAP이 원천 키와 canonical PK를 명시적으로 연결합니다.
  • generator는 승인된 canonical snapshot으로 fixture를 만들고, manifest·checksum·row map을 함께 남깁니다.
  • 첫 sync가 changed=0이 되도록 round-trip 기준선을 만듭니다.

이렇게 경계를 나누면 원천 시스템이 하나 더 늘어났을 때도 canonical 업무 로직 전체를 다시 쓰지 않고, 새 source current 계약·projection·adapter·mapping을 추가하는 방식으로 확장할 수 있습니다. 다음 글에서는 이 record가 202 Accepted 이후 실제 동기화 worker와 publish transaction을 어떻게 통과하는지 이어서 정리하겠습니다.

프로젝트 근거 자료

원천 통합 설계 문서

  • docs/integrated-inventory/SOURCE-TABLE-CONSOLIDATION-ELI5.md
  • docs/integrated-inventory/CONTRACTS.md
  • docs/integrated-inventory/README.md
  • docs/integrated-inventory/INTEGRATED-INVENTORY-CODE-OVERVIEW.md
  • docs/plans/2026-08-20-002-feat-source-normalization-sync-plan.md
  • docs/plans/2026-08-22-001-feat-integrated-inventory-closeout-plan.md
  • BackEnd/docs/erd/통합재고조회-흐름배치-한글-snapshot.json
  • BackEnd/docs/erd/flyway-v24-full-schema-mermaid.md
  • docs/integrated-inventory/FLYWAY-SCHEMA-CATALOG.md

실제 구현 경로

  • BackEnd/src/main/java/com/stockit/backend/feature/inventorysync/adapter/CanonicalInventoryRecord.java
  • BackEnd/src/main/java/com/stockit/backend/feature/inventorysync/adapter/InventorySourceAdapter.java
  • BackEnd/src/main/resources/mappers/inventorysync/InventorySyncSourcePageMapper.xml
  • BackEnd/src/main/java/com/stockit/backend/feature/inventorysync/mapper/InventorySyncSourcePageMapper.java
  • dummy-data/scripts/generate_inventory_source_current.mjs
  • dummy-data/scripts/lib/source/canonical-source-adapter.js
  • dummy-data/db/seed/inventory_source_current/README.md
  • dummy-data/tests/inventory-source-current.test.mjs
  • dummy-data/tests/source-seed-contract.test.mjs

작업 기준 커밋

저장소커밋확인한 변경
FrontEnd9489ed77fb9673baf1b60통합재고 조회 화면, 상세·위험 UI, 동기화 상태 화면
BackEnda9ac335b510342통합재고 조회·위험·수요예측, 원천 동기화 API와 worker
dummy-data844894088df80acanonical inventory fixture와 원천 current generator

위 커밋은 “원천 통합”만의 단일 커밋 목록이 아니라, 현재 기능이 어떤 작업 흐름 위에 쌓였는지 확인하기 위한 저장소별 기준점입니다. 숫자와 상태는 2026-08-23 문서·checkout 기준입니다.