C++ 백엔드·게임 서버 시스템 디자인 | 대규모 동시 접속과 메모리 풀
들어가며: 10만 CCU 서버 설계
C++ 백엔드·게임 서버 면접에서는 이론만이 아니라 “대규모 동시 접속자(CCU, Concurrent Users—동시에 접속해 있는 사용자 수)를 어떻게 처리할지”, “세션·패킷·메모리를 어떻게 관리할지”를 설계 수준으로 묻습니다. 이 글은 그런 질문에 대비해 실전에 가까운 아키텍처 키워드를 정리합니다. 구현 세부보다는 구성 요소, 트레이드오프, 면접에서 말할 수 있는 답변 뼈대를 제공합니다.
이 글에서 다루는 것:
- 대규모 동시 접속 처리: 스레드 모델(이벤트 루프·스레드 풀), Acceptor·Worker 분리, 수평 확장
- 메모리 풀 기반 객체 관리: new/delete 대신 풀·오브젝트 풀, 단편화 방지, 라이프사이클
- 세션·패킷 설계: 연결당 세션 객체, 패킷 큐·버퍼 풀
- 문제 시나리오·완전한 설계 예제·흔한 실수·프로덕션 패턴 관련 글: C++ 실전 가이드 #29: Asio, 고성능 네트워크 가이드 #1~#3에서 이벤트 루프·Strand를 다룹니다.
10만 CCU 서버에서 실제로 터지는 문제들
피크 타임에 서버가 멈춥니다
상황: MMORPG 서버가 점검 후 오픈 시점에 5만 명이 동시 접속 시도
문제: 단일 스레드 accept + 동기 I/O → 연결 수락이 병목, 나머지 클라이언트 타임아웃
결과: "접속이 안 돼요" 문의 폭주, 이벤트 루프 블로킹으로 기존 유저도 끊김
→ Acceptor·Worker 분리, 비동기 I/O, 스레드 풀 도입 필요
3일째 메모리 사용량이 계속 상승
상황: C++ 게임 서버가 72시간 운영 후 메모리 8GB → 14GB로 증가
문제: 세션·패킷 버퍼를 new/delete로 할당 → 힙 단편화 + 할당/해제 오버헤드
결과: 단편화로 인한 "가상 메모리 부족" 에러, 재시작 없이는 회복 불가
→ 메모리 풀·오브젝트 풀 도입, 스레드 로컬 풀로 경합 감소
같은 유저의 패킷이 뒤섞여 처리됩니다
상황: 한 클라이언트에서 로그인 → 캐릭터 선택 → 게임 입장 요청이 연속 전송
문제: 여러 스레드가 같은 세션의 읽기/쓰기 핸들러를 동시 실행 → 레이스 컨디션
결과: 캐릭터 선택 전에 게임 입장 처리, 상태 꼬임, 크래시
→ 연결당 Strand로 해당 연결의 I/O 직렬화 필요
10만 CCU 목표인데 3만에서 한계
상황: 단일 서버로 10만 CCU 목표, 3만 접속 시 CPU 100%, 지연 급증
문제: 스레드 수 = 연결 수 설계, 컨텍스트 스위칭·락 경합 과다
결과: 이벤트 기반 I/O 미적용, 블로킹 소켓 사용
→ io_context + run() 스레드 풀, 논블로킹 소켓, 수평 확장 검토
패킷 송신 시 크래시
상황: 여러 스레드에서 같은 세션의 send()를 동시 호출
문제: 소켓은 스레드 안전하지 않음, 버퍼 덮어쓰기·use-after-free
결과: 랜덤 크래시, 패킷 손상
→ 패킷 큐 + 단일 Strand에서 순차 송신
이 글에서는 위와 같은 문제를 예방하는 시스템 설계와 완전한 예제를 다룹니다.
개념을 잡는 비유
대규모 동시 접속자 처리 구조
아키텍처 개요
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;
}
메모리 풀·오브젝트 풀 설계
왜 풀을 쓰는가
- 힙 단편화: 수만 개의 세션·패킷 버퍼를 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_;
};
세션·패킷·버퍼 설계
연결당 세션
- 한 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
Acceptor-Worker·Strand·오브젝트 풀 코드 예제
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 직렬화, 락 불필요 |
| 버퍼 | 고정 크기 + 풀 | 단편화·할당 비용 감소 |
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
세션 + 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_;
};
오브젝트 풀 기반 세션 관리
#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_;
};
소켓 동시 접근, 매번 할당, 송신 큐 폭증: 설계 실수와 해결
세션 소켓을 여러 스레드에서 동시 사용
문제: 소켓은 스레드 안전하지 않습니다. 한 스레드에서 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 내부 → 안전
});
}
패킷 버퍼를 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);
}
};
세션 반환 시 참조가 남아 있는데 풀에 반환
문제: 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() 후 풀 반환은 모든 참조 해제 후에만
}
블로킹 작업을 이벤트 루프에서 실행
문제: 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);
});
});
}
수평 확장 시 세션 상태를 서버 로컬에만 보관
문제: 로드 밸런서가 다른 인스턴스로 라우팅하면 세션이 손실됩니다.
❌ 설계: 세션 상태를 프로세스 메모리에만 저장
→ 인스턴스 장애/재시작 시 세션 모두 손실
→ 스케일 아웃 시 세션 이전 불가
해결: 스테이트리스 + Redis 등 공유 저장소를 쓰거나, 세션 어피니티 + 복제를 사용합니다.
✅ 설계 A: 세션 상태를 Redis에 저장, 서버는 스테이트리스
✅ 설계 B: L4 스티키 세션 + 인스턴스당 세션 유지, 장애 시 복구 로직
송신 큐 무한 증가
문제: 클라이언트가 수신을 멈추면 송신 큐가 계속 쌓여 메모리가 폭발합니다.
// ❌ 잘못된 예: 큐 크기 제한 없음
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();
}
타임아웃 미설정
문제: 비활성 연결(클라이언트 종료 미통보)이 세션을 계속 점유합니다.
// ❌ 잘못된 예: 타임아웃 없음
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()
스레드·메모리·네트워크·모니터링 설계 원칙
스레드 모델
| 패턴 | 사용 시점 | 장점 | 단점 |
|---|---|---|---|
| 1 스레드 1 연결 | 소규모 | 구현 단순 | CCU 제한 |
| 이벤트 루프 + 스레드 풀 | 대규모 | 높은 CCU, 락 최소 | 비동기 코드 복잡 |
| Acceptor-Worker 분리 | 초대규모 | accept 병목 제거 | 설계 복잡 |
메모리 관리
- 크기 클래스별 풀: 64B, 256B, 1KB, 4KB 등으로 분리해 내부 단편화 감소.
- 스레드 로컬 풀: 스레드당 풀 인스턴스를 두어 락 없이 allocate/deallocate.
- 반환 시 초기화: 풀 반환 시 민감한 데이터 덮어쓰기(보안·use-after-free 방지).
네트워크
- 고정 헤더 + 가변 바디: 헤더로 길이 파악 후 바디 읽기, Composed Operation 활용.
- 송신 큐:
async_write중 추가 송신 요청은 큐에 넣고, 쓰기 완료 시 큐에서 다음 항목을 꺼내 전송. - 타임아웃:
steady_timer로 읽기/쓰기 타임아웃, 비활성 세션 정리.
모니터링
- 연결 수 Gauge: 현재 세션 수, 풀 사용량.
- 패킷 처리량 Counter: 초당 수신/송신 패킷 수.
- 지연 Histogram: 요청-응답 지연 시간 분포.
셧다운·백프레셔·서킷 브레이커: 운영 패턴
그레이스풀 셧다운
// SIGINT/SIGTERM 수신 시
void on_signal() {
acceptor_.close(); // 새 연결 거부
for (auto& session : sessions_) {
session->close(); // 기존 연결 정리
}
io_ctx_.stop();
// 모든 핸들러 완료 대기 후 프로세스 종료
}
백프레셔 (Backpressure)
// 송신 큐가 일정 크기 초과 시 수신 일시 중단
if (send_queue_.size() > 1000) {
pause_reading(); // async_read 취소
} else {
resume_reading();
}
헬스 체크
// 로드 밸런서용 헬스 체크 엔드포인트
// - /health: 200 OK
// - 연결 수 < 임계값, 메모리 < 임계값일 때만 healthy
캐나리 배포
1. 새 버전을 소수 인스턴스에만 배포
2. 에러율·지연 메트릭 비교
3. 문제 없으면 점진적 확대
4. 문제 시 즉시 롤백
서킷 브레이커
// 외부 서비스(DB, 캐시) 호출 실패 시
// - 연속 N회 실패 → 서킷 오픈, 즉시 실패 반환
// - 일정 시간 후 half-open, 1회 시도
// - 성공 시 클로즈, 실패 시 다시 오픈
스레드 로컬 메모리 풀
// 스레드당 풀 인스턴스 → 락 없이 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();
}
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/쿠키 기반으로 같은 인스턴스로 라우팅하고, 인스턴스 메모리에 세션을 보관합니다. 장애 시에는 해당 클라이언트만 재연결합니다.
면접에서 답변할 때 포인트
- “대규모 동시 접속을 어떻게 처리하나요?” → 이벤트 기반 I/O(Asio), 논블로킹 소켓, 스레드 풀에서 run(), Strand로 연결 단위 직렬화. 필요 시 수평 확장·로드밸런싱.
- “메모리 풀을 왜 쓰나요?” → 대량의 단명 객체에서 할당/해제 비용과 힙 단편화를 줄이기 위해서입니다. 오브젝트 풀로 생성/파괴 비용도 줄일 수 있습니다.
- “세션과 패킷을 어떻게 관리하나요?” → 연결당 세션 객체, Strand로 해당 연결의 I/O 직렬화, 패킷은 버퍼 풀·고정 버퍼·Composed Operation으로 처리.
- “10만 CCU를 어떻게 달성하나요?” → 단일 노드: io_context + N개 run() 스레드, 연결당 Strand, 메모리/오브젝트 풀. 한계 도달 시: 수평 확장, 로드 밸런서, 세션 분산.
10만 CCU 서버 구현 전 점검 항목
아키텍처
- 이벤트 기반 I/O (epoll/kqueue/Asio) 적용
- 스레드 수 = CPU 코어 수 (또는 N+1)
- 연결당 Strand로 I/O 직렬화
- Acceptor-Worker 분리 (필요 시)
메모리
- 메모리 풀 또는 오브젝트 풀 도입
- 스레드 로컬 풀 (락 경합 감소)
- 풀 반환 시 참조 정리 확인
세션·패킷
- 연결당 세션 객체
- 고정 버퍼 또는 버퍼 풀
- 송신 큐 + Strand 내 순차 송신
운영
- 그레이스풀 셧다운
- 헬스 체크 엔드포인트
- 메트릭 노출 (연결 수, 지연, 에러율)
- 로그 레벨·구조화 로깅
같이 보면 좋은 글
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
- C++ 메모리 풀 구현 비교
- C++ 자주 틀리는 C++ 기술 면접 질문 50선 | 출제 의도와 모범 답변 [#46-2]
- C++ 함수 객체 vs 람다 vs std::function
- C++ 오픈소스 기여: 유명 라이브러리 분석부터 첫 Pull Request까지 [#45-1]
- X-Macro로 중복 없애기
자주 묻는 질문 (FAQ)
Q. 느린 클라이언트 때문에 세션 송신 큐가 계속 커지면 어떻게 처리해야 하나요?
A. 서버가 보내는 속도보다 클라이언트가 받는 속도가 느리면 송신 대기 패킷이 세션마다 쌓여 메모리가 계속 늘어나고, 결국 서버 전체가 영향을 받습니다. 세션별 송신 큐에 개수나 바이트 기준의 상한을 두고, 초과하면 오래된 비필수 패킷을 버리거나 해당 연결을 끊는 정책을 미리 정해야 합니다. 또한 async_write는 이전 쓰기가 끝난 뒤 다음 쓰기를 시작하도록 한 번에 하나씩 체인으로 이어야 버퍼가 섞이거나 동시 쓰기 문제가 생기지 않습니다.
Q. 메모리 풀과 오브젝트 풀 중 뭘 써야 하나요?
A. 메모리 풀: 바이트 버퍼(패킷, 임시 데이터)처럼 “크기만 중요하고 타입이 없는” 경우. 오브젝트 풀: 세션·엔티티처럼 “생성자/소멸자·상태 초기화”가 필요한 객체. 둘을 함께 쓰는 경우가 많습니다(세션은 오브젝트 풀, 세션 내부 버퍼는 메모리 풀).
Q. Strand 없이 mutex로 보호하면 안 되나요?
A. 동작은 합니다. 하지만 Strand는 락을 걸지 않고 executor 큐에서 순차 실행을 보장하므로, 락 경합·컨텍스트 스위칭이 없어 성능이 좋습니다. Asio 기반이라면 Strand 사용을 권장합니다.
Q. 10만 CCU가 정말 한 서버로 가능한가요?
A. 이벤트 기반 I/O, 적절한 스레드 수, 메모리 풀, 단순한 비즈니스 로직이라면 가능합니다. 다만 CPU·메모리·네트워크 대역폭에 따라 달라지며, 실제로는 5~8만 CCU에서 수평 확장을 검토하는 경우가 많습니다.
참고 자료
대규모 동시 접속·메모리 풀 설계로 백엔드/게임 서버 시스템 디자인을 익힐 수 있습니다. 다음으로 면접 질문 50선(#46-2)를 읽어보면 좋습니다. 다음 글: [C++ 면접·시스템 설계 #46-2] 자주 틀리는 C++ 기술 면접 질문 50선: 출제 의도와 모범 답변 이전 글: 주니어에서 시니어까지: C++ 개발자가 단계별로 갖춰야 할 역량