Skip to content

v0.18.0 S08 — 진행 중 운동 초안의 수명주기·시계·서버 백업 순서 정리: ref 다섯 개의 모순 조합과 "조건 없는 덮어쓰기" 서버 백업에서, typed 상태 하나·직렬 요청 줄·주입 시계·서버 순번/은퇴 펜스까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 2개 — da45ed78 Phase 0~5 준비, 사용량 한도로 중단 → 83dec529 가 이어받아 Phase 5·6 마무리. 오너 지시 "#1326 진행해줘" → 분석·Phase 계획 게시 → "ㄱ" → "묻지말고 phase 끝까지 완주")
  • 랜딩: PR #1350 → squash 머지 06db380c(2026-09-07 21:37 KST, 자동 랜딩 큐 run 34122847623) · staging 배포 run 34122885359 초록(database → functions → frontend → smoke). Phase 0~6 한 PR, CI 1회(run 34122140893, 브라우저 4샤드·viewport·migration-smoke 포함 전부 초록), 마이그레이션 1개(20260913020100). 총괄 S08 카드 · 계획 ID S08 · Phase 2 스텝 2-1
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1326 댓글
  • 정본: docs/data/workout-draft-protection.md "Lifecycle and Ordering" 절(순서·요청 줄·서버 펜스·계정 전환·pagehide·TTL 분리) · src/react/controllers/workoutDraftLifecycle.ts(typed 상태·전이·보존 꼬리·시계) · src/react/services/workoutDraftCheckpoint.ts(요청 줄) · supabase/definitions/durable/{tables/workout_draft_checkpoint_fences,functions/*_checkpoint_v2,retire_*}.sql · ADR P7 정본 링크
  • 도구: tests/support/draftCheckpointServer.mjs(요청 도착 순서를 테스트가 정하는 가짜 서버 백업 — v1/v2 규칙·종류별 자동 배달·waitFor/deliver/deliverAll({except}))
  • 게이트: 단위 — workoutDraftLifecycleOrdering.test(4: 지운 뒤 늦은 업로드·새 운동 뒤 늦은 업로드·계정 전환 보존 대기·시계 주입) · workoutDraftLifecycle.test(5: 전이 규칙·호환 ref·보존 꼬리) · workoutDraftCheckpointLane.test(4: 순번 학습·stale 1회 재시도/retired 무재시도·시간 초과 포기·삭제가 대기 업로드를 끔) · workoutDraftClock.test(2) · workoutDraftLifecycleScenarios.test(4: pagehide·7일 TTL 3경계·인증 만료/로그아웃/계정 삭제·초안 TTL 과 대기열 분리). pgTAP workout_draft_checkpoint_fence_v2.test.sql(23 assert) + 기존 v1·commit-guard(21) 통과. npm run check·npm run ci:local(§5)
  • 버그리포트: 없음(구조 트랙 — 보고된 사고가 아니라 코드 읽기로 찾은 순서 결함)
  • 계약: 초안 보호 정본 절 추가 · ADR P7 정본 링크 · G05 규칙 무변경(기존 접두 규칙이 새 파일을 잡음) + 장부 재생성

Phase 현황

Phase내용상태
Phase 0가짜 checkpoint 서버 도구 + 현행 코드에서 실패하는 순서 4건(todo 표시)
Phase 1store 에 ClockPort 주입 — savedAt·expiresAt·보관 시각·스로틀·서버 백업 만료를 한 시계로
Phase 2typed lifecycle 상태·전이 + 서버 백업 요청 직렬 줄(응답 시간 초과 15초 포기) + 계정 전환 보존 저장 완료 뒤 복원
Phase 3서버 펜스 표·v2 함수 3 + retire 도우미·v1 재발행·클라이언트 v2·pgTAP 23 (마이그레이션 20260913020100, 위험 low)
Phase 4이슈의 증명 시나리오 3묶음을 고정 순서·고정 시계 테스트로
Phase 5정본 문서·ADR 링크·이 기록·등록 2곳·G05 장부·인계 댓글
Phase 6ci:local --full → PR → CI 1회 → landing:request → 이슈 [v0.18.0 스테이징]✅ PR #1350 · 머지 06db380c · staging run 34122885359

1. 배경

유저 A 가 헬스장에서 운동을 기록하면 앱은 편집마다 기기(IndexedDB)에 초안을 저장하고 60초에 한 번 서버에 백업(checkpoint)을 올린다. 기기가 정본이고 서버 백업은 기기 저장소를 잃었을 때의 마지막 복구 층이다(#887 Phase 3). 초안 정책은 오너가 정한 대로 유지한다 — active/archive 7일, 보관함 최대 5개, 서버 백업 3일·owner 하나(ADR P7). v0.18.0 Phase 2 는 이 초안이 계정 전환·앱 종료·삭제·새 운동 시작의 순서에 따라 엇갈리지 않게 하는 것을 S08 로 잡았다.

2. 문제 제기

유저 A 가 운동 ① 을 지우고 곧바로 ② 를 시작한다. ① 을 지울 때 앱은 서버 백업 삭제 요청을 보내 놓고 결과를 기다리지 않는다. ① 의 마지막 백업 업로드가 느린 네트워크에 걸려 있었다면 "① 삭제 → ② 업로드 → ① 늦은 업로드 도착" 순서가 되고, 서버 저장 함수가 조건 없는 덮어쓰기(upsert)라 ② 의 백업이 ① 로 바뀐다. 기기 저장소를 잃고 복원하면 지운 ① 이 "진행 중이던 운동" 으로 돌아온다. #923 이 "예약 취소 + 마지막 진행 중 업로드 뒤에 삭제" 로 막았지만 진행 중 추적이 마지막 하나뿐이고, 새 운동이 그 사이 시작되면 기다리는 것 자체가 새 운동의 백업을 잃게 했다.

같은 구조의 다른 증상: 계정 전환 때 이전 계정의 마지막 초안 저장 완료를 기다리지 않아 같은 계정이 곧바로 다시 로그인하면 마지막 편집이 "없는 것" 으로 보였다. 만료(7일)·보관·스로틀이 전부 Date.now() 를 직접 읽어 "만료 직전/경계/직후" 를 고정 시각으로 검증할 수 없었고, 실제로 기존 checkpoint 테스트 2건의 만료 날짜 픽스처는 달력을 지나 있었다.

어떤 구조라서 가능했나: ① 초안 상태가 ref 다섯 개 + boolean 조합이고 네 파일이 직접 대입했다(모순 조합이 타입상 가능). ② 로컬 저장·서버 백업·정리가 독립 레인 셋이고 순서를 호출부가 손으로 맞췄다. ③ 서버 저장 함수가 순서·이미 지운 운동을 몰랐다. ④ 시계가 흩어져 있었다. ⑤ 초안 TTL 과 미전송 기록(대기열 행) 수명 분리가 계약·테스트로 고정돼 있지 않았다.

3. 해결 방안

원칙 = 오너 결정 없음(정책 P7 불변, 전역 §26 전제로 진행). 접근은 이슈 댓글 표의 C 안 — 클라이언트만 보강(땜질)·순번 CAS 만(첫 업로드 구멍) 대신 typed lifecycle + coordinator(클라이언트) + 순번·은퇴 펜스(서버).

  • 클라이언트: 인증 계정·주인·복원 번호·복원 종결·마무리 펜스를 한 typed 상태로 두고 전이 함수로만 바꾼다(주인은 인증 계정과 같을 때만, 펜스 해제는 운동 단위). 서버 백업 요청은 한 줄로 직렬 전송한다 — 삭제는 앞선 업로드가 끝나거나 응답 시간 초과(15초)로 포기된 뒤에 나가고, 같은 운동의 예약·대기 업로드는 끈다. 계정 전환 보존 저장은 계정별 꼬리로 기억해 같은 계정의 다음 복원이 그 뒤에 읽는다. 시계는 ClockPort 하나.
  • 서버: 유저당 펜스 행(순번 + 마지막으로 지운 운동). 저장 v2 는 ① 지운 운동이면 거부(retired) ② 기대 순번이 다르면 거부(stale, 현재 순번 안내) ③ 그 외 적용·순번 +1. 삭제·lazy 삭제는 행이 있든 없든 순번을 올리고 지운 운동을 기록한다. 순번을 모르는 첫 업로드는 ① 만 검사. v1 세 함수는 v2 위의 호환 래퍼(옛 번들도 같은 규칙, 거부돼도 오류 없음).

4. 적용한 내용

Phase 0tests/support/draftCheckpointServer.mjs: 모든 요청을 우편함에 넣고 테스트가 deliver() 로 도착 순서를 정한다(v1 = 현행 upsert·펜스 삭제, v2 = 순번·은퇴 펜스). 현행 코드에서 red 인 순서 4건을 todo 로 고정.

Phase 1workoutDraftLifecycle.ts 신설(systemClock), store 가 clock 을 받아 autosave save/clear·loadDraftCache·uploader·펜스 계측에 now 전달, clearWorkoutDraftCache 에 보관 시각. 서버 백업은 만료 시각이 지났으면 서버가 아직 지우지 않았어도 무시.

Phase 2 — typed 상태·전이·호환 ref(getter/setter). 요청 줄: 업로드 스로틀은 종전대로, 삭제는 줄 끝, 시간 초과 포기, 순번 학습·stale 1회 재시도·retired 무재시도, 포기한 요청의 늦은 응답은 순번을 가르치지 못함(abandon 표식). 호출부(appController·workoutWriteController)의 ref.current = … 대입은 그대로 두되 그 ref 가 전이 창구가 되어 코드·manifest 등재 앵커 테스트 무변경.

Phase 3 — D03 절차: 원천 편집 → sql:candidate --ddl(표 DDL 명시) → 위험 헤더(level=low) → 리셋 직후 스냅샷 → sql:extractsql:check. 펜스 표는 유저 원본 열이 없는 시스템 표라 등급 목록에 올리지 않는다(계약 테스트 규칙). pgTAP 23 assert.

Phase 4 — pagehide(닿지 않은 업로드를 기다리지 않고 기기 저장물 → 없으면 서버에 실제로 닿은 마지막 백업), 7일 TTL 직전/경계/직후, 인증 만료·명시 로그아웃(둘 다 보존)·계정 삭제(purge), 초안 TTL 이 대기열 행을 건드리지 않음.

주요 결정과 근거

  • 삭제 전에 "진행 중 업로드를 기다리는" 대신 줄 + 시간 초과 + 서버 펜스: 기다리는 동안 새 운동이 시작되면 새 백업을 잃는다(Phase 0 에서 실측). 늦게 닿는 요청은 서버가 거부해야 클라이언트 응답 무시에 기대지 않는다.
  • 펜스를 백업 행이 아니라 별도 표에: 백업 행은 lazy 삭제로 사라지므로 순번을 잃는다. 펜스 행은 유저당 하나, 원본 열 없음.
  • stale 은 한 번만 재시도: 다른 기기가 앞선 경우 = "owner 하나·나중 쓴 쪽 우선" 정책 유지. retired 는 재시도하지 않는다.

작업 중 드러난 것

  • 상태 객체의 getter 를 spread 하면 그 시점 값으로 굳는다 → 선점 감지 래치가 풀리던 원인, Object.assign 으로 고정.
  • 기존 checkpoint 테스트 2건의 만료 픽스처(2026-09-01/02)가 달력을 지나 있었다 — 만료 판정을 시계로 옮기자 바로 드러남. 고정 시계 주입으로 수정(단언 완화 없음).
  • 스냅샷을 pgTAP 실행 뒤에 뜨면 dblink 확장이 섞인다 — 반드시 --reset 직후.
  • origin/main 에 같은 임시 번호(20260913010100)의 다른 마이그레이션(#1345)이 먼저 랜딩 → 리베이스 뒤 migrations:renumber20260913020100. 충돌은 생성 파일(schema.sql·registry.json)뿐이라 upstream 을 받고 재생.
  • 랜딩 중 main 이 세 번 움직여 리베이스 3회: ① 첫 head 가 main(#1346·#1347)과 장부 2파일에서 충돌하면 GitHub 이 PR CI 를 아예 띄우지 않는다(체크 목록에 skipped 둘만 보임) ② CI 초록 뒤 landing:request 직전에 A03 #1351 이 먼저 랜딩해 큐가 "PR is behind main" 으로 거절 ③ A03 이 main 의 브라우저 e2e·viewport 레인 5개 잡을 request for './result' is from a module not been linked(Node 22.23.2) 로 전멸시켰다 — A03 의 PR CI 는 범위 판정이 verify-only 라 그 레인을 건너뛰었다. 원인 = src/react/contracts/package.json("type": "module") 때문에 tsx 가 src/react 안에서 contracts/** 만 ESM 으로 읽는데, A03 이 CJS 경로(catalogCodec.ts)에서 contracts/core/ids 를 값으로 import 해 Playwright 로더가 얹힌 Node 22 에서 동기 require(esm) 가 링크 중인 모듈을 만난 것. 로컬은 Node 24 라 재현되지 않았고 npx -p node@22.23.2 node --import tsx …/cli.js test --list 로 재현·대조했다. S02 세션의 앞수리 PR #1357(그 package.json 제거)을 따르고 중복 수리는 폐기(분석 = #1330 댓글·#1357 댓글).
  • CI 플레이키 1건(재현 불가): 두 번째 head 의 브라우저 샤드 3/4 에서 CASE-028 첫 시도가 화면 새로고침 순간 끊긴 프로필 조회(rest:profiles network_failure 보고 1건)로 실패 → 재시도 통과 → failOnFlakyTests 로 잡 실패 → 실패 샤드 재실행 초록. 초안·checkpoint 경로 밖이고 로컬 브라우저 e2e 는 통과.
  • ci:local --full 이 다른 세션의 미리보기 서버(4173)와 겹치면 코드 3 으로 끝난다 → 같은 빌드(dist-vite)를 4179 에 띄워 E2E_APP_URL·E2E_SUPABASE_*·CI=true 로 브라우저·viewport 묶음을 따로 돌렸다. 미리보기 프로세스에도 E2E_SUPABASE_* 를 줘야 로컬 /api/account/delete 핸들러가 돈다(없으면 CASE-017 이 500 — 코드 결함 아님).

5. 적용 결과

항목
지운 운동·이전 운동의 늦은 업로드가 서버 백업을 되살리거나 덮음가능(pgTAP·단위 재현)서버 거부(retired/stale) — pgTAP 23 assert·단위 4
계정 전환 보존 저장 완료 전 같은 계정 복원초안 없음으로 판정보존 꼬리 대기 뒤 읽음(단위 1)
초안 상태 대입 지점원시 ref 5개, 4파일 직접 대입typed 상태 1 + 전이 함수, 호환 ref 는 전이 창구
Date.now() 직접 호출(cache·store·uploader)7곳0(기본 시계 1곳 systemClock)
TTL 경계 테스트(초안 7일·백업 3일)03경계 + 2경계
서버 백업 요청 순서레인 3개 독립줄 1개 직렬·시간 초과 15초
로컬 게이트npm run check 통과(2938 tests · 2915 pass · fail 0 · todo 0) · pgTAP 110파일/1923 assert 통과(리베이스 뒤 ci:local --only db, 3분 5초)
ci:local fullci:local full · verify 통과(6단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 110파일/1923 assert · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-persistence 통과 1/1 · 7분 3초. e2e-browser·viewport 는 포트 4173 을 다른 세션의 미리보기 서버가 쓰고 있어 스크립트가 종료(코드 3) → 같은 샌드박스·같은 빌드를 4179 에 띄워 CI=true 로 따로 실행: e2e-viewport 통과 14/14 · e2e-browser 36/38 + 재실행 2/2(CASE-017 은 손으로 띄운 미리보기에 E2E_SUPABASE_* 환경변수가 빠져 계정 삭제 API 가 500 → 환경 주입 뒤 통과 · CASE-014 는 플레이키 1회 → 재실행 통과) = 38/38

미검증: 실기기·실 네트워크의 15초 시간 초과 값은 자동 검증 대상이 아니다(느린 회선에서 업로드 포기가 잦아지면 서버 백업 갱신이 60초보다 늦어질 수 있음 — 로컬이 정본이라 데이터 영향 없음).

6. 이번 개선으로 향상된 것

  • 유저: 기기 저장소를 잃고 복원해도 지운 운동이 돌아오지 않고, 새 운동의 백업이 옛 운동으로 바뀌지 않는다. 로그아웃 직후 재로그인에서 마지막 편집이 이어진다.
  • 구조: 초안의 owner/operation/phase 가 한 파일의 typed 상태다(A07·A12·N01 이 그대로 쓴다). 서버 백업 규칙이 pgTAP 로 잠긴 계약이 됐다. 만료·보관·스로틀이 주입 시계 기준이라 G02 규칙대로 결정적이다.

남은 것

  • 인계: A07(계정 전환 flush/dispose 순서 = prepareWorkoutDraftForOwnerChange + 보존 꼬리), A12(편집 operation lifecycle = workoutDraftLifecycle 전이), N01/R02(pagehide·resume·TTL fixture = workoutDraftLifecycleScenarios.test + draftCheckpointServer).
  • 릴리스 v0.18.0 뒤 [v0.18.0 반영완료]+닫기. 다음 릴리스에서 v1 함수 3개 제거 여부는 옛 번들 사용 실측 뒤(D13 공존 규칙 §6, 두 릴리스로).