C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드

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. 문제 시나리오: 왜 로드 밸런서가 필요한가
  2. 시스템 아키텍처
  3. 로드 밸런싱 알고리즘
  4. 완전한 로드 밸런서 구현
  5. 헬스 체크 및 서킷 브레이커
  6. 자주 발생하는 에러와 해결법
  7. 성능 최적화 팁
  8. 프로덕션 패턴
  9. 구현 체크리스트

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/UDPHTTP, gRPC
세션 어피니티IP HashCookie, 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같은 클라이언트 → 같은 서버 (세션 어피니티)
헬스 체크다운 서버 자동 제외
서킷 브레이커연속 실패 시 호출 중단

핵심 원칙:

  1. 빈 백엔드 목록·0 나누기 방지
  2. Least Connections는 반드시 release
  3. 헬스 체크와 선택 간 동기화 (shared_mutex 등)
  4. 프로덕션에서는 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 |