Devin.KR

TCP와 UDP의 전달 방식 - 바이트 흐름, 소켓 상태와 연결 재사용 (CS 기초 11장)

개발자 조회 1

이 장에서 배우는 것

10장에서 패킷이 지나갈 주소와 경로를 살펴봤다. 이번 장은 그 경로 위의 두 프로그램이 데이터를 어떻게 나누어 주고받는지 다룬다. 선수지식은 I/O 대기와 네트워크 계층 구분이다.

  • TCP의 바이트 흐름과 메시지 경계를 구분한다.
  • UDP의 기본 전달 약속과 애플리케이션 책임을 설명한다.
  • 흐름 제어·혼잡 제어·종료 상태를 서로 다른 증거로 읽는다.
  • 연결 풀과 재사용을 요청의 대기 시간에 근거해 판단한다.

개념

서버가 메시지 하나를 보냈는데 수신 코드는 앞부분만 받고 오류를 냈다고 가정한다. 네트워크가 메시지를 잘랐다는 의심이 먼저 든다. 그러나 전송 계층이 약속하는 단위와 애플리케이션이 생각하는 메시지 단위가 다르면 정상적인 전달도 버그처럼 보인다. 이 차이를 정리해야 재전송이나 연결 수 조절이 엉뚱한 처방이 되지 않는다.

TCP가 보장하는 단위는 바이트 흐름이다

TCP는 연결의 양쪽 끝에서 순서 있는 바이트 흐름을 제공한다. 보낸 데이터의 일부가 누락되면 재전송 등으로 복구를 시도하지만 정해진 시간 안에 반드시 성공한다는 약속은 아니다. 연결은 실패할 수 있으며 상대가 받은 바이트를 업무 처리까지 완료했는지도 알려 주지 않는다. 전송 성공과 업무 성공은 별도로 확인한다.

송신자가 두 번 쓰더라도 수신자가 두 번 읽는다는 보장은 없다. 한 번의 읽기에서 일부만 얻거나 여러 쓰기의 내용이 합쳐질 수 있다. 길이 접두어, 구분자, 정해진 길이 같은 애플리케이션 규칙이 있어야 바이트를 메시지로 나눌 수 있다. 길이를 먼저 읽는 방식도 헤더 자체가 나뉘어 올 수 있으므로 필요한 양을 반복해서 모아야 한다.

직접 만든 메시지 예시
---------------------
보낸 논리 메시지  00 03 | c a t
받은 조각 A       00
받은 조각 B       03 c
받은 조각 C       a t
조각 경계와 메시지 경계는 다르다.

대기와 상태는 신뢰성을 위한 비용이다

일반적인 TCP 연결 수립은 요청과 응답을 주고받아 양쪽의 연결 상태를 맞추는 과정이다. 연결 후 수신 측 여유에 맞추는 흐름 제어와 경로의 혼잡에 대응하는 혼잡 제어가 각각 동작한다. 수신자가 데이터를 늦게 소비하는 문제와 중간 경로가 감당하지 못하는 문제는 관측할 증거가 다르다. 둘 다 느리다는 이유로 서버 스레드 수부터 늘리면 문제가 커질 수 있다.

연결 종료도 양방향 바이트 흐름의 종료를 따로 반영한다. 일반적인 능동 종료 쪽에서 보이는 TIME_WAIT는 늦게 도착하는 세그먼트 등과 구분하기 위한 상태이므로 개수만으로 누수라고 단정하지 않는다. CLOSE_WAIT는 상대의 종료를 받았지만 로컬 종료가 아직 남아 있다는 방향의 단서다. 구체적인 유지 시간과 제한은 운영체제와 설정에 따라 확인해야 한다.

항목TCPUDP
애플리케이션에 보이는 단위바이트 흐름데이터그램
전달·순서 복구전송 계층이 제공하는 신뢰성 절차기본 UDP가 보장하지 않음
애플리케이션 책임메시지 경계, 업무 응답, 실패 처리필요한 신뢰성·순서·혼잡 대응 정책
성능 해석연결·대기·재전송 조건 함께 관찰손실 허용 여부와 상위 프로토콜 함께 관찰

UDP는 데이터그램 경계를 유지하지만 도착, 중복 방지, 순서를 기본적으로 보장하지 않는다. 이 특성만으로 항상 TCP보다 빠르다고 말할 수 없다. 애플리케이션이 같은 신뢰성을 직접 구현하면 다른 비용이 생기며, UDP 위의 상위 프로토콜이 연결과 복구를 제공할 수도 있다. 비교할 때는 프로토콜 이름보다 실제로 요구한 전달 조건을 먼저 고정한다.

읽기 함수가 돌려준 바이트 수는 요청한 최대 크기보다 작을 수 있다. 실제 받은 수만큼만 소비하고 남은 메시지는 다음 읽기에 이어 붙이는 상태가 필요하다. 요청한 바이트 수와 받은 바이트 수를 구분한다. 준비 상태 다중화에서 읽기 가능 알림을 받았더라도 이 원칙은 바뀌지 않는다. 알림이 전체 메시지의 완성을 대신하지 않기 때문이다.

TCP의 일반적인 능동 종료 경로다. SYN_SENT에서 연결된 뒤 ESTABLISHED, FIN_WAIT_1, FIN_WAIT_2, TIME_WAIT를 거쳐 닫히며 동시 종료와 오류 경로는 생략했다.

TCP의 일반적인 능동 종료 경로다. SYN_SENT에서 연결된 뒤 ESTABLISHED, FIN_WAIT_1, FIN_WAIT_2, TIME_WAIT를 거쳐 닫히며 동시 종료와 오류 경로는 생략했다.

직접 확인하기

잘린 입력에서도 메시지를 조립한다

parts = [b'\x00', b'\x03c', b'at']
buffer = bytearray()
messages = []
for part in parts:
    buffer.extend(part)
    while len(buffer) >= 2:
        size = int.from_bytes(buffer[:2], 'big')
        if len(buffer) < 2 + size:
            break
        messages.append(bytes(buffer[2:2 + size]))
        del buffer[:2 + size]
print(messages)
print('remaining:', len(buffer))

Python 3로 실행하는 수신 조립 모델이다. 조각 배열은 학습을 위해 직접 정한 입력이며 TCP 캡처 결과가 아니다. 헤더가 한 바이트씩 나뉘어도 길이를 알기 전에는 데이터를 꺼내지 않는다. 실제 서비스에서는 허용 길이, 누적 버퍼와 대기 시간에도 한계를 둬야 한다.

출력
----
[b'cat']
remaining: 0

로컬 소켓에서 바이트 수와 종료를 구분한다

import socket
left, right = socket.socketpair()
try:
    left.settimeout(1)
    right.settimeout(1)
    left.sendall(b'abcd')
    left.shutdown(socket.SHUT_WR)
    chunks = []
    while True:
        chunk = right.recv(2)
        if not chunk:
            break
        chunks.append(chunk)
    print(b''.join(chunks).decode())
    print('bytes:', sum(map(len, chunks)))
finally:
    left.close()
    right.close()

외부 연결 없이 로컬 스트림 소켓 쌍을 사용한다. 이 실습은 TCP 핸드셰이크나 재전송을 측정하지 않고 스트림 읽기와 수신 종료의 의미를 확인한다. recv의 최대 크기를 2로 제한했으므로 적어도 두 번은 데이터를 모아야 한다. 비어 있는 바이트열은 이 스트림에서 수신 종료를 뜻한다.

출력
----
abcd
bytes: 4

같은 서버 포트에 여러 연결이 있는 이유

connections = {
    ('10.42.8.7', 41000, '10.42.8.20', 8443),
    ('10.42.8.7', 41001, '10.42.8.20', 8443),
    ('10.42.8.8', 41000, '10.42.8.20', 8443),
}
print('connections:', len(connections))
print('server ports:', len({row[3] for row in connections}))

같은 TCP 프로토콜 안에서 두 주소와 두 포트로 끝점 쌍을 구분하는 계산 예제다. 포트 값은 실습 입력이며 실제 서비스를 가리키지 않는다. 서버 포트 하나가 곧 연결 한 개라는 해석이 틀린 이유를 보여 준다. 사용 가능한 연결 수의 실제 상한을 계산하는 모델은 아니다.

출력
----
connections: 3
server ports: 1
직접 만든 상태 관찰표
---------------------
ESTABLISHED  연결된 흐름이 있음
CLOSE_WAIT   상대 종료 뒤 로컬 종료가 남음
TIME_WAIT    종료 뒤 프로토콜 상태가 남음
개수만 기록하지 않고 유입·해소 변화도 함께 기록한다.

환경별 차이

환경공통 원리다르게 확인할 항목
리눅스TCP 상태·끝점·바이트 흐름소켓 제한과 포트 사용 범위
macOS동일한 프로토콜 의미상태 표시 도구와 시스템 제한
컨테이너호스트 커널의 네트워크 기능 사용내부 소켓과 호스트 변환 상태를 분리

연결을 재사용하면 매 요청마다 새 연결을 만드는 비용을 줄일 수 있다. 그러나 풀에 보관한 연결이 영원히 살아 있지는 않다. 상대나 중간 장비가 연결을 닫았을 때의 실패 처리는 여전히 필요하다. 풀의 최대 크기, 대기 요청 수와 사용 중인 수를 함께 보면 풀이 작아서 기다리는지 느린 요청이 연결을 오래 쥐고 있는지 구분하기 쉽다.

실습은 고의 손실이나 혼잡을 발생시키지 않는다. 로컬 스트림에서 성공한 결과를 실제 네트워크의 재전송 검증으로 표현하지 않는다. 상태 개수의 변화는 연결 생성 속도와 종료 속도에 영향을 받으므로 단일 시점 캡처만으로 결론을 내리지 않는다. 같은 시간 범위의 요청량과 실패율을 함께 기록해야 정상적인 트래픽 증가와 정리되지 않는 연결을 구분할 수 있다.

실무에서 자주 틀리는 것

1. send 성공을 상대의 처리 완료로 기록한다

증상은 보냈다는 로그가 있는데 상대 결과가 없는 것이다. 흔한 오진은 상대가 응답을 숨겼다고 생각하는 것이다. 실제로 send 성공은 업무 로직의 결과를 전달하지 않으며 연결 실패와 상대 처리 실패가 뒤에 남아 있다. 확인은 전송 단계와 애플리케이션 응답 단계를 나눠 기록하는 것이다. 자동 재시도도 업무가 중복 실행될 가능성을 따로 검토해야 한다.

2. TIME_WAIT 수를 곧바로 메모리 누수라고 부른다

증상은 요청량이 늘면서 종료 관련 상태가 많이 보이는 것이다. 흔한 오진은 소켓을 닫지 않았다고 단정하는 것이다. 실제로 프로토콜상 남는 상태일 수 있고 새 연결을 지나치게 만드는 패턴일 수도 있다. 확인은 상태별 증가·감소와 연결 재사용 여부를 함께 보는 것이다. 반면 CLOSE_WAIT가 지속적으로 쌓이면 로컬 종료 경로를 살펴볼 근거가 된다.

3. 연결 풀만 키우면 지연이 줄어든다고 생각한다

증상은 풀 대기가 길어지며 응답이 늦는 것이다. 흔한 오진은 사용 가능한 연결이 적다는 사실 하나로 원인을 확정하는 것이다. 실제로 느린 원격 응답이 기존 연결을 오래 점유하거나 서버 처리 한계를 넘었을 수 있다. 확인은 대기 시간과 연결 점유 시간, 원격 처리 시간을 나누는 것이다. 풀을 늘린 뒤 처리량이 늘지 않으면 대기를 다른 위치로 옮긴 것일 수 있다.

스스로 확인하기

  1. 3개 끝점 쌍이 서버 포트 1개를 공유하는 예제에서 연결 수가 1이 아닌 이유를 설명하라.
  2. 헤더 2바이트와 본문 3바이트를 한 번의 recv로 읽겠다는 코드가 왜 불완전한지 설명하라.
  3. TIME_WAIT는 늘었다가 줄고 CLOSE_WAIT는 계속 늘어난다. 두 현상에 동일한 누수 진단을 내릴 수 있는가.

해설 1. 같은 프로토콜에서 발신 주소·포트와 수신 주소·포트를 함께 비교한다. 서버 포트가 같아도 발신 끝점이 다르면 연결을 구분할 수 있다.

해설 2. 흐름은 읽기 호출의 경계를 보장하지 않는다. 헤더부터 필요한 수만큼 모으고 길이를 해석한 뒤 본문도 반복해서 모아야 한다.

해설 3. 같은 결론을 내릴 수 없다. 종료 후 남는 상태의 유입과 해소를 보고, 상대 종료 후 로컬 정리가 남는 경로는 별도로 조사한다.

출처·작성 안내: 본문 설명, 예제, 문제와 도식은 이 장을 위해 직접 작성했다. 아래 문서는 기술적 사실 확인에 참고했으며 원문 문장·표·그림·코드를 발췌하거나 번역하지 않았다.

참고: RFC 9293 TCP, Python socket 문서, RFC 768 UDP

다음 12장에서는 연결할 주소를 이름으로 찾고 그 상대를 신뢰할 수 있는지 확인하는 과정을 연결한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.