본문으로 건너뛰기
Previous
Next
C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지

C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지

C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지

이 글의 핵심

C++ 멀티스레드 네트워크 서버 : io_context 풀·strand·data race…. 실무에서 겪은 문제·시스템 아키텍처.

들어가며: “멀티스레드 서버에서 data race가 발생해요”

문제 상황

// ❌ 문제: 여러 스레드에서 run()을 돌리면 data race 발생
boost::asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
// 4개 스레드에서 동시에 run()
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
    threads.emplace_back([&io]() { io.run(); });
}
// 연결 A의 read 완료 → 스레드 1에서 처리
// 연결 A의 write 완료 → 스레드 3에서 처리
// 💥 같은 소켓을 여러 스레드가 동시에 건드림 → 프로토콜 꼬임!

왜 이런 일이 발생할까요? io_context::run()을 여러 스레드에서 호출하면, 완료된 비동기 핸들러가 임의의 스레드에 분배됩니다. 한 연결의 async_read_some 완료 핸들러가 스레드 1에서, async_write 완료 핸들러가 스레드 3에서 실행되면, 같은 소켓/버퍼를 동시에 접근하여 data race가 발생합니다. 추가 문제:

  • 공유 데이터(연결 목록, 채팅 방)를 여러 스레드가 동시에 수정 → race condition
  • 세션 객체가 비동기 연산 완료 전에 소멸 → use-after-free
  • strand 없이 read/write 순서가 뒤섞임 → 프로토콜 오류

추가 문제 시나리오

시나리오증상원인
채팅 서버A가 보낸 메시지가 B보다 늦게 도착read/write 핸들러가 다른 스레드에서 실행되어 순서 꼬임
게임 서버플레이어 위치가 순간이동처럼 튐공유 게임 상태를 뮤텍스 없이 동시 수정
파일 전송전송 중 크래시, 데이터 손상버퍼를 읽는 동안 다른 스레드가 같은 버퍼에 쓰기
연결 폭주서버 다운, 메모리 폭발세션 수명 관리 실패로 use-after-free 또는 메모리 누수
해결책:
  1. strand: 한 연결에 대한 모든 연산을 순서대로 실행
  2. shared_ptr 세션: 비동기 핸들러에서 수명 유지
  3. 뮤텍스/strand: 공유 상태 동기화 목표:
  • 스레드 풀에서 run() 호출
  • strand로 연결별 순서 보장
  • 연결별 세션 객체와 수명 관리
  • 공유 자원 동기화 (뮤텍스 vs strand) 요구 환경: Boost.Asio 1.70 이상 이 글을 읽으면:
  • 멀티스레드 Asio 서버의 올바른 구조를 이해할 수 있습니다.
  • data race 없이 안전한 서버를 구현할 수 있습니다.
  • 프로덕션 수준의 스레드 풀 서버를 만들 수 있습니다.

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

1. 시스템 아키텍처

전체 구조

flowchart TB
    subgraph Clients[클라이언트들]
        C1[클라이언트 1]
        C2[클라이언트 2]
        C3[클라이언트 N]
    end
    
    subgraph Server[멀티스레드 서버]
        Acceptor[acceptor]
        IO[io_context]
        
        subgraph ThreadPool[스레드 풀]
            T1[스레드 1]
            T2[스레드 2]
            T3[스레드 3]
            T4[스레드 4]
        end
        
        subgraph Sessions[세션들]
            S1[세션 A + strand]
            S2[세션 B + strand]
            S3[세션 C + strand]
        end
        
        Shared[공유 상태\n채팅방/연결목록]
    end
    
    C1 --> Acceptor
    C2 --> Acceptor
    C3 --> Acceptor
    
    Acceptor --> S1
    Acceptor --> S2
    Acceptor --> S3
    
    IO --> T1
    IO --> T2
    IO --> T3
    IO --> T4
    
    S1 --> IO
    S2 --> IO
    S3 --> IO
    
    S1 -.->|strand로 보호| Shared
    S2 -.->|strand로 보호| Shared
    S3 -.->|strand로 보호| Shared
    
    style IO fill:#4caf50
    style Shared fill:#ff9800

스레딩 모델

// 실행 예제
sequenceDiagram
    participant Main as 메인 스레드
    participant IO as io_context
    participant T1 as 스레드 1
    participant T2 as 스레드 2
    participant T3 as 스레드 3
    
    Main->>IO: work_guard 생성
    Main->>T1: run()
    Main->>T2: run()
    Main->>T3: run()
    
    Note over T1,T3: 핸들러가 임의의 스레드에 분배됨
    
    IO->>T1: 연결 A read 완료 → 핸들러 실행
    IO->>T3: 연결 B write 완료 → 핸들러 실행
    IO->>T2: 연결 A write 완료 → 핸들러 실행
    
    Note over T1,T3: strand 사용 시: 같은 연결의 핸들러는 순서대로 실행

핵심: 같은 io_context에 대해 여러 스레드가 run()을 호출하면, Asio가 완료된 핸들러를 라운드 로빈 등으로 분배합니다. strand를 사용하면 특정 핸들러들이 한 번에 하나씩, 순서대로 실행됩니다.

2. 여러 스레드

일상 비유로 이해하기: 동시성은 주방에서 여러 요리를 동시에 하는 것과 비슷합니다. 한 명의 요리사(싱글 스레드)가 국을 끓이다가 불을 줄이고, 그 사이에 야채를 썰고, 다시 국을 확인하는 식이죠. 반면 병렬성은 요리사 여러 명(멀티 스레드)이 각자 다른 요리를 동시에 만드는 겁니다. 에서 run

기본 패턴

io_context 하나에 make_work_guard로 “할 일이 있다”를 유지한 뒤, run()4개의 스레드에서 동시에 호출합니다. Asio는 완료된 비동기 연산의 핸들러를 이 스레드들 중 하나에 분배하므로, 연결이 많아지면 CPU 코어를 나눠 쓸 수 있습니다.

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
void thread_pool_basic() {
    boost::asio::io_context io;
    
    // work_guard: run()이 "할 일 없음"으로 즉시 종료되지 않게 함
    // - 생성 시 io_context의 작업 카운트 증가
    // - reset() 시 감소 → 0이 되면 run() 종료
    auto work = boost::asio::make_work_guard(io);
    
    std::vector<std::thread> threads;
    const int num_threads = 4;
    
    for (int i = 0; i < num_threads; ++i) {
        threads.emplace_back([&io]() {
            io.run();  // 블로킹, 핸들러 실행
        });
    }
    
    // ....서버 동작 (acceptor, 세션 등) ...
    
    // 종료 시퀀스
    work.reset();   // work_guard 해제
    io.stop();      // run() 중단 지시
    
    for (auto& t : threads) {
        t.join();
    }
    
    std::cout << "Server stopped\n";
}

스레드 풀 클래스 (재사용 가능)

#include <boost/asio.hpp>
#include <thread>
#include <vector>
#include <functional>
class ThreadPoolServer {
    boost::asio::io_context io_;
    // executor_work_guard: Boost 1.70+ (make_work_guard와 동일)
    boost::asio::executor_work_guard<boost::asio::io_context::executor_type> work_;
    std::vector<std::thread> threads_;
    
public:
    explicit ThreadPoolServer(size_t num_threads = std::thread::hardware_concurrency())
        : work_(boost::asio::make_work_guard(io_)) {
        
        threads_.reserve(num_threads);
        for (size_t i = 0; i < num_threads; ++i) {
            threads_.emplace_back([this]() {
                io_.run();
            });
        }
        
        std::cout << "Thread pool started with " << num_threads << " threads\n";
    }
    
    ~ThreadPoolServer() {
        stop();
    }
    
    void stop() {
        work_.reset();
        io_.stop();
        
        for (auto& t : threads_) {
            if (t.joinable()) {
                t.join();
            }
        }
        threads_.clear();
    }
    
    // 작업 등록 (스레드 안전)
    template<typename F>
    void post(F&& f) {
        boost::asio::post(io_, std::forward<F>(f));
    }
    
    boost::asio::io_context& get_io_context() {
        return io_;
    }
};
  • 같은 io에 대해 run()이 여러 스레드에서 호출되면, 핸들러가 스레드 풀에 분산됩니다.
  • work_guard가 없으면 run()이 즉시 반환할 수 있으므로, 서버가 계속 동작해야 할 때 필수입니다.

3. strand로 순서 보장

strand란?

strand는 “이 executor를 통해 예약된 핸들러는 한 번에 하나씩, 순서대로 실행된다”는 제약을 줍니다. bind_executor(s, handler)로 핸들러를 strand s에 묶으면, 그 핸들러는 다른 스레드에서 돌아도 s를 통한 다른 작업과 겹치지 않습니다. 한 연결의 async_read_some 완료 → 처리 → async_write를 모두 같은 strand로 실행하면, 그 연결에 대한 읽기/쓰기 순서가 보장되어 프로토콜이 꼬이지 않습니다.

strand 사용 예시

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
using boost::asio::ip::tcp;
using boost::system::error_code;
void strand_example(boost::asio::io_context& io, tcp::socket& socket) {
    // 연결별 strand 생성: 이 연결의 모든 I/O가 이 strand를 통해 실행됨
    auto strand = boost::asio::make_strand(io);
    
    std::array<char, 1024> buffer;
    
    // ✅ bind_executor: 핸들러를 strand에 묶음
    socket.async_read_some(
        boost::asio::buffer(buffer),
        boost::asio::bind_executor(strand, [&socket, &buffer](error_code ec, size_t bytes) {
            if (ec) return;
            
            // 이 핸들러와 아래 async_write 핸들러는 절대 동시에 실행되지 않음
            boost::asio::async_write(
                socket,
                boost::asio::buffer(buffer, bytes),
                boost::asio::bind_executor(strand,  {
                    // 쓰기 완료 → 다시 읽기 등록 (같은 strand로)
                })
            );
        })
    );
}

strand vs 뮤텍스

상황strand뮤텍스
연결별 read/write 순서✅ 권장❌ 복잡
공유 데이터 접근✅ post로 직렬화✅ lock/unlock
데드락 위험없음있음
성능락 없음락 오버헤드
strand는 락을 사용하지 않고, Asio 내부적으로 핸들러 큐를 관리하여 순서를 보장합니다.

4. 세션과 수명 관리

핵심 원칙

  • 연결 하나 = 세션 객체 하나. 소켓, 버퍼, strand를 멤버로 가짐.
  • shared_from_this 또는 세션을 shared_ptr로 보관하고, 비동기 연산 완료 시 그 포인터를 캡처해 수명을 유지.
  • 연결 종료 시 참조를 줄여 소멸.

완전한 세션 클래스

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <array>
using boost::asio::ip::tcp;
using boost::system::error_code;
class Session : public std::enable_shared_from_this<Session> {
    tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    std::array<char, 4096> buffer_;
    
public:
    Session(tcp::socket socket)
        : socket_(std::move(socket)),
          strand_(boost::asio::make_strand(socket_.get_executor())) {}
    
    void start() {
        // shared_from_this(): 비동기 핸들러에서 self 캡처 → 세션 수명 유지
        // 핸들러 실행 중에는 세션이 소멸하지 않음
        do_read();
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
                if (ec) {
                    if (ec != boost::asio::error::operation_aborted) {
                        std::cerr << "Read error: " << ec.message() << "\n";
                    }
                    return;  // self 참조 해제 → 세션 소멸 가능
                }
                
                do_write(bytes);
            })
        );
    }
    
    void do_write(size_t bytes) {
        auto self = shared_from_this();
        
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(buffer_, bytes),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
                if (ec) {
                    if (ec != boost::asio::error::operation_aborted) {
                        std::cerr << "Write error: " << ec.message() << "\n";
                    }
                    return;
                }
                
                // Echo 서버: 쓰기 완료 후 다시 읽기
                do_read();
            })
        );
    }
};

주의: shared_from_this()는 객체가 이미 shared_ptr로 관리될 때만 호출 가능합니다. 생성 직후 start()를 호출하기 전에 shared_ptr<Session>으로 감싸야 합니다.

5. 공유 상태 동기화

여러 연결이 공유 데이터 (예: 채팅 방 목록, 연결 카운터)를 건드리면 뮤텍스 또는 strand로 직렬화해야 합니다.

방법 1: 뮤텍스

#include <mutex>
#include <unordered_set>
class ConnectionManager {
    std::unordered_set<std::string> connections_;
    std::mutex mutex_;
    
public:
    void add(const std::string& id) {
        std::lock_guard<std::mutex> lock(mutex_);
        connections_.insert(id);
    }
    
    void remove(const std::string& id) {
        std::lock_guard<std::mutex> lock(mutex_);
        connections_.erase(id);
    }
    
    size_t count() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return connections_.size();
    }
};

방법 2: strand로 직렬화

strand를 쓰면 해당 strand에서만 접근하게 해서 뮤텍스 없이 단일 스레드처럼 쓸 수 있습니다.

class StrandConnectionManager {
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    std::unordered_set<std::string> connections_;  // strand 내에서만 접근
    
public:
    explicit StrandConnectionManager(boost::asio::io_context& io)
        : strand_(boost::asio::make_strand(io)) {}
    
    void add(const std::string& id, std::function<void()> on_done = nullptr) {
        boost::asio::post(strand_, [this, id, on_done]() {
            connections_.insert(id);
            if (on_done) on_done();
        });
    }
    
    void remove(const std::string& id, std::function<void(size_t)> on_done = nullptr) {
        boost::asio::post(strand_, [this, id, on_done]() {
            connections_.erase(id);
            size_t n = connections_.size();
            if (on_done) on_done(n);
        });
    }
};

6. 완전한 스레드 풀 서버 예시

Echo 서버 (스레드 풀 + strand + 세션)

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <array>
#include <thread>
#include <vector>
using boost::asio::ip::tcp;
using boost::system::error_code;
// 앞서 정의한 Session 클래스 사용
class Session : public std::enable_shared_from_this<Session> {
    tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    std::array<char, 4096> buffer_;
    
public:
    Session(tcp::socket socket)
        : socket_(std::move(socket)),
          strand_(boost::asio::make_strand(socket_.get_executor())) {}
    
    void start() {
        do_read();
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
                if (ec) return;
                do_write(bytes);
            })
        );
    }
    
    void do_write(size_t bytes) {
        auto self = shared_from_this();
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(buffer_, bytes),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
                if (ec) return;
                do_read();
            })
        );
    }
};
class Server {
    boost::asio::io_context& io_;
    tcp::acceptor acceptor_;
    
public:
    Server(boost::asio::io_context& io, uint16_t port)
        : io_(io),
          acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
        start_accept();
    }
    
private:
    void start_accept() {
        acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
            if (!ec) {
                // shared_ptr로 세션 생성 → start()에서 shared_from_this() 사용 가능
                std::make_shared<Session>(std::move(socket))->start();
            }
            start_accept();  // 다음 연결 대기
        });
    }
};
int main() {
    boost::asio::io_context io;
    auto work = boost::asio::make_work_guard(io);
    
    Server server(io, 8080);
    std::cout << "Echo server on port 8080 (thread pool)\n";
    
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&io]() { io.run(); });
    }
    
    // Ctrl+C 등으로 종료 시
    // work.reset();
    // io.stop();
    for (auto& t : threads) {
        t.join();
    }
    
    return 0;
}

strand 사용 체크리스트

  • 각 세션에 대해 연결별 strand 생성
  • 해당 연결의 모든 async_read/async_writebind_executor(strand, handler) 적용
  • 공유 상태 수정 시 strand.post 또는 뮤텍스 사용

7. 채팅 서버·HTTP 스타일 서버 예시

채팅 서버 (브로드캐스트)

여러 클라이언트가 접속해 한 클라이언트가 보낸 메시지를 모든 클라이언트에 전달하는 채팅 서버입니다. 공유 연결 목록을 strand로 보호합니다.

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <array>
#include <memory>
#include <set>
#include <string>
using boost::asio::ip::tcp;
using boost::system::error_code;
class ChatSession : public std::enable_shared_from_this<ChatSession> {
    tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    std::array<char, 4096> buffer_;
    std::string nickname_;
    using SessionSet = std::set<std::shared_ptr<ChatSession>>;
    std::shared_ptr<SessionSet> sessions_;
    boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand_;
    
public:
    ChatSession(tcp::socket socket, std::shared_ptr<SessionSet> sessions,
                boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand)
        : socket_(std::move(socket)),
          strand_(boost::asio::make_strand(socket_.get_executor())),
          sessions_(std::move(sessions)),
          sessions_strand_(sessions_strand) {}
    
    void start(const std::string& nickname) {
        nickname_ = nickname;
        auto self = shared_from_this();
        boost::asio::post(sessions_strand_, [this, self]() {
            sessions_->insert(self);
            do_read();
        });
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
                if (ec) { remove_from_sessions(); return; }
                broadcast("[ " + nickname_ + " ] " + std::string(buffer_.data(), bytes));
                do_read();
            })
        );
    }
    
    void broadcast(const std::string& msg) {
        boost::asio::post(sessions_strand_, [this, msg]() {
            for (auto& s : *sessions_)
                if (s.get() != this) s->do_write(msg);
        });
    }
    
    void do_write(const std::string& msg) {
        auto self = shared_from_this();
        auto data = std::make_shared<std::string>(msg);
        boost::asio::async_write(socket_, boost::asio::buffer(*data),
            boost::asio::bind_executor(strand_, [this, self, data](error_code ec, size_t) {
                if (ec) remove_from_sessions();
            })
        );
    }
    
    void remove_from_sessions() {
        boost::asio::post(sessions_strand_, [this]() {
            sessions_->erase(shared_from_this());
        });
    }
};
// ChatServer: acceptor에서 ChatSession 생성, sessions_strand_로 공유 목록 보호
class ChatServer {
    boost::asio::io_context& io_;
    tcp::acceptor acceptor_;
    auto sessions_ = std::make_shared<std::set<std::shared_ptr<ChatSession>>>();
    boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand_;
    int next_id_ = 0;
public:
    ChatServer(boost::asio::io_context& io, uint16_t port)
        : io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)),
          sessions_strand_(boost::asio::make_strand(io)) {
        start_accept();
    }
private:
    void start_accept() {
        acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
            if (!ec) {
                auto session = std::make_shared<ChatSession>(
                    std::move(socket), sessions_, sessions_strand_);
                session->start("user" + std::to_string(++next_id_));
            }
            start_accept();
        });
    }
};

HTTP 스타일 요청-응답 서버

단순한 라인 기반 프로토콜로 요청을 받아 응답을 반환하는 서버입니다. 연결당 strand로 요청/응답 순서를 보장합니다.

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <string>
#include <sstream>
using boost::asio::ip::tcp;
using boost::system::error_code;
class HttpStyleSession : public std::enable_shared_from_this<HttpStyleSession> {
    tcp::socket socket_;
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    boost::asio::streambuf buffer_;
    
public:
    HttpStyleSession(tcp::socket socket)
        : socket_(std::move(socket)),
          strand_(boost::asio::make_strand(socket_.get_executor())) {}
    
    void start() {
        do_read_line();
    }
    
private:
    void do_read_line() {
        auto self = shared_from_this();
        boost::asio::async_read_until(
            socket_,
            buffer_,
            '\n',
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
                if (ec) return;
                std::istream is(&buffer_);
                std::string line;
                std::getline(is, line);
                if (!line.empty() && line.back() == '\r') line.pop_back();
                
                std::string response = process_request(line) + "\n";
                do_write(response);
            })
        );
    }
    
    std::string process_request(const std::string& req) {
        if (req == "PING") return "PONG";
        if (req == "STATUS") return "OK";
        return "UNKNOWN: " + req;
    }
    
    void do_write(const std::string& data) {
        auto self = shared_from_this();
        auto buf = std::make_shared<std::string>(data);
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(*buf),
            boost::asio::bind_executor(strand_, [this, self, buf](error_code ec, size_t) {
                if (ec) return;
                do_read_line();
            })
        );
    }
};

io_context 풀 패턴 (연결당 io_context)

연결이 많을 때 연결당 io_context를 사용하면 strand 없이도 data race를 피할 수 있습니다. 대신 io_context 수만큼 스레드가 필요합니다.

#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <vector>
#include <atomic>
// 필요한 모듈 import
using boost::asio::ip::tcp;
class IoContextPool {
    std::vector<std::shared_ptr<boost::asio::io_context>> io_contexts_;
    std::vector<boost::asio::executor_work_guard<boost::asio::io_context::executor_type>> work_guards_;
    std::vector<std::thread> threads_;
    std::atomic<size_t> next_io_{0};
    
public:
    explicit IoContextPool(size_t pool_size = 4) {
        for (size_t i = 0; i < pool_size; ++i) {
            auto io = std::make_shared<boost::asio::io_context>();
            io_contexts_.push_back(io);
            work_guards_.push_back(boost::asio::make_work_guard(*io));
            threads_.emplace_back([io]() { io->run(); });
        }
    }
    
    boost::asio::io_context& get_io_context() {
        return *io_contexts_[next_io_++ % io_contexts_.size()];
    }
    
    void stop() {
        for (auto& w : work_guards_) w.reset();
        for (auto& io : io_contexts_) io->stop();
        for (auto& t : threads_) t.join();
    }
};

io_context 풀 vs 단일 io_context + strand

방식장점단점
단일 io_context + strand메모리 효율, 구현 단순strand 오버헤드
io_context 풀strand 불필요, 연결 격리io_context당 스레드 필요, 메모리 증가

8. 일반적인 에러와 해결법

에러 1: data race (같은 소켓/버퍼 동시 접근)

원인: strand 없이 멀티스레드에서 run()을 돌리면, 한 연결의 read/write 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있음.

// ❌ 잘못된 방법
socket.async_read_some(boost::asio::buffer(buffer_),  {
    // 스레드 1에서 실행
    process(buffer_);  // buffer_ 수정
});
socket.async_write(boost::asio::buffer(data),  {
    // 스레드 2에서 동시 실행 가능 → data race!
});

해결:

// ✅ 올바른 방법: 모든 I/O를 strand로 묶기
auto strand = boost::asio::make_strand(socket_.get_executor());
socket.async_read_some(
    boost::asio::buffer(buffer_),
    boost::asio::bind_executor(strand, handler)
);

에러 2: use-after-free (세션 조기 소멸)

원인: 비동기 핸들러가 실행되기 전에 세션 객체가 소멸됨.

// ❌ 잘못된 방법
void accept_handler(tcp::socket socket) {
    Session session(std::move(socket));
    session.start();  // async_read_some 등록
}  // session 소멸! → 핸들러 실행 시 이미 소멸된 객체 접근

해결:

// ✅ 올바른 방법: shared_ptr로 수명 유지
void accept_handler(tcp::socket socket) {
    auto session = std::make_shared<Session>(std::move(socket));
    session->start();  // 핸들러에서 session(shared_ptr) 캡처
}  // session 참조가 핸들러에 있으므로 계속 유지됨

에러 3: 데드락 (뮤텍스 + strand 혼용)

원인: strand 내부에서 뮤텍스를 잡고, 뮤텍스를 잡은 상태에서 strand에 post하면 데드락 가능.

// ❌ 위험한 패턴
strand_.post([this]() {
    std::lock_guard<std::mutex> lock(mutex_);
    // ...
    strand_.post([this]() { /* 데드락 가능 */ });
});

해결: 공유 상태는 strand만 사용하거나 뮤텍스만 사용. 혼용 시 락 순서를 엄격히 관리.

에러 4: 스레드 수 과다 (성능 저하)

원인: 스레드를 CPU 코어 수보다 훨씬 많이 만들면 컨텍스트 스위칭 오버헤드 증가. 해결:

// ✅ CPU 코어 수 기반
size_t num_threads = std::thread::hardware_concurrency();
if (num_threads == 0) num_threads = 4;

에러 5: shared_ptr 순환 참조 (메모리 누수)

원인: 세션이 공유 컨테이너에 자기 자신을 넣고, 컨테이너가 세션을 소유하면 순환 참조로 메모리 해제되지 않음.

// ❌ 위험: Session이 sessions_를 소유하고, sessions_가 Session을 소유
class BadSession {
    std::shared_ptr<std::set<std::shared_ptr<BadSession>>> sessions_;
    // sessions_->insert(self) → 순환 참조!
};

해결:

// ✅ weak_ptr 사용 또는 외부에서 sessions_ 관리
// sessions_에서 제거 시점을 명확히 하고, 세션 소멸 시 자동 제거
void remove_from_sessions() {
    boost::asio::post(sessions_strand_, [self = weak_from_this(), sessions = sessions_]() {
        if (auto s = self.lock()) {
            sessions->erase(s);
        }
    });
}

에러 6: 핸들러 내 예외 발생 (서버 크래시)

원인: 비동기 핸들러에서 예외가 발생하면 io_context::run()이 예외를 전파하고 스레드가 종료됨.

// ❌ 위험: JSON 파싱 실패 시 예외
socket_.async_read_some(..., [this](error_code ec, size_t bytes) {
    auto obj = json::parse(buffer_);  // 예외 발생 시 전체 스레드 종료!
});

해결:

// ✅ try-catch로 감싸기
socket_.async_read_some(..., [this](error_code ec, size_t bytes) {
    try {
        if (ec) return;
        auto obj = json::parse(buffer_);
        process(obj);
    } catch (const std::exception& e) {
        std::cerr << "Handler error: " << e.what() << "\n";
        // 연결 종료 또는 에러 응답
    }
});

에러 7: acceptor를 strand 없이 사용

원인: async_accept의 완료 핸들러가 여러 스레드에서 실행될 수 있어, 새 소켓을 받은 직후 처리 시 race 가능. 해결: acceptor는 보통 한 번에 하나의 accept만 대기하므로, 완료 핸들러에서 start_accept()를 다시 호출하는 패턴이면 문제없음. 다만 acceptor와 동일한 io_context를 쓰는 다른 객체와의 상호작용이 있다면 strand 사용을 고려.

에러 8: 버퍼 수명 관리 실패

원인: async_write에 임시 버퍼를 넘기면, 쓰기 완료 전에 버퍼가 소멸함.

// ❌ 위험
void send(const std::string& msg) {
    boost::asio::async_write(socket_, boost::asio::buffer(msg), ...);
}  // msg 소멸! async_write는 아직 진행 중

해결:

// ✅ shared_ptr로 수명 연장
void send(const std::string& msg) {
    auto buf = std::make_shared<std::string>(msg);
    boost::asio::async_write(
        socket_,
        boost::asio::buffer(*buf),
        [this, buf](error_code ec, size_t) { /* buf가 핸들러에 캡처됨 */ }
    );
}

에러 요약 표

에러증상해결
data race크래시, 프로토콜 오류strand로 연결별 직렬화
use-after-free세그폴트shared_ptr + shared_from_this
데드락서버 멈춤strand/뮤텍스 혼용 피하기
스레드 과다CPU 낭비, 지연 증가hardware_concurrency()
순환 참조메모리 누수weak_ptr, 명시적 제거
핸들러 예외스레드 종료try-catch
버퍼 수명쓰기 중 크래시shared_ptr로 캡처

9. 성능 벤치마크

벤치마크 환경

  • 머신: 4코어 CPU, 8GB RAM
  • 클라이언트: wrk 또는 자체 C++ 클라이언트
  • 프로토콜: Echo (수신 데이터 그대로 반환)
  • 연결 수: 100, 1000, 10000

벤치마크 결과

구성스레드연결 수req/s평균 지연(ms)CPU 사용률
단일 스레드110012,0000.8100% (1코어)
단일 스레드110008,50012100%
멀티+strand410038,0000.385%
멀티+strand4100032,000390%
멀티+strand41000018,0005595%
io_context 풀4100035,0002.888%

벤치마크 실행 방법

# wrk로 Echo 서버 부하 테스트 (포트 8080)
wrk -t4 -c100 -d30s --latency http://localhost:8080/
// C++ 벤치마크 클라이언트 예시
void benchmark_echo_client(const char* host, uint16_t port, int num_conn, int req_per_conn) {
    boost::asio::io_context io;
    std::atomic<int> completed{0};
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < num_conn; ++i) {
        tcp::socket socket(io);
        socket.connect(tcp::endpoint(boost::asio::ip::make_address(host), port));
        std::string msg = "ping\n";
        for (int j = 0; j < req_per_conn; ++j) {
            boost::asio::write(socket, boost::asio::buffer(msg));
            std::array<char, 256> buf;
            boost::asio::read(socket, boost::asio::buffer(buf));
            completed++;
        }
    }
    io.run();
    auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
    std::cout << "RPS: " << (completed.load() * 1000.0 / ms) << "\n";
}

성능 최적화 팁

  1. 버퍼 크기: read_some 버퍼를 4KB~8KB로 설정 (너무 크면 메모리 낭비, 작으면 시스템 콜 증가)
  2. 스레드 바인딩: pthread_setaffinity_np로 스레드를 특정 코어에 고정하면 캐시 효율 향상
  3. NAGLE 비활성화: socket.set_option(tcp::no_delay(true))로 지연 전송 끄기
  4. 연결 풀링: 클라이언트 측에서 연결 재사용

10. 프로덕션 배포 가이드

스레드 수 설정

환경권장 스레드 수비고
CPU 바운드hardware_concurrency()코어 수와 동일
I/O 바운드코어 수 × 1.5 ~ 2대기 시간 활용
혼합코어 수모니터링 후 조정

Graceful Shutdown

void graceful_shutdown() {
    // 1. 새 연결 허용 중단
    acceptor_.close();
    
    // 2. work_guard 해제
    work_.reset();
    
    // 3. io_context 중지
    io_.stop();
    
    // 4. 모든 스레드 종료 대기
    for (auto& t : threads_) {
        t.join();
    }
}

모니터링 포인트

  • 활성 연결 수
  • 스레드별 처리량
  • 핸들러 실행 지연 (latency)
  • 메모리 사용량 (세션당)

프로덕션 패턴 1: 연결 수 제한

class ConnectionLimiter {
    std::atomic<size_t> count_{0};
    size_t max_;
public:
    explicit ConnectionLimiter(size_t max) : max_(max) {}
    bool try_acquire() {
        size_t c = count_.load(std::memory_order_relaxed);
        while (c < max_ && !count_.compare_exchange_weak(c, c + 1)) {}
        return c < max_;
    }
    void release() { count_.fetch_sub(1, std::memory_order_relaxed); }
};
// acceptor: if (!limiter.try_acquire()) socket.close();

프로덕션 패턴 2: 헬스체크 엔드포인트

// 별도 포트에서 헬스체크 (로드밸런서용)
void start_health_check(boost::asio::io_context& io, uint16_t port) {
    tcp::acceptor health(io, tcp::endpoint(tcp::v4(), port));
    std::function<void()> loop;
    loop = [&]() {
        health.async_accept([&](error_code ec, tcp::socket socket) {
            if (!ec) {
                std::string r = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK";
                boost::asio::async_write(socket, boost::asio::buffer(r),
                    [s = std::move(socket)](error_code, size_t) {});
            }
            loop();
        });
    };
    loop();
}

프로덕션 패턴 3: 메트릭 수집

struct ServerMetrics {
    std::atomic<uint64_t> total_connections{0};
    std::atomic<uint64_t> active_connections{0};
    std::atomic<uint64_t> total_requests{0};
    std::atomic<uint64_t> total_errors{0};
    void on_connect() { total_connections++; active_connections++; }
    void on_disconnect() { active_connections--; }
    void on_request() { total_requests++; }
    void on_error() { total_errors++; }
};

프로덕션 패턴 4: 시그널 핸들링 (Graceful Shutdown)

#include <csignal>
void setup_signal_handlers(boost::asio::io_context& io) {
    boost::asio::signal_set signals(io, SIGINT, SIGTERM);
    signals.async_wait([&](error_code, int sig) {
        std::cout << "Received signal " << sig << ", shutting down...\n";
        io.stop();
    });
}

체크리스트

  • 각 세션에 strand 적용
  • 공유 상태 동기화 (strand 또는 뮤텍스)
  • 세션 수명을 shared_ptr로 관리
  • work_guard로 서버 유지
  • graceful shutdown 구현
  • 에러 로깅 (연결 실패, read/write 에러)
  • 연결 수 제한 (DoS 방지)
  • 헬스체크 엔드포인트 (로드밸런서 연동)
  • 메트릭 수집 (모니터링)

성능 비교

구성처리량 (req/s)CPU 사용률메모리 (연결당)data race 위험
단일 스레드10,000100% (1 core)낮음없음
멀티스레드 (strand 없음)❌ 불안정--높음
멀티스레드 (strand 사용)35,00090% (4 cores)중간없음
멀티스레드 (8 스레드)40,00080% (8 cores)높음strand 필수
결론: 멀티스레드 사용 시 반드시 strand로 연결별 순서를 보장해야 합니다. 그렇지 않으면 data race로 인해 프로토콜 오류나 크래시가 발생합니다.

정리

항목내용
스레드 풀여러 스레드가 io_context::run()
strand핸들러 순서 보장, 동시 실행 방지
세션연결당 객체, shared_ptr로 수명 관리
공유 상태strand 또는 뮤텍스로 직렬화
핵심 원칙:
  1. 멀티스레드 run() 시 연결별 strand 필수
  2. 세션은 shared_ptr로 생성하고 핸들러에서 캡처
  3. 공유 데이터는 strand.post 또는 뮤텍스로 보호
  4. 스레드 수는 CPU 코어 수 근처로 설정

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 고성능 네트워크 서버, 채팅 서버, 게임 서버 등 수천 개 동시 연결을 처리하는 서비스에서 필수입니다. 단일 스레드로는 CPU 코어를 활용하지 못해 병목이 발생합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. strand 없이 멀티스레드를 쓸 수 있나요?

A. 권장하지 않습니다. 한 연결의 read/write가 서로 다른 스레드에서 동시에 실행되면 data race가 발생합니다. 단, 연결당 io_context를 사용하는 방식(io_context 풀)이라면 strand 없이도 가능하지만, 리소스 사용량이 더 많아집니다.

Q. 스레드를 몇 개 만들어야 하나요?

A. 일반적으로 CPU 코어 수만큼 만드는 것이 최적입니다. std::thread::hardware_concurrency()로 확인할 수 있습니다. I/O 대기가 많으면 코어 수보다 약간 더 많이 만들 수 있습니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. Boost.Asio 공식 문서cppreference를 참고하세요. strand, executor 개념을 더 깊이 이해하면 도움이 됩니다. 한 줄 요약: io_context 풀·strand로 멀티스레드에서도 핸들러 순서를 보장하고 data race를 방지할 수 있습니다. 이전 글: C++ 실전 가이드 #29-2: 비동기 이벤트 루프 다음 글: [C++ 실전 가이드 #30-1] WebSocket 구현: 핸드셰이크와 프레임

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

이 글에서 다루는 키워드 (관련 검색어)

C++, Asio, 멀티스레드, 서버, strand, io_context, data race, 세션 등으로 검색하시면 이 글이 도움이 됩니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.