본문 바로가기
TYLER SONGBlog
← Blog index
CS9 min read

Transport: 프로세스 간 통신을 책임지는 전송 계층

전송 계층(transport layer)은 네트워크로 연결된 호스트의 프로세스 사이에서 데이터를 전달한다. TCP는 신뢰성 있는 순서 보장 바이트 스트림을 제공하고, UDP는 전달 보장 없이 독립된 데이터그램을 전송한다. 애플리케이션은 지연 시간, 신뢰성, 메시지 경계 필요 여부에 따라 전송 프로토콜을 선택한다.

By 송민성

1. 개념

전송 계층(transport layer)은 인터넷 프로토콜(IP)이 제공하는 호스트 간 통신을 프로세스 간 통신으로 확장한다.

IP 주소가 호스트를 식별한다면 포트 번호(port number)는 호스트 내부의 통신 종단점을 식별한다. 운영체제는 출발지·목적지 IP 주소, 출발지·목적지 포트 번호, 전송 프로토콜을 이용해 수신 데이터를 적절한 소켓(socket)으로 전달한다. 이를 역다중화(demultiplexing)라고 한다.

대표적인 전송 프로토콜은 다음과 같다.

프로토콜연결 방식전달 순서재전송메시지 경계
TCP연결 지향보장수행보존하지 않음
UDP비연결보장하지 않음수행하지 않음보존
QUIC연결 지향스트림별 보장수행스트림 단위 제공

TCP(Transmission Control Protocol)는 애플리케이션에 신뢰성 있는 순서 보장 바이트 스트림(byte stream)을 제공한다. UDP(User Datagram Protocol)는 독립된 데이터그램(datagram)을 별도 연결 설정 없이 전송한다. QUIC(Quick UDP Internet Connections)은 UDP 위에서 신뢰성, 혼잡 제어, 다중 스트림, 암호화를 제공하는 전송 프로토콜이다.

2. 왜 사용하는가

IP는 패킷이 목적지 호스트에 도착하도록 전달하지만 다음 문제를 해결하지 않는다.

  • 어떤 프로세스가 데이터를 받아야 하는가
  • 데이터가 유실되거나 중복되면 어떻게 처리할 것인가
  • 패킷 순서가 바뀌면 어떻게 복원할 것인가
  • 수신자가 처리할 수 있는 속도를 어떻게 맞출 것인가
  • 네트워크 혼잡을 어떻게 줄일 것인가

TCP는 이런 기능을 프로토콜 내부에서 처리한다. HTTP/1.1, HTTP/2, SSH, PostgreSQL처럼 정확하고 순서가 맞는 데이터가 필요한 프로토콜에 적합하다.

UDP는 연결 설정과 재전송을 강제하지 않는다. DNS 질의, 실시간 음성·영상, 온라인 게임처럼 오래된 데이터의 재전송보다 낮은 지연 시간이 중요한 경우에 사용한다. 필요한 신뢰성이나 순서 제어는 애플리케이션이 직접 구현할 수 있다.

3. 동작 원리

TCP 연결 설정

TCP는 데이터를 보내기 전에 3방향 핸드셰이크(three-way handshake)를 수행한다.

text
클라이언트 → 서버: SYN
클라이언트 ← 서버: SYN + ACK
클라이언트 → 서버: ACK

양쪽은 이 과정에서 초기 순서 번호(sequence number)를 교환하고 연결 상태를 만든다.

신뢰성 있는 전달

TCP는 각 바이트에 순서 번호를 부여한다. 수신자는 확인 응답(acknowledgment)을 보내고, 송신자는 확인되지 않은 데이터가 일정 시간 안에 승인되지 않으면 재전송한다.

수신 순서가 바뀐 세그먼트(segment)는 순서 번호를 기준으로 재조립된다. 이미 받은 데이터가 다시 도착하면 중복을 제거한다.

이 보장은 애플리케이션이 전달한 메시지 단위가 아니라 바이트 순서에 적용된다. 송신자가 두 번 write()해도 수신자가 두 번 read()한다는 보장은 없다.

text
송신:
write("ABC")
write("DEF")

가능한 수신 결과:
read() → "ABCDEF"

또는:
read() → "AB"
read() → "CDEF"

따라서 TCP 애플리케이션 프로토콜은 길이 접두사(length prefix), 구분자(delimiter), 고정 길이 중 하나로 메시지 경계를 정의해야 한다.

흐름 제어와 혼잡 제어

흐름 제어(flow control)는 송신자가 수신자의 처리 능력을 넘지 않도록 제한한다. 수신자는 수신 윈도(receive window)를 알려 현재 받을 수 있는 데이터 크기를 전달한다.

혼잡 제어(congestion control)는 네트워크 상태에 맞춰 송신량을 조절한다. 패킷 손실과 왕복 시간(round-trip time, RTT) 같은 신호를 이용해 전송 속도를 높이거나 낮춘다.

두 제어의 목적은 다르다.

  • 흐름 제어: 수신자 버퍼 보호
  • 혼잡 제어: 네트워크 과부하 완화

UDP 데이터그램 전달

UDP는 연결 설정, 순서 복구, 재전송, 흐름 제어를 제공하지 않는다. 각 데이터그램은 독립적으로 전달되며 유실, 중복, 순서 변경이 발생할 수 있다.

UDP는 메시지 경계를 보존한다. 한 번 전송한 데이터그램은 수신 측에서도 하나의 데이터그램으로 처리된다. 단, 수신 버퍼보다 데이터그램이 크면 잘릴 수 있으므로 충분한 버퍼를 준비해야 한다.

4. 코드 예제

다음 예제는 TCP 바이트 스트림에서 4바이트 길이 접두사를 사용해 메시지 경계를 복원한다. Deno가 설치된 환경에서 실행할 수 있다.

서버는 여러 TCP 조각을 버퍼에 모은 뒤 완성된 메시지만 출력한다.

typescript
// server.ts const listener = Deno.listen({ hostname: "127.0.0.1", port: 8080, transport: "tcp", }); console.log("TCP 서버 실행: 127.0.0.1:8080"); for await (const connection of listener) { handleConnection(connection); } async function handleConnection(connection: Deno.Conn): Promise<void> { let pending = new Uint8Array(0); const readBuffer = new Uint8Array(1024); try { while (true) { const bytesRead = await connection.read(readBuffer); if (bytesRead === null) { break; } pending = concat(pending, readBuffer.slice(0, bytesRead)); while (pending.length >= 4) { const view = new DataView( pending.buffer, pending.byteOffset, pending.byteLength, ); const messageLength = view.getUint32(0); if (pending.length < 4 + messageLength) { break; } const body = pending.slice(4, 4 + messageLength); console.log("수신:", new TextDecoder().decode(body)); pending = pending.slice(4 + messageLength); } } } finally { connection.close(); } } function concat(left: Uint8Array, right: Uint8Array): Uint8Array { const result = new Uint8Array(left.length + right.length); result.set(left); result.set(right, left.length); return result; }

클라이언트는 메시지의 바이트 길이를 4바이트 정수로 기록한 뒤 본문과 함께 전송한다.

typescript
// client.ts const connection = await Deno.connect({ hostname: "127.0.0.1", port: 8080, transport: "tcp", }); try { await sendMessage(connection, "첫 번째 메시지"); await sendMessage(connection, "두 번째 메시지"); } finally { connection.close(); } async function sendMessage( connection: Deno.Conn, message: string, ): Promise<void> { const body = new TextEncoder().encode(message); const packet = new Uint8Array(4 + body.length); const view = new DataView(packet.buffer); view.setUint32(0, body.length); packet.set(body, 4); let offset = 0; while (offset < packet.length) { offset += await connection.write(packet.slice(offset)); } }

터미널 두 개에서 서버와 클라이언트를 순서대로 실행한다.

shell
deno run --allow-net server.ts
shell
deno run --allow-net client.ts

read() 한 번이 메시지 하나를 반환한다고 가정하지 않는다. 서버는 남은 바이트를 pending에 보관하고, 길이 접두사와 본문이 모두 도착했을 때만 메시지를 처리한다.

5. 시간 복잡도 또는 성능 특성

전송 계층의 성능은 빅오 표기보다 지연 시간, 처리량, 손실률, 연결 수로 평가한다.

연결 설정 비용

TCP 연결은 일반적으로 데이터를 보내기 전에 1 RTT의 3방향 핸드셰이크가 필요하다. TLS를 별도로 사용하면 TLS 핸드셰이크 비용도 추가된다. 연결 재사용과 지속 연결(keep-alive)은 반복되는 연결 설정 비용을 줄인다.

UDP는 전송 계층 연결 설정이 없다. 첫 데이터그램부터 데이터를 담아 보낼 수 있지만, 애플리케이션 수준의 인증이나 연결 상태가 필요하면 별도 왕복이 발생한다.

순서 보장 비용

TCP는 하나의 바이트 스트림에서 앞부분의 손실이 복구될 때까지 뒤에 도착한 바이트를 애플리케이션에 전달하지 않는다. 이를 선두 차단(head-of-line blocking)이라고 한다.

QUIC은 여러 독립 스트림을 사용한다. 한 스트림의 손실이 다른 스트림의 데이터 전달까지 막지는 않는다. 같은 스트림 내부의 순서는 계속 보장한다.

메시지 처리 비용

길이가 n인 데이터를 복사하거나 인코딩·디코딩하는 작업은 O(n) 시간이 필요하다. 예제의 concat()은 미처리 버퍼 전체를 매번 복사하므로 작은 학습용 서버에는 충분하지만, 큰 메시지나 높은 처리량에서는 링 버퍼(ring buffer)나 청크 목록을 사용하는 편이 적합하다.

최대 데이터 크기

TCP는 바이트 스트림이므로 애플리케이션 메시지 크기를 직접 제한해야 한다. 길이 접두사를 신뢰하고 그대로 메모리를 할당하면 메모리 고갈 공격을 받을 수 있다.

UDP 데이터그램의 길이 필드는 16비트이며 헤더를 포함한다. 이론적인 IPv4 UDP 페이로드 최대 크기는 65,507바이트지만, 실제 네트워크에서는 경로 최대 전송 단위(path MTU)를 넘는 데이터그램이 단편화되거나 폐기될 수 있다. 큰 데이터는 애플리케이션 수준에서 작게 나누는 편이 안전하다.

6. 실무 사용 사례

웹 서비스

HTTP/1.1과 HTTP/2는 일반적으로 TCP 위에서 동작한다. TCP 연결 재사용은 요청마다 핸드셰이크를 반복하는 비용을 줄인다.

HTTP/3는 QUIC 위에서 동작한다. QUIC은 UDP를 기반으로 동작하며 TLS 1.3 암호화를 프로토콜에 통합한다.

데이터베이스 연결

PostgreSQL 클라이언트와 서버는 TCP 연결을 통해 메시지를 교환한다. 연결 설정과 인증 비용이 있으므로 애플리케이션 서버는 연결 풀(connection pool)을 사용한다. 풀 크기를 과도하게 늘리면 데이터베이스의 메모리와 스케줄링 비용이 증가한다.

실시간 통신

음성 통화와 게임 상태 동기화에서는 늦게 도착한 데이터의 가치가 낮을 수 있다. 이런 경우 UDP를 사용하고 타임스탬프, 순서 번호, 손실 보정처럼 서비스에 필요한 기능만 추가한다.

반대로 채팅 메시지, 결제 요청, 파일 전송은 정확한 전달과 순서가 중요하므로 TCP나 QUIC의 신뢰성 있는 스트림을 사용한다.

로드 밸런서와 프록시

계층 4 로드 밸런서(L4 load balancer)는 IP 주소, 포트 번호, 전송 프로토콜 정보를 기준으로 연결을 분산한다. 애플리케이션 데이터를 해석하지 않아 계층 7 프록시보다 단순하지만, HTTP 경로나 헤더를 기준으로 라우팅할 수는 없다.

7. 주의할 점

TCP를 메시지 프로토콜로 오해하지 않는다

TCP에는 메시지 경계가 없다. 구분자, 고정 길이, 길이 접두사 같은 프레이밍(framing)이 필요하다. read() 호출 횟수와 상대방의 write() 호출 횟수는 일치하지 않는다.

메시지 크기를 제한한다

외부에서 받은 길이 값을 검증해야 한다. 서버가 허용하는 최대 메시지 크기를 정하고 초과하는 연결은 종료한다.

typescript
const MAX_MESSAGE_SIZE = 1024 * 1024; if (messageLength > MAX_MESSAGE_SIZE) { throw new Error("메시지 크기가 제한을 초과했습니다."); }

타임아웃을 설정한다

연결만 열고 데이터를 보내지 않는 클라이언트는 서버 자원을 계속 점유할 수 있다. 연결 타임아웃, 읽기 타임아웃, 쓰기 타임아웃, 유휴 타임아웃을 서비스 특성에 맞게 설정한다.

TCP 전달 성공과 업무 처리 성공을 구분한다

TCP 확인 응답은 상대 운영체제까지 바이트가 전달됐음을 뜻한다. 데이터베이스 저장이나 결제 완료 같은 애플리케이션 작업의 성공을 의미하지 않는다. 업무 처리 결과는 애플리케이션 수준 응답으로 확인해야 한다.

UDP에 필요한 보장을 명시한다

UDP를 사용하면서 모든 TCP 기능을 다시 구현하면 복잡성만 커질 수 있다. 순서 보장, 재전송, 중복 제거, 혼잡 제어 중 서비스에 필요한 기능을 먼저 정한다. 일반적인 신뢰성 있는 다중 스트림 통신이 필요하면 QUIC 사용을 검토한다.

8. 핵심 정리

  • 전송 계층은 포트와 소켓을 이용해 호스트 간 통신을 프로세스 간 통신으로 확장한다.
  • TCP는 신뢰성 있는 순서 보장 바이트 스트림이며 메시지 경계를 보존하지 않는다.
  • UDP는 연결 설정과 재전송 없이 메시지 경계를 보존하는 데이터그램을 전달한다.
  • 흐름 제어는 수신자를 보호하고, 혼잡 제어는 네트워크 과부하를 줄인다.
  • 실무에서는 신뢰성, 지연 시간, 메시지 경계, 연결 설정 비용을 기준으로 TCP, UDP, QUIC을 선택한다.