이 챕터는 "테스트로 결함을 예방하고, 디버깅으로 결함을 포착·좁혀서 잡는" 두 축을 다룬다. 각 도구가 실제로 어떤 질문에 답하도록 설계됐는지에 초점을 맞춘다 — 도구를 목적과 어긋나게 쓰면 시간만 쓰고 못 잡는다.
Q1. Unit, Integration, UI test는 각각 무엇을 검증해야 하는가?
Unit test는 하나의 유닛(함수·타입)의 로직을 외부 의존성에서 격리해 검증한다 — 빠르고 많이. Integration test는 여러 유닛·모듈이 만나는 경계의 계약(네트워크 파싱, DB 저장/조회, 모듈 간 협업)이 실제로 맞물리는지 검증한다. UI test는 사용자 관점에서 화면 전체 플로우가 끝까지 돌아가는지 검증한다 — 느리고 소수만. 세 층은 서로 다른 실패 모드를 잡으므로 하나로 다른 하나를 대체할 수 없다. 밑이 넓은 테스트 피라미드 비율이 기본이다.
CS 원리
테스트의 층을 나누는 기준은 "무엇을 격리하고, 무엇을 실제로 통과시키는가"다. Unit은 대상 유닛만 남기고 나머지(시간, 네트워크, 파일)를 test double(mock/stub/fake)로 대체해 결정적으로 만든다 — 실패하면 그 유닛의 로직이 틀렸다는 좁은 신호를 준다. Integration은 반대로 그 경계를 실제로 통과시켜, 개별 유닛은 맞는데 서로 붙였을 때 어긋나는 문제(잘못된 JSON 키, 스키마 마이그레이션, 스레드 경합)를 잡는다. UI/E2E는 가장 바깥에서 사용자 시나리오를 흉내 내 "조립된 전체가 실제로 동작하는가"를 본다. 위로 갈수록 현실성은 커지고, 속도·안정성·실패 원인 국소화 능력은 떨어진다. 이 trade-off가 피라미드 모양의 근거다.
iOS에서는
Unit·Integration은 Swift Testing(@Test/#expect)이나 XCTest로 작성하며, 앱 프로세스 안에서(호스트 앱에 링크되거나 라이브러리 타깃으로) 내부 타입에 직접 접근한다. Integration의 전형은 URLProtocol 서브클래스로 네트워크 응답을 가로채 파싱까지 통과시키거나, Core Data를 NSInMemoryStoreType(또는 SwiftData의 in-memory ModelContainer)으로 띄워 저장·조회 왕복을 검증하는 것이다. UI test(XCUITest)는 결정적으로 다르다 — 별도 프로세스에서 앱을 실제로 실행하고 XCUIApplication으로 접근성 계층(주로 accessibility identifier, 그 외 label·좌표)을 통해 요소를 질의·조작한다. 그래서 앱 내부 상태를 직접 읽지 못하고, 대신 launchArguments/launchEnvironment로 테스트 모드(스텁 서버, 로그인 우회)를 주입한다. 중간 지대로, 렌더된 뷰를 이미지로 고정하는 스냅샷 테스트를 Unit 속도로 돌려 UI 회귀를 값싸게 잡기도 한다.
실험 · 도구
Xcode Test Plan으로 층별 타깃과 실행 옵션(코드 커버리지, 병렬 실행, 랜덤 순서)을 분리하고, xcodebuild test -only-testing:로 특정 층만 돌린다. 코드 커버리지 게이지로 Unit이 실제 분기까지 덮는지 확인하되, 커버리지 숫자를 목표로 삼지는 않는다(커버 ≠ 검증). CI에서 UI test의 flaky 비율과 평균 실행 시간을 지표로 추적하면 피라미드가 뒤집혔는지(아이스크림 콘) 바로 보인다.
프로젝트 적용
① UI test는 "결제 완료" 같은 수익에 직결되는 핵심 해피패스 소수만. 엣지 케이스·검증 로직은 Unit으로 내린다.
import XCTest
// UI test는 프로세스 기동·렌더 대기 때문에 유닛 테스트의 수백 배 느리고
// 외부 요인에 취약하다. 많아지면 팀이 실패를 무시하기 시작한다.
final class CheckoutUITests: XCTestCase {
// ✅ 이 정도만 UI test로 — 깨지면 매출이 멈추는 경로
func test_결제_해피패스() {
let app = XCUIApplication()
app.launchArguments = ["-UITest", "-MockNetwork"] // ✅ 결정적으로 만든다
app.launch()
app.buttons["상품 담기"].tap()
app.buttons["장바구니"].tap()
app.buttons["결제하기"].tap()
// ✅ sleep이 아니라 조건을 기다린다
let done = app.staticTexts["결제 완료"]
XCTAssertTrue(done.waitForExistence(timeout: 15))
}
// ❌ 이런 건 UI test로 하지 않는다 — 유닛으로 내린다
// func test_할인율_경계값() { ... }
// func test_이메일_형식_검증() { ... }
// func test_빈_장바구니_에러메시지() { ... }
}
// ✅ 같은 검증을 유닛으로 — 밀리초 단위로 끝나고 케이스를 많이 넣을 수 있다
import Testing
@Test(arguments: [
(9_999, 0.0), (10_000, 0.1), (49_999, 0.1), (50_000, 0.2)
])
func 할인율_경계값(amount: Int, expected: Double) {
#expect(Pricing.discountRate(for: amount) == expected)
}
// ❌ 절대 하지 말 것
// · 실제 프로덕션 서버를 때리는 테스트 (다른 팀 배포에 우리 CI가 깨진다)
// · sleep으로 화면 전환 대기 (기기 성능에 따라 flaky)
// · 테스트 간 상태 공유 (실행 순서에 의존하게 된다)
enum Pricing {
static func discountRate(for amount: Int) -> Double { 0 }
}② Integration test는 내가 통제 못 하는 경계(서버 JSON, DB 스키마, 서드파티 SDK 계약)에 집중한다. 순수 로직에 Integration을 쓰면 느리기만 하다.
import Testing
import Foundation
// Integration test의 가치는 '내가 못 바꾸는 것과의 계약'을 지키는 데 있다.
// 서버 JSON 스키마, DB 마이그레이션, 서드파티 SDK 동작이 그 대상이다.
// ✅ ① 서버 응답 계약 — 실제 응답 샘플을 픽스처로 고정한다
@Test func 주문_응답_스키마가_모델과_맞는다() throws {
let url = Bundle.module.url(forResource: "order_response", withExtension: "json")!
let data = try Data(contentsOf: url)
let order = try JSONDecoder.api.decode(Order.self, from: data)
#expect(order.id == "ord_123")
#expect(order.items.count == 3)
#expect(order.status == .completed) // enum 매핑까지 확인
}
// ✅ 서버가 필드를 추가해도 안 깨지는지 (전방 호환)
@Test func 알수없는_필드가_있어도_디코딩된다() throws {
let json = """
{"id":"ord_1","status":"completed","items":[],"newFieldFromServer":123}
"""
let order = try JSONDecoder.api.decode(Order.self, from: Data(json.utf8))
#expect(order.id == "ord_1")
}
// ✅ ② DB 스키마·마이그레이션
@Test func v3에서_v4로_마이그레이션된다() throws {
let store = try TestStore.copyFixture(version: 3)
try store.migrate()
#expect(try store.schemaVersion() == 4)
#expect(try store.orderCount() > 0) // 데이터 보존까지 확인
}
// ❌ 순수 로직에 Integration을 쓰면 느리기만 하다
// @Test func 할인_계산() { DB를 띄우고 서버를 붙이고... } ← 유닛으로 충분하다
struct Order: Decodable {
let id: String
let items: [Item]
let status: Status
enum Status: String, Decodable { case pending, completed }
struct Item: Decodable {}
}
extension JSONDecoder { static let api = JSONDecoder() }
enum TestStore {
static func copyFixture(version: Int) throws -> Store { Store() }
}
struct Store {
func migrate() throws {}
func schemaVersion() throws -> Int { 4 }
func orderCount() throws -> Int { 1 }
}③ 하지 말 것: 실제 프로덕션 서버를 때리는 테스트, sleep으로 화면 전환을 기다리는 UI test — 둘 다 flaky의 근원이다.
import XCTest
// 이 둘이 flaky 테스트의 압도적 1·2위다.
// ❌ ① 실제 서버를 때린다
final class BadNetworkTests: XCTestCase {
func test_피드_조회() async throws {
let data = try await URLSession.shared.data(
from: URL(string: "https://api.production.com/feed")!).0
XCTAssertFalse(data.isEmpty)
// 💥 서버 배포·네트워크·데이터 변경에 우리 CI가 흔들린다
// 💥 병렬 실행 시 서로 간섭한다
// 💥 오프라인에서 못 돌린다
}
}
// ✅ URLProtocol로 네트워크를 가로챈다 — 실제 통신 없이 결정적으로
final class MockURLProtocol: URLProtocol {
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func makeMockSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
// ❌ ② sleep으로 대기
func badWait(_ app: XCUIApplication) {
app.buttons["결제"].tap()
sleep(3) // 💥 느린 기기에선 부족, 빠른 기기에선 낭비
XCTAssertTrue(app.staticTexts["완료"].exists)
}
// ✅ 조건을 기다린다
func goodWait(_ app: XCUIApplication) {
app.buttons["결제"].tap()
XCTAssertTrue(app.staticTexts["완료"].waitForExistence(timeout: 10))
}
// ✅ 비동기 코드는 신호 기반으로
func awaitCondition() async throws {
let value = try await loadValue() // await 자체가 동기화 지점
XCTAssertNotNil(value)
}
func loadValue() async throws -> String? { nil }"UI test가 많을수록 안전하다"는 오해다. UI test는 느리고(프로세스 기동·렌더 대기) 외부 요인에 취약해 flaky해지기 쉽다. 많아지면 피드백 루프가 무너지고 팀이 실패를 무시하기 시작한다 — 오히려 안전이 떨어진다. 커버리지 대부분은 아래층에서 확보하고 UI는 얇게 유지한다.
조립을 마친 자전거를 검사한다고 해보자. 브레이크 레버가 눌리는지, 체인이 도는지 부품을 하나씩 손으로 확인하는 게 unit test다. 레버를 당겼을 때 실제로 패드가 바퀴를 물어주는지 — 부품과 부품이 만나는 자리를 보는 게 integration test다. 그리고 직접 올라타서 언덕을 내려가 보는 게 UI test다.
그럼 제일 확실한 "타 보기"만 하면 되지 않나 싶은데, 여기가 사람들이 제일 많이 미끄러지는 곳이다. 넘어졌을 때 브레이크 탓인지 타이어 탓인지 체인 탓인지 알 수가 없다. 게다가 한 번 타보는 데 시간이 오래 걸리고, 그날 바람이 불면 결과가 달라진다 — 같은 코드인데 됐다 안 됐다 하는 flaky가 이거다. 그래서 아래층에서 촘촘히 잡고, 타 보기는 제일 중요한 코스 몇 개만 남긴다. 비유가 깨지는 곳도 짚어두자면, 부품 검사를 아무리 많이 해도 "실제로 굴러가는가"는 타 봐야만 안다. 세 층은 서로 다른 종류의 고장을 잡는 거라 어느 하나가 다른 하나를 대신하지 못한다.
꼬리 질문
레거시 코드가 의존성 주입 없이 싱글턴에 직접 붙어 있어 Unit test를 쓸 수 없다. 프로덕션 동작을 안 바꾸고 테스트 가능하게 만드는 첫 리팩터링은?
Michael Feathers가 말한 seam(이음새)을 하나 내는 게 첫 수다. API.shared를 코드 곳곳에서 직접 부르는 대신, 필요한 동작만 프로토콜로 뽑고 그 프로토콜을 이니셜라이저 기본 인자로 받게 바꾼다 — init(api: APIType = API.shared)처럼. 그러면 프로덕션 호출부는 인자를 생략해 동작이 그대로고, 테스트에서만 fake를 넘길 수 있다.
싱글턴 자체를 없애는 대공사보다 부작용이 적고, 컴파일러가 호출부를 다 지켜주므로 리스크가 낮다. 전역 상태를 아예 못 건드리는 상황이면 프로퍼티를 var로 열어 테스트 setUp에서 갈아끼우는 임시 방편도 있지만, 병렬 실행 시 격리가 깨지니 생성자 주입이 우선이다.
Integration test가 실제 네트워크를 쓰면 어떤 실패 모드가 생기고, URLProtocol 스텁으로 바꾸면 무엇을 잃는가?
실제 네트워크는 지연·타임아웃·rate limit·서버 배포 상태에 결과가 흔들려 flaky해지고, 오프라인 CI에서 아예 못 돌며 느리다. URLProtocol 서브클래스로 응답을 가로채면 결정적이고 빠르지만, 서버와의 실제 계약(응답 스키마, 상태 코드, 헤더)을 검증하는 능력을 잃는다.
즉 서버가 필드 이름을 바꿔도 스텁은 옛 모양 그대로라 테스트는 초록불인데 프로덕션만 깨지는 상황이 생긴다. 이 공백은 서버/클라이언트가 공유하는 contract test(예: OpenAPI 스키마 검증)나, CI 밖에서 주기적으로 도는 소수의 실서버 smoke test로 메운다.
스냅샷 테스트가 CI(Linux/다른 시뮬레이터)에서만 깨진다. 원인 후보와 방어법은?
스냅샷은 픽셀 단위 렌더 결과를 고정하는데, 렌더는 환경에 민감하다. OS 버전별 폰트 메트릭·서브픽셀 안티앨리어싱, 화면 스케일(@2x vs @3x), 색공간, Dynamic Type, 시뮬레이터 기기 차이가 몇 픽셀씩 어긋나게 만든다. Linux runner라면 UIKit 렌더링 엔진 자체가 없어 스냅샷을 생성할 수 없으니, 스냅샷 대상은 macOS runner에서만 돌려야 한다.
방어는 baseline을 특정 기기·OS 조합으로 못 박고 CI가 정확히 같은 시뮬레이터를 쓰게 고정하는 것이다. swift-snapshot-testing이라면 perceptualPrecision으로 미세 오차를 허용하고, 텍스트가 촘촘한 화면보다 레이아웃 구조 위주로 스냅샷을 잡으면 환경 차이에 덜 민감하다.
Q2. 재현 가능한 최소 사례를 어떻게 만드는가?
핵심은 변수를 하나씩 제거하며 버그가 살아남는 가장 작은 상태를 남기는 것이다. 먼저 재현을 결정적으로 만들고(시드 고정, 시간·네트워크 통제), 그다음 입력·코드·환경을 절반씩 잘라내며 버그가 사라지는 경계를 찾는다. 이 절반씩 자르기의 자동화가 코드 히스토리에는 git bisect, 입력에는 delta debugging이다. 최소 사례는 "빨리 고치기 위한 도구"이자 그 자체로 버그 리포트가 된다.
CS 원리
버그는 (입력 × 코드 × 환경 × 타이밍)의 어떤 조합에서만 나타난다. 이 조합 공간을 줄이는 원리가 Andreas Zeller의 delta debugging이다 — 실패를 유지하는 최소 부분집합을 ddmin 알고리즘으로 좁힌다 — 절반 제거에서 시작해 실패하면 분할 수를 늘려가며(어느 부분집합·여집합을 빼도 실패가 재현되지 않을 때 granularity 증가) 최소 실패 집합을 찾는다. 전제는 결정성이다. 같은 입력에 결과가 흔들리면 "제거했더니 사라졌다"가 "제거해서 사라졌다"인지 "우연히 이번엔 안 났다"인지 구분되지 않는다. 그래서 재현 최소화의 1단계는 항상 비결정성(난수, 시간, 스레드 스케줄, 잔여 상태) 제거다. 시간축 버전에서는 같은 원리가 bisection이다 — "언제부터 깨졌나"를 커밋 로그에서 log(n)번의 good/bad 판정으로 좁힌다.
iOS에서는
비결정성의 대표 원인을 하나씩 고정한다 — 시간은 주입한 Clock으로(Q4), 네트워크는 스텁으로, 난수는 시드 고정 제너레이터로, 잔여 상태는 앱 삭제·시뮬레이터 Erase All Content and Settings·테스트마다 in-memory 스토어(NSInMemoryStoreType / SwiftData in-memory ModelContainer) 사용으로 리셋한다. 환경 변수(기기 vs 시뮬레이터, OS 버전, 지역/언어, 다크모드, Dynamic Type)도 한 번에 하나씩 바꾼다 — 이것들이 버그의 트리거인 경우가 많다. "언제부터"는 git bisect run에 테스트 하나를 물려 자동화한다. 최후의 최소화는 새 빈 Xcode 프로젝트에 문제 코드만 옮겨 붙이는 것이다 — 서드파티·빌드설정을 다 걷어내 순수 원인만 남기고, 그대로 Apple Feedback Assistant에 첨부할 수 있는 리포트가 된다. 메모리·스레드 관련이면 Address/Thread Sanitizer를 켠 채 재현하면 사례가 훨씬 또렷해진다.
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# 각 후보 커밋에서 이 테스트만 돌려 자동으로 good/bad 판정
git bisect run xcodebuild test \
-scheme App -destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:AppTests/CrashReproTests/test_repro
git bisect reset프로젝트 적용
① 버그를 잡기 전에 먼저 실패하는 테스트로 재현을 고정한다. 고치고 나면 그 테스트가 회귀 방어선이 된다(TDD식 버그 수정).
import Testing
import Foundation
// 순서가 중요하다: 재현 → 테스트로 고정 → 수정 → 테스트 통과.
// 이 순서를 지키면 "고쳤다"를 증명할 수 있고, 그 테스트가 회귀 방어선이 된다.
// 버그 리포트: "자정 직전에 만든 주문이 다음 날 그룹에 들어간다"
// ① 먼저 실패하는 테스트를 쓴다
@Test func 자정_직전_주문은_같은_날짜로_묶인다() {
let clock = FixedClock(now: iso("2026-07-27T23:59:00+09:00"))
let grouper = OrderGrouper(clock: clock, timeZone: TimeZone(identifier: "Asia/Seoul")!)
let order = Order(createdAt: iso("2026-07-27T23:59:00+09:00"))
let groups = grouper.group([order])
#expect(groups.keys.first == "2026-07-27") // 지금은 "2026-07-28"이 나온다
}
// ② 이 테스트가 통과하도록 고친다 (원인: UTC 기준으로 날짜를 잘랐다)
// ③ 통과 후에는 그대로 두어 회귀를 막는다
// ✅ 경계값을 함께 넣으면 같은 종류의 버그를 미리 막는다
@Test(arguments: [
("2026-07-27T00:00:00+09:00", "2026-07-27"),
("2026-07-27T23:59:59+09:00", "2026-07-27"),
("2026-07-28T00:00:00+09:00", "2026-07-28"),
])
func 날짜_경계(_ input: String, _ expected: String) {
let grouper = OrderGrouper(clock: FixedClock(now: iso(input)),
timeZone: TimeZone(identifier: "Asia/Seoul")!)
#expect(grouper.group([Order(createdAt: iso(input))]).keys.first == expected)
}
struct FixedClock { let now: Date }
struct Order { let createdAt: Date }
struct OrderGrouper {
let clock: FixedClock
let timeZone: TimeZone
func group(_ orders: [Order]) -> [String: [Order]] { [:] }
}
func iso(_ s: String) -> Date {
ISO8601DateFormatter().date(from: s) ?? Date()
}② "내 기기에서만 재현" 이슈는 환경 변수를 표로 만들어(OS/기기/지역/네트워크/계정 상태) 팀원과 diff한다 — 다른 값 하나가 대개 범인이다.
import Foundation
import UIKit
// "내 기기에서만 재현"은 환경 변수 중 하나가 다른 것이다.
// 추측하지 말고 목록을 만들어 팀원과 대조한다.
struct DiagnosticSnapshot: Codable, CustomStringConvertible {
let osVersion: String
let deviceModel: String
let locale: String
let timeZone: String
let calendar: String
let preferredLanguages: [String]
let isLowPowerMode: Bool
let thermalState: String
let freeDiskGB: Double
let accessibilityTextSize: String
let appVersion: String
let buildNumber: String
@MainActor init() {
let info = ProcessInfo.processInfo
osVersion = info.operatingSystemVersionString
deviceModel = { var s = utsname(); uname(&s); return withUnsafeBytes(of: &s.machine) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self)) } }()
// 예: "iPhone17,1" — UIDevice.current.model은 "iPhone"만 줘서 diff에 못 쓴다
locale = Locale.current.identifier
timeZone = TimeZone.current.identifier
calendar = Calendar.current.identifier.debugDescription
preferredLanguages = Locale.preferredLanguages
isLowPowerMode = info.isLowPowerModeEnabled
thermalState = "\(info.thermalState)"
accessibilityTextSize = UIApplication.shared
.preferredContentSizeCategory.rawValue
let attrs = try? FileManager.default.attributesOfFileSystem(
forPath: NSHomeDirectory())
freeDiskGB = Double((attrs?[.systemFreeSize] as? Int64) ?? 0) / 1e9
appVersion = Bundle.main
.infoDictionary?["CFBundleShortVersionString"] as? String ?? "-"
buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "-"
}
var description: String {
(try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? ""
}
}
// ✅ 버그 리포트 화면에서 자동 첨부한다 — 사용자가 손으로 적게 하지 않는다
@MainActor
func attachDiagnostics(to report: inout BugReport) {
report.environment = DiagnosticSnapshot().description
}
// 자주 범인이 되는 항목
// 타임존·달력 (음력, 일본 연호)
// 지역 설정 (숫자 구분자 "1.234,56" vs "1,234.56")
// 접근성 텍스트 크기 (레이아웃 깨짐)
// 저장공간 부족 (캐시 삭제, 쓰기 실패)
// 저전력 모드 (백그라운드 작업 중단)
struct BugReport { var environment: String = "" }③ 하지 말 것: 재현되기도 전에 코드를 고치는 것. 재현 없는 "수정"은 검증할 수 없어 안 고쳐졌을 확률이 높다.
import Foundation
// 재현하지 못한 채 "아마 이거겠지" 하고 고치면
// ① 진짜 원인이 아닐 확률이 높고
// ② 고쳐졌는지 검증할 방법이 없고
// ③ 엉뚱한 코드에 방어 코드만 쌓인다
// ❌ 전형적인 패턴 — 원인 모르는 크래시에 nil 체크를 덧대기
func brokenFix(_ items: [Item]?, _ index: Int) -> Item? {
guard let items, index >= 0, index < items.count else { return nil }
return items[index]
// 크래시는 안 나지만, '왜 index가 범위를 벗어났는지'는 그대로 남아 있다.
// 화면에 빈 값이 나오는 새로운 버그로 바뀌었을 뿐이다.
}
// ✅ 재현이 안 되면 '재현 가능하게 만드는 것'이 먼저다
// ① 결정성 확보 — 시드·시간·네트워크를 고정
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) { state = seed }
mutating func next() -> UInt64 {
state = state &* 6364136223846793005 &+ 1442695040888963407
return state
}
}
func reproducibleShuffle(_ items: [Item], seed: UInt64) -> [Item] {
var rng = SeededGenerator(seed: seed)
return items.shuffled(using: &rng) // 항상 같은 순서
}
// ② 로그·상태를 크래시 지점에 남긴다
func instrumented(_ items: [Item], _ index: Int) -> Item {
// fatalError는 릴리스(-O)에서도 메시지를 남긴다 — precondition은 메시지가 사라진다
if index >= items.count {
fatalError("index=\(index) count=\(items.count) — 호출자를 추적할 것")
}
return items[index]
// 크래시 리포트에 상태가 남아 다음 발생 때 원인이 보인다
}
// ③ 그래도 안 되면 원격 로깅으로 실사용자 데이터를 모은다
func recordContext(_ index: Int, _ count: Int) {
// Analytics.record("index_mismatch", ["index": index, "count": count])
}
// ✅ 재현에 성공하면 그 즉시 테스트로 고정한다(앞 항목)
struct Item {}"환경을 원본 그대로 두는 게 가장 정직한 재현"이라는 오해가 있다. 원본 환경은 노이즈가 많아 원인 국소화를 방해한다. 최소 사례의 목적은 충실한 복제가 아니라 버그를 유지하는 가장 작은 조합을 남기는 것이다 — 관계없는 요소를 제거할수록 원인이 드러난다. 단, 제거할 때마다 버그가 유지되는지 확인해야 한다(그래서 결정성이 선행 조건).
어제 먹은 음식 12가지 중 뭐가 배탈의 범인인지 찾는다고 해보자. 하나씩 다시 먹어보는 것보다 빠른 방법이 있다. 반으로 나눠서 앞의 6개만 먹어보는 것이다. 멀쩡하면 범인은 뒤쪽 6개 안에 있고, 다시 그 6개를 반으로 자른다. 이 "반씩 자르기"를 커밋 기록에 대고 하면 git bisect, 입력 데이터에 대고 하면 delta debugging이다.
여기서 꼭 걸리는 함정이 하나 있다. 이 실험은 범인 음식을 먹으면 반드시 배탈이 나야 성립한다. 세 번 중 한 번만 난다면 "안 났다"가 "범인이 아니다"인지 "이번엔 운 좋게 넘어갔다"인지 구분이 안 된다. 그래서 좁히기보다 먼저 하는 일이 늘 결정성 만들기다 — 시계, 난수, 네트워크 응답, 지난번에 남아 있던 앱 데이터처럼 매번 달라지는 것부터 못을 박아 고정한다.
꼬리 질문
버그가 100번 중 3번만 재현된다. git bisect run의 판정 스크립트를 어떻게 설계해야 잘못된 good 판정을 막는가?
재현율이 낮으면 "한 번 돌려서 통과 = good"이 치명적이다 — 실제로는 bad인 커밋을 good으로 오판해 bisect가 엉뚱한 곳을 지목한다. 판정 스크립트가 테스트를 여러 번(예: 수백 회) 반복해서 한 번이라도 실패하면 bad, 그만큼 돌려도 안 나야만 good으로 판정하게 만든다. 재현율 3%면 실패를 놓칠 확률이 회당 0.97이라, N회 반복하면 0.97^N으로 급감한다.
비결정성을 먼저 줄여 재현율을 올릴 수 있으면 반복 횟수를 크게 낮출 수 있다. 빌드 실패처럼 판정 자체가 불가능한 커밋은 exit 125로 git bisect skip을 유도해 오판을 피한다.
delta debugging으로 입력을 줄였더니 개별 요소는 다 안전한데 특정 조합에서만 실패한다. ddmin이 이걸 어떻게 처리하는가?
ddmin이 보장하는 건 1-minimal이다 — 남은 집합에서 어느 요소 하나를 빼도 실패가 사라지는 상태. 상호작용(interaction) 버그면 그 조합을 이루는 요소들이 서로를 붙잡아, 하나만 빼도 실패가 풀리므로 조합 전체가 최소 실패 집합으로 함께 남는다.
알고리즘은 절반 제거로 시작해 실패가 유지되면 계속 줄이고, 어느 절반을 빼도 실패가 안 나오면 granularity를 2분할→n분할로 잘게 쪼개 부분집합과 여집합을 다시 시험한다. 이 과정에서 조합 요소들이 살아남는다. 단 ddmin은 진짜 최소(n-minimal, global minimum)를 보장하진 않아, 더 작은 조합이 있어도 못 찾을 수 있다.
ddmin은 그 둘을 함께 남겨 둔다. 다만 "이보다 더 작게는 절대 못 줄인다"까지 보장하진 않아서, 더 작은 조합이 숨어 있어도 못 찾을 수 있다.재현이 릴리스 빌드에서만 되고 디버그 빌드에선 안 된다. 무엇을 의심해야 하는가?
최적화가 바꾸는 것들을 의심한다. -O에서 인라이닝·데드코드 제거·레지스터 재사용이 일어나면서 초기화 안 된 메모리나 정의되지 않은 동작(UB)이 표면화되고, ARC 최적화로 객체 수명이 짧아져(디버그는 우연히 더 오래 살아 안 터짐) use-after-free가 드러나기도 한다.
또 assert는 릴리스에서 컴파일 아웃되므로, assertion이 우연히 상태를 붙잡아주던 게 사라져 그 아래 로직이 잘못된 값으로 진행할 수 있다. 데이터 경합(Thread Sanitizer로 확인)이나 미초기화 값도 후보다. 좁히려면 해당 파일만 -Onone으로 빌드해 어느 최적화가 트리거인지 이분한다.
-O)는 코드를 더 빠르게 돌리려고 순서를 바꾸고 군살을 뺀다. 그 과정에서 디버그 빌드에선 운 좋게 안 터지던 실수 — 초기화 안 한 값 쓰기, 이미 버린 물건 다시 쓰기 — 가 표면으로 드러난다. "코드는 그대로인데 릴리스만 터진다"면 최적화가 벗겨낸 원래 버그를 의심한다.Q3. Crash stack trace는 위에서부터 읽어야 하는가, Crash 지점부터 읽어야 하는가?
둘은 같은 것이다. 백트레이스에서 맨 위 frame 0이 곧 크래시가 난 지점(가장 안쪽 호출, innermost callee)이다. 그러니 "위에서부터" 읽되, frame 0은 종종 시스템 라이브러리(libswiftCore, libobjc)라서, 위에서부터 내려오며 내 코드가 처음 등장하는 프레임을 찾는 게 실전 시작점이다. 거기서 아래로(번호가 커지는 쪽 = 호출자, caller) 내려가며 "누가·왜 이 상태로 불렀나"를 재구성한다. 단 심볼리케이션(dSYM)이 안 됐으면 프레임 이름이 오프셋뿐이라 아무것도 못 읽으니 그것부터 해결한다.
CS 원리
콜 스택은 함수 호출이 쌓인 자료구조다. 크래시 시점의 백트레이스를 출력하면 관례상 가장 최근에 진입한 프레임이 맨 위(frame 0), 그 프레임을 부른 호출자가 그 아래, 최종적으로 main/스레드 시작이 맨 아래에 온다. 즉 "위"는 사건이 터진 곳이고 "아래"는 사건이 시작된 맥락이다. 버그의 증상은 위에 있지만 원인은 아래 어딘가(잘못된 인자를 넘긴 호출자, 잘못된 상태를 만든 더 아래)에 있는 경우가 흔하다. 그래서 "위에서 무엇이 터졌나 → 아래로 왜 그 상태였나"의 방향으로 읽는다.
iOS에서는
Apple 크래시 리포트(.ips/구형 .crash)는 Thread N Crashed: 아래에 이 순서로 프레임을 나열한다(Analyzing a crash report). 먼저 Exception Type으로 크래시 종류를 읽는다 — EXC_BAD_ACCESS (SIGSEGV/SIGBUS)는 해제된/잘못된 메모리 접근(dangling pointer, over-release), EXC_BREAKPOINT (SIGTRAP)는 Swift 런타임 트랩(force-unwrap한 nil, 배열 범위 초과, 정수 오버플로, fatalError/precondition), SIGABRT는 잡히지 않은 ObjC 예외나 assertion, 0x8badf00d는 메인 스레드를 너무 오래 막아 watchdog이 죽인 것이다(Exception types 문서). 리포트가 오프셋 숫자뿐이면 심볼리케이션이 안 된 것이다 — 빌드와 UUID가 일치하는 dSYM이 있어야 atos/Xcode Organizer가 함수명·파일·라인으로 바꿔준다. 그래서 릴리스 아카이브의 dSYM 보관과 DEBUG_INFORMATION_FORMAT = dwarf-with-dsym이 전제 조건이다.
Thread 0 Crashed:
0 libswiftCore.dylib 0x1a2... _assertionFailure + 320
1 MyApp 0x104a9c1f0 0x104a80000 + 115184 # <- 오프셋뿐
# dSYM으로 오프셋 -> 함수/파일/라인
atos -arch arm64 \
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x104a80000 0x104a9c1f0
# => Cart.total() (in MyApp) (Cart.swift:42)실험 · 도구
Xcode Organizer > Crashes는 App Store/TestFlight 크래시를 자동 심볼리케이션해 라인 단위로 보여준다. 로컬 .ips는 atos나 symbolicatecrash로 직접 처리한다. ObjC 예외는 All Exceptions 브레이크포인트로 던진 지점에서 멈춰 원본 스택을 얻고, 리포트 상단의 "Last Exception Backtrace"도 함께 본다. UUID 일치는 dwarfdump --uuid로 바이너리와 dSYM을 대조해 확인한다.
프로젝트 적용
① 릴리스 아카이브의 dSYM을 반드시 보관·업로드(Crashlytics 등)한다. dSYM 없으면 사후 크래시 로그는 사실상 못 읽는다.
# dSYM이 없으면 사후 크래시 로그는 오프셋 숫자일 뿐이다.
# 코드를 아무리 들여다봐도 어디서 죽었는지 알 수 없다.
# ✅ ① 릴리스 구성에서 생성 강제
xcodebuild archive -scheme MyApp -configuration Release \
-archivePath build/MyApp.xcarchive \
DEBUG_INFORMATION_FORMAT=dwarf-with-dsym
# ✅ ② 버전별로 영구 보관 (재빌드하면 UUID가 바뀌어 못 쓴다)
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \
build/MyApp.xcarchive/Products/Applications/MyApp.app/Info.plist)
BUILD=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" \
build/MyApp.xcarchive/Products/Applications/MyApp.app/Info.plist)
mkdir -p ~/dSYMs/"$VERSION-$BUILD"
cp -R build/MyApp.xcarchive/dSYMs/* ~/dSYMs/"$VERSION-$BUILD"/
# ✅ ③ 크래시 리포터에 업로드 (CI 자동화)
find build/MyApp.xcarchive/dSYMs -name "*.dSYM" -print0 \
| xargs -0 sentry-cli debug-files upload --org my-org --project my-app
# ✅ ④ 의존 프레임워크 dSYM이 빠지지 않았는지 확인
ls build/MyApp.xcarchive/dSYMs/
# MyApp.app.dSYM
# Core.framework.dSYM
# Analytics.framework.dSYM ← 서드파티 것도 있어야 그 안이 풀린다
# ✅ ⑤ 업로드 누락을 CI에서 잡는다
EXPECTED=$(otool -L build/.../MyApp | grep -c '@rpath')
FOUND=$(ls build/MyApp.xcarchive/dSYMs | wc -l)
[ "$FOUND" -ge "$EXPECTED" ] || { echo "❌ dSYM 누락"; exit 1; }
# ⚠️ App Store Connect에서 "빌드에 dSYM 누락" 경고가 뜨면
# Organizer → Archives → Download Debug Symbols는 bitcode 재컴파일 빌드에만 있던 경로이고, Xcode 14부터 App Store가 bitcode를 받지 않으므로 지금 올리는 빌드에는 해당되지 않는다 — 아카이브 시점에 보관하지 못했으면 그 빌드의 로그는 복구할 수 없다(Bitcode 사용 시)② 프레임을 읽을 때 시스템 프레임을 건너뛰고 내 프레임에 집중하되, 시스템 프레임의 이름(예: objc_release, layoutIfNeeded)이 크래시 성격(over-release, 레이아웃 중 접근)을 알려주므로 무시하진 않는다.
/* 프레임을 읽을 때 시스템 프레임을 건너뛰되, 그 '이름'은 보고 지나간다.
무엇이 죽었는지 성격을 알려 주기 때문이다.
── over-release / use-after-free
0 libobjc.A.dylib objc_release + 16
1 libobjc.A.dylib objc_object::sidetable_release
2 MyApp ImageCache.evict()
→ 이미 해제된 객체를 다시 release. Zombies·ASan으로 재현한다.
── 레이아웃 중 접근
0 UIKitCore -[UIView layoutSubviews]
1 UIKitCore -[UIView _layoutSublayersOfLayer:]
2 MyApp FeedCell.layoutSubviews()
→ 레이아웃 사이클 중 뷰 계층을 바꿨거나 순환 제약.
── 메인 스레드 아님
0 UIKitCore _UIAssertionFailureHandler
1 UIKitCore -[UIView setNeedsLayout]
→ 백그라운드에서 UIKit 호출. Main Thread Checker로 잡는다.
── Swift 런타임 트랩
0 libswiftCore.dylib _swift_runtime_on_report
1 libswiftCore.dylib _assertionFailure
2 MyApp OrderService.submit()
→ 강제 언래핑 nil, 배열 범위 초과, fatalError/precondition.
(swift_unexpectedError가 보이면 try!가 에러를 던진 것,
swift_dynamicCastFailure면 강제 캐스팅 실패다.)
── 워치독
Exception Type: EXC_CRASH (SIGKILL)
Termination Reason: 0x8badf00d
→ 크래시가 아니라 메인 스레드 정지. 스택은 '멈춰 있던 지점'이다.
── 스택 오버플로
0 MyApp parseNode(_:)
1 MyApp parseNode(_:)
... 같은 프레임이 수천 번 반복
→ 무한 재귀. 종료 조건을 확인한다. */
// ✅ 크래시 스레드를 먼저 찾는다 — 항상 Thread 0이 아니다
// 리포트에서 "Crashed:" 또는 "Thread N Crashed:" 표시를 찾는다
// ✅ Exception Type도 함께 읽는다
// EXC_BAD_ACCESS (SIGSEGV) → 잘못된 메모리 접근
// EXC_BREAKPOINT (SIGTRAP) → Swift 런타임 트랩 (강제 언래핑 등)
// EXC_CRASH (SIGABRT) → 처리되지 않은 예외, assertion
// EXC_RESOURCE → 메모리·CPU 한도 초과③ 하지 말 것: 심볼리케이션 안 된 오프셋만 보고 원인을 추측하는 것.
/* ❌ 이런 리포트를 놓고 코드를 추측하는 건 시간 낭비다
Thread 0 Crashed:
0 MyApp 0x0000000104b9c1f4 0x104b78000 + 148468
1 MyApp 0x0000000104ba2318 0x104b78000 + 172312
2 UIKitCore 0x00000001a2c4d8f0 0x1a2b00000 + 1366256
→ 어느 함수인지, 어느 줄인지 전혀 알 수 없다. */
// ✅ 먼저 심볼리케이션을 해결한다
// ① UUID 일치 확인
// dwarfdump --uuid MyApp.app.dSYM
// → 리포트의 Binary Images 섹션 UUID와 대조
// ② 수동 심볼리케이션 — 로드 주소와 크래시 주소를 넣는다
// atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
// -arch arm64 -l 0x104b78000 0x104b9c1f4
// → OrderViewModel.submit() (OrderViewModel.swift:142)
// ③ 리포트 전체를 한 번에
// export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
// "$DEVELOPER_DIR/usr/bin/symbolicatecrash" crash.ips MyApp.app.dSYM
// ④ Xcode Organizer에 넣으면 자동으로 붙는다
// Window → Organizer → Crashes (dSYM이 업로드돼 있어야 한다)
// ⚠️ 심볼리케이션이 끝까지 안 되면 그 리포트는 사실상 못 쓴다.
// "대략 이 근처일 것"으로 고치면 엉뚱한 코드를 건드리게 된다.
// ✅ 애초에 심볼리케이션 없이도 원인이 보이게 만드는 방법
// · 크래시 직전 상태를 breadcrumb으로 남긴다
// · precondition 메시지에 값을 담는다
// if index >= count { fatalError("index=\(index) count=\(count)") }
// → 리포트의 Application Specific Information에 그대로 찍힌다
// (precondition·preconditionFailure의 메시지는 릴리스(-O)에서 사라진다)
// · 화면 전환·주요 액션을 커스텀 키로 기록한다"맨 위(frame 0) 프레임이 항상 내 버그"라는 오해다. frame 0은 libswiftCore·libsystem 같은 시스템 프레임일 때가 많다 — 실제 원인은 그 아래 내 코드 프레임, 심지어 더 아래 호출자가 만든 잘못된 상태다. 또 하나: 크래시 스레드가 항상 Thread 0은 아니다 — Crashed: 표시가 붙은 스레드를 봐야 한다.
사고 보고서라고 생각하면 읽는 방향이 바로 정해진다. 팀장이 대리에게 일을 시키고, 대리가 신입에게 넘겼는데 사고는 신입 손에서 터졌다. 백트레이스 맨 위 frame 0이 그 신입이다. 아래로 내려갈수록 "누가 시켰나"가 차례로 나온다. 그래서 위는 터진 자리, 아래는 그렇게 된 사연이다.
그런데 실제 로그를 열어 보면 맨 위가 대개 libswiftCore 같은 시스템 프레임이다. 이건 신입이라기보다 잘 검증된 자동 기계에 가깝다 — 기계가 고장난 게 아니라 이상한 재료를 넣어줘서 멈춘 것이다. 그러니 위에서부터 훑되 내 코드가 처음 나오는 줄에서 조사를 시작하면 된다. 마지막으로 명찰 얘기 하나. 심볼리케이션이 안 된 로그는 이름 대신 사번(0x104a9c1f0)만 적힌 보고서다. dSYM이 사번과 이름을 이어주는 대조표인데, 빌드마다 대조표가 다르다. 엉뚱한 빌드의 대조표를 갖다 대면 아무 이름이나 그럴듯하게 찍혀 나오므로, 안 읽히는 것보다 오히려 위험하다.
꼬리 질문
백트레이스 전체가 시스템 프레임뿐이고 내 코드가 한 줄도 없다. 이럴 때 원인을 어떻게 좁히는가?
내 코드가 비동기 경계 너머에서 스케줄한 작업이 시스템 프레임 안에서 터진 경우가 흔하다 — DispatchQueue/Task로 넘긴 뒤라 원 호출자 컨텍스트가 스택에서 끊긴다. 먼저 크래시 스레드가 아닌 다른 스레드 스택과 리포트 상단의 "Last Exception Backtrace"를 함께 본다.
시스템 프레임 이름 자체가 성격을 알려준다 — objc_msgSend/objc_release에서 죽으면 해제된 객체 접근(over-release)이 유력하다. 이때는 Zombie Objects와 Malloc Stack Logging을 켜고 재현하면 해제 지점의 backtrace가 잡히고, ObjC 예외면 All Exceptions 브레이크포인트로 던진 순간의 내 스택을 얻는다.
Task)에게 넘긴 뒤 그 일꾼 손에서 터지면 "누가 시켰나"라는 흔적이 중간에 끊겨 시스템 프레임만 남는다. 그래도 터진 프레임 이름(예: objc_release)이 사고 종류를 귀띔해 주니, 그걸 실마리로 다른 스레드 스택과 예외 기록을 같이 뒤진다.0x8badf00d watchdog 종료의 메인 스레드 스택이 Thread 0이 아니라 백그라운드 큐를 가리킨다. 무엇을 의심하는가?
메인 스레드가 백그라운드 작업의 완료를 동기적으로 기다리다 막힌 상황을 의심한다. 스택에 semaphore_wait나 dispatch_sync가 보이면, 메인이 세마포어/락으로 다른 큐의 결과를 붙잡고 있는 것이다 — 그 큐가 응답을 못 주면 메인이 무한 대기하고 watchdog(0x8badf00d)이 앱을 죽인다.
전형은 우선순위 역전(priority inversion): 낮은 QoS 백그라운드 작업이 자원을 쥔 채, 메인이 그걸 기다리는 구조다. 메인에서의 DispatchQueue.sync·세마포어 대기·중첩 락을 걷어내고 await/콜백 기반 비동기로 바꾸는 게 근본 해법이다.
sync·세마포어) 만들면 안 된다.같은 크래시인데 두 사용자의 스택 라인 번호가 다르다. dSYM 관리에서 무엇이 틀어졌을 수 있는가?
가장 흔한 건 두 사용자가 서로 다른 빌드를 쓰는데 하나의 dSYM으로 뭉뚱그려 심볼리케이션한 경우다. 빌드마다 바이너리 UUID가 다르고 dSYM도 UUID로 짝지어지므로, 짝이 안 맞는 dSYM을 쓰면 오프셋이 엉뚱한 라인으로 매핑된다. dwarfdump --uuid로 바이너리와 dSYM의 UUID를 대조해 확인한다.
bitcode를 켰던 시절이면 App Store가 재컴파일해 dSYM을 새로 만들므로, 로컬 아카이브 dSYM이 아니라 Organizer에서 내려받은 것을 써야 한다. 결국 릴리스 빌드마다 그 빌드의 dSYM을 UUID와 함께 보관·업로드하는 규율이 없으면 사후 로그를 정확히 못 읽는다.
dSYM)가 다른데, 한 대조표로 서로 다른 빌드의 로그를 번역하면 라인이 제멋대로 찍힌다. 그러니 빌드마다 그 빌드 전용 대조표를 UUID로 짝 맞춰 보관해야 한다.Q4. 비동기 코드를 결정적으로 테스트하려면 시간과 의존성을 어떻게 제어하는가?
비결정성의 원천(시간, 스케줄러, 네트워크, 난수)을 주입 가능한 추상으로 바꿔 테스트에서 통제한다. 시간은 실제 Clock 대신 가짜 시계를 넣어 advance(by:)로 가상 시간을 즉시 점프시킨다 — 실제 대기는 0초, 결과는 항상 같다. Combine이면 스케줄러를 ImmediateScheduler/TestScheduler로 주입한다. 완료 신호는 sleep이 아니라 await 또는 expectation으로 동기화 지점을 잡는다. 원칙은 하나 — 실제 시간을 기다리지 마라.
CS 원리
테스트가 flaky한 이유는 출력이 통제되지 않은 입력(경과 시간, 스레드 스케줄 순서, 네트워크 지연, 난수)에 의존하기 때문이다. 결정성을 얻으려면 이 입력들을 고정해야 한다. 시간은 "얼마나 흘렀나"가 아니라 "테스트가 흘렸다고 선언한 만큼"으로 바꾼다(virtual time). 실제 sleep은 두 배로 나쁘다 — 느리고(진짜로 기다림), 그럼에도 불확실하다(기기가 느리면 그 시간에도 작업이 안 끝날 수 있음). 대신 완료를 신호로 기다리면 정확히 끝난 순간 진행한다. 의존성은 프로토콜 경계로 뽑아 test double을 주입한다 — 이것이 "제어의 역전"으로 결정성을 사는 방법이다.
iOS에서는
Swift Concurrency에서는 SE-0329가 도입한 Clock/Instant/Duration 프로토콜이 시간 제어의 표준 경계다. 프로덕션은 ContinuousClock(잠자는 동안도 흐름)이나 SuspendingClock을 쓰고, 타입은 any Clock<Duration>를 주입받게 설계한다. 테스트에서는 Point-Free의 swift-clocks의 TestClock(가상 시간을 advance로 밀기)이나 ImmediateClock(모든 sleep을 즉시 통과)을 넣는다. Swift Testing/XCTest의 테스트 함수는 async라서 await로 완료를 직접 기다릴 수 있고, 콜백 기반 API는 XCTestExpectation + await fulfillment(of:)로 신호를 잡는다. Combine이면 combine-schedulers의 TestScheduler/ImmediateScheduler를 주입하고, 네트워크는 URLProtocol 스텁으로 응답을 고정한다. 데이터 경합이 의심되면 Thread Sanitizer를 켜고 테스트를 반복 실행한다.
struct SearchModel {
let clock: any Clock<Duration>
var onResults: ([String]) -> Void = { _ in }
func search(_ q: String) async throws {
try await clock.sleep(for: .milliseconds(300)) // debounce
onResults(try await api.query(q))
}
}
@Test func debounceFiresAfter300ms() async throws {
let clock = TestClock()
let model = SearchModel(clock: clock)
async let task: Void = model.search("swift")
await clock.advance(by: .milliseconds(300)) // 실제 대기 0초
try await task // 여기서 결정적으로 완료
}실험 · 도구
flaky 여부는 반복 실행으로 드러낸다 — xcodebuild test -test-iterations 100 -run-tests-until-failure나 Xcode의 "Run until failure"로 돌려 통과율을 본다. 결정적으로 만든 테스트는 100/100이어야 한다. Thread Sanitizer(TSan)는 데이터 레이스를, Main Thread Checker는 UI 메인스레드 위반을 잡아 비결정성의 근본 원인을 짚어준다.
프로젝트 적용
① 시간을 쓰는 모든 타입은 Clock을 생성자 주입받게 한다. Task.sleep/DispatchQueue.main.asyncAfter를 코드에 직접 박으면 그 지점부터 테스트 불가능해진다.
import Foundation
// Date()·Task.sleep을 코드에 직접 박으면 그 지점부터 테스트가 불가능해진다.
// ❌ 시간이 하드코딩됐다
struct BadSessionTimer {
func waitForTimeout() async throws {
try await Task.sleep(for: .seconds(1800)) // 테스트에서 30분을 기다릴 수 없다
}
func isExpired(_ token: Token) -> Bool {
token.expiresAt < Date() // 현재 시각을 통제할 수 없다
}
}
// ✅ Clock을 주입받는다
struct SessionTimer<C: Clock> where C.Duration == Duration {
let clock: C
let timeout: Duration
func waitForTimeout() async throws {
try await clock.sleep(for: timeout)
}
}
// ✅ 현재 시각도 주입한다 — 가장 단순한 형태는 클로저다
struct TokenValidator {
var now: () -> Date = { Date() }
func isExpired(_ token: Token) -> Bool {
token.expiresAt < now()
}
}
// 프로덕션
let production = TokenValidator()
// 테스트 — 시각을 원하는 대로 고정한다
func test_만료_판정() {
let fixed = Date(timeIntervalSince1970: 1_800_000_000)
let validator = TokenValidator(now: { fixed })
let expired = Token(expiresAt: fixed.addingTimeInterval(-1))
let valid = Token(expiresAt: fixed.addingTimeInterval(1))
assert(validator.isExpired(expired))
assert(!validator.isExpired(valid))
}
// ✅ 프로토콜로 추상화하면 더 넓게 쓸 수 있다
protocol DateProvider: Sendable {
var now: Date { get }
}
struct SystemDateProvider: DateProvider {
var now: Date { Date() }
}
struct FixedDateProvider: DateProvider {
let now: Date
}
struct Token { let expiresAt: Date }② 완료 대기는 항상 신호 기반(await/expectation). Task.sleep(1초) 넣고 "그 안엔 끝나겠지"는 금지.
import Testing
import XCTest
import Foundation
// "그 안엔 끝나겠지"는 느린 CI에서 반드시 깨진다.
// ❌ 시간에 기대는 대기
func badWait() async throws {
startBackgroundWork()
try await Task.sleep(for: .seconds(1)) // 💥 느리면 실패, 빠르면 낭비
assert(isDone)
}
// ✅ ① async/await — await 자체가 동기화 지점이다
@Test func 작업이_완료된다() async throws {
let result = try await performWork() // 끝날 때까지 정확히 기다린다
#expect(result == .success)
}
// ✅ ② 콜백 API는 continuation으로 감싼다
func performLegacy() async throws -> Int {
try await withCheckedThrowingContinuation { cont in
legacyAPI { result in cont.resume(with: result) }
}
}
// ✅ ③ XCTest에서는 expectation
final class LegacyTests: XCTestCase {
func test_콜백_완료() {
let expectation = expectation(description: "작업 완료")
var received: Int?
legacyAPI { result in
received = try? result.get()
expectation.fulfill() // ✅ 신호
}
wait(for: [expectation], timeout: 5) // 타임아웃은 '실패 판정용' 상한일 뿐
XCTAssertNotNil(received)
}
}
// ✅ ④ 상태 변화를 기다려야 하면 AsyncStream으로 신호를 만든다
func waitForState(_ target: State, in stream: AsyncStream<State>) async {
for await state in stream where state == target { return }
}
// ⚠️ timeout 값은 '정상 동작 시간'이 아니라 '이보다 오래 걸리면 버그'인 상한이다.
// 5초 걸리는 작업에 timeout 5초를 주면 그 자체가 flaky의 원인이 된다.
enum State { case idle, done }
enum Result2 { case success }
func startBackgroundWork() {}
var isDone = false
func performWork() async throws -> Result2 { .success }
func legacyAPI(_ done: @escaping (Result<Int, Error>) -> Void) {}③ 하지 말 것: 실제 네트워크·실제 Date()·시드 없는 난수를 테스트에 남겨두기.
import Foundation
import Testing
// 이 셋이 테스트에 남아 있으면 그 테스트는 언젠가 반드시 흔들린다.
// ❌ ① 실제 Date()
struct BadReport {
func title() -> String {
"오늘의 리포트 — \(Date().formatted(date: .numeric, time: .omitted))"
// 자정에 실행되면 날짜가 바뀌어 스냅샷 테스트가 깨진다
}
}
// ✅ 주입
struct Report {
let now: Date
func title() -> String {
"오늘의 리포트 — \(now.formatted(date: .numeric, time: .omitted))"
}
}
// ❌ ② 시드 없는 난수
func badShuffle(_ items: [Int]) -> [Int] {
items.shuffled() // 매 실행마다 다른 결과
}
// ✅ 생성기를 주입
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) { state = seed }
mutating func next() -> UInt64 {
state = state &* 6364136223846793005 &+ 1442695040888963407
return state
}
}
func shuffle(_ items: [Int], using rng: inout some RandomNumberGenerator) -> [Int] {
items.shuffled(using: &rng)
}
// ❌ ③ 실제 네트워크 — 앞 항목의 MockURLProtocol로 대체한다
// ✅ 남아 있는지 CI에서 검사한다 (SwiftLint 커스텀 룰)
/*
custom_rules:
no_date_in_tests:
included: '.*Tests?\.swift'
regex: 'Date\(\)'
message: "테스트에서 Date()를 직접 쓰지 마세요. 시각을 주입하세요."
severity: error
no_real_network_in_tests:
included: '.*Tests?\.swift'
regex: 'URLSession\.shared'
message: "테스트에서 URLSession.shared를 쓰지 마세요. Mock을 주입하세요."
severity: error
*/
// ✅ flaky를 발견하면 반복 실행으로 확인한다
// xcodebuild test -test-iterations 50 -run-tests-until-failure"async 테스트는 await만 붙이면 결정적"이라는 오해다. await는 한 작업의 완료는 보장하지만, 여러 작업의 상대 순서나 actor 재진입(reentrancy)까지 결정하진 않는다. 또 await Task.sleep(...)는 여전히 실제 대기라서 느리고 흔들린다 — 반드시 주입된 Clock으로 바꿔야 진짜 결정적이 된다.
라면 3분 타이머를 떠올려 보자. 테스트에서 진짜 3분을 세고 있으면 느리다. 그렇다고 "대충 됐겠지" 하고 일찍 뚜껑을 열면 어떤 날은 덜 익는다 — sleep 넣고 기다리는 테스트가 딱 이 상태다. 대신 시계 바늘을 손으로 3분 돌려버리면 어떨까. 코드 입장에서는 "3분 지났다"는 신호만 받으면 다음 줄로 넘어가니까, 실제로는 0초 만에 끝나고 결과도 매번 똑같다. TestClock의 advance(by:)가 하는 일이 정확히 이거다.
비유가 깨지는 곳을 짚어두자. 진짜 라면은 시계를 돌린다고 익지 않는다. 코드에서 이게 되는 이유는 "기다린다"는 동작 자체를 우리가 건네준 시계에게 물어보도록 바꿔 놨기 때문이다. 코드 안에 Task.sleep을 직접 박아 두면 물어볼 상대가 시스템 시계로 고정돼서, 그 순간부터는 손으로 돌릴 방법이 없다. 그래서 시계든 네트워크든 난수든, 밖에서 갈아 끼울 수 있게 구멍(주입 지점)을 미리 뚫어두는 게 먼저다.
꼬리 질문
actor의 await 사이에 다른 호출이 끼어드는 reentrancy로 테스트가 가끔 깨진다. 어떻게 결정적으로 재현·검증하는가?
재진입이 일어나는 지점이 곧 await suspension이므로, 그 suspension을 테스트가 통제할 수 있는 지점으로 만든다. actor가 기다리는 의존성(clock·네트워크 스텁)의 continuation을 테스트가 명시적으로 재개하게 두고, async let으로 두 호출을 띄운 뒤 gate를 여는 순서로 인터리빙을 원하는 대로 강제하면 "가끔"이 "항상"이 된다.
검증은 재진입 창(await 전후) 사이에서 깨지면 안 되는 불변식을 assert로 확인하는 것이다. 근본 대응은 await 직후 상태를 다시 읽어 검증하거나(재진입 안전 설계), 그 구간을 단일 Task로 직렬화해 애초에 끼어들 틈을 없애는 것이다.
await로 잠깐 멈추는 순간 문이 열려 다른 호출이 끼어든다 — 그래서 "가끔" 깨진다. 테스트가 그 문 열리는 타이밍(의존성이 답 주는 시점)을 손으로 쥐면 끼어드는 순서를 원하는 대로 강제할 수 있어, "가끔"이 "항상"으로 바뀌어 재현된다.Task.sleep은 취소에 반응하지만 주입한 TestClock.sleep도 취소가 전파되는지 어떻게 검증하는가?
sleep 중인 Task를 async let이나 Task {}로 띄우고, advance(by:)로 시간을 밀기 전에 task.cancel()을 호출한다. 그러고 try await task가 CancellationError를 던지면 취소가 sleep까지 전파된 것이다. swift-clocks의 TestClock.sleep은 취소를 존중하도록 구현돼 있어 시간을 안 밀어도 즉시 깨어난다.
부수적으로, 취소 이후 그 뒤 로직(예: onResults 호출)이 실행되지 않았는지, TestClock의 대기 목록이 비었는지도 함께 확인하면 "취소됐지만 뒤처리가 새어 나가는" 버그까지 잡을 수 있다.
cancel)를 외쳐 본다. 자고 있던 코드가 곧바로 깨서 취소 에러를 뱉으면, 그 "그만" 신호가 잠자는 곳까지 제대로 전달됐다는 증거다.TestScheduler로 debounce를 검증하려 한다. 값을 두 번 빠르게 흘린 뒤 advance를 어떻게 나눠 밀어야 '마지막 입력 + 간격' 시점에만 한 번 방출된다(중간 입력이 타이머를 리셋한다)는 시맨틱을 증명할 수 있는가? 같은 시나리오가 throttle이었다면 왜 결과가 달라지는가?
debounce는 값이 들어올 때마다 타이머를 리셋하고, 마지막 입력 이후 간격이 온전히 흐른 순간에만 한 번 방출한다. 그래서 A를 흘린 뒤 간격보다 짧게(예: 간격의 절반) advance하고 B를 흘리면 A가 걸어 둔 타이머는 리셋돼 사라지고, 이어서 간격만큼 마저 밀었을 때 비로소 B 하나만 (B 입력 시점 + 간격에) 방출된다. 다운스트림이 정확히 한 번, 값 B로만 완료됐는지 assert하면 '중간 입력이 타이머를 리셋한다'는 시맨틱이 증명된다.
같은 시나리오를 throttle로 바꾸면 규칙이 다르다 — 새 입력이 타이머를 리셋하지 않고, 구독 시점부터 고정된 간격 창마다 최대 한 개만 통과시킨다. latest 인자는 그 창의 첫 값(false)을 낼지 마지막 값(true)을 낼지 고를 뿐이다. 그래서 같은 A·B 시나리오라도 방출 시각이 '마지막 입력 + 간격'이 아니라 창 경계에 묶이고, latest: false면 나가는 값이 B가 아니라 A가 된다.
결정적 차이는 입력이 간격보다 계속 빠르게 들어올 때다 — debounce는 타이머가 매번 리셋돼 입력이 멈출 때까지 아무것도 방출하지 않지만, throttle은 창마다 꾸준히 방출한다. 그래서 테스트에서도 debounce는 '마지막 입력 뒤 간격을 온전히 미는' 식으로, throttle은 '창 경계를 몇 개 넘기는' 식으로 advance 설계가 달라진다.
debounce는 초인종 같다 — 계속 눌러대면 안 울리고, 손 떼고 조용해진 뒤에야 "딩동" 한 번 울린다(마지막 입력 + 간격). throttle은 정해진 리듬으로 여닫는 자동문이라 사람이 아무리 몰려도 창(window)마다 한 번씩만 통과시킨다. 그래서 테스트에서 시간을 미는 방식도 둘이 달라진다.Q5. Breakpoint, Symbolic breakpoint, Watchpoint는 어떻게 다른가?
셋 다 실행을 멈추지만 트리거가 다르다. 일반 breakpoint는 특정 코드 위치(파일·라인/주소)에 도달하면 멈춘다. Symbolic breakpoint는 심볼 이름(예: -[UIViewController viewDidLoad])에 걸어, 소스가 없어도·모든 인스턴스에·프레임워크 내부에서도 멈춘다. Watchpoint(데이터 브레이크포인트)는 코드가 아니라 특정 메모리 주소의 값이 읽히거나 바뀔 때 멈춘다 — "이 변수를 대체 누가 바꾸는가"를 잡는 도구다. 앞의 둘은 소프트웨어로, watchpoint는 하드웨어 디버그 레지스터로 구현돼 개수가 제한된다(보통 4개).
CS 원리
일반 breakpoint의 구현은 대상 명령어를 트랩 명령으로 치환하는 것이다 — x86은 INT3(0xCC), ARM64는 BRK. CPU가 그 지점에 오면 트랩이 걸려 디버거로 제어가 넘어온다(software breakpoint). Symbolic breakpoint는 "위치"가 아니라 "이름"으로 지정한다 — 로더가 심볼을 주소로 해석하는 시점에 그 심볼의 진입 주소마다 breakpoint를 심는다. 그래서 내 소스가 없는 UIKit 내부 메서드에도 걸 수 있고, 같은 이름의 모든 호출을 포착한다. Watchpoint는 근본적으로 다르다 — 코드가 아니라 데이터를 감시한다. CPU의 하드웨어 디버그 레지스터에 감시할 주소·크기·접근종류(read/write)를 넣으면, 그 메모리에 접근하는 어떤 명령이든 CPU가 예외를 발생시킨다. 레지스터가 유한하므로 동시 watchpoint 수와 감시 바이트 크기에 하드웨어 제한이 있다.
| 트리거 | 대상 | 구현 | 개수 제한 | |
|---|---|---|---|---|
| Breakpoint | 코드 위치 도달 | 파일·라인 / 주소 | 소프트웨어(트랩 명령) | 사실상 무제한 |
| Symbolic breakpoint | 심볼 진입 | 함수/메서드 이름 | 소프트웨어(심볼→주소) | 사실상 무제한 |
| Watchpoint | 메모리 read/write | 변수·주소의 값 | 하드웨어 디버그 레지스터 | 제한(보통 4) |
iOS에서는
LLDB가 셋을 모두 다룬다. 실전 심볼릭 브레이크포인트 활용은 소스 없는 지점 진단이다 — Auto Layout 충돌이면 UIViewAlertForUnsatisfiableConstraints, 힙 손상이면 malloc_error_break, ObjC 예외면 All Exceptions 브레이크포인트에 걸어 던진 순간의 스택을 얻는다. Xcode에서는 Breakpoint Navigator의 "+"로 Symbolic/Exception/Constraint 브레이크포인트를 추가한다. Watchpoint는 "이 프로퍼티가 예상치 못하게 바뀐다" 유형에 강력하다 — 변수에서 우클릭 "Watch"로 걸거나 LLDB에서 직접 건다. 다만 하드웨어 제한(보통 4개, 감시 크기도 수 바이트) 때문에 남발할 수 없고, 감시 대상이 스코프를 벗어나면 자동 해제된다.
# 1) 코드 위치 breakpoint
(lldb) breakpoint set --file CartVC.swift --line 42
# 2) symbolic breakpoint (소스 없어도, 모든 인스턴스에)
(lldb) breakpoint set --name "-[UIViewController viewDidLoad]"
(lldb) breakpoint set --name "MyApp.CartViewModel.checkout()"
# 3) watchpoint: 이 값이 '언제·어디서' 바뀌는지 잡는다
(lldb) watchpoint set variable self.balance
(lldb) watchpoint set expression -- &globalCounter실험 · 도구
재현: 뷰의 alpha나 isHidden이 원인 모르게 바뀌는 버그를 만든 뒤, 그 프로퍼티에 watchpoint를 걸고 실행하면 값을 바꾸는 바로 그 프레임에서 멈춘다 — 백트레이스로 범인을 즉시 특정한다. 심볼릭 브레이크포인트 조건에 --condition과 자동 실행 액션(로그 출력 후 continue)을 붙이면, 앱을 멈추지 않고도 특정 호출만 추적하는 경량 계측이 된다.
프로젝트 적용
① "누가 이 상태를 바꿨나"는 로그를 흩뿌리기 전에 watchpoint부터. 한 방에 변경 지점을 잡는다.
# 로그를 흩뿌리기 전에 watchpoint를 먼저 쓴다. 한 번에 변경 지점을 잡는다.
# ✅ ① 프로퍼티에 걸기 — 객체를 알고 있을 때
(lldb) po self
(lldb) watchpoint set variable self.items
# Watchpoint created: Watchpoint 1: addr = 0x600001f04a18 size = 8
(lldb) continue
# 값이 바뀌는 순간 멈춘다
(lldb) bt
# → 누가 바꿨는지 콜스택이 그대로 나온다
# ✅ ② 주소로 걸기 — 전역이나 특정 메모리 위치
(lldb) expression -- &globalCounter
(lldb) watchpoint set expression -- 0x100a4b2c0
# ✅ ③ 읽기까지 감시
(lldb) watchpoint set variable -w read_write self.state
# ✅ ④ 조건을 붙여 좁힌다 (뜨거운 값에 필수)
(lldb) watchpoint modify -c 'self.items.count > 100' 1
# ✅ ⑤ 멈출 때 자동으로 정보를 찍고 계속 진행
(lldb) watchpoint command add 1
> bt 5
> po self.items.count
> continue
> DONE
# ✅ 관리
(lldb) watchpoint list
(lldb) watchpoint disable 1
(lldb) watchpoint delete 1
# ⚠️ 하드웨어 디버그 레지스터로 구현되어 동시 개수(보통 4개)와
# 감시 바이트 크기에 물리적 상한이 있다. 초과하면 설정이 실패한다.
# ⚠️ Swift에서 값 타입 프로퍼티는 주소가 자주 바뀌어 watchpoint가 풀릴 수 있다.
# 클래스 프로퍼티나 전역에서 가장 잘 동작한다.② 프레임워크 내부 동작(전환·레이아웃 타이밍) 조사는 심볼릭 브레이크포인트로. 내 코드에 코드를 넣지 않아도 된다.
# 내 코드에 로그를 넣지 않고도 프레임워크 동작을 관찰할 수 있다.
# ✅ ① 메서드 이름으로 걸기
(lldb) breakpoint set -n "-[UIViewController viewDidAppear:]"
(lldb) breakpoint set -n "-[UIView setNeedsLayout]"
# ✅ ② 정규식으로 여러 개를 한 번에
(lldb) breakpoint set -r 'UIView.*layoutSubviews'
# ✅ ③ Swift 심볼
(lldb) breakpoint set -n 'MyApp.OrderViewModel.submit() -> ()'
# ✅ ④ Xcode UI에서 (Breakpoint Navigator → + → Symbolic Breakpoint)
# Symbol: -[UIViewController viewWillAppear:]
# Condition: (BOOL)[[$arg1 description] containsString:@"FeedVC"]
# Action: po $arg1
# Options: ✓ Automatically continue after evaluating
# ✅ ⑤ 자주 쓰는 진단용 브레이크포인트들
(lldb) breakpoint set -n malloc_error_break # 힙 손상
(lldb) breakpoint set -n objc_exception_throw # ObjC 예외 발생 지점
(lldb) breakpoint set -n UIViewAlertForUnsatisfiableConstraints # 제약 충돌
# throw된 Swift 에러에서 멈춘다 (Xcode의 'Swift Error' 브레이크포인트와 같은 심볼)
# ⚠️ 강제 언래핑 nil·배열 범위 초과 같은 런타임 트랩은 여기 안 걸린다 —
# 그건 EXC_BREAKPOINT 트랩이라 디버거가 자동으로 멈춘다
(lldb) breakpoint set -n swift_willThrow
# ⚠️ 심볼릭 브레이크포인트는 이름이 맞는 '모든 것'에 걸린다.
# UIKit이 내부적으로 수백 번 부르는 메서드면 앱이 사실상 멈춘다.
# → 반드시 조건으로 좁힌다.
# ✅ 조건 예시 — 특정 클래스에서만
(lldb) breakpoint modify -c '[[$arg1 class] isEqual:[FeedViewController class]]' 1③ 하지 말 것: watchpoint를 뜨거운 루프의 자주 바뀌는 값에 거는 것 — 매 접근마다 멈춰 실행이 사실상 마비된다.
# watchpoint는 감시 대상에 '접근할 때마다' 실행을 멈춘다.
# 프레임마다 바뀌는 값에 걸면 디버깅이 아니라 마비다.
# ❌ 이런 곳에 걸면 안 된다
# · 애니메이션 진행률 (프레임마다 60~120회)
# · 스크롤 오프셋
# · 루프 카운터
# · 매 프레임 갱신되는 렌더 상태
# ✅ ① 조건으로 좁힌다 — 특정 값이 될 때만 멈춘다
(lldb) watchpoint set variable self.progress
(lldb) watchpoint modify -c 'self.progress > 0.99' 1
# ✅ ② 멈추지 말고 로그만 남긴다
(lldb) watchpoint command add 1
> po self.progress
> bt 3
> continue
> DONE
# → 실행은 계속되고 변경 이력만 콘솔에 쌓인다
# ✅ ③ 무시 횟수를 준다 — N번째 변경부터 멈춘다
(lldb) watchpoint ignore -i 1000 1
# ✅ ④ 필요한 구간에서만 켠다
(lldb) watchpoint disable 1
# ... 원하는 화면까지 진행 ...
(lldb) watchpoint enable 1
# ✅ ⑤ 코드로 켜고 끄는 방법 — 특정 시점에만 감시
# 문제 구간 직전에 브레이크포인트를 걸고, 거기서 watchpoint를 설정한다
# ⚠️ 대안: 뜨거운 값의 변경 이력이 필요하면 watchpoint보다
# didSet에 조건부 로깅을 넣는 편이 실용적이다
# var progress: Double {
# didSet {
# #if DEBUG
# if progress > 0.99 { print("progress=\(progress)"); }
# #endif
# }
# }"watchpoint는 원하는 만큼 걸 수 있다"는 오해다. watchpoint는 CPU의 유한한 하드웨어 디버그 레지스터로 구현되므로 동시 개수(보통 4개)와 감시 바이트 크기에 물리적 상한이 있다. 초과하면 설정이 실패한다. 또 하나: symbolic breakpoint가 "내 코드에만 걸린다"는 오해 — 오히려 이름이 일치하는 모든 것(프레임워크 포함)에 걸리므로 조건을 좁혀야 한다.
집에 누가 몰래 들어와 물건을 건드린다고 하자. 잡는 방법이 세 가지다. 현관문 앞에 서서 기다리면 그 문으로 들어오는 순간 잡는다 — 코드의 특정 줄에 거는 일반 breakpoint다. "택배기사 유니폼을 입은 사람"처럼 이름표로 지정하면, 어느 문으로 들어오든 잡히고 심지어 내 소스가 없는 남의 집(UIKit 내부)에서 움직여도 걸린다 — symbolic breakpoint다.
watchpoint는 사람이 아니라 물건 쪽에 센서를 붙이는 방식이다. "금고를 건드리면 알람"이니까 범인이 누군지 몰라도 손대는 순간 그 자리에서 잡힌다. "이 값을 대체 누가 바꾸는 거지"를 로그 뿌려가며 찾기 전에 watchpoint부터 쓰라는 게 이 얘기다. 다만 센서는 집에 몇 개 안 달려 있다(보통 4개) — CPU 안에 그 감시 장치가 물리적으로 그만큼만 있어서다. 그리고 센서는 물건이 아니라 그 선반에 붙는다. 물건을 다른 선반으로 옮기면 센서는 빈 선반을 계속 지키고 있고, 새 자리에서 벌어지는 일은 못 본다.
꼬리 질문
watchpoint를 걸었는데 값이 바뀌어도 안 멈춘다. 감시 대상이 재할당(다른 주소로 이동)됐을 가능성을 어떻게 확인하는가?
watchpoint는 값이 아니라 특정 메모리 주소를 감시한다. 값이 든 객체나 버퍼가 새 주소로 옮겨지면(배열이 커지며 copy-on-write, 객체 재생성) 옛 주소는 그대로라 변경을 못 잡는다. 확인은 간단하다 — watchpoint를 걸 때 p &변수로 주소를 적어두고, 안 멈추면 지금 p &변수가 같은 주소를 가리키는지 비교한다. 달라졌으면 재할당된 것이다.
struct 같은 값 타입이나 Array/Dictionary는 특히 이동이 잦다. 이럴 땐 주소 감시 대신 저장 프로퍼티의 didSet이나 setter에 코드 브레이크포인트를 걸거나, 이동할 때마다 새 주소에 다시 watch를 거는 편이 확실하다.
릴리스(-O) 빌드에서 심볼릭 브레이크포인트가 예상 라인에 안 걸린다. 인라이닝·심볼 스트립과 어떤 관계인가?
-O에서 함수가 호출부에 인라인되면 그 함수의 독립된 진입점(심볼)이 사라진다 — 코드가 caller에 녹아들어 "이름으로 여기서 멈춰"라고 걸 대상 자체가 없어진다. 또 라인 테이블이 최적화로 재배치·병합돼 소스 라인과 기계어가 1:1이 아니게 되므로 "예상 라인"에서 멈추지도 않는다.
배포 바이너리를 strip했거나 심볼이 없으면 이름→주소 해석 단계 자체가 실패해 아예 안 걸린다. 완화책은 조사 대상 함수에 @inline(never)를 붙이거나, UUID가 맞는 dSYM을 로드해 심볼을 복구하거나, 해당 부분만 -Onone으로 빌드하는 것이다.
@inline(never)로 이름표를 남기거나 그 부분만 최적화를 꺼서 본다.Swift 프로퍼티(계산/저장, didSet)에 watchpoint를 걸 때 저장 프로퍼티와 계산 프로퍼티에서 동작이 어떻게 다른가?
저장 프로퍼티는 인스턴스 안에 실제 메모리 슬롯을 차지하므로 그 주소에 watchpoint가 걸리고, 값이 기록되는 순간 멈춘다. 계산 프로퍼티는 backing storage가 없어 감시할 주소가 존재하지 않는다 — watchpoint를 걸 수 없고, 대신 그 getter/setter 심볼에 브레이크포인트를 걸어야 접근을 잡는다.
didSet이 붙은 것도 결국 저장 프로퍼티라 슬롯 watchpoint가 유효하다. 다만 실제 메모리 write와 관찰자(didSet) 본문 실행은 별개의 시점이므로, "값이 언제 바뀌나"는 watchpoint로, "바뀐 뒤 무슨 일이 벌어지나"는 didSet 브레이크포인트로 나눠 보는 게 정확하다.
Q6. Time Profiler, Allocations, Leaks는 각각 어떤 질문에 답하는가?
Time Profiler는 "CPU 시간이 어디서 쓰이나"(어느 함수가 핫스팟인가, 메인 스레드가 왜 멈추나)에 답하는 샘플링 프로파일러다. Allocations는 "힙에 무엇이·얼마나 할당되고 계속 늘어나나"(총량·상주량·증가 추이)에 답한다. Leaks는 "어떤 메모리가 루트에서 도달 불가능한 채 방치됐나"(고아가 된 메모리, 고립된 retain cycle)에 답한다. 셋은 서로 다른 질문이다 — 느린 앱엔 Time Profiler, 메모리가 계속 느는데 안 죽으면 Allocations, 확실히 새면 Leaks.
CS 원리
Time Profiler는 통계적 샘플링 방식이다 — 일정 주기(기본 약 1ms)마다 모든 스레드의 콜스택을 캡처해, 특정 함수가 표본에 자주 등장할수록 CPU 시간을 많이 썼다고 추정한다. 모든 호출을 계측하는 게 아니라 표본이라, 상대적 핫스팟엔 정확하지만 절대 시간은 근사다. Allocations는 malloc/free를 추적해 살아있는(persistent) 것과 곧 해제된(transient) 것을 구분하고, 시점 간 비교(generation)로 증가분을 본다. Leaks는 주기적으로 힙을 스캔해 프로그램 루트(스택·전역)에서 도달할 수 없는 블록을 찾는다. 여기서 핵심 구분: 서로만 참조하고 루트에서 끊긴 retain cycle은 도달 불가라 Leaks가 잡지만, 루트에서 여전히 도달 가능한데 다시 안 쓰는 메모리(무한히 커지는 캐시 = abandoned memory)는 "누수"가 아니라서 Leaks가 못 잡는다 — 이건 Allocations의 generation 분석이 담당한다.
iOS에서는
세 도구 모두 Instruments의 템플릿이다(WWDC21 "Detect and diagnose memory issues"). Time Profiler는 모든 코어·스레드를 샘플링하며, 메인 스레드가 특정 함수에서 계속 잡히면 그게 hang/버벅임의 원인이다 — "Hide System Libraries" + "Invert Call Tree"로 내 핫스팟만 빠르게 본다. Allocations는 앱을 어떤 상태로 만든 뒤 "Mark Generation"을 찍고, 화면을 열었다 닫는 사이클을 반복한 뒤 다시 찍어 세대(generation) 사이의 증가를 본다 — 닫았는데도 상주가 계속 늘면 abandoned/누수다. Malloc Stack Logging을 켜면 각 할당의 backtrace까지 보인다. Leaks는 주기적으로 스캔해 도달 불가 블록을 리포트한다. 다만 실무에서 retain cycle(closure의 [weak self] 누락, delegate strong 참조)을 찾는 1순위 도구는 Xcode의 Debug Memory Graph다 — 객체 그래프를 시각화하고 순환 참조에 보라색 경고를 띄워, 어떤 참조 사슬이 객체를 붙잡는지 바로 보여준다.
| 도구 | 답하는 질문 | 방식 | 대표 증상 |
|---|---|---|---|
| Time Profiler | CPU 시간이 어디서 쓰이나? | 주기적 스택 샘플링(통계) | 버벅임·hang·발열 |
| Allocations | 무엇이 얼마나·계속 늘어나나? | malloc/free 추적, 세대 비교 | 메모리 꾸준히 증가 |
| Leaks | 도달 불가로 방치된 메모리는? | 주기적 도달성 스캔 | 고립된 순환·C 누수 |
실험 · 도구
느린 화면 전환이면 Time Profiler로 프로파일해 메인 스레드 핫스팟(예: 이미지 디코딩, 정렬)을 찾아 백그라운드로 옮긴다. 리스트 스크롤 중 메모리가 계속 오르면 Allocations로 셀 하나를 여러 번 만들었다 없앤 뒤 generation diff로 어떤 타입이 안 풀리는지 본다. "화면 닫아도 deinit 로그가 안 찍힌다"면 Debug Memory Graph로 그 인스턴스를 붙잡는 참조 체인을 추적한다. 반드시 Release(-O) + 실기기로 프로파일한다 — 시뮬레이터·디버그 빌드는 최적화가 달라 결과가 왜곡된다.
프로젝트 적용
① 증상으로 도구를 고른다: 느림 → Time Profiler, 메모리 증가(안 죽음) → Allocations/Memory Graph, 확실한 누수 → Leaks.
import Foundation
/* 증상 → 도구 매핑
느리다 · 버벅인다
→ Time Profiler "CPU 시간이 어디서 쓰이나"
→ Animation Hitches "어느 프레임이 예산을 넘겼나"
메모리가 계속 느는데 안 죽는다
→ Allocations (Generation 분석) "무엇이 안 놓이나"
→ Memory Graph Debugger "누가 붙들고 있나"
확실히 새는 것 같다 (도달 불가 메모리)
→ Leaks
배터리·발열
→ Energy Log · CPU Counters
디스크·네트워크가 느리다
→ File Activity · Network
앱 시작이 느리다
→ App Launch */
// ✅ 도구를 켜기 전에 '재현 시나리오'를 먼저 정한다
// "피드를 20번 스크롤한 뒤 상세로 들어갔다 나오기" 처럼
// 같은 동작을 반복 가능하게 만들어야 개선 전후를 비교할 수 있다
// ✅ 코드로 상시 감시선을 두면 회귀를 빨리 잡는다
import XCTest
final class PerformanceTests: XCTestCase {
func test_피드_파싱_성능() {
let data = loadFixture()
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
_ = try? JSONDecoder().decode([Item].self, from: data)
}
}
func test_화면_해제됨() {
weak var weakVC: FeedViewController?
autoreleasepool {
let vc = FeedViewController()
vc.loadViewIfNeeded()
weakVC = vc
}
XCTAssertNil(weakVC, "abandoned memory — 루트 어딘가가 붙들고 있다")
}
private func loadFixture() -> Data { Data() }
}
struct Item: Decodable {}
final class FeedViewController: UIViewController {}
import UIKit② Time Profiler는 상대 핫스팟을 보는 용도. 표본 기반이라 절대 밀리초를 신뢰하지 말고 "가장 큰 덩어리부터" 개선한다.
import Foundation
// Time Profiler는 일정 간격으로 콜스택을 표본 추출한다.
// 절대 밀리초는 표본 오차가 크므로, '가장 큰 덩어리'를 찾는 데 쓴다.
/* 읽는 순서
① 문제 구간만 선택한다 (전체 녹화에서 버벅인 구간을 드래그)
② "Separate by Thread" 켜고 메인 스레드를 본다
③ "Hide System Libraries" 켜서 내 코드만 남긴다
④ Invert Call Tree로 '가장 아래에서 시간을 쓰는 함수'를 본다
⑤ 가장 큰 항목부터 하나씩 개선한다
⚠️ mach_msg_trap이 많다고 문제가 아니다 — 런루프가 자고 있는 상태다.
Record Waiting Threads를 꺼 두면 대기 표본이 제외되어 실제 작업만 남는다. */
// ✅ 개선 전후를 같은 시나리오로 비교한다
func measureScenario(_ label: String, _ body: () -> Void) {
let clock = ContinuousClock()
var samples: [Duration] = []
for _ in 0..<10 { samples.append(clock.measure(body)) }
samples.sort()
print("\(label): 중앙값 \((samples[4] + samples[5]) / 2) · p90 \(samples[8]) · 최악 \(samples[9])")
// 평균이 아니라 중앙값·p90을 본다 — 이상치에 덜 흔들린다
}
// ✅ signpost로 내 코드의 구간을 Instruments 타임라인에 표시한다
import os.signpost
let perfLog = OSLog(subsystem: "com.app.perf", category: .pointsOfInterest)
func parseWithSignpost(_ data: Data) throws -> [Item] {
let id = OSSignpostID(log: perfLog)
os_signpost(.begin, log: perfLog, name: "parse feed", signpostID: id)
defer { os_signpost(.end, log: perfLog, name: "parse feed", signpostID: id) }
return try JSONDecoder().decode([Item].self, from: data)
}
// → Instruments의 os_signpost 트랙에 구간이 그대로 보인다
struct Item: Decodable {}③ 하지 말 것: 디버그 빌드·시뮬레이터 수치로 성능 결론 내리기. 최적화·ARM/x86 차이로 실제와 다르다.
import Foundation
// Debug 빌드와 시뮬레이터는 실제와 다른 세계다. 두 가지가 동시에 왜곡한다.
/* ① 최적화 차이 (-Onone vs -O)
· ARC retain/release가 중복까지 그대로 방출된다
· 제네릭이 특수화되지 않아 witness table 동적 디스패치를 탄다
· 인라인이 거의 안 된다
→ 계산 중심 코드는 수십 배까지 느리게 나온다
② 아키텍처·하드웨어 차이 (시뮬레이터)
· 맥의 CPU로 돈다 — 코어 성능·개수·캐시가 전혀 다르다
· 디스크가 SSD(맥)라 I/O가 훨씬 빠르다
· 메모리 상한이 없다시피 하다 → jetsam이 재현되지 않는다
· GPU도 맥의 것이라 렌더 성능이 다르다 */
// ✅ 성능 측정의 최소 조건
// · Release 구성 (또는 최적화를 켠 커스텀 구성)
// · 실기기
// · 지원하는 가장 낮은 사양 기기로도 한 번
// · 저전력 모드·발열 상태를 통제
// ✅ 코드로 실수를 막는다
func assertMeasurementEnvironment() {
#if DEBUG
assertionFailure("Debug 빌드에서 성능을 측정하고 있습니다")
#endif
#if targetEnvironment(simulator)
fatalError("시뮬레이터에서 성능을 측정하고 있습니다") // assertionFailure는 -O에서 사라져 Release 측정 실수를 못 잡는다
#endif
}
// ✅ 예외: 시뮬레이터가 유효한 경우
// · 알고리즘 복잡도 비교 (상대 비교만)
// · 메모리 누수·순환 참조 검출
// · 로직 정확성
// → "상대적으로 어느 쪽이 나은가"는 볼 수 있지만
// "이 기기에서 몇 ms인가"는 볼 수 없다
// ✅ 실사용자 분포는 MetricKit으로 수집한다 — 가장 신뢰할 수 있는 수치다
// MXAppLaunchMetric, MXAnimationMetric, MXMemoryMetric"Leaks가 모든 메모리 문제를 잡는다"는 오해다. Leaks는 도달 불가능한 메모리만 본다. 앱에서 훨씬 흔한 문제는 도달은 가능한데 안 놓는 abandoned memory(끝없이 커지는 캐시, 해제 안 되는 뷰컨트롤러가 루트에 매달린 경우)인데, 이건 Leaks에 안 걸린다 — Allocations 세대 분석이나 Memory Graph로 잡아야 한다. 또 retain cycle이라도 어느 쪽이 루트에 연결돼 있으면 "누수"가 아니라 abandoned로 분류된다.
세 도구는 애초에 다른 질문에 답한다. Time Profiler는 교실에서 1ms마다 사진을 찰칵 찍어두고, 사진에 제일 자주 나온 사람이 제일 오래 일한 사람이라고 짐작하는 방식이다. 사진 몇 장으로 세는 거라 "정확히 몇 초"는 못 말하지만 "누가 제일 바빴나"는 잘 맞힌다. Allocations는 창고 입출고 장부다 — 지금 뭐가 얼마나 들어와 있고, 아까 표시해 둔 시점보다 얼마나 늘었는지를 본다. Leaks는 창고를 돌면서 주인이 사라져 아무도 찾아갈 수 없게 된 상자를 찾는다.
여기서 제일 헷갈리는 지점 하나. 메모리가 계속 느는데 Leaks가 아무것도 못 잡는 경우가 흔하다. 주인이 여전히 상자를 붙잡고 있으면 Leaks 기준으로는 고아가 아니기 때문이다. 다시 꺼내 쓰지도 않으면서 끝없이 쌓이는 캐시가 딱 그 모양인데, 이건 장부(Allocations)로 "사이클마다 같은 게 조금씩 계속 쌓이네"를 봐야 드러난다. 그러니 "Leaks가 깨끗함 = 메모리 문제 없음"은 성립하지 않는다.
꼬리 질문
Time Profiler에서 메인 스레드가 대부분 mach_msg_trap(대기)에 잡힌다. 이게 성능 문제가 아니라는 걸 어떻게 판단하는가?
mach_msg_trap은 run loop이 다음 이벤트를 기다리며 자고 있는 상태다 — CPU를 쓰는 게 아니라 유휴다. 앱이 사용자 입력을 기다리며 할 일이 없을 때 메인 스레드가 여기 머무는 건 지극히 정상이라, 표본에서 비율이 높다고 문제인 게 아니다.
판단하려면 실제로 버벅이는 구간(스크롤·화면 전환)만 잘라 그 창의 샘플을 본다. 그 순간에도 mach_msg면 문제 없는 것이고, 특정 함수가 CPU를 잡고 있으면 그게 핫스팟이다. Instruments Time Profiler의 레코딩 옵션 Record Waiting Threads를 꺼 두면(기본값) 대기 중인 스레드는 표본에 아예 잡히지 않아 실제 작업만 남는다.
mach_msg_trap은 메인 일꾼이 할 일이 없어 "다음 손님 오나" 기다리며 조는 상태다 — CPU를 쓰는 게 아니라 쉬는 거라, 사진(샘플)에 자주 나와도 문제가 아니다. 진짜 버벅이는 구간만 잘라 그때도 이러고 있는지 보면 오해가 풀린다.화면 열고 닫기를 반복하며 Allocations 세대(generation) diff를 찍는데, 첫 사이클엔 상주 메모리가 늘지만 그 이후 사이클부터는 더 안 는다. 이건 누수인가 아닌가? 무엇을 근거로 '누수 아님'과 '진짜 단조 증가'를 구분해 결론 내리는가?
첫 사이클 증가는 대개 일회성 워밍업이다 — lazy로 초기화되는 캐시·싱글턴, 디스패치 큐, 폰트·에셋 첫 디코드처럼 한 번 만들어 두고 정당하게 계속 재사용하는(루트에서 도달 가능하고 다시 쓰이는) 메모리라 누수가 아니다. 그래서 판별의 핵심은 정상 상태(steady state)에서 비교하는 것이다 — 앞의 한두 사이클(워밍업)을 버리고, 그 뒤 동일한 열고-닫기 사이클들 사이에서만 세대 diff를 비교한다.
이후 세대 diff가 사이클마다 거의 0이면 워밍업이 한 번 채운 것뿐이라 '누수 아님'으로 결론 내린다. 반대로 매 사이클 같은 타입이 일정량씩 계속 쌓이면 그게 진짜 단조 증가(abandoned memory 또는 누수)다. 확신을 더하려면 화면을 닫을 때 deinit이 실제로 찍히는지, diff에 어떤 타입이 반복해서 걸리는지를 함께 본다.
closure에서 [weak self]를 넣었는데도 deinit이 안 불린다. Memory Graph에서 무엇을 확인해야 하는가?
[weak self]는 그 closure가 self를 붙잡는 사슬 하나만 끊는다. deinit이 안 불린다면 다른 경로가 인스턴스를 강하게 잡고 있는 것이다. Memory Graph에서 그 인스턴스 노드를 선택해 들어오는 화살표(incoming references)를 따라가, 누가 strong으로 참조하는지 확인하는 게 핵심이다.
흔한 범인은 타깃을 strong으로 무는 Timer, 해제 안 한 NotificationCenter/KVO 옵저버, strong으로 선언된 delegate, 그리고 closure 안에서 self 대신 self.viewModel처럼 다른 프로퍼티를 캡처해 우회로가 생긴 경우다. 부모-자식이 서로 strong으로 물린 구조도 이 화살표를 따라가면 바로 보인다.
[weak self]는 self를 붙잡는 여러 줄 중 하나만 끊는다. 그래도 deinit이 안 불린다면 다른 줄(Timer, 안 뗀 옵저버, strong으로 문 delegate 등)이 여전히 붙잡고 있는 거다. Memory Graph에서 그 객체로 "들어오는 화살표"를 거꾸로 따라가면 누가 붙잡는지 바로 보인다.