컴퓨터 공학(Computer Science & Engineering))/네트워크

[네트워크] TCP 핸드쉐이크... UDP가 그렇게 빨라?

멍개. 2022. 8. 28. 15:40

안녕하세요. 멍개입니다.

지난시간까지 TCP 기반의 대표적인 프로토콜 HTTP, WebSocket, Socket.io, RPC를 알아보았습니다. 여기서 3-way handshake, 4-way handshake라는 용어를 사용했는데 이번 시간에는 TCP에서 데이터 전송 전/후로 커넥션을 생성하고 끊는 과정인 핸드쉐이크를 설명합니다.

TCP 프로토콜은 크게 3가지 흐름이 존재합니다.

연결 생성 (Connection establishment)
데이터 전송 (Data transfer)
연결 해제 Connection termination)

본론으로 들어가기 전에 한 가지 질문을 하겠습니다. TCP가 UDP보다 느린 이유가 무엇일까요?

● TCP 패킷구조

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

(출처: https://datatracker.ietf.org/doc/html/rfc793)

해당 모습은 아마 웹소켓에서 데이터 프레임을 인코딩하고 디코딩할 때 비슷한 형태의 모습을 보았을겁니다.

3-way handshake와 4-way handshake를 할때 사용하는 프레임은 Seq(Sequence Number), Ack(Acknowledgment), SYN, ACK, FIN 입니다. 핸드쉐이크에서 사용하진 않지만 Window는 본인이 사용가능한 버퍼 사이즈를 의미합니다. 여기서 Ack는 숫자를 의미하는 프레임이고, ACK는 플래그를 의미하는 프레임입니다. 이 둘을 혼용하면 안됩니다.

· Source port (16비트)

출발지 포트번호

· Destination port (16비트)

목적지 포트번호

· Seq(Sequence Number) (32비트)

Seq는 데이터를 나눠서 전송할 때 해당 패킷이 몇 번째에 해당하는지 의미하는 패킷입니다. 이를 이해하기 위해선 MSS(Max Segment Size)를 이해해야 합니다. 간단하게 설명하면 1000Mb 파일이 존재합니다. 그런데 MSS가 100MB라면 TCP는 해당 데이터를 전송하기 위해 10개의 패킷을 만듭니다. 그리고 각각의 패킷에 Seq를 순차적으로 부여하여 전송하게 됩니다. 수신자는 해당 패킷을 네트워크 상태에 따라 순서대로 받지 못할 수 있지만 Seq는 이를 순서대로 조립할 수 있도록 도와줍니다. 여기서 한 데이터를 여러 패킷으로 쪼갠것을 세그먼트라고 합니다. Seq는 해당 패킷이 몇 번째 세그먼트인지 알려줍니다.

세그먼트는 최대 크기는 MTU로 정해집니다. MTU는 네트워크 정보에서 확인할 수 있습니다.

최초의 패킷 전송인지 이어서 데이터를 전송하고 있는건지에 대한 SYN에 따라 Seq를 처리하는 방법이 달라집니다.

SYN는 0 또는 1이 될 수 있습니다. 0 또는 1에 따라 Seq가 의미하는 바가 조금은 다릅니다.

· Ack(Acknowledgment) (32비트)

세그먼트를 전달 받으면 해당 패킷은 다음번째 패킷의 Seq를 포함하고 있습니다. 다음번째 세그먼트의 Seq를 의미하는 것이 Ack 입니다. 다음번째 세그먼트의 Seq이기 때문에 Ack가 설정되는 로직은 Seq + 1 입니다.

· ACK (1비트)

Ack는 한 쪽에서 데이터를 보냈을 때 수신자가 Ack 패킷을 전송하여 해당 패킷을 받았다고 알려주는 것을 의미합니다.

· SYN (1비트)

동기화 시퀀스 번호. 최초의 패킷에만 SYN 비트가 1로 활성화 되어야합니다

· FIN (1비트)

마지막 패킷을 의미합니다.

● 연결생성을 위한 3-way handshake

3-way handshake란 두 응용프로그램간 데이터 전송을 위해 연결하는 과정을 의미합니다. 3-way인 이유는 연결을 위해 패킷을 3번 주고받기 때문입니다.

출처:  https://datatracker.ietf.org/doc/html/rfc793

RFC에 정의된 TCP의 전체 상태 이미지인데... 보기 힘들죠? 저도 보기 힘듭니다. 해당 이미지에서 연결된 부분만 정리하면 다음과 같습니다.

출처:  https://datatracker.ietf.org/doc/html/rfc793
 

· 첫 번째 패킷

클라이언트는 연결 시도를 위해 서버에게 SYN를 1로 활성화 한 패킷을 전송합니다. 해당 패킷은 Seq만 셋팅됩니다.

SYN를 1로 설정한 패킷을 보낸 클라이언트의 네트워크 상태는 SYN/ACK 패킷을 기다리며 SYN_SENT 상태가 됩니다. 이때 서버는 LISTEN 상태여야 합니다.

· 두 번째 패킷

SYN 패킷이 1로 활성화 된 패킷을 받은 서버는 연결 요청 수락을 위해 ACK와 SYN가 1로 활성화 된 패킷을 만들어 클라이언트에게 전달합니다. 해당 패킷은 서버가 초기화 한 Seq를 새로 설정하며 클라이언트가 보낸 Seq를 +1하여 Ack로 설정합니다.

이때 서버는 클라이언트가 ACK가 활성화된 패킷을 전송하기를 기다리며 SYN_RECEIVED 상태가 됩니다.

· 세 번째 패킷

서버에게 ACK와 SYN가 활성화 된 패킷을 받은 클라이언트는 서버에게 해당 패킷을 잘 받았다고 ACK만 활성화 된 패킷을 전달합니다. 해당 패킷은 서버가 만든 Ack를 Seq로 설정하며 서버가 만든 Seq를 +1하여 Ack에 포함합니다.

이때 패킷은 SYN는 0으로 비활성화 상태가 됩니다. 이때 클라이언트는 서버와 데이터를 주고받을 수 있는 상태인 ESTABLISHED가 되며 서버도 클라이언트에게 ACK 패킷을 전달받으면 ESTABLISHED 상태가 됩니다.

패킷에서 Seq와 Ack가 만들어지는 과정은 Seq는 Ack에 의해 생성합니다. Seq는 Ack에 의해 생성되는데 SYN이 1이되는 최초의 패킷의 경우 ACK가 없으므로 Seq를 랜덤한 값으로 생성합니다. Ack는 Seq + 1하여 설정합니다. 만약 Seq가 없다면? Ack는 비워집니다. 3-way handshake 첫 번째 패킷에서 Seq가 랜덤하게 만들어지고 Ack는 비워진 상태로 서버에게 패킷을 전달합니다. 서버는 해당 패킷을 받고 Seq를 +1하여 Ack에 설정하고 전달받은 패킷에 Ack이 없기 때문에 서버는 랜덤한 값으로 Seq를 설정합니다. 두 번째 패킷을 받은 클라이언트는 전달받은 패킷의 Ack를 Seq로 설정하고 Seq +1한 값을 Ack로 만들어 패킷을 만듭니다.

· TCP 서버구현

const net = require('net');

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

· TCP 클라이언트 구현

const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

· 패킷 캡처

서버를 실행한 후 와이어샤크 프로그램을 실행합니다. 그리고 클라이언트를 실행하면 다음과 같이 패킷이 캡처됩니다.

$ sudo tcpdump -i lo0 -vvv -nn port 5000                                                                                                                              
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes

23:01:55.214330 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
    127.0.0.1.60370 > 127.0.0.1.5000: Flags [S], cksum 0xfe34 (incorrect -> 0x620a), seq 2313595154, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 1648035900 ecr 0,sackOK,eol], length 0

23:01:55.214390 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
    127.0.0.1.5000 > 127.0.0.1.60370: Flags [S.], cksum 0xfe34 (incorrect -> 0x2243), seq 679172691, ack 2313595155, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 273853589 ecr 1648035900,sackOK,eol], length 0

23:01:55.214401 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.60370 > 127.0.0.1.5000: Flags [.], cksum 0xfe28 (incorrect -> 0x834c), seq 1, ack 1, win 6379, options [nop,nop,TS val 1648035900 ecr 273853589], length 0

23:01:55.214409 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.5000 > 127.0.0.1.60370: Flags [.], cksum 0xfe28 (incorrect -> 0x834c), seq 1, ack 1, win 6379, options [nop,nop,TS val 273853589 ecr 1648035900], length 0

엥 3개의 패킷이 아니라 4개의 패킷이 캡처되는데요? 마지막 패킷은 핸드쉐이킹이 완료된 후 버퍼의 크기를 확인하기 위한 통신입니다.

패킷을 하나씩 살펴보면 다음과 같습니다.

첫 번째 패킷
 127.0.0.1.60370 > 127.0.0.1.5000: Flags [S], seq 2313595154

클라이언트가 서버로 전달한 패킷입니다. Flags는 S인데 이것은 SYN가 활성화 되었다는 의미입니다. seq는 클라이언트가 랜덤하게 만든값 입니다. 앗! 근데 와이어샤크에선 아마 0이라고 뜰건데 이건 계산의 편의를 위해 offset을 별도로 가지고 있고 Seq를 0으로 표기한겁니다.

두 번째 패킷
 127.0.0.1.5000 > 127.0.0.1.60370: Flags [S.], seq 679172691, ack 2313595155

두 번째 패킷을 살펴보면 Flags에 S.이라고 되어있는데 .은 ACK을 의미합니다. 첫 번째 패킷에 ack(Ack)가 없으므로 서버가 랜던하게 만든값입니다. ack는 seq +1한 값입니다.

세 번재 패킷
 127.0.0.1.5000 > 127.0.0.1.60370: Flags [.], seq 1, ack 1

tcpdump의 세번째 패킷의 seq라 ack는 좀 신기하게 1, 1로 표시합니다. (tcpdump가 신기하게 3-way handshake 이후엔 seq랑 ack를 와이어샤크처럼 보기 편하라고 알아서 계산해서 표기합니다)

와이어샤크에서의 각 패킷을 살펴보면 다음과 같습니다. 아 참고로 tcpdump랑 서로 다른 요청일때 캡쳐했기 때문에 seq랑 ack가 서로 맞진 않습니다.

첫 번째 패킷
seq: 116187442 (랜덤하게 만든 값)
두 번째 패킷
seq: 46286935 (랜덤하게 만든 값)
ack: 116187443(첫 번째 패킷의 seq인 116187442에서 + 1한 값)
세 번째 패킷
seq: 116187443(두 번째 패킷의 ack)
ack: 46286936(en 번째 패킷의 seq인 46286935에서 + 1한 값)

와이어샤크에선 예상대로 seq와 ack이 잘 뜨는 모습을 볼 수 있습니다.

● 연결 해제를 위한 4-way handshake

4-way handshake란 두 응용프로그램간 연결을 끊는 과정을 의미합니다. 4-way인 이유는 연결을 끊기위해 패킷을 4번 주고받기 때문입니다.

4 way-handshake는 패킷 방향이 중요합니다. 연결을 끊기를 요청하면 수신자측은 2개의 패킷은 전달합니다. 그 이유가 전송중인 데이터가 있다면 해당 전송을 마무리하기 위해서입니다.

· 첫 번째 패킷

연결 끊기를 희망하는 쪽에서 FIN을 1로 설정된 패킷을 전송합니다. 패킷을 전송한 대상은 FIN_WAIT_1 상태가 됩니다.

· 두 번째 패킷

연결끊기 요청(클라이언트) 패킷을 받은 대상은 해당 패킷의 Seq와 Ack를 기준으로 Seq와 Ack를 설정한 후 ACK 플래그를 활성화하여 연결끊기 요청 패킷을 받았다는 사실을 알려줍니다. 연결끊기를 희망하는 대상은 해당 패킷을 받으면 FIN_WAIT_2 상태가 됩니다. 그리고 플래그의 FIN이 활성화 된 패킷을 받기를 기다립니다. 혹시 서버가 이전에 전송중인 데이터가 있다면 마저 전송을 합니다.

일반적으로 연결은 클라이언트가 끊기를 요청합니다.

· 세 번째 패킷

서버가 더이상 클라이언트에 보낼 데이터가 없다면 플래그의 FIN, ACK을 설정한 패킷을 클라이언트에 보낸 후 LAST_ACK 상태가 됩니다.

두 번째 패킷과 세 번째 패킷은 하나의 패킷을 대상으로 만들어진 패킷이기 때문에 seq와 ack가 동일합니다.

· 네 번째 패킷

서버로부터 FIN 패킷을 받은 클라이언트는 TIME_WAIT 상태로 바뀝니다. FIN을 받았다는 패킷을 보내기 위해 ACK이 활성화 된 패킷을 서버로 보냅니다. 클라이언트로 부터 ACK을 받은 서버는 해당 소켓이 CLOSED 상태가 됩니다.

이후 시간이 지나면 클라이언트도 소켓을 CLOSED로 변경합니다.

· TCP 서버 구현

const net = require('net');

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

서버는 앞에서 구현한것과 동일합니다.

 

· TCP 클라이언트 구현

 
const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

socket.on('connect', function () {
  console.log('connected to server!');
  setTimeout(() => {
    console.log('try disconnected');
    socket.end();
  }, 4000)
});

connect가 되면 4초후 socket.end()를 호출하여 4-way handshake를 발생시킵니다.

· 패킷캡처

(이번엔 tcpdump와 와이어샤크로 같이 캡처 했습니다....)

23:39:56.895863 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.60697 > 127.0.0.1.5000: Flags [F.], cksum 0xfe28 (incorrect -> 0x0d89), seq 1, ack 1, win 6379, options [nop,nop,TS val 1441315233 ecr 3308819552], length 0
23:39:56.895910 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.5000 > 127.0.0.1.60697: Flags [.], cksum 0xfe28 (incorrect -> 0xfdeb), seq 1, ack 2, win 6379, options [nop,nop,TS val 3308823549 ecr 1441315233], length 0
23:39:56.896161 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.5000 > 127.0.0.1.60697: Flags [F.], cksum 0xfe28 (incorrect -> 0xfdea), seq 1, ack 2, win 6379, options [nop,nop,TS val 3308823549 ecr 1441315233], length 0
23:39:56.896197 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.60697 > 127.0.0.1.5000: Flags [.], cksum 0xfe28 (incorrect -> 0xfdea), seq 2, ack 2, win 6379, options [nop,nop,TS val 1441315233 ecr 3308823549], length 0

각각의 패킷을 살펴보면 다음과 같습니다.

▶ 첫 번째 패킷

해당 패킷을 보낸 클라이언트는 FIN_WAIT_1 상태가 됩니다.

와이어샤크 캡처
첫 번째 패킷(TCP 덤프)
  127.0.0.1.60697 > 127.0.0.1.5000: Flags [F.], seq 1, ack 1

클라이언트가 서버에게 플래그 비트인 FIN과 ACK을 1로 설정한 후 seq와 ack을 포함하여 서버로 전송합니다.

▶ 두 번째 패킷

서버로 부터 두 번째 패킷을 받은 클라이언트는 TIME_WAIT 상태가 됩니다.

와이어샤크 캡처
두 번째 패킷(TCP 덤프)
  127.0.0.1.5000 > 127.0.0.1.60697: Flags [.] seq 1, ack 2

서버는 클라이언트에게 첫 번째 패킷을 기준으로 seq와 ack를 셋팅한 후 ACK 플래그를 활성화 하여 클라이언트에게 전달합니다.

▶ 세 번째 패킷

세 번째 패킷을 전달한 서버는 LAST_ACK 상태가 됩니다.

세 번째 패킷도 서버가 클라이언트에게 전달하는 과정입니다. 첫 번째 패킷을 받은 상태에서 두 번째 세 번째를 서버가 보내기 때문에 seq, ack는 동일합니다.

와이어샤크 캡처
세 번째 패킷(TCP 덤프)
  127.0.0.1.5000 > 127.0.0.1.60697: Flags [F.] seq 1, ack 2

▶ 네 번째 패킷

서버로부터 FIN 패킷을 받은 클라이언트는 TIME_WAIT 상태로 바뀝니다. 클라이언트로 부터 ACK을 받은 서버는 해당 소켓이 CLOSED 상태가 됩니다.

이후 시간이 지나면 클라이언트도 소켓을 CLOSED로 변경합니다.

와이어샤크 캡처
네 번째 패킷(TCP 덤프)
  127.0.0.1.60697 > 127.0.0.1.5000: Flags [.], seq 2, ack 2

 

● 데이터 전송

우리는 TCP 사용을 위해 8개의 패킷을 전송하여 사용하는 모습을 확인했습니다. 만약 데이터 전송을 한다면 클라이언트 또는 서버는 상대방에게 패킷을 전송합니다. 하지만 여기서 끝이 아니라 ACK 플래그를 활성화하여 해당 패킷을 잘 받았다고 알려줘야 합니다.

const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

socket.on('connect', function () {
  console.log('connected to server!');
  setInterval(() => {
    console.log('send data');
    socket.write('msg');
  }, 4000)
});

4000ms 마다 한 번씩 서버에게 데이터를 요청하는 코드를 실행 시킨 후 패킷 캡처를 시도해 보겠습니다.

ACK는 무언가 확인하는 플래그입니다. 잘 전송 받았다고 알립니다.

문득 그런생각이 들지 않나요? 그럼 UDP는? 안저런가??

· UDP 서버

 
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const port = 5000;
 
server.on('message', (message, info) => {
  console.log(`message: ${message.toString()}`);
  console.log(`from address: ${info.address} port: ${info.port}`);
});
 
server.on('listening', () => {
  const address = server.address();
  console.log(`listening ${address.address}:${address.port}`);
});
 
server.bind(port);

· UDP 클라이언트

 
const dgram = require('dgram');
const toHost = '0.0.0.0';
const toPort = 5000;
const client = dgram.createSocket('udp4');
 
const data = Buffer.from('mung');
 
client.send(data, toPort, toHost, (err) => {
  if (err) console.log(err);
  else console.log('ok');
  client.close();
});

· 패킷 캡처

와이어샤크 캡처

와이어샤크는 UDP.port로 UDP 통신만 필터합니다.

하나의 요청에 하나의 패킷만 생성되는 모습을 볼 수 있습니다.

와이어샤크나 tcpdump 모니터링을 활성화 하고 postman같은 클라이언트 프로그램 또는 http 요청 라이브러리로 요청하는 패킷을 확인해보세요 그럼 12개의 패킷이 캡처되는 모습을 확인할 수 있습니다.

왜 http/1.1에서 keep-alive 기능이 나왔는지 아시겠죠?

 

이번시간엔 TCP가 어떻게 연결을 하고 연결 해제를 하는지 알아보았습니다. 다음 시간엔 TCP의 핵심기능인 흐름제어, 오류제어, 연결제어를 알아보겠습니다.