v0.18.0 ADR — 보존하는 제품 정책과 이번에 바꾸는 기술 계약 (이슈 #1279, G01)
한 문장 — v0.18.0 리팩터링에서 사용자가 겪는 정책은 그대로 두고, 그 정책을 구현하는 기술 계약 다섯 가지만 바꾼다. 이 문서는 무엇을 지키고 무엇을 바꾸는지, 바꾼 계약이 언제부터 유효한지, 옛 앱과 어떻게 공존하는지를 한 곳에 적은 결정 기록(ADR, Architecture Decision Record = 구조 결정과 그 이유를 남기는 문서)이다.
- 이슈: #1279 (계획 ID G01). 총괄 문서 §3 보존할 정책과 이번에 바꿀 기술 계약·§8 이관·호환·전환 전략을 실제 계약 문서에 연결한 것.
- 운영 절차(병합 큐·릴리스 게이트)는 HQ 운영 정본.
- 현행 계약(이 문서가 좁게 개정하는 것): 완료 기록 쓰기 파이프라인 §3·§11. 유지되는 계약: 완료 세션 쓰기 원천, 유저 기록 원본 불변, 명시적 신원 전달, 앱 계정 권한.
- 게이트:
tests/react/completedWorkoutWritePipelineContract.test.mjs(개정 뒤에도 행 상태 4종·정책표·퇴역 문장 부재를 계속 잡는다). 이 ADR 자체를 읽는 테스트는 두지 않는다 — 계약의 진짜 게이트는 각 구현 이슈의 행동 검사다.
1. 읽는 법 — 유저 A 의 하루로
유저 A 가 지하 헬스장에서 운동을 끝내고 완료 화면에서 메모를 고쳐 [메인으로] 를 누른다. 지금도, v0.18.0 뒤에도 A 가 보는 것은 같다: 화면은 즉시 닫히고 "수정하였습니다" 가 뜨고, 지상으로 올라오면 조용히 서버에 올라간다. 같은 기록을 A 의 아이패드에서도 고쳤다면 나중에 서버에 도착한 쪽이 이기고, 진 쪽은 사라지지 않고 사본으로 남아 복구 화면에서 되살릴 수 있다.
바뀌는 것은 그 뒤에서 일어나는 일이다. 지금은 두 탭이 같은 기록을 동시에 보내도 "서버가 중복을 막아 주겠지" 에 기대고, 대기열 행을 바꾸는 도중에 저장소가 꽉 차면 이전 요청이 사라질 수 있다. v0.18.0 은 대기열 행 하나하나에 "누가 보내는 중인지, 언제 만료되는지, 늦게 온 답장을 무시해도 되는지" 를 기록하고, 행 교체를 한 번의 트랜잭션으로 한다. A 는 차이를 못 느껴야 하고, 느낀다면 그것이 결함이다.
2. 보존하는 제품 정책 (이번 릴리스에서 바꾸지 않는다)
아래는 오너가 정한 제품 정책이거나 이미 정본 계약이 된 것이다. 구현 이슈가 이 표를 바꾸려면 오너 결정이 먼저다(전역 지침 §26 의 ③).
| # | 정책 | 정본 | 지키는 이슈 |
|---|---|---|---|
| P1 | 쓰기 capability: 완료 기록 생성·수정·삭제, 계획 수정·삭제는 durable 경로(기기 대기열 먼저 → 화면 즉시 → 서버 전송 뒤에서). 신규 계획 생성은 온라인 전용(서버 발급 id 필요). 인증·파일 업로드는 별도 capability 로 구분하고 오프라인화하지 않는다 | 쓰기 파이프라인 §2·§10, 총괄 §2 | G03(명령별 정책표), S03, A12/A13 |
| P2 | 수치 정책: e1RM·PR·최대반복수·세트 스코어·볼륨·출석·반올림·시간 순서·단위 의미 | docs/data/* 정책 문서, supabase/contracts/** | D01(정책 golden corpus), D11 |
| P3 | 기록 identity: canonical parent/child ID, source/source_ref, clientMutationId·requestHash, 영수증(receipt) 이력. 재생성·재발급 없음 | 쓰기 원천, 원본 불변 | S01, S03, S06 |
| P4 | 완료 저장 UX: 동작별 wait/immediate 정책표, 무음 자동 동기화, 저장 순간 에러 0, pending 통계는 "동기화 후 반영" 표시 | 쓰기 파이프라인 §4·§7·§10 | A12, U02/U03 |
| P5 | 충돌 기본 정책: 나중 서버 도착 요청 우선(#1173 D1), 개정번호 충돌은 자동 재전송·메시지 0 | 쓰기 파이프라인 §6 | S06 |
| P6 | 복구 UX: 삭제 직후 취소 toast 금지(#1199 결정 4). 복구는 별도 사본·복구 화면의 명시 undo | 쓰기 파이프라인 §7 | S09 |
| P7 | 초안 보존: active/archive 7일, archive 최대 5개, 서버 백업(checkpoint) 3일·owner 하나 | workoutDraftCache·workout_draft_checkpoints 계약 · 초안 보호 정본(순서·펜스·시계, #1326) | S08 |
| P8 | 사본 정책: 보호 테이블 데이터 사본 매일 1회·90일 보관, PITR(시점 복원) 미사용(#1236 D2) | 원본 불변 §8 D2, user-data-copy.yml | S11, R03 |
| P9 | 원본 불변: 시스템은 유저 원본을 바꾸지 않는다(수리 티켓만 예외), 규칙 변경은 투영만 | 원본 불변 P0·R1~R5 | 모든 DATA 이슈, D13 |
| P10 | 계정·권한: owner/RLS/grants, 개인정보 삭제 연쇄, 인입 출처 행 보호 제외(#1236 D5), 명시 신원 전달 | 앱 계정 권한, 신원 전달 | B01 |
3. 이번에 바꾸는 기술 계약 (다섯 가지)
3-1. Outbox(기기 대기열) 행의 원자 상태 전이 · claim · fence
지금: 행 상태는 queued/sending/held/blocked 넷이고, sending 은 "이 탭이 보내는 중" 이라는 표시일 뿐이다. 탭 간 잠금은 없고 여러 탭이 같은 행을 보내도 서버 영수증 멱등성이 중복을 막는다. sending 뒤 10초가 지나면 queued 로 되돌린다. 행 교체(합치기)는 삭제 → 쓰기 두 단계라 중간에 저장소가 꽉 차면(quota) 이전 행이 사라질 수 있다(감사 재현 F01).
바꾸는 것:
| 항목 | v0.18.0 계약 | 담당 |
|---|---|---|
| 원자 교체 | 이전 행 조회·검증 → 새 행 쓰기 → 이전 행 삭제를 IndexedDB readwrite 트랜잭션 하나 로. 커밋된 뒤에만 화면에 반영 | S02 — 구현됨(2026-09-07, 이슈 #1325): outbox-atomic-replace.md |
| 행별 claim | 보내는 탭이 행에 claim(탭 식별자 + 만료 시각)을 CAS(비교 후 교체: "내가 본 값이 그대로일 때만 바꾼다")로 기록. 만료 전에는 다른 탭이 같은 행을 집지 않는다 | S04 — 구현됨(2026-09-08, 이슈 #1334): outbox-claim-successor.md §2 (S02 원자 교체의 재검사로 커밋, 새 트랜잭션 종류 없음) |
| fence | claim 마다 증가하는 번호(fencing token). 만료 뒤 다른 탭이 새 claim 을 잡으면 번호가 올라가고, 옛 번호로 돌아온 늦은 답장은 행 상태를 바꾸지 못한다 | S04 — 구현됨(2026-09-08): 같은 문서 §3 (성공·일시 실패·보류·격리 네 답장 모두 "내 탭·내 fence" 일 때만, 실제 Chromium 두 탭 실측) |
| durable successor | 전송 중인 행을 사용자가 다시 고치면 합치지 않고 후속 행 을 먼저 영속화한 뒤, 선행 행의 결과(영수증)를 확인하고 나서 보낸다 | S04 — 구현됨(2026-09-08): 같은 문서 §4~§5 (successorOf·의도 행·CASE-040 실제 v0.17.1 탭 공존 실측). 충돌 재전송(원 요청 → LG409 → 새 개정번호의 새 요청)을 후속 행으로 먼저 영속화하는 것은 S06 — 구현됨(2026-09-08, 이슈 #1341): 계약 |
| 전역 리더 선출 | 여전히 도입하지 않는다. claim 은 행 단위이고 탭 전체의 주인을 뽑지 않는다 | — |
| 저장소 비용·미러·timeout | 행마다 전체를 다시 읽고 미러 전체를 다시 적던 것을 owner/target 메모리 카탈로그 + 행별 미러(tombstone·세대)로. IDB 버전은 6 고정(옛 탭 VersionError). 시간 초과는 "안 썼음 / 결과 미상(재조회)" 로 가른다 | S07 — 구현됨(2026-09-08, 이슈 #1337): outbox-storage-index-mirror.md (1000행 드레인 152.8초 → 6.1초, 실제 Chromium 복구 매트릭스 7/7) |
왜: 서버 멱등성은 "같은 요청이 두 번 반영되지 않게" 할 뿐, "전송 중 편집이 늦은 답장에 덮이지 않게" 는 못 한다. 행 단위 claim·fence 는 그 빈틈을 클라이언트에서 닫는다. 전역 리더는 탭 하나가 죽으면 전체가 멈추므로 쓰지 않는다.
적용 시점과 옛 앱 공존 (총괄 §8 ③): 옛 번들(v0.17.x)의 탭은 claim·fence 를 모른다. 그래서 새 writer 는 S01 이 실제 옛 번들 + 다중 탭으로 공존 실험을 통과하기 전에는 활성화하지 않는다. S01 결과가 "안전한 공존 불가" 이면 기존 미전송 행을 보존한 채 옛 탭이 모두 업그레이드될 때까지(버전 표시로 판정) 새 writer 를 켜지 않는다. 최종 확인은 R02. 이 문서와 쓰기 계약의 문장 개정은 계약 선언이지, 새 writer 가 안전해졌다는 증거가 아니다.
3-2. Command(명령)·Receipt(영수증)·Resource(조회 자원) 의 typed 경계
지금: 화면·컨트롤러·저장소가 범용 객체(AnyRecord, 197필드 renderCtx, camel/snake 혼용 DTO)를 공유한다.
바꾸는 것: 기능별 좁은 port — prepare → command(순수 조립), dispatch → receipt(전송·확정), query → resource snapshot(owner·id·기간·version 키의 불변 스냅샷). 서버 row 는 repository/codec 층에서만 알고, 내부는 camelCase DTO 하나. 형식 버전이 있는 오프라인 decoder 로 옛 IDB/draft/receipt 를 읽는다(재생성 없음). 계약 정본은 G03 이 src/react/contracts/** 에 만든다.
왜: 병렬 작업자 6분야가 같은 ID·단위·상태를 공유해야 한다. 범용 객체는 누가 무엇을 바꿔도 컴파일이 통과해 회귀가 늦게 드러난다.
dispatch → receipt 구간은 S05 가 구현했다(2026-09-08, 이슈 #1336 — 계약): 기기 행만 보내는 dispatcher, 보낸 요청과 영수증을 대조하는 reconciler, typed 결과 하나를 받는 feature binding. 스토어가 AnyRecord env 로 잡고 있던 화면 함수 17개가 사라졌고, 그 AnyRecord 가 숨기던 죽은 호출(S04 의도 행 변환 포트)이 이 과정에서 드러나 수리됐다 — typed 경계가 회귀를 일찍 드러낸다는 이 절의 근거가 실제로 확인된 사례다.
3-3. 충돌 사본 + 명시 복구 (자동 field merge 는 도입하지 않는다)
선택지: (a) 안전한 자동 field merge — 두 기기가 서로 다른 필드를 고쳤으면 기계가 합친다. (b) 사본 + 명시 복구 — 나중 도착이 이기고, 진 쪽 요청을 사본으로 보존해 사용자가 복구 화면에서 되살린다.
결정: (b). 이유: ① 같은 필드를 두 곳에서 고쳤을 때 기계는 어느 값이 맞는지 알 수 없고, 다른 필드끼리도 "세트 무게를 바꿨는데 반복수는 옛 값" 같은 반쪽 기록이 나온다. ② 자동 병합은 시스템이 유저 원본을 만드는 셈이라 원본 불변 R2(원본은 유저 본인만 바꾼다)와 어긋난다. ③ P5(나중 도착 우선)·P6(취소 toast 금지)을 그대로 두면서 "덮인 값을 확인하고 되돌릴 수 있게" 하는 최소 변경이다. (a) 는 검증된 규칙이 생기면 별도 오너 결정으로 다시 연다.
계약: 충돌 재전송(원 요청 → 충돌 → 새 개정번호 요청)은 durable successor 로 먼저 영속화 하고 보낸다(S06 — 구현됨(2026-09-08, 이슈 #1341), 계약: 원 행 제거 + 후속 행 + 근거 conflict 원자 교체, 재시작 뒤 같은 id·지문 재생, 후속 행을 못 적으면 원 요청 유지·미전송). 진 쪽 요청의 local/base/remote 스냅샷 또는 불변 이력 참조를 사본으로 남기되, 얻을 수 없으면 "사본 없음·범위" 를 표시하고 완전 복구로 표시하지 않는다(S09). 복구는 새 durable mutation 이며 삭제 영수증·tombstone 을 지우지 않는다.
3-4. 통계의 단일 작성자 · generation 발행
지금: 여러 함수가 같은 projection 테이블에 쓰고, 세션 하나 수정에 넓은 범위를 다시 계산하며, 전역 worker 가 사용자 간 잠금을 만든다(F03~F05).
바꾸는 것: observation → session → 순차 파생 → 기간/달력 → snapshot → publication 의 DAG(방향 그래프)와 테이블/컬럼마다 작성자 하나(D01). dirty event 는 정확한 영향 범위·세대별 병합(D04), 사용자별 worker 와 lease(D08), 모든 projection 이 준비된 generation 만 발행(D11). 전환은 총괄 §8 ④~⑤: 같은 canonical 스냅샷으로 full/incremental 을 그림자 비교하고, 검증된 full projector 로 확인한 뒤 incremental 을 켠다. 정상 상태에서 구/신 작성자가 같은 테이블에 동시에 쓰지 않는다.
왜: 작성자가 둘이면 "누가 마지막에 썼는가" 로 값이 정해져 재현이 안 된다. P2(수치 정책)는 그대로이고 계산 구조만 바뀐다 — 동치 검증이 그것을 증명한다.
3-5. 타입·캐시 경계 (기존 store/coordinator 를 typed 로 완성)
선택지: TanStack Query 등 새 조회 라이브러리 도입 대 기존 store/coordinator 를 작고 typed 하게 완성. 결정: 기존 완성. 총괄 §2 기본안. 새 라이브러리는 G03 이 "기존 계약으로 충족할 수 없다" 는 근거를 낼 때 한 번만 결정하고, 두 구현을 같이 두지 않는다(A08 완료 증거).
계약: owner-scoped resource cache 는 서버 응답의 불변 스냅샷 + selector 만 제공하고, 로컬 pending overlay 는 표시용 이며 서버 상세의 쓰기 원천이 되지 않는다(총괄 §2, 쓰기 원천 유지). 서버 캐시를 로컬 정본 DB 나 outbox 로 겸용하지 않는다(#1199 오너 결정 ④ 로컬 우선 DB 미고려 유지).
4. 개정하는 현행 계약 문장 (좁게, 그 자리에서)
| 문서·절 | 지금 문장 | 개정 | 이유 |
|---|---|---|---|
| 쓰기 파이프라인 §3 | "여러 탭이 같은 행을 보내도 서버 영수증 멱등성이 중복 반영을 막는다 — 탭 간 잠금·리더 선출은 두지 않는다" | 서버 멱등성 문장은 유지. "탭 간 리더 선출 은 두지 않는다. 행 단위 claim·fence 는 v0.18.0 계약(ADR §3-1)이며 S01 공존 실험 전에는 활성화하지 않는다" | 3-1 |
| 같은 문서 §11 "Web Lock, CAS, lease, lineage, quarantine, BroadcastChannel" 행 | "부분 유지 — Web Lock·CAS·lineage·BroadcastChannel·탭 간 리더 선출은 여전히 없다. sending 만료 시각은 임대(lease)가 아니라 '10초 뒤 대기로 복귀' 한 줄이다" | "부분 유지·v0.18.0 개정 — Web Lock·BroadcastChannel·탭 간 리더 선출은 여전히 없다. 행 단위 CAS claim·만료·fence 는 v0.18.0 에서 도입한다(ADR §3-1, S04). 그 전까지 sending 만료는 '10초 뒤 대기로 복귀' 한 줄이다" | 3-1 |
| 원본 불변 | 변경 없음 | — | P9 그대로 |
| 쓰기 원천 | 변경 없음 | — | P3·3-5 그대로 |
이 표 밖의 현행 계약은 해당 구현 이슈의 PR 이 정식으로 갱신하기 전까지 그대로 유효하다. 계획 문서에 제안됐다는 사실만으로 계약이 바뀐 것이 아니다.
5. 호환 코드의 경계 (남기는 것과 지우는 것)
| 남긴다(명시 경계) | 지운다(활성 앱에서) |
|---|---|
| 적용된 migration 전부(불변) | 활성 경로의 임의 shape fallback·dual shape |
| 옛 IDB/draft/receipt 를 읽는 버전별 decoder(S01) | 옛 writer/reconcile RPC 부활(LG426 스텁 유지) |
| 현재 공개 v5 writer/조회 RPC adapter(옛 앱이 쓰는 동안) | 사용 증거 없는 공개 API DROP 을 "개선 완료" 조건으로 삼는 일 |
| 목적·담당·제거 조건이 적힌 호환 adapter | 목적 없는 내부 wrapper |
rollback 은 DB downgrade·IDB clear·receipt 삭제로 하지 않는다. 호환 frontend·검증된 full projection·feature 전환 취소·forward fix 만 쓴다. 새 대기열 형식을 모르는 옛 번들은 rollback 후보가 아니다(총괄 §8 ⑦, 허용 버전은 R02/R05 가 기록).
6. 결정 장부
| 번호 | 결정 | 근거·상태 |
|---|---|---|
| ADR-1 | 제품 정책 P1~P10 은 v0.18.0 에서 바꾸지 않는다 | 총괄 §3, 오너 기존 결정(#1199·#1173·#1236) |
| ADR-2 | outbox 행별 원자 전이·claim·fence 를 도입하되 전역 리더 선출은 두지 않는다. 활성화는 S01 공존 실험 통과 뒤 | 총괄 §3·§8 ③ |
| ADR-3 | 충돌은 사본 + 명시 복구. 자동 field merge 는 도입하지 않는다 | §3-3 |
| ADR-4 | 통계는 테이블/컬럼당 작성자 하나, generation 발행, full/incremental 동치 검증 뒤 전환 | 총괄 §8 ④⑤ |
| ADR-5 | 조회는 기존 store/coordinator 를 typed 로 완성. 새 라이브러리는 G03 이 근거를 낼 때 한 번만 | 총괄 §2 |
| ADR-6 | 현행 계약 문장은 §4 표의 두 곳만 개정한다. 나머지는 구현 PR 이 갱신 | 이슈 #1279 지시 4 |
오너 결정이 새로 필요한 항목: 없음(모두 총괄 문서·기존 오너 결정의 범위 안).