Transport layer 파해치기 - part.2

Sep 28, 2025

개요

이 글은 지난 글과 이어집니다.

지난 글에서는 TCP 등장 배경과 TCP 헤더에 담긴 세그먼트에 대해 다뤘는데요, 이어서 TCP의 연결 설정과 해제 과정에 대해 다루겠습니다.

연결 지향의 의미

TCP의 특징 중 하나는 `연결 지향(connection oriented)‘이라는 것입니다. 무엇이 연결되어 있다는 것일까요?

위키에 따르면 ‘연결 지향 통신’은 회선 교환 연결패킷 모드 가상 회선 연결로 구현될 수 있습니다.

전자의 경우 통신 중에 물리적인 회선을 독점하는 것을 의미하고, 후자의 경우 전송 계층 가상 회선 프로토콜을 사용하여 데이터를 순서대로 전달합니다.

여기서 중요한 것은 실제 ‘물리적’으로 연결된 것이 아니라 ‘가상’, 즉 논리적으로 연결된다는 것입니다.

실제로 두 컴퓨터 사이에 물리적인 전용 회선(전화선처럼) 이 깔리는 게 아니라, 운영체제의 소켓 상태와 제어 정보(시퀀스 번호, ACK, 윈도우 크기 등) 를 통해 마치 두 프로세스가 전용 회선을 쓰는 것처럼 보장한다는 뜻입니다.

왜 이런 논리적인 연결 상태를 유지하는 것일까요? 바로 신뢰성 있는 데이터 전송을 보장하기 위해서입니다.

전 세계 컴퓨터가 물리적인 회선으로 연결되어있으면 좋겠지만, 현실적으로 불가능합니다. (보안은 보장하겠지만)

https://networkencyclopedia.com/packet-switching/
https://networkencyclopedia.com/packet-switching/

우리가 사용하는 인터넷은 ‘패킷 교환’을 기반으로 하기 때문에, 각 패킷이 독립적으로 네트워크를 떠돌며 경로가 달라질 수도 있고 중간에서 손실되기도 합니다.

이렇게 무질서하게 날라오는 패킷을 TCP 같은 프로토콜이 논리적 연결 상태를 유지하며 “전용선처럼 보이도록” 보정하고, 데이터의 순서를 보장하거나 손실을 복구하고, 네트워크 혼잡 제어와 같은 기능을 제공합니다.

이 연결을 만들기 위해 TCP에서 주로 진행하는 대표적인 절차가 3-way Handshake입니다.

3-Way Handshake

세 방향 악수(?)
세 방향 악수(?)

3-Way Handshake를 쉽게 말하면 세그먼트의 시퀀스 번호 교환, MSS(Maximum Segment Size) 교환, 혼잡 제어 정책(SACK..)과 같은 정보를 교환하는 것입니다.

목적은 무엇이냐?

  1. 세그먼트에 시퀀스 번호를 부여하고 교환함으로써 데이터 순서를 보장하고
  2. 세그먼트의 크기를 교환함으로써, 상대방의 버퍼 및 네트워크 특성에 맞는 패킷을 전송하고 (네트워크 자원과 성능에 맞춘 효율적 전송)
  3. “나 보낼게”가 아니라 -> “네 것도 받을 준비가 됐어”를 확인하는 것입니다.

어떤 과정을 통해 연결이 만들어지는지 살펴보겠습니다.

Java에서 간단한 소켓 통신을 통해 tcpdump로 한 번 확인해보겠습니다.

// Server_1.java
public class Server_1 {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(8080, 50, InetAddress.getByName("127.0.0.1"));
        System.out.println("Server listening on 127.0.0.1:8080");
        Socket s = ss.accept();
        System.out.println("Accepted from: " + s.getRemoteSocketAddress() + " local: " + s.getLocalSocketAddress());
        BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream()));
        String line = in.readLine();
        System.out.println("recv: " + line);
        s.close();
        ss.close();
    }
}

먼저 서버 측 코드입니다. 하나 씩 살펴보면:

  • ServerSocket는 TCP 서버를 여는 클래스로 여기서는 127.0.0.1:8080에 바인딩하고
  • accept()는 클라이언트가 접속할 때까지 블로킹됩니다. 3-Way Handshake가 끝난 뒤에 반환하며, 클라이언트와 연결된 Socket 객체를 반환합니다.
  • 이어서 s.getInputStream()을 읽어서 클라이언트가 보낸 문자열을 받는다. (hello-server라는 문자열을 읽음)
  • 클라이언트 소켓과 서버 소켓을 닫고 연결을 종료합니다(4-Way Handshake).
// Client_1.java
public class Client_1 {
    public static void main(String[] args) throws IOException {
        Socket s = new Socket("127.0.0.1", 8080);
        System.out.println("Connected: local=" + s.getLocalSocketAddress() + " remote=" + s.getRemoteSocketAddress());
        PrintWriter out = new PrintWriter(s.getOutputStream(), true);
        out.println("hello-server");
        s.close();
    }
}

이어서 클라이언트입니다:

  • Socket을 생성하면 서버와 3-Way Handshake를 수행하여 연결하고
  • OutputStream에 “hello-server\n” 메시지를 전송합니다.

tcpdump로 캡쳐한 결과는 다음과 같습니다.

TCP 통신에 성공했을 때를 가정하고, 캡쳐 결과와 그림을 통해 과정을 살펴보겠습니다.

  1. CLOSED
    CLOSED는 연결이 없는 상태입니다. 이 상태에서 연결을 생성하기 위해서는 클라이언트가 연결 요청을 보내야 합니다.
  2. CLOSED -> SYN_SENT
127.0.0.1.64699 > 127.0.0.1.8080: Flags [S], cksum 0xfe34 (incorrect -> 0xd125), seq 3172616020, win 65535

클라이언트는 서버에 연결을 요청하는 SYN 세그먼트를 보내고, SYN_SENT 상태로 바뀝니다.
이 때 클라이언트는 자신의 초기 시퀀스 번호(ISN)를 무작위로 생성(3172616020)하고, 이 ISN은 데이터 전송의 기준이 됩니다.
tcpdump 플래그 [S]는 SYN(Synchronize sequence numbers)을 의미합니다. “내가 쓰려는 초기 시퀀스 번호(ISN)가 이것이야”를 상대방에게 알리는 것입니다.
이 경우 클라이언트가 능동적으로 연결을 여는 제스처를 취한다고 하여 Active Open라고 합니다. 패시브였으면 아마 계속 LISTEN 상태로 있었겠죠.
3. LISTEN → SYN_RECV

127.0.0.1.8080 > 127.0.0.1.64699: Flags [S.], cksum 0xfe34 (incorrect -> 0x28b9), seq 2639859124, ack 3172616021, win 65535

서버는 LISTEN 상태에서 클라이언트의 SYN을 수신합니다.
이 후, 서버에서 자신의 ISN(2639859124)을 생성하고, 클라이언트 ISN+1 (3172616020+1 = 3172616021)을 ACK에 넣어 응답합니다.
여기서 플래그 [S.]는 SYN+ACK(Acknowledgement)을 의미합니다. “네가 보낸 시퀀스 번호까지 잘 받았고, 내가 기대하는 다음 바이트 번호는 이것이야”라는 것을 알립니다.
4. SYN_SENT → ESTABLISHED (클라이언트)

127.0.0.1.64699 > 127.0.0.1.8080: Flags [.], cksum 0xfe28 (incorrect -> 0x89c1), ack 2639859125, win 6380

클라이언트는 서버의 SYN을 수신하고, 이에 대한 ACK을 보내는데, 이때 ACK 번호는 서버 ISN+1 (2639859124+1 = 2639859125)이 됩니다.
이제 클라이언트와 서버와의 연결을 확정되었습니다.
6. SYN_RECV → ESTABLISHED (서버)
서버는 클라이언트의 최종 ACK을 수신하면서 ESTABLISHED 상태로 전이합니다.
이 시점부터 양측은 신뢰성 있다고 여기고 데이터 전송이 가능한 상태가 됩니다.
7. 데이터 전송

127.0.0.1.64699 > 127.0.0.1.8080: Flags [P.], seq 1:14, ack 1, win 6380, length 13: HTTP

클라이언트는 “hello-server\n”이라는 13바이트 데이터를 전송합니다.
여기서 ack가 1로 표시되어 있는데요, 실제 TCP 세션을 고려하면 클라이언트의 ISN+1인 2639859125가 되어야 합니다.
이는 (gpt에 따르면..) tcpdump가 보기 편하도록 상대 번호(relative) 로 바꿔서 출력했기 때문입니다.
원래 ACK=2639859125 (서버 ISN=2639859124 + 1) 이지만, tcpdump가 상대 번호로 변환해서 ack=1 로 보이게 된 것이죠.
이어서 seq 1:14 → 상대 번호 표시(relative), 실제로는 클라이언트 ISN+1부터 시작한다는 의미입니다.
[P.]는 PSH+ACK 플래그를 의미하며, 데이터를 즉시 밀어 넣고 ACK 포함한다는 뜻입니다.

127.0.0.1.8080 > 127.0.0.1.64699: Flags [.], ack 14, win 6380

서버는 데이터를 잘 받았음을 ACK=14로 알립니다.
여기서 14는 “상대 SEQ 1에서 시작해 13바이트 받았으니, 이제 다음 기대 바이트는 14”라는 뜻입니다.

2-Way Handshaking로 하면 안되나?

연결 과정을 보면서 ‘2-Way Handshaking로는 연결이 불가능할까?’ 라는 예전에는 생기지 않았던 의문이 들었습니다.

연결 과정에서 클라이언트가 SYN(X)를 보낸 뒤에 서버가 ACK 응답을 보내야 하지만 네트워크 지연이나 타임아웃이 발생하는 경우를 고려해보겠습니다.

클라이언트는 일정 시간 후 새로운 SYN(Z)를 보낼 수 있는데, 서버가 이전 SYN에 대한 ACK을 나중에 보내면, 클라이언트는 그 ACK을 “새로운 SYN(Z)에 대한 응답”이라고 잘못 해석할 우려가 생기게 됩니다.

클라이언트는 이후부터 Z 시퀀스 번호 기반 통신을 시도하지만, 서버는 여전히 X 기반을 기대하고 있기 때문에 서로 꼬이는 상황이 생기게 되는 것이죠.

이러한 문제는 중복 ACK와 잘못된 시퀀스 동기화를 일으킬 수 있게되는데, 3-way handshake에서는

  • ISN을 상호 확인함으로써 마지막 ACK 덕분에 서버도 자기 ISN이 전달된 걸 확실히 알게되고
  • 연결 성립을 서버가 ACK 받고 나서야 ESTABLISHED로 확정하기 때문에, 늦게 온 ACK이 새로운 SYN과 뒤섞여도 서버는 잘못된 연결을 만들지 않습니다.
  • 또한 클라이언트와 서버가 각각 ISN을 주고받고, 서로 확인까지 완료하기 때문에, 양쪽 모두 신뢰 가능한 데이터 전송 가능하게 됩니다.

4-Way Handshake

데이터 전송을 마쳤으니 연결을 종료해야 합니다. 종료 과정이 필요한 이유가 무엇일까요?

먼저 한 쪽에서 일방적으로 연결을 끊어버린다면 TCP는 양방향 스트림 방식, 즉 두 개의 독립된 데이터 스트림으로 구성되어있기 때문에 다른 한 쪽은 연결이 끊어졌는지 지속되고 있는지 알 방법이 없습니다.

이게 무슨 의미냐. 한 쪽이 FIN이라고 보내도 ‘나는 더 이상 보낼게 없어’ 뜻이지 상대방의 데이터도 끝났다는 의미는 아닙니다.

소켓의 수신 버퍼에 남은 데이터를 애플리케이션이 꺼내 읽지 않았다면?

커널은 소켓 닫기와 함께 남은 데이터를 폐기하고, 애플리케이션 입장에서는 일부 메시지가 잘린 채 종료되어 결국 데이터 소실로 이어지겠죠.

종료 과정이 훨씬 복잡한 이유가 여기에 있습니다.

이때 클라이언트와 서버가 총 4번의 통신 과정을 거치기 때문에, 이 과정을 4-Way Handshake라고 부릅니다.

연결 종료는 과정은 어느 쪽에서나 먼저 시작할 수 있기 때문에, 편의상 ‘종료를 시작하는 쪽을 클라이언트’, ‘요청을 받는 쪽을 서버’라고 정의하겠습니다.

  1. ESTABLISHED -> FIN_WAIT_1 (클라이언트)
127.0.0.1.64699 > 127.0.0.1.8080: Flags [F.], seq 14, ack 1, win 6380

클라이언트가 연결 종료를 요청하면서 FIN 패킷을 상대방에게 보내며, 상태는 FIN_WAIT_1으로 전이합니다.
“나는 더 이상 안 보낼래(FIN)” + “네가 지금까지 보낸 건 잘 받음(ACK=1)”을 나타냅니다.
여기서 플래그를 살펴보면 [F.]로 FIN+ACK를 의미합니다. TCP는 플래그를 하나의 세그먼트에 동시에 묶어 보낼 수 있는데, FIN을 보낼 때 보통 직전에 받은 데이터에 대한 ACK를 함께 담아 보내는 경우가 많아 FIN+ACK처럼 보이는 것.
2. ESTABLISHED → CLOSE_WAIT (서버)

127.0.0.1.8080 > 127.0.0.1.64699: Flags [.], ack 15, win 6380

클라이언트로 부터 FIN 패킷을 수신한 서버는 클라이언트의 시퀀스 번호 + 1로 ACK 번호를 15로 만들어 응답하고 상태는 CLOSE_WAIT로 전이합니다.
FIN 패킷의 시퀀스 번호가 14였으니, 다음 기대 바이트는 15가 되는 것이죠.
이어서 서버가 전송할 데이터가 남아 있다면 마저 전송하고, 모든 전송을 마치면 close()와 같은 함수를 호출하여 종료 과정을 이어가는데, 여기서 명시적으로 close()를 호출하지 않으면 다음 상태로 넘어가지 않고 교착 상태에 빠질 수 있습니다.
이처럼 서버가 클라이언트로부터 연결 종료 요청(close())를 받은 후에야 연결 종료를 하기 때문에 ‘Passive Close’ 라고 합니다.
3. CLOSE_WAIT → LAST_ACK

127.0.0.1.8080 > 127.0.0.1.64699: Flags [F.], seq 1, ack 15, win 6380

서버도 더 이상 보낼 게 없으므로 FIN 전송 + 동시에 클라 FIN에 대한 ACK를 유지합니다.
4. FIN_WAIT_2 → TIME_WAIT (클라이언트)

127.0.0.1.64699 > 127.0.0.1.8080: Flags [.], ack 2, win 6380

클라이언트는 서버의 FIN을 확인하고 최종 ACK=2를 보냅니다. 상태는 TIME_WAIT으로 전이합니다.
서버에서 FIN 수신을 확인합니다. (ACK=2: 서버 SYN(1) + FIN(1)까지 합쳐서 2)
여기서 왜 종료 후에 바로 끝내지 않고, TIME_WAIT 상태로 대기하는 것 일까요?
먼저 지연 패킷(Old Duplicate Segment)을 제거해야 합니다. 이는 네트워크에서 아주 늦게 도착한 예전 연결의 패킷이 새 연결로 잘못 들어오는 걸 의미합니다.
만약 연결 종료 직후 같은 IP/Port 쌍으로 새 연결을 만들면, 이전 연결의 늦게 도착한 패킷이 새 연결에 섞여 들어가는 문제가 생길 수 있습니다.
여기서 TIME_WAIT 동안 기다리면, 이전 연결의 모든 패킷이 네트워크에서 사라지게 보장할 수 있습니다.
이 시간은 보통 MSL(Maximum Segment Lifetime, 최대 세그먼트 생존 시간) 의 2배(2MSL)로 설정되어 있으며, 이는 커널에서 정의된 값입니다.
또한 연결 종료 ACK의 재전송 보장을 보장합니다.
4-handshake 종료 절차의 마지막은 “FIN → ACK” 입니다.
만약 이 ACK가 네트워크에서 유실되면, 상대방은 FIN을 다시 보내고, TIME_WAIT 상태로 대기하는 쪽은 이 FIN을 다시 받아주고, 다시 ACK을 보내야 합니다.
여기서 TIME_WAIT 없이 바로 닫아버리면? 서버는 FIN을 재전송해도 응답을 받지 못해 연결을 비정상적으로 끊게 됩니다.
5. CLOSED (서버)
ACK 패킷을 받은 서버는 CLOSED 상태로 전이하여 연결을 완전히 종료합니다.
6. CLOSED (클라이언트)
TIME_WAIT에서 ‘2MSL’만큼 시간이 지나면 클라이언트도 CLOSED 상태로 전이합니다.

맺음

이번 글도 약간 분량 조절에 실패해버렸는데, 다음 글에서는 TCP 혼잡/흐름 제어와 UDP에 대해 다루도록 하겠습니다.

잘못된 내용이 있다면 언제든지 알려주세요.

출처