네트워크 학습 노트 목차

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 세션 재개로 왕복 수를 줄인다.

실제로 눈으로 보기

핸드셰이크를 패킷으로

sudo tcpdump -i any -nn 'host example.com and port 443' &
curl -s https://example.com > /dev/null

# 10.0.2.15.52344 > 93.184.216.34.443: Flags [S],  seq 3812004221, win 64240
# 93.184.216.34.443 > 10.0.2.15.52344: Flags [S.], seq 1204882910, ack 3812004222
# 10.0.2.15.52344 > 93.184.216.34.443: Flags [.],  ack 1204882911
#                                            │
#  S = SYN · S. = SYN+ACK · . = ACK 만

ack상대 seq + 1 인 것에 주목한다. "여기까지 받았으니 다음은 이 번호부터 보내라" 는 뜻이고, SYN 자체가 1바이트를 차지하기 때문에 +1이다.

연결 상태를 센다

ss -tan state all | awk '{print $1}' | sort | uniq -c | sort -rn
#   4821 ESTAB
#   1203 TIME-WAIT
#     42 SYN-SENT
#      8 CLOSE-WAIT

각 숫자가 다른 문제를 가리킨다.

  • TIME-WAIT 이 많다 — 정상이다. 능동적으로 닫은 쪽에 2×MSL 동안 남으며, 지연 도착한 옛 패킷이 새 연결에 섞이는 것을 막는다
  • CLOSE-WAIT 이 쌓인다애플리케이션 버그다. 상대는 닫았는데 이쪽이 close()를 안 부르고 있다. 시간이 지나도 안 없어진다
  • SYN-SENT 가 많다 — 상대가 SYN 에 응답하지 않는다. 방화벽이 조용히 버리는 경우

CLOSE-WAIT 과 TIME-WAIT 을 혼동하면 엉뚱한 곳을 고친다. 전자는 코드 문제이고 후자는 대개 정상이다.

지금 이 연결의 혼잡 윈도우

ss -tin dst 93.184.216.34
# cubic wscale:7,7 rto:212 rtt:11.482/1.204 cwnd:24 ssthresh:18
#   │                      │                 │      └ 슬로스타트 임계값
#   │                      └ 왕복 시간 (ms)  └ 혼잡 윈도우 (MSS 단위)
#   └ 혼잡 제어 알고리즘

cwnd한 번에 보낼 수 있는 양이다. 손실이 나면 여기가 줄고(AIMD의 MD), 잘 가면 조금씩 는다(AI). 처음 연결한 직후 cwnd가 작아 첫 요청이 느린 것이 슬로 스타트의 실체다.

ss -ti | grep -o 'retrans:[0-9/]*'    # 재전송 횟수

재전송이 계속 오르면 경로에 손실이 있다는 뜻이고, 그러면 cwnd가 못 자라 대역폭이 남아도 느리다.

대역폭이 남는데 왜 느린가

최대 처리량 ≈ 윈도우 크기 / RTT

RTT 200ms 에 윈도우 64KB 면 초당 320KB 가 상한이다. 회선이 1Gbps 여도 그렇다. 이것이 대역폭 지연 곱(BDP) 이고, 먼 거리 전송이 느린 근본 이유다.

sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem   # 자동 조정 범위

해법은 윈도우를 키우는 것(윈도우 스케일링)이거나, 연결을 여러 개 쓰는 것이다.

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

IP 주소 계산 — 서브넷·브로드캐스트·VLSM·라우팅 테이블UDP — 가벼움·실시간·TCP 비교·QUIC