목표
전 글에서는 TCP Server로 데이터 전송하기까지 다뤘습니다.
이번 글에서는 TCP Server로 데이터를 수신해 보는 실습을 해보겠습니다.
TCP 데이터 수신하기
전 글에서 코드를 가져와서 recv() 코드를 추가해 보겠습니다.
#include <arpa/inet.h>
#include <errno.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main() {
int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s < 0) {
cerr << "socket failed: " << strerror(errno) << endl;
return 1;
}
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = inet_addr("127.0.0.1");
sin.sin_port = htons(10001);
if (connect(s, (struct sockaddr *) &sin, sizeof(sin)) < 0) {
cerr << "connect() failed: " << strerror(errno) << endl;
return 1;
}
char buf[1024];
int r = send(s, buf, sizeof(buf), 0);
if(r < 0) {
cerr << "send() failed: " << strerror(errno) << endl;
} else {
cout << "Sent: " << r << "bytes" << endl;
}
//추가된 코드
r = recv(s, buf, sizeof(buf), 0);
if(r < 0) {
cerr << "recv() failed: " << strerror(errno) << endl;
} else {
cout << "Received: " << r << "bytes" << endl;
}
close(s);
return 0;
}
연결이 끊겼을 때 recv() 함수의 동작
전 글에서 send() 함수는 연결이 끊긴 경우 EPIPE 혹은 ECONNRESET을 반환했습니다.
(EPIPE의 경우 send() 시 flag MSG_NOSIGNAL을 지정하지 않으면 프로그램이 시그널을 맞고(= SIGPIPE) 조용히 종료됩니다.)
(중요) recv()는 연결이 끊길 경우 0을 반환합니다.
r = recv(s, buf, sizeof(buf), 0);
if(r < 0) {
cerr << "recv() failed: " << strerror(errno) << endl;
} else if (r == 0){ // 추가된 코드
cout << "Socket closed" << endl; // 추가된 코드
} else {
cout << "Received: " << r << "bytes" << endl;
}
recv() 반환값이 0일 때 Socket이 닫혔다는 코드를 추가했습니다.
recv()가 일부 데이터만 반환할 때
UDP의 sendto()는 하나의 datagram에 데이터를 실어 보냅니다.
그 때문에 상대방이 recvfrom() 호출 시, 이 datagram이 전송이 안 됐으면 모르되
전송이 되었다면 recvfrom()은 전체 data를 반환합니다.
반면 TCP는 연속되는 byte가 흐르는 것 같은 착각을 줍니다.
이 때문에 send() 한 만큼 그대로 상대가 recv() 하지 않을 수도 있습니다.
위 코드의 char buf [1024]; 를 char buf [60 * 1024]; 로 변경 후 코드를 실행해 보겠습니다.
#include <arpa/inet.h>
#include <errno.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main() {
int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s < 0) {
cerr << "socket failed: " << strerror(errno) << endl;
return 1;
}
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = inet_addr("127.0.0.1");
sin.sin_port = htons(10001);
if (connect(s, (struct sockaddr *) &sin, sizeof(sin)) < 0) {
cerr << "connect() failed: " << strerror(errno) << endl;
return 1;
}
char buf[60 * 1024]; // 변경된 코드
int r = send(s, buf, sizeof(buf), 0);
if(r < 0) {
cerr << "send() failed: " << strerror(errno) << endl;
} else {
cout << "Sent: " << r << "bytes" << endl;
}
r = recv(s, buf, sizeof(buf), 0);
if(r < 0) {
cerr << "recv() failed: " << strerror(errno) << endl;
} else if (r == 0){
cout << "Socket closed" << endl;
} else {
cout << "Received: " << r << "bytes" << endl;
}
close(s);
return 0;
}
서버는 61440 바이트를 수신하고 그것을 하나의 send()로 전송하지만,
클라이언트의 recv()는 그것보다 적은 수의 바이트를 수신함을 알 수 있습니다.
즉, TCP에서의 recv()는 필요한 바이트 수만큼 읽을 때까지 반복해야 합니다.
참고: Nagle's Algorithm
UDP에서는 sendto() 하나하나가 datagram이 되었습니다.
그 때문에 연관된 데이터를 분리된 sendto() 여러 개로 보내는 것은
일부 datagram이 유실될 수 있고, 헤더 오버헤드가 문제가 될 수 있습니다.
2023.10.21 - [CS] - [Network] Socket Programming 메모리 관리
이는 TCP에서도 마찬가지 일 수 있습니다.
그런데 TCP는 datagram 같은 메시지 단위 전송이 아니라,
byte들을 물 흐르는 것처럼(= stream) 전송하는 환상을 주는 것이 목적입니다.
그 때문에 적은 data를 보낼 경우 여러 개의 TCP datagram으로 갈 것을 하나로 합쳐서 보내도 무방합니다.
이런 방식으로 보내는 것을 Nagle's Algorithm이라고 합니다.
그 때문에 UDP에 비해 TCP는 작은 데이터를 여러 개의 send()로 전송해도 상대적으로 헤더 오버헤드가 적습니다.
또한 전송 보장 특성 때문에 여러 개의 send()중 일부가 유실되는 일도 없습니다.
TCP 복습
TCP는 연결지향(connection-oriented)이므로 "연결을 맺는" 과정을 거칩니다.
연결이 맺어진 상태의 socket을 "active socket"이라고 합니다.
연결을 맺기 위해 대기 중인 socket을 "passive socket"이라고 합니다.
TCP Passive Socket으로 전환
int listen(sock, backlog숫자);
- sock: socket()으로 얻어진 소켓 핸들
- backlog숫자
- 연결 완료를 기다리는 대기열의 크기.
- 즉, 상대가 연결 요청을 해왔는데 아직 완료되지 않은 것을 몇 개까지나 기억하고 있을지를 결정
- 그 이상의 연결 요청은 자동으로 거절된 것으로 처리됩니다.(예: 10과 같은 적당한 숫자)
Passive Socket에서 Active Socket 생성
클라이언트와 서버의 전체적인 TCP 연결 하나의 코드로 담아보겠습니다.
#include <arpa/inet.h>
#include <errno.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main(){
int passiveSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = INADDR_ANY;
sin.sin_port = htons(20113);
if(bind(passiveSock, (struct sockaddr *) &sin, sizeof(sin)) < 0){
cerr << "bind() failed: " << strerror(errno) << endl;
return 1;
}
if(listen(passiveSock, 10) < 0){
cerr << "listen() failed: " << strerror(errno) << endl;
return 1;
}
memset(&sin, 0, sizeof(sin));
unsigned int sin_len = sizeof(sin);
int clientSock = accept(passiveSock, (struct sockaddr *) &sin, &sin_len);
if(clientSock < 0){
cerr << "accept() failed: " << strerror(errno) << endl;
return 1;
}
char buf[65536];
int numRecv = recv(clientSock, buf, sizeof(buf), 0);
if(numRecv == 0){
cout << "Socket closed: " << clientSock << endl;
} else if(numRecv < 0){
cerr << "recv() failed: " << strerror(errno) << endl;
}else {
cout << "Received: " << numRecv << "bytes, clientSock" << clientSock << endl;
}
int offset = 0;
while(offset < numRecv) {
int numSend = send(clientSock, buf + offset, numRecv - offset, 0);
if(numSend < 0){
cerr << "send() failed: " << strerror(errno) << endl;
}else{
cout << "Sent: " << numSend << endl;
offset += numSend;
}
}
close(clientSock);
close(passiveSock);
}
- 소켓 생성
- socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)를 통해 TCP 소켓을 생성합니다.
- 바인딩
- bind() 함수를 사용하여 생성된 소켓에 IP 주소와 포트 번호를 바인드 합니다.
- 여기서는 'INADDR_ANY'를 사용하여 서버가 실행되는 기계의 모든 IP 주소에 바인드 하게 됩니다.
- 포트 번호는 '20113'으로 설정됩니다.
- 리스닝
- listen() 함수를 호출하여 클라이언트 연결 요청을 기다립니다.
- 동시에 최대 10개의 연결 요청을 대기할 수 있습니다.
- 연결 수락
- accept() 함수를 호출하여 클라이언트의 연결 요청을 수락합니다.
- 연결이 수립되면, 해당 클라이언트와 통신을 위한 새로운 소켓 디스크립터 'cliendSock'이 반환됩니다.
- 데이터 수신
- 클라이언트로부터 데이터를 수신받습니다.
- 수신된 데이터는 'buf'에 저장되며, 수신된 바이트 수는 'numRecv'에 저장됩니다.
- 데이터 송신
- 수신받은 데이터를 클라이언트에게 다시 전송합니다.
- 이때, 모든 데이터가 전송될 때까지 'send()' 함수를 반복 호출합니다.
- 소켓 닫기
- 통신이 끝나면 'close()' 함수를 사용하여 클라이언트 소켓과 서버 소켓을 모두 닫습니다.
전체적으로, 이 코드는 클라이언트로부터 데이터를 수신받아 그대로 다시 클라이언트에게 전송하는 에코 서버와 유사한 기능을 합니다.
TCP 3-way Handshaking (연결 맺는 부하)
TCP는 connect()로 연결의 시작을 알리고 accept()로 연결을 마무리 짓습니다.
TCP는 컴퓨터 2대가 서로 연결 준비가 됐다고 알리는 과정이 있는데 이를 3-way-handshaking이라고 합니다.
connect() 시작을 하게 되면 TCP는 헤더에 syn flag를 달고 보냅니다. 그럼 서버에서는 accept() 함수를 부르기 시작합니다.
여기서 주의할 것은 connect()는 아직 반환을 안 했으므로 아직 끝나지 않은 상태입니다.
accept()가 시작하면 ACK+SYN을 클라이언트로 보내줍니다. 이것을 받는 순간 connect()가 완료됩니다.
connect()는 이 과정이 끝날 때까지 blocking이 됩니다. 그런데 이렇게 되면 서버와의 연결이 굉장히 느려집니다.
만약 서버가 과부하가 걸려서 이 accept()를 제대로 해주지 못한다면 어떻게 될까요?
connect() 시작 <=> connect() 반환까지 멈춘 기간이 길어지게 됩니다.
connect()가 반환이 되었다면 서버로 ACK를 다시 보내줍니다.
서버가 ACK를 받게 되면 그때 accept()를 반환합니다.
마찬가지로 connect() 반환이 느려진다면 accept() 반환도 오래 걸리게 됩니다.
TCP는 연결을 맺고 끊은 다음에 다시 연결을 맺는 것이 굉장히 큰 부하입니다.
그래서 연결을 한 번 맺으면 가급적 끊지 않는 것이 좋습니다.
HTTP/1.0에서는 매 요청과 응답 후에 연결이 종료됩니다. 이렇게 되면
동일한 서버에 여러 요청을 보낼 때마다 새로운 연결을 맺어야 합니다. 연결의 설정과 종료는
리소스와 시간이 소모되므로, 이러한 연결을 반복적으로 설정하는 것은 비효율적입니다.
HTTP/1.1에서 도입된 'keep-alive' 메커니즘은 이러한 문제를 해결하기 위해 제안되었습니다.
'keep-alive'를 사용하면
- 연결의 지속성: 한 번 연결된 TCP 연결을 유지하고, 그 연결 위에서 여러 개의 HTTP 요청과 응답을 연속적으로 처리할 수 있습니다.
- 리소스 절약: 매번 연결을 설정하고 종료하는 비용을 줄일 수 있습니다. 이로 인해 전체적인 네트워크 성능과 응답 시간이 향상됩니다.
- 헤더 필드: HTTP/1.1 요청과 응답에서는 'Connection: keep-alive' 헤더를 통해 'keep-alive'를 명시적으로 지시할 수 있습니다. 그러나 HTTP/1.1에서는 기본적으로 'keep-alive'가 활성화되어 있기 때문에, 연결을 종료하고 싶을 때 'Connection: close' 헤더를 사용합니다.
- 타임아웃 설정: 서버는 'keep-alive' 연결이 무한정 유지되지 않도록 타임아웃을 설정할 수 있습니다. 이를 통해 일정 시간 동안 활동이 없는 연결을 자동으로 종료하여 리소스를 해제할 수 있습니다.
HTTP/2와 같은 더 최신 버전의 HTTP 프로토콜에서는 다중화 기술을 사용하여 여러 요청과 응답을 동일한 연결에스 병렬로 처리하는 방식을 도입하여, 'keep-alive'의 개념을 더욱 발전시켰습니다.
다시 본론으로 돌아와서 send()는 시작하자마자 거의 바로 반환합니다.
이는 상대방이 받을 때까지 기다렸다가 send()가 완료되는 것이 아니고
자신의 kernel상의 메모리에 복사를 합니다. send()는 그렇게 때문에 자신의 컴퓨터 안에서 호출하고 바로 반환이 됩니다.
반환을 하게 되면 OS가 알아서 그 데이터를 보내기 시작합니다.
받는 컴퓨터에서 이 데이터를 받았다면 이에 대한 ACK를 상대 컴퓨터에 보내줍니다.
여기서 중요한 것은 recv()에 대한 내용을 다루지 않았습니다. 그 뜻은 ACK라고 하는 것은
데이터를 받았으면 받았다고 알리기 위해 보내는 것입니다.
이렇게 받아진 데이터는 받는 쪽 컴퓨터 커널 내에 존재합니다. 받는 쪽 커널 내에 recv()가 호출된다면 그때 같이 넘어가게 됩니다.
그래서 받은 데이터가 많이 싸여 있으면 recv()할 때 한꺼번에 많이 읽을 수도 있습니다.
TCP는 적게 읽을 수도 있고 많이 읽을 수도 있습니다. 자신이 원하는 만큼 가는 것이 아닙니다.
TCP는 바이트 스트림이므로 그 스트림이 적을 수도 있고 많을 수도 있는 것입니다.
비교: UDP 데이터 전송
UDP에서 sendto()를 하게 되면 마찬가지로 커널 스페이스에 복사를 합니다.
그럼 커널에서 그것을 알아서 보내고 끝냅니다.
UDP는 연결을 맺는다는 것이 없습니다. ACK 또한 없습니다.
TCP는 왔다 갔다 하는 과정이 있어서 트랙픽을 더 쓰게 되지만 UDP는 딱 한 번 갑니다.
그리고 UDP는 socket() 함수로 얻어낸 소켓 1개로 모든 통신이 가능합니다.
매번 보낼 때마다 누구한테 보낼지 명시하고 sendto()로 보내면 끝이기 때문입니다.
반면, TCP는 accept() 함수를 통해 Active Socket을 얻어냅니다. 그래서 accept()에 넘기기 위한
Passive Socket이 필요하고 Accept()가 반환하는 클라이언트별 Active Socket이 필요합니다. 그래서 훨씬 더 많은 소켓이
필요하고 훨씬 더 많은 State를 관리하기 때문에 OS의 부하가 큽니다.
'CS' 카테고리의 다른 글
[OS] CPU utilization, CPU load (1) | 2023.12.10 |
---|---|
[Network] Socket Options, I/O Multiplexing과 Non-blocking I/O (1) | 2023.10.23 |
[Network] TCP Socket Programming - 데이터 전송하기 (1) | 2023.10.22 |
[Network] Protobuf, JSON (0) | 2023.10.22 |
[Network] Socket Programming 메모리 관리 (1) | 2023.10.21 |