Jungle

[TIL] C 네트워크 프로그래밍 - Echo 서버 작동 방식 분석

손가든 2023. 11. 21. 13:11

오늘은 이전 포스팅에서 만든 서버 호스트와 클라이언트 호스트의 소켓을 실제로 네트워크 통신해볼 것이다.

 

Echo 서버를 생성하여 클라이언트와 서버와 연결하여 정상 작동하는지 확인하고

 

localhost가 아닌 실제 EC2 서버와도 연결이 가능한지 확인해보겠다.

 


Echo 클라이언트

빠르게 코드의 역할과 연결 방식을 확인 해보자.

 

#include "../csapp.h"
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int main(int argc, char **argv){
    int clientfd;
    char *host, *port, buf[MAXLINE];
    rio_t rio;

    if (argc != 3){
        fprintf(stderr, "usage: %s <host> <port> \n", argv[0]);
        exit(0);
    }
    host = argv[1];
    port = argv[2];

    clientfd = Open_clientfd(host,port);
    Rio_readinitb(&rio, clientfd);

    while (Fgets(buf, MAXLINE, stdin) != NULL) {
        Rio_writen(clientfd, buf , strlen(buf));
        Rio_readlineb(&rio, buf, MAXLINE);
        Fputs(buf,stdout);
    }
    Close(clientfd);
    exit(0);
}

 

클라이언트는 연결할 host의 IP 주소와 해당 연결 호스트가 열어놓은 포트를 입력하여 연결요청을 수행해야 한다.

 

이전 포스팅에서 확인한 open_client() 함수에 대문자로 변경하여 예외처리까지 수행해주는 함수를 실행하여 client 파일 디스크립터를 생성한다.

 

이후 Rio_readinitb() 에 연결 식별자를 인자로 넣어 read와 write를 수행할 rio 구조체를 초기화한다.

 

이후 Rio함수로 write를 쓰고 readline으로 서버가 쓴 데이터를 읽어 buf 변수로 옮긴다.

(Rio가 무엇인지 전혀 감이 안온다면 밑의 Rio에 대한 내용부터 읽어보자.)

 

최종적으로 마지막 줄에서 fput()를 통해 터미널에 서버가 보낸 결과값을 출력한다.

 

 

해당 과정에서 통신이 직접적으로 일어난 코드는 Rio write와 read이다.

 

소켓은 마치 현실세계의 팩스와 같다.

 

클라이언트에서 write을 수행했을 때, 서버는 동시에 해당 값을 읽어 echoServer를 수행했다.

 

클라이언트는 서버가 수행하는 시간동안 readline 코드를 수행하지 않고 블록되어 서버가 write하여 팩스를 보내길 기다리고 있다.

 

만약 read할 데이터를 받게되면 해당 값을 받아 클라이언트는 본인의 터미널에 직접 서버가 보낸 값을 출력하는 방식으로 상호작용이 일어나는 것이다.

 

 


 

Echo 서버

 

에코 서버가 수행하는 코드를 확인 해보자.

 

#include "../csapp.h"
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

void echo(int connfd);

int main(int argc, char **argv){
    int listenfd , connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    char client_hostname [MAXLINE], client_port[MAXLINE];

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

    listenfd = Open_listenfd(argv[1]);
    while(1){
        clientlen = sizeof(struct sockaddr_storage);
        connfd = Accept(listenfd , (SA *)&clientaddr, &clientlen);
        Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE, client_port, MAXLINE, 0);
        printf("Connected to (%s, %s)\n", client_hostname, client_port);
        echo(connfd);
        Close(connfd);
    }
    exit(0);
}

void echo(int connfd){
    size_t n ;
    char buf[MAXLINE];
    rio_t rio;

    Rio_readinitb(&rio, connfd);
    while((n = Rio_readlineb(&rio , buf, MAXLINE)) != 0){
        printf("server received %d bytes\n", (int)n);
        Rio_writen(connfd,buf,n);
    }
}

 

이 파일은 통신을 수행하는 main과 클라이언트의 요청을 수행하는 내부 함수인 echo로 이루어져 있다.

 

먼저 listenfd를 통해 argv[1] 속에 있는 포트로 connect를 받을 준비를 한다.

 

이후 while문을 통해 클라이언트 들에게 통신을 받을 준비를 한다.

 

while문 안에 있는 accept가 실제 connect와 listen을 연결하는 수행이 되고 이를 통해 듣기 식별자인 connfd를 생성한다.

 

서버는 이 connfd를 통해 클라이언트의 요청을 read하며 , 반대로 클라이언트에게 보낼 응답을 write한다.

 

accept 함수의 두번째 인자는 연결된 클라이언트의 주소를 담는 구조체이다.

 

현재 이 코드에서 getnameinfo 함수를 사용하여 클라이언트의 ip를 host명과 port로 변환한 뒤 확인을 위해 출력하고 있다.

 

 

이후 실행하는 echo 함수를 확인해 보자.

 

에코 함수에서도 클라이언트에서와 똑같이 rio_init함수에 식별자를 넣어 rio 구조체를 초기화하고 있다.

 

이후 클라이언트에서 write한 값을 입력받아 버퍼의 바이트를 출력해주고, 해당 값을 다시 서버에서도 write하여 클라이언트에 응답한다.

 

 


 

Rio ? (robust I/O)

 

Echo 서버와 클라이언트 코드를 작성하면서 처음 본 실행함수가 나타났다.

 

바로 Rio 구조체와 rio_read , rio_write이다.

 

 

rio 함수(robust I/O)안전하게 I/O를 수행하기 위해 만들어진 함수로, 일반적으로 컴퓨터 네트워크에서 사용한다.

 

이 함수는 도중에 중단되는 상황이 발생할 수 있는 네트워크에서 I/O 동작이 중단되더라도 다시 재개하는 것을 안전하게 처리하기 위해 사용한다고 한다.

 

만약 입출력이 부분적으로 발생할 수 있는 Stream 형식의 네트워크 상황에서 이를 사용자에게는 드러내지 않고, 완전한 데이터만 전송하게 한다.

 

따라서 이 Rio 함수를 사용하여 예외적인 상황에서의 데이터 일관성을 유지할 수 있다.

 


 

EC2 서버와 연결하여 Echo 서버 네트워크 테스트

 

현재 내가 가진 EC2 인스턴스의 우분투 서버에서 echoServeri 를 실행하고 로컬 호스트에서 echoClient를 실행하여 서로가 연결이 되는지 확인해보자.

 

그전에 먼저 클라이언트와 서버가 연결할 포트를 정하고, 그 해당 포트의 inbound를 EC2 서버에서 열어주어야 한다.

 

inbound는 간단하게 말하면 클라우드 서비스의 방화벽과 같은데, 일반적으로 설정하지 않으면 모든 포트가 막혀있어서 접근할 수 없다.

 

따라서 만약 우리가 8000번 포트를 사용하여 연결하려고 하면 inbound에서 8000번 포트로 내 로컬 IP가 들어오는 것을 허용하도록 설정해야 한다.

 

 

소스 정보에는 내 IP로 설정하도록 한다.(여기서는 가려놓음)

 

 

이후 우분투 서버에 접속하여 먼저 서버를 listen 상태로 두기 위해 실행한다.

 

 

8000번 포트를 main 함수의 인자로 전달하여 해당 포트로 listen을 하겠다고 열어놓은 것이다.

 

이후 로컬에서 클라이언트를 해당 우분투 public IP와 8000번 포트로 접속한다.

로컬 클라이언트 호스트 (왼) / 우분투 서버 호스트 (오)

 

그럼 동시에 ubuntu 서버에 connect된 정보가 출력되게 된다.

 

에코 클라이언트 (왼) / 에코 서버 (오)

 

따라서 클라이언트에서 hello World ! 를 출력하니

 

서버에서 바이트의 크기를 읽고 서버 단에서 출력한 뒤 write를 수행하여

 

다시 클라이언트 터미널에 출력된 것을 확인할 수 있다.

 


 

 

Telnet 으로 echoServer 원격 접속하기

 

telnet이 뭐지?

 

telnet이란 원격 호스트 또는 원격 컴퓨터에 터미널 로그인 세션을 열 수 있게 해주는 네트워크 프로토콜이다.

 

이는 특히, 텍스트 기반의 사용자 인터페이스를 제공하며, 클라이언트와 서버 간에 텍스트 기반의 통신을 가능하게 한다.

 

Telnet은 일반적으로 TCP/IP 프로토콜을 사용하고 포트의 명시가 없다면 보통 23번을 사용한다.

 

이는 데이터를 암호화하지 않고 평문으로 전송하기 때문에, 보안의 취약성이 있어서 원격 제어를 사용할 때는 ssh를 사용하는 추세이다.

 

간단하게 말하면 암호화하지 않고 평문으로 통신하는 ssh == telnet이라고 생각해도 좋다.

 

 

자 이제 텔넷을 터미널에 다운로드 받은 뒤 접속해보자.

 

$ brew install telnet

 

mac에서는 homebrew를 설치하여 이를 통해 telnet을 설치하면 된다.

 

설치가 완료되었다면 일단 ubuntu 서버에서 echoServeri.c를 실행시키고 터미널로 telnet을 접속해보자.

 

 

telnet를 echoclient를 실행하는 대신 사용하여 Server와 연결한 것을 확인할 수 있다.