판단 초안 · ADR 후보

코스팅 · 재고 절연 판단

두 앱이 한 D1을 공유하는 구조를 어떻게 풀지에 대한 설계 판단. 현행 절연과 Rails 8 신규 구축 두 경로를 함께 놓았다. 실행은 AX팀이 맡는다.

대상 obliv-stock · obliv-costing 날짜 2026-08-03 상태 판단만 — 실행 미착수

공유 D1을 정교하게 갈라내는 설계는 비용이 크다. 이참에 재고앱을 Rails 8 기반으로 새로 지어도 된다 — 그러면 아래에 정리한 절연 과제 대부분이 재작성에 흡수된다.

대신 등록된 SSOT 데이터의 리니지는 반드시 승계한다. 값이 누가·언제·왜 바뀌었는지가 이 시스템의 코어이고, 재작성에서 가장 먼저 흘리는 것도 그것이다. 스택은 바꿔도 이력은 못 끊는다.

현행 유지로 절연만 할 경우엔 전제 하나를 먼저 깨야 한다. “DB를 나누면 원가가 격리된다”는 틀렸다. D1 바인딩에는 read-only 모드가 없어서, DB를 갈라도 코스팅 Worker에 재고 DB를 물리는 순간 쓰기가 그대로 열린다.

추천

무엇을 먼저 하나 — 착수 순서 권고

Rails 8 신규 구축(경로 B)은 선택지로 열어둔다. 다만 지금 당장 착수할 것은 아니라고 본다. 아래는 “하냐 마냐”가 아니라 순서에 대한 권고다.

flowchart LR
  N["지금"] --> S1["계보 4개 상태 선언
active / frozen / superseded-by
반나절"] S1 --> S2["절연 1단
중복 정의 제거 · 소유권 명문화
단방향화 · CI 게이트
컷오버 없음 · 롤백 쉬움"] S2 --> D{"renewal 실사 2건"} D -->|"현행 커버리지 70% 이상"| RA["Rails 재검토 값어치 있음"] D -->|"30% 이하"| RB["사실상 신축 — 접고
절연 2단 여부만 판단"] H["절연 2·3단
착수 금지 — Rails로 가면 통째로 버려진다"] -.-> D classDef now fill:#DCEBEA,stroke:#0E6C69,stroke-width:2px,color:#0B2B2A classDef calm fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 classDef alert fill:#F6DEDB,stroke:#9E2E26,stroke-width:1px,color:#460F0B classDef rails fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 class S1,S2 now class N,D,RB calm class RA rails class H alert
구분내용근거
지금 한다절연 1단 — 중복 CREATE 6개 제거 · materials 정본 명문화 · 원가 파라미터 2개 이관(단방향 완성) · CI write 금지 게이트컷오버 없음. Rails로 가더라도 안 버려진다 — 정리된 도메인 경계가 곧 이관 설계도
보류한다절연 2·3단 — 물리 DB 분리 · 데이터 이관 · 계약 뷰 · service bindingRails 결정 전 착수하면 통째로 버려진다
조건부Rails 8 신규 구축실사 2건 뒤 재검토. 커버리지 70% 이상이면 값어치 있고, 30% 이하면 신축이니 접는다

Rails를 지금 착수하지 않는 이유 넷

01재고앱은 죽어가는 앱이 아니다.두 달간 32커밋으로 가장 빠르게 성숙 중이다 — 감사 콘솔·계측, 프로덕션 E2E BDD, 유통기한 인박스, 모바일 재시도, 실사 경합 무결성. 재작성은 지금 제일 잘 자라는 자산을 버리는 선택이다.
02costing-app-renewal이 왜 멈췄는지 모른다.PR 52개를 쌓고 멈춘 데는 이유가 있었다. 그게 미상인 채로 다시 가는 건 판단이 아니라 재시도다.
03리니지 이관은 실패하면 복구가 안 된다.데이터는 다시 넣으면 되지만 “누가·언제·왜”는 재생성이 불가능하다. 현장 실사용 원장을 PK 갈아끼우며 옮기는 건 아직 안 해본 난이도다.
04재고앱만 Rails로 가면 코스팅 접점이 더 비싸진다.D1 SQL JOIN → HTTP 호출. 절연 비용이 사라지는 게 아니라 더 큰 형태로 옮겨간다.

그리고 절연보다 급한 게 하나 있다. 계보 4개가 살아 있는 채로 방치돼 있다 — costing-app(레거시) · costing-app-renewal(휴면) · obliv-stock+obliv-costing(현역) · mdb-shared-host/engines/procurement. 뭐가 죽었는지 선언이 안 돼 있어서 새 기능을 어디 넣을지가 매번 갈린다. 각 repo README에 상태 한 줄(active / frozen / superseded-by)을 박는 건 반나절이고, 이번 주 최고 ROI다.

이번 주에 닫을 순서

1계보 4개 상태 선언반나절. 이거 없이는 나머지가 다 흔들린다.
2이 페이지 접근 잠금현재 링크만 알면 누구나 읽는다.
3판단 정본 자리 확정 + 구 정본 supersede“공유 D1로 연결한다”는 설계가 아직 production-deployed-verified로 살아 있다.
4절연 1단 착수작업 클론이 32커밋 뒤처져 있으니 동기화가 첫 동작.
5renewal 실사 2건 → Rails 재검토 여부 결정중단 사유 + 현행 커버리지.
선택지

재고앱을 Rails 8로 새로 지어도 된다

Rails 8 + PostgreSQL 한 앱이면 아래 절연 과제 — 크로스 DB JOIN 불가, 중복 CREATE, 감사 원장 분할, 마이그레이션 추적 분리, 계약 뷰, CI write 게이트 — 가 대부분 소멸한다. 진짜 FK와 트랜잭션이 그 자리를 대신하기 때문이다.

그리고 이미 지어둔 자산이 있다. costing-app-renewal은 Rails 8.1 + PostgreSQL로 재고와 원가를 한 앱에 모델링해뒀다 — schema.rb 31.5KB, PR 52개, inventory_item · inventory_lot · stock_movement · cycle_count · inventory_expiry_action 등 재고 모델 15종 포함. 2026-06-02 이후 멈춰 있다.

graph TB
  L["costing-app — 레거시
~2026-05 운영"] R["costing-app-renewal — Rails 8
schema 31.5KB · PR 52
재고+원가 통합 · 2026-06 정지"] S["supply-costing-os
2026-07 동결·아카이브"] A["obliv-stock + obliv-costing
SvelteKit · CF · D1
현장 실사용 중"] L --> R L --> S S -->|"기능 분리 이관"| A A --> Q{"이제 어디로"} R -.->|"자산 재사용 가능"| Q Q -->|"경로 A · 현행 유지"| PA["절연 1~3단
계약 뷰 · CI 게이트 · 컷오버"] Q -->|"경로 B · 허용"| PB["Rails 8로 재고앱 신규
절연 과제가 재작성에 흡수"] classDef old fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 classDef live fill:#DCEBEA,stroke:#0E6C69,stroke-width:1px,color:#0B2B2A classDef pick fill:#DCEBEA,stroke:#0E6C69,stroke-width:2px,color:#0B2B2A classDef rails fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 class L,S old class R rails class A,Q live class PB pick class PA old

승계 필수 — 새로 지어도 잃으면 안 되는 것

A등록된 SSOT 데이터의 리니지 — 이게 1순위다.값 하나하나가 누가 · 언제 · 무엇을 · 왜 바꿨는지의 이력이 이 시스템의 코어다. 리니지는 두 층으로 나뉜다 — 마스터 값 변경 이력(changes: op · target · before/after · actor · reason)과 재고 이동 사실(stock_moves). 둘 다 승계 대상이다.
A-1리니지의 진짜 함정 — ID 체계가 바뀌면 이력이 고아가 된다.재작성하면 PK 체계가 바뀐다. 과거 이력의 target_row가 새 행을 못 가리키면 데이터는 살아도 리니지는 끊긴다. 이관 시 구 ID ↔ 신 ID 매핑 테이블을 영구 보존해야 “이 값이 왜 이렇게 됐나”가 계속 답해진다. 데이터만 옮기고 이력을 두고 오는 게 재작성에서 가장 흔한 손실이다.
Bappend-only 원장.재고 이동은 수정·삭제가 없다. 잔량은 항상 Σ(입고−출고)로 파생한다. 이 불변식이 감사의 근거다.
C수량 단위 = 재료 마스터의 낱개 단위.박스 단위 입력 금지. 이전 앱이 박스로 받았다가 27개 품목을 수기 변환한 함정이 있다.
D표면별 권한 경계.현장 staff에게 단가·금액 차단, Owner 전용 표면 분리, 분류별 접근. 서버단 차단이지 화면 숨김이 아니다.
E최근 두 달치 현장 기능.활동 감사 콘솔·계측, 유통기한 확인 인박스, 모바일 입출고 안전 재시도, 실사 마감 경합에서의 원장 무결성, 배포·검증 로그. renewal이 멈춘 뒤 쌓인 것들이라 그 스키마에 없다.
F현장 원장 데이터.송도·오리진에서 매일 찍히고 있다. 재작성은 곧 전면 컷오버다.

짚고 갈 것 둘. 재고앱만 Rails·PostgreSQL로 옮기면 코스팅(CF·D1)과는 런타임과 DB가 모두 갈라진다. 절연은 자동으로 완성되지만, 코스팅이 원가를 읽던 4파일은 SQL JOIN이 아니라 HTTP 호출로 바뀌어야 해서 그쪽 작업량은 오히려 커진다. 그리고 costing-app-renewal이 2026-06에 멈춘 사유가 아직 미상이다 — 그 스키마를 재사용하기 전에 확인하는 편이 좋다.

전환

As-Is → To-Be (경로 A · 현행 유지 기준)

코드 전수 조사로 확인한 현재 상태와, 재고앱을 본체로 놓고 코스팅이 단방향으로 읽는 목표 상태.

As-IsTo-Be
DB단일 D1을 두 앱이 공유재고 전용 D1 신설 · 코스팅과 분리
참조 방향양방향단방향 — 코스팅 → 재고 read only
materials 정본불명확 — 규약은 코스팅, 코드는 재고가 CRUD재고앱. 코스팅은 읽기 전용
원가 계산재고앱 cost-valuation.ts가 수행그대로 — 변화 없음
원가 파라미터
평가방식 · 구매조건 선택
코스팅 소유 · 화면도 코스팅재고앱으로 이관 · keep/ward 표면 전용
스키마 정의6개 테이블 중복 CREATE소유 repo에만 정의 · 중복 0
감사 원장 changes공유 · 양쪽이 append컷오버 시점 동결 사본 + 이후 각자 append
계약 강제문서 규약뿐 — 이미 위반됨계약 뷰 + CI write 금지 게이트
배포 · 마이그레이션같은 DB에 두 repo가 각자 적용독립
도메인 경계“원가”가 양쪽에 걸침재고 = 재고·원가 / 코스팅 = 판가·마진
결합

As-Is — 한 DB 위에 두 앱이 얹혀 있다

재고앱은 materialsCRUD하고 있다. “코스팅 테이블은 읽기만”이라는 원래 규약은 이미 깨졌고, 결합은 단방향이 아니라 양방향이다.

graph LR
  subgraph A["obliv-stock Worker"]
    SA["앱 로직"]
  end
  subgraph B["obliv-costing Worker"]
    CB["pricing.ts · package-items.ts
where-used.ts"] end subgraph D["단일 D1 — 두 앱이 공유"] M["materials
material_sku_codes"] ST["stock_* 41개"] RC["recipes · recipe_items · products
packages · clinic_* · labor_*"] CH["changes — 공유 감사 원장"] end SA -->|"CRUD · 규약 위반"| M SA -->|"write"| ST SA -->|"append"| CH CB -->|"write"| RC CB -->|"append"| CH CB -.->|"read · 단일 SQL JOIN"| M CB -.->|"read · 단일 SQL JOIN"| ST classDef stock fill:#DCEBEA,stroke:#0E6C69,stroke-width:1px,color:#0B2B2A classDef costing fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 classDef shared fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 class SA,M,ST stock class CB,RC costing class CH shared
지뢰

절연과 무관하게, 이미 깔려 있는 것

같은 테이블 6개를 두 repo가 각자 CREATE TABLE IF NOT EXISTS로 정의하고 있다. 마이그레이션 추적 테이블만 갈라놨을 뿐 정의는 안 갈라놨다.

graph TB
  MS["obliv-stock
migrations/"] -->|"CREATE TABLE IF NOT EXISTS"| T MC["obliv-costing
migrations/"] -->|"CREATE TABLE IF NOT EXISTS"| T T["중복 정의 6개
materials · material_sku_codes
material_sku_number_generator
material_cost_policy_events
material_purchase_term_selections
stock_material_cost_positions"] T --> R{"추적 테이블만 분리
정의는 안 분리"} R -->|"프로덕션"| R1["이미 굳음 — 표면상 무사"] R -->|"CI · 로컬 D1"| R2["실행 순서에 따라
스키마가 달라짐"] R -->|"한쪽이 컬럼 추가"| R3["조용히 갈라짐
감지 수단 없음"] classDef stock fill:#DCEBEA,stroke:#0E6C69,stroke-width:1px,color:#0B2B2A classDef costing fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 classDef alert fill:#F6DEDB,stroke:#9E2E26,stroke-width:1px,color:#460F0B classDef calm fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 class MS stock class MC costing class T,R,R2,R3 alert class R1 calm
판단

목적이 수단을 정한다 — 여기가 핵심

절연으로 얻으려는 게 무엇이냐에 따라 답이 완전히 갈린다. 셋 중 하나는 절연 없이도 해결되고, 다른 하나는 절연해도 해결되지 않는다.

flowchart TD
  Q{"절연으로
무엇을 얻으려는가"} Q -->|"스키마 충돌 제거"| A3["절연 불필요
중복 CREATE 제거로 해결
컷오버 없음 · 비용 최소"] Q -->|"앱 독립 · 참조는 허용"| A2["D1 물리 분리 + 계약 뷰
← Owner가 고른 지점"] Q -->|"원가 데이터 격리"| A1["D1 분리로는 달성 안 됨
바인딩에 read-only 모드 없음
→ service binding 필요"] A1 --> W["“DB 나눴으니 원가 격리됐다”
= 틀린 결론"] classDef pick fill:#DCEBEA,stroke:#0E6C69,stroke-width:2px,color:#0B2B2A classDef alert fill:#F6DEDB,stroke:#9E2E26,stroke-width:1px,color:#460F0B classDef calm fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 class A2 pick class A1,W alert class Q,A3 calm
단방향

To-Be — 재고앱이 본체, 코스팅은 읽기만

재고앱이 코스팅을 읽는 곳은 cost-valuation.ts 한 함수의 입력 파라미터 2개뿐이다. 그 2개를 옮기면 역방향 참조가 0이 된다. 옮겨야 할 화면은 코스팅의 (ward)/materials 하나이고, 재고앱엔 이미 (clinic)/costing 라우트가 있다.

graph LR
  subgraph S["obliv-stock — 본체"]
    SM["materials · SKU 마스터"]
    SL["stock_* 원장 41개"]
    SP["원가 정책 · 구매조건 선택
← 코스팅에서 이관"] SC["cost-valuation.ts
→ stock_material_cost_positions"] SV["계약 뷰
v_material_master · v_material_cost"] end subgraph C["obliv-costing — 판가 앱"] CR["recipes · products · packages"] CP["clinic_* 판가 · labor · margin_policy"] end SM --> SC SL --> SC SP --> SC SM --> SV SC --> SV SV -.->|"read only · 역방향 참조 0"| CR SV -.->|"read only"| CP classDef stock fill:#DCEBEA,stroke:#0E6C69,stroke-width:1px,color:#0B2B2A classDef moved fill:#DCEBEA,stroke:#0E6C69,stroke-width:2px,color:#0B2B2A classDef costing fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 class SM,SL,SC,SV stock class SP moved class CR,CP costing

함정 하나. 원가 정책 화면을 재고앱으로 옮기면 현장 표면에 원가가 새어 들어올 수 있다. staff에게 단가·금액을 서버단으로 막아둔 벽이 뚫리는 경로다. 이관한 화면은 keep·ward 표면 전용으로 못 박고 현장 3표면엔 라우트를 노출하지 않는다 — 이관 PR의 필수 수용조건이다.

앱 분리

앱까지 가른다면 — 2단과 3단이 합쳐진다

제품·조직 단위로 완전히 분리하는 게 목표라면 중간 단계를 건너뛰어야 한다. D1 바인딩으로 붙였다가 나중에 API로 다시 뜯으면 원가 계산 코어를 두 번 재작성한다.

DB만 절연앱까지 분리
참조 방식두 번째 D1 바인딩 + 계약 뷰service binding · 버전 있는 API
materials재고 DB의 뷰를 직접 read공유 불가 — 정본 + 복제 동기화
계약 깨짐 감지CI 검사 게이트API 버저닝 + 계약 테스트
릴리스사실상 함께독립 케이던스 · breaking change 관리
새 리스크SKU 마스터 드리프트

앱 분리 시엔 드리프트가 새로 들어온다. 지금은 한 테이블이라 드리프트가 없는데, 갈라서 동기화하는 순간 “현장엔 있는 SKU가 코스팅엔 없음”이 생긴다. 원래 공유 D1을 고른 이유가 정확히 이거였다.

소유권

쓰는 쪽이 정본이다

코드 전수 조사로 뽑은 읽기·쓰기 분포. materials 정본이 재고앱으로 간다는 Owner 결정을 반영했다.

테이블stockcosting소유
materialsCRUD · readreadstock ← 결정
material_sku_codes · _number_generatorread 17read 4stock
stock_* 41개writeread 6stock
material_purchase_term_selectionsread 1write 1 · read 4costing
material_cost_policy_eventsread 1write 1 · read 4costing
recipes · products · clinic_* · labor_*writecosting
changes 감사 원장write 4 · read 4write 3 · read 5분할 설계 필요
목표

참조는 뷰 한 겹으로만

코스팅 Worker에 재고 DB를 두 번째 바인딩으로 물리고, 재고 쪽에 계약 뷰를 파서 그것만 읽게 한다. Worker 왕복이 없어 부분실패·지연이 안 생기고, 뷰가 내부 스키마 변경을 흡수한다. 앱 분리가 확정이면 이 단계를 건너뛰고 API로 간다.

graph LR
  subgraph A["obliv-stock Worker — 정본"]
    SA["앱 로직"]
  end
  subgraph B["obliv-costing Worker"]
    CB["4파일 앱 레벨 조인으로 재작성"]
  end
  subgraph DS["D1 — obliv-stock 신설"]
    M2["materials + material_sku_*"]
    ST2["stock_* 41개"]
    SEL["원가 정책 · 구매조건 선택
← 코스팅에서 이관"] CH2["changes — 동결 사본 + 이후 append"] V["계약 뷰
v_material_master · v_material_cost"] end subgraph DC["D1 — obliv-costing"] RC2["recipes · products · clinic_* …"] CH3["changes — 동결 사본 + 이후 append"] end SA --> M2 SA --> ST2 SA --> SEL SA --> CH2 M2 --> V ST2 --> V SEL --> V CB --> RC2 CB --> CH3 CB -.->|"두 번째 바인딩 · 뷰만 read"| V G["CI 게이트
costing → stock DB write 금지"] -.->|"물리 차단이 없으니
이걸로 강제"| CB classDef stock fill:#DCEBEA,stroke:#0E6C69,stroke-width:1px,color:#0B2B2A classDef costing fill:#F4E7D1,stroke:#8A570E,stroke-width:1px,color:#3A2606 classDef alert fill:#F6DEDB,stroke:#9E2E26,stroke-width:1px,color:#460F0B class SA,M2,ST2,SEL,CH2,V stock class CB,RC2,CH3 costing class G alert
순서

이득은 1단에 몰려 있다

1단만으로 현재 실위험의 대부분이 사라진다. 컷오버가 없고 롤백이 쉽다. 2단은 현장 실사용 중인 프로덕션 D1을 건드리므로 창과 승인이 필요하다.

graph TB
  P1["1단 — 지뢰 제거
중복 CREATE 정리 · 소유권 문서화
CI write 금지 게이트"] P2["2단 — 물리 분리
stock 전용 D1 신설 · 데이터 이관
계약 뷰 + costing 4파일 재작성"] P3["3단 — 계약 강제
service binding으로 write 물리 차단"] P1 -->|"실위험 대부분 제거
컷오버 없음 · 롤백 쉬움"| P2 P2 -->|"앱 독립 달성
현장 컷오버 창 · 승인 대상"| P3 P3 --> DONE["원가 격리까지 완성"] X["본체는 설정 파일이 아니라
pricing.ts · package-items.ts
= 원가 · 정가 계산
착수 전 골든 테스트 선행"] -.-> P2 Y["앱 분리가 확정이면
2단과 3단을 합친다"] -.-> P3 Z["Rails 8 신규 구축을 택하면
2·3단은 재작성에 흡수 — 착수 불필요
1단은 그래도 이득(이관 설계도가 된다)"] -.-> P2 classDef pick fill:#DCEBEA,stroke:#0E6C69,stroke-width:2px,color:#0B2B2A classDef calm fill:#E4E8E9,stroke:#66777D,stroke-width:1px,color:#192125 classDef alert fill:#F6DEDB,stroke:#9E2E26,stroke-width:1px,color:#460F0B classDef rails fill:#F4E7D1,stroke:#8A570E,stroke-width:2px,color:#3A2606 class P1 pick class P2,P3,DONE calm class X,Y alert class Z rails
인계

실행자에게 못 박을 것

00 경로부터 고른다 — 현행 절연이냐, Rails 8 신규냐.Rails 8로 재고앱을 새로 지어도 된다. 그 경우 아래 02·03·05는 재작성 요건으로 바뀌고, 절연 2·3단은 착수하지 않는다. 어느 쪽이든 1단(중복 정의 제거·소유권 명문화)은 버려지지 않는다.
01 materials 정본 = 재고앱. 코스팅은 읽기 전용으로 강등. 확정 사항이다.
02 “코스팅 → 재고 DB write 금지”는 규약일 뿐 물리 강제가 없다.CI에 검사 게이트를 걸어야 실효가 생긴다. 이거 안 걸면 절연이 반년 안에 도로 녹는다.
03 감사 원장은 자르지 않는다.컷오버 시점 전량을 양쪽에 동결 사본으로 복사한 뒤 각자 append. 리니지 연속성이 이 앱의 코어다.
04 착수 첫 동작은 git fetch다.오케스트레이션용 로컬 클론이 32커밋 뒤처져 있었다. 다른 클론도 같은 상태일 수 있다.
05 이관 목록은 프로덕션 실재 여부를 확인하고 짠다.stock_moves_new · stock_purchase_orders_new 등은 테이블 재작성 잔재나 뷰로 보인다. 코드만 봐서는 확정 못 했다.
06 3주 전 정본과 정면 충돌한다.“원가·재고를 공유 D1에서 연결한다”는 설계가 production-deployed-verified 상태로 살아 있다. 이 판단이 승인되는 시점에 그 문서를 supersede 처리하지 않으면, 실행자가 어느 쪽을 읽느냐에 따라 정반대로 작업하게 된다.