이미지 로딩 중...

Load Balancing 베스트 프랙티스 완벽 가이드 - 슬라이드 1/8
A

AI Generated

2025. 11. 5. · 95 Views

Load Balancing 베스트 프랙티스 완벽 가이드

대규모 트래픽을 효율적으로 처리하는 로드 밸런싱의 핵심 개념과 실무 전략을 알아봅니다. 라운드 로빈부터 가중치 기반 분산까지, 실제 서비스에서 바로 적용할 수 있는 베스트 프랙티스를 제공합니다.


목차

  1. 라운드 로빈 로드 밸런싱
  2. 헬스 체크 시스템
  3. 가중치 기반 분산
  4. Least Connection 알고리즘
  5. Session Sticky
  6. Circuit Breaker 패턴
  7. Retry 및 Timeout 전략
  8. Connection Pool 관리

1. 라운드 로빈 로드 밸런싱

시작하며

여러분의 서비스가 갑자기 인기를 얻어 사용자가 10배로 늘어났다고 상상해보세요. 단일 서버로는 감당할 수 없어 서버를 3대로 늘렸는데, 특정 서버만 과부하가 걸리고 나머지는 한가한 상황이 발생합니다.

이런 문제는 트래픽을 균등하게 분산하지 못해서 발생합니다. 한 서버는 CPU 100%로 응답이 느려지는데, 다른 서버는 20%만 사용하며 놀고 있다면 자원 낭비이자 사용자 경험 저하로 이어집니다.

바로 이럴 때 필요한 것이 라운드 로빈 로드 밸런싱입니다. 순차적으로 서버를 돌아가며 요청을 배분하여 모든 서버가 공평하게 일하도록 만듭니다.

개요

간단히 말해서, 라운드 로빈은 요청을 받을 때마다 다음 서버로 순환하며 트래픽을 분산하는 방식입니다. 이 방식이 필요한 이유는 구현이 단순하면서도 효과적이기 때문입니다.

복잡한 알고리즘 없이도 서버들 간의 부하를 균등하게 분배할 수 있습니다. 예를 들어, API 게이트웨이에서 백엔드 서버로 요청을 보낼 때 각 서버가 비슷한 성능을 가지고 있다면 라운드 로빈만으로도 충분합니다.

기존에는 DNS 레벨에서만 라운드 로빈을 사용했다면, 이제는 애플리케이션 레벨에서 직접 구현하여 더 세밀한 제어가 가능합니다. 핵심 특징은 첫째, 구현이 매우 간단하고 둘째, 예측 가능한 패턴으로 디버깅이 쉬우며 셋째, 서버 간 성능이 비슷할 때 최적의 효율을 보인다는 점입니다.

이러한 특징들이 마이크로서비스 아키텍처에서 서비스 간 통신에 매우 적합합니다.

코드 예제

// 라운드 로빈 로드 밸런서 구현
class RoundRobinLoadBalancer {
  private servers: string[];
  private currentIndex: number = 0;

  constructor(servers: string[]) {
    this.servers = servers;
  }

  // 다음 서버를 순환하며 선택
  getNextServer(): string {
    const server = this.servers[this.currentIndex];
    // 인덱스를 다음으로 이동 (마지막이면 0으로)
    this.currentIndex = (this.currentIndex + 1) % this.servers.length;
    return server;
  }

  // 요청 분산 처리
  async handleRequest(request: any): Promise<any> {
    const targetServer = this.getNextServer();
    console.log(`Routing request to: ${targetServer}`);
    return await fetch(`${targetServer}/api`, {
      method: 'POST',
      body: JSON.stringify(request)
    });
  }
}

// 사용 예시
const lb = new RoundRobinLoadBalancer([
  'http://server1.example.com',
  'http://server2.example.com',
  'http://server3.example.com'
]);

설명

이것이 하는 일: 들어오는 모든 요청을 서버 목록에서 순차적으로 선택하여 균등하게 분배합니다. 첫 번째로, currentIndex 변수가 현재 어느 서버 차례인지 추적합니다.

처음에는 0부터 시작하여 첫 번째 서버를 가리킵니다. 이렇게 상태를 유지함으로써 각 요청마다 다른 서버를 선택할 수 있습니다.

그 다음으로, getNextServer() 메서드가 실행되면서 현재 인덱스의 서버를 가져옵니다. 내부에서는 모듈로 연산 (%)을 사용하여 인덱스가 배열 길이를 넘어가면 자동으로 0으로 되돌립니다.

예를 들어 서버가 3대라면 0→1→2→0→1→2 패턴으로 계속 순환합니다. 마지막으로, handleRequest() 메서드가 실제 요청을 처리하면서 선택된 서버로 HTTP 요청을 전송합니다.

최종적으로 각 서버가 전체 트래픽의 1/N을 처리하게 되어 부하가 균등하게 분산됩니다. 여러분이 이 코드를 사용하면 서버 3대로 초당 300 요청을 처리할 때 각 서버가 100 요청씩 공평하게 받게 됩니다.

특정 서버만 과부하되는 문제를 방지하고, 전체 시스템의 처리량을 최대화하며, 서버 자원을 효율적으로 활용할 수 있습니다.

실전 팁

💡 서버 목록은 불변(immutable)으로 관리하고, 서버 추가/제거 시 새 인스턴스를 생성하세요. 런타임 중 배열이 변경되면 인덱스 오류가 발생할 수 있습니다.

💡 currentIndex를 여러 스레드가 동시에 수정하면 경쟁 조건(race condition)이 발생합니다. 멀티스레드 환경에서는 atomic 연산이나 락을 사용하세요.

💡 서버 성능이 다르다면 라운드 로빈은 비효율적입니다. 고성능 서버와 저성능 서버가 같은 요청 수를 받으면 저성능 서버가 병목이 됩니다.

💡 DNS 라운드 로빈과 달리 애플리케이션 레벨 라운드 로빈은 즉시 서버를 교체할 수 있습니다. DNS 캐싱으로 인한 지연이 없어 빠른 장애 대응이 가능합니다.

💡 로그에 어느 서버로 라우팅했는지 기록하세요. 특정 서버에서 문제가 발생했을 때 빠르게 원인을 파악할 수 있습니다.


2. 헬스 체크 시스템

시작하며

여러분이 완벽한 로드 밸런서를 구축했지만, 한 서버가 장애로 다운되었는데도 계속 트래픽을 보내고 있다면 어떻게 될까요? 사용자는 3번 중 1번꼼 요청이 실패하는 끔찍한 경험을 하게 됩니다.

이런 문제는 실제로 AWS, Azure 같은 클라우드 환경에서 자주 발생합니다. 서버가 메모리 부족, 네트워크 장애, 애플리케이션 크래시 등으로 응답하지 못하는데, 로드 밸런서는 이를 모르고 계속 요청을 보냅니다.

바로 이럴 때 필요한 것이 헬스 체크 시스템입니다. 주기적으로 서버의 상태를 확인하여 정상 서버에만 트래픽을 보냅니다.

개요

간단히 말해서, 헬스 체크는 일정 간격으로 각 서버에 ping을 보내 응답하는 서버만 활성화하는 메커니즘입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 서버는 언제든지 실패할 수 있기 때문입니다.

하드웨어 장애, 소프트웨어 버그, 네트워크 문제 등 수많은 이유로 서버가 다운됩니다. 예를 들어, 쿠버네티스 환경에서 Pod가 재시작되는 동안 요청을 받으면 안 되는데, 헬스 체크 없이는 이를 감지할 수 없습니다.

기존에는 서버 장애를 수동으로 모니터링하고 관리자가 직접 서버를 제거했다면, 이제는 자동으로 불량 서버를 감지하고 격리할 수 있습니다. 핵심 특징은 첫째, 지속적인 모니터링으로 실시간 장애 감지가 가능하고 둘째, 자동 복구 메커니즘으로 사람의 개입 없이 시스템이 회복되며 셋째, 다운타임을 최소화하여 사용자 경험을 보호합니다.

이러한 특징들이 24/7 무중단 서비스 운영에 필수적입니다.

코드 예제

// 헬스 체크 기능이 포함된 로드 밸런서
class HealthCheckLoadBalancer {
  private servers: Array<{ url: string; healthy: boolean }>;
  private checkInterval: number = 5000; // 5초마다 체크

  constructor(serverUrls: string[]) {
    this.servers = serverUrls.map(url => ({ url, healthy: true }));
    this.startHealthCheck();
  }

  // 헬스 체크 시작
  private startHealthCheck(): void {
    setInterval(async () => {
      for (const server of this.servers) {
        try {
          // 각 서버에 헬스 체크 요청
          const response = await fetch(`${server.url}/health`, {
            signal: AbortSignal.timeout(3000) // 3초 타임아웃
          });
          server.healthy = response.ok;
          console.log(`${server.url}: ${server.healthy ? 'UP' : 'DOWN'}`);
        } catch (error) {
          // 타임아웃이나 네트워크 에러 시 비정상으로 표시
          server.healthy = false;
          console.error(`${server.url}: DOWN - ${error}`);
        }
      }
    }, this.checkInterval);
  }

  // 정상 서버만 반환
  getHealthyServers(): string[] {
    return this.servers
      .filter(s => s.healthy)
      .map(s => s.url);
  }

  // 정상 서버 중에서 라운드 로빈으로 선택
  getNextServer(): string | null {
    const healthy = this.getHealthyServers();
    if (healthy.length === 0) {
      throw new Error('No healthy servers available');
    }
    return healthy[Math.floor(Math.random() * healthy.length)];
  }
}

설명

이것이 하는 일: 주기적으로 모든 서버의 상태를 확인하고, 정상 응답하는 서버에만 트래픽을 라우팅합니다. 첫 번째로, startHealthCheck() 메서드가 setInterval을 사용하여 5초마다 모든 서버를 검사합니다.

각 서버의 /health 엔드포인트로 HTTP GET 요청을 보내는데, 이는 표준적인 헬스 체크 패턴입니다. 왜 이렇게 하는지 설명하자면, 주기적인 폴링이 가장 단순하고 신뢰할 수 있는 방법이기 때문입니다.

그 다음으로, AbortSignal.timeout(3000)으로 3초 타임아웃을 설정합니다. 내부에서는 서버가 3초 내에 응답하지 않으면 자동으로 요청을 중단하고 해당 서버를 비정상으로 표시합니다.

이는 느린 서버가 전체 시스템을 지연시키는 것을 방지합니다. 마지막으로, getHealthyServers() 메서드가 healthy 플래그가 true인 서버만 필터링하여 최종적으로 정상 서버 풀을 만듭니다.

getNextServer()는 이 풀에서만 서버를 선택하므로, 장애 서버로 트래픽이 가는 것을 완전히 차단합니다. 여러분이 이 코드를 사용하면 서버 3대 중 1대가 다운되어도 자동으로 나머지 2대로만 트래픽을 보냅니다.

장애 서버가 복구되면 5초 이내에 자동으로 다시 풀에 추가되어, 수동 개입 없이 시스템이 스스로 회복합니다. 이를 통해 99.9% 이상의 가용성을 달성할 수 있습니다.

실전 팁

💡 헬스 체크 간격은 너무 짧으면 서버에 부담을 주고, 너무 길면 장애 감지가 늦어집니다. 일반적으로 5~10초가 적절하며, 트래픽에 따라 조정하세요.

💡 /health 엔드포인트는 단순히 200 OK만 반환하지 말고, 데이터베이스 연결, 메모리 사용률 등 실제 서비스 가능 여부를 확인하세요. 서버는 살아있지만 DB에 연결 못하면 무용지물입니다.

💡 연속 실패 횟수를 카운트하여 일시적인 네트워크 오류와 실제 장애를 구분하세요. 예를 들어 3번 연속 실패 시에만 서버를 제외하면 false positive를 줄일 수 있습니다.

💡 헬스 체크 타임아웃은 실제 요청 타임아웃보다 짧게 설정하세요. 헬스 체크에 5초 걸리는 서버라면 실제 요청은 더 오래 걸릴 가능성이 높습니다.

💡 모든 서버가 다운되었을 때의 fallback 전략을 준비하세요. 에러 페이지를 보여주거나, 캐시된 데이터를 제공하거나, 백업 데이터센터로 전환하는 등의 계획이 필요합니다.


3. 가중치 기반 분산

시작하며

여러분의 인프라에 성능이 서로 다른 서버들이 섞여 있다고 가정해보세요. 최신 32코어 서버와 구형 4코어 서버가 같은 수의 요청을 받는다면, 구형 서버는 과부하되고 신형 서버는 여유가 넘칩니다.

이런 문제는 클라우드 환경에서 흔히 발생합니다. 스팟 인스턴스를 사용하거나, 온프레미스와 클라우드를 혼용하거나, 점진적으로 서버를 업그레이드하는 과정에서 서버 스펙이 불균등해집니다.

라운드 로빈으로는 이를 해결할 수 없습니다. 바로 이럴 때 필요한 것이 가중치 기반 분산입니다.

각 서버의 성능에 비례하여 트래픽을 분배하여 모든 서버의 활용률을 최적화합니다.

개요

간단히 말해서, 가중치 기반 분산은 각 서버에 weight 값을 부여하고, 그 비율에 따라 트래픽을 분배하는 방식입니다. 이 방식이 필요한 이유는 현실에서 서버는 절대 동일하지 않기 때문입니다.

CPU, 메모리, 네트워크 대역폭이 모두 다르고, 심지어 같은 스펙이라도 실행 중인 다른 프로세스에 따라 가용 자원이 달라집니다. 예를 들어, c5.xlarge 인스턴스와 c5.4xlarge 인스턴스를 함께 사용한다면, 4xlarge에 4배 많은 트래픽을 보내는 것이 합리적입니다.

기존에는 모든 서버를 동일하게 취급하여 자원 낭비가 발생했다면, 이제는 각 서버의 능력에 맞게 정확히 부하를 분배할 수 있습니다. 핵심 특징은 첫째, 서버별 처리 능력을 반영한 효율적인 분산이 가능하고 둘째, 동적으로 가중치를 조정하여 실시간 상황에 대응할 수 있으며 셋째, 전체 시스템의 처리량을 극대화합니다.

이러한 특징들이 비용 효율적인 인프라 운영을 가능하게 합니다.

코드 예제

// 가중치 기반 로드 밸런서
class WeightedLoadBalancer {
  private servers: Array<{ url: string; weight: number }>;
  private totalWeight: number;

  constructor(servers: Array<{ url: string; weight: number }>) {
    this.servers = servers;
    // 전체 가중치 합계 계산
    this.totalWeight = servers.reduce((sum, s) => sum + s.weight, 0);
  }

  // 가중치에 따라 서버 선택
  getNextServer(): string {
    // 0부터 전체 가중치 합계 사이의 랜덤 값 생성
    let random = Math.random() * this.totalWeight;

    // 가중치를 누적하며 해당 구간의 서버 찾기
    for (const server of this.servers) {
      random -= server.weight;
      if (random <= 0) {
        return server.url;
      }
    }

    // 혹시 모를 경우 마지막 서버 반환
    return this.servers[this.servers.length - 1].url;
  }

  // 실시간으로 서버 가중치 조정
  updateWeight(url: string, newWeight: number): void {
    const server = this.servers.find(s => s.url === url);
    if (server) {
      this.totalWeight = this.totalWeight - server.weight + newWeight;
      server.weight = newWeight;
      console.log(`Updated ${url} weight to ${newWeight}`);
    }
  }
}

// 사용 예시
const lb = new WeightedLoadBalancer([
  { url: 'http://high-spec-server', weight: 4 },  // 40% 트래픽
  { url: 'http://mid-spec-server', weight: 3 },   // 30% 트래픽
  { url: 'http://low-spec-server', weight: 3 }    // 30% 트래픽
]);

설명

이것이 하는 일: 서버별 성능에 비례하여 트래픽을 분배하여 전체 시스템의 효율을 최적화합니다. 첫 번째로, 생성자에서 모든 서버의 가중치를 합산하여 totalWeight를 계산합니다.

위 예시에서는 4+3+3=10이 됩니다. 이 값이 전체 트래픽의 100%를 나타내는 기준이 됩니다.

그 다음으로, getNextServer()가 실행되면서 0부터 totalWeight 사이의 랜덤 값을 생성합니다. 내부에서는 각 서버의 가중치를 순차적으로 빼가며 random 값이 0 이하가 되는 지점의 서버를 선택합니다.

예를 들어 random이 3.5라면, 첫 번째 서버(weight=4)에서 3.5를 빼면 0.5가 되어 음수가 되므로 첫 번째 서버가 선택됩니다. 이 알고리즘은 가중치 비율을 정확히 반영합니다.

마지막으로, updateWeight() 메서드를 통해 런타임에 가중치를 동적으로 조정할 수 있습니다. 최종적으로 서버 성능 변화나 트래픽 패턴에 따라 실시간으로 분산 비율을 조정하여 항상 최적 상태를 유지합니다.

여러분이 이 코드를 사용하면 고성능 서버에 40%, 중간 서버에 30%씩 정확히 트래픽을 분배할 수 있습니다. 예를 들어 초당 1000 요청이 들어오면 고성능 서버가 400개, 나머지가 각각 300개씩 처리하여 모든 서버의 CPU 사용률을 비슷한 수준으로 맞출 수 있습니다.

또한 특정 서버에 문제가 생기면 가중치를 0으로 설정하여 즉시 트래픽을 차단할 수 있습니다.

실전 팁

💡 가중치는 CPU 코어 수, 메모리 크기, 네트워크 대역폭을 종합적으로 고려하여 설정하세요. 단순히 인스턴스 타입만 보지 말고 실제 부하 테스트로 검증하세요.

💡 모니터링 도구로 각 서버의 CPU, 메모리 사용률을 추적하고, 불균형이 발생하면 가중치를 조정하세요. Prometheus + Grafana로 실시간 대시보드를 구축하면 편리합니다.

💡 가중치를 너무 자주 변경하면 트래픽 패턴이 불안정해집니다. 최소 5분 이상의 관찰 기간을 두고 조정하세요.

💡 Blue-Green 배포 시 가중치를 활용하세요. 새 버전을 10% 가중치로 시작해서 문제없으면 점진적으로 100%까지 올리는 카나리 배포가 가능합니다.

💡 가중치 0은 서버를 완전히 제외하는 것과 같습니다. 긴급 점검이나 배포 중인 서버는 가중치를 0으로 설정하여 트래픽을 안전하게 차단하세요.


4. Least Connection 알고리즘

시작하며

여러분의 서비스에서 일부 요청은 0.1초만에 끝나지만, 어떤 요청은 10초 이상 걸린다고 상상해보세요. 라운드 로빈으로 분산하면 한 서버는 긴 요청 10개를 처리하느라 과부하되고, 다른 서버는 짧은 요청만 받아 한가한 상황이 발생합니다.

이런 문제는 API에서 매우 흔합니다. 데이터 조회는 빠르지만 복잡한 리포트 생성은 느리고, 이미지 업로드는 순식간이지만 동영상 인코딩은 오래 걸립니다.

요청 수만 세는 방식으로는 실제 부하를 정확히 파악할 수 없습니다. 바로 이럴 때 필요한 것이 Least Connection 알고리즘입니다.

현재 처리 중인 연결 수가 가장 적은 서버로 새 요청을 보내 실제 부하를 균등화합니다.

개요

간단히 말해서, Least Connection은 각 서버의 활성 연결 수를 추적하여, 연결이 가장 적은 서버에 새 요청을 할당하는 방식입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 요청의 처리 시간이 불균등할 때 라운드 로빈은 비효율적이기 때문입니다.

WebSocket 연결처럼 장시간 유지되는 연결이나, 데이터베이스 쿼리처럼 시간이 가변적인 작업에서 특히 유용합니다. 예를 들어, 채팅 서버에서 한 서버에 1000개 연결이 있고 다른 서버에 100개만 있다면, 당연히 100개인 서버로 새 연결을 보내야 합니다.

기존에는 요청 수만 카운트하여 긴 요청과 짧은 요청을 구분하지 못했다면, 이제는 실제 활성 연결 수를 기반으로 더 정확한 부하 분산이 가능합니다. 핵심 특징은 첫째, 실시간 서버 상태를 반영한 동적 분산이 가능하고 둘째, 장시간 연결에서 특히 효과적이며 셋째, 요청 처리 시간이 불균등할 때 라운드 로빈보다 우수합니다.

이러한 특징들이 WebSocket, gRPC, 데이터베이스 커넥션 풀 등에서 필수적입니다.

코드 예제

// Least Connection 로드 밸런서
class LeastConnectionLoadBalancer {
  private servers: Map<string, number>;

  constructor(serverUrls: string[]) {
    // 각 서버의 활성 연결 수를 0으로 초기화
    this.servers = new Map(serverUrls.map(url => [url, 0]));
  }

  // 연결 수가 가장 적은 서버 선택
  getNextServer(): string {
    let minConnections = Infinity;
    let selectedServer = '';

    // 모든 서버를 순회하며 최소 연결 수 찾기
    for (const [url, connections] of this.servers.entries()) {
      if (connections < minConnections) {
        minConnections = connections;
        selectedServer = url;
      }
    }

    // 선택된 서버의 연결 수 증가
    this.servers.set(selectedServer, minConnections + 1);
    return selectedServer;
  }

  // 요청 완료 시 연결 수 감소
  releaseConnection(serverUrl: string): void {
    const currentConnections = this.servers.get(serverUrl) || 0;
    if (currentConnections > 0) {
      this.servers.set(serverUrl, currentConnections - 1);
    }
  }

  // 실제 요청 처리 예시
  async handleRequest(request: any): Promise<any> {
    const server = this.getNextServer();

    try {
      console.log(`Routing to ${server} (active connections: ${this.servers.get(server)})`);
      const response = await fetch(`${server}/api`, {
        method: 'POST',
        body: JSON.stringify(request)
      });
      return response;
    } finally {
      // 요청 완료 후 반드시 연결 해제
      this.releaseConnection(server);
    }
  }

  // 현재 상태 조회
  getStatus(): Record<string, number> {
    return Object.fromEntries(this.servers);
  }
}

설명

이것이 하는 일: 현재 처리 중인 연결 수를 기반으로 가장 여유로운 서버에 새 요청을 할당합니다. 첫 번째로, Map 자료구조를 사용하여 각 서버의 활성 연결 수를 실시간으로 추적합니다.

요청이 시작되면 카운트가 증가하고, 완료되면 감소합니다. 이렇게 상태를 유지함으로써 각 서버의 현재 부하를 정확히 알 수 있습니다.

그 다음으로, getNextServer()가 실행되면서 모든 서버를 순회하며 최소 연결 수를 가진 서버를 찾습니다. 내부에서는 단순 비교 연산으로 O(n) 시간에 최적 서버를 선택합니다.

선택과 동시에 연결 수를 1 증가시켜, 다음 요청이 다른 서버를 고려하도록 합니다. 마지막으로, finally 블록에서 releaseConnection()을 호출하여 요청 성공/실패 여부와 관계없이 반드시 연결 수를 감소시킵니다.

최종적으로 에러가 발생하더라도 카운트가 정확하게 유지되어, 메모리 누수나 잘못된 라우팅을 방지합니다. 여러분이 이 코드를 사용하면 서버 A에 10개 요청이 처리 중이고 서버 B에 3개만 처리 중이라면, 자동으로 서버 B로 새 요청을 보냅니다.

특히 WebSocket처럼 연결이 몇 분 이상 유지되는 경우, 시간이 지날수록 서버 간 부하가 크게 차이날 수 있는데 이를 자동으로 균형잡아줍니다. 결과적으로 모든 서버의 평균 응답 시간이 개선됩니다.

실전 팁

💡 연결 수만으로는 실제 부하를 완벽히 반영하지 못합니다. CPU 사용률이나 메모리 사용량을 함께 고려하는 "Least Loaded" 알고리즘으로 발전시킬 수 있습니다.

💡 finally 블록을 반드시 사용하여 예외 발생 시에도 연결 수를 감소시키세요. 그렇지 않으면 에러가 많은 서버의 카운트가 계속 증가하여 더 이상 요청을 받지 못하게 됩니다.

💡 멀티스레드 환경에서는 연결 수 증가/감소가 atomic 해야 합니다. Node.js는 싱글 스레드라 문제없지만, Java/Go에서는 synchronized나 atomic 변수를 사용하세요.

💡 서버 수가 많으면(100개 이상) 매번 전체 순회는 비효율적입니다. 최소 힙(min-heap) 자료구조를 사용하면 O(log n)으로 개선할 수 있습니다.

💡 연결 수가 음수가 되지 않도록 방어 코드를 추가하세요. 버그나 예상치 못한 상황으로 releaseConnection이 두 번 호출될 수 있습니다.


5. Session Sticky

시작하며

여러분의 로그인 시스템에서 사용자가 서버 A에서 로그인했는데, 다음 요청이 서버 B로 가서 "로그인되지 않음" 에러가 발생한다면 어떻게 될까요? 사용자는 계속 로그인 페이지로 리다이렉트되는 악순환을 겪게 됩니다.

이런 문제는 세션을 메모리에 저장하는 전통적인 웹 애플리케이션에서 흔히 발생합니다. 서버 A의 메모리에는 세션이 있지만, 서버 B는 이를 모르기 때문입니다.

로드 밸런서가 요청마다 다른 서버로 보내면 상태를 유지할 수 없습니다. 바로 이럴 때 필요한 것이 Session Sticky(Sticky Session 또는 Session Affinity)입니다.

특정 사용자의 모든 요청을 항상 같은 서버로 라우팅하여 세션 상태를 유지합니다.

개요

간단히 말해서, Session Sticky는 사용자를 식별하는 키(쿠키, IP 등)를 기반으로 항상 동일한 서버로 요청을 보내는 메커니즘입니다. 이 방식이 필요한 이유는 모든 애플리케이션이 stateless하지 않기 때문입니다.

Redis 같은 외부 세션 저장소를 사용하면 이상적이지만, 레거시 시스템이나 빠른 프로토타이핑 단계에서는 메모리 세션을 사용하는 경우가 많습니다. 예를 들어, 장바구니 데이터를 서버 메모리에 임시 저장하는 경우, 사용자가 다른 서버로 이동하면 장바구니가 초기화됩니다.

기존에는 모든 세션을 공유 스토리지에 저장해야 했다면, 이제는 각 서버가 자체 메모리를 사용하면서도 사용자 경험을 유지할 수 있습니다. 핵심 특징은 첫째, 세션 상태를 서버 로컬에 안전하게 보관할 수 있고 둘째, 구현이 간단하여 레거시 시스템 개선이 쉬우며 셋째, 외부 의존성 없이 빠른 세션 접근이 가능합니다.

이러한 특징들이 점진적인 마이크로서비스 전환 과정에서 유용합니다.

코드 예제

// Session Sticky 로드 밸런서
class StickySessionLoadBalancer {
  private servers: string[];
  private sessionMap: Map<string, string>; // sessionId -> serverUrl
  private currentIndex: number = 0;

  constructor(serverUrls: string[]) {
    this.servers = serverUrls;
    this.sessionMap = new Map();
  }

  // 세션 ID로부터 서버 할당 또는 조회
  getServerForSession(sessionId: string): string {
    // 이미 할당된 서버가 있으면 재사용
    if (this.sessionMap.has(sessionId)) {
      const server = this.sessionMap.get(sessionId)!;
      console.log(`Existing session ${sessionId} -> ${server}`);
      return server;
    }

    // 새 세션이면 라운드 로빈으로 서버 할당
    const server = this.servers[this.currentIndex];
    this.currentIndex = (this.currentIndex + 1) % this.servers.length;

    // 세션과 서버 매핑 저장
    this.sessionMap.set(sessionId, server);
    console.log(`New session ${sessionId} -> ${server}`);
    return server;
  }

  // 쿠키에서 세션 ID 추출하여 라우팅
  async handleRequest(request: Request): Promise<any> {
    // 쿠키에서 세션 ID 추출
    const cookies = request.headers.get('cookie') || '';
    const sessionMatch = cookies.match(/session_id=([^;]+)/);
    const sessionId = sessionMatch ? sessionMatch[1] : `session_${Date.now()}`;

    // 세션에 맞는 서버로 라우팅
    const targetServer = this.getServerForSession(sessionId);

    return await fetch(`${targetServer}/api`, {
      method: request.method,
      headers: request.headers,
      body: request.body
    });
  }

  // 세션 만료 시 매핑 제거
  removeSession(sessionId: string): void {
    this.sessionMap.delete(sessionId);
    console.log(`Session ${sessionId} removed`);
  }

  // 서버 다운 시 해당 서버의 모든 세션 재할당
  rebalanceSessions(downServerUrl: string): void {
    for (const [sessionId, serverUrl] of this.sessionMap.entries()) {
      if (serverUrl === downServerUrl) {
        this.sessionMap.delete(sessionId);
      }
    }
  }
}

설명

이것이 하는 일: 사용자별로 고정된 서버를 할당하여 세션 상태가 유지되도록 보장합니다. 첫 번째로, sessionMap이라는 Map 자료구조가 세션 ID와 서버 URL의 매핑을 영구적으로 저장합니다.

한번 생성된 매핑은 세션이 만료될 때까지 계속 유지되어, 동일 사용자의 모든 요청이 같은 서버로 향합니다. 그 다음으로, getServerForSession()이 실행되면서 먼저 기존 매핑을 확인합니다.

내부에서는 Map의 has() 메서드로 O(1) 시간에 매핑 존재 여부를 확인하고, 있으면 즉시 반환하여 불필요한 연산을 피합니다. 없으면 라운드 로빈으로 새 서버를 할당하고 매핑을 저장합니다.

마지막으로, handleRequest()가 HTTP 쿠키에서 session_id를 파싱하여 세션을 식별합니다. 최종적으로 사용자가 로그인 후 10번의 요청을 보내도, 모두 같은 서버로 가서 세션 데이터에 일관되게 접근할 수 있습니다.

여러분이 이 코드를 사용하면 사용자가 서버 A에서 로그인했을 때, 이후 모든 API 요청이 서버 A로만 가서 로그인 상태가 계속 유지됩니다. 장바구니 추가, 프로필 조회, 결제 등 모든 작업이 동일 서버에서 처리되어 세션 손실 문제가 완전히 해결됩니다.

단, 서버가 다운되면 해당 서버의 세션은 모두 손실되므로, rebalanceSessions()로 다른 서버로 재할당할 수 있습니다.

실전 팁

💡 세션 만료 시간(TTL)을 설정하여 sessionMap이 무한정 커지는 것을 방지하세요. setInterval로 주기적으로 오래된 세션을 정리하거나, LRU 캐시를 사용하세요.

💡 서버가 다운되면 해당 서버의 모든 세션이 손실됩니다. 중요한 서비스라면 Redis 같은 공유 세션 저장소로 마이그레이션하는 것이 장기적으로 더 안전합니다.

💡 쿠키 대신 클라이언트 IP를 사용할 수도 있지만, NAT 환경에서는 여러 사용자가 같은 IP를 공유하여 문제가 됩니다. 쿠키가 더 신뢰할 수 있습니다.

💡 Consistent Hashing을 사용하면 서버 추가/제거 시 최소한의 세션만 재할당됩니다. 단순 해싱보다 확장성이 좋습니다.

💡 로드 밸런서 레벨에서 Sticky Session을 구현하는 것도 가능합니다. Nginx의 ip_hash, AWS ELB의 sticky session 기능을 활용하면 애플리케이션 코드 수정 없이 적용할 수 있습니다.


6. Circuit Breaker 패턴

시작하며

여러분의 서비스가 외부 API를 호출하는데, 그 API가 다운되어 모든 요청이 30초 타임아웃까지 기다린다고 상상해보세요. 초당 100 요청이 들어온다면, 수천 개의 요청이 쌓이며 전체 시스템이 먹통이 됩니다.

이런 문제는 마이크로서비스 아키텍처에서 매우 위험합니다. 한 서비스의 장애가 연쇄적으로 다른 서비스들까지 다운시키는 "캐스케이딩 실패"가 발생합니다.

예를 들어, 결제 서비스가 느려지면 주문 서비스도 느려지고, 결국 전체 쇼핑몰이 마비됩니다. 바로 이럴 때 필요한 것이 Circuit Breaker 패턴입니다.

장애가 감지되면 즉시 요청을 차단하여 시스템을 보호하고, 복구되면 자동으로 재개합니다.

개요

간단히 말해서, Circuit Breaker는 전기 회로 차단기처럼 장애를 감지하면 "열림" 상태로 전환하여 요청을 즉시 실패시키는 보호 메커니즘입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 이미 실패하는 것을 알면서 계속 시도하는 것은 자원 낭비이기 때문입니다.

타임아웃을 기다리는 동안 스레드, 메모리, 네트워크 연결이 모두 묶여 있어 전체 시스템 성능이 급격히 저하됩니다. 예를 들어, 데이터베이스가 다운되었다면 연결 시도를 반복하는 것보다 빠르게 실패하고 캐시된 데이터를 제공하는 것이 낫습니다.

기존에는 장애 상황에서도 무한정 재시도하여 시스템 전체가 마비되었다면, 이제는 빠른 실패(fail-fast)로 일부 기능만 제한하고 나머지는 정상 동작하도록 할 수 있습니다. 핵심 특징은 첫째, 장애 전파를 차단하여 시스템 전체 안정성을 보호하고 둘째, 자동 복구 메커니즘으로 사람의 개입 없이 정상화되며 셋째, Fallback 응답으로 부분적인 서비스 제공이 가능합니다.

이러한 특징들이 고가용성 시스템의 필수 요소입니다.

코드 예제

// Circuit Breaker 패턴 구현
enum CircuitState {
  CLOSED,   // 정상 상태
  OPEN,     // 차단 상태
  HALF_OPEN // 복구 시도 상태
}

class CircuitBreaker {
  private state: CircuitState = CircuitState.CLOSED;
  private failureCount: number = 0;
  private lastFailureTime: number = 0;
  private readonly failureThreshold: number = 5;     // 5번 실패 시 차단
  private readonly timeout: number = 60000;          // 60초 후 복구 시도
  private readonly resetTimeout: number = 30000;     // 30초 후 카운터 리셋

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    // OPEN 상태면 즉시 실패
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        // 타임아웃 경과 시 HALF_OPEN으로 전환
        this.state = CircuitState.HALF_OPEN;
        console.log('Circuit HALF_OPEN - trying recovery');
      } else {
        throw new Error('Circuit breaker is OPEN - request blocked');
      }
    }

    try {
      // 실제 작업 실행
      const result = await operation();

      // 성공 시 회로 복구
      this.onSuccess();
      return result;
    } catch (error) {
      // 실패 시 회로 차단 고려
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;
    if (this.state === CircuitState.HALF_OPEN) {
      this.state = CircuitState.CLOSED;
      console.log('Circuit CLOSED - recovery successful');
    }
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = CircuitState.OPEN;
      console.log(`Circuit OPEN - ${this.failureCount} failures detected`);
    }
  }

  getState(): CircuitState {
    return this.state;
  }
}

// 사용 예시
const breaker = new CircuitBreaker();

async function callExternalAPI() {
  return await breaker.execute(async () => {
    const response = await fetch('https://external-api.com/data');
    if (!response.ok) throw new Error('API error');
    return response.json();
  });
}

설명

이것이 하는 일: 장애가 발생한 서비스로의 요청을 자동으로 차단하여 시스템 자원을 보호하고, 복구 시 자동으로 재개합니다. 첫 번째로, 세 가지 상태를 정의합니다.

CLOSED는 정상 동작, OPEN은 완전 차단, HALF_OPEN은 복구 테스트 상태입니다. 이 상태 머신이 Circuit Breaker의 핵심이며, 상태 전환 로직이 장애 대응을 자동화합니다.

그 다음으로, execute() 메서드가 실행되면서 먼저 현재 상태를 확인합니다. 내부에서는 OPEN 상태라면 즉시 예외를 던져 실제 작업을 시도하지 않습니다.

이로써 이미 실패할 것이 확실한 요청에 자원을 낭비하지 않습니다. 60초가 경과했다면 HALF_OPEN으로 전환하여 한 번의 시도를 허용합니다.

마지막으로, onSuccess()와 onFailure()가 결과에 따라 상태를 변경합니다. 최종적으로 5번 연속 실패하면 OPEN으로 차단되고, HALF_OPEN 상태에서 성공하면 CLOSED로 완전 복구됩니다.

여러분이 이 코드를 사용하면 외부 API가 다운되었을 때, 처음 5번의 실패 후에는 모든 요청이 즉시 실패하여 타임아웃 대기 시간을 없앱니다. 예를 들어 타임아웃이 30초라면, Circuit Breaker 없이는 100 요청이 3000초(50분)를 소비하지만, Circuit Breaker로는 첫 5번만 실패하고 나머지는 즉시 실패하여 150초만 소비합니다.

이는 시스템 자원을 95% 절약하는 효과입니다.

실전 팁

💡 실패 임계값과 타임아웃은 서비스 특성에 맞게 조정하세요. 중요한 결제 서비스는 임계값을 높게(10회), 부가 기능은 낮게(3회) 설정할 수 있습니다.

💡 OPEN 상태에서 모든 요청을 실패시키지 말고, Fallback 응답을 제공하세요. 예를 들어 추천 API가 다운되면 인기 상품 목록을 보여주는 식입니다.

💡 실패 카운트를 시간 윈도우 기반으로 관리하세요. "최근 1분간 10번 중 5번 실패" 같은 방식이 더 유연합니다. 단순 카운트는 첫 5번 실패 후 오래 기다린 요청도 차단합니다.

💡 모니터링 도구로 Circuit Breaker 상태를 추적하세요. Grafana에서 OPEN 상태 알림을 받으면 빠르게 대응할 수 있습니다.

💡 여러 서버로 분산된 환경에서는 각 서버가 독립적인 Circuit Breaker를 가집니다. Redis를 사용한 공유 Circuit Breaker를 구현하면 전체 시스템 차원의 보호가 가능합니다.


7. Retry 및 Timeout 전략

시작하며

여러분의 서비스가 네트워크 지연으로 요청이 한번 실패했는데, 재시도 없이 사용자에게 에러를 보여준다면 어떻게 될까요? 일시적인 장애였다면 한번 더 시도했으면 성공했을 요청을 놓치게 됩니다.

이런 문제는 분산 시스템에서 매우 흔합니다. 네트워크는 본질적으로 불안정하여 패킷 손실, 일시적 연결 끊김, DNS 조회 실패 등이 수시로 발생합니다.

하지만 무한정 재시도하면 장애 상황에서 시스템이 마비됩니다. 바로 이럴 때 필요한 것이 지능적인 Retry 및 Timeout 전략입니다.

일시적 장애는 빠르게 재시도하여 복구하고, 영구적 장애는 빠르게 포기하여 자원을 보호합니다.

개요

간단히 말해서, Retry는 실패한 요청을 자동으로 다시 시도하고, Timeout은 무한정 기다리지 않도록 시간 제한을 두는 메커니즘입니다. 이 방식이 필요한 이유는 첫 번째 시도의 실패가 항상 영구적인 것은 아니기 때문입니다.

통계적으로 네트워크 요청의 약 1-5%는 일시적 장애로 실패하지만, 즉시 재시도하면 95% 이상이 성공합니다. 예를 들어, AWS S3에 파일을 업로드할 때 네트워크 혼잡으로 실패했다면, 1초 후 재시도하면 대부분 성공합니다.

기존에는 단순히 한번 실패하면 포기하거나, 무한정 재시도하여 시스템을 마비시켰다면, 이제는 Exponential Backoff와 Jitter를 사용하여 지능적으로 재시도할 수 있습니다. 핵심 특징은 첫째, 일시적 장애에 대한 자동 복구로 성공률을 높이고 둘째, Exponential Backoff로 서버에 부담을 주지 않으며 셋째, 최대 재시도 횟수와 타임아웃으로 무한 대기를 방지합니다.

이러한 특징들이 안정적인 API 통신의 기본입니다.

코드 예제

// Retry와 Timeout을 포함한 요청 래퍼
class RetryHandler {
  private maxRetries: number = 3;
  private baseDelay: number = 1000; // 1초
  private maxDelay: number = 10000; // 10초
  private timeout: number = 5000;   // 5초

  async executeWithRetry<T>(
    operation: () => Promise<T>,
    retryableErrors: string[] = ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND']
  ): Promise<T> {
    let lastError: Error;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        // 타임아웃을 적용한 요청 실행
        const result = await this.withTimeout(operation, this.timeout);

        if (attempt > 0) {
          console.log(`Success on retry attempt ${attempt}`);
        }
        return result;
      } catch (error: any) {
        lastError = error;

        // 재시도 가능한 에러인지 확인
        const isRetryable = retryableErrors.some(code =>
          error.code === code || error.message.includes(code)
        );

        // 마지막 시도이거나 재시도 불가능한 에러면 중단
        if (attempt === this.maxRetries || !isRetryable) {
          console.error(`Failed after ${attempt + 1} attempts:`, error.message);
          throw error;
        }

        // Exponential Backoff with Jitter 계산
        const exponentialDelay = Math.min(
          this.baseDelay * Math.pow(2, attempt),
          this.maxDelay
        );
        const jitter = Math.random() * 0.3 * exponentialDelay; // 30% jitter
        const delay = exponentialDelay + jitter;

        console.log(`Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delay)}ms`);
        await this.sleep(delay);
      }
    }

    throw lastError!;
  }

  // 타임아웃 적용
  private async withTimeout<T>(
    operation: () => Promise<T>,
    timeoutMs: number
  ): Promise<T> {
    return Promise.race([
      operation(),
      new Promise<T>((_, reject) =>
        setTimeout(() => reject(new Error('Request timeout')), timeoutMs)
      )
    ]);
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// 사용 예시
const retryHandler = new RetryHandler();

async function fetchData() {
  return await retryHandler.executeWithRetry(async () => {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  });
}

설명

이것이 하는 일: 네트워크 장애나 일시적 에러에 대해 자동으로 재시도하며, 타임아웃으로 응답 없는 서버를 빠르게 포기합니다. 첫 번째로, executeWithRetry()가 for 루프로 최대 3번까지 시도합니다.

각 시도마다 withTimeout()을 통해 5초 타임아웃을 적용하여, 서버가 응답하지 않으면 즉시 다음 시도로 넘어갑니다. 이렇게 함으로써 한 번의 느린 요청이 전체 시스템을 지연시키는 것을 방지합니다.

그 다음으로, 에러가 발생하면 retryableErrors 배열과 비교하여 재시도 가능 여부를 판단합니다. 내부에서는 ETIMEDOUT(타임아웃), ECONNRESET(연결 끊김) 같은 일시적 네트워크 에러만 재시도하고, 404나 500 같은 HTTP 에러는 재시도하지 않습니다.

재시도해도 같은 결과가 나올 에러에 자원을 낭비하지 않습니다. 마지막으로, Exponential Backoff with Jitter를 계산합니다.

최종적으로 첫 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후에 실행되며 여기에 무작위 jitter를 추가합니다. Jitter는 여러 클라이언트가 동시에 재시도하여 서버에 부담을 주는 "thundering herd" 문제를 방지합니다.

여러분이 이 코드를 사용하면 네트워크가 불안정한 환경에서도 성공률을 크게 높일 수 있습니다. 예를 들어 일시적 장애율이 5%인 API라면, 재시도 없이는 95% 성공률이지만, 3번 재시도하면 99.9% 이상으로 향상됩니다.

또한 타임아웃으로 느린 서버가 전체 시스템을 지연시키는 것을 방지하여, p99 latency를 크게 개선할 수 있습니다.

실전 팁

💡 멱등성(idempotency)이 보장되는 작업만 재시도하세요. GET 요청은 안전하지만, POST로 결제 요청을 재시도하면 중복 결제가 발생할 수 있습니다. PUT/DELETE는 대부분 멱등적입니다.

💡 HTTP 상태 코드에 따라 재시도 여부를 결정하세요. 500/502/503/504는 재시도 가능하지만, 400/401/403/404는 재시도해도 같은 결과입니다.

💡 Jitter는 필수입니다. 1000개의 클라이언트가 동시에 실패하면, jitter 없이는 모두 정확히 1초 후 동시에 재시도하여 서버를 다시 다운시킵니다.

💡 재시도 로직을 API 클라이언트 라이브러리에 통합하세요. axios-retry, ky 같은 라이브러리를 사용하면 모든 HTTP 요청에 자동으로 적용됩니다.

💡 재시도 메트릭을 모니터링하세요. 재시도 비율이 10%를 넘으면 근본적인 인프라 문제가 있다는 신호입니다.


8. Connection Pool 관리

시작하며

여러분의 애플리케이션이 데이터베이스에 요청할 때마다 새 연결을 생성한다면, TCP handshake와 인증 과정에 수백 밀리초가 소비됩니다. 초당 1000 요청이라면 대부분의 시간을 연결 생성에만 쓰게 됩니다.

이런 문제는 모든 네트워크 통신에서 발생합니다. HTTP/2를 사용하지 않는 한, 매 요청마다 새 TCP 연결을 만드는 것은 엄청난 오버헤드입니다.

특히 데이터베이스, Redis, 외부 API처럼 자주 호출하는 서비스에서는 성능 병목이 됩니다. 바로 이럴 때 필요한 것이 Connection Pool 관리입니다.

미리 연결을 생성해두고 재사용하여 연결 오버헤드를 제거하고 처리량을 극대화합니다.

개요

간단히 말해서, Connection Pool은 미리 생성한 연결들을 풀에 보관하고, 필요할 때 빌려주고 사용 후 반환받아 재사용하는 메커니즘입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 연결 생성 비용이 매우 크기 때문입니다.

TCP 연결은 3-way handshake, TLS는 추가로 여러 왕복이 필요하고, 데이터베이스는 인증까지 해야 합니다. 이 모든 과정이 10-100ms 걸리는데, 쿼리 자체는 1ms 만에 끝날 수 있습니다.

예를 들어, PostgreSQL 연결 생성에 50ms 걸린다면, 연결 풀 없이는 초당 20 요청밖에 처리 못하지만, 연결 풀로는 1000 요청 이상 처리할 수 있습니다. 기존에는 매번 연결을 생성하고 닫아서 자원 낭비와 성능 저하가 발생했다면, 이제는 연결을 재사용하여 처리량을 10-100배 향상시킬 수 있습니다.

핵심 특징은 첫째, 연결 재사용으로 생성 오버헤드를 제거하고 둘째, 최대 연결 수 제한으로 서버 과부하를 방지하며 셋째, idle 연결 관리로 자원을 효율적으로 사용합니다. 이러한 특징들이 고성능 백엔드 시스템의 핵심입니다.

코드 예제

// Connection Pool 구현
class ConnectionPool<T> {
  private pool: T[] = [];
  private inUse: Set<T> = new Set();
  private readonly minSize: number;
  private readonly maxSize: number;
  private createConnection: () => Promise<T>;
  private destroyConnection: (conn: T) => Promise<void>;

  constructor(options: {
    minSize: number;
    maxSize: number;
    createConnection: () => Promise<T>;
    destroyConnection: (conn: T) => Promise<void>;
  }) {
    this.minSize = options.minSize;
    this.maxSize = options.maxSize;
    this.createConnection = options.createConnection;
    this.destroyConnection = options.destroyConnection;

    // 최소 연결 수만큼 미리 생성
    this.initialize();
  }

  private async initialize(): Promise<void> {
    for (let i = 0; i < this.minSize; i++) {
      const conn = await this.createConnection();
      this.pool.push(conn);
    }
    console.log(`Connection pool initialized with ${this.minSize} connections`);
  }

  // 연결 가져오기
  async acquire(): Promise<T> {
    // 사용 가능한 연결이 있으면 재사용
    if (this.pool.length > 0) {
      const conn = this.pool.pop()!;
      this.inUse.add(conn);
      return conn;
    }

    // 최대 크기 미만이면 새 연결 생성
    if (this.inUse.size < this.maxSize) {
      const conn = await this.createConnection();
      this.inUse.add(conn);
      console.log(`Created new connection (total in use: ${this.inUse.size})`);
      return conn;
    }

    // 풀이 가득 차면 대기 (실제로는 대기 큐 구현 필요)
    throw new Error('Connection pool exhausted');
  }

  // 연결 반환
  async release(conn: T): Promise<void> {
    this.inUse.delete(conn);

    // 최소 크기 이상이면 연결 파괴
    if (this.pool.length >= this.minSize) {
      await this.destroyConnection(conn);
      console.log('Destroyed excess connection');
    } else {
      // 풀에 반환
      this.pool.push(conn);
    }
  }

  // 사용 예시를 위한 헬퍼
  async execute<R>(operation: (conn: T) => Promise<R>): Promise<R> {
    const conn = await this.acquire();
    try {
      return await operation(conn);
    } finally {
      await this.release(conn);
    }
  }

  // 풀 상태 조회
  getStatus() {
    return {
      available: this.pool.length,
      inUse: this.inUse.size,
      total: this.pool.length + this.inUse.size
    };
  }
}

// 사용 예시
const dbPool = new ConnectionPool({
  minSize: 5,
  maxSize: 20,
  createConnection: async () => {
    // 실제로는 DB 연결 생성
    return { id: Math.random(), query: async (sql: string) => {} };
  },
  destroyConnection: async (conn) => {
    // 연결 종료
  }
});

// 쿼리 실행
await dbPool.execute(async (conn) => {
  return await conn.query('SELECT * FROM users');
});

설명

이것이 하는 일: 연결을 미리 생성하여 재사용함으로써 연결 생성 오버헤드를 제거하고 처리 속도를 극대화합니다. 첫 번째로, initialize()가 시작 시 minSize만큼 연결을 미리 생성합니다.

이는 워밍업 과정으로, 첫 요청부터 빠른 응답을 보장합니다. 연결 생성은 비동기 작업이므로 await로 각각을 기다립니다.

그 다음으로, acquire()가 실행되면서 먼저 풀에 사용 가능한 연결이 있는지 확인합니다. 내부에서는 배열의 pop()으로 O(1) 시간에 연결을 가져오고 inUse Set에 추가합니다.

풀이 비어있으면 maxSize까지는 새 연결을 생성하고, 그 이상이면 에러를 던집니다. 실제 프로덕션 코드에서는 대기 큐를 구현하여 연결이 반환될 때까지 기다립니다.

마지막으로, release()가 연결을 inUse에서 제거하고 풀로 반환합니다. 최종적으로 execute() 헬퍼 메서드의 finally 블록이 에러 발생 여부와 무관하게 항상 연결을 반환하여, 연결 누수를 방지합니다.

여러분이 이 코드를 사용하면 데이터베이스 쿼리 성능이 극적으로 향상됩니다. 연결 생성에 50ms 걸리는 환경에서, 풀 없이는 1000 요청 처리에 50초가 걸리지만, 풀을 사용하면 1초 이내에 완료됩니다.

또한 최대 연결 수를 제한하여 데이터베이스 서버가 과부하되는 것을 방지합니다. PostgreSQL의 max_connections가 100이라면, 풀 크기를 20으로 제한하여 안전한 범위에서 운영할 수 있습니다.

실전 팁

💡 최소 크기는 평균 동시 요청 수, 최대 크기는 피크 시간의 동시 요청 수로 설정하세요. 예를 들어 평균 10, 피크 50이라면 minSize=10, maxSize=50이 적절합니다.

💡 Idle 연결 타임아웃을 구현하세요. 10분간 사용되지 않은 연결은 자동으로 닫아 서버 자원을 절약합니다. 대부분의 DB 서버도 idle timeout을 설정하므로 클라이언트도 맞춰야 합니다.

💡 연결 상태를 주기적으로 검증하세요(health check). 네트워크 장애로 이미 끊어진 연결을 풀에서 제공하면 에러가 발생합니다. SELECT 1 같은 간단한 쿼리로 연결이 살아있는지 확인하세요.

💡 풀 크기는 서버 스펙과 애플리케이션 특성에 따라 조정하세요. CPU 코어 수의 2-4배가 일반적인 시작점입니다. 너무 크면 메모리 낭비, 너무 작으면 대기 시간 증가입니다.

💡 Node.js의 경우 pg, mysql2 같은 라이브러리가 이미 연결 풀을 제공합니다. 직접 구현하기보다 검증된 라이브러리를 사용하는 것이 안전합니다.


#NodeJS#LoadBalancing#Nginx#HAProxy#ScalableArchitecture#JavaScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.