Skip to content

v0.18.0 S04 — 전송 중 요청 보호: 행 단위 claim·fence 로 늦은 답장을 막고, 전송 중인 행 위의 편집을 후속 행으로 보존 (2026-09-08)

  • 기간: 2026-09-07 ~ 2026-09-08 (커밋 해시는 main e860d7b0 위로 마지막 리베이스한 뒤 브랜치의 것 — PR 은 squash 머지. 세션 2개 — d201873b 착수·분석·Phase 계획 게시·Phase 0~2, 세션 한도로 중단 → 14f87ec1 이어받아 Phase 3~5)
  • 랜딩: PR #1369 → main f8334135 (2026-09-08, Phase 0~5 한 PR, HQ §4-2 자동 랜딩 큐 요청 a6c566e6 → run 34142011762 이 squash 머지 + staging 확인: deploy.yml run 34142049591 success) — 마이그레이션·엣지 함수·서버 변경 없음. 앱 코드 변경은 src/react/persistence/outbox/{claimPlan(신규),replacePlan,index}.ts + 전송기 src/react/services/pendingWorkoutSaves.ts + 포트 배선 src/react/controllers/pendingWorkoutSavesStore.ts(S05 경계 직렬 합류, 수십 줄) + pendingWorkoutSavesController.ts·workoutWriteController.ts 몇 줄 + package.json 스크립트 한 줄. 저장 형식 v1, 새 값은 전부 추가 필드. 총괄 S04 카드 · 계획 ID S04 · Phase 2 스텝 2-2
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1334 착수 댓글
  • 정본: docs/contracts/outbox-claim-successor.md(유저 이야기·claim·ACK 적용·후속 행·의도 행·조합 표·실측·인계) · src/react/persistence/outbox/claimPlan.ts · ADR §3-1 행별 claim·fence·durable successor 행 · 대기열 행 codec §5·§6 활성화 조건·인계 표 · 도메인 계약 §12·§17 · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1·§6
  • 도구: npm run test:persistence-browsertests/react/outboxClaim.browser.mjs 추가(esbuild 번들 + 실제 Chromium 페이지 둘, 페이지별 주입 시계). e2e error-cases/CASE-040(신규) · CASE-039 옛 태그 기본값 v0.17.1(BARBELIC_LEGACY_TAG 로 변경)
  • 게이트: 단위 — tests/react/outboxClaimSuccessor.test.mjs 8(결함 재현 5 → 통과) · outboxClaimPlan.test.mjs 5 · 기존 대기열 테스트 8개 파일 기대값을 새 계약으로 갱신(manifest 등재 파일 없음). 실제 Chromium — outboxClaim.browser.mjs 4/4. e2e — CASE-040 3회 연속 통과 · CASE-039 v0.17.1 통과. G05 규칙 1줄 + 장부 행 2개 갱신
  • 버그리포트: 없음(구조 트랙, 감사 보고서 F01·F16·F17 의 수리)
  • 계약: outbox-claim-successor.md 신설 · ADR §3-1 표 S04 행 3개에 구현 링크 · codec 계약 §5 활성화 선언·§6 S04 행 완료 · 도메인 계약 §12(활성)·§17 OutboxPort 행 · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1 조건표 행 분할·§6 S04 행 완료

Phase 현황

Phase내용상태
Phase 0후속 명령 × 선행 상태 × 소유자 조합 표(계약 §6) + 지금 구조의 결함을 먼저 실패하는 테스트 6건(5건 실패 확인)77379689
Phase 1persistence/outbox/claimPlan.ts 순수 계획기 — 전송 중 판정(후퇴 규칙)·claim 획득 CAS·ACK 적용 4종·전송 가능 판정 + 테스트 5건a4c9d870
Phase 2교체 규칙의 successor·의도 행 분기, 전송기 claim → 전송 → ACK(전부 비교 후 교체), 선행 확정 뒤 후속, prepareSuccessor 포트·시계·tabId 주입, 스토어 배선, 대기 중 카드35ffbd94
Phase 3실제 Chromium 두 탭 4 시나리오 + test:persistence-browser 연결 · e2e CASE-040 신설(실제 v0.17.1 탭 공존) · CASE-039 v0.17.1 재실측c10cc75f
Phase 4계약 §7 실측·§8 인계 · ADR·codec·도메인·쓰기 파이프라인·원자 교체 계약 갱신 · G05 규칙·장부 · 이 기록 · 등록 2곳18773a7a
Phase 5npm run ci:local --full · PR · CI 1회 · 랜딩 큐 · 이슈 [v0.18.0 스테이징]✅ (ci:local full 13분 58초 + 브라우저 레인 재실행 7분 43초 → PR #1369 → 리베이스 2회 · CI 2회(2차 run 34141431432 전 잡 success) → 랜딩 큐 → 머지 f8334135 · staging run 34142049591 success)

1. 배경

유저 A 가 지하철에서 어제 기록의 무게를 60 → 80 으로 고쳤다. 앱은 그 수정을 기기 대기열(IndexedDB pendingSaves)에 행 하나로 남기고, 연결이 돌아오자 서버로 보내기 시작했다(전송 중). 답장이 오기 전(느린 망, 몇 초) A 가 같은 기록의 메모를 하나 더 고쳤다. v0.18.0 ADR §3-1은 이 상황을 "행 단위 claim·fence·후속 행" 으로 닫기로 했고(S04), S02(#1325)가 그 아래층인 원자 교체를, S01(#1285)이 행 형식과 실제 옛 번들 공존 실측(CASE-039)을 끝내 놓았다. ADR 은 "새 writer 는 S01 이 실제 옛 번들 + 다중 탭 공존 실험을 통과하기 전에는 켜지 않는다" 고 못 박았다 — 이 트랙이 그 조건을 확인하고 켰다.

2. 문제 제기

전송 중인 행이 지워졌다

두 번째 수정은 "같은 기록의 행은 마지막 하나" 규칙(원자 교체 계약 §1)으로 전송 중인 첫 행을 저장소에서 지우고 새 행을 썼다. 첫 요청은 이미 서버로 날아간 상태라 기기에는 그 요청이 있었다는 흔적이 없다.

옛 요청이 부활했다

첫 전송의 답장이 일시 실패 면 전송기가 첫 행을 "다시 대기" 로 되돌려 쓰는데, 저장소에 그 행이 없으므로 새로 만들어졌다. 첫 수정(무게 80, 메모 없음)과 두 번째 수정(무게 80 + 메모)이 둘 다 대기열에 남아 오래된 것이 먼저 올라가고 새 것이 개정번호 충돌로 다시 보내졌다. 첫 요청이 성공하고 새 요청이 영구 거부되면 A 의 마지막 의도(메모)가 사라진다.

두 탭이 같은 행을 같이 보냈다

탭을 두 개 열어 두면 둘 다 온라인 복귀 때 같은 행을 "전송 중" 으로 표시하고(비교 없는 덮어쓰기) 둘 다 보냈다. 서버 멱등 장부가 중복 반영은 막지만, 한 탭의 늦은 실패 답장이 다른 탭이 보내는 중인 상태를 "다시 대기" 로 덮어 한 번 더 보냈다.

새 기록의 후속 편집은 기기에 남지 않았다

아직 서버 id 가 없는 새 기록을 보내는 중에 그 기록을 수정하거나 삭제하면 앱은 아무것도 저장하지 않고 메모리에서 답장을 기다렸다(wait-for-create). 그 사이 탭이 닫히면 수정은 사라지고, 다른 탭이 먼저 올려 답장이 이 탭에 오지 않으면 "수정할 세션을 대기열에 넣지 못했습니다" 오류가 떴다.

어떤 구조라서 가능했나(§22): ⓐ 행에 "누가 언제까지 보내는 중인지" 가 없었다 — state:"sending" 과 보낸 시각뿐이라 전송기의 상태 변경(전송 시작·성공 삭제·실패 되돌림)이 비교 없는 덮어쓰기였다. ⓑ 합치기 규칙이 전송 중 행을 예외 없이 지웠다. ⓒ 전송 중 생성 기록의 후속 편집을 영속화할 자리가 없었다.

3. 해결 방안

원칙

  • 오너 결정 없음 — 계획의 전제로 진행: ① "진행해줘" 를 착수 go 로 ② lease 는 종전 10초 유지 ③ 옛 탭이 생성 행을 먼저 지운 창의 고아 의도 행은 보존만(RPC 신설 없음) ④ CASE-040 신설·CASE-039 는 v0.17.1 태그로 재실측.
  • §22(근본 구조): "sending 이면 건드리지 않기" 조건 하나로 모면하지 않고, 행에 소유·만료·번호를 두고 전송기의 모든 상태 변경을 "내 것이 그대로일 때만" 으로 바꿨다. 전역 리더 선출은 두지 않는다(ADR §3-1).

접근

대안판정
A. 행 단위 claim(탭 id·만료·fence)을 S02 원자 교체의 재검사로 기록. 전송기의 상태 변경 전부(전송 시작·성공 삭제·실패 되돌림·보류·격리)는 "내 claim 이 지금도 그대로일 때만". 전송 중 행은 지우지 않고 후속 행(successorOf)을 먼저 영속화, 선행 확정 뒤 후속 전송. 새 기록의 후속은 의도 행으로 영속화하고 생성 영수증이 오면 준비된 행으로 변환채택
B. 전송기의 삭제·되돌림에 "행이 sending 이면 건드리지 않기" 조건만 추가기각 — 어느 탭의 sending 인지 구분할 수 없고 늦은 답장과 현재 답장을 가를 수 없다
C. 전역 리더 탭 선출기각 — 이슈·ADR §3-1 이 금지. 리더 탭이 죽으면 전체가 멈춘다
D. 새 기록의 후속은 지금처럼 메모리 대기 유지기각 — 탭 종료 시 유실, 다른 탭이 먼저 올리면 오류

4. 적용한 내용

Phase 0 — 조합 표·결함 재현 (77379689, 세션 d201873b)

후속 명령(수정·삭제·계획 저장·계획 삭제) × 선행 상태(대기·전송 중·지연·보류·격리·옛 탭 sending) × 소유자(내 탭·다른 탭·다른 사용자) 조합 표를 계약 §6 으로 적고, 메모리 스토어 + 고정 시계 + 탭 둘로 지금 구조의 결함을 테스트 6건으로 고정했다. 현재 코드에서 5건 실패(전송 중 행 삭제·두 탭 동시 전송·lease 인계 없음·새 기록 후속 미보존 2건), 1건(다른 사용자 행 보호) 통과.

Phase 1 — 순수 계획기 (a4c9d870)

src/react/persistence/outbox/claimPlan.ts: 전송 중 판정(writer 가 있고 유효한 claim 이면 그 탭 소유, 아니면 종전 sentAt 10초 규칙으로 후퇴 — codec 계약 §5 조건 2·3), claim 획득(fence 이전+1·sending·evidence·writer, S02 원자 교체의 재검사로 커밋 — 새 트랜잭션 종류 없음), ACK 적용 4종(성공 삭제·일시 실패 대기·로그인 만료 보류·영구 거부 격리 — 내 탭·내 fence 일 때만, 아니면 stale 무변경; claim 은 지우지 않고 만료 처리만 해서 fence 가 오르기만 한다), 전송 가능 판정(savedAt 순, 격리·보류·전송 중·의도 행·선행 살아 있는 후속 제외).

Phase 2 — 교체 규칙·전송기·포트 배선 (35ffbd94)

교체 규칙(replacePlan.ts)이 전송 중인 이전 행을 지우지 않고 새 행에 successorOf 를 적고, 결과 미상(전송 중·지연·보류)인 새 기록의 수정·삭제는 의도 행(pending:<생성 id> 겨냥, 추정 서버 id 없음)으로 남긴다. 전송기(retryPendingWorkoutSaves)는 전송 시작·성공 삭제·실패 되돌림·보류·격리 전부를 "내 claim 이 그대로일 때만" 원자 교체로 쓰고(늦은 답장·다른 탭·옛 fence 는 무변경), 선행이 확정된 뒤 같은 플러시에서 후속을 이어 보낸다. 생성 영수증이 오면 의도 행을 prepareSave/prepareDelete 로 준비된 행으로 바꾼 뒤 생성 행을 지운다(pendingWorkoutSavesStore 포트 배선). 대기 중 카드는 수정 의도 내용을 보이고 삭제 의도면 숨긴다. 시계·tabId 주입. Phase 0 테스트 6건 → 통과 + 스토어 배선·카드 2건. npm run check 통과(단위 3,066 중 3,043 통과·건너뜀 23·실패 0).

작업 중 드러난 것

  • 기존 테스트 8개 파일의 기대값이 옛 계약(전송 중 행도 지운다·wait-for-create)이라 새 계약으로 고쳤다 — manifest 등재 파일 없음.
  • 컨트롤러 workoutWriteControllerwait-for-create 분기는 더 이상 도달하지 않는 죽은 코드 → S05 인계.
  • 대기열 적재가 시계를 주입받지 못해 테스트가 실제 시각을 탔다 → now 주입 추가.

Phase 3 — 실제 Chromium·실제 옛 번들 (c10cc75f, 세션 14f87ec1)

  • tests/react/outboxClaim.browser.mjs: esbuild 로 묶은 실제 전송기 코드를 같은 컨텍스트의 페이지 둘(같은 IndexedDB)에서 돌리고 페이지마다 시계를 주입한다. A 두 탭 동시 claim → 한 탭만 전송·이긴 탭의 답장이 삭제 · B lease 만료 뒤 인계(fence 2) → 이전 탭의 늦은 실패·성공 답장 무변경 · C 전송 중 탭 종료 → lease 안에는 손대지 않고 만료 뒤 인계, 재시작 뒤 행 0 · D 재시작 뒤 claim·writer·evidence 보존, 만료 claim 은 fence 올려 인계 — 4/4. npm run test:persistence-browser 에 연결.
  • CASE-040(신규, fault-injection-full-stack): 실제 v0.17.1 번들 탭 A 가 수정을 보내는 중(요청을 붙듦)일 때 새 번들 탭 B 가 같은 기록을 고친다 → 옛 행은 한 글자도 안 바뀐 채 남고 B 의 편집은 successorOf 후속 행, B 는 보내지 않음 → 붙든 요청을 풀면 옛 탭이 영수증으로 자기 행만 지움(서버 80kg·rev 2), 후속 생존 → B 가 후속을 보내 개정번호 충돌(LG409 400) 뒤 최신 개정번호로 재전송(서버 90kg·rev 3, 대기열 0), 두 탭 새로고침 뒤 0행, 오류 표면 0. 3회 연속 통과(25.2s·26.1s·39.4s).
  • CASE-039 를 v0.17.1 태그로 재실측(codec 계약 §5 조건 5) — 통과(1.4분): 옛 writer 는 새 필드를 행·미러에 보존하고(R4) 다른 탭의 claim 을 무시한다(R5), v0.17.0 과 같음. 기본 태그를 v0.17.1 로 올리고 BARBELIC_LEGACY_TAG 로 바꿀 수 있게 했다.

작업 중 드러난 것

  • 충돌 재전송은 새 요청 id·새 지문으로 나간다: 기대 개정번호가 지문에 들어 있어 같은 id 를 다시 쓸 수 없다(서버 멱등 장부의 재사용 거부, #1199 규칙). 본문은 서버 세부 행 id 만 뗀 같은 내용. 이 재전송을 기기 행으로 먼저 영속화하는 것은 S06(ADR §3-3) 몫 — 계약 §4·§8 에 적었다.
  • PostgREST 는 SQLSTATE 40001/40P01 원형을 재시도한다: 로컬 샌드박스(v14.16)에서 그대로 raise 하면 45초 넘게 응답이 없었다(P0001 은 400 · 14ms). save_session_v5·delete_session_v5 가 엔진의 40001 을 LG409 로 번역하는 이유가 이것이다 — 새 RPC 도 같은 규칙을 따라야 한다.
  • 장애 주입 프로필의 제약: 선언 장애는 RPC 경로마다 하나뿐이고 연결 상태 이벤트를 1개 이상 선언해야 한다. 후속의 첫 전송에 503 을 넣고 충돌 400 을 같은 경로에 두는 첫 설계는 레지스트리가 거부했다 → 연결 상태 이벤트는 새 탭 부팅의 준비 조회 503(CASE-012/014 기법)으로 만들고, save_session_v5 의 선언 장애는 실제 충돌 400 하나로 했다. 옛 탭이 살아 있는 동안 대기열에 503 을 넣으면 옛 탭도 회복 때 같은 행을 보내 시도 번호가 비결정적이 되므로(옛 탭은 successorOf 를 모른다) 이 편이 맞다.
  • headless Chromium 에서는 bringToFront 만으로 visibilitychange 가 오지 않아 e2e 가 online·visibilitychange 를 직접 준다. B 탭이 기록 생성 전에 열려 있으면 달력이 옛 데이터를 보여 새로고침이 필요했다.
  • 세션 한도: 이전 세션이 CASE-040 문서 작성 중 한도로 끊겨 이 세션이 이어받았다(인수 댓글 §21).

Phase 4 — 문서·인계 ((같은 PR))

계약 §7 실측·§8 인계(S05·S06·S07·R02) · ADR §3-1 표 S04 행 3개 구현 링크 · codec 계약 §5 "S04 가 켰다" + §6 S04 행 완료 · 도메인 계약 §12(활성)·§17 OutboxPort · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1 조건표 행 분할·§6 · G05 규칙 1줄(docs/contracts/outbox-claim-successor.md) + durable 장부 controller/service 행 갱신 + 재생성 · 이 기록 + 등록 2곳.

Phase 5 — 검증·PR·랜딩

ci:local --full 13분 58초 — verify(8단계)·db reset·schema 스냅샷·pgTAP 117파일/2036 assert·동시 저장 세대·e2e-local 11/11·empty 7/7·cardio 6/6·persistence 15/15·viewport 14/14 통과, e2e-browser 38/39. 실패 1건은 CASE-015 가 대기열 행의 필드 집합을 정확히 고정한 단언이라 새 writer 의 claim·evidence·writer 를 몰랐고, 시도마다 바뀌는 attempt 메타까지 identity 로 비교하던 것 — 결함이 아니라 계약 변경. 단언을 새 계약으로 갱신(재시도 시 fence·attemptCount +1, 실패 뒤 claim 만료 처리 단언 추가, 82f45ba1) → 브라우저 레인 재실행 39/39(7분 43초). 리베이스 2회(main 0775e6b2 A07·S03·B02·D02 합류 — codec 계약 §6 표·장부 규칙 JSON·장부 md 충돌 3건 해결, 코드 충돌 없음 · main e860d7b0 문서 2개, 충돌 없음) → PR #1369 → CI 1차(run 34140643136) unit-tests 2건 실패 → 수리(2abca52c) → CI 2차(run 34141431432) 전 잡 success → npm run landing:request -- --pr 1369(요청 a6c566e6, run 34142011762) → squash 머지 f8334135 · staging deploy run 34142049591 success.

작업 중 드러난 것

  • 사전 검증 누락 2건(로컬로 재현 가능했음): ① CASE-015 스펙 소스 검사(tests/react/case015WriteSurvivalContract.test.mjs)가 "단언 완화 표현 금지" 규칙(toBeGreaterThanOrEqual 등)을 두는데, CASE-015 를 고친 뒤 npm run check 를 다시 돌리지 않고 e2e 만 돌렸다 → CI 에서 잡힘. 정확한 값(fence 1·attemptCount 1·lastErrorCode)으로 바꿨다. ② 삭제 컷오버 테스트 ④가 생성 행과 의도 행이 같은 밀리초에 적히면 savedAt 순서가 정해지지 않아 CI 에서만 뒤집혔다 → 순서 무관 비교로.
  • main 소유 플레이키: 로컬 npm run check 병렬 실행에서 tests/auth/accountDeleteApi.test.mjs 의 시간 초과(504) 검사가 1회 실패, 단독 3회 통과·이 브랜치에서 무변경(B01 #1331 소유). CI 에서는 통과.
  • 랜딩 큐는 PR CI 초록 + merge-base = 현재 main 을 요구한다 — 요청 직전 fetch 로 확인했다.

5. 적용 결과

항목
전송 중 행 위의 새 편집전송 중 행 삭제 → 늦은 실패 답장이 부활시킴전송 중 행 보존 + 후속 행(successorOf), 부활 0(메모리·실제 Chromium·CASE-040)
두 새 탭이 같은 행을 동시에 보냄2회1회(claim 하나만 커밋, 진 탭은 stale)
다른 탭·옛 fence·다른 소유자의 늦은 답장현재 상태를 덮음무변경(ACK 4종 전부 "내 탭·내 fence" 검증)
전송 중 탭 종료10초 뒤 다른 탭이 되돌림(옛 규칙)같음(lease 10초) + 인계 시 fence 상승·재시작 뒤 행 하나
새 기록 전송 중 수정·삭제메모리 대기, 탭 종료 시 유실·다른 탭 답장 시 오류의도 행으로 기기 보존, 생성 영수증 뒤 준비된 행으로 자동 변환
답장 미상 선행과 후속의 순서후속이 먼저 나갈 수 있음선행 먼저(같은 id 재확인), 후속은 선행 확정 뒤
옛 탭(v0.17.1)과 새 탭 공존CASE-039 v0.17.0 실측CASE-039 v0.17.1 재실측 통과 + CASE-040 3/3
저장 형식·서버v1v1, 추가 필드만(claim·successorOf·evidence·writer), 서버 무변경
  • 자동 검증: npm run check Phase 마다 통과 · npm run test:persistence-browser 4/4 · CASE-040 3회 연속·CASE-039 v0.17.1 (로컬 샌드박스 6fe690a7, 2026-09-08) · ci:local --full 통과(브라우저 레인 재실행 39/39) · CI run 34141431432 전 잡 success · 랜딩 run 34142011762 · staging deploy run 34142049591 success
  • 미검증: 옛 탭이 생성 행을 먼저 지운 창(옛 탭은 의도 행 규칙을 모른다)의 고아 의도 행은 blocked(LG_PREDECESSOR_RECEIPT_UNKNOWN) 로 보존만 한다 — 화면 표시(U02/U03)와 receipt query RPC(S05·서버 트랙)는 이 트랙 밖. 실제 기기(iOS WKWebView·Android WebView)의 IndexedDB·BroadcastChannel 의미론은 Chromium 과 같다고 가정한다.

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

연달아 고쳐도 마지막 의도가 살아남는다

전송 중인 첫 요청은 지워지지 않고, 두 번째 편집은 후속 행으로 남아 첫 요청의 결과가 확정된 뒤에 나간다. 옛 요청의 늦은 답장은 아무것도 되살리지 못한다.

탭을 여러 개 열어도 같은 행을 두 번 보내지 않는다

행을 집은 탭만 보내고, 그 탭이 죽으면 10초 뒤 다른 탭이 번호를 올려 이어받는다. 죽은 탭의 늦은 답장은 이어받은 탭의 상태를 바꾸지 못한다.

새 기록을 바로 고쳐도 기기에 남는다

서버 id 가 없는 기록의 수정·삭제 의도가 기기에 저장되고, 영수증이 오면 자동으로 서버 id 요청으로 바뀐다.

구조적으로 남는 것

  • claim·ACK 계획기와 전송 가능 판정(claimPlan.ts) — S05 dispatcher 가 그대로 부른다. prepareSuccessor·findCreateReceipt 포트 형태.
  • fence 는 행이 살아 있는 동안 오르기만 한다 — S07 index/mirror 가 보존해야 할 의미(종결 tombstone 필요).
  • 실제 옛 번들 탭 공존 e2e 두 개(CASE-039·CASE-040)와 실제 Chromium 두 탭 스위트 — 앞으로의 대기열 변경은 같은 방식으로 실측된다.
  • 조합 표(계약 §6)가 문서로 있다.

남은 것

  • 릴리스 v0.18.0 머지 뒤 이슈 [v0.18.0 반영완료] + 닫기.
  • 총괄 S04 카드 상태 갱신(HQ 몫 — 이슈 마지막 댓글에 갱신안).
  • S05: wait-for-create 죽은 분기 정리 · 영수증으로 후속을 미리 재준비(충돌 왕복 1회 절감) · receipt query 포트 구현. S06: 충돌 재전송을 후속 행으로 영속화. S07: fence·successorOf·종결 의미 보존. U02/U03: 고아 의도 행 표시.