← iOS 개발자 CS 로드맵 18 / 35

18 · 프로덕션 관측성

로그·메트릭·트레이스가 각각 답하는 질문, OSLog 레벨별 영속성, breadcrumb·태그·그룹핑, 그리고 관측 자체가 앱에 물리는 비용.
진행률
0 / 0 완료

여기서부터 P1이다. P0가 "이 개념은 무엇인가"를 다뤘다면, P1은 내 손을 떠나 사용자 기기에서 도는 앱을 어떻게 관측하고 판독하는가를 다룬다. 디버거를 붙일 수 없고, 재현 절차를 물어볼 수 없고, 로그를 더 찍어보려면 다음 릴리스를 기다려야 하는 환경이다. 이 챕터는 그 환경에서 무엇을 계측하고 무엇을 버릴지를 정하는 기준을 세운다.

📝 노트

이 챕터부터 등장하는 수치는 출처 등급을 함께 표기한다. Apple 공식 문서에 명시된 값커뮤니티 실측값은 신뢰도가 다르고, 후자는 iOS 버전에 따라 조용히 바뀐다. 면접에서 실측값을 공식 스펙처럼 말하는 것은 감점 요인이다.

Q1. 관측성과 모니터링은 무엇이 다른가?

🔑 30초 답변

모니터링은 미리 정해둔 질문에 답하는 것이고, 관측성은 미리 정하지 않은 질문에도 답할 수 있는 성질이다. "크래시율이 1%를 넘었나"는 모니터링이다 — 질문을 배포 전에 알고 있었고 대시보드를 만들어뒀다. "어제부터 결제 화면만 느려졌는데, 특정 기기·특정 OS·특정 네트워크 조합에서만 그런가"는 관측성 문제다 — 이 질문은 사고가 난 뒤에 생겼고, 답하려면 이미 수집된 데이터를 새로운 축으로 쪼갤 수 있어야 한다. 실무적 차이는 하나로 압축된다. 배포 후에 새 질문을 던질 수 있는가.

CS 원리

관측성(observability)은 원래 제어 이론 용어다. 어떤 시스템이 관측 가능하다는 것은 외부에서 측정 가능한 출력만으로 내부 상태를 유한 시간 안에 결정할 수 있다는 뜻이다. 내부를 직접 열어볼 수 없다는 전제가 핵심이다 — 열어볼 수 있으면 관측성을 논할 이유가 없다.

소프트웨어로 옮기면 전제가 그대로 성립한다. 사용자 기기의 프로세스는 열어볼 수 없다. 우리가 가진 것은 그 프로세스가 밖으로 내보낸 신호뿐이고, 그 신호만으로 "그때 내부에서 무슨 일이 있었는가"를 역산해야 한다. 그래서 설계 문제는 이렇게 바뀐다.

세 번째가 모니터링과 관측성을 가르는 지점이다. 값 하나를 숫자로 요약해 보내면(예: "평균 응답 시간 320ms") 나중에 "iPhone SE에서만 느렸나"를 물을 수 없다. 요약하는 순간 축이 사라지기 때문이다. 반대로 이벤트마다 기기·OS·화면·네트워크 종류를 차원(dimension)으로 붙여 보내면, 그 조합으로 나중에 쪼갤 수 있다. 관측성은 결국 차원을 얼마나 살려서 보냈는가의 문제다.

모니터링 — 배포 전에 질문이 정해짐 이벤트 기기·OS·화면·망 평균으로 요약 320 ms 차원이 사라짐 — "SE만 느렸나?" 답 불가 관측성 — 사후에 새 질문을 던질 수 있음 이벤트 기기·OS·화면·망 차원 보존 샘플링만 적용 원본 이벤트 저장 나중에 자유롭게 쪼갬 기기별로 쪼개기 OS 버전 × 화면 교차 p95 다시 계산 요약은 되돌릴 수 없다 — 차원을 버리는 시점이 곧 답할 수 있는 질문의 한계선이다.
모니터링과 관측성의 실질적 차이는 "언제 요약하는가"다. 수집 시점에 평균으로 접으면 그 축은 영원히 복원되지 않는다.

iOS에서는

모바일 클라이언트에는 서버에 없는 제약이 셋 더 붙는다.

이 세 제약 때문에 모바일 관측 설계의 기본값은 "일단 다 보내고 나중에 거른다"가 아니라 "보낼 가치가 증명된 것만 보낸다"가 된다. 서버 관측성 담론을 그대로 옮겨오면 배터리와 요금제에서 먼저 터진다.

실험 · 도구

차원을 버리면 무엇을 못 하게 되는지는 직접 확인하는 편이 빠르다. 같은 데이터를 두 방식으로 기록해보자.

import Foundation

struct RenderSample {
    let screen: String
    let device: String
    let osMajor: Int
    let durationMs: Double
}

// 실제 사용자 분포를 흉내낸 표본 — 특정 조합만 느리다
let samples: [RenderSample] = (0..<2000).map { i in
    let device = i % 10 == 0 ? "iPhone SE (3rd)" : "iPhone 15"
    let os = i % 3 == 0 ? 17 : 18
    // SE + iOS 17 조합에서만 렌더가 크게 느려지는 상황
    let slow = (device == "iPhone SE (3rd)" && os == 17)
    return RenderSample(screen: "checkout",
                        device: device,
                        osMajor: os,
                        durationMs: slow ? Double.random(in: 900...1400)
                                         : Double.random(in: 40...120))
}

// (A) 모니터링식 — 수집 시점에 평균 하나로 접는다
let monitored = samples.map(\.durationMs).reduce(0, +) / Double(samples.count)
print(String(format: "A. 평균만 저장: %.1f ms", monitored))
// → 약 116ms. 렌더 시간으로는 "그럭저럭 정상" 범위라 그냥 넘어가게 된다.
//    느린 조합이 전체의 3.35%(67/2000)뿐이라 평균에 거의 묻힌다.
//    여기서 더 파고들 방법이 없다는 것이 핵심이다.

// (B) 관측성식 — 차원을 살려 원본을 저장했다면 사후에 쪼갤 수 있다
let byCombo = Dictionary(grouping: samples) { "\($0.device) / iOS \($0.osMajor)" }
for (combo, group) in byCombo.sorted(by: { $0.key < $1.key }) {
    let sorted = group.map(\.durationMs).sorted()
    let p95 = sorted[Int(Double(sorted.count) * 0.95)]
    print(String(format: "B. %@ — n=%d p95=%.0f ms", combo, group.count, p95))
}
// → "iPhone SE (3rd) / iOS 17 — n=67 p95=1380 ms" 가 튀어나온다.
//    같은 원본에서 나온 결과인데 (A)로는 영원히 볼 수 없던 사실이다.

핵심은 (B)가 더 많은 데이터를 보내서 이긴 게 아니라는 점이다. 두 방식이 관찰한 이벤트 수는 같다. 차이는 요약 시점뿐이다. 수집 시점에 접으면 축이 사라지고, 저장 후에 접으면 언제든 다시 펼 수 있다.

프로젝트 적용

"차원을 살려라"를 무한정 적용하면 카디널리티가 폭발한다. 실무 기준은 세 단계로 정한다.

차원 후보카디널리티판단
OS 메이저 버전한 자릿수항상 붙인다
기기 모델수십항상 붙인다
화면·기능 이름수십~수백붙인다 (열거형으로 고정)
앱 버전 · 빌드수십항상 붙인다
사용자 ID수백만검색 축이 아니라 식별자로만 — 태그로 색인하지 않는다
전체 URL (쿼리 포함)사실상 무한금지 — 경로 템플릿으로 정규화
에러 메시지 원문사실상 무한금지 — 그룹핑을 파괴한다 (Q6 참조)

규칙 하나로 요약하면 "값의 종류가 유한하고 미리 열거할 수 있으면 차원, 아니면 본문"이다. /user/12345/orders/98765는 차원으로 쓰면 사용자 수만큼 축이 늘어나지만, /user/{id}/orders/{id}로 정규화하면 유한해진다. 원본 값이 필요하면 검색 축이 아니라 이벤트 본문에 담는다.

⚠️ 흔한 오해

"관측성 = 도구를 붙이는 것"은 틀렸다. SDK를 설치했다고 관측 가능해지지 않는다. 어떤 차원을 붙여서 보내느냐가 결정하고, 그건 코드를 짜는 사람이 정한다. 반대로 "다 보내면 관측성이 좋아진다"도 틀렸다 — 카디널리티가 터지면 검색이 불가능해지고, 비용 때문에 샘플링을 강하게 걸게 되어 오히려 희귀 사건을 놓친다. 또 "관측성은 모니터링의 상위 호환"도 부정확하다. 둘은 대체 관계가 아니라 역할이 다르다 — 알람은 여전히 미리 정의한 임계값(모니터링)이 담당하고, 알람이 울린 뒤 원인을 좁히는 것이 관측성이다.

🧒 쉽게 이해하기

병원 검사를 떠올려보자. 모니터링은 건강검진 결과표다. 미리 정해둔 항목(혈압·혈당·콜레스테롤)을 재서 "정상 범위인가"를 본다. 항목이 정해져 있으니 표를 보고 바로 판단할 수 있다. 대신 표에 없는 것은 아무것도 알 수 없다.

관측성은 혈액 샘플을 냉동 보관해둔 것에 가깝다. 지금 당장은 아무 답도 주지 않는다. 그런데 몇 달 뒤 "혹시 그때 이 수치가 이상했나?"라는 새로운 의심이 생기면, 보관해둔 샘플을 꺼내서 그 항목을 새로 검사할 수 있다. 결과표만 남기고 샘플을 버렸다면 그 질문에는 영영 답할 수 없다.

그래서 "평균 응답시간 320ms"라고만 적어두는 건 샘플을 버리고 결과표 한 줄만 남기는 것과 같다. 숫자는 남았지만 다시 물어볼 방법이 사라졌다.

꼬리 질문

알람(alerting)은 모니터링과 관측성 중 어디에 속하며, 관측성 데이터로 알람을 걸면 무엇이 문제인가?

알람은 본질적으로 모니터링이다. 알람은 미리 정의한 조건이 참이 되면 사람을 깨우는 것이라 질문이 배포 전에 확정돼 있어야 하고, 값이 저카디널리티 시계열로 집계돼 있어야 빠르고 안정적으로 평가된다.

관측성 데이터(고카디널리티 원본 이벤트)로 직접 알람을 걸면 세 가지가 깨진다. 첫째, 평가 비용이 크다 — 매번 원본을 스캔해 집계해야 하므로 알람 주기를 짧게 가져갈 수 없다. 둘째, 샘플링과 충돌한다 — 원본이 샘플링돼 있으면 "0건"이 진짜 0건인지 샘플에서 빠진 건지 구분할 수 없어 오탐·미탐이 생긴다. 셋째, 카디널리티가 높은 축으로 알람을 쪼개면 알람 자체가 폭발한다.

그래서 실무 구성은 둘을 붙여 쓰는 것이다. 알람은 저카디널리티 집계 지표(크래시율·행률·에러율)로 걸고, 알람이 울린 뒤 원인 좁히기는 고카디널리티 원본으로 넘어간다.

쉽게 말하면 화재경보기는 단순해야 한다. "연기 감지되면 울린다" 하나면 충분하고, 대신 절대 안 틀려야 한다. 반면 불이 난 뒤 "어디서 시작됐나"를 조사하는 건 경보기가 할 일이 아니다. 경보기로 조사까지 하려 들면 느려지고, 조사 장비로 경보를 울리려 하면 자꾸 헛울린다.
같은 사건을 "요약해서 보내기"와 "원본 보내고 나중에 요약하기"의 비용 구조는 어떻게 다른가?

비용이 발생하는 위치가 다르다. 요약해서 보내면 전송량과 저장량이 이벤트 수와 무관하게 거의 일정하다 — 1분에 1000번 일어난 일도 값 하나로 보내기 때문이다. 대신 질의 시점 비용이 0에 수렴하고(이미 접혀 있음), 답할 수 있는 질문도 고정된다.

원본을 보내면 전송·저장 비용이 이벤트 수에 비례해 선형으로 늘고, 클라이언트에서는 네트워크·배터리 비용이 사용자에게 전가된다. 대신 질의 시점에 자유도가 생긴다. 그래서 원본 방식은 반드시 샘플링과 짝을 이룬다 — 샘플링이 선형 증가를 잘라주지 못하면 비용이 먼저 무너진다.

모바일에서 중요한 비대칭이 하나 더 있다. 서버는 요약이든 원본이든 비용을 회사가 내지만, 클라이언트는 원본 방식의 추가 비용을 사용자가 낸다(데이터 요금·배터리). 그래서 같은 트레이드오프라도 모바일에서는 원본 쪽 문턱이 서버보다 훨씬 높다.

쉽게 말하면 영수증을 다 모아두면 나중에 무슨 질문이든 답할 수 있지만 서랍이 터진다. 가계부에 합계만 적으면 서랍은 깔끔한데 "지난달 커피값만 따로" 같은 질문에는 답할 수 없다. 그리고 모바일에서는 그 서랍이 내 집이 아니라 사용자 집에 있다.
"관측 가능하도록 설계한다"는 것이 코드 수준에서는 구체적으로 무엇을 바꾸는가?

세 가지가 바뀐다. 첫째, 상태 전이에 이름이 붙는다. 불리언 플래그 몇 개로 표현되던 상태를 열거형으로 명시하면(enum CheckoutState { case idle, validating, submitting, failed(Reason) }) 그 값을 그대로 차원으로 내보낼 수 있다. 플래그 조합은 이름이 없어서 내보낼 수도, 검색할 수도 없다.

둘째, 실패에 분류가 생긴다. catch { return nil }은 관측 불가능한 코드의 전형이다 — 실패했다는 사실조차 밖으로 나오지 않는다. 실패 이유를 타입으로 구분하고(타임아웃 / 4xx / 5xx / 디코딩 실패) 그 분류를 이벤트에 실으면 "요즘 실패가 는다"가 아니라 "디코딩 실패만 는다"를 볼 수 있다.

셋째, 경계에 계측 지점이 생긴다. 네트워크·디스크·IPC처럼 프로세스나 기기를 넘는 지점은 실패와 지연이 집중되는 곳이라, 이 경계를 함수 하나로 감싸두면 계측을 한 곳에 붙일 수 있다. 경계가 코드 여기저기 흩어져 있으면 계측도 흩어지고 결국 누락된다.

요약하면 관측 가능한 설계는 계측 코드를 많이 넣는 게 아니라 내보낼 만한 이름과 경계를 먼저 만드는 것이다.

쉽게 말하면 창고를 관리하려면 CCTV를 다는 것보다 물건에 이름표를 붙이고 선반을 나누는 게 먼저다. 이름표가 없으면 CCTV를 100대 달아도 "뭐가 없어졌는지" 말할 수 없다.

Q2. 로그·메트릭·트레이스는 각각 어떤 질문에 답하는가?

🔑 30초 답변

셋은 대체재가 아니라 답하는 질문의 모양이 다르다. 메트릭은 "얼마나 자주, 얼마나 나쁜가"에 답한다 — 시간축으로 집계된 숫자라 알람과 추세에 쓴다. 로그는 "그 순간 무슨 일이 있었나"에 답한다 — 개별 사건의 서술이라 사후 판독에 쓴다. 트레이스는 "그 요청이 어디서 시간을 썼나"에 답한다 — 하나의 작업을 인과 순서로 이어붙인 구조라 병목 지목에 쓴다. 판단 기준은 단순하다. 추세가 궁금하면 메트릭, 사건이 궁금하면 로그, 경로가 궁금하면 트레이스.

CS 원리

셋의 차이는 무엇을 축으로 삼는가에서 나온다.

메트릭로그트레이스
기본 단위(시각, 숫자, 차원)(시각, 메시지, 맥락)(작업, 하위 구간들의 트리)
주 축시간시간인과 관계
집계수집 시 접힘안 접힘안 접힘
비용 증가차원 조합 수에 비례이벤트 수에 비례이벤트 수에 비례
답하는 질문얼마나 / 언제부터무엇이 / 왜어디서 / 얼마나 오래
셋은 대체재가 아니라 답하는 질문의 모양이 다르다 메트릭 "얼마나 / 언제부터" 시간축 · 수집 시 접힘 로그 "무엇이 / 왜" 개별 사건 · 안 접힘 트레이스 "어디서 / 얼마나 오래" 인과 트리 · 부모를 명시 로그에도 시각이 있는데 왜 트레이스가 따로 필요한가 스레드 A·B 로그가 시간순으로 섞임 → 어느 요청 것인지 복원 불가 각 구간이 부모 id 를 들고 있음 → 동시 실행돼도 트리로 분리
판단 기준은 하나다 — 추세가 궁금하면 메트릭, 사건이 궁금하면 로그, 경로가 궁금하면 트레이스.

가장 중요한 구분은 트레이스만 인과 구조를 갖는다는 점이다. 로그도 타임스탬프가 있으니 순서를 알 수 있을 것 같지만, 동시성이 있으면 시간 순서와 인과 순서가 어긋난다. 스레드 A의 로그와 스레드 B의 로그가 시간순으로 섞여 찍혔을 때, 그중 어느 것이 어느 요청에 속하는지는 타임스탬프로 복원되지 않는다. 트레이스는 각 구간에 부모 구간의 식별자를 명시적으로 달아 이 문제를 푼다 — 이게 22장에서 다룰 traceparent의 존재 이유다.

iOS에서는

셋 다 Apple이 제공하는 네이티브 수단이 이미 있다. 벤더 SDK 없이도 개념을 확인할 수 있다.

차이는 도달 범위다. signpost는 개발 중 Instruments로만 보고, MetricKit은 사용자 기기에서 오지만 하루 한 번 집계돼서 온다. "지금 프로덕션에서 이 요청이 어디서 막혔나"를 실시간으로 보려면 결국 자체 계측이나 벤더 SDK가 필요하다. 셋 중 무엇이 부족한지 알고 도구를 고르는 것이 순서다.

실험 · 도구

같은 결제 요청을 세 방식으로 계측하면 각각이 무엇을 남기는지가 선명해진다.

import os
import OSLog

private let log = Logger(subsystem: "com.example.shop", category: "checkout")
private let signposter = OSSignposter(subsystem: "com.example.shop", category: "checkout")

func submitOrder(_ order: Order) async throws -> Receipt {
    // ── 트레이스: 구간에 이름을 주고 시작/끝을 짝지어 기록 ──
    let state = signposter.beginInterval("submitOrder", id: signposter.makeSignpostID())
    defer { signposter.endInterval("submitOrder", state) }

    // ── 로그: 개별 사건의 서술. 동적 문자열은 기본이 private이라 리댁션된다 ──
    log.notice("주문 제출 시작 items=\(order.items.count, privacy: .public)")

    do {
        let validated = try await validate(order)          // 하위 구간
        let receipt   = try await postToServer(validated)  // 하위 구간
        log.notice("주문 성공")
        // ── 메트릭: 접어서 세는 값. 차원은 유한한 것만 ──
        Counter.increment("checkout.result", dimensions: ["outcome": "success"])
        return receipt
    } catch {
        // 실패도 분류해서 센다 — "실패가 는다"가 아니라 "무슨 실패가 느는지" 보려고
        log.error("주문 실패 reason=\(String(describing: error), privacy: .public)")
        Counter.increment("checkout.result", dimensions: ["outcome": classify(error)])
        throw error
    }
}

// 실패 분류를 유한한 집합으로 고정한다 — 이게 메트릭 차원이 될 수 있는 조건
func classify(_ error: Error) -> String {
    switch error {
    case is TimeoutError:          return "timeout"
    case let e as HTTPError where (500...599).contains(e.status): return "server"
    case let e as HTTPError where (400...499).contains(e.status): return "client"
    case is DecodingError:         return "decode"
    default:                       return "other"
    }
}

Instruments에서 이 앱을 프로파일하고 os_signpost 계측기를 추가하면 submitOrder 구간이 막대로 나타나고, 그 안에 validate·postToServer가 중첩된 모양으로 보인다. 로그는 Console.app이나 log stream --predicate 'subsystem == "com.example.shop"'으로 따로 본다. 같은 코드 경로인데 보는 창이 다르다는 것이 셋의 관계를 잘 보여준다.

프로젝트 적용

셋을 다 붙이는 것이 목표가 아니다. 실무에서는 질문의 종류로 역산한다.

흔한 실패 패턴은 로그로 메트릭을 대신하려는 것이다. "에러 로그 개수를 세서 에러율로 쓰자"는 처음엔 동작하지만, 로그 양이 늘면 샘플링·보관기한·검색 비용에 부딪히고, 로그 문구를 바꾸는 순간 지표가 조용히 끊긴다. 세어야 하는 값은 처음부터 세는 도구로 세는 것이 맞다.

⚠️ 흔한 오해

"로그를 충분히 남기면 트레이스는 필요 없다"는 틀렸다. 로그에는 인과 구조가 없다 — 동시 실행되는 두 요청의 로그가 시간순으로 섞이면 타임스탬프만으로는 분리되지 않는다. 반대로 "트레이스가 있으면 로그는 필요 없다"도 틀렸다 — 트레이스는 "어디서 얼마나 걸렸나"에 강하고 "그 안에서 어떤 값이 무엇이었나"에는 약하다. 또 "메트릭은 정확하고 샘플링된 트레이스는 부정확하다"도 부정확하다 — 메트릭도 수집 시점에 접히면서 분포 정보를 잃는다. 평균만 남긴 메트릭은 p95를 영원히 복원하지 못한다.

🧒 쉽게 이해하기

여행에서 셋을 다 쓴다고 생각해보자.

메트릭은 가계부다. "8월 교통비 12만원." 얼마나 썼는지, 지난달보다 늘었는지 바로 안다. 대신 "그 택시 왜 탔더라"는 안 적혀 있다.

로그는 일기다. "8월 3일, 비가 와서 지하철역까지 못 걸어가고 택시 탐." 그날 무슨 일이 있었는지는 알 수 있는데, 한 달치 합계를 내려면 일기를 다 읽어야 한다.

트레이스는 그날의 이동 경로 기록이다. 집 → 택시 20분 → 역 → 지하철 40분 → 도착. 어디서 시간을 얼마나 썼는지가 순서대로 보인다. 일기에도 "택시 탔다"는 적혀 있지만, 그게 전체 이동 중 얼마를 차지했는지는 경로 기록에만 있다.

그래서 "교통비가 늘었네"(가계부)→"그날 뭐 했지"(일기)→"어디서 시간을 버렸지"(경로) 순으로 넘어가게 된다.

꼬리 질문

메트릭의 카디널리티 폭발은 왜 로그의 데이터 증가보다 위험한가?

증가 방식이 다르다. 로그는 이벤트 수에 선형으로 는다 — 요청이 2배면 로그도 2배다. 반면 메트릭은 차원 값의 조합 수(곱)에 비례해 시계열 개수가 는다. 기기 30종 × OS 5종 × 화면 40개 × 결과 5종이면 이미 3만 개의 시계열이고, 여기에 사용자 ID 같은 고카디널리티 차원 하나만 끼면 수백만 개가 된다.

위험한 이유는 시계열이 이벤트가 없어도 저장·인덱싱 비용을 계속 먹기 때문이다. 로그는 안 찍히면 비용이 0이지만, 한 번 생성된 시계열은 보관 기간 내내 자리를 차지하고 질의 때마다 스캔 대상이 된다. 게다가 조합당 표본 수가 잘게 쪼개져 통계적으로도 의미가 없어진다 — 시계열 하나에 데이터포인트가 3개면 p95를 계산할 수 없다.

그래서 실무 규칙은 "차원은 유한하고 열거 가능한 것만, 식별자는 차원이 아니라 이벤트 본문으로"다.

쉽게 말하면 서랍을 나누는 것과 종이를 쌓는 것의 차이다. 종이는 쌓이면 무겁지만 그냥 무거울 뿐이다. 그런데 서랍을 "기기별 × OS별 × 화면별 × 사용자별"로 나누면 서랍 개수 자체가 수백만 개가 되고, 대부분은 종이 한 장씩만 들어 있다. 정리가 아니라 재앙이다.
클라이언트에서 트레이스를 만들 때 서버 트레이싱과 결정적으로 다른 제약은 무엇인가?

세 가지다. 첫째, 클럭을 믿을 수 없다. 서버들끼리는 NTP로 어느 정도 맞춰져 있지만 사용자 기기의 벽시계는 사용자가 임의로 바꿀 수 있고 시간대·서머타임도 바뀐다. 그래서 구간 길이는 벽시계가 아니라 단조 시계(ContinuousClock·mach_absolute_time)로 재야 하고, 서버 스팬과 이어붙일 때 클라이언트 시각과 서버 시각의 오프셋을 그대로 믿으면 안 된다. (P0 16장 Q4의 연장선이다.)

둘째, 트레이스가 중간에 끊긴다. 서버 프로세스는 요청을 끝까지 처리하고 스팬을 닫지만, 앱은 사용자가 홈 버튼을 누르거나 OS가 죽이면 스팬이 열린 채로 사라진다. 그래서 "끝나지 않은 스팬"이 정상 상황의 일부이고, 이를 이상으로 취급하면 지표가 오염된다.

셋째, 전송이 지연된다. 오프라인이면 큐에 쌓였다가 몇 시간 뒤에 올라온다. 서버 트레이스처럼 "지금 들어온 데이터 = 지금 일어난 일"이 성립하지 않으므로, 수집 시각과 발생 시각을 반드시 분리해서 다뤄야 한다.

쉽게 말하면 서버들은 같은 사무실에서 같은 벽시계를 보고 일한다. 클라이언트는 각자 다른 나라에서 각자 손목시계를 차고 있고, 그 시계를 마음대로 돌릴 수도 있으며, 일하다 말고 갑자기 사라지기도 하고, 보고서를 사흘 뒤에 부치기도 한다.
signpost와 벤더 SDK의 span은 개념이 같은데, 프로덕션에서 signpost로 대체할 수 있는가?

부분적으로만 가능하다. 먼저 정정할 것이 있다 — 일반 OSSignposter 구간에는 프로덕션 수집 경로가 없지만, MetricKit을 거치면 있다. MXMetricManager.makeLogHandle(category:)로 만든 로그 핸들에 signpost를 찍으면, OS가 실사용자 기기에서 집계해 MXMetricPayload.signpostMetrics(MXSignpostMetric)로 돌려준다.

그럼에도 span 트리를 대체하지는 못한다. 이유가 셋이다. (1) 하루 한 번 집계된 히스토그램으로 오므로 개별 트레이스를 볼 수 없다. (2) 부모-자식 관계가 없다 — 구간별 분포만 준다. (3) 일반 signpost 데이터는 메모리 기반 링 버퍼 성격이라 오래 보관되지 않는다.

그래서 실무 분업은 이렇게 된다 — 개발·QA 단계의 성능 분석은 signpost(오버헤드가 매우 낮고 Instruments와 통합), 프로덕션 수집은 별도 계측. 다만 둘의 구간 이름을 맞춰두면 "Instruments에서 본 구간"과 "프로덕션에서 느린 구간"을 같은 이름으로 대조할 수 있어서, 이름 규칙을 공유하는 것이 실용적이다.

쉽게 말하면 signpost는 내 작업실 안에 붙여둔 스톱워치다. 정확하고 공짜지만, 손님 집에 있는 스톱워치 기록을 내가 가져올 방법이 없다. 프로덕션 계측은 기록을 우편으로 부쳐주는 장치를 따로 다는 일이다.

Q3. 로그 레벨은 무엇을 바꾸는가?

🔑 30초 답변

로그 레벨은 "얼마나 심각한가"를 적어두는 라벨이 아니라, 시스템이 그 메시지를 어디까지 보존할지를 결정하는 스위치다. Apple 통합 로깅에서 모든 메시지는 일단 메모리에 저장되고, 레벨이 높을수록 디스크에 기록된다. debug는 디스크에 안 남고, infolog 도구로 수집할 때만 남으며, notice·error·fault는 저장 한도 내에서 디스크에 남는다. 그래서 "로그를 찍었는데 사용자 기기에서 안 보인다"의 대부분은 레벨 선택 실수다. 비용도 같이 움직인다 — 디스크에 쓰는 레벨일수록 느리다.

CS 원리

로깅 시스템은 쓰기 비용과 보존 가치의 트레이드오프를 레벨이라는 축 하나로 압축한 것이다. 모든 메시지를 디스크에 남기면 I/O가 앱 성능을 갉아먹고 저장소가 찬다. 아무것도 안 남기면 사후 판독이 불가능하다. 그래서 실용적인 시스템은 계층을 둔다.

레벨은 심각도 라벨이 아니라 보존 계층 스위치다 휘발 계층 — 메모리 링 버퍼 쓰기 매우 쌈 · 프로세스가 죽거나 버퍼가 돌면 사라짐 모든 메시지가 일단 여기 들어간다 레벨이 높을수록 여기까지 올라간다 영속 계층 — 디스크 쓰기 비쌈 · 용량 한도 도달 시 오래된 것부터 삭제 사용자 기기에서 사후에 얻을 수 있는 것은 여기뿐 debug — 여기까지 info — 조건부 notice (기본값) error · fault 비용도 함께 올라간다 영속 계층은 제로섬이다 — notice 를 남발하면 시끄러운 notice 가 차지 error 보존 창 ↓ → 사고 난 날 펼쳐보면 사고 전날 기록이 이미 없다
레벨은 사후에 바꿀 수 없다. 메모리에만 쓰인 메시지를 나중에 디스크로 옮길 수는 없다.

레벨은 "이 메시지를 어느 계층까지 올릴 것인가"의 선언이다. 여기서 중요한 함의가 나온다 — 레벨은 사후에 바꿀 수 없다. 이미 메모리에만 쓰인 메시지를 나중에 "역시 중요했네" 하며 디스크로 옮길 수는 없다. 그래서 레벨 선택은 미래의 디버깅 가능성을 지금 결정하는 행위다.

iOS에서는

Apple 통합 로깅의 레벨별 동작은 공식 문서에 표로 명시돼 있다. 이 표는 외워둘 가치가 있다.

레벨디스크 영속용도
debug아니오 (메모리만)개발 중에만 유용한 상세 정보
infolog 도구로 미리 영속화를 켜둔 경우만있으면 도움되지만 필수는 아닌 정보
notice (기본)예 (저장 한도까지)문제 해결에 필수적인 정보
error예 (저장 한도까지)실행 중 관찰된 오류
fault예 (저장 한도까지)코드의 결함·버그

세 가지를 짚어야 한다.

덧붙여 errorfaultactivity 객체가 있으면 관련 프로세스 체인의 정보까지 함께 캡처한다. 이게 오버헤드 차이의 실체이기도 하고, 동시에 두 레벨이 사후 판독에서 강한 이유이기도 하다.

실험 · 도구

레벨이 실제로 보존을 가른다는 것은 기기에서 직접 확인할 수 있다.

import OSLog

let log = Logger(subsystem: "com.example.shop", category: "levels")

log.debug("이 줄은 디스크에 남지 않는다")
log.info("이 줄은 영속화를 미리 켜둔 경우에만 남는다")
log.notice("이 줄은 디스크에 남는다 (기본 레벨)")
log.error("이 줄도 남고, activity 체인 정보가 더 붙는다")
log.fault("이 줄도 남는다 — 가장 비싸다")

실행 후 Mac에서 기기 로그를 뜯어본다.

# 실시간 스트림 — 기기 로그를 보려면 --device 가 필요하다.
# debug/info 까지 보려면 --level 도 명시해야 한다.
log stream --device --predicate 'subsystem == "com.example.shop"' --level debug

# 지나간 로그는 "수집 → 조회" 두 단계다.
#   ⚠️ log show 에는 --device 옵션이 없다. 로컬 Mac 저장소나 아카이브만 읽는다.
#      그래서 기기 로그는 log collect 로 아카이브를 만든 뒤 그것을 show 한다.
log collect --device --start '2026-08-14 09:00:00' --output shop.logarchive
log show shop.logarchive --predicate 'subsystem == "com.example.shop"'          # debug 없음
log show shop.logarchive --predicate 'subsystem == "com.example.shop"' --info   # info 포함

확인 포인트는 이것이다 — log stream --level debug로는 5줄이 다 보이는데, 아카이브를 log show로 열면 debug가 사라져 있다. 스트리밍은 메모리에서 실시간으로 가로채는 것이고, show는 저장소를 읽는 것이라 그렇다. 사용자 기기에서 사후에 얻을 수 있는 것은 후자뿐이다.

⚠️ 주의

log show에는 --device가 없다. --devicelog streamlog collect에만 있다. log show --predicate …를 그냥 실행하면 연결된 기기가 아니라 Mac 자신의 로그를 조회하므로, 기기에서만 나오는 subsystem을 찾으면 아무 결과도 안 나온다. "분명 로그를 찍었는데 안 보인다"의 흔한 원인이다.

프로젝트 적용

레벨 정책을 팀 규칙으로 못 박아두지 않으면 반드시 무너진다. 실무에서 쓸 만한 기준은 이렇다.

상황레벨이유
루프 안, 프레임마다, 스크롤 중debug디스크에 안 써야 성능이 산다
화면 진입·이탈, 세션 시작info 또는 notice사후 판독에 필요하면 notice
주요 플로우의 상태 전이 (결제 시작·완료)notice사고 판독의 뼈대가 된다
복구 가능한 실패 (재시도로 해결됨)error빈도가 높으면 notice로 낮추는 것도 고려
있어선 안 되는 상태 (불변식 위반)fault가장 비싸므로 정말 드물어야 한다

가장 흔한 실수는 루프 안에서 error를 찍는 것이다. 네트워크가 끊긴 상태에서 재시도 루프가 초당 수십 번 error를 찍으면, 디스크 I/O가 발생할 뿐 아니라 저장소 한도를 밀어내 정작 중요한 과거 로그를 지운다. 반복되는 실패는 첫 발생만 error로 남기고 이후는 카운터로 세는 편이 옳다.

⚠️ 흔한 오해

"릴리스 빌드에서는 로그가 다 꺼진다"는 틀렸다. 통합 로깅은 빌드 구성과 무관하게 동작하며, notice 이상은 릴리스에서도 디스크에 남는다. #if DEBUG로 감싸야 꺼지는 것은 print이지 Logger가 아니다. 반대로 "debug는 어차피 안 남으니 무거운 값을 넣어도 된다"도 조심해야 한다. Logger의 보간 인자는 @autoclosure로 지연 평가되어 해당 레벨이 비활성이면 실행되지 않지만, 레벨이 활성화된 상태(예: log stream --level debug를 붙여둔 동안이나 구성 프로파일로 켜둔 경우)에서는 무거운 description 생성 비용이 그대로 든다. 또 "레벨은 필터링용 라벨일 뿐"이라는 인식이 가장 위험하다 — 레벨은 보존 여부를 결정하므로, 잘못 고르면 사고 당일에 로그가 없다.

🧒 쉽게 이해하기

메모를 남기는 방식을 떠올려보자. debug는 손바닥에 볼펜으로 적는 것이다. 지금 당장은 보이지만 손 씻으면 사라진다. 아주 편하고 부담이 없다.

info는 포스트잇이다. 책상에 붙여두면 오늘은 보이는데, 누가 치우면 없어진다. 남기고 싶으면 미리 "이건 보관함에 넣어두세요"라고 지정해둬야 한다 — 나중에 와서 찾으면 이미 없다.

notice·error·fault는 공책에 적는 것이다. 나중에 펼쳐보면 있다. 대신 공책은 두께가 정해져 있어서, 사소한 걸 잔뜩 적으면 정작 중요한 옛날 기록이 뒤로 밀려 찢겨나간다.

그래서 "일단 다 공책에 적자"가 안전해 보이지만 정반대다. 공책이 빨리 차서, 사고가 난 날 펼쳐보면 사고 전날 기록이 이미 없어져 있다.

꼬리 질문

printLogger는 프로덕션에서 무엇이 결정적으로 다른가?

세 가지가 다르다. 첫째, 도달 범위. print는 표준 출력으로 나가므로 Xcode가 붙어 있을 때만 보인다. 사용자 기기에서는 아무 데도 기록되지 않아 사후 판독에 쓸 수 없다. Logger는 시스템 로그 저장소에 들어가 나중에 꺼낼 수 있다.

둘째, 구조. print는 문자열 한 덩어리다. Logger는 subsystem·category·레벨·타임스탬프·프로세스 정보가 구조화돼 붙으므로 --predicate로 정확히 필터링된다. 로그가 수만 줄일 때 이 차이는 결정적이다.

셋째, 프라이버시. print는 넘긴 값을 그대로 찍는다. Logger는 동적 문자열을 기본적으로 리댁션하고, 열려면 privacy: .public을 명시해야 한다. 즉 print는 실수로 PII를 흘리기 쉬운 반면 Logger는 실수의 기본값이 안전한 쪽이다.

덧붙여 성능도 다르다 — print는 동기적으로 문자열을 만들어 쓰지만 Logger는 지연 평가와 압축된 바이너리 포맷을 쓴다.

쉽게 말하면 print는 혼잣말이다. 옆에 사람이 있을 때만 들리고, 녹음도 안 되고, 무심코 비밀번호를 말해버릴 수도 있다. Logger는 정해진 양식의 업무일지다. 나중에 찾아볼 수 있고, 항목별로 검색되고, 민감한 칸은 기본적으로 가려져 있다.
로그 저장소가 한도에 도달하면 오래된 것부터 지운다는 사실은 사고 대응 설계를 어떻게 바꾸는가?

"사고가 나면 로그를 뽑자"는 전략이 시간에 쫓기는 전략이 된다는 뜻이다. 로그가 시끄러운 앱일수록 보존 창(window)이 짧아서, 사용자가 문제를 겪은 시점과 우리가 로그를 요청하는 시점 사이에 이미 지워질 수 있다. 특히 사용자가 "어제부터 이상했어요"라고 제보하는 흔한 시나리오에서 그 어제치가 없는 경우가 생긴다.

그래서 설계가 두 방향으로 바뀐다. 첫째, 로그 예산을 관리한다 — 고빈도 로그를 debug로 내리거나 샘플링해서 중요한 레벨의 보존 창을 길게 유지한다. 둘째, 중요한 사건은 로그에만 의존하지 않는다 — 결제 실패처럼 반드시 사후 판독이 필요한 사건은 시스템 로그와 별개로 이벤트를 전송하거나 앱 자체 저장소에 기록해 보존 정책을 직접 통제한다.

즉 시스템 로그는 보조 증거로 두고, 사고 대응의 1차 증거는 우리가 보존 기간을 통제할 수 있는 경로에 둬야 한다.

쉽게 말하면 CCTV가 일주일치만 저장되고 계속 덮어쓴다는 걸 알면, "문제 생기면 CCTV 보면 되지"라고 미루지 않게 된다. 정말 중요한 장면은 따로 복사해두거나, 애초에 덜 중요한 카메라의 화질을 낮춰서 저장 기간을 늘리게 된다.
로그 메시지에 동적 문자열을 넣을 때 기본이 리댁션인 것은 어떤 문제를 막고, 어떤 새 문제를 만드는가?

막는 문제는 명확하다. 개발자가 의도하지 않게 사용자 이름·이메일·주소·토큰을 로그에 흘리는 사고다. 로그는 기기에 남고 진단 데이터로 추출될 수 있으므로, 기본값이 "보인다"였다면 유출이 일상적으로 일어났을 것이다. Apple은 정수·실수·불리언은 리댁션하지 않고 동적 문자열과 복합 객체만 가리는데, 민감 정보가 주로 문자열로 표현된다는 현실을 반영한 선택이다.

새로 생기는 문제는 디버깅 중 "왜 안 보이지"다. 사용자 기기에서 받은 로그가 <private>로 가득해 아무 정보도 못 얻는 상황이 흔하다. 그래서 민감하지 않은 값은 privacy: .public으로 명시적으로 열어야 하는데, 이 판단을 사람이 매번 해야 한다는 게 부담이다.

중간 지점이 mask.hash다. 값을 가리되 같은 값이면 같은 해시가 나오게 해서, 원본을 노출하지 않고도 "이 로그들이 같은 계정에 관한 것"이라는 상관관계를 유지한다. 다만 이 해시는 현재 프로세스에 한해 고유하므로 프로세스를 넘어선 추적에는 쓸 수 없다.

쉽게 말하면 서류를 복사할 때 주민번호 칸이 자동으로 까맣게 칠해져 나오는 복사기다. 실수로 유출할 일이 없어 안전하지만, 정작 내가 확인하려고 뽑아도 까맣다. 그래서 "이 칸은 안 가려도 됨"을 매번 지정해줘야 하고, "누군지는 몰라도 같은 사람인지는 알고 싶다"면 이름 대신 같은 사람에게 같은 번호표를 붙여주는 방식을 쓴다.

Q4. Breadcrumb은 로그와 무엇이 다른가?

🔑 30초 답변

로그는 독립적으로 저장되는 시계열이고, breadcrumb은 이벤트에 첨부되기 위해 대기하는 유한 버퍼다. 로그는 찍는 순간 그 자체로 목적지에 도달하지만, breadcrumb은 크래시나 에러가 발생해야 비로소 전송되고 아무 일도 안 일어나면 조용히 버려진다. 그래서 breadcrumb의 설계 질문은 "무엇을 기록할까"가 아니라 "제한된 N개 안에 무엇을 남겨야 사고를 재구성할 수 있는가"다. 링 버퍼라 시끄러운 항목 하나가 결정적 단서를 밀어낸다.

CS 원리

breadcrumb은 고정 크기 링 버퍼(circular buffer)다. 항목이 상한 N에 도달하면 가장 오래된 것을 덮어쓴다. 이 자료구조 선택에서 모든 성질이 따라 나온다.

이 제로섬 성질이 로그와의 근본적 차이다. 로그에서 시끄러운 항목은 비용을 늘릴 뿐이지만, breadcrumb에서 시끄러운 항목은 다른 정보를 파괴한다. 스크롤 이벤트를 breadcrumb으로 남기는 순간, 사용자가 30초 전에 눌렀던 버튼 기록은 사라진다.

링 버퍼 N=8 — 새 항목이 들어오면 가장 오래된 것이 밀려난다 나쁜 설계 로그인 ← 결정적 단서였는데 밀려남 scroll scroll scroll scroll scroll scroll scroll 💥 크래시 좋은 설계 로그인 장바구니 결제 진입 주소 선택 카드 선택 POST /orders HTTP 500 💥 크래시 같은 8칸인데 아래쪽만 사고를 재구성할 수 있다 — 개수가 아니라 선별이 문제다.
breadcrumb은 제로섬이다. 고빈도 이벤트를 넣는 순간 그만큼의 맥락이 사라진다.

iOS에서는

iOS 앱에서 자동 수집되는 breadcrumb은 대체로 네 갈래다 — 화면 전환(뷰 컨트롤러 생명주기), 사용자 인터랙션(터치·버튼), 네트워크 요청(URL·메서드·상태코드), 시스템 이벤트(메모리 경고·백그라운드 전환·배터리 상태). 자동 수집은 편하지만 두 가지를 반드시 손봐야 한다.

또 하나 iOS 고유의 함정은 breadcrumb이 메모리에만 있다는 점이다. 앱이 SIGKILL로 즉사하면(Jetsam·워치독) 메모리가 통째로 사라지므로 breadcrumb도 함께 사라진다. 크래시 리포터가 시그널을 잡아 디스크에 flush할 수 있는 경우에만 살아남는다. 즉 breadcrumb은 잡히는 크래시에는 강하고 잡히지 않는 종료에는 무력하다 — 이 비대칭이 19장의 주제로 이어진다.

실험 · 도구

링 버퍼의 제로섬 성질은 40줄이면 재현된다.

import Foundation

struct Crumb {
    let at: Date
    let category: String
    let message: String
}

/// 고정 크기 링 버퍼 — breadcrumb의 실제 자료구조
final class CrumbTrail {
    private var storage: [Crumb] = []
    private let limit: Int

    init(limit: Int) { self.limit = limit }

    func add(_ category: String, _ message: String) {
        storage.append(Crumb(at: Date(), category: category, message: message))
        if storage.count > limit {
            storage.removeFirst(storage.count - limit)   // 가장 오래된 것부터 밀려난다
        }
    }

    var snapshot: [Crumb] { storage }
}

// ── 시나리오: 결정적 단서(로그인)를 남기고, 스크롤이 쏟아진다 ──
let trail = CrumbTrail(limit: 8)

trail.add("auth", "로그인 성공 method=sso")     // ← 나중에 꼭 필요할 단서
trail.add("nav",  "장바구니 진입")

for i in 1...20 {                                // 무한 스크롤 자동 수집
    trail.add("ui", "cell willDisplay row=\(i)")
}

trail.add("http", "POST /orders → 500")          // 사고 직전 결정적 신호

print("버퍼에 남은 것:")
trail.snapshot.forEach { print("  [\($0.category)] \($0.message)") }
// [ui] cell willDisplay row=15 ... row=20
// [http] POST /orders → 500
//
// → "로그인 성공"과 "장바구니 진입"은 이미 사라졌다.
//   버퍼가 8칸인데 스크롤 이벤트 20개가 전부 밀어냈기 때문이다.

해결은 버퍼를 키우는 게 아니라 들어오는 것을 거르는 것이다. 위 코드에서 "ui" 카테고리를 필터링하거나, 같은 카테고리의 연속 항목을 하나로 접으면(cell willDisplay ×20) 8칸 안에 사고 재구성에 필요한 것이 전부 남는다.

프로젝트 적용

breadcrumb 정책은 "이 사고를 처음 보는 동료에게 이 목록만 보여줬을 때 무슨 일이 있었는지 설명되는가"를 기준으로 잡는다. 실무 체크리스트는 넷이다.

덧붙여 breadcrumb과 로그를 같은 코드에서 이중으로 남기지 않는다. 둘 다 쓰면 유지보수 지점이 둘이 되고 문구가 어긋나기 시작한다. 실무에서는 로그를 남기는 얇은 함수 하나가 필요한 경우에만 breadcrumb도 함께 추가하도록 감싸는 편이 낫다.

⚠️ 흔한 오해

"breadcrumb 상한을 크게 잡으면 해결된다"는 대체로 틀렸다. 상한을 200으로 올려도 초당 100개를 찍으면 여전히 2초치일 뿐이고, 대신 이벤트마다 전송량이 커져 비용과 배터리를 먹는다. 문제는 용량이 아니라 신호 대 잡음비다. 또 "breadcrumb은 크래시가 나면 항상 함께 온다"도 틀렸다 — 메모리에만 있으므로 SIGKILL 계열 강제 종료에서는 통째로 사라진다. "자동 수집을 켜두면 알아서 유용한 게 쌓인다"도 위험한 가정이다. 자동 수집은 앱의 특성을 모르므로 고빈도 이벤트를 그대로 넣고, 그 결과 정작 필요한 도메인 사건이 밀려난다.

🧒 쉽게 이해하기

동화 헨젤과 그레텔에서 빵조각을 뿌려 길을 표시한 그 breadcrumb이 맞다. 이름 그대로 왔던 길을 되짚기 위한 표시다.

그런데 조건이 하나 있다. 빵조각을 8개만 가지고 있다고 해보자. 갈림길마다 하나씩 놓으면 8개의 갈림길을 기억할 수 있다. 그런데 걸음마다 하나씩 놓으면? 여덟 걸음 뒤에는 첫 갈림길 표시가 이미 없다. 빵을 더 많이 놓았는데 길은 오히려 못 찾는다.

로그는 이것과 다르다. 로그는 공책에 계속 적는 것이라 많이 적으면 공책이 두꺼워질 뿐이다. 반면 빵조각은 개수가 정해져 있어서, 하나를 더 놓으려면 앞에 놓은 걸 주워와야 한다. 그래서 "어디에 놓을 가치가 있는가"를 매번 골라야 한다.

그리고 한 가지 더 — 빵조각은 주머니에 있다. 갑자기 넘어져서 주머니째 잃어버리면(강제 종료) 표시고 뭐고 다 사라진다.

꼬리 질문

breadcrumb을 디스크에 쓰면 SIGKILL에도 살아남을 텐데, 왜 대부분 메모리에만 두는가?

비용과 위험이 둘 다 크기 때문이다. 첫째, 쓰기 빈도가 너무 높다. breadcrumb은 사용자 동작마다 쌓이므로 초당 여러 번 발생할 수 있고, 매번 디스크에 동기 쓰기를 하면 그 자체로 메인스레드 지연과 배터리 소모를 만든다. 관측 도구가 관측 대상을 망가뜨리는 전형적 상황이다.

둘째, 쓰기 도중 종료되면 파일이 깨진다. 원자적 쓰기로 매번 임시 파일을 만들어 교체하면 안전하지만 비용이 더 커지고, 그냥 append하면 부분 기록이 남아 다음 실행에서 파싱에 실패할 수 있다. (P0 10장의 원자적 쓰기 논의가 그대로 적용된다.)

셋째, 얻는 것에 비해 잃는 게 크다. SIGKILL 계열 종료(Jetsam·워치독)는 breadcrumb의 상세 경로보다 메모리 사용량 추이나 메인스레드 상태가 훨씬 결정적인 단서다. 그래서 실무 해법은 breadcrumb을 디스크로 옮기는 것이 아니라, 그런 종료를 별도 메커니즘(다음 실행 시 휴리스틱 추정·MetricKit)으로 감지하는 쪽이다 — 이것이 19장·20장의 내용이다.

절충안으로 주기적·저빈도 스냅샷(예: 백그라운드 전환 시점에만 flush)을 쓰기도 한다.

쉽게 말하면 빵조각을 놓을 때마다 사진을 찍어 클라우드에 올리면 잃어버려도 안전하다. 그런데 걸음마다 사진을 찍으면 걷는 속도가 절반이 되고 배터리가 금방 닳는다. 그래서 보통은 그냥 주머니에 넣고 다니고, 대신 넘어졌다는 사실 자체를 알아내는 다른 방법을 마련한다.
연속 중복을 접을 때 "같은 항목"의 기준을 어떻게 잡아야 하는가?

기준은 가변 부분을 제외한 나머지가 같은가다. cell willDisplay row=15row=16row 값만 다르므로 같은 항목으로 접어야 하고, 접힌 결과에는 개수와 마지막 값(또는 범위)을 남긴다 — cell willDisplay row=1…20 (×20)처럼.

실무에서는 breadcrumb을 만들 때 템플릿과 파라미터를 분리해두면 이 판단이 자동화된다. 메시지를 이미 조립된 문자열로 넘기면 접기 위해 문자열을 다시 파싱해야 하지만, (template: "cell willDisplay", params: ["row": 15]) 형태로 넘기면 템플릿 비교만으로 접힌다. 이건 Q6의 그룹핑 문제와 정확히 같은 구조다 — 가변 부분이 섞인 문자열은 어디서든 문제를 만든다.

주의할 점은 연속일 때만 접어야 한다는 것이다. A A A B A A를 "A ×5, B ×1"로 접으면 순서 정보가 파괴되어 사고 재구성이 오히려 어려워진다. "A ×3, B, A ×2"가 맞다.

쉽게 말하면 일기에 "밥 먹음, 밥 먹음, 밥 먹음"이라고 세 줄 쓰는 대신 "밥 3번 먹음" 한 줄로 줄이는 것이다. 다만 "밥, 밥, 산책, 밥"을 "밥 3번, 산책 1번"으로 합치면 산책을 언제 했는지가 사라진다. 순서가 중요하니까 붙어 있는 것만 합쳐야 한다.
사용자 인터랙션 breadcrumb에서 "무엇을 눌렀는지"를 남기는 것과 프라이버시는 어떻게 충돌하는가?

버튼을 식별하려면 이름이 필요한데, iOS에서 가장 손쉬운 이름이 화면에 보이는 텍스트라는 데서 충돌이 시작된다. 자동 수집이 접근성 레이블이나 버튼 타이틀을 그대로 쓰면, "김철수님 프로필" 같은 사용자 데이터가 breadcrumb에 들어간다. 검색·자동완성 필드라면 더 심각해서 사용자가 입력한 내용이 통째로 흘러갈 수 있다.

해법은 표시 텍스트가 아니라 안정적 식별자를 쓰는 것이다. 접근성 식별자(accessibilityIdentifier)나 뷰의 논리적 이름을 지정해두고 그것만 남기면, 사람이 읽을 수 있으면서 사용자 데이터와 무관한 이름이 된다. 부수 효과로 UI 테스트에서 쓰는 식별자와 통일되어 관리 지점이 줄어든다.

또 하나는 입력 필드는 값이 아니라 사건만 남기는 것이다. "검색어 입력: 강남 맛집"이 아니라 "검색 필드 편집 시작 / 검색 실행 (길이=5)"처럼. 길이나 유무 같은 메타데이터만으로도 대부분의 재구성이 가능하다.

쉽게 말하면 방문 기록을 남길 때 "김철수 씨 집에 갔음"이라고 쓰면 남의 정보가 들어간다. "3번 고객 자택 방문"이라고 쓰면 나중에 내가 알아볼 수 있으면서 남의 이름은 안 남는다. 검색창도 마찬가지다. 뭘 검색했는지까지 적을 필요 없이 "검색을 했다"만 적어도 대부분의 상황은 설명된다.

Q5. 태그·컨텍스트·스코프 중 무엇으로 검색하는가?

🔑 30초 답변

셋은 이름만 비슷할 뿐 역할이 완전히 다르다. 태그는 색인되는 key-value라 검색·필터·집계의 축이다 — 대신 카디널리티가 낮아야 한다. 컨텍스트는 이벤트에 붙는 구조화된 임의 데이터로 검색되지 않고 읽히기만 한다 — 크기 제약이 느슨해 상세 정보를 담는다. 스코프는 앞의 둘을 담아두는 상태 컨테이너다 — "지금부터 발생하는 이벤트에 이것들을 붙여라"를 관리한다. 한 줄로: 검색하고 싶으면 태그, 읽고 싶으면 컨텍스트, 범위를 정하고 싶으면 스코프.

CS 원리

이 구분은 데이터베이스 인덱스의 트레이드오프를 그대로 옮겨온 것이다. 인덱스를 만들면 검색이 빨라지지만 쓰기 비용과 저장 공간이 늘고, 카디널리티가 지나치게 높으면 인덱스의 이점이 사라진다(모든 행이 고유하면 인덱스가 테이블만큼 커진다). 관측 백엔드도 같은 제약 아래 있으므로 "모든 필드를 색인할 수는 없다"는 결론이 나오고, 그래서 색인 대상(태그)과 비색인 대상(컨텍스트)을 개발자가 나누게 한다.

이름은 비슷하지만 역할이 완전히 다르다 태그 색인됨 → 검색 축 screen=checkout network=cellular 카디널리티 낮아야 함 컨텍스트 비색인 → 읽기 전용 order: { id, items, total } 중첩 객체 가능 · 크기 제약 느슨 검색·집계 불가 스코프 앞의 둘을 담는 컨테이너 "지금부터 발생하는 이벤트에 이것들을 붙여라" 횡단 관심사 전파 판단 질문 하나 — "이 값으로 필터·집계를 하고 싶은가?" 예 → 태그 (유한·열거 가능) 화면·OS·기기·앱버전·네트워크 아니오 → 컨텍스트 주문번호·사용자ID·전체URL·에러원문 잘못 붙은 태그는 없는 태그보다 나쁘다 — 틀린 결론에 확신을 갖게 하기 때문이다.
인덱스의 트레이드오프를 그대로 옮긴 것이다 — 모든 필드를 색인할 수는 없다.

스코프는 다른 종류의 문제를 푼다 — 횡단 관심사의 전파다. 이벤트가 발생하는 지점(에러가 throw된 곳)은 대개 맥락 정보를 갖고 있지 않다. 네트워크 계층의 디코딩 실패 지점이 "지금 사용자가 결제 화면에 있다"는 사실을 알 리 없다. 이 정보를 함수 인자로 계속 넘기면 모든 시그니처가 오염된다. 스코프는 이를 앰비언트 상태로 두어 이벤트 생성 시점에 자동으로 합쳐지게 한다. 개념적으로는 태스크 로컬 값이나 스레드 로컬 저장소와 같은 계열의 해법이다.

태그컨텍스트스코프
정체색인되는 key-value비색인 구조화 데이터상태 컨테이너
검색가능불가
집계·필터가능불가
카디널리티낮아야 함제약 없음
크기짧은 문자열중첩 객체 가능
예시screen=checkout주문 상세 JSON"결제 중" 범위

iOS에서는

iOS 앱에서 태그로 삼을 만한 것과 삼으면 안 되는 것은 꽤 명확하게 갈린다.

iOS 고유의 주의점은 스코프의 생명주기가 앱 생명주기와 어긋나기 쉽다는 것이다. 화면 진입 시 screen=checkout을 스코프에 넣고 이탈 시 지우는 패턴은, 뒤로 가기가 아니라 앱이 백그라운드로 갔다가 돌아오거나 딥링크로 다른 화면이 밀고 들어오면 지우는 코드가 실행되지 않는다. 그 결과 한참 뒤 다른 화면에서 난 에러에 screen=checkout이 붙어 있는 오염이 생긴다. 그래서 스코프는 진입·이탈 쌍으로 관리하기보다 진입 시 덮어쓰기로 두는 편이 안전하다.

또 하나, 스코프가 전역 상태라는 점이 동시성과 충돌한다. 여러 태스크가 동시에 스코프를 건드리면 어느 이벤트에 어떤 값이 붙을지 결정적이지 않다. 그래서 백그라운드 작업의 맥락은 전역 스코프가 아니라 그 작업 범위에만 적용되는 스코프로 격리해야 한다.

실험 · 도구

태그와 컨텍스트를 잘못 나누면 어떤 질문이 막히는지 직접 비교해보자.

import Foundation

struct Event {
    let title: String
    let tags: [String: String]        // 색인됨 — 검색·집계 가능
    let contexts: [String: Any]       // 비색인 — 읽기 전용
}

// ── (A) 잘못된 분류: 검색해야 할 것을 컨텍스트에, 못 쓸 것을 태그에 ──
let bad = Event(
    title: "결제 실패",
    tags: [
        "order_id": "ORD-2026-0814-99213",           // ❌ 카디널리티 무한
        "url": "https://api.shop.com/orders?token=eyJhbG…", // ❌ 무한 + 토큰 유출
    ],
    contexts: [
        "screen": "checkout",                         // ❌ 검색해야 하는데 색인 안 됨
        "network": "cellular",                        // ❌ 위와 같음
    ]
)

// ── (B) 올바른 분류 ──
let good = Event(
    title: "결제 실패",
    tags: [
        "screen": "checkout",        // ✅ 유한 — "결제 화면 에러만 보기"가 된다
        "network": "cellular",       // ✅ 유한 — "셀룰러에서만 나나?" 확인 가능
        "http_status": "500",        // ✅ 유한
        "app_version": "4.12.0",     // ✅ 유한 — 릴리스별 회귀 추적
    ],
    contexts: [
        "order": [                   // ✅ 상세는 읽히기만 하면 된다
            "id": "ORD-2026-0814-99213",
            "item_count": 3,
            "total": 48_000,
        ],
        "request": [
            "path": "/orders",       // ✅ 정규화된 경로. 쿼리·토큰 제거
            "method": "POST",
        ],
    ]
)

// (A)로는 "셀룰러에서 결제 화면 에러가 늘었나?"에 답할 수 없다.
// order_id 태그는 이벤트마다 고유해서 집계 축으로 아무 의미가 없고,
// 오히려 백엔드의 태그 인덱스를 터뜨린다.

판단이 애매할 때 쓰는 질문은 하나다. "이 값으로 '~인 것만 보여줘' 또는 '~별로 개수 세줘'를 하고 싶은가?" 그렇다면 태그, 아니면 컨텍스트다. 주문 번호는 "이 주문만 보여줘"가 아니라 이미 찾은 이벤트를 읽을 때 필요한 값이므로 컨텍스트다.

프로젝트 적용

태그 스키마는 코드로 고정해두는 것이 유지보수에 결정적이다. 문자열 리터럴로 흩어놓으면 screenscreen_name이 공존하고, 오타 하나가 새로운 축을 만들어 조용히 데이터를 쪼갠다.

// 태그 키와 허용 값을 타입으로 못 박는다 — 오타와 카디널리티 폭발을 둘 다 막는다
enum ObservabilityTag {
    case screen(Screen)             // Screen은 열거형이라 값이 유한함이 보장된다
    case network(NetworkKind)
    case httpStatus(Int)
    case loggedIn(Bool)

    var pair: (key: String, value: String) {
        switch self {
        case .screen(let s):     return ("screen", s.rawValue)
        case .network(let n):    return ("network", n.rawValue)
        case .httpStatus(let c): return ("http_status", String(c))
        case .loggedIn(let b):   return ("logged_in", b ? "true" : "false")
        }
    }
}

enum Screen: String { case home, search, cart, checkout, orderDetail, settings }
enum NetworkKind: String { case wifi, cellular, none }

스코프 사용 규칙은 셋이다.

⚠️ 흔한 오해

"태그를 많이 붙일수록 검색이 편해진다"는 틀렸다. 고카디널리티 태그는 검색을 느리게 만들고 비용을 키우며, 백엔드가 태그 수·길이에 상한을 두는 경우 넘친 태그가 조용히 잘린다. 또 "컨텍스트도 어차피 저장되니 나중에 검색하면 된다"는 오해다 — 색인이 없으므로 전체 스캔이 필요해 실질적으로 불가능하거나 매우 비싸다. "스코프에 넣으면 모든 이벤트에 안전하게 붙는다"도 위험하다 — 스코프는 전역 상태라 지우는 시점을 놓치면 무관한 이벤트를 오염시킨다. 잘못 붙은 태그는 없는 태그보다 나쁘다. 없으면 모른다는 걸 알지만, 틀린 값이 붙으면 틀린 결론에 확신을 갖게 되기 때문이다.

🧒 쉽게 이해하기

도서관을 떠올려보자.

태그는 책등에 붙은 분류 스티커다. "소설", "역사", "3층". 이걸로 "3층 소설만 보여줘"가 된다. 대신 스티커는 짧고 종류가 적어야 한다. 책마다 다른 스티커를 붙이면 분류가 아니라 그냥 이름표고, 스티커로 찾는 의미가 사라진다.

컨텍스트는 책 안의 내용이다. 아무리 자세해도 상관없다. 다만 "이 문장이 들어간 책 찾아줘"는 안 된다 — 모든 책을 다 펼쳐봐야 하니까.

스코프지금 내가 서 있는 서가다. 여기서 꺼내는 책에는 자동으로 "3층 소설" 스티커가 붙는다. 편리한데 함정이 있다 — 4층으로 올라갔는데 팻말을 안 바꿨으면, 4층 책에도 "3층" 스티커가 붙어버린다. 그러면 나중에 "3층 책 찾아줘"로 검색한 결과가 틀렸는데도 맞아 보인다. 스티커가 없는 것보다 이쪽이 더 위험하다.

꼬리 질문

사용자 ID를 태그로 쓰면 안 된다면, "특정 사용자의 이벤트만 보기"는 어떻게 하는가?

대부분의 관측 백엔드는 사용자 식별을 태그와 별개의 전용 필드로 제공한다. 전용 필드는 태그 인덱스와 다른 구조(사용자 단위 파티셔닝이나 별도 인덱스)로 저장되므로 카디널리티가 높아도 견딘다. 그래서 "사용자별 조회"는 지원되지만 "사용자별 집계 축"으로는 못 쓰는 비대칭이 생긴다.

설계 관점에서 더 중요한 것은 사용자 ID로 조회할 일이 실제로 얼마나 있는가다. 대개는 고객 문의 대응처럼 개별 사례를 파는 상황이고, 이때는 이미 사용자를 특정한 상태라 "필터"보다 "조회"에 가깝다. 반면 "어떤 사용자군에서 문제가 많은가"는 사용자 ID가 아니라 사용자 속성(신규/기존, 유료/무료, 지역)으로 답해야 하고, 이 속성들은 카디널리티가 낮아 태그로 적합하다.

즉 규칙은 "개인을 특정하는 값은 식별자 필드, 개인을 분류하는 값은 태그"다. 프라이버시 관점에서도 이 분리가 낫다 — 식별자는 접근 통제와 삭제 요청의 대상이지만 태그는 집계에 섞이므로 개인을 지우기 어렵다.

쉽게 말하면 병원에서 "환자 김OO의 차트 보여줘"는 이름으로 찾는다. 그런데 "어떤 환자들이 이 병에 많이 걸리나"를 이름으로 세면 아무 의미가 없다 — 이름마다 1명씩이니까. 그건 "40대", "흡연자" 같은 분류로 세야 한다.
스코프가 전역 상태라는 점이 Swift Concurrency와 충돌하는 지점은 어디인가?

충돌 지점은 "지금"의 정의다. 전역 스코프는 "현재 시점의 상태"를 가정하는데, 동시성 환경에서는 여러 작업이 겹쳐 돌기 때문에 "현재"가 하나가 아니다. 태스크 A가 결제 스코프를 설정하고 await로 정지한 사이 태스크 B가 스코프를 설정 화면으로 바꾸면, A가 재개되어 던지는 에러에는 B의 맥락이 붙는다.

게다가 await 지점을 넘으면 실행 스레드가 바뀔 수 있으므로 스레드 로컬 저장소 기반 구현은 아예 동작하지 않는다. 정지 전과 재개 후가 다른 스레드일 수 있기 때문이다.

Swift에서 이 문제를 정면으로 푸는 도구가 @TaskLocal이다. 태스크 로컬 값은 withValue 블록 범위에서만 유효하고 자식 태스크로 상속되며, 스레드가 아니라 태스크에 묶이므로 await를 넘어도 정확히 따라간다. 관측 맥락을 태스크 로컬로 두면 동시 실행 중인 작업들이 서로의 맥락을 오염시키지 않는다.

실무적으로는 "세션 수준 정보는 전역 스코프, 작업 수준 맥락은 태스크 로컬"로 나누는 것이 안전하다.

쉽게 말하면 사무실 화이트보드에 "지금 하는 일: 결제"라고 적어두는 게 전역 스코프다. 혼자 일할 땐 잘 맞는다. 그런데 세 사람이 각자 다른 일을 하면서 같은 화이트보드를 고쳐 쓰면, 누가 무슨 일을 하다 사고를 냈는지 아무도 모르게 된다. 각자 자기 손에 든 작업 지시서를 보게 하는 게 태스크 로컬이다.
태그 값에 상한(개수·길이)이 있다는 사실이 설계에 주는 함의는?

가장 큰 함의는 초과분이 조용히 사라진다는 것이다. 대부분의 백엔드는 태그가 상한을 넘으면 에러를 던지지 않고 잘라내거나 버린다. 클라이언트에서는 아무 문제 없어 보이는데 대시보드에서만 일부 태그가 비어 있는 상황이 생기고, 원인을 찾기가 매우 어렵다.

그래서 두 가지 방어가 필요하다. 첫째, 태그 집합을 코드로 고정해 개수를 예측 가능하게 만든다(위의 열거형 방식). 동적으로 태그를 추가하는 코드가 여기저기 있으면 상한 초과가 우발적으로 발생한다. 둘째, 값 길이를 넣기 전에 자른다. 백엔드가 자르게 두면 어디서 잘렸는지 모르지만, 우리가 자르면 표시나 해시 접미사를 붙여 잘렸다는 사실을 남길 수 있다.

더 근본적으로는 상한이 있다는 사실 자체가 "태그는 축이지 데이터가 아니다"라는 설계 의도를 드러낸다. 상한에 부딪히고 있다면 대개 태그로 쓰면 안 되는 것을 태그로 쓰고 있다는 신호다.

쉽게 말하면 택배 송장에 적을 수 있는 칸이 정해져 있는 것과 같다. 칸을 넘겨 적으면 인쇄될 때 그냥 잘려 나가고, 아무도 "잘렸습니다"라고 알려주지 않는다. 그래서 미리 무엇을 적을지 정해두고, 길면 내가 줄여서 적어야 한다. 칸이 자꾸 모자란다면 송장에 적을 내용이 아닌 걸 적고 있다는 뜻이다.

Q6. 같은 버그가 여러 이슈로 쪼개지는 이유는?

🔑 30초 답변

관측 백엔드는 들어온 이벤트를 지문(fingerprint)으로 묶어 하나의 이슈로 합친다. 지문은 보통 스택트레이스의 인앱 프레임 + 예외 타입 + 메시지에서 계산되는데, 이 재료 중 하나라도 이벤트마다 달라지면 같은 버그가 수백 개 이슈로 쪼개진다. 대표 범인은 메시지에 섞인 가변값("주문 ORD-99213 실패")과, 인앱 프레임이 없어 시스템 프레임으로 묶이는 경우다. 반대 방향의 실패도 있다 — 서로 다른 버그가 공통 헬퍼 함수에서 던져져 한 이슈로 뭉치는 것. 해결은 지문 재료를 안정화하고, 필요하면 fingerprint를 직접 지정하는 것이다.

CS 원리

그룹핑은 동치 관계(equivalence relation)를 정의하는 문제다. 무한히 들어오는 이벤트를 유한한 이슈 집합으로 사상해야 하고, 이때 필요한 것은 "두 이벤트가 같은 원인인가"를 판정하는 함수다. 그런데 진짜 원인은 관측 불가능하므로, 실제로는 관측 가능한 특징에서 대리 키(surrogate key)를 뽑아 쓴다. 그게 지문이다.

대리 키를 쓰면 두 방향의 오류가 필연적으로 생긴다.

둘은 트레이드오프 관계다. 지문 재료를 늘리면 과병합이 줄지만 과분할이 늘고, 줄이면 반대가 된다. 그래서 "완벽한 그룹핑"은 존재하지 않고, 어느 쪽 오류가 덜 아픈지를 상황마다 고르게 된다. 실무에서는 과분할이 더 아프다 — 이슈 목록이 노이즈로 덮여 우선순위를 판단할 수 없고, 각 이슈의 발생 횟수가 잘게 쪼개져 "얼마나 심각한지"를 알 수 없기 때문이다.

지문 = f(스택 인앱 프레임, 예외 타입, 메시지) 과분할 "주문 ORD-99213 실패" "주문 ORD-99214 실패" "주문 ORD-99215 실패" 이슈 #1 (1건) 이슈 #2 (1건) 이슈 #3 (1건) 전부 1건씩 — 심각도 판단 불가 해결 "주문 실패" + id는 컨텍스트로 "주문 실패" + id는 컨텍스트로 "주문 실패" + id는 컨텍스트로 이슈 #1 (3건) 주문 id는 각 이벤트 안에 빈도가 보이므로 우선순위가 나온다
가변값을 메시지에서 빼 컨텍스트로 옮기기만 해도 그룹핑이 복구된다. 정보를 버리는 게 아니라 위치를 바꾸는 것이다.

iOS에서는

iOS 앱에서 그룹핑이 깨지는 원인은 몇 가지로 정형화된다.

특히 비동기 스택 절단은 P0 02장에서 다룬 "Task는 스레드가 아니다"의 실무적 귀결이다. 스레드 스택은 호출 관계를 연속적으로 보존하지만, 태스크는 정지·재개하며 스택이 끊어지므로 크래시 리포트에서 인과 사슬이 짧아진다.

실험 · 도구

지문 계산을 흉내내면 무엇이 그룹핑을 깨는지 즉시 보인다.

import Foundation
import CryptoKit

struct Report {
    let type: String
    let message: String
    let frames: [String]     // 인앱 프레임만 추린 것
}

func fingerprint(_ r: Report) -> String {
    let material = ([r.type, r.message] + r.frames).joined(separator: "|")
    let digest = SHA256.hash(data: Data(material.utf8))
    return digest.map { String(format: "%02x", $0) }.joined().prefix(8).description
}

let frames = ["CheckoutViewModel.submit()", "OrderRepository.post(_:)"]

// ── (A) 메시지에 주문 번호가 들어간 경우 ──
let bad = (99213...99215).map {
    Report(type: "OrderError", message: "주문 ORD-\($0) 실패", frames: frames)
}
print("A. 지문:", bad.map(fingerprint))
// → ["3f9c1a2b", "8e04d771", "c15b6e9a"]  ← 전부 다르다. 이슈 3개로 쪼개진다.

// ── (B) 가변값을 메시지에서 뺀 경우 ──
let good = (99213...99215).map { _ in
    Report(type: "OrderError", message: "주문 제출 실패", frames: frames)
}
print("B. 지문:", good.map(fingerprint))
// → ["a72e0f45", "a72e0f45", "a72e0f45"]  ← 동일. 이슈 1개, 발생 3건.

// ── (C) 과병합 사례: 공통 헬퍼에서 던지면 프레임이 같아진다 ──
let merged = [
    Report(type: "APIError", message: "요청 실패", frames: ["APIClient.send(_:)"]),
    Report(type: "APIError", message: "요청 실패", frames: ["APIClient.send(_:)"]),
]
print("C. 지문:", merged.map(fingerprint))
// → 동일한데, 실제로는 결제 API 장애와 프로필 API 장애가 섞여 있을 수 있다.
//   이럴 땐 fingerprint에 엔드포인트를 명시적으로 추가해 갈라줘야 한다.

(C)가 보여주는 것이 중요하다. 과분할은 눈에 잘 띄지만(이슈가 폭증하니까) 과병합은 조용하다. 이슈 하나가 "발생 5만 건"으로 보여서 심각해 보이는데, 실은 서로 무관한 세 가지 문제가 섞여 있어 어느 것부터 고쳐야 할지 알 수 없는 상태다.

프로젝트 적용

그룹핑을 건강하게 유지하는 규칙은 셋이다.

Swift에서 에러 타입을 설계할 때 이 원리를 미리 반영해두면 편하다.

// ❌ 메시지에 값이 섞여 지문이 흩어진다
struct OrderError: Error {
    let description: String   // "주문 ORD-99213 실패 (재고 0)"
}

// ✅ 분류는 타입으로, 값은 별도 필드로
enum OrderError: Error {
    case outOfStock          // 지문 재료가 되는 것은 이 case 이름
    case paymentDeclined
    case addressInvalid
    case serverUnavailable(status: Int)

    // 상세는 이벤트 컨텍스트로 따로 실어 보낸다
    var groupingKey: String {
        switch self {
        case .outOfStock:              return "order.out_of_stock"
        case .paymentDeclined:         return "order.payment_declined"
        case .addressInvalid:          return "order.address_invalid"
        case .serverUnavailable:       return "order.server_unavailable"
        }
    }
}
⚠️ 흔한 오해

"이슈가 많이 뜨는 건 버그가 많다는 뜻"은 틀릴 때가 많다. 이슈 수는 버그 수가 아니라 지문의 다양성을 반영하므로, 메시지에 UUID 하나만 섞여도 이슈 수가 발생 건수와 같아진다. 반대로 "이슈 하나에 5만 건이니 이게 제일 심각하다"도 위험하다 — 과병합된 이슈일 수 있고, 그 안에 서로 다른 원인이 섞여 있으면 하나를 고쳐도 건수가 거의 안 준다. 또 "fingerprint를 직접 지정하면 항상 낫다"도 아니다. 수동 지문은 우리가 원인을 이미 안다는 가정에 기대므로, 잘못 지정하면 새로운 원인의 버그가 기존 이슈에 흡수되어 영원히 안 보이게 된다.

🧒 쉽게 이해하기

고장 신고를 접수하는 상담센터를 떠올려보자. 하루에 500통이 오는데, 이걸 그대로 500개 사건으로 두면 아무것도 못 한다. 그래서 비슷한 신고를 묶는다.

묶는 기준을 "신고 내용 문장 그대로"로 잡으면 어떻게 될까. "3동 502호 물 안 나와요", "3동 503호 물 안 나와요"는 문장이 다르니 다른 사건이 된다. 결국 500개 사건이 그대로 남고, 정작 "3동 전체 단수"라는 진짜 문제는 안 보인다. 이게 과분할이다.

반대로 기준을 "불편 접수"처럼 뭉뚱그리면, 단수·정전·엘리베이터 고장이 전부 한 사건으로 묶인다. "불편 접수 500건"이라는 숫자는 커 보이지만 뭘 고쳐야 할지 알 수 없다. 이게 과병합이다.

답은 호수를 문장에서 빼는 것이다. "물 안 나옴"으로 묶고, 호수는 사건 안에 목록으로 적어둔다. 그러면 "물 안 나옴 — 47건, 전부 3동"이 한눈에 보인다. 정보를 버린 게 아니라 자리를 옮긴 것뿐인데 결과가 완전히 달라진다.

꼬리 질문

같은 코드인데 릴리스가 바뀔 때마다 이슈가 새로 생긴다면 무엇을 의심해야 하는가?

1순위는 심볼화 실패다. dSYM이 없으면 스택 프레임이 함수명 대신 0x1042f8a1c 같은 주소로 남고, 주소는 빌드마다 달라지므로 지문이 릴리스마다 바뀐다. CI에서 dSYM 업로드가 특정 조건(예: 특정 브랜치, 재빌드 시)에서 빠지고 있는지 확인해야 한다.

2순위는 인라이닝·최적화 변화다. 컴파일러 최적화로 함수가 인라인되면 그 프레임이 스택에서 사라진다. Swift 버전이나 최적화 설정이 바뀌면 인라이닝 결정이 달라져 인앱 프레임 구성이 통째로 변할 수 있다. 이건 심볼화가 정상이어도 발생한다.

3순위는 버전 문자열이 지문 재료에 들어간 경우다. 에러 메시지에 앱 버전이나 빌드 번호를 넣는 코드가 있으면 당연히 릴리스마다 갈라진다.

확인 방법은 단순하다 — 같은 버그로 보이는 두 릴리스의 이슈를 열어 스택트레이스를 나란히 비교하면 어느 재료가 달라졌는지 바로 보인다. 함수명이 주소로 나와 있으면 1번, 프레임 개수가 다르면 2번, 메시지가 다르면 3번이다.

쉽게 말하면 사건을 "발생 위치"로 묶는데, 주소 대신 GPS 좌표를 쓰는 상황이다. 같은 건물인데 측정할 때마다 좌표가 조금씩 달라지면 매번 다른 장소로 기록된다. 좌표를 주소로 바꿔주는 변환표(dSYM)가 없으면 이 일이 계속 벌어진다.
과병합된 이슈를 나중에 쪼갤 수 있는가?

부분적으로만 가능하고, 대개 과거 데이터는 못 살린다. 대부분의 백엔드는 fingerprint 규칙을 바꾸면 그 이후 들어오는 이벤트부터 새 규칙으로 묶는다. 이미 하나로 합쳐진 과거 이벤트를 소급해 재분류하는 기능은 없거나 제한적이다.

그리고 더 근본적인 제약이 있다 — 쪼갤 재료가 이벤트에 남아 있어야 한다. 엔드포인트별로 나누고 싶은데 엔드포인트를 애초에 이벤트에 안 실었다면, 규칙을 바꿔도 나눌 수 없다. 그래서 "나중에 필요하면 쪼개지" 하고 미루면, 정작 필요해진 시점에 재료가 없다.

실무적 대응은 두 가지다. 첫째, 구분 가능한 재료를 미리 이벤트에 심어둔다 — 지문에 안 쓰더라도 태그로 붙여두면 최소한 "이 이슈 안에서 엔드포인트별로 필터"는 가능하다. 둘째, 공통 진입점에서는 처음부터 지문을 갈라둔다. 과병합은 조용해서 문제를 늦게 발견하므로, 합쳐질 위험이 뻔한 지점(공통 클라이언트·공통 에러 래퍼)은 선제적으로 나누는 편이 낫다.

쉽게 말하면 서류를 한 상자에 다 쓸어담고 나중에 분류하려면, 최소한 서류마다 날짜와 종류가 적혀 있어야 한다. 아무것도 안 적힌 종이를 한 상자에 넣어두면 나중에 아무리 정리하고 싶어도 나눌 기준이 없다.
Swift의 async/await 스택 절단이 그룹핑에 주는 영향을 완화하려면?

세 가지 접근이 있다. 첫째, 에러에 출처를 심는다. 에러를 던지는 시점에 #function·#fileID·#line을 캡처해 에러 값 안에 넣어두면, 스택이 잘려도 "어디서 시작된 실패인가"가 값으로 따라온다. Swift는 이 정보를 기본 인자로 자동 캡처할 수 있어 호출부를 더럽히지 않는다.

struct TracedError: Error {
    let underlying: Error
    let origin: String
    init(_ underlying: Error,
         function: String = #function, file: String = #fileID, line: Int = #line) {
        self.underlying = underlying
        self.origin = "\(file):\(line) \(function)"
    }
}

둘째, 경계에서 에러를 감싸 분류를 부여한다. 각 기능 모듈이 자기 에러 타입으로 래핑하면, 스택이 짧아도 에러 타입 자체가 그룹핑 축이 된다.

셋째, 태스크 로컬로 작업 맥락을 전파한다. @TaskLocalawait를 넘어 자식 태스크까지 상속되므로, 스택이 끊긴 자리를 "어떤 사용자 작업의 일부였는가"라는 논리적 맥락이 메운다. 물리적 호출 스택 대신 논리적 인과 사슬을 우리가 직접 유지하는 셈이다.

쉽게 말하면 택배가 여러 창고를 거치다 보면 "누가 처음 보냈는지"가 송장에서 지워질 수 있다. 그래서 물건 안에 보낸 사람 쪽지를 넣어둔다. 경로 기록이 끊겨도 쪽지는 물건과 함께 끝까지 간다.

Q7. 관측 SDK는 앱에 어떤 비용을 부과하는가?

🔑 30초 답변

비용은 여섯 갈래로 나뉜다 — 시작 시간(초기화·스위즐링), 메인스레드 시간(훅이 도는 지점), 메모리(breadcrumb 버퍼·큐), 디스크(오프라인 큐·크래시 리포트), 네트워크·배터리(전송), 바이너리 크기. 이 중 사용자가 가장 먼저 체감하는 건 시작 시간이다. 관측 SDK는 크래시를 놓치지 않으려고 앱 시작 아주 이른 시점에 초기화되는데, 바로 그 지점이 앱 실행 시간에 그대로 얹히는 자리이기 때문이다. 그래서 관측 도입의 첫 번째 검증 항목은 "정확한가"가 아니라 "콜드 스타트가 얼마나 늘었나"여야 한다.

CS 원리

모든 계측은 관측자 효과를 갖는다 — 관측 행위 자체가 관측 대상을 바꾼다. 소프트웨어에서 이는 은유가 아니라 문자 그대로다. 계측 코드는 같은 CPU, 같은 메모리, 같은 I/O 대역폭을 대상 코드와 나눠 쓴다.

비용은 발생 위치에 따라 성격이 다르다.

비용언제 발생사용자 체감측정 방법
초기화앱 시작 1회매우 큼 (실행 지연)콜드 스타트 A/B
훅·스위즐링계측 지점마다중간 (누적 시 히치)Time Profiler
메모리상시낮음, 단 Jetsam 위험footprint 비교
디스크이벤트 발생 시낮음컨테이너 용량
네트워크·배터리전송 시중간 (요금·발열)Energy Log
바이너리 크기다운로드·설치중간 (설치 이탈)App Size Report
같은 총량이라도 어느 구간에 놓이느냐가 체감을 결정한다 사용자 체감 민감도 앱 시작 매우 높음 — 열 때마다 사용 중 중간 — 누적되면 히치 백그라운드 낮음 — 미룰 수 있다 SDK 초기화 · 스위즐링 핸들러 설치 · 바이너리 크기 계측 훅 · breadcrumb 메모리 상주 (→ Jetsam 위험) 이벤트 전송 네트워크 · 배터리 → 최적화 순서는 "총량이 큰 것"이 아니라 "왼쪽에 있는 것"부터 그런데 초기화는 늦출수록 손해다 — 정면 트레이드오프 프로세스 시작 첫 프레임 일찍 초기화 크래시 많이 잡음 시작이 느려짐 늦게 초기화 시작이 빠름 그 전 크래시는 못 잡음 ← 우회로가 없다 →
관측 SDK는 가장 민감한 구간에서 초기화될 수밖에 없다. 그래서 도입 검증의 첫 항목이 정확도가 아니라 콜드 스타트다.

핵심 원리는 비용이 균등하지 않다는 것이다. 초기화 비용은 사용자가 앱을 열 때마다 지불되고 그 순간은 사용자가 가장 민감한 구간이다. 반면 전송 비용은 백그라운드로 미룰 수 있어 체감이 훨씬 적다. 그래서 최적화 순서는 "총 비용이 큰 것"이 아니라 "체감 구간에 있는 것"부터다.

iOS에서는

iOS에서 특히 중요한 세 지점이 있다.

(1) 초기화 시점의 딜레마. 크래시 리포터는 시그널·예외 핸들러를 설치해야 하는데, 설치 이전에 발생한 크래시는 잡히지 않는다. 그래서 SDK는 application(_:didFinishLaunchingWithOptions:) 최상단이나 그보다 이른 시점에 초기화되기를 요구한다. 그런데 그 자리가 정확히 콜드 스타트 예산을 먹는 자리다. "빨리 초기화할수록 더 많이 잡지만 더 느려진다"는 정면 트레이드오프이고, 우회로가 없다.

(2) 메서드 스위즐링의 비용과 위험. 자동 계측은 대개 URLSession·뷰 컨트롤러 생명주기·UIApplication 메서드를 스위즐링해서 구현된다. 런타임에 메서드 구현을 교체하는 것이라 두 가지가 따라온다 — 초기화 시점에 클래스를 훑는 비용이 들고, 이후 모든 호출에 한 겹이 얹힌다. 게다가 다른 SDK도 같은 메서드를 스위즐링하면 순서에 따라 동작이 달라지는 재현 난이도 높은 버그가 생긴다.

(3) 메모리와 Jetsam. breadcrumb 버퍼, 전송 대기 큐, 세션 리플레이 버퍼는 전부 메모리를 상주시킨다. P0 01장에서 본 대로 iOS는 앱별 메모리 상한을 넘으면 시스템에 여유가 있어도 그 앱만 죽인다. 관측 SDK가 수십 MB를 상주시키면 관측 도구가 Jetsam 종료를 유발하는, 가장 역설적인 실패가 가능하다.

참고로 벤더가 공개하는 오버헤드 수치(예: 트레이싱 3% 미만, 프로파일링 5% 미만)는 특정 조건에서 측정한 값이다. 계측 지점 수, 샘플링 비율, 기기 성능에 따라 크게 달라지므로 자기 앱에서 직접 재는 것 말고 대안이 없다.

실험 · 도구

SDK가 실행 시간에 얼마를 더하는지는 직접 재야 한다. 가장 정직한 방법은 초기화를 켜고 끈 두 빌드의 콜드 스타트를 비교하는 것이다.

import os
import OSLog

// 프로세스 시작부터 첫 프레임까지를 signpost로 감싼다
let launchSignposter = OSSignposter(subsystem: "com.example.shop", category: "launch")

@main
struct ShopApp: App {
    init() {
        let state = launchSignposter.beginInterval("cold-launch",
                                                   id: launchSignposter.makeSignpostID())
        LaunchTrace.state = state

        // 컴파일 플래그로 관측 SDK 초기화를 켜고 끈다 → A/B 비교용 두 빌드
        #if OBSERVABILITY_ENABLED
        let t0 = ContinuousClock.now
        ObservabilitySDK.start(dsn: Secrets.dsn)
        let elapsed = ContinuousClock.now - t0
        // ⚠️ Duration.components 는 (seconds, attoseconds) 다. 둘 다 더해야 하고,
        //    1 ms = 1e15 attoseconds 이므로 attoseconds 는 1e15 로 나눈다.
        let ms = Double(elapsed.components.seconds) * 1000
               + Double(elapsed.components.attoseconds) / 1e15
        Logger(subsystem: "com.example.shop", category: "launch")
            .notice("SDK 초기화 \(ms, privacy: .public) ms")
        #endif
    }
    var body: some Scene { WindowGroup { RootView() } }
}

더 신뢰할 수 있는 건 OS가 재는 값이다. 두 가지를 함께 본다.

# 1) Instruments의 App Launch 템플릿으로 콜드 스타트 구간을 직접 프로파일
#    → pre-main(dyld) 구간과 post-main 구간이 나뉘어 나온다.
#      동적 프레임워크가 늘면 pre-main이, 초기화 코드가 늘면 post-main이 커진다.

# 2) MetricKit의 실사용자 실행 시간 히스토그램
#    MXAppLaunchMetric.histogrammedTimeToFirstDraw 를 릴리스별로 비교한다.
#    실험실 측정과 달리 저사양 기기·저온 캐시 상황이 섞여 들어온다.

주의할 함정 하나 — 시뮬레이터나 최신 기기에서만 재면 안 된다. 관측 SDK의 초기화 비용은 CPU가 느린 구형 기기에서 배로 늘어나고, 그 기기 사용자가 정확히 이탈에 민감한 사용자다. Apple도 성능 문제를 볼 때 기기 모델별로 필터링하라고 명시한다.

프로젝트 적용

관측 비용을 예산으로 관리하는 실무 방식은 이렇다.

그리고 판단 기준 하나를 팀에 공유해두면 좋다 — "이 계측이 없어서 못 고친 버그가 실제로 있었는가?" 관측 설정은 한번 켜면 끄기 어려운데(끄면 불안하니까), 이 질문을 주기적으로 던지지 않으면 아무도 안 보는 데이터를 계속 수집하며 비용만 낸다.

⚠️ 흔한 오해

"관측 SDK는 백그라운드에서 도니까 앱 성능과 무관하다"는 틀렸다. 초기화와 스위즐링은 메인스레드에서 일어나고, 자동 계측 훅은 계측 대상과 같은 스레드에서 실행된다. "오버헤드 3% 미만이라고 문서에 있으니 괜찮다"도 위험하다 — 그 수치는 특정 측정 조건의 값이고, 계측 지점을 늘리거나 샘플링을 올리면 달라진다. 또 "크래시 리포터는 크래시할 때만 도니까 평상시 비용이 0"도 부정확하다. 핸들러 설치 비용, breadcrumb 수집, 세션 추적이 상시 돈다. 마지막으로 가장 위험한 오해는 "데이터는 많을수록 좋다"는 것이다. 아무도 보지 않는 데이터는 가치가 0인데 비용은 계속 발생하고, 그 비용을 사용자가 배터리와 데이터 요금으로 낸다.

🧒 쉽게 이해하기

자동차에 계기판과 블랙박스를 다는 것과 같다. 있으면 사고 원인을 알 수 있으니 당연히 달아야 한다. 그런데 공짜가 아니다.

블랙박스는 시동을 켤 때 같이 켜져야 한다. 출발한 뒤에 켜면 출발 직후 사고를 못 찍으니까. 그래서 시동 거는 시간이 조금 길어진다. 이게 앱 시작 시간이 늘어나는 이유다 — 늦게 켜면 놓치고, 일찍 켜면 느려진다.

그리고 블랙박스는 주차 중에도 배터리를 조금씩 먹는다. 카메라를 여섯 대 달면 화질은 좋아지는데 배터리가 훨씬 빨리 닳고, 어느 날 시동이 안 걸린다. 관측 SDK가 메모리를 많이 먹어서 앱이 강제 종료되는 상황이 정확히 이것이다 — 사고를 기록하려던 장치가 사고를 만든다.

그래서 질문은 "블랙박스를 달까 말까"가 아니라 "카메라 몇 대가 실제로 쓸모 있었나"다. 6개월 동안 한 번도 안 돌려본 영상이 있다면, 그 카메라는 배터리만 먹고 있었던 것이다.

꼬리 질문

관측 SDK 초기화를 백그라운드 스레드로 옮기면 콜드 스타트 문제가 해결되는가?

부분적으로만 해결되고 새로운 문제를 만든다. 초기화 작업 중 일부(설정 파싱, 디스크에서 이전 크래시 리포트 읽기, 네트워크 준비)는 확실히 백그라운드로 옮길 수 있고 그만큼 메인스레드 시간이 준다.

그런데 시그널·예외 핸들러 설치는 미룰수록 손해다. 설치 전에 크래시가 나면 그 크래시는 영원히 기록되지 않고, 하필 그 구간(앱 시작 직후)이 크래시가 잦은 구간이다 — 초기화 순서 문제, 마이그레이션 실패, 캐시 손상이 다 여기서 터진다. 백그라운드로 미루면 가장 잡고 싶은 크래시를 놓친다.

또 하나, 백그라운드로 옮겨도 CPU는 여전히 공유된다. 콜드 스타트 중에는 메인스레드도 바쁘고 다른 코어도 여러 초기화로 바쁘므로, 백그라운드 작업이 메인스레드를 직접 막지 않아도 경합으로 전체가 느려질 수 있다. 특히 코어가 적은 구형 기기에서 두드러진다.

현실적 절충은 2단계 초기화다 — 핸들러 설치처럼 반드시 이른 시점이어야 하는 최소한만 동기로 하고, 나머지(이전 리포트 전송, 세션 리플레이 준비, 원격 설정 조회)는 첫 프레임이 그려진 뒤로 미룬다.

쉽게 말하면 블랙박스에서 녹화 시작 버튼만 먼저 누르고, 화질 설정이나 지난 영상 업로드는 출발한 뒤에 하는 것이다. 버튼까지 나중에 누르면 출발 직후 사고를 못 찍는다.
여러 SDK가 같은 메서드를 스위즐링하면 구체적으로 무엇이 깨지는가?

스위즐링은 원본 구현 포인터를 저장해두고 자기 구현으로 바꾼 뒤, 자기 일이 끝나면 저장해둔 원본을 호출하는 방식이다. 여러 SDK가 이걸 하면 양파 껍질처럼 겹쳐서, 마지막에 스위즐링한 쪽이 가장 바깥이 된다.

깨지는 지점은 셋이다. 첫째, 순서 의존. SDK A가 요청 헤더를 추가하고 B가 요청을 기록한다면, A가 먼저 스위즐링됐는지에 따라 B가 기록하는 헤더가 달라진다. 초기화 순서는 앱 코드가 정하므로 SDK 버전을 올리다 순서가 바뀌면 조용히 동작이 변한다.

둘째, 원본 복원 실패. 어떤 SDK가 스위즐링을 되돌리려 하면(비활성화 기능 등), 그 위에 다른 SDK가 얹혀 있으면 체인이 끊어져 호출이 유실되거나 무한 재귀에 빠진다.

셋째, 디버깅 난이도. 크래시 스택에 우리가 쓴 적 없는 SDK 프레임이 나타나고, 어느 SDK의 훅에서 문제가 생겼는지 구분하기 어렵다. 재현도 초기화 순서에 의존해 산발적이다.

대응은 자동 스위즐링을 끄고 명시적 계측으로 바꾸는 것이다. 대부분의 SDK가 옵션을 제공한다. 코드가 조금 늘지만 동작이 결정적이 되고 스택도 읽힌다.

쉽게 말하면 우편함 앞에 여러 사람이 몰래 서서, 편지가 오면 자기가 먼저 보고 다음 사람에게 넘기기로 한 상황이다. 한 명일 땐 잘 돌아간다. 셋이 되면 누가 먼저 보는지에 따라 편지 내용이 달라지고, 한 명이 갑자기 자리를 뜨면 편지가 중간에서 사라진다.
"아무도 안 보는 데이터를 수집하고 있다"를 어떻게 발견하는가?

세 가지 신호를 본다. 첫째, 대시보드·알람에서의 참조 여부. 수집 중인 이벤트·태그 중 어떤 대시보드에도, 어떤 알람 조건에도, 최근 3개월 어떤 조회에도 등장하지 않은 것이 있다면 후보다. 관측 백엔드가 쿼리 로그를 제공하면 자동화할 수 있다.

둘째, 사고 회고에서의 기여. 최근 사고들을 되짚어 "무엇이 원인 규명에 실제로 쓰였는가"를 적어보면, 대개 소수의 지표와 이벤트에 집중돼 있다. 반대로 도입 당시엔 중요해 보였지만 한 번도 인용되지 않은 계측이 드러난다.

셋째, 소유자 부재. 특정 계측을 왜 넣었는지 설명할 수 있는 사람이 팀에 없으면 대개 죽은 계측이다.

제거할 때는 한 번에 지우지 말고 먼저 샘플링을 크게 낮춰(예: 1%) 한 분기 지켜보는 편이 안전하다. 아무도 아쉬워하지 않으면 제거하고, 누군가 찾으면 되살린다. 완전히 지우면 "그때 그 데이터가 있었으면"이 나올 때 되돌릴 수 없다.

쉽게 말하면 창고 정리와 같다. 상자를 바로 버리지 말고 한쪽에 치워두고 6개월 지켜본다. 한 번도 안 찾았으면 그때 버린다. 그리고 "이걸 왜 샀는지 아무도 기억 못 하는 물건"이 대개 버려도 되는 물건이다.

출처 · 참고자료