본문 바로가기

진리는어디에

왜 내 TCP는 믿을 수 없을까?

TCP는 정말 신뢰성을 보장해주는 프로토콜일까?

정말 책에 쓰인대로 내가 보낸 모든 데이터들은 상대방에게 신뢰성 있게 잘 도착할까? 라는 궁금증을 가지고 인터넷을 검색하던 중 우연히 "bert hubert finally blogs" 의 "The ultimate SO_LINGER page, or: why is my tcp not reliable"라는 흥미로운 글을 발견하여 짧은 영어 실력으로나마 번역을 시도해 보았다. 번역 중간중간 새로 알게 된 지식이나 보충 설명은 주석으로 추가하였으니 읽으면서 참고 부탁드린다. 원문이 궁금하신 분은 다음 링크를 따라가면 영문 버전을 볼 수 있다.

아래 부터는 SO_LINGER page, or: why is my tcp not reliable 를 번역한 내용이다.

무엇이 문제인가?

TCP - the reliable Transmission Control Protocol - 그 이름만으로도 우리에게 무한한 신뢰를 주는 통신 이 프로토콜은 우리가 전송하는 데이터들을 하나도 빠짐 없이 순서대로 우리가 원하는 곳에 정확히 전달해 줄 것만 같다. 개발자에게 성경 같이 읽혀지는 리눅스 맨페이지를 보아도 아래와 같이 기술 되어 있다.

"TCP provides a reliable, stream-oriented, full-duplex connection between two sockets on top of ip(7), for both v4 and v6 versions. TCP guarantees that the data arrives in order and retransmits lost packets. It generates and checks a per-packet checksum to catch transmission errors.”

From the Linux tcp(7) manpage

"TCP는 v4, v6에 관계 없이 두 소켓 연결 사이의 신뢰성 있고, 스트림 지향이며 완전 양방향 통신을 제공한다. TCP는 데이터 도착 순서와 손실된 패킷의 재전송을 보장하며, 매 패킷 마다 체크섬을 발행하여 전송 오류를 체크한다."

하지만 우리가 순진하게 TCP만을 믿고 데이터를 전송할 때, TCP는 우리에게 예상치 못한 오류 상황을 선사하기도 한다. 예를 들면 마지막 몇 킬로바이트에서 때때로 몇 메가바이트의 데이터들이 절대 목적지에 도착하지 못하는 현상 같은 것들 말이다.

다음 프로그램 A가 1백만 바이트를 B로 전송하는 프로그램을 Posix 호환 OS에서 실행한다고 가정하자(코드는 대략적인 흐름만 파악 할수 있도록 많은 부분이 생략 되었다. 이 글의 맨 마지막에서 전체 코드를 찾을 수 있다).

Program A:

sock = socket(AF_INET, SOCK_STREAM, 0);   
connect(sock, &remote, sizeof(remote));

int writtenBytes = write(sock, buffer 1000000); // returns 1000000

printf("written bytes:%d\\n", writtenBytes);

close(sock);  

Program B:

int sock = socket(AF_INET, SOCK_STREAM, 0);  

bind(sock, &local, sizeof(local));  
listen(sock, 128);  
int client=accept(sock, &local, locallen);

sleep(1); //원문에는 없지만 예외 상황을 보다 쉽게 발생 시키기 위해 추가

write(client, "220 Welcome\\r\\n", 13);  

int bytesRead=0, res;  
for(;;) {  
    res = read(client, buffer, 4096);  
    if(res < 0)  {  
        perror("read");  
        exit(1);  
    }  
    if(!res) {
        break;
    }        
    bytesRead += res;  
}  
printf("%d\\n", bytesRead);  

그럼 여기서 문제를 하나 풀어보자. 위 프로그램 B에서는 프린트 하는 것은 무엇일까?

  1. 1000000
  2. 1000000 바이트 보다 더 작은 수
  3. 에러를 발생 시키고 프로그램을 종료한다
  4. 위 것중 어떤 것이든 가능하다

정답은 안타깝게도 4번 이다. 하지만 어떻게 이런 일이 일어 날수 있을까?

프로그램 A는 분명히 모든 데이터를 정상적으로 다 보냈다고 했다(프로그램 A의 write() 함수에서는 자신이 1000000 바이트를 보냈다는 뜻으로 1000000을 리턴했다).

실제 예제를 실행 해보면 대부분의 경우 1000000이 프린트 될 것이다. 우리가 의도한 실험 결과를 보다 쉽게 보기 위해 프로그램 B에서 accept() 이후 sleep()을 1초간 걸어 주면 A의 write()에서는 1000000을 리턴하고 B에서는 1000000 보다 작은 수를 리턴 하는 것을 쉽게 볼 수 있다.

무슨 일이 일어나고 있는거야?

데이터를 TCP 소켓을 통해 전송하는 것은 로컬 하드디스크에 데이터를 쓰는 것과는 다르다.

TCP 세계에서 write() 성공했다는 것(위 예제에서 1000000을 리턴 했다는 것)은 커널이 당신의 전송 하고자 하는 데이터를 받았다는 뜻이며, 이제 부터 적당한(?) 시점이 되면 데이터 전송을 시도 하겠다는 의미다. 심지어 커널이 데이터를 전송했다고 하는것 조차도, 실제 전송이 된것이 아니라 네트워크 어댑터로 데이터를 넘겼을 뿐이다.

위 문단이 이 포스트의 핵심 내용이라고 보면 된다. TCP 프로토콜은 신뢰성을 보장하지만 그것이 어플리케이션에서 어플리케이션 까지의 정확한 전달을 보장하는 것은 아니다.

네트워크 어댑터로 데이터가 넘어 가고 난 뒤 부터, 데이터는 셀수 없는(사실 셀수 있다) 네트워크 어댑터 등을 거치며 목적지에 도착할때 까지 네트워크를 헤쳐 나간다. 데이터가 수신 측에 도착 하면, 수신측 커널은 데이터가 수신 되었음을 알리고, 소켓을 소유하고 있는 프로세스가 데이터를 읽으려고 시도 할 때 최종적으로 데이터는 목적지 어플리케이션에게 전달 된다.

여기서 주의 할 것은 수신측 커널에서 보낸 수신 통보는 커널이 수신을 감지 했다는 것을 알리는 것이다. 절대 어플리케이션에게 데이터가 전달 되었다는 뜻이 아니다.

그래. 다 이해했어. 그런데 왜 위 예에서 데이터가 다 도착 하지 않았다는 것인데?

우리가 TCP/IP 소켓에 close() 함수를 호출하게 되면, 환경에 따라 커널은 소켓을 닫고, TCP/IP 연결도 같이 날려 버린다. 그리고 아직 전송 되지 못하고 기다리고 있는 데이터가 있거나 이미 전송 되었지만 아직 수신 응답을 받지 못한 패킷이 있다고 하더라도 커널은 해당 연결을 날려 버릴 수 있다.

이 이슈와 관련된 많은 포스트들이 메일링 리스트, 유즈넷, 포럼등에 게시 되었으며, 이 문제를 염두해 두고 작성된 것 처럼 보이는 SO_LINGER 소켓 옵션에 대한 설명을 살펴 보자.

“When enabled, a close(2) or shutdown(2) will not return until all queued messages for the socket have been successfully sent or the linger timeout has been reached. Otherwise, the call returns immediately and the closing is done in the background. When the socket is closed as part of exit(2), it always lingers in the background.”

"(링거 옵션)이 활성화 되어 있을때 close(2)혹은 shotdown(2)는 메시지 큐에 있는 모든 메시지가 전송되거나 linger timeout 시간이 될 때까지 리턴하지 않는다. 그렇지 않다면(활성화 되어있지 않다면), close(2) 혹은 shotdown(2) 호출은 바로 리턴하고 작업은 백그라운드에서 진행된다. 만일 소켓이 exit(2)의 결과로서 close된다면 해당 소켓은 항상 백그라운드에 남아 있다."

그래서 우리는 이 옵션을 셋팅하고 프로그램을 다시 실행했다. 그리고...우리의 백만 바이트는 여전히 도착하지 않았다.

어째서?

RFC 1122 문서의 4.2.2 13 를 살펴 보면, 전송되지 않거나 전송 되었지만 아직 수신 응답을 받지 못한 모든 데이터를 가진 close() 호출은 리셋(RST) 패킷을 보낸다고 되어 있다.

“A host MAY implement a ‘half-duplex’ TCP close sequence, so that an application that has called CLOSE cannot continue to read data from the connection. If such a host issues a CLOSE call while received data is still pending in TCP, or if new data is received after CLOSE is called, its TCP SHOULD send a RST to show that data was lost.”

"호스트는 CLOSE를 호출 호출한 어플리케이션이 연결에서 부터 더 이상 데이터를 읽어 들이지 못하게 하기 위해 반이중(half-duplex) TCP close 시퀀스를 구현 할 수 있다. 만일 어떤 호스트가 펜딩된 - 아직 커널의 소켓 버퍼에 남아 있는 - 데이터를 수신 하는 중에 CLOSE를 발행하거나 CLOSE 이후에 데이터를 수신한다면, TCP는 데이터가 손실 되었다는 것을 알리기 위해 RST를 보내야 한다."

위 예제에서, 프로그램 B가 전송한 “220 Welcome\r\n”는 A입장에서 절대 읽을 수 없는 펜딩된 데이터다.

만일 프로그램 B가 “220 Welcome\r\n”를 전송하지 않았다면 아마도 모든 데이터가 정상적으로 도착 했을 것 처럼 보인다.

그래서, 만일 우리가 처음 부터 데이터를 읽고 LINGER 옵션을 셋팅한다면 정상적으로 진행 되었을까?

실제로는 그렇지 않다. close() 호출은 실제로 우리가 연결을 닫기 전에 모든 데이터들을 전송 해달라고 한 부탁을 커널에게 전달해주지 않는다.

다행히도, 우리는 커널에게 위의 부탁을 정확하게 전달 할 수 있는 shutdown() 시스템콜을 사용할 수 있다. 하지만 이것만으로는 충분하지 않다. shutdown()이 리턴 했을 때, 우리는 여전히 프로그램 B가 모든 데이터들을 받았는지 알수 없다.

하지만 우리가 할 수 있는 것은 FIN 패킷을 프로그램 B로 보내는 shutdown()호출이다.

프로그램 B는 소켓을 닫고, 우리는 이것을 프로그램 A에서 read() 함수가 0을 리턴하는 것으로 알수 있다. 프로그램 A를 아래와 같이 수정 해보자 :

sock = socket(AF_INET, SOCK_STREAM, 0);

connect(sock, &remote, sizeof(remote));

write(sock, buffer, 1000000);             // returns 1000000

shutdown(sock, SHUT_WR); // <- !!

for(;;) {
    res=read(sock, buffer, 4000);

    if(res < 0) {
        perror("reading");
        exit(1);
    }

    if(!res)
        break;
}

close(sock);

그래서 이제 완벽해?

음..HTTP 프로토콜을 보자면, 데이터를 보낼때 길이 정보를 길이 정보를 포함해서 보내기도한다. 그리고 이것이 모든 데이터들을 다 수신했는지 확인 할 수 있는 유일한 방법이다.

위의 shutdown() 기술을 사용하는 것은 리모트 연결이 close 되었다고만 알려 줄 뿐이다. 그것은 프로그램 B가 모든 데이터를 정상적으로 수신 했음을 보장하진 않는다. 최고의 조언은 항상 길이를 함께 보내는 것이며, 수신 프로그램은 모든 데이터가 수신 되었음을 알리는 것이다.

그 밖에 다른 할 일은 없나요?

내가 그랬던것 처럼 당신도 멍청한 TCP/IP의 구멍으로 데이터를 보내야만 한다면, 아마도 위의 패킷에 길이 정보를 추가하고, 수신 알림을 받으라는 현명한 충고를 따르기는 거의 불가능 할 것이다. 그런 경우, 모든 데이터들을 다 수신했다는 의미로 close를 받는 것은 충분하지 않다.

다행히, 리눅스에서는 ioctl()의 SIOCOUTQ 옵션을 이용해, 아직 수신 응답을 받지 못한 데이터의 양을 추적 할 수 있다. 응답 받지 못한 데이터의 양이 0이 되는 순간 우리는 최소한 목적지 OS까지 모든 데이터가 정상적으로 전송되었음을 확인 할수 있다.

위에 언급된 shutdown() 과는 다르게, SIOCOUTQ는 리눅스 OS에 의존성을 가지고 있다.

하지만 그럼 어떻게 지금까지 '잘' 동작 했죠?

읽지 않은 펜딩된 데이터가 없는 한은, 모든 것은 정상적일 것이다. 대부분의 경우 다행히도 위의 시나리오는 대부분의 경우에는 적용이 안될 것이다. 하지만 너무 믿지는 말라.

부록 1. SO_LINGER 옵션

SO_LINGER 소켓에 close 함수를 호출 했을 때, 소켓 버퍼에 남아 있는 데이터를 어떻게 할 것인지 결정하기 위해 사용되는 소켓 옵션이다. 인자는 아래와 같다 :

struct linger {
    int l_onoff;
    int l_linger;
};
  • l_onoff : 링거 옵션을 켤 것인지 끌 것인지 결정
  • l_linger : 기다리는 시간 지정

위 두 변수의 값에 따라 아래 세가지의 close 방식이 결정 된다.

  • l_onoff == 0 : 링거 옵션을 사용하지 않는다. close() 함수 호출 시, 함수는 바로 리턴 하고 백그라운드에서 소켓 버퍼에 남아 있는 모든 데이터를 보낸다. graceful close를 보장한다.
  • l_onoff > 0 : close() 는 바로 리턴하며, 소켓 버퍼에 남아 있는 데이터는 바로 버려진다. TCP 연결 상태의 경우는 상대편 호스트에 리셋을 위한 RST를 보낸다.
  • l_onoff > 0 && l_linger > 0 : close() 호출 시 l_linger에 지정 된 시간 만큼 블록킹 상태에서 대기하며 그 동안 소켓 버퍼에 남아 있는 데이터를 보내려고 시도한다. 지정된 시간 내에 데이터를 모두 보냈다면 정상 리턴이 되고, 시간이 초과되는 경우는 에러를 리턴한다.

부록 2. 전체 코드

프로그램 A :

#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv)
{    
    int sock = socket(AF_INET, SOCK_STREAM, 0);

    sockaddr_in clientaddr;
    clientaddr.sin_family = AF_INET;
    clientaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    clientaddr.sin_port = htons(9999);

    int client_len = sizeof(sockaddr_in);

    connect(sock, (struct sockaddr *)&clientaddr, client_len);

    char buffer[1000000];

    int written = write(sock, buffer, 1000000);

    printf("wrote:%d\n", written);

    close(sock);

    return 0;
}

프로그램 B :

#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int main(int argc, char** argv)
{     
     int sock = socket(AF_INET, SOCK_STREAM, 0);

     sockaddr_in addr;

     bzero(&addr, sizeof(sockaddr_in));
     addr.sin_family = AF_INET;
     addr.sin_addr.s_addr = htonl(INADDR_ANY);
     addr.sin_port = htons(9999);

     bind(sock, (sockaddr*)&addr, sizeof(sockaddr_in));

     listen(sock, 128);

     sockaddr_in clientAddr;

     int clientAddrLen = sizeof(sockaddr_in);

     int client = accept(sock, (sockaddr*)&clientAddr, (socklen_t*)&clientAddrLen);

     sleep(1);

     write(client, "220 Welcome\r\n", 13);

     int bytesRead=0, res;

     for(;;) {
             char buffer[4096];

             res = read(client, buffer, 4096);

             printf("res:%d, errno:%d\n", res, errno);

             if(res < 0)  {
                    perror("read");
                    exit(1);
             }

             if(!res)
                     break;

             bytesRead += res;
     }

     printf("%d\\n", bytesRead);

     return 0;
}     

부록 3. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!