D14 — 세션 전체 재저장을 실제 변경 행 저장으로 (2026-09-10)
- 기간: 2026-09-10, Codex 작업 1개. 오너 지시: “바벨릭 #1533 진행해줘. 나한테 물어보지 말고 phase 끝까지 완주”.
- 랜딩: 앱 PR #1551 ·
576dae9b9dc77478908c846a406a477a0bac6459, 2026-09-10 21:00:46 KSTrelease/v0.18.0실제 병합 확인. forward migration20260914203000,20260914213000, 둘 다 low. staging/Production 배포 없음. - 설계서: D14 실행 이슈 #1533, Phase 4 채택 계획.
- 정본: 세션 자식의 변경 행 저장, 세션 모델, 사용자 사실 불변.
- 도구: 앱
scripts/performance/probe/session-write-delta.mjs,scripts/performance/workload/generate.mjs,scripts/migrations/upgrade/harness.mjs. 전후 함수 교체 드라이버와 원본 상세 로그는 작업 디렉터리work/의 비추적 파일이며, 아래 공개 증거는 해당 실행에서 추출했다. - 게이트:
sessionWriteDelta.test.mjs,sessionWriteDeltaSafety.test.mjs; 로컬 precheck/승격 CI 필수 DB 묶음에 연결했다. 개별 pgTAP 8파일/331 assert, 실제 DB 동시성·안전성 18개와 26개 행 시나리오를 실행했다. 브라우저/e2e는 제품 UI를 바꾸지 않은 이 작업에서 별도 실행하지 않았다. - 버그리포트: BUG-110 — 앞 삽입·큰 위치값 저장 실패.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | D11 실제 release 통합·고정 fixture 기준선 | 완료, 4a06d42f / D11 보완 통합 81efa0c4 |
| Phase 2 | 정규화된 typed 행 비교·불변 UPDATE 생략 | 완료, a6270f34, d9e31532 |
| Phase 3 | 실제 재정렬·필요한 파생값 갱신 | 완료, fd5c4cba |
| Phase 4 | 실패·경합·복원·guard와 필수 DB 게이트 | 완료, 6e44c746, 08fa29e9 |
| Phase 5 | 전후 비용·populated upgrade·최종 통합 | 완료, 08fa29e9 / release 576dae9b |
1. 배경
D11의 통계 출력 재사용은 원본 저장의 반복 UPDATE를 없애지 않았다. 세션 메모만 수정해도 자식 네 테이블에서 UPDATE 21건이 발생했다. 종목·세트는 값이나 순서가 그대로여도 위치를 10000만큼 옮겼다가 되돌렸다.
직접 선행 D02/D04/S10과 D11 PR #1547의 실제 release 반영을 확인한 뒤 분기했다. D11 후속 PR #1548의 검사 보완도 원 담당자에게 맡기고 00a93ad3을 정상 통합했다. 선행 대기 중 사용자가 요청한 5분 간격 확인은 D11 병합 확인 후 멈추고 구현을 재개했다.
2. 문제 제기
메모·같은 내용·같은 반올림 값에도 자식 UPDATE와 불필요한 위치 이력이 생겼다. 앞쪽에 새 세트나 종목을 넣을 때는 기존 1번 행을 옮기기 전에 INSERT하여 유일키 충돌이 발생했다. 큰 양의 위치에 고정값 10000을 더하는 방식은 정수 범위도 넘을 수 있었다. 모두 격리 DB의 고정 fixture에서 관측했으며 Production 발생률은 측정하지 않았다.
3. 해결 방안
오너가 승인한 범위는 실제 값이 달라진 행만 저장하면서 ID·출처·원본 이력·revision·영수증·통계 세대 계약을 유지하는 것이다. writer 안에서 typed 후보를 정규화한 뒤 IS DISTINCT FROM으로 비교한다. hash나 memo-only 특례는 누락/null·정규화·필요한 파생값을 놓칠 수 있어 채택하지 않았다. 트리거를 끄거나 모델·클라이언트 patch API를 바꾸지 않았다.
4. 적용한 내용
Phase 1은 일반·복합·자유 기록을 가진 고정 세션에서 실제 I/U/D·WAL·함수 호출을 수집했다. Phase 2는 세부 종목과 세부 세트를 테이블 rowtype에 담아 비교하며, 기존 단위 정규화와 투영 계산만 내부 core로 추출했다. 기존 UPDATE의 열 목록과 보호 트리거는 유지했다.
Phase 3은 실제 이동 ID를 먼저 모아 기존/요청 위치를 피하는 빈 양의 자리에 옮긴다. 새 행을 넣기 전에 이동 집합 전체를 비우며, 값이 같은 형제는 그대로 둔다. 부모 변경의 재투영도 실제 결과가 달라지는 자식만 UPDATE한다.
Phase 4는 같은 mutation 재전송·stale revision·타 owner/타 owner 자식 ID·인입 세션 읽기 전용·중간 실패·복원 후 재편집을 확인했다. 첫 세트를 UPDATE한 다음 두 번째에서 실패하도록 주입해 원본·이력·revision·영수증·세대·잡·dirty 전체의 롤백을 비교했다. 기존 수정/삭제·enqueue·복원 경합도 통과했다.
작업 중 드러난 것
- Phase 1 check의 D11 reader 관련 6개 실패는 D11 원 담당자의 PR #1548로 해소했다. 중복 수리하지 않았다.
- Phase 2에서 deployment manifest 동기화와 감사 신고 형식을 보완했고, 추출한 계산·typed 저장을 옛 SQL 문자열로 찾던 3개 검사는 연결·소속·출처·값 단언을 유지해 갱신했다.
- 추가 pgTAP 첫 실행의 원본 보호 3개 실패는 기존 복원 동시성 fixture가 남긴 이력 5건을 전역 count/min(id)로 읽었기 때문이다. 제품을 바꾸지 않고 DB 재생 후 195개 모두 통과했다. 실패 기록과 깨끗한 재생 후 기록을 함께 보존한다.
- Phase 2의 예비 시간 측정은 전체 검사와 겹쳤으므로 지연 개선 근거에서 제외했다. 최종 전후 표는 같은 DB에서 순차 실행한 별도 20회 표본이다.
5. 적용 결과
아래는 Production 수치가 아니다. PostgreSQL 17.6.1.127의 전용 Supabase DB에서 트랜잭션을 롤백하는 고정 fixture를 사용했다. I/U/D는 AFTER ROW 감사로 실제 DML을 셌고, 시간·WAL은 감사 트리거 없이 track_functions=all로 측정했다. 호스트 실행 순서와 백그라운드 WAL의 영향은 남는다.
| 시나리오 | 자식 UPDATE 전→후 | WAL 중앙값 전→후 | 저장 왕복 p95 전→후 (각 20회) |
|---|---|---|---|
| 메모 수정 | 21→0 | 22,696→5,472 bytes | 16.46→13.00ms |
| 동일 내용 새 저장 | 21→0 | 20,168→4,104 bytes | 17.09→13.82ms |
| 무게 한 칸 | 21→1 | 22,336→6,272 bytes | 17.41→13.35ms |
| 체중 변경, 모든 계수 0 | 26→0 | 27,208→5,552 bytes | 16.82→12.23ms |
전체 비용 요약, 변경 전 표본, 변경 후 표본, 전 DML, 후 DML.
변경 후 26시나리오×20회=520회 성공. 변경 전에는 앞 세트/종목 삽입·큰 위치값 3시나리오×20회=60회가 기존 오류로 실패했다. 이 실패를 지연 표본에 섞지 않았다. 성공하던 23시나리오는 과거 writer와 최종 값을 비교했으며, 변경하지 않은 행의 전체 JSON·ctid와 원본 ID·출처를 확인했다. 기록 항목을 숨겨도 무게 원본은 100을 보존하고 통계값만 0이 됐다.
| 검증 | 결과 |
|---|---|
| Phase 2/3 check | 각 3,572 통과, 실패 0, 조건부 미실행 89 |
| Phase 4/5 최종 check | 3,572 통과, 실패 0, 조건부 미실행 92 |
| 행별 쓰기·동치 | 26시나리오, 독립 예상 ID별 I/U/D 및 보존 확인 |
| 실제 안전성·동시성 | 18개 통과 |
| 저장·위계·정규화 pgTAP | 4파일/136 assert 통과 |
| 원본 보호·복원 pgTAP | 깨끗한 재생 뒤 4파일/195 assert 통과 |
| populated upgrade | 260세션·3,120세트, 원본 8,854행 보존. 2개 migration 합계 560ms, 잠금 대기 0, 소유자 probe 28회 포함 총 35회 오류 0. 둘째 migration 1/2문장 뒤 롤백·재실행 성공, 공존 break 0 |
| 필수 precheck | 8분 41초, static 통과 · unit-1 1,682 pass/0 fail/40 skip · unit-2 1,890 pass/0 fail/52 skip · 전체 DB reset/schema · pgTAP 143파일/2,825 assert · 동시성/복원/D14·순차 통계·worker·큰 이력 CRUD/수렴 모두 통과. 예외·생략 없음 |
| Merge Check | 실행 34474243962 성공, 검증한 merge commit 576dae9b의 목적 release 도달 확인 |
| Production·실기기 효과 | 미측정·미배포 |
필수 precheck 요약: HEAD 08fa29e9, 최신 base 00a93ad3. precheck는 브라우저/viewport·승격 full CI를 실행한 결과가 아니다.
Populated upgrade 증거 (JSON). 기준 00a93ad3 → 08fa29e9; 실제 workload는 공개 생성기의 history-1y이며 Production 데이터 사본은 아니다.
개별 검증: Phase 2 DB, Phase 3 DB, pgTAP, 경합, 안전성, check 2, check 3, check 4, check 5.
6. 이번 개선으로 향상된 것
메모와 작은 수정이 무관한 원본 행·인덱스·위치 이력을 다시 쓰지 않는다. 실제로 바뀐 값의 보호 이력과 복원 가능성은 유지하며, 정규화 계산을 공유하므로 writer와 트리거의 저장 의미가 어긋나는 위험을 줄였다. 필수 DB 검사에서 이 계약을 다시 확인한다.
남은 것
full payload 검증과 완료 저장의 통계 enqueue는 유지된다. R01/G05에 활성 저장 경계와 증거를, R04에 전체 비용 확인을, R05에 전체 release의 최신 데이터 upgrade·운영 전환을 인계한다. 이 기록은 해당 트랙들의 완료나 staging/Production 승격을 뜻하지 않는다.