2026년 6월 6일

로드 밸런싱 마스터하기: 고가용성과 확장성을 위한 트래픽 분산 전략

100
로드 밸런싱 마스터하기: 고가용성과 확장성을 위한 트래픽 분산 전략

로드 밸런싱 마스터하기: 고가용성과 확장성을 위한 트래픽 분산 전략

로드 밸런싱 마스터하기: 고가용성과 확장성을 위한 트래픽 분산 전략

안녕하세요, 10년차 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 현대 웹 서비스와 분산 시스템에서 빼놓을 수 없는 핵심 기술, 바로 **로드 밸런싱(Load Balancing)**에 대해 깊이 있게 알아보는 시간을 갖겠습니다. 초중급 개발자라면 반드시 이해하고 넘어가야 할 이 개념을 통해 여러분의 시스템 설계 능력을 한 단계 업그레이드할 수 있을 것입니다.

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

정의: 트래픽을 효율적으로 나누는 기술

로드 밸런싱은 이름 그대로 '부하(Load)'를 '분산(Balancing)'하는 기술입니다. 즉, 네트워크 트래픽이나 작업 부하를 여러 대의 서버에 효율적으로 나누어 처리하는 과정을 말합니다. 웹 서버, 애플리케이션 서버, 데이터베이스 서버 등 어떤 종류의 서버에도 적용될 수 있습니다.

탄생 배경: 단일 서버의 한계를 넘어

초기 웹 서비스는 대부분 하나의 서버에서 모든 요청을 처리했습니다. 하지만 사용자 수가 폭발적으로 증가하고 서비스가 복잡해지면서, 단일 서버의 한계에 부딪히게 됩니다.

  1. 성능 병목(Performance Bottleneck): 하나의 서버가 감당할 수 있는 동시 접속자 수나 처리량에는 분명한 한계가 있습니다. 트래픽이 몰리면 서버는 느려지거나 응답을 멈추게 됩니다.
  2. 단일 실패 지점(Single Point of Failure, SPOF): 만약 유일한 서버가 고장 나면, 서비스 전체가 중단됩니다. 이는 비즈니스에 치명적인 손실을 가져올 수 있습니다.

이러한 문제들을 해결하기 위해 등장한 것이 바로 로드 밸런싱입니다. 여러 대의 서버를 준비하고, 이 서버들로 트래픽을 분산함으로써 단일 서버의 한계를 극복하고 더 많은 요청을 안정적으로 처리할 수 있게 된 것입니다.

왜 중요한가: 고가용성, 확장성, 성능 최적화의 핵심

로드 밸런싱은 현대 시스템에서 다음과 같은 이유로 매우 중요합니다.

  • 고가용성(High Availability): 여러 서버 중 일부가 고장 나더라도, 로드 밸런서는 자동으로 해당 서버를 트래픽 분배 대상에서 제외하고 정상 작동하는 서버로만 요청을 보냅니다. 이를 통해 서비스 중단 없이 안정적으로 운영될 수 있습니다. 마치 여러 명의 점원이 있는 식당에서 한 명이 아파도 다른 점원들이 손님을 계속 받는 것과 같습니다.
  • 확장성(Scalability): 트래픽이 증가할 때, 단순히 서버를 추가하기만 하면 로드 밸런서가 자동으로 새로운 서버에도 트래픽을 분산시킵니다. 이는 수평적 확장(Horizontal Scaling)을 가능하게 하여, 유연하게 시스템 용량을 조절할 수 있게 합니다.
  • 성능 최적화(Performance Optimization): 트래픽을 여러 서버에 고르게 분산하여 각 서버의 부하를 줄이고, 결과적으로 사용자 요청에 대한 응답 시간을 단축시켜 전체적인 서비스 성능을 향상시킵니다.
  • 안정적인 운영: 특정 서버에 과부하가 걸리는 것을 방지하여 시스템 전반의 안정성을 높입니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

로드 밸런싱의 핵심 원리는 간단합니다. 클라이언트로부터 요청이 들어오면, 로드 밸런서가 이 요청을 미리 설정된 규칙(알고리즘)에 따라 여러 백엔드 서버 중 하나로 전달하는 방식입니다.

동작 방식 (비유와 다이어그램)

만약 여러분이 아주 인기 있는 레스토랑의 주인이라고 상상해봅시다. 손님(클라이언트)은 식당 입구로 들어오고, 여러분은 여러 명의 웨이터(백엔드 서버)를 고용해서 손님을 테이블로 안내하고 주문을 받게 합니다.

초기 상황 (단일 서버): 손님(클라이언트) ----> 웨이터 1 (서버 1) ----> 주방

문제 발생: 손님이 너무 많아지면 웨이터 1 혼자서는 감당하기 어렵습니다. 손님들은 기다리다 지쳐 돌아가거나, 서비스가 매우 느려질 것입니다. 웨이터 1이 아프면 식당 문을 닫아야 합니다.

로드 밸런싱 적용: 여러분은 식당 입구에 '호스트'(로드 밸런서)를 한 명 고용합니다. 호스트는 들어오는 손님들을 보면서 여러 웨이터(서버) 중 한 명에게 안내하는 역할을 합니다.

                  +-------------------+
                  |   Load Balancer   |
                  |     (호스트)      |
                  +---------+---------+
                            |
           +----------------+----------------+
           |                                  |
           V                                  V
+----------+----------+           +----------+----------+
|  Backend Server 1   |           |  Backend Server 2   |
|     (웨이터 1)      |           |     (웨이터 2)      |
+---------------------+           +---------------------+

클라이언트가 로드 밸런서의 IP 주소로 요청을 보내면, 로드 밸런서는 어떤 백엔드 서버로 요청을 보낼지 결정하고 해당 서버로 요청을 전달합니다. 백엔드 서버는 요청을 처리한 후, 응답을 다시 로드 밸런서를 통해 클라이언트에게 전달합니다. 클라이언트는 로드 밸런서와 직접 통신하는 것처럼 느끼게 됩니다.

주요 로드 밸런싱 알고리즘

호스트(로드 밸런서)가 어떤 웨이터(서버)에게 손님을 보낼지 결정하는 방법은 여러 가지가 있습니다.

  1. 라운드 로빈(Round Robin):

    • 가장 단순한 방식입니다. 요청이 들어오는 순서대로 서버들에게 차례대로 분배합니다.
    • 예: 첫 번째 요청은 서버 1, 두 번째는 서버 2, 세 번째는 서버 3, 네 번째는 다시 서버 1...
    • 장점: 구현이 쉽고, 서버 간 부하를 비교적 고르게 분산합니다.
    • 단점: 각 서버의 처리 능력이나 현재 부하 상태를 고려하지 않습니다. 성능이 낮은 서버도 똑같이 요청을 받습니다.
  2. 가중치 기반 라운드 로빈(Weighted Round Robin):

    • 각 서버에 가중치(weight)를 부여하여, 성능이 더 좋은 서버에는 더 많은 요청을 보내고, 성능이 낮은 서버에는 더 적은 요청을 보내는 방식입니다.
    • 예: 서버 1(가중치 3), 서버 2(가중치 1)라면, 서버 1에 3개의 요청이 갈 때 서버 2에 1개의 요청이 갑니다.
  3. 최소 연결(Least Connection):

    • 현재 가장 적은 활성 연결(active connection)을 가지고 있는 서버로 새로운 요청을 보냅니다.
    • 장점: 각 서버의 현재 부하 상태를 실질적으로 반영하여, 부하가 적은 서버에 더 많은 요청을 보내 효율성을 높입니다.
    • 단점: 연결 수가 적다는 것이 항상 서버의 '부하'가 적다는 것을 의미하지는 않을 수 있습니다 (예: 연결은 적지만 각 연결이 매우 무거운 작업을 수행 중일 때).
  4. IP 해시(IP Hash):

    • 클라이언트의 IP 주소를 해시(Hash)하여, 그 결과에 따라 특정 서버로 요청을 보냅니다.
    • 장점: 특정 클라이언트의 모든 요청이 항상 동일한 서버로 전달됩니다. 이는 '세션 지속성(Session Persistence)'이 필요한 경우에 유용합니다.
    • 단점: 특정 IP에서 트래픽이 폭증하면 해당 서버에 부하가 집중될 수 있습니다.

헬스 체크(Health Check)

로드 밸런서의 가장 중요한 기능 중 하나는 백엔드 서버의 상태를 주기적으로 확인하는 '헬스 체크'입니다. 만약 웨이터(서버) 중 한 명이 아파서 손님을 받을 수 없는 상태가 되면, 호스트(로드 밸런서)는 더 이상 그 웨이터에게 손님을 보내지 않습니다.

로드 밸런서는 주기적으로 각 서버에 특정 포트로 접속하거나, 미리 정의된 URL(예: /healthz)로 HTTP 요청을 보내 응답을 확인합니다. 응답이 없거나 에러 코드를 반환하면 해당 서버를 '비정상(unhealthy)' 상태로 간주하고, 정상 상태로 돌아올 때까지 트래픽 분배 대상에서 제외합니다.

                  +-------------------+
                  |   Load Balancer   |
                  +---------+---------+
                            |
           (Health Check)   |   (Traffic)
           <----------------+---------------->
           |                                  |
           V                                  V
+----------+----------+           +----------+----------+
|  Backend Server 1   |           |  Backend Server 2   |
|     (Healthy)       |           |    (Unhealthy)      |
+---------------------+           +---------------------+

세션 지속성(Session Persistence / Sticky Sessions)

일부 웹 애플리케이션은 사용자의 세션 정보(로그인 상태, 장바구니 내용 등)를 특정 서버에 저장하는 경우가 있습니다. 이런 경우, 한 사용자의 요청은 항상 동일한 서버로 전달되어야 합니다. 그렇지 않으면 로그인 상태가 풀리거나 장바구니 내용이 사라지는 문제가 발생할 수 있습니다.

세션 지속성은 로드 밸런서가 특정 클라이언트의 모든 요청을 정해진 기간 동안 동일한 백엔드 서버로 보내도록 하는 기능입니다. 이를 구현하는 방법으로는 클라이언트 IP 주소 기반(IP Hash), 쿠키 기반, SSL 세션 ID 기반 등이 있습니다.

L4 vs L7 로드 밸런서

로드 밸런서는 OSI 7계층 모델의 어느 계층에서 동작하느냐에 따라 크게 L4와 L7으로 나뉩니다.

  • L4 (Layer 4) 로드 밸런서:

    • **전송 계층(Transport Layer)**에서 동작합니다. IP 주소와 포트 번호를 기반으로 트래픽을 분산합니다.
    • 패킷 내용을 깊이 들여다보지 않고, 단순하게 연결을 분배합니다.
    • 장점: 처리 속도가 빠르고, 부하가 적습니다.
    • 단점: HTTP 헤더나 URL 경로와 같은 애플리케이션 계층 정보를 활용한 섬세한 라우팅이 불가능합니다.
    • 예: TCP/UDP 트래픽 분산, AWS NLB(Network Load Balancer).
  • L7 (Layer 7) 로드 밸런서:

    • **애플리케이션 계층(Application Layer)**에서 동작합니다. HTTP/HTTPS 헤더, URL 경로, 쿠키 등 애플리케이션 레벨의 정보를 분석하여 트래픽을 분산합니다.
    • 장점: URL 경로 기반 라우팅, 콘텐츠 기반 라우팅, SSL 오프로딩(SSL 암복호화 처리), 웹 방화벽(WAF) 연동 등 더 정교하고 다양한 기능을 제공합니다.
    • 단점: 패킷 내용을 분석해야 하므로 L4보다 처리 속도가 느리고, 더 많은 리소스를 소모합니다.
    • 예: HTTP/HTTPS 트래픽 분산, AWS ALB(Application Load Balancer), Nginx, HAProxy.

레스토랑 비유로 다시 돌아가면, L4 로드 밸런서는 단순히 '어떤 웨이터가 지금 가장 한가한가?'만 보고 손님을 보내는 웨이터장과 같습니다. 반면 L7 로드 밸런서는 손님이 '스테이크를 먹고 싶다'고 하면 스테이크 담당 웨이터에게, '파스타를 먹고 싶다'고 하면 파스타 담당 웨이터에게 보내는 매니저와 같습니다. 더 많은 정보를 바탕으로 더 스마트하게 분배하는 것이죠.

3. 코드 예제 2개 (Python)

실제로 로드 밸런서 코드를 구현하는 것은 복잡하지만, 핵심 로직을 이해하기 위한 간단한 시뮬레이션 코드를 파이썬으로 작성해 보겠습니다.

예제 1: 간단한 라운드 로빈 로드 밸런서 시뮬레이션

이 예제는 요청이 들어올 때마다 서버 목록을 순환하며 다음 서버를 선택하는 로직을 보여줍니다. 실제 네트워크 통신은 포함하지 않습니다.

class RoundRobinLoadBalancer:
    def __init__(self, servers):
        # 로드 밸런싱할 서버 목록을 초기화합니다.
        # 각 서버는 "http://ip:port" 형식의 문자열이라고 가정합니다.
        self.servers = servers
        # 현재 요청을 보낼 서버의 인덱스를 추적합니다.
        self.current_server_index = 0
        print(f"로드 밸런서 초기화. 서버 목록: {self.servers}")

    def get_next_server(self):
        """
        라운드 로빈 방식으로 다음 서버를 선택합니다.
        """
        if not self.servers:
            print("오류: 로드 밸런싱할 서버가 없습니다.")
            return None

        # 현재 인덱스에 해당하는 서버를 선택합니다.
        selected_server = self.servers[self.current_server_index]

        # 다음 요청을 위해 인덱스를 업데이트합니다.
        # 서버 목록의 끝에 도달하면 다시 처음으로 돌아갑니다 (순환).
        self.current_server_index = (self.current_server_index + 1) % len(self.servers)

        print(f"[{selected_server}] 서버가 선택되었습니다.")
        return selected_server

# 서버 목록 정의
backend_servers = [
    "http://192.168.1.10:8000",
    "http://192.168.1.11:8000",
    "http://192.168.1.12:8000"
]

# 로드 밸런서 인스턴스 생성
lb = RoundRobinLoadBalancer(backend_servers)

# 10개의 가상 요청을 시뮬레이션합니다.
print("\n--- 라운드 로빈 로드 밸런싱 시뮬레이션 시작 ---")
for i in range(1, 11):
    print(f"요청 {i}:", end=" ")
    server = lb.get_next_server()
    if server:
        # 실제 환경에서는 이 서버로 HTTP 요청을 보냅니다.
        # print(f"  -> 요청이 {server}로 전달되었습니다.")
        pass # 실제 요청은 생략
print("--- 라운드 로빈 로드 밸런싱 시뮬레이션 종료 ---\n")

실행 결과 예시:

로드 밸런서 초기화. 서버 목록: ['http://192.168.1.10:8000', 'http://192.168.1.11:8000', 'http://192.168.1.12:8000']

--- 라운드 로빈 로드 밸런싱 시뮬레이션 시작 ---
요청 1: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 2: [http://192.168.1.11:8000] 서버가 선택되었습니다.
요청 3: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 4: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 5: [http://192.168.1.11:8000] 서버가 선택되었습니다.
요청 6: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 7: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 8: [http://192.168.1.11:8000] 서버가 선택되었습니다.
요청 9: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 10: [http://192.168.1.10:8000] 서버가 선택되었습니다.
--- 라운드 로빈 로드 밸런싱 시뮬레이션 종료 ---

예제 2: 헬스 체크 기능을 포함한 로드 밸런서 시뮬레이션

이 예제는 서버의 헬스 상태를 추적하고, ' unhealthy' 서버에는 트래픽을 보내지 않으며, 'healthy' 서버 중에서 라운드 로빈 방식으로 선택하는 로직을 보여줍니다.

import time

class HealthCheckedLoadBalancer:
    def __init__(self, servers):
        self.servers = servers
        # 각 서버의 상태를 저장하는 딕셔너리. 초기에는 모두 healthy로 설정.
        self.server_health = {server: True for server in servers}
        self.current_server_index = 0
        print(f"로드 밸런서 초기화. 서버 목록: {self.servers}")
        print(f"초기 서버 상태: {self.server_health}")

    def _perform_health_check(self, server_url):
        """
        가상의 헬스 체크 함수. 실제로는 HTTP 요청 등을 통해 서버 상태를 확인합니다.
        여기서는 임의로 서버 192.168.1.11:8000만 'unhealthy'로 가정합니다.
        """
        # 실제 환경에서는 requests.get(server_url + "/healthz") 등을 사용합니다.
        # 여기서는 특정 서버를 unhealthy로 가정하여 시뮬레이션합니다.
        if "192.168.1.11" in server_url:
            return False # 이 서버는 비정상이라고 가정
        return True # 다른 서버는 정상이라고 가정

    def update_health_status(self):
        """
        모든 서버에 대해 헬스 체크를 수행하고 상태를 업데이트합니다.
        """
        print("\n--- 헬스 체크 업데이트 시작 ---")
        for server in self.servers:
            is_healthy = self._perform_health_check(server)
            self.server_health[server] = is_healthy
            print(f"  서버 {server} 상태: {'Healthy' if is_healthy else 'Unhealthy'}")
        print("--- 헬스 체크 업데이트 완료 ---\n")

    def get_next_healthy_server(self):
        """
        헬스 체크를 통과한 서버 중에서 라운드 로빈 방식으로 다음 서버를 선택합니다.
        """
        healthy_servers = [server for server, is_healthy in self.server_health.items() if is_healthy]

        if not healthy_servers:
            print("오류: 현재 트래픽을 처리할 수 있는 정상 서버가 없습니다.")
            return None

        # 라운드 로빈 인덱스가 healthy_servers 리스트의 범위를 벗어나지 않도록 조정.
        # 만약 이전 인덱스가 unhealthy 서버를 가리키고 있었다면, 새롭게 healthy_servers 내에서 유효한 인덱스를 찾아야 할 수 있습니다.
        # 단순화를 위해, 여기서는 healthy_servers 내에서 라운드 로빈을 수행합니다.
        selected_server = healthy_servers[self.current_server_index % len(healthy_servers)]
        self.current_server_index = (self.current_server_index + 1) % len(healthy_servers)

        print(f"[{selected_server}] 서버가 선택되었습니다.")
        return selected_server

# 서버 목록 정의
backend_servers_hc = [
    "http://192.168.1.10:8000",
    "http://192.168.1.11:8000", # 이 서버는 _perform_health_check 함수에서 unhealthy로 간주됨
    "http://192.168.1.12:8000"
]

# 로드 밸런서 인스턴스 생성
lb_hc = HealthCheckedLoadBalancer(backend_servers_hc)

# 헬스 체크 상태 업데이트 (초기 상태 설정 및 시뮬레이션)
lb_hc.update_health_status()

# 10개의 가상 요청을 시뮬레이션합니다.
print("\n--- 헬스 체크 기반 로드 밸런싱 시뮬레이션 시작 ---")
for i in range(1, 11):
    print(f"요청 {i}:", end=" ")
    server = lb_hc.get_next_healthy_server()
    if server:
        # print(f"  -> 요청이 {server}로 전달되었습니다.")
        pass
    time.sleep(0.1) # 시뮬레이션을 위해 잠시 대기

# 중간에 서버 상태가 변경되었다고 가정하고 다시 헬스 체크
print("\n(가정: 192.168.1.11 서버가 다시 Healthy 상태로 돌아옴)")
# 실제로는 _perform_health_check 내부 로직을 변경해야 하지만, 여기서는 직접 상태 변경으로 시뮬레이션
lb_hc.server_health["http://192.168.1.11:8000"] = True 
lb_hc.update_health_status()

print("\n--- 서버 복구 후 로드 밸런싱 시뮬레이션 재개 ---")
for i in range(11, 16):
    print(f"요청 {i}:", end=" ")
    server = lb_hc.get_next_healthy_server()
    if server:
        # print(f"  -> 요청이 {server}로 전달되었습니다.")
        pass
    time.sleep(0.1)
print("--- 헬스 체크 기반 로드 밸런싱 시뮬레이션 종료 ---")

실행 결과 예시:

로드 밸런서 초기화. 서버 목록: ['http://192.168.1.10:8000', 'http://192.168.1.11:8000', 'http://192.168.1.12:8000']
초기 서버 상태: {'http://192.168.1.10:8000': True, 'http://192.168.1.11:8000': True, 'http://192.168.1.12:8000': True}

--- 헬스 체크 업데이트 시작 ---
  서버 http://192.168.1.10:8000 상태: Healthy
  서버 http://192.168.1.11:8000 상태: Unhealthy
  서버 http://192.168.1.12:8000 상태: Healthy
--- 헬스 체크 업데이트 완료 ---

--- 헬스 체크 기반 로드 밸런싱 시뮬레이션 시작 ---
요청 1: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 2: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 3: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 4: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 5: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 6: [http://192.168.1.12:8000] 서버가 선택되었습니다.
요청 7: [http://192.168.1.10:8000] 서버가 선택되었습니다.
요청 8: [http://192.168.1.12:8000