Skip to content

v0.18.0 ADR — 보존하는 제품 정책과 이번에 바꾸는 기술 계약 (이슈 #1279, G01)

한 문장 — v0.18.0 리팩터링에서 사용자가 겪는 정책은 그대로 두고, 그 정책을 구현하는 기술 계약 다섯 가지만 바꾼다. 이 문서는 무엇을 지키고 무엇을 바꾸는지, 바꾼 계약이 언제부터 유효한지, 옛 앱과 어떻게 공존하는지를 한 곳에 적은 결정 기록(ADR, Architecture Decision Record = 구조 결정과 그 이유를 남기는 문서)이다.

1. 읽는 법 — 유저 A 의 하루로

유저 A 가 지하 헬스장에서 운동을 끝내고 완료 화면에서 메모를 고쳐 [메인으로] 를 누른다. 지금도, v0.18.0 뒤에도 A 가 보는 것은 같다: 화면은 즉시 닫히고 "수정하였습니다" 가 뜨고, 지상으로 올라오면 조용히 서버에 올라간다. 같은 기록을 A 의 아이패드에서도 고쳤다면 나중에 서버에 도착한 쪽이 이기고, 진 쪽은 사라지지 않고 사본으로 남아 복구 화면에서 되살릴 수 있다.

바뀌는 것은 그 뒤에서 일어나는 일이다. 지금은 두 탭이 같은 기록을 동시에 보내도 "서버가 중복을 막아 주겠지" 에 기대고, 대기열 행을 바꾸는 도중에 저장소가 꽉 차면 이전 요청이 사라질 수 있다. v0.18.0 은 대기열 행 하나하나에 "누가 보내는 중인지, 언제 만료되는지, 늦게 온 답장을 무시해도 되는지" 를 기록하고, 행 교체를 한 번의 트랜잭션으로 한다. A 는 차이를 못 느껴야 하고, 느낀다면 그것이 결함이다.

2. 보존하는 제품 정책 (이번 릴리스에서 바꾸지 않는다)

아래는 오너가 정한 제품 정책이거나 이미 정본 계약이 된 것이다. 구현 이슈가 이 표를 바꾸려면 오너 결정이 먼저다(전역 지침 §26 의 ③).

#정책정본지키는 이슈
P1쓰기 capability: 완료 기록 생성·수정·삭제, 계획 수정·삭제는 durable 경로(기기 대기열 먼저 → 화면 즉시 → 서버 전송 뒤에서). 신규 계획 생성은 온라인 전용(서버 발급 id 필요). 인증·파일 업로드는 별도 capability 로 구분하고 오프라인화하지 않는다쓰기 파이프라인 §2·§10, 총괄 §2G03(명령별 정책표), 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·§10A12, U02/U03
P5충돌 기본 정책: 나중 서버 도착 요청 우선(#1173 D1), 개정번호 충돌은 자동 재전송·메시지 0쓰기 파이프라인 §6S06
P6복구 UX: 삭제 직후 취소 toast 금지(#1199 결정 4). 복구는 별도 사본·복구 화면의 명시 undo쓰기 파이프라인 §7S09
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.ymlS11, 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 원자 교체의 재검사로 커밋, 새 트랜잭션 종류 없음)
fenceclaim 마다 증가하는 번호(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-2outbox 행별 원자 전이·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

오너 결정이 새로 필요한 항목: 없음(모두 총괄 문서·기존 오너 결정의 범위 안).