Home Eureka 없이 Spring Cloud Gateway에서 로드밸런싱하기
Post
Cancel

Eureka 없이 Spring Cloud Gateway에서 로드밸런싱하기

Spring Cloud Eureka를 도입했다가 제거하고 Gateway에서 직접 VM을 관리하는 방식으로 변경했다. 이유와 설정 방법을 정리한다.


Eureka를 제거한 이유

Eureka는 서비스가 많고 인스턴스가 동적으로 늘어나는 환경에서 진가를 발휘한다. 새 VM이 뜨면 Eureka에 자동 등록되고, Gateway는 그걸 감지해서 자동으로 라우팅 대상에 포함시킨다.

하지만 현재 구조는 다르다.

  • 서비스당 VM이 1~2개로 고정
  • VM이 동적으로 추가되는 일이 거의 없음
  • Eureka 서버 자체도 하나의 운영 포인트가 됨 (Eureka가 죽으면 라우팅 불가)

이 상황에서 Eureka를 유지하는 건 운영 복잡도만 올라가고 실제 이점이 없었다. VM 목록이 고정적이라면 Gateway에서 직접 관리하는 게 더 단순하고 안정적이다.


변경 전/후 구조

변경 전 (Eureka 사용)

1
2
3
4
5
6
7
클라이언트
    ↓
Gateway :8080
    ↓ Eureka에서 인스턴스 조회
Eureka Server :8761
    ↓
data-api VM

변경 후 (SimpleDiscoveryClient 사용)

1
2
3
4
5
클라이언트
    ↓
Gateway :8080
    ↓ yml에 정의된 정적 인스턴스 목록 조회
data-api VM1 / VM2 (round-robin)

Eureka 서버가 없어도 lb:// 로드밸런싱이 된다. Gateway 내부에서 SimpleDiscoveryClient가 yml에 정의된 인스턴스 목록을 관리하고, Spring Cloud LoadBalancer가 round-robin으로 분배한다.


build.gradle

eureka-client 대신 loadbalancer만 있으면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ext {
    set('springCloudVersion', '2022.0.4')
}

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

application-dev.yml

로컬 개발 환경은 인스턴스 1개만 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
server:
  port: 8080

spring:
  application:
    name: platform-be-gateway
  cloud:
    discovery:
      client:
        simple:
          instances:
            data-api:
              - uri: http://localhost:8081
    gateway:
      routes:
        - id: data-api
          uri: lb://data-api
          predicates:
            - Path=/data-api/**

      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - http://localhost:3000
            allowedMethods:
              - "*"
            allowedHeaders:
              - "*"
            allowCredentials: true
            maxAge: 3600

application-prd.yml

prd는 서비스별로 VM IP를 등록한다. VM이 추가되면 instances 목록에 항목만 추가하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
server:
  port: 8080

spring:
  application:
    name: platform-be-gateway
  cloud:
    discovery:
      client:
        simple:
          instances:
            data-api:
              - uri: http://VM1_IP  # VM1 data-api
              - uri: http://VM2_IP  # VM2 data-api (추가 시)
            mobile-api:
              - uri: http://VM1_IP  # VM1 mobile-api
    gateway:
      routes:
        - id: data-api
          uri: lb://data-api
          predicates:
            - Path=/data-api/**
        - id: mobile-api
          uri: lb://mobile-api
          predicates:
            - Path=/api/**

      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - https://example.com
            allowedMethods:
              - "*"
            allowedHeaders:
              - "*"
            allowCredentials: true
            maxAge: 3600

핵심 포인트

spring.cloud.discovery.client.simple.instances

Eureka 없이 서비스 인스턴스를 정적으로 정의하는 설정이다. 서비스 이름(data-api, mobile-api)이 키가 되고, uri 목록이 로드밸런싱 대상이 된다.

lb://data-api

lb://는 Spring Cloud LoadBalancer에게 해당 서비스 이름으로 등록된 인스턴스들을 round-robin으로 분배하라는 의미다. Eureka가 없어도 SimpleDiscoveryClient가 인스턴스 목록을 제공하면 동일하게 동작한다.

새 서비스 추가 시

라우트와 인스턴스를 각각 추가하면 된다.

1
2
3
4
5
6
7
8
9
instances:
  new-service:
    - uri: http://VM_IP

routes:
  - id: new-service
    uri: lb://new-service
    predicates:
      - Path=/new-service/**

장애 시 자동 failover (Retry 필터)

VM1이 죽었을 때 Gateway가 자동으로 VM2로 재시도하게 하려면 Retry 필터를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
gateway:
  default-filters:
    - name: Retry
      args:
        retries: 1                                               # 실패 시 다른 인스턴스로 1회 재시도
        statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE, GATEWAY_TIMEOUT  # 재시도할 HTTP 상태
        methods: GET                                             # GET만 재시도 (POST는 중복 처리 위험)
        backoff:
          firstBackoff: 10ms
          maxBackoff: 100ms
          factor: 2
          basedOnPreviousValue: false

동작 흐름:

1
2
3
VM1 요청 → 502 BAD_GATEWAY
    ↓ Retry 필터 동작
VM2 요청 → 200 OK  (클라이언트는 오류 없이 응답 받음)

methods: GET만 재시도하는 이유

POST/PUT/DELETE는 같은 요청이 두 번 처리될 수 있다. 예를 들어 VM1에서 데이터 저장이 완료됐는데 응답 도중 연결이 끊긴 경우, Retry로 VM2에 재시도하면 데이터가 중복 저장된다. 멱등성이 보장되는 API라면 추가할 수 있다.


오류 발생 VM 알람

Retry가 동작해서 클라이언트는 정상 응답을 받더라도, 어떤 VM에서 오류가 났는지 운영자는 알아야 한다. LoadBalancerLifecycle을 구현하면 LoadBalancer가 인스턴스를 선택하고 요청을 완료할 때 훅이 호출된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Slf4j
@Component
public class LoadBalancerFailureListener implements LoadBalancerLifecycle<RequestDataContext, ResponseData, ServiceInstance> {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @Override
    public boolean supports(Class requestContextClass, Class responseClass, Class serverTypeClass) {
        return ServiceInstance.class.isAssignableFrom(serverTypeClass);
    }

    @Override
    public void onStart(Request<RequestDataContext> request) {}

    @Override
    public void onStartRequest(Request<RequestDataContext> request, Response<ServiceInstance> lbResponse) {}

    @Override
    public void onComplete(CompletionContext<ResponseData, ServiceInstance, RequestDataContext> completionContext) {
        CompletionContext.Status status = completionContext.status();

        if (status == CompletionContext.Status.FAILED || status == CompletionContext.Status.DISCARD) {
            ServiceInstance instance = completionContext.getLoadBalancerResponse().getServer();
            String serviceId = instance.getServiceId();
            String host = instance.getHost();
            int port = instance.getPort();
            String time = LocalDateTime.now().format(FORMATTER);
            Throwable cause = completionContext.getThrowable();

            log.warn("[GATEWAY] 인스턴스 오류 감지 - serviceId={}, host={}:{}, status={}, cause={}",
                    serviceId, host, port, status, cause != null ? cause.getMessage() : "unknown");

            String message = String.format(
                    "[Gateway] 인스턴스 오류 감지\n서비스: %s\nVM: %s:%d\n상태: %s\n원인: %s\n시각: %s",
                    serviceId, host, port, status,
                    cause != null ? cause.getMessage() : "unknown",
                    time
            );

            sendAlert(message);
        }
    }

    private void sendAlert(String message) {
        // TODO: 알람 전송 구현
        // Slack 예시:
        // slackNotifier.send(message);

        // 카카오 알림톡 예시:
        // kakaoNotifier.send(message);

        // 이메일 예시:
        // emailNotifier.send("Gateway 인스턴스 오류 알람", message);
    }
}

CompletionContext.Status 종류:

상태의미
SUCCESSFULL요청 성공
FAILED연결 실패, 타임아웃 등 네트워크 오류
DISCARD인스턴스 선택 불가 (등록된 인스턴스 없음)

Retry와 함께 동작하면 아래처럼 흐른다.

1
2
3
4
5
VM1 요청 → 실패
    ↓ onComplete(FAILED) → 로그 + 알람 발송 (VM1 IP 포함)
    ↓ Retry 필터가 VM2로 재시도
VM2 요청 → 성공
    ↓ onComplete(SUCCESSFULL) → 알람 없음

클라이언트는 정상 응답을 받지만, 운영자는 VM1에서 오류가 발생했다는 알람을 받는다.


Eureka가 필요한 시점

지금 구조에서는 필요 없지만, 아래 상황이 되면 다시 고려할 수 있다.

  • VM이 오토스케일링으로 동적으로 추가/제거되는 경우
  • 서비스가 10개 이상으로 늘어나서 yml 관리가 어려워지는 경우
  • 서비스가 직접 헬스체크 기반으로 라우팅 대상에서 빠져야 하는 경우
This post is licensed under CC BY 4.0 by the author.