TCP: 신뢰성 있는 바이트 스트림과 연결 지향 통신
전송 제어 프로토콜(TCP)은 두 호스트 사이에 연결을 만들고 데이터를 순서대로 빠짐없이 전달하는 전송 계층 프로토콜이다. 순서 번호, 확인 응답, 재전송, 흐름 제어, 혼잡 제어를 사용해 신뢰성을 제공한다. 애플리케이션에는 메시지가 아니라 연속된 바이트 스트림을 전달하므로 별도의 메시지 경계 설계가 필요하다.
By 송민성
1. 개념
전송 제어 프로토콜(TCP, Transmission Control Protocol)은 인터넷 프로토콜(IP) 위에서 동작하는 연결 지향형(Connection-oriented) 전송 계층 프로토콜이다.
TCP 연결은 다음 네 값의 조합으로 식별한다.
출발지 IP 주소
출발지 포트
목적지 IP 주소
목적지 포트TCP가 애플리케이션에 제공하는 핵심 기능은 다음과 같다.
- 신뢰성 있는 전달: 손실된 데이터를 재전송한다.
- 순서 보장: 도착 순서가 바뀐 데이터를 원래 순서로 정렬한다.
- 중복 제거: 같은 데이터가 여러 번 도착해도 한 번만 전달한다.
- 오류 검출: 체크섬(Checksum)으로 전송 중 손상을 검사한다.
- 흐름 제어(Flow Control): 수신자가 처리할 수 있는 양만 전송한다.
- 혼잡 제어(Congestion Control): 네트워크 상태에 맞춰 전송량을 조절한다.
- 전이중 통신(Full-duplex Communication): 양쪽이 동시에 데이터를 보낼 수 있다.
TCP는 메시지 단위가 아닌 바이트 스트림(Byte Stream)을 전달한다. 송신자가 한 번 호출한 write()와 수신자가 한 번 호출한 read()의 경계는 일치하지 않는다.
송신: ["HELLO"] ["WORLD"]
수신: ["HEL"] ["LOWOR"] ["LD"]수신 결과가 나뉘거나 합쳐져도 TCP 관점에서는 같은 10바이트 스트림이다.
2. 왜 사용하는가
인터넷 프로토콜(IP, Internet Protocol)은 패킷의 도착, 순서, 중복 방지를 보장하지 않는다. 패킷이 유실되거나 늦게 도착하거나 다른 순서로 도착할 수 있다.
애플리케이션이 이 문제를 직접 처리하려면 다음 기능을 별도로 구현해야 한다.
- 전송 데이터에 번호 부여
- 수신 여부 확인
- 시간 초과 감지
- 손실 데이터 재전송
- 순서가 바뀐 데이터 임시 보관
- 수신 버퍼와 네트워크 혼잡 상태 반영
TCP는 이 기능을 운영체제의 네트워크 스택에서 처리한다. 애플리케이션은 소켓(Socket)에 바이트를 쓰고 읽는 방식으로 통신할 수 있다.
TCP는 일부 데이터가 빠지거나 순서가 바뀌면 안 되는 통신에 적합하다.
- HTTP/1.1과 HTTP/2
- 데이터베이스 연결
- 파일 전송
- 보안 셸(SSH, Secure Shell)
- 이메일 전송
- 웹소켓(WebSocket)
반대로 일부 데이터 손실보다 낮은 지연 시간이 중요한 실시간 음성·영상 전송에서는 사용자 데이터그램 프로토콜(UDP, User Datagram Protocol)이나 UDP 기반의 별도 프로토콜을 사용할 수 있다.
3. 동작 원리
3.1 연결 수립
TCP는 데이터를 보내기 전에 3방향 핸드셰이크(Three-way Handshake)로 연결을 수립한다.
클라이언트 서버
│ │
│ ── SYN, 순서 번호=x ─────────────▶ │
│ │
│ ◀─ SYN+ACK, 순서 번호=y, 확인=x+1 ─│
│ │
│ ── ACK, 확인 번호=y+1 ────────────▶ │
│ │
│ 연결 수립 완료 │각 단계의 의미는 다음과 같다.
- 클라이언트가
SYN을 보내 연결을 요청한다. - 서버가
SYN+ACK로 요청 수신과 서버 측 연결 요청을 함께 알린다. - 클라이언트가
ACK를 보내 서버의 응답을 확인한다.
양쪽은 이 과정에서 초기 순서 번호(Initial Sequence Number)를 교환한다.
3.2 순서 번호와 확인 응답
TCP는 전송한 바이트에 순서 번호(Sequence Number)를 부여한다. 수신자는 다음에 받을 바이트의 순서 번호를 확인 번호(Acknowledgment Number)로 응답한다.
송신자 수신자
│ │
│ ── 순서 번호=1000, 길이=500 ───────▶ │
│ │
│ ◀─ 확인 번호=1500 ────────────────── │확인 번호 1500은 1499번 바이트까지 받았고 다음에는 1500번 바이트를 기대한다는 뜻이다.
TCP는 누적 확인 응답(Cumulative Acknowledgment)을 사용한다. 확인 번호가 3000이면 그 이전 바이트까지 연속해서 수신했다는 뜻이다.
3.3 손실 감지와 재전송
송신자는 보낸 데이터를 일정 시간 동안 버퍼에 보관한다. 확인 응답이 재전송 시간 제한(RTO, Retransmission Timeout) 안에 도착하지 않으면 데이터를 다시 보낸다.
같은 확인 응답이 반복되는 중복 확인 응답(Duplicate ACK)도 손실 신호로 사용할 수 있다. 일반적인 TCP 구현은 중복 확인 응답을 통해 손실을 추정하면 시간 제한을 기다리지 않고 빠른 재전송(Fast Retransmit)을 수행할 수 있다.
재전송 시간 제한은 고정값이 아니다. 측정된 왕복 시간(RTT, Round-trip Time)과 변동 폭을 바탕으로 조정된다.
3.4 슬라이딩 윈도와 흐름 제어
TCP는 각 바이트의 확인 응답을 기다린 뒤 다음 바이트를 보내지 않는다. 확인받지 않은 여러 세그먼트(Segment)를 연속해서 보내는 슬라이딩 윈도(Sliding Window)를 사용한다.
수신자는 수신 윈도(Receive Window)를 TCP 헤더에 담아 자신이 추가로 받을 수 있는 버퍼 크기를 알린다. 송신자는 이 범위를 넘지 않도록 전송량을 제한한다.
전송 가능량 ≤ 수신자가 광고한 윈도 크기수신 애플리케이션이 데이터를 느리게 읽으면 수신 버퍼가 차고, 광고 윈도 크기가 줄어든다. 버퍼가 가득 차면 수신자는 제로 윈도(Zero Window)를 알릴 수 있다.
3.5 혼잡 제어
흐름 제어가 수신자의 처리 능력을 보호한다면 혼잡 제어는 네트워크 전체의 과부하를 줄인다.
송신자는 혼잡 윈도(Congestion Window)를 관리하며 손실, 확인 응답, 왕복 시간 같은 신호에 따라 전송량을 조절한다. 실제 전송 가능한 미확인 데이터 크기는 대략 다음 두 값 중 작은 값으로 제한된다.
전송 가능량 = min(수신 윈도, 혼잡 윈도)사용되는 혼잡 제어 알고리즘과 세부 동작은 운영체제 설정과 구현에 따라 다르다.
3.6 연결 종료
일반적인 정상 종료에서는 각 방향을 독립적으로 닫는다.
클라이언트 서버
│ │
│ ── FIN ─────────────────────▶ │
│ ◀─ ACK ────────────────────── │
│ ◀─ FIN ────────────────────── │
│ ── ACK ─────────────────────▶ │FIN은 해당 방향에서 더 보낼 데이터가 없음을 뜻한다. RST는 연결을 즉시 중단하거나 유효하지 않은 연결을 거부할 때 사용한다.
먼저 연결을 닫은 쪽은 지연된 세그먼트가 새 연결에 섞이는 것을 막고 마지막 ACK 재전송에 대응하기 위해 일정 시간 TIME_WAIT 상태에 머무를 수 있다.
4. 코드 예제
다음 예제는 Node.js의 TCP 소켓으로 줄바꿈 문자를 메시지 경계로 사용하는 에코 서버와 클라이언트를 구현한다.
서버
server.ts로 저장한다.
import * as net from "node:net";
const server = net.createServer((socket) => {
console.log(
`연결됨: ${socket.remoteAddress}:${socket.remotePort}`
);
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
while (true) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const message = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
console.log(`수신: ${message}`);
socket.write(`응답: ${message}\n`);
}
});
socket.on("end", () => {
console.log("클라이언트가 전송을 종료함");
});
socket.on("error", (error) => {
console.error(`소켓 오류: ${error.message}`);
});
});
server.on("error", (error) => {
console.error(`서버 오류: ${error.message}`);
});
server.listen(4000, "127.0.0.1", () => {
console.log("127.0.0.1:4000에서 대기 중");
});클라이언트
client.ts로 저장한다.
import * as net from "node:net";
const socket = net.createConnection(
{
host: "127.0.0.1",
port: 4000,
},
() => {
socket.write("첫 번째 메시지\n");
socket.write("두 번째 메시지\n");
socket.end();
}
);
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
while (true) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const message = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
console.log(message);
}
});
socket.on("end", () => {
console.log("서버가 연결을 종료함");
});
socket.on("error", (error) => {
console.error(`연결 오류: ${error.message}`);
});다음 명령으로 실행한다.
npx tsx server.ts다른 터미널에서 클라이언트를 실행한다.
npx tsx client.ts예상 출력은 다음과 같다.
응답: 첫 번째 메시지
응답: 두 번째 메시지
서버가 연결을 종료함data 이벤트 한 번이 메시지 하나를 뜻하지 않으므로 버퍼에 데이터를 누적하고 줄바꿈 문자를 기준으로 메시지를 분리한다.
5. 시간 복잡도 또는 성능 특성
TCP의 성능은 알고리즘 시간복잡도보다 지연 시간, 대역폭, 손실률, 윈도 크기의 영향을 크게 받는다.
연결 수립 비용
새 TCP 연결은 데이터 전송 전에 일반적으로 1회의 왕복 시간(RTT)이 필요하다.
TLS를 함께 사용하면 암호화 연결 수립 비용도 추가된다. 실제 비용은 TLS 버전, 세션 재개 여부, TCP Fast Open 같은 기능 사용 여부에 따라 달라진다.
대역폭-지연 곱
대역폭-지연 곱(BDP, Bandwidth-delay Product)은 네트워크 경로를 충분히 활용하기 위해 전송 중이어야 하는 데이터의 양이다.
대역폭-지연 곱 = 병목 대역폭 × 왕복 시간대역폭이 초당 100메가비트이고 왕복 시간이 100밀리초라면 다음과 같다.
100,000,000비트/초 × 0.1초
= 10,000,000비트
= 1,250,000바이트이 경로를 충분히 활용하려면 윈도 크기가 약 1.25메가바이트 이상이어야 한다.
처리 비용
길이가 n바이트인 데이터를 애플리케이션 버퍼로 읽거나 복사하는 작업은 일반적으로 O(n)이다. TCP 스택은 순서 관리, 체크섬 검사, 버퍼 관리, 재전송 처리를 추가로 수행한다.
선두 차단
앞선 TCP 세그먼트가 손실되면 뒤 세그먼트가 이미 도착했더라도 애플리케이션에는 연속된 바이트까지만 전달된다. 이를 선두 차단(HOL Blocking, Head-of-line Blocking)이라고 한다.
작은 쓰기와 처리량
작은 데이터를 지나치게 자주 쓰면 TCP/IP 헤더와 시스템 호출 비용의 비중이 커진다. 반대로 데이터를 무조건 크게 모으면 응답 지연이 늘어난다. 처리량과 지연 시간 요구에 맞춰 버퍼링 정책을 정해야 한다.
6. 실무 사용 사례
HTTP 연결 재사용
HTTP/1.1의 지속 연결(Keep-alive)과 HTTP/2의 다중화(Multiplexing)는 이미 만든 TCP 연결을 여러 요청에 재사용한다. 연결 수립과 혼잡 윈도 증가 비용을 반복하지 않아도 된다.
연결을 과도하게 만들면 다음 문제가 발생한다.
- 핸드셰이크 증가
- 소켓과 메모리 사용량 증가
- 서버의 파일 서술자(File Descriptor) 소비
TIME_WAIT연결 증가- 혼잡 제어 상태 재사용 불가
HTTP 클라이언트와 데이터베이스 드라이버에서는 연결 풀(Connection Pool)을 사용해 제한된 연결을 재사용한다.
데이터베이스 연결
PostgreSQL 같은 데이터베이스는 TCP 연결 위에서 자체 애플리케이션 프로토콜을 사용한다. 쿼리와 결과의 경계는 TCP가 아니라 데이터베이스 프로토콜의 메시지 형식으로 구분한다.
원격 데이터베이스 연결이 중간에 끊어지면 클라이언트가 결과를 받지 못했더라도 서버에서는 쿼리가 실행됐을 수 있다. 쓰기 작업의 무조건적인 재시도는 중복 변경을 만들 수 있다.
로드 밸런서와 프록시
로드 밸런서가 TCP 연결을 종료하는 구조에서는 다음 두 연결이 별도로 존재한다.
클라이언트 ── TCP 연결 1 ── 로드 밸런서
로드 밸런서 ── TCP 연결 2 ── 애플리케이션 서버연결 시간 제한, 유지 연결 설정, 백엔드 연결 풀을 각각 관리해야 한다.
장기 연결
웹소켓이나 메시지 스트림은 TCP 연결을 오래 유지한다. 장시간 데이터가 없으면 방화벽이나 네트워크 주소 변환(NAT) 장비가 연결 상태를 제거할 수 있다. 애플리케이션 수준의 핑(Ping)과 퐁(Pong), 유휴 시간 제한을 사용해 연결 상태를 확인한다.
7. 주의할 점
메시지 경계가 없다
다음 가정은 잘못됐다.
socket.on("data", (chunk) => {
// chunk 하나가 요청 하나라고 가정하면 안 된다.
handleMessage(chunk);
});메시지 경계는 애플리케이션 프로토콜에서 정의해야 한다.
- 줄바꿈 같은 구분자 사용
- 고정 길이 메시지 사용
- 본문 앞에 길이 필드 추가
- JSON Lines 같은 기존 형식 사용
길이 필드를 사용할 때는 허용 가능한 최대 메시지 크기를 검사해야 한다. 검사하지 않으면 공격자가 큰 길이를 보내 과도한 메모리 할당을 유발할 수 있다.
write() 완료는 상대방 수신 완료가 아니다
소켓의 write() 성공은 데이터가 로컬 버퍼에 기록됐다는 뜻이다. 상대 애플리케이션이 데이터를 읽거나 처리했다는 뜻이 아니다.
업무 처리 완료를 확인해야 한다면 애플리케이션 수준의 응답 메시지를 정의해야 한다.
연결 생존 여부를 즉시 알 수 없다
상대 호스트의 전원이 갑자기 꺼지거나 네트워크가 단절되면 로컬 TCP 스택이 이를 즉시 감지하지 못할 수 있다. 읽기·쓰기 시간 제한, TCP 유지 확인(TCP Keepalive), 애플리케이션 하트비트(Heartbeat)를 함께 설계해야 한다.
재시도는 중복 실행을 만들 수 있다
응답을 받기 전에 연결이 끊어졌다면 요청이 서버에서 실행됐는지 클라이언트는 알 수 없다. 결제나 주문 같은 작업은 멱등성 키(Idempotency Key), 고유 제약 조건, 트랜잭션을 이용해 중복 실행을 막아야 한다.
백프레셔를 처리해야 한다
송신 속도가 수신 속도보다 빠르면 사용자 공간이나 커널의 버퍼가 계속 찬다. Node.js에서 socket.write()가 false를 반환하면 즉시 추가 전송을 멈추고 drain 이벤트를 기다려야 한다.
import * as net from "node:net";
function writeWithBackpressure(
socket: net.Socket,
data: string
): Promise<void> {
return new Promise((resolve, reject) => {
socket.once("error", reject);
if (socket.write(data)) {
socket.off("error", reject);
resolve();
return;
}
socket.once("drain", () => {
socket.off("error", reject);
resolve();
});
});
}8. 핵심 정리
- TCP는 연결 지향형 바이트 스트림 프로토콜이다.
- 순서 번호, 확인 응답, 재전송으로 신뢰성과 순서를 보장한다.
- 수신 윈도는 수신자를 보호하고, 혼잡 윈도는 네트워크 과부하를 제어한다.
- TCP는 메시지 경계를 보존하지 않으므로 애플리케이션이 프레이밍(Framing)을 정의해야 한다.
- 새 연결에는 핸드셰이크 비용이 있으므로 HTTP와 데이터베이스 통신에서는 연결 재사용이 중요하다.
write()성공은 상대 애플리케이션의 처리 완료를 보장하지 않는다.- 시간 제한, 백프레셔, 최대 메시지 크기, 멱등한 재시도를 애플리케이션 수준에서 설계해야 한다.