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

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

이 글의 핵심

C++ 멀티스레드 네트워크 서버 완벽 가이드에 대한 실전 가이드입니다. 개념부터 실무 활용까지 예제와 함께 상세히 설명합니다.

들어가며: “멀티스레드 서버에서 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는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. 시스템 아키텍처
  2. 여러 스레드에서 run
  3. strand로 순서 보장
  4. 세션과 수명 관리
  5. 공유 상태 동기화
  6. 완전한 스레드 풀 서버 예시
  7. 채팅 서버·HTTP 스타일 서버 예시
  8. 일반적인 에러와 해결법
  9. 성능 벤치마크
  10. 프로덕션 배포 가이드

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>

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++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법

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

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


관련 글

  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
  • C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
  • C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]