지역 비교 카드가 화면에서 통째로 사라졌습니다. 에러 메시지는 없었습니다. RPC는 정상 응답 중이었고 서버 로그도 깨끗했습니다. 카드가 그냥 없었습니다.
어느 단계에서도 빨간불이 안 켜진다
Postgres의 date 컬럼은 PostgREST가 "2026-07-01" — 시각 없이 — 내려줍니다. 이걸 Swift 모델에서 let basisYmd: Date?로 받으면 supabase-swift의 ISO8601 디코더가 거부합니다. 날짜만 있고 시각이 없어서죠.
여기까지는 흔한 타입 불일치입니다. 문제는 그 다음입니다.
한 필드의 디코딩 실패가 decode 전체를 throw시킵니다. 그리고 대부분의 코드는 catch에서 빈 배열로 떨어집니다. 빈 배열이 오면 화면은 "데이터가 없다"고 판단하고 아무것도 안 그립니다. 에러가 아니라 정상적인 빈 상태로 보입니다.
그래서 증상이 이렇게 나옵니다 — 서버는 200을 주고, 데이터도 실제로 들어 있고, 네트워크 탭에도 이상이 없는데, 화면만 비어 있습니다. 표시에 쓰지도 않는 필드 하나 때문에 화면 전체가 사라진 겁니다.
디코딩이 all-or-nothing이라는 점과, catch가 빈 값으로 떨어진다는 점이 겹치면, 사용하지 않는 필드조차 전체를 죽일 권한을 갖게 됩니다. 모델에 필드를 하나 추가하는 행위가 그만큼 위험하다는 뜻입니다.
형제 함정 — .single()의 406
같은 라이브러리에 형제 격 함정이 하나 더 있습니다. .single()은 결과가 정확히 1건이 아니면 406을 냅니다. 다른 SDK에 있는 maybeSingle이 없어서, 0건일 수도 있는 조회에 .single()을 쓰면 정상적인 "없음"이 HTTP 에러로 올라옵니다. .limit(1) 후 first를 쓰는 게 맞습니다. 둘 다 "조용히, 혹은 엉뚱한 모양으로 실패한다"는 같은 계열입니다.
코드
문제:
struct RegionRow: Decodable {
let basisYmd: Date? // ← Postgres date 컬럼. "2026-07-01" 이 온다
// ...
}
// ISO8601 디코더가 거부 → decode 전체 throw → catch → [] → 화면 공백대응:
// 1) 표시에 쓰지 않는 날짜는 String 으로 (가장 간단)
let basisYmd: String?
// 2) 실제로 Date 가 필요하면 커스텀 디코더에 date-only 포맷 추가형제 함정:
// ❌ 0건이면 406
try await client.from("t").select().eq("id", id).single().execute()
// ✅
try await client.from("t").select().eq("id", id).limit(1).execute() // → first등장 앱: HiddenGem.
정직하게
- 원인을 찾는 데 시간이 걸렸습니다. RPC가 정상 응답 중이라는 사실이 오히려 수사를 잘못된 방향으로 몰았습니다. 서버가 정상이면 클라이언트 로직을 의심하게 되는데, 실제 범인은 그 사이의 디코딩 계층이었고 거기는 로그가 없었습니다.
- 근본 해결을 한 게 아닙니다. 표시에 안 쓰는 날짜를
String?으로 바꾼 것뿐입니다. 같은 실수를 다음에 또 할 수 있습니다. 제대로 하려면 커스텀 디코더에 date-only 포맷을 추가하거나, 애초에decode실패를 빈 배열로 삼키지 않고 표면화해야 합니다. 후자가 진짜 수정인데 아직 안 했습니다. - 같은 프로젝트에서 timestamptz의 6자리 마이크로초를 거부하는 건도 따로 있었습니다. 이 라이브러리의 날짜 처리에서 세 번 걸렸습니다. 한 번씩 대증요법으로 넘긴 결과입니다.
이 버그의 무서운 점은 "실패를 삼키는 catch"와 "all-or-nothing 디코딩"이 각각은 합리적이라는 겁니다. 문제는 둘이 겹칠 때 생깁니다. 지금 당신 코드의 catch { return [] } 하나를 떠올려 보세요. 그게 삼키는 건 정말 "빈 결과"입니까, 아니면 표시하지도 않는 필드 하나의 디코딩 실패입니까?