C++로 로드 밸런서 구현하기: Round-Robin·Least Connections·Consistent Hashing, 헬스 체크
들어가며: 로드 밸런서가 하는 일
서버 한 대로 처리할 수 있는 요청에는 한계가 있고, 한 대가 죽으면 서비스 전체가 멈춥니다. 서버를 여러 대 두더라도 요청을 고르게 나누지 못하면 문제는 그대로입니다. DNS 라운드 로빈은 클라이언트와 리졸버가 응답을 캐시하므로 특정 IP로 트래픽이 쏠리기 쉽고, 죽은 서버를 목록에서 빼는 데도 TTL만큼 시간이 걸립니다. 로드 밸런서는 연결이나 요청마다 백엔드를 고르고, 죽은 백엔드를 감지해 후보에서 빼며, 필요하면 같은 클라이언트를 같은 서버로 보냅니다.
이 글은 Boost.Asio로 TCP 수준(L4) 로드 밸런서를 만들면서 분배 알고리즘(Round-Robin, 가중치, Least Connections, Consistent Hashing), 양방향 TCP 프록시, 비동기 헬스 체크, 서킷 브레이커를 구현하고, 각 단계에서 생기기 쉬운 버그를 짚습니다. 요구 환경은 C++17 이상(일부 C++20)과 Boost.Asio입니다.
flowchart TB
subgraph Client[클라이언트]
C1[TCP 연결]
end
subgraph LB["로드 밸런서 (C++)"]
L1[accept]
L2[백엔드 선택]
L3[헬스 체크]
L4[양방향 프록시]
L1 --> L2
L2 --> L4
L3 -.->|healthy 갱신| L2
end
subgraph Backend[백엔드 서버 풀]
B1[서버 1]
B2[서버 2]
B3[서버 3]
end
C1 --> L1
L4 --> B1
L4 --> B2
L4 --> B3
L4 로드 밸런서는 TCP 연결 단위로 백엔드를 고르고 바이트를 그대로 중계합니다. 구현이 단순하고 프로토콜을 가리지 않지만, URL이나 쿠키를 보고 라우팅할 수는 없습니다. L7(HTTP) 로드 밸런서는 요청을 파싱하므로 경로·헤더·쿠키 기반 라우팅과 요청 단위 분배가 가능하지만, HTTP 파서와 연결 재사용까지 구현해야 해서 훨씬 복잡합니다.
백엔드 표현
여러 스레드가 동시에 백엔드 상태를 읽고 쓰므로, 상태 필드는 원자 변수로 둡니다. std::atomic은 복사·이동이 안 되기 때문에 std::vector<Backend>에 값으로 담을 수 없고, 백엔드 목록을 바꿀 때 진행 중인 연결이 들고 있는 백엔드가 사라지는 문제도 있으므로 shared_ptr로 관리합니다.
// backend.hpp
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace lb {
struct Backend {
Backend(std::string h, uint16_t p, int w = 1) : host(std::move(h)), port(p), weight(w) {}
const std::string host;
const uint16_t port;
const int weight;
std::atomic<bool> healthy{true};
std::atomic<int> active{0}; // 진행 중인 연결 수 (Least Connections용)
std::atomic<int> failures{0}; // 헬스 체크 연속 실패 횟수
};
using BackendPtr = std::shared_ptr<Backend>;
using BackendList = std::vector<BackendPtr>;
} // namespace lb
분배 알고리즘
Round-Robin
순서대로 돌아가며 고르되, 건강하지 않은 백엔드는 건너뜁니다. 카운터 하나만 원자적으로 증가시키면 되므로 락이 필요 없습니다.
class RoundRobin {
std::atomic<std::size_t> next_{0};
public:
BackendPtr select(const BackendList& list) {
const std::size_t n = list.size();
if (n == 0) return nullptr; // 0으로 나누기 방지
const std::size_t start = next_.fetch_add(1, std::memory_order_relaxed);
for (std::size_t i = 0; i < n; ++i) {
const auto& b = list[(start + i) % n];
if (b->healthy.load(std::memory_order_relaxed)) return b;
}
return nullptr; // 전부 다운
}
};
size_t 카운터가 넘쳐 0으로 돌아가는 순간에는 순서가 한 번 어긋나지만, 부호 없는 정수의 오버플로는 정의된 동작이라 문제가 되지 않습니다.
가중치 라운드 로빈 (smooth weighted round-robin)
16코어 서버와 4코어 서버를 같은 비율로 쓰면 작은 서버만 과부하됩니다. 가중치를 주면 성능 비율대로 나눌 수 있는데, 가중치 5인 서버를 다섯 번 연속으로 고르면 짧은 순간에는 여전히 한 서버로 몰립니다. nginx가 쓰는 smooth weighted round-robin은 매번 모든 후보의 현재 점수에 가중치를 더하고, 점수가 가장 높은 서버를 고른 뒤 그 서버 점수에서 전체 가중치 합을 뺍니다. 가중치 5·1·1이면 a a b a c a a처럼 고르게 섞인 순서가 나옵니다.
#include <mutex>
class SmoothWeightedRoundRobin {
std::mutex mtx_;
std::vector<int> current_; // 백엔드별 현재 점수 (list와 같은 순서)
public:
BackendPtr select(const BackendList& list) {
std::lock_guard<std::mutex> lock(mtx_);
if (current_.size() != list.size()) current_.assign(list.size(), 0);
int total = 0;
std::ptrdiff_t best = -1;
for (std::size_t i = 0; i < list.size(); ++i) {
const auto& b = list[i];
if (!b->healthy.load(std::memory_order_relaxed) || b->weight <= 0) continue;
current_[i] += b->weight;
total += b->weight;
if (best < 0 || current_[i] > current_[best]) best = static_cast<std::ptrdiff_t>(i);
}
if (best < 0) return nullptr;
current_[best] -= total;
return list[best];
}
};
가중치가 0인 백엔드는 후보에서 빼야 합니다. 그렇지 않으면 점수 비교에서 엉뚱하게 뽑히거나, 모든 가중치 합이 0이 되는 경계 조건이 생깁니다.
Least Connections
요청마다 처리 시간이 크게 다르거나 연결이 오래 유지된다면, 지금 활성 연결이 가장 적은 서버를 고르는 편이 공정합니다.
#include <limits>
class LeastConnections {
public:
BackendPtr select(const BackendList& list) {
BackendPtr best;
int min_active = std::numeric_limits<int>::max();
for (const auto& b : list) {
if (!b->healthy.load(std::memory_order_relaxed)) continue;
const int a = b->active.load(std::memory_order_relaxed);
if (a < min_active) { min_active = a; best = b; }
}
return best;
}
};
// 활성 연결 수를 RAII로 관리: 어떤 경로로 연결이 끝나도 반드시 감소
class ActiveGuard {
BackendPtr b_;
public:
explicit ActiveGuard(BackendPtr b) : b_(std::move(b)) {
b_->active.fetch_add(1, std::memory_order_relaxed);
}
~ActiveGuard() { b_->active.fetch_sub(1, std::memory_order_relaxed); }
ActiveGuard(const ActiveGuard&) = delete;
ActiveGuard& operator=(const ActiveGuard&) = delete;
};
여러 스레드가 동시에 선택하면 같은 순간 같은 서버를 “가장 한가하다”고 보고 함께 고를 수 있습니다. 선택과 증가를 한 번에 원자적으로 하려면 락이 필요하지만, 연결 수는 어차피 계속 변하는 근사값이므로 대부분의 구현은 이 정도 오차를 받아들입니다. 활성 연결 감소를 에러 경로에서 빠뜨리면 카운트가 계속 쌓여 그 서버가 영영 선택되지 않으므로, 감소는 반드시 소멸자에 둡니다.
Consistent Hashing
같은 클라이언트를 같은 서버로 보내는 가장 단순한 방법은 hash(client_ip) % 서버 수지만, 서버가 하나 추가되거나 죽어서 서버 수가 바뀌면 거의 모든 키의 배정이 바뀝니다. Consistent Hashing은 서버들을 해시 공간의 원(ring) 위에 올려 두고, 키의 해시값에서 시계 방향으로 처음 만나는 서버를 고릅니다. 서버 하나가 빠지면 그 서버가 맡던 구간의 키만 다음 서버로 옮겨 가고 나머지는 그대로입니다. 서버마다 여러 개의 가상 노드를 올려야 원 위의 구간 크기가 고르게 나뉩니다.
#include <map>
#include <string_view>
class ConsistentHashRing {
std::map<std::uint64_t, BackendPtr> ring_;
static std::uint64_t hash(std::string_view s) { // FNV-1a 64비트 (실무에서는 xxHash·Murmur 등)
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : s) { h ^= c; h *= 1099511628211ULL; }
return h;
}
public:
explicit ConsistentHashRing(const BackendList& list, int virtual_nodes = 100) {
for (const auto& b : list)
for (int v = 0; v < virtual_nodes; ++v)
ring_[hash(b->host + ":" + std::to_string(b->port) + "#" + std::to_string(v))] = b;
}
BackendPtr select(std::string_view key) const {
if (ring_.empty()) return nullptr;
auto it = ring_.lower_bound(hash(key));
for (std::size_t n = 0; n < ring_.size(); ++n, ++it) { // 다운된 노드는 건너뜀
if (it == ring_.end()) it = ring_.begin();
if (it->second->healthy.load(std::memory_order_relaxed)) return it->second;
}
return nullptr;
}
};
std::hash는 구현마다(심지어 실행마다) 결과가 달라도 되므로, 여러 로드 밸런서 인스턴스가 같은 배정을 해야 한다면 위처럼 알고리즘이 고정된 해시를 써야 합니다. 클라이언트 IP를 키로 쓰면 같은 NAT 뒤의 많은 사용자가 한 서버로 몰릴 수 있다는 점도 고려해야 합니다. 세션을 서버 메모리에 두지 않고 Redis 같은 공유 저장소에 두면 어피니티 자체가 필요 없어집니다.
양방향 TCP 프록시
선택한 백엔드에 연결한 뒤에는 클라이언트→백엔드, 백엔드→클라이언트 두 방향의 바이트를 각각 중계합니다. 한쪽이 연결을 닫으면(EOF) 반대쪽에 쓰기 종료(shutdown(send))를 전달해, HTTP처럼 요청을 다 보낸 뒤 응답을 기다리는 프로토콜도 제대로 동작하게 합니다.
// proxy_session.hpp
#include <boost/asio.hpp>
#include <array>
namespace asio = boost::asio;
using asio::ip::tcp;
using boost::system::error_code;
class ProxySession : public std::enable_shared_from_this<ProxySession> {
tcp::socket client_;
tcp::socket server_;
lb::BackendPtr backend_;
lb::ActiveGuard guard_; // 세션이 사라질 때 active 감소
std::array<char, 8192> up_{}, down_{};
public:
ProxySession(tcp::socket client, lb::BackendPtr b)
: client_(std::move(client)),
server_(client_.get_executor()), // 클라이언트 소켓과 같은 strand 사용
backend_(b), guard_(std::move(b)) {}
void start() {
auto resolver = std::make_shared<tcp::resolver>(client_.get_executor());
resolver->async_resolve(backend_->host, std::to_string(backend_->port),
[self = shared_from_this(), resolver](error_code ec, tcp::resolver::results_type eps) {
if (ec) return; // self가 사라지며 클라이언트 연결도 닫힘
asio::async_connect(self->server_, eps,
[self](error_code ec, const tcp::endpoint&) {
if (ec) return;
self->relay(self->client_, self->server_, self->up_);
self->relay(self->server_, self->client_, self->down_);
});
});
}
private:
void relay(tcp::socket& from, tcp::socket& to, std::array<char, 8192>& buf) {
from.async_read_some(asio::buffer(buf),
[self = shared_from_this(), &from, &to, &buf](error_code ec, std::size_t n) {
if (ec) { // EOF 또는 오류: 반대 방향에 쓰기 종료 전달
error_code ignore;
to.shutdown(tcp::socket::shutdown_send, ignore);
return;
}
asio::async_write(to, asio::buffer(buf, n),
[self, &from, &to, &buf](error_code ec, std::size_t) {
if (ec) { // 쓰기 실패: 양쪽 모두 닫아 다른 방향도 끝냄
error_code ignore;
self->client_.close(ignore);
self->server_.close(ignore);
return;
}
self->relay(from, to, buf);
});
});
}
};
두 방향의 비동기 연산이 모두 끝나면 shared_ptr 참조가 사라져 세션이 소멸하고, 소켓이 닫히며 ActiveGuard가 활성 연결 수를 줄입니다. 핸들러에서 멤버 참조(from, to, buf)를 캡처해도 되는 것은 self가 세션을 살려 두기 때문입니다. 실제 서비스라면 백엔드 연결 타임아웃과 유휴 연결 타임아웃을 steady_timer로 추가해야 합니다.
accept와 선택
class LoadBalancer {
asio::io_context& io_;
tcp::acceptor acceptor_;
lb::BackendList backends_;
lb::RoundRobin selector_;
public:
LoadBalancer(asio::io_context& io, uint16_t port, lb::BackendList backends)
: io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)), backends_(std::move(backends)) {
do_accept();
}
private:
void do_accept() {
// 연결마다 새 strand: io_context를 여러 스레드가 돌려도 한 세션의 핸들러는 직렬화됨
acceptor_.async_accept(asio::make_strand(io_),
[this](error_code ec, tcp::socket socket) {
if (!ec) {
if (auto b = selector_.select(backends_)) {
std::make_shared<ProxySession>(std::move(socket), std::move(b))->start();
}
// 고를 백엔드가 없으면 socket이 여기서 소멸하며 연결이 닫힘
}
do_accept();
});
}
};
Consistent Hashing으로 클라이언트 IP를 키로 쓰려면 socket.remote_endpoint(ec).address().to_string()으로 주소를 얻습니다. 예외를 던지지 않는 error_code 버전을 쓰는 이유는, accept 직후 클라이언트가 이미 연결을 끊었으면 주소 조회가 실패하기 때문입니다.
헬스 체크와 서킷 브레이커
비동기 헬스 체크
헬스 체크를 asio::connect 같은 동기 호출로 하면, 응답하지 않는 백엔드 하나 때문에 연결 타임아웃(수십 초가 될 수도 있음) 동안 이벤트 루프 스레드 전체가 멈춥니다. 모든 백엔드를 동시에 비동기로 확인하고, 각 확인에 짧은 타임아웃을 거는 것이 맞습니다.
class HealthChecker : public std::enable_shared_from_this<HealthChecker> {
asio::io_context& io_;
lb::BackendList backends_;
asio::steady_timer timer_;
std::chrono::seconds interval_;
public:
HealthChecker(asio::io_context& io, lb::BackendList backends,
std::chrono::seconds interval = std::chrono::seconds(5))
: io_(io), backends_(std::move(backends)), timer_(io), interval_(interval) {}
void start() { schedule(); }
private:
struct Probe {
explicit Probe(const asio::any_io_executor& ex) : sock(ex), resolver(ex), deadline(ex) {}
tcp::socket sock;
tcp::resolver resolver;
asio::steady_timer deadline;
};
static void mark(lb::Backend& b, bool ok) {
if (ok) {
b.failures.store(0);
b.healthy.store(true);
} else if (b.failures.fetch_add(1) + 1 >= 3) { // 3회 연속 실패 시 제외
b.healthy.store(false);
}
}
void schedule() {
timer_.expires_after(interval_);
timer_.async_wait([self = shared_from_this()](error_code ec) {
if (ec) return;
for (auto& b : self->backends_) self->check(b); // 모든 백엔드를 동시에 확인
self->schedule();
});
}
void check(lb::BackendPtr b) {
auto p = std::make_shared<Probe>(asio::make_strand(io_));
p->deadline.expires_after(std::chrono::seconds(2));
p->deadline.async_wait([p](error_code ec) {
if (ec) return; // 취소됨 = 제때 끝남
error_code ignore;
p->resolver.cancel();
p->sock.close(ignore); // 진행 중인 connect를 operation_aborted로 끝냄
});
p->resolver.async_resolve(b->host, std::to_string(b->port),
[p, b](error_code ec, tcp::resolver::results_type eps) {
if (ec) { p->deadline.cancel(); mark(*b, false); return; }
asio::async_connect(p->sock, eps, [p, b](error_code ec, const tcp::endpoint&) {
p->deadline.cancel();
mark(*b, !ec);
});
});
}
};
// 사용: std::make_shared<HealthChecker>(io, backends)->start();
TCP 연결 성공만으로는 프로세스가 떠 있다는 것만 알 수 있습니다. 애플리케이션이 실제로 요청을 처리할 수 있는지 보려면 /health 같은 엔드포인트에 요청을 보내 상태 코드까지 확인하는 L7 헬스 체크가 더 정확합니다. 한 번 실패로 바로 빼지 않고 연속 실패 횟수를 세는 것은, 일시적인 네트워크 지연 때문에 멀쩡한 서버가 들락날락하는 것(flapping)을 막기 위해서입니다.
서킷 브레이커
헬스 체크는 몇 초 간격이라, 그 사이에 죽은 서버로 요청이 계속 갑니다. 서킷 브레이커는 실제 요청의 실패를 세다가 임계값을 넘으면 그 백엔드로의 호출을 일정 시간 즉시 거절(Open)하고, 시간이 지나면 시험 요청 하나만 통과시켜(Half-Open) 성공하면 다시 닫습니다(Closed).
#include <chrono>
#include <mutex>
class CircuitBreaker {
enum class State { Closed, Open, HalfOpen };
std::mutex mtx_;
State state_ = State::Closed;
int failures_ = 0;
bool trial_in_flight_ = false;
std::chrono::steady_clock::time_point opened_at_;
const int threshold_;
const std::chrono::seconds open_for_;
public:
explicit CircuitBreaker(int threshold = 5, std::chrono::seconds open_for = std::chrono::seconds(30))
: threshold_(threshold), open_for_(open_for) {}
bool allow() {
std::lock_guard<std::mutex> lock(mtx_);
if (state_ == State::Open) {
if (std::chrono::steady_clock::now() - opened_at_ < open_for_) return false;
state_ = State::HalfOpen;
}
if (state_ == State::HalfOpen) {
if (trial_in_flight_) return false; // 시험 요청은 하나만
trial_in_flight_ = true;
}
return true;
}
void on_success() {
std::lock_guard<std::mutex> lock(mtx_);
state_ = State::Closed;
failures_ = 0;
trial_in_flight_ = false;
}
void on_failure() {
std::lock_guard<std::mutex> lock(mtx_);
trial_in_flight_ = false;
if (state_ == State::HalfOpen || ++failures_ >= threshold_) {
state_ = State::Open;
opened_at_ = std::chrono::steady_clock::now();
failures_ = 0;
}
}
};
상태, 실패 횟수, 열린 시각이 함께 일관되어야 하므로 각각을 원자 변수로 두기보다 작은 뮤텍스 하나로 묶는 편이 정확합니다. Half-Open 상태에서 요청을 무제한으로 통과시키면, 아직 회복되지 않은 서버에 트래픽이 한꺼번에 몰려 다시 쓰러지게 됩니다.
자주 생기는 버그
빈 목록과 0으로 나누기
설정 실수로 백엔드가 하나도 없거나 모두 다운되면, index % list.size()나 hash % healthy.size()가 0으로 나누기(정수에서는 미정의 동작)가 됩니다. 모든 선택 함수는 후보가 없을 때 nullptr을 돌려주고, 호출자는 그 경우 연결을 닫거나 L7이라면 503을 응답해야 합니다.
헬스 상태 변경과 선택의 경쟁
healthy를 일반 bool로 두고 헬스 체커 스레드가 쓰는 동안 요청 스레드가 읽으면 데이터 레이스(미정의 동작)입니다. 위처럼 std::atomic<bool>로 두면 레이스는 사라지지만, “선택한 직후에 그 서버가 죽는” 상황 자체는 피할 수 없습니다. 그래서 백엔드 연결이 실패하면 다른 백엔드로 한 번 더 시도하는 재시도 로직과 서킷 브레이커가 함께 필요합니다. 단, 요청을 이미 일부 보낸 뒤라면 같은 요청을 다른 서버에 다시 보내도 안전한지(멱등성)를 따져야 합니다.
실행 중 백엔드 목록 변경
설정 API로 백엔드를 추가·제거할 때 vector::erase로 원소를 지우면, 진행 중인 세션이 들고 있던 포인터가 댕글링이 됩니다. 이 글처럼 백엔드를 shared_ptr로 두면 세션이 끝날 때까지 객체가 살아 있습니다. 목록 자체는 불변 스냅샷으로 만들고, 바꿀 때 새 목록을 만들어 통째로 교체하면 읽는 쪽에 락이 필요 없습니다.
// C++20: 목록 전체를 원자적으로 교체
std::atomic<std::shared_ptr<const lb::BackendList>> backends_;
void set_backends(lb::BackendList list) {
backends_.store(std::make_shared<const lb::BackendList>(std::move(list)));
}
// 선택 시: auto snapshot = backends_.load(); selector.select(*snapshot);
C++17에서는 std::atomic_load/std::atomic_store의 shared_ptr 오버로드로 같은 일을 할 수 있습니다(C++20에서 deprecated). 가중치 라운드 로빈처럼 선택기가 백엔드별 상태를 들고 있다면, 목록이 바뀔 때 그 상태도 새로 만들어야 합니다.
연결 재사용
L7 로드 밸런서가 요청마다 백엔드와 새 TCP(그리고 TLS) 연결을 맺으면 핸드셰이크 비용이 지연에 그대로 더해집니다. 그래서 백엔드별 keep-alive 연결 풀을 두는데, 풀에서 꺼낸 연결이 그사이 백엔드 쪽에서 닫혀 있을 수 있으므로 실패 시 새 연결로 재시도하는 처리와, 한 연결을 동시에 두 요청이 쓰지 않도록 하는 소유권 관리가 필요합니다. 이 글의 L4 프록시는 클라이언트 연결 하나를 백엔드 연결 하나에 그대로 대응시키므로 풀이 필요 없습니다.
배포
Docker Compose
# docker-compose.lb.yml
services:
load-balancer:
build: .
ports: ["8080:8080"]
environment:
- BACKENDS=backend1:8000,backend2:8000,backend3:8000
- ALGORITHM=least_connections
depends_on: [backend1, backend2, backend3]
backend1:
image: my-app:latest
expose: ["8000"]
backend2:
image: my-app:latest
expose: ["8000"]
backend3:
image: my-app:latest
expose: ["8000"]
포트 매핑은 따옴표로 감싸는 것이 안전합니다. YAML 1.1 파서는 22:22처럼 콜론으로 구분된 숫자를 60진수 정수로 해석할 수 있어, Compose 문서도 문자열로 쓰기를 권장합니다.
Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: cpp-load-balancer
spec:
replicas: 2
selector:
matchLabels:
app: cpp-lb
template:
metadata:
labels:
app: cpp-lb # selector와 반드시 일치해야 함
spec:
containers:
- name: lb
image: my-cpp-lb:latest
ports:
- containerPort: 8080
env:
- name: BACKENDS
valueFrom:
configMapKeyRef:
name: lb-config
key: backends
Kubernetes 안에서는 Service가 이미 L4 부하 분산을 해 주므로, 직접 만든 로드 밸런서는 Service가 못 하는 일(커스텀 라우팅 규칙, 특수 프로토콜)이 있을 때 의미가 있습니다. 백엔드 목록도 ConfigMap보다 Kubernetes API의 EndpointSlice를 감시해 자동으로 갱신하는 방식이 일반적입니다.
nginx·HAProxy와의 역할 분담
일반적인 HTTP 부하 분산이라면 nginx, HAProxy, Envoy나 클라우드 로드 밸런서를 쓰는 것이 맞습니다. 오랜 기간 운영 환경에서 검증된 타임아웃·재시도·TLS·관측 기능이 이미 들어 있기 때문입니다. 직접 구현이 의미 있는 경우는 자체 바이너리 프로토콜을 이해해야 하는 라우팅, 게임 서버처럼 특수한 세션 배정 규칙, 또는 이 글처럼 원리를 익히는 학습 목적입니다.
다음 글: [C++ 실전 가이드 #50-11] 파일 스토리지 시스템 이전 글: [C++ 실전 가이드 #50-9] 검색 엔진 구현