C++ 백엔드·게임 서버 시스템 디자인 | 대규모 동시 접속과 메모리 풀
C++ 백엔드·게임 서버 면접에서는 언어 문법보다 “많은 동시 접속(CCU, Concurrent Users)을 어떻게 받을지”, “세션·패킷·메모리를 어떻게 관리할지”를 설계 수준에서 묻는 경우가 많습니다. 이 글은 그런 질문에 답할 수 있도록 이벤트 루프와 스레드 풀, Strand를 이용한 연결 단위 직렬화, 메모리·오브젝트 풀, 세션과 송신 큐 설계를 Boost.Asio 기준의 코드 골격과 함께 정리합니다. Asio 자체의 기초는 C++ 실전 가이드 #29: Asio와 고성능 네트워크 가이드 #1에서 다룹니다.
설계가 잘못되었을 때 나타나는 증상
설계 질문에 답하려면 먼저 어떤 증상이 어떤 설계 결함에서 나오는지 연결할 수 있어야 합니다.
- 점검 직후처럼 접속이 몰릴 때 새 연결이 타임아웃되고 기존 유저까지 끊긴다면, 연결 수락이나 I/O가 블로킹 호출로 이벤트 루프를 막고 있을 가능성이 높습니다. 비동기 accept와 비동기 I/O, 또는 Acceptor·Worker 분리가 해법입니다.
- 서버를 며칠 띄워 두면 메모리 사용량이 계속 늘어난다면, 누수가 아닌지부터 확인한 뒤 크기가 제각각인 짧은 수명의 할당이 힙 단편화를 일으키는지 봅니다. 풀이나 다른 할당자(jemalloc, tcmalloc 등)를 검토할 지점입니다.
- 한 클라이언트가 연속으로 보낸 요청의 처리 순서가 뒤바뀌거나 세션 상태가 꼬인다면, 여러 스레드가 같은 세션의 핸들러를 동시에 실행하고 있는 것입니다. 연결 단위 직렬화(Strand)가 필요합니다.
- 연결 수에 비례해 스레드를 만드는 구조라면, 연결이 늘수록 컨텍스트 스위칭과 스택 메모리 비용이 커져 CPU가 실제 일보다 스케줄링에 시간을 씁니다. 소수의 스레드가 이벤트 기반으로 많은 소켓을 처리하는 구조로 바꿔야 합니다.
- 여러 스레드가 같은 세션의
send()를 동시에 호출해 패킷이 섞이거나 크래시가 난다면, 송신 큐를 두고 한 번에 하나의async_write만 진행되도록 해야 합니다.
대규모 동시 접속자 처리 구조
아키텍처 개요
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이고, io_context::run()을 호출한 스레드가 완료된 비동기 작업의 핸들러를 실행합니다. 여러 스레드가 같은 io_context의 run()을 호출하면 핸들러가 그 스레드들에 분산되므로, 소켓마다 스레드를 두지 않고도 소수의 스레드로 많은 소켓을 처리할 수 있습니다. 운영체제 수준에서는 Linux의 epoll, macOS·BSD의 kqueue, Windows의 IOCP가 이 이벤트 통지를 담당합니다.
구성 방법은 크게 두 가지입니다. 하나의 io_context를 여러 스레드가 공유하면 부하가 자동으로 고르게 나뉘지만, 같은 연결의 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있어 Strand가 필요합니다. 스레드마다 별도 io_context를 두고 새 연결을 round-robin으로 배정하면, 한 연결의 핸들러는 항상 같은 스레드에서 실행되어 Strand 없이도 직렬화되지만, 특정 스레드에 무거운 연결이 몰려도 다른 스레드가 도와줄 수 없습니다.
// g++ -std=c++17 -pthread pool_demo.cpp -o pool_demo (Boost 헤더 필요)
#include <boost/asio.hpp>
#include <algorithm>
#include <iostream>
#include <thread>
#include <vector>
int main() {
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io); // 할 일이 없어도 run()이 반환되지 않게 유지
unsigned n = std::max(1u, std::thread::hardware_concurrency());
std::vector<std::thread> threads;
for (unsigned i = 0; i < n; ++i) {
threads.emplace_back([&io] { io.run(); });
}
auto strand = boost::asio::make_strand(io);
for (int i = 0; i < 5; ++i) {
// 같은 strand에 post한 핸들러는 동시에 실행되지 않고 post 순서대로 실행됨
boost::asio::post(strand, [i] { std::cout << "handler " << i << '\n'; });
}
work.reset(); // 남은 핸들러를 모두 실행하면 run()이 반환되도록 허용
for (auto& t : threads) t.join();
}
이 예제에서 핸들러들은 여러 스레드 중 어느 스레드에서든 실행될 수 있지만, 같은 strand에 있으므로 handler 0부터 handler 4까지 순서대로, 한 번에 하나씩 실행됩니다. std::cout에 락을 걸지 않았는데도 출력이 섞이지 않는 이유입니다.
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 완료 후 실행
Strand는 자기에게 들어온 핸들러를 큐에 넣고, 한 번에 하나만 실행되도록 보장하는 실행기(executor)입니다. 연결마다 Strand를 하나 두고 그 연결의 읽기·쓰기·타이머 핸들러를 모두 그 Strand에서 실행하면, 세션의 상태를 뮤텍스 없이 다룰 수 있습니다.
std::mutex로 세션을 보호해도 동작은 합니다. 차이는 경합이 생겼을 때입니다. 뮤텍스는 이미 다른 스레드가 잡고 있으면 현재 스레드가 그 자리에서 기다리므로, 이벤트 루프 스레드 하나가 그동안 다른 연결의 일을 처리하지 못합니다. Strand는 실행 중인 핸들러가 있으면 새 핸들러를 큐에 넣어 두고 스레드는 다른 일을 하러 가므로, 이벤트 루프 스레드가 블록되지 않습니다. Strand 구현도 내부 큐를 보호하기 위해 짧은 동기화를 쓰기는 하지만, 사용자 코드가 락을 쥔 채 오래 기다리는 상황은 생기지 않습니다.
수평 확장
한 머신의 CPU, 메모리, 네트워크 대역폭에는 한계가 있으므로, 단일 노드 최적화 다음 단계는 로드 밸런서 뒤에 여러 서버 인스턴스를 두는 것입니다. 이때 세션 상태를 어디에 둘지가 핵심 결정입니다. 같은 클라이언트를 항상 같은 인스턴스로 보내는 세션 어피니티를 쓰면 상태를 인스턴스 메모리에 둘 수 있지만 인스턴스가 죽으면 그 세션이 사라집니다. 상태를 Redis 같은 공유 저장소에 두고 서버를 스테이트리스로 만들면 어느 인스턴스로 가도 처리할 수 있지만 매 요청마다 저장소 왕복 비용이 듭니다. 실시간 게임 서버처럼 상태가 크고 자주 바뀌는 경우에는 어피니티를 쓰고 중요한 상태만 주기적으로 저장하는 절충이 흔합니다.
한 서버가 몇 명의 CCU를 받을 수 있는지는 패킷 빈도, 로직의 무게, 하드웨어에 따라 크게 달라서 일반적인 숫자를 말하기 어렵습니다. 면접에서는 숫자보다 “부하 테스트로 단일 노드의 한계를 측정하고, 병목이 CPU인지 메모리인지 네트워크인지 확인한 뒤 수평 확장 단위를 정한다”는 절차를 설명하는 편이 설득력이 있습니다.
메모리 풀·오브젝트 풀 설계
왜 풀을 쓰는가
세션과 패킷 버퍼처럼 짧은 수명의 객체를 초당 수만 번 new/delete하면, 할당 자체의 비용과 여러 스레드가 전역 힙을 두고 경합하는 비용이 쌓이고, 크기가 제각각인 할당이 섞이면 힙 단편화가 생길 수 있습니다. 풀은 미리 할당해 둔 블록을 재사용해서 이 경로를 단순하게 만듭니다.
메모리 풀은 같은 크기(또는 몇 가지 크기 클래스)의 바이트 블록을 나눠 주고 돌려받는 방식으로, 패킷 버퍼처럼 타입보다 크기가 중요한 데이터에 맞습니다. 오브젝트 풀은 세션이나 엔티티처럼 생성자·소멸자와 상태 초기화가 필요한 객체를 통째로 재사용하는 방식입니다. 실제로는 세션은 오브젝트 풀로, 세션 안의 버퍼는 메모리 풀로 관리하는 식으로 함께 쓰는 경우가 많습니다.
풀을 도입하기 전에 확인할 점도 있습니다. 최근의 범용 할당자(glibc malloc의 tcache, jemalloc, tcmalloc, mimalloc)는 작은 크기의 할당을 스레드별 캐시로 처리하므로, 직접 만든 풀이 기대만큼 빠르지 않을 수 있습니다. 프로파일러에서 할당 경로가 실제로 병목으로 보일 때 도입하고, 도입 전후를 같은 부하로 비교하는 것이 안전합니다.
flowchart LR
subgraph Heap["힙 (new/delete)"]
H1[블록1]
H2[빈공간]
H3[블록2]
H4[빈공간]
H5[블록3]
end
subgraph Pool[메모리 풀]
P1[연속 블록]
P2[연속 블록]
P3[연속 블록]
end
크기 클래스를 64B, 256B, 4KB처럼 나누면 요청 크기와 블록 크기의 차이(내부 단편화)를 줄일 수 있습니다. 풀에서 꺼낸 객체는 반환할 때 다른 곳에 남은 참조가 없어야 하며, 디버그 빌드에서 반환된 블록을 특정 패턴(예: 0xDD)으로 덮어쓰면 반환 후 사용(use-after-free)이 일어났을 때 이상한 값이 바로 드러나 원인을 찾기 쉬워집니다. 이 덮어쓰기는 버그를 막아 주는 것이 아니라 드러나게 하는 장치입니다.
메모리 풀 구현 예제
#include <cstddef>
#include <mutex>
#include <new>
#include <vector>
// 단일 크기 메모리 풀: 같은 크기 블록만 관리
class FixedSizePool {
public:
explicit FixedSizePool(std::size_t block_size, std::size_t initial_count = 64)
: block_size_(block_size) {
free_list_.reserve(initial_count);
for (std::size_t i = 0; i < initial_count; ++i) {
free_list_.push_back(::operator new(block_size_));
}
}
~FixedSizePool() {
// 반환된 블록만 해제함. 사용 중인 블록이 남아 있으면 누수가 되므로
// 풀은 모든 사용자보다 오래 살아야 함
for (void* p : free_list_) ::operator delete(p);
}
FixedSizePool(const FixedSizePool&) = delete;
FixedSizePool& operator=(const FixedSizePool&) = delete;
void* allocate() {
std::lock_guard<std::mutex> lock(mtx_);
if (free_list_.empty()) {
return ::operator new(block_size_); // 풀이 비면 힙에서 보충
}
void* p = free_list_.back();
free_list_.pop_back();
return p;
}
void deallocate(void* p) {
std::lock_guard<std::mutex> lock(mtx_);
free_list_.push_back(p);
}
private:
std::size_t block_size_;
std::vector<void*> free_list_;
std::mutex mtx_;
};
::operator new로 얻은 블록은 alignof(std::max_align_t)에 맞춰 정렬되므로, 그보다 큰 정렬이 필요한 타입을 담을 때는 정렬 인자를 받는 operator new 오버로드를 써야 합니다. 이 풀은 모든 호출에서 뮤텍스를 잡으므로, 여러 Worker 스레드가 자주 부르면 그 뮤텍스가 새로운 경합 지점이 됩니다.
스레드 로컬 풀
// 스레드마다 별도 풀: 같은 스레드 안에서만 쓰면 잠금이 필요 없음
thread_local FixedSizePool tls_pool(256, 64);
void* fast_allocate() { return tls_pool.allocate(); }
void fast_deallocate(void* p) { tls_pool.deallocate(p); }
스레드마다 풀을 두면 경합이 사라지지만, 위의 FixedSizePool은 여전히 뮤텍스를 잡으므로 진짜로 잠금을 없애려면 뮤텍스 없는 버전을 따로 두어야 합니다. 더 큰 문제는 스레드 A에서 할당한 블록을 스레드 B에서 해제하는 경우입니다. 위 코드에서는 블록이 B의 풀로 들어가므로, 생산자 스레드와 소비자 스레드가 나뉜 구조에서는 한쪽 풀은 계속 비고 다른 쪽 풀은 계속 쌓입니다. 연결을 특정 스레드에 고정하는 구조(스레드당 io_context)에서는 이 문제가 잘 생기지 않지만, 공유 io_context 구조에서는 해제를 원래 스레드로 돌려보내는 장치가 필요합니다.
오브젝트 풀
#include <cassert>
#include <cstddef>
#include <vector>
// T는 기본 생성과 대입이 가능해야 함. 단일 스레드에서 사용한다고 가정
template <typename T>
class ObjectPool {
public:
explicit ObjectPool(std::size_t capacity) : objects_(capacity) {
free_indices_.reserve(capacity);
for (std::size_t i = 0; i < capacity; ++i) {
free_indices_.push_back(capacity - 1 - i);
}
}
T* acquire() {
if (free_indices_.empty()) return nullptr;
std::size_t idx = free_indices_.back();
free_indices_.pop_back();
return &objects_[idx];
}
void release(T* obj) {
std::size_t idx = static_cast<std::size_t>(obj - objects_.data());
assert(idx < objects_.size());
objects_[idx] = T{}; // 상태 초기화
free_indices_.push_back(idx);
}
private:
std::vector<T> objects_; // 생성 후 크기를 바꾸지 않으므로 포인터가 안정적
std::vector<std::size_t> free_indices_;
};
객체를 std::vector<T> 하나에 연속으로 두었기 때문에 반환된 포인터에서 objects_.data()를 빼면 인덱스가 나옵니다. 객체를 std::optional<T>처럼 다른 타입으로 감싸 저장하면 원소 간격이 sizeof(T)가 아니게 되어 이 계산이 틀어지므로, 감싸는 경우에는 인덱스를 따로 들고 다녀야 합니다.
세션·패킷·버퍼 설계
TCP 연결 하나에 세션 객체 하나를 둡니다. 세션은 소켓, 수신 버퍼, 송신 큐, 프로토콜 상태(헤더를 읽는 중인지 바디를 읽는 중인지 등)를 가지며, 고성능 네트워크 가이드 #3처럼 연결당 Strand를 두면 이 상태를 뮤텍스 없이 다룰 수 있습니다.
TCP는 바이트 스트림이라 한 번의 읽기가 패킷 하나와 일치한다는 보장이 없습니다. 그래서 보통 고정 길이 헤더에 바디 길이를 넣고, 헤더를 먼저 정확히 읽은 뒤 그 길이만큼 바디를 읽습니다. 이 두 단계 읽기를 하나의 비동기 연산으로 묶는 방법은 Composed Operation에서 다룹니다.
송신은 큐로 관리합니다. async_write는 내부적으로 여러 번의 쓰기로 나뉠 수 있어서, 이전 async_write가 끝나기 전에 같은 소켓에 다음 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·세션·송신 큐 코드 골격
Acceptor
#include <boost/asio.hpp>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// GameSession은 다음 절에서 정의 (실제 코드에서는 Server보다 먼저 선언되어야 함)
class Server {
public:
Server(asio::io_context& io_ctx, unsigned short port)
: io_ctx_(io_ctx), acceptor_(io_ctx, tcp::endpoint(tcp::v4(), port)) {
do_accept();
}
private:
void do_accept() {
// 새 소켓을 연결별 strand에 묶어서 받음: 이후 이 소켓의 핸들러는 그 strand에서 실행
acceptor_.async_accept(
asio::make_strand(io_ctx_),
[this](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<GameSession>(std::move(socket))->start();
}
do_accept(); // 다음 연결 대기
});
}
asio::io_context& io_ctx_;
tcp::acceptor acceptor_;
};
async_accept에 executor를 넘기면 수락된 소켓이 그 executor를 기본 실행기로 갖습니다. 연결마다 make_strand로 새 strand를 만들어 넘기면, 이후 이 소켓에서 시작한 비동기 작업의 완료 핸들러는 별도 지정 없이도 그 strand에서 실행됩니다. 스레드마다 io_context를 두는 구조라면 여기서 make_strand(io_ctx_) 대신 round-robin으로 고른 Worker의 io_context를 넘깁니다.
세션과 송신 큐
#include <boost/asio.hpp>
#include <array>
#include <cstdint>
#include <deque>
#include <memory>
#include <vector>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class GameSession : public std::enable_shared_from_this<GameSession> {
public:
explicit GameSession(tcp::socket socket) : socket_(std::move(socket)) {}
void start() { do_read(); }
// 다른 strand나 스레드에서 호출해도 안전하도록 세션의 strand로 넘김
void send(std::vector<std::uint8_t> packet) {
asio::post(socket_.get_executor(),
[self = shared_from_this(), p = std::move(packet)]() mutable {
bool idle = self->send_queue_.empty();
self->send_queue_.push_back(std::move(p));
if (idle) self->do_write(); // 진행 중인 쓰기가 없을 때만 시작
});
}
private:
void do_read() {
// 소켓의 executor가 이 세션의 strand이므로 핸들러도 strand에서 실행됨
socket_.async_read_some(asio::buffer(recv_buffer_),
[self = shared_from_this()](boost::system::error_code ec, std::size_t n) {
if (ec) return; // 연결 종료 또는 오류: self가 마지막 참조면 세션 소멸
self->on_read(n);
self->do_read();
});
}
void on_read(std::size_t n) {
// recv_buffer_[0, n)을 누적 버퍼에 붙이고, 완성된 패킷이 있으면 처리
}
void do_write() {
asio::async_write(socket_, asio::buffer(send_queue_.front()),
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return;
self->send_queue_.pop_front();
if (!self->send_queue_.empty()) self->do_write();
});
}
tcp::socket socket_;
std::array<std::uint8_t, 4096> recv_buffer_{};
std::deque<std::vector<std::uint8_t>> send_queue_;
};
async_read는 버퍼를 가득 채울 때까지 완료되지 않으므로, 길이를 모르는 수신에는 async_read_some으로 도착한 만큼 읽고 프레이밍은 직접 처리합니다. 핸들러에 shared_from_this()를 캡처하는 이유는 비동기 작업이 대기하는 동안 세션이 소멸되지 않게 하기 위해서입니다. 연결이 끊겨 더 이상 대기 중인 작업이 없으면 마지막 shared_ptr이 사라지면서 세션이 자연스럽게 소멸합니다. 송신 큐의 앞 원소는 async_write가 끝날 때까지 버퍼로 쓰이므로, 완료 핸들러에서만 pop_front()합니다.
자주 하는 설계 실수
세션 소켓을 여러 스레드에서 동시 사용
// 잘못된 예: 별도 스레드에서 세션의 송신을 직접 호출
void on_packet_received(Session* session, Packet p) {
std::thread([session, p]() {
auto response = process(p);
session->write_now(response); // 세션의 strand 밖에서 소켓 접근
}).detach();
}
Asio 소켓 객체는 같은 객체에 대한 동시 호출에 안전하지 않습니다. 위 코드는 세션의 strand 밖에서 소켓에 접근하고, 원시 포인터를 캡처했기 때문에 스레드가 실행될 때 세션이 이미 소멸했을 수도 있습니다. 위의 GameSession::send처럼 세션 strand로 post하는 함수만 외부에 공개하고, shared_ptr로 수명을 붙잡아야 합니다.
패킷마다 new/delete
// 수신할 때마다 버퍼를 새로 할당
void on_read(std::size_t len) {
auto* buf = new std::uint8_t[len];
// ...
delete[] buf;
}
세션마다 고정 수신 버퍼를 두고, 패킷 조립용 누적 버퍼는 std::vector를 재사용(clear()는 용량을 유지함)하거나 버퍼 풀에서 꺼내 쓰면 할당 횟수를 줄일 수 있습니다.
참조가 남은 세션을 풀에 반환
void on_disconnect(Session* session) {
session_pool_.release(session); // 대기 중인 핸들러가 아직 session을 참조할 수 있음
}
반환된 세션이 곧바로 새 연결에 재사용되면, 이전 연결의 늦게 도착한 핸들러가 새 연결의 상태를 건드리는 use-after-free가 됩니다. 핸들러가 shared_ptr을 붙잡게 하고, 풀을 쓴다면 shared_ptr의 커스텀 삭제자에서 풀에 반환하도록 해서 마지막 참조가 사라진 뒤에만 반환되게 합니다.
이벤트 루프에서 블로킹 호출
void on_read(...) {
auto result = db.query("SELECT ..."); // 동기 DB 호출이 이 스레드를 멈춤
}
완료 핸들러 안에서 동기 DB 쿼리나 파일 I/O를 하면 그 시간 동안 해당 스레드가 다른 연결을 처리하지 못합니다. 블로킹 작업은 별도 스레드 풀로 넘기고, 결과는 세션의 strand로 다시 돌려보냅니다.
void on_request(Request req) {
asio::post(blocking_pool_, [self = shared_from_this(), req]() {
auto result = db.query(req.sql()); // 별도 풀에서 블로킹
asio::post(self->socket_.get_executor(), // 세션 strand로 복귀
[self, result]() { self->send(make_response(result)); });
});
}
송신 큐 무한 증가
클라이언트가 수신을 멈추거나 느리게 받으면 서버가 보낼 패킷이 그 세션의 송신 큐에 계속 쌓입니다. 세션 하나의 문제가 프로세스 전체의 메모리 부족으로 번지지 않도록 큐에 상한을 둡니다.
// send()가 strand에서 실행하는 람다 안
if (send_queue_.size() >= kMaxQueuedPackets) {
close("send queue full"); // 또는 비필수 패킷(위치 갱신 등)만 버리기
return;
}
상한을 넘었을 때 연결을 끊을지, 오래된 비필수 패킷을 버릴지는 게임 규칙에 따라 정합니다. 상대가 보내는 요청을 줄이게 하려면 송신 큐가 일정 수준을 넘는 동안 그 연결의 다음 async_read를 시작하지 않는 방법도 있습니다. 읽기를 멈추면 TCP 수신 윈도가 차서 상대의 송신도 자연스럽게 느려집니다.
타임아웃 없음
상대가 연결 종료를 알리지 못하고 사라지면(전원 차단, 네트워크 단절) 서버 쪽 소켓은 오랫동안 열린 채 세션을 점유합니다. 애플리케이션 수준의 하트비트와 읽기 타이머로 이런 연결을 정리합니다.
void start_read_timer() {
read_timer_.expires_after(std::chrono::seconds(30));
read_timer_.async_wait([self = shared_from_this()](boost::system::error_code ec) {
if (!ec) self->close("read timeout"); // cancel()되면 ec == operation_aborted
});
}
// 패킷을 받을 때마다 start_read_timer()를 다시 호출해 기한을 연장
expires_after를 다시 호출하면 대기 중인 이전 async_wait는 operation_aborted로 완료되므로, 위처럼 !ec일 때만 닫으면 됩니다. 타이머도 세션 strand의 executor로 만들어야 소켓 핸들러와 동시에 실행되지 않습니다.
헤더-바디 두 단계 읽기
void Session::read_header() {
asio::async_read(socket_, asio::buffer(header_), // 4바이트를 정확히 읽음
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return;
std::uint32_t len = parse_length(self->header_);
if (len > kMaxPacketSize) { self->close("packet too large"); return; }
self->body_.resize(len);
self->read_body();
});
}
void Session::read_body() {
asio::async_read(socket_, asio::buffer(body_),
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return; // 오류 시 다시 읽지 않음
self->on_packet(self->body_);
self->read_header(); // 다음 패킷
});
}
길이 필드는 신뢰할 수 없는 입력이므로 상한을 검사하지 않으면 악의적인 클라이언트가 수 GB를 할당하게 만들 수 있습니다. 오류가 났을 때 다시 읽기를 시작하면 닫힌 소켓에서 즉시 실패하는 읽기가 무한히 반복되므로, 오류 경로에서는 체인을 끊어야 합니다.
운영 패턴
그레이스풀 셧다운
asio::signal_set signals(io_ctx, SIGINT, SIGTERM);
signals.async_wait([&](boost::system::error_code, int) {
acceptor.close(); // 새 연결 거부
for (auto& s : sessions) s->close(); // 각 세션 strand에서 소켓을 닫도록 post
work_guard.reset(); // 남은 작업이 끝나면 run()이 반환되도록 허용
});
io_context::stop()을 바로 부르면 대기 중인 핸들러가 실행되지 않은 채 run()이 반환되어, 송신 중이던 응답이나 정리 코드가 실행되지 않습니다. 새 연결을 막고 기존 연결을 닫은 뒤 work guard를 풀어 두면, 남은 핸들러가 모두 끝났을 때 run()이 스스로 반환합니다. 일정 시간 안에 끝나지 않는 경우에 대비해 타이머로 stop()을 예약해 두는 것이 일반적입니다.
헬스 체크, 서킷 브레이커, 점진 배포
로드 밸런서가 비정상 인스턴스를 빼낼 수 있도록 헬스 체크 엔드포인트를 두고, 연결 수나 메모리가 임계값을 넘으면 unhealthy를 반환해 새 연결이 다른 인스턴스로 가게 합니다. DB나 캐시 같은 외부 서비스가 느려질 때는 서킷 브레이커로 연속 실패가 일정 횟수를 넘으면 한동안 호출을 바로 실패시키고, 시간이 지나면 시험 호출 한 번으로 회복 여부를 확인합니다. 그래야 외부 서비스의 지연이 모든 세션의 대기로 번지지 않습니다. 새 버전은 소수 인스턴스에 먼저 배포해 에러율과 지연을 비교한 뒤 넓히고, 문제가 보이면 되돌립니다.
모니터링
현재 세션 수와 풀 사용량은 게이지로, 초당 수신·송신 패킷 수는 카운터로, 요청-응답 지연은 히스토그램으로 노출합니다. 평균 지연만 보면 일부 세션의 긴 지연이 가려지므로 p99 같은 백분위를 함께 보는 것이 좋습니다. 송신 큐 길이의 최댓값도 느린 클라이언트를 찾는 데 유용한 지표입니다.
스레드 모델 비교
| 패턴 | 맞는 상황 | 장점 | 단점 |
|---|---|---|---|
| 연결당 스레드 | 연결 수가 적을 때 | 동기 코드라 구현이 단순 | 연결 수에 비례해 스레드·스택 비용 증가 |
| 공유 io_context + 스레드 풀 | 연결 수가 많을 때 | 부하가 자동 분산 | Strand 필요, 비동기 코드가 복잡 |
| 스레드당 io_context | 연결 간 상호작용이 적을 때 | 연결이 한 스레드에 고정되어 직렬화가 공짜 | 스레드 간 부하 불균형 |
스레드 수는 CPU 코어 수에서 출발하는 것이 일반적입니다. 이벤트 루프 스레드가 블로킹 호출을 하지 않는다면 코어 수보다 많은 스레드는 컨텍스트 스위칭만 늘립니다. 블로킹 작업이 있다면 이벤트 루프 스레드를 늘리기보다 블로킹 전용 풀을 따로 두는 편이 낫습니다.
면접에서 답변할 때
“대규모 동시 접속을 어떻게 처리하나요?”라는 질문에는 이벤트 기반 I/O(epoll/IOCP 위의 Asio)로 소수의 스레드가 많은 소켓을 처리하고, 여러 스레드가 io_context::run()을 호출해 핸들러를 분산하며, 연결 단위 Strand로 같은 세션의 핸들러를 직렬화한다고 답하고, 단일 노드의 한계를 넘으면 로드 밸런서와 수평 확장으로 간다고 이어 가면 됩니다.
“메모리 풀을 왜 쓰나요?”에는 짧은 수명의 객체가 대량으로 생기는 경로에서 할당 비용, 전역 힙 경합, 단편화를 줄이기 위해서라고 답하되, 최근 할당자도 스레드 캐시를 쓰므로 프로파일링으로 병목을 확인한 뒤 도입한다고 덧붙이면 트레이드오프를 이해하고 있다는 인상을 줍니다.
“세션과 패킷을 어떻게 관리하나요?”에는 연결당 세션 객체, 길이 헤더를 이용한 프레이밍, 길이 상한 검사, 한 번에 하나의 쓰기만 진행되는 송신 큐와 그 상한, 읽기 타임아웃을 차례로 설명하면 됩니다.
같이 보면 좋은 글
- C++ 메모리 풀 구현 비교
- C++ 개발자 기술 면접 | 자주 나오는 질문과 답변 스크립트(30초·90초)·꼬리 질문 대비
- C++ 오픈소스 기여: 유명 라이브러리 분석부터 첫 Pull Request까지 [#45-1]
- X-Macro로 중복 없애기
참고 자료
다음 글: C++ 개발자 기술 면접 | 자주 나오는 질문과 답변 스크립트(30초·90초)·꼬리 질문 대비 이전 글: 주니어에서 시니어까지: C++ 개발자가 단계별로 갖춰야 할 역량