본문 바로가기
Programming/열혈 TCP, IP 소켓 프로그래밍(저자 윤성우)

Ch 05. TCP 기반 서버/클라이언트 2

by minjunkim.dev 2020. 8. 6.

    모든 내용은 [윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!


# 에코 서버는 문제가 없고, 에코 클라이언트만 문제가 있나요?

- Ch 04에서 구현한 에코 서버/클라이언트에서 우리는 클라이언트에 문제가 있음을 공부했다.

- 그렇다면 정확히 왜 에코 클라이언트에 문제가 있는걸까? 코드를 보며 살펴보자

- echo_server.c 중 일부

while((str_len=read(clnt_sock,message,BUF_SIZE))!=0) // 클라이언트로부터 수신한 문자열이 있을때에
    write(clnt_sock,message,str_len); // 그 문자열을 그대로 에코(그러나, 문자열 끝 널문자는 제외!)

- echo_client.c 중 일부

write(sock,message,strlen(message)); // 서버로 문자열(널문자 포함) 전송
str_len=read(sock,message,BUF_SIZE-1); // 서버에서 에코한 문자열 수신

# 먼저 TCP 소켓 기반이므로 "데이터의 경계가 없음"을 기억하자.

1) 클라이언트가 서버로 문자열을 write 함수 한번을 호출하여 전달

2) 서버가 이 문자열을 while문을 통해 read하여 읽고, write하여 다시 클라이언트로 전달

(반복문을 통해 구현했으므로, 서버가 클라이언트가 보낸 문자열을 모두 수신하고 다시 전달함이 보장됨)

3) 클라이언트는 서버가 에코한 문자열을 read 함수 "한번 호출"로 수신하고 있는데,

이는 TCP 소켓의 특성상, 서버가 에코한 문자열을 수신했음을 보장하지 못함

=> 여기서 클라이언트가 문제임을 알 수 있음

 

# 이를 해결하기 위해서 클라이언트가 모든 문자열을 수신할 때까지

read 함수를 반복 호출하는 것이 하나의 대안이 될 수 있음

 

echo.client2.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc,char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len, recv_len, recv_cnt;
    struct sockaddr_in serv_adr;

    if(argc!=3) // 실행파일의 경로 / IP / PORT 번호를 입력받아야 함
    {
        printf("Usage : %s <IP> <port>\n",argv[0]);
        exit(EXIT_FAILURE);
    }

    sock=socket(PF_INET,SOCK_STREAM,0); // TCP 소켓 생성
    if(sock==-1)
        error_handling("socket() error");

    // 서버 주소정보의 초기화
    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_adr.sin_port=htons(atoi(argv[2]));

    // 주소정보를 기반으로 서버에 연결요청
    // 이 때, 명시적인 클라이언트 소켓이 되며 주소정보를 할당함
    if(connect(sock,(struct sockaddr*)&serv_adr,sizeof(serv_adr))==-1)
        error_handling("connect() error!");
    else
        puts("Connected................");
    
    while(true) // q나 Q를 입력할 때까지 문자열 전송
    {
        fputs("Input message(Q to exit): ",stdout);
        fgets(message,BUF_SIZE,stdin);

        if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
            break;
        
        
        str_len=write(sock,message,strlen(message)); // 서버로 문자열(널문자 포함하지 않고) 전송
        
        recv_len=0;
        while(recv_len<strlen) // 자신이 전송한 문자열 길이보다 수신한 문자열 길이가 작을때까지
        {
        	recv_cnt=read(sock,message,BUF_SIZE-1); // 서버에서 에코한 문자열 수신
            // 단, 서버에서는 널문자를 제외하고 보냄을 유의
            if(recv_cnt==-1)
            	error_handling("read() error!");
            recv_len+=recv_cnt; // 수신한 문자열 길이만큼 추가
        }
        message[recv_cnt]='\0'; // 서버에서 수신한 문자열 맨 뒤에 널문자 추가
        printf("Message from server: %s",message);
    }
    close(sock);

    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

# 에코 클라이언트 이외의 경우에는? 어플리케이션 프로토콜의 정의

- 에코 클라이언트의 경우 수신할 데이터의 크기를 미리 파악이 가능

(자기가 서버에 송신한 데이터 크기이므로) => 해결이 쉬움
- 그러나, 아닌 경우가 더 많다. 이럴 때는 어떻게 해야할까?
=> 이럴 때 필요한 것이 어플리케이션 프로토콜의 정의

 

# 어플리케이션 프로토콜

데이터 송수신 과정에서, 데이터의 끝을 파악할 수 있는 약속(프로토콜)을 별도로 정의해서
데이터의 끝을 표현하거나, 송수신될 데이터의 크기를 미리 알려줘서 그에 따른 대비가 가능하게 하는 것.

즉, 목적에 맞는 프로그램의 구현에 따라서 정의하게 되는 약속

 

# 실제 프로그램의 구현을 위해서는 자세하고 정확한 프로토콜이 정의되어야 함
그만큼 네트워크 프로그래밍에서는 어플리케이션 프로토콜의 정의가 중요
프로토콜만 잘 정의하면, 구현은 큰 문제가 되지 않음


# 계산기 서버, 클라이언트의 예

1. 예제 구현에 필요한 최소한의 프로토콜 정의

- 클라이언트는 서버에 접속하자마자 피연산자의 개수정보를 1바이트 정수형태로 전달

- 클라이언트가 서버에 전달하는 정수 하나는 4바이트로 표현

- 정수를 전달한 다음에는 연산의 종류를 전달, 연산정보는 1바이트로 전달

- 문자 +, -, * 중 하나를 선택해서 전달

- 서버는 연산결과를 4바이트 정수의 형태로 클라이언트에 전달

- 연산결과를 얻은 클라이언트는 서버와의 연결을 종료

 

2. close 함수가 호출되면, 상대방에게 EOF가 전달된다!

3. 데이터의 송수신을 위한 메모리 공간은 배열을 기반으로 생성하는 것이 좋음.
데이터를 누적해서 송수신해야하기 때문임.

4. 하나의 배열에 다양한 종류(타입)의 데이터를 저장해서 전송하려면, char형 배열을 이용하자.

그리고 포인터변환을 통해 적절히 이용하자.

5. TCP는 데이터의 경계가 존재하지 않기 때문에, 한번의 write 함수 호출을 통해서 묶어 보내도 되고,
여러번의 write 함수 호출을 통해 나눠 보내도 된다.

 

# op_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
#define RLT_SIZE 4
#define OPSZ 4
void error_handling(char *message);

int main(int argc,char *argv[])
{
    int sock;
    char opmsg[BUF_SIZE]; // 데이터의 송수신을 위한 메모리 공간을 배열로 생성(데이터 누적 송신을 위함임)
    int result, opnd_cnt, i;
    struct sockaddr_in serv_adr;

    if(argc!=3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    sock=socket(PF_INET,SOCK_STREAM,0); // TCP 소켓 생성
    if(sock==-1)
        error_handling("socket() error");

    /*
    서버 주소정보 초기화
    */
    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_adr.sin_port=htons(atoi(argv[2]));

    /*
    서버 주소정보를 기반으로 연결요청
    이 때, 비로소 클라이언트 소켓이 됨
    서버의 연결요청 대기큐에 들어가게 됨
    (단, 서버의 listen 함수 호출 이후에 호출 되어야 함)
    */
    if(connect(sock,(struct sockaddr*)&serv_adr,sizeof(serv_adr))==-1)
        error_handling("connect() error!");
    else
        puts("Connected................");
    
    fputs("Operand count: ",stdout);
    scanf("%d", &opnd_cnt); // 피연산자 개수 입력받기
    opmsg[0]=(char)opnd_cnt; // 피연산자 개수를 1바이트 형태로 전달하기 위함임

    for(i=0;i<opnd_cnt;++i)
    {
        printf("Operand %d: ",i+1);
        scanf("%d",(int*)&opmsg[i*OPSZ+1]); // 피연산자 정수는 4바이트로 전달하기 위함임
    }

    fgetc(stdin); // '\n'(개행문자)를 입력버퍼에서 읽어 들이기 위함임, getchar()도 가능
    fputs("Operator: ",stdout);
    scanf("%c",&opmsg[opnd_cnt*OPSZ+1]); // 연산의 종류를 1바이트 형태로 전달하기 위함임
    write(sock,opmsg,opnd_cnt*OPSZ+2); // 계산 관련 정보를 한번에 묶어 서버로 전달
    read(sock,&result,RLT_SIZE); // 서버에서 전달하는 계산결과는 4바이트이므로 read 한번으로 모두 수신 가능

    printf("Operation result: %d\n",result);
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

 

# op_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
#define OPSZ 4
void error_handling(char *message);
int calculate(int opnum, int opnds[], char oprator);

int main(int argc,char *argv[])
{
    int serv_sock, clnt_sock;
    char opinfo[BUF_SIZE];
    int result, opnd_cnt, i;
    int recv_cnt, recv_len;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;

    if(argc!=2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    serv_sock=socket(PF_INET,SOCK_STREAM,0); // TCP 소켓 생성
    if(serv_sock==-1)
        error_handling("socket() error");

    /* 서버 주소정보 초기화 */
    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");

    /* listen 함수 호출 이후로 서버소켓이 되며,
    연결요청 대기큐가 생성 => 클라이언트의 연결요청이 가능해짐
    */
    if(listen(serv_sock, 5)==-1)
        error_handling("listen() error");
    
    clnt_adr_sz=sizeof(clnt_adr);

    for(i=0;i<5;++i)
    {
        opnd_cnt=0; // 피연산자 개수
        /* 연결요청 대기큐에 있는 클라이언트에 대해
        순서대로 연결을 수락, 클라이언트와의 데이터 송수신을 위해 새로운 소켓 생성 */
        clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_adr,&clnt_adr_sz);

        read(clnt_sock,&opnd_cnt,1); // 피연산자 개수를 수신(1바이트로 한번의 read 함수 호출이면 충분)

        recv_len=0; // 모든 피연산자와 연산 종류의 총 바이트 수
        while((opnd_cnt*OPSZ+1)>recv_len) // 피연산자와 연산 종류를 모두 읽어들일 때까지 read 함수 반복호출
        {
            recv_cnt=read(clnt_sock,&opinfo[recv_len],BUF_SIZE);
            recv_len+=recv_cnt;
        }
        
        result=calculate(opnd_cnt,(int*)opinfo,opinfo[recv_len-1]);
        // 피연산자 개수, 피연산자 정보, 연산 종류를 인자로 전달

        write(clnt_sock,(char*)&result,sizeof(result)); // 계산결과를 클라이언트에 전달
        close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(EXIT_FAILURE);
}

int calculate(int opnum, int opnds[], char oprator)
{
    int result=opnds[0], i;
    switch(oprator)
    {
    case '+':
        for(i=1;i<opnum;++i)
            result+=opnds[i];
        break;
    case '-':
        for(i=1;i<opnum;++i)
            result-=opnds[i];
        break;
    case '*':
        for(i=1;i<opnum;++i)
            result*=opnds[i];
        break;
    }
    return result;
}

# TCP 소켓에 존재하는 입출력버퍼

- write 함수가 호출되는 순간, 데이터는 바로 전송되는 것이 아님 => 데이터가 출력버퍼로 이동함

- read 함수가 호출되는 순간, 데이터는 바로 수신되는 것이 아님 => 입력버퍼에 저장된 데이터를 읽음

 

# 입출력버퍼의 특성

1) TCP 소켓 각각에 별도로 존재
2) 소켓 생성시 자동으로 생성됨
3) 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이루어짐("데이터 송신을 보장함")
4) 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸되어버림("데이터 수신을 보장하지 않음")

 

# TCP의 슬라이딩 윈도우(Sliding Window) 프로토콜

- TCP 소켓은 데이터의 흐름까지 컨트롤(흐름제어)한다.

- 따라서 데이터를 송수신할때 입력버퍼의 크기를 초과하는 분량의 데이터 전송은 발생하지 않는데,

이 역할을 하는 것이 "슬라이딩 윈도우" 프로토콜

 

# 소켓은 전 이중(full-duplex) 방식으로 동작 => 양방향으로 데이터 송수신 가능


# TCP 소켓의 생성에서 소멸의 과정(TCP 흐름제어(Flow Control))

1) 상대 소켓과의 연결

2) 상대 소켓과의 데이터 송수신

3) 상대 소켓과의 연결종료

 

1. TCP의 내부 동작원리1 : 상대 소켓과의 연결

- 상대 소켓과의 데이터 송수신 준비과정

- Three-way handshaking(총 3번의 대화를 서로 주고받음)
- SYN(동기화 메시지), ACK(응답 메시지)

e.g.

1) SYN => SEQ : 1000(A소켓이 패킷에 1000번이라는 번호를 부여하여 B소켓에 전달), ACK : -
2) SYN + ACK => SEQ : 2000(B소켓이 패킷에 2000번이라는 번호를 부여하여 A소켓에 전달),

ACK : 1001(전에 전달한 SEQ가 1000인 패킷을 잘 받았으니 SEQ가 1001인 패킷을 보내라는 의미)
3) ACK => SEQ : 1001(B소켓에게 ACK로 1001을 받았으므로 SEQ로 1001을 전달),

ACK : 2001(전에 전달한 SEQ가 2000인 패킷을 잘 받았으니 SEQ가 2001인 패킷을 보내라는 의미)
=> 이렇게 "패킷에 번호를 부여해서 확인하는 절차"를 거치기 때문에 손실된 데이터의 확인 및 재전송이 가능
=> 때문에 TCP는 손실 없는 데이터의 전송을 보장

 

2. 상대 소켓과의 데이터 송수신

- ACK 번호 = SEQ 번호 + 전송된 바이트 크기 + 1(다음번에 전달될 SEQ의 번호를 알리기 위해 1을 더함)

- TCP 소켓은 ACK 응답을 요구하는 패킷 전송 시에 타이머를 동작시킴
- SEQ를 보내고 일정시간(타이머) 안에 상대 소켓으로부터 ACK를 받지 못한다면 재전송을 진행

 

3. 상대 소켓과의 연결종료(상호간에 연결종료 합의과정을 거침)
- Four-way handshaking
- 패킷 안에 삽입되어 있는 FIN은 종료를 알리는 메시지를 의미
- 상호간의 FIN 메시지를 한번씩 주고 받고서 연결이 종료됨

e.g.
1) 소켓 A가 종료 메시지(FIN)를 전달
2) 소켓 B가 이를 수신했다고 알림
3) 소켓 B도 종료 메시지(FIN)를 전달
4) 소켓 A가 이를 수신했다고 알리고 연결종료


 

[출처] : 윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어