들어가며
안녕하세요, 센디 iOS엔지니어 로디입니다.
Sendy 앱을 개발하면서 네트워크 이슈를 확인해야 할 일이 자주 있었는데, iOS에서는 개발 중인 실기기 요청을 바로 들여다보는 흐름이 생각보다 매끄럽지 않았습니다. Alamofire나 URLSession에 print 로깅을 달아 놔도 Xcode 콘솔에서 필요한 요청만 골라 읽기 어려웠고, 헤더·바디·타이밍을 요청 단위로 묶어서 보기도 불편했습니다.
Apple은 WWDC21: Analyze HTTP traffic in Instruments에서 Instruments로 URLSession HTTP 트래픽을 분석하는 방법을 소개했습니다. SSL Pinning 때문에 프록시가 막힐 때 특히 의미가 있는 방법이지만, 기본 사용 흐름은 맥에 기기를 붙여 세션을 기록한 뒤 타임라인을 돌려 보며 원인을 찾는 쪽에 가깝습니다. 개발 단계에서 화면 조작과 요청을 동시에 맞춰 가며 즉시 확인하는 실시간 디버깅에는 답답함이 남았고, QA가 들고 있는 실기기에서 재현되는 흐름을 그 자리에서 따라가기에도 불리했습니다.
이 글은 그 상황을 풀기 위해 앱 안에 붙인 네트워크 디버거의 구현 경험을 정리한 글입니다. 개발 중인 iPhone에서 발생한 요청을 앱 안에서 가로채고, 같은 Wi-Fi에 있는 맥에서 실시간으로 보게 만드는 데 필요했던 선택과 제약을 순서대로 적었습니다.
진행 배경
문제 정의
처음에는 있는 방법부터 써 보려 했고, 그다음에는 시중에 있는 디버깅 도구를 도입해 문제를 줄여 보려 했습니다. 기존 도구들이 Sendy의 제약을 끝까지 풀어 주지 못한다는 점이 문제였습니다.
Alamofire나 URLSession에 로깅을 붙여 요청·응답을 출력하는 방식은 요청 수가 조금만 늘어도 로그가 금방 섞였고, 어느 요청이 얼마나 걸렸는지 한눈에 파악하기 어려웠습니다. Charles나 Proxyman 같은 외부 프록시는 Wi-Fi 프록시 설정과 SSL Pinning 제약을 동시에 탔고, Instruments의 HTTP Traffic Analyzer는 원인 분석에는 강하지만 실시간 관측에는 맞지 않았습니다.
결국 풀어야 했던 문제는 QA가 들고 있는 실기기나 디버그성 빌드에서 벌어진 네트워크 흐름을 개발 환경에서 실시간으로 확인하는 일이었는데, 기존 도구들로는 마지막 그 한 구간이 비어 있었습니다.
개선 방안 도출
기존 도구가 각각 어떤 지점에서 막혔는지 먼저 한 번 정리하고 넘어갑니다.
외부 프록시 (Charles / Proxyman)
Charles나 Proxyman은 흔히 말하는 MITM 프록시입니다. 기기와 서버 사이에서 트래픽을 보는 방식인데, 두 가지 조건이 같이 맞아야 합니다.
하나는 Wi-Fi에 프록시를 수동으로 박을 수 있어야 한다는 점인데, 사무실 Wi-Fi는 MDM으로 이 설정이 막혀 있었습니다.
둘째는 SSL Pinning입니다. 핀이 걸린 앱은 프록시가 끼워 넣는 인증서를 믿지 않고 TLS 핸드셰이크에서 끊어 버립니다. Sendy도 주요 API에 핀이 박혀 있어서 이쪽 루트는 처음부터 닫혀 있었습니다.
MITM 프록시가 TLS에서 어떤 식으로 끼어들고 SSL Pinning이 왜 그 흐름을 끊는지는 HTTP Proxy와 TLS에 컴퓨터 네트워크 관점에서 따로 정리해 두었습니다. 여기서는 Sendy가 가진 제약만 맞춰서 다룹니다.
Instruments: HTTP Traffic Analyzer
WWDC21에서 소개된 Instruments의 HTTP Traffic Analyzer는 NSURLSession 레이어에서 직접 트래픽을 긁어 오기 때문에 SSL Pinning을 우회할 필요가 없습니다. 헤더·바디·타이밍을 모두 볼 수 있고 Release 빌드에서도 동작합니다. Instruments를 열고 Network Connections 템플릿을 고른 뒤 기기를 타깃으로 Record만 누르면 됩니다.
걸리는 지점은 맥에 기기를 물려 둬야 한다는 점입니다. USB든 무선 페어링이든 연결이 있어야 합니다.
QA 폰은 개발자 맥에 물려 있지 않습니다. 그래서 Instruments 루트도 이 상황에서는 빠졌습니다.
Analyzing HTTP traffic with Instruments — Apple Developer Documentation
방향: 앱 안에서 가로채기
외부에서 보는 방법이 모두 막히면 남는 선택지는 하나입니다. 앱 안에서 요청을 가로채는 것입니다. 이 방향이면 위의 제약이 한 번에 풀립니다. 맥에 선을 꽂지 않아도 되고, Pinning이 있어도 괜찮고, QA가 들고 있는 실기기에서 난 요청도 같은 Wi-Fi에 있는 맥에서 실시간으로 볼 수 있습니다.
대신 구현은 직접 해야 합니다. Instruments는 켜기만 하면 되지만, 앱 안에 디버거를 붙이려면 몇 겹을 직접 짜야 합니다. 당시 제약을 봤을 때 감당 가능한 범위라고 판단했고, 아래 순서대로 쌓았습니다.
- 앱 안에서 모든 HTTP 요청을 한 번에 가로채는 레이어 (
URLProtocol) - 요청 구간별 시간을 쪼개 모으는 레이어 (
URLSessionTaskMetrics) - 같은 Wi-Fi에 있는 맥을 자동으로 찾는 레이어 (Bonjour / mDNS)
- 맥에 실시간으로 메시지를 흘려 보내는 전송 프로토콜 (TCP 위의 길이 프리픽스 + JSON)
각 레이어를 왜 이 선택지를 골랐는지 순서대로 적습니다.
문제 해결 1단계: URLProtocol로 요청 가로채기
문제: 어디서 요청을 잡아야 하는가
앱 안에서 HTTP를 가로챌 방법을 찾으면서 URLProtocol을 가장 먼저 후보로 두게 되었습니다. URLSession이 요청을 처리할 때 핸들러 체인을 먼저 거친다는 점을 이용하면, 커스텀 핸들러를 체인 맨 앞에 끼워 넣기만 해도 모든 요청을 저희 쪽에서 먼저 받을 수 있을 것이라고 판단했기 때문입니다.
동작을 좀 더 구체적으로 들여다보면, URLSession은 요청이 들어올 때마다 등록된 protocolClasses 목록을 위에서부터 훑으며 canInit(with:)로 "이 요청을 처리할 수 있는가"를 물어봅니다. 이때 가장 먼저 true를 돌려준 핸들러가 요청을 가져가는 구조입니다. iOS에는 HTTP·HTTPS·FTP용 기본 핸들러가 이미 등록되어 있기 때문에, 저희 로거를 체인 맨 앞에 배치하기만 하면 모든 트래픽이 로거를 한 번 거쳐 가도록 만들 수 있다고 생각했습니다.
뼈대는 아래와 같이 작성했습니다. handledKey는 같은 요청이 무한 루프로 자기 자신을 다시 타지 않도록 심어 둔 마커로, 이 부분에 대한 자세한 이야기는 뒤에서 다시 드리겠습니다.
final class NetworkLoggerURLProtocol: URLProtocol {
private static let handledKey = "NetworkLoggerURLProtocol.handled"
override class func canInit(with request: URLRequest) -> Bool {
guard URLProtocol.property(forKey: handledKey, in: request) == nil else {
return false
}
return true
}
}해결 방법: 등록 경로가 두 가지입니다
URLProtocol을 체인에 등록하는 방법은 크게 두 가지가 있었습니다.
방법 1. 전역 등록
URLProtocol.registerClass(NetworkLoggerURLProtocol.self)URLSession.shared나 기본 설정으로 만든 URLSession에 적용됩니다.
방법 2. 인스턴스별 등록
let config = URLSessionConfiguration.default
config.protocolClasses = [NetworkLoggerURLProtocol.self] + (config.protocolClasses ?? [])
let session = URLSession(configuration: config)두 경로로 나뉜 이유를 파악해 보니, URLSession이 생성되는 시점에 URLSessionConfiguration을 깊은 복사해 자기 안에 저장해 두는 구조 때문이었습니다. 세션이 한 번 만들어진 뒤에는 원본 configuration을 아무리 수정해도 이미 복사된 쪽에는 반영되지 않습니다. 그 대신 전역 registerClass는 이 복사가 일어나기 전에 기본 설정에 미리 들어가 있기 때문에 URLSession.shared에도 자연스럽게 적용된다는 사실을 확인할 수 있었습니다.
예상치 못한 변수: Alamofire가 전역 등록을 무시했습니다
여기까지는 이론상의 이야기였고, 실제로 Sendy에 붙여 본 결과 Alamofire를 통해 나가는 요청만 저희 로거에 잡히지 않는 현상을 확인했습니다.
원인을 좁혀 보기 위해 Alamofire 내부 코드를 살펴봤는데, Alamofire.Session이 초기화될 때 자체적으로 URLSession을 생성하도록 구현되어 있음을 알 수 있었습니다.
// Alamofire 내부 (단순화)
public class Session {
let session: URLSession
public init(configuration: URLSessionConfiguration = .af.default) {
self.session = URLSession(configuration: configuration)
}
}Session.default가 평가되는 순간 configuration이 이미 세션 안에 복사되어 들어가 있게 되고, 그 이후에 전역 URLProtocol.registerClass를 호출해도 Alamofire가 들고 있는 복사본에는 반영되지 않는 구조임을 파악할 수 있었습니다.
즉, Sendy처럼 네트워크 레이어가 Alamofire 중심으로 구성된 앱에서는 전역 등록만으로는 충분하지 않다는 사실을 확인하게 되었습니다.
해결 방법: 두 가지 옵션을 저울질했습니다
이 문제를 풀기 위해 두 가지 선택지를 놓고 검토했습니다.
옵션 A. Alamofire Session을 커스텀 configuration으로 교체
let config = URLSessionConfiguration.af.default
config.protocolClasses = [NetworkLoggerURLProtocol.self] + (config.protocolClasses ?? [])
let session = Session(configuration: config)정석에 가까운 방법이었지만, Sendy 내부에는 Session.default를 그대로 사용하고 있는 호출부가 이미 많은 상태였습니다. 디버거 하나를 붙이기 위해 네트워크 레이어 전반을 수정해야 한다는 점이 부담스럽게 다가왔습니다.
옵션 B. Method Swizzling으로 URLSessionConfiguration의 protocolClasses getter 후킹
URLSession이 configuration을 복사해 가기 전에, getter 자체를 바꿔서 읽을 때마다 로거 클래스를 앞에 끼워 넣도록 만드는 방식이었습니다.
extension URLSessionConfiguration {
static func swizzleProtocolClasses() {
let original = class_getInstanceMethod(
URLSessionConfiguration.self,
#selector(getter: protocolClasses)
)
let swizzled = class_getInstanceMethod(
URLSessionConfiguration.self,
#selector(URLSessionConfiguration.swizzled_protocolClasses)
)
if let original, let swizzled {
method_exchangeImplementations(original, swizzled)
}
}
@objc func swizzled_protocolClasses() -> [AnyClass]? {
var classes = self.swizzled_protocolClasses() ?? []
if !classes.contains(where: { $0 == NetworkLoggerURLProtocol.self }) {
classes.insert(NetworkLoggerURLProtocol.self, at: 0)
}
return classes
}
}이 방식이라면 Alamofire가 내부적으로 configuration을 읽는 순간에도 스위즐링된 getter를 타기 때문에, 세션이 어떤 경로로 만들어지더라도 로거가 끼어들게 됩니다. 호출부를 하나도 수정하지 않아도 된다는 점이 가장 큰 장점이라고 판단했습니다.
저희는 옵션 B를 선택했습니다. Method Swizzling이 일반적으로 권장되는 방식은 아니라는 점을 알고는 있었지만, 이 디버거가 DEBUG 빌드에서만 동작하고 호출부 대신 설정 한 곳만 건드린다는 점에서 도입 비용 대비 이득이 크다고 판단했기 때문입니다. 실제 앱에서는 AppDelegate에 별도 Configurator를 두어 시작 지점을 명시하고, 이 안에서 로거 설정과 UI 진입점을 함께 켜도록 구성했습니다.
struct AppDelegateNetworkLoggerConfigurator: Configurable {
init(completionHandler: () -> Void) {
#if DEBUG
let config = NetworkLogger.Configuration(
maxLogCount: 500,
persistToDisk: true,
remoteLoggingEnabled: true,
excludeUrlPatterns: [
".*analytics.*",
".*firebase.*",
".*datadog.*",
".*mixpanel.*",
".*appsflyer.*"
],
sensitiveHeaders: ["Authorization", "Cookie", "X-Auth-Token"],
appIdentifier: Bundle.main.bundleIdentifier ?? "com.sendy.app"
)
NetworkLogger.shared.start(configuration: config)
NetworkLogger.shared.enableShakeToShow()
#endif
completionHandler()
}
}효과: 인터셉터가 실제로 하는 일
이렇게 설정을 마친 뒤에는, 체인에 들어온 요청이 저희 핸들러를 지나갈 때 안쪽에서 세 가지 일이 순서대로 일어나도록 구현했습니다. 먼저 요청 정보를 로그로 남기고, 그다음 실제 통신은 그대로 밖으로 내보내며, 마지막으로 응답을 원래 클라이언트에게 돌려주는 구조입니다.
final class NetworkLoggerURLProtocol: URLProtocol {
private static let handledKey = "NetworkLoggerURLProtocol.handled"
private var dataTask: URLSessionDataTask?
private lazy var internalSession: URLSession = {
let config = URLSessionConfiguration.default
config.protocolClasses = []
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
override func startLoading() {
guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {
return
}
URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest)
dataTask = internalSession.dataTask(with: mutableRequest as URLRequest)
dataTask?.resume()
}
override func stopLoading() {
dataTask?.cancel()
}
}여기서 handledKey 마커가 핵심입니다. 인터셉트한 요청을 밖으로 다시 내보내기 위해 만든 내부 URLSession이 같은 로거에게 다시 잡히면 무한 루프가 돌기 때문입니다. 실제 구현은 마커를 심는 것과 함께, 내부 세션의 protocolClasses를 비워 재귀 가능성을 한 번 더 차단합니다.
문제 해결 2단계: URLSessionTaskMetrics로 구간 나누기
문제: "느리다"의 어디가 느린지가 안 보였습니다
요청을 가로채는 단계까지는 마무리가 되었지만, 실제로 써 보니 이 단계에서 얻을 수 있는 정보는 요청 한 건의 총 소요 시간이 전부였습니다. QA에서 "API가 느려요"라는 리포트가 올라왔을 때, DNS 단계가 느린 건지, TCP 핸드셰이크가 느린 건지, TLS 협상이 느린 건지, 서버 응답이 느린 건지를 구분할 방법이 없었다는 뜻입니다. 원인마다 손을 대야 할 위치가 전혀 다르다는 점을 감안하면, 숫자를 좀 더 쪼개서 볼 수 있어야겠다는 생각이 들었습니다.
마침 Apple이 URLSession 쪽에 URLSessionTaskMetrics라는 API를 기본으로 내장해 두고 있다는 사실을 알게 되었습니다. 요청 한 건이 지나간 각 단계의 시작·끝 타임스탬프를 모아 주는 API라서, 앞서 필요했던 구간 분해에 정확히 맞는 도구였습니다.
배경 지식: HTTP 한 번에 들어 있는 여러 층
HTTP 요청 한 건은 실제로는 여러 단계를 순서대로 거치도록 구성되어 있습니다.
도메인 이름 조회 (DNS)
↓
TCP 3-way 핸드셰이크
↓
TLS 협상 (HTTPS일 때)
↓
HTTP 요청 전송
↓
서버 응답 수신 (첫 바이트 — TTFB)
↓
응답 본문 수신 완료
URLSessionTaskMetrics는 이 각 단계의 시작·끝 타임스탬프를 URLSessionTaskTransactionMetrics로 전달해 주는 구조였습니다. 리다이렉트가 있는 경우에는 트랜잭션이 갈래로 나뉘고, 각 갈래마다 위의 구간들이 다시 기록된다는 점도 함께 파악할 수 있었습니다.
해결 방법: 메트릭 수집 코드
실제로는 태스크가 끝날 무렵 urlSession(_:task:didFinishCollecting:) 델리게이트 메서드로 메트릭이 전달되는 방식이었습니다. 저희 URLProtocol 쪽에 해당 델리게이트를 구현해 두고, 넘어온 트랜잭션 메트릭을 하나씩 로그 엔트리에 붙이도록 구성했습니다.
extension NetworkLoggerURLProtocol: URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
for transaction in metrics.transactionMetrics {
recordTransaction(transaction)
}
}
}
func recordTransaction(_ metrics: URLSessionTaskTransactionMetrics) {
let dnsStart = metrics.domainLookupStartDate
let dnsEnd = metrics.domainLookupEndDate
let tcpStart = metrics.connectStartDate
let tcpEnd = metrics.connectEndDate
let tlsStart = metrics.secureConnectionStartDate
let tlsEnd = metrics.secureConnectionEndDate
let requestStart = metrics.requestStartDate
let requestEnd = metrics.requestEndDate
let responseStart = metrics.responseStartDate // 첫 바이트 (TTFB)
let responseEnd = metrics.responseEndDate
}한 가지 주의할 점이 있었습니다. didFinishCollecting과 didCompleteWithError는 거의 비슷한 타이밍에 호출되지만 호출 순서가 보장되지 않는다는 점이었습니다. 처음에는 한쪽이 다른 쪽을 기다리도록 구현했는데, 특정 상황에서 로그가 비어 있는 채로 저장되는 경우가 관찰되어 각 콜백을 독립적으로 처리하도록 수정했습니다.
nil이 말해 주는 것
초반에 메트릭을 모으고 나서 한 가지 이상한 점을 발견했습니다. 타임스탬프 중 일부가 nil로 돌아오는 경우가 있었는데, 처음에는 API 사용법을 잘못 쓴 것이거나 버그인가 의심이 들었습니다. 다시 한 번 Apple 문서를 살펴본 결과, 이것이 의도된 동작이라는 사실을 확인할 수 있었습니다. 연결이 재사용된 경우에는 해당 단계를 다시 거치지 않기 때문에 타임스탬프 자체가 기록되지 않는 것이었습니다.
- DNS가 nil: 기존 TCP 연결을 재사용한 경우.
keep-alive나 HTTP/2 환경에서 자주 나타나는 패턴입니다. - TLS가 nil: HTTP이거나, 역시 연결이 재사용된 경우.
- TCP와 TLS가 모두 nil: 연결이 통째로 재사용된 경우로, 지연이 가장 적은 패턴입니다.
즉 nil이 나오는 것 자체가 "연결이 잘 재사용되고 있다"는 신호였다는 점을, 실제 메트릭을 쌓아 보면서 알게 되었습니다.
효과: 숫자가 있으니 원인이 보였습니다
메트릭을 화면에 띄워 본 직후에 처음 확인한 사실이 하나 있었습니다. 같은 도메인으로 반복 요청을 보내는 상황에서 매번 DNS 구간에 수십 밀리초씩 지연이 붙고 있었다는 점이었습니다. 예상과는 달리 DNS 캐시가 기대만큼 재사용되지 않고 있었던 것입니다.
원인을 추적해 보니 URLSessionConfiguration의 urlCache·캐시 정책 설정이 특정 요청 경로에서 예상과 다르게 동작하고 있음을 파악할 수 있었습니다. 숫자가 없었다면 "서버 응답이 느린 것 같습니다" 수준에서 정리되었을 이슈가, 구간별 타임스탬프가 쌓인 덕분에 DNS 레이어 문제로 정확히 좁혀졌습니다. 메트릭을 붙이는 데 들인 코드량 대비 얻은 관측력이 훨씬 컸다는 점이 이 단계의 가장 큰 수확이었습니다.
문제 해결 3단계: Bonjour로 맥 자동 탐색
문제: 데이터를 어디로 보낼 것인가
요청을 가로채고 구간별 숫자까지 모은 다음에는, 이 데이터를 어디에서 볼 것인가가 다음 고민거리였습니다. 파일로만 쌓으면 실시간성이 사라지고, Xcode 콘솔로 그대로 출력하면 애초에 해결하고자 했던 "로그가 섞인다"는 문제로 다시 돌아오게 됩니다. 저희가 원하는 모습은 QA 기기에서 실행 중인 앱을 같은 Wi-Fi 망에 있는 맥 뷰어가 자동으로 찾아 붙는 구조였습니다.
이때 가장 걸리는 부분이 IP 주소였습니다. 회사 Wi-Fi에서는 DHCP로 기기 IP가 수시로 바뀌고, QA가 들고 있는 기기의 IP를 매번 확인해서 입력하는 일 자체가 작업 흐름을 크게 끊어 놓는다는 판단이 들었습니다. 자동 탐색 방식이 필요했고, 이 목적에 맞게 Apple이 기본으로 제공하고 있는 답이 Bonjour라는 사실을 알게 되었습니다.
배경 지식: mDNS와 DNS-SD
Bonjour가 내부적으로 어떻게 동작하는지를 파악하기 위해 두 가지 관련 프로토콜을 먼저 살펴봤습니다.
- mDNS (Multicast DNS, RFC 6762) — 일반적인 DNS는 중앙 서버에 이름 해석을 맡기는 구조이지만, 사무실 Wi-Fi 같은 로컬 네트워크에는 그런 중앙 서버가 없습니다. mDNS는 같은 DNS 동작을 로컬 망에서 멀티캐스트 방식으로 치환한 프로토콜로,
224.0.0.251주소로 쿼리를 뿌리면 같은 망에 있는 기기가 직접 응답하도록 되어 있습니다. 이름이.local.로 끝나는 영역이 이 방식으로 해석된다는 점도 함께 확인할 수 있었습니다. - DNS-SD (DNS Service Discovery, RFC 6763) — mDNS 위에서 "이 호스트가 이런 서비스를 제공하고 있습니다"라고 광고하고 탐색하는 상위 레이어입니다.
정리해 보면 mDNS는 이름을 해석하는 층, DNS-SD는 서비스를 찾는 층이라고 볼 수 있었고, Apple은 이 두 레이어를 묶어 Bonjour라는 이름으로 한 번에 제공하고 있었습니다.
이 구조 위에서 저희는 로거 전용 서비스 타입 하나를 새로 정의해 광고하도록 했습니다.
서비스 타입: _networklogger._tcp.local.
인스턴스 이름: com.sendy.app - iPhone [AB12CD34]._networklogger._tcp.local.
포트: 런타임에 할당
iOS 앱이 _networklogger._tcp를 광고하면, 맥 뷰어는 같은 Wi-Fi 망에서 해당 서비스 타입을 탐색하면서 엔드포인트를 자동으로 받아 오게 됩니다. IP 주소는 Bonjour 내부에서 mDNS로 해석해 주기 때문에, 저희 코드 쪽에서는 IP를 직접 관리할 필요가 없다는 점이 가장 편했습니다.
mDNS와 DNS-SD가 실제 네트워크 상에서 어떤 메시지를 주고받고 이름 해석이 어떤 시점에 이뤄지는지는, 이 글의 범위를 넘는 내용이라 mDNS & DNS-SD — 중앙 서버 없는 서비스 탐색에 따로 정리해 두었습니다.
해결 방법: 권한 키부터
iOS 14부터는 로컬 네트워크 접근에 별도 권한 프롬프트가 붙는다는 점을 개발 중에 확인하게 되었습니다. 권한을 요청하지 않으면 Bonjour가 아무런 에러를 내지 않고 조용히 실패하는 경우가 있어 처음에 디버깅에 꽤 애를 먹었습니다. 이 부분을 확실히 하기 위해, Info.plist에 키 두 개를 세트로 먼저 넣어 두었습니다.
<key>NSLocalNetworkUsageDescription</key>
<string>같은 Wi-Fi의 Mac 디버거와 연결하기 위해 로컬 네트워크 접근이 필요합니다.</string>
<key>NSBonjourServices</key>
<array>
<string>_networklogger._tcp</string>
</array>NSLocalNetworkUsageDescription 쪽이 빠져 있을 때 권한 팝업 자체가 뜨지 않는다는 점이 디버깅에 가장 까다로운 패턴이었습니다. 이 때문에 두 키는 반드시 세트로 넣어야 한다는 점을 팀 온보딩 문서에도 명시해 두었습니다.
해결 방법: iOS 앱에서 광고 (NWListener)
서비스 광고는 NWListener를 사용해 구성했습니다. _networklogger._tcp를 서비스 타입으로 지정하고 광고를 시작하면, 같은 Wi-Fi 망에 있는 다른 기기가 이 서비스를 탐색할 수 있도록 노출됩니다.
final class BonjourServiceAdvertiser {
private var listener: NWListener?
func startAdvertising(port: UInt16 = 0) throws {
listener = try NWListener(using: .tcp, on: port == 0 ? .any : .init(rawValue: port)!)
listener?.service = NWListener.Service(
name: serviceName,
type: "_networklogger._tcp"
)
listener?.start(queue: queue)
}
}포트를 0으로 지정해 두면 OS가 런타임에 비어 있는 포트를 선택해 할당하도록 되어 있어, 포트 충돌 여부를 따로 관리하지 않아도 된다는 점이 편리했습니다.
해결 방법: Mac에서 탐색 (NWBrowser)
맥 뷰어 쪽에서는 NWBrowser를 사용해 동일한 서비스 타입을 탐색하도록 구성했습니다. 탐색 결과가 바뀔 때마다 browseResultsChangedHandler가 호출되기 때문에, 이 지점에서 현재 보이는 기기 목록을 갱신하도록 구현했습니다.
final class BonjourServiceBrowser: ObservableObject {
private var browser: NWBrowser?
func startBrowsing() {
let parameters = NWParameters()
parameters.includePeerToPeer = true
browser = NWBrowser(
for: .bonjour(type: "_networklogger._tcp", domain: nil),
using: parameters
)
browser?.browseResultsChangedHandler = { results, _ in
self.discoveredServices = results.compactMap { result in
guard case .service(let name, let type, let domain, _) = result.endpoint else {
return nil
}
return DiscoveredService(
id: "\(name).\(type).\(domain).\(result.hashValue)",
name: name,
endpoint: result.endpoint
)
}
}
browser?.start(queue: queue)
}
}사용자가 목록에서 원하는 기기를 선택하면, 해당 엔드포인트에 NWConnection을 열어 TCP 연결을 맺도록 구현했습니다.
func connect(to endpoint: NWEndpoint) {
let connection = NWConnection(to: endpoint, using: .tcp)
connection.stateUpdateHandler = { state in
if case .ready = state {
self.activeConnection = connection
}
}
connection.start(queue: .global())
}NWEndpoint에는 IP 대신 서비스 이름이 들어 있고, NWConnection이 내부에서 mDNS를 통해 IP를 해석한 뒤 TCP 핸드셰이크까지 처리해 주도록 되어 있었습니다. 덕분에 코드 어디에도 IP 주소를 박아 둘 필요가 없다는 점이 이 방식의 가장 큰 이점이었습니다.
효과: IP 없이 연결되는 흐름이 완성되었습니다
이 레이어가 붙고 나서 디버깅 루틴이 눈에 띄게 단순해졌습니다. 맥 뷰어를 실행하면 같은 Wi-Fi에 접속된 Sendy 앱이 자동으로 목록에 올라오고, 더블클릭 한 번으로 연결이 맺어지는 흐름이 되었습니다. IP 주소를 주고받는 단계 자체가 사라졌다는 점이 가장 큰 변화라고 할 수 있습니다.
물론 게스트망과 사내망이 분리된 구간처럼 일부 예외 상황에서는 여전히 수동 재접속이 필요했지만, 가장 자주 반복되던 "QA 폰 IP가 뭐예요?"라는 질문이 사라진 것만으로도 디버깅을 시작하기까지의 진입 비용이 크게 줄어들었다는 점을 체감할 수 있었습니다.
문제 해결 4단계: TCP 위에 전송 프로토콜 짜기
문제: TCP는 경계를 보장하지 않습니다
Bonjour로 연결이 맺어지고 나면 TCP 소켓 하나가 생깁니다. 처음에는 "이제 메시지만 실어 보내면 되겠다"고 생각했는데, 실제로 데이터를 주고받아 보면서 다시 확인하게 된 사실이 하나 있었습니다. TCP는 바이트 스트림 전송이고, 메시지 단위 전송이 아니라는 점이었습니다.
한 번에 send한 데이터가 받는 쪽에 하나의 덩어리로 도착한다는 보장은 어디에도 없었습니다. 두 번의 send가 하나로 합쳐져 도착하는 경우도 있었고, 반대로 한 번의 send가 여러 번에 걸쳐 나뉘어 도착하는 경우도 있었습니다. 로그 한 건이 JSON으로 5KB 정도일 때, 맥 뷰어 쪽에서 3KB + 2KB로 쪼개져 들어오는 상황도 드물지 않게 관찰되었습니다.
이 때문에 TCP 소켓 위에 메시지 경계를 별도로 잡아 주는 프레이밍 레이어가 필요하다는 판단이 들었습니다. 저희는 이 역할을 BonjourConnection 안쪽에 두고, 바깥쪽 메시지는 JSON으로 인코딩하는 방식으로 설계했습니다. 스트림과 프레이밍의 일반적인 이론은 이 글의 범위를 넘기 때문에, TCP 프레이밍 — 스트림 위에서 메시지 경계 만들기에 따로 정리해 두었습니다. 여기에서는 저희가 선택지를 어떻게 비교했는지만 다루겠습니다.
해결 방법: 두 가지 프레이밍 방식을 저울질했습니다
TCP 위에서 메시지 경계를 잡는 방법은 크게 두 가지입니다.
방법 A. 구분자(Delimiter)
메시지 끝에 특정 바이트 시퀀스를 붙입니다.
[메시지 내용][0x00]
구현은 단순하지만, 본문에 같은 구분자가 섞이면 이스케이프 처리를 해야 해서 바이너리 데이터를 통째로 실어 나르기가 애매해집니다. 네트워크 로거가 언젠가 요청·응답 바디까지 보내야 할 수 있다는 점이 부담이었습니다.
방법 B. 길이 프리픽스(Length-Prefixed Framing)
메시지 앞에 크기를 정수(보통 4바이트)로 박습니다.
[4바이트 길이][메시지 내용]
본문에 어떤 바이트가 있어도 경계가 흔들리지 않고, JSON이든 바이너리든 동일한 구조로 실을 수 있습니다. 나중에 응답 바디나 이미지까지 스트리밍하는 가능성을 열어 두고 싶어서 이쪽을 골랐습니다.
해결 방법: 메시지 포맷과 타입 분기
struct RemoteLoggingMessage: Codable {
let type: MessageType
let timestamp: Date
let appIdentifier: String
let deviceName: String
let payload: Data
let authToken: String?
enum MessageType: String, Codable {
case handshake
case authorize
case log
case logs
case clear
case commandResult
case ping
case pong
}
func encode() throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(self)
}
}정리하면, 전송 계층은 [4바이트 길이][JSON payload] 포맷으로 메시지 경계를 잡고, 각 JSON 메시지의 논리적 종류는 RemoteLoggingMessage.type으로 구분하도록 두 층을 분리했습니다.
해결 방법: 수신 측 파싱
func receiveMessage(on connection: NWConnection) {
connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { data, _, _, _ in
guard let data, data.count == 4 else { return }
let length = data.reduce(UInt32(0)) { partial, byte in
(partial << 8) | UInt32(byte)
}
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { payload, _, _, _ in
guard let payload else { return }
self.handleMessage(payload)
self.receiveMessage(on: connection)
}
}
}NWConnection의 receive는 한 번 호출에 한 번만 받기 때문에, 스트림을 계속 읽으려면 콜백 안에서 다시 receive를 걸어 주는 재귀형 패턴으로 구성해야 합니다. 이 패턴이 빠지면 첫 메시지 이후에 아무 것도 받지 못하는 상태로 조용히 멈춰 있게 됩니다. 초기 구현에서 한 번 놓쳤다가 로그가 안 들어온다는 피드백을 받고 바로 알아챈 부분이었습니다.
해결 방법: 최소한의 인증 레이어
같은 Wi-Fi에 있는 아무 클라이언트나 이 프로토콜에 붙어서 명령을 내리면 곤란합니다. 완전한 인증 체계까지는 오버엔지니어링이라 판단했고, 대신 앱과 뷰어 사이에서 가벼운 핸드셰이크만 정의했습니다.
앱 측이 먼저 handshake 메시지를 보내 자신의 앱 정보와 인증 필요 여부를 알립니다. 토큰이 필요한 경우에는 맥 뷰어가 authorize 메시지로 토큰을 보내는 방식입니다. 토큰 자체는 DEBUG 빌드에 한정된 공유 시크릿으로 두어, 회사 Wi-Fi 외부에서의 접근 위험을 수용 가능한 수준까지 낮췄습니다.
private func sendHandshake(to connection: BonjourConnection) {
let payload: [String: Any] = [
"appIdentifier": appIdentifier,
"deviceName": deviceName,
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
"appBuild": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown",
"authRequired": authToken != nil
]
let message = RemoteLoggingMessage(
type: .handshake,
appIdentifier: appIdentifier,
deviceName: deviceName,
payload: try! JSONSerialization.data(withJSONObject: payload)
)
connection.send(try! message.encode())
}그다음 authorize, ping, clear 같은 제어 명령은 authToken을 기준으로 따로 검증합니다. 완전한 인증 체계는 아니지만, 개발 환경에서 의도치 않은 접근을 막기엔 충분한 수준이라고 판단했습니다.
디버그 빌드와 프로덕션 나누기
문제: 프로덕션 바이너리에 디버거가 섞이면 안 됩니다
디버거가 프로덕션 빌드에도 같이 들어가면 여러 가지가 걸립니다. App Store 심사에서 NSLocalNetworkUsageDescription이 왜 있냐는 질문이 돌아올 수 있고, Bonjour 관련 심볼이 릴리스 바이너리 크기를 쓸데없이 키웁니다. DEBUG 빌드에만 로거를 켜 두는 분기가 필요했습니다.
해결 방법: 컴파일 조건
// 실제 앱 코드는 DEBUG 빌드에서만 로거를 붙입니다.
#if DEBUG
let config = NetworkLogger.Configuration(
maxLogCount: 500,
persistToDisk: true,
remoteLoggingEnabled: true
)
NetworkLogger.shared.start(configuration: config)
#endif현재 프로젝트는 별도 STAGING 컴파일 플래그를 두지 않습니다. SWIFT_ACTIVE_COMPILATION_CONDITIONS는 DEBUG 중심으로 유지하고, 로컬 네트워크 권한 키는 빌드 스크립트로 주입하는 방식을 택했습니다.
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"
#if DEBUG 안은 프로덕션 빌드에서는 컴파일되지 않는다.
Info.plist 분기
로컬 네트워크 권한 키는 기본 Info.plist에 두지 않고, Info-Debug.plist를 빌드 단계에서 합쳐 넣었다.
Sendy_iOS/
├── Sendy/
│ └── Info-Debug.plist
├── scripts/
│ └── inject-debug-plist.sh
DEBUG_PLIST="${SRCROOT}/Sendy/Info-Debug.plist"
OUTPUT_PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
INJECT_FLAG="${INJECT_DEBUG_INFO_PLIST:-NO}"
if [ "$INJECT_FLAG" = "YES" ]; then
/usr/libexec/PlistBuddy -c "Delete :NSLocalNetworkUsageDescription" "$OUTPUT_PLIST" || true
/usr/libexec/PlistBuddy -c "Delete :NSBonjourServices" "$OUTPUT_PLIST" || true
/usr/libexec/PlistBuddy -c "Merge \"$DEBUG_PLIST\"" "$OUTPUT_PLIST"
fi실제 권한 키 파일은 이렇게 생겼다.
<key>NSLocalNetworkUsageDescription</key>
<string>NetworkLogger uses the local network to send network logs to the Mac viewer app for debugging.</string>
<key>NSBonjourServices</key>
<array>
<string>_networklogger._tcp</string>
</array>이렇게 해 두면 프로덕션 바이너리에는 디버거 UI도 빠지고, 로컬 네트워크 권한 키도 따라 들어가지 않는다.
개선 효과 측정
이 도구는 프로덕션 지표를 직접 움직이는 종류가 아니라서, 효과는 크게 세 축으로 나누어 정리했습니다. 앱 전체 네트워크 트래픽 중 어디까지 관측 가능해졌는지(관측 범위), DEBUG 빌드에 이 도구를 끼워 넣는 비용이 얼마나 되는지(도입 비용), 이 도구가 없었다면 놓쳤을 법한 기술 이슈를 얼마나 찾아냈는지(발견 가치) 세 가지입니다.
관측 범위
도구를 붙이기 전에는 URLSession.shared로 나가는 요청 정도만 print 로깅으로 확인할 수 있었고, 나머지 경로는 사실상 블랙박스에 가까웠습니다. Swizzling과 Bonjour 기반 스트리밍까지 얹은 뒤에는 다음과 같이 관측 범위가 넓어졌습니다.
| 요청 경로 | 도입 전 | 도입 후 |
|---|---|---|
URLSession.shared | 부분 (수동 print) | 전수 관측 |
Alamofire.Session.default | 미관측 | 전수 관측 |
커스텀 URLSession(configuration:) | 미관측 | 전수 관측 |
| 써드파티 SDK의 자체 네트워킹 | 미관측 | URLSession 기반인 경우 전수 관측 |
채울 기준 — 관측 범위 수치가 아니라 경로별 유무로 기록해도 됩니다. 실제 수치로 보강하려면 최근 1개월 Mac 뷰어 DB에서 요청 출처별 카운트를 뽑으면 됩니다.
도입 비용 (DEBUG 빌드 한정)
"만들어 두면 좋은 도구"가 아니라 "비용 대비 이득이 뚜렷한 도구"인지를 따져야 한다고 판단해서, DEBUG 빌드에 끼웠을 때의 오버헤드를 측정 기준으로 잡았습니다.
| 항목 | 측정값 |
|---|---|
| 디버거 모듈 코드 규모 | 채울 빈칸 줄 |
| DEBUG 빌드 바이너리 크기 증가 | 채울 빈칸 MB |
| 앱 cold start 오버헤드 (Swizzling + 로거 초기화) | 채울 빈칸 ms |
| Release 빌드 바이너리 크기 변화 | 0 (컴파일 제외) |
채울 기준
- 코드 규모:
cloc또는tokei로NetworkLogger모듈 경로 측정- 바이너리 크기: Xcode Organizer → App Thinning Size Report에서 DEBUG vs 디버거 제거한 커스텀 빌드 비교
- cold start 오버헤드: Instruments > App Launch 또는 Firebase Performance trace 비교
발견 가치
실제로 이 도구를 붙인 뒤에야 확인할 수 있었던 기술 이슈 사례가 있었습니다. 다음은 도구가 없었다면 "그냥 느린 것 같다"로 끝났을 가능성이 높은 항목들입니다.
- DNS 캐시 재사용 누락 — 같은 도메인 반복 호출에서 매번 DNS 구간에 수십 ms가 붙고 있던 현상.
URLSessionConfiguration의 캐시 정책이 특정 요청 경로에서 의도대로 동작하지 않고 있다는 것을 확인. - keep-alive 미작동 구간 — 특정 엔드포인트에서 TCP·TLS 재협상이 예상보다 자주 일어나고 있던 현상.
nil로 찍혀야 할 TCP/TLS 타임스탬프가 반복적으로 값이 찍히는 걸로 드러남. - 특정 SDK의 불필요한 폴링 — 써드파티 SDK 하나가 백그라운드 복귀 시 짧은 간격으로 동일 요청을 반복 전송하고 있던 현상. 설정 조정으로 주기를 줄임.
채울 기준 — 이후 발견되는 이슈를 이 목록에 추가해 주세요. 발견 건수만 세는 것보다, "이 도구가 아니면 발견이 어려웠을 이슈"만 엄선해 두는 편이 가치 판단이 더 선명합니다.
단점과 예상치 못한 변수
1. 앱에 코드 무게가 생깁니다.
DEBUG 빌드에 한정했지만 URLProtocol·Swizzling·NWListener·TCP 프레이밍까지 레이어가 다섯 겹이라 관리 포인트가 적지 않습니다. iOS 버전이 올라갈 때마다 URLSessionConfiguration의 동작이 미묘하게 바뀌지 않는지 주기적으로 확인해야 합니다.
2. 로컬 네트워크 권한 UX 이슈.
iOS 14 이후 NSLocalNetworkUsageDescription 권한 프롬프트가 뜨는데, 실기기에서 처음 디버거를 쓸 때 권한을 거부하면 복구 경로가 불편합니다. 팀 온보딩 문서에 권한 허용을 명시해 두는 정도로 대응하고 있습니다.
3. Bonjour 자동 탐색이 네트워크 분리 환경에서 끊깁니다.
사무실 Wi-Fi가 게스트/사내망으로 분리되면 맥과 기기가 서로 탐색 못 합니다. 회의실 Wi-Fi 구간에서 한두 번 겪었고, 지금은 수동 재접속 버튼을 둔 상태입니다.
4. 프로덕션 빌드에 디버거 UI가 잘못 들어가지 않도록 하는 규율이 필요합니다.
#if DEBUG 한 줄 실수로 릴리스 바이너리에 심볼이 섞일 수 있어서, CI 단계에서 릴리스 바이너리 내 NetworkLogger 심볼 검색을 하나의 방어막으로 두고 있습니다.
이후 방향성
- WebSocket·gRPC 지원 확대: 현재는 HTTP 요청 중심. 최근 일부 API가 WebSocket으로 옮겨가고 있어 같은 뷰어에서 함께 보도록 확장할 계획입니다. 스트리밍 프레이밍은 이미 길이 프리픽스 기반이라 메시지 타입만 추가하면 됩니다.
- RN 브릿지 이벤트 관측: RN 브라운필드 도입 후 RN ↔ 네이티브 브릿지 호출도 같은 방식으로 수집할 수 있는지 검토 중입니다.
- 공개 소스 정리: 팀 의존성(Sendy 전용 인증 흐름 등)을 분리해
NetworkLogger자체를 공개 가능한 형태로 리팩토링하는 작업이 남아 있습니다. - 메트릭 대시보드: 현재는 뷰어에서 요청 단위로만 보고 있는데, 요청별 구간 분포를 시각화하면 DNS 캐시 · 커넥션 재사용 같은 문제를 더 빨리 발견할 수 있을 것 같습니다.
글을 마치며
처음 하려던 건 단순했습니다. 프록시도 막히고 Instruments도 애매한 상황에서, 어떤 요청이 어디서 얼마나 걸리는지 보고 싶었을 뿐입니다.
URLProtocol로 요청을 잡고, URLSessionTaskMetrics로 구간을 쪼개고, 앱이 Bonjour로 자기 존재를 광고하고, 길이 프리픽스 위에 JSON 메시지를 실어 보냅니다. 레이어마다 하나씩 책임을 가지고, 각 레이어는 그 자체로 표준 iOS API로 해결되는 구조라 한번 붙이면 유지비가 크지 않았습니다.
목표가 처음부터 Charles를 대체하는 만능 툴은 아니었습니다. 제약이 겹치는 상황에서도 관측을 이어 붙일 수 있는 최소 장치를 확보하는 쪽에 가까웠습니다. 결과적으로 그 최소 장치가 팀 디버깅 루틴에 자연스럽게 스며들었고, 이후에는 iOS + macOS 도구 체인으로서의 가능성이 조금씩 보이기 시작했습니다.
읽어 주셔서 감사합니다.