소켓의 옵션과 입출력 버퍼의 크기

소켓의 다양한 옵션

지금까지의 예제들은 매우 간단했기 때문에 특별히 소켓의 특성을 조작할 필요가 없었다. 그러나 소켓의 특성을 변경시켜야만 하는 경우도 흔히 발생한다. 그럼 먼저 다양한 소켓의 옵션 중 일부를 표를 통해 정리해 보이겠다.

Protocol Level Option Name Get Set
SOL_SOCKET SO_SNDBUF
SO_RCVBUF
SO_REUSEADDR
SO_KEEPALIVE
SO_BROADCAST
SO_DONTROUTE
SO_OOBINLINE
SO_ERROR
SO_TYPE
O
O
O
O
O
O
O
O
O
O
O
O
O
O
O
O
X
X
IPPROTO_IP IP_TOS
IP_TTL
IP_MULTICAST_TTL
IP_MULTICAST_LOOP
IP_MULTICST_IF
O
O
O
O
O
O
O
O
O
O
IPPROTO_TCP TCP_KEEPALIVE
TCP_NODELAY
TCP_MAXSEG
O
O
O
O
O
O

SOL_SOCKET 은 일반적인 소켓 옵션, IPPROTO_IP는 IP 프로토콜에 관련된 옵션, IPPROTO_TCP는 TCP 프로토콜에 관련된 옵션을 의미한다.

getsockopt, setsockopt

거의 모든 옵션은 참조(Get) 및 변경(Set)이 가능하다(아닌 것도 있다).

옵션의 참조 및 변경에는 다음 두 함수를 사용한다.

getsockopt

#include <sys/socket.h>

int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
// 성공 시 0, 실패 시 -1 반환
  • sock: 옵션확인을 위한 소켓의 파일 디스크립터 전달.
  • level: 확인할 옵션의 프로토콜 레벨 전달.
  • optname: 확인할 옵션의 이름 전달
  • optval: 확인 결과의 저장을 위한 버퍼의 주소 값 전달
  • optlen: 네 번째 매개변수 optval로 전달된 주소 값의 버퍼크기를 담고 있는 변수의 주소 값 전달, 함수호출이 완료되면 이 변수에는 optval를 통해 반환된 옵션정보의 크기가 바이트 단위로 계산되어 저장됨.

setsockopt

#include <sus/socket.h>

int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
// 성공 시 0, 실패 시 -1 반환
  • sock: 옵션변경을 위한 소켓의 파일 디스크립터 전달
  • level: 변경할 옵션의 프로토콜 레벨 전달
  • optname: 변경할 옵션의 레벨 전달
  • optval: 변경할 옵션정보를 저장한 버퍼의 주소 값 전달
  • optlen: optval로 전달된 옵션정보의 바이트 단위 크기 전달

SO_TYPE으로 소켓 타입정보(TCP/UDP) 확인

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int tcp_sock, udp_sock;
    int sock_type;
    socklen_t optlen;
    int state;

    optlen = sizeof(sock_type);
    tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
    udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
    printf("SOCK_STREAM: %d \n", SOCK_STREAM);
    printf("SOCK_DGRAM: %d \n", SOCK_DGRAM);

    state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
    if(state)
        error_handling("getsockopt() error");
    printf("Socket type one: %d \n", sock_type);

    state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
    if(state)
        error_handling("setsockopt() error");
    printf("Socket type two: %d \n", sock_type);


    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
<실행결과>
SOCK_STREAM: 1 
SOCK_DGRAM: 2 
Socket type one: 1 
Socket type two: 2 

SO_SNDBUF & SO_RCVBUF

소켓이 생성되면 기본적으로 입력버퍼와 출력버퍼가 생성된다.

먼저, 입출력버퍼의 크기를 참조해보겠다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    int snd_buf, rcv_buf, state;
    socklen_t len;

    sock = socket(PF_INET, SOCK_STREAM, 0);

    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*) &snd_buf, &len);
    if(state)
        error_handling("getsockopt() error");

    len=sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*) &rcv_buf, &len);
    if(state)
        error_handling("getsockopt() error");

    printf("Input buffer size: %d \n", rcv_buf);
    printf("Output buffer size: %d \n", snd_buf);
    
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
<실행결과>
Input buffer size: 131072 
Output buffer size: 131072 

이번에는 입출력 버퍼의 크기를 임의로 변경해 보자.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    int snd_buf=1024*10, rcv_buf=1024*10;
    int state;
    socklen_t len;

    sock = socket(PF_INET, SOCK_STREAM, 0);

    state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*) &rcv_buf, sizeof(rcv_buf));
    if(state)
        error_handling("setsockopt() error");

    state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*) &snd_buf, sizeof(snd_buf));
    if(state)
        error_handling("setsockopt() error");


    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*) &snd_buf, &len);
    if(state)
        error_handling("getsockopt() error");

    len=sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*) &rcv_buf, &len);
    if(state)
        error_handling("getsockopt() error");


    printf("Input buffer size: %d \n", rcv_buf);
    printf("Output buffer size: %d \n", snd_buf);

    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
<실행 결과>
Input buffer size: 10240 
Output buffer size: 10240 

내 경우에는 버퍼 사이즈가 제대로 변경되었지만, 출력버퍼의 크기를 0으로 만드는 경우의 요구사항이나 너무 작은 버퍼 등에 대해서는 커널이 마음대로 값을 넣는 경우가 존재한다. 메모리 정렬 또는 패딩에 의해서도 이 값이 의도치 않게 할당될 수 있다.

SO_REUSEADDR

서버 종료 후 곧바로 재가동 했을때 bind 오류가 발생하는 이유

호스트는 서버 연결 종료 시(강제종료시에도 커널이 알아서) FIN메세지를 보내며 4-way handshaking을 하는데, 호스트 A와 호스트 B가 4-way handshaking을 한다고 하자.

  1. 호스트 A는 연결 종료를 위해 B에게 FIN을 보낸다. (4-way handshaking 시작)
  2. 호스트 B는 FIN을 받고 A에게 ACK를 보낸다.
  3. 호스트 B는 남은 데이터 전송이 다 끝나면 A에게 FIN을 보낸다.
  4. 호스트 A는 받은 FIN에 대한 ACK를 B에게 보낸다.

위 과정에서 4번의 호스트 A가 보낸 ACK패킷이 사라졌다고 가정해보자. 이 경우 호스트 B는 ACK를 받지 못했으므로 자신의 요청이 유실됐다고 판단하여 다시 FIN 요청을 보낸다. 하지만 A는 이미 닫힌 상태이므로 호스트 B는 어쩔 도리가 없다.

이런 상황을 막기 위해 마지막 FIN을 받고 ACK를 보내는 호스트는 time wait을 가진다.

이 time wait 때문에 해당 소켓이 아직 열려있어 같은 포트로 bind시 오류가 발생하는 것이다.

클라이언트의 경우, 포트 번호가 자동으로 할당되기 때문에 이러한 이슈를 생각하지 않아도 괜찮다.

time wait은 매우 중요하지만, 상황에 따라(게임 서버 다운시 곧바로 다시 서버를 열어야 하는 경우) 우선순위에서 밀리는데, 이때 SO_REUSEADDR옵션을 통해 time wait을 없앨 수 있다. 이 말은 곧 주소 재사용 옵션을 켜주는 것과 같다.

SO_REUSEADDR 의 디폴트 값은 0(FALSE)인데, 이를 1(TRUE)로 바꿔주면 된다.

optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);

이제 해당 서버는 언제건 실행 가능한 상태가 된다.

TCP_NODELAY

Nagle 알고리즘

nagle.png

Nagle 알고리즘은 앞서 전송한 데이터에 대한 ACK 메세지를 받아야만, 다음 데이터를 전송하는 알고리즘이다.

Nagle 알고리즘이 없다면, 출력버퍼에 데이터가 들어오는대로 바로바로 데이터를 전송한다. 이때, 데이터의 크기가 작은 경우 패킷의 페이로드보다 헤더의 크기가 더 커져 전송이 효율적이지 못할 수 있다. Nagle 알고리즘은 ACK를 기다리다가 ACK를 받는 즉시 출력버퍼의 데이터를 모아서 한번에 전달한다. 따라서 좀 더 효율적이다.

그러나 Nagle 알고리즘이 항상 좋은 것은 아니다. 전송하는 데이터의 특성에 따라 Nagle 알고리즘의 적용 여부에 따른 트래픽의 차이가 크지 않으면서도 Nagle 알고리즘을 적용하는 것보다 데이터의 전송이 빠른 경우도 있다. ‘용량이 큰 파일 데이터의 전송’이 대표적인 예이다. 파일 데이터를 출력버퍼로 밀어 넣는 작업은 시간이 걸리지 않는다. 때문에 Nagle 알고리즘을 적용하지 않아도 출력버퍼를 거의 꽉 채운 상태에서 패킷을 전송하게 된다. 따라서 패킷의 수가 크게 증가하지도 않을 뿐더러, ACK를 기다리지 않고 연속해서 데이터를 전송하니 전송속도도 놀랍게 향상된다.

🎮네트워크 게임에서 네이글 알고리즘

게임은 반응성을 중요시한다. 게임을 하는데 내 입력에 대한 피드백이 바로바로 오지 않는다면, 그리고 그 결과 원치않은 결과를 얻었다면, 유저들은 불편함과 부당함을 느끼고 게임을 빠르게 삭제해버릴 것이다. 네트워크 게임에서 네이글을 적용시켰을 때, 반응성과 효율성 두가지를 모두 얻을 수 있을까? 게임 서버와 클라이언트에서 네이글을 사용했다는 상상실험을 통해 그 결과를 유추해보자.

온라인 게임의 네트워크 상황을 생각해보자. 클라이언트의 경우 서버로 유저의 입력을 전송한다. 이 입력신호의 경우데이터 양 자체가 작고, 또 반복적으로 여러 입력이 요청될 수 있기 때문에, 쉽게 위 3번의 경우에 빠지기 쉽다. 하지만 이 입력 패킷이 크기가 작다는 이유로 지연된다면, 사용자의 반응성에 큰 영향을 미치게된다. 입력 패킷은 크기는 작아도 게임에 있어 그 의미는 매우 크다. 뿐만아니라 PC 클라이언트의 입장에서 네트워크의 효율 문제는 큰 이슈가 아니다. 어느정도의 트래픽 효율을 포기하더라도 빠른 반응성을 얻는것이 유저 입장에서도 좋은 거래가 될 것이다.따라서 클라이언트 네트워크의 경우에는 네이글 알고리즘을 사용하지 않는 것이 유리하다고 생각한다.

서버의 경우는 클라이언트와 다르다. 서버는 클라이언트에게 현제 유저가 처한 게임 상황의 결과를 지속적으로 보내줄 것이다. 이 정보는 패킷 크기에 비해 헤더 크기가 비대해 보이지 않을 정도로 충분히 큰 양의 데이터일 것이며, 지속적으로 갱신해서 보내주는 데이터가 될 것이다. 이러한 데이터가 네이글 알고리즘에 의해 지연되는 경우는 클라이언트의 가용 윈도우 사이즈가 충분히 크지 않은 경우가 될 것이다. 클라이언트의 처리 능력이 원활하지 않은 경우 계속해서 데이터를 잘라서 우겨넣는것이 유저가 체감하는 반응성에 큰 영향을 미칠 것이라 생각하지 않는다. 또한 동시에 여러 클라이언트가 사용하는 서버에서 트래픽이슈는 민감한 부분이기 때문에, 네이글 알고리즘을 사용했을때 얻는 이득이 더 크지 않을까 생각해본다

출처: https://ozt88.tistory.com/18 [공부 모음:티스토리]

Nagle 알고리즘의 중단

방법은 간단하다. 아래의 코드에서 보이듯이 소켓옵션 TCP_NODELAY를 1(TRUE)로 변경해주면 된다.

int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, sizeof(opt_val));



그리고 Nagle 알고리즘의 설정상태를 확인하려면 다음과 같이 TCP_NODELAY에 설정된 값을 확인하면 된다.

int opt_val;
socklen_t opt_len;
opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, &opt_len);

Nagle 알고리즘이 설정된 상태라면 함수호출의 결과로 변수 opt_val에는 0이 저장되며, 반대로 설정되지 않은 상태라면 1이 저장된다.

윈도우 기반으로 구현하기

getsockopt

#include <winsock2.h>

int getsockopt(SOCKET sock, int level, int optname, char * optval, int * optlen);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환
  • sock: 옵션 확인을 위한 소켓의 핸들 전달.
  • level: 확인할 옵션의 프로토콜 레벨 전달
  • optname: 확인할 옵션의 이름 전달
  • optval: 확인 결과의 저장을 위한 버퍼의 주소 값 전달
  • optlen: optval로 전달된 주소 값의 버퍼 크기를 담고 있는 변수의 주소 값 전달, 함수 호출이 완료되면 이 변수에는 optval을 통해 반환된 옵션정보의 크기가 바이트 단위로 계산되어 저장된다.

결국 리눅스의 getsockopt함수와 크게 다르지 않다는 것을 알 수 있다. 단 한가지 주의할 것은 매개변수 optval의 자료형이 char형 포인터라는 것이다. 리눅스 기반에서는 void형 포인터였다. 따라서 리눅스 예제를 윈도우 기반으로 변경할 때, 이 부분에서 적절히 형 변환을 해주는 것이 좋다.

setsockopt

#include <winsock2.h>

int setsockopt(SOCKET sock, int level, int optname, const char* optval, int optlen);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환
  • sock: 옵션 변경을 위한 소켓의 핸들 전달
  • level: 변경할 옵션의 프로토콜 전달
  • optname: 변경할 옵션의 이름 전달
  • optval: 변경할 옵션정보를 저장한 버퍼의 주소 값 전달
  • optlen: optval로 전달된 옵션정보의 바이트 단위 크기 전달