소켓과 전송 계층 (2) - TCP 연결의 상태 기계 - soo:bak
작성일 :
연결이란 무엇인가
Part 1에서 소켓의 본질을 살펴보았습니다.
TCP 소켓은 “연결”을 맺는다고 합니다.
연결(Connection)이란 정확히 무엇일까요?
물리적으로 전용 회선이 생기는 것이 아닙니다.
인터넷은 패킷 교환 네트워크입니다.
각 패킷은 독립적으로 라우팅되며, 같은 연결의 패킷이라도 다른 경로로 갈 수 있습니다.
그렇다면 TCP의 “연결”은 무엇일까요?
연결은 양쪽 끝점의 상태 동기화입니다.
TCP 연결이 수립되면, 양쪽 호스트의 커널은 동일한 정보를 공유합니다.
- 서로의 시퀀스 번호
- 윈도우 크기 (얼마나 받을 수 있는지)
- 연결 상태
이 정보가 동기화되어 있으면 “연결되어 있다”고 말합니다.
반면 UDP는 상태를 유지하지 않습니다.
각 데이터그램은 독립적입니다.
이전에 데이터를 주고받았는지, 상대방이 준비되어 있는지 알지 못합니다.
그래서 UDP를 비연결형(Connectionless) 프로토콜이라고 합니다.
TCP 상태 전이 다이어그램
TCP 연결은 11가지 상태를 가집니다.
각 상태는 연결 수립, 데이터 전송, 연결 종료 과정에서 나타납니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
┌──────────┐
│ CLOSED │
└────┬─────┘
│
┌───────────────────────┼───────────────────────┐
│ (서버: listen()) │ │ (클라이언트: connect())
↓ │ ↓
┌───────────┐ │ ┌───────────┐
│ LISTEN │ │ │ SYN_SENT │
└─────┬─────┘ │ └─────┬─────┘
│ SYN 수신 │ │ SYN+ACK 수신
↓ │ ↓
┌───────────┐ │ ┌─────────────┐
│ SYN_RCVD │ │ │ ESTABLISHED │
└─────┬─────┘ │ └──────┬──────┘
│ ACK 수신 │ │
└───────────────────────┼────────────────────────┘
│
↓
┌─────────────┐
│ ESTABLISHED │ ← 데이터 전송
└──────┬──────┘
│
┌──────────────────────────┼──────────────────────────┐
│ (능동 종료: close()) │ │ (수동 종료)
↓ │ ↓
┌───────────┐ │ ┌────────────┐
│ FIN_WAIT_1│ │ │ CLOSE_WAIT │
└─────┬─────┘ │ └──────┬─────┘
│ ACK 수신 │ │ close()
↓ │ ↓
┌───────────┐ │ ┌──────────┐
│ FIN_WAIT_2│ │ │ LAST_ACK │
└─────┬─────┘ │ └──────┬───┘
│ FIN 수신 │ │ ACK 수신
↓ │ ↓
┌───────────┐ │ ┌──────────┐
│ TIME_WAIT │ ─── 2MSL 대기 ────→│←───────────────────│ CLOSED │
└───────────┘ │ └──────────┘
↓
┌──────────┐
│ CLOSED │
└──────────┘
각 상태의 의미를 살펴봅시다.
| 상태 | 설명 |
|---|---|
| CLOSED | 연결 없음, 초기 상태 |
| LISTEN | 연결 요청 대기 중 (서버) |
| SYN_SENT | SYN 전송 후 응답 대기 중 (클라이언트) |
| SYN_RCVD | SYN 수신 후 SYN+ACK 전송, ACK 대기 중 |
| ESTABLISHED | 연결 수립됨, 데이터 전송 가능 |
| FIN_WAIT_1 | FIN 전송 후 ACK 대기 중 |
| FIN_WAIT_2 | 자신의 FIN에 대한 ACK 수신, 상대 FIN 대기 중 |
| CLOSE_WAIT | 상대방이 FIN 전송, 자신이 close() 대기 중 |
| LAST_ACK | 자신의 FIN에 대한 ACK 대기 중 |
| TIME_WAIT | 연결 종료 후 대기 (2MSL) |
| CLOSING | 양쪽이 동시에 FIN 전송한 드문 경우 |
3-Way Handshake: 왜 3번인가
TCP 연결을 수립할 때 3-Way Handshake가 필요합니다.
왜 2번이 아니라 3번일까요?
2-Way Handshake의 문제
2번의 교환으로 연결을 수립한다고 가정해봅시다.
1
2
3
4
5
6
7
클라이언트 서버
│ │
│──── 연결 요청 (seq=100) ────→ │
│ │
│←── 연결 수락 (ack=101) ───── │
│ │
연결 수립? 연결 수립?
이것으로 충분할까요?
하지만 네트워크에서는 오래된 패킷이 늦게 도착할 수 있습니다.
1
2
3
4
5
6
7
8
9
시나리오: 오래된 연결 요청
1. 클라이언트가 연결 요청 (seq=100)을 보냄
2. 네트워크 지연으로 패킷이 오래 떠돌음
3. 클라이언트가 타임아웃, 새 연결 요청 (seq=200)을 보냄
4. 새 연결이 정상적으로 수립되고 종료됨
5. 이제야 오래된 패킷 (seq=100)이 서버에 도착
6. 서버: "새 연결 요청이네!" → 연결 수락
7. 클라이언트: "난 요청한 적 없는데?"
서버는 존재하지 않는 연결을 위해 자원을 낭비하게 됩니다.
3-Way Handshake의 해결책
1
2
3
4
5
6
7
8
9
10
11
클라이언트 서버
│ │
│─── SYN (seq=x) ────────────────→ │
│ "내 시퀀스 번호는 x입니다" │
│ │
│←── SYN+ACK (seq=y, ack=x+1) ──── │
│ "알겠습니다. 내 시퀀스 번호는 y입니다" │
│ │
│─── ACK (ack=y+1) ──────────────→ │
│ "알겠습니다" │
│ │
세 번째 ACK가 핵심입니다.
서버는 자신의 SYN에 대한 응답(ACK)을 받아야 연결이 유효하다고 확신합니다.
오래된 SYN이 도착해도, 클라이언트가 ACK를 보내지 않으면 연결이 수립되지 않습니다.
시퀀스 번호 동기화
3-Way Handshake의 또 다른 목적은 시퀀스 번호(ISN, Initial Sequence Number)를 교환하는 것입니다.
시퀀스 번호는 왜 필요할까요?
TCP는 순서 보장을 제공합니다.
각 바이트에 번호를 매겨서 순서가 뒤바뀌어 도착해도 재정렬할 수 있습니다.
시퀀스 번호는 왜 0이 아니라 임의의 값으로 시작할까요?
보안상 이유입니다.
예측 가능한 시퀀스 번호는 TCP 세션 하이재킹 공격에 취약합니다.
현대 운영체제는 암호학적으로 안전한 방법으로 ISN을 생성합니다.
4-Way Handshake: 연결 종료
연결 수립은 3번, 연결 종료는 4번이 필요합니다.
왜 더 많을까요?
반이중 종료(Half-Close)
TCP 연결은 양방향입니다.
A→B와 B→A, 두 개의 독립적인 데이터 흐름이 있습니다.
각 방향을 독립적으로 종료해야 합니다.
A가 “나는 더 보낼 데이터가 없다”고 해도, B는 아직 보낼 데이터가 있을 수 있습니다.
그래서 A의 종료와 B의 종료가 별도로 이루어집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
클라이언트 서버
│ │
│─── FIN (seq=x) ────────────────→ │
│ "나는 더 보낼 데이터가 없습니다" │
│ │
│←── ACK (ack=x+1) ─────────────── │
│ "알겠습니다" │
│ │
│ (서버는 아직 데이터를 보낼 수 있음) │
│←── 데이터... ─────────────────── │
│ │
│←── FIN (seq=y) ───────────────── │
│ "나도 더 보낼 데이터가 없습니다" │
│ │
│─── ACK (ack=y+1) ──────────────→ │
│ "알겠습니다" │
│ │
종료 과정의 상태 변화
능동적으로 종료를 시작한 쪽(클라이언트):
1
ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED
수동적으로 종료를 받은 쪽(서버):
1
ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED
CLOSE_WAIT 상태
CLOSE_WAIT은 상대방이 FIN을 보냈지만, 자신은 아직 close()를 호출하지 않은 상태입니다.
이 상태가 오래 지속되면 문제입니다.
1
2
3
4
$ netstat -an | grep CLOSE_WAIT
tcp 0 0 192.168.1.100:8080 10.0.0.1:52000 CLOSE_WAIT
tcp 0 0 192.168.1.100:8080 10.0.0.2:52001 CLOSE_WAIT
...
CLOSE_WAIT이 쌓이면 애플리케이션 버그의 징후입니다.
연결을 제대로 닫지 않는 코드가 있다는 의미입니다.
TIME_WAIT: 왜 바로 닫지 않는가
연결 종료 후 TIME_WAIT 상태에서 일정 시간 대기합니다.
이 시간은 2MSL(Maximum Segment Lifetime)입니다.
MSL은 패킷이 네트워크에서 살아있을 수 있는 최대 시간으로, 보통 60초입니다.
2MSL = 2분.
왜 바로 CLOSED로 가지 않고 2분이나 기다릴까요?
두 가지 이유가 있습니다.
이유 1: 지연된 패킷 처리
연결이 종료되어도 네트워크에 해당 연결의 패킷이 떠돌 수 있습니다.
1
2
3
4
연결 1: (TCP, A:5000, B:80) ← 종료됨
연결 2: (TCP, A:5000, B:80) ← 즉시 같은 주소로 새 연결
지연된 패킷이 연결 2에 도착 → 데이터 오염!
TIME_WAIT 동안 같은 5-tuple로 새 연결을 맺지 않으면, 지연된 패킷이 사라질 시간을 확보합니다.
이유 2: 마지막 ACK 손실 대비
1
2
3
4
5
6
A B
│ │
│←── FIN ──────────────────── │
│ │
│─── ACK ────────────────────→ │ ← 이 ACK가 손실되면?
│ │
마지막 ACK가 손실되면 B는 FIN을 재전송합니다.
A가 이미 CLOSED라면 FIN에 응답할 수 없습니다.
B는 계속 FIN을 재전송하다가 타임아웃됩니다.
A가 TIME_WAIT에서 대기하면, 재전송된 FIN에 ACK로 응답할 수 있습니다.
TIME_WAIT와 서버 재시작 문제
서버를 재시작할 때 문제가 생길 수 있습니다.
1
2
3
4
$ ./server # 8080 포트에서 실행
^C # 종료
$ ./server # 즉시 재시작
Error: Address already in use
서버 소켓이 TIME_WAIT 상태이면, 같은 주소에 bind()할 수 없습니다.
SO_REUSEADDR 옵션이 이 문제를 해결합니다.
1
2
3
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
bind(sockfd, ...);
이 옵션은 TIME_WAIT 상태의 주소에도 바인딩을 허용합니다.
서버 개발에서 거의 필수적으로 사용됩니다.
2MSL의 수학적 근거
왜 하필 2MSL일까요?
A가 보낸 ACK가 B에 도달하는 데 최대 1MSL이 걸립니다.
만약 ACK가 손실되면, B가 FIN을 재전송하고 A에 도달하는 데 또 최대 1MSL이 걸립니다.
합해서 2MSL입니다.
1
2
3
4
5
A → ACK → (최대 1MSL) → B
손실!
B → FIN 재전송 → (최대 1MSL) → A
총: 2MSL
동시 연결과 동시 종료
드물지만 양쪽이 동시에 연결을 시작하거나 종료할 수 있습니다.
동시 열기(Simultaneous Open)
1
2
3
4
5
6
7
8
9
10
A B
│ │
│─── SYN (seq=x) ────────────→ │
│ │
│←── SYN (seq=y) ───────────── │
│ │
│─── SYN+ACK (seq=x, ack=y+1) →│
│ │
│←── SYN+ACK (seq=y, ack=x+1) ─│
│ │
양쪽 모두 SYN_SENT → SYN_RCVD → ESTABLISHED를 거칩니다.
동시 닫기(Simultaneous Close)
1
2
3
4
5
6
7
8
9
10
A B
│ │
│─── FIN ────────────────────→ │
│ │
│←── FIN ───────────────────── │
│ │
│─── ACK ────────────────────→ │
│ │
│←── ACK ───────────────────── │
│ │
양쪽 모두 FIN_WAIT_1 → CLOSING → TIME_WAIT → CLOSED를 거칩니다.
CLOSING 상태는 이 경우에만 나타납니다.
소켓 버퍼: 데이터의 대기실
커널의 소켓 구조에는 버퍼가 있습니다.
송신 버퍼와 수신 버퍼, 두 개의 버퍼가 각 연결마다 존재합니다.
1
2
3
4
5
6
7
8
애플리케이션 커널 네트워크
┌───────────┐ ┌─────────────────┐
│ │ write() │ 송신 버퍼 │ ─────→
│ 프로세스 │ ─────────→ │ [데이터 대기] │
│ │ │ │
│ │ read() │ 수신 버퍼 │ ←─────
│ │ ←───────── │ [도착한 데이터] │
└───────────┘ └─────────────────┘
송신 버퍼
write()를 호출하면 데이터가 소켓의 송신 버퍼에 복사됩니다.
write()가 반환되어도 데이터가 전송된 것이 아니라 버퍼에 복사된 것입니다.
커널이 적절한 시점에 버퍼의 데이터를 TCP 세그먼트로 만들어 전송합니다.
송신 버퍼가 가득 차면 write()가 블록됩니다.
(non-blocking 모드에서는 EAGAIN/EWOULDBLOCK 에러 반환)
수신 버퍼
네트워크에서 데이터가 도착하면 수신 버퍼에 저장됩니다.
read()를 호출하면 수신 버퍼에서 데이터를 꺼내옵니다.
수신 버퍼가 비어 있으면 read()가 블록됩니다.
(non-blocking 모드에서는 EAGAIN/EWOULDBLOCK 에러 반환)
버퍼와 윈도우 크기
TCP 흐름 제어에서 사용하는 윈도우 크기(Window Size)는 수신 버퍼의 여유 공간과 관련됩니다.
1
2
3
수신 버퍼 크기: 64KB
현재 데이터: 20KB
여유 공간: 44KB → 윈도우 크기로 광고
송신측은 수신측이 광고한 윈도우 크기 이상 전송하지 않습니다.
수신측 버퍼가 넘치지 않도록 보장합니다.
버퍼 크기 조정
버퍼 크기는 소켓 옵션으로 조정할 수 있습니다.
1
2
3
int bufsize = 1024 * 1024; // 1MB
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize));
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
고대역폭 장거리 연결(높은 BDP)에서는 큰 버퍼가 필요합니다.
1
2
3
4
BDP (Bandwidth-Delay Product) = 대역폭 × RTT
예: 1Gbps 링크, RTT 100ms
BDP = 1,000,000,000 bps × 0.1s = 100,000,000 bits = 12.5MB
버퍼가 BDP보다 작으면 대역폭을 완전히 활용할 수 없습니다.
마무리: 연결은 상태의 동기화다
TCP 연결은 물리적 회선이 아니라 상태의 동기화입니다.
3-Way Handshake는 양쪽의 시퀀스 번호를 동기화하고, 오래된 연결 요청을 구분합니다.
4-Way Handshake는 양방향 데이터 흐름을 독립적으로 종료합니다.
TIME_WAIT는 지연된 패킷과 손실된 ACK에 대비합니다.
이 모든 상태 전이는 커널에서 자동으로 처리됩니다.
애플리케이션은 connect(), accept(), close()만 호출하면 됩니다.
그 뒤에서 커널이 SYN, ACK, FIN을 주고받으며 상태를 관리합니다.
Part 3에서는 멀티플렉싱과 패킷 흐름을 살펴봅니다.
운영체제가 어떻게 수천 개의 연결을 하나의 네트워크 인터페이스로 처리하는지, 패킷이 어떻게 생성되고 분해되는지 알아봅니다.
관련 글
시리즈
- 소켓과 전송 계층 (1) - 소켓의 탄생과 추상화
- 소켓과 전송 계층 (2) - TCP 연결의 상태 기계 (현재 글)
- 소켓과 전송 계층 (3) - 멀티플렉싱과 패킷 흐름