인터넷 프로토콜 기반 소켓의 경우 데이터 전송 방법에 따라 TCP(Transmission Control Protocol)/UDP(User Datagram Protocol)로 나뉜다. TCP/UDP는 TCP/IP 프로토콜 스택에 속해 있다. TCP/IP 프로토콜 스택은 4개의 계층으로 나뉘어 있는데 이를 통해 데이터 송수신 과정을 4개의 영역으로 나누어 구현했다는 것을 알 수 있다.
만약 TCP소켓을 생성해서 데이터를 송수신한다면 아래 그림에 있는 4개의 계층을 통해서 이루어 질 것이다.
UDP소켓을 생성해서 데이터를 송수신한다면 아래 그림에 있는 계층들을 통할 것이다.
각 계층에 대해 먼저 알아보자.
LINK 계층
LINK 계층은 물리적인 영역의 표준화에 대한 결과이다. LAN, WAN과 같은 네트워크 표준에 관한 프로토콜을 정의하는 영역이다.
IP 계층
목적지로 데이터를 전송하기 위해서 중간에 어떤 경로를 거쳐갈 것인가에 대한 결정을 하는 계층. IP 계층 자체는 비 연결지향적이며 신뢰할 수 없다. 데이터가 전송될 경로는 일정하지 않고, 데이터 전송 도중에 문제가 생기면 다른 경로를 선택하는데 이 과정에서 데이터 손실에 대해 해결하지 않는다. 즉, 오류 발생에 대한 대비가 되어있지 않다.
TCP/UDP 계층
IP계층에서 알려준 경로를 바탕으로 실제 데이터 송수신을 담당하는 계층. Transport 계층이라고도 부름. TCP계층은 IP계층에서 보장해주지 않는 데이터 손실을 막아줘서 신뢰성을 부여함.
APPLICATION 계층
프로그램 성격에 따라 클라이언트와 서버간의 데이터 송수신에 대한 약속들을 정해놓은 계층
TCP 서버/클라이언트 호출 순서(윈도우 기반)
서버 : WSAStartUp -> socket -> bind -> listen -> accept -> recv/send -> closesocket -> WSACleanUp
여기서 서버는 2개의 소켓이 만들어지는데, 1번째는 socket 함수에서 만들어지고 이는 일종의 클라이언트가 연결 요청시 문지기 역할을 한다. 2번째는 accept 호출 시 자동으로 만들어져 클라이언트와 연결돼서 리턴 되고, 이 소켓은 클라이언트와 실질적인 데이터 송수신을 담당한다. 그리고 서버의 accept함수는 클라이언트가 connect를 호출하기 전까지 블로킹 상태이다.
클라이언트 : WSAStartUp -> socket -> connect -> recv/send -> closesocket -> WSACleanUp
클라이언트의 connect 호출 시 클라이언트 소켓에 커널이 (클라이언트)호스트의 IP와 임의의 PORT번호를 할당한다. 즉 클라이언트는 서버처럼 bind를 이용해서 명시적으로 IP/PORT를 할당해주지 않아도 connect 호출 시 자동으로 할당된다.
TCP 소켓에 존재하는 입출력 버퍼
TCP 소켓에는 입력 버퍼, 출력 버퍼가 존재한다. send, recv함수 호출되는 순간 바로 데이터가 전송되는 것은 아니고 send 호출시 출력 버퍼로 데이터가 이동하고, recv 호출 시 입력 버퍼에서 호스트로 데이터가 들어온다(send 리턴시 목적지로 데이터 전송이 완료된 것이 아니다!). 그래서 TCP 소켓으로 데이터 전송 시 한번에 30바이트를 전송해도 (예를 들어)10바이트씩 3번으로 나누어 전송이 가능한 것이다. 입출력 버퍼는 다음과 같은 특징이 있다.
- 입출력 버퍼는 TCP 소켓 각각에 별도로 존재
- 입출력 버퍼는 소켓 생성시 자동으로 생성
- 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송된다.
- 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸된다
TCP 내부 동작 원리
TCP 소켓이 생성에서 소멸까지 거치는 일을 나누면 크게 3가지로 나눌 수 있다.
- 상대 소켓과의 연결
- 상대 소켓과의 데이터 송수신
- 상대 소켓과의 연결 종료
TCP 소켓은 상대 소켓과의 연결과정에서 총 세번의 대화를 주고 받는데 이를 3-way handshaking이라고 부른다.
먼저 첫번째 보낸 패킷은 [SYN] SEQ : 1000, ACK : - 인데 SEQ 가 100번이라는 것은 호스트 A가 보내는 패킷 번호가 1000번이라는 의미이고, 답장으로 1001번의 ACK 패킷을 기대한다는 의미다. 호스트 B는 답으로 [SYN+ACK] SEQ : 2000, ACK : 1001을 보낸다. 이는 답장으로 보낸 패킷 번호가 2000번이며 답으로 2001번을 기대하며 동시에 ACK 1001을 통해 이전에 받은 SEQ : 1000의 패킷을 잘 받았다는 것을 의미한다. 마지막으로 호스트 A는 [ACK] SEQ : 1001, ACK : 2001 인데 이것은 이전에 A가 보낸 패킷의 번호가 1000번이었으니 다음 번호로 1001번을 사용했고, 직전에 호스트 B가 보낸 SEQ : 2000의 패킷을 잘 받았다는 의미에서 1을 더한 ACK : 2001을 보낸 것이다.
3-handshaking은 위와 같이 진행되고 이제 서로 연결이 됐으니 데이터를 주고 받아야 한다.
호스트 A가 SEQ : 1200의 100바이트 패킷을 보내자 호스트 B가 ACK : 1301을 보낸 것을 알 수 있다. 여기에는 간단한 약속이 있다.
ACK 번호 = SEQ 번호 + 전송된 바이트 크기 + 1
ACK 번호는 다음 SEQ번호를 알리는 것 외에도 보낸 데이터의 바이트 크기를 확인하는 용도도 있다. 100바이트를 보냈으면 받은 호스트는 데이터 손실 없이 100바이트 모두를 잘 받았다는 ACK을 보내줘야 패킷이 송신되었는지 여부와 데이터 손실 여부를 확인할 수 있다. 만약 호스트 A가 보낸 패킷을 호스트 B가 받지 못한다면 어떻게 될까?
위 그림처럼 호스트 A는 데이터를 보내놓고 타이머를 작동시킨다. 지정된 시간내에 호스트 B로부터 정확한 ACK 패킷이 오지 않는다면 같은 패킷을 다시 보냄으로서 데이터 손실을 막는다.
마지막으로 상대 소켓과의 연결을 종료하는 과정이다. 아래와 같이 서로 상호간의 종료도 합의가 되어야 하는 이유는 일방적인 종료로 데이터 손실을 막기 위해서이다.
호스트 A가 연결 종료를 의미하는 FIN을 패킷안에 삽입해서 보내면 호스트 B는 이것을 잘 받았다는 의미로 1을 더한 ACK 패킷을 보낸다. 호스트 B도 연결을 종료할 준비가 됐다면 똑같이 FIN을 패킷안에 삽입해서 보내고 호스트 A가 잘받았다는 의미로 1을 더한 ACK패킷을 보내고 연결 종료는 완료된다. 이렇게 소켓 연결 종료는 4단계를 거치므로 4-way handshaking이라고 부르기도 한다.
UDP 소켓의 특성과 원리
UDP는 TCP와 비교했을때 신뢰성이 떨어지지만 TCP보다 훨씬 간결한 구조로 설계되어있다. ACK과 같은 패킷을 보내지 않고, SEQ같이 패킷에 번호를 부여하지도 않는다. 따라서 TCP보다 대부분 좋은 성능을 보인다. 또한 TCP에는 존재하는 흐름 제어(flow control)가 없다.
UDP가 TCP에 비해 좋은 성능을 보이는 이유는 크게 두가지다
- 데이터 송수신 이전, 이후에 거치는 연결설정 및 해제 과정이 없다 -> TCP 서버 구현에서 봤던 listen, accept, TCP 클라이언트 구현에서 봤던 connect가 필요 없다.
- 데이터 송수신 과정에서 거치는 신뢰성보장을 위한 흐름제어가 없다 -> 알맞은 ACK packet을 보내지 않거나 timeout지나면 패킷을 재전송해주지 않음
UDP가 이런 이유는 애초에 서버와 클라이언트가 연결된 상태로 송수신하지 않기 때문이다. 그리고 TCP와 또 하나 다른 점은 소켓과 소켓이 일대일 대응이 아니라는 점이다. TCP는 10개의 클라이언트에게 서비스하기 위해서 10개의 송수신용 소켓(문지기 역할의 소켓말고 accept함수 호출하면 생기는 소켓)이 필요하다. 하지만 UDP는 편지를 넣는 우체통마냥 여러 클라이언트와 송수신할때도 1개의 송수신용 소켓만 있으면 된다.
이렇게 1개의 소켓이 1개의 소켓과 연결 상황이 아니므로 소켓 생성시 데이터를 보내는 목적지의 주소정보를 할당하지 않고, 보낼때마다 목적지의 주소정보를 새로 할당하게된다. 데이터를 받을 때도 어디서부터 오는 것인지 미리 모르니까 인자로 넣어준 sockaddr구조체 주소를 넣어주면 recvfrom함수가 sockaddr구조체에 어디서부터 이 데이터가 온것인지에 대한 주소정보를 넣어준다.
#include <winsock2.h>
// 성공 시 전송된 바이트 수, 실패시 SOCKET_ERROR 반환
int sendto(SOCKET s, const char* buf, int len, int flags, const struct sockaddr* to, int tolen);
// 성공 시 수신한 바이트 수, 실패시 SOCKET_ERROR 반환
int recvfrom(SOCKET s, char* buf, int len, int flags, struct sockaddr* from, int* fromlen);
sendto()함수의 동작은 크게 3단계로 나뉜다.
- UDP소켓에 목적지의 IP와 PORT번호 등록
- 데이터 전송
- UDP 소켓에 등록된 목적지 정보 삭제
TCP 소켓에서는 클라이언트가 connect함수를 호출하면 자동으로 IP주소와 PORT번호가 할당된다 했다. 그런데 UDP 소켓으로 송수신할때는 클라이언트가 connect함수를 호출하지 않아도 된다고 했는데 그러면 어떻게 IP주소와 PORT번호를 할당할까? UDP소켓은 sendto() 함수 이전에 IP주소와 PORT번호가 할당되어 있어야 한다. 물론 bind()함수를 호출해서도 가능하지만 만약 sendto() 이전에 bind()가 호출되지 않는다면 sendto()함수가 처음 호출되는 시점에 해동 소켓에 IP주소와 PORT번호가 자동으로 할당된다. 물론 IP는 호스트의 IP주소로, PORT번호는 사용중이 아닌 임의의 번호로 할당된다. 이렇게 sendto()함수에서 자동으로 소켓에 IP주소와 PORT번호를 할당해주므로, 일반적으로는 bind를 사용하지 않는다.
데이터 경계가 존재하는 UDP 소켓
TCP와 다르게 UDP는 데이터의 경계가 존재한다고 했다. 이말은 데이터 송신 횟수와 수신 횟수가 정확히 일치해야 송신한 데이터 전부를 수신할 수 있다는 의미이다.
connected UDP 소켓, unconnected UDP 소켓
위에서 언급했듯이 UDP 소켓은 소켓끼리 연결된 상태로 송수신하지 않는다. 따라서 sendto함수가 호출될 때마다 목적지의 IP와 PORT를 등록한다. 그런데 만약 프로그램이 오랫동안 매번 같은 목적지로 데이터를 송신한다면 이렇게 IP와 PORT를 매번 등록하는 과정이 낭비가 아닐까? 그래서 한번만 IP주소와 PORT를 등록하고 계속 그 정보들을 UDP소켓이 사용하도록 하게 할 수 있는데 이걸 가리켜 connected UDP 소켓이라고 부른다(기존 UDP 소켓을 unconnected UDP 소켓이라고 부른다). 방법은 간단하다. sendto이전에 connect함수를 호출해서 IP주소와 PORT번호를 등록해주면 된다. 이렇게 하면 송수신 대상이 송수신 전에 정해졌으므로 sendto(), recvfrom()으로 데이터 송수신을 하지 않아도 되고 send()와 recv()로도 데이터 송수신이 가능하다.
* 이 글은 윤성우의 열혈 TCP/IP 소켓 프로그래밍 서적을 공부하고 작성한 글입니다.
'공부 > Server' 카테고리의 다른 글
소켓 옵션 정리 (0) | 2021.08.01 |
---|---|
도메인(Domain)과 DNS(Domain Name System) (0) | 2021.08.01 |
TCP 기반의 소켓의 우아한 연결 종료(Half-close) (0) | 2021.08.01 |
윈도우 기반 소켓 프로그래밍 필수 함수 (0) | 2021.07.31 |
윈도우 기반 소켓 프로그래밍 시작하기(socket, protocol 개념 및 기본적인 함수 정리) (0) | 2021.07.27 |