웹 요청 하나가 화면에 그려지기까지 거치는 계층(DNS · TCP · TLS · HTTP)을 CS 원리로 먼저 세우고, iOS의 URLSession/Network.framework가 그 계층들을 실제로 어떻게 다루는지, 그리고 timeout·재시도처럼 실무에서 사고가 나는 지점을 면접 답변 수준으로 정리한다.
Q1. URL을 입력한 뒤 DNS 조회부터 응답 표시까지 어떤 일이 일어나는가?
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/이미지 등)을 해석해 화면에 반영한다.
iOS에서는
대부분 URLSession이 이 전 과정을 대신한다. 이름 해석은 Darwin의 getaddrinfo/mDNSResponder가 담당하고, IPv4/IPv6가 둘 다 있으면 RFC 8305 Happy Eyeballs로 더 빨리 응답하는 쪽을 골라 붙는다. URLSession은 같은 호스트로의 커넥션을 풀에 두고 재사용하며, 서버가 지원하면 HTTP/2로 여러 요청을 한 커넥션에 멀티플렉싱한다. App Transport Security(ATS) 때문에 평문 HTTP는 기본 차단되어 사실상 TLS 단계가 항상 낀다. 더 낮은 계층을 직접 다뤄야 하면 Network.framework의 NWConnection이 DNS·TCP·TLS를 하나의 엔드포인트 연결로 묶어준다.
실험 · 도구
단계별로 어디서 시간이 쓰이는지 갈라 보는 게 핵심이다. Instruments의 Network 템플릿으로 요청 타임라인(DNS/connect/TLS/TTFB)을 보고, Proxyman·Charles 같은 중간자 프록시로 실제 프로토콜 버전과 헤더를 확인한다. 커맨드라인에서는 dig example.com으로 DNS만, nscurl --ats-diagnostics https://...로 ATS 통과 여부만 따로 검증할 수 있다. 예상 결과: 재요청 시 DNS/TLS 시간이 캐시·커넥션 재사용으로 크게 줄어든다.
프로젝트 적용
URLSession인스턴스를 요청마다 새로 만들지 말 것 — 커넥션 풀과 캐시가 인스턴스에 붙어 있어, 매번 새로 만들면 재사용 이점이 사라진다.- "느리다"는 리포트는 반드시 계층으로 쪼개서 볼 것: DNS 콜드? TLS 콜드? 서버 TTFB? 계층마다 대응이 다르다.
- 서버가 HTTP/2를 지원하도록 하고, 도메인을 지나치게 쪼개면(sharding) HTTP/2 멀티플렉싱 이점이 줄어드니 주의.
"앱은 요청할 때마다 DNS를 새로 조회하고, 요청 하나당 커넥션 하나를 새로 연다"는 오해가 흔하다. 실제로는 리졸버·OS·앱 계층에 TTL 기반 DNS 캐시가 있고, HTTP는 keep-alive와 HTTP/2 멀티플렉싱으로 커넥션을 재사용한다. 반대 방향 오해도 있다 — DNS 캐시가 오래되면(stale) 서버가 IP를 바꿔도 옛 IP로 붙어 실패할 수 있으니 "항상 최신"이라고 가정하면 안 된다.
꼬리 질문
- HTTP/1.1과 HTTP/2에서 이 흐름의 "요청·응답" 단계는 어떻게 달라지나?
- DNS 응답의 TTL이 만료되기 전에 서버 IP가 바뀌면 어떤 장애가 나고, 클라이언트/서버는 어떻게 완화하나?
- 같은 호스트로 이미지 20개를 동시에 요청하면 HTTP/1.1과 HTTP/2에서 각각 TCP 커넥션이 몇 개 열리나?
Q2. HTTP는 TCP 위에서 어떻게 동작하는가?
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) 위에서 스트림별 독립 전송을 한다.
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/2를 지원하면 도메인당 커넥션을 잘게 쪼개지 말 것 — 한 커넥션 멀티플렉싱이 더 효율적이다.
- 모바일처럼 네트워크 전환(Wi-Fi↔셀룰러)이 잦은 환경에서 HTTP/3(QUIC)는 커넥션 마이그레이션 이점이 있으니 서버 지원 시 활성 검토.
- "HTTP를 튜닝한다"면서 애플리케이션 헤더만 보지 말고, 실제 병목이 TCP 재전송/혼잡 제어인지 계층을 나눠 볼 것.
"HTTP는 항상 TCP 위에서 돈다"와 "HTTP/2면 head-of-line 블로킹이 사라진다"는 둘 다 부정확하다. HTTP/3는 UDP 기반 QUIC 위에서 동작하므로 TCP가 필수가 아니다. 그리고 HTTP/2는 애플리케이션 계층 HOL만 없앨 뿐, 한 TCP 커넥션에서 패킷이 유실되면 생기는 TCP 계층 HOL은 남는다 — 이걸 없앤 게 QUIC의 스트림별 독립 전송이다.
꼬리 질문
- HTTP/1.1의 파이프라이닝은 왜 실무에서 거의 쓰이지 않았나?
- HTTP/2 스트림이 하나의 TCP 커넥션을 공유하는데, 왜 TCP 계층 HOL이 여전히 문제가 되나?
- QUIC이 UDP 위에서 신뢰성·순서·혼잡 제어를 어떻게 다시 구현하나?
Q3. TCP와 UDP는 무엇이 다른가?
TCP는 연결 지향 + 신뢰성 + 순서 보장이 있는 바이트 스트림입니다. 연결을 맺고(3-way handshake), 유실은 재전송, 순서는 시퀀스 번호로 맞추고, 흐름 제어·혼잡 제어까지 합니다. 대가로 지연과 오버헤드(헤더 20바이트+, handshake RTT)가 붙습니다. UDP는 비연결 + 무신뢰 + 무순서의 데이터그램 전송입니다. 그냥 보내고 끝이라 헤더 8바이트로 가볍고 빠릅니다. 신뢰성이 필요하면 애플리케이션이 직접 얹어야 합니다. 그래서 웹/API는 TCP, 실시간 음성·영상·게임은 UDP(또는 UDP 위 QUIC)가 기본입니다.
CS 원리
둘 다 IP 위에서 도는 전송 계층 프로토콜이지만 보장 수준이 정반대다. TCP(RFC 9293)는 연결을 세운 뒤 모든 바이트에 시퀀스 번호를 붙여, 유실되면 재전송하고 도착 순서가 뒤엉키면 재정렬한다. 수신자 버퍼가 넘치지 않게 하는 흐름 제어, 네트워크가 막히지 않게 전송 속도를 조절하는 혼잡 제어도 TCP가 한다. UDP는 이 모든 걸 생략한다 — 목적지로 데이터그램을 던지고, 도착 여부·순서·중복을 신경 쓰지 않는다.
| 항목 | TCP | UDP |
|---|---|---|
| 연결 | 연결 지향(handshake 필요) | 비연결 |
| 신뢰성(유실 재전송) | 보장 | 없음 |
| 순서 보장 | 보장 | 없음 |
| 흐름/혼잡 제어 | 있음 | 없음(앱이 직접) |
| 전송 단위 | 바이트 스트림 | 데이터그램(메시지) |
| 헤더 크기 | 20바이트+ | 8바이트 |
| 대표 용도 | HTTP, 파일 전송, API | DNS, 실시간 A/V, 게임, QUIC |
iOS에서는
URLSession 기반 HTTP 통신은 전부 TCP(HTTP/3면 QUIC) 위에서 돈다. 저수준으로 프로토콜을 고를 때는 Network.framework의 NWConnection(to:using:)에 넘길 NWParameters를 .tcp / .udp / .tls 등으로 지정한다(QUIC은 NWProtocolQUIC.Options로 구성). 실시간 오디오/영상은 보통 UDP 기반 프로토콜(RTP, WebRTC)을 쓰는데, 늦게 도착한 프레임은 재전송해봐야 이미 쓸모가 없어서 "정확하지만 늦은 것"보다 "빠지더라도 최신"이 낫기 때문이다.
// 신뢰성이 필요한 경우: 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)프로젝트 적용
- "기본은 TCP, 실시간·지연 민감은 UDP"가 출발점. 단, UDP를 고르면 재전송·순서·중복 제거를 앱이 책임져야 한다는 점을 각오할 것.
- 직접 UDP 신뢰성 계층을 구현하기 전에, 이미 그걸 잘 해둔 QUIC/WebRTC를 먼저 검토 — 바퀴를 다시 만들지 말 것.
"UDP는 신뢰성이 없으니 언제나 열등하다"는 오해. 실시간 통신에서는 유실 패킷을 재전송하느라 생기는 지연이 오히려 품질을 해친다 — 지나간 음성 프레임은 다시 받아도 소용없다. 또 "UDP는 신뢰성이 아예 불가능"도 틀렸다. QUIC은 UDP 위에 신뢰성·순서·혼잡 제어를 애플리케이션 레벨로 다시 쌓아 HTTP/3의 전송이 된다.
꼬리 질문
- DNS는 왜 기본적으로 UDP를 쓰고, 어떤 경우 TCP로 폴백하나?
- QUIC이 UDP를 고른 이유가 TCP의 어떤 한계 때문인가?
- UDP 위에서 순서 보장이 필요할 때 최소한으로 무엇을 직접 구현해야 하나?
Q4. TCP 3-way handshake는 왜 필요한가?
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이 말하는 "오래된 중복 세그먼트가 유령 연결을 여는" 문제를 못 막는다.
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에 해당한다.
프로젝트 적용
- 느린 네트워크에서 "첫 요청만 유독 느린" 현상은 대개 handshake + TLS 콜드 비용이다 — 커넥션 재사용/사전 워밍으로 완화.
- 매 요청마다 새
URLSession을 만드는 코드는 이 왕복을 매번 새로 치른다. 피할 것.
"3-way handshake가 보안/암호화를 담당한다"는 오해가 잦다. handshake는 시퀀스 번호 합의와 양방향 도달성 확인일 뿐, 데이터를 암호화하지 않는다 — 암호화·서버 인증은 그 위에 얹는 TLS의 역할이다. 또 "왜 4-way가 아니냐"는 반대 오해도 있는데, 서버의 ACK와 SYN을 한 패킷(SYN-ACK)으로 합칠 수 있어 3단계로 충분하다(연결 종료는 각 방향을 따로 닫아 4-way).
꼬리 질문
- 연결 종료는 왜 4-way(FIN/ACK 왕복)이고, TIME_WAIT 상태는 왜 필요한가?
- 초기 시퀀스 번호(ISN)를 0이 아니라 무작위로 시작하는 이유는?
- SYN flood 공격은 이 handshake의 어떤 상태를 악용하며, SYN cookie는 어떻게 막나?
Q5. HTTP Method의 의미와 멱등성은 무엇인가?
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가 아니지만, 두 번 지워도 "없음"이라는 최종 상태는 같으므로 멱등이다.
| Method | Safe | 멱등 | 의미 |
|---|---|---|---|
| GET | O | O | 리소스 조회 |
| HEAD | O | O | 헤더만 조회 |
| PUT | X | O | 리소스 전체 교체(있으면 갱신, 없으면 생성) |
| DELETE | X | O | 리소스 삭제 |
| POST | X | X | 생성·실행(호출마다 새 효과 가능) |
| PATCH | X | 보장 안 됨 | 부분 수정(연산에 따라 멱등일 수도) |
iOS에서는
URLRequest.httpMethod로 지정한다(기본 GET). 이 구분이 iOS에서 실무적으로 중요한 이유는 재시도와 얽히기 때문이다. URLSession은 내부적으로 커넥션이 끊긴 경우 등에서 멱등으로 간주되는 요청을 재전송할 수 있는데, POST처럼 비멱등인 요청을 임의로 재시도하면 중복 부작용(중복 주문/결제)이 난다. 그래서 커스텀 재시도 로직을 짤 때 method의 멱등성을 반드시 조건으로 넣어야 한다(→ Q9).
// 멱등한 PATCH: 절대값 설정 — 몇 번 보내도 최종 상태 동일
PATCH /account/42 {"balance": 1000}
// 비멱등한 PATCH: 상대적 증감 — 보낼 때마다 효과 누적
PATCH /account/42 {"balance_delta": 100}프로젝트 적용
- "이 method가 멱등인가?"를 API 설계 시점에 못 박을 것. 특히 PATCH를 절대값 기반으로 설계하면 재시도가 안전해진다.
- 생성 요청(POST)이라도
Idempotency-Key헤더를 받도록 서버를 설계하면 클라이언트가 안전하게 재시도할 수 있다(→ Q9). - DELETE는 "이미 없음"에 대해 404 대신 성공(200/204)으로 처리할지 팀 규칙을 정할 것 — 멱등 재시도 시 혼란을 줄인다.
"멱등하면 응답도 항상 똑같다"는 착각. 멱등성은 서버 상태의 효과가 같다는 것이지 응답이 같다는 게 아니다. DELETE를 두 번 하면 첫 번째는 204, 두 번째는 404가 나올 수 있지만 "해당 리소스가 없음"이라는 최종 상태는 동일하므로 여전히 멱등이다. 반대로 "POST는 절대 재시도하면 안 된다"도 과장이다 — Idempotency-Key로 서버가 중복을 무시하게 설계하면 안전하게 재시도할 수 있다.
꼬리 질문
- PUT과 POST로 리소스를 생성할 때 각각 URL과 멱등성이 어떻게 달라지나?
- "safe하지만 멱등이 아닌" method가 존재할 수 있나? 왜?
- 캐시(브라우저/CDN) 관점에서 GET과 POST를 다르게 취급하는 이유는?
Q6. HTTP 상태 코드와 Transport error를 왜 구분해야 하는가?
둘은 실패의 계층이 다릅니다. HTTP 상태 코드(4xx/5xx)는 "서버까지 요청이 도달해 응답을 받았는데, 그 결과가 오류"라는 애플리케이션 레벨 신호입니다. Transport error는 "그 이전 단계에서 깨져 유효한 HTTP 응답 자체를 못 받았다"는 것 — DNS 실패, 연결 거부, timeout, TLS 실패 등입니다. 구분이 중요한 이유는 재시도 판단과 사용자 메시지가 완전히 다르기 때문입니다. 404는 재시도해도 소용없고, timeout은 재시도 후보이며 심지어 서버엔 이미 도달했을 수도 있습니다.
CS 원리
HTTP 상태 코드는 서버가 요청을 받아서 응답을 보냈다는 전제 위에 있는, 처리 결과의 요약이다(RFC 9110). 2xx 성공, 3xx 리다이렉트, 4xx 클라이언트 잘못(요청을 고쳐야 함), 5xx 서버 잘못. 반면 transport/네트워크 오류는 그보다 아래 계층에서 연결이 성립하지 못했거나 응답을 온전히 못 받은 경우다. 이 경우 "상태 코드"라는 게 아예 없다 — 응답이 없으니까.
| 코드/오류 | 계층 | 의미 | 재시도 |
|---|---|---|---|
| 200 / 204 | HTTP | 성공 | 불필요 |
| 400 / 404 / 422 | HTTP | 클라이언트 요청 오류 | 무의미(요청을 고쳐야) |
| 401 | HTTP | 인증 실패(신원 증명 안 됨) | 토큰 갱신 후 1회 재시도 |
| 403 | HTTP | 인가 실패(권한 없음) | 무의미(권한이 바뀌기 전엔 동일 실패) |
| 429 | HTTP | 요청 과다 | Retry-After 따라 |
| 500 / 502 / 503 | HTTP | 서버 오류/일시 불가 | 조건부(백오프) |
| timeout / DNS 실패 / 연결 거부 | Transport | 응답 자체를 못 받음 | 멱등이면 후보 |
iOS에서는
URLSession의 completion/async API는 이 두 세계를 다른 자리로 돌려준다. 네트워크(transport) 오류는 error(구체적으로 URLError)로, 서버가 준 HTTP 결과는 response(HTTPURLResponse.statusCode)로 온다. 결정적으로, 서버가 500을 줘도 error는 nil이다 — 통신 자체는 성공했기 때문이다. 그래서 "에러가 nil이면 성공"으로 처리하면 500·404를 성공으로 오인한다.
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)
}
}프로젝트 적용
error == nil을 성공으로 쓰지 말 것. 항상HTTPURLResponse.statusCode를 별도로 검사한다.- 재시도 정책은 이 두 축(상태 코드 vs transport)으로 분기: 4xx는 재시도 금지, 5xx/429는 백오프 재시도, transport 오류는 멱등성에 따라(→ Q9).
- 사용자 메시지도 분리: transport 오류는 "네트워크를 확인하세요", 5xx는 "잠시 후 다시", 4xx는 "요청을 확인하세요"처럼 원인 계층에 맞춰야 한다.
"URLSession의 error가 nil이면 요청이 성공한 것"이라는 오해가 가장 흔한 버그 원인이다. 4xx·5xx는 통신 성공 + 애플리케이션 실패라서 error가 nil이고 statusCode에만 드러난다. 반대로 timeout처럼 error가 채워지는 경우는 상태 코드가 아예 없다. 두 자리를 모두 봐야 한다.
꼬리 질문
- 502(Bad Gateway)와 504(Gateway Timeout)는 어디서 발생하며 클라이언트 대응이 어떻게 다른가?
- timeout이 났을 때 서버가 요청을 이미 처리했을 수 있는데, 이걸 상태 코드만으로 알 수 있나?
- 401과 403을 클라이언트에서 다르게 처리해야 하는 이유는?
Q7. TLS는 서버 신원과 전송 데이터를 어떻게 보호하는가?
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 키 교환을 쓰면 세션키가 서버의 장기 개인키와 독립적이라, 나중에 개인키가 유출돼도 과거 트래픽을 복호화할 수 없는 전방향 비밀성이 생긴다.
iOS에서는
ATS가 기본으로 TLS 1.2 이상 + 강한 cipher를 강제하고, 평문 HTTP를 막는다. URLSession은 서버 인증서 체인을 시스템 신뢰 저장소로 자동 검증한다. 추가 신뢰가 필요하면(사설 CA, 또는 공개키/인증서 피닝) URLSessionDelegate의 urlSession(_:didReceive:completionHandler:)에서 URLAuthenticationChallenge를 받아 직접 판단한다. Network.framework에서는 sec_protocol_options로 검증 콜백을 건다. 프로토콜 버전 협상(HTTP/2 등)도 이 핸드셰이크의 ALPN에서 함께 정해진다.
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는 "지금 붙은 상대가 그 도메인의 주인이 맞다"와 "중간에서 엿보거나 변조하지 못한다"만 보장한다. 그 도메인 주인이 선한지는 별개라, 피싱 사이트도 정상 인증서로 자물쇠를 띄운다. 또 "피닝하면 무조건 더 안전"도 위험한 반쪽 진실 — 백업 핀 없이 피닝하면 인증서 만료/교체 때 앱이 통째로 죽는다.
꼬리 질문
- TLS 1.3의 0-RTT(early data)는 어떤 재생(replay) 공격 위험이 있고 왜 멱등 요청에만 권장되나?
- 인증서 피닝과 공개키 피닝의 차이는 무엇이고, 로테이션 관점에서 어느 쪽이 유리한가?
- 중간자(MITM) 프록시가 HTTPS 트래픽을 열어 보는 원리는 무엇이며, 피닝이 그걸 어떻게 막나?
Q8. Timeout에는 어떤 종류가 있으며 각각 무엇을 의미하는가?
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이 필요하다.
iOS에서는
timeoutIntervalForRequest는 애플 문서 표현으로 "새 데이터를 기다리는 동안 허용하는 시간"이다 — 즉 정체(stall) 감지 타이머이고 데이터 활동이 있을 때마다 리셋된다(기본 60초). timeoutIntervalForResource는 재시도를 포함해 리소스를 받아내는 데 허용하는 총 시간이다(기본 7일). URLRequest.timeoutInterval은 요청 단위로 request timeout을 덮어쓴다. 또 waitsForConnectivity = true면, 연결이 아예 없을 때 즉시 실패하는 대신 연결이 생길 때까지 (resource timeout 한도 내에서) 기다린다.
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의 차이를 가장 확실히 보여준다.
프로젝트 적용
- 사용자 대면 요청은 request와 resource를 둘 다 현실적으로(예: 15~30초 / 60~120초) 잡을 것. request만 잡으면 느리게 질질 오는 응답을 못 끊는다.
- 백그라운드 대용량 다운로드는 resource를 넉넉히, 인터랙티브 API는 짧게 — 용도별로
URLSessionConfiguration을 분리. - timeout 값은 서버의 p99 지연 + 여유로 정하고, 클라이언트 timeout이 서버 처리시간보다 짧으면 "서버는 처리 성공, 클라는 timeout"이 생겨 재시도 중복 위험이 커진다는 걸 염두(→ Q9).
"timeoutIntervalForRequest = 60이면 요청은 무조건 60초 안에 끝나거나 실패한다"는 오해가 가장 흔하다. 이건 전체 상한이 아니라 "데이터가 안 들어오고 멈춰 있는 시간"의 상한이라, 서버가 1초에 한 바이트씩만 흘려도 타이머가 계속 리셋되어 영원히 안 끊길 수 있다. 전체 시간을 제한하려면 timeoutIntervalForResource를 써야 한다.
꼬리 질문
waitsForConnectivity가true일 때 timeout은 어떻게 상호작용하나?- connect timeout과 read timeout을 분리해서 잡고 싶다면 iOS에서 어떻게 접근하나?
- 클라이언트 timeout이 서버 처리시간보다 짧을 때 생기는 문제와, 서버 측 완화책(요청 취소 전파 등)은?
Q9. 재시도 가능한 요청과 재시도하면 위험한 요청은 어떻게 구분하는가?
기준은 멱등성 + 부작용의 성격 + 요청이 서버에 도달했는지의 불확실성입니다. 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 헤더로 보호한다. 키는 "재시도해도 같은 논리적 요청"에 대해 동일해야 하므로, 재시도 루프 밖에서 한 번 생성해 재사용한다.
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))
}
}프로젝트 적용
- 결제·주문처럼 부작용이 큰 POST는 반드시 서버가
Idempotency-Key를 지원하도록 설계하고, 클라는 재시도 루프 밖에서 키를 만들어 재사용한다. - 재시도는 지수 백오프 + 지터 + 최대 횟수 상한을 항상 함께. 지터 없이 동시 재시도하면 서버가 회복하려는 순간 다시 몰려(thundering herd) 장애가 길어진다.
- 429·503의
Retry-After헤더가 있으면 그 값을 존중할 것 — 자체 백오프보다 서버 지시가 우선. - 하지 말 것: 4xx 재시도, 지터 없는 즉시 재시도, 멱등키 없는 결제 POST 재시도.
"timeout이 났으니 서버에는 요청이 도달하지 않았다"는 위험한 가정. 실제로는 서버가 처리를 끝냈는데 응답만 유실됐을 수 있어, 이 상태에서 비멱등 요청을 재시도하면 이중 결제가 난다. 또 "5xx는 전부 재시도해도 된다"도 틀렸다 — 501(Not Implemented)처럼 재시도가 무의미한 5xx도 있다. 재시도 판단은 상태 코드 하나가 아니라 멱등성과 함께 봐야 한다.
꼬리 질문
- 서버가
Idempotency-Key를 구현할 때, 같은 키로 다른 본문이 오면 어떻게 처리해야 하나? - 지수 백오프에 지터를 넣는 이유(thundering herd)를 구체적으로 설명하면?
- 재시도와 서킷 브레이커는 어떻게 함께 동작해야 장애를 악화시키지 않나?