백엔드 서버를 개발하다 보면 TCP와 UDP를 의식하지 않고 사용하는 경우가 많다. HTTP API와 WebSocket은 TCP 위에서 동작하고, 음성/영상 스트리밍이나 DNS에는 UDP가 쓰인다. 채팅 서버나 광고 서버처럼 성능과 신뢰성 요구가 다른 워크로드를 다루다 보면 전송 계층의 동작 원리를 정리할 필요가 생긴다.

전송 계층

전송 계층은 애플리케이션 간 데이터 전달을 담당한다. IP가 호스트까지의 경로를 찾는다면, 전송 계층은 해당 호스트의 어떤 프로세스에 데이터를 전달할지 결정한다. 포트 번호가 이 역할을 한다.

TCP와 UDP는 모두 IP 위에서 동작한다. 차이는 신뢰성과 속도 사이의 선택이다.

TCP

TCP(Transmission Control Protocol)는 연결 지향 프로토콜이다. 데이터를 보내기 전에 연결을 수립하고, 전송 중 유실이 발생하면 재전송한다.

연결 수립

TCP는 3-way handshake로 연결을 수립한다.

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    C->>S: SYN (seq=x)
    Note right of S: SYN 수신, 연결 준비
    S->>C: SYN-ACK (seq=y, ack=x+1)
    Note left of C: SYN-ACK 수신
    C->>S: ACK (ack=y+1)
    Note over C,S: 연결 수립 완료

클라이언트가 SYN 패킷과 함께 자신의 시퀀스 번호를 보낸다. 서버가 SYN-ACK로 응답하며 서버 측 시퀀스 번호를 전달한다. 클라이언트가 ACK를 보내면 연결이 성립된다. 양쪽이 교환한 시퀀스 번호는 이후 데이터 전송에서 순서를 추적하기 위한 기준점이다.

연결 종료

연결 종료는 4-way handshake를 사용한다. TCP는 전이중(full-duplex) 통신이므로 양방향을 각각 종료해야 한다.

sequenceDiagram
    participant A as 종료 요청 측
    participant B as 상대 측

    A->>B: FIN
    Note right of B: FIN 수신, 수신 방향 종료
    B->>A: ACK
    Note over B: 남은 데이터 전송 완료
    B->>A: FIN
    Note left of A: FIN 수신, 송신 방향 종료
    A->>B: ACK
    Note over A: TIME_WAIT 상태 진입
    Note over A,B: 연결 해제 완료

한쪽이 FIN을 보내면 “더 이상 보낼 데이터가 없다"는 의미다. 상대는 ACK로 응답한 뒤 자신의 남은 데이터를 마저 보내고, 자기 쪽에서도 FIN을 보낸다. 최초 FIN을 보낸 측은 마지막 ACK를 보낸 후 TIME_WAIT 상태에 진입한다. 네트워크에 남아있을 수 있는 지연 패킷이 도착할 시간을 확보하기 위한 대기다.

세그먼트 분할

애플리케이션이 보낸 데이터는 TCP가 세그먼트 단위로 분할하여 전송한다. 한 번의 send() 호출로 4KB를 보내도 TCP는 이를 MSS 크기에 맞춰 여러 세그먼트로 나눈다.

flowchart LR
    A["애플리케이션\n4KB 전달"] --> B["TCP"]
    B --> C["세그먼트 1\nseq=1\n1460 bytes"]
    B --> D["세그먼트 2\nseq=1461\n1460 bytes"]
    B --> E["세그먼트 3\nseq=2921\n1160 bytes"]

수신 측 TCP는 도착한 세그먼트를 시퀀스 번호 순서대로 재조립하여 애플리케이션에 전달한다. 분할과 재조립은 TCP가 투명하게 처리하므로 애플리케이션은 원래의 연속된 바이트 스트림만 보게 된다.

신뢰성 보장

세그먼트 단위로 분할된 데이터는 네트워크를 지나면서 유실되거나 순서가 뒤바뀔 수 있다. TCP는 두 가지 메커니즘으로 데이터 도착을 보장한다.

순서 보장: 각 세그먼트에 시퀀스 번호가 부여되어 있다. 수신 측은 이 번호를 기준으로 데이터를 원래 순서대로 재조립한다. 세그먼트가 순서 없이 도착해도 애플리케이션에는 정렬된 데이터가 전달된다.

재전송: 송신 측은 데이터를 보낸 후 ACK를 기다린다. 일정 시간, RTO(Retransmission Timeout) 내에 ACK가 오지 않으면 해당 데이터를 다시 보낸다.

ACK 번호는 누적 확인 방식이다. “ACK 3"은 “3번 이전까지 모두 받았고, 다음에 3번을 기대한다"는 의미다.

정상 흐름

sequenceDiagram
    participant S as 송신
    participant R as 수신
    S->>R: Segment 1
    R->>S: ACK 2
    S->>R: Segment 2
    R->>S: ACK 3

세그먼트가 순서대로 도착하면 수신 측은 다음 기대 번호를 ACK로 보낸다. ACK 2는 “1번 수신, 다음은 2번"이라는 의미다.

유실 시나리오

sequenceDiagram
    participant S as 송신
    participant R as 수신
    S->>R: Segment 1, 2
    S--xR: Segment 3 [유실]
    S->>R: Segment 4
    R->>S: ACK 3
    R->>S: ACK 3 (중복)
    R->>S: ACK 3 (중복)
    Note over S: 중복 3회 → 유실 판단
    S->>R: Segment 3 [재전송]
    R->>S: ACK 5

Segment 4가 도착해도 3번이 빠져 있으므로 수신 측은 ACK 번호를 올리지 못하고 ACK 3을 반복한다. 송신 측은 같은 ACK가 3번 중복되면 타임아웃을 기다리지 않고 즉시 재전송한다. 수신 측은 재전송된 3번을 받으면 버퍼에 보관하고 있던 4번과 합쳐서 ACK 5를 보낸다.

흐름 제어

수신 측의 처리 능력을 초과하면 데이터가 유실된다. TCP는 슬라이딩 윈도우 메커니즘으로 이를 방지한다.

수신 측은 자신이 처리할 수 있는 버퍼 크기를 수신 윈도우(rwnd, receive window)로 송신 측에 알려준다. 송신 측은 ACK를 받지 않은 상태로 rwnd 크기 이상의 데이터를 보내지 않는다.

ACK 대기 중:

block-beta
    columns 10
    block:window["송신 윈도우 (rwnd = 4)"]:4
        s3["3 전송"]
        s4["4 전송"]
        s5["5 대기"]
        s6["6 대기"]
    end
    s7["7"]
    s8["8"]
    s9["9"]
    s10["10"]
    s11["11"]
    s12["12"]

    style s3 fill:#4CAF50
    style s4 fill:#4CAF50
    style s5 fill:#42A5F5
    style s6 fill:#42A5F5

3, 4는 전송 완료, 5, 6은 윈도우 안이지만 아직 전송하지 않은 세그먼트다. 7 이후는 윈도우 밖이라 보낼 수 없다.

ACK 3 수신 후 — 윈도우가 오른쪽으로 이동:

block-beta
    columns 10
    s3["3 ✓"]
    block:window["송신 윈도우 (rwnd = 4)"]:4
        s4["4 전송"]
        s5["5 전송"]
        s6["6 대기"]
        s7["7 대기"]
    end
    s8["8"]
    s9["9"]
    s10["10"]
    s11["11"]
    s12["12"]

    style s3 fill:#9E9E9E
    style s4 fill:#4CAF50
    style s5 fill:#4CAF50
    style s6 fill:#42A5F5
    style s7 fill:#42A5F5

3의 ACK가 돌아오면 윈도우가 한 칸 오른쪽으로 이동한다. 3은 완료되어 윈도우 밖으로 빠지고, 7이 새로 윈도우 안에 들어온다. ACK를 받을 때마다 이 과정이 반복된다. 수신 측의 버퍼가 가득 차면 rwnd를 0으로 보내서 송신을 일시 중단시킨다. 버퍼에 여유가 생기면 다시 윈도우 크기를 알려 송신을 재개한다.

슬라이딩 윈도우는 Stop-and-Wait 방식의 한계를 극복한 기법이다. Stop-and-Wait는 패킷 하나를 보내고 ACK를 기다린 후 다음을 보낸다. 한 번에 하나만 전송하므로 링크 활용률이 낮다. 슬라이딩 윈도우는 ACK를 기다리는 동안에도 윈도우 범위 내의 여러 패킷을 연속으로 전송한다.

재전송 방식도 두 가지다. Go-Back-N은 유실된 패킷 이후의 모든 패킷을 재전송한다. 구현이 간단하지만 불필요한 재전송이 발생한다. Selective Repeat는 유실된 패킷만 선택적으로 재전송한다. 수신 측 버퍼가 필요하지만 네트워크 효율이 높다. TCP는 Selective Repeat 방식을 사용한다. SACK 옵션이 이를 지원한다.

혼잡 제어

흐름 제어가 수신 측의 처리 능력에 맞추는 것이라면, 혼잡 제어는 네트워크 경로의 처리 능력에 맞추는 것이다. 네트워크가 혼잡하면 라우터의 버퍼가 넘치고 패킷이 유실된다.

TCP는 혼잡 윈도우(cwnd, congestion window)라는 변수를 관리한다. 실제 전송량은 rwnd와 cwnd 중 작은 값으로 결정된다.

슬로우 스타트

연결 초기에는 네트워크 용량을 모른다. cwnd를 1 MSS로 시작하고, ACK가 정상적으로 돌아올 때마다 cwnd를 1 MSS씩 증가시킨다. RTT당 cwnd가 두 배로 늘어나므로 지수적 증가다.

---
config:
    xyChart:
        xAxis:
            label: "RTT"
        yAxis:
            label: "cwnd (MSS)"
---
xychart-beta
    x-axis ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
    y-axis 0 --> 40
    line "cwnd" [1, 2, 4, 8, 16, 17, 18, 19, 20, 10, 11]

cwnd가 ssthresh(slow start threshold)에 도달하면 혼잡 회피 단계로 전환된다. 위 그래프에서 RTT 4에서 ssthresh(=16)에 도달한 후 선형 증가로 바뀌고, RTT 9에서 패킷 유실이 감지되어 cwnd가 절반으로 줄어든다.

혼잡 회피

ssthresh 이후에는 cwnd를 RTT당 1 MSS씩만 증가시킨다. 선형 증가다. 패킷 유실이 감지될 때까지 조금씩 전송량을 늘린다. AIMD, Additive Increase/Multiplicative Decrease 전략이다. 네트워크를 과도하게 사용하지 않으면서 가용 대역폭을 점진적으로 탐색한다.

빠른 재전송

패킷 유실을 감지하는 방법은 두 가지다. 타임아웃(RTO 만료)과 중복 ACK다. 타임아웃은 수백 밀리초에서 수 초까지 걸릴 수 있어 느리다.

빠른 재전송은 동일한 ACK가 3회 중복되면 타임아웃을 기다리지 않고 즉시 해당 세그먼트를 재전송한다. 중복 ACK 자체가 “해당 패킷 이후의 데이터는 도착하고 있지만 중간에 빠진 것이 있다"는 신호이기 때문이다.

빠른 복구

빠른 재전송 후 슬로우 스타트로 돌아가면 전송량이 급격히 떨어진다. 빠른 복구는 이를 방지한다. 패킷 유실이 감지되면 cwnd를 1로 떨어뜨리는 대신 절반으로 줄이고 혼잡 회피 단계에서 바로 시작한다.

중복 ACK가 도착한다는 것은 네트워크가 완전히 막힌 것이 아니라 일부 패킷은 전달되고 있다는 의미다. 슬로우 스타트까지 후퇴할 필요가 없다.

flowchart TD
    A[연결 시작] --> B[슬로우 스타트
cwnd 지수 증가] B -->|cwnd >= ssthresh| C[혼잡 회피
cwnd 선형 증가] B -->|타임아웃| D[ssthresh = cwnd/2
cwnd = 1 MSS] C -->|3회 중복 ACK| E[빠른 재전송 + 빠른 복구
ssthresh = cwnd/2
cwnd = ssthresh] C -->|타임아웃| D D --> B E --> C

타임아웃이 발생하면 네트워크가 심각하게 혼잡한 것으로 판단하고 cwnd를 1 MSS로 초기화한다. 중복 ACK로 감지한 유실은 상대적으로 경미한 혼잡이므로 cwnd를 절반만 줄인다.

구현체별 차이

위 4대 알고리즘의 기본 골격은 동일하지만, 구체적인 동작은 구현체마다 다르다.

TCP Reno: 위에서 설명한 슬로우 스타트, 혼잡 회피, 빠른 재전송, 빠른 복구를 처음 통합한 구현이다. 한 번에 하나의 패킷 유실만 효율적으로 처리한다.

TCP NewReno: Reno의 한계를 보완했다. 하나의 윈도우에서 여러 패킷이 유실된 경우, 부분 ACK에도 슬로우 스타트로 후퇴하지 않고 빠른 복구 상태를 유지하면서 유실된 패킷들을 순차적으로 재전송한다.

TCP CUBIC: Linux의 기본 혼잡 제어 알고리즘이다. 혼잡 회피 단계에서 RTT에 비례하는 선형 증가 대신 3차 함수를 사용한다. 대역폭이 큰 장거리 네트워크에서 가용 대역폭을 빠르게 활용한다.

BBR(Bottleneck Bandwidth and RTT): Google이 개발한 모델 기반 알고리즘이다. 패킷 유실이 아닌 측정된 대역폭과 RTT를 기반으로 전송 속도를 결정한다. 네트워크 경로의 실제 병목 대역폭을 추정하고, 라우터 버퍼를 과도하게 채우지 않으면서 최대 처리량을 달성한다. 인터넷 트래픽 중 상당 비중이 BBR을 사용하는 것으로 알려져 있다.

UDP

UDP(User Datagram Protocol)는 비연결 프로토콜이다. handshake 없이 데이터를 즉시 전송한다.

구조

UDP 헤더는 8바이트다. 출발지 포트, 목적지 포트, 길이, 체크섬만 포함한다. TCP 헤더가 최소 20바이트인 것과 비교하면 오버헤드가 적다.

block-beta
    columns 4
    block:tcp["TCP 헤더 (20+ 바이트)"]:4
        t1["출발지 포트"]
        t2["목적지 포트"]
        t3["시퀀스 번호"]
        t4["ACK 번호"]
        t5["플래그"]
        t6["윈도우 크기"]
        t7["체크섬"]
        t8["옵션..."]
    end

    space:4

    block:udp["UDP 헤더 (8 바이트)"]:4
        u1["출발지 포트"]
        u2["목적지 포트"]
        u3["길이"]
        u4["체크섬"]
    end

    style tcp fill:#E3F2FD
    style udp fill:#E8F5E9

순서 보장이 없다. 재전송도 없다. 패킷이 유실되면 애플리케이션이 직접 처리해야 한다. 흐름 제어와 혼잡 제어도 제공하지 않는다.

왜 필요한가

TCP의 신뢰성은 지연을 수반한다. 3-way handshake에 최소 1 RTT가 소요된다. 재전송은 추가 지연을 만든다. 혼잡 제어로 인해 전송 속도가 제한될 수도 있다.

실시간성이 중요한 워크로드에서는 이 지연이 신뢰성보다 큰 문제가 된다. 음성 통화에서 0.5초 전 음성이 재전송으로 도착하면 대화 흐름이 깨진다. 게임에서 오래된 위치 정보가 뒤늦게 도착하면 의미가 없다. 이런 경우 유실된 데이터를 버리는 것이 재전송하는 것보다 낫다.

비교

flowchart LR
    subgraph TCP
        direction TB
        tc1[연결 지향]
        tc2[순서 보장]
        tc3[재전송]
        tc4[흐름/혼잡 제어]
        tc5[20+ 바이트 헤더]
    end

    subgraph UDP
        direction TB
        uc1[비연결]
        uc2[순서 보장 없음]
        uc3[재전송 없음]
        uc4[제어 없음]
        uc5[8 바이트 헤더]
    end

    TCP --- 신뢰성
    UDP --- 속도

선택 기준

TCP가 적합한 경우:

  • 데이터 무결성이 필수일 때: 웹 통신(HTTP/HTTPS), 파일 전송(FTP), 이메일(SMTP)
  • DB 통신: 쿼리 결과가 정확히 도착해야 한다
  • API 호출: 요청과 응답이 유실되면 안 된다

UDP가 적합한 경우:

  • 실시간 스트리밍: 영상, 음성 통화(VoIP)
  • 온라인 게임: 위치, 상태 업데이트
  • DNS: 작은 요청/응답을 빠르게 주고받는다
  • IoT 센서 데이터: 주기적 전송, 일부 유실 허용

QUIC: HTTP/3의 기반 프로토콜이다. UDP 위에서 TCP의 신뢰성, 재전송과 순서 보장, 그리고 TLS 암호화를 구현한다. TCP의 3-way handshake + TLS handshake 지연을 줄이면서도 신뢰성을 확보하려는 접근이다. TCP가 OS 커널에 구현되어 변경이 어려운 반면, QUIC는 애플리케이션 수준에서 동작하므로 빠른 개선이 가능하다.

백엔드 개발에서 전송 계층을 의식하지 않고 지나치기 쉽다. 하지만 HTTP가 TCP 위에서 동작하는 이유, WebSocket이 TCP를 선택한 이유, DNS가 UDP를 쓰는 이유는 모두 여기서 출발한다. 결국 프로토콜 선택은 워크로드가 신뢰성을 더 요구하는지, 지연을 더 줄여야 하는지에 따라 갈린다.