본문 바로가기

CS/네트워크

[네트워크 개념 복습] 소켓 알아보기

개요

소켓은 L4계층의 데이터를 윗계층과 주고받을 수 있는 통로와 같습니다. 

L5부터는 커널이 아닌 유저모드의 영역이므로 이를 연결한다고도 볼 수 있습니다.

https://basaeng.tistory.com/80 

 

[네트워크 개념] OSI 7계층

개요네트워크에 대한 간단한 개념을 복습할겸 포스팅을 남겨봅니다.복습용이므로 자료사진같은 것은 생략했습니다. OSI(Operating System Interconnection) 모델은 ISO에서 만든 모델로 컴퓨터 네트워크

basaeng.tistory.com

 

BSD소켓 이전에는 각각의 방식으로 통신했지만 BSD소켓이 나오며 현재는 소켓 방식의 통신이 표준이 되어 사용되고 있습니다.


Client-Server(CS)모델

네트워크 통신은 서버와 클라이언트가 존재합니다.

TCP의 경우 listen소켓을 만들고 accept를 통해 connect를 기다리는 쪽이 서버이고, connect를 능동적으로 거는 쪽이 클라이언트 임이 명백합니다.

 

UDP의 경우에는 데이터를 받는 곳이 서버 데이터를 보내는 쪽이 클라이언트로 이는 수신 송신마다 바뀔 수 있습니다.


Socket 그리고 WinSock

Socket은 일종의 파일 디스크립터(핸들)로 구분되기도 합니다.

파일핸들과 마찬가지로 socket 변수를 선언한다고 바로 사용할 수 있는 것은 아니고 연결이되어야 통신이 가능합니다.

 

다만 socket은 프로세스 간 공유가 안되는 등의 파일핸들과 다른 특징도 존재합니다.

 

socket은 커널의 몫입니다.

windows에서의 socket api는 winsock이라고 불립니다.

 

이후로는 기본적으로는 winsock을 기준으로 설명하겠습니다.


WSAStartup(), WSACleanup()

WinSock은 기본적으로 Windows의 DLL을 통해서 만들어졌기 때문에, #include Winsock2.h 를 통해 불러왔을 때 어떻게 커널에 요청할지 정해지지 않았습니다.

 

따라서 기본적으로 WinSock 라이브러리를 사용한다면 WSAStartup()을 호출해야 합니다.

추가적으로 반환할 때는 WSACleanup()을 사용합니다.

 

WinSock이 DLL이기 때문에 프로그램 내에서도 WSAStartup()이 여러번 호출될 가능성도 있는데 이를 위해 내부적으로 reference count를 관리하고 reference count가 0이되면 그 때 메모리를 해제합니다.


socket()

위에서 간단하게 socket 변수를 선언한다고 했는데요 정확하게는 그것은 SOCKET 타입이며, WinSock에는 SOCKET이라는 타입과, socket이라는 SOCKET의 타입을 설정하는 함수가 별도로 존재합니다.

 

실제로 타고 들어가보면 SOCKET타입은 그저 UINT_PTR을 typedef로 래핑한 것임을 확인할 수 있죠

 

하지만 socket()함수는 socket에 필요한 설정 값들을 받고 이를 통해 시스템 콜을 발생시켜 커널에 소켓을 생성합니다.

 

socket()에 필요한 정보에 대해 알아봅시다.

SOCKET WSAAPI socket(
  [in] int af,
  [in] int type,
  [in] int protocol
);

이것이 socket 함수의 파라미터입니다.

 

af는 addressL3에서 사용하는 프로토콜을 넣습니다. 일반적으로 대부분 IPv4혹은 IPV6

 

type의 경우 L4의 프로토콜을 넣습니다. TCP, UDP중 고르면 됩니다. (실제로는 더 많습니다.)

 

protocol은 type을 따라갑니다. 

 

생성 후에는 SOCKET의 디스크립터의 값이 생기고 이를 통해 이후에 해당 소켓 디스크립터를 통해 진짜 커널 소켓에 접근할 수 있게 됩니다.


bind()

bind는 생성한 소켓을 포트와 연결하는 함수입니다.

이전에 생성한 소켓은 특성만 지정했지 실제 통신에 사용할 수는 없는 상황이었습니다.

왜냐하면 L4의 어떤 주소와 소통할지 정하지 않았기 때문입니다.

 

이를 위해 먼저 소켓의 5way tuple에 대해서 알아야합니다.

이는 소켓이 하나의 연결을 식별하는 방법입니다.

1. 프로토콜 (TCP or UDP)

2. source ip

3. source port

4. destination ip

5. destination port

 

이렇게 5가지가 필요합니다. 

1은 socket 생성에서 설정했으니 제외하면 4가지를 설정해야합니다.

이 때 서버의 입장에서는 연결 요청이 들어오기 전까지는 상대방의 주소를 알 수 없지만

클라 서버 공통적으로 본인의 주소는 알 수 있습니다.

 

따라서 먼저 bind를 통해 공통적으로 소켓의 source ip, port를 설정합니다.


listen()

listen과 connect는 논리적 연결이 필수적인 TCP에서 사용하는 함수입니다.

UDP에는 이러한 연결과정이 없습니다.

 

listen은 서버에서만 하는 행위로 연결을 받기 위한 listen socket을 만드는 동작입니다.

서버는 listen socket을 통해 클라이언트의 연결을 받고 3-way handshake 이후에는 새롭게 생성된 소켓을 통해 (accept) 통신합니다.

 

listen을 할 때는 사용할 소켓과 backlog의 크기를 파라미터로 받습니다.

backlog는 accept를 기다리는(3handshake가 완료되어 연결은 성립된) 소켓들이 대기하는 곳이라고 보면 됩니다.

SOMAXCONN을 사용하면 기본적으로 backlog의 크기는 200이되며 이후 설정을 통해 더 키울 수 있습니다.


connect()

connect는 클라이언트에서 하는 행위로 지정한 주소로 연결을 시도해 3-way handshake를 진행합니다.

이 과정에서 클라이언트는 destination ip와 destination port를 지정하게 됩니다.

 

추가적으로 위에서 connect는 tcp에서 사용한다고 했는데 사실은 UDP에서도 활용이 가능합니다.

다만 UDP소켓에서는 3-way handshake의 동작이 일어나는 것은 아니고, 커널 내부의 소켓 상태에 입력한 목적지 주소를 기입하고 보낼 때 마다 주소를 입력해야 하는 수고를 줄이는 용도로 사용됩니다.


3-way handshake

연결에 대해서 계속 다루고 있기 때문에 이번에는 3-way handshake에 대해서 좀 더 알아보겠습니다.

https://commons.wikimedia.org/wiki/File:Tcp-handshake.svg / ??? / Snubcube / CC BY-SA 3.0

위는 3-way handshake의 예시입니다.

client가 connect()를 TCP소켓을 통해 호출하면 위의 과정이 시작됩니다.

 

클라이언트는 먼저 SYN플래그를 1로 하고, seq번호를 설정해 보냅니다.

여기서 seq번호는 책같은 곳에서는 편의를 위해 간단한 숫자로 표현하지만, 실제로는 보안을 위해 랜덤한 숫자로 시작됩니다.

 

서버는 이에대한 응답ACK와, 서버의 seq를 설정해 보냅니다. (SYN, ACK = 1)

이에대한 응답을 클라이언트가 보내면 이제 연결이 수립됩니다.


closesocket()과 4-way handshake

그 다음은 통신 과정 이전에 연결 끊기에 대해서 살펴보겠습니다.

1. closesocket()은 소켓의 핸들을 반환하고, 2. 상대에게 FIN패킷을 보내 연결을 끊는 요청을 합니다.

 

이 때 UDP의 경우 연결이 없기 때문에  closesocket()을 호출하면 1번 동작은 하지만 2번의 FIN패킷을 보내는 것이 아니라 즉시 커널도 메모리를 해제합니다. 

 

TCP의 경우에는 1번 동작은 바로 일어나고, 2번 동작도 일어납니다.

연결을 끊는 주체가 FIN패킷을 보내면 4-way handshake가 그 때 부터 일어납니다.

 

https://commons.wikimedia.org/wiki/File:Fin_de_conexi%C3%B3n_TCP.svg / Clemente / CC BY-SA 3.0

 위 사진은 4-way handshake에 대한 과정을 표현한 이미지입니다.

 

이미지에서의 각 상태는 TCP 이해를 위해서는 유의깊게 보아야 합니다. 

아래보는 것처럼 실제로 Window가 관리하는 상태와 일치하기 때문입니다.

 

이제 다시 연결 종료 과정에 대해 차례차례 따라가보겠습니다.

이미지의 표현처럼 FIN패킷을 먼저 보내는 쪽을 Initiator, FIN패킷을 받는 쪽을 Receiver라고 합니다.

ESTABLISHED: 연결이 되어있는 상황입니다.

FIN_WAIT_1: initiator가 FIN 패킷을 보내고 이에 대한 ACK를 기다리는 상황입니다.

CLOSE_WAIT: receiver가 FIN패킷을 받으면 CLOSE_WAIT으로 상태를 바꾸고 ACK와 FIN패킷을 보냅니다.

다만 이 ACK와 FIN사이에 Receiver가 Initiator에게 보낼 데이터가 담아있다면 이를 보내는 과정이 있습니다.

 

여기서 또 함정이 있습니다. initiator가 closesocket을 호출하면 그 즉시 소켓에 대한 핸들이 사용할 수 없게 되기 때문에 receiver가 데이터를 보내도 받을 수 없습니다.(커널 단에서 drop함) 만약 받고 싶다면 송신만 끊는 shutdown()함수를 호출해야 합니다.

 

FIN_WAIT2: Initiator가 ACK를 받으면 FIN_WAIT_2가 됩니다.

TIME_WAIT: FIN을 받으면 TIME_WAIT상태가 됩니다.

TIME_WAIT상태에서 이제 Receiver의 FIN에 대한 ACK를 보내고 받으면 Receiver는 비로소 CLOSED 상태가 됩니다.

TIME_WAIT 상태에서는 정해진 시간(몇분)을 기다리며 해당 포트의 재사용을 막습니다.

혹시라도 재사용이 되었는데 같은 사용자라 4-way tuple이 일치하는 상황에서 만약 이전에 보내었던(이전 연결) 데이터가 뒤늦게 도착한다면 문제가 생길 수도 있기 때문입니다. (다만 매우 낮은 확률)  

 

이렇게 정상적으로 4-way handshake가 일어나는 상황을 다른 말로 graceful shutdown이라고도 합니다.

 

실제로 코드 단에서는 recv를 호출했을 때 돌아오는 recvBytes가 0인경우 상대방에게 FIN을 받고 ACK를 보낸상황으로 이후 적절한 처리 후 closesocket()을 호출해야 graceful shutdown이 정상적으로 일어납니다.


LINGER 옵션

아래에 소켓의 옵션을 따로 다루겠지만 LINGER옵션은 disconnect와 관련이 깊기 때문에 먼저 알아보도록 하겠습니다.

LINGER 옵션은 내가 상대방과의 연결을 끊는 initiator일 때의 동작을 설정합니다.

typedef struct linger {
  u_short l_onoff;
  u_short l_linger;
} LINGER, *PLINGER, *LPLINGER;

 

onoff는 linger옵션을 사용할지 여부입니다. 

어짜피 옵션을 사용하면 on인데 onoff가 왜 필요하냐고 생각할 수도 있는데, 중간에 off도 할 수 있게 하기 위해 있습니다.

linger의 경우 TIME_WAIT상태에서의 timeout과 같습니다.  

 

만약 이 linger값을 0으로 한다면, fin패킷을 보내는 대신 즉시 RST패킷을 보냅니다.

RST를 보내는 쪽에서도 받는쪽에서도 상태가 바로 CLOSED로 전이됩니다.

 

만약 0보다 큰 값을 넣는다면, 해당 시간동안은 4-way handshake를 진행하고 timeout이 되면 RST패킷을 보냅니다.

 

 

LINGER옵션을 사용하는 경우

일반적으로 게임에서의 클라이언트와 서버라고 한다면 기본적으로 클라이언트가 먼저 연결을 종료합니다.

만약 서버가 먼저 클라이언트와의 연결을 끊는다고 한다면, 보통은 부정 사용자를 식별해서 능동적으로 연결을 끊는 것입니다.

 

이 때 생각해야 할 것은 만약에 FIN을 보내었을 때 발생할 일입니다.

 

1.만약에 상대방이 recvBytes가 0인데 closesocket을 하지 않는다면 영원이 FIN_WAIT2상태에서 기다리게 될 것입니다.

2.만약에 상대방이 악의적으로 연결을 연결했다가 끊는 것을 반복한다면 TIME_WAIT상태인 연결이 많이 생길 것입니다.

다만 모든 네트워크 연결 정보는 non-paged pool에 상주하는 메모리이고 이는 한정되어있기 때문에 메모리를 최대한 아껴야 합니다.

 

결론적으로 서버가 Initiator가 될 때는 Linger옵션을 0으로 설정해서 소켓을 활용해야한다는 결론을 내볼 수 있습니다. 

 


recv, send, recvfrom, sendto

연결을 했으니 통신을 해야겠습니다.

TCP에는 recv, send, UDP에는 recvfrom,sendto함수를 사용합니다.

 

송수신 과정에서 중요한 것은 해당 함수는 실제 전송 수신과는 다르다는 점입니다.

 

위의 함수들은 코드에서 호출되는 함수로 L7(애플리케이션 레이어)단 에서 일어나지만, 실제 데이터는 TCP/IP 프로그램을 거치기 때문에 해당 함수는 L7에서 L4로 데이터를 보내는 일 만을 담당합니다.

 

만약 송신을 한다면 L4의 송신버퍼에 L7 데이터를 카피하고, 수신을 한다면 L4의 수신버퍼에서 L7으로 데이터를 카피합니다.

만약에 버퍼가 없다면 L7과 L4가(그 이하 계층도) 완벽하게 동기화되어 데이터를 건내는 즉시 전송이 일어나야하는데, 이는 설계적으로 부적절합니다.

 

이러한 버퍼는 TCP Session마다 존재하며, UDP는 하나의 버퍼만 존재합니다.

 

TCP의 경우 연결이 존재해 send와 recv가 보낼 데이터만 있으면 되지만 sendto는 보낼 주소가 필요하고, recvfrom은 데이터를 받았을 때 어느 주소인지를 확인할 sockaddr구조체가 필요합니다.

int WSAAPI send(
  [in] SOCKET     s,
  [in] const char *buf,
  [in] int        len,
  [in] int        flags
);
int sendto(
  [in] SOCKET         s,
  [in] const char     *buf,
  [in] int            len,
  [in] int            flags,
  [in] const sockaddr *to,
  [in] int            tolen
);

stream과 datagram

TCP는 데이터를 stream으로 보내고 UDP는 데이터를 datagram 형태로 보냅니다.

이로 인해 송수신 함수 호출 시에 데이터의 양상이 달라지게 됩니다.

 

Stream

이전 포스팅에서 설명한 것 처럼 TCP의 경우 L4에서 MSS의 크기로 데이터가 잘려 보내집니다. 그렇다면 L4에서 L3로도 MSS를 넘지 않는 크기로 보내지겠죠

따라서 받는 입장에서도 L4에서 재조립이 일어납니다.

재조립은 seq번호 단위로 이루어져 상대가 L7에서 L4로 send한 크기가 sendBytes일 때 내가 L4에서 L7으로 recvBytes는 sendBytes와 같다는 보장이 없습니다.

이러한 데이터를 Stream이라고 부릅니다.

 

Datagram

UDP의 데이터는 Datagram이라고 불려집니다.

UDP는 Fragmentation을 L3에서 하기 때문에 L4에서 L3로 데이터를 보낼 때 메시지 전체를 보냅니다.

 

수신할 때도 L4에서 받는 데이터는 이미 L3에서 재조립이 끝난 데이터입니다.

(만약 단 하나의 조각이라도 유실된다면 L3에서 모두 드랍합니다.)

따라서 만약 L7에서 recv한다면 보냈을 때의 크기 sendBytes가 그대로 recvBytes가 됩니다.

또한 같은 의미로 n번 데이터를 보냈으면 n번 recv하면 모든 데이터를 얻을 수 있습니다.


Blocking Socket과 NonBlock Socket

소켓의 경우 블로킹소켓과 논블로킹 소켓으로 동작을 나눌 수 있습니다.

해당 동작은 실제 소켓의 동작인 L4에 영향을 미치는 것은 아니고 L7에서의 정책과 비슷합니다.

 

블로킹 소켓

송신: 송신 버퍼의 크기가 보내고 싶은 데이터의 크기 (sendBytes)를 수용하지 못한다면 그만큼의 크기를 확보할 때 까지 대기(Blocking)합니다.

수신: recv를 호출했는데 만약 수신버퍼가 비어있다면, 수신버퍼에 수신할 수 있는 데이터가 있을 때 까지 대기(Blocking) 합니다.

 

논블로킹 소켓

만약 논블로킹 소켓을 사용하고 싶다면, ioctlsocket()을 사용해야 합니다.

int ioctlsocket(
  [in]      SOCKET s,
  [in]      long   cmd,
  [in, out] u_long *argp
);

만약 listensocket을 논블로킹으로 만든다면 listensocket을 통해 만들어지는 소켓들은 모두 논블로킹 소켓이 됩니다.

 

송신: 송신 버퍼의 크기가 보내고 싶은 데이터의 크기 (sendBytes)를 수용하지 못한다면 보낼 수 있는 크기만큼만 보내고 해당 크기를 return합니다. 만약 송신 버퍼가 꽉차있다면 -1(SOCKET_ERROR)를 반환하고, WSAGetLastError()를 통해 확인하면, WSAEWOULDBLOCK error가 나옵니다.

 

수신:  recv를 호출했는데 만약 수신버퍼가 비어있다면, -1(SOCKET_ERROR)를 반환하고, WSAGetLastError()를 통해 확인하면, WSAEWOULDBLOCK error가 나옵니다.

 

공통적으로 recv가 0을 return하면 상대가 FIN을 보내고 내가 ACK를 보낸 상황이므로 disconnect(closesocket)해야 합니다.

 

connect(): connect는 논블로킹으로 한다면 반드시 처음에 실패합니다. 그렇기 때문에 추후에 확인


소켓 옵션

소켓은 옵션을 지정해 소켓의 동작을 설정할 수 있습니다.

https://learn.microsoft.com/ko-kr/windows/win32/api/winsock/nf-winsock-setsockopt

 

setsockopt 함수(winsock.h) - Win32 apps

setsockopt 함수(winsock.h)는 소켓 옵션을 설정합니다.

learn.microsoft.com

자세한 옵션은 위 링크에서 확인 가능합니다. 

 

이번에는 그 중 자주 사용되는 혹은 알아봐야할 옵션에 대해서만 언급하겠습니다.(LINGER는 위에서 언급했으니 제외)

 

먼저 소켓옵션을 설정하는 함수에 대해 보겠습니다.

int setsockopt(
  [in] SOCKET     s,
  [in] int        level,
  [in] int        optname,
  [in] const char *optval,
  [in] int        optlen
);

s: 설정할 소켓

level: 설정할 레벨 (소켓에 대한 옵션 = SOL_SOCKET, TCP에 대한 옵션 = IPPROTO_TCP) 어떤 계층에서 해석할지를 미리 기입합니다. option의 이름을 통해 level을 추측할 수 있습니다.

optname: 설정할 옵션을 기입합니다.

optval: 옵션에 대한 값

optlen: 값 길이

 

SO_KEEPALIVE

TCP의 Keep alive 기능을 켜고 끄는, 옵션입니다.

Keep Alive는 L4까지의 통신을 확인하는 로직이기 때문에, L7단에서 Keep Alive기능 (Heartbeat)을 구현한다면 Keep Alive가 필요없기 때문에, 꺼볼 수 있습니다.

 

TCP_NODELAY

TCP의 Nagle알고리즘의 동작을 사용할지 결정합니다.

Nagle Algorithm은 TCP에서 데이터를 MSS크기로 모아 보낼지 결정하는 알고리즘입니다.

1. MSS크기 (1460Bytes)만큼 데이터가 모였음

2. ACK를 받았음 

이 경우가 발생하기 전까지는 데이터를 모읍니다.

 

이 때 네트워크적으로는 효율적이지만 반응성이 약간 느려질 수 있어 이를 해결하기 위해 비활성화할 수 있습니다.

 

SO_SENDBUF, SO_RCVBUF

L4의 sendbuf, recvbuf크기를 조정합니다. 값에 0을 넣어도 실제 크기가 0이되지는 않습니다.


Accept

Accept를 사용한다면, backlog(accept queue)에서 하나의 대기중인 연결을 꺼냅니다. 이 과정에서 커널에 소켓이 생기며 유저에는 소켓에 대한 핸들이 반환됩니다.

 

SOCKET WSAAPI accept(
  [in]      SOCKET   s,
  [out]     sockaddr *addr,
  [in, out] int      *addrlen
);

socket에는 listensocket을 넣으며, addr에는 상대방 클라이언트의 주소값을 out으로 받을 수 있습니다.

 

accept또한 논블로킹으로 호출 시 wouldblock이 발생할 수 있습니다.