← iOS 개발자 CS 로드맵 4 / 35

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 시간이 캐시·커넥션 재사용으로 크게 줄어든다.

프로젝트 적용

URLSession 인스턴스를 요청마다 새로 만들지 말 것 — 커넥션 풀과 캐시가 인스턴스에 붙어 있어, 매번 새로 만들면 재사용 이점이 사라진다.

URLSession 인스턴스를 앱 전역에서 재사용
import Foundation

// 커넥션 풀·TLS 세션 재개 정보·HTTP 캐시가 '세션 인스턴스'에 붙어 있다.
// (DNS 캐시는 세션이 아니라 OS 리졸버 mDNSResponder가 들고 있어 세션과 무관하게 공유된다.)
// 매번 새로 만들면 요청마다 TCP → TLS를 처음부터 다시 친다.

// ❌ 요청마다 새 세션 — 재사용 이점이 전부 사라진다
func fetchBad(_ url: URL) async throws -> Data {
    let session = URLSession(configuration: .default)
    return try await session.data(from: url).0
}

// ✅ 용도별로 하나씩 만들어 재사용한다
enum Net {
    /// 사용자가 화면에서 기다리는 API 호출
    static let api: URLSession = {
        let c = URLSessionConfiguration.default
        c.timeoutIntervalForRequest = 15
        c.timeoutIntervalForResource = 60
        c.httpMaximumConnectionsPerHost = 6
        c.waitsForConnectivity = true
        c.requestCachePolicy = .useProtocolCachePolicy
        return URLSession(configuration: c)
    }()

    /// 이미지·대용량 — 캐시 정책과 타임아웃이 다르다
    static let media: URLSession = {
        let c = URLSessionConfiguration.default
        c.timeoutIntervalForResource = 300
        c.urlCache = URLCache(memoryCapacity: 32 << 20, diskCapacity: 256 << 20)
        return URLSession(configuration: c)
    }()
}

// ⚠️ 세션은 delegate를 '강하게' 붙잡는다. delegate가 세션을 소유하면
//    session → self → session 순환이 되어 deinit이 아예 불리지 않는다.
//    따라서 무효화는 deinit이 아니라 '소유자가 끝내는 시점'에 명시적으로 호출해야 한다.
final class PinnedClient: NSObject, URLSessionDelegate {
    private lazy var session = URLSession(configuration: .default,
                                          delegate: self, delegateQueue: nil)
    /// 화면 종료·로그아웃 등 이 객체의 수명이 끝나는 지점에서 반드시 호출한다
    func shutdown() { session.finishTasksAndInvalidate() }
}

"느리다"는 리포트는 반드시 계층으로 쪼개서 볼 것: DNS 콜드? TLS 콜드? 서버 TTFB? 계층마다 대응이 다르다.

'느리다'를 계층으로 쪼개 측정하기
import Foundation

// "API가 느려요"는 진단이 아니다. DNS·TCP·TLS·TTFB·본문 중 어디인지에 따라
// 처방이 완전히 달라진다. URLSessionTaskMetrics가 이걸 다 알려준다.

final class MetricsCollector: NSObject, URLSessionTaskDelegate {

    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didFinishCollecting metrics: URLSessionTaskMetrics) {
        for t in metrics.transactionMetrics {
            func ms(_ a: Date?, _ b: Date?) -> String {
                guard let a, let b else { return "-" }
                return String(format: "%.0fms", b.timeIntervalSince(a) * 1000)
            }

            print("""
            프로토콜: \(t.networkProtocolName ?? "-")  재사용: \(t.isReusedConnection)
              DNS   \(ms(t.domainLookupStartDate, t.domainLookupEndDate))
              연결   \(ms(t.connectStartDate, t.connectEndDate))   // TCP+TLS 합계(connectEnd는 TLS 완료 시점)
              └ TLS  \(ms(t.secureConnectionStartDate, t.secureConnectionEndDate))
              └ TCP  \(ms(t.connectStartDate, t.secureConnectionStartDate))
              TTFB  \(ms(t.requestEndDate, t.responseStartDate))
              본문   \(ms(t.responseStartDate, t.responseEndDate))
            """)
        }
    }
}

// 읽는 법 — 계층마다 처방이 다르다
//   DNS가 크다        → 콜드 조회. 세션 재사용·사전 워밍으로 완화
//   TLS가 크다        → 콜드 핸드셰이크. 커넥션 재사용, TLS 1.3 확인
//   TTFB가 크다       → 서버 처리 시간. 클라가 할 수 있는 게 없다 → 백엔드로
//   본문이 크다       → 페이로드 크기·압축 문제 → 응답을 줄이거나 페이지네이션
//   isReused=false만  → 풀이 안 돌고 있다 → 세션을 매번 만들고 있지 않은지 확인

let session = URLSession(configuration: .default,
                         delegate: MetricsCollector(), delegateQueue: nil)

서버가 HTTP/2를 지원하도록 하고, 도메인을 지나치게 쪼개면(sharding) HTTP/2 멀티플렉싱 이점이 줄어드니 주의.

HTTP/2를 쓰면 도메인을 쪼개지 않기
import Foundation

// HTTP/1.1 시절 관행: 호스트당 커넥션 한도(기본 6개)를 우회하려고
// img1.cdn.com, img2.cdn.com … 으로 도메인을 쪼갰다(domain sharding).
//
// HTTP/2에서는 이게 역효과다. 한 커넥션에 스트림을 멀티플렉싱하는 게 핵심인데
// 도메인을 쪼개면 커넥션이 늘어 그 이점이 사라지고 핸드셰이크만 늘어난다.

// ❌ HTTP/2 서버에 대고 샤딩
let shardedHosts = ["img1.cdn.com", "img2.cdn.com", "img3.cdn.com"]

// ✅ 하나로 모아 멀티플렉싱을 받는다
let singleHost = "cdn.example.com"

// 실제로 h2로 붙었는지, 커넥션이 재사용되는지 확인한다
func verifyProtocol(_ metrics: URLSessionTaskMetrics) {
    let protocols = Set(metrics.transactionMetrics.compactMap(\.networkProtocolName))
    let reused = metrics.transactionMetrics.filter(\.isReusedConnection).count
    print("프로토콜 \(protocols) · 재사용 \(reused)/\(metrics.transactionMetrics.count)")
    // "h2"가 나오고 재사용 비율이 높아야 정상이다
}

// ✅ 서버가 HTTP/2를 지원하는지 먼저 확인
//    curl -I --http2 https://cdn.example.com/  →  "HTTP/2 200"

// ⚠️ 한도 설정도 프로토콜에 맞춰 생각한다
let config = URLSessionConfiguration.default
config.httpMaximumConnectionsPerHost = 6   // HTTP/1.1 폴백 시에만 의미가 있다
// HTTP/2에서는 이 값과 무관하게 보통 커넥션 1개로 처리된다
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

친구네 집에 전화를 건다고 해보자. 먼저 전화번호부에서 "민수네"라는 이름을 찾아 번호를 알아낸다 — 이게 DNS다. 번호를 누르고 "여보세요" "어, 나야"로 서로 말이 닿는지 확인하는 게 TCP 연결이고, 남이 엿들으면 곤란한 얘기라 둘만 아는 규칙으로 바꿔 말하기로 약속하는 게 TLS다. 그러고 나서야 진짜 용건("숙제 뭐 나왔어?")을 말하는데, 그게 HTTP 요청이다.

여기서 중요한 건 이 네 가지가 각각 따로 느려질 수 있다는 점이다. 번호를 못 찾아서 느린 것, 전화가 안 걸려서 느린 것, 상대가 대답을 늦게 해서 느린 것은 원인도 해결책도 전혀 다르다. 그래서 "앱이 느려요"라는 말을 한 덩어리로 두면 손을 못 대고, 단계로 잘라 봐야 범인이 나온다. 두 번째 전화부터 빨라지는 이유도 같은 그림으로 설명된다 — 번호를 이미 외웠고(DNS 캐시), 통화를 아예 안 끊고 붙잡아 뒀기 때문(커넥션 재사용)이다. 비유가 깨지는 곳: 전화는 사람이 한 단계씩 직접 하지만, URLSession은 이 네 단계를 알아서 다 처리해서 앱 코드에는 "요청 보냄" 한 줄로만 보인다.

꼬리 질문

HTTP/1.1과 HTTP/2에서 이 흐름의 "요청·응답" 단계는 어떻게 달라지나?

DNS·TCP·TLS까지는 동일하고 "요청·응답"의 다중화 방식만 다르다. HTTP/1.1은 한 커넥션에서 요청을 하나씩 순차 처리하므로 병렬성을 위해 브라우저가 호스트당 6개 안팎의 커넥션을 따로 연다. HTTP/2는 커넥션 하나 위에 여러 스트림을 두고 요청·응답 프레임을 인터리빙해, 같은 왕복 안에서 수십 개 요청이 동시에 오간다. 다만 HTTP/2로 붙을지는 TLS 핸드셰이크의 ALPN에서 정해지므로, 실질적으로 "요청·응답" 단계의 형태가 TLS 단계에서 이미 결정된다.

쉽게 말하면 은행 창구를 떠올리면 된다. HTTP/1.1은 창구 하나에 한 명씩이라, 여러 명을 동시에 받으려면 창구를 여러 개 열어야 한다(그래서 커넥션이 여러 개). HTTP/2는 창구 하나에서 여러 사람 서류를 조금씩 번갈아 처리한다. 그리고 어느 방식으로 갈지는 문 안에 들어가서 정하는 게 아니라, TLS 악수를 하면서 ALPN으로 미리 정해진다.
DNS 응답의 TTL이 만료되기 전에 서버 IP가 바뀌면 어떤 장애가 나고, 클라이언트/서버는 어떻게 완화하나?

캐시된 옛 IP가 TTL 동안 유효하다고 간주되어, 그 IP가 이미 내려갔으면 연결 거부나 timeout이 나고 살아 있으면 옛 서버로 붙는다. 서버 쪽 완화는 전환 예정 구간에 TTL을 미리 낮춰 두고, 옛 IP를 최소 한 TTL 이상 트래픽을 받도록 유지하는 것(graceful drain)이다. 클라이언트 쪽은 연결 실패 시 리졸버를 강제로 다시 조회하거나 여러 A/AAAA 레코드를 받아 하나가 죽으면 다음으로 넘어가는 식으로 대비한다. iOS URLSession은 OS DNS 캐시에 의존하므로 앱이 TTL을 직접 제어하긴 어렵고, 실패 시 재시도로 새 조회를 유도하는 게 현실적이다.

쉽게 말하면 친구가 이사를 갔는데, 내 수첩에 적힌 옛날 주소에 "이 주소는 10분간 유효"라고 써 있는 상황이다. 그 10분 동안 나는 계속 빈집 앞으로 찾아간다. 그래서 이사하기 전에 유효기간(TTL)을 1분으로 미리 줄여 두고, 옛집에도 한동안 사람을 남겨 손님을 받게 해 두는 것(graceful drain)이 서버가 할 일이다.
같은 호스트로 이미지 20개를 동시에 요청하면 HTTP/1.1과 HTTP/2에서 각각 TCP 커넥션이 몇 개 열리나?

HTTP/1.1은 커넥션당 요청 하나라 클라이언트가 정한 호스트당 병렬 한도(브라우저는 대개 6, URLSessionhttpMaximumConnectionsPerHost도 현행 Apple 문서 기준 기본값 6 — 예전 문서에는 iOS만 4로 적혀 있었다)만큼 커넥션을 열고 나머지는 큐에서 대기한다 — 20개면 6개 커넥션에 나눠 실리고 나머지 14개는 큐 대기. HTTP/2는 이론상 커넥션 1개에 20개 스트림을 멀티플렉싱해 동시에 내려받는다. 그래서 HTTP/2 환경에서 도메인을 잘게 쪼개면(domain sharding) 오히려 커넥션이 늘어 멀티플렉싱 이점이 깎인다.

쉽게 말하면 짐을 든 사람 20명이 건물에 들어가려는데, HTTP/1.1은 문이 6개(기본값)라 6명씩만 들어가고 나머지 14명은 줄을 선다. HTTP/2는 문 하나가 아주 넓어서 20명이 그냥 같이 지나간다. 그래서 옛날에 쓰던 "문을 늘리려고 도메인 쪼개기"는 HTTP/2에서는 오히려 손해다 — 넓은 문 하나면 될 걸 좁은 문 여러 개로 되돌리는 셈이라.

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)도 자동 협상한다. 어떤 버전으로 붙었는지는 HTTPURLResponse에는 드러나지 않지만, URLSessionTaskMetricstransactionMetrics[].networkProtocolName("http/1.1"·"h2"·"h3")으로 앱 안에서 바로 확인할 수 있고, 프록시(Proxyman)나 Instruments로도 볼 수 있다. 프로토콜 협상은 TLS의 ALPN 확장으로 이뤄지므로, 사실상 TLS 핸드셰이크 안에서 "우리 HTTP/2로 얘기하자"가 정해진다.

프로젝트 적용

서버가 HTTP/2를 지원하면 도메인당 커넥션을 잘게 쪼개지 말 것 — 한 커넥션 멀티플렉싱이 더 효율적이다.

커넥션을 잘게 쪼개지 않기
import Foundation

// HTTP/2는 한 TCP 커넥션 위에서 여러 요청을 스트림으로 동시에 실어 나른다.
// 커넥션을 늘리는 건 이 설계를 거스르는 것이다.

// ✅ 같은 호스트로 여러 요청을 동시에 보내도 커넥션은 1개면 충분하다
func loadFeedAssets(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { try await Net.api.data(from: url).0 }
        }
        var out: [Data] = []
        for try await d in group { out.append(d) }
        return out
    }
}
// HTTP/2라면 20개 요청 → 커넥션 1개 · 스트림 20개
// HTTP/1.1이라면      → 커넥션 6개(URLSession 기본값) · 나머지는 큐 대기

// ✅ 서버 푸시 대신 이제는 Early Hints·preload를 쓴다(서버 설정 영역)

// ⚠️ HTTP/2에서도 남는 것: TCP 계층 head-of-line 블로킹.
//    패킷 하나가 유실되면 그 커넥션의 모든 스트림이 함께 기다린다.
//    → 이걸 없앤 게 QUIC(HTTP/3)이다.

enum Net {
    static let api = URLSession(configuration: .default)
}

모바일처럼 네트워크 전환(Wi-Fi↔셀룰러)이 잦은 환경에서 HTTP/3(QUIC)는 커넥션 마이그레이션 이점이 있으니 서버 지원 시 활성 검토.

모바일에서 HTTP/3(QUIC) 검토
import Foundation

// 모바일은 Wi-Fi ↔ 셀룰러 전환이 잦다. TCP는 4-tuple(출발지 IP/포트)이 바뀌면
// 커넥션이 끊겨 핸드셰이크를 처음부터 다시 친다.
// QUIC은 connection ID로 식별해 IP가 바뀌어도 커넥션을 이어 간다(migration).

// ✅ URLSession은 서버가 지원하면 자동으로 HTTP/3를 쓴다 (Alt-Svc 협상)
//    별도 코드는 필요 없고, 실제로 붙었는지 확인만 하면 된다
func checkHTTP3(_ metrics: URLSessionTaskMetrics) {
    for t in metrics.transactionMetrics {
        if t.networkProtocolName == "h3" { print("HTTP/3로 연결됨") }
    }
}

// ✅ Network.framework을 직접 쓴다면 명시적으로 지정할 수 있다
import Network

func makeQUICConnection(host: String) -> NWConnection {
    let options = NWProtocolQUIC.Options(alpn: ["h3"])
    let params = NWParameters(quic: options)
    return NWConnection(host: NWEndpoint.Host(host), port: 443, using: params)
}

// ✅ 전환 상황을 실제로 테스트한다
//    · Network Link Conditioner로 대역폭·손실 조절
//    · 요청 중에 Wi-Fi를 끄고 셀룰러로 넘어가 보기
//    · TCP: 요청 실패 후 재시도 / QUIC: 그대로 이어짐

// ⚠️ 서버·CDN이 지원해야 의미가 있다. 확인:
//    curl -I --http3 https://api.example.com/
//    또는 응답 헤더의 alt-svc: h3=":443"

"HTTP를 튜닝한다"면서 애플리케이션 헤더만 보지 말고, 실제 병목이 TCP 재전송/혼잡 제어인지 계층을 나눠 볼 것.

병목이 애플리케이션인지 전송인지 나눠 보기
import Foundation

// 헤더·페이로드만 들여다보면 정작 원인이 TCP 재전송·혼잡 제어일 때 놓친다.

// ① 애플리케이션 계층 — URLSessionTaskMetrics로 단계별 시간
func appLayer(_ m: URLSessionTaskMetrics) {
    for t in m.transactionMetrics {
        let sent = t.countOfRequestBodyBytesSent
        let received = t.countOfResponseBodyBytesReceived
        print("요청 \(sent)B · 응답 \(received)B · \(t.networkProtocolName ?? "-")")
    }
}

// ② 전송 계층 — Network.framework의 path·경로 상태
import Network

let monitor = NWPathMonitor()

func watchPath() {
    monitor.pathUpdateHandler = { path in
        print("상태 \(path.status) · 비쌈 \(path.isExpensive) · 제한 \(path.isConstrained)")
        for i in path.availableInterfaces { print("  \(i.type) \(i.name)") }
    }
    monitor.start(queue: .global(qos: .utility))
}

/*  ③ 그 아래는 도구로 본다
      · Instruments → Network 템플릿 (재전송·RTT·윈도우)
      · Xcode → Debug Navigator → Network
      · 서버 쪽: ss -ti / netstat -s 로 retransmit 카운터

    판별 감각
      응답 크기는 작은데 시간이 길다     → TTFB(서버) 또는 RTT
      전송량 대비 시간이 비정상적으로 길다 → 재전송·혼잡 제어 의심
      특정 네트워크에서만 느리다         → 경로 문제(MTU·프록시·중간 장비)      */
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

TCP를 공장의 컨베이어 벨트라고 생각하자. 벨트는 물건을 순서대로, 빠짐없이 옮겨준다. HTTP는 그 벨트에 얹는 물건이다. 그런데 벨트에는 칸막이가 없어서, 받는 쪽은 "여기까지가 한 상자"인지 알 수가 없다. 그래서 상자 앞에 "이건 36칸짜리예요"라고 쪽지를 붙이는데, 그게 Content-Length다. 몇 칸인지 미리 모르면 "조각 하나 보냄 → 또 하나 보냄 → 끝"이라고 알려주는데 그게 chunked다.

속도 문제는 계산대 줄로 보면 그림이 잡힌다. HTTP/1.1은 계산대 하나에 한 사람씩이라, 앞사람 카트가 크면 뒤에 선 사람들은 그냥 서 있어야 한다. HTTP/2는 한 계산대에서 여러 사람 물건을 조금씩 번갈아 찍어줘서 다 같이 끝난다. 여기서 학습자가 제일 많이 걸려 넘어지는 지점이 있다 — 계산대를 아무리 잘 나눠도 벨트는 여전히 하나라는 것. 벨트 중간에서 물건 하나가 떨어지면(TCP 패킷 유실) 뒤에 실린 남의 물건까지 전부 멈춰 서 기다린다. 이게 TCP 계층 HOL이고, HTTP/3(QUIC)는 아예 벨트를 여러 줄로 깔아서 한 줄이 멈춰도 나머지는 계속 흐르게 만든 것이다.

시간 HTTP/1.1 커넥션 1개 · 순차 요청 A (느림) B C A가 끝나야 B가 시작 — 앞이 늦으면 뒤가 통째로 밀린다 ✕ 여기서 TCP 패킷 유실 HTTP/2 커넥션 1개 · 스트림 3개 A B C 동시에 흐르지만 벨트는 하나 — 무관한 B·C까지 함께 멈춘다 (TCP 계층 HOL) ✕ 똑같이 유실 HTTP/3 QUIC · 스트림별 독립 전송 A B C 멈추는 건 A 하나뿐 — B·C는 그대로 내려온다
같은 "패킷 하나 유실"이 버전마다 얼마나 번지는지가 다르다. HTTP/2가 없앤 건 계산대 줄(HTTP 계층)이고, 벨트 줄(TCP 계층)을 없앤 건 HTTP/3다.

꼬리 질문

HTTP/1.1의 파이프라이닝은 왜 실무에서 거의 쓰이지 않았나?

파이프라이닝은 응답을 기다리지 않고 요청을 연달아 보내는 기능이지만, 서버가 요청 순서 그대로 응답해야 한다는 제약 때문에 앞 요청이 느리면 뒤가 다 막히는 head-of-line 블로킹을 못 벗어났다. 게다가 중간의 프록시·서버 구현이 파이프라인을 제대로 못 다뤄 응답이 뒤섞이거나 깨지는 호환성 사고가 잦아, 대부분의 브라우저가 기본 비활성화했다. 결국 "커넥션 여러 개 열기"가 더 안전하고 빨라 실무에서 밀려났고, 이 문제를 제대로 푼 것이 HTTP/2의 스트림 멀티플렉싱이다.

쉽게 말하면 주문은 한꺼번에 몰아서 넣을 수 있는데, 음식은 반드시 주문한 순서대로만 나와야 한다는 식당이다. 첫 번째가 오래 걸리는 요리면 뒤 사람 음식이 다 됐어도 그냥 식어간다. 게다가 중간에 낀 서빙 직원(프록시)들이 이 규칙을 제대로 못 지켜서 음식이 뒤바뀌는 사고까지 잦았다. 이득은 없고 사고만 나니 다들 꺼버린 것이다.
HTTP/2 스트림이 하나의 TCP 커넥션을 공유하는데, 왜 TCP 계층 HOL이 여전히 문제가 되나?

HTTP/2는 애플리케이션 계층에서 스트림을 나누지만, 그 프레임들은 결국 하나의 TCP 바이트 스트림에 직렬화되어 실린다. TCP는 순서 보장을 위해 중간 세그먼트가 유실되면 그 뒤 바이트를 전부 버퍼에 쥔 채 재전송을 기다리므로, 논리적으로 무관한 다른 스트림의 프레임까지 커널 소켓 버퍼에서 함께 멈춘다. HTTP/2 계층은 "이건 다른 스트림이니 먼저 올려도 된다"는 걸 알지만 TCP가 순서대로만 넘겨줘 손을 못 쓴다. QUIC은 스트림별로 독립된 순서·재전송을 관리해 이 한 유실이 다른 스트림을 막지 않게 한 것이다.

쉽게 말하면 택배를 상자 여러 개로 나눠 부쳐도 트럭은 한 대다. 트럭이 중간에 서면 상자 전부가 같이 멈춘다. HTTP/2는 "이건 다른 상자니까 먼저 내려도 돼"를 알고 있지만, 트럭(TCP)이 "실은 순서대로만 내려준다"고 고집해서 손을 못 쓴다. QUIC은 상자마다 트럭을 따로 붙인 것이라 한 대가 서도 나머지는 계속 간다.
QUIC이 UDP 위에서 신뢰성·순서·혼잡 제어를 어떻게 다시 구현하나?

QUIC은 UDP 데이터그램을 단순 운반체로만 쓰고, 그 위에 TCP가 커널에서 하던 일을 사용자 공간 프로토콜로 다시 쌓는다. 각 패킷에 단조 증가하는 패킷 번호를 붙이고 ACK로 유실을 감지·재전송하며, 스트림별로 별도의 순서 번호를 둬 순서를 보장한다. 혼잡 제어도 TCP와 유사한 알고리즘(CUBIC·BBR 등)을 QUIC 라이브러리가 구현한다. 핵심 이점은 재전송·순서가 스트림 단위로 독립이라 TCP 계층 HOL이 사라지고, TLS 1.3를 프로토콜에 통합해 1-RTT(재접속 0-RTT)에 암호화 연결까지 세운다는 점이다.

쉽게 말하면 UDP는 "그냥 던지고 끝"인 택배다. QUIC은 그 위에 송장번호 붙이기, 도착 확인 받기, 안 오면 다시 보내기를 자기가 직접 만들어 얹은 것이다. TCP가 운영체제 안쪽에서 해주던 일을 앱 쪽에서 새로 짠 셈. 다만 송장번호를 짐칸별로 따로 매겨서, 한 짐이 늦어도 다른 짐은 기다리지 않는다는 게 결정적 차이다.

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
TCP — 연결 지향 · 신뢰성 · 순서 ① 연결 수립 (3-way handshake) 클라이언트 서버 ② 보낸 순서대로 · 빠짐없이 도착 1 2 3 4 스트림 ③ 유실되면 재전송 · 흐름/혼잡 제어 3번 유실 → 서버가 요청 → 다시 보냄 대가 · 헤더 20바이트 이상 · 연결에 최소 1 RTT 선행 비용 · 앞이 막히면 뒤도 대기 (HOL) UDP — 비연결 · 무신뢰 · 무순서 ① 연결 수립 없음 — 곧바로 전송 클라이언트 서버 ② 유실·순서 뒤바뀜이 그대로 남는다 1 3 2 데이터그램 ③ 필요하면 앱이 직접 신뢰성을 얹는다 QUIC이 바로 이 방식 → HTTP/3의 전송 이득 · 헤더 8바이트 · 선행 왕복 없이 즉시 전송 · 지나간 프레임을 기다리지 않는다
차이는 "무엇을 보장하느냐"가 아니라 보장을 누가 지느냐다. TCP는 커널이 재전송·순서를 대신 져 주고 그만큼 지연을 얹으며, UDP는 그 부담을 앱에 넘기는 대신 지연을 최소로 남긴다 — 실시간 음성처럼 "늦게 온 데이터가 쓸모없는" 경우엔 후자가 맞다.

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)

프로젝트 적용

"기본은 TCP, 실시간·지연 민감은 UDP"가 출발점. 단, UDP를 고르면 재전송·순서·중복 제거를 앱이 책임져야 한다는 점을 각오할 것.

기본은 TCP, 실시간만 UDP
import Foundation
import Network

// 판단 기준은 "늦게 온 데이터가 쓸모 있는가"다.
//   쓸모 있다 → TCP (재전송으로 반드시 도착시킨다)
//   쓸모 없다 → UDP (지난 음성 프레임을 다시 받아도 의미가 없다)

// ✅ API·파일·이미지 — TCP(위에 TLS)
func makeAPIConnection() -> NWConnection {
    NWConnection(host: "api.example.com", port: 443, using: .tls)
}

// ✅ 실시간 음성·게임 상태 — UDP
func makeVoiceConnection() -> NWConnection {
    NWConnection(host: "voice.example.com", port: 9000, using: .udp)
}

// ⚠️ UDP를 고르면 아래를 전부 앱이 책임진다
struct VoicePacket: Codable {
    let sequence: UInt32      // ① 순서 — 뒤바뀜을 직접 정렬해야 한다
    let timestamp: UInt64     // ② 지연 판단 — 너무 늦은 건 버린다
    let payload: Data
}

final class JitterBuffer {
    private var packets: [UInt32: VoicePacket] = [:]
    private var nextSequence: UInt32 = 0

    // ③ 유실은 재전송하지 않고 '메운다'(concealment) — 실시간의 정석
    func pop() -> VoicePacket? {
        defer { nextSequence += 1 }
        if let p = packets.removeValue(forKey: nextSequence) { return p }
        return nil            // 유실 → 이전 프레임 반복이나 무음으로 대체
    }

    func push(_ p: VoicePacket) {
        guard p.sequence >= nextSequence else { return }   // ④ 너무 늦은 건 폐기
        packets[p.sequence] = p                            // ⑤ 중복도 자연히 제거
    }
}

직접 UDP 신뢰성 계층을 구현하기 전에, 이미 그걸 잘 해둔 QUIC/WebRTC를 먼저 검토 — 바퀴를 다시 만들지 말 것.

직접 만들기 전에 QUIC·WebRTC 먼저 검토
import Foundation
import Network

// "UDP 위에 신뢰성을 얹는다"는 이미 수십 년간 다듬어진 문제다.
// 혼잡 제어 하나만 해도 직접 만들면 거의 반드시 틀린다.

// ✅ ① 신뢰성 있는 스트림이 필요하면 → QUIC (HTTP/3의 전송 계층)
func quicStream(host: String) -> NWConnection {
    let options = NWProtocolQUIC.Options(alpn: ["h3"])
    return NWConnection(host: NWEndpoint.Host(host), port: 443,
                        using: NWParameters(quic: options))
}
//    얻는 것: 재전송·순서·혼잡 제어·TLS 1.3·커넥션 마이그레이션·스트림별 독립 HOL

// ✅ ② 실시간 음성/영상이면 → WebRTC
//    얻는 것: 지터 버퍼, 에코 제거, 대역폭 추정, NAT 통과(ICE/STUN/TURN),
//            코덱 협상, FEC·PLC
//    이걸 직접 만드는 건 사실상 불가능하다.

// ✅ ③ 로컬 네트워크 피어 발견·연결이면 → Network.framework의 Bonjour
let listener = try? NWListener(using: .udp)
func advertise(_ l: NWListener) {
    l.service = NWListener.Service(name: "MyApp", type: "_myapp._udp")
}

// ✅ ④ 그래도 직접 만들어야 한다면, 최소한 이것들은 반드시 있어야 한다
//    · 시퀀스 번호 + ACK
//    · RTO(재전송 타임아웃) 추정 — 고정값 금지
//    · 혼잡 제어 (최소한 AIMD)
//    · 흐름 제어 (수신 버퍼 한계)
//    · MTU 고려 (조각화 회피, 보통 1200바이트 이하 페이로드)

// 판단: 위 목록을 보고 "이걸 우리가 유지보수할 수 있나?"를 먼저 물어본다.
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

TCP는 등기우편이다. 봉투마다 번호를 매기고, 받는 사람 사인을 받고, 사인이 안 오면 같은 걸 다시 보낸다. 순서가 뒤바뀌어 도착해도 받는 쪽이 번호대로 다시 정렬해 준다. 확실한 대신 손이 많이 가고, 보내기 전에 "이제부터 편지 주고받을게요" 하고 약속부터 잡느라(handshake) 시간이 든다. UDP는 전단지를 그냥 뿌리는 것에 가깝다. 던지고 끝, 몇 장이 바람에 날아가도 신경 쓰지 않는다. 대신 가볍고 빠르다.

여기서 헷갈리기 쉬운 게 "확실한 게 무조건 좋은 거 아냐?"인데, 축구 중계를 떠올리면 답이 나온다. 3초 전 화면이 안 왔다고 그걸 다시 받아 지금 화면 앞에 끼워 넣으면 중계가 오히려 망가진다. 늦게라도 정확한 것보다 빠지더라도 지금 것이 나은 상황이 실제로 있다는 뜻이다. 그리고 UDP가 "영원히 못 믿을 것"도 아니다 — 필요하면 번호 매기기와 재전송을 앱이 직접 얹으면 되고, QUIC이 바로 그걸 아주 잘 해낸 물건이라 HTTP/3가 그 위에 올라탄다.

꼬리 질문

DNS는 왜 기본적으로 UDP를 쓰고, 어떤 경우 TCP로 폴백하나?

대부분의 DNS 질의·응답은 한 패킷에 들어갈 만큼 작아서, 연결 수립 왕복이 없는 UDP가 지연·서버 부하 면에서 훨씬 유리하다. 질의 하나에 응답 하나로 끝나므로 TCP의 순서·재전송 보장이 필요 없고, 재시도는 리졸버가 직접 한다. 응답이 512바이트(또는 EDNS0로 협상한 크기)를 넘어 잘리면(TC 비트가 켜지면) 같은 질의를 TCP로 재전송해 온전히 받는다. 존 전송(AXFR)이나 DNS over TLS처럼 큰 데이터·신뢰 채널이 필요한 경우도 TCP를 쓴다.

쉽게 말하면 "이 이름이 몇 번이야?" 한 마디 묻고 한 마디 듣고 끝나는 대화다. 이런 걸 위해 "지금부터 통화 시작할게요" 하고 약속부터 잡는 건 낭비라, 엽서 한 장(UDP)으로 충분하다. 그런데 답이 엽서 한 장에 안 들어가면 잘려서 오고, 그때 서버가 "잘렸음(TC)" 표시를 붙여 준다. 그러면 클라이언트가 제대로 된 통화(TCP)로 같은 질문을 다시 한다.
QUIC이 UDP를 고른 이유가 TCP의 어떤 한계 때문인가?

TCP는 커널에 깊이 박혀 있어 새 기능(스트림별 독립 전송, 통합 암호화, 커넥션 마이그레이션)을 넣으려면 OS와 중간 장비(방화벽·NAT)가 모두 바뀌어야 하는데, 이 고착화(ossification) 때문에 진화가 사실상 막혀 있었다. UDP는 커널이 거의 관여하지 않는 얇은 계층이라, 그 위에서 프로토콜 전체를 사용자 공간에서 자유롭게 구현·배포할 수 있다. 또 TCP는 커넥션 하나에 순서를 강제해 HOL을 못 피하는데, UDP 위에서는 스트림별 독립 순서를 새로 설계할 수 있다.

쉽게 말하면 TCP는 전국에 이미 깔려서 콘크리트처럼 굳어 버린 도로다(ossification). 폭을 넓히려면 온 세상의 운영체제와 통신사 장비를 다 갈아야 하니 사실상 불가능하다. UDP는 아무 규칙도 없는 빈 땅에 가깝다. 그래서 QUIC은 굳은 도로를 뜯는 대신, 빈 땅에 원하는 모양으로 새로 짓는 쪽을 택했다.
TCP의 흐름 제어와 혼잡 제어는 각각 무엇을 보호하려는 것이고, 둘은 어떻게 구분되나?

둘 다 송신 속도를 조절하지만 지키려는 대상이 다르다. 흐름 제어는 수신자가 감당 못 할 만큼 빨리 보내 수신 버퍼가 넘치는 것을 막는다 — 수신자가 매 ACK에 남은 버퍼 크기를 advertised window(rwnd)로 알려 주고 송신자가 그 한도를 넘지 않는, 순전히 종단 두 호스트 사이의 조율이다. 혼잡 제어는 그 사이 네트워크(라우터·링크)가 막히는 것을 막는다 — 수신자가 알려 주는 값이 아니라 송신자가 유실·RTT 증가 같은 신호로 혼잡을 스스로 추정해 cwnd(혼잡 윈도우)를 늘렸다 줄였다 한다(CUBIC·BBR 등). 실제 전송량은 두 한도의 최솟값 min(rwnd, cwnd)이라, 수신자에 여유가 넘쳐도 경로가 혼잡하면 혼잡 제어가 속도를 잡는다. iOS에서 URLSession 트래픽의 이 조절은 커널 TCP가 대신 하지만, 셀룰러처럼 대역이 출렁이는 환경에서 처리량이 주저앉는 원인은 대개 수신 버퍼가 아니라 혼잡 제어 쪽이라 "느리다"를 진단할 땐 둘을 갈라 봐야 한다.

쉽게 말하면 지키려는 대상이 다르다. 흐름 제어는 받는 사람 책상이 넘치지 않게 하는 것이라, 받는 쪽이 "지금은 세 개까지만"이라고 직접 말해 준다(rwnd). 혼잡 제어는 중간 도로가 막히지 않게 하는 것인데, 도로는 아무 말도 안 해 주니까 보내는 쪽이 "요즘 편지가 자꾸 유실되네, 길이 막혔나 보다" 하고 눈치로 속도를 줄인다(cwnd). 실제 속도는 둘 중 더 빡빡한 쪽에 맞춰진다.

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에 해당한다.

프로젝트 적용

느린 네트워크에서 "첫 요청만 유독 느린" 현상은 대개 handshake + TLS 콜드 비용이다 — 커넥션 재사용/사전 워밍으로 완화.

첫 요청만 느린 현상 — 콜드 비용 확인
import Foundation

// "첫 요청만 3초, 이후엔 200ms"는 거의 항상 DNS + TCP + TLS 콜드 비용이다.
// 서버가 느린 게 아니므로 백엔드를 붙잡고 있어도 답이 안 나온다.

final class ColdStartDiagnostics: NSObject, URLSessionTaskDelegate {
    func urlSession(_ s: URLSession, task: URLSessionTask,
                    didFinishCollecting m: URLSessionTaskMetrics) {
        guard let t = m.transactionMetrics.first else { return }

        func ms(_ a: Date?, _ b: Date?) -> Double {
            guard let a, let b else { return 0 }
            return b.timeIntervalSince(a) * 1000
        }

        let dns = ms(t.domainLookupStartDate, t.domainLookupEndDate)
        let tcp = ms(t.connectStartDate, t.connectEndDate)
        let tls = ms(t.secureConnectionStartDate, t.secureConnectionEndDate)
        let ttfb = ms(t.requestEndDate, t.responseStartDate)
        // connectEnd는 TLS 핸드셰이크 완료 시점이라 tcp 구간이 tls를 이미 포함한다 — 더하면 이중 계산이다
        let coldCost = dns + tcp          // = DNS + (TCP + TLS)

        print(String(format: "콜드 비용 %.0fms (DNS %.0f + TCP %.0f + TLS %.0f) · TTFB %.0fms",
                     coldCost, dns, tcp, tls, ttfb))

        if !t.isReusedConnection && coldCost > ttfb {
            print("→ 서버가 아니라 커넥션 수립이 병목이다")
        }
    }
}

// ✅ 완화 ① 세션을 살려 둔다 (커넥션 풀 재사용)
// ✅ 완화 ② TLS 1.3 확인 — 1-RTT, 재접속 시 0-RTT
// ✅ 완화 ③ 사전 워밍 (아래 항목)
// ✅ 완화 ④ 첫 화면에 필요한 요청 수 자체를 줄인다(배치 API)

매 요청마다 새 URLSession을 만드는 코드는 이 왕복을 매번 새로 치른다. 피할 것.

커넥션 사전 워밍
import Foundation

// 사용자가 버튼을 누르기 전에 핸드셰이크를 미리 쳐 두면
// 실제 요청의 체감 지연이 콜드 비용만큼 사라진다.

enum Warmer {
    /// 앱 시작·로그인 화면 진입처럼 '곧 API를 쓸 것이 확실한' 시점에 호출한다
    static func warmUp() {
        var request = URLRequest(url: URL(string: "https://api.example.com/health")!)
        request.httpMethod = "HEAD"                 // 본문 없이 핸드셰이크만
        request.cachePolicy = .reloadIgnoringLocalCacheData

        Net.api.dataTask(with: request) { _, _, _ in
            // 결과는 쓰지 않는다. 목적은 커넥션을 풀에 올려 두는 것.
        }.resume()
    }
}

// AppDelegate / SceneDelegate에서
func applicationDidFinishLaunching() {
    Warmer.warmUp()
}

// ✅ 여러 호스트를 쓴다면 각각 데워야 한다 (풀은 호스트별이다)
enum MultiWarmer {
    static let hosts = ["api.example.com", "cdn.example.com"]
    static func warmAll() {
        for host in hosts {
            var r = URLRequest(url: URL(string: "https://\(host)/health")!)
            r.httpMethod = "HEAD"
            Net.api.dataTask(with: r).resume()
        }
    }
}

// ⚠️ 남용하지 말 것: 쓰지도 않을 커넥션을 데우면 배터리·데이터만 쓴다.
//    "곧 확실히 쓴다"가 성립할 때만.
// ⚠️ 커넥션은 유휴 상태로 오래 두면 서버·중간 장비가 끊는다.
//    워밍은 '직전'에 해야 의미가 있다.

enum Net { static let api = URLSession(configuration: .default) }
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

무전기 교신을 떠올려 보자. A가 "여기 A, 들리나?"라고 부른다. B가 "들린다, 여기 B, 내 말도 들리나?"라고 답한다. A가 "들린다"라고 마무리한다. 왜 두 번으로는 안 될까? 두 번에서 끊으면 B는 자기 목소리가 A에게 닿는지 모르는 채로 이야기를 시작하게 된다. 세 번째 응답이 와야 비로소 양쪽 다 "내 말이 상대에게 간다"를 확인한 셈이 된다.

이 교신에는 덤이 하나 더 붙는다. 서로 "나는 이제부터 47번부터 번호를 붙여 말할게"라고 시작 번호(ISN)를 알려주는 일이다. 말이 뒤섞여 도착해도 번호를 보고 순서를 되맞추려는 것이고, 이 번호를 0이 아니라 아무 데서나 시작하는 건 옛날 교신이 끼어들어 헷갈리는 걸 막기 위해서다. 첫 요청만 유독 느린 이유가 여기 있다 — 이 왕복 한 번(그리고 HTTPS면 TLS 왕복까지)을 먼저 치러야 본론이 시작된다. 비유가 깨지는 곳: 무전기 교신은 그냥 "들리나" 확인일 뿐 아무것도 숨기지 않는다. 도청을 막는 건 이 위에 얹는 TLS의 일이지 handshake의 일이 아니다.

꼬리 질문

연결 종료는 왜 4-way(FIN/ACK 왕복)이고, TIME_WAIT 상태는 왜 필요한가?

TCP는 양방향(full-duplex)이라 두 방향을 각각 따로 닫는다. 한쪽이 FIN을 보내면 상대가 ACK로 받고, 상대도 보낼 게 끝나면 자기 FIN을 보내고 다시 ACK를 받는다 — 연결 수립 때 SYN-ACK로 합쳤던 것과 달리 각 방향의 종료 시점이 달라 4번이 된다. 능동적으로 닫은 쪽은 TIME_WAIT에 잠시(대개 2×MSL) 머무는데, 마지막 ACK가 유실돼 상대가 FIN을 재전송할 경우 다시 응답해 주기 위해서고, 옛 커넥션의 지연된 세그먼트가 같은 포트로 새로 연 커넥션에 섞여 들어오는 것을 막기 위해서다.

쉽게 말하면 전화를 끊는 장면이다. 내가 "나 할 말 다 했어"라고 해도 상대는 아직 할 말이 남아 있을 수 있으니, 각자 따로 "나도 끝"이라고 말해야 한다. 연결을 열 때는 "들었어 + 나도 시작할게"를 한 마디로 합칠 수 있었지만, 끊을 때는 끝나는 시점이 서로 달라 합칠 수가 없다. TIME_WAIT은 끊고도 수화기를 잠깐 들고 있는 것 — 내 마지막 "응"이 안 들렸으면 상대가 다시 부를 수 있고, 아까 통화에서 늦게 도착한 말이 새 통화에 섞이면 곤란하니까.
초기 시퀀스 번호(ISN)를 0이 아니라 무작위로 시작하는 이유는?

두 가지다. 첫째는 보안: ISN이 예측 가능하면 공격자가 유효한 시퀀스 번호를 추측해 커넥션에 위조 세그먼트를 끼워 넣거나(TCP injection) 세션을 가로챌 수 있어, RFC 9293은 시계 기반에 암호학적 요소를 섞어 예측을 어렵게 하라고 한다. 둘째는 옛 커넥션과의 혼동 방지: 같은 IP·포트 쌍으로 커넥션을 다시 열 때 ISN이 매번 0이면, 이전 커넥션의 지연된 세그먼트가 새 커넥션의 유효한 데이터로 오인될 수 있는데, 무작위 시작점이 이 겹침 확률을 크게 낮춘다.

쉽게 말하면 사물함 번호를 매번 1번부터 다시 붙이면, 옆 사람이 예전 번호를 기억했다가 남의 칸을 열 수 있다. 그래서 시작 번호를 아무 데서나 잡는 것 — 나쁜 사람이 번호를 못 찍게 하고, 지난번에 쓰던 번호와 겹쳐 헷갈리는 것도 막으려는 두 가지 목적이다.
SYN flood 공격은 이 handshake의 어떤 상태를 악용하며, SYN cookie는 어떻게 막나?

공격자는 SYN만 잔뜩 보내고 마지막 ACK를 보내지 않는다. 서버는 각 SYN마다 SYN_RCVD 상태의 반쯤 열린 커넥션을 백로그 큐에 잡아 두는데, 이 큐가 가득 차면 정상 사용자의 SYN을 받지 못해 서비스 거부가 된다. SYN cookie는 이 큐에 상태를 저장하지 않는 방어책으로, 서버가 4-tuple·비밀값·시각을 섞은 암호학적 해시와 MSS 같은 최소 정보를 인코딩한 값을 ISN 자체에 심어 SYN-ACK로 보낸다. 진짜 클라이언트가 ack=ISN+1로 응답하면 그 값에서 원래 정보를 복원해 커넥션을 세우므로, 응답이 올 때까지 아무 메모리도 잡지 않아 flood를 견딘다.

쉽게 말하면 식당에 가짜 예약 전화만 잔뜩 걸어 자리를 다 잡아 두면 진짜 손님이 못 앉는다 — 이게 SYN flood다. SYN cookie는 예약 장부에 아예 안 적고, 손님이 들고 온 번호표만 보고 진짜인지 그 자리에서 계산하는 방식이라, 장부가 넘쳐서 마비될 일이 없다.

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}

프로젝트 적용

"이 method가 멱등인가?"를 API 설계 시점에 못 박을 것. 특히 PATCH를 절대값 기반으로 설계하면 재시도가 안전해진다.

멱등성을 API 설계 시점에 못 박기
import Foundation

// 멱등성은 나중에 붙이는 게 아니라 스키마를 정할 때 결정된다.

// ❌ 상대적 증감 — 재시도할 때마다 효과가 누적된다
struct AddPointsRequest: Encodable {
    let delta: Int              // 100을 두 번 보내면 200이 더해진다
}

// ✅ 절대값 설정 — 몇 번을 보내도 최종 상태가 같다
struct SetPointsRequest: Encodable {
    let points: Int             // 500으로 설정. 재시도해도 500.
}

// ✅ 상대 연산이 꼭 필요하면 '버전'이나 '기대값'을 함께 보낸다 (낙관적 잠금)
struct AddPointsSafely: Encodable {
    let delta: Int
    let expectedCurrentVersion: Int   // 서버가 버전 불일치면 409로 거부
}

// ✅ 컬렉션 갱신도 마찬가지 — PUT(전체 교체)이 PATCH(부분 변경)보다 멱등하기 쉽다
struct ReplaceTagsRequest: Encodable {
    let tags: [String]          // 최종 상태를 통째로 보낸다
}

// 메서드별 기대 (GET·HEAD·PUT·DELETE·POST는 RFC 9110, PATCH는 RFC 5789)
//   GET·HEAD  safe + idempotent
//   PUT       idempotent  (전체 교체)
//   DELETE    idempotent  (없앤 상태로 수렴)
//   POST      비멱등      (멱등 키로 보완)
//   PATCH     설계에 따라 다름  ← 여기가 실무에서 갈리는 지점

func encodeExample() throws -> Data {
    try JSONEncoder().encode(SetPointsRequest(points: 500))
}

생성 요청(POST)이라도 Idempotency-Key 헤더를 받도록 서버를 설계하면 클라이언트가 안전하게 재시도할 수 있다(→ Q9).

생성 요청에 Idempotency-Key 지원
import Foundation

// POST가 본질적으로 비멱등이어도, 서버가 키로 중복을 걸러 주면
// 클라이언트는 안전하게 재시도할 수 있다.

struct CreateOrder: Encodable {
    let sku: String
    let quantity: Int
}

func createOrder(_ body: CreateOrder,
                 idempotencyKey: String) async throws -> Order {
    var request = URLRequest(url: URL(string: "https://api.example.com/orders")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
    request.httpBody = try JSONEncoder().encode(body)

    let (data, response) = try await Net.api.data(for: request)
    guard let http = response as? HTTPURLResponse else { throw NetError.invalidResponse }

    switch http.statusCode {
    case 200, 201:
        // ✅ 서버는 같은 키의 재요청에 '처음의 응답'을 그대로 재현해야 한다.
        //    200이든 201이든 클라는 성공으로 다룬다.
        return try JSONDecoder().decode(Order.self, from: data)
    case 409:
        // ⚠️ 서버가 이렇게 설계돼 있으면 클라가 '실패'로 오인한다 —
        //    서버 팀과 "재요청엔 원래 응답을 재현"으로 합의하는 편이 낫다
        throw NetError.duplicate
    case 422:
        // ✅ 같은 키인데 본문이 다르다 = 재시도가 아니라 버그/공격
        throw NetError.keyReuseWithDifferentBody
    default:
        throw NetError.http(http.statusCode)
    }
}

struct Order: Decodable {}
enum NetError: Error {
    case invalidResponse, duplicate, keyReuseWithDifferentBody, http(Int)
}
enum Net { static let api = URLSession(configuration: .default) }

DELETE는 "이미 없음"에 대해 404 대신 성공(200/204)으로 처리할지 팀 규칙을 정할 것 — 멱등 재시도 시 혼란을 줄인다.

DELETE의 '이미 없음'을 팀 규칙으로
import Foundation

// DELETE를 두 번 보내면 첫 번째는 204, 두 번째는 404가 흔하다.
// 최종 상태는 같으므로 멱등하지만, 클라가 404를 '실패'로 다루면
// 재시도 로직에서 사용자에게 잘못된 오류가 뜬다.

// ✅ 규칙을 코드 한 곳에 못 박는다
func delete(_ url: URL) async throws {
    var request = URLRequest(url: url)
    request.httpMethod = "DELETE"

    let (_, response) = try await Net.api.data(for: request)
    guard let http = response as? HTTPURLResponse else { throw NetError.invalidResponse }

    switch http.statusCode {
    case 200, 202, 204:
        return                       // 삭제됨
    case 404, 410:
        // ✅ "이미 없음"도 목표 상태에 도달한 것이므로 성공으로 취급한다
        return
    case 409:
        throw NetError.conflict      // 삭제할 수 없는 상태 (예: 진행 중 주문)
    default:
        throw NetError.http(http.statusCode)
    }
}

// ⚠️ 단, 화면에 따라 구분이 필요할 수도 있다.
//    "삭제했습니다" vs "이미 삭제된 항목입니다"를 다르게 보여줘야 한다면
//    성공/부재를 값으로 구분해 돌려준다.
enum DeleteOutcome { case deleted, alreadyAbsent }

func deleteDetailed(_ url: URL) async throws -> DeleteOutcome {
    var request = URLRequest(url: url)
    request.httpMethod = "DELETE"
    let (_, response) = try await Net.api.data(for: request)
    let code = (response as? HTTPURLResponse)?.statusCode ?? 0
    switch code {
    case 200, 202, 204: return .deleted
    case 404, 410:      return .alreadyAbsent
    default:            throw NetError.http(code)
    }
}

enum NetError: Error { case invalidResponse, conflict, http(Int) }
enum Net { static let api = URLSession(configuration: .default) }
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

엘리베이터 버튼을 열 번 눌러도 엘리베이터는 한 대만 온다. 이게 멱등이다. 반대로 자판기에 동전을 넣는 건 넣을 때마다 음료가 하나씩 더 나온다. 이게 비멱등이다. GET·PUT·DELETE는 엘리베이터 버튼 쪽이고, POST는 자판기 쪽이라고 보면 거의 맞는다.

PATCH가 "설계에 따라 다르다"는 말이 아리송한데, 방 조명 스위치로 생각하면 바로 풀린다. "밝기를 50으로 맞춰"는 몇 번을 말하든 결국 50이다. 그런데 "밝기를 10 올려"는 말할 때마다 더 밝아진다. 같은 PATCH라도 값을 정해 주느냐, 얼마를 더하라고 하느냐에 따라 성격이 갈리는 것이다.

  • 제일 자주 걸리는 함정: 멱등은 "화면에 뜨는 답이 늘 똑같다"가 아니라 "세상이 같은 모습으로 남는다"는 뜻이다.
  • 이미 켜진 불의 스위치를 또 올리면 "이미 켜져 있는데요"라는 반응이 올 수 있다 — DELETE를 두 번 하면 204 다음에 404가 나오는 게 딱 그 경우고, 그래도 "그 리소스는 없다"는 최종 모습이 같으니 여전히 멱등이다.
멱등 — PUT {"balance": 1000} 1번째 → 잔액 1000 2번째 → 잔액 1000 3번째 → 잔액 1000 최종 1000 몇 번을 보내든 비멱등 — PATCH {"balance_delta": 100} 1번째 → 1000 → 1100 2번째 → 1100 → 1200 3번째 → 1200 → 1300 최종 1300 보낼수록 늘어남
같은 요청을 3번 보냈을 때 서버에 남는 최종 상태. 같은 PATCH라도 "값을 정해 주면" 멱등, "얼마 더해줘"면 비멱등이 된다 — 재시도해도 되는지가 여기서 갈린다.

꼬리 질문

PUT과 POST로 리소스를 생성할 때 각각 URL과 멱등성이 어떻게 달라지나?

PUT은 클라이언트가 최종 URL을 정해 그 자리에 리소스를 통째로 놓는다 — PUT /users/42. 같은 요청을 여러 번 보내도 그 URL의 상태가 같은 표현으로 덮어써질 뿐이라 멱등이다. POST는 보통 컬렉션 URL에 보내고 서버가 새 ID를 발급한다 — POST /users → 서버가 /users/42를 만들어 Location으로 알려준다. 매 호출이 새 리소스를 만들 수 있어 비멱등이다. 그래서 "클라가 ID를 알고 자리 지정" 이면 PUT, "서버가 ID 발급" 이면 POST가 자연스럽다.

쉽게 말하면 PUT은 "42번 사물함에 이걸 넣어"라, 몇 번을 넣어도 그 칸 내용만 같아진다. POST는 "빈 칸 아무 데나 하나 잡아서 넣어 줘"라, 부를 때마다 새 칸이 하나씩 늘어난다. 그래서 자리를 내가 정하면 PUT, 서버가 정해 주면 POST다.
"safe하지만 멱등이 아닌" method가 존재할 수 있나? 왜?

정의상 존재할 수 없다. safe는 "서버에 관찰 가능한 상태 변화를 전혀 일으키지 않는다"는 뜻이고, 상태를 바꾸지 않으면 몇 번을 반복하든 최종 상태가 그대로이므로 자동으로 멱등이 된다. 즉 safe ⊆ 멱등이라 safe는 멱등의 부분집합이다. 역은 성립하지 않아서, DELETE처럼 상태를 바꿔 safe는 아니지만(두 번 지워도 "없음"으로 같아) 멱등인 method는 있다.

쉽게 말하면 아무것도 안 건드리고 구경만 하는 일은 백 번을 해도 세상이 그대로다. 그러니 "안 건드림"이면 "여러 번 해도 똑같음"이 저절로 따라온다. 앞이 참이면 뒤가 공짜로 딸려 오니까, "구경만 하는데 할 때마다 세상이 바뀌는" 조합은 아예 있을 수가 없다.
캐시(브라우저/CDN) 관점에서 GET과 POST를 다르게 취급하는 이유는?

GET은 safe·멱등이라 "같은 URL이면 같은 결과"라는 가정이 서고 부작용이 없어, 응답을 캐시해 재사용해도 안전하다. POST는 서버 상태를 바꾸는 실행 요청이라 임의로 캐시해 재사용하면 그 부작용을 왜곡하거나 놓치므로 기본적으로 캐시하지 않는다(명시적 Cache-Control과 조건이 갖춰진 예외적 경우만 허용). 그래서 CDN·브라우저는 GET을 캐시 키의 기본 대상으로 삼고, POST는 원 서버로 그대로 흘려보낸다. "조회는 GET으로" 라는 관행에는 이 캐시 이점도 깔려 있다.

쉽게 말하면 GET은 "지금 몇 시야?" 같은 질문이라, 옆 사람이 방금 들은 답을 그대로 베껴 써도 문제가 없다(캐시). POST는 "돈 좀 보내 줘" 같은 부탁이라, 남의 답을 베끼면 큰일 난다 — 그래서 POST는 캐시하지 않고 매번 진짜 서버까지 보낸다.

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

프로젝트 적용

error == nil을 성공으로 쓰지 말 것. 항상 HTTPURLResponse.statusCode를 별도로 검사한다.

error == nil을 성공으로 쓰지 않기
import Foundation

// 4xx·5xx는 '통신 성공 + 애플리케이션 실패'다. error는 nil이고
// 실패 정보는 statusCode에만 있다. 이걸 놓치는 게 가장 흔한 버그다.

// ❌ 콜백 API에서의 전형적인 실수
func fetchBad(_ url: URL, completion: @escaping (Data?) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard error == nil else { completion(nil); return }
        completion(data)          // 💥 500 응답의 에러 HTML을 성공으로 넘긴다
    }.resume()
}

// ✅ 두 자리를 모두 본다
func fetch(_ url: URL, completion: @escaping (Result<Data, NetError>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error { completion(.failure(.transport(error))); return }
        guard let http = response as? HTTPURLResponse, let data else {
            completion(.failure(.invalidResponse)); return
        }
        guard (200..<300).contains(http.statusCode) else {
            completion(.failure(.http(http.statusCode, data))); return
        }
        completion(.success(data))
    }.resume()
}

// ✅ async/await에서도 동일하다 — throw는 transport 오류만 발생시킨다
func load(_ url: URL) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(from: url)
    //                     ↑ 여기서 throw되는 건 transport 계층 오류뿐이다
    guard let http = response as? HTTPURLResponse else { throw NetError.invalidResponse }
    guard (200..<300).contains(http.statusCode) else {
        throw NetError.http(http.statusCode, data)   // ✅ 상태 코드를 직접 검사
    }
    return data
}

enum NetError: Error {
    case transport(Error), invalidResponse, http(Int, Data)
}

재시도 정책은 이 두 축(상태 코드 vs transport)으로 분기: 4xx는 재시도 금지, 5xx/429는 백오프 재시도, transport 오류는 멱등성에 따라(→ Q9).

재시도 정책을 두 축으로 분기
import Foundation

enum Failure {
    case transport(URLError)      // 응답 자체를 못 받음
    case http(Int)                // 응답은 받았고 상태 코드가 오류
}

// ✅ 재시도 여부는 '계층 × 멱등성'으로 결정된다
func shouldRetry(_ failure: Failure, isIdempotent: Bool) -> Bool {
    switch failure {
    case .http(let code):
        switch code {
        case 408, 429:            return true               // 타임아웃·레이트리밋
        case 500, 502, 503, 504:  return true               // 일시적 서버 오류
        case 501:                 return false              // 미구현 — 재시도 무의미
        case 400..<500:           return false              // ✅ 4xx는 재시도 금지
        default:                  return false
        }
    case .transport(let e):
        // ⚠️ 여기가 위험 구간 — 서버에 도달했는지 알 수 없다
        let retriableCodes: Set<URLError.Code> = [
            .timedOut, .networkConnectionLost, .cannotConnectToHost,
            .dnsLookupFailed, .notConnectedToInternet
        ]
        guard retriableCodes.contains(e.code) else { return false }
        return isIdempotent       // ✅ 멱등할 때만 재시도
    }
}

// 사용
func attempt(_ endpoint: Endpoint, failure: Failure, count: Int) -> Bool {
    count < 3 && shouldRetry(failure, isIdempotent: endpoint.isIdempotent)
}

struct Endpoint {
    let path: String
    let isIdempotent: Bool
}

사용자 메시지도 분리: transport 오류는 "네트워크를 확인하세요", 5xx는 "잠시 후 다시", 4xx는 "요청을 확인하세요"처럼 원인 계층에 맞춰야 한다.

사용자 메시지를 원인 계층에 맞추기
import Foundation

// 원인 계층이 다르면 사용자가 할 수 있는 행동도 다르다.
// "오류가 발생했습니다" 하나로 뭉치면 사용자가 아무것도 못 한다.

enum AppError: Error {
    case offline
    case timeout
    case server(Int)
    case client(Int)
    case unauthorized
    case rateLimited(retryAfter: TimeInterval?)
    case transport(URLError.Code)     // TLS 실패·인증서 미신뢰·호스트 못 찾음 등 (재시도해도 동일)
}

extension AppError {
    /// 무엇이 잘못됐는지 + 사용자가 무엇을 할 수 있는지
    var userMessage: (title: String, body: String, action: String?) {
        switch self {
        case .offline:
            return ("연결 없음", "네트워크 연결을 확인해 주세요.", "다시 시도")
        case .timeout:
            return ("응답 지연", "네트워크가 불안정합니다.", "다시 시도")
        case .server:
            return ("일시적 오류", "잠시 후 다시 시도해 주세요.", "다시 시도")
        case .unauthorized:
            return ("로그인 필요", "다시 로그인해 주세요.", "로그인")
        case .rateLimited(let after):
            let s = after.map { "\(Int($0))초 후" } ?? "잠시 후"
            return ("요청이 많습니다", "\(s) 다시 시도해 주세요.", nil)
        case .client:
            return ("요청 오류", "입력 내용을 확인해 주세요.", nil)
        case .transport:
            return ("연결할 수 없음", "보안 연결에 실패했습니다. 네트워크 환경을 확인해 주세요.", nil)
        }
    }

    /// 재시도 버튼을 보여줄지도 계층에서 결정된다
    var isRetriable: Bool {
        switch self {
        case .offline, .timeout, .server, .rateLimited: return true
        case .client, .unauthorized, .transport:        return false
        }
    }
}

// 매핑은 한곳에서
func map(status: Int?, error: URLError?) -> AppError {
    if let error {
        switch error.code {
        case .notConnectedToInternet: return .offline
        case .timedOut:               return .timeout
        default:                      return .transport(error.code)   // 서버 오류로 뭉뚱그리면 무한 재시도가 된다
        }
    }
    switch status ?? 0 {
    case 401:        return .unauthorized
    case 429:        return .rateLimited(retryAfter: nil)
    case 500..<600:  return .server(status ?? 500)
    default:         return .client(status ?? 400)
    }
}
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

동생에게 문방구 심부름을 보냈다고 하자. 동생이 돌아와서 "그거 다 팔렸대"라고 말했다면, 심부름 자체는 성공한 것이다. 갔고, 물어봤고, 답을 듣고 왔으니까. 원하던 물건이 없었을 뿐이다. 이게 4xx·5xx다 — 서버까지 갔고 서버가 대답을 줬는데, 그 대답의 내용이 "안 됨"인 것.

반대로 동생이 가게를 못 찾았거나, 아예 안 돌아왔다면? 그때는 대답이라는 게 존재하지 않는다. 이게 transport error(DNS 실패, 연결 거부, timeout)이고, 그래서 이 경우엔 상태 코드라는 게 아예 없다. iOS에서 가장 흔한 버그가 여기서 나온다. errornil이라는 건 "동생이 무사히 돌아왔다"는 뜻이지 "물건을 사왔다"는 뜻이 아닌데, 이걸 성공으로 처리해 버리면 500과 404를 전부 성공으로 세는 코드가 된다. 그래서 errorstatusCode 두 군데를 다 봐야 한다. 그리고 셋 중 제일 무서운 건 안 돌아온 경우다 — 돈을 이미 냈는지 안 냈는지를 알 방법이 없기 때문이다(→ Q9).

요청 전송 유효한 HTTP 응답을 받았나? 아니오 Transport 계층 실패 error 에 URLError 가 담긴다 timedOut · DNS 실패 · 연결 거부 statusCode 는 존재하지 않음 응답 도착 = 통신은 성공 error 는 nil ← 여기가 함정 500 이어도 error 는 nil 이다 성패는 statusCode 로만 판별 2xx 성공 · 4xx·5xx 는 실패
두 실패는 서로 다른 자리로 돌아온다. error 만 보면 500·404를 성공으로 세고, statusCode 만 보면 timeout 을 통째로 놓친다.

꼬리 질문

502(Bad Gateway)와 504(Gateway Timeout)는 어디서 발생하며 클라이언트 대응이 어떻게 다른가?

둘 다 앞단의 게이트웨이/리버스 프록시(로드밸런서·CDN 등)가 내는 코드다. 502는 프록시가 업스트림에 붙었는데 응답이 깨졌거나 유효하지 않을 때(업스트림 크래시·잘못된 응답), 504는 업스트림이 제한 시간 안에 응답을 못 줬을 때다. 클라이언트 입장에선 둘 다 일시적 서버 문제라 백오프 재시도 후보지만, 504는 업스트림이 실제로 처리를 완료했을 가능성이 502보다 커서(응답만 늦어 프록시가 끊었을 수 있음) 비멱등 요청이면 멱등 키 없이 재시도하기가 더 위험하다.

쉽게 말하면 둘 다 안내 데스크(게이트웨이) 뒤쪽에서 난 문제다. 502는 안쪽 담당자가 이상한 대답을 줬거나 쓰러진 것, 504는 담당자가 시간 안에 대답을 안 준 것이다. 특히 504는 담당자가 일은 다 해 놓고 대답만 늦은 걸 수도 있어서, 결제 같은 요청을 그냥 다시 보내면 두 번 될 위험이 더 크다.
timeout이 났을 때 서버가 요청을 이미 처리했을 수 있는데, 이걸 상태 코드만으로 알 수 있나?

알 수 없다. timeout은 응답 자체를 못 받은 transport 계층 사건이라 그 자리에 상태 코드가 아예 존재하지 않는다 — 서버가 요청을 처리하고 200을 만들었지만 그 응답이 돌아오는 길에 끊겼을 수도, 애초에 서버에 닿지 못했을 수도 있는데 클라이언트는 둘을 구분할 수 없다. 이 모호성을 없애는 유일한 방법은 상태 코드에 기대지 않고, Idempotency-Key로 서버가 중복을 흡수하게 하거나, 처리 결과를 조회하는 별도 확인 API로 실제 상태를 되묻는 것이다.

쉽게 말하면 편지를 부쳤는데 답장이 안 온다고 해서, 상대가 편지를 못 받은 건지 읽고도 답장이 오는 길에 사라진 건지는 알 방법이 없다. 상태 코드는 답장 안에 적혀 오는 건데 답장 자체가 없으니 물어볼 데가 없는 것. 그래서 "몇 번을 물어도 상관없게" 만들거나, "그거 처리됐나요?"라고 따로 물어보는 창구가 있어야 확실해진다.
401과 403을 클라이언트에서 다르게 처리해야 하는 이유는?

401은 "네가 누구인지 증명이 안 됐다"(인증 실패)라서, 만료된 토큰이 원인일 때가 많아 토큰을 갱신한 뒤 한 번 재시도하면 성공할 수 있다 — 재시도 여지가 있다. 403은 "네가 누구인지는 알지만 그 권한이 없다"(인가 실패)라서, 권한 자체가 바뀌기 전에는 몇 번을 다시 보내도 같은 실패다 — 재시도가 무의미하고 사용자에게 "권한 없음"을 알리거나 다른 흐름으로 유도해야 한다. 이 둘을 같은 "에러"로 뭉뚱그리면 403인데도 토큰 갱신 후 재시도하는 무한 루프 같은 버그가 생긴다.

쉽게 말하면 401은 "당신이 누구인지 모르겠어요"라, 학생증을 새로 보여 주면(토큰 갱신) 들어갈 수 있다. 403은 "누군지는 아는데 여긴 못 들어와요"라, 아무리 다시 두드려도 권한이 바뀌기 전엔 똑같이 막힌다. 이 둘을 한 덩어리로 묶으면, 절대 안 열릴 문을 토큰만 새로 받아 계속 두드리는 헛수고가 벌어진다.

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

🔑 30초 답변

TLS는 세 가지를 제공한다. 인증: 서버가 제시한 X.509 인증서 체인을 신뢰된 루트 CA까지 검증하고 호스트명이 맞는지 확인해 "지금 붙은 상대가 그 도메인의 주인"임을 보장. 기밀성: 핸드셰이크에서 (ECDHE(타원곡선 Diffie-Hellman, 매번 새 임시 키를 주고받는 키 교환) 같은 키 교환으로) 세션 대칭키를 합의해 이후 데이터를 암호화. 무결성: AEAD(Authenticated Encryption with Associated Data, 암호화와 변조 검사를 한 번에 하는 방식)로 변조를 감지. 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) 먼저 시스템 기본 검증(체인·호스트명·만료)을 '실제로 수행'한다.
    //    이 호출을 빼면 만료·호스트명 불일치 인증서도 통과시키는 delegate가 된다.
    var trustError: CFError?
    guard SecTrustEvaluateWithError(trust, &trustError) else {
        completionHandler(.cancelAuthenticationChallenge, nil); return
    }
    // 2) 그다음 서버 공개키 해시가 '핀 집합(현재 + 백업)'에 있는지 대조
    if isPinned(trust, against: pinnedKeyHashes) {
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

프로젝트 적용

피닝을 도입하면 반드시 백업 핀(차기 인증서/키)을 함께 배포할 것. 안 그러면 서버 인증서 로테이션 순간 전 사용자가 접속 불가가 된다.

백업 핀을 함께 배포하기
import Foundation
import CryptoKit

// 핀을 하나만 박으면 서버 인증서를 교체하는 순간 전 사용자가 접속 불가가 된다.
// 앱은 심사·배포에 며칠이 걸리므로 되돌릴 방법도 없다.

enum Pinning {
    /// 현재 인증서 + 다음 인증서(백업). 교체 전에 백업 핀을 먼저 배포해 둔다.
    static let publicKeyHashes: Set<String> = [
        "current-spki-sha256-base64=",
        "backup-spki-sha256-base64="      // ✅ 차기 인증서용
    ]

    /// SPKI(공개키) 해시를 계산한다 — 인증서 전체가 아니라 공개키를 핀한다.
    /// 인증서를 갱신해도 키를 유지하면 핀이 그대로 유효하다.
    static func spkiHash(of certificate: SecCertificate) -> String? {
        guard let key = SecCertificateCopyKey(certificate),
              let data = SecKeyCopyExternalRepresentation(key, nil) as Data?
        else { return nil }
        // 실제로는 ASN.1 SPKI 헤더를 앞에 붙여야 표준 핀과 일치한다
        return Data(SHA256.hash(data: data)).base64EncodedString()
    }

    static func isPinned(_ trust: SecTrust) -> Bool {
        guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
        else { return false }
        for cert in chain {
            if let hash = spkiHash(of: cert), publicKeyHashes.contains(hash) {
                return true
            }
        }
        return false
    }
}

// ⚠️ 핀 로테이션 절차를 문서로 못 박아 둔다
//   1) 차기 키를 만들고 그 핀을 앱에 '추가'해 배포 (현재 + 백업 둘 다 유효)
//   2) 사용자 대부분이 그 버전으로 올라올 때까지 대기 (수 주)
//   3) 서버 인증서를 차기 키로 교체
//   4) 다음 릴리스에서 옛 핀 제거 + 새 백업 핀 추가

피닝은 강제 업데이트 정책과 세트로 설계 — 핀이 바뀌어야 하는데 앱을 못 올리면 사용자가 막힌다.

피닝은 강제 업데이트 정책과 세트로
import Foundation

// 핀이 만료되는데 사용자가 앱을 업데이트하지 않으면 그 사용자는 영구 차단이다.
// 그래서 피닝을 켜는 순간 "앱을 강제로 올릴 수단"이 필수가 된다.

// ✅ ① 핀에 만료일을 넣고, 다가오면 사용자에게 알린다
struct Pin {
    let hash: String
    let expiresAt: Date
}

enum PinStore {
    static let pins: [Pin] = [
        Pin(hash: "current...", expiresAt: Date(timeIntervalSince1970: 1_800_000_000)),
        Pin(hash: "backup...",  expiresAt: Date(timeIntervalSince1970: 1_900_000_000))
    ]

    /// 모든 핀이 만료되기 전에 업데이트를 유도해야 한다
    static var latestExpiry: Date { pins.map(\.expiresAt).max() ?? .distantPast }

    static var daysUntilLockout: Int {
        Calendar.current.dateComponents([.day], from: Date(), to: latestExpiry).day ?? 0
    }
}

// ✅ ② 원격 설정으로 최소 지원 버전을 내려 강제 업데이트를 건다
struct RemoteConfig: Decodable {
    let minimumSupportedVersion: String
    let forceUpdateMessage: String
}

@MainActor
func checkForcedUpdate(_ config: RemoteConfig) -> Bool {
    let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
    return current.compare(config.minimumSupportedVersion,
                           options: .numeric) == .orderedAscending
}

// ✅ ③ 만료가 임박하면 미리 경고 (차단되기 전에)
func warnIfPinExpiringSoon() {
    if PinStore.daysUntilLockout < 30 {
        print("⚠️ 핀 만료 \(PinStore.daysUntilLockout)일 전 — 업데이트 유도 필요")
    }
}

// ⚠️ 강제 업데이트 화면 자체는 피닝된 API를 거치지 않아야 한다.
//    핀이 깨진 상태에서 "업데이트하세요"조차 못 받으면 아무것도 못 한다.

개발 중 프록시 디버깅을 위해 피닝을 끄는 빌드 플래그를 두되, 릴리즈에서 절대 새지 않게 격리할 것.

디버그 우회를 릴리스에서 격리
import Foundation

// 프록시(Charles·Proxyman)로 디버깅하려면 피닝을 꺼야 한다.
// 그 스위치가 릴리스에 새면 피닝을 켠 의미가 사라진다.

// ✅ 컴파일 단계에서 배제한다 — 런타임 플래그면 코드가 바이너리에 남는다
#if DEBUG && ALLOW_PROXY_DEBUG
let isPinningEnabled = false
#else
let isPinningEnabled = true
#endif

final class PinnedSessionDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition,
                                                  URLCredential?) -> Void) {
        guard isPinningEnabled else {
            completionHandler(.performDefaultHandling, nil)   // 디버그 빌드만 도달
            return
        }
        guard challenge.protectionSpace.authenticationMethod
                == NSURLAuthenticationMethodServerTrust,
              let trust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil)
            return
        }
        // ① 시스템 기본 검증(체인·호스트명·만료)을 먼저 통과시키고
        // ② 그다음 핀을 대조한다 — 순서를 바꾸면 안 된다
        if Pinning.isPinned(trust) {
            completionHandler(.useCredential, URLCredential(trust: trust))
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

/*  ALLOW_PROXY_DEBUG는 Debug 구성에만 정의한다
      Build Settings → Swift Compiler - Custom Flags
        → Active Compilation Conditions
          Debug:   DEBUG ALLOW_PROXY_DEBUG
          Release: (비움)

    ✅ 릴리스 바이너리에 문자열이 남지 않았는지 확인
       strings MyApp.app/MyApp | grep -i proxy                                 */

enum Pinning { static func isPinned(_ t: SecTrust) -> Bool { true } }
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

학교 정문 경비 아저씨가 하는 일이 딱 TLS다. 첫째, 학생증을 확인한다(인증). 그 학생증이 진짜인 줄 어떻게 알까? 학교 도장이 찍혀 있고, 그 학교는 교육청이 보증한다 — 이렇게 위로 거슬러 올라가는 게 인증서 체인이고, iPhone 안에는 "이 교육청 도장까진 믿어도 됨"이라는 목록이 미리 들어 있다(시스템 신뢰 저장소). 둘째, 안에서 나눌 이야기를 둘만 아는 방식으로 바꿔서 한다(기밀성). 셋째, 서류 봉투에 봉인 스티커를 붙여서 누가 몰래 뜯어 고쳤으면 바로 티가 나게 한다(무결성).

여기서 사람들이 제일 많이 착각하는 게 자물쇠 아이콘의 의미다. 자물쇠는 "이 학생증이 진짜다"까지만 말해 준다. 나쁜 마음을 먹은 사람도 자기 진짜 학생증은 갖고 있다 — 그래서 피싱 사이트도 버젓이 HTTPS로 뜬다. 피닝은 여기서 한 발 더 나가서 "나는 학생증 말고 이 사람 지문만 믿을래"라고 못을 박는 것이라 훨씬 강하지만, 위험도 그만큼 크다. 서버가 인증서를 갈아 끼우는 순간 지문이 달라져서, 백업 핀을 미리 넣어 두지 않았다면 모든 사용자가 한꺼번에 문 앞에서 막힌다.

iOS 시스템 신뢰 저장소 여기 있으면 통과 서버 인증서 api.example.com 누가 보증? 중간 CA 서버 인증서에 서명 누가 보증? 루트 CA 체인의 끝 앱에 박아 둔 핀 (공개키 해시) 체인 검증을 통과한 뒤 한 번 더 대조
기본 검증은 "누가 보증했나"를 루트까지 거슬러 올라가 기기 신뢰 저장소와 만나면 끝난다. 피닝은 그 위에 "그런데 이 키가 내가 아는 그 키인가?"를 하나 더 얹는 것이라, 키가 바뀌면 함께 끊긴다.

꼬리 질문

TLS 1.3의 0-RTT(early data)는 어떤 재생(replay) 공격 위험이 있고 왜 멱등 요청에만 권장되나?

0-RTT는 재접속 시 이전 세션에서 유도한 키(PSK)로 핸드셰이크 완료를 기다리지 않고 첫 왕복에 애플리케이션 데이터를 실어 보내 지연을 줄인다. 문제는 이 early data가 정상 핸드셰이크의 신선도(fresh) 보장을 못 받아서, 공격자가 그 암호문을 가로챈 뒤 그대로 다시 보내는 재생 공격이 가능하다는 점이다. 서버가 완벽한 재생 방지를 하기 어려우므로, 재생돼도 상태가 달라지지 않는 멱등·safe 요청(GET 등)에만 0-RTT를 쓰고, 결제 같은 비멱등 요청은 정규 1-RTT로 보내는 것이 RFC 8446의 권고다.

쉽게 말하면 0-RTT는 "악수부터 하고 얘기하자"를 건너뛰고 첫마디부터 바로 던지는 것이라 빠르다. 문제는 나쁜 사람이 그 첫마디를 몰래 녹음했다가 나중에 그대로 다시 틀 수 있다는 점이다. 그래서 "몇 번 틀어도 상관없는 말"(조회)만 이렇게 보내고, "돈 보내" 같은 말은 정식 악수를 마친 뒤에 보낸다.
인증서 피닝과 공개키 피닝의 차이는 무엇이고, 로테이션 관점에서 어느 쪽이 유리한가?

인증서 피닝은 서버 인증서 파일 전체(또는 그 해시)를 고정하고, 공개키 피닝은 인증서 안의 공개키(SPKI) 해시만 고정한다. 인증서는 유효기간이 있어 만료 시 새 인증서로 교체해야 하는데, 이때 같은 키페어로 재발급하면 공개키는 그대로다. 따라서 공개키 피닝은 인증서를 로테이션해도 핀이 유지돼 앱을 다시 배포하지 않아도 되지만, 인증서 피닝은 교체 때마다 핀이 깨져 앱 업데이트가 강제된다. 그래서 로테이션 관점에선 공개키 피닝이 유리하고, 어느 쪽이든 다음 키를 미리 넣어 두는 백업 핀이 필수다.

쉽게 말하면 인증서 피닝은 신분증을 통째로 외워 두는 것이라, 신분증을 새로 발급받으면(만료 교체) 못 알아본다. 공개키 피닝은 그 사람 지문만 외워 두는 것이라, 신분증만 새로 뽑고 지문(키)이 그대로면 계속 알아본다 — 그래서 교체에 덜 깨진다. 어느 쪽이든 다음에 쓸 지문을 미리 하나 더 외워 두는 게 안전장치다.
중간자(MITM) 프록시가 HTTPS 트래픽을 열어 보는 원리는 무엇이며, 피닝이 그걸 어떻게 막나?

Charles·Proxyman 같은 프록시는 클라이언트와 서버 사이에 끼어, 클라이언트에는 자신이 즉석에서 발급한 가짜 인증서를 내밀고 서버와는 따로 TLS를 맺어 트래픽을 복호화한다. 이게 통하는 전제는 그 프록시의 루트 CA가 기기의 신뢰 저장소에 설치돼 있어 가짜 인증서 체인 검증이 통과하는 것이다. 피닝은 체인 검증을 넘어 "이 서버의 공개키/인증서가 내가 박아 둔 핀과 일치하는가"를 추가로 요구하므로, 프록시의 가짜 키는 핀과 달라 검증에 걸려 연결이 끊긴다 — 그래서 피닝을 켜면 디버깅 프록시로 그 트래픽을 못 열게 된다.

쉽게 말하면 프록시는 배달부인 척 중간에 끼어들어, 나한테는 자기가 급조한 가짜 도장을 보여 주고 몰래 편지를 읽는다. 이게 통하는 건 내 폰이 그 배달부 도장을 "믿어도 됨" 목록에 넣어 뒀기 때문이다. 피닝은 "도장 말고 이 사람 지문까지 맞아야 함"이라 조건을 하나 더 걸어서, 지문이 안 맞는 가짜 배달부를 걸러낸다.

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의 차이를 가장 확실히 보여준다.

프로젝트 적용

사용자 대면 요청은 request와 resource를 둘 다 현실적으로(예: 15~30초 / 60~120초) 잡을 것. request만 잡으면 느리게 질질 오는 응답을 못 끊는다.

request와 resource를 둘 다 설정
import Foundation

// 두 값의 의미가 완전히 다르다. 하나만 잡으면 반쪽짜리 방어다.
//   timeoutIntervalForRequest  = "데이터가 멈춰 있는 시간"의 상한 (흐르면 리셋)
//   timeoutIntervalForResource = 요청 전체의 총 예산 (재시도 포함)

let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest  = 15      // 기본 60
config.timeoutIntervalForResource = 60      // 기본 604800(7일) ← 이걸 안 잡으면 사실상 무제한

// ❌ request만 15로 잡았을 때 생기는 일:
//    서버가 1초에 1바이트씩 흘려보내면 타이머가 계속 리셋되어 영원히 안 끊긴다.

// ✅ 요청 단위로 덮어쓸 수도 있다 (resource는 세션 설정만 가능)
func makeRequest(_ url: URL) -> URLRequest {
    var r = URLRequest(url: url)
    r.timeoutInterval = 10                  // 이 요청만 request timeout 10초
    return r
}

// ✅ async/await에서 '전체 시간'을 더 강하게 제어하고 싶다면 Task 레벨에서
func fetchWithDeadline(_ url: URL, within duration: Duration) async throws -> Data {
    try await withThrowingTaskGroup(of: Data.self) { group in
        group.addTask { try await URLSession.shared.data(from: url).0 }
        group.addTask {
            try await Task.sleep(for: duration)
            throw NetError.deadlineExceeded
        }
        let first = try await group.next()!
        group.cancelAll()                   // 나머지 취소
        return first
    }
}

enum NetError: Error { case deadlineExceeded }

백그라운드 대용량 다운로드는 resource를 넉넉히, 인터랙티브 API는 짧게 — 용도별로 URLSessionConfiguration을 분리.

용도별로 configuration 분리
import Foundation

// 인터랙티브 API와 백그라운드 대용량 다운로드는 요구가 정반대다.
// 하나의 세션으로 둘 다 하려 하면 어느 쪽도 맞지 않는다.

enum Sessions {

    /// 사용자가 화면에서 기다린다 — 짧게 끊고 빨리 실패시킨다
    static let interactive: URLSession = {
        let c = URLSessionConfiguration.default
        c.timeoutIntervalForRequest = 15
        c.timeoutIntervalForResource = 45
        c.waitsForConnectivity = false        // 오프라인이면 즉시 실패시켜 UI를 돌려준다
        c.requestCachePolicy = .useProtocolCachePolicy
        return URLSession(configuration: c)
    }()

    /// 사용자가 기다리지 않는다 — 오래 걸려도 되고, 연결을 기다려도 된다
    static let deferred: URLSession = {
        let c = URLSessionConfiguration.default
        c.timeoutIntervalForRequest = 60
        c.timeoutIntervalForResource = 60 * 30
        c.waitsForConnectivity = true         // 연결될 때까지 대기
        c.allowsExpensiveNetworkAccess = false // 셀룰러·핫스팟 회피
        return URLSession(configuration: c)
    }()

    /// 앱이 종료돼도 계속되는 대용량 다운로드
    static let background: URLSession = {
        let c = URLSessionConfiguration.background(withIdentifier: "com.app.bulk")
        c.timeoutIntervalForRequest = 90
        c.timeoutIntervalForResource = 60 * 60 * 24
        c.isDiscretionary = true              // 시스템이 좋은 시점을 고른다
        c.sessionSendsLaunchEvents = true
        return URLSession(configuration: c, delegate: BulkDelegate(), delegateQueue: nil)
    }()
}

final class BulkDelegate: NSObject, URLSessionDownloadDelegate {
    func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {}
}

timeout 값은 서버의 p99 지연 + 여유로 정하고, 클라이언트 timeout이 서버 처리시간보다 짧으면 "서버는 처리 성공, 클라는 timeout"이 생겨 재시도 중복 위험이 커진다는 걸 염두(→ Q9).

timeout 값은 서버 지연 분포에서 정하기
import Foundation

// timeout을 감으로 정하면 두 방향으로 다 틀린다.
//   너무 짧다 → 정상 요청이 끊긴다. 게다가 서버는 이미 처리했을 수 있어
//                비멱등 요청의 재시도 중복 위험이 커진다.
//   너무 길다 → 사용자가 하염없이 기다린다.

// ✅ 기준: 서버의 p99 지연 + 네트워크 여유
//    예) 결제 API의 p99가 3.2초라면 → 3.2 + 여유 ≈ 8~10초
struct TimeoutPolicy {
    let serverP99: TimeInterval
    let networkMargin: TimeInterval

    var requestTimeout: TimeInterval { serverP99 * 2 + networkMargin }
}

let payment = TimeoutPolicy(serverP99: 3.2, networkMargin: 3)   // → 9.4초

// ✅ 클라이언트에서 실제 분포를 수집해 검증한다
final class LatencyRecorder: NSObject, URLSessionTaskDelegate {
    private var samples: [TimeInterval] = []

    func urlSession(_ s: URLSession, task: URLSessionTask,
                    didFinishCollecting m: URLSessionTaskMetrics) {
        samples.append(m.taskInterval.duration)
    }

    func percentile(_ p: Double) -> TimeInterval {
        guard !samples.isEmpty else { return 0 }
        let sorted = samples.sorted()
        let index = Int(Double(sorted.count - 1) * p)
        return sorted[index]
    }

    func report() {
        print(String(format: "p50 %.2fs · p95 %.2fs · p99 %.2fs",
                     percentile(0.5), percentile(0.95), percentile(0.99)))
    }
}

// ⚠️ 클라 timeout < 서버 처리 시간이면 "서버는 성공, 클라는 timeout"이 상시로 생긴다.
//    이 조합에서 비멱등 요청을 재시도하면 이중 실행이 된다 → Q9의 멱등 키가 필수.
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

약속 장소에서 친구를 기다린다고 하자. 규칙을 두 가지로 정할 수 있다.

  • 규칙 A — "연락이 끊긴 지 30분 되면 간다." 친구가 5분마다 "거의 다 왔어!"라고 톡을 보내면 그때마다 내 시계는 0으로 되돌아간다. 그래서 친구가 계속 톡만 보내면 나는 세 시간도 기다리게 된다. 이게 timeoutIntervalForRequest다.
  • 규칙 B — "몇 시까지는 무조건 간다." 톡이 아무리 자주 와도 그 시각이 지나면 끝이다. 이게 timeoutIntervalForResource다.

"60초로 걸어 놨는데 왜 안 끊기지?"의 정체가 바로 규칙 A다. 서버가 1초에 한 바이트씩만 찔끔찔끔 흘려도 시계가 계속 리셋되니까 영원히 안 끊긴다. 그래서 두 규칙을 같이 걸어야 한다. 기본값이 각각 60초와 7일이라, resource 쪽을 손대지 않으면 사실상 "총 상한이 없다"에 가깝다는 걸 기억해 두자.

꼬리 질문

waitsForConnectivitytrue일 때 timeout은 어떻게 상호작용하나?

waitsForConnectivity = true면 연결이 아예 없을 때 즉시 실패하는 대신 연결이 생길 때까지 기다리는데, 이 대기 동안 정체 감지 타이머인 timeoutIntervalForRequest발동하지 않는다 — 아직 데이터를 주고받기 전이기 때문이다. 대기의 상한을 실제로 잡는 것은 리소스 전체 예산인 timeoutIntervalForResource다(기본 7일이라 사실상 무한 대기처럼 보일 수 있음). 따라서 이 옵션을 켤 때는 resource timeout을 현실적인 값으로 낮춰 무한 대기를 막아야 한다.

쉽게 말하면 이 옵션은 "인터넷이 아예 없어도 바로 포기하지 말고 생길 때까지 기다려"라는 뜻이다. 아직 데이터를 주고받기 전이라 "데이터 멈춤 감시 시계"(timeoutIntervalForRequest)는 안 돌아가고, 오직 "전체 마감 시각"(timeoutIntervalForResource)만 대기를 끝낼 수 있다. 그런데 그 기본값이 7일이라, 안 줄여 두면 사실상 무한정 기다린다.
connect timeout과 read timeout을 분리해서 잡고 싶다면 iOS에서 어떻게 접근하나?

URLSession의 고수준 설정에는 connect 전용 timeout 항목이 따로 없다 — timeoutIntervalForRequest는 사실상 정체(idle) 감지라 연결 수립과 데이터 대기를 뭉뚱그린다. 둘을 명확히 나누려면 Network.framework로 내려가, NWConnection의 상태 전이를 직접 관찰하며 .ready가 될 때까지의 시간(연결 수립 = connect)에 별도 타이머를 걸고, 데이터 수신 대기(read)에 또 다른 타이머를 거는 식으로 계층별로 제어한다. 또는 요청 자체에 짧은 request timeout을 걸고 재시도로 연결 지연을 흡수하는 실용적 절충도 가능하다.

쉽게 말하면 iOS 기본 설정에는 "연결되기까지"와 "연결 후 데이터 기다리기"를 따로 재는 손잡이가 없다 — 둘이 하나로 뭉쳐 있다. 정말 나눠서 재고 싶으면 한 단계 아래(Network.framework)로 내려가, 연결이 붙는 순간까지 스톱워치 하나, 데이터 기다리는 데 또 하나를 따로 눌러야 한다.
클라이언트 timeout이 서버 처리시간보다 짧을 때 생기는 문제와, 서버 측 완화책(요청 취소 전파 등)은?

클라이언트가 먼저 끊으면 서버는 그 사실을 모른 채 처리를 계속해, "서버는 성공, 클라는 timeout"이라는 어긋남이 생긴다. 클라가 재시도하면 같은 작업이 중복 실행되고, 취소된 요청까지 서버가 붙들고 있어 자원이 낭비된다. 완화책은 (1) 클라 timeout을 서버 p99 처리시간 + 여유보다 넉넉히 잡아 어긋남 자체를 줄이고, (2) 서버가 커넥션 끊김을 감지하면 진행 중인 작업을 취소 전파(cancellation propagation)로 중단하며(Swift의 Task 취소, gRPC의 deadline 전파처럼), (3) Idempotency-Key로 중복 재시도를 한 번으로 흡수하는 것이다.

쉽게 말하면 내 인내심 시계가 서버가 일하는 시간보다 짧으면, 서버는 열심히 하고 있는데 나 혼자 "안 되나 보다" 하고 끊어 버린다. 그러고 다시 보내면 같은 일이 두 번 된다. 그래서 내 시계를 서버가 보통 걸리는 시간보다 넉넉히 두고, 서버도 "손님이 끊었네" 싶으면 하던 일을 멈추게(취소 전파) 만든다.

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

프로젝트 적용

결제·주문처럼 부작용이 큰 POST는 반드시 서버가 Idempotency-Key를 지원하도록 설계하고, 클라는 재시도 루프 밖에서 키를 만들어 재사용한다.

멱등 키는 재시도 루프 바깥에서 한 번만
import Foundation

// 재시도들이 '같은 논리적 연산'임을 서버가 알아보려면 키가 동일해야 한다.
// 키를 루프 안에서 만들면 매 시도가 새 연산이 되어 멱등성이 무의미해진다.

struct IdempotentRequest {
    let url: URL
    let body: Data
    /// ✅ 요청을 '시작'할 때 한 번만 만든다. 모든 재시도가 이 값을 공유한다.
    let key: String = UUID().uuidString
}

func send(_ req: IdempotentRequest, maxAttempts: Int = 4) async throws -> Data {
    var lastError: Error?

    for attempt in 1...maxAttempts {
        var request = URLRequest(url: req.url)
        request.httpMethod = "POST"
        request.httpBody = req.body
        request.setValue(req.key, forHTTPHeaderField: "Idempotency-Key")  // ✅ 항상 같은 키

        do {
            let (data, response) = try await Net.api.data(for: request)
            let code = (response as? HTTPURLResponse)?.statusCode ?? 0
            if (200..<300).contains(code) { return data }
            guard code == 429 || (500..<600).contains(code) else {
                throw NetError.http(code)          // 4xx는 재시도하지 않는다
            }
            lastError = NetError.http(code)
        } catch let e as URLError where e.isRetriable {
            lastError = e
        }

        guard attempt < maxAttempts else { break }
        try await Task.sleep(for: backoff(attempt: attempt))
    }
    throw lastError ?? NetError.exhausted
}

// ✅ 오프라인 큐에 넣을 때도 키를 함께 저장한다 — 앱 재시작 후 재전송도 같은 신원
struct QueuedRequest: Codable {
    let key: String
    let url: URL
    let body: Data
    let createdAt: Date
}

extension URLError {
    var isRetriable: Bool {
        [.timedOut, .networkConnectionLost, .cannotConnectToHost].contains(code)
    }
}
enum NetError: Error { case http(Int), exhausted }
enum Net { static let api = URLSession(configuration: .default) }
func backoff(attempt: Int) -> Duration { .seconds(1) }

재시도는 지수 백오프 + 지터 + 최대 횟수 상한을 항상 함께. 지터 없이 동시 재시도하면 서버가 회복하려는 순간 다시 몰려(thundering herd) 장애가 길어진다.

지수 백오프 + 지터 + 상한
import Foundation

// 지터가 없으면 장애가 복구되는 순간 모든 클라이언트가 동시에 재시도해
// 서버를 다시 무너뜨린다(thundering herd).

struct RetryPolicy {
    var maxAttempts = 4
    var baseDelay: TimeInterval = 1
    var maxDelay: TimeInterval = 30
    var maxTotalDuration: TimeInterval = 60

    /// full jitter — 0 ~ 지수 상한 사이에서 균등 추출 (AWS 권장 방식)
    func delay(attempt: Int) -> Duration {
        let exponential = min(baseDelay * pow(2, Double(attempt - 1)), maxDelay)
        return .seconds(Double.random(in: 0...exponential))
    }
}

func withRetry<T>(_ policy: RetryPolicy = .init(),
                  operation: (Int) async throws -> T) async throws -> T {
    let deadline = Date().addingTimeInterval(policy.maxTotalDuration)
    var lastError: Error?

    for attempt in 1...policy.maxAttempts {
        do { return try await operation(attempt) }
        catch { lastError = error }

        guard attempt < policy.maxAttempts, Date() < deadline else { break }
        try await Task.sleep(for: policy.delay(attempt: attempt))
    }
    throw lastError!
}

// 지터 유무의 차이를 눈으로 보면 이해가 쉽다
func illustrate() {
    let p = RetryPolicy()
    print("지터 없음: 1s, 2s, 4s, 8s   ← 모든 클라이언트가 같은 시각에 몰린다")
    print("지터 있음: \((1...4).map { p.delay(attempt: $0) })  ← 흩어진다")
}

// ⚠️ 상한은 '횟수'와 '총 시간' 둘 다 둔다. 횟수만 두면 백오프가 길어질 때
//    사용자가 몇 분을 기다리게 된다.

429·503의 Retry-After 헤더가 있으면 그 값을 존중할 것 — 자체 백오프보다 서버 지시가 우선.

Retry-After 헤더를 존중하기
import Foundation

// 429·503에 서버가 Retry-After를 주면, 그건 "언제 다시 오라"는 명시적 지시다.
// 자체 백오프보다 우선한다 — 서버가 자기 상태를 가장 잘 안다.

func retryDelay(from response: HTTPURLResponse,
                fallback: Duration) -> Duration {
    guard let value = response.value(forHTTPHeaderField: "Retry-After") else {
        return fallback
    }

    // 형식 ①: 초 단위 정수  →  Retry-After: 120
    if let seconds = Double(value) {
        return .seconds(seconds)
    }

    // 형식 ②: HTTP-date  →  Retry-After: Wed, 21 Oct 2026 07:28:00 GMT
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.timeZone = TimeZone(identifier: "GMT")
    formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
    if let date = formatter.date(from: value) {
        let interval = max(0, date.timeIntervalSinceNow)
        return .seconds(interval)
    }

    return fallback
}

// 사용
func handle(_ response: HTTPURLResponse, attempt: Int) async throws {
    guard response.statusCode == 429 || response.statusCode == 503 else { return }
    let wait = retryDelay(from: response, fallback: .seconds(pow(2, Double(attempt))))

    // ⚠️ 서버가 아주 긴 값을 줄 수도 있다 — 상한을 걸어 UI가 멈추지 않게 한다
    let capped = min(wait, .seconds(60))
    try await Task.sleep(for: capped)
}

// ⚠️ Retry-After가 매우 길면 재시도 대신 사용자에게 알리는 편이 낫다
func decide(_ wait: Duration) -> String {
    wait > .seconds(30) ? "사용자에게 안내 후 종료" : "자동 재시도"
}

하지 말 것: 4xx 재시도, 지터 없는 즉시 재시도, 멱등키 없는 결제 POST 재시도.

하지 말 것 — 4xx 재시도 · 지터 없는 즉시 재시도 · 키 없는 결제 재시도
import Foundation

// ❌ ① 4xx 재시도 — 요청 자체가 잘못됐으므로 몇 번을 보내도 같은 결과다.
//        서버 부하만 늘고 사용자는 더 오래 기다린다.
func badRetryOn4xx(_ code: Int) -> Bool {
    (400..<600).contains(code)          // 💥 400·401·404까지 재시도한다
}
func goodRetryOn4xx(_ code: Int) -> Bool {
    code == 408 || code == 429 || (500..<600).contains(code)   // ✅
}

// ❌ ② 지터 없는 즉시 재시도 — 장애 복구 순간 모든 기기가 동시에 몰린다
func badBackoff(attempt: Int) -> Duration {
    .seconds(pow(2, Double(attempt)))   // 전 사용자가 정확히 같은 시각에 재시도
}
func goodBackoff(attempt: Int) -> Duration {
    let cap = min(pow(2, Double(attempt)), 30)
    return .seconds(Double.random(in: 0...cap))   // ✅ full jitter
}

// ❌ ③ 멱등 키 없는 결제 POST를 타임아웃만 보고 재시도 — 이중 결제 직행
func badPaymentRetry(_ body: Data) async throws {
    for _ in 0..<3 {
        do { _ = try await post("/payments", body: body); return }
        catch { continue }              // 💥 서버가 이미 처리했을 수 있다
    }
}

// ✅ 키가 없으면 재시도하지 말고, 대신 '조회'로 결과를 확인한다
func safePaymentFlow(_ body: Data, key: String?) async throws {
    guard let key else {
        // 멱등 키를 서버가 지원하지 않으면 재시도 자체가 위험하다
        // → 사용자에게 "처리 여부를 확인 중"으로 안내하고 조회 API로 확인
        _ = try await get("/payments/status")
        return
    }
    _ = try await post("/payments", body: body, idempotencyKey: key)   // ✅ 안전
}

func post(_ path: String, body: Data,
          idempotencyKey: String? = nil) async throws -> Data { Data() }
func get(_ path: String) async throws -> Data { Data() }
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

ATM에서 5만 원을 뽑는데 화면이 멈췄다. 돈은 안 나왔다. 자, 한 번 더 뽑을까? 무섭다. 계좌에서는 이미 빠져나갔는데 돈만 안 나온 걸 수도 있으니까. timeout이 정확히 이 상황이다 — 내가 답을 못 받은 것서버가 일을 안 한 것은 전혀 다른 얘기인데, 밖에서는 이 둘이 똑같이 보인다.

그래서 판단 기준은 "여러 번 해도 세상이 그대로인 일인가"가 된다. 잔액 조회나 삭제처럼 몇 번을 해도 결과가 같은 일은 마음 놓고 다시 하면 된다. 하지만 "돈 보내기"처럼 할 때마다 세상이 바뀌는 일은 다시 하면 진짜로 두 번 나간다. 이걸 푸는 방법이 주문번호를 붙이는 것이다. 요청마다 고유한 번호(Idempotency-Key)를 달아 두면, 서버가 "아, 이 번호는 아까 처리했어요" 하고 새 작업 대신 예전 결과만 돌려준다. 재시도할 때 이 번호를 새로 만들면 안 된다는 게 핵심이다 — 새 번호는 서버 눈에 완전히 다른 주문이라 그대로 두 번 처리된다. 마지막으로, 다시 시도하는 타이밍도 조심해야 한다. 다 같이 "1초 뒤, 2초 뒤, 4초 뒤"에 몰려가면 겨우 일어서던 서버가 다시 넘어지니까, 간격을 점점 늘리고 여기에 무작위를 살짝 섞어 시간을 흩뿌린다(지수 백오프 + 지터).

① 요청이 서버에 닿지 못한 경우 Client Server → 서버는 아무 일도 하지 않음 ② 서버가 처리까지 끝냈는데 응답만 유실된 경우 Client 요청 도달 Server → 결제까지 이미 완료됨 (!) 응답 클라이언트가 보는 건 ①·② 모두 URLError.timedOut — 둘을 구분할 방법이 없다 → Idempotency-Key 로 '같은 요청은 한 번만'을 서버가 보장하게 한다
timeout 은 "서버가 일을 안 했다"는 증거가 아니다. 클라이언트가 두 상황을 구분할 수 없으므로, 중복을 막는 책임은 재시도 로직이 아니라 멱등 키가 진다.

꼬리 질문

서버가 Idempotency-Key를 구현할 때, 같은 키로 다른 본문이 오면 어떻게 처리해야 하나?

이건 재시도가 아니라 키 오용/충돌이므로 원래 결과를 재생해선 안 된다. 흔한 규약은 첫 요청 때 본문의 해시를 키와 함께 저장해 두고, 같은 키로 다른 본문 해시가 오면 422(또는 409)로 거절하는 것이다. 같은 키 + 같은 본문이면 저장된 결과를 그대로 돌려주고, 아직 첫 요청이 처리 중이면 409로 "진행 중"을 알려 클라가 잠시 후 다시 조회하게 한다. 이렇게 해야 "같은 논리적 요청만 한 번으로 합친다"는 멱등 키의 계약이 지켜진다.

쉽게 말하면 같은 주문번호인데 내용이 다르면, 그건 "다시 보낸 것"이 아니라 "번호를 잘못 붙인 것"이다. 그러니 예전 결과를 그대로 주면 안 되고 "이 번호는 딴 주문이었는데요"라고 되돌려보내야 한다. 번호도 같고 내용도 똑같을 때만 예전 결과를 그대로 내준다.
지수 백오프에 지터를 넣는 이유(thundering herd)를 구체적으로 설명하면?

지터 없이 순수 지수 백오프만 쓰면, 같은 순간 실패한 다수 클라이언트가 모두 "1초 뒤, 2초 뒤, 4초 뒤"라는 동일한 시각에 재시도해 요청이 파도처럼 몰린다(thundering herd). 서버가 막 회복하려는 순간 이 동기화된 재시도가 다시 과부하를 만들어 장애가 길어지는 것이다. 지터는 각 대기 시간에 무작위 흔들림을 더해 재시도 시점을 시간축에 흩뿌려, 순간 부하를 평탄하게 만든다. 실무에선 0..backoff 범위에서 뽑는 full jitter가 서버 부하 최소화에 특히 효과적이라고 알려져 있다.

쉽게 말하면 다들 똑같이 "1초 뒤, 2초 뒤"에 맞춰 다시 몰려가면, 겨우 일어서던 서버가 그 순간 또 밟혀 넘어진다(thundering herd, 우르르 몰림). 지터는 각자 기다리는 시간에 무작위를 조금씩 섞어, 다시 두드리는 순간을 시간축에 흩뿌리는 것이다.
재시도와 서킷 브레이커는 어떻게 함께 동작해야 장애를 악화시키지 않나?

재시도는 개별 요청의 일시적 실패를 흡수하고, 서킷 브레이커는 실패가 임계치를 넘으면 회로를 열어(open) 한동안 요청을 아예 보내지 않고 즉시 실패시킨다 — 역할이 층이 다르다. 둘을 조합하는 핵심은, 서킷이 open이면 그 아래의 재시도 루프도 멈춰야 한다는 것이다. 서킷이 열렸는데 각 클라가 여전히 백오프 재시도를 돌리면 이미 쓰러진 서버에 부하를 계속 얹어 회복을 막는다. 그래서 재시도는 서킷이 closed/half-open일 때만 시도하고, half-open에서 소수의 탐색 요청이 성공하면 회로를 닫아 정상 트래픽으로 복귀한다.

쉽게 말하면 서킷 브레이커는 두꺼비집이다 — 문제가 심해지면 아예 차단해서 요청을 못 나가게 한다. 재시도는 "한 번 삐끗한 요청 다시 해 보기"라 층이 다르다. 두꺼비집이 내려가 있으면 재시도도 같이 멈춰야 한다. 안 그러면 이미 쓰러진 서버를 계속 두드려서 못 일어나게 만든다.

출처 · 참고자료