본문 바로가기

Network

TCP, UDP 차이와 3-Way-Handshake, 4-Way-Handshake 이해하기

OSI 7 Layer

TCP, UDP는 OSI 7 계층에서 4 계층인 전송 계층(Transport Layer)에 속한다.
전송 계층은 송신자와 수신자 간에 통신 서비스를 제공해 데이터를 상호 전달할 수 있게 해주는 계층이다.

 

TCP(Transmission Control Protocol)

TCP는 송신자와 수신자 간에 서로 연결이 수립되었음을 보장하기 위한 규약이다.
왜 서로 연결이 수립됨을 보장해야 되는 걸까? 연결지향형 프로토콜인 이유가 있을까?

 

송신자와 수신자의 연결이 수립됨으로써 얻을 수 있는 이점이 있다.

TCP는 Full-Duplex, Point to Point 가상 회선 방식을 통해 패킷을 전송할 논리적 경로를 만들어 연결한다. 송신자와 수신자만의 전용 회선이 생기는 것이다. 둘 만의 전용 회선을 사용하기 때문에 연결의 신뢰성이 보장될 수 있다. 또한 TCP는 흐름 제어와 혼잡 제어라는 기능을 제공하는데 이 제어 기법들 덕분에 연결의 신뢰성 뿐만 아니라 데이터의 신뢰성까지 보장할 수 있게 된다. 더 나아가 회선내에 존재하는 패킷의 유실을 방지하기 위해 TCP는 송신자와 수신자 간의 연결을 해제할 때 4-Way-Handshake 방식을 사용하는데 이 방식은 TIME_WAIT라는 상태를 제공해 연결 해제 전 아직 수신지에 도착하지 못한 패킷을 기다려줌으로써 패킷이 유실되는 것을 방지해주기도 한다.

장점

  • 가상 회선 방식으로 신뢰성있는 연결이 보장됨.
  • 흐름 제어, 혼잡 제어는 곧 데이터의 신뢰성
  • 가상 회선 즉, 전용 회선으로 데이터가 스트림으로 처리될 수 있는 구조이기 때문에 전송할 수 있는 데이터의 크기는 무제한이다.

단점

  • 연결이 수립된 회선으로만 데이터를 전송할 수 있고, 흐름 제어와 혼잡 제어 때문에 UDP에 비해 속도가 느리다.

 

Packet(패킷)

데이터를 보내기 위한 Routing을 효율적으로 하기 위해 데이터를 여러 개의 조각으로 나눈 것을 의미한다.

 

TCP의 Packet Tracking

패킷은 데이터를 여러 개의 조각으로 나눈 것인데 여러 개의 조각이 나눠져서 수신자에게 전송된다면, 수신자는 패킷을 어떻게 다시 조립하여 데이터로 만들까? TCP는 패킷에 번호를 부여한다. 패킷에 번호를 부여함으로써 패킷의 분실 여부를 확인할 수 있고, 수신자는 패킷의 번호를 통해 데이터를 조립하여 사용할 수 있게 된다. (수신측 개발자가 직접 조립하는 것이 아니라 TCP가 수신지에서 패킷을 데이터로 조립한다.)

 

Flow Control(흐름 제어)

송신측과 수신측의 데이터 처리 속도의 차이를 보완하기 위한 기법으로 수신자가 송신자에게 자신의 상태를 주기적으로 피드백하여 수신자가 짧은 시간 내에 많은 양의 패킷을 받지 않도록 조절한다.

 

Congestion Control(혼잡 제어)

네트워크 내에 패킷의 수가 과도하게 증가하는 현상을 혼잡 현상이라고 한다.

패킷이 하나의 라우터에만 집중된다면, 해당 라우터는 모든 데이터를 혼자 처리하기 어려워진다.

이러한 혼잡 현상을 방지하거나 제거하는 것을 혼잡 제어라고 한다.

혼잡 제어는 흐름 제어보다 더 넓은 범위의 관점으로 호스트와 라우터를 포함한 전송 제어 기법이다.

 

TCP 연결 수립과 해제 과정

3-Way-Handshake

TCP에서 논리적인 연결을 수립(Establish)하기 위하여 사용하는 방식이다.

Handshake를 통해 송신자와 수신자는 서로의 Synchronize Sequence Number를 얻을 수 있기 때문에 송신자 수신자 모두 데이터를 전송할 준비가 되었다는 것을 미리 알 수 있다.

송신자와 수신자 간의 연결이 수립됨으로써 얻을 수 있는 이점은 위에서 충분히 설명했으니 어떤 방식으로 연결이 수립되는지 알아보자.

 

송신자는 클라이언트, 수신자는 서버로 가정하고 진행하겠다.

SYN: Synchronize Sequence Number

ACK: Acknowledgement Number

 

[STEP 1]

클라이언트는 서버에 연결을 요청하기 위해 SYN을 전송한다.

클라이언트 상태: CLOSED -> SYN_SENT

 

[STEP 2]

서버는 클라이언트로부터 SYN을 받고 연결 요청을 수락한다는 의미로 SYN, ACK를 전송한다.

서버 상태: LISTEN -> SYN_RECEIVED

 

[STEP 3]

클라이언트는 서버로부터 받은 SYN에 1을 더해 ACK에 저장하고 ACK을 서버로 전송한다.

클라이언트 상태: SYN_SENT -> ESTABLISHED

서버 상태: SYN_RECEIVED -> ESTABLISHED

 

4-Way-Handshake

TCP에서 논리적인 연결을 해제하기 위해 사용하는 방식이다.

의아하다. 연결은 그냥 해제하면 되지 왜 네 번에 걸쳐 복잡하게 해제를 하는 걸까?

아래에서 각 단계별로 설명하면서 나오겠지만, 4-Way-Handshake 방식을 사용하는 이유는 데이터 유실을 방지하기 위함이다.

만약 클라이언트가 서버에 연결 해제 요청을 전송하고 서버 측에서 '응 그래 연결 해제' 이렇게 한 번에 연결이 해제된다면, 아직 네트워크 내에 즉, 가상 회선 내에 아직 서버에 도달하지 못한 패킷이 존재한다면 이 패킷은 유실될 가능성이 매우 높을 것이다.

클라이언트가 전송한 패킷이 Routing 지연이나 패킷 유실로 인한 재전송으로 서버 측에 늦게 도착하는 상황에서 패킷이 유실되는 것을 방지하기 위해 4-Way-Handshake는 4 단계 방식과 함께 TIME_WAIT라는 상태를 제공한다.

 

FIN: Finish Flag

 

[STEP 1]

클라이언트가 연결 해제 요청 FIN을 서버로 전송한다.

클라이언트 상태: ESTABLISHED -> FIN_WAIT_1

 

[STEP 2]

서버는 연결 해제 요청을 승인한다는 의미로 클라이언트에 ACK을 전송한다.

연결 해제 요청에 대한 승인이다. 요청이 바로 해제 되는 것이 아니고, CLOSE_WAIT 상태로 진입한 후 연결 해제 준비를 한다.

서버 상태: ESTABLISHED -> CLOSE_WAIT

클라이언트 상태: FIN_WAIT_1 -> FIN_WAIT_2

 

[STEP 3]

서버는 연결 해제 준비가 완료되었다는 FIN을 클라이언트로 전송한다.

서버 상태: CLOSE_WAIT -> LASK_ACK

클라이언트 상태: FIN_WAIT_2 -> TIME_WAIT

 

[STEP 4]

클라이언트는 TIME_WAIT 시간 만큼 대기 후 서버로 ACK을 전송함으로써 마침내 연결이 해제된다.

드디어 TIME_WAIT 상태가 나왔다. 대개의 경우 2MSL(maximum segment lifetime - 1분~4분) 동안 TIME_WAIT 상태로 대기한다.

클라이언트는 연결을 해제하겠다는 ACK을 바로 서버로 보내는 대신 2MSL 동안 TIMW_WAIT 상태로 대기함으로써 서버는 아직 도착하지 못한 패킷들을 받을 수 있는 여유 시간이 생긴다.

클라이언트 상태: TIME_WAIT -> CLOSED

서버 상태: LAST_ACK -> CLOSED

 

사실 위 글과 그림만으로는 충분히 이해되지 않을 수도 있다. 나도 그랬다.

시간이 충분하다면 아래 코드는 IDE로 가져가서 직접 확인해보길 바란다.

SYN과 ACK이 연결과 데이터 전송, 연결 해제시에 어떤 식으로 전달되는지 이해하면 Handshake 과정이 더욱 쉽게 느껴질 것이다.

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;

public class TcpClientExample {
    /*
    여기는 Application Layer 다.
    아래에서 설명하는 Handshake 는 connect 와 close 명령이 수행될 경우
    OS Layer 에서 어떻게 처리하는 지 설명한 것이다.
     */
    public static void main(String[] args) {
        try (Socket socket = new Socket()) {
            /**
             * Connect 3-Way-Handshake
             *
             * (1) Client - SYN 전송
             * Synchronize Sequence Number 임의의 수 (1111) 생성
             * Synchronize Sequence Number: 1111
             * Acknowledgement Number: 0
             * Client 상태: CLOSED -> SYN_SENT
             *
             * (2) Server - SYN, ACK 전송
             * Synchronize Sequence Number 임의의 수 (2222) 생성
             * Synchronize Sequence Number: 2222
             * Acknowledgement Number: 1112 (Client 가 보낸 Synchronize Sequence Number + 1)
             * Server 상태: LISTEN -> SYN_RECEIVED
             *
             * (3) Client - ACK 전송
             * Synchronize Sequence Number: 1112 (Server 가 보낸 Acknowledgement Number)
             * Acknowledgement Number: 2223 (Server 가 보낸 Synchronize Sequence Number + 1)
             * Client 상태: SYN_SENT -> ESTABLISHED
             * Server 상태: SYN_RECEIVED -> ESTABLISHED
             */
            socket.connect(new InetSocketAddress("localhost", 5000));
            
            OutputStream os = socket.getOutputStream();
            os.write(new byte[]{'T','E','S','T'});
            /**
             * Client Data(Size 4) 전송
             * Synchronize Sequence Number: 1113 ~ 1116
             * Acknowledgement Number: 2223
             *
             * Server Data(Size 4) 수신
             * Synchronize Sequence Number: 2223
             * Acknowledgement Number: 1117 (Client 가 보낸 Synchronize Sequence Number + 1)
             *
             * Client Data 전송 모두 끝난 후
             * Synchronize Sequence Number: 1117 (Server 가 보낸 Acknowledgement Number)
             * Acknowledgement Number: 2224 (Server 가 보낸 Synchronize Sequence Number + 1)
             */
            os.flush();

            /**
             * Try-with-resources 방식이므로 close 명시하지 않아도 됨.
             *
             * Close 4-Way-Handshake
             *
             * FIN: Finish Flag
             *
             * (1) Client - FIN 전송
             * Synchronize Sequence Number: 1117
             * Acknowledgement Number: 2224
             * Client 상태: ESTABLISHED -> FIN_WAIT_1
             *
             * (2) Server - ACK 전송
             * Synchronize Sequence Number: 2224 (Client 가 보낸 Acknowledgement Number)
             * Acknowledgement Number: 1118 (Client 가 보낸 Synchronize Sequence Number + 1)
             * Server 상태: ESTABLISHED -> CLOSE_WAIT
             * Client 상태: FIN_WAIT_1 -> FIN_WAIT_2
             *
             * (3) Server - FIN 전송
             * Synchronize Sequence Number: 2224
             * Acknowledgement Number: 1118
             * 단계 (2), (3) 과정에서 추가로 데이터가 전송된다면 데이터 크기만큼 Synchronize Sequence Number 증가
             * Server 상태: CLOSE_WAIT -> LAST_ACK
             * Client 상태: FIN_WAIT_2 -> TIME_WAIT
             *
             * (4) Client - ACK 전송
             * Synchronize Sequence Number: 1118 (Server 가 보낸 Acknowledgement Number)
             * Acknowledgement Number: 2225 (Server 가 보낸 Synchronize Sequence Number + 1)
             * Client 상태: TIME_WAIT -> CLOSED
             * Server 상태: LAST_ACK -> CLOSED
             */
            socket.close();
        } catch (IOException ioException) {
            ioException.printStackTrace();
        }
    }
}

UDP(User Datagram Protocal)

Datagram이란 독립적인 관계를 지닌 패킷이다.

아래 사진을 보면 한 번에 이해될 것이다. TCP와는 다르게 가상 회선 즉, 전용 회선이 존재 하지 않는 비연결형 프로토콜이다.

TCP 처럼 논리적으로 연결되는 경로가 없기 때문에 각 패킷들은 서로 다른 경로로 전송되고, 독립적으로 처리된다.

당연하게도 UDP는 비연결형 프로토콜이기 때문에 TCP처럼 데이터 전송을 위해 사전에 연결 수립이 당연히 없을 뿐더러 흐름 제어나 혼잡 제어도 제공되지 않기에 TCP에 비해 데이터 전송 속도가 빠르지만, 데이터의 신뢰성은 보장되지 않는다. 그저 Check Sum 이라는 필드를 통해 최소한의 오류만 검출한다. 이러한 특성으로 인해 신뢰성보다는 연속성이 중요한 Streaming 서비스에 자주 사용되는 프로토콜이다.

한 번에 전송할 수 있는 Datagram 크기는 65535 Bytes로 제한되어 있어서 크기가 초과하면 잘라서 보낸다.

Check Sum

전송된 데이터가 변형이 되지 않았는지 확인하는 용도로 사용되는 값이다. 

전송하는 데이터로 Check Sum을 만들어 패킷에 담아 전송하면, 서버는 전송된 데이터로 다시 Check Sum을 계산해서 패킷에 담긴 Check Sum과 비교하는데 이때 두 값이 다르면 패킷이 변형되었음을 의미한다.

UDP Check Sum을 계산하는 방법은 패킷 도착 IP 주소, 송신 Port, 수신 Port, 데이터, 데이터 사이즈를 모두 16 Bits 단위로 쪼갠 후 전부 더하고 Overflow되어 Carry된 값들을 다시 더해 1의 보수를 취하면 얻을 수 있다.

 

import java.io.IOException;
import java.net.*;

public class UdpClientExample {

    public static void main(String[] args) throws UnknownHostException, SocketException, IOException {
        byte[] bytes = new byte[]{'T','E','S','T'};
        InetAddress inetAddress = InetAddress.getByName("localhost");
        DatagramSocket datagramSocket = new DatagramSocket();
        DatagramPacket datagramPacket = new DatagramPacket(bytes, bytes.length, inetAddress, 5000);
        datagramSocket.send(datagramPacket);
    }
}