로드밸런싱 알고리즘 성능 비교 연구[5]

Chu Sang Yoon·2026년 3월 18일

lab

목록 보기
5/11

5편: Spring Boot로 로드밸런서 직접 구현하기 — 6가지 알고리즘 실전 코딩

이론은 충분히 했으니, 이제 진짜로 만들어보자.

4편에서 Round Robin부터 Least Response Time까지 6가지 알고리즘의 원리를 공부했다.
이번 편에서는 Spring Boot로 실제 동작하는 로드밸런서를 구현한다.
3편에서 만들어둔 4개의 Docker 서버와 연동해서, 각 알고리즘의 성능을 직접 측정할 수 있는 시스템이 목표다.


전체 아키텍처 한눈에 보기

[클라이언트 요청]
       ↓ http://localhost:8080
[Spring Boot 로드밸런서] ← 이번 편에서 구현!
       ↓ 선택된 알고리즘으로 분산
┌──────────────────────────────────────────────────────────┐
│ [Docker 서버1] [Docker 서버2] [Docker 서버3] [Docker 서버4] │
│   :5001         :5002         :5003         :5004        │
│   50ms 응답     150ms 응답    300ms 응답    500ms 응답    │
└──────────────────────────────────────────────────────────┘

각 서버는 응답 속도가 다르다. 이 차이 덕분에 알고리즘마다 어떤 서버를 얼마나 많이 선택하는지 눈으로 확인할 수 있다.

패키지 구조

loadBalancer/
├── config/
│   ├── ServerConfig.java          # 서버 설정
│   └── WebClientConfig.java       # WebClient 설정
├── controller/
│   ├── DashboardController.java   # 대시보드 UI
│   └── LoadBalancerController.java
├── model/
│   └── Server.java                # 서버 엔티티
├── service/
│   └── HealthCheckService.java    # 헬스체크
└── strategy/                      # 로드밸런싱 전략들
    ├── LoadBalancingStrategy.java
    ├── RoundRobinStrategy.java
    ├── WeightedRoundRobinStrategy.java
    ├── LeastConnectionsStrategy.java
    ├── ConsistentHashingStrategy.java
    ├── IpHashStrategy.java
    └── LeastResponseTimeStrategy.java

1. 핵심 모델: Server 클래스

서버 하나를 나타내는 클래스다. 단순한 데이터 홀더처럼 보이지만, 실시간 메트릭 수집Thread-Safety까지 고려해야 한다.

public class Server {
    private final String id;
    private final String host;
    private final int port;
    private final String url;
    private boolean healthy;
    private int weight;

    // 실시간 메트릭
    private final AtomicInteger currentConnections;
    private final AtomicLong totalRequests;
    private final Queue<Long> recentResponseTimes; // 최근 10개 응답시간
    private final Object responseTimeLock = new Object();

    public Server(String id, String host, int port) {
        this.id = id;
        this.host = host;
        this.port = port;
        this.url = "http://" + host + ":" + port;
        this.healthy = true;
        this.weight = 1;
        this.currentConnections = new AtomicInteger(0);
        this.totalRequests = new AtomicLong(0);
        this.recentResponseTimes = new LinkedList<>();
    }
    ...
}

생성자에서 몇 가지를 주목하자.

  • url은 생성 시점에 자동으로 만들어진다. http://localhost:5001 형태로.
  • 연결 수와 총 요청 수는 AtomicInteger, AtomicLong을 쓴다. 여러 스레드가 동시에 값을 바꿔도 안전하게.
  • 기본 가중치는 1. ServerConfig에서 서버마다 다르게 설정한다.

연결 수 관리

public void incrementConnections() {
    currentConnections.incrementAndGet();
    totalRequests.incrementAndGet();
}

public void decrementConnections() {
    currentConnections.decrementAndGet();
}

요청이 들어오면 increment, 처리가 끝나면 decrement. totalRequests는 줄지 않는다. 누적 요청 수이기 때문.

💡 실무 팁: decrementConnections()는 반드시 요청이 성공하든 실패하든 호출해야 한다. try-finally 패턴으로 감싸두지 않으면, 타임아웃이나 예외 상황에서 연결 수가 계속 쌓이는 버그가 생긴다. 실제로 이런 버그는 재현하기도 어렵고, 트래픽이 몰릴 때 갑자기 특정 서버로 요청이 안 가는 증상으로 나타난다.

응답시간 슬라이딩 윈도우

public void recordResponseTime(long responseTime) {
    synchronized (responseTimeLock) {
        recentResponseTimes.offer(responseTime);
        if (recentResponseTimes.size() > 10) {
            recentResponseTimes.poll(); // 가장 오래된 것 제거
        }
    }
}

public double getAverageResponseTime() {
    synchronized (responseTimeLock) {
        if (recentResponseTimes.isEmpty()) {
            return Double.MAX_VALUE; // 데이터 없으면 최악으로 간주
        }
        return recentResponseTimes.stream()
                .mapToLong(Long::longValue)
                .average()
                .orElse(Double.MAX_VALUE);
    }
}

최근 10개 응답시간만 유지하는 슬라이딩 윈도우 방식이다. offer()로 추가하고 크기가 넘치면 poll()로 가장 오래된 것을 뺀다.

데이터가 없으면 Double.MAX_VALUE를 반환하는데, 이건 "아직 측정되지 않은 서버는 일단 가장 느린 걸로 취급" 하겠다는 의미다. Least Response Time 알고리즘에서 이 값을 사용한다.

💡 실무 팁: 슬라이딩 윈도우 크기(여기선 10)는 트레이드오프다. 너무 작으면 일시적인 스파이크에 과민반응하고, 너무 크면 서버가 회복됐을 때도 한참 동안 느린 서버로 인식한다. 실제 프로덕션에서는 윈도우 크기를 설정값으로 빼두고, 트래픽 패턴에 따라 조정하는 게 좋다.


2. Config 설정

ServerConfig — 서버 목록 정의

@Configuration
public class ServerConfig {

    @Bean
    public List<Server> backendServers() {
        Server server1 = new Server("server-1", "localhost", 5001);
        server1.setWeight(4); // 고성능 서버

        Server server2 = new Server("server-2", "localhost", 5002);
        server2.setWeight(3);

        Server server3 = new Server("server-3", "localhost", 5003);
        server3.setWeight(2);

        Server server4 = new Server("server-4", "localhost", 5004);
        server4.setWeight(1); // 매우 낮은 성능

        return Arrays.asList(server1, server2, server3, server4);
    }
}

가중치를 4:3:2:1로 설정했다. Docker 서버들의 응답속도 차이(50ms / 150ms / 300ms / 500ms)를 반영한 것.

💡 : 실제 서비스에서 가중치는 보통 서버 스펙(CPU 코어 수, 메모리) 기준으로 정한다. 예를 들어 4코어 서버와 2코어 서버가 섞여 있으면 2:1로 설정하는 식. 다만 스펙이 같아도 GC 세팅이나 JVM 튜닝 상태에 따라 실제 처리량이 다를 수 있어서, 런타임에 응답시간을 보고 동적으로 조정하는 방법도 있다.

WebClientConfig — HTTP 클라이언트 설정

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
                .codecs(configurer -> configurer
                        .defaultCodecs()
                        .maxInMemorySize(1024 * 1024)) // 1MB
                .build();
    }

    public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
    public static final Duration HEALTH_CHECK_TIMEOUT = Duration.ofSeconds(3);
}

RestTemplate 대신 WebClient를 선택한 이유는 Non-blocking 비동기 처리 때문이다. 로드밸런서 특성상 여러 요청을 동시에 처리해야 하므로, 스레드를 블로킹하지 않는 WebClient가 적합하다.

타임아웃을 두 가지로 분리한 것도 포인트다. 일반 요청은 10초, 헬스체크는 3초. 헬스체크가 느리면 장애 감지도 느려지기 때문에 더 짧게 잡았다.

💡 : 타임아웃 설정은 생각보다 중요하다. 헬스체크 타임아웃이 길면, 서버가 죽었을 때 "아직 살아있나 확인하는 중"인 동안에도 계속 요청이 그 서버로 들어간다. 반대로 너무 짧으면 일시적인 GC pause나 네트워크 지연을 장애로 오인한다. 서비스 SLA를 기준으로 잡되, 헬스체크는 일반 요청 타임아웃의 1/3 정도가 경험상 적당하다.


3. 전략 인터페이스 설계

Strategy 패턴을 활용해서 알고리즘을 교체할 수 있게 설계했다.

public interface LoadBalancingStrategy {

    // 핵심: 서버 선택
    Server selectServer(List<Server> servers, String clientInfo);

    // 요청 완료 후 메트릭 업데이트
    void updateServerMetrics(Server server, long responseTime, boolean success);

    // 알고리즘 이름
    String getStrategyName();

    // 선택적 기능들 (default 메서드로 기본 구현 제공)
    default String getDescription() {
        return "Load balancing strategy: " + getStrategyName();
    }

    default void initialize(List<Server> servers) { }
    default void onServerAdded(Server server) { }
    default void onServerRemoved(Server server) { }
}

default 메서드를 활용한 부분이 깔끔하다. 서버 추가/제거나 초기화가 필요 없는 알고리즘은 오버라이드하지 않아도 된다.

💡 : selectServer()clientInfoString 하나로 뭉뚱그린 게 처음엔 이상해 보일 수 있다. IP Hash는 IP가 필요하고, 세션 기반 알고리즘은 세션 ID가 필요하다. 타입 안전성을 위해 ClientContext 같은 별도 객체로 만들어도 되지만, 인터페이스를 단순하게 유지하는 것도 설계 선택이다. 확장이 자주 일어나는 게 아니라면 단순함이 낫다.


4. 알고리즘 구현

구현 전에: 동시성을 어떻게 다룰 것인가?

알고리즘을 짜기 전에 반드시 생각해야 할 게 있다. 각 알고리즘은 여러 스레드가 동시에 selectServer()를 호출한다. 그러면 공유 상태는 어디에 있고, 어떻게 보호할 것인가?

스레드 세이프 여부를 판단하는 사고 흐름은 이렇다.

1) 공유 상태를 찾는다

// 이 필드를 여러 스레드가 동시에 읽거나 쓰는가?
private AtomicInteger counter;         // YES → 공유됨
private Map<String, String> ipMapping; // YES → 공유됨
// 메서드 안의 지역 변수는?           // NO  → 스레드마다 별도 스택

2) 읽기만 하는가, 쓰기도 하는가?

String value = map.get(key);  // 읽기만 → 보통 안전
map.put(key, value);           // 쓰기 포함 → 위험 신호

3) "확인 후 행동" 패턴을 찾는다

// 확인과 행동 사이에 다른 스레드가 끼어들 수 있으면 위험!
if (map.containsKey(key)) {  // 확인
    map.remove(key);          // 행동 ← 이 사이에 다른 스레드가?
}

핵심 관점: 동시성 문제는 제거 대상이 아니라 관리 대상이다. 어떤 레이스를 허용하고, 어떤 레이스를 막을지 결정하는 게 설계다.

예를 들어 연결 수가 한 번 잘못 카운팅되는 건 허용 가능한 오류다. 하지만 인덱스 범위를 벗어나거나, 초기화가 두 번 실행되는 건 치명적인 버그다. 모든 걸 막으려 하면 코드가 복잡해지고 성능이 떨어진다. "이 오류가 발생하면 얼마나 심각한가?" 를 기준으로 판단하자.


4-1. Round Robin

가장 단순한 알고리즘. 서버를 순서대로 돌아가며 선택한다.

@Component("roundRobin")
public class RoundRobinStrategy implements LoadBalancingStrategy {

    private final AtomicInteger currentIndex = new AtomicInteger(0);

    @Override
    public Server selectServer(List<Server> servers, String clientInfo) {
        if (servers == null || servers.isEmpty()) {
            throw new IllegalArgumentException("서버 목록이 비어있습니다.");
        }

        int index = currentIndex.getAndIncrement() % servers.size();
        Server selectedServer = servers.get(index);
        selectedServer.incrementConnections();

        return selectedServer;
    }

    @Override
    public void updateServerMetrics(Server server, long responseTime, boolean success) {
        server.decrementConnections();
        if (success) {
            server.recordResponseTime(responseTime);
        }
    }
}

핵심은 getAndIncrement() % servers.size() 한 줄이다.

호출 1: counter=0 → 0%4=0 → server-1, counter=1
호출 2: counter=1 → 1%4=1 → server-2, counter=2
호출 3: counter=2 → 2%4=2 → server-3, counter=3
호출 4: counter=3 → 3%4=3 → server-4, counter=4
호출 5: counter=4 → 4%4=0 → server-1, counter=5

AtomicInteger를 쓰기 때문에 여러 스레드가 동시에 호출해도 같은 서버가 두 번 선택되는 일이 없다.

updateServerMetrics()에서 실패한 요청은 응답시간 통계에서 제외하는 것도 주의하자. 타임아웃이나 에러로 인한 이상값이 섞이면 통계가 왜곡된다.

💡 : counter가 계속 증가하면 언젠가 Integer.MAX_VALUE(약 21억)를 넘어 음수로 overflow된다. 그러면 % servers.size()가 음수가 되어 ArrayIndexOutOfBoundsException이 발생한다. 트래픽이 많은 서비스라면 이건 실제로 일어날 수 있는 버그다. Math.abs()로 감싸거나, 아예 counter % size가 음수가 될 수 없도록 처리해두는 게 안전하다.

int index = Math.abs(currentIndex.getAndIncrement() % servers.size());

4-2. Weighted Round Robin

서버 성능에 따라 더 자주 선택되도록 가중치를 부여한다. 가중치만큼 서버를 리스트에 반복해서 넣어두는 방식이다.

가중치가 4:3:2:1이면 이런 리스트가 만들어진다:

[server-1, server-1, server-1, server-1,
 server-2, server-2, server-2,
 server-3, server-3,
 server-4]

이 리스트를 Round Robin으로 순회하면 자연스럽게 server-1이 4배 더 자주 선택된다.

@Override
public Server selectServer(List<Server> servers, String clientInfo) {
    if (servers == null || servers.isEmpty()) {
        throw new IllegalArgumentException("서버 목록이 비어있습니다.");
    }

    synchronized (listLock) {
        if (weightedServerList.isEmpty() || needsRebuild(servers)) {
            buildWeightedServerList(servers);
        }
    }

    // snapshot으로 리스트 참조를 고정
    List<Server> snapshot = weightedServerList;
    int index = currentIndex.getAndIncrement() % snapshot.size();
    Server server = snapshot.get(index);
    server.incrementConnections();

    return server;
}

Round Robin보다 동시성 처리가 복잡해진다. 세 가지 도구를 함께 쓴다.

필드타입역할
currentIndexAtomicInteger인덱스 증가의 원자성 보장
weightedServerListvolatile List리스트 참조 교체 시 다른 스레드에 즉시 반영
listLockObject리스트 재구성 시 한 스레드만 접근하도록

volatile이 필요한 이유:

Thread A (재구성)                Thread B (읽기)
  새 ArrayList 생성
  서버들 추가
  weightedServerList = 새 리스트
         │
         │ ─── volatile write ──▶
                                 weightedServerList.get(index)
                                 → 새 리스트가 즉시 보임!

volatile 없이는 Thread B가 CPU 캐시에 남아있는 옛날 리스트를 계속 참조할 수 있다.

snapshot이 필요한 이유:

// 이 시점에 리스트 크기 = 10, index = 8
int index = currentIndex.getAndIncrement() % weightedServerList.size();

// ← 여기서 다른 스레드가 리스트 재구성 → 크기가 5로 줄어들면?

Server server = weightedServerList.get(index); // index=8 → IndexOutOfBoundsException!

로컬 변수 snapshot으로 참조를 고정하면 이 문제를 방지할 수 있다. 지역 변수는 스레드별 스택에 존재하기 때문에, 할당 이후로는 이 스레드 안에서 리스트가 바뀔 일이 없다.

💡 : synchronized로 재구성을 막고 volatile로 가시성을 보장하는 이 패턴은 꽤 자주 쓰인다. 더 나아가면 ReadWriteLock을 써서 읽기는 동시에, 쓰기만 단독으로 실행하도록 할 수 있다. 재구성보다 읽기가 압도적으로 많은 로드밸런서 특성상, ReadWriteLock이 성능상 더 유리할 수 있다.

private final ReadWriteLock lock = new ReentrantReadWriteLock();

// 읽기 시
lock.readLock().lock();
try { ... } finally { lock.readLock().unlock(); }

// 재구성 시
lock.writeLock().lock();
try { ... } finally { lock.writeLock().unlock(); }

마치며

이번 편에서 구현한 내용을 정리하면:

  • Server 클래스: AtomicInteger, synchronized로 Thread-Safe하게 메트릭 수집
  • ServerConfig: 서버 4개를 성능 차이를 반영한 가중치(4:3:2:1)로 등록
  • WebClientConfig: Non-blocking WebClient + 용도별 타임아웃 분리
  • LoadBalancingStrategy 인터페이스: Strategy 패턴으로 알고리즘 교체 가능하게
  • Round Robin: AtomicInteger 하나로 심플하게 (단, overflow 주의)
  • Weighted Round Robin: volatile + synchronized + AtomicInteger 조합으로 동시성 제어

동시성 코드를 짜다 보면 "여기도 락 걸어야 하지 않나?" 하는 불안감이 생기는데, 핵심은 "이 버그가 발생하면 얼마나 심각한가?" 를 기준으로 판단하는 거다. 모든 걸 막으려다가 성능을 잡아먹는 오버엔지니어링이 되지 않도록.

다음 편에서는 Least Connections, IP Hash, Consistent Hashing, Least Response Time 나머지 4개 알고리즘을 구현하고, K6로 성능을 측정해보겠다.

0개의 댓글