C++ 백엔드·게임 서버 시스템 디자인 | 대규모 동시 접속과 메모리 풀 [#46-1]

C++ 백엔드·게임 서버 시스템 디자인 | 대규모 동시 접속과 메모리 풀 [#46-1]

이 글의 핵심

C++ 면접 단골 주제인 대규모 동시 접속자 처리 구조, 메모리 풀 기반 객체 관리 설계 등 실전 아키텍처를 다룹니다. 문제 시나리오, 완전한 설계 예제, 흔한 실수, 프로덕션 패턴까지 C++ 실전 가이드 시리즈에서 예제와 함께 다룹니다.

들어가며: “10만 CCU 서버를 어떻게 설계하시겠어요?”

왜 시스템 디자인을 묻는가

C++ 백엔드·게임 서버 면접에서는 이론만이 아니라 “대규모 동시 접속자(CCU)(Concurrent Users—동시에 접속해 있는 사용자 수)를 어떻게 처리할지”, “세션·패킷·메모리를 어떻게 관리할지”를 설계 수준으로 묻습니다. 이 글은 그런 질문에 대비해 실전에 가까운 아키텍처 키워드를 정리합니다. 구현 세부보다는 구성 요소, 트레이드오프, 면접에서 말할 수 있는 답변 뼈대를 제공합니다.

이 글에서 다루는 것:

  • 대규모 동시 접속 처리: 스레드 모델(이벤트 루프·스레드 풀), Acceptor·Worker 분리, 수평 확장
  • 메모리 풀 기반 객체 관리: new/delete 대신 풀·오브젝트 풀, 단편화 방지, 라이프사이클
  • 세션·패킷 설계: 연결당 세션 객체, 패킷 큐·버퍼 풀
  • 문제 시나리오·완전한 설계 예제·흔한 실수·프로덕션 패턴

관련 글: C++ 실전 가이드 #29: Asio, 고성능 네트워크 가이드 #1~#3에서 이벤트 루프·Strand를 다룹니다.


실제 문제 시나리오

시나리오 1: 피크 타임에 서버가 멈춘다

상황: MMORPG 서버가 점검 후 오픈 시점에 5만 명이 동시 접속 시도
문제: 단일 스레드 accept + 동기 I/O → 연결 수락이 병목, 나머지 클라이언트 타임아웃
결과: "접속이 안 돼요" 문의 폭주, 이벤트 루프 블로킹으로 기존 유저도 끊김
→ Acceptor·Worker 분리, 비동기 I/O, 스레드 풀 도입 필요

시나리오 2: 3일째 메모리 사용량이 계속 상승

상황: C++ 게임 서버가 72시간 운영 후 메모리 8GB → 14GB로 증가
문제: 세션·패킷 버퍼를 new/delete로 할당 → 힙 단편화 + 할당/해제 오버헤드
결과: 단편화로 인한 "가상 메모리 부족" 에러, 재시작 없이는 회복 불가
→ 메모리 풀·오브젝트 풀 도입, 스레드 로컬 풀로 경합 감소

시나리오 3: 같은 유저의 패킷이 뒤섞여 처리된다

상황: 한 클라이언트에서 로그인 → 캐릭터 선택 → 게임 입장 요청이 연속 전송
문제: 여러 스레드가 같은 세션의 읽기/쓰기 핸들러를 동시 실행 → 레이스 컨디션
결과: 캐릭터 선택 전에 게임 입장 처리, 상태 꼬임, 크래시
→ 연결당 Strand로 해당 연결의 I/O 직렬화 필요

시나리오 4: 10만 CCU 목표인데 3만에서 한계

상황: 단일 서버로 10만 CCU 목표, 3만 접속 시 CPU 100%, 지연 급증
문제: 스레드 수 = 연결 수 설계, 컨텍스트 스위칭·락 경합 과다
결과: 이벤트 기반 I/O 미적용, 블로킹 소켓 사용
→ io_context + run() 스레드 풀, 논블로킹 소켓, 수평 확장 검토

시나리오 5: 패킷 송신 시 크래시

상황: 여러 스레드에서 같은 세션의 send()를 동시 호출
문제: 소켓은 스레드 안전하지 않음, 버퍼 덮어쓰기·use-after-free
결과: 랜덤 크래시, 패킷 손상
→ 패킷 큐 + 단일 Strand에서 순차 송신

이 글에서는 위와 같은 문제를 예방하는 시스템 설계와 완전한 예제를 다룹니다.

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.


목차

  1. 대규모 동시 접속자 처리 구조
  2. 메모리 풀·오브젝트 풀 설계
  3. 세션·패킷·버퍼 설계
  4. 완전한 시스템 설계 예제
  5. 자주 발생하는 실수와 해결법
  6. 모범 사례
  7. 프로덕션 패턴
  8. 면접에서 답변할 때 포인트
  9. 구현 체크리스트

1. 대규모 동시 접속자 처리 구조

아키텍처 개요

flowchart TB
    subgraph Client["클라이언트"]
        C1[Client 1]
        C2[Client 2]
        Cn[Client N]
    end

    subgraph LB["로드 밸런서"]
        LB1[L4/L7 LB]
    end

    subgraph Server["서버 인스턴스"]
        subgraph Acceptor["Acceptor 스레드"]
            A[async_accept]
        end
        subgraph Workers["Worker 스레드 풀"]
            W1["io_context run"]
            W2["io_context run"]
            W3["io_context run"]
        end
    end

    Client --> LB
    LB --> Acceptor
    A -->|round-robin| Workers

이벤트 루프 + 스레드 풀

  • 한 스레드 한 이벤트 루프: Asio의 io_context::run()을 여러 스레드가 돌리면, 완료 핸들러가 스레드 풀에 분산됩니다. 블로킹 없이 수만 개의 소켓을 소수의 스레드로 처리할 수 있습니다.
  • Strand: 같은 연결(세션)에 대한 읽기/쓰기 핸들러를 한 Strand에 묶으면 락 없이 순차 실행이 보장되어, 공유 상태를 안전하게 다룰 수 있습니다.
  • Acceptor와 Worker 분리: 연결 수락(accept)은 전용 스레드/루프에서 하고, 수락된 소켓을 round-robin 또는 부하에 따라 Worker 풀에 넘기는 패턴. Worker는 자신만의 io_context 또는 공유 io_context에서 run()을 돌립니다.

Strand로 연결 단위 직렬화

sequenceDiagram
    participant C as Client
    participant S1 as Worker 스레드 1
    participant S2 as Worker 스레드 2
    participant Strand as Session Strand

    C->>S1: read 완료 (핸들러 1)
    S1->>Strand: post(핸들러 1)
    Strand->>S1: 순차 실행
    C->>S2: read 완료 (핸들러 2)
    S2->>Strand: post(핸들러 2)
    Strand->>S2: 핸들러 1 완료 후 실행

수평 확장

  • 단일 프로세스 한계: 한 머신의 CPU·메모리·네트워크에 한계가 있으면 여러 프로세스·여러 서버로 나눕니다. 로드 밸런서 앞에 서버 인스턴스를 두고, 세션 어피니티(같은 클라이언트는 같은 서버로) 또는 스테이트리스 + 공유 저장소로 설계합니다.
  • 면접에서는 “단일 노드에서는 이벤트 루프·Strand·스레드 풀로 CCU를 늘리고, 그 다음에는 수평 확장·로드밸런싱·세션 분산”이라고 요약할 수 있습니다.

예시 시나리오: “10만 CCU를 한 서버로 받는다”면, 프로세스 하나에 io_context 하나를 두고, CPU 코어 수만큼(예: 8개) 스레드가 run()을 호출합니다. 연결마다 세션 객체Strand를 두면, 같은 연결의 읽기/쓰기 핸들러는 한 스레드에서만 순차 실행되고, 서로 다른 연결의 핸들러는 스레드 풀에 분산됩니다. 이렇게 하면 락 없이 수만 개 소켓을 소수의 스레드로 처리할 수 있습니다.

실행 가능 예제 (이벤트 루프·스레드 풀 개념)

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o sys_demo sys_demo.cpp && ./sys_demo
#include <iostream>
#include <thread>
#include <vector>

int main() {
    const int num_workers = 4;
    std::vector<std::thread> workers;
    for (int i = 0; i < num_workers; ++i) {
        workers.emplace_back([i]() {
            std::cout << "Worker thread " << i << "\n";
        });
    }
    for (auto& w : workers) w.join();
    std::cout << "Main thread\n";
    return 0;
}

2. 메모리 풀·오브젝트 풀 설계

왜 풀을 쓰는가

  • 힙 단편화: 수만 개의 세션·패킷 버퍼를 new/delete하면 단편화와 할당/해제 비용이 커집니다.
  • 메모리 풀: 미리 큰 블록을 할당해 두고, 같은 크기(또는 크기 클래스) 단위로 나눠 주고 반환받아 재사용합니다. 스레드 로컬 풀을 쓰면 락 없이 할당 속도를 높일 수 있습니다.
  • 오브젝트 풀: 세션·패킷 객체 자체를 풀에서 꺼내 쓰고, 연결 종료 시 재설정 후 풀에 반환합니다. 생성/파괴 비용과 단편화를 줄입니다.

풀 vs new/delete 비교

flowchart LR
    subgraph Heap["힙 (new/delete)"]
        H1[블록1]
        H2[빈공간]
        H3[블록2]
        H4[빈공간]
        H5[블록3]
    end

    subgraph Pool["메모리 풀"]
        P1[연속 블록]
        P2[연속 블록]
        P3[연속 블록]
    end

설계 포인트

  • 크기 클래스: 작은 버퍼(64B), 중간(256B), 큰(4KB) 등으로 나누면 내부 단편화를 줄일 수 있습니다.
  • 라이프사이클: 풀에서 꺼낸 객체는 “사용 중” 상태를 명확히 하고, 반환 시 모든 참조가 사라졌는지(스마트 포인터·참조 카운트) 보장해야 합니다. use-after-free를 막기 위해 반환 후 재사용 전까지 덮어쓰기하는 패턴도 있습니다.

면접에서는 “대량의 단명 객체가 있으면 new/delete 대신 메모리 풀·오브젝트 풀로 할당 비용과 단편화를 줄인다. 스레드 로컬 풀로 경합을 줄일 수 있다”라고 말할 수 있습니다.

메모리 풀 구현 예제

#include <vector>
#include <stack>
#include <mutex>
#include <cstddef>

// 단일 크기 메모리 풀: 같은 크기 블록만 관리
class FixedSizePool {
public:
    explicit FixedSizePool(size_t block_size, size_t initial_count = 64)
        : block_size_(block_size) {
        for (size_t i = 0; i < initial_count; ++i) {
            void* p = ::operator new(block_size_);
            free_list_.push(p);
        }
    }

    ~FixedSizePool() {
        while (!free_list_.empty()) {
            void* p = free_list_.top();
            free_list_.pop();
            ::operator delete(p);
        }
    }

    void* allocate() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (free_list_.empty()) {
            return ::operator new(block_size_);
        }
        void* p = free_list_.top();
        free_list_.pop();
        return p;
    }

    void deallocate(void* p) {
        std::lock_guard<std::mutex> lock(mtx_);
        free_list_.push(p);
    }

private:
    size_t block_size_;
    std::stack<void*> free_list_;
    std::mutex mtx_;
};

3. 세션·패킷·버퍼 설계

연결당 세션

  • 한 TCP 연결 = 한 세션 객체. 세션은 소켓, 수신/송신 버퍼, 프로토콜 상태(헤더 파싱 등)를 가집니다. 고성능 네트워크 가이드 #3처럼 연결당 Strand를 두면 해당 연결의 모든 핸들러가 순차 실행됩니다.
  • 세션 풀: 연결이 끊기면 세션 객체를 풀에 반환하고, 새 연결 시 풀에서 꺼내 재사용합니다.

패킷·버퍼

  • 고정 버퍼 vs 동적 버퍼: 지연이 중요하면 스레드 로컬·세션 전용 고정 크기 버퍼를 두고, 큰 메시지는 청크 단위로 읽어 처리합니다. Composed Operation으로 “헤더 읽기 → 바디 읽기”를 한 비동기 연산으로 묶을 수 있습니다.
  • 패킷 큐: 송신할 패킷을 큐에 넣고, 소켓이 쓰기 가능할 때마다 큐에서 꺼내 async_write하는 패턴. 큐에 넣는 쪽과 쓰는 쪽이 같은 Strand에 있으면 락 없이 안전합니다.

세션·패킷 흐름

flowchart LR
    subgraph Session["세션"]
        S1[소켓]
        S2[수신 버퍼]
        S3[송신 큐]
        S4[Strand]
    end

    subgraph Packet["패킷 처리"]
        P1[헤더 파싱]
        P2[바디 읽기]
        P3[비즈니스 로직]
        P4[응답 큐잉]
    end

    S1 --> S2
    S2 --> P1
    P1 --> P2
    P2 --> P3
    P3 --> P4
    P4 --> S3
    S3 --> S1
    S4 -.->|직렬화| S1

4. 완전한 시스템 설계 예제

예제 1: 10만 CCU 서버 아키텍처

flowchart TB
    subgraph Clients["클라이언트"]
        C[10만 연결]
    end

    subgraph Server["단일 서버 프로세스"]
        subgraph IO["io_context"]
            IO1[비동기 I/O]
        end
        subgraph Threads["8 Worker 스레드"]
            T1[run]
            T2[run]
            T3[run]
            T4[run]
            T5[run]
            T6[run]
            T7[run]
            T8[run]
        end
        subgraph Sessions["세션 풀"]
            S[10만 세션 객체]
        end
    end

    C --> IO
    IO --> Threads
    Threads --> Sessions

핵심 설계 결정:

항목선택이유
I/O 모델이벤트 기반 (epoll/kqueue)수만 소켓을 소수 스레드로 처리
스레드 수CPU 코어 수컨텍스트 스위칭 최소화
세션당 Strand1개같은 연결 I/O 직렬화, 락 불필요
버퍼고정 크기 + 풀단편화·할당 비용 감소

예제 2: Acceptor-Worker 분리 코드 골격

#include <boost/asio.hpp>
#include <iostream>
#include <vector>
#include <memory>

namespace asio = boost::asio;
using tcp = asio::ip::tcp;

class Session;  // 전방 선언

class Server {
public:
    Server(asio::io_context& io_ctx, unsigned short port)
        : acceptor_(io_ctx, tcp::endpoint(tcp::v4(), port)) {
        do_accept();
    }

private:
    void do_accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    // Worker에 세션 전달 (round-robin 등)
                    on_new_session(std::move(socket));
                }
                do_accept();  // 다음 accept
            });
    }

    virtual void on_new_session(tcp::socket socket) = 0;

    tcp::acceptor acceptor_;
};

// Worker 풀: 각 Worker가 io_context::run() 실행
// 새 소켓은 round-robin으로 Worker의 io_context에 post

예제 3: 세션 + Strand + 패킷 큐

#include <boost/asio.hpp>
#include <queue>
#include <array>

namespace asio = boost::asio;
using tcp = asio::ip::tcp;

class GameSession : public std::enable_shared_from_this<GameSession> {
public:
    GameSession(tcp::socket socket, asio::io_context& io_ctx)
        : socket_(std::move(socket)),
          strand_(asio::make_strand(io_ctx)),
          recv_buffer_() {}

    void start() {
        // 모든 I/O를 strand_를 통해 실행 → 락 없이 순차 보장
        asio::async_read(
            socket_,
            asio::buffer(recv_buffer_),
            asio::bind_executor(strand_, [self = shared_from_this()](
                boost::system::error_code ec, std::size_t bytes) {
                if (!ec) self->on_read(bytes);
            }));
    }

private:
    void on_read(std::size_t bytes) {
        // 패킷 파싱 후 비즈니스 로직
        // 응답은 send_queue_에 넣고 do_write() 호출
    }

    void do_write() {
        if (send_queue_.empty()) return;
        auto& buf = send_queue_.front();
        asio::async_write(
            socket_,
            asio::buffer(buf),
            asio::bind_executor(strand_, [self = shared_from_this()](
                boost::system::error_code ec, std::size_t) {
                if (!ec) {
                    self->send_queue_.pop();
                    self->do_write();
                }
            }));
    }

    tcp::socket socket_;
    asio::strand<asio::io_context::executor_type> strand_;
    std::array<uint8_t, 4096> recv_buffer_;
    std::queue<std::vector<uint8_t>> send_queue_;
};

예제 4: 오브젝트 풀 기반 세션 관리

#include <vector>
#include <optional>
#include <cassert>

template <typename T>
class ObjectPool {
public:
    explicit ObjectPool(size_t capacity) {
        pool_.reserve(capacity);
        for (size_t i = 0; i < capacity; ++i) {
            pool_.emplace_back(std::in_place);
        }
        free_indices_.reserve(capacity);
        for (size_t i = 0; i < capacity; ++i) {
            free_indices_.push_back(i);
        }
    }

    T* acquire() {
        if (free_indices_.empty()) return nullptr;
        size_t idx = free_indices_.back();
        free_indices_.pop_back();
        return &pool_[idx].value();
    }

    void release(T* obj) {
        size_t idx = obj - &pool_[0].value();
        assert(idx < pool_.size());
        pool_[idx].reset();  // 소멸자 호출, 상태 초기화
        pool_[idx].emplace();  // 기본 생성
        free_indices_.push_back(idx);
    }

private:
    std::vector<std::optional<T>> pool_;
    std::vector<size_t> free_indices_;
};

5. 자주 발생하는 실수와 해결법

실수 1: 세션 소켓을 여러 스레드에서 동시 사용

문제: 소켓은 스레드 안전하지 않음. 한 스레드에서 async_read, 다른 스레드에서 async_write를 동시에 호출하면 레이스 컨디션.

// ❌ 잘못된 예: Strand 없이 여러 스레드에서 같은 세션 접근
void on_packet_received(Session* session, Packet p) {
    std::thread([session, p]() {
        process(p);
        session->send(response);  // 다른 스레드! 위험
    }).detach();
}

해결: 해당 세션의 모든 I/O를 Strand로 직렬화.

// ✅ 올바른 예: Strand를 통해 post
void on_packet_received(std::shared_ptr<Session> session, Packet p) {
    asio::post(session->strand(), [session, p]() {
        auto response = process(p);
        session->send(response);  // Strand 내부 → 안전
    });
}

실수 2: 패킷 버퍼를 new/delete로 매번 할당

문제: 초당 수만 패킷 처리 시 할당/해제 비용과 단편화가 심해짐.

// ❌ 잘못된 예
void on_read(std::size_t len) {
    auto* buf = new uint8_t[len];
    parse_packet(buf, len);
    process(buf, len);
    delete[] buf;
}

해결: 세션별 고정 버퍼 또는 버퍼 풀 사용.

// ✅ 올바른 예: 세션에 고정 버퍼
class Session {
    std::array<uint8_t, 4096> recv_buffer_;
    void on_read(std::size_t len) {
        parse_packet(recv_buffer_.data(), len);
        process(recv_buffer_.data(), len);
    }
};

실수 3: 세션 반환 시 참조가 남아 있는데 풀에 반환

문제: use-after-free. 다른 스레드나 핸들러가 아직 세션을 참조하는데 풀에 넣고 새 연결에 재할당.

// ❌ 잘못된 예
void on_disconnect(Session* session) {
    session_pool_.release(session);  // 아직 pending 핸들러가 session 참조 중!
}

해결: shared_ptr로 참조 카운트 관리, 또는 weak_ptr + 만료 체크.

// ✅ 올바른 예: shared_ptr로 수명 관리
void on_disconnect(std::shared_ptr<Session> session) {
    session->close();
    // shared_ptr이 핸들러에 캡처되어 있으면, 마지막 핸들러 완료 후 소멸
    // 풀 사용 시: session->reset() 후 풀 반환은 모든 참조 해제 후에만
}

실수 4: 블로킹 작업을 이벤트 루프에서 실행

문제: async_read 완료 핸들러 안에서 DB 쿼리·파일 I/O 등 블로킹 호출 → 전체 이벤트 루프 블로킹.

// ❌ 잘못된 예
void on_read(...) {
    auto result = db.query("SELECT ...");  // 블로킹!
    send(response);
}

해결: 블로킹 작업은 별도 스레드 풀로 오프로드.

// ✅ 올바른 예
void on_read(...) {
    asio::post(blocking_pool_, [self = shared_from_this(), data]() {
        auto result = db.query("SELECT ...");
        asio::post(self->strand_, [self, result]() {
            self->send(response);
        });
    });
}

실수 5: 수평 확장 시 세션 상태를 서버 로컬에만 보관

문제: 로드 밸런서가 다른 인스턴스로 라우팅하면 세션 손실.

❌ 설계: 세션 상태를 프로세스 메모리에만 저장
→ 인스턴스 장애/재시작 시 세션 모두 손실
→ 스케일 아웃 시 세션 이전 불가

해결: 스테이트리스 + Redis 등 공유 저장소, 또는 세션 어피니티 + 복제.

✅ 설계 A: 세션 상태를 Redis에 저장, 서버는 스테이트리스
✅ 설계 B: L4 스티키 세션 + 인스턴스당 세션 유지, 장애 시 복구 로직

실수 6: 송신 큐 무한 증가

문제: 클라이언트가 수신을 멈추면 송신 큐가 계속 쌓여 메모리 폭발.

// ❌ 잘못된 예: 큐 크기 제한 없음
void send(Packet p) {
    send_queue_.push(std::move(p));
    do_write();
}

해결: 백프레셔. 큐 크기 임계값 초과 시 수신 일시 중단 또는 연결 종료.

// ✅ 올바른 예
void send(Packet p) {
    if (send_queue_.size() >= MAX_QUEUE_SIZE) {
        close_connection("send queue full");
        return;
    }
    send_queue_.push(std::move(p));
    do_write();
}

실수 7: 타임아웃 미설정

문제: 비활성 연결(클라이언트 종료 미통보)이 세션을 계속 점유.

// ❌ 잘못된 예: 타임아웃 없음
asio::async_read(socket_, ..., on_read);

해결: steady_timer로 읽기/쓰기 타임아웃 설정.

// ✅ 올바른 예
void start_read_timer() {
    read_timer_.expires_after(std::chrono::seconds(30));
    read_timer_.async_wait([self = shared_from_this()](ec) {
        if (!ec) self->close("read timeout");
    });
}
// async_read 완료 시 read_timer_.cancel()

6. 모범 사례

6.1 스레드 모델

패턴사용 시점장점단점
1 스레드 1 연결소규모구현 단순CCU 제한
이벤트 루프 + 스레드 풀대규모높은 CCU, 락 최소비동기 코드 복잡
Acceptor-Worker 분리초대규모accept 병목 제거설계 복잡

6.2 메모리 관리

  • 크기 클래스별 풀: 64B, 256B, 1KB, 4KB 등으로 분리해 내부 단편화 감소.
  • 스레드 로컬 풀: 스레드당 풀 인스턴스를 두어 락 없이 allocate/deallocate.
  • 반환 시 초기화: 풀 반환 시 민감한 데이터 덮어쓰기(보안·use-after-free 방지).

6.3 네트워크

  • 고정 헤더 + 가변 바디: 헤더로 길이 파악 후 바디 읽기, Composed Operation 활용.
  • 송신 큐: async_write 중 추가 송신 요청은 큐에 넣고, 쓰기 완료 시 큐에서 다음 꺼내 전송.
  • 타임아웃: steady_timer로 읽기/쓰기 타임아웃, 비활성 세션 정리.

6.4 모니터링

  • 연결 수 Gauge: 현재 세션 수, 풀 사용량.
  • 패킷 처리량 Counter: 초당 수신/송신 패킷 수.
  • 지연 Histogram: 요청-응답 지연 시간 분포.

7. 프로덕션 패턴

패턴 1: 그레이스풀 셧다운

// SIGINT/SIGTERM 수신 시
void on_signal() {
    acceptor_.close();  // 새 연결 거부
    for (auto& session : sessions_) {
        session->close();  // 기존 연결 정리
    }
    io_ctx_.stop();
    // 모든 핸들러 완료 대기 후 프로세스 종료
}

패턴 2: 백프레셔 (Backpressure)

// 송신 큐가 일정 크기 초과 시 수신 일시 중단
if (send_queue_.size() > 1000) {
    pause_reading();  // async_read 취소
} else {
    resume_reading();
}

패턴 3: 헬스 체크

// 로드 밸런서용 헬스 체크 엔드포인트
// - /health: 200 OK
// - 연결 수 < 임계값, 메모리 < 임계값일 때만 healthy

패턴 4: 캐나리 배포

1. 새 버전을 소수 인스턴스에만 배포
2. 에러율·지연 메트릭 비교
3. 문제 없으면 점진적 확대
4. 문제 시 즉시 롤백

패턴 5: 서킷 브레이커

// 외부 서비스(DB, 캐시) 호출 실패 시
// - 연속 N회 실패 → 서킷 오픈, 즉시 실패 반환
// - 일정 시간 후 half-open, 1회 시도
// - 성공 시 클로즈, 실패 시 다시 오픈

패턴 6: 스레드 로컬 메모리 풀

// 스레드당 풀 인스턴스 → 락 없이 allocate/deallocate
thread_local FixedSizePool* tls_pool = nullptr;

void init_thread_pool() {
    if (!tls_pool) tls_pool = new FixedSizePool(256, 64);
}

void* fast_allocate() {
    init_thread_pool();
    return tls_pool->allocate();
}

패턴 7: Composed Operation으로 패킷 파싱

// 헤더(4바이트) 읽기 → 바디 길이 확인 → 바디 읽기
void read_packet(std::shared_ptr<Session> self) {
    asio::async_read(
        socket_,
        asio::buffer(header_buffer_),
        asio::bind_executor(strand_, [self](ec, bytes) {
            if (ec) return;
            uint32_t body_len = parse_header(header_buffer_);
            if (body_len > MAX_PACKET_SIZE) { /* 에러 */ return; }
            asio::async_read(
                socket_,
                asio::buffer(body_buffer_, body_len),
                asio::bind_executor(strand_, [self, body_len](ec, bytes) {
                    if (!ec) self->on_packet(body_buffer_, body_len);
                    self->read_packet(self);  // 다음 패킷
                }));
        }));
}

성능 벤치마크 및 트레이드오프

할당 방식별 비교 (개요)

방식할당 비용 (상대)단편화스레드 안전구현 복잡도
new/delete1.0 (기준)높음O낮음
메모리 풀 (락)~0.3낮음O중간
스레드 로컬 풀~0.1낮음스레드당높음
오브젝트 풀~0.2낮음풀별중간

CCU별 권장 스레드 수

목표 CCU권장 스레드 수io_context비고
1,0002~41개 공유소규모
10,0004~81개 공유Strand 필수
50,0008~161~2개Acceptor 분리 검토
100,000+16~322개 이상수평 확장 병행

버퍼 크기 선택 가이드

패킷 유형권장 버퍼 크기설명
제어 패킷64~256B로그인, 하트비트
일반 게임256~1KB채팅, 아이템 사용
대용량4KB~16KB파일 전송, 스트리밍

추가 설계 예제: 수평 확장 아키텍처

flowchart TB
    subgraph Clients["클라이언트"]
        C1[Client]
    end

    subgraph LB["로드 밸런서"]
        LB1[L4/L7]
    end

    subgraph Instances["서버 인스턴스"]
        I1[Instance 1]
        I2[Instance 2]
        I3[Instance N]
    end

    subgraph Shared["공유 저장소"]
        Redis["(Redis)"]
    end

    C1 --> LB
    LB --> I1
    LB --> I2
    LB --> I3
    I1 --> Redis
    I2 --> Redis
    I3 --> Redis

스테이트리스 설계: 세션 상태를 Redis에 저장하면, 어떤 인스턴스로 요청이 와도 처리 가능. 인스턴스 장애 시에도 세션 유지.

세션 어피니티 설계: L4에서 클라이언트 IP/쿠키 기반으로 같은 인스턴스로 라우팅. 인스턴스 메모리에 세션 보관. 장애 시 해당 클라이언트만 재연결.


8. 면접에서 답변할 때 포인트

  • “대규모 동시 접속을 어떻게 처리하나요?” → 이벤트 기반 I/O(Asio), 논블로킹 소켓, 스레드 풀에서 run(), Strand로 연결 단위 직렬화. 필요 시 수평 확장·로드밸런싱.
  • “메모리 풀을 왜 쓰나요?” → 대량의 단명 객체에서 할당/해제 비용과 힙 단편화를 줄이기 위해. 오브젝트 풀로 생성/파괴 비용도 줄일 수 있다.
  • “세션과 패킷을 어떻게 관리하나요?” → 연결당 세션 객체, Strand로 해당 연결의 I/O 직렬화, 패킷은 버퍼 풀·고정 버퍼·Composed Operation으로 처리.
  • “10만 CCU를 어떻게 달성하나요?” → 단일 노드: io_context + N개 run() 스레드, 연결당 Strand, 메모리/오브젝트 풀. 한계 도달 시: 수평 확장, 로드 밸런서, 세션 분산.

9. 구현 체크리스트

아키텍처

  • 이벤트 기반 I/O (epoll/kqueue/Asio) 적용
  • 스레드 수 = CPU 코어 수 (또는 N+1)
  • 연결당 Strand로 I/O 직렬화
  • Acceptor-Worker 분리 (필요 시)

메모리

  • 메모리 풀 또는 오브젝트 풀 도입
  • 스레드 로컬 풀 (락 경합 감소)
  • 풀 반환 시 참조 정리 확인

세션·패킷

  • 연결당 세션 객체
  • 고정 버퍼 또는 버퍼 풀
  • 송신 큐 + Strand 내 순차 송신

운영

  • 그레이스풀 셧다운
  • 헬스 체크 엔드포인트
  • 메트릭 노출 (연결 수, 지연, 에러율)
  • 로그 레벨·구조화 로깅

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

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

  • C++ 도메인별 요구 역량 | 네카라쿠배·금융·게임 [#46-3]
  • C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
  • C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]

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

시스템 설계, C++, 대규모 서버, 동시 접속, 메모리 풀, 게임 서버 아키텍처 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

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

A. C++ 면접 단골 주제인 대규모 동시 접속자 처리 구조, 메모리 풀 기반 객체 관리 설계 등 실전 아키텍처를 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

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

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

Q. 메모리 풀과 오브젝트 풀 중 뭘 써야 하나요?

A. 메모리 풀: 바이트 버퍼(패킷, 임시 데이터)처럼 “크기만 중요하고 타입이 없는” 경우. 오브젝트 풀: 세션·엔티티처럼 “생성자/소멸자·상태 초기화”가 필요한 객체. 둘을 함께 쓰는 경우가 많습니다(세션은 오브젝트 풀, 세션 내부 버퍼는 메모리 풀).

Q. Strand 없이 mutex로 보호하면 안 되나요?

A. 동작은 합니다. 하지만 Strand는 락을 걸지 않고 executor 큐에서 순차 실행을 보장하므로, 락 경합·컨텍스트 스위칭이 없어 성능이 좋습니다. Asio 기반이라면 Strand 사용을 권장합니다.

Q. 10만 CCU가 정말 한 서버로 가능한가요?

A. 이벤트 기반 I/O, 적절한 스레드 수, 메모리 풀, 단순한 비즈니스 로직이라면 가능합니다. 다만 CPU·메모리·네트워크 대역폭에 따라 달라지며, 실제로는 5~8만 CCU에서 수평 확장을 검토하는 경우가 많습니다.


참고 자료

  • Boost.Asio 공식 문서
  • C++ 실전 가이드 #29: Asio
  • 고성능 네트워크 가이드 #3: Strand
  • 고성능 네트워크 가이드 #7: Composed Operation


한 줄 요약: 대규모 동시 접속·메모리 풀 설계로 백엔드/게임 서버 시스템 디자인을 익힐 수 있습니다. 다음으로 면접 질문 50선(#46-2)를 읽어보면 좋습니다.

다음 글: [C++ 면접·시스템 설계 #46-2] 자주 틀리는 C++ 기술 면접 질문 50선: 출제 의도와 모범 답변

이전 글: [커리어 가이드 #45-3] C++ 개발자 로드맵: 주니어에서 시니어로 가기 위한 필수 역량 총정리


관련 글

  • C++ 도메인별 요구 역량 | 네카라쿠배·금융·게임 [#46-3]
  • C++ 자주 틀리는 C++ 기술 면접 질문 50선 | 출제 의도와 모범 답변 [#46-2]
  • C++ 함수 객체(Functor) 완벽 가이드 | operator·상태 보유
  • C++ 오픈소스 기여: 유명 라이브러리 분석부터 첫 Pull Request까지 [#45-1]
  • C++ X-Macro 완벽 가이드 | enum-string 매핑·에러 코드·상태 머신·커맨드 테이블 실전
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3