URLProtocol과 Bonjour로 직접 만든 iOS 실시간 네트워크 디버거

안녕하세요. 센디에서 iOS 앱을 개발하는 박정환(Lody)입니다.

센디 앱을 개발하면서 네트워크부터 의심해야 하는 순간이 자주 있었습니다. QA에서 올라오는 "API가 느려요" 리포트, 특정 화면에서만 재현되는 빈 응답 같은 문제는 대부분 네트워크 구간을 봐야 답이 나옵니다. 그런데 iOS에서 개발 중인 실기기의 요청을 바로 들여다보는 흐름이 생각보다 매끄럽지 않았습니다. Alamofire나 URLSession에 print 로깅을 달아 놔도 Xcode 콘솔에서 필요한 요청만 골라 읽기 어려웠고, 헤더·바디·타이밍을 요청 단위로 묶어서 보기도 불편했습니다.

이 글은 그 답답함을 풀기 위해 앱 안에 직접 붙인 네트워크 디버거의 구현 기록입니다. 팀에서 iOS를 혼자 맡고 있어서 문제 정의부터 iOS 쪽 인터셉터, 맥 뷰어 앱까지 전부 직접 설계하고 구현했습니다. 개발 중인 iPhone에서 발생한 요청을 앱 안에서 가로채고, 같은 Wi-Fi에 있는 맥에서 실시간으로 보게 만드는 데 필요했던 선택과 제약을 순서대로 적었습니다.


진행 배경

문제 정의

처음에는 있는 방법부터 써 보려 했고, 그다음에는 시중에 있는 디버깅 도구를 도입해 문제를 줄여 보려 했습니다. 기존 도구들이 센디의 제약을 끝까지 풀어 주지 못한다는 점이 문제였습니다.

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 핸드셰이크에서 끊어 버립니다. 센디도 주요 API에 핀이 박혀 있어서 이쪽 루트는 처음부터 닫혀 있었습니다.

MITM 프록시가 TLS에서 어떤 식으로 끼어들고 SSL Pinning이 왜 그 흐름을 끊는지는 여기서 깊게 다루지 않고, 센디가 가진 제약만 맞춰서 다룹니다.

Instruments HTTP Traffic Analyzer

WWDC21에서 소개된 Instruments의 HTTP Traffic Analyzer는 NSURLSession 레이어에서 직접 트래픽을 긁어 오기 때문에 SSL Pinning을 우회할 필요가 없습니다. 헤더·바디·타이밍을 모두 볼 수 있고, 개발 서명된 빌드라면 Release 구성에서도 동작합니다. 다만 TestFlight나 배포 서명 빌드에는 붙일 수 없습니다. Instruments를 열고 Network Connections 템플릿을 고른 뒤 기기를 타깃으로 Record만 누르면 됩니다.

걸리는 지점은 맥에 기기를 물려 둬야 한다는 점입니다. USB든 무선 페어링이든 연결이 있어야 합니다.

개발자 환경(Xcode·Instruments)과 기기를 USB나 무선 페어링으로 연결해야 하는 기존 방식

QA 폰은 개발자 맥에 물려 있지 않습니다. 기본 사용 흐름도 맥에 기기를 붙여 세션을 기록한 뒤 타임라인을 돌려 보며 원인을 찾는 쪽에 가까워서, 화면 조작과 요청을 동시에 맞춰 가며 즉시 확인하는 용도로는 답답함이 남았습니다. 그래서 Instruments 루트도 이 상황에서는 빠졌습니다.

Analyzing HTTP traffic with Instruments (Apple Developer Documentation)

남은 선택지: 앱 내부 인터셉터

외부에서 보는 방법이 모두 막히면 남는 선택지는 하나입니다. 앱 안에서 요청을 가로채는 것입니다. 이 방향이면 위의 제약이 한 번에 풀립니다. 맥에 선을 꽂지 않아도 되고, Pinning이 있어도 괜찮고, QA가 들고 있는 실기기에서 난 요청도 같은 Wi-Fi에 있는 맥에서 실시간으로 볼 수 있습니다.

앱 내부의 URLProtocol 인터셉터가 모든 요청을 가로채 Wi-Fi로 개발자 Mac에 보내는 경로

대신 구현은 직접 해야 합니다. Instruments는 켜기만 하면 되지만, 앱 안에 디버거를 붙이려면 몇 겹을 직접 짜야 합니다. 당시 제약을 봤을 때 감당할 수 있는 범위라고 봤고, 아래 순서대로 쌓았습니다.

  • 앱 안에서 모든 HTTP 요청을 한 번에 가로채는 레이어 (URLProtocol)
  • 요청 구간별 시간을 쪼개 모으는 레이어 (URLSessionTaskMetrics)
  • 같은 Wi-Fi에 있는 맥을 자동으로 찾는 레이어 (Bonjour / mDNS)
  • 맥에 실시간으로 메시지를 흘려 보내는 전송 프로토콜 (TCP 위의 길이 프리픽스 + JSON)

각 레이어를 왜 이 선택지로 골랐는지 순서대로 적고, 마지막으로 이 코드 전체가 프로덕션 빌드에 실려 나가지 않게 격리한 방법을 정리합니다.


문제 해결 1단계: URLProtocol 인터셉터

문제: 요청을 잡을 위치

앱 안에서 HTTP를 가로챌 방법을 찾으면서 가장 먼저 후보에 올린 것은 URLProtocol이었습니다. URLSession이 요청을 처리할 때 핸들러 체인을 먼저 거치기 때문에, 커스텀 핸들러를 체인 맨 앞에 끼워 넣기만 해도 모든 요청을 우리 쪽에서 먼저 받을 수 있겠다고 봤습니다.

동작을 좀 더 구체적으로 들여다보면, URLSession은 요청이 들어올 때마다 등록된 protocolClasses 목록을 위에서부터 훑으며 canInit(with:)로 "이 요청을 처리할 수 있는가"를 물어봅니다. 이때 가장 먼저 true를 돌려준 핸들러가 요청을 가져가는 구조입니다. iOS에는 HTTP·HTTPS·FTP용 기본 핸들러가 이미 등록되어 있기 때문에, 우리 로거를 체인 맨 앞에 배치하기만 하면 모든 트래픽이 로거를 한 번 거쳐 갑니다.

protocolClasses 체인에서 NetworkLoggerURLProtocol이 canInit으로 요청을 먼저 확인하고 가로채거나 기본 핸들러로 넘기는 흐름

뼈대는 아래와 같이 작성했습니다. 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에 적용됩니다.

방법 2. 인스턴스별 등록

let config = URLSessionConfiguration.default
config.protocolClasses = [NetworkLoggerURLProtocol.self] + (config.protocolClasses ?? [])
let session = URLSession(configuration: config)

두 경로는 등록 시점이 아니라 적용 대상이 다릅니다. 전역 registerClass는 configuration에 값을 써넣는 것이 아니라 별도의 전역 레지스트리에 클래스를 올리고, URLSession.shared가 요청을 처리할 때마다 이 레지스트리를 조회합니다. 반면 커스텀 URLSessionConfiguration으로 만든 세션은 이 레지스트리를 아예 보지 않습니다. 자신이 생성될 때 받은 configuration의 protocolClasses 목록만 씁니다. 참고로 URLSession은 생성 시점에 configuration을 깊은 복사해 저장하기 때문에, 세션을 만든 뒤 원본 configuration을 수정해도 반영되지 않습니다.

예상치 못한 변수: Alamofire의 전역 등록 미반영

여기까지는 이론상의 이야기였고, 실제로 센디에 붙여 본 결과 Alamofire를 통해 나가는 요청만 우리 로거에 잡히지 않는 현상을 확인했습니다.

원인을 좁혀 보려고 Alamofire 내부 코드를 열어 봤습니다. Alamofire.Session은 초기화될 때 자체적으로 URLSession을 만들고 있었습니다.

// Alamofire 내부 (단순화)
public class Session {
    let session: URLSession
 
    public init(configuration: URLSessionConfiguration = .af.default) {
        self.session = URLSession(configuration: configuration)
    }
}

Alamofire는 기본값이 커스텀 configuration(.af.default)입니다. 앞에서 본 대로 커스텀 configuration으로 만든 세션은 전역 레지스트리를 조회하지 않으니, registerClass를 언제 호출하든 이 세션에는 미치지 않습니다.

registerClass 전역 레지스트리를 URLSession.shared는 조회하지만 Alamofire.Session.default는 조회하지 않는 차이

즉, 센디처럼 네트워크 레이어가 Alamofire 중심으로 구성된 앱에서는 전역 등록만으로는 충분하지 않았습니다.

해결 방법: 스위즐링 선택

이 문제를 풀기 위해 두 가지 선택지를 놓고 검토했습니다.

옵션 A. Alamofire Session을 커스텀 configuration으로 교체

let config = URLSessionConfiguration.af.default
config.protocolClasses = [NetworkLoggerURLProtocol.self] + (config.protocolClasses ?? [])
let session = Session(configuration: config)

정석에 가까운 방법이었지만, 센디 내부에는 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(getter: URLSessionConfiguration.nl_protocolClasses)
        )
        if let original, let swizzled {
            method_exchangeImplementations(original, swizzled)
        }
    }
 
    @objc var nl_protocolClasses: [AnyClass]? {
        var classes = self.nl_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를 비워 두긴 하지만, 스위즐링이 켜진 상태에서는 getter가 읽히는 순간 로거가 도로 끼어들기 때문에 실제로 재귀를 끊는 것은 이 마커 하나입니다.

효과: 전 경로 관측

스위즐링까지 붙인 뒤 요청이 잡히는 범위가 어떻게 달라졌는지 경로별로 확인했습니다.

요청 경로도입 전도입 후
URLSession.shared부분 (수동 print)전수 관측
Alamofire.Session.default미관측전수 관측
커스텀 URLSession(configuration:)미관측전수 관측
써드파티 SDK의 자체 네트워킹미관측URLSession 기반인 경우 전수 관측

가로챈 로그는 우선 앱 안에 넣은 뷰어로 확인했습니다. 기기를 흔들거나 플로팅 버튼을 누르면 요청 목록이 열리고, 각 요청의 헤더·바디·상태 코드를 바로 볼 수 있습니다. print 로깅 시절과 달리 분석용 SDK 요청(excludeUrlPatterns)은 걸러지고, Authorization 같은 민감 헤더는 마스킹된 채로 쌓입니다.

다만 모든 요청이 잡히기 시작하자 다음 문제가 바로 드러났습니다. 로그 목록에서 알 수 있는 것은 요청 한 건의 총 소요 시간뿐이었습니다.


문제 해결 2단계: URLSessionTaskMetrics 구간 분해

문제: 총 소요 시간의 한계

QA에서 "API가 느려요"라는 리포트가 올라왔을 때, DNS 단계가 느린 건지, TCP 핸드셰이크가 느린 건지, TLS 협상이 느린 건지, 서버 응답이 느린 건지를 총 소요 시간만으로는 구분할 수 없었습니다. 원인마다 손을 대야 할 위치가 전혀 다르다는 점을 감안하면, 숫자를 더 쪼개서 볼 수 있어야 했습니다.

마침 URLSession에는 URLSessionTaskMetrics라는 API가 기본으로 들어 있었습니다. 요청 한 건이 지나간 각 단계의 시작·끝 타임스탬프를 모아 주는 API라서, 필요했던 구간 분해에 정확히 맞는 도구였습니다.

배경 지식: HTTP 요청 한 건의 여러 층

HTTP 요청 한 건은 실제로는 DNS 조회, TCP 3-way 핸드셰이크, TLS 협상(HTTPS일 때), 요청 전송, 첫 바이트 수신(TTFB), 본문 수신 완료까지 여러 단계를 순서대로 거칩니다. URLSessionTaskMetrics는 이 각 단계의 시작·끝 타임스탬프를 URLSessionTaskTransactionMetrics로 전달해 줍니다. 리다이렉트가 있으면 트랜잭션이 갈래로 나뉩니다. 각 갈래마다 위의 구간들이 다시 기록됩니다.

HTTP 요청 한 건을 DNS 조회·TCP 연결·TLS 핸드셰이크·요청 전송·응답 대기(TTFB)·응답 수신 구간으로 나눈 URLSessionTaskMetrics 타임라인

요청 시작
  ├── DNS 조회*   domainLookupStartDate ~ domainLookupEndDate
  ├── TCP 연결*   connectStartDate ~ connectEndDate
  ├── TLS 협상*   secureConnectionStartDate ~ secureConnectionEndDate
  ├── 요청 전송   requestStartDate ~ requestEndDate
  ├── 첫 바이트   responseStartDate (TTFB)
  └── 본문 수신   responseEndDate

  * 연결 재사용 시 nil

해결 방법: 메트릭 수집

실제로는 태스크가 끝날 무렵 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이 나오는 것 자체가 "연결이 잘 재사용되고 있다"는 신호였습니다.

효과: 구간 숫자로 좁힌 원인

메트릭을 뷰어에 띄우자마자 도구가 아니었으면 "그냥 느린 것 같다"로 끝났을 문제들이 하나씩 잡히기 시작했습니다.

  • 커넥션 재사용 실패: 같은 도메인으로 반복 요청을 보내는데, nil로 찍혀야 할 DNS·TCP·TLS 타임스탬프에 매번 값이 찍혔습니다. DNS 구간만 해도 요청마다 30~60ms입니다 (뷰어 타이밍 탭 기준). 특정 요청 경로에서 세션이 커넥션을 재사용하지 못하고 매번 새로 열고 있다는 뜻이라, 해당 경로의 세션 생성 방식을 손봤습니다.
  • 써드파티 SDK의 불필요한 폴링: SDK 하나가 백그라운드 복귀 시 짧은 간격으로 동일 요청을 반복 전송하고 있었습니다. 설정 조정으로 주기를 줄였습니다.

숫자가 없었다면 서버 탓으로 정리되었을 이슈들이 클라이언트 설정 문제로 정확히 좁혀졌습니다. 델리게이트 메서드 하나를 구현해서 얻은 관측입니다.

그런데 이 시점까지의 뷰어는 기기 화면 안에 있었습니다. 내 개발 기기라면 흔들어서 열면 그만이지만, QA가 재현 중인 기기라면 화면을 뺏어야 합니다. 재현 조작과 로그 확인을 동시에 할 수 없다는 뜻입니다. 로그를 기기 밖으로, 맥으로 보내야 하는 이유가 여기서 생겼습니다.


문제 해결 3단계: Bonjour 자동 탐색

문제: 데이터의 목적지

로그를 맥으로 보내기로 하고 나니 첫 고민은 연결을 어떻게 맺는가였습니다. 파일로 쌓아 옮기면 실시간성이 사라지고, 수동으로 IP를 입력하는 방식은 회사 Wi-Fi의 DHCP 환경에서 기기 IP가 수시로 바뀌는 문제를 그대로 안고 갑니다. QA가 들고 있는 기기의 IP를 매번 확인해서 입력하는 일 자체가 작업 흐름을 크게 끊습니다.

원하는 모습은 QA 기기에서 실행 중인 앱을 같은 Wi-Fi 망에 있는 맥 뷰어가 자동으로 찾아 붙는 구조였고, 이 목적에 맞게 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가 실제 망에서 주고받는 메시지와 이름 해석 시점은 이 글에서는 깊게 다루지 않습니다.

NWListener 광고와 NWBrowser 탐색이 멀티캐스트 mDNS로 서로를 찾아 TCP 연결까지 가는 Bonjour 자동 탐색 시퀀스

해결 방법: 권한 키 두 개

iOS 14부터는 로컬 네트워크 접근에 별도 권한 프롬프트가 붙습니다. 이걸 개발 중에야 알았습니다. 권한을 요청하지 않으면 Bonjour가 아무런 에러 없이 조용히 실패해서 처음에 디버깅에 꽤 애를 먹었습니다. 이 부분을 확실히 하기 위해, Info.plist에 키 두 개를 세트로 먼저 넣어 두었습니다.

<key>NSLocalNetworkUsageDescription</key>
<string>같은 Wi-Fi의 Mac 디버거와 연결하기 위해 로컬 네트워크 접근이 필요합니다.</string>
 
<key>NSBonjourServices</key>
<array>
    <string>_networklogger._tcp</string>
</array>

NSLocalNetworkUsageDescription 쪽이 빠져 있을 때 권한 팝업 자체가 뜨지 않는다는 점이 디버깅에 가장 까다로운 패턴이었습니다. 이 때문에 두 키는 반드시 세트로 넣어야 한다는 점을 팀 온보딩 문서에도 명시해 두었습니다.

해결 방법: NWListener 광고와 NWBrowser 탐색

서비스 광고는 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가 런타임에 비어 있는 포트를 선택해 할당하기 때문에, 포트 충돌 여부를 따로 관리하지 않아도 됩니다.

맥 뷰어 쪽에서는 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에 접속된 센디 앱이 몇 초 안에 목록에 올라오고, 더블클릭 한 번으로 연결이 맺어집니다. IP 주소를 주고받는 단계 자체가 사라졌습니다.

물론 게스트망과 사내망이 분리된 구간처럼 일부 예외 상황에서는 여전히 수동 재접속이 필요했지만, 가장 자주 반복되던 "QA 폰 IP가 뭐예요?"라는 질문이 사라졌습니다. 기기 IP 확인이나 Wi-Fi 설정 변경 같은 준비 단계 없이, 뷰어 실행과 더블클릭만으로 로그가 흐르기 시작합니다.


문제 해결 4단계: TCP 위 전송 프로토콜

문제: 메시지 경계 없는 바이트 스트림

Bonjour로 연결이 맺어지고 나면 TCP 소켓 하나가 생깁니다. 처음에는 "이제 메시지만 실어 보내면 되겠다"고 생각했는데, 실제로 데이터를 주고받아 보면서 다시 확인하게 된 사실이 하나 있었습니다. TCP는 바이트 스트림 전송이고, 메시지 단위 전송이 아니라는 점이었습니다.

한 번에 send한 데이터가 받는 쪽에 하나의 덩어리로 도착한다는 보장은 어디에도 없었습니다. 두 번의 send가 하나로 합쳐져 도착하는 경우도 있었고, 반대로 한 번의 send가 여러 번에 걸쳐 나뉘어 도착하는 경우도 있었습니다. 로그 한 건이 JSON으로 5KB 정도일 때, 맥 뷰어 쪽에서 3KB + 2KB로 쪼개져 들어오는 일도 드물지 않았습니다.

이 때문에 TCP 소켓 위에 메시지 경계를 별도로 잡아 주는 프레이밍 레이어가 필요했습니다. 이 역할을 연결 계층 안쪽에 두고, 바깥쪽 메시지는 JSON으로 인코딩하는 방식으로 설계했습니다. 스트림 위에 메시지 경계를 세우는 일반론은 이 글에서는 따로 떼어 다루지 않습니다.

해결 방법: 길이 프리픽스 프레이밍

TCP 위에서 메시지 경계를 잡는 방법은 크게 두 가지입니다. 구분자(delimiter) 방식은 메시지 끝에 0x00 같은 바이트 시퀀스를 붙입니다. 구현은 단순하지만 본문에 같은 구분자가 섞이면 이스케이프 처리를 해야 해서, 요청·응답 바디 같은 바이너리를 통째로 실어 나르기에는 애매합니다. 네트워크 로거가 언젠가 바디까지 보내야 할 수 있다는 점이 걸렸습니다.

그래서 길이 프리픽스(length-prefixed framing)를 골랐습니다. 메시지 앞에 크기를 4바이트 정수로 박는 방식입니다.

메시지 경계가 없는 TCP 바이트 스트림을 4바이트 길이 프리픽스와 JSON 메시지 단위로 잘라 읽는 프레이밍

0                       4                                      4+N
+-----------------------+-------------------------------------+
|  length (4 bytes)     |  payload (N bytes)                  |
|  UInt32, Big Endian   |  JSON-encoded RemoteLoggingMessage  |
+-----------------------+-------------------------------------+

본문에 어떤 바이트가 있어도 경계가 흔들리지 않고, 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를 걸어 주는 재귀형 패턴으로 구성해야 합니다. 이 패턴이 빠지면 첫 메시지 이후로는 아무것도 받지 못한 채 조용히 멈춥니다. 초기 구현에서 한 번 놓쳤다가 로그가 안 들어온다는 피드백을 받고 바로 알아챈 부분이었습니다.

예상치 못한 변수: 인증 없는 로그 노출

처음 프로토콜을 설계할 때는 앱이 handshake 메시지로 자기 정보를 알리는 정도만 두었습니다. 그런데 이 상태로는 같은 Wi-Fi에 있는 아무 클라이언트나 서비스 타입만 알면 붙어서 로그를 받아볼 수 있습니다. 이 구멍을 뒤늦게 알았습니다. 로그에는 마스킹을 거쳐도 API 경로와 파라미터 구조가 그대로 남습니다.

완전한 인증 체계까지는 과했습니다. 대신 앱과 뷰어 사이의 가벼운 핸드셰이크를 인증 관문으로 확장했습니다. 앱이 handshake로 인증 필요 여부를 알리면 맥 뷰어가 authorize 메시지로 페어링 토큰을 보내고, 검증 전에는 로그 스트림을 열지 않습니다.

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())
}

clear, ping 같은 제어 명령도 authToken을 기준으로 따로 검증합니다. 토큰 자체는 DEBUG 빌드에 한정된 페어링 토큰으로 두어, 회사 Wi-Fi 외부에서의 접근 위험을 수용 가능한 수준까지 낮췄습니다. 완전한 인증 체계는 아니지만, 개발망 밖 접근을 막는 용도로는 이 정도면 된다고 봤습니다.

효과: 맥에서 실시간으로 흐르는 로그

프레이밍과 핸드셰이크까지 얹고 나서야 처음 세운 목표가 완성됐습니다. QA 기기에서 재현 조작을 하는 동안, 같은 Wi-Fi의 맥 뷰어에는 그 기기에서 발생한 요청이 구간별 타이밍과 함께 실시간으로 쌓입니다. 화면을 뺏지 않아도 됩니다. 프레이밍을 넣은 뒤로는 뷰어 쪽에서 메시지 경계가 깨져 파싱이 실패하는 문제도 재발하지 않았습니다. 로그 한 건이 어떤 크기로 쪼개져 도착하든 길이 헤더 기준으로 다시 조립되기 때문입니다.


디버그 빌드와 프로덕션 분리

여기까지로 도구는 동작합니다. 남은 걱정은 이 다섯 겹의 코드가 릴리스 바이너리에 실려 나가는 사고입니다.

문제: 프로덕션 바이너리 오염

디버거가 프로덕션 빌드에도 같이 들어가면 여러 가지가 걸립니다. App Store 심사에서 NSLocalNetworkUsageDescription이 왜 있냐는 질문이 돌아올 수 있고, Bonjour 관련 심볼이 릴리스 바이너리 크기를 쓸데없이 키웁니다. DEBUG 빌드에만 로거를 켜 두는 분기가 필요했습니다.

해결 방법: 컴파일 조건과 plist 주입

로거 시작 코드는 #if DEBUG 안에만 두었습니다. 별도 STAGING 플래그 없이 SWIFT_ACTIVE_COMPILATION_CONDITIONS는 DEBUG 중심으로 유지합니다.

SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"

로컬 네트워크 권한 키는 기본 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

여기에 스크립트 오동작으로 권한 키가 릴리스에 섞여 나가는 사고를 막기 위해, CI가 빌드 전과 아카이브 후 두 번 Info.plist를 검사해 릴리스 산출물에 로컬 네트워크 키가 없는지 확인합니다.

이렇게 해 두면 릴리스 빌드에서는 로거 시작 코드가 컴파일되지 않아 스위즐링도 서비스 광고도 일어나지 않고, 로컬 네트워크 권한 키도 따라 들어가지 않습니다. 다만 패키지 자체는 앱 타깃에 링크된 채라 바이너리에서 완전히 빠지는 것은 아닙니다. 릴리스 구성에서 링크까지 제외하는 정리는 남은 과제로 두었습니다.


단점과 예상치 못한 변수

1. 앱에 코드 무게가 생긴다.

DEBUG 빌드에 한정했지만 URLProtocol·Swizzling·NWListener·TCP 프레이밍까지 레이어가 다섯 겹입니다. 패키지 전체가 Swift 약 6,800줄(맥 뷰어 라이브러리 포함, 소스 라인 수 기준) 규모라 관리 포인트가 적지 않습니다. iOS 버전이 올라갈 때마다 URLSessionConfiguration의 동작이 미묘하게 바뀌지 않는지 주기적으로 확인해야 합니다.

2. 로컬 네트워크 권한 UX 이슈.

실기기에서 처음 디버거를 쓸 때 권한을 거부하면 복구 경로가 불편해서, 팀 온보딩 문서에 권한 허용을 명시해 두는 정도로 대응하고 있습니다.

3. Bonjour 자동 탐색이 네트워크 분리 환경에서 끊긴다.

사무실 Wi-Fi가 게스트/사내망으로 분리되면 맥과 기기가 서로 탐색하지 못합니다. 회의실 Wi-Fi 구간에서 한두 번 겪었고, 지금은 수동 재접속 버튼을 둔 상태입니다.

4. Method Swizzling은 계속 지고 가는 부채다.

호출부 수정 없이 전 경로를 덮는 대가로, Foundation 내부 구현이 바뀌면 가장 먼저 의심해야 하는 지점이 이 스위즐링입니다. iOS 메이저 업데이트마다 회귀 확인 항목에 올려 두고는 있지만, 스위즐링 자체를 대체할 답은 아직 없습니다.


마치며

처음 하려던 건 단순했습니다. 프록시도 막히고 Instruments도 애매한 상황에서, 어떤 요청이 어디서 얼마나 걸리는지 보고 싶었을 뿐입니다.

선택한 구조

URLProtocol로 요청을 잡고, URLSessionTaskMetrics로 구간을 쪼개고, 앱이 Bonjour로 자기 존재를 광고하고, 길이 프리픽스 위에 JSON 메시지를 실어 보냅니다. 레이어마다 하나씩 책임을 가지고, 각 레이어는 그 자체로 표준 iOS API로 해결되는 구조라 한번 붙이면 유지비가 크지 않았습니다.

DEBUG 빌드에서 스위즐링·URLProtocol·메트릭·Bonjour·길이 프리픽스 프레이밍을 거쳐 개발자 Mac 뷰어로 로그가 흐르는 전체 구조

  • URLProtocol + 스위즐링: 호출부 수정 없이 Alamofire를 포함한 전 경로를 체인 맨 앞에서 가로챕니다.
  • URLSessionTaskMetrics 구간 분해: DNS·TCP·TLS·요청·응답을 숫자로 쪼개 지연 원인의 위치를 좁힙니다.
  • Bonjour 자동 탐색: IP 입력 없이 같은 Wi-Fi의 맥이 기기를 찾아 붙습니다.
  • 길이 프리픽스 프레이밍: TCP 바이트 스트림 위에 메시지 경계를 세워 로그를 안전하게 복원합니다.
  • DEBUG 분리: 컴파일 조건과 plist 주입으로 프로덕션 바이너리 오염을 차단합니다.

남은 비용과 판단 기준

위의 한계들, 특히 스위즐링 부채와 코드 무게를 안고도 도구는 계속 자라는 중입니다. 응답을 뷰어에서 가짜로 바꿔치기하는 목킹 기능과 로그를 HAR 포맷으로 내보내는 기능이 붙었고, 최근에는 WebSocket 로깅 1차 지원이 들어갔습니다. URLSessionWebSocketTask는 URLProtocol 체인을 타지 않아서 HTTP처럼 자동으로 가로챌 수 없었고, 대신 WebSocket 클라이언트 쪽에 로깅 API를 붙여 WS-IN / WS-OUT 방향 라벨로 같은 타임라인에 기록하는 방식을 택했습니다. 지금은 클라이언트 코드가 로깅 API를 직접 불러야 해서, 호출부 수정 없이 계측되는 방법을 찾는 것이 바로 다음 과제입니다. 그 밖에 RN 브라운필드 도입 후 RN과 네이티브 사이 브릿지 호출을 같은 뷰어에서 볼 수 있는지 검토 중이고, 센디 전용 인증 흐름 같은 팀 의존성을 분리해 NetworkLogger 자체를 공개 가능한 형태로 리팩토링하는 작업도 남아 있습니다.

목표가 처음부터 Charles를 대체하는 만능 툴은 아니었습니다. 제약이 겹치는 상황에서도 관측을 이어 붙일 수 있는 최소 장치를 확보하는 쪽에 가까웠습니다. 당장 쉬운 방법보다 각 레이어를 표준 API로 풀어 유지비를 낮추는 쪽을 택한 것이, 지금까지 이 도구를 계속 쓰게 만든 기준이었습니다. 지금은 QA 재현을 지켜보거나 "API가 느려요" 리포트를 검증할 때 가장 먼저 여는 도구가 됐고, 목킹과 WebSocket 로깅까지 얹히면서 단순 로거보다는 iOS + macOS 디버깅 도구 체인에 가까워졌습니다.

이전 글GitHub Actions + Fastlane 기반 iOS 배포 자동화다음 글채팅을 운송 오더로 바꾸는 파이프라인