에코 서버
에코 서버란
에코란 말 그대로 메아리를 떠올리면 된다. 클라이언트에서 전송받은 데이터를 그대로 다시 전송해 주는 서버를 말합니다
에코 서버 작동원리
- 서버는 특정 IP 주소와 포트에서 수신을 위해 소켓을 엽니다. 이것은 클라이언트가 연결을 시작할 수 있는 '문'을 열어둔 것입니다.
- 클라이언트가 서버에 연결을 요청하면, 서버는 연결을 수락하고 데이터를 전송 받기 시작합니다.
- 클라이언트가 데이터를 보내면, 서버는 이 데이터를 읽고 그대로 다시 클라이언트에게 전송합니다. 이것이 "에코"의 개념입니다.
- 클라이언트나 서버가 연결을 종료하기를 원하면, 해당 소켓은 닫힙니다.
에코 서버 특징
클라이언트가 서버로 데이터를 전송하면 서버는 그 데이터를 받았다가 그대로 다시 되돌려주기 때문에
클라이언트가 서버로부터 몇 바이트의 데이터를 수신할 것인지 예상할 수 있다.
에코 서버 코드1
// 필요한 헤더 파일들
#include <stdio.h> // 표준 입력/출력 헤더
#include <stdlib.h> // 표준 라이브러리 헤더
#include <string.h> // 문자열 처리 라이브러리
#include <WinSock2.h> // 윈도우 소켓 2 헤더
#include <WS2tcpip.h> // WinSock2에 추가 기능을 제공하는 헤더
#pragma comment(lib, "ws2_32.lib") // WinSock2를 위한 링커 지시문
#define BUFSIZE 1024
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hServSock;
SOCKET hClntSock;
char message[BUFSIZE];
int strLen;
SOCKADDR_IN servAddr;
SOCKADDR_IN clntAddr;
int clntAddrSize;
if (argc != 2)
{
printf_s("Usage, : %s <port> \n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hServSock = socket(PF_INET, SOCK_STREAM, 0);
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(atoi(argv[1]));
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error");
if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("socket() error");
clntAddrSize = sizeof(clntAddr);
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &clntAddrSize);
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error");
while ((strLen = recv(hClntSock, message, BUFSIZE, 0)) != 0)
{
// 데이터 수신 및 전송
send(hClntSock, message, strLen, 0);
}
closesocket(hClntSock);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
에코 클라 코드 1
// 필요한 헤더 파일들
#include <stdio.h> // 표준 입력/출력 헤더
#include <stdlib.h> // 표준 라이브러리 헤더
#include <string.h> // 문자열 처리 라이브러리
#include <WinSock2.h> // 윈도우 소켓 2 헤더
#include <WS2tcpip.h> // WinSock2에 추가 기능을 제공하는 헤더
#pragma comment(lib, "ws2_32.lib") // WinSock2를 위한 링커 지시문
#define BUFSIZE 1024
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
char message[BUFSIZE];
int strLen;
SOCKADDR_IN servAddr;
if (argc != 3)
{
printf_s("Usage : %s <IP> <port> \n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error");
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(atoi(argv[2]));
inet_pton(AF_INET, argv[1], &servAddr.sin_addr);
if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error");
while (1)
{
fputs("전송할 메시지를 입력하세요 (q to quit) : ", stdout);
fgets(message, BUFSIZE, stdin); // 전송할 데이터 콘솔로부터 입력
if (!strcmp(message, "q\n"))
break;
send(hSocket, message, strlen(message), 0); // 메시지 전송
strLen = recv(hSocket, message, BUFSIZE - 1, 0);
message[strLen] = 0;
printf_s("서버로부터 전송된 메시지 : %s \n", message);
}
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
결과
에코 수정 버전
위의 에코 클라이언트는 사실 완벽한 방식이 아닙니다.
그러면 왜 완벽한 방식이 아닌지 확인해 보도록 하겠습니다.
우선 TCP는 연결 지향 프로토콜이고 전송 데이터의 경계가 없다고 이전 포스팅에서 언급했던 적이 있습니다.
그래서 상황에 따라 한 번의 send함수 호출을 통해 "ABCD"의 문자열을 전송해도 데이터들이 반드시 하나의 패킷으로 구성되어 전송된다고 보장할 수 없다고 한다. 상황에 따라 A, BC, D 가 전송될 수도 있다 함.
그림에서처럼 ABCD를 하나의 패킷으로 전송을 했는데 돌아올 때는 3개의 패킷으로 나뉘어 돌아온 것과 같은 예이다.
그래서 위와 방법을 해결하기 위해 살짝 다른 방식으로 구현을 해야 한다.
보안된 에코 클라 코드
// 필요한 헤더 파일들
#include <stdio.h> // 표준 입력/출력 헤더
#include <stdlib.h> // 표준 라이브러리 헤더
#include <string.h> // 문자열 처리 라이브러리
#include <WinSock2.h> // 윈도우 소켓 2 헤더
#include <WS2tcpip.h> // WinSock2에 추가 기능을 제공하는 헤더
#pragma comment(lib, "ws2_32.lib") // WinSock2를 위한 링커 지시문
#define BUFSIZE 1024
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
char message[BUFSIZE];
int strLen, recvLen, recvNum;
SOCKADDR_IN servAddr;
if (argc != 3)
{
printf_s("Usage : %s <IP> <port> \n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error");
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(atoi(argv[2]));
inet_pton(AF_INET, argv[1], &servAddr.sin_addr);
if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error");
while (1)
{
fputs("전송할 메시지를 입력하세요 (q to quit) : ", stdout);
fgets(message, BUFSIZE, stdin); // 전송할 데이터 콘솔로부터 입력
if (!strcmp(message, "q\n"))
break;
strLen = send(hSocket, message, strlen(message), 0); // 메시지 전송
for (recvLen = 0; recvLen < strLen;)
{
recvNum = recv(hSocket, &message[recvLen], strLen - recvLen, 0);
if (recvNum == -1)
ErrorHandling("recv() error");
recvLen += recvNum;
}
message[strLen] = 0;
printf_s("서버로부터 전송된 메시지 : %s \n", message);
}
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
수정된 부분은 아래의 코드와 같다
strLen = send(hSocket, message, strlen(message), 0); // 메시지 전송
for (recvLen = 0; recvLen < strLen;)
{
recvNum = recv(hSocket, &message[recvLen], strLen - recvLen, 0);
if (recvNum == -1)
ErrorHandling("recv() error");
recvLen += recvNum;
}
보낼 때 meesage의 총길이를 얻어 놓고 받을 때 받은 패킷(메시지)이 분할되어 들어왔다면 strLen의 길이보다 작을 것이다 그러므로 반복문을 통해서 보낼 때의 길이와 받을 때의 길이를 비교해 최종 message를 뽑아내는 방식이다.
버퍼가 존재한다
클라이언트, 서버 모두 소켓 생성 시 입. 출력을 위한 버퍼가 커널에 의해 생성되고, 데이터 송, 수신이 흐름제어 프로토콜을 기반으로 해서 진행된다.
입출력 버퍼의 존재로 인해 몇 가지 중요한 작업이 가능해집니다.
- 데이터의 일시적인 저장: 입출력 버퍼는 데이터를 일시적으로 저장하는 데 사용됩니다. 데이터가 전송되거나 읽히기 전에 버퍼에 저장되어 안정성을 보장하고, 데이터 손실을 방지합니다.
- 데이터의 조각화 및 조립: TCP는 데이터를 패킷으로 나누어 전송하는데, 송신 버퍼를 통해 데이터를 작은 조각으로 나누고, 수신 버퍼를 통해 패킷을 조립하여 전체 데이터를 복원할 수 있습니다.
- 비동기적 입출력: 입출력 버퍼를 사용하면 비동기적 입출력이 가능해집니다. 데이터를 송신 버퍼에 기록한 후 다른 작업을 수행하거나, 수신 버퍼에 데이터가 도착하기 전에 다른 작업을 수행할 수 있습니다.
- 효율적인 데이터 전송: 입출력 버퍼를 사용하여 데이터를 블록 단위로 처리하면, 네트워크 오버헤드를 줄일 수 있고 효율적인 데이터 전송이 가능합니다. 데이터를 한 번에 전송하면 네트워크 오버헤드가 감소하고 전송 속도가 향상됩니다.
- 입출력의 분리: 송신 버퍼와 수신 버퍼의 분리는 입출력 작업을 독립적으로 수행할 수 있게 해 줍니다. 이를 통해 동시에 송신과 수신을 처리하거나, 송신과 수신 간의 우선순위를 다르게 설정할 수 있습니다.
TCP의 내부 구조
TCP 기반의 소켓이 연결되어 데이터를 송 수신하고 종료되기까지는 아래와 같이 총 3단계를 거치게 된다.
연결 설정
연결 설정 단계는 클라이언트와 서버 간의 TCP 연결을 수립하는 단계이다.
이 단계는 일반적으로 TCP의 "3-way handshake" 프로토콜을 사용하여 이루어진다
- 클라이언트가 서버에게 연결을 요청하는 SYN 패킷을 보냅니다.
- 서버는 SYN 패킷을 받고, 클라이언트에게 연결을 수락한다는 ACK와 SYN 패킷을 함께 보냅니다.
- 클라이언트는 서버의 응답을 받고, ACK 패킷을 보내면서 연결 설정이 완료됩니다. 이로써 클라이언트와 서버는 연결이 성립되고 양방향 데이터 전송이 가능해집니다.
패킷 안에 SEQ와 ACK의 정보가 들어있는 것
서적에는 위를 쉽게 이해하기 위해 아래와 같이 설명이 되어있다.
1. A가 B에게 : 패킷의 SEQ는 1000번이고 ACK 정보는 비어있다 = 내가 보내는 패킷의 번호를 1천 번이라 지정할 테니 수신하면 1천 번이라는 넘버가 부여된 패킷을 잘 받았다고 답해주세요 라는 의미락 ㅗ한다.
2. B가 A에게 : 패킷의 SEQ는 2000번이고 ACK는 1001번이다 = SEQ가 1천 번인 패킷 잘 받았으니 다음 데이터 전송 시에는 1001번으로 줘라는 의미이고 SEQ 2000은 마찬가지로 수신 확인을 위해 부여한 것이다.
3. A가 B에게 : SEQ2000인 패킷 잘 받았으니 다음번에는 SEQ가 2001 패킷을 기대한다는 의미이고
마지막으로 전송하는 패킷은 오로지 수신 확인 용도로서 의미를 지닌다
데이터 송 수신
연결 설정이 완료되면 클라이언트와 서버는 데이터를 주고받을 수 있습니다. 데이터 전송 단계에서는 양측의 송신 버퍼와 수신 버퍼를 이용하여 데이터가 안정적으로 전송됩니다. 클라이언트는 데이터를 송신 버퍼에 저장하고, 서버는 수신 버퍼에서 데이터를 읽어 처리합니다. 이 단계는 송신과 수신이 반복되면서 양방향 통신이 이루어집니다.
A에서 B로 100바이트 전송하면서 SEQ를 1301로 전송하면 다음 번 패킷을 전송할 때는 1302가 아니라 100바이트 때문에
SEQ 1401이 되어야 한다.
바이트 수만큼 SEQ를 증가시켜 주는 이유는 정확하게 몇 바이트가 전송되었는지 확인을 할 수 없기 때문이다.
그리고 A가 B로 100바이트 전송하면서 1401로 전송해 주는데 중간에 소멸되면
일정시간이 지나도 ACK가 돌아오지 않는다면 재전송을 하게 된다.
결국 B는 패킷을 수신하고 ACK 1501을 전송하게 되는 것이다.
연결 종료
데이터 통신이 끝나면 연결을 종료하기 위해 "4-way handshake" 프로토콜이 사용됩니다.
- 클라이언트는 더 이상 데이터를 전송하지 않을 것임을 나타내기 위해 FIN 패킷을 전송합니다.
- 서버는 FIN (finish의 줄임 말) 패킷을 받고, 더 이상 수신할 데이터가 없음을 의미하는 ACK 패킷을 보냅니다.
(B는 아직 전송해야 할 데이터가 남아 있는 상황이라고 생각해 볼 수 있다) - 서버가 자신의 데이터 전송을 완료했다면, 종료를 위해 FIN 패킷을 전송합니다.
- 클라이언트는 FIN 패킷을 받고, 확인을 의미하는 ACK 패킷을 보내면서 연결 종료가 완료됩니다.
해당 게시글은 서적을 통한 개인 공부 목적으로 작성된 글입니다.
출처 : TCP/IP 소켓 프로그래밍 서적
'TCP_IP 소켓 프로그래밍' 카테고리의 다른 글
윈도우즈 기반의 쓰레드 동기화 (0) | 2023.07.04 |
---|---|
UDP 기반 서버/클라이언트 (0) | 2023.06.16 |
TCP 기반 서버 / 클라이언트에 대한 이해1 (0) | 2023.06.13 |
IP 주소 체계와 데이터 정렬의 이해 (0) | 2023.06.12 |
소켓 프로그래밍에서 프로토콜이란 (1) | 2023.06.09 |
댓글