유저 기록 원본 불변 — 마이그레이션이 유저 무게 16건을 지운 사고에서 원본 보호 트리거·이력·투영 규칙·스키마 변경 보호까지 (2026-09-04)
- 기간: 2026-09-04 (세션 3개 — ea8375e7 분석·Phase 0·도구, 7d93c5c5 아님, b421a68d PR 1 랜딩·PR 2. 오너 지시 원문: "마이그레이션하면서 유저 데이터를 지웠잖아. 서비스중에는 절대 발생하면 안 돼.")
- 랜딩: PR #1243(Phase 0·4 도구·5,
30808adb) · PR #1247(정적 검사 유예 대조 수정,92eca62f) · PR #1254(Phase 1·2·3·4 — 마이그레이션 4개20260910200000_user_fact_history_and_protection_v1·20260910200100_recording_fields_change_is_projection_v1·20260910200200_system_write_paths_reclassified_v1·20260910200300_user_fact_ddl_guard_v1,c0d7f217, CI 1회 초록: verify·migration-smoke·browser-journeys 2샤드·viewport-matrix) — Vercel 배포 없음(앱 코드 변경 없음) - 설계서: 없음 — 설계 원칙과 계획은 이슈 #1236 본문(2차 개정)과 "PR 2 설계 확정" 댓글
- 정본:
docs/contracts/user-fact-immutability.md(P0·R1~R5·티켓 형식·연쇄 규칙·함수 분류표) · 등급 목록supabase/contracts/user-fact-columns.json(v2) · 전역 지침 CLAUDE.md §23 - 도구:
scripts/migrations/generate-user-fact-protection.mjs(보호 SQL 생성기) ·npm run db:loss-audit(Production 롤백 유실 감사) ·scripts/check-supabase-migrations.mjs(원본 DML 정적 검사) ·.github/workflows/user-data-copy.yml(매일 데이터 사본) - 게이트: 계약 테스트
tests/react/userFactColumnsContract.test.mjs(등급 누락·마이그레이션 = 생성기 출력) · pgTAP 4파일user_fact_protection_v1(43)·user_fact_projection_rule_change_v1(17)·user_fact_write_paths_v1(8)·user_fact_ddl_guard_v1(11) · 로컬 통과 기록:npm run ci:local -- --full2026-09-04 04:57 KST — db reset 통과 · pgTAP 103파일/1727 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 35/35·viewport 13/13 ·npm run check통과(2,600여 tests) · 유실 감사(스테이징 롤백) 파괴 0·삭제 0 - 버그리포트: 없음 — 사고 자체는 이슈 #1235(무게 소실 수리 트랙)의 몫
- 계약:
docs/contracts/user-fact-immutability.md전체(신설) ·docs/process/rollback.md§8 값 복원 ·docs/process/migration-landing.md0-2 유실 감사 ·supabase/migrations/README.md규칙 5
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 원칙 정본화 — 계약 문서·컬럼 등급 목록(21→23 테이블)·계약 테스트·전역 규칙 | ✅ PR #1243 (30808adb) |
| Phase 1 | 이력 테이블 + 원본 보호 트리거(유저 테이블 23개) + 복원 함수 | ✅ PR #1254 (c0d7f217) |
| Phase 2 | 규칙 변경 = 투영 — 값 삭제 트리거 폐지, 투영 계산 함수, 저장 수정 경로의 숨은 값 보존 | ✅ PR #1254 (c0d7f217) · 2-2 반복수 투영은 #1256 |
| Phase 3 | 시스템 쓰기 경로 재분류(48개) — 엔진 명시 신원, 관리자 함수 티켓 | ✅ PR #1254 (c0d7f217) |
| Phase 4 | 스키마 변경 보호 — 정적 검사·유실 감사 도구(PR #1243·#1247) + 이벤트 트리거(PR 2) | ✅ 도구 PR #1243·#1247 · 트리거 PR #1254 |
| Phase 5 | 매일 데이터 사본(90일) + 값 복원 절차 | ✅ PR #1243, 첫 사본 2026-09-04 03:40 UTC |
| Phase 6 | 나머지 유저 테이블 확장 | Phase 1 에 흡수(등급 목록 23개 전부) |
1. 배경
8/13 마이그레이션 20260812001100(PR #407)이 "기록 유형에 없는 세트 무게는 자리표시자 0이다"라고 가정하고 유저가 실제로 입력한 무게 16건을 null 로 바꿨다(#1235, 3주 뒤 발견). 그 값은 어디에도 남지 않았다 — 시점 복원(PITR)은 꺼져 있었고 이력 테이블도 없었다. 처음 계획(1차 안)은 "랜딩 전에 유실 건수를 세는 절차"였는데, 오너가 "절차 검사는 근본책이 아니다"라고 지적해 설계 원칙부터 다시 세웠다.
2. 문제 제기
스키마와 코드가 "유저가 기록한 원본"과 "시스템이 계산한 투영"을 구분하지 않았다
세트 테이블에서 유저가 친 load(원본)와 계산값 effective_load·stats_load_kg(투영)가 같은 권한으로 나란히 있었다. 마이그레이션·트리거·서버 함수가 아무 컬럼이나 쓸 수 있었고, 최종 스키마에서 원본 테이블에 UPDATE/DELETE 하는 서버 함수가 48개였다.
규칙(기록 유형)이 저장된 값을 지웠다
세부 종목의 기록 유형이 바뀌면 트리거 rescrub_session_exercise_sets_v1 이 그 유형에 없는 값을 null 로 지웠다 — #407 과 같은 발상이 런타임에도 살아 있었다. Production 실측(9/4): 세트 값 행 16,628건 중 기록 유형 밖 값 0건(그동안 지워 온 결과).
이력이 없었다
개정 번호(server_revision)와 내용 없는 영수증만 있었다. 덮어쓰면 끝.
3. 해결 방안
원칙 (오너 결정 D1~D5, 2026-09-04)
- D1 P0 승인 — "유저가 기록한 것은 원본, 앱이 계산한 것은 투영. 원본은 유저 본인만 바꾸고 바꿔도 이전 버전이 남는다. 시스템은 해석만 바꾼다."
- D2 PITR 은 재정 문제로 켜지 않는다 → 매일 데이터 사본(90일).
- D3 규칙 변경 시 맞지 않는 값은 숨김 — 값 삭제 트리거 2개 폐지, 숨긴 값은 재저장 시 건드리지 않는다.
- D4 #1215 랜딩 직후 첫 후속 랜딩으로 적용(새 테이블 5개에 태어날 때부터).
- D5 인입 출처 행(WodUp·Motra)은 보호 밖, 관리자 대리 수정은 티켓 방식.
접근
| 대안 | 판정 |
|---|---|
| 랜딩 전 유실 감사 절차(1차 안) | 기각 — 사고 뒤 세어 보는 것. 증명 도구(db:loss-audit)로만 남긴다 |
| 앱·함수마다 "원본을 건드리지 않게" 고치기 | 기각 — 다음 함수·다음 마이그레이션에서 또 난다 |
| DB 층에서 길을 막기 — 컬럼 등급 계약 + 행 트리거(주인 또는 티켓만) + 추가만 되는 이력 + 규칙 변경은 투영만 + DDL 보호 | 채택 — 어느 경로(마이그레이션·트리거·함수·관리자·psql)로 와도 같은 판정 |
4. 적용한 내용
Phase 0 — 원칙 정본화 (PR #1243)
계약 문서·등급 목록·계약 테스트·전역 지침. 등급 목록은 #1215 이후 이름으로 적고 이전 이름을 aliases 로 받아 랜딩 전후 어느 스키마에서도 테스트가 돈다.
Phase 4 도구·Phase 5 (PR #1243, #1247)
check:migrations확장 — 원본 테이블 DML/DDL 은 같은 파일에 티켓 선언 +-- loss-audit:줄 필수. 유예 목록은 번호 뒤 이름으로 대조(#1247, 랜딩 시 번호 재부여에도 유지). #1215 파일 8개 유예.npm run db:loss-audit— Production 롤백 트랜잭션에서 원본 파괴·삭제 건수를 센다(#407 형 문장 → 파괴 7건 검출).- 매일 사본 워크플로 — 풀러 호스트를 실측값
aws-1-ap-northeast-1로 정정 후 첫 실행 성공(21테이블, 35MB, 90일).
Phase 1 — 이력 + 보호 트리거 (PR 2, user_fact_history_and_protection_v1)
생성기가 등급 목록에서 뽑은 SQL 을 마커 사이에 그대로 담고 계약 테스트가 대조한다. 테이블 23개마다 BEFORE UPDATE/DELETE 트리거(zz_ 이름으로 정규화 트리거 뒤에 판정)와 TRUNCATE 차단. 판정 순서: 원본 안 바뀜 → 보호 밖(인입·공식 카탈로그) → 시스템 삭제 허용(만료 백업·빈 그룹) → 주인 계정 없음(계정 삭제 연쇄, 이력 없음) → 부모 행 없음(부모 삭제 연쇄) → 행위자 = 주인(이력) → 티켓(이력) → 42501.
Phase 2 — 규칙 변경 = 투영 (PR 2, recording_fields_change_is_projection_v1)
값 삭제 트리거·함수 폐지. 통계 투영 컬럼 stats_load_kg·stats_effective_load_kg 를 생성 컬럼(다른 테이블의 기록 유형을 볼 수 없다)에서 트리거 계산 컬럼으로 바꾸고, 계산 함수 하나 project_exercise_set_part_values_v1 이 기록 유형을 보고 effective_load 와 함께 낸다(숨은 무게 = 통계 0). 부모(세션 체중·세부 종목 계수/배수/기록 유형)가 바뀌면 stats_load_kg = stats_load_kg 로 투영 트리거만 다시 태운다. 저장 함수 write_session_children_v5 의 수정 경로는 기록 유형에 없는 값을 기존 값으로 둔다.
Phase 3 — 시스템 쓰기 경로 재분류 (PR 2, system_write_paths_reclassified_v1)
48개 함수를 유저 경로 31 / 투영·시스템 갱신 4 / 인입 4 / 관리자 4 / 이미 폐기 5 로 분류(계약 §6). v5 엔진 2개는 명시 신원 lift_guild.actor_user_id 를 선언하고 트리거는 user_fact_actor_v1()(auth.uid() 우선)로 읽는다. 관리자 함수 4개는 본문 첫머리에서 admin:<함수명> 티켓을 선언한다.
Phase 4 — 스키마 변경 보호 (PR 2, user_fact_ddl_guard_v1)
이벤트 트리거 2개 — sql_drop(보호 테이블·원본 컬럼·보호 트리거/함수 DROP), ddl_command_end(ALTER TABLE 뒤 보호 트리거가 꺼져 있으면 거부). 타입 변경은 정적 검사 몫.
주요 결정과 그 근거
- 연쇄 삭제·계정 삭제 — 자식 트리거는 부모 행이 없으면 부모가 판정한 것으로 보고 통과. 주인 계정이 없으면 이력도 남기지 않고 이력 테이블 자체가
owner_user_id로 계정과 함께 지워진다(개인정보 삭제 요청과 맞물림). - 그룹 — 승계 트리거가 바꾸는
group_members.role은 시스템 등급, 멤버 0명 그룹은 시스템 삭제 허용. 공지·채팅·댓글 소유 규칙은 현재 함수 동작(글쓴이 또는 그룹/세션 주인) 그대로 SQL 로 적는다. - 통계 투영을 생성 컬럼에서 트리거 컬럼으로 — 생성 컬럼은 부모 테이블의 기록 유형을 볼 수 없어 "숨김"을 표현할 수 없었다.
- 관리자 티켓은 함수 설정이 아니라 본문 선언 —
ALTER FUNCTION … SET "lift_guild.repair_ticket"은 Supabase 의postgres역할(비 슈퍼유저)이 미등록 설정값을 넣을 수 없어 42501 (샌드박스 실측). - 반복수 투영(Phase 2-2)은 뒤로 — 반복수를 직접 읽는 통계 함수 14개 교체는 같은 이슈에서 이어서. 기록 유형에서 반복수가 빠지는 경우는 유산소 프로필뿐.
작업 중 드러난 것
- PR #1243 의 정적 검사가 #1215 브랜치의 마이그레이션 8개를 140건으로 걸러냈다(랜딩 중인 다른 트랙) — 유예 목록을 번호 뒤 이름으로 대조하도록 고쳐 #1247 로 즉시 랜딩하고 #1215 이슈에 알렸다.
- 사본 워크플로의 풀러 호스트
aws-0-…은 존재하지 않는 주소였다(Management APIconfig/database/pooler실측aws-1-…). 원안대로면 첫 실행에서 접속 실패. - 샌드박스 1차: pgTAP 25파일 실패 — 픽스처의
profilesupsert(postgres 권한), 엔진 직접 호출(auth.uid()없음), 등급 목록의 만료 조건에old.접두어 누락, 그룹 픽스처의 시각 캐스트. → 픽스처 티켓pgtap:fixture43파일, 명시 신원 함수, 등급 목록 수정. - 샌드박스 2차:
ALTER FUNCTION … SET커스텀 설정값 42501 → 본문 선언으로 전환. - 샌드박스 3차: 값 삭제 경로가 하나 더 있었다 — 세트 값 채움 트리거
populate_wodup_atomic_set_values_v1이 INSERT/UPDATE 마다 기록 유형 밖 값을 null 로 지웠다(#407·rescrub 과 같은 발상의 세 번째 자리, 인입 행이 아닌 행에도). 그 블록을 뺐다. 세션 상세 읽기build_session_detail_payload_v1도 값을 걸러 내지 않아 재발행했다. schema.sql의 "마지막 정의" 로 살아 있는 함수를 고르면 이미 DROP 된 함수를 되살린다 —remap_wodup_user_customs_to_canonical_v1·materialize_wodup_unmatched_user_customs_v1(#1175 폐기)을 재발행했다가 기존 pgTAP(폐기 확인·신원 전환 0건)이 잡았다. 마지막drop function줄이 마지막create뒤에 있으면 폐기된 것.- 이벤트 트리거
pg_event_trigger_dropped_objects()는 트리거 객체의schema_name이 비고, 함수 객체의object_name이 빈다(오버로드) —object_identity로 판정해야 한다. - 이력의 주인: 부모 삭제 연쇄에서 자식 트리거는 부모 행이 이미 없어 주인을 알 수 없다 → 부모의 마지막 이력에서 잇는다(안 그러면 계정 삭제 때 자식 이력이 남는다).
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
#407 형 문장(update … set load = null where … recording_fields)의 Production 실행 | 무게 16건 소실, 이력 없음 | 첫 행에서 42501 거부, 0행 변경(마이그레이션 자가 검증 + pgTAP) |
| 규칙(기록 유형) 변경 시 세트 값 | 트리거가 null 로 삭제 | 값은 그대로, 통계 투영만 0(숨김), 되돌리면 다시 계산 |
| 원본 변경 이력 | 없음 | user_fact_history — 이전 행·바뀐 컬럼·행위자/티켓·주인·시각, 추가만 가능 |
| 특정 유저 특정 행 복원 | 불가 | restore_user_fact_v1 + 매일 사본(90일) |
| 보호 트리거를 끄거나 원본 컬럼을 지우는 DDL | 자유 | 티켓 없이는 42501(이벤트 트리거) + 정적 검사 |
| 시스템 쓰기 함수 48개 | 구분 없음 | 갈래별 판정(계약 §6) |
Production 적용 후 프로브(릴리스 v0.17.0, 2026-09-07 읽기 전용): 보호 트리거 23개 · 이벤트 트리거 2개(user_fact_ddl_guard_drop_v1·user_fact_ddl_guard_alter_v1) 존재 · 투영 열(stats_load_kg) 채움 16,704행(9/4 16,628 + 이후 기록, 빈 행 0) · user_fact_history 464행(티켓 issue#1237 227 · issue#1235 6 · 티켓 없음 = 유저 본인 변경 231). db:loss-audit 는 대기 마이그레이션이 0개라 이번에는 대상이 없다(다음 마이그레이션 트랙에서 쓴다).
6. 이번 개선으로 향상된 것
- 유저 기록 원본은 어느 경로로도 시스템이 조용히 바꾸거나 지울 수 없다. 바꾸려면 티켓과 이력이 있어야 한다.
- 규칙 변경은 값이 아니라 해석을 바꾼다 — 사고의 발상 자체(자리표시자 가정)가 코드에서 사라졌다.
- 사고가 나도 되돌릴 원천이 둘(이력·매일 사본) 생겼다.
남은 것
- Phase 2-2 — 반복수 투영(
stats_reps)과 반복수를 직접 읽는 통계 함수 14개 교체 → 후속 이슈 #1256로 분리(오너 결정 2026-09-04). - #1235 Phase 1 — 이 계약의 첫 티켓 사례(
issue#1235, 종목·블록 기록 유형 정정).