← Study Hub

04 · 네트워크

DNS·TCP·TLS·HTTP 계층부터 timeout과 재시도 안전성까지, 요청 하나가 화면에 뜨기까지의 네트워크 전 과정
진행률
0 / 0 완료

← 챕터 목록

웹 요청 하나가 화면에 그려지기까지 거치는 계층(DNS · TCP · TLS · HTTP)을 CS 원리로 먼저 세우고, iOS의 URLSession/Network.framework가 그 계층들을 실제로 어떻게 다루는지, 그리고 timeout·재시도처럼 실무에서 사고가 나는 지점을 면접 답변 수준으로 정리한다.

Q1. URL을 입력한 뒤 DNS 조회부터 응답 표시까지 어떤 일이 일어나는가?

🔑 30초 답변

URL을 파싱해 스킴·호스트·포트·경로를 뽑고, 호스트 이름을 DNS로 IP 주소로 변환합니다. 그 IP로 TCP 연결(3-way handshake)을 맺고, HTTPS면 그 위에 TLS 핸드셰이크로 암호화 채널을 세웁니다. 그다음 HTTP 요청을 보내고 서버 응답을 받아 본문을 파싱·렌더합니다. 각 단계가 독립적인 지연·실패 지점이라 "느리다/실패한다"를 진단할 때 어느 계층인지부터 나눠야 합니다. iOS에서는 URLSession이 이 DNS→TCP→TLS→HTTP를 한 번에 처리하고, 커넥션 재사용과 HTTP/2 멀티플렉싱을 자동으로 챙깁니다.

CS 원리

URL은 scheme://host:port/path?query#fragment 구조다. 브라우저/클라이언트는 먼저 이를 파싱한다. 핵심은 사람이 읽는 도메인 이름과 기계가 쓰는 IP 주소가 다르다는 것이고, 그 간극을 메우는 게 DNS다.

DNS 조회는 보통 스텁 리졸버 → 재귀 리졸버 → 루트 → TLD(.com) → 권한 있는 네임서버 순으로 내려가며, 결과는 TTL 동안 각 계층에서 캐시된다. 질의는 대개 UDP 53번 포트를 쓰고, 응답이 커서 잘리면(truncated) TCP로 폴백한다(RFC 9110은 HTTP, DNS 자체는 별도 RFC지만 흐름은 이렇다). IP를 얻으면 그 주소로 TCP 연결을 맺고, HTTPS면 TLS로 암호화 채널을 올린 뒤 HTTP 요청/응답을 주고받는다. 마지막으로 본문(HTML/JSON/이미지 등)을 해석해 화면에 반영한다.

URL 파싱 DNS 조회 TCP 연결 TLS 핸드셰이크 HTTP 요청·응답 파싱 렌더 이름→IP (UDP 53) 3-way 인증·암호화
DNS→TCP→TLS→HTTP는 순서가 있는 파이프라인이라, "콜드 스타트" 첫 요청은 DNS·TLS 비용이 얹혀 느리다. TLS(강조 박스)는 HTTPS일 때만 낀다.

iOS에서는

대부분 URLSession이 이 전 과정을 대신한다. 이름 해석은 Darwin의 getaddrinfo/mDNSResponder가 담당하고, IPv4/IPv6가 둘 다 있으면 RFC 8305 Happy Eyeballs로 더 빨리 응답하는 쪽을 골라 붙는다. URLSession은 같은 호스트로의 커넥션을 풀에 두고 재사용하며, 서버가 지원하면 HTTP/2로 여러 요청을 한 커넥션에 멀티플렉싱한다. App Transport Security(ATS) 때문에 평문 HTTP는 기본 차단되어 사실상 TLS 단계가 항상 낀다. 더 낮은 계층을 직접 다뤄야 하면 Network.frameworkNWConnection이 DNS·TCP·TLS를 하나의 엔드포인트 연결로 묶어준다.

실험 · 도구

단계별로 어디서 시간이 쓰이는지 갈라 보는 게 핵심이다. Instruments의 Network 템플릿으로 요청 타임라인(DNS/connect/TLS/TTFB)을 보고, Proxyman·Charles 같은 중간자 프록시로 실제 프로토콜 버전과 헤더를 확인한다. 커맨드라인에서는 dig example.com으로 DNS만, nscurl --ats-diagnostics https://...로 ATS 통과 여부만 따로 검증할 수 있다. 예상 결과: 재요청 시 DNS/TLS 시간이 캐시·커넥션 재사용으로 크게 줄어든다.

프로젝트 적용

⚠️ 흔한 오해

"앱은 요청할 때마다 DNS를 새로 조회하고, 요청 하나당 커넥션 하나를 새로 연다"는 오해가 흔하다. 실제로는 리졸버·OS·앱 계층에 TTL 기반 DNS 캐시가 있고, HTTP는 keep-alive와 HTTP/2 멀티플렉싱으로 커넥션을 재사용한다. 반대 방향 오해도 있다 — DNS 캐시가 오래되면(stale) 서버가 IP를 바꿔도 옛 IP로 붙어 실패할 수 있으니 "항상 최신"이라고 가정하면 안 된다.

꼬리 질문

Q2. HTTP는 TCP 위에서 어떻게 동작하는가?

🔑 30초 답변

HTTP는 애플리케이션 계층의 요청-응답 프로토콜이고, 그 자체는 신뢰성·순서를 보장하지 않습니다. 대신 TCP가 제공하는 신뢰성 있는 바이트 스트림 위에 메시지를 실어 보냅니다. HTTP/1.1은 텍스트 헤더 + Content-Length나 chunked 인코딩으로 메시지 경계를 정하고, keep-alive로 한 TCP 커넥션을 재사용합니다. HTTP/2는 같은 TCP 위에서 바이너리 프레이밍 + 스트림 멀티플렉싱으로 HTTP 계층의 head-of-line 블로킹을 없앴지만 TCP 계층 HOL은 남습니다. HTTP/3는 아예 QUIC(UDP 기반) 위로 옮겨 TCP HOL까지 없앴습니다.

CS 원리

HTTP는 상태를 저장하지 않는(stateless) 요청-응답 프로토콜이다. "신뢰성 있게 순서대로 전달"은 HTTP가 아니라 아래 계층인 TCP의 몫이다. TCP는 순서 없는 IP 패킷을 재조립·재전송해 끊김 없는 바이트 스트림처럼 보이게 해주고, HTTP는 그 스트림 위에 텍스트/바이너리 메시지를 얹는다. 그래서 HTTP는 "이 바이트가 도착하긴 하나"를 걱정하지 않고 "요청 라인 + 헤더 + 본문" 구조에만 집중할 수 있다.

문제는 메시지 경계다. 바이트 스트림에는 "여기서 한 응답이 끝난다"는 표시가 없으므로, HTTP/1.1은 Content-Length(길이 명시)나 Transfer-Encoding: chunked(조각 단위)로 경계를 정한다. HTTP/1.1의 약점은 한 커넥션에서 요청이 순서대로 처리되어, 앞 응답이 늦으면 뒤가 막히는 head-of-line 블로킹이다. HTTP/2는 하나의 TCP 커넥션 안에 여러 스트림을 두고 프레임을 인터리빙해 이 문제를 애플리케이션 계층에서 해소한다(RFC 9110 의미론은 그대로 두고 전송만 바꾼 것). 단, TCP 한 커넥션의 특정 패킷이 유실되면 그 뒤 모든 스트림이 재전송을 기다리는 TCP 계층 HOL은 여전하다. 이를 없애려 HTTP/3는 QUIC(RFC 9000) 위에서 스트림별 독립 전송을 한다.

HTTP/1.1 요청/응답 원문 — TCP 스트림에 실려 오가는 바이트 (텍스트)
GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/json
Connection: keep-alive

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 36

{"id":42,"name":"Ada","active":true}

iOS에서는

URLSession은 HTTP/1.1과 HTTP/2를 오래전부터 지원하고, iOS 15+에서는 서버가 지원할 때 HTTP/3(RFC 9114)도 자동 협상한다. 어떤 버전으로 붙었는지는 응답 계층에서 직접 알기 어렵지만, 프록시(Proxyman)나 Instruments로 확인할 수 있다. 프로토콜 협상은 TLS의 ALPN 확장으로 이뤄지므로, 사실상 TLS 핸드셰이크 안에서 "우리 HTTP/2로 얘기하자"가 정해진다.

프로젝트 적용

⚠️ 흔한 오해

"HTTP는 항상 TCP 위에서 돈다"와 "HTTP/2면 head-of-line 블로킹이 사라진다"는 둘 다 부정확하다. HTTP/3는 UDP 기반 QUIC 위에서 동작하므로 TCP가 필수가 아니다. 그리고 HTTP/2는 애플리케이션 계층 HOL만 없앨 뿐, 한 TCP 커넥션에서 패킷이 유실되면 생기는 TCP 계층 HOL은 남는다 — 이걸 없앤 게 QUIC의 스트림별 독립 전송이다.

꼬리 질문

Q3. TCP와 UDP는 무엇이 다른가?

🔑 30초 답변

TCP는 연결 지향 + 신뢰성 + 순서 보장이 있는 바이트 스트림입니다. 연결을 맺고(3-way handshake), 유실은 재전송, 순서는 시퀀스 번호로 맞추고, 흐름 제어·혼잡 제어까지 합니다. 대가로 지연과 오버헤드(헤더 20바이트+, handshake RTT)가 붙습니다. UDP는 비연결 + 무신뢰 + 무순서의 데이터그램 전송입니다. 그냥 보내고 끝이라 헤더 8바이트로 가볍고 빠릅니다. 신뢰성이 필요하면 애플리케이션이 직접 얹어야 합니다. 그래서 웹/API는 TCP, 실시간 음성·영상·게임은 UDP(또는 UDP 위 QUIC)가 기본입니다.

CS 원리

둘 다 IP 위에서 도는 전송 계층 프로토콜이지만 보장 수준이 정반대다. TCP(RFC 9293)는 연결을 세운 뒤 모든 바이트에 시퀀스 번호를 붙여, 유실되면 재전송하고 도착 순서가 뒤엉키면 재정렬한다. 수신자 버퍼가 넘치지 않게 하는 흐름 제어, 네트워크가 막히지 않게 전송 속도를 조절하는 혼잡 제어도 TCP가 한다. UDP는 이 모든 걸 생략한다 — 목적지로 데이터그램을 던지고, 도착 여부·순서·중복을 신경 쓰지 않는다.

항목TCPUDP
연결연결 지향(handshake 필요)비연결
신뢰성(유실 재전송)보장없음
순서 보장보장없음
흐름/혼잡 제어있음없음(앱이 직접)
전송 단위바이트 스트림데이터그램(메시지)
헤더 크기20바이트+8바이트
대표 용도HTTP, 파일 전송, APIDNS, 실시간 A/V, 게임, QUIC

iOS에서는

URLSession 기반 HTTP 통신은 전부 TCP(HTTP/3면 QUIC) 위에서 돈다. 저수준으로 프로토콜을 고를 때는 Network.frameworkNWConnection(to:using:)에 넘길 NWParameters.tcp / .udp / .tls 등으로 지정한다(QUIC은 NWProtocolQUIC.Options로 구성). 실시간 오디오/영상은 보통 UDP 기반 프로토콜(RTP, WebRTC)을 쓰는데, 늦게 도착한 프레임은 재전송해봐야 이미 쓸모가 없어서 "정확하지만 늦은 것"보다 "빠지더라도 최신"이 낫기 때문이다.

Network.framework — 전송 프로토콜을 직접 고른다 (Swift)
// 신뢰성이 필요한 경우: TCP (여기서는 TLS over TCP)
let tcp = NWConnection(host: "example.com", port: 443, using: .tls)

// 지연에 민감하고 유실을 감내하는 경우: UDP
let udp = NWConnection(host: "example.com", port: 9000, using: .udp)

프로젝트 적용

⚠️ 흔한 오해

"UDP는 신뢰성이 없으니 언제나 열등하다"는 오해. 실시간 통신에서는 유실 패킷을 재전송하느라 생기는 지연이 오히려 품질을 해친다 — 지나간 음성 프레임은 다시 받아도 소용없다. 또 "UDP는 신뢰성이 아예 불가능"도 틀렸다. QUIC은 UDP 위에 신뢰성·순서·혼잡 제어를 애플리케이션 레벨로 다시 쌓아 HTTP/3의 전송이 된다.

꼬리 질문

Q4. TCP 3-way handshake는 왜 필요한가?

🔑 30초 답변

TCP는 신뢰성 있는 순서 전송을 위해 양방향 각각의 초기 시퀀스 번호(ISN)를 서로 합의해야 합니다. handshake는 SYN(클라 ISN 알림) → SYN-ACK(서버 ISN 알림 + 클라 ISN 확인) → ACK(서버 ISN 확인) 세 단계로, 이걸 마치면 양쪽이 서로 보내고 받을 수 있음두 방향의 시퀀스 시작점이 확정됩니다. 2-way로는 양방향 ISN 합의와 양방향 도달 가능성 확인을 동시에 만족할 수 없고, 오래된 중복 SYN이 잘못된 연결을 여는 것도 막아야 하므로 3단계가 최소입니다.

CS 원리

TCP는 "몇 번째 바이트"를 시퀀스 번호로 추적해 순서와 재전송을 관리한다. 그런데 이 번호는 0에서 시작하지 않고 보안·중복 방지를 위해 무작위 초기 시퀀스 번호(ISN)에서 시작한다. 통신은 양방향이므로 클라이언트→서버, 서버→클라이언트 각각의 ISN을 서로 알고 확인해야 첫 바이트부터 순서를 맞출 수 있다.

3단계인 이유는 이 "양방향 합의"를 최소 왕복으로 끝내기 때문이다. ① SYN: 클라이언트가 자기 ISN(x)을 알린다. ② SYN-ACK: 서버가 "x 잘 받았다(ack=x+1)"와 자기 ISN(y)을 한 패킷에 합쳐 보낸다. ③ ACK: 클라이언트가 "y 잘 받았다(ack=y+1)"를 보낸다. 이 시점에 양쪽 모두 상대의 ISN을 알고, 서로가 송수신 가능함을 확인했다. 2-way로 줄이면 서버 ISN에 대한 클라이언트의 확인이 빠져 RFC 9293이 말하는 "오래된 중복 세그먼트가 유령 연결을 여는" 문제를 못 막는다.

Client Server ① SYN seq=x ② SYN-ACK seq=y, ack=x+1 ③ ACK ack=y+1 SYN_SENT SYN_RCVD ESTABLISHED ESTABLISHED
세 번째 ACK가 도착해야 서버도 ESTABLISHED가 된다. 이 왕복(1 RTT)이 콜드 커넥션 지연의 한 축이라, 커넥션 재사용이 중요하다.

iOS에서는

이 handshake는 URLSession이나 NWConnection이 커넥션을 세울 때 내부에서 일어나 앱 코드에 직접 노출되지 않는다. 성능상 의미는 두 가지다. 첫째, 커넥션을 재사용하면 이 1 RTT를 매번 아낄 수 있다. 둘째, TCP Fast Open(TFO)이나 HTTPS의 경우 TLS 1.3와 결합하면 핸드셰이크 왕복을 줄일 수 있다(TLS 1.3는 1-RTT, 재접속 시 0-RTT). Instruments Network 템플릿에서 connect 구간이 이 handshake에 해당한다.

프로젝트 적용

⚠️ 흔한 오해

"3-way handshake가 보안/암호화를 담당한다"는 오해가 잦다. handshake는 시퀀스 번호 합의와 양방향 도달성 확인일 뿐, 데이터를 암호화하지 않는다 — 암호화·서버 인증은 그 위에 얹는 TLS의 역할이다. 또 "왜 4-way가 아니냐"는 반대 오해도 있는데, 서버의 ACK와 SYN을 한 패킷(SYN-ACK)으로 합칠 수 있어 3단계로 충분하다(연결 종료는 각 방향을 따로 닫아 4-way).

꼬리 질문

Q5. HTTP Method의 의미와 멱등성은 무엇인가?

🔑 30초 답변

Method는 요청의 의도를 나타냅니다. GET(조회), POST(생성/실행), PUT(전체 교체), PATCH(부분 수정), DELETE(삭제) 등. 두 가지 속성이 중요합니다. Safe는 서버 상태를 바꾸지 않는다는 뜻(GET, HEAD), Idempotent(멱등)는 같은 요청을 1번 보내든 N번 보내든 서버 상태의 최종 효과가 같다는 뜻입니다. GET·PUT·DELETE·HEAD는 멱등, POST는 비멱등, PATCH는 설계에 따라 다릅니다. 멱등성은 재시도 안전성을 판단하는 근거라서 실무에서 결정적입니다.

CS 원리

RFC 9110이 method 의미론을 정의한다. 핵심 개념 두 개를 정확히 구분해야 한다. Safe method는 "읽기 전용" — 서버에 관찰 가능한 상태 변화를 일으키지 않는다(GET, HEAD, OPTIONS). Idempotent method는 동일 요청을 여러 번 반복해도 서버의 최종 상태가 한 번 보낸 것과 같다. 모든 safe method는 멱등이지만, 역은 아니다 — DELETE는 상태를 바꾸므로 safe가 아니지만, 두 번 지워도 "없음"이라는 최종 상태는 같으므로 멱등이다.

MethodSafe멱등의미
GETOO리소스 조회
HEADOO헤더만 조회
PUTXO리소스 전체 교체(있으면 갱신, 없으면 생성)
DELETEXO리소스 삭제
POSTXX생성·실행(호출마다 새 효과 가능)
PATCHX보장 안 됨부분 수정(연산에 따라 멱등일 수도)

iOS에서는

URLRequest.httpMethod로 지정한다(기본 GET). 이 구분이 iOS에서 실무적으로 중요한 이유는 재시도와 얽히기 때문이다. URLSession은 내부적으로 커넥션이 끊긴 경우 등에서 멱등으로 간주되는 요청을 재전송할 수 있는데, POST처럼 비멱등인 요청을 임의로 재시도하면 중복 부작용(중복 주문/결제)이 난다. 그래서 커스텀 재시도 로직을 짤 때 method의 멱등성을 반드시 조건으로 넣어야 한다(→ Q9).

PATCH가 멱등일 수도, 아닐 수도 있는 예 (개념)
// 멱등한 PATCH: 절대값 설정 — 몇 번 보내도 최종 상태 동일
PATCH /account/42   {"balance": 1000}

// 비멱등한 PATCH: 상대적 증감 — 보낼 때마다 효과 누적
PATCH /account/42   {"balance_delta": 100}

프로젝트 적용

⚠️ 흔한 오해

"멱등하면 응답도 항상 똑같다"는 착각. 멱등성은 서버 상태의 효과가 같다는 것이지 응답이 같다는 게 아니다. DELETE를 두 번 하면 첫 번째는 204, 두 번째는 404가 나올 수 있지만 "해당 리소스가 없음"이라는 최종 상태는 동일하므로 여전히 멱등이다. 반대로 "POST는 절대 재시도하면 안 된다"도 과장이다 — Idempotency-Key로 서버가 중복을 무시하게 설계하면 안전하게 재시도할 수 있다.

꼬리 질문

Q6. HTTP 상태 코드와 Transport error를 왜 구분해야 하는가?

🔑 30초 답변

둘은 실패의 계층이 다릅니다. HTTP 상태 코드(4xx/5xx)는 "서버까지 요청이 도달해 응답을 받았는데, 그 결과가 오류"라는 애플리케이션 레벨 신호입니다. Transport error는 "그 이전 단계에서 깨져 유효한 HTTP 응답 자체를 못 받았다"는 것 — DNS 실패, 연결 거부, timeout, TLS 실패 등입니다. 구분이 중요한 이유는 재시도 판단과 사용자 메시지가 완전히 다르기 때문입니다. 404는 재시도해도 소용없고, timeout은 재시도 후보이며 심지어 서버엔 이미 도달했을 수도 있습니다.

CS 원리

HTTP 상태 코드는 서버가 요청을 받아서 응답을 보냈다는 전제 위에 있는, 처리 결과의 요약이다(RFC 9110). 2xx 성공, 3xx 리다이렉트, 4xx 클라이언트 잘못(요청을 고쳐야 함), 5xx 서버 잘못. 반면 transport/네트워크 오류는 그보다 아래 계층에서 연결이 성립하지 못했거나 응답을 온전히 못 받은 경우다. 이 경우 "상태 코드"라는 게 아예 없다 — 응답이 없으니까.

코드/오류계층의미재시도
200 / 204HTTP성공불필요
400 / 404 / 422HTTP클라이언트 요청 오류무의미(요청을 고쳐야)
401HTTP인증 실패(신원 증명 안 됨)토큰 갱신 후 1회 재시도
403HTTP인가 실패(권한 없음)무의미(권한이 바뀌기 전엔 동일 실패)
429HTTP요청 과다Retry-After 따라
500 / 502 / 503HTTP서버 오류/일시 불가조건부(백오프)
timeout / DNS 실패 / 연결 거부Transport응답 자체를 못 받음멱등이면 후보

iOS에서는

URLSession의 completion/async API는 이 두 세계를 다른 자리로 돌려준다. 네트워크(transport) 오류는 error(구체적으로 URLError)로, 서버가 준 HTTP 결과는 response(HTTPURLResponse.statusCode)로 온다. 결정적으로, 서버가 500을 줘도 errornil이다 — 통신 자체는 성공했기 때문이다. 그래서 "에러가 nil이면 성공"으로 처리하면 500·404를 성공으로 오인한다.

두 계층을 분리해서 처리한다 (Swift, async/await)
do {
    let (data, response) = try await session.data(for: request)
    guard let http = response as? HTTPURLResponse else {
        throw AppError.invalidResponse
    }
    switch http.statusCode {
    case 200..<300:
        return try decoder.decode(User.self, from: data)   // 성공
    case 401:
        throw AppError.unauthorized       // 토큰 갱신 필요
    case 400..<500:
        throw AppError.client(http.statusCode)   // 재시도 무의미
    case 500..<600:
        throw AppError.server(http.statusCode)   // 조건부 재시도
    default:
        throw AppError.unexpected(http.statusCode)
    }
} catch let urlError as URLError {
    // ↓ 여기는 '응답을 못 받은' transport 계층
    switch urlError.code {
    case .timedOut, .networkConnectionLost, .cannotConnectToHost:
        throw AppError.retriableTransport(urlError)
    case .notConnectedToInternet:
        throw AppError.offline
    case .secureConnectionFailed, .serverCertificateUntrusted:
        throw AppError.tls(urlError)
    default:
        throw AppError.transport(urlError)
    }
}

프로젝트 적용

⚠️ 흔한 오해

"URLSessionerrornil이면 요청이 성공한 것"이라는 오해가 가장 흔한 버그 원인이다. 4xx·5xx는 통신 성공 + 애플리케이션 실패라서 errornil이고 statusCode에만 드러난다. 반대로 timeout처럼 error가 채워지는 경우는 상태 코드가 아예 없다. 두 자리를 모두 봐야 한다.

꼬리 질문

Q7. TLS는 서버 신원과 전송 데이터를 어떻게 보호하는가?

🔑 30초 답변

TLS는 세 가지를 제공합니다. 인증: 서버가 제시한 X.509 인증서 체인을 신뢰된 루트 CA까지 검증하고 호스트명이 맞는지 확인해 "지금 붙은 상대가 그 도메인의 주인"임을 보장. 기밀성: 핸드셰이크에서 (ECDHE 같은 키 교환으로) 세션 대칭키를 합의해 이후 데이터를 암호화. 무결성: AEAD로 변조를 감지. TLS 1.3는 이 합의를 1-RTT(재접속 시 0-RTT)에 끝내고, ECDHE로 전방향 비밀성(forward secrecy)을 기본 제공합니다. iOS에서는 ATS가 TLS 1.2+를 강제하고 URLSession이 인증서 체인을 시스템 신뢰 저장소로 자동 검증합니다.

CS 원리

TLS 1.3(RFC 8446)의 핸드셰이크를 보면 세 보호가 어떻게 나오는지 보인다. 클라이언트가 ClientHello(지원 TLS 버전, cipher suite, 그리고 미리 계산한 키 공유값)를 보내면, 서버는 ServerHello와 함께 인증서, 자기 키 공유값, Finished를 보낸다. 클라이언트는 서버 인증서를 검증하고 자기 Finished를 보내면 끝이다. 인증(신원)은 인증서 체인 검증에서, 기밀성은 양쪽 키 공유값으로 합의한 세션 대칭키에서, 무결성은 그 키로 걸린 AEAD에서 나온다.

인증서 검증은 구체적으로 (1) 체인이 신뢰된 루트 CA까지 이어지는가, (2) 호스트명이 인증서의 SAN과 일치하는가, (3) 유효기간이 지나지 않았는가, (4) 폐기(revocation)되지 않았는가를 본다. ECDHE 키 교환을 쓰면 세션키가 서버의 장기 개인키와 독립적이라, 나중에 개인키가 유출돼도 과거 트래픽을 복호화할 수 없는 전방향 비밀성이 생긴다.

Client Server ClientHello (버전·cipher·key share) ServerHello + Certificate + key share + Finished 인증서 체인·호스트명 검증 Finished 🔒 암호화된 Application Data
TLS 1.3는 1-RTT 만에 인증·키 합의를 끝낸다. 그 뒤 모든 HTTP 바이트는 이 세션키로 암호화되어 흐른다.

iOS에서는

ATS가 기본으로 TLS 1.2 이상 + 강한 cipher를 강제하고, 평문 HTTP를 막는다. URLSession은 서버 인증서 체인을 시스템 신뢰 저장소로 자동 검증한다. 추가 신뢰가 필요하면(사설 CA, 또는 공개키/인증서 피닝) URLSessionDelegateurlSession(_:didReceive:completionHandler:)에서 URLAuthenticationChallenge를 받아 직접 판단한다. Network.framework에서는 sec_protocol_options로 검증 콜백을 건다. 프로토콜 버전 협상(HTTP/2 등)도 이 핸드셰이크의 ALPN에서 함께 정해진다.

공개키 피닝 골격 — 잘못 다루면 앱이 죽으니 백업 핀 필수 (Swift)
func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let trust = challenge.protectionSpace.serverTrust else {
        completionHandler(.performDefaultHandling, nil); return
    }
    // 1) 먼저 시스템 기본 검증(체인·호스트명·만료)을 통과시킨 뒤
    // 2) 서버 공개키 해시가 '핀 집합(현재 + 백업)'에 있는지 대조
    if isPinned(trust, against: pinnedKeyHashes) {
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

프로젝트 적용

⚠️ 흔한 오해

"HTTPS(자물쇠)면 그 서버는 안전·신뢰할 수 있다"는 오해. TLS는 "지금 붙은 상대가 그 도메인의 주인이 맞다"와 "중간에서 엿보거나 변조하지 못한다"만 보장한다. 그 도메인 주인이 선한지는 별개라, 피싱 사이트도 정상 인증서로 자물쇠를 띄운다. 또 "피닝하면 무조건 더 안전"도 위험한 반쪽 진실 — 백업 핀 없이 피닝하면 인증서 만료/교체 때 앱이 통째로 죽는다.

꼬리 질문

Q8. Timeout에는 어떤 종류가 있으며 각각 무엇을 의미하는가?

🔑 30초 답변

timeout은 계층마다 다릅니다. 일반론으로는 connect timeout(연결 수립까지), read/idle timeout(다음 데이터가 올 때까지의 정체), overall/resource timeout(요청 전체의 총 상한)이 있습니다. iOS URLSession에서 자주 헷갈리는 두 개가 핵심입니다. timeoutIntervalForRequest전체 시간이 아니라 "데이터가 오다가 멈춰 있는 시간"의 상한이라 데이터가 흐르면 매번 리셋됩니다(기본 60초). timeoutIntervalForResource는 재시도까지 포함한 리소스 전체의 총 예산입니다(기본 7일). 이 둘을 혼동하면 "60초로 설정했는데 왜 안 끊기지"가 생깁니다.

CS 원리

timeout은 "무한정 기다리지 않겠다"는 안전장치인데, 무엇을 기다리는가에 따라 종류가 갈린다. 연결 자체를 세우는 데까지의 상한(connect), 연결 후 다음 바이트가 도착할 때까지의 정체 감지(read/idle), 그리고 요청 시작부터 끝까지의 총 시간(overall). idle timeout은 데이터가 조금씩이라도 흐르는 한 계속 리셋되므로, 느리지만 꾸준히 오는 응답은 idle timeout으로는 안 끊긴다 — 그런 경우를 잡으려면 overall timeout이 필요하다.

time timeoutIntervalForResource — 리소스 전체 총 예산 (기본 7일) 데이터 데이터 데이터 데이터 request timeout: 다음 데이터까지 최대 대기 (기본 60s, 활동마다 리셋)
데이터가 도착할 때마다 request timer는 0으로 리셋된다. 그동안 resource timer는 멈추지 않고 전체 시간을 잰다.

iOS에서는

timeoutIntervalForRequest는 애플 문서 표현으로 "새 데이터를 기다리는 동안 허용하는 시간"이다 — 즉 정체(stall) 감지 타이머이고 데이터 활동이 있을 때마다 리셋된다(기본 60초). timeoutIntervalForResource는 재시도를 포함해 리소스를 받아내는 데 허용하는 총 시간이다(기본 7일). URLRequest.timeoutInterval은 요청 단위로 request timeout을 덮어쓴다. 또 waitsForConnectivity = true면, 연결이 아예 없을 때 즉시 실패하는 대신 연결이 생길 때까지 (resource timeout 한도 내에서) 기다린다.

두 timeout을 명시적으로 잡는다 (Swift)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest  = 30    // 데이터가 30초간 안 오면(정체) 실패
config.timeoutIntervalForResource = 120   // 전체가 120초를 넘으면 실패
config.waitsForConnectivity       = true  // 오프라인이면 즉시 실패 대신 대기
let session = URLSession(configuration: config)

var req = URLRequest(url: url)
req.timeoutInterval = 15   // 이 요청만 request timeout을 15초로 덮어씀

실험 · 도구

느린 응답을 흉내 내려면 서버에서 바이트를 천천히 흘려보내는 엔드포인트를 만들거나 네트워크 조건을 조절한다(Xcode의 Network Link Conditioner). request timeout만 짧게 잡고 데이터를 1초마다 조금씩 흘리면 안 끊긴다(리셋되니까). 반대로 resource timeout을 짧게 잡으면 전체 시간이 넘는 순간 URLError.timedOut이 난다 — 이 대비 실험이 두 timeout의 차이를 가장 확실히 보여준다.

프로젝트 적용

⚠️ 흔한 오해

"timeoutIntervalForRequest = 60이면 요청은 무조건 60초 안에 끝나거나 실패한다"는 오해가 가장 흔하다. 이건 전체 상한이 아니라 "데이터가 안 들어오고 멈춰 있는 시간"의 상한이라, 서버가 1초에 한 바이트씩만 흘려도 타이머가 계속 리셋되어 영원히 안 끊길 수 있다. 전체 시간을 제한하려면 timeoutIntervalForResource를 써야 한다.

꼬리 질문

Q9. 재시도 가능한 요청과 재시도하면 위험한 요청은 어떻게 구분하는가?

🔑 30초 답변

기준은 멱등성 + 부작용의 성격 + 요청이 서버에 도달했는지의 불확실성입니다. GET·PUT·DELETE 같은 멱등 요청은 재시도해도 서버 상태가 달라지지 않아 안전합니다. POST·PATCH 같은 비멱등 요청은 재시도하면 중복 주문·이중 결제 같은 부작용이 납니다. 특히 timeout은 위험한데, 응답만 못 받았을 뿐 서버는 이미 처리했을 수 있어 재시도가 중복을 만듭니다. 그래서 비멱등 요청을 안전하게 재시도하려면 Idempotency-Key로 서버가 중복을 무시하게 만들어야 합니다. 재시도는 5xx/429/transport 오류 + 멱등 조건에서만, 지수 백오프 + 지터로 합니다.

CS 원리

재시도 안전성은 두 질문으로 갈린다. (1) 이 요청을 여러 번 해도 서버 최종 상태가 같은가? — 멱등성(Q5). 멱등이면 몇 번을 재시도해도 안전하다. (2) 재시도해도 성공 가능성이 있는가? — 4xx(클라이언트 잘못)는 재시도해도 결과가 같으니 무의미하고, 5xx·429·일시적 transport 오류는 상황이 나아지면 성공할 수 있다.

가장 미묘한 지점이 timeout의 모호성이다. 클라이언트가 응답을 못 받았다고 해서 서버가 처리를 안 했다는 보장이 없다 — 서버는 요청을 받아 처리(예: 결제)를 마쳤는데 응답이 돌아오는 길에 끊겼을 수 있다. 이 상태에서 비멱등 요청을 재시도하면 같은 작업이 두 번 일어난다. 해법은 서버가 각 요청을 유일하게 식별하는 멱등 키를 받아, 같은 키의 두 번째 요청은 처음 결과를 그대로 돌려주고 부작용은 한 번만 일으키는 것이다. 재시도 자체는 서버 부하를 폭증시키지 않도록 지수 백오프(간격을 2배씩) + 지터(무작위 흔들기)로 분산한다.

상황재시도이유
GET / PUT / DELETE + 5xx·timeout안전멱등 — 반복해도 최종 상태 동일
POST + Idempotency-Key안전서버가 중복을 한 번으로 취급
429 (Retry-After 있음)지시대로서버가 알려준 시각 이후 재시도
POST/PATCH (멱등키 없음) + timeout위험서버가 이미 처리했을 수 있음 → 중복 부작용
4xx (400/404/422)무의미요청 자체가 틀림 — 반복해도 동일 실패
501 Not Implemented무의미서버가 지원 안 하는 기능

iOS에서는

URLSession은 커넥션 유실 등 일부 상황에서 멱등으로 판단되는 요청을 자체 재전송할 수 있다. 그 위에 앱 레벨 재시도를 얹을 때는 method의 멱등성과 오류 계층(Q6)을 조건으로 넣고, 비멱등 요청은 Idempotency-Key 헤더로 보호한다. 키는 "재시도해도 같은 논리적 요청"에 대해 동일해야 하므로, 재시도 루프 밖에서 한 번 생성해 재사용한다.

멱등 키 + 지수 백오프 재시도 골격 (Swift, async/await)
func post(_ body: Data, to url: URL,
          idempotencyKey: String,           // 재시도 전체에서 '동일'해야 함
          maxAttempts: Int = 3) async throws -> Data {
    var attempt = 0
    while true {
        attempt += 1
        do {
            var req = URLRequest(url: url)
            req.httpMethod = "POST"
            req.httpBody = body
            req.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
            let (data, resp) = try await session.data(for: req)
            let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
            if (200..<300).contains(code) { return data }
            // 5xx·429만 재시도 대상, 그 외(4xx 등)는 즉시 던짐
            guard code == 429 || (500..<600).contains(code), attempt < maxAttempts
            else { throw AppError.http(code) }
        } catch let e as URLError where e.code == .timedOut || e.code == .networkConnectionLost {
            // transport 오류: 멱등키가 있으니 재시도 안전
            if attempt >= maxAttempts { throw e }
        }
        // 지수 백오프 + 지터
        let backoff = pow(2.0, Double(attempt)) * 0.2
        let jitter  = Double.random(in: 0...0.3)
        try await Task.sleep(for: .seconds(backoff + jitter))
    }
}

프로젝트 적용

⚠️ 흔한 오해

"timeout이 났으니 서버에는 요청이 도달하지 않았다"는 위험한 가정. 실제로는 서버가 처리를 끝냈는데 응답만 유실됐을 수 있어, 이 상태에서 비멱등 요청을 재시도하면 이중 결제가 난다. 또 "5xx는 전부 재시도해도 된다"도 틀렸다 — 501(Not Implemented)처럼 재시도가 무의미한 5xx도 있다. 재시도 판단은 상태 코드 하나가 아니라 멱등성과 함께 봐야 한다.

꼬리 질문

출처 · 참고자료