이 챕터는 "테스트로 결함을 예방하고, 디버깅으로 결함을 포착·좁혀서 잡는" 두 축을 다룬다. 각 도구가 실제로 어떤 질문에 답하도록 설계됐는지에 초점을 맞춘다 — 도구를 목적과 어긋나게 쓰면 시간만 쓰고 못 잡는다.
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으로 내린다.
- Integration test는 내가 통제 못 하는 경계(서버 JSON, DB 스키마, 서드파티 SDK 계약)에 집중한다. 순수 로직에 Integration을 쓰면 느리기만 하다.
- 하지 말 것: 실제 프로덕션 서버를 때리는 테스트,
sleep으로 화면 전환을 기다리는 UI test — 둘 다 flaky의 근원이다.
"UI test가 많을수록 안전하다"는 오해다. UI test는 느리고(프로세스 기동·렌더 대기) 외부 요인에 취약해 flaky해지기 쉽다. 많아지면 피드백 루프가 무너지고 팀이 실패를 무시하기 시작한다 — 오히려 안전이 떨어진다. 커버리지 대부분은 아래층에서 확보하고 UI는 얇게 유지한다.
꼬리 질문
- 레거시 코드가 의존성 주입 없이 싱글턴에 직접 붙어 있어 Unit test를 쓸 수 없다. 프로덕션 동작을 안 바꾸고 테스트 가능하게 만드는 첫 리팩터링은?
- Integration test가 실제 네트워크를 쓰면 어떤 실패 모드가 생기고,
URLProtocol스텁으로 바꾸면 무엇을 잃는가? - 스냅샷 테스트가 CI(Linux/다른 시뮬레이터)에서만 깨진다. 원인 후보와 방어법은?
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식 버그 수정).
- "내 기기에서만 재현" 이슈는 환경 변수를 표로 만들어(OS/기기/지역/네트워크/계정 상태) 팀원과 diff한다 — 다른 값 하나가 대개 범인이다.
- 하지 말 것: 재현되기도 전에 코드를 고치는 것. 재현 없는 "수정"은 검증할 수 없어 안 고쳐졌을 확률이 높다.
"환경을 원본 그대로 두는 게 가장 정직한 재현"이라는 오해가 있다. 원본 환경은 노이즈가 많아 원인 국소화를 방해한다. 최소 사례의 목적은 충실한 복제가 아니라 버그를 유지하는 가장 작은 조합을 남기는 것이다 — 관계없는 요소를 제거할수록 원인이 드러난다. 단, 제거할 때마다 버그가 유지되는지 확인해야 한다(그래서 결정성이 선행 조건).
꼬리 질문
- 버그가 100번 중 3번만 재현된다.
git bisect run의 판정 스크립트를 어떻게 설계해야 잘못된 good 판정을 막는가? - delta debugging으로 입력을 줄였더니 개별 요소는 다 안전한데 특정 조합에서만 실패한다.
ddmin이 이걸 어떻게 처리하는가? - 재현이 릴리스 빌드에서만 되고 디버그 빌드에선 안 된다. 무엇을 의심해야 하는가?
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 없으면 사후 크래시 로그는 사실상 못 읽는다.
- 프레임을 읽을 때 시스템 프레임을 건너뛰고 내 프레임에 집중하되, 시스템 프레임의 이름(예:
objc_release,layoutIfNeeded)이 크래시 성격(over-release, 레이아웃 중 접근)을 알려주므로 무시하진 않는다. - 하지 말 것: 심볼리케이션 안 된 오프셋만 보고 원인을 추측하는 것.
"맨 위(frame 0) 프레임이 항상 내 버그"라는 오해다. frame 0은 libswiftCore·libsystem 같은 시스템 프레임일 때가 많다 — 실제 원인은 그 아래 내 코드 프레임, 심지어 더 아래 호출자가 만든 잘못된 상태다. 또 하나: 크래시 스레드가 항상 Thread 0은 아니다 — Crashed: 표시가 붙은 스레드를 봐야 한다.
꼬리 질문
- 백트레이스 전체가 시스템 프레임뿐이고 내 코드가 한 줄도 없다. 이럴 때 원인을 어떻게 좁히는가?
0x8badf00dwatchdog 종료의 메인 스레드 스택이Thread 0이 아니라 백그라운드 큐를 가리킨다. 무엇을 의심하는가?- 같은 크래시인데 두 사용자의 스택 라인 번호가 다르다. dSYM 관리에서 무엇이 틀어졌을 수 있는가?
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를 코드에 직접 박으면 그 지점부터 테스트 불가능해진다. - 완료 대기는 항상 신호 기반(
await/expectation).Task.sleep(1초)넣고 "그 안엔 끝나겠지"는 금지. - 하지 말 것: 실제 네트워크·실제
Date()·시드 없는 난수를 테스트에 남겨두기.
"async 테스트는 await만 붙이면 결정적"이라는 오해다. await는 한 작업의 완료는 보장하지만, 여러 작업의 상대 순서나 actor 재진입(reentrancy)까지 결정하진 않는다. 또 await Task.sleep(...)는 여전히 실제 대기라서 느리고 흔들린다 — 반드시 주입된 Clock으로 바꿔야 진짜 결정적이 된다.
꼬리 질문
- actor의
await사이에 다른 호출이 끼어드는 reentrancy로 테스트가 가끔 깨진다. 어떻게 결정적으로 재현·검증하는가? Task.sleep은 취소에 반응하지만 주입한TestClock.sleep도 취소가 전파되는지 어떻게 검증하는가?- Combine
debounce를TestScheduler로 테스트할 때advance(by:)없이0만큼 진행하면 이벤트가 안 나온다. 왜인가?
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를 뜨거운 루프의 자주 바뀌는 값에 거는 것 — 매 접근마다 멈춰 실행이 사실상 마비된다.
"watchpoint는 원하는 만큼 걸 수 있다"는 오해다. watchpoint는 CPU의 유한한 하드웨어 디버그 레지스터로 구현되므로 동시 개수(보통 4개)와 감시 바이트 크기에 물리적 상한이 있다. 초과하면 설정이 실패한다. 또 하나: symbolic breakpoint가 "내 코드에만 걸린다"는 오해 — 오히려 이름이 일치하는 모든 것(프레임워크 포함)에 걸리므로 조건을 좁혀야 한다.
꼬리 질문
- watchpoint를 걸었는데 값이 바뀌어도 안 멈춘다. 감시 대상이 재할당(다른 주소로 이동)됐을 가능성을 어떻게 확인하는가?
- 릴리스(-O) 빌드에서 심볼릭 브레이크포인트가 예상 라인에 안 걸린다. 인라이닝·심볼 스트립과 어떤 관계인가?
- Swift 프로퍼티(계산/저장,
didSet)에 watchpoint를 걸 때 저장 프로퍼티와 계산 프로퍼티에서 동작이 어떻게 다른가?
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.
- Time Profiler는 상대 핫스팟을 보는 용도. 표본 기반이라 절대 밀리초를 신뢰하지 말고 "가장 큰 덩어리부터" 개선한다.
- 하지 말 것: 디버그 빌드·시뮬레이터 수치로 성능 결론 내리기. 최적화·ARM/x86 차이로 실제와 다르다.
"Leaks가 모든 메모리 문제를 잡는다"는 오해다. Leaks는 도달 불가능한 메모리만 본다. 앱에서 훨씬 흔한 문제는 도달은 가능한데 안 놓는 abandoned memory(끝없이 커지는 캐시, 해제 안 되는 뷰컨트롤러가 루트에 매달린 경우)인데, 이건 Leaks에 안 걸린다 — Allocations 세대 분석이나 Memory Graph로 잡아야 한다. 또 retain cycle이라도 어느 쪽이 루트에 연결돼 있으면 "누수"가 아니라 abandoned로 분류된다.
꼬리 질문
- Time Profiler에서 메인 스레드가 대부분
mach_msg_trap(대기)에 잡힌다. 이게 성능 문제가 아니라는 걸 어떻게 판단하는가? - Leaks는 깨끗한데 앱 메모리가 계속 는다. 어떤 도구·절차로 원인을 좁히는가?
- closure에서
[weak self]를 넣었는데도deinit이 안 불린다. Memory Graph에서 무엇을 확인해야 하는가?