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. 대규모 동시 접속자 처리 구조
아키텍처 개요
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 코어 수 | 컨텍스트 스위칭 최소화 |
| 세션당 Strand | 1개 | 같은 연결 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/delete | 1.0 (기준) | 높음 | O | 낮음 |
| 메모리 풀 (락) | ~0.3 | 낮음 | O | 중간 |
| 스레드 로컬 풀 | ~0.1 | 낮음 | 스레드당 | 높음 |
| 오브젝트 풀 | ~0.2 | 낮음 | 풀별 | 중간 |
CCU별 권장 스레드 수
| 목표 CCU | 권장 스레드 수 | io_context | 비고 |
|---|---|---|---|
| 1,000 | 2~4 | 1개 공유 | 소규모 |
| 10,000 | 4~8 | 1개 공유 | Strand 필수 |
| 50,000 | 8~16 | 1~2개 | Acceptor 분리 검토 |
| 100,000+ | 16~32 | 2개 이상 | 수평 확장 병행 |
버퍼 크기 선택 가이드
| 패킷 유형 | 권장 버퍼 크기 | 설명 |
|---|---|---|
| 제어 패킷 | 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 매핑·에러 코드·상태 머신·커맨드 테이블 실전