본문으로 건너뛰기
Previous
Next
C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드

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)

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.

실무 적용 경험: 이 글은 대규모 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. 시스템 아키텍처

전체 구조

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. 세션 어피니티와 스테이트리스 중 뭘 선택하나요?

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, 로드밸런서, 부하분산, 헬스체크, 고가용성, Boost.Asio 등으로 검색하시면 이 글이 도움이 됩니다.