LODY/기록

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

박정환박정환

들어가며

센디 앱을 개발하면서 네트워크부터 의심해야 하는 순간이 자주 있었다. QA에서 올라오는 "API가 느려요" 리포트, 특정 화면에서만 재현되는 빈 응답 같은 문제는 대부분 네트워크 구간을 봐야 답이 나온다. 그런데 iOS에서 개발 중인 실기기의 요청을 바로 들여다보는 흐름이 생각보다 매끄럽지 않았다. Alamofire나 URLSessionprint 로깅을 달아 놔도 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이 왜 그 흐름을 끊는지는 HTTP Proxy와 TLS에 컴퓨터 네트워크 관점에서 따로 정리해 두었다. 여기서는 센디가 가진 제약만 맞춰서 다룬다.

Instruments HTTP Traffic Analyzer

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

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

Xcode + Instruments(Mac)USB / 무선 페어링 필수개발자 환경기기

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

Analyzing HTTP traffic with Instruments (Apple Developer Documentation)

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

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

URLSession(Alamofire 포함)URLProtocol인터셉터개발자 Mac(Bonjour 자동 탐색)모든 요청Wi-Fi (USB 불필요)앱 내부 (어떤 기기든)

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

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

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


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

문제: 요청을 잡을 위치

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

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

URLRequestNetworkLoggerURLProtocolcanInit(with:) = true_NSURLHTTPProtocol(기본 핸들러)요청 가로채기+ 실제 전송1. 순서대로 확인2. 처리canInit = false면 다음으로protocolClasses 체인

뼈대는 아래와 같이 작성했다. 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를 언제 호출하든 이 세션에는 미치지 않는다.

URLProtocol.registerClass()전역 레지스트리protocolClasses:[NetworkLoggerURLProtocol, ...]protocolClasses:[_NSURLHTTPProtocol, ...](커스텀 configuration만 사용)요청마다 조회조회하지 않음URLSession.sharedAlamofire.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으로 URLSessionConfigurationprotocolClasses 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로 전달해 준다. 리다이렉트가 있으면 트랜잭션이 갈래로 나뉜다. 각 갈래마다 위의 구간들이 다시 기록된다.

요청 시작
  ├── 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
}

한 가지 주의할 점이 있었다. didFinishCollectingdidCompleteWithError는 거의 비슷한 타이밍에 호출되지만 호출 순서가 보장되지 않는다는 점이었다. 처음에는 한쪽이 다른 쪽을 기다리게 짰는데, 특정 상황에서 로그가 비어 있는 채로 저장되는 걸 보고 각 콜백을 독립적으로 처리하게 바꿨다.

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가 실제 망에서 주고받는 메시지와 이름 해석 시점은 mDNS & DNS-SD: 중앙 서버 없는 서비스 탐색에서 따로 다룬다.

NWListener_networklogger._tcp 광고RemoteLoggingServer로그 브로드캐스트멀티캐스트224.0.0.251(같은 Wi-Fi)NWBrowser_networklogger._tcp 탐색RemoteLoggingClientNWConnection 연결1. 탐색 쿼리2. 쿼리 수신3. 서비스 응답4. NWEndpoint 반환5. 연결 시도6. TCP 연결 완료iOS 앱 (어떤 기기든)Mac

해결 방법: 권한 키 두 개

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 프레이밍: 스트림 위에서 메시지 경계 만들기로 뺐다.

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

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

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

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

NWConnectionreceive는 한 번 호출에 한 번만 받기 때문에, 스트림을 계속 읽으려면 콜백 안에서 다시 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_CONDITIONSDEBUG 중심으로 유지한다.

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 메이저 업데이트마다 회귀 확인 항목에 올려 두고는 있지만, 스위즐링 자체를 대체할 답은 아직 없다.


이후 방향성

위의 한계들, 특히 스위즐링 부채와 코드 무게를 안고도 도구는 계속 자라는 중이다. 응답을 뷰어에서 가짜로 바꿔치기하는 목킹 기능과 로그를 HAR 포맷으로 내보내는 기능이 붙었고, 최근에는 WebSocket 로깅 1차 지원이 들어갔다. URLSessionWebSocketTaskURLProtocol 체인을 타지 않아서 HTTP처럼 자동으로 가로챌 수 없었고, 대신 WebSocket 클라이언트 쪽에 로깅 API를 붙여 WS-IN / WS-OUT 방향 라벨로 같은 타임라인에 기록하는 방식을 택했다. 지금은 클라이언트 코드가 로깅 API를 직접 불러야 해서, 호출부 수정 없이 계측되는 방법을 찾는 것이 바로 다음 과제다.

그 밖에 남은 것들도 있다. RN 브라운필드 도입 후 RN과 네이티브 사이 브릿지 호출을 같은 뷰어에서 볼 수 있는지 검토 중이고, 센디 전용 인증 흐름 같은 팀 의존성을 분리해 NetworkLogger 자체를 공개 가능한 형태로 리팩토링하는 작업도 남아 있다. 요청별 구간 분포를 시각화하는 대시보드까지 가면 DNS 캐시나 커넥션 재사용 문제를 더 빨리 발견할 수 있을 것이다.


글을 마치며

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

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

URLSessionConfigurationSwizzlingNetworkLoggerURLProtocol(URLProtocol)URLSessionTaskMetrics구간별 타이밍NWListenerBonjour 광고4바이트 길이 프리픽스+ JSON 메시지개발자 Mac(NWBrowser + 뷰어)모든 세션 커버요청 완료 후데이터 전송TCP 연결 위에Wi-Fi앱 (DEBUG 빌드)

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