연결 수립과 종료
3-way handshake로 두 시작 순서 번호를 확인하는 과정과, FIN으로 두 송신 방향을 하나씩 닫는 과정을 순서 번호와 상태의 변화로 따라갑니다. TIME_WAIT가 필요한 이유와 쌓일 때의 대응, 연결을 즉시 끊는 RST가 생기는 상황도 함께 정리합니다.
앞 글에서 TCP는 데이터를 보내기 전에 연결 상태를 만든다고 했습니다. 이 글에서는 그 연결이 어떻게 맺어지고 닫히는지를 세그먼트 하나하나의 순서 번호와 양쪽의 상태로 따라갑니다. "SYN, SYN+ACK, ACK" 순서만 외워 두면 면접 질문에는 답할 수 있지만, 서버에 CLOSE-WAIT가 수천 개 쌓였을 때나 연결이 갑자기 리셋될 때 원인을 찾으려면 각 단계에서 무엇이 확인되고 어떤 상태가 남는지를 알아야 합니다.
3-way handshake는 두 시작 번호를 확인한다
TCP는 연결마다 양쪽이 각자 시작 순서 번호(initial sequence number, ISN)를 고르고, 상대가 그 번호를 받았는지 확인한 뒤에야 데이터를 보냅니다. 이 확인을 세그먼트 세 개로 끝내는 것이 3-way 핸드셰이크입니다.
TCP 연결 수립에서 확인하는 두 초기 순서 번호
클라이언트가 초기 순서 번호를 보냅니다. 클라이언트가 서버 SYN을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TCP · 연결 수립
두 초기 순서 번호를 세 메시지로 확인한다
양쪽의 SYN은 각각 순서 번호 하나를 소비합니다. 데이터 없는 기본 예에서 ACK 값은 상대의 다음 순서 번호이며, 세 메시지를 세 번의 왕복으로 세지 않습니다.
아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. 실전에서는 세 번째 ACK에 애플리케이션 데이터를 실을 수 있으며, 이 그림은 데이터가 없는 연결 수립만 표시합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 SYN부터 ACK까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | TCP 연결 수립에서 확인하는 두 초기 순서 번호 |
| 관계 설명 | 클라이언트 SYN seq 100, 서버 SYN ACK seq 500 ack 101, 클라이언트 ACK seq 101 ack 501 순입니다. SYN은 순서 번호 하나를 소비합니다. |
장면별 값과 변화
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| SYN 클라이언트가 초기 순서 번호를 보냅니다. |
|
|
| SYN-ACK 서버가 자기 초기 번호와 상대 SYN 확인을 보냅니다. |
|
|
| ACK 클라이언트가 서버 SYN을 확인합니다. |
|
|
TCP 연결 수립에서 확인하는 두 초기 순서 번호
클라이언트가 초기 순서 번호를 보냅니다. 클라이언트가 서버 SYN을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TCP · 연결 수립
두 초기 순서 번호를 세 메시지로 확인한다
양쪽의 SYN은 각각 순서 번호 하나를 소비합니다. 데이터 없는 기본 예에서 ACK 값은 상대의 다음 순서 번호이며, 세 메시지를 세 번의 왕복으로 세지 않습니다.
아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. 실전에서는 세 번째 ACK에 애플리케이션 데이터를 실을 수 있으며, 이 그림은 데이터가 없는 연결 수립만 표시합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 SYN부터 ACK까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | TCP 연결 수립에서 확인하는 두 초기 순서 번호 |
| 관계 설명 | 클라이언트 SYN seq 100, 서버 SYN ACK seq 500 ack 101, 클라이언트 ACK seq 101 ack 501 순입니다. SYN은 순서 번호 하나를 소비합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| SYN 클라이언트가 초기 순서 번호를 보냅니다. |
|
|
| SYN-ACK 서버가 자기 초기 번호와 상대 SYN 확인을 보냅니다. |
|
|
| ACK 클라이언트가 서버 SYN을 확인합니다. |
|
|
먼저 연결을 요청하는 클라이언트가 자기 ISN 100을 담은 SYN을 보냅니다. SYN은 데이터가 없어도 순서 번호 하나를 차지하므로, 서버가 다음에 기대하는 클라이언트의 번호는 101입니다. 서버는 이를 확인하는 ACK 101과 자기 ISN 500을 담은 SYN을 한 세그먼트에 실어 보내고, 클라이언트는 서버의 SYN을 확인하는 ACK 501을 보냅니다. 이 마지막 ACK가 서버에 닿으면 양쪽 모두 ESTABLISHED가 됩니다.
SYN과 SYN+ACK에는 연결의 성질을 정하는 옵션도 실립니다. 한 세그먼트에 실을 수 있는 최대 데이터 크기인 MSS, 윈도우를 크게 키우는 Window Scale, 빠진 구간만 알려 주는 SACK Permitted, 왕복 시간을 재는 Timestamps가 대표적입니다. Window Scale과 SACK처럼 양쪽의 SYN에 모두 실려야 쓸 수 있는 옵션이 많아서, 중간 장비가 SYN의 옵션을 지우면 연결 내내 성능이 떨어지기도 합니다. 세 번째 ACK에는 애플리케이션 데이터를 함께 실을 수 있으므로, 세 세그먼트를 왕복 세 번으로 세지는 않습니다. 클라이언트 입장에서 데이터를 보내기까지 걸리는 시간은 한 번의 왕복입니다.
두 번으로는 왜 부족할까요? RFC 9293은 3-way 핸드셰이크의 주된 목적이 오래된 중복 SYN 때문에 생기는 혼란을 막는 것이라고 설명합니다. 네트워크에서 오래 떠돌던 예전 연결의 SYN이 서버에 늦게 도착했다고 합시다. 서버가 SYN+ACK를 보내는 것만으로 연결을 맺었다고 본다면, 아무도 원하지 않는 연결이 생깁니다. 3-way에서는 클라이언트가 그 SYN+ACK의 ACK 번호가 자기가 지금 보낸 SYN과 맞지 않는다는 것을 알아채고 RST로 거절하므로, 서버는 그 반쪽짜리 연결을 버릴 수 있습니다.
순서 번호를 세는 규칙
SYN과 FIN은 데이터가 없어도 순서 번호를 하나씩 차지하고, 데이터는 바이트 수만큼 차지하며, 데이터 없는 ACK는 번호를 차지하지 않습니다. ACK 번호는 언제나 "다음에 받기를 기대하는 번호"입니다. 다음 코드는 클라이언트가 연결을 맺고 5바이트를 보낸 뒤 자기 쪽 송신을 닫는 동안의 번호를 따라갑니다.
def trace(client_isn, server_isn, data_len):
c, s = client_isn, server_isn
print(f"C->S SYN seq={c}")
c += 1 # SYN은 번호 하나를 쓴다
print(f"S->C SYN+ACK seq={s} ack={c}")
s += 1
print(f"C->S ACK seq={c} ack={s}") # 데이터 없는 ACK는 번호를 쓰지 않는다
print(f"C->S DATA seq={c} len={data_len}")
c += data_len # 데이터는 바이트 수만큼 쓴다
print(f"S->C ACK ack={c}")
print(f"C->S FIN seq={c}")
c += 1 # FIN도 번호 하나를 쓴다
print(f"S->C ACK ack={c}")
trace(100, 500, 5)C->S SYN seq=100
S->C SYN+ACK seq=500 ack=101
C->S ACK seq=101 ack=501
C->S DATA seq=101 len=5
S->C ACK ack=106
C->S FIN seq=106
S->C ACK ack=107세 번째 ACK와 첫 데이터의 seq가 모두 101인 것은 그 ACK가 번호를 쓰지 않았기 때문입니다. 5바이트는 101부터 105까지를 차지하므로 서버의 ACK는 106이고, FIN이 106을 쓰고 나면 FIN까지 확인한 ACK는 107이 됩니다. 패킷 캡처를 볼 때 이 규칙을 알면 어느 세그먼트가 빠졌는지 번호만으로 찾을 수 있습니다. Wireshark 같은 도구는 ISN을 0으로 놓은 상대 번호로 보여 주므로, 실제 번호와 헷갈리지 않도록 주의합니다.
ISN은 왜 0이 아닌가
ISN을 모든 연결에서 0으로 시작한다면 두 가지 문제가 생깁니다. 하나는 같은 4-tuple로 연결을 닫았다가 곧바로 다시 맺었을 때, 예전 연결에서 늦게 도착한 세그먼트의 번호가 새 연결의 번호와 겹칠 수 있다는 것입니다. 다른 하나는 공격자가 번호를 쉽게 짐작할 수 있다는 것입니다. 남의 연결에 끼어들거나 출발지를 속여 연결을 맺으려면 상대가 받아들일 순서 번호를 맞혀야 하는데, ISN을 예측할 수 있으면 이 일이 쉬워집니다.
그래서 RFC 6528은 시간에 따라 증가하는 값에 4-tuple과 비밀 키로 만든 해시 값을 더해 ISN을 고르도록 권합니다. 같은 4-tuple 안에서는 번호가 시간에 따라 앞으로 나아가므로 예전 연결의 번호와 겹치기 어렵고, 비밀 키를 모르는 바깥에서는 다음 번호를 짐작하기 어렵습니다.
FIN은 한 방향씩 닫는다
TCP 연결은 양방향이고, 두 방향은 따로 닫힙니다. FIN은 "나는 더 보낼 데이터가 없다"는 뜻일 뿐, 상대도 다 보냈다는 뜻이 아닙니다. 그래서 정상 종료에는 보통 세그먼트 네 개가 오갑니다.
FIN으로 두 송신 방향을 각각 닫는 TCP 종료
클라이언트가 자기 송신 방향만 닫습니다. 클라이언트가 서버 FIN을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TCP · 정상 종료
FIN은 두 송신 방향을 각각 닫는다
클라이언트의 FIN 뒤에도 서버는 송신할 수 있습니다. 여기서는 추가 데이터 없이 각 FIN이 순서 번호 하나를 소비하고, 마지막 ACK를 보낸 클라이언트가 TIME-WAIT로 들어가는 기본 예를 봅니다.
아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. ACK와 FIN이 합쳐지거나 양쪽이 동시에 종료할 수도 있고, RST로 중단하는 경우는 이 정상 종료 예와 다릅니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 클라이언트 FIN부터 마지막 ACK까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | FIN으로 두 송신 방향을 각각 닫는 TCP 종료 |
| 관계 설명 | 클라이언트 FIN seq 101, 서버 ACK 102, 서버 FIN seq 501, 클라이언트 ACK 502 순입니다. 두 FIN 사이 서버는 송신을 계속할 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 클라이언트 FIN 클라이언트가 자기 송신 방향만 닫습니다. |
|
|
| 서버 ACK 서버가 FIN이 소비한 순서 번호 하나를 확인합니다. |
|
|
| 서버 FIN 서버 앱도 자기 송신 방향을 닫습니다. |
|
|
| 마지막 ACK 클라이언트가 서버 FIN을 확인합니다. |
|
|
FIN으로 두 송신 방향을 각각 닫는 TCP 종료
클라이언트가 자기 송신 방향만 닫습니다. 클라이언트가 서버 FIN을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.
TCP · 정상 종료
FIN은 두 송신 방향을 각각 닫는다
클라이언트의 FIN 뒤에도 서버는 송신할 수 있습니다. 여기서는 추가 데이터 없이 각 FIN이 순서 번호 하나를 소비하고, 마지막 ACK를 보낸 클라이언트가 TIME-WAIT로 들어가는 기본 예를 봅니다.
아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. ACK와 FIN이 합쳐지거나 양쪽이 동시에 종료할 수도 있고, RST로 중단하는 경우는 이 정상 종료 예와 다릅니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 클라이언트 FIN부터 마지막 ACK까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | FIN으로 두 송신 방향을 각각 닫는 TCP 종료 |
| 관계 설명 | 클라이언트 FIN seq 101, 서버 ACK 102, 서버 FIN seq 501, 클라이언트 ACK 502 순입니다. 두 FIN 사이 서버는 송신을 계속할 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 클라이언트 FIN 클라이언트가 자기 송신 방향만 닫습니다. |
|
|
| 서버 ACK 서버가 FIN이 소비한 순서 번호 하나를 확인합니다. |
|
|
| 서버 FIN 서버 앱도 자기 송신 방향을 닫습니다. |
|
|
| 마지막 ACK 클라이언트가 서버 FIN을 확인합니다. |
|
|
| 순서 | 세그먼트 | 상태 변화 |
|---|---|---|
| 1 | 클라이언트 → 서버 FIN, seq 101 | 클라이언트가 자기 송신 방향을 닫고 FIN-WAIT-1로 간다 |
| 2 | 서버 → 클라이언트 ACK 102 | 서버는 CLOSE-WAIT, 클라이언트는 FIN-WAIT-2. 서버는 아직 데이터를 보낼 수 있다 |
| 3 | 서버 → 클라이언트 FIN, seq 501 | 서버 애플리케이션이 소켓을 닫아 서버의 송신 방향도 닫히고, 서버는 LAST-ACK로 간다 |
| 4 | 클라이언트 → 서버 ACK 502 | 서버는 CLOSED, 클라이언트는 TIME-WAIT로 간다 |
먼저 닫는 쪽을 능동 종료(active close), 상대의 FIN을 받고 닫는 쪽을 수동 종료(passive close)라고 합니다. 서버가 보낼 데이터가 없다면 2와 3의 ACK와 FIN이 한 세그먼트에 함께 실려 세 개로 끝나기도 하고, 양쪽이 거의 동시에 FIN을 보내는 동시 종료(simultaneous close)에서는 둘 다 CLOSING을 거쳐 TIME-WAIT로 갑니다.
한 방향만 닫는 것을 하프 클로즈(half-close)라고 하며, 소켓 API에서는 shutdown()으로 합니다. 파일을 다 올린 클라이언트가 "다 보냈다"고 알리고 서버의 처리 결과를 기다리는 경우에 씁니다. 다음 코드는 한 프로세스 안에서 루프백으로 이 흐름을 흉내 냅니다.
import socket
listener = socket.create_server(("127.0.0.1", 0))
client = socket.create_connection(listener.getsockname())
conn, _ = listener.accept()
client.sendall(b"upload-done")
client.shutdown(socket.SHUT_WR) # 클라이언트 → 서버 방향만 닫는다(FIN)
received = b""
while chunk := conn.recv(1024): # b''가 돌아오면 상대가 FIN을 보낸 것
received += chunk
print("서버가 받은 것:", received)
conn.sendall(b"ok " + str(len(received)).encode()) # 서버 → 클라이언트 방향은 아직 열려 있다
conn.close()
reply = b""
while chunk := client.recv(1024):
reply += chunk
print("클라이언트가 받은 응답:", reply)
client.close()
listener.close()서버가 받은 것: b'upload-done'
클라이언트가 받은 응답: b'ok 11'서버는 recv()가 빈 바이트열을 돌려줄 때까지 읽어 클라이언트가 다 보냈다는 것을 알고, 받은 11바이트에 대한 응답을 보냅니다. 클라이언트가 shutdown() 대신 close()를 불렀다면 송신과 수신을 모두 닫은 것이므로 서버의 응답을 읽을 수 없었을 것입니다. 상대가 FIN을 보냈다는 것은 recv()가 빈 바이트열을 돌려주는 것으로 알 수 있으며, 이것이 앞 글에서 말한 EOF입니다.
상태 이름이 알려 주는 것
TCP의 상태 이름은 무엇을 보냈고 무엇을 기다리는지, 애플리케이션이 소켓을 닫았는지를 알려 줍니다. 운영 중인 서버에서 어느 상태가 오래 쌓여 있는지를 보면 문제가 어느 쪽에 있는지 짐작할 수 있습니다.
| 상태 | 뜻 | 오래 쌓이면 볼 것 |
|---|---|---|
| SYN-SENT | SYN을 보내고 SYN+ACK를 기다린다 | 서버가 듣고 있는지, 방화벽이 SYN을 버리는지 |
| SYN-RECEIVED | SYN+ACK를 보내고 마지막 ACK를 기다린다 | 대기열이 부족한지, SYN Flood인지 |
| ESTABLISHED | 데이터를 주고받을 수 있다 | 정상이거나, 오래 유지되는 연결 |
| FIN-WAIT-1 | 내 FIN을 보내고 ACK를 기다린다 | 상대에게 닿지 않거나 상대 수신 버퍼가 가득 찼는지 |
| FIN-WAIT-2 | 내 FIN은 확인되었고 상대의 FIN을 기다린다 | 상대 애플리케이션이 소켓을 닫지 않는지 |
| CLOSE-WAIT | 상대의 FIN을 받았고 내 애플리케이션이 닫기를 기다린다 | 내 애플리케이션이 close()를 빠뜨렸는지 |
| LAST-ACK | 내 FIN을 보내고 마지막 ACK를 기다린다 | 마지막 ACK가 사라지는지 |
| TIME-WAIT | 마지막 ACK를 보낸 뒤 일정 시간 기다린다 | 짧은 연결을 너무 자주 맺고 끊는지 |
CLOSE-WAIT는 특히 눈여겨볼 상태입니다. 상대는 이미 닫았고 운영체제도 FIN에 ACK를 보냈으므로, 이 상태를 끝낼 수 있는 것은 내 애플리케이션의 close()뿐입니다. CLOSE-WAIT가 시간이 갈수록 늘어난다면 예외가 났을 때 소켓을 닫지 않는 코드 경로가 있다는 뜻인 경우가 대부분입니다.
TIME_WAIT는 왜 기다리는가
TIME-WAIT는 오류가 아니라 정상 종료의 마지막 단계입니다. 마지막 ACK를 보내는 능동 종료 쪽이 이 상태에 들어가며, 두 가지 이유로 연결 정보를 바로 지우지 않고 기다립니다.
첫째, 마지막 ACK가 사라지면 상대는 FIN을 다시 보냅니다. 이때 연결 정보가 남아 있어야 그 FIN에 다시 ACK할 수 있고, 지워졌다면 RST로 답하게 되어 상대는 정상 종료를 오류로 받아들입니다. 둘째, 같은 4-tuple로 곧바로 새 연결을 맺으면 예전 연결에서 늦게 도착한 세그먼트가 새 연결의 데이터로 섞일 수 있습니다. 네트워크에 남아 있던 세그먼트가 모두 사라질 때까지 그 4-tuple을 쓰지 않으면 이런 일을 막을 수 있습니다.
RFC 9293은 세그먼트가 네트워크에 머무를 수 있는 최대 시간(maximum segment lifetime, MSL)을 2분으로 잡고, TIME-WAIT에서 그 두 배를 기다리게 합니다. 실제 운영체제는 이보다 짧게 쓰는 경우가 많아서, 예를 들어 Linux는 TIME-WAIT를 60초로 고정해 두고 있습니다. 운영체제와 버전마다 값과 조정 방법이 다르므로 숫자보다 목적을 기억해 두는 편이 좋습니다.
TIME_WAIT가 쌓일 때
요청마다 연결을 새로 맺고 끊는 클라이언트는 TIME-WAIT를 빠르게 쌓습니다. 같은 서버의 같은 포트로 가는 연결은 클라이언트의 임시 포트로만 구별되는데, TIME-WAIT 상태의 포트는 그 목적지로 다시 쓸 수 없기 때문입니다. 앞 글의 연습 문제처럼 Linux의 기본 임시 포트가 28232개라면, TIME-WAIT가 60초인 동안 한 서버의 한 포트로는 초당 약 28232 ÷ 60 ≈ 470개보다 많은 새 연결을 계속 맺을 수 없습니다. 이 한도를 넘으면 connect()가 쓸 포트를 찾지 못해 실패합니다.
ss -Htan state time-wait | wc -l # TIME-WAIT 개수
ss -Htan state time-wait '( dport = :443 )' | wc -l # 443번으로 나간 연결의 TIME-WAIT
cat /proc/sys/net/ipv4/ip_local_port_range # 임시 포트 범위| 대응 | 효과 | 주의할 점 |
|---|---|---|
| 연결 풀, HTTP Keep-Alive | 연결을 재사용해 맺고 끊는 횟수 자체를 줄인다 | 가장 먼저 검토할 방법. 서버와 프록시의 유휴 시간 제한과 맞춘다 |
| HTTP/2 | 요청 여러 개를 연결 하나에 함께 싣는다 | 연결 하나에 문제가 생기면 그 위의 요청이 모두 영향을 받는다 |
| 임시 포트 범위 넓히기 | 포트에 여유가 생긴다 | 연결을 남발하는 원인은 그대로 남는다 |
Linux tcp_tw_reuse | 안전한 조건에서 나가는 연결이 TIME-WAIT 포트를 다시 쓴다 | Timestamps 옵션이 필요하며, 커널 문서의 설명을 확인한 뒤 적용한다 |
SO_LINGER 0으로 닫기 | FIN 대신 RST로 끊어 TIME-WAIT를 건너뛴다 | 보내지 못한 데이터가 버려지므로 일반적인 해결책이 아니다 |
예전 Linux에는 tcp_tw_recycle이라는 설정도 있었지만, NAT 뒤의 여러 클라이언트를 한 대로 착각해 연결을 거부하는 문제 때문에 커널 4.12에서 없어졌습니다. 인터넷의 오래된 튜닝 글을 그대로 따라 하지 말아야 하는 이유입니다. 커널 문서에서 지금 쓰는 커널의 설정 항목을 확인합니다.
RST는 연결을 즉시 끊는다
FIN이 "다 보냈으니 닫자"는 정중한 인사라면, RST(reset)는 "이 연결은 더 없다"며 상태를 즉시 버리는 신호입니다. RST를 받은 쪽은 ACK를 보내지 않고, 버퍼에 남은 데이터도 버립니다. RST가 생기는 대표적인 상황은 다음과 같습니다.
- 아무도 듣지 않는 포트로 SYN이 오면, 운영체제는 연결을 거부하는 RST로 답합니다.
- 한쪽이 재부팅되어 연결 정보를 잃었는데 상대가 예전 연결로 데이터를 보내면, 모르는 연결의 세그먼트이므로 RST로 답합니다. 상대는 그제야 연결이 반쯤 열린(half-open) 상태였음을 압니다.
- 애플리케이션이 받지 않은 데이터를 버퍼에 남긴 채 소켓을 닫거나,
SO_LINGER를 0으로 두고 닫으면 FIN 대신 RST가 나갑니다. - 방화벽이나 프록시가 정책에 따라 연결을 끊을 때 RST를 보내기도 합니다.
다음 코드는 첫 번째 경우를 보여 줍니다. 빈 포트를 하나 얻은 뒤 닫아 두고 그 포트로 연결하면, 운영체제의 RST가 Python의 ConnectionRefusedError로 나타납니다.
import socket
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close() # 이제 아무도 이 포트를 듣지 않는다
try:
socket.create_connection(("127.0.0.1", port), timeout=5)
except ConnectionRefusedError:
print("연결 거부: 상대가 SYN에 RST로 답했다")연결 거부: 상대가 SYN에 RST로 답했다장애를 진단할 때는 "연결 거부"와 "시간 초과"를 구별하는 것이 중요합니다. 연결 거부는 누군가 RST로 답했다는 뜻이므로 그 주소까지는 닿았고, 그 포트에 서버가 없거나 방화벽이 RST로 거절한 것입니다. 시간 초과는 아무 답도 없었다는 뜻이므로 경로가 끊겼거나, 방화벽이 아무 말 없이 패킷을 버리고 있을 가능성이 큽니다.
더 깊이: 남이 보낸 가짜 RST는 어떻게 걸러 내나
RST가 연결을 바로 끊는다면, 4-tuple만 아는 공격자가 출발지를 속인 RST로 남의 연결을 끊을 수도 있습니다. 예전 규칙은 순서 번호가 수신 윈도우 안에만 들면 RST를 받아들였으므로, RFC 5961의 계산으로는 윈도우가 32,768바이트일 때 공격자가 평균 65,536개쯤의 패킷만 보내면 맞는 번호를 맞힐 수 있었습니다. 윈도우가 클수록 맞히기는 더 쉬워집니다.
RFC 5961은 이 판정을 엄격하게 바꾸었습니다. RST의 순서 번호가 다음에 기대하는 번호와 정확히 같을 때만 연결을 끊고, 윈도우 안이지만 정확히 같지 않으면 연결을 그대로 둔 채 상대에게 챌린지 ACK(challenge ACK)를 보냅니다. 진짜 상대라면 이 ACK에 담긴 번호로 RST를 다시 보낼 것이고, 경로 밖의 공격자는 그 ACK를 볼 수 없으므로 번호를 맞히기가 훨씬 어려워집니다. 이미 맺어진 연결에 SYN이 와도 같은 방식으로 챌린지 ACK로 답합니다.
RFC는 챌린지 ACK를 보내는 빈도를 제한하라고 권하지만, 이 제한이 오히려 정보를 흘릴 수도 있습니다. Linux의 커널 문서는 tcp_challenge_ack_limit이 네트워크 네임스페이스 전체에 거는 초당 한도라서 부채널 공격을 허용할 수 있으므로 켜지 않는 편이 낫다고 적고, 기본값을 사실상 제한이 없는 INT_MAX로 둡니다. 연결마다 거는 한도는 TCP 스택이 따로 두고 있습니다.
정리
- 3-way 핸드셰이크는 양쪽의 ISN을 서로 확인하는 과정으로, SYN과 SYN+ACK에 실린 옵션이 연결의 성질을 정합니다. 세 번째 세그먼트가 필요한 것은 오래된 중복 SYN으로 생기는 연결을 막기 위해서입니다.
- SYN과 FIN은 순서 번호 하나를, 데이터는 바이트 수만큼을 차지하고, 데이터 없는 ACK는 번호를 차지하지 않습니다.
- ISN은 예전 연결의 세그먼트와 섞이지 않고 공격자가 짐작하기 어렵도록, 시간과 4-tuple, 비밀 키로 고릅니다.
- FIN은 한 방향의 송신만 닫으므로 정상 종료에는 보통 세그먼트 네 개가 오가며,
shutdown()으로 한 방향만 닫을 수 있습니다. - CLOSE-WAIT가 쌓이면 내 애플리케이션이 소켓을 닫지 않는 것이고, TIME-WAIT는 마지막 ACK의 재전송과 늦게 온 세그먼트를 처리하려고 능동 종료 쪽이 기다리는 정상 상태입니다.
- RST는 연결을 즉시 버리는 신호로, 연결 거부는 RST를 받았다는 뜻이고 시간 초과는 아무 답도 없었다는 뜻입니다.
연습 문제
1. 번호 세기
클라이언트의 ISN이 1000입니다. 연결을 맺은 뒤 클라이언트가 4바이트를 보내고 곧바로 FIN을 보냈습니다. 손실과 재정렬이 없다면, 서버가 데이터를 받은 뒤 보내는 ACK 번호와 FIN까지 받은 뒤 보내는 ACK 번호는 각각 얼마인가요?
해설 보기
SYN이 1000을 쓰므로 데이터는 1001부터 1004까지를 차지하고, 데이터를 받은 뒤의 ACK는 다음에 기대하는 번호인 1005입니다. FIN이 1005를 하나 더 쓰므로 FIN까지 받은 뒤의 ACK는 1006입니다. ACK 번호는 마지막으로 받은 번호가 아니라 다음에 받기를 기대하는 번호라는 점, 데이터 없는 ACK는 번호를 쓰지 않는다는 점을 함께 기억합니다.
2. 줄지 않는 CLOSE-WAIT
어느 API 서버에서 CLOSE-WAIT 상태의 연결이 하루 종일 조금씩 늘어나 수천 개가 되었습니다. 문제는 서버와 클라이언트 가운데 어느 쪽에 있나요? 그동안 클라이언트 쪽의 연결은 어떤 상태에 있을까요?
해설 보기
CLOSE-WAIT는 상대의 FIN을 받았는데 내 애플리케이션이 아직 소켓을 닫지 않은 상태이므로, 문제는 CLOSE-WAIT가 쌓이는 서버의 애플리케이션 코드에 있습니다. 예외가 나거나 시간이 초과된 경로에서 소켓이나 연결 객체를 닫지 않는 경우가 흔합니다. 클라이언트는 FIN을 보내고 ACK를 받았으므로 FIN-WAIT-2에서 서버의 FIN을 기다립니다. 클라이언트가 소켓을 이미 닫아 그 연결을 쓰는 프로그램이 없다면, Linux는 tcp_fin_timeout(기본 60초)이 지나면 FIN-WAIT-2 연결을 정리합니다. 반면 서버의 CLOSE-WAIT는 애플리케이션이 닫을 때까지 남아 파일 디스크립터를 계속 차지합니다.
3. 요청마다 새 연결을 맺는 클라이언트
Linux 서버 한 대가 다른 API 서버 203.0.113.5:443으로 요청마다 연결을 새로 맺고, 응답을 받으면 먼저 연결을 닫습니다. 임시 포트 범위와 TIME-WAIT 시간이 모두 기본값이고 다른 설정은 바꾸지 않았다면, 초당 몇 건 정도에서 문제가 생기기 시작하나요? 가장 먼저 검토할 해결책은 무엇인가요?
해설 보기
먼저 닫는 클라이언트 쪽에 TIME-WAIT가 남고, 같은 목적지로는 임시 포트로만 연결을 구별하므로 TIME-WAIT 동안 그 포트를 다시 쓸 수 없습니다. 기본 범위 32768~60999의 28232개 포트를 60초 동안 하나씩 묶어 두므로, 초당 약 28232 ÷ 60 ≈ 470건을 넘게 계속 보내면 포트가 바닥나 connect()가 실패하기 시작합니다. 가장 먼저 검토할 것은 HTTP Keep-Alive나 연결 풀로 연결을 재사용하는 것입니다. 포트 범위를 넓히거나 tcp_tw_reuse를 켜는 것은 한도를 조금 늘릴 뿐 요청마다 연결을 맺는 구조는 바꾸지 못합니다.
다음 글 흐름 제어와 혼잡 제어에서는 연결이 맺어진 뒤 TCP가 한 번에 얼마나 보낼지를 수신 측의 여유와 네트워크의 상태에 맞추어 정하는 방법을 살펴봅니다.