Skip to content

유저 기록 원본 불변 — 마이그레이션이 유저 무게 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 -- --full 2026-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.md 0-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 API config/database/pooler 실측 aws-1-…). 원안대로면 첫 실행에서 접속 실패.
  • 샌드박스 1차: pgTAP 25파일 실패 — 픽스처의 profiles upsert(postgres 권한), 엔진 직접 호출(auth.uid() 없음), 등급 목록의 만료 조건에 old. 접두어 누락, 그룹 픽스처의 시각 캐스트. → 픽스처 티켓 pgtap:fixture 43파일, 명시 신원 함수, 등급 목록 수정.
  • 샌드박스 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, 종목·블록 기록 유형 정정).