소켓의 옵션과 입출력 버퍼의 크기
소켓의 다양한 옵션
지금까지는 소켓을 생성해서 별다른 조작 없이 바로 사용해왔습니다. 이런 경우에는 기본적으로 설정되어 있는 소켓의 특성을 바탕으로 데이터를 송수신하게 됩니다. 그러나 소켓의 특성을 변경시켜야 할 경우도 많습니다. 다양한 소켓의 옵션 중 일부를 보겠습니다.
Protocol Level | Option Name | Get | Set |
SOL_SOCKET | SO_SNDBUF | O | O |
SO_RCVBUF | O | O | |
SO_REUSEADDR | O | O | |
SO_KEEPALIVE | O | O | |
SO_BROADCAST | O | O | |
SO_DONTROUTE | O | O | |
SO_OOBINLINE | O | O | |
SO_ERROR | O | O | |
SO_TYPE | O | X | |
IPPROTO_IP | IP_TOS | O | O |
IP_TTL | O | O | |
IP_MULTICAST_TTL | O | O | |
IP_MULTICAST_LOOP | O | O | |
IP_MULTICAST_IF | O | O | |
IPPROTO_TCP | TCP_KEEPALIVE | O | O |
TCP_NODELAY | O | O | |
TCP_MAXSEG | O | O |
위의 표에서 보이듯이 소켓의 옵션은 계층별로 분류됩니다. SOL_SOCKET 레벨의 옵션들은 소켓에 대한 가장 일반적인 옵션들이고, IPPROTO_IP 레벨의 옵션들은 IP 프로토콜에 관련한 옵션들이고, IPPROTO_TCP 레벨의 옵션들은 TCP 프로토콜에 관련한 옵션들입니다. 이런 것들을 외우려고 하지는 않으셔도 됩니다. 설정할 수 있는 옵션의 종류는 위 표의 몇 배나 될 뿐더러, 한꺼번에 모든 옵션을 이해해야 하는 것도 아니기 때문입니다. 천천히 코딩을 하다 보면 일반적으로 중요하게 여겨지는 옵션의 대부분을 알게 될 것입니다.
getsockopt과 setsockopt
옵션의 참조 및 변경에는 getsockopt와 setsockopt를 사용합니다.
getsockopt에는 다음과 같은 인자들을 전달해줘야 합니다.
- int sock: 옵션확인을 위한 소켓의 파일 디스크립터 전달
- int level: 확인할 옵션의 프로토콜 레벨 전달
- int optname: 확인할 옵션의 이름 전달
- void* optval: 확인결과의 저장을 위한 버퍼의 주소 값 전달
- socklen_t* optlen: 4번째 매개변수 optval로 전달된 주소 값의 버퍼크기를 담고 있는 변수의 주소 값 전달, 함수호출이 완료되면 이 변수에는 네 번째 인자를 통해 반환된 옵션정보의 크기가 바이트 단위로 계산되어 저장됩니다.
// 성공 시 0, 실패 시 -1 반환
#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void* optval, socklen_t* optlen);
그리고 setsockopt에는 다음과 같은 인자들을 전달해줘야 합니다.
- int sock: 옵션변경을 위한 소켓의 파일 디스크립터 전달
- int level: 변경할 옵션의 프로토콜 레벨 전달
- int optname: 변경할 옵션의 이름 전달
- const void* optval: 변경할 옵션정보를 저장한 버퍼의 주소 값 전달
- socklen_t optlen: 네 번째 매개변수 optval로 전달된 옵션정보의 바이트 단위 크기 전달
// 성공 시 0, 실패 시 -1 반환
#include <sys/socket.h>
int setsockopt(int sock, int level, int optname, const void* optval, socklen_t optlen);
먼저 getsockopt 함수를 사용하는 예제 코드를 보겠습니다. 이 코드는 프로토콜 레벨이 SOL_SOCKET이고 이름이 SO_TYPE인 옵션을 이용해 소켓의 타입정보(TCP or 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); // tcp 소켓 생성
udp_sock = socket(PF_INET, SOCK_DGRAM, 0); // udp 소켓 생성
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("getsockopt() error");
printf("socket type one: %d\n", sock_type);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
결과는 아래와 같습니다. SOCK_STREAM과 SOCK_DGRAM에 해당하는 상수 값을 가져왔음을 알 수 있습니다.
여기서 확인한 타입정보 확인을 위한 옵션 SO_TYPE은 확인만 가능하고 변경이 불가능한 대표적 옵션입니다.
SO_SNDBUF와 SO_RCVBUF
소켓이 생성되면 기본적으로 생성되는 입력버퍼와 출력버퍼에 관한 소켓옵션에 대해 얘기해보겠습니다. SO_RCVBUF는 입력버퍼의 크기와 관련된 옵션이고, SO_SNDBUF는 출력버퍼의 크기와 관련된 옵션입니다. 이 두 옵션을 이용해 버퍼의 크기를 참조할 수 있을 뿐만 아니라 변경도 가능합니다. 다음 예제를 통해 생성되는 입출력 버퍼의 크기를 확인해보겠습니다.
#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(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if (state)
error_handling("getsockopt() error");
len = sizeof(snd_buf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_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);
}
결과는 아래와 같습니다.
이번에는 입출력 버퍼의 크기를 임의로 변경해 보겠습니다.
#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 * 3, rcv_buf = 1024 * 3, 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(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if (state)
error_handling("getsockopt() error");
len = sizeof(snd_buf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_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);
}
결과는 아래와 같습니다.
출력 결과를 보면 코드에서는 3072를 전달했는데 입출력 버퍼가 모두 6144로 설정되어 있습니다. 입출력 버퍼는 상당히 주의 깊게 다뤄져야 하는 영역이기 때문에 우리의 요구대로 버퍼의 크기가 정확히 맞춰지지 않습니다. 다만 우리는 setsockopt 함수로 버퍼의 크기에 대한 우리의 요구사항을 전달하는 것입니다. 이번 예제에서도 보면 100% 우리의 요구대로 버퍼의 크기가 만들어지지는 않았지만, setsockopt 함수호출을 통해 전달한 버퍼의 크기가 나름대로 반영되었음을 알 수 있습니다.
SO_REUSEADDR
이번에 다루는 SO_REUSEADDR 옵션과 이와 관련있는 Time-wait 상태는 상대적으로 중요하므로 꼭 이해하고 넘어가야 합니다.
주소할당 에러 발생
SO_REUSEADDR 옵션에 대한 얘기에 앞서 Time-wait 상태를 먼저 이해해야 합니다. 이와 관련된 예제를 보겠습니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define TRUE 1
#define FALSE 0
void error_handling(char* message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
char message[30];
int option, str_len;
socklen_t optlen, clnt_adr_sz;
struct sockaddr_in serv_adr, clnt_adr;
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error");
/*
optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, )
*/
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)))
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accpet(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
while ((str_len = raed(clnt_sock, message, sizeof(message))) != 0) {
write(clnt_sock, message, str_len);
write(1, message, str_len);
}
close(clnt_sock);
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
여태 몇 번이나 봐오던 에코 서버 프로그램입니다. 위에서 주석 친 부분을 그대로 두고 실행했다고 하고, 두 가지 상황을 보겠습니다. 먼저, 클라이언트 콘솔에서 프로그램을 종료했을 경우에는 클라이언트 측에서 서버 측으로 서버 측으로 FIN 메시지를 전달하게 되며 Four-way handshaking 과정을 거치게 됩니다. 이 경우는 매우 일반적인 상황이라 별다른 일이 발생할 것이 없습니다. 서버의 재실행도 전혀 문제되지 않습니다. 그러나 서버 콘솔에서 프로그램을 종료했을 경우에는 서버가 클라이언트 측으로 먼저 FIN 메시지를 보내게 되고 이렇게 서버를 종료하게 되면 서버의 재실행에 문제가 생깁니다. 동일한 PORT 번호를 기준으로 서버를 재실행하면 bind() error 라는 메시지가 출력되고 서버는 재실행되지 않습니다. 이 두 경우의 유일한 차이점은 FIN 메시지를 누가 먼저 전송했는지에 있습니다. 그럼에도 이러한 차이를 보이는 이유를 알아보겠습니다.
Time-wait 상태
위 그림에서 호스트 A를 서버라고 보면, 서버가 콘솔창에서 종료를 해서 먼저 FIN 메시지를 보낸 상황이라고 볼 수 있습니다. 여기서 주목할 점은 Four-way handshaking 이후에 소켓이 바로 소멸되지 않고 Time-wait 상태라는 기간을 거친다는 것입니다. 물론 Time-wait 상태는 먼저 연결의 종료를 요청한 호스트만 거칩니다. 이 때문에 서버가 먼저 연결의 종료를 요청해서 종료하고 나면 바로 이어서 실행을 할 수 없는 것입니다. 소켓이 Time-wait 상태에 있는 동안 해당 소켓의 PORT 번호가 사용중인 상태이기 때문입니다(클라이언트 소켓은 서버와 달리 프로그램이 실행될 때마다 PORT 번호가 유동적으로 할당되기 때문에 Time-wait 상태에 대해 신경을 쓰지 않아도 됩니다.).
만약 Time-wait 상태가 없어서 호스트 A(서버)가 마지막 ACK 메시지를 보낸 후 곧바로 소켓을 소멸시켰는데 문제가 있어서 마지막 ACK 메시지가 호스트 B에게 전달되지 못하고 소멸되었다면, 호스트 B는 좀 전에 보낸 FIN 메시지를 호스트 A가 받지 못하였다고 생각해서 재전송을 시도할 것입니다. 이렇게 호스트 B는 평생 자신이 보낸 FIN 메시지에 대한 ACK 메시지를 받지 못하게 됩니다. 이런 상황을 위하여 Time-wait 상태가 필요한 것입니다.
주소의 재할당
그러나 이러한 Time-wait가 항상 반가운 것은 아닙니다. 시스템에 문제가 생겨 서버가 갑작스레 종료된 상황을 생각해보면 재빨리 서버를 재가동시켜 서비스를 이어가야 하는데, Time-wait 상태 때문에 몇 분을 기다릴 수밖에 없다면 이는 문제가 될 수 있습니다. 소켓의 옵션 중 SO_REUSEADDR의 상태를 변경하면 이러한 문제를 해결할 수 있습니다. 이 옵션의 디폴트 값은 0(FALSE)인데 이는 Time-wait 상태에 있는 PORT 번호는 할당이 불가능함을 의미합니다. 따라서 이 값을 1(TRUE)로 바꿔주면 Time-wait 상태에 있는 PORT 번호여도 곧바로 사용이 가능합니다. 위 코드의 주석친 부분을 해제하고 다시 실행해보세요.
TCP_NODELAY
Nagle 알고리즘
Nagle 알고리즘은 네트워크상에서 돌아다니는 패킷들의 흘러넘침을 막기 위해 개발된 알고리즘입니다. 이는 TCP 상에서 적용되는 매우 단순한 알고리즘으로, 이의 적용여부에 따른 데이터 전송방식은 다음과 같습니다.
위 그림은 문자열 Nagle을 Nagle 알고리즘을 적용해서 전송할 때와 적용하지 않고 전송할 때의 차이를 보여줍니다. 위 그림으로부터 Nagle 알고리즘은 앞서 전송한 데이터에 대한 ACK 메시지를 받아야만 다음 데이터를 전송하는 알고리즘이라는 것을 알 수 있습니다. 기본적으로 TCP 소켓은 Nagle 알고리즘을 적용하여 데이터를 전송합니다.
Nagle 알고리즘을 적용한 경우(왼쪽)에는 첫 문자 N이 들어왔을 때에는 받을 ACK이 없으므로 이를 바로 전송하고, 이에 대한 ACK이 올 때까지 문자열의 나머지 agle이 출력버퍼에 채워집니다. 그리고 ACK을 수신하면 이를 한번에 보내서 데이터 전송을 마칩니다. 즉, 문자열 전송에 4개의 패킷이 송수신되었습니다.
Nagle 알고리즘을 적용하지 않은 경우(오른쪽)에는 ACK의 수신여부에 관계없이 바로바로 데이터가 전송되므로 이 문자열의 전송에는 10개의 패킷이 송수신됩니다. 이렇듯 Nagle 알고리즘을 적용하지 않으면 네트워크 트래픽에 좋지 않은 영향을 줍니다. 1바이트를 전송하더라도 패킷에 포함되어야 하는 헤더정보의 크기가 수십 바이트에 이르기 때문입니다.
그렇다고 해서 Nagle 알고리즘이 항상 좋은 것은 아닙니다. Nagle 알고리즘은 데이터 손실이 없어야 하고 트래픽을 줄이고 싶거나 크기가 작은 데이터가 많은 경우에는 유용하지만, 반응속도가 중요하거나 크기가 큰 데이터를 전송해야 할 경우에는 부적합합니다. 따라서 데이터의 특성을 이해하고 Nagle 알고리즘을 적용할지에 대한 여부를 결정해야 합니다.
Nagle 알고리즘의 중단
Nagle 알고리즘의 적용을 중단하는 방법은 간단합니다. 아래와 같이 설정해주면 됩니다.
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, sizeof(opt_val));
그리고 Nagle 알고리즘의 설정상태를 확인하려면 다음과 같이 하면 됩니다.
int opt_val;
socklen_t opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, &opt_len);
Nagle 알고리즘이 적용된 상태라면 opt_val에는 0이 저장될 것이고, 아니라면 1이 저장될 것입니다.
'네트워크 프로그래밍 > C' 카테고리의 다른 글
[네트워크 프로그래밍/C] IP 주소와 도메인 이름 사이의 변환 (0) | 2022.02.27 |
---|---|
[네트워크 프로그래밍/C] Domain Name System (0) | 2022.02.26 |
[네트워크 프로그래밍/C] TCP 기반의 Half-close (0) | 2022.02.26 |
[네트워크 프로그래밍/C] UDP의 데이터 송수신 특성과 UDP에서의 connect 함수호출 (0) | 2022.02.26 |
[네트워크 프로그래밍/C] UDP 기반 서버, 클라이언트의 구현 (0) | 2022.02.25 |