배포와 인프라7 분 읽기

제 로그인은 한 OS에서만 죽었습니다. 파서가 한쪽에서만 예외를 던졌습니다

같은 로직을 Swift에서 Kotlin으로 그대로 포팅했는데 Android에서만, 그것도 일부 계정만 로그인이 크래시했습니다. 원인은 코드가 아니라 두 언어가 '없는 필드'를 다루는 방식이었습니다.

#gotchas#android#ios#reality-check#debugging
Left panel: Swift reads json["displayName"].stringValue and gets an empty string when the key is absent. Right panel: Kotlin decodes a non-null String field and throws MissingFieldException on the same payload.
같은 응답, 같은 의도. Swift는 빈 문자열을 돌려주고 Kotlin은 예외를 던집니다.

iOS 앱의 로그인 응답 파서를 Android로 옮겼습니다. 줄 단위로 옮겼고, 필드 이름도 타입도 그대로였습니다. 시뮬레이터/에뮬레이터 둘 다 통과. 테스트 계정으로 로그인도 잘 됐습니다.

그런데 출시 뒤 일부 사용자가 Android에서만 로그인 직후 크래시한다고 들어왔습니다. 전부가 아니라 일부. iOS는 같은 계정으로 멀쩡했습니다.

여기서 무엇을 의심하십니까? 저는 네트워크 계층, 토큰 저장, 프로가드 난독화를 차례로 팠습니다. 전부 아니었습니다.

이상한 조합

정리하면 이렇습니다.

  • 같은 서버 응답을 iOS와 Android가 같은 방식으로 파싱한다.
  • iOS: 모든 계정 정상.
  • Android: 일부 계정만 크래시. 나머지는 정상.

"일부 계정만"이 핵심 힌트였습니다. 코드 경로가 통째로 틀렸다면 전부 깨져야 합니다. 특정 계정에서만 갈렸다는 건 그 계정들의 응답에 뭔가 다른 게 들어 있다는 뜻입니다.

크래시 로그를 다시 봤습니다.

kotlinx.serialization.MissingFieldException:
Field 'displayName' is required for type 'UserProfile', but it was missing

두 파서를 나란히 놓기

Swift 쪽은 이렇게 생겼습니다.

let displayName = json["displayName"].stringValue   // 없으면 ""

Kotlin으로 옮기면서 이렇게 "정리"했습니다.

@Serializable
data class UserProfile(
    val id: String,
    val displayName: String,   // non-null. 옵셔널 줄이는 게 Kotlin답다고 생각했다
)

문제는 서버였습니다. 표시 이름을 한 번도 설정하지 않은 계정에는 응답에서 displayName 키가 아예 빠집니다. 값이 null도 아니고 키 자체가 없습니다.

  • Swift: 키가 없으면 .stringValue""를 돌려줍니다. 조용히 넘어갑니다.
  • Kotlin: @Serializable non-null 필드에 대응하는 키가 없으면 디코딩 시점에 MissingFieldException던집니다.

포팅한 코드는 원본보다 깔끔해 보였습니다. 옵셔널이 하나 줄었으니까요. 그리고 정확히 그게 깨진 지점이었습니다.

여기서 당신이라면?

두 갈래가 있습니다.

  1. 응답에서 오는 모든 필드를 하나씩 방어한다 — 전부 nullable + ?: 기본값.
  2. 파서 경계에서 한 번에 처리한다 — 관대한 디코더 설정 + 필드별 기본값.

1번은 지금은 되지만 다음 필드에서 또 납니다. 필드가 20개면 방어도 20군데입니다. 저는 2번으로 갔습니다.

고친 코드

private val json = Json {
    ignoreUnknownKeys = true
    explicitNulls = false        // null과 "키 없음"을 같게 취급
    coerceInputValues = true     // null이 온 non-null 필드는 기본값으로
}
 
@Serializable
data class UserProfile(
    val id: String,
    val displayName: String = "",   // 기본값이 있으면 키가 없어도 안 던진다
)

핵심은 마지막 줄의 = ""입니다. @Serializable 필드에 기본값이 있으면 그 키가 응답에 없어도 예외가 아니라 기본값이 들어갑니다. Swift의 .stringValue가 하던 일을, 이제 Kotlin에서도 명시적으로 합니다.

자가진단 3개

  • 두 플랫폼 파서에서 같은 필드가 한쪽은 옵셔널, 한쪽은 non-null인 곳이 있습니까? 포팅하면서 "정리"한 필드가 특히 위험합니다.
  • 서버 응답에서 선택 입력값(표시 이름, 소개, 프로필 사진 URL 등)이 미설정 계정에 대해 null로 오는지, 키가 통째로 빠지는지 확인했습니까? 둘은 다릅니다.
  • 파서 크래시가 전체가 아니라 일부 계정에서만 난다면, 그 계정들의 실제 raw 응답을 하나 확보했습니까?

솔직한 부분

이 버그를 빨리 못 잡은 이유는 "같은 로직을 옮겼으니 같게 동작하겠지"라고 믿었기 때문입니다. 로직은 같았습니다. 다른 건 없는 값을 만났을 때의 기본 동작이었습니다. Swift Codable/SwiftyJSON은 관대한 쪽이 기본이고, kotlinx.serialization은 엄격한 쪽이 기본입니다.

그래서 규칙 하나를 만들었습니다. 포팅에서 옵셔널을 없애려면, 그 필드가 원본에서 왜 옵셔널이었는지 먼저 설명할 수 있어야 한다. 설명이 안 되면 그 옵셔널은 버그를 막고 있던 겁니다.

제 스텁이 너무 깨끗했습니다와 같은 계열입니다. 그때는 테스트 입력이 실제 데이터보다 얌전해서 문제를 못 봤고, 이번엔 포팅한 타입이 실제 응답보다 얌전해서 크래시가 났습니다.

지금 딱 하나만 해보세요. 두 플랫폼의 응답 모델을 나란히 열고, 한쪽만 ?가 붙은 필드를 세어 보시기 바랍니다.

유튜브

제 로그인은 한 OS에서만 죽었습니다. 파서가 한쪽에서만 예외를 던졌습니다

관련 글