[2026] 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)
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오: 왜 로드 밸런서가 필요한가
- 시스템 아키텍처
- 로드 밸런싱 알고리즘
- 완전한 로드 밸런서 구현
- 헬스 체크 및 서킷 브레이커
- 자주 발생하는 에러와 해결법
- 성능 최적화 팁
- 프로덕션 패턴
- 구현 체크리스트
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. 시스템 아키텍처
전체 구조
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
요청 흐름 시퀀스
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 (라운드 로빈)
순서대로 백엔드를 선택합니다. 단순하고 공정합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 (가중치 라운드 로빈)
서버 성능에 비례해 요청을 분배합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 (최소 연결)
현재 활성 연결 수가 가장 적은 백엔드를 선택합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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의 해시로 같은 백엔드를 선택합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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. 완전한 로드 밸런서 구현
통합 로드 밸런서 클래스
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 프록시 핵심 로직
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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. 헬스 체크 및 서킷 브레이커
주기적 헬스 체크
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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_;
};
서킷 브레이커
연속 실패 시 일정 시간 백엔드 호출을 중단합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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
원인: 설정 오류 또는 모든 백엔드가 헬스 체크 실패로 제외됨
해결법:
다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 코드
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가 비어 있음
해결법:
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 코드
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() 호출을 빠뜨림
해결법:
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 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_를 수정/조회
해결법:
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 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 체크 누락 해결법: 다음은 간단한 cpp 코드 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 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 시 전체 순회 대신 캐시된 목록만 사용합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 헬스 체크 시 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)
매 요청마다 백엔드에 새 연결을 열지 않고, 연결 풀에서 재사용합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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로 병렬 체크합니다.
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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. 메트릭 수집
선택 횟수, 응답 시간, 에러 비율을 수집해 모니터링합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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. 그레이스풀 셧다운
로드 밸런서 종료 시 새 연결을 받지 않고, 기존 연결이 끝날 때까지 대기합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
void shutdown() {
acceptor_.close();
// 기존 프록시 세션들이 완료될 때까지 대기
// 예: shared_ptr 카운트가 0이 될 때까지
}
2. 동적 백엔드 등록
설정 파일/API로 런타임에 백엔드 추가·제거. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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 예시
다음은 yaml를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
# 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 컨트롤러로 동작시킬 수 있습니다. 다음은 yaml를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
# 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가 더 검증되어 있습니다.