Skip to content

RPC 표면 정리 (2026-08-20)

2026-08-20 RPC 전수 감사에서 나온 작업 패키지 P0~P4와, 그 과정에서 오너 결정이 필요했던 3건을 처리한 기록이다. 마이그레이션 6건이 Production까지 적용됐고, 그 중 2건은 감사가 예상하지 못했던 문제를 고치기 위해 나중에 추가된 것이다.

관련 PR: #468 #469 #471 #472 #473 #475 #477 #478 #480


1. 배경

앱은 화면 하나를 그릴 때마다 DB 함수(RPC)를 부른다. 이 함수들이 곧 앱과 DB 사이의 계약이고, 계약이 흐트러지면 화면이 조용히 잘못된 값을 보여주거나 저장이 실패한다.

이 표면이 1년 가까이 누적되면서 몇 가지가 겹쳤다.

  • 은퇴한 함수가 남아 있었다. 앱에서 호출을 끊었지만 DB에는 그대로 살아 있는 함수들이 있었다. 실행 권한도 대부분 남아 있어서, 아무 로그인 사용자나 REST로 직접 부를 수 있었다.
  • 권한이 기본값에 의존하고 있었다. PostgreSQL은 새 함수에 PUBLIC 실행 권한을 자동으로 준다. 함수를 만들 때마다 명시적으로 회수하지 않으면 열린 채로 배포된다.
  • 함수 본문이 저장소에 없었다. 과거에 pg_get_functiondef로 라이브 정의를 읽어 문자열 치환한 뒤 실행하는 패치가 몇 번 있었다. 결과물은 재생된 DB 안에만 존재해서, schema.sql이 자기가 정의한다고 주장하는 함수를 실제로는 정의하지 못하고 있었다.
  • 운영 관측이 얼어 있었다. 관리자 운영 점검 화면의 기대치가 2026-06 시점 상수로 코드에 박혀 있어서, 그 뒤 배포는 전부 "예상과 다름"으로 보였다.

감사는 전수로 진행했고 45건을 찾았다. 반박된 건 0건이었다. 그 중 HIGH는 2건 (cheer_session, 즐겨찾기 시드 트리거)으로, 둘 다 예정된 바벨 로우 행 병합을 막고 있었다.


2. 문제 제기

찾은 것들을 성격별로 묶으면 네 덩어리였다.

표면 위생 — 부를 수 있으면 안 되는 것이 부를 수 있었다

앱이 더 이상 쓰지 않는 함수 8종이 DB에 살아 있었다. 응원 기능 cheer_session, 온보딩 중간 저장 save_onboarding_progress, 달력 read model의 수리·점검 함수 쌍, 죽은 통계 갱신 함수 3종, 그리고 완료 세션 하위 항목을 직접 고치는 함수 하나.

이들 대부분은 로그인만 하면 REST로 직접 호출할 수 있었다. 쓰는 곳이 없으니 눈에 띄지도 않았고, 그래서 더 위험했다.

카탈로그 지뢰 — 종목 하나를 손대면 가입이 막혔다

신규 가입자에게 기본 즐겨찾기를 심는 트리거가, 정해둔 7개 슬러그가 정확히 7개의 활성 종목으로 풀리지 않으면 예외를 던지고 있었다. 즉 종목 하나를 비활성화하거나 병합하면 그 순간부터 모든 신규 가입의 온보딩 완료가 실패한다. 즐겨찾기 시드는 편의 기능인데 가입을 인질로 잡고 있었던 셈이다.

같은 자리에서 어드민 카탈로그 편집도 문제였다. 어드민 폼에는 기록 항목 입력이 아예 없어서, 단위 선택으로 measurement_type만 옮기고 기존 recording_fields는 그대로 다시 보냈다. 결과적으로 종목의 단위를 바꾸면 요약만 움직이고 기록 프로파일은 뒤에 남았다.

규약·계약 불일치

  • 충돌 코드 파리티 없음. 완료 운동·계획 라이터 5종 중 일부만 재시도 가능한 직렬화 충돌(40001)을 애플리케이션 충돌(LG409)로 번역하고 있었다. 번역하지 않는 쪽은 PostgREST가 요청을 재생해서 경합에 진 쓰기가 조용히 사라질 수 있었다.
  • 내부 표현이 공개 계약 노릇. get_volume_overview(date) 한 인자 오버로드는 내부 프레젠테이션 계층인데 공개 표면에 노출돼 있어서, 내부 구조를 바꾸려면 공개 계약을 깨야 했다.

운영 관측 마비

  • 관리자 운영 점검 체크리스트가 2026-06-30에 얼린 마이그레이션 목록 23개와 엣지 함수 버전 상수를 들고 있었다. 그 뒤 모든 배포가 불일치로 표시됐고, 그래서 아무도 그 화면을 믿지 않았다.
  • 클라이언트 오류 이벤트 테이블에 보존 정책이 없었다. 무한히 쌓이는 중이었고, 읽는 표면도 없어서 쌓이기만 했다.
  • 통계 백필 스캐너에 스케줄이 없어서 수동으로만 돌았다.
  • 통계 무결성 검증기가 치환 패치 3회분을 거친 상태라 저장소에 실제 본문이 없었다.

3. 해결 방안

원칙

작업 전에 규범부터 세웠다. 지금은 supabase/migrations/README.md의 "Function Change Rules"에 6개 규칙으로 정리돼 있고, 전부 뭔가 깨진 뒤에 적은 것이다.

  1. 동적 치환 금지. 함수 변경은 언제나 본문 전체를 담은 create or replace다. pg_get_functiondef를 읽어 문자열 치환한 뒤 실행하지 않는다. 그렇게 만든 정의는 저장소에 보이지 않고, schema.sql이 정의를 주장하지 못하게 된다. npm run check:migrations가 새 마이그레이션에서 이 패턴을 막는다. 이미 실려 있던 파일은 이름으로 예외 처리하고, 그 목록은 늘리지 않는다.
  2. 양파 껍질은 실제 변경이 있을 때 접는다. 미리 접지 않는다 — 동작 변경 없는 대규모 재작성은 검토 비용만 크다.
  3. 권한은 기본값에 기대지 않는다. alter default privileges로 신규 함수의 PUBLIC 실행 권한을 원천 차단하고, 필요한 롤에만 명시적으로 준다.
  4. 은퇴한 함수는 되살아나지 못하게 한다. 원격 스키마 게이트에 거부 목록을 두고, 드롭한 이름이 원격에 다시 보이면 실패한다.
  5. 어드민 RPC는 방향으로 이름 짓는다. 쓰기는 admin_<동사>, 읽기는 get_admin_<명사>.
  6. 버전 번호는 한 번만 점유되고, 누가 점유했는지는 원격이 정한다. (§4에서 설명)

접근

Production 데이터에 손대지 않는 것부터 시작해서, 위험이 큰 쪽으로 순서를 잡았다. 각 단계는 로컬 Docker supabase 스택에서 전 마이그레이션을 재생해 검증한 뒤 올렸다.

권한 정리는 되돌리기 쉽고 사용자 영향이 없으므로 먼저, 카탈로그 지뢰 제거는 그 다음, 데이터 정정은 오너 승인을 받고 마지막에 했다.


4. 적용한 내용

마이그레이션 6건

버전내용
20260820120000표면 위생 — 은퇴 함수 8종 드롭, 기본 PUBLIC 실행 권한 차단, 진입점 3종·트리거 함수 7종 봉인
20260820120100바벨 로우 병합 선행 수리 — 즐겨찾기 시드 트리거를 경고로, 어드민 편집의 기록 프로파일 재파생을 좁힘
20260820120200라이터 충돌 코드 파리티 — 완료 운동·계획 라이터 5종 전부 LG409
20260820120300volume 한 인자 오버로드 내부화 — 비공개 코어로 분리하고 공개 표면에서 제거
20260820120400운영 관측 회복 — 체크리스트 동결 해소, 90일 보존 정책, 백필 시간당 스케줄러, 검증기 verbatim 재수록
20260820230000종목 measurement_type 108행 + track_pr 94행 드리프트 정정

주요 결정과 그 근거

즐겨찾기 시드는 예외 대신 경고로. 카탈로그가 불완전하면 풀리는 만큼만 심고 나머지는 경고한다. 편의 기능이 가입을 막을 이유가 없다.

어드민 편집의 기록 프로파일 재파생을 좁혔다. 지시서의 원안 두 가지는 둘 다 안전하지 않았다 — 맨몸 횟수 타입은 파생으로 복원할 수 없고, 이미 어긋난 행이 100건 넘게 있어서 무조건 재파생하면 그것들을 덮어쓴다. 그래서 measurement_type이 움직였고, recording_fields가 그대로이고, 이전 프로파일이 이전 타입의 정확한 미러였을 때만 재파생하도록 좁혔다.

충돌 게이트를 재시도 큐 함수에는 걸지 않았다. P4에서 통계 cron 진입점에 권한 게이트를 붙이면서 재시도 큐 함수에도 걸었더니, 브라우저 여정 9건이 42501로 죽었다. 그 함수는 로그인 사용자의 드레인 경로가 매번 무조건 호출하기 때문이다.

로컬 psql로는 재현되지 않았다. set local rolesession_userpostgres로 남기고, 게이트의 session_user in (...) 절이 그걸 통과시킨다. CI의 브라우저 여정만 잡아냈다. 게이트를 걷어내고, 대신 역방향 postcheck(이 함수는 사용자 드레인에서 호출 가능해야 하고, 클라이언트가 직접 부를 수는 없어야 한다)와 구조 테스트로 잠갔다.

recording_fields를 정본으로 삼았다. 종목은 기록 프로파일을 두 벌 들고 있다 — recording_fields(운동 화면이 실제로 물어보는 항목)와 measurement_type(한 단어 요약). 카탈로그 시드가 둘을 각각 리터럴로 써서 시스템 종목 108행이 서로 어긋나 있었다.

종목recording_fieldsmeasurement_type
러닝·로잉머신{distance,duration}reps
플랭크·L싯{duration}reps
밴드 풀어파트·버드독{reps}time
터키쉬 겟업{load,reps}time
파머스 캐리{load,distance}weight_reps

recording_fields가 정본인 이유는 셋이다. 앱이 읽는 컬럼이고, 커스텀 종목 라이터와 어드민 정규화 트리거가 파생의 출발점으로 삼는 컬럼이고, 둘 중 애초에 옳았던 유일한 쪽이다.

track_pr도 같은 실수의 두 번째 미러였다. 드리프트 108행 전부에서 track_pr은 틀린 measurement_type이 횟수 계열일 때 정확히 참이었다 — 큐레이션 결정이 아니라 같은 실수가 한 번 더 찍힌 것이다. 카탈로그 전체로 94행이 어긋나 있었다.

  • 48행: reps 원자가 없는데 PR 추적이 켜져 있었다(러닝, 플랭크, 파머스 캐리, 수영). 커스텀 종목 라이터가 아예 금지하는 조합이고, PR 엔진은 reps가 있어야 하므로 이 플래그는 한 번도 작동한 적이 없다.
  • 46행: reps를 기록하는데 꺼져 있었다(터키쉬 겟업, 케틀벨 롱사이클, 클러스터, 밴드 계열). 카탈로그는 이미 버피·크런치·푸쉬업·런지 같은 동급 동작에 전부 횟수 PR을 붙이고 있다.

적용 전 Production을 실측해서 94행 전부 PR 레코드·PR 상태·PR 이벤트·요약 스냅샷이 0건이고, 46행은 기록된 세트조차 0건임을 확인했다. 백필할 것도, 사용자가 지금 보고 있는 것도 없었다. 업데이트는 origin = 'system'으로 스코프했다 — 라이터는 reps가 있을 때 추적 끄기를 허용하고, 그건 사용자의 선택이다.

맨몸 횟수 타입 34행은 남겼다. {reps}를 기록하면서 스스로를 bodyweight_reps로 타이핑한다. 파생 함수가 표현하지 못하는(횟수로 접히는) 실재하는 구분이고 정식 행 하나가 하드 선결조건으로 걸고 있어서, 오너가 그대로 두기로 했다.

정규화 트리거는 일부러 건드리지 않았다. 요약에서 프로파일 방향까지 양방향으로 맞추게 하면, 어드민이 단위를 바꿨을 때 그 편집이 에러도 없이 조용히 되돌려진다. 시끄럽게 실패하는 테스트가 조용히 되돌리는 트리거보다 낫다.

작업 중 드러난 두 가지 (감사에 없던 것)

리베이스가 되돌린 산출물 4건 (#475). 브랜치를 새 main 위로 옮길 때 브랜치와 upstream이 둘 다 고친 파일을 옛 팁에서 통째 복사해 앞선 PR의 변경을 덮어썼다. 타입 체크와 계약 테스트가 잡아준 것은 바로 고쳤지만, 어서션이 없는 곳은 전 게이트가 초록인 채로 유실됐다.

가장 무거운 건 원격 게이트의 은퇴 함수 거부 목록 11개가 통째로 사라진 것이었다. 되살아난 함수를 잡는 방어가 빠져 있었는데, 그 목록을 검사하는 테스트가 없어서 아무도 몰랐다. 복구하면서 목록 내용을 어서트하는 테스트를 함께 넣었다.

마이그레이션 번호 충돌 (#480). 병행 스레드가 같은 타임스탬프를 먼저 랜딩하면 supabase db push버전 번호만 보고 이미 적용된 것으로 판단해 건너뛴다. 그런데 조용하다 — schema.sql엔 파일이 있고, 꼬리 잠금 테스트는 통과하고, migration list는 양쪽에 버전을 보여주고, 관리자 체크리스트는 최신이라고 말한다. --dry-run조차 "Remote database is up to date"라고 답한다. 적용 전에 확인하려고 부르는 바로 그 명령이, 절대 실행되지 않을 파일을 두고 이상 없다고 말하는 것이다.

이번 정정 마이그레이션이 실제로 이 함정에 두 번 들어갔다가 머지 직전에 손으로 잡았다. 그대로 갔다면 108행이 안 고쳐진 채 "적용됨"으로 기록됐을 것이다.

supabase_migrations.schema_migrations는 버전 옆에 이름도 기록한다. 원격 게이트가 그걸 읽지 않고 있었다. 이제 로컬 파일명과 원격 적용명을 비교해 migration:<version>:name-mismatch:<remote>!=<local>로 실패한다. 실제 Production을 대상으로 충돌을 주입해 발화를 확인했다.

로컬 중복 번호 검사는 원래 있었지만 두 파일이 같은 트리에 있을 때만 보인다 — 상대가 랜딩하기 전에 갈라진 브랜치에서는 발화하지 않는다. 원격 검사는 브랜치 나이와 무관하게 Production에 직접 물어보므로 잡는다.


5. 적용 결과

Production 상태

항목결과
적용된 마이그레이션123건, 미적용 0건
최신 버전20260820230000
check:remote-schemaEXIT=0 (missing 0, release contract failures 0)

카탈로그 정정 실측 (적용 후 Production)

검사결과
요약이 자기 기록 프로파일과 어긋나는 행0
reps 없이 PR 추적이 켜진 행0
시스템 종목이 자기 프로파일과 어긋나는 PR 플래그0
맨몸 횟수 타입(의도적 예외)34행 보존

실제 행으로도 확인했다.

running:         distance    {distance,duration}  pr=false
turkish-get-up:  weight_reps {load,reps}          pr=true
farmer-s-carry:  distance    {load,distance}      pr=false
band-pull-apart: reps        {reps}               pr=true
plank:           time        {duration}           pr=false

게이트

게이트결과
npm run check1666 테스트 통과 / 0 실패
pgTAP32파일 511건 통과
CI (verify, migration-smoke)전 PR 통과

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

공격 표면이 줄었다

로그인만 하면 부를 수 있던 은퇴 함수 8종이 사라졌다. 더 중요한 건 기본값을 막았다는 것이다 — 앞으로 만드는 함수는 PUBLIC 실행 권한을 자동으로 받지 않으므로, 권한 회수를 깜빡해도 열린 채로 배포되지 않는다. 한 번의 실수를 막는 대신 실수의 종류를 없앴다.

카탈로그를 손댈 수 있게 됐다

종목 하나를 비활성화하거나 병합해도 신규 가입이 막히지 않는다. 예정된 바벨 로우 행 병합의 선결 조건이 풀렸고, 어드민이 종목 단위를 바꿔도 기록 프로파일이 뒤에 남지 않는다.

저장된 데이터가 자기 자신과 일치한다

종목 108행의 요약이 자기 기록 프로파일과 어긋나 있던 상태가 해소됐다. 이 중 29행은 체중 계수를 갖고 있어서 무게 표기가 실제로 갈리고 있었다.

track_pr 94행이 함께 정리되면서, 터키쉬 겟업·케틀벨 롱사이클·클러스터 같은 실제 중량 동작이 이제 PR을 기록한다. 반대로 러닝·플랭크처럼 순위 매길 횟수가 없는 동작은 작동하지도 않던 플래그를 내려놨다.

저장소가 DB를 다시 설명한다

치환 패치로 저장소에서 사라졌던 함수 본문이 도면에 돌아왔고, 새 마이그레이션에서 그 패턴은 게이트가 막는다. schema.sql을 읽어서 함수가 무엇을 하는지 알 수 있는 상태가 회복됐다.

운영 화면을 다시 믿을 수 있다

체크리스트가 배포 중인 빌드의 기대치를 쓰므로 배포마다 불일치가 뜨지 않는다. "뒤처짐"은 critical, "앞섬"은 경고로 나눠서, DB 먼저 배포하는 중간 상태를 장애로 부르지 않는다. 클라이언트 오류에 90일 보존 정책과 읽기 표면이 생겼고, 통계 백필은 시간당 스케줄로 돈다.

조용한 실패 두 종류를 시끄럽게 만들었다

이번 작업에서 가장 값이 나가는 부분일 수 있다. 둘 다 전 게이트가 초록인 채로 진행되던 실패였다.

  • 되살아난 은퇴 함수 — 거부 목록이 통째로 사라져도 아무 테스트가 알아채지 못했다. 이제 목록 내용 자체를 어서트한다.
  • 실행되지 않은 마이그레이션 — 번호를 뺏기면 db push가 조용히 건너뛰고 --dry-run까지 이상 없다고 답했다. 이제 원격이 적용한 이름과 대조해서 잡는다.

카탈로그 드리프트가 재발하면 CI가 막는다

새 pgTAP 14건이 불변식 자체를 어서트한다. 앞으로 어떤 시드가 두 컬럼을 어긋나게 써도 빨개진다. 마지막 두 케이스는 실제로 드리프트를 주입해서 어서션이 정말 발화하는지 확인하고, 별도 케이스는 사용자 소유 종목 행을 실제로 만들어 의도적 옵트아웃이 드리프트로 오인되지 않는지 확인한다.


남은 것

이번 범위 밖으로 남긴 항목이다.

  • 맨몸 횟수 타입 34행 — 파생 함수가 표현하지 못하는 구분. 오너가 유지하기로 결정.
  • 화면 RPC 이름 파생 — 화면 RPC 목록을 타입에서 파생시키는 심화 작업.
  • 통계 스캔 신호 확장 — 백필 스캐너가 보는 신호를 넓히는 작업.
  • 엣지 배포 버전 upsert — 배포 시 버전 기록을 자동화하는 작업.
  • 마이그레이션 인덱스 소급 등록supabase/migrations/README.md는 이름만으로 이유를 알 수 없는 마이그레이션을 적는 곳이고, 전수 목록이 아니라는 사실을 문서에 명시했다.
  • 수동 스모크 — 관리자 운영 화면 카드 확인과 운동 화면 기록 항목 확인.