배포와 인프라8 분 읽기

제 검증 스크립트가 실사용자 데이터를 고치고 하나만 되돌렸습니다

보안 마이그레이션 끝에 넣은 기능 시험이 진짜 유저 행을 건드렸습니다. 되돌리는 코드도 같이 넣었는데, 그 함수가 실제로 바꾸는 필드 세 개 중 하나를 빼먹었습니다.

#gotchas#reality-check#verification#database
왼쪽은 3개 중 2개 컬럼만 되돌리는 v1 검증 블록, 오른쪽은 longest_streak이 0에서 1로 오염된 채 남은 실제 유저 행
테스트 호출은 컬럼 세 개를 바꿨습니다. 되돌리는 코드는 두 개만 알고 있었습니다.

프로덕션 DB에 대고 기능 시험을 돌리는 마이그레이션을 짤 때, 당신은 그 함수가 정확히 몇 개의 컬럼을 바꾸는지 세어 보셨습니까?

Supabase 보안 점검에서 함수 하나가 걸렸습니다. moodbite.update_streak(p_user_id, ...)security definer로 돌면서 p_user_id를 인자로 받는데, 호출자가 자기 자신인지 검사가 없었습니다. anon 키는 클라이언트 바이너리에 박혀 사실상 공개이니, 남의 uuid를 넣으면 남의 연속 기록일을 조작할 수 있었습니다. 위험도는 낮았습니다(크레딧이나 구독 같은 금전 가치는 없습니다) — 그래도 데이터 무결성 문제였습니다.

고치는 코드는 한 줄이었습니다.

if p_user_id is distinct from auth.uid() then
  raise exception 'forbidden';
end if;

이걸 마이그레이션 파일 하나로 배포하면서, "가드가 실제로 막는지"와 "가드를 통과하면 여전히 정상 동작하는지"를 같은 파일 안에서 검증하기로 했습니다. 배포와 동시에 증명까지 남기자는 생각이었습니다.

당신이라면?

검증하려면 실제 함수를 실제로 호출해야 합니다. 그런데 이 함수는 부작용이 있는 함수입니다 — 부르면 profiles 행이 바뀝니다. 프로덕션에 시험용 행을 새로 만들겠습니까, 있는 행 하나를 빌려 쓰고 되돌리겠습니까?

시험용 행을 새로 만들면 나중에 지워야 하고, 지우는 걸 잊으면 그것도 오염입니다. 저는 있는 행을 빌려 쓰고 되돌리는 쪽을 택했습니다. 호출 전 값을 저장해 뒀다가 호출 후 그 값으로 update하면 된다고 생각했습니다.

select id, current_streak, last_logged_date
into v_id, v_before_streak, v_before_date
from moodbite.profiles limit 1;
 
-- 가드 무효 시험, 정상 호출 시험 ...
 
update moodbite.profiles
   set current_streak = v_before_streak,
       last_logged_date = v_before_date
 where id = v_id;

배포했습니다. 검증 로그는 통과를 찍었습니다. 가드는 실제로 남의 uuid를 막았고, 본인 uuid는 정상 통과했습니다. 그 순간엔 그게 전부였습니다.

놓친 것

update_streak 함수 본문을 다시 읽어 보면, 실제로 쓰는 컬럼은 셋입니다.

update moodbite.profiles
   set current_streak   = v_curr_streak,
       longest_streak   = v_long_streak,
       last_logged_date = v_today
 where id = p_user_id;

되돌리는 코드가 저장해 둔 값은 current_streaklast_logged_date 둘뿐이었습니다. longest_streak을 저장하지도, 되돌리지도 않았습니다. 정상 호출 시험이 그 실제 유저의 연속 기록을 1로 만들었고, 함수는 greatest(coalesce(longest_streak, 0), 1)로 최고 기록을 0에서 1로 갱신했습니다. 되돌리는 update는 그 컬럼을 언급조차 안 했으니, 원래 값으로 돌아갈 방법이 없었습니다.

배포는 성공, 검증 로그는 통과, 그런데 실사용자 프로필 하나의 longest_streak은 그 이후로 조용히 1이었습니다. 발견은 사후 감사에서였습니다 — 함수가 실제로 건드리는 컬럼 목록과 되돌리는 코드가 언급하는 컬럼 목록을 나란히 놓고서야 하나가 빈다는 걸 봤습니다.

수정

수정은 되돌리는 코드가 아니라 저장하는 코드부터였습니다. 함수 본문의 set 절에 있는 컬럼 전부를 호출 전에 저장하도록 고쳤습니다.

select last_logged_date, current_streak, longest_streak
into v_last_date, v_before_streak, v_before_long
from moodbite.profiles limit 1;
 
-- ...
 
update moodbite.profiles
   set current_streak = v_before_streak,
       longest_streak = v_before_long,
       last_logged_date = v_before_date
 where id = v_id;

그리고 이번 마이그레이션 파일 안에 그날의 사고를 주석으로 남겼습니다 — 다음에 이 함수를 또 건드릴 사람(자신 포함)이 같은 실수를 반복하지 않도록.

-- (2026-08-26 최초 적용 때 longest_streak 복구를 빠뜨려 실사용자 프로필의
--  longest_streak 이 0→1로 오염된 채 남았던 걸 사후 발견해 수동 정정함.
--  재실행 대비 여기서 제대로 세 필드 다 되돌린다.)

오염된 행은 수동으로 정정했습니다. 하지만 그 행이 원래 정확히 어떤 값이었는지는 첫 번째 검증 로그에 안 남아 있었으므로, 사후 감사로 재구성한 값을 믿을 수밖에 없었습니다 — 저장을 빠뜨린 값은, 나중에 완벽히 복원할 수 있다는 보장이 없습니다.

자가진단

프로덕션 행에 대고 부작용 있는 함수를 시험하고 되돌리는 코드가 있다면, 세 가지를 확인하세요.

  1. 되돌리는 코드가 저장하는 컬럼 목록과, 함수의 set/update 절이 실제로 쓰는 컬럼 목록을 나란히 놓고 세었습니까? 손으로 옮겨 적은 목록은 둘이 갈라져도 알아채지 못합니다.
  2. "저장 → 호출 → 복원"을 별도 단계로 짰다면, 그 사이에 함수가 새 컬럼을 건드리게 바뀌어도 되돌리는 코드가 자동으로 따라갑니까? 따라가지 않는다면 이 버그는 재발합니다.
  3. 검증이 통과했다는 로그가, "가드가 작동했다"는 증거입니까 "아무것도 오염되지 않았다"는 증거입니까? 이번 경우는 전자만 증명했고 후자는 거짓이었습니다.

프로덕션에 직접 시험을 돌리는 검증 블록은 강력하지만, 되돌리는 코드는 시험 대상 함수와 같은 컬럼 목록을 손으로 두 번 세는 일입니다. 테스트를 전부 통과시켰더니 프로덕션에 유저가 생겼다도 같은 자리에서 걸렸습니다 — 초록불이 "부작용이 없었다"를 뜻하지 않는다는 것. 되돌리기 전에, 그 함수가 진짜로 몇 개를 바꾸는지부터 다시 세어 보세요.

유튜브

제 검증 스크립트가 실사용자 데이터를 고치고 하나만 되돌렸습니다

관련 글