로드밸런싱 알고리즘 — Round Robin에서 Least Connections까지
서버가 여러 대인데, 어떤 서버에 요청을 보내야 가장 효율적일까요?
개념 정의
로드밸런싱(Load Balancing) 은 들어오는 요청을 여러 서버에 분배하는 기술입니다. 핵심은 "어떤 알고리즘으로 서버를 선택하느냐"입니다.
왜 필요한가
- **고가용성 **: 한 서버가 죽어도 나머지가 처리
- ** 확장성 **: 서버를 추가하면 처리량 증가
- ** 성능 **: 부하를 균등하게 분배하여 응답 시간 최소화
Round Robin
가장 단순합니다. 서버 목록을 순서대로 돌면서 요청을 분배합니다.
public class RoundRobinBalancer {
private final List<String> servers;
private final AtomicInteger index = new AtomicInteger(0);
public RoundRobinBalancer(List<String> servers) {
this.servers = servers;
}
public String nextServer() {
int idx = index.getAndIncrement() % servers.size();
return servers.get(idx);
}
}
- ** 장점 **: 구현이 매우 간단, 균등 분배
- ** 단점 **: 서버 성능 차이를 고려하지 않음, 요청 처리 시간 차이 무시
Weighted Round Robin
서버에 ** 가중치 **를 부여하여, 성능이 좋은 서버에 더 많은 요청을 보냅니다.
public class WeightedRoundRobin {
private final List<String> servers;
private final int[] weights;
private int currentIndex = 0;
private int currentWeight = 0;
private final int maxWeight;
private final int gcdWeight;
public WeightedRoundRobin(List<String> servers, int[] weights) {
this.servers = servers;
this.weights = weights;
this.maxWeight = Arrays.stream(weights).max().orElse(1);
this.gcdWeight = gcd(weights);
}
public synchronized String nextServer() {
while (true) {
currentIndex = (currentIndex + 1) % servers.size();
if (currentIndex == 0) {
currentWeight -= gcdWeight;
if (currentWeight <= 0) {
currentWeight = maxWeight;
}
}
if (weights[currentIndex] >= currentWeight) {
return servers.get(currentIndex);
}
}
}
private int gcd(int[] arr) {
int result = arr[0];
for (int i = 1; i < arr.length; i++) {
result = gcd(result, arr[i]);
}
return result;
}
private int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
}
// 사용: 서버A(weight=5), 서버B(weight=3), 서버C(weight=2)
// → A에 50%, B에 30%, C에 20%의 요청
Least Connections
** 현재 연결 수가 가장 적은 서버 **에 요청을 보냅니다.
public class LeastConnectionsBalancer {
private final Map<String, AtomicInteger> connections = new ConcurrentHashMap<>();
public LeastConnectionsBalancer(List<String> servers) {
servers.forEach(s -> connections.put(s, new AtomicInteger(0)));
}
public String nextServer() {
return connections.entrySet().stream()
.min(Comparator.comparingInt(e -> e.getValue().get()))
.map(Map.Entry::getKey)
.orElseThrow();
}
public void onRequestStart(String server) {
connections.get(server).incrementAndGet();
}
public void onRequestEnd(String server) {
connections.get(server).decrementAndGet();
}
}
- ** 장점 **: 느린 요청이 많은 서버에 추가 요청을 보내지 않음
- ** 단점 **: 연결 수 추적 오버헤드, 새 서버 추가 시 몰림 현상
IP Hash
클라이언트 IP를 해싱하여 항상 같은 서버로 라우팅합니다.
public class IPHashBalancer {
private final List<String> servers;
public IPHashBalancer(List<String> servers) {
this.servers = servers;
}
public String getServer(String clientIP) {
int hash = clientIP.hashCode();
int index = Math.abs(hash) % servers.size();
return servers.get(index);
}
}
- ** 장점 **: 세션 친화성(같은 클라이언트 → 같은 서버)
- ** 단점 **: 서버 추가/제거 시 매핑 변경 (일관된 해싱으로 개선 가능)
Least Response Time
** 응답 시간이 가장 짧은 서버 **에 요청을 보냅니다.
public class LeastResponseTimeBalancer {
private final Map<String, Double> avgResponseTime = new ConcurrentHashMap<>();
private final double alpha = 0.3; // 지수 이동 평균 가중치
public String nextServer() {
return avgResponseTime.entrySet().stream()
.min(Comparator.comparingDouble(Map.Entry::getValue))
.map(Map.Entry::getKey)
.orElseThrow();
}
public void recordResponseTime(String server, double responseTime) {
avgResponseTime.compute(server, (k, avg) ->
avg == null ? responseTime : alpha * responseTime + (1 - alpha) * avg
);
}
}
알고리즘 비교
| 알고리즘 | 균등 분배 | 성능 고려 | 세션 친화성 | 복잡도 |
|---|---|---|---|---|
| Round Robin | O | X | X | 낮음 |
| Weighted RR | O | 정적 | X | 낮음 |
| Least Connections | O | 동적 | X | 중간 |
| IP Hash | X | X | O | 낮음 |
| Least Response Time | O | 동적 | X | 높음 |
| Random | 확률적 | X | X | 낮음 |
백엔드/실무 연결
Nginx
# Round Robin (기본값)
upstream backend {
server backend1.example.com;
server backend2.example.com;
}
# Weighted
upstream backend {
server backend1.example.com weight=5;
server backend2.example.com weight=3;
}
# Least Connections
upstream backend {
least_conn;
server backend1.example.com;
server backend2.example.com;
}
# IP Hash
upstream backend {
ip_hash;
server backend1.example.com;
server backend2.example.com;
}
Spring Cloud LoadBalancer
@Configuration
public class LoadBalancerConfig {
// Round Robin (기본)
@Bean
public ReactorLoadBalancer<ServiceInstance> roundRobinLoadBalancer(
ServiceInstanceListSupplier supplier) {
return new RoundRobinLoadBalancer(supplier, "my-service");
}
// Random
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
ServiceInstanceListSupplier supplier) {
return new RandomLoadBalancer(supplier, "my-service");
}
}
AWS ALB / NLB
- ALB (Application Load Balancer): Round Robin + 느린 시작(slow start) 모드
- NLB (Network Load Balancer): 플로우 해시 (IP + 포트 조합)
L4 vs L7 로드밸런싱
| 구분 | L4 (Transport) | L7 (Application) |
|---|---|---|
| 기준 | IP, 포트 | URL, 헤더, 쿠키 |
| 속도 | 빠름 | 느림 |
| 기능 | 단순 분배 | 콘텐츠 기반 라우팅 |
| 예시 | AWS NLB | AWS ALB, Nginx |
헬스 체크
어떤 알고리즘을 쓰든, 죽은 서버에 요청을 보내면 안 됩니다.
// 주기적 헬스 체크
@Scheduled(fixedRate = 5000)
public void healthCheck() {
for (String server : servers) {
try {
HttpResponse response = httpClient.send(
HttpRequest.newBuilder()
.uri(URI.create(server + "/health"))
.timeout(Duration.ofSeconds(2))
.build(),
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() == 200) {
markHealthy(server);
} else {
markUnhealthy(server);
}
} catch (Exception e) {
markUnhealthy(server);
}
}
}
주의할 점
Round Robin에서 서버 성능 차이를 무시하는 실수
서버 사양이 다른데 단순 Round Robin을 적용하면, 느린 서버에 요청이 쌓입니다. Weighted Round Robin이나 Least Connections를 사용해야 합니다.
세션 친화성이 필요한데 Round Robin을 사용하는 실수
세션 기반 인증을 사용하면서 Round Robin을 적용하면, 같은 사용자의 요청이 다른 서버로 가서 세션을 찾을 수 없습니다. IP Hash나 쿠키 기반 고정(sticky session)이 필요합니다.
정리
- Round Robin: 가장 단순하고 균등한 분배, 서버 성능 차이가 없을 때 적합합니다
- Weighted Round Robin: 서버 성능에 따라 가중치를 부여합니다
- Least Connections: 현재 부하가 적은 서버를 선택, 요청 처리 시간이 다양할 때 유리합니다
- IP Hash: 세션 친화성이 필요할 때 사용합니다
- Nginx, Spring Cloud, AWS ALB/NLB 등 모든 로드밸런서가 이 알고리즘들을 조합하여 사용합니다
- 헬스 체크와 결합하여 장애 서버를 자동으로 제외하는 것이 필수입니다
댓글 로딩 중...