네트워크 학습 노트 목차

TCP — 믿을 수 없는 길 위에 신뢰를 얹다

앞 편에서 IP는 best-effort — 순서도 도착도 보장하지 않는다고 했다. 그런데 우리가 웹페이지를 받을 땐 한 바이트도 안 빠지고 순서대로 온다. 이 마법을 부리는 게 TCP다. TCP는 믿을 수 없는 IP 위에서 — 빠진 조각은 다시 받고, 뒤바뀐 순서는 바로잡고, 받는 쪽이 감당할 만큼만 보내고, 네트워크가 막히면 속도를 늦춘다. 신뢰성이라는 무거운 짐을 양 끝단이 진다는 end-to-end 원칙의 화신이다. 그 네 가지 일 — 연결 수립·신뢰 전달·흐름 제어·혼잡 제어 — 을 하나씩 본다.

먼저 악수부터 — 3-way 핸드셰이크

TCP는 데이터를 보내기 전에 서로 준비됐는지 확인하는 악수를 한다. 세 번 주고받는다.

다이어그램 로딩 중…

클라이언트가 SYN(연결하자, 내 시작 순서번호는 x)을 보내면, 서버가 SYN+ACK(좋다, 내 시작번호는 y, 그리고 네 x는 잘 받았다)로 답하고, 클라이언트가 ACK(네 y도 받았다)로 마무리한다. 왜 두 번이 아니라 세 번일까? TCP는 *양방향(full-duplex)*이라 — 양쪽 모두 상대의 시작 순서번호를 알리고 확인받아야 하기 때문이다. 그리고 시작 순서번호를 랜덤으로 고르는 데도 이유가 있다 — 예측 가능하면 공격자가 끼어들어 세션을 가로채거나, 이전 연결의 늦게 도착한 패킷과 헷갈릴 수 있어서다. (출처: TCP 3-way Handshake.) 한 가지 기억할 비용 — 이 악수는 *한 번의 왕복(RTT)*을 잡아먹는다. 그래서 매 요청마다 새로 연결하면 느려져, HTTP가 연결을 *재사용(keep-alive)*하는 이유가 된다(HTTP 편에서 다시).

한 조각도 안 빠지게 — 순서번호와 재전송

연결이 서면, TCP는 보내는 모든 바이트에 순서번호를 매긴다. 받는 쪽은 *"여기까지 잘 받았으니 다음은 N번부터 줘"*라고 **ACK(누적 확인)**로 알린다. 만약 보낸 쪽이 일정 시간 안에 ACK를 못 받으면 — 그 조각이 길에서 사라졌다고 보고 재전송한다. 순서가 뒤바뀌어 도착한 조각은 버퍼에 모아 두었다 제자리에 끼워 위층엔 항상 순서대로 올려 준다. 이 순서번호+ACK+재전송의 조합이 — best-effort IP 위에 *"빠짐없이, 순서대로"*를 세우는 토대다.

받는 쪽을 배려하기 — 흐름 제어(슬라이딩 윈도우)

빠르게 보낸다고 다 좋은 게 아니다. 받는 쪽의 버퍼가 가득 차면 그 뒤로 보낸 건 버려진다. 그래서 TCP는 흐름 제어를 한다 — 받는 쪽이 *매 ACK에 "내 버퍼에 아직 이만큼 여유 있어(수신 윈도우, rwnd)"*를 함께 광고하고, 보내는 쪽은 그 윈도우 크기만큼만 ACK를 기다리지 않고 미리 보낸다. 이 "ACK 없이 한 번에 날릴 수 있는 양"이 윈도우이고, 데이터가 처리되며 윈도우가 앞으로 미끄러진다고 해서 슬라이딩 윈도우라 부른다. (출처: TCP Flow Control.)

왜 윈도우인가 — 이 슬라이딩 윈도우는 생산자-소비자의 발상이다. 받는 쪽(소비자)이 감당할 수 있는 만큼만 보내는 쪽(생산자)이 흘려보내게 *역압(backpressure)*을 거는 것 — 빠른 생산자가 느린 소비자를 덮치지 않게 흐름을 거꾸로 제어하는 보편적 기법이다.

네트워크를 배려하기 — 혼잡 제어(AIMD)

흐름 제어가 받는 쪽을 배려하는 거라면, 혼잡 제어네트워크 전체를 배려한다. 받는 쪽은 멀쩡한데 중간 라우터가 막혀 패킷이 버려질 수 있기 때문이다. TCP는 얼마나 보내도 되는지를 스스로 더듬어 찾는다 — 이게 **혼잡 윈도우(cwnd)**다.

처음엔 조심스럽게 시작한다(slow start) — 작은 윈도우로 시작해 ACK가 올 때마다 두 배로(지수적으로) 키운다. 그러다 어떤 문턱(ssthresh)을 넘으면 조심 모드로 바꿔(congestion avoidance) — 이제는 한 RTT에 1씩만(가산적으로) 천천히 키운다. 그러다 패킷 손실이 감지되면 — "아, 너무 빨랐다" 하고 윈도우를 확 줄인다(절반 등). 이 "조금씩 늘리다 막히면 확 줄이는" 규칙이 **AIMD(가산 증가·곱셈 감소)**이고, 그래서 cwnd 그래프가 톱니(sawtooth) 모양을 그린다.

다이어그램 로딩 중…

늘릴 땐 천천히, 줄일 땐 확일까? 조심스러운 게 안전하기 때문이다 — 여러 TCP 흐름이 한 링크를 나눠 쓸 때, 모두가 이 규칙을 따르면 공평하게 나눠 갖고 네트워크가 붕괴하지 않는다. 손실을 타임아웃까지 기다리지 않고 빨리 감지하는 장치도 있다 — 같은 ACK가 3번 중복되면(특정 조각만 안 왔다는 신호) 즉시 재전송하는 fast retransmit과, 그때 slow start로 완전히 되돌아가지 않고 절반에서 회복하는 fast recovery(TCP Reno)다. (출처: TCP Congestion Control.) 핵심은 — 흐름 제어(rwnd)는 받는 쪽 사정, 혼잡 제어(cwnd)는 네트워크 사정이고, TCP는 둘 중 더 작은 쪽에 맞춰 보낸다는 것이다.

헤어질 때도 인사 — 4-way 종료와 TIME_WAIT

연결을 끊을 땐 네 번 주고받는다. 양방향이라 각 방향을 따로 닫기 때문이다 — 한쪽이 FIN(나는 보낼 거 다 보냈어)을 보내면 상대가 ACK하고, 상대도 준비되면 자기 FIN을 보내고 처음 쪽이 ACK한다. 그런데 마지막으로 ACK를 보낸 쪽은 바로 사라지지 않고 TIME_WAIT 상태로 잠시(2MSL) 머문다내 마지막 ACK가 유실돼 상대가 FIN을 재전송할 경우 받아 주고, 이 연결의 늦게 도착한 패킷이 같은 포트의 새 연결을 오염시키지 않게 하기 위해서다. (출처: TCP Termination & TIME_WAIT.)

실무에서는 — TIME_WAIT 소켓 고갈. 짧은 연결을 초당 수천 개씩 맺고 끊는 서버(예: 헬스체크·프록시)에서는, 연결을 먼저 끊는 쪽에 TIME_WAIT 소켓이 수만 개씩 쌓여 포트·메모리가 마를 수 있다. 그래서 ① 연결을 재사용(keep-alive·연결 풀)해 맺고 끊는 횟수 자체를 줄이고, ② 커널 튜닝(tcp_tw_reuse 등)으로 완화한다 — 핵심 교훈은 맺고 끊기는 비싸니 한 번 맺은 연결을 오래 쓰라는 것이다. 또 핸드셰이크가 RTT를 잡아먹으니, 지연이 중요한 서비스는 연결 재사용TLS 세션 재개로 왕복 수를 줄인다.

정리하면, TCP는 믿을 수 없는 IP 위에 신뢰를 얹는다 — 3-way 핸드셰이크로 양방향 순서번호를 맞춰 연결하고, 순서번호+ACK+재전송으로 빠짐없이·순서대로 전달하며, *슬라이딩 윈도우(흐름 제어)*로 받는 쪽을, *AIMD(혼잡 제어)*로 네트워크를 배려하고(둘 중 작은 쪽에 맞춤), 4-way 종료TIME_WAIT로 깔끔히 닫는다. 이 모든 상태와 절차가 신뢰성의 대가다 — 그래서 그런 보장이 필요 없는 경우엔 더 가벼운 UDP를 쓴다. 그게 다음 편이다.

IP 계층 — 라우팅·서브넷·NAT·best-effortUDP — 가벼움·실시간·TCP 비교·QUIC