TCP는 어떻게 신뢰성을 지킬까: 핸드셰이크부터 혼잡 제어까지

TCP는 어떻게 신뢰성을 지킬까: 핸드셰이크부터 혼잡 제어까지

TCP를 한 단어로 요약하라면 대개 “신뢰성”이 나옵니다. 보낸 데이터가 순서대로, 빠짐없이 도착한다는 뜻이죠.

그런데 인터넷의 아래층인 IP는 아무것도 보장하지 않습니다. 패킷을 버려도 되고, 순서를 바꿔도 되고, 같은 것을 두 번 보내도 규칙 위반이 아닙니다. 그 위에서 TCP는 어떻게 “빠짐없이 순서대로”를 만들어낼까요? 🤔

TCP와 UDP의 차이에서 두 프로토콜의 성격을 견줘봤고, UDP는 따로 파고들었으니 이번에는 TCP 차례입니다. 신뢰성이라는 결과가 아니라 그것을 만드는 장치를 하나씩 뜯어보겠습니다.

스트림에는 경계가 없다

먼저 감을 잡고 가면 좋겠습니다. UDP에서 세 번 보내면 세 번 도착했는데, TCP는 어떨까요?

tcp-stream.ts
const server = Bun.listen({
  hostname: "127.0.0.1",
  port: 41235,
  socket: {
    data(socket, data) {
      console.log(`${data.length}바이트 "${data.toString()}"`);
    },
  },
});

const client = await Bun.connect({
  hostname: "127.0.0.1",
  port: 41235,
  socket: {
    open(socket) {
      socket.write("하나");
      socket.write("둘");
      socket.write("셋");
    },
    data() {},
  },
});
결과
12바이트 "하나둘셋"

세 번 썼는데 한 번에 12바이트로 붙어서 도착했습니다. UDP였다면 6바이트, 3바이트, 3바이트로 세 번 나뉘어 왔을 텐데요.

TCP가 보장하는 건 “보낸 바이트가 보낸 순서대로 도착한다”까지입니다. 어디서 끊어 읽을지는 알려주지 않습니다. 그래서 TCP 위에서 메시지를 주고받으려면 길이를 앞에 붙이거나 구분자를 정하는 규칙이 따로 필요하죠. HTTP가 헤더 끝을 빈 줄로 표시하고 Content-Length를 두는 것도 결국 이 때문입니다.

세 번의 왕복으로 연결을 맺는다

TCP가 신뢰성을 만드는 출발점은 순서 번호입니다. 그리고 연결을 맺는 절차는 이 번호를 서로 맞추는 과정이기도 합니다.

3-way handshake
클라이언트                        서버
    │  SYN  seq=x                  │
    │─────────────────────────────>│
    │        SYN-ACK  seq=y ack=x+1│
    │<─────────────────────────────│
    │  ACK  ack=y+1                │
    │─────────────────────────────>│

각자 자기가 쓸 시작 번호를 알리고, 상대의 번호를 받았다고 확인해 줍니다. 그래서 왕복이 한 번 반, 패킷으로는 세 개가 오갑니다. “연결을 맺는다”고 표현하지만 중간 경로에 무언가 만들어지는 건 아닙니다. 양쪽 끝에 상태가 생길 뿐이죠.

세 번이 꼭 필요한지 궁금해질 수 있는데요. 두 번으로 줄이면 서버가 보낸 시작 번호를 상대가 제대로 받았는지 확인할 길이 없습니다. 그래서 마지막 확인까지 받아야 비로소 양쪽이 같은 출발선에 섰다고 볼 수 있습니다.

이 절차에는 대가도 따릅니다. 서버는 첫 패킷을 받은 순간부터 연결이 완성되기 전까지 상태를 들고 기다려야 하거든요. 첫 패킷만 잔뜩 보내고 마지막 확인을 보내지 않으면 서버 자원이 묶이는데, SYN 플러딩이라 부르는 오래된 공격이 정확히 이 빈틈을 노립니다.

시작 번호가 0이 아니라 무작위라는 점도 재미있습니다. 0부터 시작하면 공격자가 번호를 예측해 남의 연결에 패킷을 끼워 넣을 수 있고, 앞선 연결에서 뒤늦게 도착한 패킷이 새 연결에 섞일 수도 있습니다. 그래서 연결마다 시작 번호를 예측하기 어렵게 정합니다.

순서 번호가 스트림을 떠받친다

연결이 열리면 보내는 쪽은 바이트마다 번호를 매겨 보내고, 받는 쪽은 “여기까지 잘 받았다”고 알려줍니다. 이 확인 응답이 누적 방식이라는 점이 핵심입니다. ack=1001은 “1000번까지 다 받았다”는 뜻이지, 1000번 하나만 받았다는 뜻이 아닙니다.

덕분에 확인 응답 하나만 도착해도 그 앞의 것이 전부 확인됩니다. 확인 응답을 몇 개 잃어도 복구가 되는 구조죠. 대신 중간에 구멍이 나면 답답해집니다. 1번부터 5번까지 보냈는데 3번만 잃으면, 4번과 5번이 멀쩡히 도착해도 받는 쪽은 “2번까지 받았다”는 말밖에 할 수 없습니다.

이 낭비를 줄이려고 선택적 확인 응답(SACK, Selective Acknowledgment)이 추가되었습니다. “2번까지 순서대로 받았고, 덧붙여 4번과 5번도 갖고 있다”고 알려주는 방식이라 보내는 쪽이 3번만 다시 보내면 됩니다.

그래도 구멍이 메워질 때까지 4번과 5번을 응용에 넘겨주지는 않습니다. 순서를 지킨다는 약속 때문이죠. 이 대기가 바로 QUIC가 스트림을 따로 관리해서 없애려 한 head-of-line blocking입니다.

받는 쪽이 감당할 만큼만

보내는 쪽이 빠르고 받는 쪽이 느리면 어떻게 될까요? 받는 쪽 버퍼가 넘치고 데이터는 버려집니다.

그래서 받는 쪽은 확인 응답을 보낼 때마다 “지금 내가 더 받을 수 있는 양”을 함께 알립니다. 이 값을 윈도라고 부르고, 보내는 쪽은 이 크기를 넘겨 보내지 않습니다. 받는 쪽이 처리에 밀려 버퍼가 꽉 차면 윈도를 0으로 알리고, 보내는 쪽은 전송을 멈춥니다.

여기서 교착이 생길 뻔합니다. 윈도가 0이라 멈췄는데, 여유가 생겼다는 소식을 담은 패킷마저 사라지면 영원히 기다리게 되니까요. 그래서 보내는 쪽은 주기적으로 아주 작은 패킷을 찔러 넣어 “이제 좀 받을 수 있나요?”라고 물어봅니다.

이건 어디까지나 받는 쪽 사정에 맞추는 장치입니다. 중간 경로가 막혔는지는 전혀 모르죠. 그 문제는 다른 장치가 맡습니다.

네트워크 사정은 짐작할 수밖에 없다

혼잡 제어가 어려운 이유는 단순합니다. 경로 중간이 얼마나 붐비는지 알려주는 사람이 아무도 없기 때문입니다. 라우터는 버퍼가 넘치면 패킷을 조용히 버릴 뿐, 누구에게도 알리지 않습니다.

그래서 TCP는 손실을 신호로 씁니다. 잃어버렸다는 건 어딘가 붐빈다는 뜻이라고 해석하는 것이죠. 그리고 얼마나 보낼지를 스스로 정하는데, 이 값을 혼잡 윈도라고 부릅니다. 흐름 제어의 윈도와 별개로 관리하고, 실제 전송량은 둘 중 작은 쪽을 따릅니다.

연결이 막 열렸을 때는 아무 정보가 없으니 조심스럽게 시작합니다. 요즘은 대략 열 개 남짓한 세그먼트로 출발하죠. 확인 응답이 돌아올 때마다 보내는 양을 두 배로 늘려갑니다. 이름은 슬로 스타트인데 실제로는 지수적으로 빠르게 커집니다. 시작점이 낮을 뿐이죠.

어느 지점에서 손실이 나면 그때부터는 조심스럽게 움직입니다. 왕복마다 조금씩만 늘리고, 손실이 나면 크게 줄입니다. 조금씩 늘리고 확 줄이는 이 방식을 AIMD라고 부르는데, 여러 연결이 같은 경로를 나눠 쓸 때 서로 비슷한 몫으로 수렴하게 만드는 성질이 있습니다.

손실을 알아채는 방법도 두 가지입니다. 같은 확인 응답이 연달아 세 번 오면 “그 뒤는 도착하는데 하나만 빈다”는 뜻이니 곧바로 다시 보냅니다. 반면 아무 소식 없이 시간만 흐르면 상황이 훨씬 나쁘다고 보고 전송량을 대폭 줄입니다.

지금은 손실만 보는 방식의 한계도 알려져 있습니다. 라우터 버퍼가 큰 요즘 환경에서는 버퍼가 가득 차 지연이 길어지는데도 손실은 안 나거든요. 그래서 손실 대신 대역폭과 지연을 직접 재서 조절하는 방식도 쓰입니다. 혼잡 제어 알고리즘을 갈아 끼울 수 있게 설계해 둔 덕분에 가능한 일입니다.

끊을 때는 네 번 인사한다

연결을 맺을 때 세 번이었으니 끊을 때도 그럴 것 같지만, 네 번입니다.

4-way handshake
    │  FIN                         │
    │─────────────────────────────>│
    │                          ACK │
    │<─────────────────────────────│
    │                          FIN │
    │<─────────────────────────────│
    │  ACK                         │
    │─────────────────────────────>│

TCP 연결은 방향마다 독립적이라 각자 따로 닫아야 하기 때문입니다. 한쪽이 “나는 더 보낼 게 없다”고 알려도 반대 방향은 계속 열려 있어서, 남은 응답을 마저 보낼 수 있습니다.

먼저 닫은 쪽에는 숙제가 하나 남습니다. 마지막 확인 응답을 보낸 뒤에도 한동안 자리를 지켜야 하죠. 이 상태를 TIME_WAIT라고 부릅니다.

이유는 두 가지입니다. 마지막 확인 응답이 사라지면 상대가 FIN을 다시 보낼 텐데, 그때 받아줄 누군가가 있어야 합니다. 또 하나는 뒤늦게 도착한 패킷이 같은 포트로 열린 새 연결에 섞이는 사고를 막기 위해서입니다.

기다리는 시간은 패킷이 네트워크에 살아 있을 수 있는 최대 시간의 두 배로 잡습니다. 이 값은 커널 설정으로 확인할 수 있습니다.

최대 생존 시간 확인
sysctl net.inet.tcp.msl
결과 (macOS)
net.inet.tcp.msl: 15000

15초로 잡혀 있으니 TIME_WAIT는 30초입니다. 서버를 껐다 켰을 때 “주소가 이미 사용 중”이라는 오류가 잠깐 나는 것도 이 상태 때문이죠.

연결을 맺고 끊는 데 이만한 비용이 드니, 같은 상대와 여러 번 주고받을 일이 있으면 연결을 재사용하는 편이 낫습니다. HTTP/1.1이 keep-alive를 기본으로 만든 것도, 데이터베이스 드라이버가 연결 풀을 두는 것도 결국 이 비용을 아끼려는 선택입니다.

Nagle 알고리즘과 지연 확인 응답이 만나면

TCP 헤더는 20바이트가 넘습니다. 1바이트를 보내려고 40바이트가 넘는 포장을 씌우는 셈이라, 작은 데이터를 자주 보내면 낭비가 큽니다.

Nagle 알고리즘은 이 낭비를 줄입니다. 아직 확인받지 못한 데이터가 있으면 새로 생긴 작은 조각을 바로 보내지 않고 모아뒀다가, 확인 응답이 오면 한꺼번에 내보냅니다. 앞선 예제에서 “하나둘셋”이 한 덩어리로 붙어 온 데에도 이런 성격이 작용합니다.

받는 쪽에는 지연 확인 응답이라는 장치가 있습니다. 확인 응답만 담은 빈 패킷을 아끼려고, 잠깐 기다렸다가 보낼 데이터에 얹어 보내는 방식이죠.

문제는 둘이 만날 때입니다. 보내는 쪽은 확인 응답을 기다리며 참고, 받는 쪽은 얹어 보낼 데이터를 기다리며 참습니다. 결국 지연 확인 응답의 타이머가 끝날 때까지 아무 일도 일어나지 않습니다. 흔히 40밀리초 안팎으로, 원인을 모르면 한참 헤매게 되는 지연입니다.

대화형 프로토콜이나 실시간 응답이 중요한 서비스에서 소켓 옵션으로 Nagle을 끄는 이유가 여기 있습니다. 다만 끄는 순간 작은 패킷이 그대로 쏟아지니, 응용에서 쓸 만큼 모아 보내는 편이 대체로 낫습니다.

지금 내 컴퓨터의 TCP 들여다보기

여기까지 읽은 내용은 지금 쓰는 컴퓨터에서 바로 확인할 수 있습니다.

연결 상태 세어보기
netstat -an -p tcp | awk 'NR>2 {print $NF}' | sort | uniq -c | sort -rn
결과
  81 ESTABLISHED
  16 LISTEN
   9 TIME_WAIT

ESTABLISHED는 지금 데이터를 주고받는 연결, LISTEN은 접속을 기다리는 소켓, TIME_WAIT는 방금 닫혔지만 아직 자리를 지키는 연결입니다. TIME_WAIT가 꾸준히 잡히는 건 정상입니다. 다만 수만 개까지 쌓인다면 짧은 연결을 지나치게 자주 맺고 끊는다는 신호라, 연결을 재사용하는 쪽을 고민해야 합니다.

버퍼 크기도 볼 수 있습니다.

송수신 버퍼 기본값
sysctl net.inet.tcp.sendspace net.inet.tcp.recvspace
결과
net.inet.tcp.sendspace: 131072
net.inet.tcp.recvspace: 131072

각각 128KB입니다. 이 값이 흐름 제어에서 알리는 윈도의 상한을 좌우하고, 지연이 큰 국제 구간에서는 처리량의 천장이 되기도 합니다.

마치며

TCP의 신뢰성은 장치 하나가 아니라 여러 장치가 겹쳐 만든 결과입니다. 순서 번호와 확인 응답이 빠짐과 뒤바뀜을 잡고, 흐름 제어가 받는 쪽을 배려하고, 혼잡 제어가 경로를 배려합니다. 연결을 맺고 끊는 절차는 이 모든 것의 시작과 끝을 맞추는 의식이고요.

동시에 이 설계가 어디서 발목을 잡는지도 드러납니다. 순서를 지키느라 뒤에 도착한 데이터를 붙잡아 두고, 커널 안에 있어서 고치기 어렵고, 연결을 주소로 식별해서 네트워크가 바뀌면 끊깁니다. QUIC가 UDP 위에 신뢰성을 새로 쌓은 것도 이 세 가지를 다르게 풀어보려는 시도였습니다.

명세를 직접 읽고 싶다면 RFC 9293을 보시면 됩니다. 1981년의 RFC 793을 40년 만에 다시 정리한 통합본이라, 그동안 흩어져 있던 개선안까지 한자리에서 볼 수 있습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord