C++로 웹 서버 만들기: HTTP 서버 구현 프로젝트 🖥️🌐

콘텐츠 대표 이미지 - C++로 웹 서버 만들기: HTTP 서버 구현 프로젝트 🖥️🌐

 

 

웹 개발의 세계는 끊임없이 진화하고 있습니다. 그 중심에서 C++은 여전히 강력한 도구로 자리 잡고 있죠. 오늘은 C++을 사용하여 HTTP 서버를 구현하는 흥미진진한 여정을 떠나보려고 합니다. 이 프로젝트는 단순히 코드를 작성하는 것을 넘어서, 웹의 근간을 이루는 기술을 직접 만들어보는 소중한 경험이 될 것입니다.

우리의 목표는 간단하면서도 효율적인 HTTP 서버를 만드는 것입니다. 이 과정에서 네트워크 프로그래밍의 기초부터 고급 기술까지 다양한 개념을 다루게 될 것입니다. 재능넷과 같은 플랫폼에서 활동하는 개발자들에게 이런 프로젝트는 실력 향상의 좋은 기회가 될 수 있습니다.

 

자, 그럼 이제 본격적으로 시작해볼까요? 🚀

1. 프로젝트 개요 및 준비 📋

1.1 프로젝트 목표

우리의 주요 목표는 다음과 같습니다:

  • 기본적인 HTTP 요청을 처리할 수 있는 서버 구현
  • 멀티스레딩을 통한 동시 연결 처리
  • 정적 파일 서빙 기능 구현
  • 간단한 라우팅 시스템 개발
  • 로깅 및 에러 처리 구현

이 프로젝트를 통해 우리는 네트워크 프로그래밍, 동시성 처리, 파일 I/O, 문자열 처리 등 C++의 다양한 측면을 깊이 있게 다루게 될 것입니다.

1.2 개발 환경 설정

먼저, 우리의 개발 환경을 설정해봅시다. 이 프로젝트를 위해 다음과 같은 도구들이 필요합니다:

  • C++ 컴파일러: GCC나 Clang을 추천합니다.
  • IDE 또는 텍스트 에디터: Visual Studio Code, CLion, 또는 Sublime Text 등을 사용할 수 있습니다.
  • 버전 관리 도구: Git을 사용하여 프로젝트를 관리합니다.
  • 빌드 도구: CMake를 사용하여 프로젝트를 빌드합니다.

개발 환경이 준비되었다면, 이제 프로젝트 구조를 설계해볼까요?

1.3 프로젝트 구조

우리의 프로젝트는 다음과 같은 구조를 가질 것입니다:

Project Structure src/ - main.cpp - server.cpp - request_handler.cpp include/ - server.h - request_handler.h

이 구조는 코드의 모듈성과 재사용성을 높여줄 것입니다. src/ 디렉토리에는 실제 구현 코드를, include/ 디렉토리에는 헤더 파일을 저장합니다.

1.4 필요한 라이브러리

C++ 표준 라이브러리 외에도, 다음과 같은 추가 라이브러리들을 사용할 예정입니다:

  • Boost.Asio: 비동기 I/O 작업을 위한 라이브러리
  • nlohmann/json: JSON 파싱을 위한 라이브러리
  • spdlog: 로깅을 위한 라이브러리

이 라이브러리들은 우리의 서버 구현을 더욱 강력하고 효율적으로 만들어줄 것입니다.

💡 Pro Tip: 프로젝트를 시작하기 전에 모든 필요한 라이브러리를 설치하고 테스트해보세요. 이는 나중에 발생할 수 있는 문제를 미리 방지할 수 있습니다.

이제 우리의 프로젝트 기반이 준비되었습니다. 다음 섹션에서는 실제 서버 구현을 시작해보겠습니다. 흥미진진한 코딩 여정이 우리를 기다리고 있습니다! 🚀

2. 기본 서버 구조 구현 🏗️

2.1 서버 클래스 설계

우리의 HTTP 서버의 핵심은 Server 클래스가 될 것입니다. 이 클래스는 클라이언트의 연결을 수신하고, 요청을 처리하며, 응답을 전송하는 역할을 담당합니다.

먼저 include/server.h 파일에 Server 클래스의 선언을 작성해봅시다:

#ifndef HTTP_SERVER_H
#define HTTP_SERVER_H

#include <boost/asio.hpp>
#include <string>
#include <memory>

class Server {
public:
    Server(const std::string& address, unsigned short port);
    void run();

private:
    void do_accept();
    void handle_request(boost::asio::ip::tcp::socket socket);

    boost::asio::io_context io_context_;
    boost::asio::ip::tcp::acceptor acceptor_;
};

#endif // HTTP_SERVER_H

이 클래스는 서버의 주소와 포트를 생성자 매개변수로 받아 초기화합니다. run() 메서드는 서버를 시작하고, do_accept()는 새로운 연결을 수락하며, handle_request()는 클라이언트의 요청을 처리합니다.

2.2 서버 클래스 구현

이제 src/server.cpp 파일에 Server 클래스의 구현을 작성해봅시다:

#include "server.h"
#include <iostream>

Server::Server(const std::string& address, unsigned short port)
    : acceptor_(io_context_, boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address(address), port))
{
    do_accept();
}

void Server::run()
{
    std::cout << "Server running on http://" << acceptor_.local_endpoint().address().to_string() 
              << ":" << acceptor_.local_endpoint().port() << std::endl;
    io_context_.run();
}

void Server::do_accept()
{
    acceptor_.async_accept(
        [this](boost::system::error_code ec, boost::asio::ip::tcp::socket socket)
        {
            if (!ec)
            {
                handle_request(std::move(socket));
            }

            do_accept();
        });
}

void Server::handle_request(boost::asio::ip::tcp::socket socket)
{
    // 여기에 요청 처리 로직을 구현할 예정입니다.
    // 지금은 간단한 "Hello, World!" 메시지만 보내봅시다.
    std::string response = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, World!";
    boost::asio::write(socket, boost::asio::buffer(response));
}

이 구현에서 주목할 점은 다음과 같습니다:

  • 서버는 비동기 I/O를 사용하여 효율적으로 여러 연결을 처리할 수 있습니다.
  • do_accept() 메서드는 재귀적으로 호출되어 계속해서 새로운 연결을 수락합니다.
  • handle_request() 메서드는 현재 매우 간단하지만, 이후에 더 복잡한 로직을 추가할 것입니다.

2.3 메인 함수 구현

마지막으로, src/main.cpp 파일에 메인 함수를 구현하여 서버를 실행해봅시다:

#include "server.h"
#include <iostream>

int main()
{
    try
    {
        Server server("0.0.0.0", 8080);
        server.run();
    }
    catch (std::exception& e)
    {
        std::cerr << "Exception: " << e.what() << "\n";
    }

    return 0;
}

이 메인 함수는 서버 객체를 생성하고 실행합니다. 서버는 모든 인터페이스(0.0.0.0)에서 8080 포트로 리스닝하게 됩니다.

2.4 빌드 및 테스트

이제 우리의 기본적인 서버 구조가 완성되었습니다. CMake를 사용하여 프로젝트를 빌드해봅시다. 루트 디렉토리에 CMakeLists.txt 파일을 다음과 같이 작성합니다:

cmake_minimum_required(VERSION 3.10)
project(http_server)

set(CMAKE_CXX_STANDARD 17)

find_package(Boost REQUIRED COMPONENTS system)

include_directories(${Boost_INCLUDE_DIRS} include)

add_executable(http_server src/main.cpp src/server.cpp)
target_link_libraries(http_server ${Boost_LIBRARIES})

이제 다음 명령어로 프로젝트를 빌드할 수 있습니다:

mkdir build
cd build
cmake ..
make

빌드가 완료되면 ./http_server 명령어로 서버를 실행할 수 있습니다. 웹 브라우저에서 http://localhost:8080에 접속하면 "Hello, World!" 메시지를 볼 수 있을 것입니다.

🎉 축하합니다! 여러분은 방금 C++로 기본적인 HTTP 서버를 구현했습니다. 이는 우리 프로젝트의 기반이 될 것입니다.

다음 섹션에서는 이 기본 구조를 확장하여 더 복잡한 HTTP 요청을 처리하고, 정적 파일을 서빙하며, 라우팅 시스템을 구현해볼 것입니다. 계속해서 흥미진진한 여정을 이어가봅시다! 🚀

3. HTTP 요청 처리 구현 🔍

3.1 HTTP 요청 파싱

이제 우리의 서버가 실제 HTTP 요청을 이해하고 처리할 수 있도록 만들어봅시다. 먼저 HTTP 요청을 파싱하는 클래스를 만들어보겠습니다.

include/http_request.h 파일을 생성하고 다음과 같이 작성합니다:

#ifndef HTTP_REQUEST_H
#define HTTP_REQUEST_H

#include <string>
#include <map>

class HttpRequest {
public:
    HttpRequest() = default;
    void parse(const std::string& raw_request);

    std::string method;
    std::string uri;
    std::string http_version;
    std::map<std::string, std::string> headers;
    std::string body;
};

#endif // HTTP_REQUEST_H

이제 src/http_request.cpp 파일을 생성하고 parse 메서드를 구현합니다:

#include "http_request.h"
#include <sstream>

void HttpRequest::parse(const std::string& raw_request) {
    std::istringstream request_stream(raw_request);
    std::string line;

    // 요청 라인 파싱
    std::getline(request_stream, line);
    std::istringstream request_line(line);
    request_line >> method >> uri >> http_version;

    // 헤더 파싱
    while (std::getline(request_stream, line) && line != "\r") {
        auto colon_pos = line.find(':');
        if (colon_pos != std::string::npos) {
            auto key = line.substr(0, colon_pos);
            auto value = line.substr(colon_pos + 1);
            // 앞뒤 공백 제거
            value.erase(0, value.find_first_not_of(" \t"));
            value.erase(value.find_last_not_of(" \t") + 1);
            headers[key] = value;
        }
    }

    // 바디 파싱
    std::string body_str((std::istreambuf_iterator<char>(request_stream)),
                          std::istreambuf_iterator<char>());
    body = body_str;
}

3.2 HTTP 응답 생성

다음으로, HTTP 응답을 생성하는 클래스를 만들어봅시다. include/http_response.h 파일을 생성합니다:

#ifndef HTTP_RESPONSE_H
#define HTTP_RESPONSE_H

#include <string>
#include <map>

class HttpResponse {
public:
    HttpResponse() = default;
    void set_status(int status_code, const std::string& status_message);
    void set_header(const std::string& key, const std::string& value);
    void set_body(const std::string& body);
    std::string to_string() const;

private:
    int status_code_ = 200;
    std::string status_message_ = "OK";
    std::map<std::string, std::string> headers_;
    std::string body_;
};

#endif // HTTP_RESPONSE_H

src/http_response.cpp 파일을 생성하고 메서드들을 구현합니다:

#include "http_response.h"
#include <sstream>

void HttpResponse::set_status(int status_code, const std::string& status_message) {
    status_code_ = status_code;
    status_message_ = status_message;
}

void HttpResponse::set_header(const std::string& key, const std::string& value) {
    headers_[key] = value;
}

void HttpResponse::set_body(const std::string& body) {
    body_ = body;
    headers_["Content-Length"] = std::to_string(body_.length());
}

std::string HttpResponse::to_string() const {
    std::ostringstream response;
    response << "HTTP/1.1 " << status_code_ << " " << status_message_ << "\r\n";
    
    for (const auto& header : headers_) {
        response << header.first << ": " << header.second << "\r\n";
    }
    
    response << "\r\n" << body_;
    return response.str();
}

3.3 요청 핸들러 개선

이제 우리의 Server 클래스의 handle_request 메서드를 개선하여 실제 HTTP 요청을 처리하고 응답을 생성하도록 만들어봅시다. src/server.cpp 파일을 다음과 같이 수정합니다:

#include "server.h"
#include "http_request.h"
#include "http_response.h"
#include <iostream>
#include <vector>

// ... (이전 코드는 그대로 유지)

void Server::handle_request(boost::asio::ip::tcp::socket socket)
{
    auto self(shared_from_this());
    socket.async_read_some(boost::asio::buffer(data_, max_length),
        [this, self, &socket](boost::system::error_code ec, std::size_t length)
        {
            if (!ec)
            {
                HttpRequest request;
                request.parse(std::string(data_, length));

                HttpResponse response;
                
                // 간단한 라우팅 로직
                if (request.uri == "/") {
                    response.set_status(200, "OK");
                    response.set_body("<html><body><h1>Welcome to our C++ HTTP Server!</h1></body></html>");
                } else if (request.uri == "/about") {
                    response.set_status(200, "OK");
                    response.set_body("<html><body><h1>About Us</h1><p>We are passionate C++ developers.</p></body></html>");
                } else {
                    response.set_status(404, "Not Found");
                    response.set_body("<html><body><h1>404 Not Found</h1></body></html>");
                }

                response.set_header("Content-Type", "text/html");
                
                std::string response_str = response.to_string();
                boost::asio::async_write(socket, boost::asio::buffer(response_str),
                    [this, self, &socket](boost::system::error_code ec, std::size_t /*length*/)
                    {
                        if (!ec)
                        {
                            handle_request(std::move(socket));
                        }
                    });
            }
        });
}

이 개선된 버전에서는 다음과 같은 변경사항이 있습니다:

  • HTTP 요청을 파싱합니다.
  • 간단한 라우팅 로직을 구현하여 다른 URI에 대해 다른 응답을 생성합니다.
  • HTTP 응답을 생성하고 클라이언트에게 전송합니다.

3.4 테스트 및 결과 확인

이제 우리의 서버를 다시 빌드하고 실행해봅시다. 웹 브라우저에서 다음 URL들을 테스트해볼 수 있습니다:

  • http://localhost:8080/ - 환영 메시지를 표시합니다.
  • http://localhost:8080/about - "About Us" 페이지를 표시합니다.
  • http://localhost:8080/nonexistent - 404 Not Found 오류를 표시합니다.
🎉 축하합니다! 여러분은 이제 기본적인 HTTP 요청 처리와 라우팅 기능을 갖춘 웹 서버를 구현했습니다. 이는 더 복잡한 웹 애플리케이션을 개발하는 기반이 될 것입니다.

다음 섹션에서는 정적 파일 서빙, 더 복잡한 라우팅 시스템, 그리고 보안 기능 등을 추가하여 우리의 서버를 더욱 강력하게 만들어볼 것입니다. C++로 웹 서버를 구현하는 이 여정이 얼마나 흥미진진한지 느껴지시나요? 계속해서 더 깊이 파고들어봅시다! 🚀

4. 정적 파일 서빙 구현 📁

4.1 파일 시스템 작업

정적 파일을 서빙하기 위해서는 파일 시스템과 상호 작용해야 합니다. C++17에서 도입된 <filesystem> 라이브러리를 사용하여 이 작업을 수행할 수 있습니다.

먼저, include/file_handler.h 파일을 생성하고 다음과 같이 작성합니다:

#ifndef FILE_HANDLER_H
#define FILE_HANDLER_H

#include <string>
#include <optional>

class FileHandler {
public:
    static std::optional<std::string> read_file(const std::string& path);
    static std::string get_mime_type(const std::string& path);
};

#endif // FILE_HANDLER_H

이제 src/file_handler.cpp 파일을 생성하고 다음과 같이 구현합니다:

#include "file_handler.h"
#include <fstream>
#include <sstream>
#include <filesystem>

std::optional<std::string> FileHandler::read_file(const std::string& path) {
    std::ifstream file(path, std::ios::binary);
    if (!file) {
        return std::nullopt;
    }

    std::ostringstream content;
    content << file.rdbuf();
    return content.str();
}

std::string FileHandler::get_mime_type(const std::string& path) {
    std::filesystem::path file_path(path);
    std::string extension = file_path.extension().string();

    if (extension == ".html" || extension == ".htm") return "text/html";
    if (extension == ".css") return "text/css";
    if (extension == ".js") return "application/javascript";
    if (extension == ".jpg" || extension == ".jpeg") return "image/jpeg";
    if (extension == ".png") return "image/png";
    if (extension == ".gif") return "image/gif";
    
    return "application/octet-stream";  // 기본 MIME 타입
}

4.2 정적 파일 서빙 로직 추가

이제 Server 클래스에 정적 파일 서빙 기능을 추가해봅시다. src/server.cpp 파일을 다음과 같이 수정합니다:

#include "server.h"
#include "http_request.h"
#include "http_response.h"
#include "file_handler.h"
#include <iostream>
#include <filesystem>

// ... (이전 코드는 그대로 유지)

void Server::handle_request(boost::asio::ip::tcp::socket socket)
{
    auto self(shared_from_this());
    socket.async_read_some(boost::asio::buffer(data_, max_length),
        [this, self, &socket](boost::system::error_code ec, std::size_t length)
        {
            if (!ec)
            {
                HttpRequest request;
                request.parse(std::string(data_, length));

                HttpResponse response;
                
                // 정적 파일 서빙 로직
                std::string file_path = "public" + request.uri;
                if (std::filesystem::exists(file_path) && !std::filesystem::is_directory(file_path)) {
                    auto file_content = FileHandler::read_file(file_path);
                    if (file_content) {
                        response.set_status(200, "OK");
                        response.set_body(*file_content);
                        response.set_header("Content-Type", FileHandler::get_mime_type(file_path));
                    } else {
                        response.set_status(500, "Internal Server Error");
                        response.set_body("<html><body><h1>500 Internal Server Error</h1></body></html>");
                    }
                } else {
                    // 기존의 라우팅 로직
                    if (request.uri == "/") {
                        response.set_status(200, "OK");
                          네, 계속해서 C++로 웹 서버를 만드는 과정을 설명해드리겠습니다.

<pre><code>                        response.set_body("<html><body><h1>Welcome to our C++ HTTP Server!</h1></body></html>");
                        response.set_header("Content-Type", "text/html");
                    } else if (request.uri == "/about") {
                        response.set_status(200, "OK");
                        response.set_body("<html><body><h1>About Us</h1><p>We are passionate C++ developers.</p></body></html>");
                        response.set_header("Content-Type", "text/html");
                    } else {
                        response.set_status(404, "Not Found");
                        response.set_body("<html><body><h1>404 Not Found</h1></body></html>");
                        response.set_header("Content-Type", "text/html");
                    }
                }
                
                std::string response_str = response.to_string();
                boost::asio::async_write(socket, boost::asio::buffer(response_str),
                    [this, self, &socket](boost::system::error_code ec, std::size_t /*length*/)
                    {
                        if (!ec)
                        {
                            handle_request(std::move(socket));
                        }
                    });
            }
        });
}

4.3 정적 파일 준비

이제 서버가 정적 파일을 제공할 수 있도록 준비되었습니다. 프로젝트 루트 디렉토리에 'public' 폴더를 만들고 몇 가지 테스트 파일을 추가해봅시다:

  1. public/index.html:
    <!DOCTYPE html>
    <html>
    <head>
        <title>Welcome to Our C++ HTTP Server</title>
        <link rel="stylesheet" href="styles.css">
    </head>
    <body>
        <h1>Welcome to Our C++ HTTP Server</h1>
        <p>This page is served by our custom C++ HTTP server.</p>
        <img src="cpp_logo.png" alt="C++ Logo">
        <script src="script.js"></script>
    </body>
    </html>
  2. public/styles.css:
    body {
        font-family: Arial, sans-serif;
        margin: 0;
        padding: 20px;
        background-color: #f0f0f0;
    }
    h1 {
        color: #333;
    }
    img {
        max-width: 200px;
    }
  3. public/script.js:
    console.log("Hello from C++ HTTP Server!");
  4. C++ 로고 이미지를 public/cpp_logo.png로 저장합니다.

4.4 테스트 및 결과 확인

이제 서버를 다시 빌드하고 실행한 후, 웹 브라우저에서 http://localhost:8080/index.html에 접속해봅시다. 우리가 만든 HTML 페이지가 스타일시트, 자바스크립트, 이미지와 함께 올바르게 로드되는 것을 확인할 수 있을 것입니다.

🎉 축하합니다! 여러분은 이제 정적 파일 서빙 기능을 갖춘 웹 서버를 구현했습니다. 이는 실제 웹 애플리케이션을 호스팅하는 데 큰 걸음을 내딛은 것입니다.

5. 보안 고려사항 🔒

정적 파일 서빙을 구현할 때는 보안에 특히 주의를 기울여야 합니다. 다음과 같은 보안 문제를 고려해야 합니다:

  1. 디렉토리 탐색 방지: 사용자가 서버의 다른 디렉토리에 접근하지 못하도록 해야 합니다.
  2. 파일 확장자 검사: 허용된 파일 타입만 서빙하도록 제한해야 합니다.
  3. 최대 파일 크기 제한: 너무 큰 파일이 서빙되어 서버 리소스를 고갈시키는 것을 방지해야 합니다.

이러한 보안 기능을 구현하기 위해 FileHandler 클래스를 다음과 같이 개선할 수 있습니다:

class FileHandler {
public:
    static std::optional<std::string> read_file(const std::string& path);
    static std::string get_mime_type(const std::string& path);
    static bool is_path_safe(const std::string& path);
    static bool is_file_type_allowed(const std::string& path);
    static bool is_file_size_allowed(const std::string& path, size_t max_size);
};

// FileHandler.cpp
bool FileHandler::is_path_safe(const std::string& path) {
    std::filesystem::path canonical_path = std::filesystem::canonical(path);
    std::filesystem::path public_dir = std::filesystem::canonical("public");
    return canonical_path.string().find(public_dir.string()) == 0;
}

bool FileHandler::is_file_type_allowed(const std::string& path) {
    static const std::set<std::string> allowed_extensions = {".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif"};
    std::filesystem::path file_path(path);
    return allowed_extensions.find(file_path.extension().string()) != allowed_extensions.end();
}

bool FileHandler::is_file_size_allowed(const std::string& path, size_t max_size) {
    return std::filesystem::file_size(path) <= max_size;
}

그리고 Server::handle_request 메서드에서 이러한 검사를 수행합니다:

if (std::filesystem::exists(file_path) && !std::filesystem::is_directory(file_path)) {
    if (!FileHandler::is_path_safe(file_path)) {
        response.set_status(403, "Forbidden");
        response.set_body("<html><body><h1>403 Forbidden</h1></body></html>");
    } else if (!FileHandler::is_file_type_allowed(file_path)) {
        response.set_status(403, "Forbidden");
        response.set_body("<html><body><h1>403 Forbidden: File type not allowed</h1></body></html>");
    } else if (!FileHandler::is_file_size_allowed(file_path, 10 * 1024 * 1024)) {  // 10MB 제한
        response.set_status(403, "Forbidden");
        response.set_body("<html><body><h1>403 Forbidden: File too large</h1></body></html>");
    } else {
        auto file_content = FileHandler::read_file(file_path);
        if (file_content) {
            response.set_status(200, "OK");
            response.set_body(*file_content);
            response.set_header("Content-Type", FileHandler::get_mime_type(file_path));
        } else {
            response.set_status(500, "Internal Server Error");
            response.set_body("<html><body><h1>500 Internal Server Error</h1></body></html>");
        }
    }
} else {
    // 기존의 라우팅 로직
    // ...
}

이러한 보안 기능을 추가함으로써, 우리의 웹 서버는 더욱 안전하고 견고해질 것입니다.

💡 Pro Tip: 실제 프로덕션 환경에서는 더 많은 보안 고려사항이 필요합니다. SSL/TLS 암호화, DDoS 방어, 입력 유효성 검사 등을 고려해야 합니다.

이제 우리의 C++ HTTP 서버는 기본적인 라우팅, 정적 파일 서빙, 그리고 몇 가지 중요한 보안 기능을 갖추게 되었습니다. 이는 더 복잡한 웹 애플리케이션을 개발하는 데 훌륭한 기반이 될 것입니다. 다음 단계에서는 데이터베이스 연동, RESTful API 구현, 웹소켓 지원 등을 추가하여 서버의 기능을 더욱 확장할 수 있습니다.

C++로 웹 서버를 구현하는 이 여정이 얼마나 흥미진진한가요? 우리는 저수준 네트워크 프로그래밍부터 고수준 웹 기술까지 다양한 개념을 다루었습니다. 이는 C++의 강력함과 유연성을 잘 보여주는 예시입니다. 계속해서 더 깊이 파고들어 더 많은 기능을 추가해보세요! 🚀