본문 바로가기
개발/리눅스

[Linux]TCP 서버/클라이언트 구현

by p_human 2020. 3. 30.

이전 글에서는 TCP, UDP, 그리고 프로토콜 스택에 대해서 알아보았습니다.

이번에 소개할 내용은 TCP 서버/클라이언트에 대한 이론과 실제 구현 코드입니다.

그리고 이번에 소개하는 함수들은 Windows 운영체제에서는 컴파일이 불가능하고,

Linux에서만 작동하는 함수입니다. 그 점 참고하고 봐주세요.

 

오늘 작성할 TCP의 서버 프로그램은 다음과 같은 순서로 구현이 됩니다. 


TCP 서버 프로그램의 순서

 

  1. 서버의 소켓 생성
  2. 서버의 소켓을 서버의 주소 정보로 초기화
  3. 클라이언트의 연결 대기실을 생성
  4. 클라이언트와 서버의 연결을 진행
  5. 데이터의 송수신
  6. 소켓 연결 종료 

 

 

 

 


연결 요청 대기상태로의 진입

서버가 listen함수를 호출하면 연결 요청 대기상태가 가능한 상태가 됩니다. 이는 클라이언트와 관련이 있습니다. 서버의 listen함수 호출이 되어야 클라이언트의 connect함수 호출이 가능하기 때문에 그전에 클라이언트의 connect함수를 호출하면 오류가 발생합니다.

 

아래는 listen함수의 정의입니다. 


#include<sys/scoket.h>

int listen(int sock, int backlog);

함수 성공 시 0, 실패 시 -1을 반환

sock : 연결 요청 대기상태에 두고자 하는 소켓 전달, 이 인자로 전달하게 되는 소켓이 서버 소켓이 됩니다.

backlog : 연결 요청 대기 큐의 크기 정보 전달


listen함수에서 sock는 외부에서 들어오는 클라이언트의 연결 요청을 분석하는 문지기의 역할을 한다고 볼 수 있습니다. 그리고 backlog를"연결 요청 대기 큐"의 크기 정보로 초기화하면 그 크기만큼 대기실이 만들어집니다. 그런데 약간 헷갈리는 단어가 보입니다. 위에서 언급한"연결 요청 대기상태""연결 요청 대기 큐"란 무엇일까요?


방금 전에 listen함수의 두 번째 인자를 초기화할 때 만들어진 대기실을 "연결 요청 대기 큐"라고 합니다. 그리고 첫 번째 인자인 서버 소켓과 "연결 요청 대기 큐"가 클라이언트의 연결 요청을 받아들일 수 있게 되면 이를 "연결 요청 대기상태"라고 합니다. 

정리를 하자면

서버가 '연결 요청 대기상태'에 있다는 것은 클라이언트가 연결 요청을 했을 때 연결이 수락될 때까지 연결 요청 자체를 대기시킬 수 있는 상태라는 의미이다. (이 내용은 윤성우 책의 101page에 있습니다. 위의 내용을 이해를 했다면, 책에 나온 정리가 더 머릿속에 기억에 남을 것 같아서 인용을 했습니다.) 

클라이언트의 연결 요청 수락

서버에서 listen함수 호출 후에 클라이언트가 연결 요청(connect함수 호출)을 했다면, 대기 큐에 들어온 순서대로 요청을 수락해야 합니다. 이렇게 서버가 연결 요청을 수락한다는 것은 서버와 클라이언트가 데이터를 주고받을 수 있는 상태가 된 것입니다. 이 상태가 되려면 데이터의 송수신을 담당하는 또 하나의 소켓이 필요하게 됩니다. (물론 서버 소켓도 있지만, 서버 소켓은 문지기를 하느라 데이터를 송수신할 겨를이 없기 때문에 제외합니다.) 
결론은 서버에게 연결 요청한 클라이언트를 새로운 소켓과 연결하는 함수가 accept함수입니다.

 

아래는 accept함수의 정의입니다. 


#include<sys/socket.h>

int accept(int sock, struct sockaddr * addr, socklen_t * addrlen);

함수 성공 시 반환되는 값은 생성된 소켓의 파일 디스크립터, 실패 시에는 -1

sock : 서버 소켓 전달

addr : 클라이언트의 주소 정보가 담길 변수의 주소 값

addrlen : 클라이언트의 주소 정보의 크기를 담은 주소 값


이렇게 해서 TCP 서버 프로그램의 중요한 부분들을 알아보았습니다. 이제 완성된 코드를 보겠습니다.

참고로 아래의 코드는 일반적으로 Windows 운영체제에서는 컴파일이 안 되는 코드입니다. Windows에서 컴파일되는 코드는 간단하게 함수의 설명과 코드만 작성해서 따로 올리겠습니다.

// C++ 서버 프로그램
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(const char *message);

int main(int argc, char *argv[])
{
    if(argc!=2){
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
	
    // 소켓을 생성
    int serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    if(serv_sock == -1)
        error_handling("socket() error");
	
    // 주소 정보 초기화
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    // INADDR_ANY는 서버 자신의 IP주소
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_addr.sin_port=htons(atoi(argv[1]));
	
    // 소켓에 위에서 생성한 주소 정보를 할당
    if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 )
        error_handling("bind() error"); 
	
    // 클라이언트가 연결요청이 가능하도록 5크기의 대기실 생성
    if(listen(serv_sock, 5)==-1)
        error_handling("listen() error");
	
    // accept()함수호출을 해서 실제 데이터를 보낼 수 있는 소켓을 생성
    socklen_t clnt_addr_size=sizeof(clnt_addr);
    int clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
    if(clnt_sock==-1)
        error_handling("accept() error");  
    
    // 메시지를 보낸다
    char message[]="Hello World!";
    write(clnt_sock, message, sizeof(message));
    // 서버, 클라이언트 소켓의 연결을 해제
    close(clnt_sock);	
    close(serv_sock);
    return 0;
}

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

TCP 클라이언트 프로그램의 함수 호출 순서

클라이언트의 구현은 서버보다 간단합니다. 그럼 서버와 클라이언트의 함수 호출 순서를 봐주세요.

 

TCP 서버/클라이언트 함수호출 순서 비교

위의 그림에서와 같이 딱 봐도 클라이언트 쪽이 구현하기가 간단해 보입니다. 실제로도 클라이언트가 서버보다 구현하기가 상대적으로도 쉽고요... 서버는 이것저것 할게 많았지만 클라이언트는 소켓의 생성과 연결 요청이 전부이기 때문입니다. 위의 그림에서 차이가 있는 부분은 클라이언트에서 연결 요청이라는 과정입니다. 위에서 서버의 listen함수에서 connect함수를 언급했듯이 이 connect함수는 listen함수를 호출한 뒤에 호출이 가능한 함수입니다.

정리해보면 클라이언트가 서버에게 연결 요청을 보내려면 서버의 listen함수 호출 후에 connect함수를 호출합니다.

 

아래는 connect함수의 정의입니다.


#include<sys/socket.h>

int connect(int sock, struct sockaddr * servaddr, socklen_t addrlen);

함수 성공 시 0, 실패 시 -1 반환

sock : 클라이언트의 소켓 전달

servaddr : 서버의 주소 정보가 담길 주소 값 전달

addrlen : 서버의 주소 정보의 크기를 담은 변수의 주소 값 전달


클라이언트가 connect함수를 호출했을 때 일어나는 경우는 두 가지입니다. 

  • 서버에 의해 연결 요청 대기 큐에 등록
  • 네트워크 단절 등 오류 상황이 발생해서 연결 요청이 중단

이렇게 해서 클라이언트를 만들기 위한 함수에 대한 설명은 마치고 코드를 보여드리겠습니다.

// C++ TCP 클라이언트 프로그램
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(const char *message);

int main(int argc, char* argv[])
{
    if(argc!=3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
	
    int sock=socket(PF_INET, SOCK_STREAM, 0);
    if(sock == -1)
        error_handling("socket() error");
	
    // 클라이언트와 마찬가지로 주소정보를 초기화
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));
		
    // 서버의 주소정보로 클라이언트 소켓이 연결요청을 한다.
    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
        error_handling("connect() error!");
	
    // 성공적으로 서버에게서 메시지를 받아오면
    // 0을 반환하고 실패하면 -1을 반환한다.
    char message[30];
    int str_len;
    str_len=read(sock, message, sizeof(message)-1);
    if(str_len==-1)
        error_handling("read() error!");
	
    printf("Message from server: %s \n", message);  
    close(sock);
    return 0;
}

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

이렇게 해서 TCP 서버 / 클라이언트에 대한 함수부터 코드 구현까지 알아보았습니다.

어제보다 좀 긴 글이었는데, 여기 끝까지 봐주신 분들은 정말 고맙습니다.

 

내일은 책의 진도대로 Iterative 기반의 서버 / 클라이언트 구현에 대해서 알아보겠습니다.

 

아 그리고 윈속에 대해서 한번 쭉 훑어보실 분들은 이 블로그 가셔서 보시면 괜찮을 것 같아서 링크 첨부합니다.

 

http://ehpub.co.kr/

 

언제나 휴일 – 언제나 휴일 프로그래머

 

ehpub.co.kr