---
title: "TCP 는 무엇을 보장하려고 무엇을 포기했나"
description: "사라진 것을 다시 보내고 뒤집힌 것을 바로 세워 주는 대신, TCP 는 앞의 하나가 늦으면 뒤엣것을 전부 세워 둡니다. 그 보장이 어떻게 만들어지고 무엇을 대가로 내놓는지 봅니다."
author: "유니얼"
pubDate: 2026-09-18
category: "programming/network"
tags: []
canonical: https://blog.unialgames.com/posts/net-tcp/
---
앞 편에서 다섯 개를 보내고 **1·2·5·3** 을 받았습니다. 4는 아예 오지 않았습니다.

받는 쪽이 이걸 그대로 쓸 수는 없습니다.
순서가 뒤집혀 있고 중간이 비어 있는데, 그 사실을 받는 쪽이 알아서 알아내야 합니다.

**TCP 는 이 일을 전부 대신 해 줍니다.**
보낸 순서 그대로, 빠짐없이, 중복 없이 올려 줍니다.
그래서 TCP 위에서는 네트워크가 아니라 파일을 읽는 것처럼 코드를 쓸 수 있습니다.

공짜일 리가 없습니다. 이 편은 **그 보장을 무엇으로 만들었고 무엇을 대가로 냈는지**를 봅니다.

## 연결이라는 말의 뜻

TCP 를 "연결형" 이라고 부릅니다.
선이 새로 깔린다는 뜻이 아닙니다. 선은 그대로입니다.

**양쪽이 서로에 대한 상태를 들고 있다**는 뜻입니다.

- 지금까지 몇 번까지 보냈는지
- 상대가 몇 번까지 받았다고 했는지
- 답이 안 온 것이 무엇이고 언제 보냈는지
- 지금 얼마나 보내도 되는지

이 상태가 있어야 "빠진 것" 이라는 말이 성립합니다.
아무 상태도 없으면 안 온 것과 원래 없던 것을 구별할 방법이 없습니다.

상태를 들고 있다는 말에는 두 가지가 따라옵니다.
**시작할 때 그 상태를 맞춰야 하고, 끝날 때 정리해야 합니다.**
그게 아래의 세 번과 네 번입니다.

## 시작할 때 왜 세 번인가

연결을 열 때 세 번 주고받습니다.
**여는 쪽이 클라이언트, 문을 열어 두고 기다리는 쪽이 서버**입니다.

<x-seq>
<script type="application/json">
{
  "label": "연결을 여는 세 번",
  "sides": [
    { "name": "클라이언트", "role": "연결을 여는 쪽" },
    { "name": "서버", "role": "기다리고 있던 쪽" }
  ],
  "dwell": 2.2,
  "steps": [
    {
      "from": 0, "to": 1, "msg": "SYN  seq=1000",
      "state": ["SYN_SENT", "LISTEN"],
      "text": "클라이언트가 자기 시작 번호를 알려 줍니다. \"나는 1000번부터 셉니다\". 서버는 그 전까지 문만 열어 두고 기다리던 상태(LISTEN)였습니다."
    },
    {
      "from": 1, "to": 0, "msg": "SYN + ACK  seq=5000, ack=1001",
      "state": ["ESTABLISHED", "SYN_RCVD"],
      "text": "서버가 그 번호를 받았다고 답하면서(ack=1001), 동시에 자기 시작 번호를 알려 줍니다. 두 가지 일이 한 패킷에 들어 있습니다. 클라이언트는 여기서 할 말을 다 들었으니 연결이 섰다고 봅니다."
    },
    {
      "from": 0, "to": 1, "msg": "ACK  ack=5001",
      "state": ["ESTABLISHED", "ESTABLISHED"],
      "text": "클라이언트가 서버의 시작 번호를 받았다고 답합니다. 이걸 받아야 서버도 연결이 섰다고 봅니다. 양쪽 상태가 여기서 처음 같아집니다."
    }
  ],
  "note": "ack 값이 상대 번호보다 1 큰 것은 \"다음에 올 것이 이 번호\" 라는 뜻이기 때문입니다."
}
</script>
</x-seq>

세 번인 이유는 **번호를 알려주는 쪽이 둘이기 때문**입니다.

한쪽이 번호를 알려주면, 상대가 그걸 받았는지 확인해야 합니다.
알려주기 한 번과 확인 한 번이 양쪽에 필요하니 네 번이어야 하는데,
가운데 둘이 한 패킷에 합쳐져서 세 번이 됩니다.

> 시작 번호를 0 부터 세지 않고 매번 다르게 고르는 이유도 여기 있습니다.
> 항상 0 부터 시작하면, 예전 연결에서 늦게 도착한 패킷이 새 연결의 번호와 겹쳐서
> 엉뚱한 데이터로 섞여 들어갈 수 있습니다.

두 번이면 안 되는 이유도 같은 자리에서 나옵니다.
클라이언트가 보낸 SYN 이 길에서 한참 헤매다 도착하는 경우가 있습니다.
서버가 그걸 새 요청으로 알고 바로 연결을 열어 버리면, **아무도 원하지 않는 연결이 서버에 하나 생깁니다.**
세 번째 확인이 있으면 클라이언트가 "나는 그런 것 보낸 적 없다" 는 사실이 드러나서 그 연결은 정리됩니다.

## 번호를 붙이고, 받았다고 답한다

연결이 열리면 보내는 것마다 번호가 붙습니다.
정확히는 **바이트 단위로 번호가 매겨집니다.** 몇 번째 패킷이 아니라 몇 번째 바이트인지가 기준입니다.

받는 쪽은 답할 때 **"여기까지는 빠짐없이 받았다"** 를 말합니다.
받은 것 하나하나를 지목하는 게 아니라, 끊기지 않고 이어진 끝을 말합니다. 이것을 누적 ACK 라고 합니다.

앞 편의 상황을 여기에 대 보면 이렇게 됩니다.

| 도착한 것 | 받는 쪽이 답하는 값 | 뜻 |
| --- | --- | --- |
| 1 | 2 | 1까지 받았으니 다음은 2 |
| 2 | 3 | 2까지 받았으니 다음은 3 |
| 5 | 3 | **여전히 3** |
| 3 | 4 | 3까지 받았으니 다음은 4 |

5가 도착했는데도 답하는 값이 3에서 안 움직입니다.
받은 것이 없어서가 아니라, **이어진 끝이 거기까지라서**입니다.
5는 버리지 않고 들고 있습니다. 다만 아직 이어붙일 수가 없습니다.

이 "안 움직이는 답" 이 뒤에 나오는 이야기 전부의 씨앗입니다.

## 사라진 것을 되찾는 두 가지 방법

보낸 쪽 입장에서, 답이 안 오면 두 가지 중 하나입니다.
가는 길에 사라졌거나, 오는 답이 사라졌거나.
어느 쪽이든 다시 보내야 합니다. 언제 다시 보낼지가 문제입니다.

**첫째, 시간을 재고 기다립니다.**
보낼 때 시계를 켜 두고, 정해진 시간 안에 답이 안 오면 다시 보냅니다.
그 시간을 RTO 라고 하고, 그동안 잰 왕복 시간을 바탕으로 잡습니다.
안전하게 잡아야 하니 실제 왕복보다 넉넉합니다. **느립니다.**

**둘째, 같은 답이 반복되는 것을 신호로 씁니다.**
위 표에서 5가 도착했을 때 받는 쪽은 3을 또 말했습니다.
뒤엣것이 계속 도착하면 같은 답이 계속 옵니다.
보낸 쪽은 이걸 보고 짐작할 수 있습니다. **뒤엣것은 가고 있는데 3만 빠졌구나.**

그래서 같은 답이 세 번 겹치면 시계가 끝나기를 안 기다리고 바로 다시 보냅니다.
이것을 빠른 재전송이라고 합니다.

왜 하필 세 번인가 하면, **한두 번은 순서가 뒤바뀐 것만으로도 생기기 때문**입니다.
앞 편에서 본 재정렬이 그것입니다. 그 정도로 재전송을 걸면 멀쩡한 것을 또 보내게 됩니다.

<x-calc>
<script type="application/json">
{
  "label": "하나가 빠졌을 때 뒤엣것이 더 기다리는 시간",
  "inputs": [
    { "id": "r", "label": "왕복 지연", "options": [20, 50, 100, 200], "default": 100, "suffix": "ms" },
    { "id": "m", "label": "되찾는 방법", "options": [["빠른 재전송", 0], ["시계가 끝나기를 기다림", 200]], "default": 0 }
  ],
  "bars": [
    { "label": "빠진 것이 다시 도착하기까지", "expr": "m + r", "unit": "ms", "accent": true },
    { "label": "60fps 프레임으로", "expr": "(m + r) / 16.6", "unit": "프레임" }
  ],
  "verdict": { "expr": "m > 0", "yes": "그동안 도착한 것도 전부 같이 묶여 있습니다", "no": "그래도 한 번 더 왕복해야 합니다" },
  "note": "시계(RTO)의 최소값을 200ms 로 잡았습니다. 구현마다 다릅니다."
}
</script>
</x-calc>

**가장 좋은 경우에도 왕복 한 번이 더 듭니다.** 이게 뒤에서 문제가 됩니다.

## 얼마나 보낼지를 정하는 손잡이가 둘이다

여기서 자주 섞이는 두 가지를 갈라 둡니다.
보낼 양을 줄이는 이유가 두 개고, **서로 다른 문제**입니다.

<x-compare>
<script type="application/json">
{
  "label": "무엇이 벅찬가",
  "tabs": [
    { "label": "흐름 제어", "text": "받는 쪽이 못 따라오는 문제입니다. 받는 쪽 버퍼가 차면 \"지금은 이만큼만 받을 수 있다\" 를 답에 실어 보냅니다. 보낸 쪽은 그 값을 넘겨서 보내지 않습니다. 길은 멀쩡한데 상대가 벅찬 경우입니다.", "stats": [["누가 정하나", "받는 쪽"], ["막힌 곳", "상대의 버퍼"], ["어떻게 아나", "답에 적혀서 옴"]], "tone": "good" },
    { "label": "혼잡 제어", "text": "중간 길이 막힌 문제입니다. 아무도 얼마나 보내도 되는지 말해 주지 않습니다. 그래서 조금씩 늘려 보다가 손실이 나면 줄입니다. 상대는 멀쩡한데 길이 벅찬 경우입니다.", "stats": [["누가 정하나", "보낸 쪽 혼자"], ["막힌 곳", "중간 장비의 큐"], ["어떻게 아나", "손실을 보고 짐작"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

TCP 는 이 둘 중 **작은 쪽**을 따릅니다. 상대가 벅차든 길이 벅차든, 벅찬 쪽에 맞춥니다.

혼잡 제어 쪽을 한 줄 더 봅니다.
**아무도 알려 주지 않으니 짐작해야 하고, 짐작의 근거는 손실입니다.**
보내는 양을 조금씩 늘리다가 손실이 나면 크게 줄이고 다시 늘립니다.

이 짐작에는 전제가 있습니다. **손실은 길이 막혀서 생긴다**는 것입니다.
유선에서는 대체로 맞습니다. 큐가 넘치는 것이 손실의 주된 이유니까요.

무선에서는 틀립니다.
신호가 깨져서 생긴 손실도 혼잡으로 읽고 보내는 양을 줄입니다.
**길은 비어 있는데 스스로 좁혀 놓는 상태**가 됩니다.

## 작은 것을 모으는 습관

TCP 에는 작은 패킷을 아껴 쓰려는 장치가 둘 있습니다. 게임에서는 둘 다 방해가 됩니다.

**Nagle 알고리즘**은 보내는 쪽에 있습니다.
보낼 것이 작고 아직 답을 못 받은 것이 있으면, 바로 안 보내고 조금 모읍니다.
1바이트를 보내려고 헤더 40바이트를 붙이는 낭비를 막으려는 것입니다.

**지연 ACK** 는 받는 쪽에 있습니다.
받자마자 답하지 않고 잠깐 기다립니다. 마침 보낼 데이터가 생기면 거기에 얹어서 한 번에 보내려고요.

둘이 만나면 서로를 기다립니다.
보내는 쪽은 답이 와야 보내고, 받는 쪽은 보낼 것이 생겨야 답합니다.
그 결과로 수십 밀리초가 그냥 사라집니다.

그래서 게임에서는 대개 Nagle 을 끕니다. 소켓 옵션 이름이 `TCP_NODELAY` 입니다.
**작은 것을 자주 보내는 것이 목적인 쪽에서는, 모아 주는 친절이 그냥 지연입니다.**

## 끝낼 때는 네 번

열 때 세 번이었으니 닫을 때도 셋일 것 같지만 넷입니다.
**한쪽 방향씩 따로 닫기 때문**입니다.

TCP 의 연결은 방향이 둘입니다. 내가 보낼 것이 없어진 것과 상대가 보낼 것이 없어진 것은 다른 사건입니다.
그래서 알리기와 답하기가 방향마다 한 번씩, 둘 곱하기 둘로 넷이 됩니다.

열 때는 가운데 둘을 한 패킷에 합쳐 셋으로 줄였습니다. 닫을 때는 못 합칩니다.
**"알았습니다" 와 "나도 끝입니다" 사이에 상대가 보낼 데이터가 낄 수 있기 때문**입니다.

<x-seq>
<script type="application/json">
{
  "label": "연결을 닫는 네 번",
  "sides": [
    { "name": "클라이언트", "role": "먼저 끊는 쪽" },
    { "name": "서버", "role": "나중에 끊는 쪽" }
  ],
  "dwell": 2.2,
  "steps": [
    {
      "from": 0, "to": 1, "msg": "FIN",
      "state": ["FIN_WAIT_1", "CLOSE_WAIT"],
      "text": "클라이언트가 \"나는 더 보낼 것이 없습니다\" 를 알립니다. 서버는 그 사실만 알았을 뿐, 자기가 보낼 것이 남았는지는 별개입니다."
    },
    {
      "from": 1, "to": 0, "msg": "ACK",
      "state": ["FIN_WAIT_2", "CLOSE_WAIT"],
      "text": "\"알았습니다\" 만 먼저 답합니다. 아직 닫는다는 말은 안 했습니다. 여기가 세 번으로 못 줄어드는 자리입니다."
    },
    {
      "self": 1, "msg": "남은 것을 마저 보냄",
      "text": "서버에서 클라이언트로 가는 방향은 아직 살아 있습니다. 보낼 것이 남았으면 이 동안 마저 보냅니다. 이 시간이 얼마나 길지 모르니, 위의 \"알았습니다\" 와 아래의 \"나도 끝\" 을 한 패킷에 합칠 수가 없습니다."
    },
    {
      "from": 1, "to": 0, "msg": "FIN",
      "state": ["TIME_WAIT", "LAST_ACK"],
      "text": "서버도 보낼 것이 없어졌습니다. 이제야 끊겠다고 알립니다."
    },
    {
      "from": 0, "to": 1, "msg": "ACK",
      "state": ["TIME_WAIT", "CLOSED"],
      "text": "클라이언트가 마지막 답을 보냅니다. 이걸 받은 서버는 여기서 끝납니다."
    },
    {
      "self": 0, "msg": "잠시 그대로 (TIME_WAIT)",
      "state": ["CLOSED", "CLOSED"],
      "text": "마지막 답을 보낸 클라이언트는 바로 안 끝냅니다. 그 답이 사라지면 상대가 FIN 을 또 보낼 텐데, 이미 자리를 비웠으면 받아 줄 쪽이 없습니다. 길에서 헤매던 옛 패킷이 같은 포트의 새 연결에 섞이는 것도 이 동안 막습니다."
    }
  ],
  "note": "여기서는 클라이언트가 먼저 끊는 것으로 그렸지만, 서버가 먼저 끊을 수도 있습니다. 순서는 같고 역할만 바뀝니다."
}
</script>
</x-seq>

닫은 뒤에도 마지막 답을 보낸 쪽은 잠시 상태를 들고 있습니다. TIME_WAIT 입니다.
서버에서 연결을 자주 열고 닫으면 이 상태가 쌓입니다. 포트가 모자라는 문제가 여기서 나옵니다.

## 그래서 남는 문제: 줄 서서 막힌다

지금까지가 TCP 가 해 주는 것들입니다. 이제 대가를 봅니다.

**받는 쪽은 순서대로만 올려 줍니다.**
5가 도착해도 3이 없으면 5를 못 올립니다. 들고만 있습니다.
3이 다시 와서 자리를 메워야 5도 같이 올라갑니다.

<x-net>
<script type="application/json">
{
  "label": "앞의 하나가 늦을 때",
  "interval": 0.7,
  "hold": true,
  "packets": [
    { "d": 1.2 },
    { "d": 1.2 },
    { "d": 3.8 },
    { "d": 1.2 },
    { "d": 1.2 }
  ],
  "note": "셋째만 늦습니다. 넷째와 다섯째는 진작 도착했는데 순서 때문에 쓰지 못하고 기다립니다. 실제 TCP 에서 저 늦은 셋째는 다시 보낸 것입니다."
}
</script>
</x-net>

앞 편의 그림과 같은 요소인데 **기다리게 하는 것 하나만 켰습니다.**
도착한 것이 넷인데 쓸 수 있는 것은 둘에서 멈춰 있습니다.

이것을 **줄 서서 막히는 것**(head-of-line blocking)이라고 합니다.
줄 맨 앞에 선 사람이 안 움직이면 뒤가 다 못 움직이는 상황입니다.

이게 왜 게임에서 특히 아픈가 하면, **기다린 끝에 받은 것이 이미 쓸모없는 경우가 많기 때문**입니다.

캐릭터 위치를 초당 20번 보낸다고 해 봅시다.
하나가 빠져서 왕복 한 번을 더 기다리는 동안, 뒤이어 두세 개가 더 도착합니다.
기다림이 끝나면 그 네 개가 한꺼번에 올라옵니다.

**그중 앞의 셋은 볼 필요가 없습니다.** 위치는 최신만 있으면 됩니다.
없어도 되는 것을 기다리느라 최신 것까지 늦게 본 셈입니다.

파일을 받을 때는 이 보장이 정확히 필요한 것입니다. 한 바이트라도 빠지면 파일이 깨지니까요.
**위치처럼 덮어쓰면 그만인 값에서는 그 보장이 그냥 비용입니다.**

## 정리

- 연결은 선이 아니라 **양쪽이 들고 있는 상태**입니다. 그래서 열 때와 닫을 때 절차가 있습니다
- 세 번 주고받는 이유는 **번호를 알려주는 쪽이 둘**이고, 서로 받았음을 확인해야 해서입니다
- 답은 **"여기까지 빠짐없이"** 를 말합니다. 그래서 중간이 비면 답하는 값이 안 움직입니다
- 되찾는 방법은 둘. 시계를 기다리는 쪽은 느리고, **같은 답 세 번으로 걸리는 쪽이 빠릅니다.** 그래도 왕복 한 번이 더 듭니다
- **흐름 제어는 상대가 벅찬 것, 혼잡 제어는 길이 벅찬 것.** 다른 문제입니다
- 혼잡 제어는 **손실을 혼잡으로 읽습니다.** 무선에서 오판이 생기는 이유입니다
- Nagle 과 지연 ACK 는 작은 것을 모으다가 서로를 기다립니다. 게임에서 `TCP_NODELAY` 를 켜는 이유입니다
- 대가는 **줄 서서 막히는 것.** 앞의 하나가 늦으면 뒤에 도착한 것이 전부 묶입니다

여기서 갈림길이 하나 생깁니다.
**보장을 통째로 사거나, 통째로 안 사거나** 둘밖에 없는가.

다음 편의 UDP 는 아무것도 안 해 줍니다.
그래서 오히려 **필요한 것만 골라서 직접 얹을 수 있습니다.**