C++ Linux 멀티스레드 소켓 서버 예제

Brie·2023년 11월 10일
0

C++

목록 보기
7/9

개요

C++ 언어를 사용하여 Linux/Unix 환경에서 동작할 수 있는 멀티스레드 소켓 서버를 구현해보았습니다.

개발 환경

  • Language: C++
  • IDE: Visual Studio 2022
  • OS: OracleLinux8.7
  • Compiler: g++

socketServer.cpp

#include <iostream>
#include <thread>
#include <vector>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <errno.h>
using namespace std;

#define PACKET_SIZE 1024
#define serverPort 7000

struct ClientInfo { // 클라이언트 정보 필드
	int socket;
	sockaddr_in clientAddress;
};

include, namespace 사용과 함께 PACKET_SIZE, serverPort, 그리고 클라이언트 정보를 담고 있는 구조체의 형식을 선언하였습니다.

vector<pthread_t> clientThreads;
vector<ClientInfo*> clientPool;

int server_socket;
void* handle_client(void* data);
void* client_accept(void* data);

int main(){
	return 0;
}

스레드와 클라이언트 정보 구조체를 관리할 vector인 clientThreads, clientPool을 선언하였습니다.
int server_socket; 구문을 통해 서버 소켓을 선언하였습니다. 특이한 것은 리눅스에서 소켓은 정수로 표현된다는 것입니다. 윈도우에서 소켓 프로그래밍 시 소켓이 SOCKET으로 표현되었던 것과는 다른 경우입니다.
void* handle_client(void* data);로 클라이언트 관리 thread 함수를 선언하고, void* client_accept(void* data);로 클라이언트 연결 thread 함수를 선언하였습니다.

int main(){
    int optvalue = 1;
    struct sockaddr_in serverAddr;

    server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1) {
        cerr << "서버 소켓 생성 실패" << endl;
        perror("socket");
        
        return 1;
    }
    setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &optvalue, sizeof(optvalue));
}

가장 먼저 main() 함수에서 서버 소켓을 생성해주었습니다. socket() 함수는 에러가 발생한 경우 '-1' 값을 반환하기 때문에 if문을 통해 에러가 발생한 경우 에러 메세지를 출력함과 동시에 perror() 함수를 통해 meaning of the value of errno를 출력하고 메인 함수가 종료되도록 하였습니다.
setsockopt() 함수는 소켓 옵션을 설정하는 데 사용되는 함수로, SO_REUSEADDR 옵션을 사용하여 주소 재사용 기능이 활성화되도록 하였습니다.

	……
    memset(&serverAddr, 0, sizeof(serverAddr));

    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(serverPort);

    // 서버 소켓을 주소와 바인딩
    int bindResult = bind(server_socket, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
    if (bindResult < 0) {
        cerr << "바인딩 실패" << endl;
        perror("bind");

        close(server_socket);
        return 1;
    }

    listen(server_socket, 5); // 5는 백로그 크기 = 동시에 처리 가능한 연결 요청의 최대 수
    cout << "서버가 "<< serverPort << " 포트에서 대기 중..." << endl;

memset() 함수를 사용해 sockaddr_in serverAddr 구조체를 초기화하고, serverAddr의 각 멤버를 설정하고 bind() 함수를 통해 서버 소켓을 주소와 바인딩 해주었습니다. 마찬가지로 if문을 통해 에러가 발생한 경우 에러 메세지를 출력함과 동시에 perror() 함수를 통해 meaning of the value of errno를 출력하고 메인 함수가 종료되도록 하였습니다.
그리고 listen() 함수를 사용해 서버가 클라이언트의 연결 요청을 대기할 수 있도록 하였습니다.

int main(){
	……
	pthread_t thread_listen;
    if (pthread_create(&thread_listen, nullptr, client_accept, nullptr) != 0) {
        cerr << "스레드 생성 실패" << endl;
        return 1;
    }
}

void* client_accept(void* data){    // 클라이언트 연결 thread 함수
    while(1) {
        int client_socket;
        struct sockaddr_in clientAddr;
        socklen_t clientAddrLen = sizeof(clientAddr);

        client_socket = accept(server_socket, (struct sockaddr*)&clientAddr, &clientAddrLen);
        if (client_socket == -1) {
            // 클라이언트 연결 실패 시 소켓을 닫고 다음 while 회차로 넘어감
            std::cerr << "클라이언트 연결 수락 실패" << std::endl;
            perror("client");
            close(client_socket);
            continue;
        }

        cout << "클라이언트와 연결이 완료되었습니다" << endl;
        cout << "Client IP: " << inet_ntoa(clientAddr.sin_addr) << endl;
		cout << "Port: " << ntohs(clientAddr.sin_port) << endl;
        
        // 클라이언트의 데이터를 담은 구조체를 생성하고
        // 연결된 클라이언트의 구조체 정보를 관리하기 위해 vector를 사용
        ClientInfo* clientData = new ClientInfo;
		clientData->socket = client_socket;
		clientData->clientAddress = clientAddr;

        clientPool.push_back(clientData);

        // 클라이언트를 처리할 스레드 생성
        pthread_t thread_client;
        pthread_create(&thread_client, nullptr, handle_client, (void*)clientData);
        clientThreads.push_back(thread_client);
    }
    return nullptr;
}

main() 문에서 클라이언트의 연결을 수락할 스레드인 pthread_t thread_listen를 생성하고, 스레드 함수로 client_accept()를 실행하도록 하였습니다.
client_accept() 내부에서는 while(1)문에 의해 반복되면서 accept() 함수로 클라이언트의 요청을 수락하고 있습니다. 또한 서버 소켓과 마찬가지로 에러 처리를 하고 있습니다. 연결이 완료되면 연결이 완료된 클라이언트의 IP Address와 Port Number를 출력합니다.
연결 완료 후, 구조체 ClientInfo를 동적 메모리를 할당받아 생성하고, 구조체의 멤버 변수에 연결된 클라이언트의 소켓 정보와 sockaddr_in 정보를 저장합니다. 이후 vector<ClientInfo*> clientPool에 push하여 vector에서 저장된 구조체를 통해 연결된 클라이언트의 정보를 관리할 수 있도록 해주었습니다.
마지막으로 클라이언트를 처리할 스레드인 pthread_t thread_client를 생성하고, 스레드 함수로 handle_client()를 실행하도록 하였습니다. 마찬가지로 vector<pthread_t> clientThreads에 push하여 vector를 통해 클라이언트 스레드를 관리할 수 있도록 해주었습니다.

void* handle_client(void* data){
    ClientInfo* clientData = static_cast<ClientInfo*>(data);
    int clientSocket = clientData->socket;
	sockaddr_in clientAddr = clientData->clientAddress;

    char buffer[1024];
	int recvSize;

    do {
        memset(buffer, 0, PACKET_SIZE);
		recvSize = recv(clientSocket, buffer, sizeof(buffer), 0);
		if (recvSize > 0) {
			cout << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << "로부터 받은 메세지: [" << buffer << "]를 echo합니다." << endl;
			send(clientSocket, buffer, recvSize, 0);
		}
	} while (recvSize > 0);

    for (size_t i = 0; i < clientPool.size(); ++i) {
		if (clientPool[i]->socket == clientSocket) {
			// 클라이언트 정보 삭제
			cout << "클라이언트와 연결이 종료되었습니다: " << inet_ntoa(clientPool[i]->clientAddress.sin_addr) << ":" << ntohs(clientPool[i]->clientAddress.sin_port) << endl;
			clientPool.erase(clientPool.begin() + i);
			break;
		}
	}
    delete clientData;
    close(clientSocket);
    return nullptr;
}

handle_client()는 클라이언트를 처리하기 위한 스레드 함수입니다. 가장 먼저, void* 형태로 데이터를 일반화하여 매개 변수를 받았기 때문에 ClientInfo* clientData = static_cast<ClientInfo*>(data); 구문을 통해 ClientInfo* 타입으로 형변환을 해주었습니다.
do{ ... } while( ... ) 문 내부에서는 client로부터 데이터를 받아오고, 받아온 데이터를 다시 echo하는 동작을 수행하고 있습니다. recvSize를 통해 데이터 버퍼의 사이즈를 구하고, 사이즈가 0보다 큰 경우 데이터를 수신한 것으로 간주하여 버퍼의 내용을 출력합니다.
for ( ... ) { ... } 문 내부에서는 스레드 함수에서 관리하고 있는 클라이언트의 정보를 vector<ClientInfo*> clientPool에서 삭제하는 동작을 수행하고 있습니다.
do while문과 for문의 모든 동작이 완료된 경우, clientData를 삭제하여 할당된 동적 메모리를 반환하고 clientSocket를 닫으며, return nullptr;를 호출하여 스레드를 종료시킵니다.

int main(){
	……
	char msg[PACKET_SIZE] = {0};

    while (1) {
        // 메세지 버퍼 초기화하고 입력받기
        memset(msg, 0, PACKET_SIZE);
        cin >> msg;
        if ((string)msg == "list") { // 입력한 값이 "list"면 연결된 Client 목록 출력
            cout << "연결된 Client IP Address 목록" << endl;
			for (ClientInfo* c : clientPool) {
				cout << inet_ntoa(c->clientAddress.sin_addr) << ":" << ntohs(c->clientAddress.sin_port) << endl;
			}
        } else if ((string)msg == "exit") {
            cout << "Server를 종료합니다." << endl;
            pthread_detach(thread_listen);
            break;
        } 
        else { // 입력한 값이 "list", "exit"가 아니면 연결된 Client들에게 메세지 전송
            for (ClientInfo* c : clientPool) {
				send(c->socket, msg, strlen(msg), 0);
			}
        }
    }
}

다시 main() 문으로 넘어와서, while(1) { ... } 문 내부에서는 메세지 버퍼를 입력받고 입력받은 데이터의 값에 따라 각각 다른 동작을 수행하도록 하였습니다.
입력된 값이 "list"면 연결된 Client 목록을 출력하고, 입력된 값이 "exit"면 thread_listen 스레드를 강제 종료함과 동시에 while문을 빠져나오며, 이외의 조건의 경우 연결된 모든 클라이언트들에게 입력된 값을 전송하도록 하였습니다.

int main(){
	……
    /* 메모리 할당 해제 */
    pthread_join(thread_listen, nullptr);
    for (ClientInfo* c : clientPool){
        cout << "clientPool 메모리 할당 해제: " << c->socket << endl;
        delete c;
    }
    for(pthread_t hThread : clientThreads){
        cout << "clientThreads 메모리 할당 해제: " << hThread << endl;
        pthread_detach(hThread);
    }
    /* 메모리 할당 해제 */

    close(server_socket);
    return 0;
}

가장 마지막 부분으로 스레드들을 종료하며 메모리 할당을 해제하는 부분입니다.
먼저 pthread_join() 함수를 통해 thread_listen 스레드의 작업이 완료될때 까지 대기한 후 스레드를 종료시키며 자원을 회수합니다.
첫 번째 for문에서는 clientPool을 순회하고 있습니다. vector<ClientInfo*> clientPool에 존재하는 모든 ClientInfo* c에 대해 delete c;문을 통해 삭제하고 메모리를 반환하도록 하고 있습니다.
두 번째 for문에서는 clientThreads를 순회하고 있습니다. vector<pthread_t> clientThreads에 존재하는 모든 pthread_t hThread에 대해 pthread_detach(hThread);문을 통해 스레드를 종료하고 자원을 반환하도록 하고 있습니다.
이러한 작업들이 모두 완료된 후, 서버 소켓을 닫고 main() 함수에서 0을 return하여 프로그램을 끝내도록 하고 있습니다.

전체 코드

#include <iostream>
#include <thread>
#include <vector>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <errno.h>
using namespace std;

#define PACKET_SIZE 1024
#define serverPort 7000

struct ClientInfo { // 클라이언트 정보 필드
	int socket;
	sockaddr_in clientAddress;
};

vector<pthread_t> clientThreads;    // 스레드들을 관리할 vector 선언
vector<ClientInfo*> clientPool;     // 클라이언트 정보 구조체를 관리할 vector 선언

int server_socket;                  // 서버 소켓
void* handle_client(void* data);    // 클라이언트 관리 thread 함수
void* client_accept(void* data);    // 클라이언트 연결 thread 함수

int main(){
    int optvalue = 1;
    struct sockaddr_in serverAddr;

    server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1) {
        cerr << "서버 소켓 생성 실패" << endl;
        perror("socket");
        
        return 1;
    }
    setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &optvalue, sizeof(optvalue));

    memset(&serverAddr, 0, sizeof(serverAddr));

    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(serverPort);

    // 서버 소켓을 주소와 바인딩
    int bindResult = bind(server_socket, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
    if (bindResult < 0) {
        cerr << "바인딩 실패" << endl;
        perror("bind");

        close(server_socket);
        return 1;
    }

    listen(server_socket, 5); // 5는 백로그 크기 = 동시에 처리 가능한 연결 요청의 최대 수
    cout << "서버가 "<< serverPort << " 포트에서 대기 중..." << endl;

    pthread_t thread_listen;
    if (pthread_create(&thread_listen, nullptr, client_accept, nullptr) != 0) {
        cerr << "스레드 생성 실패" << endl;
        return 1;
    }

    char msg[PACKET_SIZE] = {0};

    while (1) {
        // 메세지 버퍼 초기화하고 입력받기
        memset(msg, 0, PACKET_SIZE);
        cin >> msg;
        if ((string)msg == "list") { // 입력한 값이 "list"면 연결된 Client 목록 출력
            cout << "연결된 Client IP Address 목록" << endl;
			for (ClientInfo* c : clientPool) {
				cout << inet_ntoa(c->clientAddress.sin_addr) << ":" << ntohs(c->clientAddress.sin_port) << endl;
			}
        } else if ((string)msg == "exit") {
            cout << "Server를 종료합니다." << endl;
            pthread_detach(thread_listen);
            break;
        } 
        else { // 입력한 값이 "list", "exit"가 아니면 연결된 Client들에게 메세지 전송
            for (ClientInfo* c : clientPool) {
				send(c->socket, msg, strlen(msg), 0);
			}
        }
    }

    pthread_join(thread_listen, nullptr);
    /* 메모리 할당 해제 */
    for (ClientInfo* c : clientPool){
        cout << "clientPool 메모리 할당 해제: " << c->socket << endl;
        delete c;
    }
    for(pthread_t hThread : clientThreads){
        cout << "clientThreads 메모리 할당 해제: " << hThread << endl;
        pthread_detach(hThread);
    }
    /* 메모리 할당 해제 */

    close(server_socket);
    return 0;
}

void* client_accept(void* data){    // 클라이언트 연결 thread 함수
    while(1) {
        int client_socket;
        struct sockaddr_in clientAddr;
        socklen_t clientAddrLen = sizeof(clientAddr);

        client_socket = accept(server_socket, (struct sockaddr*)&clientAddr, &clientAddrLen);
        if (client_socket == -1) {
            // 클라이언트 연결 실패 시 소켓을 닫고 다음 while 회차로 넘어감
            std::cerr << "클라이언트 연결 수락 실패" << std::endl;
            perror("client");
            close(client_socket);
            continue;
        }

        cout << "클라이언트와 연결이 완료되었습니다" << endl;
        cout << "Client IP: " << inet_ntoa(clientAddr.sin_addr) << endl;
		cout << "Port: " << ntohs(clientAddr.sin_port) << endl;
        
        // 클라이언트의 데이터를 담은 구조체를 생성하고
        // 연결된 클라이언트의 구조체 정보를 관리하기 위해 vector를 사용
        ClientInfo* clientData = new ClientInfo;
		clientData->socket = client_socket;
		clientData->clientAddress = clientAddr;

        clientPool.push_back(clientData);

        // 클라이언트를 처리할 스레드 생성
        pthread_t thread_client;
        pthread_create(&thread_client, nullptr, handle_client, (void*)clientData);
        clientThreads.push_back(thread_client);
    }
    return nullptr;
}

void* handle_client(void* data){
    ClientInfo* clientData = static_cast<ClientInfo*>(data);
    int clientSocket = clientData->socket;
	sockaddr_in clientAddr = clientData->clientAddress;

    char buffer[1024];
	int recvSize;

    do {
        memset(buffer, 0, PACKET_SIZE);
		recvSize = recv(clientSocket, buffer, sizeof(buffer), 0);
		if (recvSize > 0) {
			cout << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << "로부터 받은 메세지: [" << buffer << "]를 echo합니다." << endl;
			send(clientSocket, buffer, recvSize, 0);
		}
	} while (recvSize > 0);

    for (size_t i = 0; i < clientPool.size(); ++i) {
		if (clientPool[i]->socket == clientSocket) {
			// 클라이언트 정보 삭제
			cout << "클라이언트와 연결이 종료되었습니다: " << inet_ntoa(clientPool[i]->clientAddress.sin_addr) << ":" << ntohs(clientPool[i]->clientAddress.sin_port) << endl;
			clientPool.erase(clientPool.begin() + i);
			break;
		}
	}
    delete clientData;
    close(clientSocket);
    //pthread_detach(pthread_self());
    return nullptr;
}

코드 실행하기

컴파일 하기

코드를 실행하려면 먼저 컴파일을 통해 작성된 코드를 실행할 수 있는 형태로 만들어야 합니다.
pthread 패키지가 사용된 코드를 gcc/g++로 컴파일 하려면 "-lpthread" 옵션을 사용해야 하는데, 저는 VSCode의 프로젝트에 있는 task.json 파일에서 다음과 같이 arguments를 추가해 주었습니다.

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "shell",
            "label": "g++ build active file",
            "command": "/usr/bin/g++",
            "args": [
                "-g",
                "${file}",
                "-o",
                "${fileDirname}/${fileBasenameNoExtension}",
                "-lpthread"
            ],
            "options": {
                "cwd": "/usr/bin"
            },
            "problemMatcher": [
                "$gcc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

아니면 그냥 다음과 같이 빌드해도 됩니다.

g++ socketServer.cpp -o socketServer

socketServer 실행하기

g++로 빌드를 완료하였다면, 실행 파일이 생성됩니다. 실행 파일인 socketServer를 실행하면 가장 먼저 다음과 같이 서버가 포트에서 대기중이라는 메세지가 출력됩니다.

./socketServer
서버가 7000 포트에서 대기 중...

이 때 2개의 클라이언트를 통해 서버에 접속하면, 동일한 PC에서 테스트하였기에 IP는 127.0.0.1로 같지만 각각 다른 포트에서 클라이언트와 연결이 완료되는 것을 확인할 수 있습니다.

# ./socketServer
서버가 7000 포트에서 대기 중...
클라이언트와 연결이 완료되었습니다
Client IP: 127.0.0.1
Port: 39028
클라이언트와 연결이 완료되었습니다
Client IP: 127.0.0.1
Port: 36118

이 때 "list"를 입력하면 연결된 클라이언트 목록이 출력됩니다. 그리고 "list"나 "exit"가 아닌 다른 메세지를 입력하면 클라이언트에서 입력한 메세지를 수신하는 것을 확인할 수 있습니다.

socketServer

list
연결된 Client IP Address 목록
127.0.0.1:39028
127.0.0.1:36118
test

socketClient

./socketClient
서버에 연결되었습니다.
받은 메세지: test

socketServer에 접속한 두 개의 클라이언트 중 하나의 클라이언트를 종료한 경우, 클라이언트와의 연결이 종료되었다는 메세지가 출력되는 것을 확인할 수 있습니다.
그리고 "list" 명령어를 입력해 보면 클라이언트 목록에서 접속이 해제된 클라이언트가 삭제된 것을 확인할 수 있습니다.

클라이언트와 연결이 종료되었습니다: 127.0.0.1:36118
list
연결된 Client IP Address 목록
127.0.0.1:39028

0개의 댓글