소켓의 다양한 옵션
소켓의 옵션과 입출력 버퍼의 크기
소켓 프로그래밍을 할때 데이터의 송수신에만 신경을 쓰고, 소켓이 지니는 그 이외의 특성에는 관심을 덜 갖는 경우가 있다. 그러나 소켓이 지니는 다양한 특성을 파악하고, 또 그 특성을 필요에 맞게 변경하는 것은 데이터 송수신만큼이나 중요한일이다.
소켓의 다양한 옵션
지금까지는 소켓을 생성해서 별다는 조작 없이 바로 사용해왔다. 이러한 경우 기본적으로 설정되어있는 소켓의 특성을 바탕으로 데이터를 송수신하게 된다. 지금까지의 예제들은 매우 간단했기 때문에 특별히 소켓의 특성을 조작할 필요가 없었다. 그러나 소켓의 특성을 변경시켜야만 하는 경우도 흔히 발생한다.
다양한 소켓의 옵션 중 일부를 표를 통해 표현하면 다음과 같다.
위의 표에서 보이듯이 소켓의 옵션을 계층별로 분류된다.
IPPROTO_IP 레벨의 옵션들은 IP 프로토콜에 관련된 사항들이며, IPPROTO_TCP 레벨의 옵션들은 TCP 프로토콜에 관련된 사항들이다. 그리고 SOL_SOCKET 레벨의 옵션들은 소켓에 대한 가장 일반적인 옵션들로 생각하면 된다.
설정할 수 있는 옵션의 종류는 표에있는 것보다 몇 배가 되지만, 일단 중요한 옵션 중 몇가지에 대해서만 그 의미와 변경 방법을 알아보자.
getsockopt & setsockopt
위 표에서 보았듯이 거의 모든 옵션은 설정상태의 참조(Get) 및 변경(Set)이 가능하다.
그리고 옵션의 참조 및 변경에는 다음 두 함수를 사용한다.
#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *option); // 성공 시 0, 실패시 -1 반환
- sock : 옵션확인을 위한 소켓의 파일 디스크립터 전달
- level : 확인할 옵션의 프로토콜 레벨 전달
- optname : 확인할 옵션의 이름 전달
- optval : 확인결과의 저장을 위한 버퍼의 주소 값 전달
- optlen : 네번째 매개변수 optval로 전달된 주소 값의 버퍼 크기를 담고 있는 변수의 주소값 전달. 함수호출이 완료되면 이 변수에는 네번째 인자를 통해 반환된 옵션정보의 크기가 바이트 단위로 계산되어 저장된다.
위 함수는 소켓의 옵션을 확인할때 호출하는 함수이다. 이어서 소켓의 옵션을 변경할때 호출하는 함수를 알아보자.
#include <sys/socket.h>
int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen); // 성공 시 0, 실패시 -1 반환
- sock : 옵션변경을 위한 소켓의 파일 디스크립터 전달
- level : 변경할 옵션의 프로토콜 레벨 전달
- optname : 변경할 옵션의 이름 전달
- optval : 변경할 옵션 정보를 저장한 버퍼의 주소 값 전달
- optlen : 네번째 매개변수 optval로 전달된 옵션정보의 바이트 단위 크기 전달
이번에는 함수 호출방법을 살펴볼텐데, getsockopt를 사용할 것이다. setsockopt 함수 호출방법은 다른 예제를 통해 알아보도록하자. 해당 예제는 프로토콜 레벨이 SOL_SOCKET이고 이름이 SO_TYPE인 옵션을 이용해서 소켓의 타입정보(TCP or UDP)를 확인하는 예제이다.
// sock_type.c
#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("getsockopt() error!");
printf("Socket type two : %d \n", sock_type);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
위 예제를 통해 getsockopt 함수 호출을 통한 소켓정보의 확인 방법을 간단히 보였다. 참고로 소켓의 타입정보 확인을 위한 옵션 SO_TYPE은 확인만 가능하고 변경이 불가능한 대표적인 옵션이다.
따라서 소켓의 타입은 소켓 생성시 한번 결정되면 변경이 불가능하다는 것을 알 수 있다.
SO_SNDBUF & SO_RCVBUF
소켓이 생성되면 기본적으로 입력버퍼와 출력버퍼가 생성된다.
이번에는 이 입출력버퍼와 관려있는 소켓옵션에 대해 알아보자.
SO_RCVBUF는 입력 버퍼의 크기와 관련된 옵션이고, SO_SNDBUF는 출력버퍼의 크기와 관련된 옵션이다.
즉, 이 두 옵션을 이용해서 입출력 버퍼의 크기를 참조할 수 있을 뿐만 아니라, 변경도 가능하다.
다음 예제를 통해 소켓생성시 기본적으로 만들어지는 입출력 버퍼의 크기를 확인해보자.
// get_buf.c
#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("getsock() error");
len = sizeof(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if(state)
error_handling("getsock() 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);
}
위 실행결과는 필자의 시스템에서 보이는 결과이기때문에 사람마다 실행결과가 많은 차이를 보일 수 있다.
그렇다면 이번에는 입출력 버퍼의 크기를 임의로 변경해보자.
// set_buf.c
#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;
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);
}
출력결과는 우리의 기대와 전혀 다른 결과를 보이고 있다. 그런데 이런 결과를 보이는 데는 입출력 버퍼에 상당히 주의 깊게 다뤄져야하는 영역이다. 때문에 우리의 요구대로 버퍼의 크기가 정확히 맞춰지지는 않는다.
다만 우리는 setsockopt 함수호출을 통해 버퍼의 크기에 대한 우리의 요구사항을 전달할 뿐이다.
만약 출력버퍼의 크기를 0으로 변경하려는 경우 이를 그대로 반영한다면 TCP 프로토콜을 어떻게 진행하겠는가?
흐름제어와 오류 발생시의 데이터 재전송과 같은 일을 위해서라도 최소한의 버퍼는 가지고있어야할 것이다.
setsockopt 함수 호출을 통해 전달한 버퍼의 크기가 나름대로 반영되었음을 알 수 있다.
SO_REUSEADDR
이번에는 SO_REUSEADDR 옵션, 그리고 이와 관련있는 Time-wait 상태는 상대적으로 중요하다.
주소할당 에러(Binding Error) 발생
SO_REUSEADDR 옵션에 대한 이해에 앞서 Time-wait 상태를 먼저 이해하는 것이 순서이다.
이를 위해 다음 예제를 작성해보자.
// resueadr_eserver.c
#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, optlen);
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 = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
while((str_len = read(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);
}
위 예제는 몇 차례 구현해 온 에코 서버 프로그램이다. 따라서 4번 포스팅에서 소개한 에코 클라이언트와 함께 실행하면 된다. 일단 주석처리된 상태 그대로 실행해보자. 그리고는 Q를 입력하거나 컨트롤 + C를 입력해서 클라이언트를 종료해보자. 즉, 클라이언트 측에서 서버 측으로 종료를 먼저 알리게끔 하라는 뜻이다.
클라이언트 콘솔에 Q 메세지를 입력하면 close 함수를 호출하게 되어 서버측으로 FIN 메시지를 먼저 전송하면서 Four-way handshaking 과정을 거치게 된다. (이는 컨트롤 + C를 입력해도 마찬가지이다)
프로그램을 강제종료할 경우에도 운영체제가 파일 및 소켓을 모두 닫아주는데, 이 과정에서 close 함수를 호출한 것과 마찬가지로 서버 측에서 FIN 메세지가 전달된다.
하지만 이렇게되면 클라이언트가 먼저 연결종료를 요청하는 경우, 즉 매우 일반적인 상황이기 때문에 별다른 일이 발생할 것이 없다. 서버의 재실행도 전혀 문제가 되지 않는다. 그러나 다음과 같이 프로그램을 종료하면 이야기는 달라진다.
"서버와 클라이언트가 연결된 상태에서 서버 측 콘솔에서 컨트롤 + C를 입력한다. 즉, 서버 프로그램을 강제 종료한다."
이는 서버가 클라이언트 측으로 먼저 FIN 메세지를 전달하는 상황의 연출을 위한 것이다. 그런데 이렇게 서버를 종료하고 나면 서버의 재실행에 문제가 생긴다. 동일한 PORT 번호를 기준으로 서버를 재실행하면 "bind() error"라는 메세지가 출력되면서 서버는 실행되지 않는다. (이것이 실행 화면의 위 이미지의 상황이다)
그러나 이 상태에서 3분정도 지난 다음 재실행하면 정상적인 실행을 확인할 수 있다.
이때 주석을 해제하고 다시 실행하면 "bind() error" 메세지가 출력되지 않고 그대로 서버가 실행되는 것을 확인할 수 있다. 이렇듯 차이를 보이는 이유가 어디에 있는지를 함께 고민해보기로 하자.
Time-wait 상태
이전에 설명한 Four-way handshaking의 과정을 살펴보자.
(참고로 쓰레웨이 핸드쉐이킹 : 시작할때, 포웨이 핸드쉐이킹 : 종료할때)
위 그림에서 호스트 A를 서버라고 보면, 호스트 A가 호스트 B로 FIN 메세지를 먼저 보내고 있으니, 서버가 콘솔상에서 컨트롤 + C를 입력한 상황으로 볼 수 있다. 그런데 여기서 주목할 점은 연결의 해제 과정인 Four-way handshaking 이후에 소켓이 바로 소멸되지 않고 Time-wait 상태라는 것을 일정시간 거친다는 점이다. 물론 Time-wait 상태는 먼저 연결의 종료를 요청한(먼저 FIN 메세지를 전송한) 호스트만 거친다. 이 때문에 서버가 먼저 연결의 종료를 요청해서 종료하고 나면, 바로 이어서 실행을 할 수 없는 것이다.
소켓이 Time-wait 상태에 있는 동안에는 해당 소켓의 PORT 번호가 사용중인 상태이기 때문이다. 따라서 앞서 확인한 것처럼 bind 함수의 호출과정에서 오류가 발생하는 것은 당연하다.
클라이언트 소켓은 Time-wait 상태를 거치는가?
Time-wait 상태는 서버에만 존재하는 것으로 오해하는 경우가 있다. 그러나 소켓의 Time-wait 상태는 클라이언트냐 서버냐에 상관없이 존재한다. 먼저 연결의 종료를 요청하면 해당 소켓은 반드시 Time-wait 상태를 거친다. 그러나 클라이언트의 Time-wait 상태는 신경을 쓰지않아도 된다. 왜냐하면 클라이언트 소켓의 PORT번호는 임의로 할당되기 때문이다. 즉, 서버와 달리 프로그램이 실행될때마다 PORT번호가 유동적으로 할당되기 때문에 Time-wait 상태에 대해 신경을 쓰지 않아도 된다.
그렇다면 Time-wait 상태는 무엇때문에 존재하는 것일까? 그림 9-1에서 호스트 A가 호스트 B로 마지막 ACK 메세지(SEQ 5001, ACK 7202)를 전송하고 나서 소켓을 바로 소멸시켰다고 가정해보자.
그런데 이 마지막 ACK 메세지가 호스트 B로 전달되지 못하고 중간에 소멸되어 버렸다. 그렇다면 어떤일이 일어날까?
아마도 호스트 B는 자신이 좀 전에 보낸 FIN 메세지(SEQ 7501, ACK 5001)가 호스트 A에 전송되지 못했다고 생각하고 재전송을 시도할 것이다. 그러나 호스트 A의 소켓은 완전히 종료된 상태이기 때문에, 호스트 B는 호스트 A로부터 영원히 마지막 ACK 메세지를 받지 못하게 된다.
반면 호스트 A의 소켓이 Time-wait 상태로 놓여있다면 호스트 B로 마지막 ACK 메세지를 재전송하게되고, 호스트 B는 정상적으로 종료할 수 있게 된다. 이러한 이유로 먼저 FIN 메세지를 전송한 호스트의 소켓은 Time-wait 과정을 거치는 것이다.
주소의 재할당
설명만 보면 Time-wait은 매우 중요한 것으로 생각된다. 그러나 이러한 Time-wait이 늘 반가운 것은 아니다. 시스템에 문제가 생겨서 서버가 갑작스럽게 종료된 상황을 생각해보자. 재빨리 서버를 재가동시켜서 서비스를 이어가야 하는데, Time-wait 상태 때문에 몇 분을 기다릴 수밖에 없다면 이는 문제가 될 수 있다. 따라서 Time-wait의 존재가 늘 반가울 수만은 없다. 또한 Time-wait 상태는 상황에 따라 더 길어질 수 있어서 더 큰 문제로 이어질 수 있다.
다음 그림은 종료과정인 Four-wayhandshaking 과정에서 Time-wait 상태가 길어질 수 밖에 없는 문제의 상황을 보여준다.
위 그림에서와 같이 호스트 A가 전송하는 Four-way handshaking 과정에서 마지막 데이터가 손실이 되면, 호스트 B는 자신이 보낸 FIN 메세지를 호스트 A가 수신하지 못한 것으로 생각하고 FIN 메세지를 재전송한다. 그러면 FIN 메세지를 수신한 호스트 A는 Time-wait 타이머를 재가동한다. 때문에 네트워크의 상황이 원활하지 못하다면 Time-wait 상태가 언제까지 지속될지 모르는 일이다.
이제 해결책을 제시해보겠다.
소켓의 옵션중에서 SO_REUSEADDR의 상태를 변경하면 된다.
이의 적절한 변경을 통해 Time-wait 상태에 있는 소켓이 할당되어 있는 PORT 번호를 새로 시작하는 소켓에 할당되게끔 할 수 있다. SOREUSEADDR의 디폴트 값은 0(FALSE)인데, 이는 Time-wait 상태에 있는 소켓의 PORT번호는 할당이 불가능함을 의미한다. 따라서 이 값을 1(TRUE)로 변경해줘야한다. 이 방법은 이미 예제 reuseadr_eserver.c 에서 보였다.
주석 처리되어 있는 코드를 해제하면 실행 결과 중 밑의 실행결과가 나오게 되는 것이다.
TCP_NODELAY
Nagle 알고리즘
Nagle 알고리즘은 네트워크상에서 돌아다니는 패킷들의 흘러 넘침을 막기위해 제안된 알고리즘이다.
이는 TCP상에서 적용되는 매우 단순한 알고리즘으로써, 적용여부에 따른 데이터 송수신 방식의 차이는 다음과 같다.
위 그림은 문자열 "Nagle"을 Nagle 알고리즘을 적용해서 전송할 때와 적용하지 않고 전송할 때의 차이를 보여준다.
그리고 이를 통해 다음의 결론을 내릴 수 있다.
"Nagle 알고리즘은 앞서 전송한 데이터에 대한 ACK 메세지를 받아야만, 다음 데이터를 전송하는 알고리즘이다"
기본적으로 TCP 소켓은 Nagle 알고리즘을 적용해서 데이터를 송수신한다. 때문에 ACK가 수신될 때까지 최대한 버퍼링을해서 데이터를 전송한다. 위 그림의 왼편에서는 이러한 상황을 보여준다. 문자열 "Nagle"의 전송을 위해 이를 출력버퍼로 전달한다. 이 때 첫 문자 'N'이 들어온 시점에서는 이전에 전송한 패킷이 없으므로(수신할 ACK가 없으므로) 바로 전송이 이뤄진다. 그리고는 문자 'N'에 대한 ACK를 기다리게 되는데, 기다리는 동안에 출력버퍼에는 문자열의 나머지 "agle"이 채워진다. 이어서 문자 'N'에 대한 ACK를 수신하고 출력버퍼에 존재하는 데이터 "agle"을 하나의 패킷으로 구성해서 전송하게 된다. 즉, 하나의 문자열 전송에 총 4개의 패킷이 송수신되었다.
그럼 이번에는 Nagle 알고리즘을 적용하지 않은 상태에서의 문자열 "Nagle" 전송에 대해서도 이야기해보자.
문자 'N'에서 문자 'e' 까지 순서대로 출력버퍼로 전달된다고 가정해보자.
이 상황에서 ACK의 수신에 상관없이 패킷의 전송이 이뤄지기 때문에 출력버퍼에 데이터가 전달되는 즉시 전송이 이뤄진다. 따라서 위 그림의 오른쪽에서 보이듯이 문자열 "Nagle"의 전송에는 총 10개의 패킷이 송수신될 수 있다. 이렇듯 Nagle 알고리즘을 적용하지 않으면 네트워크 트래픽(Traffic : 네트워크에 걸리는 부하나 혼잡의 정도를 의미함)에는 좋지 않은 영향을 미친다. 1바이트를 전송하더라도 패킷에 포함되어야하는 헤더 정보의 크기가 수십 바이트에 이르기 때문이다. 따라서 네트워크의 효율적인 사용을 위해서는 Nagle 알고리즘을 반드시 적용해야 한다.
그러나 Nagle 알고리즘이 항상 좋은것은 아니다. 전송하는 데이터의 특성에 따라 Nagle 알고리즘의 적용 여부에 따른 트래픽의 차이가 크지 않으면서도 Nagle 알고리즘을 적용하는 것보다 데이터의 전송이 빠른 경우도 있다. '용량이 큰 파일 데이터의 전송'이 대표적인 예이다.
파일 데이터를 출력버퍼로 밀어 넣는 작업은 시간이 걸리지 않는다. 때문에 Nagle 알고리즘을 적용하지 않아도 출력버퍼를 거의 꽉채운 상태에서 패킷을 전송하게 된다. 따라서 패킷의 수가 크게 증가하지도 않을뿐더러, ACK를 기다리지 않고 연속해서 데이터를 전송하니 전송속도도 놀랍게 향상된다.
이제 결론을 내려보자.
일반적으로 Nagle 알고리즘을 적용하지 않으면 속도의 향상을 기대할 수 있으나, 무조건 Nagle 알고리즘을 적용하지 않을 경우에는 트래픽에 상당한 부담을 주게 되어 더 좋지 않은 결과를 얻을 수 있다. 따라서 데이터의 특성을 정확히 판단하지 않은 상태에서 Nagle 알고리즘을 중지하는 일은 없어야한다.
Nagle 알고리즘의 중단
바로 위에서 언급한 다음 상황에서까지 Nagle 알고리즘을 고집할 필요는 없다. 즉, 필요하다면 Nagle 알고리즘도 중단시켜야 한다.
"Nagle 알고리즘의 적용 여부에 따른 트래픽의 차이가 크지 않으면서도
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이 저장된다.
'개발자과정준비 > TCP IP Socket Programming' 카테고리의 다른 글
[Socket 프로그래밍] 11. 프로세스간 통신 (0) | 2021.06.28 |
---|---|
[Socket 프로그래밍] 10. 멀티프로세스 기반의 서버 구현 (0) | 2021.06.25 |
[Socket 프로그래밍] 8. 도메인 이름과 인터넷 주소 (0) | 2021.06.23 |
[Socket 프로그래밍] 7. TCP의 우아한 연결 종료 (0) | 2021.06.22 |
[Socket 프로그래밍] 6. UDP 기반 서버/클라이언트 (0) | 2021.06.21 |