Computer Science/네트워크 (Network)

네트워크 프로그래밍 | Proxy Lab | echo 서버 구현 (소켓 프로그래밍 공부)

hyuga_ 2023. 11. 21. 16:51

 

echo 서버 - 전체 작동 구조도

server 파일 분석

echoserveri.c 전체 코드

중간중간에 혼자 테스트할 목적으로 쓴 printf가 있으니, 그냥 무시하면 된당

#include "csapp.h"

// echo: 클라이언트 요청을 처리함
void echo(int connfd);

// main: 서버 프로그램의 entry point
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]); // argv[0] = ./echoserveri | argv[1] = 포트번호
    printf("listening file descriptor: %d\n", listenfd);
    while (1) {
        // 클라이언트 요청을 처리하기 위한 무한 루프
        clientlen = sizeof(struct sockaddr_storage);
        printf("연결 요청 대기 중\n");
        connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
        printf("연결 요청 수락, 연결 소켓(connfd) 생성\n");
        Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE,
                    client_port, MAXLINE, 0);
        printf("Connectd 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);
    }
}

 

argv[]

main 함수를 실행할 때, 터미널에 뭐라고 입력받았는지가 자동으로 여기에 담긴다.

만약 ./echoserveri 10000 으로 서버 파일을 실행했다면, 

  • argv[0] = ./echoserveri
  • argv[1] = 포트번호(10000)

가 된다.

 

open_listenfd()

open_listenfd()는 연결 요청을 기다리는 listening 소켓을 연다.

csapp.c 파일 내에 정의되어 있으며, 소켓 통신을 위한 내장 함수들도 포함한다. 

 

1. getaddrinfo()

 

  1. 서버에서는 getaddrinfo() 함수를 통해 자신이 listening할 주소와 포트 정보를 설정한다. 
  2. 이 정보를 addrinfo(= ai) 구조체에 담고, 반환한다. 
  3. 이후에 이는 소켓을 열고, 연결하기 위해 사용된다. 
  4. 또한 도메인 주소를 IP 주소로 변환하는 DNS 조회도 담당한다. (IPv4, IPv6 모두 처리 가능)
// getaddrinfo 구조체
int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);

/*
    node: 호스트 이름 또는 IP 주소를 나타내는 문자열
    service: 서비스 이름("http" 등) 또는 포트 번호를 나타내는 문자열
    hints: 원하는 주소 타입과 프로토콜에 대한 옵션을 지정하는 addrinfo 구조체를 가리킨다
    res: 결과 주소 정보를 가리키는 addrinfo 구조체의 리스트에 대한 포인터
*/

 

2. socket()

 

새로운 소켓을 생성한다. 

int sockfd = socket(domain, type, protocol);

/*
domain: 소켓이 사용할 프로토콜 체계를 지정한다. 예를 들어, AF_INET은 IPv4 인터넷 프로토콜을, AF_INET6은 IPv6를 사용한다.
type: 소켓의 타입을 지정한다. (SOCK_STREAM = TCP 소켓, SOCK_DGRAM = UDP 소켓)
protocol: 특정 프로토콜을 명시한다. 보통 0으로 설정하여 기본 프로토콜을 사용한다.
*/

 

3. bind()

 

소켓에 주소(포트 번호, IP 주소)를 할당한다. 서버가 특정 포트에서 클라이언트의 요청을 기다리기 위해 사용된다.

int status = bind(sockfd, (struct sockaddr *)&address, sizeof(address));

/*
sockfd: socket() 함수를 통해 생성된 소켓의 파일 디스크립터
(struct sockaddr *)&address: 소켓에 바인딩할 주소를 나타내는 sockaddr 구조체에 대한 포인터. 주로 sockaddr_in 구조체를 사용하여 IPv4 주소 정보를 설정한다.
sizeof(address): 주소 구조체의 크기
*/

 

4. listen()

 

소켓을 연결 요청을 받을 수 있는 상태(listneing 상태)로 만든다. 이제 서버는 클라이언트로부터 연결 요청을 받을 준비가 되었다. 

int status = listen(sockfd, backlog);
/*
sockfd: bind() 함수로 주소가 할당된 소켓의 파일 디스크립터
backlog: 소켓이 받아들일 수 있는 대기 중인 연결 요청의 최대 수. 이 값은 연결 대기 큐의 크기를 결정한다고 함.
*/

 

 

accept()

소켓 통신을 위한 함수이다. 다음과 같은 역할을 한다.

  1. 다른 host의 연결 요청이 listening 소켓으로 들어오기를 기다린다.
  2. 연결 요청이 오면, 3-way handshake를 내부적으로 수행한다.
  3. 연결 소켓을 만들고, 이에 대한 파일 디스크립터(connfd)를 반환한다. 
    • connfd는 해당 연결정보를 담고있는 소켓을 참조한다. 
    • 해당 연결에 대한 각종 조작은 앞으로 connfd를 통해 이루어진다.

 

 

해당 디렉토리에 있는 socket 헤더에 소켓 통신을 위한 여러 함수(accept, bind, connect, recv, send ..)가 선언되어 있는 것을 확인할 수 있었다. 

 

 

echo()

csapp.c에 정의된 rio_readinit(), rio_readline(), rio_writen() 함수를 통해 echo 서버의 목적에 맞는 통신을 수행한다. 

 

rio는 Robust I/O의 약자이다. Robust는 네트워크 프로그래밍에서 사용되는 I/O 방식으로, 기본적인 입출력의 안정성을 강화한 Wrapper 함수들이다. 이 함수들의 역할을 해석할 때는 그냥 rio를 빼고 해석하면 된다. 

 

Q. RIO 버퍼란?
다음 내용 참고 -> 네트워크 모델 | (6) TCP/IP 데이터 흐름 더 깊게 이해하기 (feat. Buffered I/O)
 

네트워크 모델 | (6) TCP/IP 데이터 흐름 더 깊게 이해하기 (feat. Buffered I/O)

앞에서(네트워크 모델 | (5) 데이터 단위와 흐름 이해하기) TCP 통신에서의 데이터 흐름에 대한 큰 그림을 그려보고, 각 단계별 데이터를 뭐라고 부르는지 알아보는 시간을 가졌었다. 이번에는 이

hyuga.tistory.com

 

 

rio_readinit()

RIO 버퍼 정보(= rio_t 구조체) 초기화, 연결 소켓 디스크립터(connfd)와 rio 버퍼 연결을 수행한다.

rio_t 구조체는 RIO 버퍼를 관리하고 추적할 수 있도록 하는 구조체이다. (남은 버퍼 크기, 읽기/쓰기할 위치 등)

void rio_readinitb(rio_t *rp, int fd);

/*
rp: RIO 버퍼 구조체(rio_t)에 대한 포인터
fd: 데이터를 읽을 소켓의 파일 디스크립터
*/

 

rio_readline()

버퍼를 한 줄 read(= receive)한다. 한 줄이 최대 길이보다 큰 게 아니라면, 개행문자(\n)가 나올 때까지 읽는다. 

(그래서, 서버는 개행문자까지 포함해서 클라이언트가 입력한 데이터보다 1 byte를 더 읽게 된다.)

ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);

/*
rp: RIO 버퍼 구조체(rio_t)에 대한 포인터
usrbuf: 읽은 데이터를 저장할 버퍼
maxlen: 읽을 수 있는 최대 바이트 수
*/

 

rio_writen()

버퍼의 내용을 소켓에 write(= send)한다. 지정된 byte 만큼 데이터를 전송할 때까지 반복적으로 쓰기 작업이 수행된다. 

ssize_t rio_writen(int fd, void *usrbuf, size_t n);
/*
fd: 데이터를 쓸 소켓의 파일 디스크립터
usrbuf: 쓸 데이터가 저장된 버퍼
n: 쓸 바이트 수
*/

 

client 파일 분석 

echoclient.c 전체 코드

#include "csapp.h"

// main: 클라이언트 프로그램의 entry point
int main(int argc, char **argv)
{
    // 클라이언트 파일 디스크립터 선언
    int clientfd;
    char *host, *port, buf[MAXLINE]; // MAXLINE = 8192
    /* 서버는 최대 4096 byte 까지밖에 못받음*/
    rio_t rio; // Robust I/O = 네트워크 프로그래밍에서의 입출력 방식

    if (argc != 3){ // 호스트, 포트, 프로그램 이름 3개의 인자 필요
        fprintf(stderr, "usage: %s <host> <port> \n", argv[0]);
        exit(0);
    }
    host = argv[1];
    port = argv[2];

    // 클라이언트 파일 디스크립터 열기
    clientfd = Open_clientfd(host, port); // p.903 -> 연결될 때까지 리스트의 주소 다 테스트
    Rio_readinitb(&rio, clientfd); // RIO 버퍼 초기화. 

    // 사용자의 입력을 읽고 서버로 전송
    while (Fgets(buf, MAXLINE, stdin) != NULL){ // Fgets() = C 내장 함수 (readline)
        Rio_writen(clientfd, buf, strlen(buf));
        Rio_readlineb(&rio, buf, MAXLINE);
        Fputs(buf, stdout);
    }
    printf("gggg");
    Close(clientfd);
    exit(0);
}

 

 

서버 파일을 이해했다면 클라이언트 파일의 작동 원리도 자연스레 알 수 있을 것이다.

클라이언트는 비교적 간단하게, 

  1. 소켓 열기
  2. 입력 받기
  3. 이 데이터를 서버측에 송신
  4. 서버측으로부터 오는 echo를 수신 및 출력

으로 구성되어 있다. 

 

Telnet을 통한 서버 테스트

./echoclient 파일을 실행하지 않고, Telnet을 통해 먼저 서버를 테스트해볼수도 있다. 네트워크 프로그래밍에서 서버 애플리케이션을 먼저 개발하고, telnet을 활용해서 서버를 테스트해본 다음, 클라이언트를 마저 완성하는 식의 접근도 많다고 한다. 

 

내가 만든 클라이언트 파일을 실행하는 게 아니라, 이미 만들어진 기성품(?)을 사용하여 클라이언트 접속을 수행하는 것이기 때문에, 해당 컴퓨터에 telnet이 설치되어 있기만 하면 어디서든 실행할 수 있다. 

 

Telnet이란?

Telnet 클라이언트

  • Telnet은 클라이언트 프로그램이자, TCP/IP 네트워크를 통해 다른 원격 서버에 접속하고, 원격 컴퓨터에서 명령을 실행할 수 있게 해주는 클라이언트-서버 프로토콜이다. Telnet 프로토콜은 애플리케이션 계층에 위치한다. 네트워크를 통한 원격 터미널 접속을 제공한다.
  • 예전에는 OS에 기본적으로 탑재되어 있었다. 

역사적으로, Telnet은 네트워크 상의 다른 컴퓨터에 원격으로 로그인하고, 텍스트 기반 인터페이스를 통해 작업을 수행하는 데 많이 사용되었다고 한다. 이러한 사용은 인터넷이 상업화되기 이전인 1970년대부터 1990년대 초반까지 특히 두드러졌다. 

 

Telnet의 리즈 시절

  1. 초기 컴퓨터 네트워크에서, telnet은 원격 서버에 접속하여 작업을 수행하는 주요 수단이었다. 유저는 자신의 터미널에서 telnet 명령을 사용하여 대학이나 연구소의 메인프레임, 미니컴퓨터 등에 접속했다. 
  2. 네트워크 프로토콜을 개발하거나 테스트할 때, telnet을 통해 간단하게 테스팅과 디버깅을 했다. HTTP, SMTP 등과 같은 텍스트 기반 프로토콜의 테스트에 주로 사용되었다. 
  3. 대학교 및 연구 기관에서, telnet을 사용하여 원격 서버의 리소스에 접근하고 컴퓨터과학 관련 연구를 수행했다고 한다. 

그러나 현재는 일반적 목적으로 telnet을 사용하지는 않는다. 왜냐하면 데이터를 암호화하지 않고 전송하기 때문에 보안상 취약하기 때문이다. 1990년대 후반부터 보안이 강화된 SSH(Secure Shell) 프로토콜이 널리 사용되기 시작하면서, telnet의 사용은 크게 감소했다.

 

현재는 telnet 대신 SSH, VPN, 웹 기반 인터페이스 등 보안성이 높고 사용하기 쉬운 도구들이 주로 사용된다. 물론, 네트워크 서비스의 테스팅이나 디버깅 도구로써 telnet의 가치는 여전히 있다. 물론 Telnet은 암호화되지 않은 통신을 사용하므로, 민감한 정보를 전송할 때는 적합하지 않다. 평소에는 telnet 대신 SSH 같은 보안된 연결 방식을 사용하는 것이 권장된다. 

 

이러한 이유로, 예전에는 telnet이 unix 계열 OS나 windows에 기본 탑재되어있었지만 최신 버전의 macOS에서는 기본적으로 설치되어 있지 않다. 따라서 macOS에서는 터미널에 바로 telnet 명령어를 쳐도 인식되지 않는다. 그래서 만일 로컬 컴퓨터에서 telnet을 통해 테스트하고싶다면 Homebrew를 통해 설치하면 된다.

 

Telnet 이용하기

근데 만일 본인이 ubuntu 원격서버 환경에서 실험하고 있다면, 별도 설치 없이 telnet을 바로 사용할 수 있다. 그 이유는 ubuntu 20.04 버전 기준으로 telnet이 기본 설치되어있기 때문이다. 

 

filezilla로 EC2 인스턴스로 실행한 ubuntu 서버의 내부를 들여다보면, 해당 서버 내부에는 telnet이 기본 설치되어있음을 확인가능하다. 

 

 

혹은 터미널에 which telnet 이라는 명령어를 입력하면 telnet의 위치를 확인할 수 있다. 만일 telnet이 설치되어 있다면 바로 사용하면 된다. 

 

 

telnet <IP 번호> <Port 번호> 를 통해 텔넷을 통해 테스트하려는 서버에 접속하고, 테스트해보면 된다. 

그리고 클라이언트 연결(telnet 세션)을 종료하려면, ctrl + ] 를 누르고 quit 명령어를 입력하면 된다.