안드로이드 포팅본의 라이브 버전을 점검하다가 발견했습니다. 로그인한 신규 사용자가 전부 스플래시에서 못 나오는 상태로 배포돼 있었습니다.
기존 사용자는 멀쩡했습니다. 그래서 신고가 한 건도 없었습니다.
당신 앱의 신규 가입 경로는, 기존 사용자 경로와 같은 코드로 검증됩니까?
기본값은 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이었습니다.
자가진단 세 줄
- 모델의 기본값이 nullable 컬럼을 막아준다고 가정하고 있습니까? 기본값은 키 부재만 막습니다. 명시적 null은 그대로 터집니다.
- 게이트 화면(스플래시·로그인)에 오류를 표시할 자리가 있습니까? 없으면 그 화면에서 막힌 사용자는 스피너만 봅니다.
- 컬럼 nullable 판정을
information_schema로만 하고 있습니까? 뷰가 전부 nullable로 보고돼 오탐이 섞입니다.relkind = 'r'로 조이십시오.
솔직한 부분
이 결함은 신규 가입자만 때립니다. 그리고 신규 가입자는 아직 저에게 연락할 이유가 없는 사람들입니다 — 앱을 써 본 적이 없으니까요.
그래서 "신고 0건"이 아무 정보도 아니었습니다. 제가 이걸 찾은 건 사용자 제보가 아니라 라이브 점검이었고, 점검을 안 했으면 얼마나 더 갔을지 모릅니다.