20장이 "메인스레드가 얼마나 막혔나"를 다뤘다면, 이 챕터는 "이 작업이 어디서 시간을 썼나"를 다룬다. 사용자가 결제 버튼을 누르고 3초를 기다렸을 때, 그 3초가 네트워크였는지 디코딩이었는지 메인스레드 대기였는지를 인과 순서대로 쪼개는 기술이다. 원리는 2010년 Google의 Dapper 논문이 세웠고, 그 개념이 오늘날 모든 트레이싱 시스템의 뼈대다. 여기서는 그 원리가 왜 그렇게 설계됐는지부터 시작해 모바일 클라이언트 고유의 제약까지 내려간다.
Q1. 트랜잭션과 Span은 무엇이며 어떤 단위로 나눠야 하는가?
Span은 "시작 시각·끝 시각·이름·부모"를 가진 하나의 작업 구간이고, 트랜잭션은 그 트리의 루트 span이다. 즉 둘은 다른 종류가 아니라 같은 것의 역할 차이다. 분할 기준은 하나로 정리된다 — "이 구간이 느렸다는 사실만으로 다음 행동이 결정되는가?" 결정된다면 span이고, 아니면 쪼갤 필요가 없다. 실무에서는 경계가 좋은 후보다. 네트워크 왕복, 디스크 I/O, 직렬화·역직렬화, 메인스레드 대기, 렌더링. 반대로 내부 함수 호출을 기계적으로 span으로 만드는 것은 오버헤드만 늘리고 읽기 어렵게 만든다.
CS 원리
Dapper 논문이 세운 데이터 모델은 놀랄 만큼 단순하다. 각 span은 다음을 갖는다.
- trace id — 이 span이 속한 전체 트레이스의 식별자. 트리 전체가 공유한다.
- span id — 이 span 자신의 식별자.
- parent span id — 부모 span의 id. 루트는 없다.
- 이름 · 시작 시각 · 종료 시각
이 네 개만으로 인과 트리가 복원된다. 로그와의 결정적 차이가 여기 있다(18장 Q2) — 로그는 시간 순서만 있지만, span은 부모를 명시하므로 동시 실행되는 작업들이 섞여도 각자의 트리로 분리된다.
분할 단위를 정하는 원리는 정보 이론적 관점에서 보면 명확하다. span을 하나 추가하면 그만큼 정보가 늘지만 비용도 는다. 그래서 "그 구간의 소요 시간을 아는 것이 의사결정을 바꾸는가"가 기준이 된다.
| 구간 | 느리다는 걸 알면 무엇을 하나 | span? |
|---|---|---|
| HTTP 요청 | 서버 팀에 문의 / 타임아웃 조정 / 캐시 도입 | 예 |
| JSON 디코딩 | 모델 구조 단순화 / 백그라운드로 이동 | 예 |
| DB 쿼리 | 인덱스 추가 / 쿼리 수정 | 예 |
| 이미지 디코딩 | 다운샘플링 / 사전 디코딩 | 예 |
| 메인스레드 큐 대기 | 다른 작업을 메인 밖으로 | 예 |
| 배열 정렬 헬퍼 | 대개 아무것도 — 상위 구간에 묻힌다 | 아니오 |
| getter 호출 | — | 아니오 |
Dapper의 설계 목표도 같은 방향을 가리킨다 — 저오버헤드, 애플리케이션 수준 투명성, 대규모 보편 배치. 이를 위해 Dapper는 계측을 RPC·스레딩·제어흐름 같은 공통 라이브러리에만 넣었고, 그 결과 핵심 계측 코드가 C++ 1000줄 미만, Java 800줄 미만이었다. 모든 곳에 넣지 않았기 때문에 보편 배치가 가능했다는 점이 핵심이다.
iOS에서는
iOS 클라이언트에서 트랜잭션 경계로 삼기 좋은 것은 사용자 관점의 작업 단위다.
- 화면 진입 — 진입부터 콘텐츠가 표시될 때까지. 사용자가 체감하는 "로딩"의 실체다.
- 사용자 액션 — 결제 제출, 검색 실행, 업로드 시작.
- 앱 시작 — 프로세스 시작부터 첫 프레임까지(18장 Q7).
- 백그라운드 작업 — 동기화, 프리페치.
그리고 Apple 네이티브 수단으로도 구간 계측은 할 수 있다. signpost의 구간(interval)이 span의 시간 측정 부분에 대응한다. 다만 signpost에는 부모-자식 관계 개념이 없어 트리는 만들어지지 않는다 — Instruments가 중첩처럼 보여주는 것은 시간 포함 관계일 뿐이다.
import OSLog
let signposter = OSSignposter(subsystem: "com.example.shop", category: "checkout")
func submitOrder(_ order: Order) async throws -> Receipt {
// ⚠️ OSSignpostID 는 '동시에 열린 같은 이름의 구간'을 구분하는 식별자일 뿐,
// 부모-자식 관계를 표현하지 않는다. Instruments 는 (subsystem, category, name, id)
// 로 begin/end 를 짝지을 뿐 트리를 복원하지 않는다.
// → 구간마다 새 id 를 발급해야 동시 실행에서 짝짓기가 깨지지 않는다.
let id = signposter.makeSignpostID()
let root = signposter.beginInterval("checkout.submit", id: id)
defer { signposter.endInterval("checkout.submit", root) }
let validated = try await withInterval("validate") { try await validate(order) }
let response = try await withInterval("http.post") { try await api.post("/orders", validated) }
let receipt = try await withInterval("decode") { try decoder.decode(Receipt.self, from: response) }
return receipt
}
/// 구간을 감싸는 헬퍼 — 계측 코드가 본문을 어지럽히지 않게 한다
/// 구간마다 고유 id 를 발급한다. 같은 id 를 공유하면 동시 실행 시 짝짓기가 깨진다.
func withInterval<T>(_ name: StaticString,
_ body: () async throws -> T) async rethrows -> T {
let state = signposter.beginInterval(name, id: signposter.makeSignpostID())
defer { signposter.endInterval(name, state) }
return try await body()
}
signpost의 한계는 프로덕션 수집이 제한적이라는 것이다. 일반 OSSignposter 구간은 Instruments로만 보지만, MXMetricManager.makeLogHandle(category:)로 만든 핸들에 찍으면 MetricKit이 MXSignpostMetric으로 집계해 준다(18장 Q2 꼬리질문). 다만 하루 한 번 집계된 히스토그램이라 개별 트레이스나 부모-자식 트리는 얻을 수 없다. 그래서 실무 구성은 구간 이름을 공유해 두 경로로 내보내는 방식이 좋다 — 개발 중에는 Instruments에서 보고, 프로덕션에서는 같은 이름의 span으로 백엔드에 보낸다.
실험 · 도구
span 트리를 직접 만들어보면 데이터 모델이 얼마나 단순한지 알 수 있다.
import Foundation
struct Span {
let traceID: String
let spanID: String
let parentID: String?
let name: String
let start: ContinuousClock.Instant
var end: ContinuousClock.Instant?
var durationMs: Double? {
guard let end else { return nil }
let d = end - start
return Double(d.components.seconds) * 1000
+ Double(d.components.attoseconds) / 1e15
}
}
final class Tracer {
private(set) var spans: [Span] = []
private var stack: [String] = [] // 현재 열린 span id 스택
private let traceID = Tracer.randomHex(32)
func begin(_ name: String) -> String {
let id = Tracer.randomHex(16)
spans.append(Span(traceID: traceID, spanID: id,
parentID: stack.last, // ← 부모는 스택 top
name: name, start: .now, end: nil))
stack.append(id)
return id
}
func end(_ id: String) {
guard let i = spans.firstIndex(where: { $0.spanID == id }) else { return }
spans[i].end = .now
stack.removeAll { $0 == id }
}
/// 트리 모양으로 출력 — 병목이 들여쓰기로 드러난다
func printTree(parent: String? = nil, depth: Int = 0) {
for s in spans where s.parentID == parent {
let pad = String(repeating: " ", count: depth)
let ms = s.durationMs.map { String(format: "%.0f ms", $0) } ?? "미완료"
print("\(pad)└ \(s.name) — \(ms)")
printTree(parent: s.spanID, depth: depth + 1)
}
}
static func randomHex(_ n: Int) -> String {
(0..<n).map { _ in "0123456789abcdef".randomElement()! }.reduce(into: "") { $0.append($1) }
}
}
// 사용
let tracer = Tracer()
let root = tracer.begin("checkout.submit")
let http = tracer.begin("http.post")
Thread.sleep(forTimeInterval: 0.3)
tracer.end(http)
let decode = tracer.begin("decode")
Thread.sleep(forTimeInterval: 0.05)
tracer.end(decode)
tracer.end(root)
tracer.printTree()
// └ checkout.submit — 352 ms
// └ http.post — 301 ms
// └ decode — 51 ms
이 구현의 한계가 곧 실전 트레이싱의 어려움을 보여준다 — stack이 단일 배열이라 동시 실행을 다루지 못한다. 두 작업이 겹치면 부모 관계가 뒤섞인다. 실제 구현은 이 스택을 태스크 로컬이나 명시적 컨텍스트 전달로 관리해야 한다(18장 Q5 꼬리질문).
프로젝트 적용
span 설계에서 정할 것 넷.
- 이름 규칙을 고정한다.
영역.동작형태(checkout.submit,http.post,db.query)로 통일하면 집계와 검색이 쉬워진다. 이름에 가변값을 넣지 않는다 — 18장 Q6의 그룹핑 문제가 그대로 재현된다. - 깊이를 제한한다. 3~4단계면 대부분 충분하다. 더 깊어지면 읽기 어렵고 오버헤드가 는다.
- 계측 코드를 본문에서 분리한다. 위의
withInterval처럼 감싸는 헬퍼를 만들면 비즈니스 로직이 계측으로 어지러워지지 않는다. Dapper가 공통 라이브러리에만 계측을 넣어 투명성을 확보한 것과 같은 발상이다. - 실패한 span도 닫는다.
defer로 종료를 보장하고, 실패 사유를 span 속성으로 남긴다. 열린 채 사라진 span은 집계를 오염시킨다.
"span은 많을수록 좋다"는 틀렸다. Dapper의 설계 목표 자체가 저오버헤드와 보편 배치였고, 그래서 계측 지점을 공통 라이브러리로 제한했다. 무분별한 span은 오버헤드를 늘리고 트레이스를 읽기 어렵게 만든다. "트랜잭션과 span은 다른 자료구조"도 오해다 — 트랜잭션은 부모가 없는 span일 뿐이다. "span 이름에 파라미터를 넣으면 자세해서 좋다"도 위험하다 — http.post /orders/12345처럼 쓰면 이름이 무한히 늘어나 집계가 불가능해진다. 값은 이름이 아니라 속성으로 넣는다. 또 "signpost는 프로덕션에서 아예 못 쓴다"도 틀렸다 — MetricKit의 makeLogHandle로 찍으면 MXSignpostMetric으로 수집된다. 다만 하루 1회 집계 히스토그램이라 span 트리를 대체하지는 못한다.
택배가 집에 오기까지를 생각해보자. "주문하고 3일 걸렸다"는 것만 알면 어디를 개선해야 할지 모른다.
그래서 배송 조회를 보면 단계가 나뉘어 있다. 주문 접수(10분) → 상품 준비(2일) → 집화(1시간) → 간선 이동(8시간) → 배달(2시간). 이제 보인다. 문제는 배송이 아니라 상품 준비 2일이다.
각 단계가 span이고, "주문부터 도착까지" 전체가 트랜잭션이다. 그리고 중요한 건, 트랜잭션도 결국 제일 바깥 단계일 뿐이라는 것이다. 특별한 종류가 아니다.
여기서 얼마나 잘게 나눌까가 문제다. "간선 이동"을 "고속도로 진입 → 톨게이트 → 휴게소 → 톨게이트 → 진출"까지 나누면? 정보는 늘어난다. 그런데 그걸 안다고 내가 뭘 할 수 있나? 아무것도 못 한다. 반면 "상품 준비가 2일"이라는 건 판매자에게 문의할 근거가 된다.
그래서 기준은 하나다. "이 단계가 느리다는 걸 알면 내가 뭔가 할 수 있는가?" 할 수 있으면 나누고, 없으면 안 나눈다.
꼬리 질문
동시 실행되는 작업의 부모-자식 관계는 어떻게 관리하는가?
핵심은 "현재 span"을 어디에 저장하는가다. 단순 구현처럼 전역 스택을 쓰면 동시 실행에서 즉시 깨진다 — 태스크 A가 span을 열고 await로 정지한 사이 태스크 B가 span을 열면, B의 자식이 A의 자식으로 붙는다.
해법은 세 가지다.
(1) 명시적 컨텍스트 전달. 부모 span id를 함수 인자로 넘긴다. 가장 확실하지만 모든 시그니처가 오염된다. Dapper가 "애플리케이션 수준 투명성"을 목표로 삼은 것이 이 문제를 피하려는 것이었다.
(2) 태스크 로컬. Swift의 @TaskLocal에 현재 span을 담으면, await를 넘어도 정확히 따라가고 자식 태스크로 상속된다. 스레드가 아니라 태스크에 묶이므로 동시성 환경에서 정확하다. 현재 Swift에서 가장 자연스러운 방법이다.
enum TraceContext {
@TaskLocal static var currentSpanID: String?
}
func withSpan<T>(_ name: String, _ body: () async throws -> T) async rethrows -> T {
let id = tracer.begin(name, parent: TraceContext.currentSpanID)
defer { tracer.end(id) }
return try await TraceContext.$currentSpanID.withValue(id) {
try await body()
}
}(3) 구조적 동시성 활용. async let이나 TaskGroup으로 만든 자식은 부모 관계가 언어 차원에서 명확하므로, 태스크 로컬과 결합하면 트리가 자동으로 맞는다.
주의할 것은 Task.detached가 태스크 로컬을 상속하지 않는다는 점이다(20장 Q6 꼬리질문). detached 경계에서는 span 부모가 끊기므로, 필요하면 id를 명시적으로 넘겨야 한다.
span에 속성(attribute)을 붙일 때의 기준은 태그와 같은가?
원리는 같지만 적용 범위가 다르다. 18장 Q5의 태그 기준(유한하고 열거 가능하면 검색 축, 아니면 본문)이 그대로 적용되는데, span 속성은 집계 대상이 시간이라는 특성이 추가된다.
span 속성이 유용한 전형적 예는 이렇다.
http.status— 유한. "500 응답일 때 평균 소요 시간"을 볼 수 있다.http.method— 유한.db.rows— 숫자. 구간별 버킷으로 집계하면 "행 수와 소요 시간의 관계"가 보인다.cache.hit— 불리언. 캐시 적중 여부에 따른 시간 차이를 정량화한다.payload.bytes— 숫자. 크기와 시간의 상관을 본다.
여기서 숫자 속성이 span에서 특히 가치가 크다는 점이 태그와 다르다. 태그는 카디널리티 때문에 숫자를 꺼리지만, span 속성은 대개 검색 축이 아니라 분석 입력으로 쓰이므로 숫자를 넣어도 된다. "요청 크기가 클 때만 느린가"는 payload.bytes가 없으면 답할 수 없다.
넣지 말아야 할 것도 같다 — 요청 본문·응답 본문·개인정보. 크기는 넣되 내용은 넣지 않는다(23장).
span이 열린 채 끝나지 않는 경우(앱 종료, 화면 이탈)는 어떻게 처리하는가?
모바일에서는 정상 상황의 일부로 취급해야 한다(18장 Q2 꼬리질문). 처리 방법은 셋이다.
(1) 타임아웃으로 자동 종료. span에 최대 수명을 두고 초과하면 status=deadline_exceeded로 닫는다. 이렇게 하면 데이터가 남지만, 실제 소요 시간이 아니라 타임아웃 값이 기록되므로 집계에서 제외하거나 별도 상태로 구분해야 한다. 평균에 섞이면 지표가 왜곡된다.
(2) 앱 생명주기 훅에서 일괄 종료. 백그라운드 전환 시점에 열린 span을 status=abandoned로 닫고 flush한다. 사용자가 화면을 떠난 것이므로 "완료되지 않은 작업"으로 기록하는 것이 사실에 부합한다.
(3) 아예 버린다. 미완료 span을 전송하지 않는다. 가장 단순하지만 "시작은 했는데 끝나지 않는 작업이 얼마나 되는가"라는 중요한 정보를 잃는다.
실무적으로는 (2) + 상태 구분이 좋다. 완료율 자체가 지표가 되기 때문이다 — "결제 트랜잭션 시작 대비 완료율이 떨어졌다"는 소요 시간 평균보다 훨씬 중요한 신호일 수 있다.
그리고 SIGKILL로 앱이 즉사하면 (2)도 실행되지 않는다(19장). 이 경우는 다음 실행에서 "열린 채 남은 span"을 감지해 보고하는 방법이 있는데, 그러려면 span 상태를 디스크에 남겨야 해서 비용이 든다. 대개는 완료율 하락으로 간접 감지하는 것으로 충분하다.
Q2. 분산 트레이스는 클라이언트와 서버를 어떻게 잇는가?
HTTP 헤더로 trace id와 부모 span id를 전달한다. W3C 표준은 traceparent 헤더이고 형식이 정확히 정해져 있다 — version-trace-id-parent-id-trace-flags. 예: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. trace-id는 32자리 hex(16바이트), parent-id는 16자리 hex(8바이트)이고, 전부 0인 값은 무효다. trace-flags의 최하위 비트가 sampled 플래그로, 1이면 "호출자가 기록했을 수 있다"는 뜻이라 하위 서비스가 같은 결정을 따르도록 조율한다. 벤더별 확장은 tracestate 헤더에 담는다. 이 표준 덕분에 클라이언트 SDK와 서버 스택이 서로 달라도 트레이스가 이어진다.
CS 원리
분산 트레이싱의 근본 문제는 프로세스 경계를 넘어 컨텍스트를 전달하는 것이다. 한 프로세스 안에서는 태스크 로컬이나 스택으로 "현재 span"을 알 수 있지만, 네트워크를 건너면 그 정보가 사라진다.
해법은 대역 내 전달(in-band propagation)이다 — 요청 자체에 컨텍스트를 실어 보낸다. HTTP라면 헤더가 자연스러운 자리다.
W3C Trace Context가 정한 형식을 뜯어보면 설계 의도가 보인다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
── ──────────────────────────────── ──────────────── ──
│ │ │ └ trace-flags (2 hex)
│ │ └ parent-id (16 hex = 8바이트)
│ └ trace-id (32 hex = 16바이트)
└ version (2 hex, 현재 00. ff는 무효)
- 고정 길이 — 파싱이 단순하고 빠르다. 가변 길이 구조를 피한 것은 모든 홉에서 파싱된다는 점을 고려한 설계다.
- trace-id 16바이트 — 전 세계 규모에서 충돌하지 않을 만큼 크다. 전부 0은 무효로 규정해 "값이 없음"과 구분한다.
- parent-id는 "호출자의 span id" — 이름이 parent인 이유는 받는 쪽 관점에서 부모이기 때문이다. 서버는 이 값을 부모로 삼아 자기 span을 만든다.
- version 필드 — 형식이 진화할 수 있도록. 모르는 버전을 만나면 안전하게 무시하거나 처리한다.
sampled 플래그가 특히 중요하다. trace-flags의 최하위 비트가 1이면 "호출자가 트레이스 데이터를 기록했을 수 있다"는 뜻이고, 이를 통해 하위 서비스들이 기록 결정을 맞춘다. 이게 없으면 클라이언트는 기록하는데 서버는 버려서 반쪽짜리 트레이스가 남는다 — Q3에서 다룰 문제다.
tracestate는 벤더별 정보를 담는 확장 슬롯이다. 여러 벤더가 각자 항목을 쉼표로 이어 붙이므로, 그 목록 자체가 트레이스가 지나온 경로를 드러낸다.
iOS에서는
클라이언트에서 이 헤더를 붙이는 것은 어렵지 않지만, 주의할 점이 몇 가지 있다.
import Foundation
struct TraceParent {
let traceID: String // 32 hex
let spanID: String // 16 hex
let sampled: Bool
/// W3C traceparent 헤더 값 생성
var headerValue: String {
let flags = sampled ? "01" : "00"
return "00-\(traceID)-\(spanID)-\(flags)"
}
/// 유효성 검사 — 전부 0인 값은 무효라고 스펙이 규정한다
var isValid: Bool {
traceID.count == 32 && spanID.count == 16
&& traceID != String(repeating: "0", count: 32)
&& spanID != String(repeating: "0", count: 16)
&& traceID.allSatisfy(\.isHexDigit) && spanID.allSatisfy(\.isHexDigit)
}
}
extension URLRequest {
mutating func attach(_ tp: TraceParent, tracestate: String? = nil) {
guard tp.isValid else { return }
setValue(tp.headerValue, forHTTPHeaderField: "traceparent")
if let tracestate { setValue(tracestate, forHTTPHeaderField: "tracestate") }
}
}
// ── 사용: 우리 서버로 가는 요청에만 붙인다 ──
func makeRequest(_ url: URL, span: Span) -> URLRequest {
var req = URLRequest(url: url)
// ⚠️ 서드파티 도메인에는 붙이지 않는다 — 내부 식별자가 외부로 새어 나간다
if TrustedHosts.contains(url.host ?? "") {
req.attach(TraceParent(traceID: span.traceID,
spanID: span.spanID,
sampled: span.isSampled))
}
return req
}
모바일 클라이언트 고유의 제약 셋을 짚어야 한다.
(1) 클럭을 신뢰할 수 없다. 사용자 기기의 벽시계는 임의로 바뀔 수 있다. 그래서 구간 길이는 반드시 단조 시계(ContinuousClock)로 재고, 서버 span과 시간축을 맞출 때는 절대 시각을 그대로 믿지 않는다. 클라이언트 span이 서버 span보다 나중에 시작한 것처럼 보이는 물리적으로 불가능한 트레이스가 흔히 나온다. (P0 16장 Q4의 연장선이다.)
(2) 헤더가 외부로 나갈 수 있다. 우리 서버가 아닌 곳에 traceparent를 붙이면 내부 추적 식별자가 제3자에게 전달된다. 허용 호스트 목록으로 제한해야 한다. 자동 계측이 모든 요청에 헤더를 붙이는 구현이라면 특히 확인이 필요하다.
(3) CORS. 웹뷰에서 나가는 요청이라면 서버가 traceparent를 허용 헤더로 선언해야 한다. 안 하면 요청이 실패한다.
실험 · 도구
헤더가 제대로 붙고 서버가 이어받는지는 직접 확인해야 한다.
# 1) 헤더 형식 검증 — 스펙에 맞는지 정규식으로 확인
echo "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
| grep -E '^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' \
&& echo "형식 OK"
# 2) 전부 0인 무효값 검출
for v in "00000000000000000000000000000000" "0000000000000000"; do
echo "$v" | grep -qE '^0+$' && echo "❌ 무효: $v"
done
# 3) 실제 요청 확인 — 프록시로 헤더를 들여다본다
# Charles/Proxyman 또는 로컬 서버로 요청을 받아 헤더를 출력
python3 -m http.server 8080 & # 간단 확인용
# 더 정확히는 헤더를 에코하는 스크립트를 쓴다
// 4) 앱 안에서 검증 — 디버그 빌드에서 나가는 요청의 헤더를 로깅한다
/// ⚠️ URLProtocol.registerClass 만으로는 URLSession 트래픽을 못 가로챈다.
/// URLSessionConfiguration.protocolClasses 맨 앞에 넣어야 한다:
/// config.protocolClasses = [TraceHeaderInspector.self] + (config.protocolClasses ?? [])
final class TraceHeaderInspector: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
#if DEBUG
if let tp = request.value(forHTTPHeaderField: "traceparent") {
let host = request.url?.host ?? "?"
// 외부 도메인에 붙었다면 즉시 잡아낸다
assert(TrustedHosts.contains(host), "❌ 외부 호스트 \(host)에 traceparent 유출")
Logger(subsystem: "com.example.shop", category: "trace")
.debug("→ \(host, privacy: .public): \(tp, privacy: .public)")
}
#endif
return false // 실제로 가로채지는 않는다
}
}
클럭 문제를 재현해보는 것도 유익하다.
// 벽시계와 단조 시계의 차이 — 기기 시각을 바꾸면 결과가 갈린다
let wallStart = Date()
let monoStart = ContinuousClock.now
// (이 사이에 설정 › 일반 › 날짜 및 시간에서 시각을 1시간 뒤로 바꿔본다)
Thread.sleep(forTimeInterval: 5)
print("벽시계 측정: \(Date().timeIntervalSince(wallStart)) 초") // ← 음수가 나올 수 있다
print("단조 측정: \(ContinuousClock.now - monoStart)") // ← 항상 약 5초
프로젝트 적용
분산 트레이스를 실제로 쓰려면 서버 팀과 합의가 필요하다. 정할 것 넷.
- 헤더 표준을 W3C로 통일한다. 벤더 고유 헤더(
x-…-trace-id등)를 쓰면 나중에 백엔드를 바꿀 때 양쪽을 다 고쳐야 한다. 표준을 쓰면 이식성이 생긴다. - 샘플링 결정 주체를 정한다. 클라이언트가 정하고 서버가 따를지, 서버가 재결정할지. 대개 클라이언트가 정하고 서버가 존중하는 쪽이 트레이스 완결성에 유리하다(Q3).
- 허용 호스트 목록을 코드로 관리한다. 하드코딩보다 설정으로 두되, 기본값은 차단이어야 한다.
- 시간축 불일치를 다루는 규칙을 정한다. 클라이언트 span과 서버 span의 시각이 어긋날 때 어느 쪽을 기준으로 표시할지, 음수 구간을 어떻게 표시할지.
그리고 실용적 팁 하나 — 서버 응답에 처리 시간을 담아 보내면 클럭 문제를 상당 부분 우회할 수 있다. 클라이언트가 잰 "왕복 시간"에서 서버가 알려준 "서버 처리 시간"을 빼면 네트워크 구간이 나오고, 이 계산은 양쪽 시계가 안 맞아도 성립한다.
"trace id는 아무 문자열이나 쓰면 된다"는 틀렸다 — W3C 스펙은 32자리 소문자 hex로 규정하고 전부 0인 값은 무효다. 형식이 다르면 중간 프록시나 서버 라이브러리가 거부할 수 있다. "traceparent를 모든 요청에 붙이면 된다"도 위험하다 — 외부 도메인에 내부 식별자가 유출된다. "클라이언트와 서버 시각을 이으면 정확한 타임라인이 나온다"도 틀렸다 — 기기 벽시계는 신뢰할 수 없다. "sampled 플래그가 0이면 서버가 반드시 버린다"도 부정확하다 — 스펙은 "호출자가 기록했을 수 있다"를 나타낼 뿐이고 하위 서비스가 자체 판단할 여지가 있다. 마지막으로 tracestate를 벤더 데이터 저장소로 남용하면 헤더가 커져 모든 요청에 오버헤드가 붙는다.
여러 회사를 거치는 국제 택배를 생각해보자. 한국 택배사 → 항공사 → 미국 택배사. 회사마다 자기 시스템이 있어서 운송장 번호가 다 다르다.
그래서 "내 물건이 지금 어디 있나"를 알려면 회사마다 따로 조회해야 하고, 어느 회사의 어느 번호가 내 것인지 연결이 안 된다.
해결책은 공통 번호다. 처음 접수할 때 전 세계에서 안 겹치는 긴 번호를 하나 만들고, 모든 회사가 그 번호를 상자에 적어서 넘긴다. 그러면 어느 회사 시스템에서 조회하든 같은 물건임을 안다. 이게 trace id다.
여기에 하나 더 적는다 — "바로 앞 단계의 처리 번호". 이걸로 순서가 복원된다. 이게 parent id다.
그리고 체크 표시가 하나 있다. "이 물건은 상세 추적 대상"이라는 표시다. 모든 물건을 상세 추적하면 비용이 너무 크니까, 일부만 표시해두고 표시된 것은 모든 회사가 똑같이 상세 기록한다. 어느 한 회사만 기록을 안 하면 중간이 뻥 뚫린 기록이 남아서 아무 쓸모가 없기 때문이다.
마지막으로 주의할 게 있다 — 이 번호를 아무 회사에나 알려주면 안 된다. 우리 물류망의 내부 정보가 새어 나간다.
꼬리 질문
클라이언트와 서버의 시계가 안 맞을 때 트레이스 타임라인을 어떻게 표시해야 하는가?
세 가지 접근이 있고, 조합해 쓴다.
(1) 상대 시간으로 그린다. 절대 시각 대신 루트 span 시작을 0으로 두고 상대 오프셋으로 표시한다. 클라이언트 내부 구간들은 같은 단조 시계로 쟀으므로 서로 정확하고, 서버 구간은 "요청 전송 후 얼마 뒤"로 배치한다.
(2) 서버 처리 시간을 응답으로 받는다. 서버가 Server-Timing 헤더나 응답 본문에 자기 처리 시간을 담아 보내면, 클라이언트는 왕복 − 서버처리 = 네트워크로 분해할 수 있다. 두 시계를 비교하지 않으므로 시각 불일치와 무관하다.
(3) 오프셋을 추정해 보정한다. NTP의 기본 아이디어와 같다 — 요청 전송 시각 T1, 서버 수신 시각 T2, 서버 응답 시각 T3, 클라이언트 수신 시각 T4를 알면 왕복 지연과 시계 오프셋을 추정할 수 있다. 다만 비대칭 지연에서는 오차가 크고, 구현 복잡도가 높다.
실무에서는 (1)+(2) 조합이 비용 대비 효과가 가장 좋다. 그리고 음수 구간이 나오면 경고로 표시해 사람이 오해하지 않게 한다 — 서버 span이 클라이언트 요청보다 먼저 시작한 것처럼 보이는 트레이스를 그대로 두면 조사가 엉뚱한 방향으로 간다.
tracestate는 실제로 무엇에 쓰이며 클라이언트가 신경 써야 하는가?
tracestate는 벤더별 확장 정보를 담는 슬롯이다. traceparent가 표준화된 최소 정보만 담는 대신, 각 벤더가 필요한 추가 정보(자체 span id 형식, 샘플링 우선순위, 지역 정보 등)를 여기에 넣는다. 형식은 vendor1=value1,vendor2=value2처럼 쉼표로 구분된 키-값 목록이다.
중요한 성질은 누적된다는 것이다. 각 홉이 자기 항목을 앞에 추가하므로, 목록을 보면 트레이스가 어떤 벤더 시스템들을 지나왔는지가 드러난다.
클라이언트 입장에서 신경 쓸 것은 셋이다.
(1) 받은 값을 보존해 전달한다. 우리가 이해하지 못하는 항목이라도 지우지 말고 그대로 넘겨야 한다. 지우면 하위 시스템의 컨텍스트가 끊긴다.
(2) 크기를 제한한다. 스펙에도 항목 수·길이 제한이 있고, 무한히 커지면 모든 요청에 헤더 오버헤드가 붙는다. 모바일에서는 특히 민감하다.
(3) 민감 정보를 넣지 않는다. 이 헤더는 여러 중간 시스템을 거치므로 가장 신뢰도 낮은 홉의 보안 수준에 노출된다고 봐야 한다.
대부분의 iOS 앱은 트레이스의 시작점이라 받을 tracestate가 없고, 벤더 SDK가 자기 항목을 넣는 정도다. 그래서 실무 부담은 크지 않지만, 웹뷰가 섞이거나 서버에서 받은 컨텍스트를 이어가는 구조라면 (1)이 중요해진다.
웹뷰가 섞인 앱에서 트레이스를 이으려면 무엇이 필요한가?
네이티브와 웹뷰는 서로 다른 실행 컨텍스트라 컨텍스트가 자동으로 이어지지 않는다. 세 가지 방법이 있다.
(1) 초기 로드 요청에 헤더를 심는다. WKWebView에 URLRequest로 로드할 때 traceparent를 붙이면, 그 문서 로드까지는 이어진다. 다만 문서 안에서 나가는 후속 요청에는 자동으로 붙지 않는다.
(2) 문서에 메타 태그로 주입한다. W3C 관행상 <meta name="traceparent" content="…">를 서버가 렌더링해 넣고, 웹 쪽 SDK가 그것을 읽어 이어간다. 서버 렌더링이 아니라면 WKUserScript로 문서 시작 시 값을 주입할 수 있다.
(3) 브리지로 전달한다. WKScriptMessageHandler로 네이티브 ↔ 웹 통신 채널을 만들고, 현재 trace 컨텍스트를 넘긴다. 유연하지만 구현이 많다.
그리고 웹뷰 자체를 span으로 감싸는 것이 먼저다. 네이티브 관점에서 "웹뷰 로드 시작 → 첫 페인트 → 로드 완료"를 span으로 두면, 안쪽을 못 이어도 웹뷰가 전체 시간의 얼마를 차지하는지는 알 수 있다. 대개 이것만으로 "웹이 느린가 네이티브가 느린가"라는 1차 질문에는 답이 된다.
주의할 점은 CORS다. 웹 쪽에서 나가는 크로스 오리진 요청에 traceparent를 붙이려면 서버가 그 헤더를 허용해야 한다. 안 하면 요청 자체가 실패하므로, 트레이싱을 켜자마자 기능이 깨지는 사고가 난다.
Q3. 샘플링은 왜 트레이스 전체 단위로 해야 하는가?
부분 트레이스는 쓸모가 없기 때문이다. span을 각각 독립적으로 10% 확률로 샘플링하면, 5단계짜리 트레이스가 온전히 남을 확률은 0.001%에 불과하고 대부분은 중간이 뚫린 조각이 된다. 조각으로는 "어디가 병목인가"에 답할 수 없다. 그래서 Dapper는 샘플링 결정을 trace id에 의존시켜, 트레이스 전체가 통째로 샘플링되거나 통째로 버려지도록 설계했다. 이 원리가 W3C traceparent의 sampled 플래그로 이어진다 — 시작점이 결정하고 모든 하위 홉이 그 결정을 따른다. 즉 샘플링은 확률 문제가 아니라 일관성 문제다.
CS 원리
수식으로 보면 명확하다. span 하나가 샘플링될 확률을 p, 트레이스의 span 수를 n이라 하자.
- 독립 샘플링 — 트레이스가 온전할 확률 = pn. p=0.1, n=5면 0.00001(십만 분의 1).
- 트레이스 단위 샘플링 — 트레이스가 온전할 확률 = p. p=0.1이면 0.1.
같은 데이터 양을 보내면서 온전한 트레이스 수가 1만 배 차이난다. 그리고 독립 샘플링에서 남는 나머지 데이터는 버려지는 것이 아니라 더 나쁘다 — 조각난 트레이스가 저장 공간을 차지하면서 분석을 방해한다.
Dapper의 해법은 우아하다. trace id 자체를 샘플링 결정의 입력으로 쓴다.
sampled = hash(trace_id) < threshold
이 방식의 성질이 결정적이다.
- 결정적(deterministic) — 같은 trace id면 어느 노드에서 계산해도 같은 답이 나온다.
- 조율 불필요 — 노드끼리 통신하지 않아도 자동으로 일치한다.
- 균일 — trace id가 무작위이므로 해시도 균일 분포다.
실제로는 여기에 명시적 전달이 더해진다. 시작점이 결정한 결과를 traceparent의 sampled 비트로 실어 보내면, 하위 노드는 해시를 계산할 필요도 없이 플래그만 보고 따르면 된다. 스펙 표현으로는 "하위 서비스가 기록 결정을 조율하는 데 도움이 된다"이다.
iOS에서는
클라이언트가 트레이스의 시작점인 경우가 대부분이라, 샘플링 결정 권한이 우리에게 있다. 이건 책임이기도 하다 — 여기서 버리면 서버 쪽도 그 트레이스를 못 본다.
결정 방식은 두 갈래다.
| 방식 | 동작 | 장단점 |
|---|---|---|
| 고정 비율 (rate) | 모든 트레이스를 같은 확률로 | 단순·예측 가능 / 희귀 사건을 놓친다 |
| 동적 판정 (sampler) | 트랜잭션 이름·상황별로 다른 확률 | 중요한 것만 높게 / 편향이 생겨 비율 보정 필요 |
모바일에서 동적 판정이 특히 유용한 이유는 트랜잭션의 중요도 차이가 크기 때문이다. 결제 플로우는 하루 수천 건인데 스크롤 트랜잭션은 수백만 건이다. 같은 비율을 쓰면 결제 표본이 너무 적거나 스크롤 데이터가 너무 많다.
/// 트랜잭션 이름과 상황에 따라 샘플링 비율을 다르게 준다
func sampleRate(for name: String, context: TraceContext) -> Double {
// 1. 실패는 무조건 기록한다 — 희귀하고 가장 가치 있다
if context.hasError { return 1.0 }
// 2. 느린 것도 무조건 — 병목 조사의 핵심 표본
if let elapsed = context.elapsedSoFar, elapsed > .seconds(3) { return 1.0 }
// 3. 나머지는 트랜잭션 종류별로
switch name {
case "checkout.submit", "payment.process": return 1.0 // 저빈도·고가치
case "app.launch": return 0.1
case "screen.load": return 0.05
case "list.scroll": return 0.001 // 고빈도·저가치
default: return 0.01
}
}
여기서 2번(느린 것은 무조건)은 구현이 까다롭다. 소요 시간은 끝나야 알 수 있는데 샘플링 결정은 시작할 때 해야 하기 때문이다. 해법은 두 가지다 — 모든 트레이스를 일단 메모리에 기록해두고 끝날 때 전송 여부를 결정하거나(tail-based), 시작 시점의 힌트(직전 실행에서 이 화면이 느렸는지)로 추정한다. 전자는 메모리 비용이 크고, 후자는 부정확하다. 클라이언트에서는 짧은 트랜잭션에 한해 tail-based가 현실적이다.
실험 · 도구
두 샘플링 방식의 차이를 시뮬레이션으로 확인해보자.
import Foundation
let p = 0.3
let spansPerTrace = 5
let traceCount = 100_000
// (A) 독립 샘플링 — span마다 따로 판정
var intactA = 0, spansSentA = 0
for _ in 0..<traceCount {
let kept = (0..<spansPerTrace).filter { _ in Double.random(in: 0...1) < p }.count
spansSentA += kept
if kept == spansPerTrace { intactA += 1 }
}
// (B) 트레이스 단위 — trace id 해시로 한 번 판정
var intactB = 0
var spansSentB = 0
for _ in 0..<traceCount {
let traceID = UInt64.random(in: 0...UInt64.max)
// 결정적 판정: 해시 값이 임계 아래면 채택. 모든 노드가 같은 답을 낸다.
let sampled = Double(traceID % 10_000) / 10_000 < p
if sampled { intactB += 1; spansSentB += spansPerTrace }
}
print(String(format: "A 독립: 온전한 트레이스 %d개 (%.4f%%), 전송 span %d개",
intactA, Double(intactA) / Double(traceCount) * 100, spansSentA))
print(String(format: "B 트레이스: 온전한 트레이스 %d개 (%.2f%%), 전송 span %d개",
intactB, Double(intactB) / Double(traceCount) * 100, spansSentB))
// A 독립: 온전한 트레이스 243개 (0.2430%), 전송 span 149,8xx개
// B 트레이스: 온전한 트레이스 29981개 (29.98%), 전송 span 149,9xx개
// → 전송한 span 총량은 거의 같은데(둘 다 약 15만) 쓸 수 있는 트레이스가 약 123배 차이난다.
결정적 샘플링이 여러 노드에서 같은 답을 내는지도 확인할 수 있다.
// 같은 trace id에 대해 서로 다른 "노드"가 독립적으로 판정해도 결과가 같아야 한다
func decide(traceID: String, rate: Double) -> Bool {
// trace id의 하위 비트를 균일 난수로 취급한다 (W3C trace-id는 무작위이므로 유효)
let tail = String(traceID.suffix(8))
guard let v = UInt32(tail, radix: 16) else { return false }
return Double(v) / Double(UInt32.max) < rate
}
let tid = "4bf92f3577b34da6a3ce929d0e0e4736"
print(decide(traceID: tid, rate: 0.3)) // 클라이언트
print(decide(traceID: tid, rate: 0.3)) // 게이트웨이
print(decide(traceID: tid, rate: 0.3)) // 백엔드 서비스
// → 셋 다 같은 값. 서로 통신하지 않았는데 자동으로 일치한다.
프로젝트 적용
샘플링 정책에서 정할 것 다섯.
- 결정 지점을 하나로 고정한다. 트레이스 시작점(대개 클라이언트)에서만 결정하고, 이후는
traceparent의 플래그를 따른다. - 실패와 이상치는 항상 기록한다. 이것들이 가장 정보량이 크고 가장 희귀하다. 균일 샘플링은 정확히 이런 것들을 놓친다.
- 트랜잭션 종류별 비율을 설정으로 뺀다. 코드에 하드코딩하면 조정할 때마다 릴리스가 필요하다. 원격 설정으로 두면 사고 중에 임시로 올릴 수 있다.
- 보정 계수를 함께 전송한다. 비율이 종류마다 다르면 집계 시 원래 규모를 복원해야 한다. 각 트레이스에 샘플링 비율을 실어 보내면 백엔드가 가중치로 쓸 수 있다.
- 서버 팀과 합의한다. 클라이언트가 1.0으로 올렸는데 서버가 0.01로 잘라버리면 여전히 반쪽이다.
"샘플링 비율을 낮추면 그만큼만 덜 본다"는 독립 샘플링에서는 완전히 틀린다 — 온전한 트레이스는 지수적으로 사라진다. "10%를 샘플링하면 10%의 정보를 얻는다"도 틀렸다. 균일 샘플링은 희귀 사건을 사실상 못 본다 — 0.1% 확률의 실패가 10% 샘플링에서 잡히려면 표본이 수만 건 필요하다. "샘플링은 서버에서 하면 된다"도 위험하다 — 클라이언트가 이미 안 보냈으면 서버는 존재조차 모른다. 또 "sampled=0이면 아무것도 기록하지 않는다"도 부정확하다 — 트레이스는 안 남겨도 메트릭은 전부 세는 구성이 일반적이고, 그래야 샘플링과 무관한 정확한 집계가 유지된다.
학교에서 급식 만족도를 조사한다고 하자. 전교생을 다 조사하면 정확하지만 시간이 너무 걸린다. 그래서 일부만 조사한다.
설문지에는 5개 문항이 있다. 여기서 두 가지 방법이 있다.
방법 A: 문항마다 따로 "이 사람에게 이 문항을 물어볼까?"를 30% 확률로 정한다. 결과가 어떻게 될까? 어떤 학생은 1번과 3번만, 어떤 학생은 2번만 답한다. 5문항을 다 답한 학생은 1000명 중 2~3명뿐이다. 그런데 "1번에 불만이라고 한 학생이 5번에는 뭐라고 했나"를 알고 싶으면 두 문항을 다 답한 학생이 필요하다. 거의 없다.
방법 B: 학생 단위로 "이 학생을 조사할까?"를 30% 확률로 정한다. 뽑힌 학생은 5문항을 다 답한다. 조사한 총 문항 수는 A와 비슷한데, 완전한 응답이 30%나 된다.
여기서 더 똑똑한 방법이 있다. 학생마다 학번이 있으니, "학번을 7로 나눈 나머지가 0인 학생"처럼 정하면 선생님 여러 명이 각자 판단해도 같은 학생이 뽑힌다. 서로 물어볼 필요가 없다. 이게 trace id 기반 샘플링이다.
꼬리 질문
tail-based 샘플링(끝나고 결정)은 클라이언트에서 왜 어려운가?
세 가지 이유가 있다.
(1) 메모리 비용. 결정을 미루려면 모든 트레이스를 일단 메모리에 들고 있어야 한다. 서버는 전용 수집기가 큰 메모리를 쓸 수 있지만, 클라이언트는 앱 메모리 예산 안에서 해야 하고 초과하면 Jetsam 대상이 된다(19장 Q5). 관측 도구가 앱을 죽이는 상황이다.
(2) 분산 조율 불가. 클라이언트가 "이 트레이스는 느렸으니 보내자"고 결정해도, 서버 쪽은 이미 자기 span을 버렸을 수 있다. 서버가 클라이언트의 사후 결정을 기다릴 방법이 없다. 결국 클라이언트 구간만 남은 반쪽 트레이스가 된다.
(3) 전송 지연. 결정을 미루면 전송도 미뤄진다. 오프라인이거나 앱이 종료되면 그 트레이스는 영영 안 온다.
현실적 절충안은 하이브리드다. 시작 시점에 낮은 비율로 head-based 결정을 하되(서버와 일관성 유지), 클라이언트 로컬에서만 이상치를 추가로 기록한다. 이 추가분은 서버 span이 없는 반쪽이지만, "클라이언트 구간 중 어디가 느렸나"에는 답할 수 있다.
또 하나 실용적인 방법은 이상치를 트레이스가 아니라 이벤트로 보내는 것이다. "결제가 8초 걸렸다"는 사실과 클라이언트 구간 분해만 이벤트로 남기면, 트레이스 인프라를 거치지 않고도 대부분의 조사가 가능하다.
샘플링 비율이 종류마다 다르면 집계를 어떻게 보정하는가?
역확률 가중(inverse probability weighting)을 쓴다. 각 표본에 1 / 샘플링비율의 가중치를 주면 원래 모집단 규모가 복원된다.
예를 들어 결제 트랜잭션을 100%, 스크롤을 0.1%로 샘플링했다면, 수집된 스크롤 표본 하나는 1000건을 대표한다. "전체 트랜잭션 중 스크롤이 몇 %인가"를 계산할 때 이 가중치를 안 쓰면 스크롤이 극단적으로 과소평가된다.
구현상 중요한 것은 비율을 각 트레이스에 함께 실어 보내는 것이다. 백엔드가 "이 트레이스는 어떤 비율로 뽑혔는지"를 알아야 가중치를 계산할 수 있다. 비율을 코드에서 나중에 바꿨는데 과거 데이터에 기록이 없으면, 그 구간의 집계를 보정할 방법이 없다.
// 트레이스에 샘플링 비율을 남긴다 — 백엔드가 가중치로 쓴다
span.setAttribute("sampling.rate", value: rate)
span.setAttribute("sampling.weight", value: 1.0 / rate)주의할 함정 하나 — 가중치는 개수 집계에는 유효하지만 분포 통계에는 조심해야 한다. "평균 소요 시간"은 가중 평균으로 계산하면 되지만, "p95"는 가중 분위수를 제대로 계산해야 하고 구현이 더 까다롭다. 그리고 이상치를 항상 기록하는 규칙(비율 1.0)이 있으면 분포가 상단으로 편향되므로, 분위수 계산에서는 그 표본들을 별도 처리해야 한다.
희귀 사건을 놓치지 않으려면 샘플링 외에 어떤 수단이 있는가?
세 가지를 조합한다.
(1) 메트릭은 전수 집계한다. 트레이스는 샘플링해도 카운터는 모든 사건을 센다(18장 Q2). "결제 실패 건수"는 샘플링과 무관하게 정확해야 알람을 걸 수 있다. 트레이스는 "왜"를 위한 것이고 메트릭은 "얼마나"를 위한 것이라, 샘플링 정책도 달라야 한다.
(2) 조건부 100% 기록. 실패·타임아웃·이상 소요 시간처럼 미리 정의 가능한 이상 상태는 비율 1.0으로 둔다. 희귀하므로 데이터 양에 큰 영향이 없고, 가치는 가장 높다.
(3) 동적 부스팅. 사고가 감지되면 원격 설정으로 샘플링 비율을 일시적으로 올린다. 평상시에는 낮게 두고 필요할 때만 올리는 방식이라, 비용과 정보량을 동시에 잡는다. 다만 이미 지나간 사건은 복구되지 않으므로 사고 초기에 빨리 올리는 것이 관건이고, 그래서 릴리스마다 조정하는 게 아니라 즉시 반영되는 원격 설정이어야 한다.
덧붙여 사용자 코호트 기반 상시 고비율도 유용하다. 내부 직원 계정이나 베타 그룹은 100%로 두면, 재현 가능한 사용자에 대해서는 항상 완전한 데이터를 갖게 된다.
Q4. 샘플링 비율은 얼마로 잡아야 하는가?
비율이 아니라 "필요한 표본 수"에서 역산한다. 질문에 답하려면 표본이 몇 개 필요한지 정하고, 그 트랜잭션의 일일 발생 건수로 나누면 비율이 나온다. 예컨대 p95를 안정적으로 보려면 수백 건, 1% 수준의 희귀 실패를 잡으려면 수천 건이 필요하다. 여기서 두 가지가 따라온다. (1) 저빈도 트랜잭션은 비율을 높여야 한다 — 하루 500건인 결제를 1%로 샘플링하면 5건뿐이라 아무 통계도 못 낸다. (2) 고빈도는 아주 낮춰도 된다 — 하루 1000만 건인 스크롤은 0.01%만 해도 1000건이다. 즉 단일 비율은 거의 항상 틀린 선택이고, 종류별로 달라야 한다.
CS 원리
표본 수와 추정 정확도의 관계는 통계학의 기본 결과로 정리된다. 비율 p를 추정할 때 표본 n개의 표준오차는 대략 √(p(1−p)/n)이다. 여기서 핵심 성질이 나온다 — 정확도는 표본 수의 제곱근에 비례한다. 오차를 절반으로 줄이려면 표본이 4배 필요하다.
이 관계가 실무에서 뜻하는 바는 이렇다.
| 알고 싶은 것 | 대략 필요한 표본 | 이유 |
|---|---|---|
| 평균 소요 시간 | 수십~수백 | 중심 추정은 비교적 적은 표본으로 안정 |
| p95 | 수백 이상 | 상위 5%만 쓰므로 100건이면 5건으로 계산하는 셈 |
| p99 | 수천 이상 | 100건에서는 1건 — 사실상 최댓값 |
| 1% 실패율의 변화 감지 | 수천 | 드문 사건은 표본이 많아야 개수가 쌓인다 |
| 0.1% 희귀 사건 관찰 | 수만 | 기대 발생 수 = n × 0.001 |
특히 분위수는 표본을 잡아먹는다는 점이 중요하다. p99를 보려면 상위 1%가 통계적으로 의미 있을 만큼 쌓여야 하는데, 표본 100건이면 그 1%는 단 1건이다. 그 한 건이 우연히 이상치면 p99가 통째로 흔들린다.
희귀 사건 쪽은 계산이 더 직관적이다. 발생률 r인 사건을 n개 표본에서 한 번도 못 볼 확률은 (1−r)n이다. r=0.001, n=1000이면 약 37%가 되어, 천 건을 봐도 세 번 중 한 번은 놓친다. 이게 Q3에서 "균일 샘플링은 희귀 사건을 못 본다"고 한 것의 수치적 근거다.
iOS에서는
모바일에서 비율을 정할 때 고려할 축이 세 개 더 붙는다.
- 사용자당 비용. 서버는 비율을 올려도 회사가 비용을 내지만, 클라이언트는 사용자의 데이터 요금과 배터리가 늘어난다(18장 Q7). 그래서 같은 통계적 필요라도 모바일에서는 문턱이 높다.
- 사용자 수 대비 트랜잭션 수. 서버는 요청 수가 곧 표본 수지만, 클라이언트는 사용자 수도 봐야 한다. 표본 1000건이 사용자 10명에게서 나왔다면 그 10명의 기기·네트워크 특성에 편향된다. "표본 수"와 "표본을 낸 사용자 수"를 함께 봐야 한다.
- 전송 지연. 오프라인 큐 때문에 표본이 며칠에 걸쳐 도착한다. "오늘 1000건"이 오늘 발생한 1000건이 아니다.
비율을 역산하는 절차는 이렇다.
/// 필요 표본 수에서 샘플링 비율을 역산한다
struct SamplingPlan {
let transaction: String
let dailyVolume: Int // 일일 발생 건수 (메트릭으로 전수 집계한 값)
let neededSamples: Int // 답하려는 질문에 필요한 표본 수
let maxRate: Double = 1.0
var rate: Double {
guard dailyVolume > 0 else { return maxRate }
return min(maxRate, Double(neededSamples) / Double(dailyVolume))
}
}
let plans = [
// 결제: 하루 500건뿐. p95를 보려면 300건은 필요 → 60%가 나오지만 전량 수집이 낫다
SamplingPlan(transaction: "checkout.submit", dailyVolume: 500, neededSamples: 300),
// 화면 로드: 하루 200만 건. p95에 1000건이면 충분 → 0.05%
SamplingPlan(transaction: "screen.load", dailyVolume: 2_000_000, neededSamples: 1_000),
// 스크롤: 하루 1000만 건. 500건이면 충분 → 0.005%
SamplingPlan(transaction: "list.scroll", dailyVolume: 10_000_000, neededSamples: 500),
]
for p in plans {
print(String(format: "%@ — 일 %d건, 필요 %d건 → 비율 %.4f%%",
p.transaction, p.dailyVolume, p.neededSamples, p.rate * 100))
}
// checkout.submit — 일 500건, 필요 300건 → 비율 60.0000%
// screen.load — 일 2000000건, 필요 1000건 → 비율 0.0500%
// list.scroll — 일 10000000건, 필요 500건 → 비율 0.0050%
//
// → 같은 앱 안에서 비율이 1만 배 넘게 차이난다. 단일 비율로는 절대 맞출 수 없다.
여기서 일일 발생 건수를 어떻게 아는가가 관건인데, 답은 Q3 꼬리질문에서 이미 나왔다 — 메트릭은 전수 집계한다. 카운터로 센 전체 건수를 분모로 쓰면 된다. 트레이스만 있고 메트릭이 없으면 분모를 모르므로 비율을 계산할 수도, 결과를 보정할 수도 없다.
실험 · 도구
표본 수가 분위수 안정성에 미치는 영향을 직접 확인해보자.
import Foundation
/// 실제 분포에서 n개를 뽑아 p95를 추정하는 실험
func estimateP95(sampleSize: Int, trials: Int = 5) -> [Double] {
// 현실적인 응답 시간 분포 — 대부분 빠르고 꼬리가 긴 형태
func draw() -> Double {
Double.random(in: 0...1) < 0.9
? Double.random(in: 40...120) // 정상
: Double.random(in: 300...3000) // 느린 꼬리
}
return (0..<trials).map { _ in
let s = (0..<sampleSize).map { _ in draw() }.sorted()
return s[min(s.count - 1, Int(Double(s.count) * 0.95))]
}
}
for n in [20, 100, 1000, 10_000] {
let runs = estimateP95(sampleSize: n)
let fmt = runs.map { String(format: "%.0f", $0) }.joined(separator: ", ")
print(String(format: "표본 %6d개 → p95 추정: %@ ms", n, fmt))
}
// 이 분포의 이론적 참값은 p95 = 1650 ms 다.
// P(X ≤ x) = 0.9 + 0.1·(x−300)/2700 = 0.95 → x = 1650
//
// 표본 20개 → p95 추정: 426, 619, 2164, 119, 2968 ms ← 25배 편차. 못 쓴다.
// 표본 100개 → p95 추정: 2054, 1777, 2203, 1305, 684 ms
// 표본 1000개 → p95 추정: 1614, 1983, 1599, 1617, 1790 ms
// 표본 10000개 → p95 추정: 1528, 1632, 1662, 1653, 1686 ms ← 참값 근처로 수렴
//
// 주의: 10000개에서도 폭이 ±80 ms 남는다. "표본을 늘리면 값이 고정된다"가 아니라
// "오차가 √n 에 반비례해 줄어든다"가 정확한 표현이다.
희귀 사건 탐지 확률도 계산해두면 비율 결정이 쉬워진다.
/// 발생률 r인 사건을 표본 n개에서 한 번 이상 볼 확률
func detectionProbability(rate r: Double, samples n: Int) -> Double {
1 - pow(1 - r, Double(n))
}
for r in [0.01, 0.001, 0.0001] {
let line = [100, 1_000, 10_000, 100_000]
.map { String(format: "n=%d: %.0f%%", $0, detectionProbability(rate: r, samples: $0) * 100) }
.joined(separator: " ")
print(String(format: "발생률 %.2f%% → %@", r * 100, line))
}
// 발생률 1.00% → n=100: 63% n=1000: 100% n=10000: 100% n=100000: 100%
// 발생률 0.10% → n=100: 10% n=1000: 63% n=10000: 100% n=100000: 100%
// 발생률 0.01% → n=100: 1% n=1000: 10% n=10000: 63% n=100000: 100%
//
// → 0.01% 사건을 안정적으로 보려면 표본이 수만 건 필요하다.
// 그래서 "실패는 무조건 100% 기록"(Q3)이 압도적으로 효율적인 것이다.
이 표가 조건부 100% 기록의 정당성을 수치로 보여준다. 0.01% 사건을 균일 샘플링으로 잡으려면 전체 표본을 수만 건으로 늘려야 하는데, 그러면 정상 케이스 수만 건까지 함께 보내게 된다. 반면 실패만 골라 100% 기록하면 필요한 것만 온다.
프로젝트 적용
비율 결정 절차를 문서로 만들면 이렇다.
- 답하려는 질문을 적는다. "결제 p95가 릴리스별로 어떻게 변하나", "특정 기기에서만 느린가". 질문이 없으면 비율을 정할 근거도 없다.
- 질문에 필요한 표본 수를 정한다. 평균이면 수백, p95면 수백~1000, p99나 희귀 사건이면 수천 이상.
- 분모(전수 건수)를 메트릭에서 가져온다. 없으면 먼저 카운터부터 붙인다.
- 비율 = 필요 표본 ÷ 전수 건수. 1.0을 넘으면 전량 수집이다.
- 세분화 축을 고려해 곱한다. 기기별·릴리스별로 나눠 볼 거라면 각 칸마다 필요 표본이 있어야 하므로, 칸 수만큼 곱해야 한다. 이 단계가 자주 빠져서 "전체로는 충분한데 쪼개면 표본이 없는" 상황이 생긴다.
- 비용 한도와 대조한다. 계산된 비율이 전송량·배터리 예산을 넘으면 질문의 우선순위를 낮추거나 세분화 축을 줄인다.
- 원격 설정으로 두고 주기적으로 재검토한다. 트래픽이 늘면 같은 비율이 과잉이 된다.
5번을 구체적으로 보면 이렇다 — p95를 기기 10종 × 릴리스 3개로 나눠 보려면 칸이 30개이고, 칸마다 1000건이 필요하면 총 3만 건이 있어야 한다. 전체 1000건으로는 어느 칸도 신뢰할 수 없다.
"샘플링 비율은 앱 전체에 하나면 된다"가 가장 흔한 잘못이다. 트랜잭션마다 발생 빈도가 수천 배 차이나므로 단일 비율은 저빈도에는 부족하고 고빈도에는 과잉이다. "10%면 충분하다"처럼 비율부터 정하는 것도 순서가 거꾸로다 — 필요 표본 수에서 역산해야 한다. "표본이 많으면 항상 좋다"도 절반만 맞다 — 정확도는 제곱근에 비례하므로 4배를 더 보내야 오차가 절반이 되고, 그 비용은 사용자가 낸다. 또 "전체 표본이 충분하니 쪼개서 봐도 된다"가 특히 위험하다 — 세분화하면 칸마다 표본이 나뉜다. 마지막으로 메트릭 없이 트레이스만 있으면 분모를 모르므로 비율 계산도 보정도 불가능하다.
다시 급식 만족도 조사다. 이번엔 몇 명을 조사할까를 정해야 한다.
"전교생의 10%"라고 정하면 될까? 학년마다 사정이 다르다.
- 1학년은 500명. 10%면 50명. 평균은 대충 나오는데, "상위 5%가 얼마나 불만인가"를 보려면 50명 중 2~3명뿐이라 그 두세 명이 누구냐에 따라 결과가 뒤집힌다.
- 전교 급식 이용자는 10만 명. 10%면 1만 명이다. 이건 너무 많다. 1000명만 조사해도 결과는 거의 같은데, 1만 명을 조사하느라 시간과 종이가 10배 든다.
그래서 순서를 바꿔야 한다. "몇 %를 조사할까"가 아니라 "몇 명이 필요한가"부터 정한다. 평균만 알면 되면 200명, 상위 5%를 보려면 1000명. 그러고 나서 필요한 인원 ÷ 전체 인원으로 비율을 구한다.
그리고 함정이 하나 더 있다. "학년별로 나눠서 보고 싶다"면? 학년이 3개면 학년마다 1000명씩, 총 3000명이 필요하다. 전체 1000명을 조사해놓고 학년별로 나누면 학년당 333명이라 다시 부정확해진다. 나눠 볼 계획이면 그만큼 곱해서 표본을 잡아야 한다.
꼬리 질문
세분화 축이 늘어날수록 필요 표본이 곱해지는 문제를 어떻게 다루는가?
세 가지 전략이 있다.
(1) 축을 계층화한다. 모든 조합을 동시에 보려 하지 말고, 먼저 큰 축으로 나누고 이상이 발견된 칸만 더 쪼갠다. 전체 → 릴리스별 → (이상 발견 시) 기기별 순이다. 이렇게 하면 대부분의 시간에는 적은 표본으로 충분하고, 필요할 때만 깊이 판다.
(2) 축을 묶는다. 기기 모델 30종을 그대로 축으로 쓰지 말고 성능 등급 3~4개로 묶는다. 정보 손실은 있지만 칸당 표본이 10배 가까이 늘어 훨씬 신뢰할 만해진다. 20장 Q7의 "느린 기기" 개념도 이런 묶음이다.
(3) 필요할 때 비율을 올린다. 평소에는 전체 추세만 보고, 조사가 필요해지면 원격 설정으로 해당 트랜잭션의 비율을 올린다(Q3 꼬리질문의 동적 부스팅). 며칠이면 세분화에 충분한 표본이 쌓인다.
실무에서는 (1)+(2)가 기본이고 (3)이 비상 수단이다. 처음부터 모든 조합을 고해상도로 보려 하면 비용이 감당 불가능해진다.
덧붙여 어떤 칸이 표본 부족인지 대시보드에 표시하는 것이 중요하다. 표본 5건짜리 칸의 p95를 다른 칸과 나란히 보여주면 사람이 그대로 믿어버린다. "n < 100"인 칸은 회색 처리하거나 값을 숨기는 편이 안전하다.
표본 수는 충분한데 표본을 낸 사용자 수가 적으면 어떤 문제가 생기는가?
사용자 편향이 생긴다. 트랜잭션 1000건이 사용자 5명에게서 나왔다면, 그 결과는 5명의 기기·네트워크·데이터 규모를 반영할 뿐 전체를 대표하지 않는다.
모바일에서 이 상황은 생각보다 흔하다.
- 헤비 유저 편중. 앱을 많이 쓰는 소수가 트랜잭션의 대부분을 만든다. 균일 샘플링을 하면 표본도 그들에게 편중된다. 그런데 헤비 유저는 로컬 데이터가 많아 성능 특성이 다르다(20장 Q4 꼬리질문).
- 기기 편중. 특정 기기가 더 자주 쓰이면 그쪽 표본이 많아진다.
- 지역·시간대 편중. 전송이 와이파이 우선이면 와이파이 환경 사용자의 데이터가 더 많이 온다 — 정작 셀룰러에서 느린 문제가 과소 대표된다.
대응은 두 가지다. (1) 표본 수와 함께 고유 사용자 수를 항상 표시한다. "n=1000 (users=5)"라고 보이면 사람이 편향을 인지한다. (2) 사용자 단위 샘플링을 고려한다. 트랜잭션마다 판정하는 대신 사용자를 먼저 뽑고 그 사용자의 트랜잭션은 전부 기록하면, 사용자 대표성이 확보된다. 대신 뽑힌 사용자의 배터리·데이터 부담이 커지므로 주기적으로 순환시켜야 한다.
(2)는 세션 리플레이처럼 무거운 계측에서 특히 유용한 방식이다.
트래픽이 계속 늘어나는 앱에서 비율을 그대로 두면 어떻게 되는가?
비용이 트래픽에 비례해 늘어난다. 사용자가 10배가 되면 같은 비율에서 데이터도 10배가 된다. 그런데 통계적으로 필요한 표본 수는 거의 늘지 않는다 — p95를 보는 데 1000건이 필요했다면 사용자가 10배가 돼도 여전히 1000건이면 된다.
즉 고정 비율은 성장하는 앱에서 구조적으로 낭비를 만든다. 정확도는 그대로인데(제곱근 관계라 10배 표본이 오차를 3분의 1로 줄이는 정도) 비용만 10배가 된다.
대응은 고정 비율이 아니라 목표 표본 수 기반 동적 조정이다. "일일 1000건을 목표로" 두고, 실제 발생 건수에 따라 비율을 자동 조정한다. 이게 Dapper가 적응형 샘플링 레이트를 쓴 이유이기도 하다.
/// 목표 표본 수를 유지하도록 비율을 조정한다
func adaptiveRate(target: Int, observedDailyVolume: Int, current: Double) -> Double {
guard observedDailyVolume > 0 else { return current }
let ideal = Double(target) / Double(observedDailyVolume)
// 급격한 변화를 막기 위해 완만하게 수렴시킨다
return min(1.0, max(0.00001, current * 0.7 + ideal * 0.3))
}주의할 점은 비율이 바뀌면 보정 계수도 바뀐다는 것이다(Q3 꼬리질문). 각 트레이스에 그 시점의 비율을 실어 보내야 나중에 집계를 복원할 수 있고, 비율이 자주 바뀔수록 이 기록이 더 중요해진다.
반대 방향도 있다 — 트래픽이 줄면 비율을 올려야 표본이 유지된다. 신규 기능이나 소수 사용자 플로우에서 이 조정이 없으면 데이터가 조용히 사라진다.
Q5. 자동 계측은 무엇을 잡고 무엇을 놓치는가?
자동 계측은 대개 메서드 스위즐링으로 구현되어 URLSession, 뷰 컨트롤러 생명주기, 앱 생명주기처럼 프레임워크가 정의한 경계를 잡는다. 놓치는 것은 우리 도메인의 경계다 — "결제 검증", "이미지 후처리", "로컬 DB 마이그레이션" 같은 구간은 프레임워크가 모르므로 계측되지 않는다. 그래서 자동 계측만 켜두면 트레이스가 네트워크 요청 목록처럼 보이고, 정작 시간을 쓰는 우리 코드는 빈 구간으로 남는다. 여기에 대가도 있다 — 초기화 비용, 호출마다 얹히는 오버헤드, 그리고 다른 SDK와 스위즐링이 겹칠 때의 순서 의존 버그(18장 Q7).
CS 원리
자동 계측의 근본 전략은 가로채기(interception)다. 우리 코드를 수정하지 않고 계측을 넣으려면, 실행 흐름이 반드시 지나는 지점을 가로채야 한다.
Dapper가 같은 문제를 푼 방식이 참고가 된다 — 애플리케이션 수준 투명성을 목표로 삼고, 계측을 RPC·스레딩·제어흐름 같은 공통 라이브러리에 넣었다. 애플리케이션 개발자는 아무것도 안 해도 트레이스가 생긴다. 핵심 계측 코드가 C++ 1000줄 미만이었던 것은 가로챌 지점이 적었기 때문이다.
여기서 자동 계측의 본질적 한계가 나온다. 가로챌 수 있는 것은 공통 경계뿐이고, 공통 경계는 도메인을 모른다.
| 자동 계측 | 수동 계측 | |
|---|---|---|
| 잡는 것 | 프레임워크 경계 (HTTP, 화면 전환, 앱 생명주기) | 도메인 경계 (결제 검증, 동기화 단계) |
| 코드 변경 | 없음 | 필요 |
| 커버리지 | 넓고 얕음 | 좁고 깊음 |
| 이름 | 기계적 (URL, 클래스명) | 의미 있음 |
| 오버헤드 | 전역적 — 안 쓰는 곳에도 | 넣은 곳만 |
| 충돌 위험 | 다른 SDK와 스위즐링 경합 | 없음 |
결론은 둘 다 필요하다는 것이다. 자동 계측이 골격을 만들고, 수동 계측이 그 안을 채운다. 자동만 있으면 "HTTP 요청이 2초 걸렸다"까지만 알고, 수동만 있으면 계측 누락이 많다.
iOS에서는
iOS에서 자동 계측이 흔히 가로채는 지점과 그 방식은 이렇다.
URLSession—URLProtocol등록 또는 델리게이트 스위즐링. 요청 시작·응답 수신 시점을 잡는다.- 뷰 컨트롤러 생명주기 —
viewDidLoad·viewWillAppear·viewDidAppear스위즐링. "화면 로드 시간"의 근거가 된다. - 앱 생명주기 —
UIApplicationDelegate메서드와 알림 관찰. 콜드 스타트 측정. - SwiftUI —
View수정자나onAppear훅. UIKit보다 가로채기가 까다로워 커버리지가 낮은 편이다.
여기서 iOS 고유의 사각지대가 여럿 생긴다.
(1) 화면 로드 시간의 정의가 애매하다. viewDidAppear는 "뷰가 나타났다"이지 "콘텐츠가 표시됐다"가 아니다. 데이터를 비동기로 불러와 채우는 화면이라면, 자동 계측은 빈 화면이 뜬 시점을 로드 완료로 기록한다. 사용자가 체감하는 것은 콘텐츠가 채워진 시점이므로 실제보다 훨씬 좋게 나온다.
(2) Swift Concurrency 경계를 못 따라간다. 스위즐링은 Objective-C 런타임 기반이라 Swift의 async 함수를 가로챌 수 없다. await를 넘나드는 작업의 구간은 수동으로 계측해야 한다.
(3) 스위즐링 자체의 제약. Apple 플랫폼에서 스위즐링은 런타임에 메서드 구현을 교체하는 것이라, 여러 SDK가 같은 메서드를 대상으로 하면 초기화 순서에 따라 동작이 달라진다(18장 Q7 꼬리질문). 재현이 어려운 버그의 원인이 된다.
(4) SwiftUI 커버리지. SwiftUI는 뷰가 값 타입이고 생명주기가 UIKit과 달라 스위즐링 대상이 거의 없다. SwiftUI 앱일수록 수동 계측 비중이 커진다.
실험 · 도구
자동 계측이 실제로 무엇을 잡는지 확인하려면 트레이스를 열어 빈 구간을 찾는 것이 가장 빠르다.
// 자동 계측만 켠 상태의 전형적 트레이스
// └ ProductListViewController — 2400 ms
// └ GET /products — 320 ms
// └ (빈 구간 2080 ms) ← 여기서 뭘 했는지 아무도 모른다
//
// 빈 구간을 수동 계측으로 채운다
@MainActor
final class ProductListViewModel {
func load() async {
await withSpan("products.load") {
let data = await withSpan("products.fetch") { // 자동 계측과 중복될 수 있다
try? await api.get("/products")
}
let items = await withSpan("products.decode") { // ← 자동으로는 안 잡힌다
try? decoder.decode([Product].self, from: data ?? Data())
}
await withSpan("products.enrich") { // ← 안 잡힌다
await enrichWithLocalData(items ?? [])
}
await withSpan("products.render") { // ← 안 잡힌다
applySnapshot(items ?? [])
}
}
}
}
"콘텐츠 표시 시점"을 제대로 재는 것도 수동 작업이다.
import UIKit
/// viewDidAppear가 아니라 "실제 콘텐츠가 그려진 첫 프레임"을 잰다
final class ContentAppearanceTracker {
private var span: SpanHandle?
func begin(_ name: String) { span = tracer.begin(name) }
/// 데이터가 채워진 뒤 호출한다 — 다음 프레임이 실제로 표시된 시점에 종료
///
/// ⚠️ CATransaction.setCompletionBlock 을 쓰면 안 된다. 이 블록은
/// "이 트랜잭션에 추가된 애니메이션이 모두 끝났을 때" 불리고,
/// 애니메이션이 없으면 **즉시** 실행된다. 렌더 서버 도착과 무관하다.
/// 그래서 다음 프레임을 기다리려면 CADisplayLink 를 한 번 돌린다.
func markContentReady() {
var link: CADisplayLink?
link = CADisplayLink(target: BlockTarget { [weak self] in
link?.invalidate() // 한 프레임만 기다린다
self?.span?.end()
self?.span = nil
}, selector: #selector(BlockTarget.fire))
link?.add(to: .main, forMode: .common)
}
/// CADisplayLink 가 셀렉터를 요구하므로 얇은 타깃을 둔다
final class BlockTarget: NSObject {
private let block: () -> Void
init(_ block: @escaping () -> Void) { self.block = block }
@objc func fire() { block() }
}
}
// 사용
tracker.begin("screen.products.contentReady")
// … 비동기 로드 …
collectionView.reloadData()
tracker.markContentReady() // ← viewDidAppear 가 아니라 '콘텐츠가 그려진 프레임' 시점
스위즐링 충돌 여부는 런타임에 확인할 수 있다.
import ObjectiveC
/// 특정 메서드가 스위즐링됐는지 확인 — 구현 주소가 원래 이미지 밖이면 교체된 것
func isSwizzled(_ cls: AnyClass, _ sel: Selector) -> Bool {
guard let m = class_getInstanceMethod(cls, sel) else { return false }
let imp = method_getImplementation(m)
var info = Dl_info()
guard dladdr(UnsafeRawPointer(imp), &info) != 0,
let fname = info.dli_fname else { return false }
let owner = String(cString: fname)
// UIKit 메서드인데 구현이 UIKit 바이너리 밖에 있으면 누군가 바꾼 것
return !owner.contains("UIKit")
}
#if DEBUG
print("viewDidAppear 스위즐링됨:", isSwizzled(UIViewController.self, #selector(UIViewController.viewDidAppear(_:))))
#endif
프로젝트 적용
자동·수동 계측의 조합 전략은 이렇다.
- 자동 계측은 필요한 것만 켠다. 기본값이 "전부 켜짐"인 경우가 많은데, 안 쓰는 계측은 스위즐링 대상과 오버헤드만 늘린다. 파일 I/O 추적이나 세션 리플레이처럼 무거운 것은 특히 검토한다.
- 주요 플로우는 반드시 수동 계측한다. 결제·검색·업로드처럼 비즈니스 핵심 플로우는 도메인 구간으로 직접 나눈다.
- "콘텐츠 표시" 시점을 직접 정의한다. 자동 계측의 화면 로드 시간을 그대로 믿지 말고, 각 화면의 "준비 완료" 조건을 코드로 명시한다.
- 스위즐링 충돌을 의심할 근거를 만든다. SDK 초기화 순서를 코드 한 곳에 모아두고 주석으로 이유를 남긴다. 문제가 생겼을 때 순서를 바꿔볼 수 있는 것과 없는 것의 차이가 크다.
- SwiftUI 비중이 크면 수동 계측 예산을 더 잡는다. 자동 커버리지가 낮다는 것을 전제로 계획한다.
"자동 계측을 켜면 앱 전체가 계측된다"가 가장 큰 오해다. 잡히는 것은 프레임워크 경계뿐이고, 대개 시간의 대부분은 그 사이 빈 구간에서 소모된다. "화면 로드 시간이 좋게 나오니 빠른 앱"도 위험하다 — viewDidAppear 기준이면 빈 화면이 뜬 시점을 잰 것일 수 있다. "자동 계측은 코드를 안 건드리니 위험이 없다"도 틀렸다 — 스위즐링은 런타임 동작을 바꾸고 다른 SDK와 충돌할 수 있다. 또 "수동 계측은 자동보다 정확하니 자동을 끄면 된다"도 균형이 안 맞다 — 자동 계측은 계측을 깜빡한 곳을 메워주는 안전망 역할을 한다.
공장에 자동 기록 장치를 달았다고 하자. 이 장치는 문에 달려 있어서, 물건이 어느 방에 언제 들어가고 나왔는지를 자동으로 기록한다. 아무도 손을 안 대도 기록이 쌓인다.
편리하다. 그런데 기록을 보면 이렇다. "A방 입장 09:00 → A방 퇴장 11:00". 2시간 걸렸다는 건 알겠는데, 그 방 안에서 뭘 했는지는 전혀 모른다. 문에만 장치가 있으니까.
그리고 방 안에서 하는 일이 진짜 오래 걸리는 일이다. 문을 지나는 데는 1초도 안 걸린다.
그래서 방 안에도 기록을 남겨야 하는데, 이건 자동으로 안 된다. 방 안에서 무슨 단계를 거치는지는 그 일을 아는 사람만 알기 때문이다. 문 장치는 "여기서 조립하고 저기서 검사한다"는 걸 모른다.
또 하나 조심할 게 있다. 문 장치를 여러 회사가 각자 달면 서로 겹쳐서, 어느 장치가 먼저 기록하느냐에 따라 결과가 달라진다. 심하면 문이 안 열린다.
꼬리 질문
"화면 로드 완료"를 어떻게 정의해야 사용자 체감과 맞는가?
정의 후보가 여럿이고 각각 다른 값이 나온다.
viewDidAppear— 뷰가 화면에 붙은 시점. 가장 빠르고 가장 부정확하다. 빈 화면일 수 있다.- 데이터 수신 완료 — 네트워크 응답을 받은 시점. 아직 화면에 안 그려졌다.
- 첫 콘텐츠 프레임 — 실제 데이터가 그려진 프레임이 화면에 나온 시점. 체감에 가장 가깝다.
- 상호작용 가능 — 스크롤·탭이 반응하는 시점. 메인스레드가 자유로워진 때.
실무 권장은 "첫 콘텐츠 프레임"을 기본 지표로 삼고, 필요하면 "상호작용 가능"을 보조로 두는 것이다. 측정 방법은 위 실험 코드처럼 CATransaction 완료 블록을 쓰거나, CADisplayLink로 다음 프레임을 기다린다.
화면마다 "콘텐츠"의 정의가 다르다는 점도 중요하다. 목록 화면은 첫 화면분 셀이 채워지면 완료지만, 상세 화면은 주요 정보 블록이 다 채워져야 한다. 이걸 화면마다 명시적으로 정의하고 코드에 남기면, 나중에 지표가 흔들릴 때 정의 변경 때문인지 실제 회귀인지 구분할 수 있다.
플레이스홀더(스켈레톤 UI)를 쓰는 경우가 특히 까다롭다 — 스켈레톤이 뜬 시점을 완료로 치면 지표는 좋아지지만 사용자는 여전히 기다린다. 실제 데이터가 채워진 시점을 재야 한다.
SwiftUI에서 자동 계측이 어려운 이유와 대안은?
이유가 셋이다.
(1) 값 타입이라 스위즐링 대상이 없다. SwiftUI의 View는 구조체이고 Objective-C 런타임 위에 있지 않다. 메서드 구현을 교체할 대상 자체가 없다.
(2) 생명주기 개념이 다르다. UIKit의 viewDidLoad·viewDidAppear에 정확히 대응하는 것이 없다. onAppear는 뷰가 나타날 때마다 여러 번 불릴 수 있고, body는 상태가 바뀔 때마다 재평가되므로 "한 번 로드"라는 개념과 맞지 않는다.
(3) 뷰 식별이 어렵다. UIKit은 클래스 이름이 자연스러운 span 이름이 되지만, SwiftUI 뷰는 제네릭 타입이 중첩되어 타입 이름이 읽을 수 없게 길다. 그대로 쓰면 span 이름이 엉망이 된다.
대안은 명시적 계측을 뷰 수정자로 감싸는 것이다.
struct TracedScreen: ViewModifier {
let name: String
@State private var span: SpanHandle?
func body(content: Content) -> some View {
content
.onAppear { if span == nil { span = tracer.begin("screen.\(name)") } }
.onDisappear { span?.end(); span = nil }
}
}
extension View {
func traced(_ name: String) -> some View { modifier(TracedScreen(name: name)) }
}
// 사용 — 이름을 우리가 정하므로 읽을 수 있고 안정적이다
ProductListView().traced("products")이 방식의 장점은 이름을 우리가 통제한다는 것이다. 자동 계측이 만드는 기계적 이름보다 집계와 검색에 훨씬 유리하다(18장 Q6의 그룹핑 원리와 같다). 단점은 빠뜨릴 수 있다는 것이고, 그래서 코드 리뷰 체크리스트에 넣거나 lint 규칙으로 강제하는 팀도 있다.
자동 계측과 수동 계측이 같은 구간을 이중으로 재면 어떤 문제가 생기는가?
세 가지 문제가 있다.
(1) 트레이스가 지저분해진다. 같은 HTTP 요청이 자동 span과 수동 span으로 두 번 나타나 중첩된다. 읽는 사람이 "이게 두 번 요청한 건가?"라고 오해할 수 있다.
(2) 집계가 왜곡된다. "HTTP 구간의 총 소요 시간"을 계산할 때 같은 시간이 두 번 세어진다. 전체 시간 대비 비율을 내면 100%를 넘는 이상한 값이 나온다.
(3) 오버헤드가 두 배. 같은 일을 두 번 한다.
해결 방법은 셋이다.
(a) 자동 계측을 끄고 수동만 쓴다. 통제가 확실하지만 계측 누락 위험이 생긴다.
(b) 수동 span을 상위 개념으로 둔다. 자동 계측이 http.client를 만든다면, 수동은 products.fetch처럼 도메인 이름으로 감싸고 그 안에 자동 span이 자식으로 들어오게 한다. 이름이 다르므로 중복으로 보이지 않고, 오히려 "이 도메인 작업이 어떤 HTTP 요청으로 구성되는가"가 드러나 유용하다.
(c) 집계 시 특정 계층만 센다. span에 instrumentation=auto|manual 속성을 붙여 집계 쿼리에서 한쪽만 쓴다.
실무 권장은 (b)다. 이중 계측을 없애려 하기보다 계층을 다르게 만드는 것이 정보량도 늘고 혼동도 없다.
Q6. 프로파일링은 Time Profiler와 무엇이 다른가?
원리는 같고 실행 환경이 다르다. 둘 다 주기적으로 스레드 스택을 샘플링해 "어느 함수가 CPU 시간을 많이 썼는가"를 통계적으로 추정한다. 차이는 Time Profiler는 개발 기기에 Instruments를 붙여 돌리는 것이고, 프로덕션 프로파일러는 사용자 기기에서 상시 돌며 결과를 서버로 보낸다는 점이다. 이 차이가 모든 제약을 만든다 — 프로덕션 프로파일러는 샘플링 주기를 낮추고, 샘플링된 트랜잭션에만 붙이고, 배터리 예산 안에서 동작해야 한다. 그래서 해상도는 Time Profiler가 압도적으로 높고, 대표성은 프로덕션 프로파일러가 높다. 개발 기기 한 대의 프로파일은 실사용자 분포를 대표하지 않는다.
CS 원리
샘플링 프로파일러의 원리는 통계적 추정이다. 모든 함수 호출을 기록하는 것(instrumenting profiler)은 오버헤드가 너무 커서, 대신 일정 주기로 스택을 훔쳐본다.
1 ms마다 스택을 뜬다:
t=0ms [main, viewDidLoad, parseJSON, decodeObject]
t=1ms [main, viewDidLoad, parseJSON, decodeObject]
t=2ms [main, viewDidLoad, parseJSON, decodeArray]
t=3ms [main, viewDidLoad, layoutSubviews]
…
1000개 샘플 중 parseJSON이 700개에 등장 → CPU 시간의 약 70%로 추정
여기서 중요한 성질이 셋 나온다.
- 확률적이다. 샘플이 적으면 오차가 크다. 100개 샘플로 "이 함수가 5% 썼다"는 신뢰할 수 없다.
- 짧고 잦은 함수를 놓칠 수 있다. 샘플링 주기보다 훨씬 짧게 실행되는 함수는 우연히 샘플에 안 잡힐 수 있다. 반대로 총 시간이 길면 개별 호출이 짧아도 통계적으로 드러난다.
- 대기 시간은 안 잡힌다. 스레드가 블록돼 있으면 CPU를 안 쓰므로 기본 설정에서는 샘플에 안 나온다(20장 Q5 꼬리질문). 그래서 "느린데 프로파일러에 안 보이는" 상황이 생긴다.
세 번째가 특히 중요하다. 프로파일러는 "바쁜" 문제를 잡고, 행 도구는 "기다리는" 문제를 잡는다. 어느 쪽인지 모르면 잘못된 도구로 몇 시간을 헤맨다.
iOS에서는
두 환경의 차이를 구체적으로 정리하면 이렇다.
| Time Profiler (Instruments) | 프로덕션 프로파일러 | |
|---|---|---|
| 실행 위치 | 개발 기기 + Mac | 사용자 기기 |
| 표본 대표성 | 기기 1대, 상황 1개 | 실사용 분포 |
| 샘플링 주기 | 1 ms 수준까지 | 훨씬 낮음 |
| 지속 시간 | 원하는 만큼 | 트랜잭션 구간에 한정 |
| 오버헤드 허용치 | 높음 (개발자만 겪음) | 매우 낮음 (사용자가 겪음) |
| 부가 정보 | 같은 트레이스에 Allocations·Metal 등 다른 계측기 추가 가능 | CPU 스택 중심 |
| 대기 시간 | 옵션으로 포함 가능 | 대개 제외 |
실무적으로 중요한 것은 서로를 대체할 수 없다는 점이다.
Time Profiler만 쓰면 — 개발자의 최신 기기, 좋은 네트워크, 적은 데이터에서만 본 결과다. 20장 Q7에서 본 "재현 안 되는 이유 세 가지"가 그대로 적용된다. 실사용자의 병목은 다른 곳일 수 있다.
프로덕션 프로파일러만 쓰면 — 해상도가 낮아 세밀한 최적화 근거로 쓰기 어렵고, 메모리·I/O 같은 다른 축을 못 본다.
그래서 순서가 있다. 프로덕션 프로파일로 "어느 함수가 실사용자에게서 무겁나"를 좁히고, Time Profiler로 그 함수를 정밀 분석한다. 20장 Q7의 좁히기 절차와 같은 구조다.
실험 · 도구
샘플링 프로파일러를 직접 만들어보면 원리와 한계가 동시에 이해된다.
import Foundation
/// 아주 단순한 샘플링 프로파일러 — 메인스레드 스택을 주기적으로 뜬다
final class MiniProfiler {
private var counts: [String: Int] = [:]
private var thread: Thread?
private let interval: TimeInterval
init(intervalMs: Double = 10) { self.interval = intervalMs / 1000 }
func start() {
let t = Thread { [weak self] in
guard let self else { return }
while !Thread.current.isCancelled {
Thread.sleep(forTimeInterval: self.interval)
// ⚠️ 실제 구현은 메인스레드를 정지시키고 스택을 언와인딩해야 한다.
// 여기서는 개념만 — 20장 Q3 꼬리질문의 위험이 그대로 적용된다.
let frames = Thread.callStackSymbols // (자기 스레드 기준이라 데모용)
let key = frames.prefix(4).joined(separator: " < ")
self.counts[key, default: 0] += 1
}
}
t.qualityOfService = .utility
t.start()
thread = t
}
func stop() -> [(String, Int)] {
thread?.cancel()
let total = counts.values.reduce(0, +)
return counts.sorted { $0.value > $1.value }.prefix(10).map { k, v in
(String(format: "%.1f%% %@", Double(v) / Double(total) * 100, k), v)
}
}
}
샘플 수와 정확도의 관계도 실험으로 확인할 수 있다.
// 실제 비율이 5%인 함수를, 샘플 수를 바꿔가며 추정해본다
func estimate(trueRatio: Double, samples: Int) -> Double {
let hits = (0..<samples).filter { _ in Double.random(in: 0...1) < trueRatio }.count
return Double(hits) / Double(samples) * 100
}
for n in [50, 200, 1000, 10_000] {
let runs = (0..<5).map { _ in estimate(trueRatio: 0.05, samples: n) }
print(String(format: "샘플 %6d개 → 추정치: %@", n,
runs.map { String(format: "%.1f%%", $0) }.joined(separator: ", ")))
}
// 샘플 50개 → 추정치: 2.0%, 8.0%, 4.0%, 0.0%, 6.0% ← 편차가 매우 크다
// 샘플 200개 → 추정치: 4.5%, 6.0%, 3.5%, 5.5%, 4.0%
// 샘플 1000개 → 추정치: 5.1%, 4.7%, 5.3%, 4.9%, 5.2%
// 샘플 10000개 → 추정치: 5.02%, 4.98%, 5.04%, 4.97%, 5.01% ← 안정
//
// → 프로덕션 프로파일러는 샘플링 주기가 낮아 샘플 수가 적다.
// 그래서 "5% 차이"를 논하기 어렵고, "이 함수가 압도적"만 신뢰할 수 있다.
Instruments 쪽 실무 팁 셋.
# 1) 대기 스레드도 보고 싶으면 — Time Profiler의 스레드 상태 옵션을 켠다
# (블록된 스레드가 샘플에 포함되어 "기다리는" 문제도 보인다)
# 2) 릴리스 구성으로 프로파일링한다
# 디버그 빌드는 최적화가 없어 프로파일이 완전히 다르게 나온다.
# Product › Scheme › Edit Scheme › Profile › Build Configuration = Release
# 3) 느린 기기에서 프로파일링한다
# 최신 기기의 프로파일은 실사용자 병목과 다를 수 있다.
프로젝트 적용
두 도구의 역할 분담을 팀 절차로 만들면 이렇다.
- 프로덕션 프로파일 / 지표로 대상 선정. "실사용자에게서 시간을 많이 쓰는 함수"를 찾는다. 여기서 개발자의 직관이 자주 틀린다.
- 재현 시나리오 구성. 20장 Q7의 조건 — 느린 기기, 나쁜 네트워크, 큰 데이터.
- Time Profiler로 정밀 분석. 릴리스 구성, 실기기. 콜 트리를 펼쳐 어느 경로가 무거운지 본다.
- 수정 후 두 곳에서 검증. Instruments에서 개선을 확인하고, 다음 릴리스의 프로덕션 지표로 실사용자에게도 개선됐는지 확인한다.
4번이 자주 빠진다. 개발 기기에서 30% 빨라진 것이 실사용자에게서는 0%일 수 있다 — 병목이 달랐거나, 개선한 경로를 실사용자는 잘 안 타거나, 다른 병목이 지배적이거나.
그리고 프로덕션 프로파일링을 켤 때는 18장 Q7의 비용 항목을 반드시 측정한다. 프로파일러는 상시 도는 계측이라 배터리와 CPU에 지속적 부담을 준다. 벤더가 제시하는 오버헤드 수치는 특정 조건의 값이므로 자기 앱에서 직접 재야 한다.
"프로파일러에 안 보이면 문제가 없다"는 틀렸다 — 대기 시간은 CPU를 안 쓰므로 기본 설정에서 안 잡힌다. 동기 네트워킹이 대표적이다(20장 Q5). "샘플링이라 부정확하니 믿을 수 없다"도 과장이다 — 샘플이 충분하면 지배적 병목은 매우 신뢰할 수 있다. 부정확한 것은 작은 차이를 논할 때다. "Time Profiler 결과가 실사용자 병목"이라는 가정도 위험하다 — 기기 1대, 상황 1개의 표본이다. "프로덕션 프로파일러를 켜면 Time Profiler는 필요 없다"도 틀렸다 — 해상도와 부가 정보가 비교가 안 된다. 또 "디버그 빌드로 프로파일링해도 비슷하다"도 틀렸다 — 최적화가 없으면 프로파일이 완전히 다르다.
공장에서 어느 공정이 시간을 많이 잡아먹나를 알고 싶다. 모든 작업을 초 단위로 다 기록하면 정확하지만, 기록하느라 일이 안 된다.
그래서 다른 방법을 쓴다. 10초마다 한 번씩 공장을 둘러보고, 사람들이 지금 뭘 하고 있는지만 적는다. 하루 종일 하면 수천 번 적게 되고, 그 중 "조립 중"이 70%였다면 조립이 시간의 70%를 차지한다고 볼 수 있다. 이게 샘플링 프로파일링이다.
여기에 두 가지 함정이 있다.
첫째, 몇 번 안 둘러보면 부정확하다. 다섯 번 둘러보고 "조립이 20%"라고 하면 우연일 수 있다. 많이 볼수록 정확해진다.
둘째, 가만히 기다리는 사람은 안 보인다. "재료가 오기를 기다리는 중"인 사람은 아무것도 안 하고 있으니, "일하는 사람"만 세는 방식에서는 빠진다. 그런데 진짜 문제는 그 기다림일 수 있다.
그리고 마지막으로 — 우리 공장을 둘러본 결과가 다른 공장에도 맞을까? 우리 공장은 최신 설비고 재료도 넉넉하다. 오래된 설비를 쓰는 공장은 완전히 다른 곳에서 막힐 수 있다. 그래서 여러 공장의 기록을 함께 봐야 한다.
꼬리 질문
샘플링 주기를 높이면 정확해지는데 왜 프로덕션에서는 낮추는가?
오버헤드가 주기에 반비례하기 때문이다. 1 ms마다 샘플링하면 초당 1000번 스택을 떠야 하고, 스택 언와인딩은 공짜가 아니다. 게다가 매번 대상 스레드를 잠시 멈춰야 하므로 그 자체가 실행을 방해한다.
비용 항목을 나눠보면 이렇다.
- CPU — 샘플링과 언와인딩 자체의 계산.
- 스레드 정지 — 샘플링할 때마다 대상이 잠깐 멈춘다. 잦으면 누적된다.
- 메모리 — 샘플을 모아둘 버퍼.
- 배터리 — CPU를 계속 깨우므로 저전력 상태로 못 내려간다. 19장 Q5의
WAKEUPS문제와 같은 축이다. - 전송 — 샘플이 많으면 보낼 데이터도 많다.
개발 중에는 이 비용을 개발자만 감당하므로 높은 주기가 정당하다. 프로덕션에서는 사용자가 배터리와 데이터로 지불하므로 훨씬 보수적이어야 한다(18장 Q7).
보상 전략은 샘플을 여러 사용자·여러 세션에서 합치는 것이다. 한 세션의 샘플은 적어도, 수천 세션을 합치면 통계적으로 충분해진다. 이건 프로덕션 프로파일러만 할 수 있는 일이고, Time Profiler로는 불가능하다 — 낮은 해상도를 넓은 표본으로 보상하는 구조다.
프로파일에서 상위에 뜬 함수가 실제로는 문제가 아닌 경우는?
몇 가지 전형적 사례가 있다.
(1) 원래 무거운 게 정상인 함수. 이미지 디코딩이나 압축은 원래 CPU를 쓴다. 프로파일 상위에 뜬다고 "버그"는 아니다. 질문은 "이 작업이 여기서 이만큼 필요한가"이지 "이 함수가 느린가"가 아니다.
(2) 인라이닝으로 비용이 몰린 경우. 21장 Q4에서 본 대로 인라인되면 여러 함수의 시간이 하나의 프레임에 합산돼 보인다. 실제 원인은 그 안에 인라인된 다른 코드일 수 있다.
(3) 시스템 함수가 상위에 뜬 경우. objc_msgSend나 메모리 할당 함수가 상위에 오는 것은 어느 특정 코드가 아니라 전반적 패턴의 문제다. "동적 디스패치가 너무 많다", "할당이 너무 잦다"처럼 구조를 봐야 한다.
(4) 재귀·공통 유틸. 여러 곳에서 호출되는 유틸이 합산되어 상위에 뜬다. 역방향 콜 트리(누가 이 함수를 불렀나)를 봐야 어느 호출자가 지배적인지 알 수 있다.
그래서 프로파일을 읽을 때는 self time과 total time을 구분하고, 콜 트리를 양방향으로 봐야 한다. self가 크면 그 함수 자체가 무겁고, total만 크면 그 아래가 무거운 것이다.
개발 기기에서 개선했는데 프로덕션 지표가 안 움직이는 이유는?
20장 Q7 꼬리질문과 겹치지만, 프로파일링 맥락에서 원인이 더 있다.
(1) 병목이 달랐다. 개발 기기의 병목과 실사용자의 병목이 다르다. 최신 기기에서는 CPU가 빨라 I/O가 병목이지만, 구형 기기에서는 CPU가 병목일 수 있다. 개선한 것이 실사용자의 병목이 아니었던 것이다.
(2) 그 경로를 잘 안 탄다. 개발 중에 반복 테스트한 경로가 실사용에서는 드물게 쓰일 수 있다. 사용 빈도 데이터를 함께 봐야 한다.
(3) 개선폭이 전체에서 작다. 어떤 함수가 프로파일의 30%를 차지해도, 그 함수가 전체 사용자 대기 시간의 3%일 수 있다. 프로파일은 CPU 시간의 분포를 보여주지 사용자 대기 시간의 분포를 보여주지 않는다.
(4) 다른 것이 동시에 나빠졌다. 같은 릴리스의 다른 변경이 성능을 깎아 상쇄됐다.
(5) 측정 조건이 달라졌다. 20장 Q4 꼬리질문의 채택 곡선·기기 분포 교란.
대응은 가설을 미리 정량화하는 것이다. "이 함수가 전체 시간의 X%를 차지하고 절반으로 줄이면 전체가 X/2% 개선된다"를 예측해두면, 실제 결과와 비교해 어느 가정이 틀렸는지 알 수 있다. 예측 없이 고치면 "안 움직였다"는 사실만 남고 학습이 없다.
Q7. 계측 오버헤드를 어떻게 측정하고 예산화하는가?
벤더 수치를 믿지 말고 직접 재야 한다. 공개된 오버헤드 수치는 특정 조건에서 측정한 값이고, 계측 지점 수·샘플링 비율·기기 성능에 따라 크게 달라진다. 재는 방법은 A/B 빌드 비교다 — 계측을 켠 빌드와 끈 빌드를 만들어 콜드 스타트, 주요 플로우 소요 시간, 메모리 footprint, 배터리를 비교한다. 예산은 사용자 체감 구간을 기준으로 잡는다. Dapper가 보고한 스팬 생성 비용은 루트 204 ns, 비루트 176 ns 수준이었는데, 이 숫자가 말해주는 것은 span 하나의 비용은 무시할 만하지만 수천 개면 아니라는 것이다. 결국 지점 수를 통제하는 것이 오버헤드 관리의 본질이다.
CS 원리
계측 오버헤드는 고정 비용 + 변동 비용으로 나뉜다.
| 고정 비용 | 변동 비용 | |
|---|---|---|
| 발생 | 앱 시작 1회 | 계측 지점마다 |
| 항목 | SDK 초기화, 스위즐링, 핸들러 설치 | span 생성·종료, 샘플링 판정, 버퍼 기록 |
| 사용자 체감 | 매우 큼 (실행 지연) | 지점 수에 비례 |
| 줄이는 법 | 초기화 지연·기능 축소 | span 수 줄이기·샘플링 |
Dapper의 측정값이 이 구조를 잘 보여준다. 루트 span 생성·소멸에 평균 204 ns, 비루트에는 176 ns. 나노초 단위라 하나로는 무시할 만하다. 그런데 트레이스당 span이 수천 개면 밀리초 단위가 되고, 초당 수백 트레이스면 유의미해진다.
여기서 나오는 원칙이 "단가가 아니라 총량을 본다"는 것이다. "span 하나가 200 ns니까 괜찮다"는 판단은 몇 개를 만드는지 모르면 무의미하다.
그리고 비용의 위치가 중요하다는 점을 다시 짚어야 한다(18장 Q7). 총량이 같아도 콜드 스타트에 있는 1 ms와 백그라운드 작업 중의 10 ms는 체감이 전혀 다르다. 예산은 총량이 아니라 구간별로 잡아야 한다.
iOS에서는
iOS에서 측정할 항목과 방법을 정리하면 이렇다.
| 항목 | 측정 방법 | 제안 예산 |
|---|---|---|
| 콜드 스타트 | Instruments App Launch / MXAppLaunchMetric | 총 실행 시간의 5% 이내 |
| 주요 플로우 지연 | A/B 빌드로 같은 시나리오 반복 측정 | 구간당 1% 이내 |
| 메모리 | phys_footprint 비교 (19장 Q5) | 절대값으로 상한 설정 |
| 디스크 | 앱 컨테이너 크기 추이 | 상한 + 정리 주기 |
| 배터리 | Instruments Energy Log / MetricKit 전력 지표 | 기준선 대비 증가율 |
| 바이너리 크기 | App Size Report (26장) | SDK당 상한 |
| 네트워크 | 일일 전송량 (셀룰러 구분) | 사용자당 일일 상한 |
콜드 스타트가 1순위인 이유는 18장 Q7에서 본 대로다 — 사용자가 앱을 열 때마다 지불하고, 그 순간이 가장 민감하다. 그리고 관측 SDK는 크래시를 놓치지 않으려고 아주 이른 시점에 초기화되므로 정확히 그 자리에 얹힌다.
네트워크 전송량도 모바일 고유의 항목이다. 셀룰러에서 계측 데이터를 계속 보내면 사용자 데이터 요금을 쓴다. 배치 전송과 와이파이 우선 전송이 기본이어야 한다.
실험 · 도구
A/B 빌드 비교가 가장 정직한 방법이다. 컴파일 플래그로 계측을 통째로 끈다.
// Build Settings › Swift Compiler - Custom Flags 에 -DOBSERVABILITY_ENABLED 추가/제거
@main
struct ShopApp: App {
init() {
#if OBSERVABILITY_ENABLED
let t0 = ContinuousClock.now
ObservabilitySDK.start(config: .default)
let dt = ContinuousClock.now - t0
LaunchMetrics.record("sdk_init_ms", ms(dt))
#endif
}
var body: some Scene { WindowGroup { RootView() } }
}
# 두 빌드를 같은 기기에서 번갈아 여러 번 실행해 콜드 스타트를 비교한다
# (한 번만 재면 캐시 상태·시스템 부하 때문에 편차가 크다)
# 1) 실기기 콜드 스타트 — Instruments 의 App Launch 템플릿이 표준이다.
# (시뮬레이터 측정은 CPU/GPU 가 Mac 것이라 참고도 되지 않는다)
#
# 2) pre-main 구간 분해 — 스킴 환경변수에 DYLD_PRINT_STATISTICS=1 (26장 Q3)
#
# 3) 자체 계측 — 첫 프레임에 signpost 를 찍고 로그로 회수한다
# (위 launchSignposter 방식) 후 아카이브에서 구간을 뽑는다:
log collect --device --last 10m --output launch.logarchive
log show launch.logarchive --signpost --style compact \
--predicate 'subsystem == "com.example.shop" AND category == "launch"'
# 2) 더 신뢰할 수 있는 방법 — MetricKit 히스토그램을 릴리스별로 비교
# MXAppLaunchMetric.histogrammedTimeToFirstDraw
# 실사용자 분포라 기기·상황이 섞여 들어온다 (18장 Q7)
메모리와 span 수를 함께 재면 변동 비용의 실체가 보인다.
/// 계측 지점 수와 실제 비용을 함께 기록한다 — "단가가 아니라 총량" 원칙
final class InstrumentationBudget {
private static var spanCount = 0
private static var totalOverheadNs: UInt64 = 0
static func measured<T>(_ name: String, _ body: () throws -> T) rethrows -> T {
let t0 = ContinuousClock.now
let span = tracer.begin(name)
let createCost = ContinuousClock.now - t0
defer {
let t1 = ContinuousClock.now
tracer.end(span)
let endCost = ContinuousClock.now - t1
spanCount += 1
totalOverheadNs += UInt64((createCost + endCost).components.attoseconds / 1_000_000_000)
}
return try body()
}
/// 세션 종료 시 보고 — 트랜잭션당 span 수가 예상보다 많은지 확인
static func report() {
let avgNs = spanCount > 0 ? totalOverheadNs / UInt64(spanCount) : 0
Logger(subsystem: "com.example.shop", category: "budget").notice(
"span \(spanCount, privacy: .public)개, 총 오버헤드 \(totalOverheadNs / 1_000_000, privacy: .public)ms, 평균 \(avgNs, privacy: .public)ns")
// Dapper 보고값(루트 204ns / 비루트 176ns)과 비교해볼 수 있다.
// 크게 벗어나면 우리 구현에 불필요한 비용이 있다는 뜻이다.
}
}
프로젝트 적용
오버헤드 관리를 절차로 만들면 이렇다.
- 도입 시 기준선을 측정한다. SDK를 넣기 전에 콜드 스타트·메모리·바이너리 크기를 기록한다. 넣은 뒤에 재면 비교 대상이 없다.
- 예산을 숫자로 못 박는다. "콜드 스타트 증가분 5% 이내" 같은 기준을 정하고 CI에서 회귀를 감시한다. 숫자가 없으면 매 릴리스마다 조금씩 는다.
- 기능 단위로 켜고 끈다. 자동 계측 항목, 세션 리플레이, 프로파일링을 각각 원격 설정으로 제어할 수 있게 해두면, 문제가 생겼을 때 릴리스 없이 끌 수 있다.
- 주기적으로 "이 계측이 쓰였는가"를 검토한다. 18장 Q7 꼬리질문의 방법 — 대시보드 참조 여부, 사고 회고 기여, 소유자 부재를 점검하고 안 쓰는 것은 샘플링을 낮췄다가 제거한다.
- span 수 상한을 둔다. 트랜잭션당 span 수에 상한(예: 100)을 걸고 초과분은 버리되 버렸다는 사실을 기록한다. 조용히 자르면 트레이스가 잘린 것을 모른다.
"문서에 오버헤드 X% 미만이라 쓰여 있으니 괜찮다"가 가장 흔한 오해다. 그 수치는 특정 측정 조건의 값이고, 계측 지점을 늘리거나 샘플링을 올리면 달라진다. "span 하나가 200 ns라 무시할 만하다"도 개수를 모르면 판단할 수 없다. "오버헤드는 CPU만 보면 된다"도 부족하다 — 메모리·디스크·배터리·바이너리 크기·네트워크가 모두 비용이고, 그중 메모리는 Jetsam 종료로 이어질 수 있다(19장). "시뮬레이터에서 재면 된다"도 위험하다 — 실기기, 특히 느린 기기에서 비용이 훨씬 크다. 마지막으로 "한 번 재고 끝"이 아니라, 계측 지점은 시간이 지나며 계속 늘어나므로 주기적 재측정이 필요하다.
가게에 CCTV를 다는 것과 같다. 카메라 하나 값은 얼마 안 한다. 전기도 조금밖에 안 먹는다. 그래서 "하나 더 달지 뭐"가 반복된다.
그런데 어느 날 보니 카메라가 200대다. 전기요금이 눈에 띄게 늘었고, 녹화 서버가 자리를 다 차지했고, 인터넷이 느려졌다. 각각은 사소했는데 합치니 문제가 됐다.
여기서 두 가지를 알 수 있다.
첫째, "하나당 얼마"가 아니라 "총 몇 개"를 봐야 한다. 카탈로그에 "소비전력 5W"라고 적혀 있어도, 몇 대를 달지 모르면 아무 판단도 못 한다.
둘째, 어디에 다는지가 중요하다. 창고에 단 카메라는 아무도 신경 안 쓴다. 그런데 계산대 바로 위에 달아서 결제가 1초씩 늦어지면 손님이 다 알아챈다. 같은 카메라인데 위치에 따라 체감이 완전히 다르다.
그래서 "계산대 근처는 카메라 2대까지"처럼 자리별 한도를 정해두는 게 좋다. 그리고 가끔 돌아보며 "이 카메라 영상을 지난 6개월 동안 한 번이라도 봤나?"를 물어야 한다. 안 봤으면 전기만 먹고 있었던 것이다.
꼬리 질문
계측 오버헤드가 계측 자체에 의해 왜곡되는 문제는 어떻게 다루는가?
고전적인 관측자 효과 문제다. 계측 코드가 측정 대상의 실행 시간에 포함되므로, "이 구간이 100 ms"라는 값에는 계측 비용도 섞여 있다.
다루는 방법이 셋 있다.
(1) 계측 비용을 별도로 측정해 차감한다. span 생성·종료 자체의 비용을 벤치마크로 재두고, 구간 시간에서 span 수 × 단가를 뺀다. 근사치지만 크기 감을 잡는 데 유용하다. Dapper가 스팬 생성 비용을 나노초 단위로 보고한 것도 이런 용도다.
(2) A/B로 총량만 비교한다. 개별 구간의 정확한 값을 구하려 하지 말고, 계측 켠 빌드와 끈 빌드의 전체 소요 시간을 비교한다. 차이가 곧 총 오버헤드다. 개별 구간의 절대값은 부정확해도 구간 간 비율은 여전히 유효하므로 병목 판단에는 문제가 없다.
(3) 계측을 구간 밖에 둔다. span 시작 시각을 계측 코드 실행 후에, 종료 시각을 계측 코드 실행 전에 찍으면 계측 비용이 구간에서 빠진다. 완벽하진 않지만 개선된다.
실무적으로는 (2)가 가장 실용적이다. 절대값의 정확도보다 상대 비교가 중요하기 때문이다. "결제가 2초 걸리는데 그중 HTTP가 70%"라는 결론은 계측 오버헤드가 조금 섞여도 바뀌지 않는다.
span 수 상한에 걸려 잘린 트레이스를 어떻게 다뤄야 하는가?
가장 중요한 것은 잘렸다는 사실을 명시하는 것이다. 조용히 자르면 그 트레이스를 보는 사람이 "이게 전부"라고 오해하고, 없는 구간을 "빠르다"고 해석한다.
구체적 처리는 이렇다.
(1) 절단 표시를 남긴다. 트랜잭션에 spans.dropped=42 같은 속성을 붙인다. UI가 이걸 표시하면 사람이 오해하지 않는다.
(2) 무엇을 남길지 정책을 정한다. 무작정 앞에서부터 채우면 트레이스 후반이 통째로 사라진다. 더 나은 전략은 (a) 깊이 제한을 걸어 얕은 span 우선, (b) 소요 시간 상위 span 우선, (c) 실패한 span 우선이다.
(3) 상한에 자주 걸리면 계측 설계를 고친다. 상한은 안전장치이지 정상 동작이 아니다. 트랜잭션당 100개를 넘는다면 루프 안에서 span을 만들고 있을 가능성이 높다. Q1의 기준(그 구간이 느리다는 걸 알면 뭘 할 수 있나)으로 되돌아가 재검토한다.
루프 안 계측이 필요하다면 개별 span 대신 집계가 낫다 — "항목 100개를 처리했고 총 340 ms 걸렸다"를 span 하나와 속성으로 표현하면 정보 손실이 거의 없으면서 개수가 100분의 1이 된다.
배터리 소모를 계측 탓으로 돌리기 전에 확인할 것은?
배터리는 여러 요인이 합쳐진 결과라 인과를 특정하기 어렵다. 계측을 의심하기 전에 확인할 것들이 있다.
(1) 위치·네트워크·화면. 배터리 소모의 큰 축은 대개 GPS, 셀룰러 통신, 화면 밝기·주사율이다. 계측 데이터 전송이 늘었다면 그건 네트워크 항목이고, 배치·와이파이 우선으로 크게 줄일 수 있다.
(2) 웨이크업. 19장 Q5의 EXC_RESOURCE (WAKEUPS)가 이미 신호를 주고 있을 수 있다. 계측이 짧은 주기 타이머를 쓰거나 큐 홉을 많이 하면 여기에 걸린다.
(3) CPU 지속 사용. 프로파일러처럼 상시 도는 계측은 CPU를 저전력 상태로 못 내려가게 한다. Instruments의 Energy Log에서 CPU 항목이 올랐는지 본다.
(4) MetricKit 전력 지표로 비교. A/B 빌드의 실사용자 전력 지표를 비교하면 가장 신뢰할 만하다. 실험실 측정은 조건이 인위적이다.
확인 순서는 "무엇이 늘었나"를 먼저 특정하는 것이다. 전송량이 늘었으면 전송 정책을, CPU가 늘었으면 샘플링 주기를, 웨이크업이 늘었으면 타이머·큐 구조를 고친다. "계측을 껐더니 나아졌다"만으로는 어느 부분이 원인인지 몰라서 최적화할 수 없다.