C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드
이 글의 핵심
트래픽 분산과 고가용성: Round-Robin·가중치·Least Connections·IP Hash, 헬스 체크·서킷 브레이커, 프로덕션 패턴까지 C++로 구현합니다. 단일 서버로 수천 개의 동시 연결을 받으면 CPU·메모리·네트워크가 한계에 도달합니다.
들어가며: “한 서버에 트래픽이 몰려 다운돼요”
로드 밸런서가 필요한 이유
단일 서버로 수천 개의 동시 연결을 받으면 CPU·메모리·네트워크가 한계에 도달합니다. “트래픽이 몰리면 한 서버만 과부하되고, 나머지는 놀고 있어요”, “서버 한 대가 죽으면 전체 서비스가 중단돼요” 같은 문제는 로드 밸런서로 해결합니다. 여러 백엔드 서버에 요청을 분산하고, 장애 서버를 자동으로 제외하며, 세션 유지가 필요한 경우 같은 클라이언트를 같은 서버로 보내는 세션 어피니티까지 C++로 구현합니다.
이 글에서 다루는 것:
- 문제 시나리오: 단일 서버 과부하, 장애 전파, 세션 불일치 등 실제 겪는 상황
- 완전한 로드 밸런서 예제: Round-Robin·가중치·Least Connections·IP Hash
- 자주 발생하는 에러: 빈 서버 목록, 0으로 나누기, 헬스 체크 경쟁 조건
- 성능 최적화: 락 프리 선택, 연결 풀, 배치 업데이트
- 프로덕션 패턴: 헬스 체크, 서킷 브레이커, 그레이스풀 셧다운
요구 환경: C++17 이상, Boost.Asio (또는 standalone Asio)
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오: 왜 로드 밸런서가 필요한가
- 시스템 아키텍처
- 로드 밸런싱 알고리즘
- 완전한 로드 밸런서 구현
- 헬스 체크 및 서킷 브레이커
- 자주 발생하는 에러와 해결법
- 성능 최적화 팁
- 프로덕션 패턴
- 구현 체크리스트
1. 문제 시나리오: 왜 로드 밸런서가 필요한가
시나리오 1: “한 서버에만 트래픽이 몰려요”
"서버 4대를 두었는데, DNS가 첫 번째 IP만 반환해서 한 대만 과부하예요."
"나머지 3대는 CPU 5%인데, 한 대만 100%로 터져요."
원인: DNS 라운드 로빈은 클라이언트/ISP 캐시 때문에 한 IP에 편중되기 쉽습니다. 클라이언트가 여러 번 연결해도 같은 IP를 재사용합니다.
해결 포인트: 애플리케이션 레벨 로드 밸런서를 두고, 매 요청/연결마다 백엔드를 선택해 균등 분산합니다.
시나리오 2: “서버 한 대가 죽으면 502 에러가 나요”
"한 백엔드가 OOM으로 죽었는데, 로드 밸런서가 계속 그쪽으로 요청을 보내요."
"사용자는 502 Bad Gateway만 보고, 원인을 찾기 어려워요."
원인: 로드 밸런서가 백엔드 상태를 모르고, 죽은 서버에도 요청을 전달합니다.
해결 포인트: 헬스 체크로 주기적으로 백엔드 상태를 확인하고, 다운된 서버를 풀에서 제외합니다.
시나리오 3: “세션이 서버마다 달라요”
"로그인했는데 새로고침하면 로그아웃돼요."
"쇼핑 카트에 담았는데 다른 서버로 가면 비어 있어요."
원인: 로드 밸런서가 요청마다 다른 백엔드로 보내면, 세션 상태가 서버별로 분리되어 있습니다.
해결 포인트: 세션 어피니티(IP Hash, Cookie 기반)로 같은 클라이언트를 항상 같은 백엔드로 보냅니다. 또는 세션 저장소(Redis)를 공유해 스테이트리스하게 만듭니다.
시나리오 4: “서버 성능이 제각각인데 동일하게 분배돼요”
"새 서버 2대는 16코어, 구 서버 2대는 4코어인데 요청을 똑같이 나눠요."
"구 서버만 과부하되고 새 서버는 여유 있어요."
원인: Round-Robin은 서버 처리 능력을 고려하지 않습니다.
해결 포인트: 가중치 기반 분배(Weighted Round-Robin)로 성능 비율에 맞게 요청을 분배합니다.
시나리오 5: “연결 수는 비슷한데 응답 시간이 천차만별이에요”
"서버 A는 연결 100개, B는 100개인데 A는 각 연결이 무거운 작업을 해서 응답이 2초예요."
"B는 가벼운 요청만 있어서 10ms인데, 둘 다 같은 비율로 새 요청을 받아요."
원인: Round-Robin은 연결 수가 아니라 요청 수로만 분배합니다. 한 서버에 무거운 요청이 몰리면 지연이 발생합니다.
해결 포인트: Least Connections로 현재 연결 수가 가장 적은 서버를 선택합니다.
시나리오별 해결 방향 요약
| 시나리오 | 특징 | 권장 알고리즘 |
|---|---|---|
| 트래픽 편중 | DNS 한계 | 앱 레벨 LB, Round-Robin |
| 장애 서버 | 다운 감지 필요 | 헬스 체크, 서킷 브레이커 |
| 세션 불일치 | 같은 클라이언트 → 같은 서버 | IP Hash, Cookie 기반 |
| 성능 차이 | 서버별 처리 능력 상이 | Weighted Round-Robin |
| 응답 시간 편차 | 연결당 부하 상이 | Least Connections |
2. 시스템 아키텍처
전체 구조
flowchart TB
subgraph Client["클라이언트"]
C1[HTTP/WebSocket 요청]
end
subgraph LB["로드 밸런서 (C++)"]
L1[요청 수신]
L2[백엔드 선택]
L3[헬스 체크]
L4[프록시/포워드]
L1 --> L2
L2 --> L4
L3 -.->|상태 갱신| L2
end
subgraph Backend["백엔드 서버 풀"]
B1[서버 1]
B2[서버 2]
B3[서버 3]
end
C1 --> L1
L4 --> B1
L4 --> B2
L4 --> B3
요청 흐름 시퀀스
sequenceDiagram
participant C as 클라이언트
participant LB as 로드 밸런서
participant H as 헬스 체커
participant B1 as 백엔드 1
participant B2 as 백엔드 2
H->>B1: GET /health
B1-->>H: 200 OK
H->>B2: GET /health
B2-->>H: 503 (다운)
C->>LB: 요청
LB->>LB: 백엔드 선택 (B1만 유효)
LB->>B1: 프록시
B1-->>LB: 응답
LB-->>C: 응답
L4 vs L7 로드 밸런싱
| 구분 | L4 (Transport) | L7 (Application) |
|---|---|---|
| 기준 | IP, 포트 | URL, 헤더, Cookie |
| 프로토콜 | TCP/UDP | HTTP, gRPC |
| 세션 어피니티 | IP Hash | Cookie, Custom Header |
| 구현 복잡도 | 낮음 | 높음 |
| C++ 구현 | 소켓 포워딩 | HTTP 파싱 필요 |
이 글에서는 L7 HTTP 로드 밸런서를 중심으로 다룹니다. L4는 소켓 단위 포워딩으로 더 단순합니다.
3. 로드 밸런싱 알고리즘
Round-Robin (라운드 로빈)
순서대로 백엔드를 선택합니다. 단순하고 공정합니다.
// round_robin.hpp
#pragma once
#include <atomic>
#include <vector>
#include <string>
namespace lb {
struct Backend {
std::string host;
uint16_t port;
bool healthy = true;
};
class RoundRobinSelector {
public:
explicit RoundRobinSelector(std::vector<Backend> backends)
: backends_(std::move(backends)), next_(0) {}
// 다음 백엔드 선택 (헬스 체크 통과한 것만)
std::optional<Backend*> select() {
if (backends_.empty()) return std::nullopt;
size_t start = next_.fetch_add(1, std::memory_order_relaxed);
for (size_t i = 0; i < backends_.size(); ++i) {
size_t idx = (start + i) % backends_.size();
if (backends_[idx].healthy) {
return &backends_[idx];
}
}
return std::nullopt; // 모든 백엔드 다운
}
std::vector<Backend>& backends() { return backends_; }
private:
std::vector<Backend> backends_;
std::atomic<size_t> next_;
};
} // namespace lb
Weighted Round-Robin (가중치 라운드 로빈)
서버 성능에 비례해 요청을 분배합니다.
// weighted_round_robin.hpp
#pragma once
#include <atomic>
#include <vector>
#include <string>
namespace lb {
struct WeightedBackend {
std::string host;
uint16_t port;
int weight; // 1, 2, 3 등 — 비율에 맞게 요청 분배
bool healthy = true;
};
class WeightedRoundRobinSelector {
public:
explicit WeightedRoundRobinSelector(std::vector<WeightedBackend> backends)
: backends_(std::move(backends)), current_idx_(0) {}
std::optional<WeightedBackend*> select() {
if (backends_.empty()) return std::nullopt;
for (size_t round = 0; round < backends_.size(); ++round) {
size_t idx = current_idx_.fetch_add(1, std::memory_order_relaxed)
% backends_.size();
auto& b = backends_[idx];
if (b.healthy && b.weight > 0) return &b;
}
return std::nullopt;
}
private:
std::vector<WeightedBackend> backends_;
std::atomic<size_t> current_idx_;
};
} // namespace lb
참고: 프로덕션에서는 각 백엔드를 weight번씩 순환하는 스케줄을 미리 구축하는 방식을 사용합니다.
Least Connections (최소 연결)
현재 활성 연결 수가 가장 적은 백엔드를 선택합니다.
// least_connections.hpp
#pragma once
#include <atomic>
#include <vector>
#include <string>
#include <shared_mutex>
namespace lb {
struct LCBackend {
std::string host;
uint16_t port;
std::atomic<int> active_connections{0};
bool healthy = true;
};
class LeastConnectionsSelector {
public:
explicit LeastConnectionsSelector(std::vector<LCBackend> backends)
: backends_(std::move(backends)) {}
std::optional<LCBackend*> select() {
LCBackend* best = nullptr;
int min_conn = std::numeric_limits<int>::max();
for (auto& b : backends_) {
if (!b.healthy) continue;
int conn = b.active_connections.load(std::memory_order_relaxed);
if (conn < min_conn) {
min_conn = conn;
best = &b;
}
}
if (best) {
best->active_connections.fetch_add(1, std::memory_order_relaxed);
return best;
}
return std::nullopt;
}
void release(LCBackend* backend) {
backend->active_connections.fetch_sub(1, std::memory_order_relaxed);
}
private:
std::vector<LCBackend> backends_;
};
} // namespace lb
주의: select() 후 요청 완료/실패 시 반드시 release()를 호출해야 합니다. RAII로 감싸는 것이 안전합니다.
IP Hash (세션 어피니티)
클라이언트 IP의 해시로 같은 백엔드를 선택합니다.
// ip_hash.hpp
#pragma once
#include <functional>
#include <string>
#include <vector>
namespace lb {
struct Backend {
std::string host;
uint16_t port;
bool healthy = true;
};
class IPHashSelector {
public:
explicit IPHashSelector(std::vector<Backend> backends)
: backends_(std::move(backends)) {}
std::optional<Backend*> select(const std::string& client_ip) {
if (backends_.empty()) return std::nullopt;
size_t hash = std::hash<std::string>{}(client_ip);
std::vector<Backend*> healthy;
for (auto& b : backends_) {
if (b.healthy) healthy.push_back(&b);
}
if (healthy.empty()) return std::nullopt;
size_t idx = hash % healthy.size();
return healthy[idx];
}
private:
std::vector<Backend> backends_;
};
} // namespace lb
4. 완전한 로드 밸런서 구현
통합 로드 밸런서 클래스
// load_balancer.hpp
#pragma once
#include <boost/asio.hpp>
#include <memory>
#include <string>
#include <vector>
#include <functional>
namespace asio = boost::asio;
namespace lb {
enum class Algorithm { RoundRobin, WeightedRoundRobin, LeastConnections, IPHash };
struct BackendConfig {
std::string host;
uint16_t port;
int weight = 1;
bool healthy = true;
std::atomic<int> active_connections{0};
};
class LoadBalancer {
public:
LoadBalancer(asio::io_context& io, uint16_t listen_port,
std::vector<BackendConfig> backends,
Algorithm algo = Algorithm::RoundRobin)
: io_(io), acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), listen_port)),
backends_(std::move(backends)), algo_(algo), rr_index_(0) {
do_accept();
}
void do_accept() {
acceptor_.async_accept([this](boost::system::error_code ec,
asio::ip::tcp::socket socket) {
if (!ec) {
// 클라이언트 소켓 수신 후 백엔드 선택 및 프록시
auto* backend = select_backend("");
if (backend) {
start_proxy(std::move(socket), backend);
}
// backend == nullptr 이면 503 반환 등 처리
}
do_accept();
});
}
private:
BackendConfig* select_backend(const std::string& client_ip) {
if (backends_.empty()) return nullptr;
switch (algo_) {
case Algorithm::RoundRobin: {
size_t start = rr_index_.fetch_add(1);
for (size_t i = 0; i < backends_.size(); ++i) {
size_t idx = (start + i) % backends_.size();
if (backends_[idx].healthy) return &backends_[idx];
}
break;
}
case Algorithm::LeastConnections: {
BackendConfig* best = nullptr;
int min_conn = INT_MAX;
for (auto& b : backends_) {
if (!b.healthy) continue;
int c = b.active_connections.load();
if (c < min_conn) { min_conn = c; best = &b; }
}
if (best) best->active_connections.fetch_add(1);
return best;
}
case Algorithm::IPHash: {
size_t hash = std::hash<std::string>{}(client_ip);
std::vector<BackendConfig*> healthy;
for (auto& b : backends_) {
if (b.healthy) healthy.push_back(&b);
}
if (healthy.empty()) return nullptr;
return healthy[hash % healthy.size()];
}
default:
return backends_.empty() ? nullptr : &backends_[0];
}
return nullptr;
}
void start_proxy(asio::ip::tcp::socket client_socket, BackendConfig* backend) {
// TCP 터널: 클라이언트 ↔ 백엔드 양방향 전달
// 실제 구현은 async_connect + async_read/async_write 조합
(void)client_socket;
(void)backend;
}
asio::io_context& io_;
asio::ip::tcp::acceptor acceptor_;
std::vector<BackendConfig> backends_;
Algorithm algo_;
std::atomic<size_t> rr_index_;
};
} // namespace lb
TCP 프록시 핵심 로직
// tcp_proxy.cpp - 클라이언트와 백엔드 간 양방향 데이터 전달
#include <boost/asio.hpp>
#include <array>
#include <memory>
namespace asio = boost::asio;
class TcpProxy : public std::enable_shared_from_this<TcpProxy> {
public:
TcpProxy(asio::io_context& io,
asio::ip::tcp::socket client_socket,
const std::string& backend_host, uint16_t backend_port)
: io_(io), client_(std::move(client_socket)),
backend_(io), backend_host_(backend_host), backend_port_(backend_port) {}
void start() {
asio::ip::tcp::resolver resolver(io_);
auto endpoints = resolver.resolve(backend_host_, std::to_string(backend_port_));
asio::async_connect(backend_, endpoints,
[self = shared_from_this()](boost::system::error_code ec, const auto&) {
if (!ec) self->do_relay_client_to_backend();
});
}
private:
void do_relay_client_to_backend() {
client_.async_read_some(asio::buffer(client_buf_),
[self = shared_from_this()](boost::system::error_code ec, size_t n) {
if (ec || n == 0) return;
asio::async_write(self->backend_, asio::buffer(self->client_buf_, n),
[self](boost::system::error_code e, size_t) {
if (!e) self->do_relay_client_to_backend();
});
});
}
asio::io_context& io_;
asio::ip::tcp::socket client_;
asio::ip::tcp::socket backend_;
std::string backend_host_;
uint16_t backend_port_;
std::array<char, 8192> client_buf_;
};
참고: 위는 클라이언트 → 백엔드 방향만 보여줍니다. 백엔드 → 클라이언트 방향도 do_relay_backend_to_client()를 추가해 양방향 릴레이를 완성합니다.
5. 헬스 체크 및 서킷 브레이커
주기적 헬스 체크
// health_checker.hpp
#pragma once
#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <vector>
namespace asio = boost::asio;
struct BackendHealth {
std::string host;
uint16_t port;
bool healthy = true;
int consecutive_failures = 0;
std::chrono::steady_clock::time_point last_check;
};
class HealthChecker {
public:
HealthChecker(asio::io_context& io,
std::vector<BackendHealth>& backends,
std::chrono::seconds interval = std::chrono::seconds(5))
: io_(io), backends_(backends), interval_(interval), timer_(io) {
schedule_check();
}
void schedule_check() {
timer_.expires_after(interval_);
timer_.async_wait([this](boost::system::error_code ec) {
if (ec) return;
run_checks();
schedule_check();
});
}
void run_checks() {
for (auto& b : backends_) {
asio::ip::tcp::socket socket(io_);
asio::ip::tcp::resolver resolver(io_);
boost::system::error_code ec;
auto endpoints = resolver.resolve(b.host, std::to_string(b.port));
asio::connect(socket, endpoints, ec);
if (ec) {
b.consecutive_failures++;
b.healthy = (b.consecutive_failures < 3); // 3회 연속 실패 시 다운
} else {
b.consecutive_failures = 0;
b.healthy = true;
}
b.last_check = std::chrono::steady_clock::now();
}
}
private:
asio::io_context& io_;
std::vector<BackendHealth>& backends_;
std::chrono::seconds interval_;
asio::steady_timer timer_;
};
서킷 브레이커
연속 실패 시 일정 시간 백엔드 호출을 중단합니다.
// circuit_breaker.hpp
#pragma once
#include <chrono>
#include <atomic>
namespace lb {
enum class CircuitState { Closed, Open, HalfOpen };
class CircuitBreaker {
public:
CircuitBreaker(int failure_threshold = 5,
std::chrono::seconds open_duration = std::chrono::seconds(30))
: failure_threshold_(failure_threshold),
open_duration_(open_duration),
failures_(0),
state_(CircuitState::Closed),
last_failure_time_(std::chrono::steady_clock::now()) {}
bool allow_request() {
switch (state_.load(std::memory_order_relaxed)) {
case CircuitState::Closed:
return true;
case CircuitState::Open: {
auto now = std::chrono::steady_clock::now();
if (now - last_failure_time_ >= open_duration_) {
state_.store(CircuitState::HalfOpen, std::memory_order_relaxed);
return true;
}
return false;
}
case CircuitState::HalfOpen:
return true;
}
return false;
}
void record_success() {
state_.store(CircuitState::Closed, std::memory_order_relaxed);
failures_.store(0, std::memory_order_relaxed);
}
void record_failure() {
last_failure_time_ = std::chrono::steady_clock::now();
int f = failures_.fetch_add(1, std::memory_order_relaxed) + 1;
if (f >= failure_threshold_) {
state_.store(CircuitState::Open, std::memory_order_relaxed);
}
}
private:
int failure_threshold_;
std::chrono::seconds open_duration_;
std::atomic<int> failures_;
std::atomic<CircuitState> state_;
std::chrono::steady_clock::time_point last_failure_time_;
};
} // namespace lb
6. 자주 발생하는 에러와 해결법
문제 1: 빈 백엔드 목록으로 select 시 크래시
증상: backends_.empty() 체크 없이 backends_[0] 접근 시 segmentation fault
원인: 설정 오류 또는 모든 백엔드가 헬스 체크 실패로 제외됨
해결법:
// ❌ 잘못된 코드
Backend* select() {
size_t idx = next_++ % backends_.size(); // size()가 0이면 0 % 0 → UB
return &backends_[idx];
}
// ✅ 올바른 코드
std::optional<Backend*> select() {
if (backends_.empty()) return std::nullopt;
// 헬스 체크 통과한 백엔드만 필터
std::vector<Backend*> healthy;
for (auto& b : backends_) {
if (b.healthy) healthy.push_back(&b);
}
if (healthy.empty()) return std::nullopt;
size_t idx = next_++ % healthy.size();
return healthy[idx];
}
문제 2: 0으로 나누기 (Division by Zero)
증상: hash % healthy.size()에서 healthy.size() == 0일 때 UB
원인: 모든 백엔드 다운 시 healthy가 비어 있음
해결법:
// ❌ 잘못된 코드
size_t idx = hash % healthy.size(); // size() == 0 이면 UB
// ✅ 올바른 코드
if (healthy.empty()) return std::nullopt;
size_t idx = hash % healthy.size();
문제 3: Least Connections에서 release 누락
증상: active_connections가 계속 증가해 음수 되거나, 새 요청이 잘못된 서버로 감
원인: select() 후 요청 완료/예외 시 release() 호출을 빠뜨림
해결법:
// ✅ RAII로 자동 release
class ConnectionGuard {
public:
ConnectionGuard(LeastConnectionsSelector& sel, LCBackend* backend)
: selector_(sel), backend_(backend) {}
~ConnectionGuard() {
if (backend_) selector_.release(backend_);
}
ConnectionGuard(const ConnectionGuard&) = delete;
ConnectionGuard& operator=(const ConnectionGuard&) = delete;
private:
LeastConnectionsSelector& selector_;
LCBackend* backend_;
};
// 사용
auto* backend = selector.select();
if (!backend) return 503;
ConnectionGuard guard(selector, backend);
// ... 요청 처리 ...
문제 4: 헬스 체크와 선택 간 경쟁 조건
증상: 헬스 체크가 healthy = false로 갱신하는 순간 select가 해당 백엔드를 선택해 502 발생
원인: 헬스 체크 스레드와 요청 처리 스레드가 동시에 backends_를 수정/조회
해결법:
// ✅ shared_mutex로 읽기/쓰기 분리
#include <shared_mutex>
class LoadBalancer {
mutable std::shared_mutex mtx_;
public:
void update_health(size_t idx, bool healthy) {
std::unique_lock lock(mtx_);
backends_[idx].healthy = healthy;
}
Backend* select() {
std::shared_lock lock(mtx_);
// ... 선택 로직 ...
}
};
문제 5: Weighted Round-Robin에서 weight가 0인 백엔드
증상: weight=0인 백엔드가 선택되어 요청이 한 서버로만 몰림
원인: weight 0 체크 누락
해결법:
// ✅ weight > 0 && healthy 인 백엔드만 선택
if (b.healthy && b.weight > 0) {
candidates.push_back(&b);
}
문제 6: IP Hash에서 백엔드 수 변경 시 세션 깨짐
증상: 서버 추가/제거 시 기존 클라이언트가 다른 백엔드로 라우팅됨
원인: hash % healthy.size()에서 healthy.size()가 바뀌면 결과가 바뀜
해결법: 일관된 해싱(Consistent Hashing) 사용. 서버 수가 바뀌어도 대부분의 키가 같은 서버로 매핑됩니다. 또는 세션 데이터를 Redis 등 공유 저장소에 두어 서버 변경에 무관하게 합니다.
7. 성능 최적화 팁
1. 락 프리 Round-Robin
atomic만 사용해 락 없이 선택합니다.
// ✅ 이미 RoundRobinSelector에서 atomic 사용
size_t idx = next_.fetch_add(1, std::memory_order_relaxed) % backends_.size();
memory_order_relaxed로 충분합니다. 단, 헬스 체크 갱신과의 동기화가 필요하면 memory_order_acquire/release를 고려합니다.
2. 백엔드 목록 로컬 캐시
헬스 체크 결과로 healthy 백엔드 목록을 주기적으로 갱신하고, select 시 전체 순회 대신 캐시된 목록만 사용합니다.
// 헬스 체크 시 healthy_backends_ 갱신
void update_healthy_list() {
std::vector<Backend*> list;
for (auto& b : backends_) {
if (b.healthy) list.push_back(&b);
}
healthy_backends_.store(list); // 또는 shared_ptr로 스왑
}
3. 연결 풀 (Connection Pool)
매 요청마다 백엔드에 새 연결을 열지 않고, 연결 풀에서 재사용합니다.
class BackendConnectionPool {
std::vector<asio::ip::tcp::socket> pool_;
std::mutex mtx_;
public:
asio::ip::tcp::socket& acquire(const std::string& host, uint16_t port) {
std::lock_guard lock(mtx_);
for (auto& s : pool_) {
if (s.is_open()) return s;
}
// 풀에 없으면 새로 연결
asio::ip::tcp::socket socket(io_);
// connect...
pool_.push_back(std::move(socket));
return pool_.back();
}
};
4. 배치 헬스 체크
백엔드가 많을 때 한 번에 하나씩 체크하면 오래 걸립니다. async_connect로 병렬 체크합니다.
void run_checks_parallel() {
std::vector<std::future<bool>> results;
for (auto& b : backends_) {
results.push_back(std::async(std::launch::async, [&b]() {
asio::io_context io;
asio::ip::tcp::socket s(io);
asio::ip::tcp::resolver r(io);
boost::system::error_code ec;
auto ep = r.resolve(b.host, std::to_string(b.port));
asio::connect(s, ep, ec);
return !ec;
}));
}
for (size_t i = 0; i < results.size(); ++i) {
backends_[i].healthy = results[i].get();
}
}
5. 메트릭 수집
선택 횟수, 응답 시간, 에러 비율을 수집해 모니터링합니다.
struct BackendMetrics {
std::atomic<uint64_t> requests_total{0};
std::atomic<uint64_t> errors_total{0};
std::atomic<uint64_t> total_latency_us{0};
};
8. 프로덕션 패턴
1. 그레이스풀 셧다운
로드 밸런서 종료 시 새 연결을 받지 않고, 기존 연결이 끝날 때까지 대기합니다.
void shutdown() {
acceptor_.close();
// 기존 프록시 세션들이 완료될 때까지 대기
// 예: shared_ptr 카운트가 0이 될 때까지
}
2. 동적 백엔드 등록
설정 파일/API로 런타임에 백엔드 추가·제거.
void add_backend(const std::string& host, uint16_t port) {
std::lock_guard lock(mtx_);
backends_.push_back({host, port, true});
}
void remove_backend(size_t idx) {
std::lock_guard lock(mtx_);
if (idx < backends_.size()) backends_.erase(backends_.begin() + idx);
}
3. Docker Compose 예시
# docker-compose.lb.yml
version: '3.8'
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"]
4. nginx / HAProxy와의 역할 분담
| 용도 | C++ 로드 밸런서 | nginx / HAProxy |
|---|---|---|
| 프로토타입·학습 | ✅ | - |
| 커스텀 라우팅 | ✅ | 설정 제한 |
| 프로덕션 L7 | 가능하나 검증 필요 | ✅ 검증됨 |
프로덕션에서는 nginx, HAProxy, AWS ALB를 먼저 고려하고, 커스텀 로직이 필요할 때 C++ 로드 밸런서를 선택합니다.
5. Kubernetes 연동
C++ 로드 밸런서를 Kubernetes 서비스로 배포하고, Ingress 뒤에 두거나, 자체 Ingress 컨트롤러로 동작시킬 수 있습니다.
# k8s load balancer deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: cpp-load-balancer
spec:
replicas: 1
selector:
matchLabels:
app: cpp-lb
template:
spec:
containers:
- name: lb
image: my-cpp-lb:latest
ports:
- containerPort: 8080
env:
- name: BACKENDS
valueFrom:
configMapKeyRef:
name: lb-config
key: backends
9. 구현 체크리스트
알고리즘
- Round-Robin: 빈 목록·전부 다운 시
nullopt반환 - Weighted: weight 0 제외
- Least Connections: select 후 반드시 release (RAII 권장)
- IP Hash: 빈 목록 체크, Consistent Hashing 고려
헬스 체크
- 주기적 체크 (5–10초)
- 연속 N회 실패 시 다운 처리
- 복구 시 재등록
- 병렬 체크로 지연 최소화
에러 처리
- 모든 백엔드 다운 시 503 반환
- 백엔드 타임아웃 시 재시도 또는 다음 백엔드
- 서킷 브레이커로 연쇄 장애 방지
성능
- 락 프리 또는 최소 락 사용
- 연결 풀 (선택)
- 메트릭 수집
운영
- 그레이스풀 셧다운
- 동적 백엔드 등록/제거
- 로깅·모니터링
정리
| 항목 | 설명 |
|---|---|
| Round-Robin | 순서대로 분배. 단순·공정 |
| Weighted RR | 성능 비율에 맞게 분배 |
| Least Connections | 연결 수 최소인 서버 선택 |
| IP Hash | 같은 클라이언트 → 같은 서버 (세션 어피니티) |
| 헬스 체크 | 다운 서버 자동 제외 |
| 서킷 브레이커 | 연속 실패 시 호출 중단 |
핵심 원칙:
- 빈 백엔드 목록·0 나누기 방지
- Least Connections는 반드시 release
- 헬스 체크와 선택 간 동기화 (shared_mutex 등)
- 프로덕션에서는 nginx/HAProxy도 함께 고려
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. API 게이트웨이, 마이크로서비스 프록시, 채팅 서버 확장, 게임 서버 풀 등 트래픽 분산이 필요한 모든 서비스에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. nginx 대신 C++ 로드 밸런서를 쓰는 이유는?
A. 커스텀 라우팅(URL·헤더 기반), 프로토콜 확장, 임베디드/엣지 환경, 학습 목적에 적합합니다. 일반적인 HTTP 부하 분산은 nginx/HAProxy가 더 검증되어 있습니다.
Q. 세션 어피니티와 스테이트리스 중 뭘 선택하나요?
A. 세션을 서버 메모리에 두면 IP Hash나 Cookie 기반 어피니티가 필요합니다. Redis 등 공유 저장소에 세션을 두면 스테이트리스하게 설계할 수 있어 확장이 쉽습니다.
한 줄 요약: Round-Robin·Least Connections·IP Hash·헬스 체크·서킷 브레이커를 구현해 실전 로드 밸런서 개발 경험을 쌓을 수 있습니다.
다음 글: [C++ 실전 가이드 #50-11] 파일 스토리지 시스템
이전 글: [C++ 실전 가이드 #50-9] 검색 엔진 구현
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |