Chapter 03트랜스포트 계층
2장에서 우리는 애플리케이션이 어떤 메시지를 주고받는지 살펴봤다. 그런데 그 메시지들은 정말로 어떻게 상대방의 프로세스까지 가닿을까? 인터넷은 사실 매 순간 패킷을 잃어버리고, 순서를 뒤섞고, 가끔은 똑같은 패킷을 두 번 배달하기도 하는 꽤나 어수선한 우편 시스템이다. 그 어수선함 위에서 "내가 보낸 그대로, 상대 프로세스의 손에" 데이터를 쥐여주는 마법이 바로 트랜스포트 계층(transport layer)의 일이다.
이 장에서는 두 거인 TCP와 UDP를 만나고, 그 둘 사이에서 신뢰성·흐름제어·혼잡제어가 어떻게 직조되는지 풀어본다. 마지막에는 HTTP/3 시대의 새 주역인 QUIC까지 가본다. 트랜스포트 계층은 어쩌면 네트워크에서 가장 알고리즘적인 계층이다 — 패킷을 안 잃어버리겠다는 의지, 너무 빨리 보내지 않겠다는 절제, 그리고 친구에게 양보하겠다는 미덕이 코드로 남아 있는 곳.
3.1 트랜스포트의 임무
네트워크 계층(IP)이 약속하는 건 단 하나다. "내가 호스트 A에서 호스트 B로 패킷을 옮겨보겠다." 그게 전부. 도착이 보장되지도 않고, 순서도 안 지킨다. 그런데 우리는 매일 메신저로 글자 하나도 안 빠진 채 친구와 대화한다. 그 사이를 메우는 게 트랜스포트 계층이다.
좀 더 정확히 말하면, 트랜스포트는 호스트 대 호스트 통신을 프로세스 대 프로세스 통신으로 끌어올린다. IP는 컴퓨터까지만 데려다주지만, 우리에게 필요한 건 그 컴퓨터 안에서 돌아가는 수많은 프로세스 중 "정확히 그 카카오톡 프로세스"에 메시지를 꽂아주는 일이다.
다중화와 역다중화
한 호스트에는 동시에 수십 개의 소켓이 열려 있을 수 있다. 브라우저 탭 5개, 메신저, 음악 스트리밍, 백그라운드에서 도는 깃허브 동기화까지. 송신 측에서 이 모든 소켓의 데이터를 모아 하나의 IP 흐름으로 내려보내는 일을 다중화(multiplexing), 수신 측에서 그걸 다시 정확한 소켓에 분배하는 일을 역다중화(demultiplexing)라고 부른다.
이 마법의 도구가 포트 번호다. 16비트 정수, 즉 0번부터 65535번까지 있는 우편함 번호. 트랜스포트 헤더에는 출발 포트와 도착 포트가 함께 박혀 있어서, 운영체제는 이 숫자만 보고 "아, 이건 8080번 포트에 묶여 있는 그 웹서버 프로세스에게 줘야겠군" 하고 분배한다.
- 잘 알려진 포트(0~1023): HTTP는 80, HTTPS는 443, SSH는 22, DNS는 53. 관례로 굳어진 번호들.
- 등록된 포트(1024~49151): 특정 애플리케이션이 IANA에 신청해 받아둔 번호.
- 동적 포트(49152~65535): 클라이언트가 연결을 시작할 때 OS가 즉석에서 골라 쓰는 임시 번호.
UDP의 역다중화는 단순하다. 도착 포트 번호 하나만 보고 소켓을 찾는다. 그래서 같은 UDP 포트에 여러 클라이언트가 보낸 패킷이 모두 한 소켓으로 들어온다. 반면 TCP는 4-튜플 — (출발 IP, 출발 포트, 도착 IP, 도착 포트) — 전체로 소켓을 식별한다. 같은 80번 포트로 들어와도 클라이언트 IP나 포트가 다르면 다른 소켓이다. 웹서버 하나가 동시에 수천 명을 상대할 수 있는 비밀이 여기에 있다.
"포트가 65535개나 되는데도 부족할 일이 있나요?" 하는 질문이 가끔 나온다. 서버 입장에선 보통 한 포트만 열어두면 되니 부족할 일이 없지만, 클라이언트 측에서 같은 서버에 폭발적으로 많은 연결을 만들 때는 동적 포트가 동나는 일이 실제로 생긴다 — 이걸 ephemeral port exhaustion이라고 부른다. NAT 뒤에 클라이언트가 잔뜩 모여 있으면 더 빨리 터진다.
3.2 UDP — 가벼움이 미덕
UDP(User Datagram Protocol)는 자기 일을 굉장히 솔직하게 한다. 받은 메시지를 IP에 그냥 던지고, 도착 못해도 신경 안 쓴다. "성격 쿨한 친구"에 가깝다. 헤더는 단 8바이트, 그것도 출발 포트, 도착 포트, 길이, 체크섬뿐이다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 출발 포트 (16) | 도착 포트 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 길이 (16) | 체크섬 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 페이로드 (가변) |
+---------------------------------------------------------------+
UDP 헤더
이 단순함이 단점만은 아니다. 오히려 미덕이 될 때가 많다.
- 핸드셰이크가 없다: TCP처럼 "안녕? 그래 안녕" 인사를 주고받지 않는다. 그래서 첫 패킷부터 바로 데이터다. 지연이 1RTT 줄어든다.
- 연결 상태가 없다: 서버가 클라이언트마다 상태를 들고 있을 필요가 없으니 메모리·CPU가 가볍다. 한 대로 더 많은 클라이언트를 감당할 수 있다.
- 혼잡제어가 없다: 보내고 싶은 만큼 보낸다. 실시간성이 생명인 응용에 유리. 다만 양심이 없는 만큼 네트워크가 막혀도 양보하지 않으니, 응용이 알아서 적당히 보내야 한다.
그래서 UDP는 어디에 쓰일까?
- DNS: 한 번의 짧은 질의·응답으로 끝나는 시나리오. 굳이 연결을 맺을 필요가 없다.
- 실시간 게임: 한 패킷 잃어버려도 다음 패킷이 곧 새 위치를 알려줌. 굳이 재전송 기다리느니 그냥 잊어버리는 편이 자연스럽다.
- VoIP, 라이브 스트리밍: 음성·영상은 늦게 오는 데이터보다 차라리 살짝 끊기고 다음 프레임이 빨리 오는 편이 낫다.
- QUIC: 의외지만, HTTP/3의 토대인 QUIC도 UDP 위에 얹혀 있다 (3.6절에서).
체크섬 — 작은 양심
UDP가 아예 책임감이 없는 건 아니다. 헤더에 16비트 체크섬이 들어 있어서, 비트가 한두 개 뒤집히는 단순한 손상은 잡아낸다. 송신측은 헤더와 페이로드를 16비트 단위로 더한 뒤 1의 보수를 취해 체크섬 칸에 박아 보낸다. 수신측이 같은 방식으로 다시 더하면 모든 비트가 1이 나와야 한다. 그렇지 않으면 — 그 패킷은 조용히 버려진다.
중요한 건 UDP의 체크섬은 탐지만 하지 복구는 안 한다는 점이다. 망가졌다는 걸 알게 됐으니 응용에게 알리지 않고 그냥 잊는다. 신뢰성을 원했다면 TCP를 골랐어야지, 라는 듯.
| 항목 | UDP | TCP |
|---|---|---|
| 연결 설정 | 없음 | 3-way 핸드셰이크 |
| 신뢰성 | 없음 (응용이 알아서) | 재전송으로 보장 |
| 순서 보장 | 없음 | 시퀀스 번호로 보장 |
| 흐름제어 | 없음 | 수신 윈도우(rwnd) |
| 혼잡제어 | 없음 | 혼잡 윈도우(cwnd) |
| 헤더 크기 | 8바이트 | 20바이트 이상 |
| 전송 단위 | 데이터그램(메시지 경계 보존) | 바이트 스트림 |
| 대표 사용처 | DNS, 게임, VoIP, QUIC | HTTP/1.1, HTTP/2, SSH, 메일 |
3.3 신뢰적 데이터 전송의 원리
TCP의 동작을 곧장 이해하려고 하면 부품이 너무 많아 머리가 어지럽다. 그래서 학계에서 오래 써온 방법이 있다 — 가상의 신뢰적 전송 프로토콜(reliable data transfer, 줄여서 rdt)을 점점 가혹한 환경 위에 올려가며 한 단계씩 만들어보는 것. 마지막에 도달하는 모양이 사실상 TCP의 뼈대다.
rdt 1.0 — 동화 같은 세상
출발은 동화. "하부 채널이 절대 데이터를 잃지 않고, 손상시키지도 않는다"고 가정하자. 그럼 송신측은 그냥 보내고, 수신측은 그냥 받으면 끝. 코드로 쓰면 보내고-받기 두 줄. 진짜 인터넷이 이렇다면 트랜스포트 계층이 따로 필요 없다.
rdt 2.0 — 비트가 가끔 뒤집힌다
이제 채널이 비트를 손상시킬 수 있다고 하자. 분실은 없지만, 도착한 패킷이 망가져 있을 수는 있다. 해결책은 인간이 통화하다 잘 안 들릴 때 쓰는 그 방법이다.
- 오류 검출: 패킷에 체크섬을 붙인다.
- 피드백: 수신측이 잘 받았으면 ACK(긍정 응답), 손상됐으면 NAK(부정 응답)를 돌려보낸다.
- 재전송: 송신측은 NAK가 오면 같은 패킷을 다시 보낸다.
이 ACK/NAK 기반 프로토콜을 ARQ(Automatic Repeat reQuest)라고 부른다. 그런데 여기엔 함정이 있다. ACK 자체도 손상되면? "응? 뭐라고 했는데?" 같은 곤란한 상황. 이걸 풀려고 패킷에 시퀀스 번호를 붙인다. 0과 1만 번갈아도 충분 — 송신측이 0번을 보냈는데 NAK가 와서 다시 0번을 보내면, 수신측은 "어, 같은 0번이네, 중복이구나" 알 수 있다.
rdt 3.0 — 분실까지
마지막 가혹함은 분실이다. 패킷이 통째로 사라질 수 있다면? 무한정 ACK만 기다릴 수는 없다. 송신측은 패킷을 보낼 때 타이머를 켜고, 일정 시간 안에 ACK가 안 오면 그냥 다시 보낸다. 타임아웃 기반 재전송이다.
"너무 일찍 타임아웃이 터져서 ACK가 늦게 도착하면 어떡하지?" 그래도 괜찮다. 시퀀스 번호 덕분에 수신측은 중복을 걸러낸다. 다만 그러면 네트워크에 같은 패킷이 두 번 떠 있게 되니, 타임아웃 값을 너무 짧게 잡으면 비효율이 커진다. 적절한 타임아웃을 고르는 일은 의외로 까다로운 문제이고, TCP는 RTT를 측정해서 동적으로 조정한다 (3.4절에서).
"한 번에 하나만 보내고 ACK 기다린다" — 이걸 스톱 앤 웨이트(stop-and-wait)라고 부른다. 안전하지만 끔찍하게 느리다. 광속과 거리만 봐도, 서울-LA를 한 번 왕복하는 데 100ms 넘게 걸린다. 1KB짜리 패킷 하나 보내고 100ms를 기다리면 처리량이 80kbps 수준 — 1Gbps 회선이 무색해진다.
슬라이딩 윈도우 — 줄지어 보내기
속도를 올리려면 ACK를 받기 전에 다음 패킷을 미리 보내야 한다. 단, 무한정 보내면 수신측이 감당 못 하니, "한 번에 떠도는 미확인 패킷의 최대 개수"를 정해둔다. 이걸 윈도우 크기라고 부르고, 보낼수록 윈도우가 옆으로 밀려가니 슬라이딩 윈도우(sliding window) 프로토콜이라 한다.
슬라이딩 윈도우를 어떻게 운용하느냐에 따라 두 가지 유명한 방식이 갈린다.
Go-Back-N (GBN)
"하나라도 잘못되면 그 뒤를 다 다시 보낸다" 학파. 수신측은 누적 ACK(cumulative ACK)만 보낸다 — "지금까지 N번까지 정확히 받았어"라는 식. 만약 5번이 분실됐다면, 수신측은 6, 7, 8을 받아도 "4번까지 받았다"는 ACK만 반복해서 보낸다. 송신측은 5번이 타임아웃되면 5, 6, 7, 8을 모두 재전송한다.
장점은 단순함. 수신측은 윈도우 안의 다음 기대 패킷만 받으면 되고, 그 외엔 다 버린다. 버퍼링 부담이 없다. 단점은 명확 — 한 번 분실 났을 때 멀쩡히 잘 도착한 패킷들까지 다시 보내니 낭비가 크다.
Selective Repeat (SR)
"잘못된 것만 골라서 다시 보낸다" 학파. 수신측이 윈도우 안의 패킷이라면 순서가 어긋나도 일단 버퍼에 받아두고, 개별적으로 ACK한다. 송신측은 ACK 안 온 그 패킷만 재전송한다. 효율은 훨씬 좋지만, 수신측 버퍼와 윈도우 관리가 한 단계 복잡해진다.
실전 TCP는 GBN과 SR 사이의 어딘가에 있다. 누적 ACK를 기본으로 쓰면서도, SACK(Selective Acknowledgement)이라는 옵션으로 "이 구간은 받았고 이 구간은 못 받았다"는 정보를 함께 실어 보낼 수 있다. 사실상 오늘날의 모든 TCP는 SACK을 기본으로 켜둔다.
3.4 TCP — 연결, 세그먼트, 흐름제어
TCP(Transmission Control Protocol)는 한 마디로 신뢰감 있는 택배기사다. 받는 사람이 정확히 같은 박스를, 정확한 순서로, 빠짐없이 받도록 끝까지 책임진다. 단, 그 책임감 때문에 동작이 무거워지고 첫 한 발 떼는 데도 시간이 걸린다.
3-way 핸드셰이크 — 본격 통화 전 인사
TCP는 데이터를 보내기 전에 두 호스트가 서로의 상태를 동기화하는 의식을 거친다. 이걸 3-way 핸드셰이크라고 부른다.
Client Server
| |
| -------- SYN, seq=x ----------------> | (1) 연결 요청
| |
| <----- SYN+ACK, seq=y, ack=x+1 ------ | (2) 수락 + 자기 시퀀스
| |
| -------- ACK, ack=y+1 --------------> | (3) 확인. 이제 연결됨
| |
| ======== 데이터 전송 시작 ======== |
| |
왜 굳이 세 번일까? 한 번 더 적게 — 두 번 — 으로 끝낼 수도 있을 것 같지만, 그러면 옛날 SYN이 네트워크를 헤매다 뒤늦게 도착했을 때 서버가 진짜 새 연결인 줄 착각하는 문제가 생긴다. 세 번째 ACK까지 받아야 양쪽이 "이 시퀀스 번호로 진짜 데이터를 시작하자"고 합의한 것이 된다.
연결을 끊을 때는 보통 4-way 핸드셰이크가 필요하다 — 양방향이 독립적으로 닫히기 때문에, FIN과 ACK가 두 번씩 오간다. 그러는 동안 TIME_WAIT 상태가 한참 남아 있는데, 이게 잦은 단명 연결에서 가끔 골치를 썩인다.
세그먼트 헤더 — 20바이트의 우주
TCP가 보내는 데이터 단위를 세그먼트라고 부른다. 헤더는 옵션이 없으면 20바이트.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 출발 포트 (16) | 도착 포트 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 시퀀스 번호 (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ACK 번호 (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 헤더 | 예약 |U|A|P|R|S|F| |
| 길이 | (6) |R|C|S|S|Y|I| 수신 윈도우 rwnd (16) |
| (4) | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 체크섬 (16) | 긴급 포인터 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 옵션 (가변, MSS / SACK / 타임스탬프 등) |
+---------------------------------------------------------------+
| 페이로드 |
+---------------------------------------------------------------+
TCP 세그먼트 헤더
핵심 필드를 짚어보자.
- 시퀀스 번호: 이 세그먼트의 첫 바이트가 전체 스트림에서 몇 번째 바이트인지를 가리킨다. TCP는 메시지 경계를 보존하지 않고 바이트 스트림으로 다루기 때문에, 단위가 바이트다.
- ACK 번호: "다음에 받기를 기대하는 바이트의 번호." 즉, "지금까지 ACK 번호 직전까지의 바이트는 다 잘 받았다"는 누적 의미를 갖는다.
- 플래그: SYN(연결 시작), FIN(연결 종료), ACK(확인), RST(강제 종료), PSH(즉시 전달), URG(긴급).
- 수신 윈도우 (rwnd): "내 수신 버퍼에 지금 이만큼은 더 들어올 수 있어"라는 신호. 흐름제어의 핵심.
- 옵션: MSS(Maximum Segment Size), SACK, 타임스탬프, 윈도우 스케일 등 — 옵션이지만 사실상 다 켜져 있다.
RTT 추정과 적응형 타임아웃
3.3절에서 잠깐 미뤄둔 질문이다 — 타임아웃은 얼마로 잡아야 할까? 너무 짧으면 멀쩡한 패킷도 재전송하게 되고, 너무 길면 진짜 분실됐을 때 한참을 기다린다. TCP는 정답을 갖고 시작하는 대신, RTT(Round-Trip Time)를 측정해 가며 적응한다.
대략적인 아이디어는 이렇다. 세그먼트 하나에 대한 ACK가 돌아오는 데 걸린 시간을 SampleRTT로 측정한다. 이걸 그대로 쓰면 변동이 너무 심하니 지수가중이동평균(EWMA)을 적용해 부드러운 EstimatedRTT를 만든다. 그리고 변동성도 따로 추정해서, 평균에 변동성의 몇 배만큼을 안전 마진으로 더한 값을 타임아웃으로 쓴다 — 정확한 가중치는 구현마다 약간씩 다르지만, 핵심은 "예상 RTT보다 충분히 여유 있게, 그러나 무지막지하게 길지 않게."
한 가지 미묘한 규칙이 있다. 재전송된 세그먼트는 RTT 측정에 쓰지 않는다(Karn의 알고리즘). 안 그러면 ACK가 원본에 대한 건지 재전송에 대한 건지 구분이 안 돼서 추정이 뒤틀린다.
흐름제어 — 수신자 컴퓨터 살리기
송신자가 천하무적이라도, 수신자가 받은 데이터를 처리하는 속도가 느리면 수신 버퍼는 금방 차버린다. 그래서 수신자는 자기 버퍼의 빈 공간 크기를 매 ACK마다 rwnd 필드에 실어 보낸다. 송신자는 그 값을 보고 자기가 보낸 미확인 데이터의 총량이 rwnd를 넘지 않게 조절한다. 이게 흐름제어(flow control)다.
흐름제어와 혼잡제어를 헷갈리는 사람이 정말 많은데, 이 둘은 시선이 다르다.
- 흐름제어: 수신자를 위한 배려. "내 버퍼 터질라."
- 혼잡제어: 네트워크를 위한 배려. "라우터들 막힐라."
실제 송신 가능량은 두 윈도우 중 더 작은 쪽이다 — min(rwnd, cwnd). 둘 중 하나라도 좁으면 그쪽이 병목이 된다.
// 의사코드: 송신자가 한 번에 띄울 수 있는 미확인 바이트
unacked_bytes = bytes_sent - bytes_acked;
window = min(rwnd, cwnd);
can_send_now = window - unacked_bytes;
if (can_send_now > 0) {
send(min(can_send_now, mss));
}
수신자가 rwnd = 0을 보내면 송신자는 멈춘다. 그런데 이후 수신자가 "이제 좀 비었어!"라며 보낸 윈도우 갱신 메시지가 분실되면? 양쪽이 영원히 서로 기다리는 데드락이 생길 수 있다. TCP는 이걸 막으려고 지속 타이머(persist timer)를 두고, 송신자가 주기적으로 1바이트 짜리 탐사 세그먼트를 찔러본다. 인생도 가끔은 먼저 안부를 물어야 풀린다.
3.5 TCP 혼잡제어
여기서부터가 TCP의 진짜 매력이자 골치다. 흐름제어는 두 호스트만 잘 협상하면 됐는데, 혼잡제어는 네트워크 한가운데의 라우터들 — 자기가 직접 볼 수도, 협상할 수도 없는 — 의 상태를 추측해야 한다.
혼잡이 일어나는 곳
패킷이 라우터에 도착했는데 출구 링크의 큐가 가득 차 있다면, 라우터는 그 패킷을 버린다. 송신자는 ACK가 안 오는 걸로 그 사실을 뒤늦게 안다. 즉, TCP가 분실로 인식하는 사건은 사실상 네트워크가 보내는 "지금 너무 많이 보내고 있어!" 신호다. (광 케이블이 정말 비트를 흘리는 일은 아주 드물다.)
그래서 TCP의 혼잡제어는 두 개의 가정 위에 서 있다.
- 패킷 분실 = 네트워크 혼잡의 신호.
- 라우터의 상태는 직접 못 보니, 분실/지연 같은 관찰 가능한 사건으로부터 역산한다.
Reno — TCP 혼잡제어의 고전
가장 오래도록 표준처럼 쓰여온 알고리즘이 TCP Reno다(그 직전 버전인 Tahoe도 자주 같이 언급된다). Reno에는 네 단계가 있다.
① Slow Start — 시작은 조심스럽게
이름은 "느린" 시작이지만, 사실은 지수적으로 빠르게 자란다. 연결 초기 cwnd는 아주 작은 값(보통 몇 MSS)에서 출발해서, ACK가 한 번 올 때마다 1 MSS씩 커진다. 한 RTT 안에 cwnd 만큼의 ACK가 들어오니 결과적으로 RTT마다 cwnd가 두 배가 된다. 네트워크가 어디까지 받아주는지 빠르게 탐색하는 단계다.
② AIMD — 평소 모드의 미덕
분실이 한 번 감지되면 (보통 중복 ACK 3개로) Reno는 혼잡 회피(congestion avoidance) 모드로 들어간다. 이 모드의 운영 원칙은 AIMD(Additive Increase, Multiplicative Decrease).
- Additive Increase: 분실 없이 잘 가는 동안엔 RTT마다
cwnd를 1 MSS씩만 천천히 늘린다. 욕심내지 않고. - Multiplicative Decrease: 분실이 감지되면
cwnd를 절반으로 깎는다. 한 번에 확.
왜 더하기와 곱하기를 비대칭으로 섞을까? 수학적으로 증명된 결과인데, AIMD가 여러 흐름이 같은 병목을 나눠 쓸 때 공정성(fairness)으로 수렴하게 만든다. 모두가 욕심을 줄여가다 보면 결국 비슷한 몫을 갖게 된다는 뜻.
cwnd
^
| /| /|
| slow / | / |
| start / | / |
| /\ / | / | AIMD
| / \ / | / | (톱니파)
| / \ / |/ |
| / \ / + +
| / \ /
| / \/ <-- 분실. cwnd /= 2
| /
|/
+----------------------------------------> 시간
(3중복 ACK 시 절반,
타임아웃 시 1로)
③ Fast Retransmit — 타임아웃 전에 눈치채기
같은 ACK 번호가 세 번 연달아 오면 (즉 3중복 ACK), Reno는 타임아웃을 기다리지 않고 즉시 빠진 세그먼트를 재전송한다. 수신자는 뒤따른 세그먼트를 받을 때마다 같은 누적 ACK를 다시 보내는데, 그게 세 번이면 "확실히 빠졌네"라고 판단할 수 있다.
④ Fast Recovery — 절반만 깎고 회복
타임아웃이 터졌다면 네트워크가 정말 막혔다는 강한 신호니 cwnd를 1로 떨어뜨리고 다시 slow start. 그러나 3중복 ACK였다면, 일부는 여전히 흐르고 있다는 뜻이니 절반만 깎고 곧장 혼잡 회피 모드로 복귀한다. 이게 Fast Recovery다. Tahoe에는 없고 Reno에서 새로 도입한 단계.
CUBIC — 빨라진 시대의 새 표준
현대의 광대역·장거리 네트워크에서는 Reno의 톱니가 너무 보수적이라는 게 알려졌다. 한 번 분실 후에 다시 큰 윈도우까지 회복하는 데 시간이 너무 오래 걸린다. 그래서 등장한 게 CUBIC이다(고려대 이인종 교수 연구실 작업이 토대가 됐고, 오늘날 리눅스 기본 알고리즘이다).
CUBIC의 핵심 아이디어는 단순하다 — cwnd를 시간에 대한 3차 함수(cubic function)로 키운다. 분실 직전 도달했던 윈도우 크기 근처에서는 천천히 머물고, 그 너머로 갈 때는 빠르게 탐색한다. 그래서 회복은 빠르되 안정 구간에서는 친구들과 공정하게 나눠 쓴다. RTT가 짧은 흐름이 RTT가 긴 흐름을 일방적으로 깔아뭉개는 Reno의 약점도 일부 완화된다.
BBR — 분실 말고 대역폭으로
Reno와 CUBIC은 모두 "분실이 곧 혼잡"이라는 가정 위에 있다. 그런데 라우터의 큐가 깊으면, 분실이 일어나기 한참 전부터 큐잉 지연이 늘어난다. 버퍼블로트(bufferbloat)라고 부르는 이 현상은 영상 통화 끊김의 주범이기도 하다.
BBR(Bottleneck Bandwidth and Round-trip propagation time)은 구글이 2016년 무렵 공개한 알고리즘으로, 발상이 다르다. "분실 신호 말고, 직접 병목 대역폭과 최소 RTT를 추정하자." BBR은 ACK가 돌아오는 속도에서 가용 대역폭을 추정하고, 변하지 않는 최소 RTT에서 전파 지연을 추정한 뒤, "이 둘의 곱(BDP)만큼만" 채워서 보낸다. 큐를 일부러 가득 채우지 않으니 지연이 작고, 분실에도 덜 민감하다.
BBR이 만능은 아니다. 손실 기반 알고리즘과 같은 병목을 공유할 때 공정성에 대한 논쟁이 꾸준히 있어 왔고, v2/v3로 갈수록 그 간격을 좁히려는 노력이 이어지고 있다. 다만 유튜브·구글 클라우드 같은 대형 서비스에서 실측 효과가 뚜렷해 빠르게 퍼졌다.
리눅스에서 현재 쓰이는 혼잡제어 알고리즘을 확인하는 명령은 sysctl net.ipv4.tcp_congestion_control이다. 대부분의 배포판은 cubic이 기본이고, BBR을 쓰려면 모듈을 로드하고 설정을 바꿔야 한다. 한 호스트에 여러 알고리즘이 동시에 살 수 있어서, 연결마다 다르게 줄 수도 있다.
3.6 QUIC — 트랜스포트의 미래
TCP는 1980년대에 자리잡은 뒤 거의 30년 동안 "트랜스포트"라는 단어와 동의어처럼 쓰였다. 그런데 모바일·HTTPS·HTTP/2의 시대가 오면서 한계가 드러났다. 그 한계를 우회하려고 등장한 새 트랜스포트가 QUIC이다 — 2장에서 잠깐 만난 HTTP/3의 토대.
왜 UDP 위에?
"새 트랜스포트라며 왜 UDP에 얹었나" 하는 의문이 자연스럽다. 답은 현실적 제약이다. 인터넷의 NAT, 방화벽, 로드밸런서 같은 중간장비들은 TCP와 UDP 외엔 거의 알지 못한다. 완전히 새 IP 프로토콜 번호를 만들면 전 세계 박스들에 안 통한다. UDP 위라면 어디든 통과한다 — 그래서 QUIC은 UDP 페이로드 안에 자기만의 헤더와 프레임을 직접 만들어 넣는다.
중요한 건 QUIC이 사용자 공간(user space)에서 구현된다는 점이다. TCP는 OS 커널 안에 깊이 박혀 있어서 새 알고리즘을 보급하려면 운영체제를 한참 기다려야 한다. QUIC은 애플리케이션 라이브러리로 배포 가능하니, 브라우저 한 번 업데이트로 전 세계 수억 사용자가 새 버전을 쓰게 된다. 진화 속도가 비교 안 된다.
0-RTT — 첫 패킷에 데이터까지
TCP+TLS의 첫 연결을 다시 떠올려 보자. TCP 3-way 핸드셰이크에 1 RTT, TLS 1.3 핸드셰이크에 1 RTT가 필요하다. 즉, 데이터를 보내기 전에 최소 2 RTT가 새어 나간다. 모바일에서 RTT가 100ms라면 0.2초가 그냥 사라진다.
QUIC은 트랜스포트와 암호화 핸드셰이크를 하나로 합쳤다. 처음 만나는 서버라면 1 RTT로 연결 + 암호화가 완료되고, 한번 만난 적 있는 서버라면 클라이언트가 캐시해둔 정보를 써서 0-RTT — 첫 UDP 패킷에 이미 암호화된 응용 데이터를 끼워서 — 보낼 수 있다. 사용자가 체감하는 페이지 로드 시간이 눈에 띄게 줄어든다.
0-RTT는 마법이 아니다. 첫 패킷의 데이터는 재전송 공격에 노출되기 쉬워서, 멱등(idempotent)한 GET 요청 정도에만 쓰는 게 안전하다. 결제 같은 비멱등 요청은 1-RTT 핸드셰이크를 마친 뒤 보낸다.
스트림 다중화 — HoL 블로킹의 종말
HTTP/2는 한 TCP 연결 위에 여러 요청을 동시에 다중화했다. 멋진 아이디어였지만, TCP 자체가 단일 바이트 스트림이라는 약점이 있다. 스트림 7번의 한 세그먼트가 분실되면, 그 뒤에 멀쩡히 도착한 스트림 13번의 데이터까지도 OS의 TCP 버퍼에서 대기열에 갇힌다. 응용은 7번이 회복될 때까지 13번 데이터를 못 읽는다 — head-of-line(HoL) 블로킹.
QUIC은 이걸 트랜스포트 차원에서 푼다. 한 QUIC 연결 안에 여러 스트림을 두고, 각 스트림이 독립적으로 신뢰성을 보장한다. 7번에서 분실이 있어도 13번은 도착하는 대로 응용에 전달된다. HTTP/3가 이 다중화를 그대로 받아 쓴다.
HTTP/2 over TCP HTTP/3 over QUIC
[s1][s2][s3][s4]... [s1] [s2] [s3] [s4]
\ | / \ | | /
\ | / \ | | /
단일 TCP 스트림 스트림별 독립 신뢰성
(분실 1개 = 모두 정지) (한 스트림 분실은 그 스트림만)
연결 마이그레이션
덤으로 QUIC은 IP 주소가 바뀌어도 연결을 유지할 수 있다. TCP는 4-튜플로 연결을 식별하니 와이파이에서 LTE로 옮기는 순간 연결이 끊긴다. QUIC은 4-튜플 대신 Connection ID로 식별해서, 클라이언트가 어떤 네트워크로 옮겨가든 같은 연결을 이어간다. 영상 통화 도중에 지하철 와이파이가 끊겨도 LTE로 부드럽게 넘어가는 미래.
# Wireshark에서 QUIC 보기
# UDP 443번 포트로 오가는 트래픽이 거의 다 QUIC.
# 앞부분은 평문이라 헤더의 Connection ID 정도는 보이지만,
# 페이로드는 처음부터 암호화되어 있어 들여다볼 수 없다.
$ tshark -i en0 -Y 'quic' -T fields -e quic.connection.id -e quic.frame_type
요약하면 QUIC은 TCP+TLS+HTTP/2의 지난 30년 경험을 한꺼번에 뒤집어 다시 짠 트랜스포트다. 빠른 핸드셰이크, 사용자 공간 진화, 진짜 다중화, 그리고 네트워크를 넘나드는 연결. HTTP/3로 이미 구글, 메타, 클라우드플레어 트래픽 상당 부분이 QUIC을 타고 있고, 앞으로 점점 더 그럴 것이다.
한 줄 요약
- 트랜스포트 계층은 호스트 사이의 IP 통신을 프로세스 사이의 통신으로 끌어올리며, 그 핵심 도구가 포트 번호와 다중화/역다중화다.
- UDP는 헤더 8바이트의 가벼운 데이터그램. 분실에 신경 안 쓰는 대신 지연이 작고 상태가 없다 — DNS, 게임, 스트리밍, QUIC의 토대.
- 신뢰성은 ACK·시퀀스 번호·타임아웃·재전송의 조합으로 만들어지며, GBN과 SR 같은 슬라이딩 윈도우 방식으로 처리량을 끌어올린다.
- TCP는 3-way 핸드셰이크로 연결을 맺고, 누적 ACK와 RTT 추정으로 신뢰성을,
rwnd로 흐름제어를 구현한다. - 혼잡제어의 고전은 Reno의 slow start + AIMD + fast retransmit/recovery, 현대 리눅스의 기본은 CUBIC, 구글이 밀어붙인 BBR은 대역폭·RTT 직접 추정으로 발상을 바꿨다.
- QUIC은 UDP 위에서 TLS·다중화·연결 마이그레이션까지 묶어 재설계한 미래의 트랜스포트. HTTP/3의 토대이며, HoL 블로킹과 핸드셰이크 비용을 동시에 해결한다.