본문 바로가기
Project/socket_chat_server

[CPP] 소켓 통신 채팅 서버 만들기 - 기본 기능 구현하기 - server

by 블로블로글 2024. 3. 18.

참고할만한 코드를 둘러보던 중 좋은 코드가 있는 블로그를 발견하여 분석하는 동시에 따라하면서 공부를 진행했다.

https://a-researcher.tistory.com/122

 

[c++] TCP/IP 서버 클라이언트 설명 및 예제 코드 (소켓 프로그래밍)

TCP(전송 제어 프로토콜)는 두 컴퓨터 간의 안정적인 통신을 설정하는 데 사용됩니다. TCP 서버와 클라이언트 코드를 통해 두 컴퓨터 간의 데이터를 전송할 수 있습니다. 이 문서에서는 C++로 작성

a-researcher.tistory.com

네트워크 프로그래밍에서 가장 첫 번째는 역시 통신을 위한 소켓 생성이다.

  - 소켓: 네트워크 상에서 데이터를 주고받기 위한 엔드포인트 역할

 

if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0){
    cerr << "Socket creation error!" << endl;
    return -1;
}

domain

  - 소켓이 사용할 주소 체계 지정

  - AF_INET : 인터넷 프로토콜 v4 주소 체계를 사용하는 소켓을 지정

type

  - 소켓의 타입을 지정

  - SOCK_STREAM : TCP 통신을 위한 소켓 타입

protocol

  - 소켓에서 사용할 프로토콜, 보통 0을 사용

  - 0 : 기본 프로토콜 지정

 

소켓을 생성한 다음에는 해당 소켓을 특정 포트에 바인딩을 해야한다.

이를 통해 소켓이 특정 포트를 통해 들어오는 네트워크 요청을 수신할 수 있음

 

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))){
    perror("Setsockopt failed");
    return -1;
}

sockfd

  - 옵션을 소켓의 파일 디스크립터

level

  - 옵션 설정의 적용 범위 지정

  - SOL_SOCKET : 소켓 레벨의 옵션

optname

  - 설정하려는 옵션의 이름

  - SO_REUSEADDR : 소켓의 로컬의 주소를 재사용 가능

    - 빠른 재시작

    - 서버가 비정상적으로 종료되거나 재시작해야 하는 경우, TIME_WAIT 상태의 소켓 때문에 포트가 바로 사용 가능 상태가 되지 않을 수 있음

    - 따라서 이 옵션을 통해, 위의 상황에서 해당 포트를 바로 재바인딩하고 재시작할 수 있게 해줌

    - 주소 사용의 유연성

    - 혹은 동일한 로컬 주소에 여러 서비스가 바인딩되어야 하는 경우에, 다른 네트워크 인터페이스를 통해 동일한 포트 번호에 여러 서버 인스턴스를 사용 가능하게 해줌

  - SO_REUSEPORT : 여러 소켓이 동일한 포트를 공유 가능

    - 부하 분산

    - 동일한 호스트에서 실행되는 여러 프로세스나 스레드가 동일한 포트 번호를 공유 가능하게 해줌

    - 들어오는 연결에 대한 부하를 분산 시킬 수 있으며, 시스템 전체 처리량을 향상시킬 수 있음

    - 확장성과 성능

    - 여러 서버 인스턴스가 동일한 포트를 듣고 있을 경우, OS는 들어오는 연결을 여러 프로세스 또는 스레드에 자동으로 분산시킬 수 있음

    - 특히 다중 코어 시스템에서 서버의 성능과 확장성을 크게 향상 시켜줌

optval

  - 옵션의 새 값에 대한 포인터

optlen

  - optval의 크기

 

바인딩에 사용되는 주소 설정

struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);

sin_family

  - 소켓이 사용할 주소 체계를 지정

sin_addr.s_addr

  - 소켓이 바인딩될 호스트의 IP주소를 지정

  - INADDR_ANY : 모든 인터페이스를 통해 들어오는 연결을 수락

sin_port

  - 연결을 수락할 소켓의 포트 번호

  - htons() : 호스트 바이트 순서에서 네트워크 바이트 순서로 변환

    - TIP/IP 통신은의 네트워크 바이트 순서 사용 규칙 때문에 사용

 

위에서 바인딩될 주소 설정과 소켓의 옵션을 설정하였으면 이제 소켓에 포트를 바인딩을 해주면 된다.

 

bind(server_fd, (struct sockaddr *)&address, sizeof(address));
bind(server_fd, (struct sockaddr *)&address, sizeof(address));

server_fd

  - 바인딩할 소켓의 파일 디스크립터

*(struct sockaddr )&address

  - 바인딩할 주소를 지정

  - sockaddr_in 구조체를 사용해 IP 주소와 포트 번호를 설정한 후, 이를 sockaddr 구조체로 캐스팅하여 bind() 함수에 전달

sizeof(address)

  - 전달된 주소의 크기

  - bind() 함수가 주소의 길이를 정확히 알 필요가 있기 때문에 사용

 

위의 과정을 통해 서버 는 특정 포트에서 들어오는 연결 요청을 기다릴 준비가 되고, 이제 클라이언트로부터의 연결 요청을 수락하고 처리하는 함수를 작성하면 된다.

 

listen(server_fd, accerptable_num)
if (listen(server_fd, 3) < 0) {
    cerr << "Listen error" << endl;
    return -1;
}

server_fd

  - 수신 대기 상태로 설정할 소켓의 파일 디스크립터

accerptable_num

  - 소켓이 동시에 수락할 수 있는 연결 요청의 최대 개수

 

listen 함수 호출을 통해 서버는 클라이언트의 연결 요청을 받기 위한 수신 대기 상태로 변환됨.

이를 위해 바인딩된 소켓에 listen 함수를 적용하여, 해당 소켓을 들어오는 연결 요청을 수신 대기할 수 있는 상태로 만듬

이제 서버는 클라이언트의 연결 요청을 기다리는 준비를 마쳤으므로 연결을 수락하고 통신을 시작하는 단계가 필요함

 

 

accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        cerr << "Accept error" << endl;
        return -1;
    }

server_fd

  - 수신 대기 상태로 설정할 소켓의 파일 디스크립터

*(struct sockaddr )&address

  - 클라이언트의 주소 정보를 저장할 구조체의 주소.

  - 연결이 수락된 후, 이 구조체에는 클라리언트의 주소 정보가 저장

sizeof(address)

  - 전달된 주소의 크기

  - accept() 호출이 성공하면, 실제로 저장된 주소 정보의 크기가 저장

 

이제 서버는 소켓 생성 및 설정, 바인딩될 주소 설정을 마친 뒤 소켓에 포트를 바인딩하였고, listen과 accept를 통해 클라이언트와의 연결 요청을 수신 및 수락을 하였으므로 이제 통신 세션을 시작할 준비를 모두 마치게 되었음

 

따라서 뒤에는 클라이언트와 주고 받을 데이터를 처리하는 로직을 만들면 됨

이 코드의 목적은 간단한 메시지 주고 받기이므로 read와 write를 통해 연결을 확인하는 메시지를 주고 받는 함수를 작성 함

valread = read(new_socket, buffer, 1024);
cout << buffer << endl;

send(new_socket, hello, strlen(hello), 0);
cout << "Hello message sent" << endl;

  - read() 함수를 사용해 클라이언트로부터 데이터를 수신하고 buffer에 저장하고, 서버 콘솔에 출력

  - send() 함수를 통해 클라이언트에게 메시지를 전달

 

전체 코드

#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <cstring>
#include <istream>
#include <unistd.h>

#define PORT 3000

using namespace std;

int server(){
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    const char *hello = "Hello from server";

    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0){
        cerr << "Socket creation error!" << endl;
        return -1;
    }

    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))){
        perror("Setsockopt failed");
        cerr << "Setsockopt err!" << endl;
        return -1;
    }

    address.sin_family = AF_INET; // 소켓이 사용할 주소 체계 지정, 이 경우엔 IPv4
    address.sin_addr.s_addr = INADDR_ANY; // 소켓이 바인딜 될 호스트의 IP 주소 지정, 이경우엔 모든 인터페이스를 통해 들어오는 연결을 수락
    address.sin_port = htons(PORT); // 네트워크 바이트 순서 (Big-Endian)으로 변환하여 연결을 수락한 소켓의 포트 번호를 변환

    // Bind the socket to the specified port
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    // Listen for incoming connections
    if (listen(server_fd, 3) < 0) {
        cerr << "Listen error" << endl;
        return -1;
    }

    // Accept incoming connections
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        cerr << "Accept error" << endl;
        return -1;
    }

    // Receive message from the client
    valread = read(new_socket, buffer, 1024);
    cout << buffer << endl;

    // Send message to the client
    send(new_socket, hello, strlen(hello), 0);
    cout << "Hello message sent" << endl;

    return 0;
}

이렇게 TCP를 활용해서 서버를 짜면, 우리가 원하는 정보가 정확하게, 순서대로, 또 잘못되거나 빠짐 없이 전달되는 걸 확실하게 할 수 있다. 이러한 방식을 통해 채팅 앱이나 이메일, 파일 공유등을 구현한다. 복잡하지 않은 코드지만 tcp의 중요 개념이 모두 들어간 좋은 코드였다.