배포와 인프라6 분 읽기

date 컬럼 하나가 화면 전체를 지웠다

지역 비교 카드가 통째로 사라졌습니다. 에러도 없고, RPC는 200이고, 데이터도 들어 있는데 화면만 비었습니다. 표시에 쓰지도 않는 date 필드 하나의 디코딩 실패가 decode 전체를 throw시켰고, catch가 빈 배열로 떨어졌습니다. 어느 단계에서도 빨간불이 안 켜집니다.

#ios#supabase#swift#gotchas
개념 도식: 서버 200 → JSON "2026-07-01" → ISO8601 디코더가 행 전체 throw → catch가 빈 배열 → 빈 화면. 앞 단계는 전부 초록 체크, 마지막만 빈 사각형.
표시에 안 쓰는 필드 하나가 화면 전체를 죽일 권한을 가진다. 모델에 필드를 더하는 게 그만큼 위험하다.

지역 비교 카드가 화면에서 통째로 사라졌습니다. 에러 메시지는 없었습니다. 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 [] } 하나를 떠올려 보세요. 그게 삼키는 건 정말 "빈 결과"입니까, 아니면 표시하지도 않는 필드 하나의 디코딩 실패입니까?

관련 글