CRM에서 20개의 연락처를 아카이브해야 했지만, UI에는 초록색 성공 배지가 표시되었고 작업은 아무런 일도 하지 않은 채 조용히 넘어갔습니다. Supabase-JS 뮤테이션(mutation)에서 error 필드를 필수 항목으로 만드는 TypeScript 헬퍼는 개발자가 실패를 숨기는 대신 직면하도록 강제합니다.

Supabase-JS의 반환 형태가 함정인 이유

Supabase의 JavaScript 클라이언트(@supabase/supabase-js)는 데이터베이스 쓰기가 거부될 때 예외(exception)를 던지지 않습니다. 대신 { data, error } 객체로 프로미스(promise)를 해결(resolve)합니다. 행 수준 보안(RLS) 정책, 고유 키 위반 또는 기타 제약 조건이 쿼리를 차단하면 datanull로 반환되고 error에는 데이터베이스 메시지가 포함됩니다. 클라이언트는 호출자가 error를 검사할 것이라고 가정하며, 호출을 절대 중단하지 않습니다.

실제로 많은 코드베이스에서는 이 호출을 실행 후 잊어버리는(fire-and-forget) 작업으로 취급합니다:

await supabase
  .from('contacts')
  .update({ statut: 'ancien', archived_at: now })
  .eq('id', id)

return { ok: true }

update가 RLS 규칙에 의해 차단되어도 프로미스는 여전히 해결됩니다. 개발자가 { error }를 구조 분해 할당(destructure)하지 않으면 실패는 보이지 않습니다. 함수는 { ok: true }를 반환하고, UI는 성공을 표시하며, 데이터는 변경되지 않은 채로 남습니다. 로그도 남지 않고, Sentry 알림도 발생하지 않으며, 버그는 며칠 동안 방치될 수 있습니다.

린터(Linter)만으로는 부족합니다

정적 분석 도구는 error 속성이 무시될 때 경고를 줄 수 있지만, 런타임 계약(runtime contract)을 강제할 수는 없습니다. 개발자는 여전히 const _ = await …와 같이 작성하여 경고를 무시하거나, 규칙을 억제하기 위해 주석을 추가할 수 있습니다. 근본적인 문제는 타입 시스템이 error를 전혀 언급하지 않고도 호출이 성공하도록 허용한다는 점입니다.

mutate() 헬퍼: 에러 처리를 필수 사항으로 만들기

저자는 반환 타입의 형태를 변경하는 mutate()라는 작은 래퍼(wrapper)를 만들었습니다. { data, error } 대신, 이 헬퍼는 error가 필수 필드인 튜플(tuple) [data, error]를 반환합니다. 그러면 TypeScript는 두 번째 요소를 버리는 모든 호출의 컴파일을 거부합니다.

async function mutate<T>(promise: Promise<{ data: T | null; error: any }>) {
  const { data, error } = await promise
  // Send error to Sentry immediately
  if (error) Sentry.captureException(error)
  return [data, error] as const
}

사용법이 명시적으로 변합니다:

const [result, err] = await mutate(
  supabase
    .from('contacts')
    .update({ statut: 'ancien', archived_at: now })
    .eq('id', id)
)

if (err) {
  // Handle or rethrow
  return { ok: false, message: err.message }
}
return { ok: true, data: result }

개발자가 err를 캡처하는 것을 잊으면 TypeScript는 다음과 같은 에러를 발생시킵니다: “Tuple type [T, any] of length 2 has no element at index 1.” 에러가 해결될 때까지 코드는 컴파일되지 않습니다. 또한 이 헬퍼는 Sentry 인스트루멘테이션(instrumentation)을 주입하여, 호출자가 나중에 에러를 삼키더라도 모든 데이터베이스 거부 작업이 로그에 기록되도록 보장합니다.

위험 요소

  • 데이터 무결성 – 조용한 실패는 잘못된 상태가 운영 환경에 스며들게 합니다. 활성 목록에서 제거되지 않은 아카이브된 연락처는 후속 보고서의 오류를 유발할 수 있습니다.
  • 사용자 신뢰 – 아무것도 변하지 않았는데 성공했다고 주장하는 UI는 신뢰를 떨어뜨립니다. 고객은 "아카이브됨"을 보지만 검색에서는 여전히 해당 연락처를 발견하게 됩니다.
  • 개발 시간 – 유령 버그를 쫓는 데 많은 시간이 소모됩니다. 명시적인 에러 처리는 실패 지점에서 문제를 드러내어 디버깅 루프를 단축합니다.
  • 운영 비용 – 조용한 데이터 손실 사고의 비용에 비하면, 몇 줄의 코드와 작은 래퍼를 추가하는 것은 미미한 수준입니다.

트레이드오프

이 헬퍼는 코드의 장황함(verbosity)을 더합니다. 이제 모든 뮤테이션 호출은 튜플을 반환하며, 호출자는 if (err) 블록을 작성해야 합니다. 일부 팀은 특히 성공을 기대하는 단순한 CRUD 작업의 경우 이를 불필요한 보일러플레이트(boilerplate) 노이즈로 간주할 수 있습니다. 반론은 이 추가 코드가 선택 사항이 아닌 안전장치라는 점입니다. 데이터 정확성이 무엇보다 중요한 환경(CRM 시스템, 금융, 의료 등)에서는 이 검사 과정을 강제하는 것이 그 가치를 충분히 증명합니다.

다음 단계

저자는 설명 없이 0개의 행을 반환하는 RLS 정책을 살펴보는 세 번째 편을 약속했습니다. 해당 패턴은 조용한 에러 사례와 마찬가지로, 겉보기에 성공적인 쿼리 뒤에 실패를 숨깁니다. 이 시리즈는 Supabase API의 "소문(rumor)" 측면을 폭로하고 개발자들에게 사실을 요구할 수 있는 구체적인 도구를 제공하는 것을 목표로 합니다.

핵심 요약: Supabase-JS의 설계는 error 필드를 읽는 것을 잊지 않는 한, 실패한 쓰기가 성공한 것처럼 보이게 만듭니다. error를 필수 항목으로 만들고 Sentry에 로그를 남기는 TypeScript 기반의 헬퍼로 호출을 감싸면, 조용한 실패를 눈에 보이고 조치 가능한 이벤트로 바꿀 수 있습니다. 약간의 코드 양 증가로 데이터 신뢰성과 사용자 신뢰라는, 조용한 성공은 결코 제공할 수 없는 두 가지를 얻을 수 있습니다.