배포와 인프라8 분 읽기

라이브 앱의 신규 가입자는 전부 스플래시에 갇혀 있었습니다

널 허용 컬럼 하나가 신규 사용자의 프로필 로드를 통째로 터뜨렸습니다. 기존 사용자는 멀쩡했으니 신고가 한 건도 없었습니다. 그리고 그 오류를 표시할 자리가 스플래시 화면에는 없었습니다.

#android#kotlin#supabase#gotchas#reality-check
두 패널 도식. 왼쪽은 널 허용 컬럼이 명시적 null을 담고 있어 기본값이 적용되지 않아 디코딩이 실패하고, 실패 핸들러가 프로필 로드 플래그를 되돌려 스피너가 영원히 도는 경로를 보여준다. 오른쪽은 읽기 쪽 강제 설정과 게이트 화면 자체의 실패 표시, 그리고 실테이블만 보는 컬럼 조회를 보여준다.
기본값은 키가 없을 때만 쓰입니다. 명시적 null에는 쓰이지 않습니다.

안드로이드 포팅본의 라이브 버전을 점검하다가 발견했습니다. 로그인한 신규 사용자가 전부 스플래시에서 못 나오는 상태로 배포돼 있었습니다.

기존 사용자는 멀쩡했습니다. 그래서 신고가 한 건도 없었습니다.

당신 앱의 신규 가입 경로는, 기존 사용자 경로와 같은 코드로 검증됩니까?

기본값은 null을 구제하지 않습니다

사용자 행 테이블의 컬럼 하나가 NULL을 허용합니다. 모델은 이렇게 적혀 있었습니다.

val timezone: String = "UTC"

기본값은 키가 없을 때만 쓰입니다. 명시적 null에는 쓰이지 않습니다.

온보딩을 아직 안 한 계정은 이 컬럼이 항상 null입니다. 그래서 행을 한 건 읽는 호출이 통째로 터졌습니다.

JsonDecodingException: Unexpected JSON token at offset 183:
  Expected string literal but 'null' literal was found at path: $[0].timezone

그 실패가 프로필 로드 실패로 이어지고, 실패 핸들러가 "프로필 로드됨" 플래그를 false로 되돌립니다. 스피너가 영원히 돌았습니다.

표시에도 안 쓰는 date 컬럼 하나가 화면 전체를 지운 적도 있습니다 — 같은 계열이고, 이번엔 신규 사용자가 앱에 들어오지 못했습니다. 포팅한 앱이 특히 잘 걸립니다. iOS 모델을 그대로 옮기면서 "기본값이 있으니 안전하다"고 가정하기 때문입니다.

이 결함을 숨긴 두 번째 결함

원인을 찾는 데 4분이 걸렸습니다. 그 4분 동안 화면에는 스피너만 있었습니다.

오류 스낵바 호스트가 메인 화면 스캐폴드 안에만 있었습니다. 그래서 스플래시·인증 화면에서 난 오류는 어디에도 표시되지 않았습니다. 비밀번호를 틀려도 화면에 아무 변화가 없어서, "버튼이 고장 났나"와 구별할 수 없었습니다.

한 세션에 같은 형태를 셋 고쳤습니다. 프로필 로드 실패는 재시도 화면으로, 로그인 실패는 인라인 문구로.

여기서 규칙이 나옵니다. 게이트 화면을 만들면, 그 화면 자체에 실패를 표시할 자리가 있는지 봐야 합니다. 게이트 뒤쪽에만 오류 표시가 있으면, 게이트에서 막힌 사용자는 아무 설명도 못 받습니다.

설정 이름 두 개가 비슷하게 생겼습니다

디코더에 explicitNulls = false를 켜 둔 채 안심하고 있었습니다. 그건 인코딩 쪽 설정입니다. 읽기를 지켜주지 않습니다.

읽기는 이쪽입니다.

coerceInputValues = true

그리고 이것도 절반만 구제합니다. coerceInputValues는 기본값이 있는 필드만 살립니다. 기본값 없는 필드(val content: String)는 따로 = ""를 넣어야 합니다. 전부 손으로 확인했습니다.

한 가지 더. 직렬화 설정을 클라이언트 생성 블록 안에 인라인으로 두면 테스트가 그 설정을 못 잡습니다. 의존성 없는 object로 빼서 넘기도록 바꿨습니다.

당신이라면?

플릿에 앱이 여덟 개 있습니다. 어느 앱이 같은 결함을 갖고 있는지 알아내야 합니다.

  • 모델 파일을 눈으로 읽는다 — 기본값이 있는 필드가 수십 개다. null 허용 여부는 코드에 없다.
  • 컬럼 메타데이터를 조회한다information_schema.columns로 nullable 컬럼을 뽑는다.
  • 실테이블만 조회한다 — 뷰를 걸러낸다.

두 번째로 시작했다가 세 번째로 갔습니다. information_schema.columns는 뷰의 모든 컬럼을 nullable로 보고합니다. 그래서 한 앱이 전부 위험해 보였는데, 실제 테이블 컬럼은 NOT NULL이었습니다(UPDATE로 null을 넣어 보고 제약 위반 코드로 확인했습니다).

실테이블만 보려면 이렇게 조입니다.

join pg_class k on k.relname = c.table_name
join pg_namespace n on n.oid = k.relnamespace and n.nspname = c.table_schema
where k.relkind = 'r'

플릿 전수 대조 결과 실제 위험은 두 앱뿐이었고 나머지 여섯은 깨끗했습니다. 한 건은 오탐이었습니다 — 서버 함수 응답 모델이었고 DB 행 모델은 전부 nullable이었습니다.

자가진단 세 줄

  1. 모델의 기본값이 nullable 컬럼을 막아준다고 가정하고 있습니까? 기본값은 키 부재만 막습니다. 명시적 null은 그대로 터집니다.
  2. 게이트 화면(스플래시·로그인)에 오류를 표시할 자리가 있습니까? 없으면 그 화면에서 막힌 사용자는 스피너만 봅니다.
  3. 컬럼 nullable 판정을 information_schema로만 하고 있습니까? 뷰가 전부 nullable로 보고돼 오탐이 섞입니다. relkind = 'r'로 조이십시오.

솔직한 부분

이 결함은 신규 가입자만 때립니다. 그리고 신규 가입자는 아직 저에게 연락할 이유가 없는 사람들입니다 — 앱을 써 본 적이 없으니까요.

그래서 "신고 0건"이 아무 정보도 아니었습니다. 제가 이걸 찾은 건 사용자 제보가 아니라 라이브 점검이었고, 점검을 안 했으면 얼마나 더 갔을지 모릅니다.

관련 글