Supabase RPC 에러가 「데이터 없음」으로 보였다
480건 쌓인 전시에서 통계가 0으로 보였다. 에러와 0건이 같은 null 이었다
통계 화면에 「아직 쌓인 기록이 없어요」라고 떠 있으면, 보통은 정말 기록이 없는 것이다. 그런데 480건이 쌓인 전시에서도 같은 문장이 떴다.
2026-08-12. 갤러리 통계에서 직원 방문을 빼는 마이그레이션을 운영 DB 에 적용했다. 집계 함수 하나(get_fair_analytics)를 통째로 갈아끼우는 작업이었다.
적용 직후 화면을 확인했다 — 「아직 쌓인 기록이 없어요」. 시험용으로 쓰던 갤러리라 스캔 24건이 전부 내 브라우저 것이었고, 새 규칙이 직원을 거르니 0이 나오는 게 맞았다. 그렇게 판단하고 넘어갔다.
30분 뒤 다른 갤러리 계정으로 통계를 열었다. 똑같이 「아직 쌓인 기록이 없어요」. 그쪽 전시에는 480건이 있었다.
Postgres 로그를 뒤지니 에러가 반복되고 있었다.
COALESCE could not convert type uuid[] to text[]
visitor_id 를 uuid 라고 알고 배열 변수를 uuid[] 로 선언했는데, 네 테이블 모두 text 였다. 브라우저 쿠키에 uuid 를 만들어 넣지만 칸 타입은 text 다. 함수는 호출될 때마다 죽고 있었다.
30분 동안 고장을 보고 정상이라고 판단했다
화면이 「기록 없음」이라고 말했고, 그 순간 내 머릿속 설명("직원이 다 걸러져서 0이구나")과 맞아떨어졌다. 그래서 확인을 멈췄다.
원인은 조회 코드 한 줄이었다.
const { data, error } = await supabase.rpc("get_fair_analytics", { p_fair_id })
if (error || !data) return null // ← 에러와 「0건」이 같은 null 이 된다
화면은 null 을 받으면 「아직 쌓인 기록이 없어요」를 띄운다. 에러가 삼켜져서 「고장」과 「데이터 없음」이 화면에서 구분되지 않았다.
여기서 갈린 건 "에러 처리를 어디까지 해야 하나" 가 아니었다. 조용히 실패하는 것과 시끄럽게 실패하는 것 중 무엇이 더 위험한가였다.
조용한 실패가 더 위험하다. 갤러리가 이 화면을 봤다면 "우리 전시에 아무도 안 왔구나" 라고 읽고 우리에게 아무 말도 안 했을 것이다. 시끄럽게 실패했으면 그날 바로 연락이 왔다.
뭘 바꿨나
하나 — 반환값을 갈랐다. null 하나로 두 상태를 표현하지 않는다.
export type AnalyticsLoad =
| { status: "ok"; data: FairAnalytics }
| { status: "failed" }
둘 — 실패를 우리 쪽 감시 기록에 남긴다. 갤러리가 알려주기를 기다리면 며칠이 지나고, 그 사이 숫자는 계속 틀리다. 심각도는 high 로 잡았다 — 화면이 「기록 없음」으로 보여서 아무도 신고하지 않기 때문이다.
셋 — 화면이 다르게 말한다. 「통계를 불러오지 못했어요 / 잠시 뒤 새로고침해 주세요. 계속 이러면 알려 주세요 — 저희 쪽에도 기록이 남았어요」.
넷 — 실제로 함수를 깨뜨려서 확인했다. 일부러 raise exception 을 넣은 함수로 바꾸고 화면과 감시 기록 양쪽을 본 뒤 되돌렸다. 이걸 안 했으면 "고쳤겠지" 로 끝났을 것이다.
같은 날, 같은 병의 다른 얼굴 둘
마이그레이션에서 두 번 더 걸렸다. 둘 다 실패했는데 성공처럼 보이는 구조였다.
drop constraint if exists <틀린 이름>— 제약 이름을 잘못 알고 있었는데if exists가 조용히 건너뛰었다. 그 뒤 문장은 옛 제약이 살아 있는 채로 돌아서 새 가입이 전부 막혔다- 되돌리기 SQL 을 여러 문장 한 번에 보냈다가 하나가 실패해 통째로 롤백됐는데, 그 상태에서 마이그레이션이
if not exists로 통과해 「빈 상태에서 검증했다」고 착각했다
셋의 공통점은 하나다. 에러가 안 났다. 에러가 났으면 30초에 끝났을 것들이다.