macOS 네트워크 계층: 개발자 가이드
macOS 네트워크 스택을 개발자 관점에서 둘러봅니다. socket(2)부터 Network.framework까지, 각 계층에서의 관측 가능성에 대한 메모를 함께 담았습니다.
- Developer tools
- macOS
- Networking
- Deep dive
느린 API 호출을 디버깅하고 있습니다. 서버 로그는 응답이 80ms에 떠났다고 말합니다. 사용자는 페이지가 4초 걸렸다고 말합니다. 선과 UI 사이 스택의 어딘가에서 시간이 사라졌으며, "네트워크"는 유용하기에 너무 광범위한 답입니다. 어디를 봐야 할지 알기 위해서는 macOS 네트워크 계층의 정신 지도가, 하단의 BSD 소켓 호출에서 앱이 실제로 사용하는 상단의 프레임워크까지 필요합니다.
이는 패킷이 macOS에서 어떻게 URLResponse가 되는지, 각 계층이 어디에서 로그하고 계측하는지, 각 수준에서 어떤 도구에 손을 뻗는지에 대한 개발자 투어입니다. 스택이 좋은 이유로 계층화되어 있어서 길고, 모두 이해할 만합니다.
macOS 네트워크 계층, 위에서 아래로
아래에서 위로, macOS 네트워킹 스택은 대략 이렇게 보입니다.
- 하드웨어 및 커널 드라이버 — Wi-Fi의
IO80211Family, 이더넷의IOEthernetFamily같은 IOKit 패밀리. - BSD 네트워크 스택 — IP, TCP, UDP, 소켓 버퍼, 패킷 필터링을 위한
pf. - 네트워크 확장 — 콘텐츠 필터, 패킷 터널 제공자, DNS 프록시, 새
nw_path기계. socket(2)시스템 호출 — 가장 낮은 사용자 공간 API. 더 이상 직접 호출되는 일이 거의 없음.- CFNetwork / Network.framework — 연결, 경로, 리스너를 위한 현대 C/Swift API.
- NSURLSession (
URLSession) — Foundation의 HTTP/HTTPS 클라이언트. 거의 모든 앱의 기본값. - 앱 수준 라이브러리 — Alamofire, AFNetworking, gRPC-Swift, WebSocket 라이브러리, Apollo,
URLSession을 감싸는 모든 것. - 뷰 계층 — 응답을 결국 렌더링하는 SwiftUI, UIKit, AppKit 뷰.
대부분의 앱 개발자는 6-8 계층에서 시간을 보내고 그 아래의 모든 것을 "네트워크"로 취급합니다. 무언가가 깨질 때까지 그것은 괜찮습니다. 진짜 버그를 사냥할 때 — TLS 핸드셰이크 시간 초과, 프록시 인증에 갇힌 요청, 의존성에서의 의심스러운 트래픽 — macOS 네트워크 계층 각각이 무엇을 제공하고 어디에 로그하는지 알아야 합니다.
하위 스택: BSD, 네트워크 확장, 소켓
하단 세 프로그램 가능한 계층은 빽빽하게 함께 맞으므로, 한 슬래브로 보는 것이 보상이 됩니다.
계층 2: BSD 스택
패킷이 en0에 도착하면 커널이 BSD 네트워크 스택을 통해 그것을 실행합니다. 패킷이 본인을 위한 것인지 결정하고(목적지 IP와 포트를 소켓에 매칭), pf(BSD 패킷 필터, macOS 방화벽)를 통과시키고, 수신 소켓의 버퍼에 큐잉합니다.
이 계층은 tcpdump와 Wireshark가 사는 곳입니다. BPF(Berkeley Packet Filter) 장치를 두드리는데, 커널이 그것으로 많은 일을 하기 전에 원시 패킷을 봅니다.
sudo tcpdump -i en0 -n 'tcp port 443'유용한 BSD 스택 수준 도구:
netstat -an— 청취 소켓, 설정된 연결, 청취 포트netstat -rn— 라우팅 테이블pfctl -s rules— 현재 pf 규칙 세트(방화벽을 활성화하지 않으면 종종 비어 있음)ifconfig/networksetup -listallnetworkservices— 인터페이스 상태
여기서 IP/TCP 계층의 문제를 의심할 때 계측합니다. TCP 재전송 폭풍, 경로 깜박임, 트래픽을 차단하는 pf 규칙.
계층 3: 네트워크 확장
네트워크 확장은 서드파티 앱과 Apple 자체가 kext(이는 더 이상 사용되지 않음)를 작성하지 않고 데이터 경로를 후크하는 방법입니다. 큰 범주:
- 콘텐츠 필터 제공자 — 흐름 메타데이터를 보고 허용/거부 결정. Little Snitch와 LuLu가 이것을 사용합니다.
- 패킷 터널 제공자 — 완전한 VPN 스타일 터널링. Tailscale, WireGuard 앱, NordVPN 등.
- 앱 프록시 제공자 — 원격 엔드포인트를 통한 앱별 라우팅.
- DNS 프록시 제공자 — 해석 전에 DNS 쿼리 가로채기.
트래픽이 예상치 못한 곳으로 가고 있다면, 네트워크 확장이 더 가능성 있는 범인 중 하나입니다. 활성 확장에 대해 systemextensionsctl list를 확인하세요.
계층 4: socket(2)와 친구들
고전적인 POSIX API: socket(), bind(), connect(), send(), recv(), close(). macOS에서 이 코드를 직접 작성하는 일은 거의 없습니다. Apple은 새 애플리케이션에 대해 이를 권장하지 않습니다. 연결성 변경에 대해 nw_path와 통합되지 않고, Happy Eyeballs(IPv4/IPv6 듀얼 스택)를 처리하지 않으며, TLS를 무료로 얻지 못하기 때문입니다.
하지만 여전히 기초입니다. 모든 더 높은 수준 API는 결국 socket()을 호출합니다. 커널에서 프로세스 귀속을 읽을 때 — PROC_PIDFDSOCKETINFO가 있는 proc_pidinfo를 통해 또는 /dev/bpf를 통해 — 소켓 호출에 의해 설정된 상태를 읽고 있는 것입니다.
여기서 다음으로 계측합니다.
lsof -i -P -n— 시스템의 모든 열린 소켓, PID와 프로세스 이름과 함께proc_pidinfoAPI — Apple이 축복한 "이 PID가 어떤 소켓을 열고 있는가"를 묻는 방법dtrace와dtruss— 시스템 호출 수준 추적(서명되지 않은 스크립트에는 SIP 끄기 필요)Instruments네트워크 템플릿 — 소켓 호출에 대한dtrace프로브를 감쌈
ova 같은 앱별 대역폭 모니터는 이 계층을 읽습니다. 약 1Hz로 커널의 PID별 소켓 및 인터페이스 바이트 카운터를 샘플링하고, 각 PID의 바이트를 상위 앱 번들에 귀속시키고, 결과 시계열을 로컬에 저장합니다. 패킷 검사는 없습니다 — 바이트는 커널이 nettop에 노출하는 같은 카운터에서 옵니다.
중간 스택: Network.framework와 URLSession
대부분의 앱 수준 네트워킹은 이 두 API에 자리 잡고 있습니다. 같은 혈통을 공유하지만 다른 문제를 해결합니다.
계층 5: CFNetwork와 Network.framework
CFNetwork는 더 오래된 C API입니다. Network.framework(2018년 도입, Swift 친화적)는 그것의 현대 대체이며 새 저수준 네트워킹 코드에 Apple이 권장하는 것입니다.
Network.framework는 다음을 제공합니다.
- 외부 연결을 위한
NWConnection(TCP, UDP, QUIC, TLS, 사용자 정의 프로토콜) - 들어오는 연결을 받기 위한
NWListener - 경로 가용성 관찰을 위한
NWPathMonitor(Wi-Fi 대 셀룰러 대 이더넷) - Bonjour 발견을 위한
NWBrowser NWProtocolFramer로 합성 가능한 TLS, 프록시, 프로토콜 스택
피어 투 피어 프로토콜, 사용자 정의 바이너리 프로토콜, 다중 경로 TCP를 작성하거나 macOS용 서버 소프트웨어를 구현할 때 직접 사용합니다. 모든 HTTP 모양의 것에 대해서는 한 계층 위로 갑니다.
이 계층의 계측: NWConnection.stateUpdateHandler, 경로 변경을 지켜보기 위한 NWPathMonitor, 그리고 통합 로그 서브시스템 com.apple.network(아래에서 통합 로그에 대해 더).
계층 6: URLSession
앱이 HTTP를 사용한다면, 거의 확실히 URLSession을 사용합니다. HTTP/1.1, HTTP/2, 지원되는 곳에서 HTTP/3(QUIC), TLS, 리디렉션, 캐싱, 쿠키, 인증 도전을 처리합니다. 또한 NSURLProtocol과 통합되어 테스트를 위해 그것의 트래픽을 가로챌 수 있습니다.
여기서 가장 유용한 계측 후크는 다음과 같습니다.
URLSessionDelegate와URLSessionTaskDelegate— 모든 리디렉션, 도전, 완료 보기URLSessionTaskMetrics— 작업당 타이밍 분석: DNS, 연결, 보안 연결, 요청, 응답URLSessionConfiguration.protocolClasses— 테스트에서 모든 요청을 로그하기 위해NSURLProtocol하위 클래스 등록- 서브시스템
com.apple.CFNetwork이 있는os_log— CFNetwork가 방출하는 통합 로그 채널
URLSessionTaskMetrics는 활용도가 낮습니다. 작업당 DNS 해석 대 TCP 연결 대 TLS 핸드셰이크 대 요청 전송 대 서버 처리 대 응답 전송에 정확히 얼마의 시간이 소요되었는지 알려줍니다. 느린 API 호출 문제가 실제로 느린 핸드셰이크라면, 이것이 그것을 증명하는 데이터입니다.
func urlSession(_ session: URLSession,
task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
for tx in metrics.transactionMetrics {
print("DNS:", tx.domainLookupEndDate?.timeIntervalSince(tx.domainLookupStartDate ?? .distantPast) ?? 0)
print("Connect:", tx.connectEndDate?.timeIntervalSince(tx.connectStartDate ?? .distantPast) ?? 0)
print("TLS:", tx.secureConnectionEndDate?.timeIntervalSince(tx.secureConnectionStartDate ?? .distantPast) ?? 0)
}
}ova 작동 모습 보기
한눈에 볼 수 있는 메뉴 바 대역폭 모니터 — 로컬, 서명, 약 3MB.
스택의 상단: 라이브러리와 뷰 계층
가장 높은 두 계층은 대부분의 앱 코드가 시간을 보내는 곳입니다. 여기의 버그는 네트워크 문제로 오해하기 쉽습니다.
계층 7: 앱 수준 라이브러리
대부분의 앱은 URLSession을 Alamofire나 사용자 정의 네트워킹 모듈 같은 것으로 감쌉니다. 이 라이브러리는 보통 로깅을 위해 후크할 수 있는 요청 완료 신호를 노출합니다. 또한 종종 자체 재시도, 제한, 큐잉 로직을 가지고 있어서 더 낮은 계층의 문제를 가릴 수 있습니다.
디버깅할 때:
- 라이브러리가 자동 재시도를 하고 있는지 확인 — 세 번의 재시도는 한 번의 실패한 요청을 로그의 네 줄과 선상의 네 배의 바이트로 만들 수 있습니다.
- 시간 초과 구성 확인. 기본값은 종종 의외입니다(
URLSession자원 시간 초과의 60초). - 숨은 병렬성 확인.
maxConcurrentOperationCount = 1인OperationQueue는 예상치 못한 방식으로 요청을 직렬화할 것입니다.
계층 8: 뷰 계층
때때로 버그는 네트워크에 전혀 없습니다. 응답은 80ms에 도착했지만 누군가가 메인 스레드에 동기 JSON 디코드를 박았거나 레이아웃 패스가 800개 셀에 걸쳐 캐스케이드되었기 때문에 뷰가 렌더링하는 데 3.9초 걸렸습니다. Instruments의 Time Profiler가 이를 위한 적절한 도구입니다. 메인 스레드 시간이 JSONDecoder.decode나 UIView.layoutSubviews에서 소모되었는지 알려줍니다.
교훈: 측정하기 전에 네트워크를 탓하지 마세요. URLSessionTaskMetrics에 더해 Time Profiler 추적은 대부분의 "느린 페이지" 토론을 10분 안에 해결합니다.
통합 로그
모든 계층에 걸쳐 통합 로그(os_log / Logger)가 있습니다. 네트워크 관련 서브시스템은 다음을 포함합니다.
com.apple.CFNetwork— URLSession 수준 이벤트com.apple.network— Network.framework 이벤트com.apple.networkd— 네트워크 데몬 이벤트com.apple.cfnetwork.NSURLConnection— 레거시 NSURLConnection 로그
네트워크 로그를 라이브로 읽기:
log stream --predicate 'subsystem == "com.apple.network"' --level debugCFNetwork 이벤트의 지난 한 시간 읽기:
log show --last 1h --predicate 'subsystem == "com.apple.CFNetwork"'이는 문제가 "내 요청이 기기를 떠난 적도 없다"일 때 엄청나게 도움이 됩니다. 로그는 경로가 만족스럽지 않았다고, 프록시 자동 구성 스크립트가 DIRECT를 반환했다고, 또는 TLS 서버 인증서가 특정 이유 코드로 거부되었다고 알려줄 것입니다.
적절한 도구 선택, 그리고 몇 가지 습관
"이 계층에 문제가 있다, 어떤 도구?"를 위한 치트시트:
| 증상 | 가능성 있는 계층 | 첫 도구 |
|---|---|---|
| 앱이 어떤 호스트에도 도달할 수 없음 | 2-3 (BSD / NE) | ping, traceroute, systemextensionsctl list |
| 한 호스트가 도달 불가 | 2 (BSD / DNS) | dig, nslookup, dscacheutil -q host |
| 느린 핸드셰이크 | 5-6 (TLS / URLSession) | URLSessionTaskMetrics, 통합 로그 |
| 요청이 기기를 떠난 적 없음 | 5-6 (Network.framework) | 통합 로그 com.apple.network |
| 앱별 트래픽 예산 알려지지 않음 | 4 (커널 카운터) | nettop, ova |
| 의심스러운 목적지 | 2-3 (BSD / NE 필터) | lsof -i, 콘텐츠 필터 |
| 무작위 본문 손상 | 6+ (HTTP) | URLProtocol 가로채기, 패킷 캡처 |
| 빠른 응답 후 느린 렌더 | 8 (뷰) | Instruments Time Profiler |
이 모든 것에 걸쳐 보상을 주는 몇 가지 습관:
- 항상
taskMetrics를 로그하세요. 릴리스에서도. 저렴하고 나중에 시간을 절약합니다. - 모든 외부 요청에 태그를 다세요.
X-Request-Id같은 사용자 정의 헤더를 설정하고 그것을 클라이언트 로그 줄과 서버 로그 줄에 넣으세요. 이제 그것들을 조인할 수 있습니다. - 활동 표시기를 신뢰하지 마세요. 어떤 요청이 진행 중이라는 것만 보여주지, 어느 것이거나 얼마나 오래 대기했는지 보여주지 않습니다.
- 콜드와 웜을 별도로 프로파일링하세요. 시작 후 첫 요청은 DNS, TCP, TLS, 그리고 가능한 HTTP/3 협상에 도달합니다. 열 번째 요청은 연결을 재사용하고 10-50배 빠릅니다. 두 숫자 모두 중요합니다.
- 연결 결합을 주의하세요. HTTP/2와 HTTP/3는 인증서를 공유하는 호스트에 걸쳐 단일 연결을 재사용합니다. 이는 호스트별 추론을 더 어렵게 만듭니다.
마무리
macOS 네트워크 계층 스택은 빽빽하지만 알 수 있습니다. 하단의 socket(2)에서 상단의 URLSession과 뷰 계층까지, 각 계층은 특정 렌즈를 제공합니다 — 패킷, 흐름, 요청, 작업, 프레임. 적절한 렌즈를 고르면 버그가 "네트워크가 느리다"에서 "api.example.com으로의 TLS 핸드셰이크가 매번 인증서 체인이 다시 가져와지기 때문에 1.2초 걸린다"로 무너집니다.
지금 어떤 앱이 대역폭을 사용하고 있는지의 캐주얼하고 항상 켜진 뷰를 — 터미널에서 nettop이 실행되지 않은 채 — 원한다면, ova를 설치하세요. 메뉴 바 앱이며, 약 3MB, macOS 14 이상에서 실행, 약 1Hz로 샘플링, 모든 것을 로컬에 저장. 텔레메트리 없음, 원격 대시보드 없음, 일회성 결제.