TCP 너무 느려요. UDP 쓸까요?

심규민·2024년 8월 20일
post-thumbnail

문득 "TCP도 최적화가 가능할까? 뭐... 대충 소켓 버퍼 사이즈를 조정하면 되지 않을까?" 를 생각하게 됐습니다. 그래서 TCP를 최적화에 대해 내용을 가져와 봤습니다. 한 번 살펴볼까요?

TCP(with Kernel)

간단하게 TCP와 OS에 대해서 짚고 넘어가겠습니다.

혹시 Linux에서 free 커멘드 호출을 통해 메모리 사용량을 확인해보신적 있으신가요? 제가 운영하는 서버에는 다음과 같은 결과를 출력했습니다.

[ec2-user@ip-172-31-2-187 ~]$ free
              total        used        free      shared  buff/cache   available
Mem:         975536      317024      282700         468      375812      516564

이 중 저희가 유심히 살펴봐야할 부분은 buff/cache 영역입니다.
해당 영역은 애플리케이션(ex 스프링)이 사용하는 부분이기보다는 TCP 커넥션이 사용하는 영역입니다.

즉, TCP 커넥션이 생성되면 buff/cache에 4K 정도의 영역을 점유하며, 데이터 전송이 발생한다면 더 큰 영역을 점유하게 됩니다.

TCP 커넥션은 커널에 의해 관리됩니다.
커널은 TCP 커넥션이 생성될 때마다, 네트워크와 프로세스간 데이터 교환을 위한 버퍼 브릿지 역할을 수행하는 버퍼를 할당해줍니다. 이때, 할당해주는 버퍼의 크기가 너무 작다면 네트워크 속도가 매우 느리릴 것이라 예상할 수 있습니다. 또한 버퍼가 너무 크다면, 메모리가 매우 빠르게 고갈되며, 새 커넥션을 생성하지 못하게 될 수 있습니다.

따라서 Linux에서의 올바른 TCP 버퍼 크기 설정을 위해, TCP 메모리 사용에 대해서 이해 해야합니다.

우선 Sliding Window가 전송 속도에 어떤 영향을 주는지 살펴보겠습니다.

Sliding Window(버퍼)와 전송 속도

이 글을 읽으시는 여러분들은 TCP의 특징 중 하나인 신뢰성 보장을 알고 계실 겁니다. 간단히 요약을 해보면, TCP를 사용하면 전송하는 패킷은 무조건 상대 서버에 도착함을 보장합니다.

TCP는 신뢰성 보장을 위해 패킷을 전송할 때, 해당 패킷을 커널 버퍼에서 바로 지우지 않고 커널 버퍼에 저장해 둡니다. 즉, buff/cache 영역에 패킷을 임시로 저장해두는 것입니다. 이러한 저장으로 인해 많은 커넥션이 생성되고 데이터 전송이 동시에(concurrency) 많이 진행 되면 buff/cache 영역이 점차 증가하게 됩니다.

그러면 TCP를 통해 데이터를 교환하는 과정에서 느려지는 부분은 어느 부분일까요?

TCP는 신뢰성 보장을 위해 ACK를 주고 받습니다. 즉, 패킷을 전송하고 ACK 수신까지 기다린 후, 다음 패킷을 전송합니다. 이는 각 패킷을 전송할 때 RTT(Rount Trip Time) 딜레이가 발생할 것이라 예상할 수 있습니다. 좀 더 자세히 살펴봐볼까요?

Single

이더넷이 전송할 수 있는 최대 패킷의 크기가 1500 바이트이기에, 매번 전송하는 패킷의 크기를 1KB로 가정해보겠습니다. 또한 RTT는 10ms로 가정하겠습니다.

그러면 전송 속도는 (패킷 크기 / RTT)로 1KB/10ms = 100KB/s로 알 수 있습니다. 이 전송 속도로 요즘 인터넷을 사용하기란 불가능할 겁니다!

위 간단한 예를 통해 ACK를 수신하는 것은 매우 속도에 큰 영향을 줌을 알 수 있습니다. 그러면 이를 어떻게 최적화할 수 있을까요?

"여러 패킷을 parellel하게 전송할 수 있다" 들어보셨을 겁니다. 그러면 패킷을 병렬로 전송하는 방법에 대해서 살펴봐보겠습니다.

Parallel

패킷을 병렬적으로 전송하는 것은 여러 커넥션을 생성하고, 각 커넥션을 통해 데이터를 주고 받는 방식입니다. 이를 통해 총 지연시간을 줄일 수 있습니다. 예를 들어 설명해보겠습니다.

100MB 데이터를 전송하며, 패킷의 최대 크기는 1KB로 가정해보겠습니다. 그러면 대략 10만개의 패킷을 생성해야합니다. 이 10만개의 패킷을 병렬로 모두 전송한다면 10ms의 지연시간만을 통해 모두 전송할 수 있습니다. 즉, 전송 속도는 100MB/10ms = 10GB/s를 달성할 수 있습니다! 매우 빠르지 않나요?

하지만 당연히 항상 빠를 수 없습니다. 만약에 수신자가 데이터를 모두 처리하지 못한다면, 오히려 더 비효율적일 수 있습니다.

그러면 수신자가 감당할 수 있는 속도로 전송한다면 위 문제를 해결할 수 있지 않을까요?

전송 속도 조절하기

TCP에서 수신자(receiver)는 전송자(sender)에게 수신자가 최대로 처리할 수 있는 전송 속도를 receive window를 통해 알릴 수 있습니다. 수신자는 이 값을 바탕으로 연속해서 받을 수 있는 최대 패킷 수를 계산합니다.

수신자의 커널이 패킷을 수신하면, 먼저 그 패킷을 버퍼(receive window)에 저장합니다. 이후 프로세스가 read 함수를 통해 해당 패킷을 유저 영역으로 읽어들이면, 커널은 버퍼를 비우게 됩니다. 이를 통해 receive window의 공간이 확보됩니다.

즉, 수신자의 버퍼의 여유 공간에 따라 전송자는 전송 속도를 조절해야 합니다. 그러면 어떻게 전송자에게 receiver window의 상태를 알릴 수 있을까요? TCP에는 수신자의 상태를 알릴 수 있는 window field 헤더가 있습니다.

Window Header

전송자는 패킷을 통해 수신자의 window 크기를 알 수 있습니다. 이 크기를 통해 전송할 수 있는 최대 크기를 계산할 수 있습니다.

수신자의 window 크기는 패킷의 Receive Window Size 헤더를 통해 알 수 있습니다. 이 헤더는 2바이트 할당되어 있어, 최대 65535바이트(2^16 - 1) 를 수신할 수 있음을 알릴 수 있습니다.

그러면 RTT가 10ms 인 상황에서 최대 6MB/s(65KB/10ms)의 전송 속도를 가지게 됩니다. 여전히 요즘 인터넷 속도를 생각한다면 매우 느리지 않나요?

물론 TCP 세션을 여러개 생성해서 동시에 데이터를 주고받는다면 좀 더 빠르게 사용할 수 있습니다. 하지만 세션 하나의 속도가 느린 것은 바뀌지 않습니다.

이는 Receive Window Size의 크기로 인해 발생하는 한계입니다.
이러한 문제를 RFC 1323를 통해 해결할 수 있습니다.

RFC 1323을 확인해보면, 윈도우 크기를 30비트로 늘려 수신 가능한 패킷의 최대 크기를 1GB(2^30) 까지 알릴 수 있습니다.
리눅스의 경우 tcp_window_scaling 설정을 통해 활성화할 수 있습니다.

[ec2-user@ip-172-31-2-187 ~]$ sysctl net.ipv4.tcp_window_scaling
net.ipv4.tcp_window_scaling = 1

일반적으로 클라이언트 OS(윈동, MAC OS, iOS, AOS)들은 이 설정이 활성화되어 있습니다.

위 설정을 통해 TCP 통신 과정에서 최대 패킷의 크기를 늘려 전송 속도를 더 빠르게 만들 수 있습니다. 하지만 무한정 빠르게 만들 수는 없습니다.

네트워크 대역폭은 한정되어 있으며, 데이터 통신 과정에서 너무 큰 데이터를 주고받는 다면 라우터에서 해당 요청을 무시하게 되기 때문입니다. 따라서 너무 큰 버퍼 사이즈를 설정하지 말아야합니다. 그러면 최대 버퍼 크기를 어떻게 결정하면 좋을까요?

최대 버퍼 크기 결정하기

앞서 TCP 통신 과정에서 윈도우 크기와 네트워크 대역폭을 통해 전송 속도가 결정되며, 윈도우 크기는 버퍼 사이즈에 의해 결정됨을 알 수 있습니다. 따라서 네트워크 최대 전송 용량(transmission capacity)과 버퍼의 크기가 같다면, 네트워크를 최대한 활용할 수 있을 것임을 예상할 수 있습니다!

그러면 어떻게 네트워크의 전송 용량(transmission capacity)를 계산할 수 있을까요?

네트워크 대역폭의 경우 (크기/시간) 단위로 표현되기 때문에 시간 단위를 곱함으로써 전송 용량을 알 수 있습니다.

예를 들면, 최대 대역폭이 100MB/s인 네트워크가 있을 때, RTT가 10ms이면 네트워크에 이동중인 데이터의 최대 크기(전송 용량)은 1MB임을 알 수 있습니다. 이를 Bandwidth-Delay Product(BDP)라고 부릅니다.

Bandwidth-Delay Product란?
Bandwidth-Delay Product는 데이터 통신 과정에서 처리가능한 최대 데이터량을 말합니다. 즉, 이동중인 데이터의 최대 양을 말합니다.

계산 단위는 초당 비트 혹은 바이트로 표현하며 다음 공식을 통해 계산할 수 있습니다.
BDP = Bandwidth * Delay

만약 BDP 보다 큰 크기의 데이터가 전송되면 네트워크 과부화로 인해 데이터가 유실될 수 있습니다.

따라서 버퍼의 크기는 BDP보다 클 수 없으며, 네트워크를 최대한 활용하기 위해서는 버퍼의 크기와 BDP와 같게 설정해야 합니다.

버퍼 사이즈 조정하기

그러면 모든 버퍼 사이즈를 BDP로 설정하면 될까요? 예를들어 소켓 프로그래밍에서 SO_SNDBUF를 통해 소켓의 버퍼 사이즈 조정을 하는게 좋을까요?

하지만 위 방법을 통해 버퍼 사이즈를 조정하는 것은 좋지 않습니다.

당연히 네트워크의 상태는 매번 바뀌고 항상 최대 속도를 가질 수 없기 때문입니다. 즉, 메모리 낭비가 발생할 수 있습니다.

그러면 어떤 방법을 통해 버퍼 크기를 조정해야 할까요?

리눅스에서는 네트워크 상태에 따라 버퍼의 사이즈를 동적으로 조절해줍니다.
제가 관리하는 서버의 쓰기 버퍼의 범위를 확인해보면 다음과 같습니다.

[ec2-user@ip-172-31-2-187 ~]$ sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096	20480	4194304

세 개의 값은 각각 최소, 중간(초기화값), 최대 값으로 구성됩니다. 또한 바이트 단위로 관리됩니다.

이 범위를 안에서, 전송 버퍼의 사용량에 따라 자동으로 메모리를 할당하거나 해제하게 됩니다.

수신 버퍼는 어떻게 관리될까요? 전송 버퍼와는 약간 다르게 동작합니다.

우선 수신 버퍼의 용량은 다음과 같이 확인할 수 있습니다.

[ec2-user@ip-172-31-2-187 ~]$ sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096	131072	6291456

수신 버퍼는 시스템의 여유 메모리 공간의 크기에 따라 버퍼 크기가 조정됩니다. 만역 여유 메모리 공간이 많다면 수신 버퍼는 더 많은 크기의 메모리 공간을 할당받게 됩니다. 반대로 여유 메모리 공간이 많지 않다면, 버퍼 사이즈를 줄이게 됩니다.

전송 버퍼는 기본적으로 조정이 가능하지만, 수신 버퍼의 범위를 변경하기 위해서는 tcp_moderate_rcvbuf를 1로 설정해야 합니다.

[ec2-user@ip-172-31-2-187 ~]$ sysctl net.ipv4.tcp_moderate_rcvbuf
net.ipv4.tcp_moderate_rcvbuf = 1

그러면 수신 버퍼의 크기를 조정할 때, 할당할 수 있는 메모리의 크기를 어떻게 결정할 수 있을까요? tcp_mem 값을 통해 결정하게 됩니다.

[ec2-user@ip-172-31-2-187 ~]$ sysctl net.ipv4.tcp_mem
net.ipv4.tcp_mem = 10413	13886	20826

tcp_mem은 리눅스 커널에서 TCP를 위해 사용할 수 있는 메모리 크기를 나타냅니다. 앞서 소개한 tcp_rmem, tcp_wmem은 개별 소켓당 지정되는 값이라면, tcp_mem은 소켓 전체에 대한 값입니다. 또한 바이트 단위가 아닌 페이지 단위로 관리됩니다.

tcp_mem은 앞선 범위들과 유사하게 3개의 값을 가지고 있습니다.
현재 소켓 유지를 위해 사용하는 메모리량이 첫 번째 값보다 작다면, 수신 버퍼의 크기를 조정하지 않습니다. 첫 번째 값보다 커지게 되면, 수신 버퍼의 크기를 조정하기 시작합니다. 마지막으로 세 번째 값보다 커지게 되면, 더 이상 새로운 TCP 커넥션을 생성하지 않습니다.

그러면 이 값을 어떻게 조정하면 좋을까요?

네트워크 자원을 최대한 활용하기 위해서는 각 버퍼의 최대 크기를 BDP 값으로 조정해야 합니다. 이를 통해서 패킷 유실 가능성 또한 낮출 수 있습니다.

추가로 전체 소켓에서 사용하는 메모리가 증가함에 따라, 각 소켓 버퍼에 할당된 메모리 크기가 줄어든다면 tcp_mem 값을 증가시킴으로써 전송 속도를 향상시킬 수 있습니다.

반대로, 메모리를 더 할당할 수 없다면, tcp_wmemtcp_rmem의 기본(두 번째 값)값을 조정함으로써 메모리 사용량을 최적화할 수 있습니다.

요약

  • TCP 데이터 전송에서 single, parallel 두 방법을 통해 데이터를 전송할 수 있습니다.
  • 데이터 전송 속도는, 송수신 버퍼의 크기와 네트워크 대역폭에 의해 결정됩니다.
  • 네트워크를 최대한 활용하기 위해서는 BDP를 계산하여, 해당 값을 최대 버퍼 크기로 지정해야 합니다.
  • 송수신 버퍼의 크기는 각각 tcp_wmemtcp_rmem을 통해 알 수 있습니다.
    • 각 크기는 바이트 단위로 표현됩니다.
  • 소켓이 사용할 수 있는 메모리 범위는 tcp_mem을 통해 알 수 있습니다.
    • 각 크기는 페이지 단위로 표현됩니다.
  • 상황에 맞게 tcp_mem, tcp_wmem, tcp_rmem을 조정하여, 성능 최적화를 진행할 수 있습니다.

참고
https://datatracker.ietf.org/doc/html/rfc1323
https://hoonheui.tistory.com/entry/Bandwidth-delay-product
https://meetup.nhncloud.com/posts/53
https://medium.com/dev-genius/how-to-modify-tcp-buffers-to-balance-concurrent-connections-and-transmission-speed-086b730e1522

1개의 댓글

글을 써주셔서 감사합니다. 어떤 부분을 중점으로 공부해야 할지 좋은 길잡이가 되었습니다.

답글 달기