이론은 충분히 했으니, 이제 진짜로 만들어보자.
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
서버 하나를 나타내는 클래스다. 단순한 데이터 홀더처럼 보이지만, 실시간 메트릭 수집과 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을 쓴다. 여러 스레드가 동시에 값을 바꿔도 안전하게.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)는 트레이드오프다. 너무 작으면 일시적인 스파이크에 과민반응하고, 너무 크면 서버가 회복됐을 때도 한참 동안 느린 서버로 인식한다. 실제 프로덕션에서는 윈도우 크기를 설정값으로 빼두고, 트래픽 패턴에 따라 조정하는 게 좋다.
@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 튜닝 상태에 따라 실제 처리량이 다를 수 있어서, 런타임에 응답시간을 보고 동적으로 조정하는 방법도 있다.
@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 정도가 경험상 적당하다.
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()에clientInfo를String하나로 뭉뚱그린 게 처음엔 이상해 보일 수 있다. IP Hash는 IP가 필요하고, 세션 기반 알고리즘은 세션 ID가 필요하다. 타입 안전성을 위해ClientContext같은 별도 객체로 만들어도 되지만, 인터페이스를 단순하게 유지하는 것도 설계 선택이다. 확장이 자주 일어나는 게 아니라면 단순함이 낫다.
알고리즘을 짜기 전에 반드시 생각해야 할 게 있다. 각 알고리즘은 여러 스레드가 동시에 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); // 행동 ← 이 사이에 다른 스레드가?
}
핵심 관점: 동시성 문제는 제거 대상이 아니라 관리 대상이다. 어떤 레이스를 허용하고, 어떤 레이스를 막을지 결정하는 게 설계다.
예를 들어 연결 수가 한 번 잘못 카운팅되는 건 허용 가능한 오류다. 하지만 인덱스 범위를 벗어나거나, 초기화가 두 번 실행되는 건 치명적인 버그다. 모든 걸 막으려 하면 코드가 복잡해지고 성능이 떨어진다. "이 오류가 발생하면 얼마나 심각한가?" 를 기준으로 판단하자.
가장 단순한 알고리즘. 서버를 순서대로 돌아가며 선택한다.
@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: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보다 동시성 처리가 복잡해진다. 세 가지 도구를 함께 쓴다.
| 필드 | 타입 | 역할 |
|---|---|---|
currentIndex | AtomicInteger | 인덱스 증가의 원자성 보장 |
weightedServerList | volatile List | 리스트 참조 교체 시 다른 스레드에 즉시 반영 |
listLock | Object | 리스트 재구성 시 한 스레드만 접근하도록 |
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 패턴으로 알고리즘 교체 가능하게AtomicInteger 하나로 심플하게 (단, overflow 주의)volatile + synchronized + AtomicInteger 조합으로 동시성 제어동시성 코드를 짜다 보면 "여기도 락 걸어야 하지 않나?" 하는 불안감이 생기는데, 핵심은 "이 버그가 발생하면 얼마나 심각한가?" 를 기준으로 판단하는 거다. 모든 걸 막으려다가 성능을 잡아먹는 오버엔지니어링이 되지 않도록.
다음 편에서는 Least Connections, IP Hash, Consistent Hashing, Least Response Time 나머지 4개 알고리즘을 구현하고, K6로 성능을 측정해보겠다.