소켓에는 다양한 특성이 존재한다. 이러한 특성은 소켓의 옵션 변경을 통해서 변경이 가능하다.
지금까지는 소켓을 생성해서 별다른 조작없이 사용했다.
이러한 경우에는 기본적으로 설정되어 있는 소켓의 특성을 바탕으로 데이터를 송수신한다.
때에 따라 소켓의 특성을 변경시켜야 하는 경우도 있다. 다양한 소켓의 옵션 중 일부를 소개하겠다.
소켓의 특성을 변경시킬 때 사용하는 옵션 정보들이다.
이러한 소켓의 옵션은 게층별로 분류된다.
IPPROTO_IP 레벨의 옵션들은 IP 프로토콜에 관련된 사항
IPPROTO_TCP 레벨의 옵션들은 TCP 프로토콜에 관련된 사항
SOL_SOCKET 레벨의 옵션들은 소켓에 대한 가장 일반적인 옵션들
옵션 정보를 얻어올 때 (Get)
#include <sys/socket.h>
int getsocketopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
// 성공 시 0, 실패 시 -1 반환
/*
sock : 옵션확인을 위한 소켓의 파일 디스크립터 전달
level : 확인할 옵션의 프로토콜 레벨 전달
optname : 확인할 옵션의 이름 전달
optval : 확인 결과의 저장을 위한 버퍼의 주소 값 전달
optlen : 네 번째 매개변수 optval로 전달된 주소 값의 버퍼 크기를 담고 있는 변수의 주소 값 전달,
함수 호출이 완료되면 이 변수에는 네 번째 인자를 통해 반환 된 옵션정보의 크기가 바이트 단위로 계산되어 저장
위에 표에서 제시한 Protocol Level이 두 번째, Option Name이 세 번째 인자로 전달되어 해당 옵션의 등록 정보를 얻어온다.
옵션 정보를 변경할 때 (Set)
#include <sys/socket.h>
int setsocketopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
// 성공 시 0, 실패 시 -1 반환
/*
sock : 옵션변경을 위한 소켓의 파일 디스크립터 전달
level : 변경할 옵션의 프로토콜 레벨 전달
optname : 변경할 옵션의 이름 전달
optval : 변경할 옵션정보를 저장을 위한 버퍼의 주소 값 전달
optlen : 네 번째 매개변수 optval로 전달된 옵션 정보의 바이트 단위 크기 전달
함수 호출을 이용한 예제로 자세히 알아보자
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(const char *message)
{
fputs(message,stderr);
fputc('\n',stderr);
exit(1);
}
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;
}
tcp_sock은 TCP Socket 타입
udp_sock은 UDP Socket 타입
지정된 소켓의 타입은 set으로 바꿀 수 없기 때문에 getsocket함수를 소개하겠다.
프로토콜 레벨이 SOL_SOCKET이고 이름이 SO_TYPE인 옵션을 이용해서 소켓의 타입정보(TCP or UDP)를 확인하였다.
언급한대로 소켓의 타입 정보는 변경이 불가능하기 때문에 옵션 SO_TYPE은 확인만 가능하고 변경이 불가능한 옵션이다.
실행 결과
즉, 소켓의 타입은 소켓 생성시 한 번 결정되면 변경이 불가능하다 !
소켓이 생성되면 기본적으로 입력버퍼와 출력버퍼가 생성됨을 우리는 알고 있다.
SO_RCVBUF는 입력버퍼의 크기와 관련된 옵션
SO_SNDBUF는 출력버퍼의 크기와 관련된 옵션이다.
즉, 이 두 옵션을 이용해서 입출력 버퍼의 크기를 참조할 뿐만 아니라 변경도 가능하다.
출력버퍼의 크기를 참조 및 변경할 때는 SO_SNDBUF를 사용
입력버퍼의 크기를 참조 및 변경할 때에는 SO_RCVBUF를 사용
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(const char *message)
{
fputs(message,stderr);
fputc('\n',stderr);
exit(1);
}
int main(int argc, char *argv[]){
int sock;
int snd_buf, rcv_buf;
socklen_t len;
int state;
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;
}
결과
시스템 마다 입출력 버퍼의 결과는 다를 수 있다.
본인은 Virtual machine을 통한 Linux 환경에서 작성하였다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(const char *message)
{
fputs(message,stderr);
fputc('\n',stderr);
exit(1);
}
int main(int argc, char *argv[]){
int sock;
int snd_buf = 1024*3, rcv_buf=1024*3;
socklen_t len;
int state;
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;
}
결과
- 출력 결과에서 우리의 기대와 다른 결과가 나온다.
코드에서는 3KByte로 변경을 요구했다. 하지만 결과는 6KByte로 변경이 된 것을 알 수 있다.
그 이유는 입출력 버퍼는 주의깊게 다뤄야 하는 영역이기 때문이다.
때문에 우리의 요구대로 버퍼의 크기가 정확히 맞춰지지 않는다.
다만 우리는 setsocketopt 함수 호출을 통해서 버퍼의 크기에 대한 우리의 요구사항을 전달할 뿐이다.
왜 이런 제약이 생기는 걸까 ?
생각해보면 출력버퍼의 크기를 0으로 변경하려는 경우 이를 그대로 반영한다면 TCP 프로토콜은 어떻게 되겠는가 ?
흐름제어와 오류 발생시의 데이터 재전송과 같은 일을 위해서라도 최소한의 버퍼는 존재해야 한다.
이번 예제를 통해서 우리의 요구대로 버퍼의 크기가 만들어지지 않았지만,
setsockopt 함수 호출을 통해서 버퍼의 크기가 나름대로 반영된다는 걸 알 수 있다.
- 주소 할당 에러의 원인 Time-wait
SO_REUSEADDR을 이해하기 전에 Time-wait를 이해하는 것이 순서이다.
-> Time-wait란
서버, 클라이언트에 상관없이 TCP 소켓에서 연결의 종료를 목적으로 Four-way handshaking의 첫 번째 메시지를 전달하는 호스트의 소켓은 Time-wait 상태를 거친다.
Time-wait 상태 동안에는 해당 소켓이 소멸되지 않아서 할당 받은 Port를 다른 소켓이 할당할 수 없다.
사실 클라이언트 측에서 FIN 메시지를 통해 종료할 때는 크게 상관이 없다.
문제는 서버 측에서 클라이언트 측으로 FIN 메시지를 보낼 때 다시 클라이언트에게 FIN 메시지를 받고 잘 받았다는 뜻으로 ACK를 송신해줄 때 클라이언트가 받고 소켓이 소멸되는 시점까지가 Time-wait 상태에 빠져 동일한 PORT 번호를 기준으로 서버를 재실행하면 bind에러가 발생한다.
이 그림을 살펴보면 이해하기 쉽다.
서버측에서 콘솔 창에 CTRL+C를 입력한 상황이다.
여기서 주목할 점은 Four-way handshaking 이후에 소켓이 바로 소멸되지 않고 Time-wait 상태라는 것을 일정시간 거친다.
물론 Time-waite 상태라는 것은 먼저 연결의 종료를 요청한(먼저 FIN 메시지를 전송한) 호스트만 거친다.
이 때문에 서버가 먼저 연결의 종료를 요청해서 종료하고 나면 바로 이어서 실행할 수 업슨 것이다.
소켓이 Time-wait 상태에 있는 동안에는 해당 소켓의 PORT 번호가 사용중인 상태이기 때문이다.
Time-wait의 존재 이유
위 그림에서 호스트 A의 마지막 ACK가 소멸되는 상황으 대비해서 이러한 상태가 필요하다.
호스트 A의 마지막 ACK가 소멸되면, 호스트 B는 계속해서 FIN 메시지를 호스트 A에게 전달하게 된다.
즉, 위 그림에서 예를 들어 보자면
호스트 A가 호스트 B로 마지막 ACK 메시지(SEQ 5001, ACK 7502)를 전송하고 나서 소켓을 바로 소멸시킨다고 가정해보자
그런데 이 마지막 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 상황에 따라서 더 길어질 수 있기 때문에 더 큰 문제가 발생할 수 있다.
다음 그림은 종료 과정인 Four-way handshaking 과정에서 Time-wait의 상태가 길어질 수밖에 없는 문제의 상황이다.
위 그림에서와 같이 호스트 A가 전송하는 Four-way handshaking 과정에서 마지막 데이터가
손실이되면, 호스트 B는 자신이 보낸 FIN 메시지를 호스트 A가 수신하지 못한 것으로 생각하고 FIN 메시지를 재전송한다. 그러면 FIN 메시지를 수신한 호스트 A는 Time-wait 타이머를 재 가동한다. 때문에 네트워크의 상황이 원활하지 못하다면 Time-wait 상태가 언제까지 지속될 지 모르는 일이다.
소켓의 옵션 중에서 SO_REUSEADDR의 상태를 변경하면 된다.
이를 통해 적절한 병경을 하여 Time-wait 상태에 있는 소켓에 할당되어 있는 PORT 번호를 새로 시작하는 소켓에 할당되게끔 할 수 있다.
SO_REUSEADDR의 디폴트 값은 0(False)이다.
이는 Time-wait 상태에 있는 소켓의 PORT번호는 할당이 불가능함을 의미한다.
따라서 이 값을 1(TRUE)로 변경해줘야 한다.
optlen = sizeof(option);
option = TRUE;
setsocket(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);
#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(const char *message);
int main(int argc, char *argv[])
{
int serv_sock;
int clnt_sock;
char message[30];
int str_len = 0;
int option;
socklen_t optlen, clnt_adr_size;
struct sockaddr_in serv_adr;
struct sockaddr_in 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;
setsocket(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))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
clnt_adr_size=sizeof(clnt_adr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr,&clnt_adr_size);
if(clnt_sock==-1)
error_handling("accept() error");
while((str_len = read(clnt_sock,message,sizeof(message)))!=0)
{
write(clnt_sock, message, str_len);
write(1, message, str_len);
}
close(serv_sock);
close(clnt_sock);
return 0;
}
void error_handling(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(const char *message);
int main(int argc, char* argv[])
{
int sock;
struct sockaddr_in serv_addr;
char message[BUF_SIZE];
int str_len=0;
if(argc!=3){
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
sock=socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket() error");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
serv_addr.sin_port=htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
error_handling("connect() error!");
else
puts("Connected..................");
while(1)
{
fputs("Input message(Q to quit):",stdout);
fgets(message, BUF_SIZE,stdin);
if(!strcmp(message,"q\n")||!strcmp(message,"Q\n"))
break;
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE -1);
message[str_len] = 0;
printf("Message from server: %s\n", message);
}
close(sock);
return 0;
}
void error_handling(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
개발자들이 가볍게 생각하고 넘어가는 것 중 하나가 Nagle 알고리즘이다.
인터넷의 과도한 트래픽과 그로 인한 전송속도의 저하를 막기 위해 중요하다고 생각한다.
해당 챕터에서는 이 알고리즘에 대해 설명하겠다.
- Nagle Algorithm
네트워크 상에서 돌아다니는 패킷들의 흘러 넘침을 막기 위해서 1984년에 제안된 알고리즘
이는 TCP상에서 적용되는 매우 단순한 알고리즘으로써, 이의 적용여부에 따른 데이터 송수신 방식의 차이는 다음과 같다.
문자열 Nagle을 해당 알고리즘을 적용해서 전송할 때와 적용하지 않을 때의 차이를 보여준다.
해당 그림을 보고 다음과 같이 결론을 내릴 수 있다.
"Nagle 알고리즘은 앞서 전송한 데이터에 대한 ACK 메시지를 받아야만, 다음 데이터를 전송하는 알고리즘이다."
알다시피 기본적으로 TCP 소켓은 Nagle 알고리즘을 적용해서 데이터를 송수신한다.
ACK가 수신될 때까지 최대한 버퍼링을 해서 데이터를 전송한다.
위 그림에서는 왼편과 같다. 문자열 "Nagle"의 전송을 위해 이를 출력 버퍼로 전달한다.
이 때 첫 문자 'N'이 들어온 시점에서는 이전에 전송한 패킷이 없으므로 (수신할 ACK가 없으므로) 바로 전송이 이뤄진다.
그리고 문자 'N'에 대한 ACK를 기다리게 되는데, 기다리는 동안에 출력버퍼에는 문자열의 나머지 'agle'가 채워진다. 이어서 문자 'N'에 대한 ACK를 수신하고 출력버퍼에 존재하는 데이터 "agle"의 하나의 패킷으로 구성해서 전송하게 된다.
즉, 하나의 문자열 전송에 총 4개의 패킷이 송수신 되었다.
오른쪽에 알고리즘을 적용하지 않은 상태를 보자.
문자 'N'부터 'e'까지 순서대로 출력버퍼에 전달된다고 가정해 보자
이 상황에서 이전에 송신한 ACK를 수신하는 것에 신경쓰지 않고 패킷의 전송이 이뤄지기 때문에 출력 버퍼에 데이터가 전달되는 즉시 전송이 이뤄진다.
따라서 문자열 "Nagle"을 보내는데 총 10개의 패킷이 송수신될 수 있다.
이렇게 Nagle Algorithm을 사용하지 않으면 네트워크 트래픽에는 좋지 않은 영향을 미치는 것은 자명한 사실이다.
1byte를 전송하더라도 패킷에 포함되어야 하는 헤더정보의 크기가 수십 byte에 이르기 때문이다.
따라서 네트워크의 효율적인 사용을 위해서 Nagle 알고리즘은 반드시 적용해야 한다.
물론 윗 그림의 예시는 최악의 경우로 극단적인 예시를 든 것이다.
보통 프로그램상에서 문자열을 출력버퍼로 보낼 때 한 문자씩 전달하지 않고 전체 문자열을 한 번에 전달하지 않는가 ?
그렇기에 오른쪽 그림처럼 전개되지는 않는다. 하지만 문자열을 이루는 문자가 약간의 시간간격을 두고 출력버퍼로 전달된다면 비슷한 상황이 연출될 수 있다.
하지만 알고리즘에서도 시간복잡도는 최악의 경우를 계산하지 않는가 ?
그것과 비슷하게 생각하자 !
3-2. Nagle Algorithm의 단점
그러나 해당 알고리즘이 항상 좋지는 않다.
전송하는 데이터의 특성에 따라서 알고리즘의 적용 여부에 따른 트래픽의 차이가 크지 않으면서도 적용하는 것보다 데이터의 전송이 빠른 경우도 있다.
'용량이 큰 파일 데이터의 전송'이 대표적인 예이다.
파일 데이터를 출력버퍼로 밀어 넣는 작업은 시간이 걸리지 않는다.
때문에 알고리즘을 적용하지 않아도 출력버퍼를 거의 꽉 채운 상태로 패킷을 전송하게 된다.
따라서 패킷의 수가 크게 증가하지도 않을 뿐더러, ACK를 기다리지도 않고 연속해서 데이터를 전송하니 전송속도도 엄청나게 향상된다.
일반적으로 알고리즘을 적용하지 않으면 속도의 향상을 기대할 수 있다.
하지만 무작정 적용하지 않을 경우에는 트래픽에 부담을 주게 되어 더 좋지 않은 결과를 얻을 수 있다.
즉, 데이터의 특성을 정확히 판단하지 않은 상태에서 Nagle Algorithm을 중지하는 일은 없어야 한다.
3-3. Code
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, sizeof(opt_val));
int opt_val;
socklen_t opt_len;
opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&opt_val, &opt_len);
나는 현재 논리회로를 수강중이기 때문에 간단히 이렇게 외웠다.
opt_val = 1; // 꺼라
opt_val = 0; // 켜라
즉, Nagle Algorithm은 키고 끄는 것에 Inverter(not)을 붙이자.
이유는 보통 컴퓨터에서는 1은 TRUE, 0은 FALSE를 나타내지 않는가 ?
해당 알고리즘은 반대였다.
그래서 반대로 생각하기 위해 "Nagle은 Inverter다" 라고 외웠다.
무슨 소린지 이해 안 갈 수도 있다. 나만의 암기 방법이였다.
어쨌든 코드를 보면 알겠지만
setsockopt 함수를 통해 옵션을 변경시켰다.
그리고 getsockopt로 설정이 무엇인지 확인하였다.
두 번째 코드의 결과 값은 0이 나올 것이다.
그리고 두 번째 인자를 통해 TCP Level에서의 소켓임을 알 수 있을 것이다.
참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab06/ch9