본문 바로가기

TCP의 핵심 특성

IP 위에서 연결 지향의 신뢰성 있는 바이트 스트림을 제공하는 TCP의 네 가지 성질을 정리합니다. 메시지 경계를 애플리케이션이 정해야 하는 이유를 구분자와 길이 헤더로 확인하고, 세그먼트 헤더와 포트, 4-tuple로 연결을 구별하는 방법, 연결 상태를 확인하는 명령을 함께 봅니다.

지금까지는 패킷이 네트워크를 건너 목적지 컴퓨터에 닿기까지를 보았습니다. 하지만 컴퓨터에 도착하는 것만으로는 끝나지 않습니다. 한 컴퓨터에서 브라우저와 메신저, 데이터베이스 클라이언트가 함께 돌고 있다면 도착한 데이터를 어느 프로그램에 넘길지 정해야 하고, IP는 패킷을 잃어버리거나 순서를 바꿔 전달할 수 있으므로 그 문제도 누군가 해결해야 합니다. 이 일을 맡는 것이 전송 계층(transport layer)이며, 그 대표가 TCP(transmission control protocol)입니다. 이 장에서는 RFC 9293에 정의된 TCP를 차례로 살펴봅니다.

TCP가 제공하는 네 가지 성질

연결 지향(connection-oriented)은 데이터를 주고받기 전에 양쪽이 연결 상태를 만든다는 뜻입니다. 다음 글에서 볼 3-way 핸드셰이크(3-way handshake)로 서로의 시작 순서 번호와 옵션을 맞추고, 그 뒤의 세그먼트는 이 상태를 기준으로 해석합니다. 이 연결은 양 끝의 운영체제만 기억하는 논리적인 것입니다. 회선 교환처럼 중간 경로를 독점하지 않으며, 중간의 라우터는 TCP 연결이 있는지조차 모릅니다.

신뢰성(reliability)은 순서 번호와 확인 응답(ACK), 재전송, 체크섬으로 손실과 중복, 손상을 애플리케이션에게서 숨긴다는 뜻입니다. 보낸 데이터의 ACK가 오지 않으면 다시 보내고, 같은 데이터가 두 번 오면 하나를 버리며, 체크섬이 맞지 않는 세그먼트는 버리고 재전송을 기다립니다. 다만 네트워크가 계속 끊겨 있거나 상대 컴퓨터가 꺼지면 TCP도 데이터를 전달할 수 없고, 결국 연결 오류로 알립니다. 또 ACK는 상대의 TCP가 바이트를 받았다는 뜻일 뿐, 상대 애플리케이션이 그 데이터를 읽고 처리했다는 뜻은 아닙니다. 주문이 저장되었는지 같은 업무의 성공은 애플리케이션이 따로 응답으로 알려야 합니다.

순서 보장(ordered delivery)은 보낸 순서대로 받는다는 뜻입니다. TCP는 보내는 모든 바이트에 순서 번호(sequence number)를 매기므로, 수신 측은 순서가 바뀌어 도착한 세그먼트를 잠시 쥐고 있다가 빈 곳이 채워지면 원래 순서로 애플리케이션에 넘깁니다.

바이트 스트림(byte stream)은 TCP가 데이터를 끊김 없는 바이트의 흐름으로 다룬다는 뜻입니다. 이것이 처음 소켓 프로그래밍을 할 때 가장 많이 놓치는 성질입니다.

메시지 경계는 애플리케이션이 정한다

TCP는 애플리케이션이 send()를 몇 번 불렀는지 기억하지 않습니다. ABC와 DEF를 두 번에 나누어 써도, TCP는 그 여섯 바이트를 자기 사정에 맞게 ABCD와 EF 두 세그먼트로 보낼 수 있고, 받는 쪽의 recv()는 AB와 CDEF를 돌려줄 수 있습니다. 보장되는 것은 바이트의 순서뿐입니다.

동일한 여섯 바이트의 서로 다른 쓰기·세그먼트·읽기 경계

두 write의 바이트를 순서대로 이어 보냅니다. 수신 앱은 AB·CDEF로 읽을 수도 있습니다. 각 단계의 값과 전환 이유를 비교합니다.

TCP · 바이트 스트림

바이트 순서는 같아도 쓰기·세그먼트·읽기 경계는 달라진다

TCP는 ABCDEF의 순서를 전달합니다. 메시지 길이나 구분자는 애플리케이션이 정해야 하며, 한 번 쓴 데이터가 한 번의 read로 반환된다는 보장은 없습니다.

쓰기가 달라도 읽기는 같은 바이트 순서를 따른다ABC와 DEF를 두 번 쓴 여섯 바이트가 ABCD와 EF 두 세그먼트로 전송되고 AB와 CDEF로 읽히는 가능한 예입니다. 각 단계의 바이트 순서는 ABCDEF로 같습니다.앱 writeABCDEFTCP 세그먼트ABCDEF앱 readABCDEF
쓰기가 달라도 읽기는 같은 바이트 순서를 따른다ABC와 DEF를 두 번 쓴 여섯 바이트가 ABCD와 EF 두 세그먼트로 전송되고 AB와 CDEF로 읽히는 가능한 예입니다. 각 단계의 바이트 순서는 ABCDEF로 같습니다.앱 writeABCDEFTCP 세그먼트ABCDEF앱 readABCDEF

가능한 분할의 한 예입니다. read 호출 크기·반환 횟수는 고정되지 않으며 메시지 경계는 애플리케이션이 정의합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 앱 쓰기부터 앱 읽기까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목동일한 여섯 바이트의 서로 다른 쓰기·세그먼트·읽기 경계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
앱 쓰기

두 write의 바이트를 순서대로 이어 보냅니다.

  • 6바이트·경계 1: ABC
  • 6바이트·경계 2: DEF
  • 6바이트·경계 3: 총 ABCDEF 6B
  • ABC와 DEF의 총 6바이트; 쓰기 경계 두 개
세그먼트

가능한 전송 분할 ABCD·EF를 비교합니다.

  • 6바이트·경계 1: ABCD
  • 6바이트·경계 2: EF
  • 6바이트·경계 3: 같은 6B
  • 같은 6바이트를 다른 세그먼트 경계로 나눔
앱 읽기

수신 앱은 AB·CDEF로 읽을 수도 있습니다.

  • 6바이트·경계 1: AB
  • 6바이트·경계 2: CDEF
  • 6바이트·경계 3: 같은 순서 6B
  • 바이트 순서는 유지; 메시지 경계는 앱 규칙이며 반환 횟수는 고정되지 않음

동일한 여섯 바이트의 서로 다른 쓰기·세그먼트·읽기 경계

두 write의 바이트를 순서대로 이어 보냅니다. 수신 앱은 AB·CDEF로 읽을 수도 있습니다. 각 단계의 값과 전환 이유를 비교합니다.

TCP · 바이트 스트림

바이트 순서는 같아도 쓰기·세그먼트·읽기 경계는 달라진다

TCP는 ABCDEF의 순서를 전달합니다. 메시지 길이나 구분자는 애플리케이션이 정해야 하며, 한 번 쓴 데이터가 한 번의 read로 반환된다는 보장은 없습니다.

쓰기가 달라도 읽기는 같은 바이트 순서를 따른다ABC와 DEF를 두 번 쓴 여섯 바이트가 ABCD와 EF 두 세그먼트로 전송되고 AB와 CDEF로 읽히는 가능한 예입니다. 각 단계의 바이트 순서는 ABCDEF로 같습니다.앱 writeABCDEFTCP 세그먼트ABCDEF앱 readABCDEF
쓰기가 달라도 읽기는 같은 바이트 순서를 따른다ABC와 DEF를 두 번 쓴 여섯 바이트가 ABCD와 EF 두 세그먼트로 전송되고 AB와 CDEF로 읽히는 가능한 예입니다. 각 단계의 바이트 순서는 ABCDEF로 같습니다.앱 writeABCDEFTCP 세그먼트ABCDEF앱 readABCDEF

가능한 분할의 한 예입니다. read 호출 크기·반환 횟수는 고정되지 않으며 메시지 경계는 애플리케이션이 정의합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 앱 쓰기부터 앱 읽기까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목동일한 여섯 바이트의 서로 다른 쓰기·세그먼트·읽기 경계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
앱 쓰기

두 write의 바이트를 순서대로 이어 보냅니다.

  • 6바이트·경계 1: ABC
  • 6바이트·경계 2: DEF
  • 6바이트·경계 3: 총 ABCDEF 6B
  • ABC와 DEF의 총 6바이트; 쓰기 경계 두 개
세그먼트

가능한 전송 분할 ABCD·EF를 비교합니다.

  • 6바이트·경계 1: ABCD
  • 6바이트·경계 2: EF
  • 6바이트·경계 3: 같은 6B
  • 같은 6바이트를 다른 세그먼트 경계로 나눔
앱 읽기

수신 앱은 AB·CDEF로 읽을 수도 있습니다.

  • 6바이트·경계 1: AB
  • 6바이트·경계 2: CDEF
  • 6바이트·경계 3: 같은 순서 6B
  • 바이트 순서는 유지; 메시지 경계는 앱 규칙이며 반환 횟수는 고정되지 않음

그래서 메시지를 주고받는 프로그램은 어디서 한 메시지가 끝나는지를 스스로 정해야 합니다. 흔한 방법은 두 가지입니다. 하나는 메시지 끝에 정해 둔 구분자, 예를 들어 개행을 붙이는 것입니다. 다음 코드는 ABC\nDEF\n을 보냈는데 recv()가 세 조각으로 나누어 돌려준 상황을 흉내 냅니다. 받은 조각을 버퍼에 이어 붙이고, 구분자가 나올 때마다 메시지 하나를 꺼냅니다.

chunks = [b"AB", b"C\nD", b"EF\n"]       # recv()가 돌려줄 수 있는 조각의 한 예
pending = b""
messages = []

for chunk in chunks:
    pending += chunk
    while b"\n" in pending:
        message, pending = pending.split(b"\n", 1)
        messages.append(message)

print(messages)
print(pending)
[b'ABC', b'DEF']
b''

다른 하나는 메시지 앞에 길이를 적는 것입니다. 구분자 방식은 메시지 안에 구분자가 들어가면 곤란하지만, 길이 방식은 본문에 무엇이 들어 있든 상관없습니다. 다음 코드는 메시지마다 앞 4바이트에 길이를 적고, 받는 쪽에서 길이 헤더가 중간에 잘려 와도 다시 맞춰 읽습니다. 길이는 큰 자리 바이트부터 적는 빅 엔디언으로 적으며, struct 형식 문자열 맨 앞의 !가 이 순서를 뜻합니다.

import struct

def frame(payload):
    return struct.pack("!I", len(payload)) + payload       # 4바이트 길이 + 본문

stream = frame(b"hello") + frame(b"TCP")
chunks = [stream[:3], stream[3:10], stream[10:]]            # 길이 헤더도 중간에서 잘릴 수 있다

pending = b""
for chunk in chunks:
    pending += chunk
    while len(pending) >= 4:
        (size,) = struct.unpack("!I", pending[:4])
        if len(pending) < 4 + size:
            break                                           # 본문이 아직 덜 왔다
        print(pending[4:4 + size])
        pending = pending[4 + size:]
print("남은 바이트:", len(pending))
b'hello'
b'TCP'
남은 바이트: 0

첫 조각은 3바이트라 길이 헤더조차 다 오지 않았으므로 기다립니다. 둘째 조각까지 오면 hello를 꺼낼 수 있고, 남은 1바이트는 다음 메시지의 길이 헤더 앞부분입니다. 셋째 조각이 와서야 TCP가 완성됩니다. HTTP/1.1의 Content-Length, HTTP/2의 프레임 헤더처럼 실제 프로토콜도 대부분 이 두 방법 가운데 하나를 씁니다. Python Socket HOWTO도 같은 이유로 메시지의 길이나 구분자를 정하라고 권합니다.

세그먼트 헤더 읽기

TCP가 주고받는 단위를 세그먼트(segment)라고 하며, 모든 세그먼트는 다음 헤더로 시작합니다. 옵션이 없으면 20바이트이고, 옵션을 붙이면 최대 60바이트까지 늘어납니다.

필드크기설명
Source Port, Destination Port각 16비트출발지와 목적지의 포트 번호
Sequence Number32비트이 세그먼트가 실은 첫 바이트의 순서 번호. SYN이면 시작 순서 번호(ISN)
Acknowledgment Number32비트ACK 플래그가 켜져 있을 때, 다음에 받기를 기대하는 바이트의 번호
Data Offset4비트헤더 길이를 4바이트 단위로 적은 값
Control Bits8비트CWR, ECE, URG, ACK, PSH, RST, SYN, FIN 플래그
Window16비트수신 측이 더 받을 수 있는 양. Window Scale 옵션을 쓰면 배수를 곱한다
Checksum16비트의사 헤더(pseudo header), TCP 헤더, 데이터 전체의 오류 검출 값
Urgent Pointer16비트URG 플래그가 켜져 있을 때 긴급 데이터의 위치
Options가변MSS, Window Scale, Timestamps, SACK Permitted 등

플래그는 세그먼트의 용도를 알려 줍니다.

플래그뜻주로 보이는 때
SYN연결을 시작하고 순서 번호를 맞춘다3-way 핸드셰이크
ACKAcknowledgment Number가 유효하다연결이 맺어진 뒤의 거의 모든 세그먼트
FIN더 보낼 데이터가 없다정상 종료
RST연결을 즉시 끊는다닫힌 포트로 온 요청, 상태가 맞지 않는 세그먼트
PSH받은 데이터를 지체 없이 애플리케이션에 넘겨 달라대화형 통신
URGUrgent Pointer가 유효하다과거의 Telnet 등, 지금은 거의 쓰지 않음
ECE, CWRECN으로 혼잡을 알리고 응답한다ECN을 쓰는 연결

다음 코드는 클라이언트의 SYN과 서버의 SYN+ACK 헤더를 struct로 만든 뒤 다시 읽어 냅니다. 플래그 바이트는 가장 낮은 비트부터 FIN, SYN, RST, PSH, ACK, URG, ECE, CWR 순서입니다. 실제 SYN에는 MSS 같은 옵션이 붙지만 여기서는 생략했고, 체크섬도 계산하지 않고 0으로 두었습니다.

import struct

FORMAT = "!HHIIBBHHH"                    # 포트 2개, 순서 번호, ACK 번호, 오프셋, 플래그, 윈도우, 체크섬, 긴급 포인터
NAMES = ["FIN", "SYN", "RST", "PSH", "ACK", "URG", "ECE", "CWR"]

segments = [
    struct.pack(FORMAT, 50234, 443, 1000, 0, 5 << 4, 0x02, 64240, 0, 0),       # 클라이언트의 SYN
    struct.pack(FORMAT, 443, 50234, 7000, 1001, 5 << 4, 0x12, 65535, 0, 0),    # 서버의 SYN+ACK
]
for seg in segments:
    src, dst, seq, ack, offset, bits, window, _, _ = struct.unpack(FORMAT, seg)
    flags = "+".join(name for i, name in enumerate(NAMES) if bits & (1 << i))
    print(f"{src} -> {dst} seq={seq} ack={ack} 헤더 {(offset >> 4) * 4}바이트 {flags} 윈도우 {window}")
50234 -> 443 seq=1000 ack=0 헤더 20바이트 SYN 윈도우 64240
443 -> 50234 seq=7000 ack=1001 헤더 20바이트 SYN+ACK 윈도우 65535

Data Offset 5는 5 × 4 = 20바이트라는 뜻이고, 0x12는 SYN 비트(0x02)와 ACK 비트(0x10)가 함께 켜진 값입니다. 서버의 ACK 번호가 1001인 것은 SYN이 데이터는 없어도 순서 번호 하나를 차지하기 때문인데, 이 규칙은 다음 글에서 자세히 봅니다.

더 깊이: TCP 체크섬은 얼마나 믿을 수 있나

헤더의 체크섬은 2장 4편에서 본 IPv4 헤더 체크섬과 같은 16비트 1의 보수 합이며, TCP는 헤더와 데이터에 더해 출발지와 목적지 IP 주소를 담은 의사 헤더(pseudo-header)까지 함께 더합니다. 16비트 합은 계산이 싼 대신 약합니다. 예를 들어 덧셈은 순서를 따지지 않으므로, 16비트 단어 두 개의 자리가 서로 바뀌는 오류는 찾지 못합니다.

Stone과 Partridge가 SIGCOMM 2000에 발표한 논문 "When the CRC and TCP Checksum Disagree"는 이 약점이 실제로 문제가 되는지를 쟀습니다. 두 해 동안 모은 인터넷 패킷을 조사했더니, 링크 계층의 CRC가 40억 개에 하나를 빼고 모든 오류를 잡아야 할 링크에서도 1,100개에 하나에서 3만 2,000개에 하나꼴로 TCP 체크섬이 맞지 않는 패킷이 있었습니다. 링크의 CRC가 검사하지 않는 곳, 곧 네트워크의 여러 계층에서 오류가 생긴다는 뜻입니다. 저자들은 이 결과를 분석해 TCP 체크섬이 오류를 놓치는 패킷이 대략 1,600만 개에 하나에서 100억 개에 하나꼴이라고 추정했습니다.

하루에 수억 개의 패킷을 주고받는 서비스라면 이 비율은 무시할 수 있는 값이 아닙니다. 그래서 데이터의 무결성이 중요한 곳에서는 TCP 체크섬에만 기대지 않고, TLS의 메시지 인증이나 파일의 해시처럼 위 계층에서 더 강한 검사를 함께 씁니다.

포트로 프로그램을 찾는다

IP 주소가 컴퓨터를 찾는다면, 포트 번호(port number)는 그 컴퓨터 안에서 데이터를 받을 프로그램을 찾습니다. 포트는 16비트이므로 0부터 65535까지이고, RFC 6335는 이를 세 범위로 나눕니다.

범위이름쓰임과 예
0~1023System(well-known)표준 서비스. SSH 22, DNS 53, HTTP 80, HTTPS 443
1024~49151User(registered)IANA에 등록된 애플리케이션. MySQL 3306, PostgreSQL 5432
49152~65535Dynamic(private)등록하지 않는 범위로, 임시 포트(ephemeral port)에 쓰도록 권장

클라이언트가 연결을 맺으면 운영체제가 출발지 포트로 임시 포트 하나를 골라 줍니다. 실제로 고르는 범위는 운영체제의 설정을 따릅니다. Windows는 49152부터 65535까지를 쓰지만, Linux는 ip_local_port_range 설정의 기본값인 32768부터 60999까지를 씁니다. 또 Linux에서 1024 미만의 포트에 바인드하려면 관리자 권한이나 CAP_NET_BIND_SERVICE 권한이 필요한데, 이것도 프로토콜이 아니라 운영체제의 정책입니다.

소켓과 4-tuple

소켓(socket)은 운영체제가 애플리케이션에 내주는 통신의 끝점입니다. 서버는 정해진 포트에 소켓을 바인드하고 listen()으로 연결을 기다리며, 연결 요청이 들어오면 accept()가 그 연결만을 위한 새 소켓을 돌려줍니다. 그래서 서버에는 연결을 기다리는 리스닝 소켓 하나와, 연결마다 하나씩 생기는 연결 소켓이 따로 있습니다.

연결 소켓들은 모두 같은 로컬 포트, 예를 들어 443을 쓰는데도 운영체제는 도착한 세그먼트를 정확히 제 연결로 보냅니다. TCP 연결은 로컬 IP, 로컬 포트, 원격 IP, 원격 포트의 네 값, 곧 4-tuple로 구별되기 때문입니다. 프로토콜까지 더해 5-tuple이라고 부르기도 합니다. 다음 코드는 한 프로세스 안에서 루프백으로 연결을 하나 맺고 양쪽 끝점을 비교합니다.

import socket

listener = socket.create_server(("127.0.0.1", 0))         # 포트 0이면 운영체제가 빈 포트를 고른다
client = socket.create_connection(listener.getsockname())
conn, addr = listener.accept()

print("서버 연결의 로컬 = 클라이언트의 원격:", conn.getsockname() == client.getpeername())
print("서버 연결의 원격 = 클라이언트의 로컬:", conn.getpeername() == client.getsockname())
print("연결 소켓과 리스닝 소켓의 포트가 같다:", conn.getsockname()[1] == listener.getsockname()[1])
print("accept()가 알려 준 주소 = 클라이언트의 로컬:", addr == client.getsockname())

for s in (client, conn, listener):
    s.close()
서버 연결의 로컬 = 클라이언트의 원격: True
서버 연결의 원격 = 클라이언트의 로컬: True
연결 소켓과 리스닝 소켓의 포트가 같다: True
accept()가 알려 준 주소 = 클라이언트의 로컬: True

포트 번호 자체는 실행할 때마다 달라지므로 출력하지 않고 관계만 비교했습니다. 한쪽의 로컬 끝점이 다른 쪽의 원격 끝점과 정확히 맞물리고, 서버의 연결 소켓은 리스닝 소켓과 같은 포트를 쓰면서도 원격 끝점으로 구별됩니다. create_connection()이 accept()보다 먼저 끝날 수 있는 것은, 연결을 맺는 일을 운영체제가 대신 해 두고 accept()는 이미 맺어진 연결을 꺼내 가기만 하기 때문입니다.

포트와 연결 상태를 확인하는 명령

서버를 운영하다 보면 어떤 프로그램이 어느 포트를 쓰는지, 연결이 어떤 상태로 몇 개나 있는지를 자주 확인합니다. Linux에서는 ss를 씁니다.

ss -tlnp                                   # 리스닝 중인 TCP 소켓과 그 프로세스
ss -tlnp 'sport = :8080'                   # 8080번 포트를 쓰는 리스닝 소켓만
ss -tn state established                   # 맺어진 연결만
ss -tan | awk 'NR > 1 {print $1}' | sort | uniq -c | sort -rn   # 상태별 개수

-t는 TCP, -l은 리스닝 소켓, -n은 이름 대신 숫자, -p는 프로세스, -a는 모든 상태를 뜻합니다. 마지막 명령은 첫 줄의 머리글을 빼고 상태 열만 세며, 바쁜 웹 서버라면 다음과 같은 모양이 나옵니다.

   1523 ESTAB
    342 TIME-WAIT
     15 CLOSE-WAIT
      5 LISTEN

Windows에서는 netstat -ano | findstr :8080이나 PowerShell의 Get-NetTCPConnection -LocalPort 8080, macOS에서는 lsof -iTCP -sTCP:LISTEN -n -P로 같은 것을 볼 수 있습니다. 자주 보는 상태는 다음과 같습니다.

상태뜻많을 때 의심할 것
LISTEN서버가 연결을 기다리는 중해당 없음
ESTABLISHED연결이 맺어져 데이터를 주고받는 중연결 풀 설정, 연결이 해제되지 않는 클라이언트
TIME-WAIT먼저 닫은 쪽이 늦게 오는 세그먼트를 기다리는 중짧은 연결을 너무 자주 맺고 끊는지
CLOSE-WAIT상대가 닫았는데 내 쪽은 아직 닫지 않음애플리케이션의 close() 누락
SYN-SENTSYN을 보내고 답을 기다리는 중상대 서버에 닿지 않거나 방화벽이 막는지
SYN-RECEIVEDSYN을 받고 SYN+ACK를 보낸 뒤 마지막 ACK를 기다리는 중대기열 부족이나 SYN Flood

ss는 상태 이름을 ESTAB, SYN-RECV처럼 줄여서 보여 주고, Linux의 netstat은 같은 상태를 TIME_WAIT, SYN_RECV처럼 하이픈 대신 밑줄로 적습니다. TIME-WAIT와 CLOSE-WAIT가 왜 생기는지는 다음 글에서 연결 종료 과정과 함께 봅니다.

정리

  • TCP는 IP 위에서 연결 지향, 신뢰성, 순서 보장을 갖춘 바이트 스트림을 제공하며, 연결 상태는 양 끝의 운영체제만 기억합니다.
  • ACK는 상대 TCP가 바이트를 받았다는 뜻이고, 상대 애플리케이션이 처리를 마쳤다는 뜻은 아닙니다.
  • TCP는 메시지 경계를 지키지 않으므로, 애플리케이션이 구분자나 길이 헤더로 메시지를 나누고 받은 조각을 버퍼에 모아 읽어야 합니다.
  • 세그먼트 헤더는 옵션 없이 20바이트이며, 포트와 순서 번호, ACK 번호, 플래그, 윈도우를 담습니다.
  • 포트는 컴퓨터 안의 프로그램을 찾는 16비트 번호이고, TCP 연결은 로컬과 원격의 IP와 포트를 합친 4-tuple로 구별됩니다.
  • ss로 리스닝 소켓과 연결 상태를 확인할 수 있으며, CLOSE-WAIT가 쌓이면 애플리케이션이 연결을 닫지 않는 것을 의심합니다.

연습 문제

1. 마지막 구분자가 아직 오지 않았다면

구분자 예제에서 마지막 조각만 b"EF"로 바꾸면 messages와 pending은 각각 무엇이 되나요? 상대가 아직 연결을 닫지 않았다면, recv()를 세 번 했다는 사실로 메시지 두 개를 다 받았다고 말할 수 있나요?

해설 보기

messages는 [b'ABC'], pending은 b'DEF'가 됩니다. 두 번째 개행이 아직 오지 않았으므로 DEF는 완성된 메시지가 아니고, 다음 조각이 올 때까지 버퍼에 남겨 두어야 합니다. recv()를 몇 번 했는지는 메시지의 수와 아무 관계가 없습니다. 상대가 연결을 닫아 EOF가 온다면 그때 남은 DEF를 불완전한 메시지로 보고 어떻게 처리할지는 이 프로토콜이 따로 정해 두어야 합니다.

2. 길이 헤더를 그대로 믿으면

길이 헤더 방식에서 악의적인 클라이언트가 길이 필드에 0xFFFFFFFF를 적어 보냈습니다. 받는 쪽이 이 값을 그대로 믿고 그만큼의 본문을 기다리며 버퍼를 키운다면 어떤 문제가 생기나요? 어떻게 막을 수 있나요?

해설 보기

0xFFFFFFFF는 약 4GiB이므로, 받는 쪽은 4GiB의 본문이 올 때까지 메모리를 계속 쌓으며 기다리게 됩니다. 연결 몇 개만으로 서버의 메모리를 바닥낼 수 있는 공격이 됩니다. 프로토콜에 메시지의 최대 크기를 정해 두고, 길이 헤더를 읽자마자 그 값을 넘으면 연결을 끊어야 합니다. 본문이 다 오기까지 기다리는 시간에도 제한을 두면 일부러 아주 천천히 보내는 공격도 막을 수 있습니다.

3. 같은 서버 포트로 맺은 여러 연결

클라이언트 한 대(192.0.2.10)가 서버 203.0.113.5의 443번 포트로 연결을 두 개 맺었습니다. 두 연결은 무엇으로 구별되나요? 이 클라이언트가 Linux의 기본 임시 포트 범위를 쓴다면, 이 서버의 443번 포트로 동시에 맺을 수 있는 연결은 최대 몇 개인가요?

해설 보기

두 연결은 로컬 IP, 원격 IP, 서버 포트가 모두 같고, 클라이언트의 임시 포트만 다릅니다. 4-tuple 가운데 하나만 달라도 다른 연결이므로 운영체제는 두 연결을 구별할 수 있습니다. 같은 클라이언트 IP에서 같은 서버 IP와 포트로 가는 연결은 클라이언트 포트로만 구별되므로, 기본 범위 32768~60999에서 고를 수 있는 60999 − 32768 + 1 = 28232개가 최대입니다. 서버 쪽은 원격 IP와 포트가 다른 클라이언트들이 모두 443번으로 들어올 수 있으므로, 서버 포트 하나가 받을 수 있는 연결 수는 포트 개수가 아니라 메모리와 파일 디스크립터 같은 자원으로 정해집니다. 파일 디스크립터는 프로세스가 연 파일과 소켓을 가리키는 번호로, 연결 하나마다 하나씩 쓰며 운영체제 교재의 파일 시스템 실무에서 자세히 다룹니다.

다음 글 연결 수립과 종료에서는 3-way 핸드셰이크로 연결이 맺어지고 FIN으로 닫히는 과정을 순서 번호와 상태의 변화로 따라가고, TIME-WAIT와 RST가 생기는 상황을 살펴봅니다.