배포와 인프라10 분 읽기

진단 코드를 넣으면 버그가 사라졌다

TestFlight에서만 안 뜨는 배너를 4시간 쫓았습니다. Debug/Release도, 서명도, 실기기도 범인이 아니었습니다. 범인은 제가 원인을 보려고 넣은 진단 코드 그 자체였습니다. 관측 장치가 관측 대상을 바꾸고 있었습니다.

#swiftui#ios#debugging#gotchas
개념 도식: 진단 빌드에선 배너가 100% 뜨고 정상 빌드에선 100% 안 뜬다. 빈 body → row 없음 → .task 안 돎 → 영원히 빈 상태로 순환.
진단 코드가 없던 브랜치를 만들어 레이아웃 트리를 바꾸고, 그 바뀐 트리에서만 버그가 사라졌다.

앱에 크로스프로모 배너를 하나 넣었습니다. 원격 JSON에서 문구와 링크를 받아 그리는 몇십 줄짜리 SwiftUI 뷰라, 빌드 없이 서버에서 카피를 바꾸는 게 요점이었습니다. 그런데 이게 TestFlight에서 영영 뜨지 않았습니다. 시뮬레이터에서는 떴습니다.

질문 하나 먼저 드립니다. 버그를 보려고 넣은 로그·플래그·임시 뷰가 있는데 그걸 넣자 증상이 사라졌다면, 당신은 그 진단 코드를 믿으시겠습니까? 저는 믿었고, 그래서 4시간을 태웠습니다.

증상이 진단을 배신한다

실기기 로그를 볼 방법이 마땅치 않아서(그 자체가 두 번째 함정입니다, 아래), "실패 이유를 화면에 그리는" 진단 빌드를 만들었습니다. 결과가 이랬습니다.

  • 진단 빌드: 배너 100% 정상으로 뜸
  • 정상 빌드: 배너 100% 안 뜸

여기서부터 저는 환경을 의심했습니다. Debug ↔ Release, 시뮬레이터 ↔ 실기기, 개발 서명 ↔ App Store 서명을 차례로 갈아 끼웠습니다. 전부 아니었습니다. 4시간이 통째로 날아갔습니다.

증거는 처음부터 있었습니다. 100%로 갈리는 변수는 환경이 아니라 코드입니다. 환경 문제는 확률적으로 흔들립니다. 100:0으로 딱 갈리면 그건 스위치가 하나 있다는 뜻이고, 그 스위치는 제가 방금 넣은 진단 코드였습니다.

범인은 else 한 줄이었다

원격 config를 그리는 뷰는 이렇게 생겼습니다.

// ❌ 첫 진입에서 로드가 시작되지 않는다
struct PromoBanner: View {
    @StateObject private var loader = Loader()
    var body: some View {
        Group {
            if let r = loader.resolved { Card(r) }   // 처음엔 항상 nil
        }
        .task { await loader.load() }                // ← 실행되지 않음
    }
}
// 놓인 자리: Form { Section { PromoBanner() } }

Form/ListSection 안에서 뷰의 body가 빈 결과를 돌려주면, SwiftUI는 그 row를 아예 만들지 않습니다. row가 없으면 거기 붙인 .task도 실행되지 않습니다. 그런데 "아직 로드 안 됨" 상태가 곧 "빈 콘텐츠"이므로 첫 진입은 언제나 그 상태입니다. 사슬이 자기 자신으로 돌아옵니다.

로드 안 됨 → body 빔 → row 안 생김 → .task 안 돎 → 영원히 로드 안 됨

진단용 else { Text("...") } 한 줄을 넣는 순간 body가 더 이상 비지 않습니다. row가 생기고, .task가 돌고, 배너가 뜹니다. 관측 장치가 관측 대상을 바꾸고 있었습니다. 진단 빌드가 100% 성공한 이유는 진단 코드가 버그를 고쳤기 때문입니다 — 물론 릴리스에는 그 코드가 없었죠.

수정: init에서 시작하고, 멱등하게

핵심은 로드 시작을 body의 결과에 의존시키지 않는 것입니다. Section은 자식을 만들 때 뷰의 init을 부르므로, 거기서 시작합니다.

// ✅ init 은 body 결과와 무관하게 호출된다
final class Loader: ObservableObject {          // @MainActor
    static let shared = Loader()
    private var started = false
    nonisolated func start() {                   // View init 에서 부르므로 nonisolated
        Task { @MainActor in
            guard !started else { return }       // SwiftUI 가 init 을 여러 번 부른다 → 멱등 필수
            started = true
            await load()
        }
    }
}
 
struct PromoBanner: View {
    @ObservedObject private var loader = Loader.shared   // 뷰 수명보다 오래 살아야 한다
    init() { Loader.shared.start() }
    var body: some View {
        Group { if let r = loader.resolved { Card(r) } }  // .task 는 안전망으로만
    }
}

두 가지가 필수입니다. 로더는 뷰보다 오래 살아야 하니 shared로 두고, SwiftUI가 init을 여러 번 부르므로 start()는 멱등이어야 합니다.

회귀 테스트가 오판을 굳혔다

정직하게 밝힙니다. 저는 회귀 테스트를 먼저 짰는데, 그 테스트가 저를 4시간 더 헤매게 만들었습니다.

// ❌ 프로덕션 뷰 계층을 재현하지 못한 테스트
let vc = UIHostingController(rootView: PromoBanner())   // 직접 올림

뷰를 UIHostingController직접 올리면 row가 비지 않아 .task가 정상 실행됩니다. 이 테스트가 통과하는 걸 보고 "Group + .task는 조건이 false여도 동작한다"고 결론 냈습니다. 틀렸습니다. 테스트가 Form/List라는 프로덕션의 컨테이너를 재현하지 못했습니다. 테스트는 반드시 실제 놓이는 자리와 같은 컨테이너 안에 넣어야 합니다.

// ✅ 실제 놓이는 자리와 같은 컨테이너
let vc = UIHostingController(rootView: Form { Section { PromoBanner() } })
let w = UIWindow(frame: ...); w.rootViewController = vc; w.makeKeyAndVisible()
// 트리거 없으면 20초 대기 후에도 nil, 있으면 채워진다

그리고 수정을 일시 제거해 테스트가 먼저 실패하는 것을 확인한 뒤 복원했습니다. 통과만 보고 넘어갔다면 그 테스트가 사실 아무것도 안 잡고 있었을 수 있습니다.

딸린 두 번째 함정 — 실기기 로그

애초에 화면에 진단을 그린 이유는 실기기에서 로그를 볼 방법이 없어서였습니다.

  • xcrun devicectl device process launch --consoleNSLog/os_log를 안 받습니다. stdout만 붙습니다.
  • log stream 에는 --device-name이 없습니다. 호스트 전용입니다.
  • Console.app은 되지만 TestFlight 배포본엔 접근성이 떨어집니다.

그래서 "진단을 화면에 그리는 임시 빌드"가 실기기 진단의 가장 확실한 수단이라는 결론에 도달했었습니다. 그 결론은 지금도 유효합니다. 동시에 이번 버그는 바로 그 수단 때문에 4시간 가려졌습니다. 같은 기법이 최선의 도구이자 최악의 함정이었습니다.

배너가 실제로 들어간 앱은 Itda입니다.

Itda 앱 화면 — 원격 config로 그리는 크로스프로모 배너가 실린 화면

3줄 자가진단

  • 진단을 넣었더니 증상이 사라졌다면, 그 진단을 1순위 용의자로 볼 것. 특히 조건부 렌더링에 무언가를 더하는 진단은 없던 브랜치를 만들어 레이아웃 트리를 바꾼다.
  • 100:0으로 갈리는 변수는 환경이 아니라 코드다. 확률적으로 흔들리지 않으면 스위치를 찾아라.
  • 회귀 테스트는 프로덕션과 같은 컨테이너 안에 넣어라. UIHostingController에 직접 올린 뷰는 프로덕션의 Form/Section을 재현하지 못한다.

솔직한 부분

원격 config 경로도 처음 설계대로 가지 못했습니다. 의도한 URL이 Cloudflare 챌린지에 막혀 실제 동작 경로는 .well-known/ 폴백입니다. 배너 하나에 함정이 세 겹이었던 셈인데, 그중 제일 오래 저를 속인 건 제가 스스로 넣은 코드였습니다.

지금 당신이 "환경 탓" 같은 버그를 쫓고 있다면, 하나만 확인해 보세요. 그 증상, 진단을 끄면 돌아옵니까?

관련 글