let task = URLSession.shared.dataTask(with: url) { data, response, error in
// 응답 처리
}
task.resume()
딸-깍
그런데 안에서는 어떤 동작이 이루어지는 지 아시나요?
이번 시간은 URLSession에 뭐가 있는지 아예 베이스에 대해서라기보다는 어떻게 동작하는지에 초점을 맞춰 글을 작성하려합니다.
대신!! URLSession에 관해서는 아주 좋은 블로그를 추천해드리겠습니다:)
https://codeisfuture.tistory.com/148
URLSession에 대한 에브리띵
실제로는 URLSession은 Apple이 만든 거대한 URL Loading System의 하나인 거고 그 안에는 여러 라이브러리들이 있어요:) URL Loading System의 전체 구조URL Loading System의 핵심 특징:비동기 처리: 모든 네트워크
codeisfuture.tistory.com
7단계 흐름
[1] Application Layer (내 Swift 코드)
↓
[2] Foundation (HTTP 메시지 만들기)
↓
[3] CFNetwork (DNS, TLS, 연결 관리) ← 여기가 핵심!
↓
[4] BSD Socket (TCP 연결)
↓
[5] Darwin Kernel (IP 패킷 만들기)
↓
[6] Network Driver (MAC 주소 붙이기)
↓
[7] Hardware (전자기파로 날아가기)
애플리케이션 레이어
개발자가 작성하는 Swift 코드가 이 레이어에 형성이 됩니다.
위에서 학습한 URL Loading System에 의해 개발자는 복잡한 소켓 통신 프로토콜 처리 신경쓰지 않고 코드를 칠 수 있는 거죠
Foundation Framework: HTTP 메시지로 번역하기
Foundation은 우리가 만든 Swift 객체를 HTTP 프로토콜이 이해할 수 있는 형태로 바꿔요.
원본 URLRequest:
- URL: https://api.github.com/user
- Method: GET
- Headers: ["User-Agent": "MyApp/1.0"]
Foundation이 생성하는 HTTP 메시지:
GET /user HTTP/1.1
Host: api.github.com
User-Agent: MyApp/1.0
Connection: keep-alive
Accept: */*
이 과정에서 주목할 점은 Foundation이 개발자가 명시하지 않은 필수 헤더들을 자동으로 추가한다는 것입니다.
Host나 Content-Length 같은 표준 header를 붙여줘요.
하지만 contentType은 개발자가 직접 설정해줘야해요!!!!!
왜 Content-Type만 개발자가 설정해야 할까요?
Host나 Content-Length는 요청 자체에서 결정되는 값이에요.
URL에서 Host를 뽑아내고, body의 바이트 수로 Content-Length를 계산하면 되죠.
하지만 Content-Type은 '의미'를 담고 있어요.
같은 바이트 배열이라도 JSON인지, 이미지인지, 폼 데이터인지는
시스템이 추론할 수 없거든요. 그래서 개발자가 명시해야 해요.
퍼센트 인코딩: 한글은 어떻게 보낼까?
https://api.mysocialapp.com/search?q=안녕하세요
HTTP는 ASCII 기반이라 이런 문자를 직접 보낼 수 없어요.
그래서 Foundation이 퍼센트 인코딩을 해요:
https://api.mysocialapp.com/search?q=%EC%95%88%EB%85%95%ED%95%98%EC%84%B8%EC%9A%94
왜 이렇게까지 해야 할까요?
HTTP가 만들어진 1990년대엔 영어권 중심이었어요. 국제화는 나중에 생각한 거죠.
그래서 지금도 이런 우회 방법을 쓰고 있어요. 레거시의 흔적아닐까..싶네요 ㅎ
CFNetwork: 실제 네트워크 프로토콜 처리
Foundation이 HTTP 메시지를 만들었으면, 이제 실제로 네트워크를 통해 보내야겠죠?
CFNetwork가 바로 그 일을 해요.
CFNetwork는 크게 3가지 일을 해요:
1. DNS: "naver.com"을 "223.130.195.95"로 바꾸기
2. TLS: 암호화된 터널 만들기
3. Connection Pool: 한번 만든 연결 재활용하기
하나씩 볼까요?
1) DNS: 도메인 이름 -> IP 주소
naver.com이라는 이름을 컴퓨터가 이해할 수 있나요? 아니에요.
네트워크는 숫자로 된 IP 주소만 이해하죠. 그래서 CFNetwork는 가장 먼저 DNS 조회를 해요.
질문: "naver.com의 IP 주소가 뭐야?"
DNS 서버 응답: "198.51.100.42"
CFNetwork는 DNS 결과를 캐싱해둬요. 같은 도메인으로 다시 요청하면 DNS 조회를 건너뛰는 거죠.
2) TLS 핸드셰이크
HTTPS의 'S'가 뭔지 아시죠? Secure예요. 근데 어떻게 secure한 걸까요?
CFNetwork와 서버는 실제 데이터를 주고받기 전에 TLS 핸드셰이크라는 과정을 거쳐요.
1. 클라이언트: "안녕! 나 이런 암호화 방식 지원해"
2. 서버: "좋아, 그럼 이걸로 하자. 내 인증서 여기 있어"
3. 클라이언트: "인증서 확인했어. Apple이 신뢰하는 곳이 서명했네? OK!"
4. 둘이 함께: "이제 대칭키 만들자. 이 키로 암호화할게"
5. 준비 완료: "이제부터 모든 데이터는 암호화해서 보낼게"
3) Connection Pooling
같은 서버로 여러 요청을 보낸다고 생각해볼까요?
매번 새로 연결하면 어떻게 될까요?
요청 1: TCP 3-Way Handshake (100ms) + TLS Handshake (200ms) = 300ms
요청 2: TCP 3-Way Handshake (100ms) + TLS Handshake (200ms) = 300ms
요청 3: TCP 3-Way Handshake (100ms) + TLS Handshake (200ms) = 300ms
총 900ms의 오버헤드! 😱
그래서 CFNetwork는 Connection Pool을 관리해요. 한번 만든 연결을 재사용하는 거죠.
// 첫 번째 요청
URLSession.shared.dataTask(with: url1).resume()
// → 새 연결 생성: TCP + TLS Handshake (약 300ms)
// 두 번째 요청 (같은 호스트)
URLSession.shared.dataTask(with: url2).resume()
// → 기존 연결 재사용 (약 50ms 절약!)
근데 여기서 주의할 점이 있어요. URLSession을 매번 새로 만들면 Connection Pool의 이점을 못 받아요:
// ❌ 매번 새 세션 생성 - 비효율적
let session1 = URLSession(configuration: .default)
session1.dataTask(with: url1).resume()
let session2 = URLSession(configuration: .default)
session2.dataTask(with: url2).resume()
// ✅ 세션 재사용 - 효율적
let session = URLSession.shared
session.dataTask(with: url1).resume()
session.dataTask(with: url2).resume()
URLSession을 매번 새로 만들면, 시스템이 "아, 새로운 세션이구나" 하고 연결을 새로 만들어요.
URLSession.shared를 쓰거나, 직접 만든 세션을 재사용해야 Connection Pool의 이점을 받을 수 있어요.
연결은 언제까지 유지될까?
영원히는 아니에요. 몇 가지 경우에 연결이 닫혀요:
- 서버의 Keep-Alive 타임아웃: 보통 60~120초 정도 안 쓰면 서버가 연결을 끊어요
- 네트워크 변경: Wi-Fi → 셀룰러로 바뀌면 IP 주소가 달라지니까 연결이 끊겨요
- iOS의 리소스 관리: URLSession이 비활성 연결을 정리해요
4~7단계: 시스템이 알아서 해주는 영역
BSD Socket: TCP 연결 수립
CFNetwork가 IP 주소를 알아냈으면, 이제 실제 연결을 만들어야겠죠.
BSD Socket API가 그 역할을 해요.
TCP는 "3-Way Handshake"라는 과정으로 연결을 만들어요
1. 클라이언트 → 서버: "SYN (연결하고 싶어요)"
2. 서버 → 클라이언트: "SYN-ACK (좋아요, 나도 준비됐어요)"
3. 클라이언트 → 서버: "ACK (확인했어요!)"
이 과정이 끝나면 비로소 안정적인 연결이 만들어져요.
Darwin Kernel: 패킷 만들기
운영체제의 커널이 데이터를 IP 패킷으로 쪼개요.
512KB 이미지를 보낸다면?
한 번에 보낼 수 없어요. 네트워크는 보통 한 번에 1,460바이트까지만 보낼 수 있거든요.
그래서 커널이 이미지를 360개 정도의 작은 패킷으로 나눠요.
각 패킷에는 시퀀스 번호가 붙어요:
패킷 1: seq=1000
패킷 2: seq=2460
패킷 3: seq=3920
...
왜 시퀀스 번호가 필요할까요?
네트워크는 패킷 순서를 보장하지 않기 때문에 3보다 2가 먼저 도착할 수 있어요. 그래서 받는쪽에서 조립하는 거죠
Network Driver: MAC 주소 붙이기
IP 패킷에 MAC 주소를 붙여요.
IP 주소가 "서울시 강남구 테헤란로 123"이라는 논리적 주소라면,
MAC 주소는 "3층 302호"처럼 물리적 위치예요.
그런데!!, MAC 주소는 홉마다 바뀐다는 거 아시나요!
iPhone → 공유기: [iPhone MAC → 공유기 MAC][IP 패킷]
공유기 → ISP: [공유기 MAC → ISP MAC][IP 패킷]
ISP → 서버: [ISP MAC → 서버 MAC][IP 패킷]
IP 주소는 끝까지 똑같은데, MAC 주소만 계속 바뀌어요.
각 홉에서 "다음 목적지의 물리적 주소"로 바꾸는 거죠.
그러면 어떻게 다음 홉 MAC 주소를 알까요?? -> ARP
1. iPhone: "192.168.0.1(공유기 IP)의 MAC 주소 아는 사람?"
→ 브로드캐스트로 동네방네 물어봄
2. 공유기: "나야! 내 MAC은 AA:BB:CC:DD:EE:FF"
3. iPhone: "고마워, 이제 너한테 보낼 수 있어"
→ ARP 캐시에 저장 (다음엔 안 물어봄)
ARP 캐시를 볼 수 있어요:
// 터미널에서
arp -a
? (192.168.0.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
? (192.168.0.100) at 11:22:33:44:55:66 on en0 ifscope [ethernet]
Hardware: 전자기파로 날아가기
마지막으로 Wi-Fi 칩이 디지털 신호(0과 1)를 전자기파로 바꿔요.
Wi-Fi는 보통 2.4GHz나 5GHz 주파수를 써요:
- 2.4GHz: 파장이 길어서 벽을 잘 통과해요. 하지만 전자레인지, 블루투스와 간섭이 많아요.
- 5GHz: 파장이 짧아서 빠르지만, 벽 통과가 약해요.
iPhone은 상황에 따라 자동으로 주파수를 바꿔요.
공유기 가까이 있으면 5GHz, 멀거나 벽이 많으면 2.4GHz로 전환하죠.
패킷은 이제 수많은 라우터를 거쳐 서버로 향해요.
iPhone
↓
Wi-Fi 공유기
↓
ISP 게이트웨이
↓
ISP 코어 라우터
↓
국제 인터넷 백본
↓
목적지 ISP
↓
서버
보통 8~15개 정도의 라우터를 거쳐요.
각 라우터는 "다음은 어디로 보낼까?" 라우팅 테이블을 보고 결정하죠.
패킷 손실과 재전송
네트워크는 완벽하지 않아요. 패킷이 손실될 수 있죠.
패킷 1 → 서버 ✅
패킷 2 → 손실 ❌
패킷 3 → 서버 ✅
TCP는 이를 감지하고 재전송해요:
1. 서버가 패킷 1, 3만 받음
2. "패킷 2가 없네?" → ACK를 안 보냄
3. 클라이언트: "일정 시간 내에 ACK 안 왔네?"
4. 패킷 2 재전송!
이게 TCP가 "신뢰할 수 있는" 이유예요.
UDP는 이런 재전송이 없어서 빠르지만, 손실된 패킷은 그냥 사라져요.
서버 도착과 응답의 귀환
드디어 패킷이 서버에 도착했어요!
서버는 우리가 보낸 과정을 정확히 역순으로 수행해요:
전자기파 → 디지털 신호 → MAC 헤더 제거 → IP 헤더 제거 → TCP 재조립 → HTTP 파싱 → 애플리케이션 처리
서버가 응답을 만들어요:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 45
{"success": true, "message": "Upload complete"}
이 응답이 똑같은 여정을 거쳐 iPhone으로 돌아와요.
최종 콜백
드디어 우리의 completion handler가 호출돼요:
URLSession.shared.dataTask(with: request) { data, response, error in
// 여기가 실행됨!
guard let data = data, error == nil else { return }
// JSON 파싱
let json = try? JSONDecoder().decode(Response.self, from: data)
print(json?.message) // "Upload complete"
}.resume()
Network.Framework의 탄생 👼🏻
여기까지 보면 CFNetwork가 완벽해 보이죠? 개발자가 건드릴것도 없이 알아서 척척 해주고
CFNetwork의 한계점
개발자가 건드릴 수 없다!
DNS 조회 방식, TLS 버전, Connection Pool 설정... 다 자동으로 시스템이 해주죠? 이게 바로 단점이에요
URLSession이라는 고수준 API를 통해서만 간접적으로 쓸 수 있죠.
"DNS 쿼리를 커스터마이징하고 싶은데?"
"UDP를 직접 쓰고 싶은데?"
"TLS 대신 QUIC를 쓰고 싶은데?"
직접 못해요. .
HTTP에 최적화돼 있어요!
CFNetwork는 HTTP/HTTPS 통신에 특화돼 있어요.
UDP가 발전하면서 HTTP/3, QUIC 같은 새로운 프로토콜은 전부 UDP 기반이죠...
또한 CFNetwork는 이름부터 알 수 있듯 Objective-C의 유산이기에 Swift에서 쓰려면 한단계 브릿징을 하죠
그리고 가장 큰 문제: 비효율적인 I/O 패턴
자, 지금까지 본 것처럼 CFNetwork + BSD Socket 방식은:
Application (User Space)
↓
write() 시스템 콜 (컨텍스트 스위칭!)
↓
Kernel Space (TCP/IP 처리)
↓
다시 컨텍스트 스위칭
↓
User Space로 복귀
데이터를 보낼 때마다 커널로 내려갔다가 올라와야 해요.
Network.framework는 뭐가 다를까?
프로토콜 처리 로직을 User Space로 최대한 끌어올려 = 효율적인 I/O 처리
Application (User Space)
↓
Network.framework (User Space에서 Transport 처리!)
↓
메모리-맵 채널 (Memory-Mapped Channel)
↓
필요한 경우에만 Kernel로
Network.framework는 여러 작은 전송을 모아서 한 번에 처리해요.
마치 택배 기사님이 소포를 하나씩 배달하는 게 아니라, 여러 개를 모아서 한 번에 트럭에 싣는 것처럼요!
프로토콜 독립적 설계
// QUIC를 User Space에서 구현!
let parameters = NWParameters.udp
let quicOptions = NWProtocolQUIC.Options(alpn: ["h3"])
parameters.defaultProtocolStack.applicationProtocols.insert(quicOptions, at: 0)
let connection = NWConnection(
host: "example.com",
port: 443,
using: parameters
)
왜 이게 중요할까?
HTTP/3, QUIC 같은 프로토콜이 빠르게 진화하고 있어요. 기존 방식이라면:
- 커널에 새 프로토콜 구현
- iOS 업데이트 배포
- 사용자가 업데이트할 때까지 대기...
Network.framework는 User Space에서 프로토콜 구현이 가능해요! → 앱 업데이트만으로 최신 프로토콜 사용 가능
// TLV(Type-Length-Value) 프레이밍 추가
let coder = NWProtocolFramer.Options(definition: TLVProtocol.definition)
let parameters = NWParameters(tls: nil)
parameters.defaultProtocolStack.applicationProtocols.insert(coder, at: 0)
// Wi-Fi Direct 같은 P2P도 지원
parameters.includePeerToPeer = true
메시지 중심 사고: 바이트 스트림에서 탈피
CFNetwork + BSD Socket: 바이트 스트림이에요.
데이터를 보내면 상대방이 받는 건데, 어디서 끊어야 할지 모르죠.
// 개발자가 직접 프레이밍 해야 했어요
let lengthBytes = withUnsafeBytes(of: data.count.bigEndian) { Data($0) }
let packet = lengthBytes + data // "앞 4바이트는 길이, 나머지가 데이터"
send(socket, packet.bytes, packet.count, 0)
Network.framework는 메시지 단위로 생각할 수 있게 해줘요.
// 메시지 타입 정의
enum GameMessage: Codable {
case playerMove(x: Int, y: Int)
case chat(message: String)
}
// 메시지 전송 - 경계를 자동으로 유지!
let message = GameMessage.playerMove(x: 100, y: 200)
let data = try JSONEncoder().encode(message)
connection.send(
content: data,
contentContext: .defaultMessage,
isComplete: true, // ← "이게 완전한 메시지야"
completion: .contentProcessed { error in
if let error = error {
print("전송 실패: \(error)")
}
}
)
// 메시지 수신 - 완전한 메시지 단위로 받아요
connection.receive(
minimumIncompleteLength: 1,
maximumLength: 65536
) { content, context, isComplete, error in
if let data = content, isComplete {
let message = try? JSONDecoder().decode(GameMessage.self, from: data)
switch message {
case .playerMove(let x, let y):
print("플레이어 이동: (\(x), \(y))")
case .chat(let text):
print("채팅: \(text)")
default:
break
}
}
}
프레이밍을 직접 안 해도 돼요. Network.framework가 알아서 메시지 경계를 유지해주거든요.
추가 핵심 기능들
실시간 네트워크 상태 모니터링
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
// 네트워크 상태 실시간 감지!
if path.status == .satisfied {
print("연결됨!")
// Wi-Fi인지 셀룰러인지
if path.usesInterfaceType(.wifi) {
print("Wi-Fi 사용 중 → 고화질 스트리밍 ON")
} else if path.usesInterfaceType(.cellular) {
print("셀룰러 사용 중 → 데이터 절약 모드")
}
// 비용이 드는 네트워크인지 (로밍, 데이터 제한 등)
if path.isExpensive {
print("비싼 네트워크! 대용량 다운로드 중단")
}
} else {
print("연결 끊김!")
}
}
monitor.start(queue: .main)
연결 상태 기반 API
let connection = NWConnection(host: "example.com", port: 443, using: .tcp)
// 연결 상태를 명확하게 알 수 있어요
connection.stateUpdateHandler = { state in
switch state {
case .preparing:
print("연결 준비 중...")
case .ready:
print("연결 완료! 이제 데이터 전송 가능")
case .waiting(let error):
print("연결 대기 중: \(error)")
case .failed(let error):
print("연결 실패: \(error)")
case .cancelled:
print("연결 취소됨")
@unknown default:
break
}
}
connection.start(queue: .main)
그럼 URLSession은 이제 구식인가?
아니에요! 이건 정말 중요한데, URLSession은 여전히 HTTP 통신의 최선의 선택이에요.
URLSession을 쓸 때:
- 일반적인 REST API 호출
- 이미지/파일 다운로드
- JSON 통신
- 백그라운드 다운로드
이런 용도라면 URLSession을 쓰세요. URLSession도 내부적으로 점점 현대화되고 있고, HTTP/3도 지원하기 시작했어요. 그리고 async/await도 지원하죠.
// 이걸로 충분해요
let (data, response) = try await URLSession.shared.data(from: url)
Network.framework는 이럴 때 쓰는 거예요:
- 게임처럼 UDP가 필요할 때
- WebRTC나 실시간 스트리밍처럼 커스텀 프로토콜이 필요할 때
- 네트워크 레벨을 직접 제어하고 싶을 때
- P2P 통신(Wi-Fi Aware, Bonjour)이 필요할 때
아직까지 취준생입장으로서 Network.framework까지 직접 건드릴일은 없을거 같긴한데... 하루 빨리 기회가 생겼으면 좋겠네요
'iOS' 카테고리의 다른 글
UIKit / SwiftUI에서는 무슨 디자인 패턴을 사용해야할까? (0) | 2025.10.17 |
---|---|
HTTP 캐싱(Etag & max-age) 그리고 iOS에서는? (0) | 2025.10.01 |
URLSession에 대한 에브리띵 (0) | 2025.09.27 |
스크롤뷰는 어떻게 스크롤될까? 🤔 UIView 렌더링부터 시작하는 완벽 분석 (0) | 2025.09.26 |
데이터 보관은 어떻게이루어질까(직렬화: NSCoding부터 Codable까지) (0) | 2025.09.09 |