본문 바로가기
Project/socket_chat_server

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

by 블로블로글 2024. 3. 21.

이번 구현에서는 아래와 같은 ChatServer 클래스를 통해 구현하였다

class ChatServer {
public:
    ChatServer(unsigned short port);
    ~ChatServer();
    void start();

private:
    unsigned short port_;
    SOCKET listeningSocket_;
    std::vector<SOCKET> clientSockets_;
    std::mutex driverMutex;

    void initializeWinsock();
    std::map<std::string, std::string> loadEnv(const std::string& envFilePath);
    void saveMessageToDatabase(const std::string& message);
    void setupServerSocket();
    void listenForClients();
    void handleClient(SOCKET clientSocket);
    void cleanup();
};

 

우선 initializeWinsock을 통해 서버는 winsockDLL을 초기화 해줬다.

void ChatServer::initializeWinsock() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0) {
        std::cerr << "WSAStartup failed with error: " << result << std::endl;
        std::cerr << "Detailed error: " << WSAGetLastError() << std::endl;
        std::cerr << "WSAStartup failed: " << result << std::endl;
        exit(1);
    }
    std::cout << "WSAStartup successful" << std::endl;
}
winsock이란?
Windows Sockets API의 줄임말로, WIndows 운영 체제에서 네트워크 통신을 위한 프로그래밍 인터페이스이다.
TCP/IP 프로토콜 기반의 표준 API세트를 제공해준다.

WSADATA 구조체

  - WinsockDLL에 대한 정보를 저장

  - WSAStartup를 통해 이 구조체를 채움

int result = WSAStartup(MAKEWORD(2, 2), &wsaData);

  - MAKEWORD(2, 2)

    - 버전을 나타내는 인자로 이 경우엔 버전 2.2를 뜻함

  - &wsaData

    -  WSADATA 구조체를 전달하며, 전달받은 구조체를 채움

Winsock API 사용을 위한 필수적인 초기화 과정이다. 통신을 시작하기 전에 필수적인 과정이며, 실패 시 프로그램을 종료하게 했다.

 

두번째로는 setup을 통해 소켓을 생성, listen을 통해 연결 수락을 한다.

void ChatServer::setupServerSocket() {
    listeningSocket_ = socket(AF_INET, SOCK_STREAM, 0);
    if (listeningSocket_ == INVALID_SOCKET) {
        std::cerr << "Failed to create socket: " << WSAGetLastError() << std::endl;
        WSACleanup();
        exit(1);
    }
    std::cout << "Socket created" << std::endl;

    sockaddr_in hint;
    hint.sin_family = AF_INET;
    hint.sin_port = htons(port_);
    hint.sin_addr.S_un.S_addr = INADDR_ANY;

    if (bind(listeningSocket_, (sockaddr*)&hint, sizeof(hint)) == SOCKET_ERROR) {
        std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
        closesocket(listeningSocket_);
        WSACleanup();
        exit(1);
    }

    std::cout << "Server is set up and bound to port " << port_ << std::endl;

    if (listen(listeningSocket_, SOMAXCONN) == SOCKET_ERROR) {
        std::cerr << "Listen failed: " << WSAGetLastError() << std::endl;
        closesocket(listeningSocket_);
        WSACleanup();
        exit(1);
    }

    std::cout << "Server is now listening on port " << port_ << std::endl;
}
listeningSocket_ = socket(AF_INET, SOCK_STREAM, 0);
if (listeningSocket_ == INVALID_SOCKET) {
  std::cerr << "Failed to create socket: " << WSAGetLastError() << std::endl;
  WSACleanup();
  exit(1);
}

socket을 통해 TCP연결을 위한 리스팅 소켓을 생성한다. 

소켓 생성에 실패시 WSACleanup을 통해 Winsock을 정리한 뒤에 프로그램을 종료 시킨다.

sockaddr_in hint; hint.sin_family = AF_INET;
hint.sin_port = htons(port_);
hint.sin_addr.S_un.S_addr = INADDR_ANY;
if (bind(listeningSocket_, (sockaddr*)&hint, sizeof(hint)) == SOCKET_ERROR) {
  std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
  closesocket(listeningSocket_);
  WSACleanup();
  exit(1);
}

그 뒤에 소켓 주소 정보를 설정한 뒤에 bind함수를 통해서 hint의 주소 정보를 소켓에 바인딩 한다.

마찬가지로 실패시에 소켓과 WSACleanup을 정리하고 프로그램을 종료 시킨다.

if (listen(listeningSocket_, SOMAXCONN) == SOCKET_ERROR) {
  std::cerr << "Listen failed: " << WSAGetLastError() << std::endl;
  closesocket(listeningSocket_);
  WSACleanup();
  exit(1);
}

listen(listeningSocket_, SOMAXCONN)

  - 소켓을 리스닝 상태로 전환

  -  SOMAXCONN은 소켓이 동시에 대기할 수 있는 최대 연결 요청 수를 시스템 기본 값을 설정

실패시 위와 마찬가지로 리소스를 정리한 뒤에 종료.

 

현재 과정을 통해 서버 소켓을 생성, 바인딩 하였고, 리스닝 상태로 전환하는 과정을 거쳤다. 이제 서버는 네트워크 상에서 특정 포트에 대해 클라이언트의 연결 요청을 받을 준비를 끝냈다.

다음으로는 클라이언트와의 통신을 관리할 차례이다.

void ChatServer::handleClient(SOCKET clientSocket) {
    char buf[4096];
    while (true) {
        ZeroMemory(buf, 4096);
        int bytesReceived = recv(clientSocket, buf, 4096, 0);
        if (bytesReceived <= 0) {
            closesocket(clientSocket);
            break;
        }

        std::string message(buf, bytesReceived);
        saveMessageToDatabase(message);

        for (SOCKET outSock : clientSockets_) {
            if (outSock != clientSocket) {
                send(outSock, buf, bytesReceived, 0);
            }
        }
    }
}

클라이언트와의 통신을 처리하는 메소드이고, 이 메소드를 통해 각 클라이언트가 서버에 연결 될 때마다 실행시킨다.

클라리언트로부터 메시지를 받아, 해당 메시지를 데이터 베이스에 저장하고, 다른 모든 클라이언트들에게 메시지를 전송시킨다.

ZeroMemory(buf, 4096); 

  - 클라이언트로부터 받은 메시지를 저장하기 위한 버퍼를 0으로 초기화한다.

  - 버퍼에 이전 메시지가 남아 다음 메시지에 영향을 주는 것을 막기 위해 매번 실행

int bytesReceived = recv(clientSocket, buf, 4096, 0);
if (bytesReceived <= 0) {
  closesocket(clientSocket);
  break;
}

  - 클라이언트 소켓에서 데이터를 받아온다

  - recv 함수는 수신된 바이트 수를 반환하며, 에러가 발생하거나 연결이 종료되면 0 또는 음수를 반환

std::string message(buf, bytesReceived);
saveMessageToDatabase(message);

  - std::string을 통해 message 객체를 만들어 데이터베이스에 저장시킨다.

for (SOCKET outSock : clientSockets_) {
  if (outSock != clientSocket) {
    send(outSock, buf, bytesReceived, 0);
  }
}

clientSockets_ 벡터에 저장된 모든 클라이언트 소켓을 순회하며,  현재 메시지를 보낸 클라이언트를 제외한 달은 모든 클라이언트에게 메시지를 전송한다.

std::vector<SOCKET> clientSockets_;

를 통해 clientSockets_라는 SOCKET 타입을 저장할 수 있는 제네릭을 만들어준다.

 

이제 서버는 클라이언트로부터 메시지를 수신하고, 해당 메시지를 데이터베이스에 저장, 다른 사용자들에게 전달하는 기능을 가지게 되었다. 연결이 끊어질 때까지 위의 작업들을 반복하며, 종료 시에 자원을 정리하고 스레드를 종료한다.

 

서버가 클라이언트의 연결 요청을 대기하고, 요청이 도착하면 새로운 클라리언트와 연결을 설정한 후 해당 클라리언트를 별도의 스레드에서 처리하는 과정이다. 이를 통해 위의 작업도 클라이언트마다 별도의 스레드에서 작업이 가능하게 한다.

void ChatServer::listenForClients() {
    std::cout << "Waiting for clients..." << std::endl;
    sockaddr_in clientAddr;
    int clientAddrSize = sizeof(clientAddr);

    while (true) {
        SOCKET clientSocket = accept(listeningSocket_, reinterpret_cast<sockaddr*>(&clientAddr), &clientAddrSize);
        if (clientSocket == INVALID_SOCKET) {
            std::cerr << "Accept failed: " << WSAGetLastError() << std::endl;
            continue;
        }

        char clientIp[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &clientAddr.sin_addr, clientIp, INET_ADDRSTRLEN);
        std::cout << "Client connected from " << clientIp << ":" << ntohs(clientAddr.sin_port) << std::endl;

        clientSockets_.push_back(clientSocket);
        std::thread(&ChatServer::handleClient, this, clientSocket).detach();
    }
}
SOCKET clientSocket = accept(listeningSocket_, reinterpret_cast<sockaddr*>(&clientAddr), &clientAddrSize);
if (clientSocket == INVALID_SOCKET) {
  std::cerr << "Accept failed: " << WSAGetLastError() << std::endl;
  continue;
}

accept를 통해 연결이 성공적으로 수립되면 새로운 소켓을 반환한다.

  - reinterpret_cast

  - clientAddr이 가리키는 socakaddr_in 구조체의 메모리를 sockaddr 포인터 메모리로 타입 캐스팅해주는 역할

char clientIp[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientAddr.sin_addr, clientIp, INET_ADDRSTRLEN);

클라이언트의 IP주소를 문자열 형태로 변환하여 저장한다.

char clientIp[INET_ADDRSTRLEN];

  - 변환된 IP주소를 정장하기 위한 문자 배열

inet_ntop

  - 네트워크 주소를 문자열 형태로 전환하여 저장

clientSockets_.push_back(clientSocket);
std::thread(&ChatServer::handleClient, this, clientSocket).detach();

clientSockets_.push_back(clientSocket);

  - clientSocket을 위에서 말한 벡터에 저장

std::thread(&ChatServer::handleClient, this, clientSocket).detach();

  - handleClient 메소드를 새 스레드에서 실행시킨다.

  - 이를 통해 각 클라이언트를 독립적으로 처리하며, detach 호출로 생성된 스레드가 메인 스레드로부터 분리되어 각각 실행된다.

이 메소드를 통해 서버는 네트워크 상에서 클라이언트의 연결 요청을 기다리고, 각 클라이언트와의 연결을 설정하여 통신을 시작한다.

thread 메소드를 통해 멀티스레딩을 활용하여 각 클라이언트를 별도의 스레드에서 처리하여 서버의 동시 처리성을 늘려 클라이언트와의 통신을 효율적이게 한다.

void ChatServer::cleanup() {
    closesocket(listeningSocket_);
    for (SOCKET sock : clientSockets_) {
        closesocket(sock);
    }
    WSACleanup();
}

마지막으로 cleanup 메소드를 통해 서버 종료시에 필요한 자원을 정리하고 소켓을 종료시킨다.

리스닝 소켓을 닫고, clientSockets벡터에 저장되어 있는 소켓들을 순회하면서 소켓들을 닫아준다.

마지막으로 WSACleanup을 통해 Winsock을 정리하고 빠져 나오게된다.

 

이제 데이터베이스와 관련한 메서드를 작성할 차례이다.

우선은 데이터베이스에 접근할 기본적인 정보가 담긴 .env 파일을 파싱하는 함수를 만들었다.

std::map<std::string, std::string> ChatServer::loadEnv(const std::string& envFilePath) {
    std::map<std::string, std::string> env;
    std::ifstream file(envFilePath);
    std::string line;

    while (std::getline(file, line)) {
        std::istringstream is_line(line);
        std::string key;
        if (std::getline(is_line, key, '=')) {
            std::string value;
            if (key[0] == '#') continue; // 주석 처리된 줄은 무시
            if (std::getline(is_line, value)) {
                env[key] = value;
            }
        }
    }
    return env;
}

std::istringstream is_line(line);

  - 읽은 줄을 문자열 스트림으로 변환한다

std::getline(is_line, key, '=')

  - '='를 구분자로 사용하여 값을 읽어온다

key[0] == '#'

  - 주석으로 시작하는 줄은 무시한다

while (std::getline(file, line))

  - 루프를 통해 파일의 각 줄을 순차적으로 읽어온다

이를 통해 .env파일의 내용들을 파싱해서 가져올 수 있다.

 

그럼 이를 통해서 이제 데이터 베이스에 접속하는 메소드를 만들면된다.

void ChatServer::saveMessageToDatabase(const std::string& message) {
    try {
        auto env = loadEnv("\.env");
        std::string host = env["DB_HOST"];
        std::string port = env["DB_PORT"];
        std::string user = env["DB_USER"];
        std::string password = env["DB_PASSWORD"];
        std::string connectionStr = "tcp://" + host + ":" + port;

        // MySQL 드라이버 인스턴스 호출을 보호하기 위해 뮤텍스 사용
        std::lock_guard<std::mutex> guard(driverMutex);
        sql::mysql::MySQL_Driver* driver = sql::mysql::get_mysql_driver_instance();
        if (!driver) {
            std::cerr << "MySQL driver instance not found" << std::endl;
            return;
        }
        std::unique_ptr<sql::Connection> con(driver->connect(connectionStr, user, password));

        con->setSchema("chat_db");

        std::unique_ptr<sql::PreparedStatement> pstmt(con->prepareStatement("INSERT INTO messages(message) VALUES(?)"));

        pstmt->setString(1, message);

        pstmt->executeUpdate();
    }
    catch (sql::SQLException& e) {
        std::cerr << "SQLException in saveMessageToDatabase: " << e.what() << std::endl;
    }
}

loadEnv("\.env");

  - .env 파일로부터 데이터베이스 접속 정보(호스트, 포트, 사용자 이름, 비밀번호)를 로드한다.

sql::mysql::get_mysql_driver_instance();

  - MySQL 드라이버 인스턴스를 가져와 데이터베이스와 연결한다.

driver->connect  

  - 연결 문자열(connectionStr)을 구성하고, 이 메서드를 사용하여 데이터베이스에 연결한다.

con->setSchema("chat_db");

  - 연결된 데이터베이스의 스키마를 chat_db로 설정합니다.

std::unique_ptr<sql::PreparedStatement> pstmt(con->prepareStatement("INSERT INTO messages(message) VALUES(?)"));
pstmt->setString(1, message);
pstmt->executeUpdate();

  - PreparedStatement에 메시지를 설정(setString)

  - executeUpdate 메서드를 호출하여 쿼리를 실행한다.

  - 실행 결과로 메시지가 데이터베이스에 저장된다.

 

위의 메소드들을 통해 사용자 간의 메시지를 데이터베이스에 저장한다.다른 코드들과 다른 점들 이 몇가지 있는데, 

우선 try-catch 블록을 사용하여 sql::SQLException 예외를 처리했다.

구현중 데이터베이스 연결 실패, 쿼리 실행 오류 등 데이터베이스 작업 중 발생할 수 있는 예외 상황이 많이 발생하여, 자세한 디버깅 및 확인을 위하여 사용했다.

그리고 스레드 안정성을 위한 Mutex 사용 권장이 공식 문서에 있어서 driverMutex, std::lock_guard<std::mutex>를 사용하여 뮤텍스로 드라이버 인스턴스 호출 부분을 보호하였다.

또한 PreaparedStatement를 이용하여 SQL인젝션 방어도 구현해보았다.

 

  • 스레드 안전성: 데이터베이스 드라이버 인스턴스에 대한 접근은 스레드 안전하게 처리되어야 합니다. 이를 위해 std::lock_guard<std::mutex>를 사용하여 driverMutex 뮤텍스로 드라이버 인스턴스 호출 부분을 보호합니다.
  • 환경 설정 분리: 데이터베이스 접속 정보는 코드 내에 하드코딩되지 않고, 별도의 환경 설정 파일(.env)로부터 로드됩니다. 이는 보안과 유지 보수성을 높이는 좋은 방법입니다.
  • SQL 준비 명령문 사용: SQL 인젝션 공격을 방지하기 위해, 사용자 입력을 포함하는 쿼리 실행에는 준비 명령문(PreparedStatement)을 사용합니다.

 

전체 코드

chatServer.hpp

#ifndef CHATSERVER_HPP
#define CHATSERVER_HPP

#include <vector>
#include <string>
#include <WinSock2.h>
#include <ws2tcpip.h>
#include <iostream>
#include <fstream>
#include <sstream>
#include <mysql_connection.h>
#include <mysql_driver.h>
#include <cppconn/prepared_statement.h>
#include <stdexcept>
#include <mutex>
#include <map>

// Forward declaration of SQL classes
namespace sql {
    namespace mysql {
        class MySQL_Driver;
    }
}

class ChatServer {
public:
    ChatServer(unsigned short port);
    ~ChatServer();
    void start();

private:
    unsigned short port_;
    SOCKET listeningSocket_;
    std::vector<SOCKET> clientSockets_;
    std::mutex driverMutex;

    void initializeWinsock();
    std::map<std::string, std::string> loadEnv(const std::string& envFilePath);
    void saveMessageToDatabase(const std::string& message);
    void setupServerSocket();
    void listenForClients();
    void handleClient(SOCKET clientSocket);
    void cleanup();
};

#endif // CHATSERVER_HPP

chatServer.cpp

#include "ChatServer.hpp"

ChatServer::ChatServer(unsigned short port) : port_(port), listeningSocket_(INVALID_SOCKET) {
    initializeWinsock();
    setupServerSocket();
}

ChatServer::~ChatServer() {
    cleanup();
}

void ChatServer::start() {
    listenForClients();
}

void ChatServer::initializeWinsock() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0) {
        std::cerr << "WSAStartup failed with error: " << result << std::endl;
        std::cerr << "Detailed error: " << WSAGetLastError() << std::endl;
        std::cerr << "WSAStartup failed: " << result << std::endl;
        exit(1);
    }
    std::cout << "WSAStartup successful" << std::endl;
}

std::map<std::string, std::string> ChatServer::loadEnv(const std::string& envFilePath) {
    std::map<std::string, std::string> env;
    std::ifstream file(envFilePath);
    std::string line;

    while (std::getline(file, line)) {
        std::istringstream is_line(line);
        std::string key;
        if (std::getline(is_line, key, '=')) {
            std::string value;
            if (key[0] == '#') continue; // 주석 처리된 줄은 무시
            if (std::getline(is_line, value)) {
                env[key] = value;
            }
        }
    }
    return env;
}

void ChatServer::saveMessageToDatabase(const std::string& message) {
    try {
        auto env = loadEnv("\.env");
        std::string host = env["DB_HOST"];
        std::string port = env["DB_PORT"];
        std::string user = env["DB_USER"];
        std::string password = env["DB_PASSWORD"];
        std::string connectionStr = "tcp://" + host + ":" + port;

        // MySQL 드라이버 인스턴스 호출을 보호하기 위해 뮤텍스 사용
        std::lock_guard<std::mutex> guard(driverMutex);
        sql::mysql::MySQL_Driver* driver = sql::mysql::get_mysql_driver_instance();
        if (!driver) {
            std::cerr << "MySQL driver instance not found" << std::endl;
            return;
        }
        std::cout << "Driver instance created" << std::endl;
        std::unique_ptr<sql::Connection> con(driver->connect(connectionStr, user, password));
        std::cout << "Connected to database" << std::endl;

        con->setSchema("chat_db");
        std::cout << "Schema set to chat_db" << std::endl;

        std::unique_ptr<sql::PreparedStatement> pstmt(con->prepareStatement("INSERT INTO messages(message) VALUES(?)"));
        std::cout << "Prepared statement created" << std::endl;

        pstmt->setString(1, message);
        std::cout << "Message set to prepared statement" << std::endl;

        pstmt->executeUpdate();
        std::cout << "Message saved to database" << std::endl;
    }
    catch (sql::SQLException& e) {
        std::cerr << "SQLException in saveMessageToDatabase: " << e.what() << std::endl;
    }
}

void ChatServer::setupServerSocket() {
    listeningSocket_ = socket(AF_INET, SOCK_STREAM, 0);
    if (listeningSocket_ == INVALID_SOCKET) {
        std::cerr << "Failed to create socket: " << WSAGetLastError() << std::endl;
        WSACleanup();
        exit(1);
    }
    std::cout << "Socket created" << std::endl;

    sockaddr_in hint;
    hint.sin_family = AF_INET;
    hint.sin_port = htons(port_);
    hint.sin_addr.S_un.S_addr = INADDR_ANY;

    if (bind(listeningSocket_, (sockaddr*)&hint, sizeof(hint)) == SOCKET_ERROR) {
        std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
        closesocket(listeningSocket_);
        WSACleanup();
        exit(1);
    }

    std::cout << "Server is set up and bound to port " << port_ << std::endl;

    if (listen(listeningSocket_, SOMAXCONN) == SOCKET_ERROR) {
        std::cerr << "Listen failed: " << WSAGetLastError() << std::endl;
        closesocket(listeningSocket_);
        WSACleanup();
        exit(1);
    }

    std::cout << "Server is now listening on port " << port_ << std::endl;
}

void ChatServer::listenForClients() {
    std::cout << "Waiting for clients..." << std::endl;
    sockaddr_in clientAddr;
    int clientAddrSize = sizeof(clientAddr);

    while (true) {
        SOCKET clientSocket = accept(listeningSocket_, reinterpret_cast<sockaddr*>(&clientAddr), &clientAddrSize);
        if (clientSocket == INVALID_SOCKET) {
            std::cerr << "Accept failed: " << WSAGetLastError() << std::endl;
            continue;
        }

        char clientIp[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &clientAddr.sin_addr, clientIp, INET_ADDRSTRLEN);
        std::cout << "Client connected from " << clientIp << ":" << ntohs(clientAddr.sin_port) << std::endl;

        clientSockets_.push_back(clientSocket);
        std::thread(&ChatServer::handleClient, this, clientSocket).detach();
    }
}

void ChatServer::handleClient(SOCKET clientSocket) {
    char buf[4096];
    while (true) {
        ZeroMemory(buf, 4096);
        int bytesReceived = recv(clientSocket, buf, 4096, 0);
        if (bytesReceived <= 0) {
            closesocket(clientSocket);
            break;
        }

        std::string message(buf, bytesReceived);
        saveMessageToDatabase(message);

        for (SOCKET outSock : clientSockets_) {
            if (outSock != clientSocket) {
                send(outSock, buf, bytesReceived, 0);
            }
        }
    }
}

void ChatServer::cleanup() {
    closesocket(listeningSocket_);
    for (SOCKET sock : clientSockets_) {
        closesocket(sock);
    }
    WSACleanup();
}

main.cpp

#include "ChatServer.hpp"

int main() {
    ChatServer server(9999);
    server.start();
    return 0;
}

 

ChatServer 클래스를 통해 Windows 환경에서의 네트워크 프로그래밍 기본, 멀티스레딩 처리, 데이터베이스 연결 및 SQL 쿼리 실행 등 서버 애플리케이션 구축에 필요한 다양한 개념과 구현 방법을 공부할 수 있었다. 이 과정을 통해 TCP/IP 기반의 채팅 서버를 구현하는 방법과, 데이터베이스를 이용한 메시지 저장 및 관리 기법을 다시 한 번 잘 공부하게 된 기회였다.