디버깅에 쓰는 시간이 정말 많습니다. 누구나 그렇죠. 요즘은 제 앱의 버그를 쫓느라 Xcode와 시뮬레이터 안에서 보내는 시간이 대부분인데, 그럴 때마다 새삼 느낍니다. Xcode에는 아무도 거의 안 건드리는 강력한 디버깅 기능이 정말 많다는 걸요. 이 글은 오랜 시간에 걸쳐 자리 잡은 제 디버깅 워크플로우 중에서, 실제로 시간을 아껴주는 부분들을 정리한 것입니다.
브레이크포인트는 생각보다 강력합니다#
대부분은 거터를 클릭해 브레이크포인트를 하나 찍고 끝냅니다. 그런데 우클릭해 보면 디버깅 방식 자체를 바꿔놓을 옵션이 잔뜩 나옵니다.
조건부 브레이크포인트#
1000개 항목을 처리하는 루프에서 347번째만 말썽이라고 해봅시다. 브레이크포인트에 346번 걸려가며 기다릴 수는 없죠.
for i in 0..<users.count {
processUser(users[i]) // 여기에 브레이크포인트 설정, 조건: i == 347
}브레이크포인트를 우클릭해 조건에 i == 347을 넣으면 Xcode는 조건이 참일 때만 멈춥니다. userId == "abc123"이나 error != nil 같은 조건도 똑같이 됩니다.
심볼릭 브레이크포인트#
메서드가 어디서 호출되는지 모를 때 씁니다. 브레이크포인트 네비게이터(Cmd+8)에서 + 버튼을 누르고 “Symbolic Breakpoint"를 선택합니다.
뷰 컨트롤러가 로드될 때마다 전부 보고 싶다면 심볼을 viewDidLoad로 설정하면 됩니다. Xcode가 모든 호출에서 멈춥니다. 너무 시끄러우면 MyViewController.viewDidLoad처럼 좁히면 되고요.
제가 상시로 걸어두는 건 이 세 가지입니다.
UIViewAlertForUnsatisfiableConstraints: Auto Layout 충돌을 그 자리에서 잡아줍니다objc_exception_throw: 크래시가 나기 전에 Objective-C 예외에서 멈춥니다malloc_error_break: 메모리 할당 문제를 찾아냅니다
예외 브레이크포인트#
새 프로젝트에서 가장 먼저 해야 할 설정입니다. 예외 브레이크포인트를 추가해 두면(브레이크포인트 네비게이터에서 + → Exception Breakpoint) 앱이 이미 죽은 뒤가 아니라 뭔가 throw되는 그 순간에 Xcode가 멈춥니다.
콘솔에 정체불명 크래시로만 찍히던 nil 언래핑 버그를 이걸로 수도 없이 잡았습니다.
브레이크포인트 액션#
브레이크포인트가 꼭 실행을 멈춰야 하는 건 아닙니다. 브레이크포인트를 편집해서 “Add Action"을 누르면 이런 게 됩니다.
- 멈추지 않고 변수 출력:
"User count: @(users.count)@" - LLDB 명령 자동 실행
- 셸 스크립트 실행
- 소리 재생(저는 드물게 타는 코드 경로를 확인할 때 씁니다)
“Automatically continue after evaluating actions"에 체크하면, 흐름을 끊지 않는 로거가 됩니다.
LLDB: 실제로 쓰게 되는 콘솔 명령어#
브레이크포인트에서 멈췄을 때 아래쪽 콘솔은 크래시 로그나 들여다보는 곳이 아닙니다. 그게 LLDB고, 정말 유용합니다.
기본#
(lldb) po user
▿ User
- id: "123"
- name: "John Doe"
- email: "john@example.com"po는 객체를 읽기 좋은 형태로 출력합니다. 제 LLDB 사용의 90%가 이겁니다.
더 자세히 보고 싶으면 p를 씁니다.
(lldb) p user.name
(String) $R0 = "John Doe"로컬 변수를 한 번에 다 보려면 이렇게 하고요.
(lldb) frame variable런타임에 값 바꾸기#
LLDB가 진짜 밥값을 하는 지점입니다. 재컴파일 없이 변수를 바꿀 수 있거든요.
(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0버그를 찾았는데 리빌드 없이 수정을 시험해 보고 싶다면? 변수를 바꾸고 계속 실행하면 됩니다. 엣지 케이스를 좁혀갈 때 늘 이렇게 값을 바꿔가며 확인합니다.
메서드 호출도 됩니다.
(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)네비게이션#
(lldb) bt # 콜 스택 표시
(lldb) frame select 3 # 다른 스택 프레임으로 이동
(lldb) continue # 실행 계속 (또는 단순히 'c')
(lldb) n # 스텝 오버 (다음 줄)
(lldb) s # 함수로 스텝 인워치포인트#
변수가 언제 바뀌는지 알고 싶으면 워치포인트를 겁니다.
(lldb) watchpoint set variable user.isLoggedIn이제 코드 어디에서 값이 바뀌든 그 순간 실행이 멈춥니다. 예상 밖의 상태 변경을 추적할 때 이만한 게 없습니다.
Console.app: 숨은 디버깅 도구#
Xcode 콘솔로 충분할 때가 많지만, 시스템 로그며 크래시 리포트며 전부 봐야 할 때는 Console.app을 엽니다.
OSLog를 쓰고 있다면(써야 합니다) Console.app에서 로그를 검색하고 필터링할 수 있습니다.
import os.log
let logger = Logger(subsystem: "com.example.app", category: "networking")
logger.info("Starting API request")
logger.debug("Request URL: \(url.absoluteString)")
logger.error("Failed to decode: \(error.localizedDescription)")Console.app에서는 이렇게 합니다.
- 시뮬레이터나 연결된 기기를 선택
- 서브시스템으로 필터:
subsystem:com.example.app - 레벨로 필터:
subsystem:com.example.app AND level:error
자주 쓰는 필터 술어는 저장해둘 수 있습니다. 저는 네트워크 요청용, 데이터베이스 작업용, 오류와 경고만 보여주는 것, 이렇게 세 개를 갖고 있습니다.
print() 대신 OSLog를 쓰는 이유#
OSLog는 print()를 여기저기 뿌리는 것보다 훨씬 낫습니다. 구조화되어 있어서 레벨과 카테고리로 필터링할 수 있고, 로깅 자체가 최적화되어 있어 앱을 느리게 만들지도 않습니다. 프로덕션에서는 민감한 데이터를 자동으로 가려주고, 앱이 죽은 뒤에도 로그가 남습니다.
급하게 확인할 때는 print()도 괜찮습니다. 나중에 다시 볼 가능성이 있는 건 OSLog로 남기세요.
Console.app 필터 고급 활용#
Console.app은 꽤 강력한 술어 쿼리를 지원합니다. 제가 자주 쓰는 것들입니다.
# 앱의 모든 오류와 결함
subsystem:com.example.app AND (level:error OR level:fault)
# 실패한 네트워크 요청
subsystem:com.example.app AND category:networking AND eventMessage CONTAINS "failed"
# 지난 5분간의 모든 것
subsystem:com.example.app AND timestamp >= now(-5m)로그를 내보내서 버그 리포트에 첨부할 수도 있습니다. Xcode 콘솔에서 복사해 붙여넣는 것보다 훨씬 낫죠.
뷰 디버깅#
UI가 깨졌는데 이유를 모르겠을 때 가장 빠른 길은 뷰 디버거입니다.
앱을 실행하고 문제의 화면으로 이동한 다음, Xcode 디버그 바의 “Debug View Hierarchy” 버튼을 누릅니다(또는 Cmd+Shift+D). 뷰 계층 전체가 3D로 분해되어 나타납니다.
돌려보면 이런 게 눈에 들어옵니다.
- 다른 뷰 뒤에 가려진 채 렌더링되는 뷰
- 화면 저 바깥에 나가 있는 뷰
- 크기가 0인 뷰
- 선택한 뷰의 전체 제약 체인
저는 이걸로 탭 이벤트를 삼키던 투명 버튼, 0x0으로 렌더링되던 레이블, 어쩌다 폭이 10,000픽셀이 된 이미지를 찾아냈습니다.
Auto Layout 디버깅#
뷰 계층에 보라색 경고 아이콘이 보이면 제약 충돌입니다. 클릭하면 어떤 제약끼리 싸우고 있는지 Xcode가 정확히 보여줍니다.
제약에는 식별자를 붙여두세요.
heightConstraint.identifier = "ProfileImageHeight"제약이 깨졌을 때 오류에 메모리 주소 대신 "ProfileImageHeight"가 찍힙니다. 로그 읽기가 완전히 달라집니다.
LLDB에서 제약 트리를 출력할 수도 있습니다.
(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()시간을 아껴주는 시뮬레이터 기능#
느린 애니메이션#
Debug → Slow Animations를 켜면 모든 게 1/10 속도로 움직입니다. 전환 중에 무슨 일이 벌어지는지, 애니메이션이 왜 이상해 보이는지 확인하기 딱 좋습니다.
메모리 경고 시뮬레이션#
Debug → Simulate Memory Warning. 메모리가 부족할 때 앱이 어떻게 버티는지 테스트합니다. 이걸로 이미지 캐싱 버그를 꽤 찾았습니다. 경고가 오기 전까지는 멀쩡하다가, 오는 순간 이미지가 전부 사라지는 종류죠.
Network Link Conditioner#
Xcode → Open Developer Tools → Network Link Conditioner
3G, LTE, 심한 패킷 손실, 완전 오프라인까지 시뮬레이션할 수 있습니다. WiFi에서는 잘 되던 API 요청이 셀룰러에서 타임아웃된 적이 있다면, 바로 이걸로 재현할 수 있습니다.
저는 지연 500ms에 패킷 손실 10%짜리 “Bad Network” 프로필을 상비해 둡니다. 빠른 사무실 WiFi에서는 절대 안 보이는 타임아웃 버그와 로딩 상태 문제가 여기서 걸려 나옵니다.
상태 표시줄 오버라이드#
시뮬레이터 상태 표시줄을 우클릭하면 시간, 배터리 잔량, 신호 세기, 통신사를 바꿀 수 있습니다. 스크린샷을 통일하거나 배터리 잔량별 UI를 확인할 때 유용합니다.
위치 시뮬레이션#
Debug → Location으로 책상에 앉은 채 온갖 위치를 재현할 수 있습니다. 커스텀 좌표, 도시 산책, 고속도로 주행까지 다 있습니다. 위치 기반 기능을 테스트하거나 특정 지역에서만 나는 문제를 잡을 때 아주 요긴합니다.
메모리 디버깅#
Debug Memory Graph#
Debug Memory Graph 버튼(Cmd+Shift+M)을 누르면 메모리에 있는 모든 객체와 참조 관계, 그리고 정작 궁금한 것, 즉 어떤 게 누수되고 있는지를 Xcode가 보여줍니다.
보라색 느낌표가 누수입니다. 클릭하면 retain cycle이 보입니다.
제가 가장 자주 보는 누수는 이겁니다.
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setupHandler() {
onComplete = {
self.dismiss(animated: true) // ❌ self를 강하게 캡처
}
}
}weak self로 고칩니다.
onComplete = { [weak self] in
self?.dismiss(animated: true) // ✓ retain cycle 없음
}delegate도 weak이어야 합니다.
weak var delegate: ManagerDelegate? // 단순한 'var'가 아님Instruments#
메모리를 제대로 파고들 때는 Instruments를 씁니다(Product → Profile, 또는 Cmd+I).
Allocations 인스트루먼트로는 모든 객체 할당, 시간에 따른 메모리 증가, 메모리를 가장 많이 쓰는 클래스, 할당이 일어난 위치의 스택 트레이스까지 볼 수 있습니다.
제가 주로 찾는 건 시간에 비례해 계속 늘어나는 메모리(거의 누수입니다), 예상 밖의 큰 할당(이미지 1000장을 한 번에 다 읽어버린 건 아닌지?), 해제됐어야 하는데 남아 있는 객체, 이 세 가지입니다.
동작 전후로 세대를 표시해 두면(작은 깃발 버튼) 남아 있으면 안 되는 게 뭐가 남았는지 확인할 수 있습니다.
성능 프로파일링#
Time Profiler#
Product → Profile → Time Profiler. 앱에서 느린 동작을 하면서 기록한 뒤 호출 트리를 읽습니다.
“Self Weight"로 정렬하면 병목이 보입니다. 어떤 함수가 self weight 40%를 차지한다면, 시간이 거기로 새고 있는 겁니다.
예전에 JSON 파싱 함수가 초당 1000번 호출되는 걸 발견한 적이 있습니다. 백그라운드 큐로 옮겼더니 UI 버벅임이 사라졌죠.
Main Thread Checker#
백그라운드 스레드에서의 UI 업데이트는 Xcode가 알아서 잡아줍니다. 이런 메시지가 보이면,
Main Thread Checker: UI API called on a background thread: -[UILabel setText:]이런 짓을 한 겁니다.
URLSession.shared.dataTask(with: url) { data, response, error in
self.label.text = "Loaded" // ❌ 크래시!
}고치면 이렇게 됩니다.
URLSession.shared.dataTask(with: url) { data, response, error in
DispatchQueue.main.async {
self.label.text = "Loaded" // ✓
}
}런타임 진단#
Edit Scheme → Run → Diagnostics. 여기 늘어선 체크박스들이 손으로는 절대 못 찾을 버그를 잡아줍니다.
Address Sanitizer#
메모리 손상을 찾아냅니다. use-after-free, 버퍼 오버플로, 메모리 누수. 앱이 2~3배 느려지지만, 다른 방법으로는 사실상 추적이 불가능한 버그를 잡아줍니다. 가끔씩만 나는 크래시를 쫓을 때 켭니다.
Thread Sanitizer#
데이터 레이스와 스레딩 문제를 잡습니다. 켜놓고 앱을 평소처럼 쓰기만 하면 됩니다. race condition이 있다면 Thread Sanitizer가 찾아냅니다.
Address Sanitizer와 Thread Sanitizer는 동시에 못 켜기 때문에, 이상한 크래시를 잡을 때는 둘을 번갈아 씁니다.
Zombie Objects#
해제된 객체로 보낸 메시지를 잡습니다. 켜두면 해제된 메모리를 건드리는 순간 Xcode가 정확히 알려줍니다.
*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000계속 켜두면 안 됩니다. 객체가 해제되지 않아서 메모리가 계속 불어나거든요. 하지만 특정 use-after-free 버그를 집어낼 때는 최고입니다.
흔한 디버깅 시나리오#
실행하자마자 크래시#
일단 예외 브레이크포인트부터 추가합니다. 보통 그걸로 잡힙니다.
안 잡히면 Info.plist에 빠진 키, 초기화 코드의 강제 언래핑, 의존성 주입 실패 순으로 의심해 봅니다.
UI가 업데이트되지 않음#
매번. 매번 이 중 하나입니다.
- 업데이트가 메인 스레드에서 일어나지 않음
- outlet이 연결되지 않음
- 뷰가 실제로는 화면에 없음(LLDB에서
po view.window로 확인, nil이면 계층에 없는 겁니다)
테이블 뷰나 컬렉션 뷰라면 reloadData() 호출을 잊은 거고요.
메모리 경고로 앱이 죽음#
Instruments Allocations로 뭐가 메모리를 붙잡고 있는지 봅니다. 보통은 캐시에서 해제되지 않는 이미지, 해제 안 되는 뷰 컨트롤러(retain cycle), 끝없이 자라는 배열이나 딕셔너리 중 하나입니다.
시뮬레이터에서 메모리 경고를 시뮬레이션해서 정리 코드를 테스트해 두세요.
원인 모를 크래시#
sanitizer를 전부 켭니다. 간헐적이면 대개 스레딩 문제니 Thread Sanitizer로 돌리고, 시스템 프레임워크 안에서 죽는다면 대개 메모리 손상이니 Address Sanitizer로 돌립니다.
시뮬레이터에서 OTA 업데이트 디버깅#
제 앱은 over-the-air 업데이트를 내보내는데, 이 플로우 전체를 디버깅하기에는 시뮬레이터가 제일 편합니다.
먼저 네트워크 트래픽부터 봅니다. 앱 서브시스템으로 필터링한 Console.app에 매니페스트 요청과 응답이 실시간으로 흘러가니, 어떤 헤더가 나가고 서버가 뭘 돌려주는지 정확히 보입니다.
다음엔 일부러 네트워크를 나쁘게 만듭니다. Network Link Conditioner로 느린 연결이나 패킷 손실에서 업데이트 플로우가 어떻게 버티는지 확인합니다. 앱이 멈추는지, 로딩 인디케이터는 뜨는지, 제대로 폴백하는지.
업데이트 체크 로직 주변에는 로그를 심어둡니다. 체크 시작, 업데이트 발견, 다운로드 시작, 새 번들 로드, 각 시점마다 남깁니다.
import os.log
let logger = Logger(subsystem: "com.example.app", category: "updates")
logger.info("Checking for updates...")
let update = try await Updates.checkForUpdateAsync()
logger.info("Update available: \(update.isAvailable)")마지막으로 매니페스트 엔드포인트 자체도 따로 테스트해 봅니다. 앱이 보내는 것과 같은 헤더로 직접 curl 하면 됩니다.
curl -H "expo-protocol-version: 1" \
-H "expo-platform: ios" \
-H "expo-runtime-version: 1.0.0" \
https://your-server.com/api/expo-updates/manifest/클라이언트 쪽을 파기 전에 서버가 올바른 데이터를 주고 있는지 여기서 먼저 확인할 수 있습니다.
서드파티 도구#
Charles Proxy / Proxyman#
네트워크 트래픽 전체 보기, 요청과 응답 수정, 오류 조건 테스트. API 디버깅에는 필수입니다.
Proxyman이 Charles보다 UI가 낫고 macOS에서 더 자연스럽습니다. 작년에 갈아탄 뒤로 돌아갈 생각이 없습니다.
Reveal#
Xcode 뷰 디버거와 비슷하지만 더 강력합니다. Xcode에 연결하지 않아도 실기기에서 동작해서, 테스터 기기의 레이아웃 문제를 잡을 때 좋습니다.
실기기에서 디버깅#
무선 디버깅#
USB로 기기를 한 번만 연결하고, Window → Devices and Simulators에서 “Connect via network"에 체크한 뒤 선을 뽑습니다. 같은 WiFi에 있는 한 기기는 Xcode에 계속 잡혀 있습니다.
Device Console#
Window → Devices and Simulators → Open Console. 시스템 로그, 앱 로그, 크래시 리포트까지 전부 보입니다. Xcode 콘솔보다 훨씬 상세합니다.
테스터가 “앱이 죽었어요"라고 하면 저는 꼭 기기를 연결해서 콘솔 로그를 받아봅니다. 모든 걸 설명해 주는 시스템 레벨 오류가 남아 있는 경우가 많거든요.
실제 디버깅 순서#
- 예외 브레이크포인트가 없으면 추가
- 버그 재현
- 문제로 의심되는 곳 근처에 브레이크포인트 설정
- LLDB에서
po로 값 확인 - 변수를 바꿔가며 재컴파일 없이 수정 시험
- UI 문제면 뷰 계층 확인
- 성능 문제면 Instruments로 프로파일
- 메모리나 스레딩 문제면 sanitizer 켜기
요령은 증상에서 거꾸로 짚어가는 겁니다. 크래시? 예외 브레이크포인트. 느림? Time Profiler. 메모리? Instruments. UI가 이상함? 뷰 디버거.
더 일찍 알았으면 좋았을 것들#
assertion을 쓰세요. 로직 오류가 버그가 되기 전에 잡힙니다.
assert(users.count > 0, "Users array should never be empty here")디버깅을 시작하기 전에 커밋해 두세요. 가설을 시험한다고 이것저것 고치다 보면 반드시 되돌리고 싶어집니다.
끝나면 print 문은 지우세요. 최소한 #if DEBUG로 감싸두기라도 하고요. 묵은 디버그 로그는 다음 버그를 가립니다.
LLDB는 제대로 익혀두세요. 앱을 스무 번 리빌드하는 것보다 빠릅니다.
그리고 시뮬레이터는 진짜 폰이 아닙니다. 기기에서만 나는 버그는 거의 항상 스레딩 아니면 메모리 문제입니다. 아니면 성능 문제거나요. 시뮬레이터는 진짜 iPhone 칩이 아니라 Mac CPU로 돌아가니까요.
늘 열어두는 도구#
- Xcode(당연히)
- 앱 서브시스템으로 필터링한 Console.app
- 네트워크 디버깅용 Proxyman
- 상황이 심각해지면 Instruments
어느 도구도 만능은 아닙니다. 뷰 디버거는 메모리 누수를 못 찾고, Instruments는 Auto Layout 충돌을 풀어주지 못합니다. 결국 실력은 증상에 맞는 도구를 고르는 데서 갈리고, 그건 횟수를 쌓아야 늡니다. retain cycle을 몇 번 밟다 보면 스택 트레이스만 봐도 냄새가 나기 시작하죠. 디버깅이 재미있어지는 날은 아마 안 오겠지만, 적어도 고역은 아니게 됩니다.

