원천 4종을 하나의 통합재고로 연결하기: 공통 메모리 형식과 승인 매핑 설계
서로 다른 원천 재고 4종을 물리적으로 합치지 않고 공통 메모리 형식으로 정규화한 과정과, canonical snapshot 기반 fixture·row map·hash 설계를 정리합니다.
이 기능이 필요했던 배경
해당 서비스는 그리팅, 이커머스, 오프라인, 물류센터로 나뉘어 관리되는 재고 정보를 하나의 통합재고로 활용하는 서비스입니다. 각 시스템은 자신에게 필요한 방식으로 데이터를 저장하고 있었기 때문에, 같은 재고를 가리키더라도 컬럼명과 값의 기준이 서로 달랐습니다.
예를 들어 가용 수량 하나만 해도 오프라인은 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 같은 기존 행을 업데이트합니다.
통합 목표
서로 다른 원천을 같은 기준으로 연결합니다.

문제는 컬럼명이 아니라 책임의 경계였습니다
네 원천은 모두 “재고”를 말하지만 같은 언어를 사용하지 않습니다. 가용 수량만 해도 오프라인은 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
데이터 계층
현재값, 연결 정보, 업무 데이터를 섞지 않습니다.

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·정책·수량의 정식 모델입니다. |
| 원천 current | INVENTORY_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와 시연용 원천 차감 이력을 보관합니다. |

이 범위는 BackEnd/docs/erd/통합재고조회-흐름배치-한글-snapshot.json과 BackEnd/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 → LOT | PRODUCT.product_id → SKU.product_id → LOT.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_BALANCE의 on_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에서 추가되었습니다. |

공통 의미로 번역하는 방식
실제 필드 변환의 중심은 Java adapter보다 InventorySyncSourcePageMapper.xml의 SQL projection입니다. 원천별 SELECT가 서로 다른 컬럼을 같은 alias와 타입으로 맞추고, CanonicalInventoryRecord가 그 결과를 받습니다.
| 공통 의미 | OFFLINE | ECOMMERCE | GREETING | WAREHOUSE |
|---|---|---|---|---|
| 상품명 | item_name | display_product_title | menu_name | center_product_name |
| SKU명 | branch_option_name | seller_option_name | greeting_sku_name | center_sku_name |
| 판매 가격 | store_sale_price_amount | discounted_sale_price | member_price_amount | 없음 |
| 가용 수량 | available_stock_count | sellable_quantity | saleable_meal_count | physical_available_qty |
| 예약 수량 | reserved_stock_count | ordered_quantity | committed_meal_count | physical_reserved_qty |
| 안전재고 | safe_stock_count | safety_quantity | minimum_stock_count | center_safety_qty |
| 소비기한 | use_by_date_text | expires_on_text | consume_by_text | expiry_date_text |
SQL은 이름만 바꾸지 않습니다. 문자열 날짜를 TO_DATE(..., 'YYYY-MM-DD')로 바꾸고, 그리팅의 sold_out_flag를 sale_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가 양수인지, 수량이 음수가 아닌지, reservedQty가 onHandQty보다 크지 않은지 검사합니다. 검증을 통과하지 못한 행은 임의의 기본값으로 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가 맡습니다.
필드 표준화
원천 컬럼명을 서비스가 이해하는 공통 필드로 번역합니다.

원천 키와 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와 명시적으로 연결합니다.

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_id | publish 시 갱신할 행을 명시적으로 결정합니다. |
| 매핑 상태 | mapping_method, mapping_status, mapping_version, validated_at | 승인되지 않은 매핑을 운영 경로에서 사용하지 않도록 합니다. |
| 감사 정보 | 생성·수정 사용자와 시각 | 누가 어떤 매핑을 승인했는지 역추적합니다. |

현재 초기 매핑은 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_RUN | client_request_id, request_hash, run_status, current_phase, fencing_token, read_count, changed_count, error_count | 중복 요청을 멱등하게 묶고, 실행 전체의 상태와 통계를 남깁니다. |
INVENTORY_SYNC_RUN_SOURCE | source_type, start_version, end_version, start_checksum, end_checksum, source_status | 같은 실행 안에서도 원천별 시작·종료 기준과 결과를 분리합니다. |
INVENTORY_SOURCE_STATE | current_version, last_verified_checksum, last_success_sync_run_id, last_success_synced_at | 다음 실행이 비교할 원천별 기준선을 보관합니다. |
INVENTORY_SYNC_ERROR | source_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_MATJIP | ECOMMERCE |
GREETING | GREETING |
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에서 원천 입력을 만듭니다.

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로 확인합니다.
변경 검증
바뀐 행만 반영하고 첫 동기화 기준선을 확인합니다.

시연용 차감 테이블은 정식 정규화 경로와 분리합니다
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_ADJUSTMENT | canonical 업무 테이블 |
|---|---|---|
| 목적 | 시연·검증을 위한 원천 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_hash와 synced_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 행을 안전하게 갱신하는 데 초점을 맞춥니다. 그래서 원천에만 새로 등장한 상품을 자동으로 PRODUCT나 SKU로 만들지 않고, 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.mddocs/integrated-inventory/CONTRACTS.mddocs/integrated-inventory/README.mddocs/integrated-inventory/INTEGRATED-INVENTORY-CODE-OVERVIEW.mddocs/plans/2026-08-20-002-feat-source-normalization-sync-plan.mddocs/plans/2026-08-22-001-feat-integrated-inventory-closeout-plan.mdBackEnd/docs/erd/통합재고조회-흐름배치-한글-snapshot.jsonBackEnd/docs/erd/flyway-v24-full-schema-mermaid.mddocs/integrated-inventory/FLYWAY-SCHEMA-CATALOG.md
실제 구현 경로
BackEnd/src/main/java/com/stockit/backend/feature/inventorysync/adapter/CanonicalInventoryRecord.javaBackEnd/src/main/java/com/stockit/backend/feature/inventorysync/adapter/InventorySourceAdapter.javaBackEnd/src/main/resources/mappers/inventorysync/InventorySyncSourcePageMapper.xmlBackEnd/src/main/java/com/stockit/backend/feature/inventorysync/mapper/InventorySyncSourcePageMapper.javadummy-data/scripts/generate_inventory_source_current.mjsdummy-data/scripts/lib/source/canonical-source-adapter.jsdummy-data/db/seed/inventory_source_current/README.mddummy-data/tests/inventory-source-current.test.mjsdummy-data/tests/source-seed-contract.test.mjs
작업 기준 커밋
| 저장소 | 커밋 | 확인한 변경 |
|---|---|---|
| FrontEnd | 9489ed7 → 7fb9673 → baf1b60 | 통합재고 조회 화면, 상세·위험 UI, 동기화 상태 화면 |
| BackEnd | a9ac335 → b510342 | 통합재고 조회·위험·수요예측, 원천 동기화 API와 worker |
| dummy-data | 8448940 → 88df80a | canonical inventory fixture와 원천 current generator |
위 커밋은 “원천 통합”만의 단일 커밋 목록이 아니라, 현재 기능이 어떤 작업 흐름 위에 쌓였는지 확인하기 위한 저장소별 기준점입니다. 숫자와 상태는 2026-08-23 문서·checkout 기준입니다.
