Spring Cloud Gateway 면접 대비: 라우팅, 필터, 로드 밸런싱

기술 면접을 위한 Spring Cloud Gateway 정복: 라우팅, 필터, 로드 밸런싱, API 게이트웨이 패턴을 다루는 12개의 질문과 코드 예제.

Spring Cloud Gateway: 라우팅, 필터, 로드 밸런싱 면접 질문

Spring Cloud Gateway는 Spring 마이크로서비스 아키텍처에서 API 게이트웨이를 구현하기 위한 표준 솔루션입니다. 기술 면접에서는 라우팅 구성, 커스텀 필터 작성, 효과적인 로드 밸런싱 관리 능력을 평가합니다.

면접 준비 팁

면접관은 게이트웨이 패턴, 즉 중앙 집중식 인증, 레이트 리미팅, 서킷 브레이커에 대한 이해를 확인합니다. 다른 대안이 아닌 Spring Cloud Gateway를 선택해야 하는 이유를 설명할 수 있다면 차별화됩니다.

Spring Cloud Gateway의 아키텍처와 기본 원리

질문 1: Spring Cloud Gateway란 무엇이며 왜 사용합니까?

Spring Cloud Gateway는 Spring WebFlux와 Project Reactor 위에 구축된 리액티브 API 게이트웨이입니다. 마이크로서비스로 향하는 모든 요청의 단일 진입점 역할을 하며, 라우팅, 필터링, 로드 밸런싱 기능을 제공합니다.

GatewayApplication.javajava
// Spring Cloud Gateway 기본 구성
@SpringBootApplication
public class GatewayApplication {

    public static void main(String[] args) {
        // 리액티브 Netty 서버를 시작합니다(Tomcat이 아닙니다)
        SpringApplication.run(GatewayApplication.class, args);
    }
}
yaml
# application.yml
# 게이트웨이 최소 구성
spring:
  cloud:
    gateway:
      routes:
        # 사용자 서비스로의 라우트
        - id: user-service
          uri: http://localhost:8081
          predicates:
            - Path=/api/users/**
        # 주문 서비스로의 라우트
        - id: order-service
          uri: http://localhost:8082
          predicates:
            - Path=/api/orders/**

Spring Cloud Gateway의 주요 장점은 다음과 같습니다. 고성능을 위한 논블로킹 아키텍처, Spring Cloud 생태계와의 네이티브 통합, 최신 리액티브 패턴 지원입니다.

질문 2: Route, Predicate, Filter 개념을 설명해 주십시오

Spring Cloud Gateway는 세 가지 핵심 개념으로 구성됩니다. Route는 목적지를 정의하고, Predicate는 라우트를 적용할 시점을 결정하며, Filter는 요청과 응답을 수정합니다.

RouteConfiguration.javajava
// 프로그래밍 방식의 라우트 구성
@Configuration
public class RouteConfiguration {

    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            // 여러 predicate를 가진 라우트
            .route("product-service", r -> r
                // Predicate: URL 경로
                .path("/api/products/**")
                // Predicate: HTTP 메서드
                .and()
                .method(HttpMethod.GET, HttpMethod.POST)
                // Predicate: 헤더 존재
                .and()
                .header("X-Api-Version", "v2")
                // Filter: 경로 재작성
                .filters(f -> f
                    .rewritePath("/api/products/(?<segment>.*)", "/products/${segment}")
                    // Filter: 헤더 추가
                    .addRequestHeader("X-Gateway-Source", "spring-cloud-gateway")
                )
                // 대상 URI
                .uri("http://localhost:8083"))
            .build();
    }
}

처리 흐름은 다음 순서를 따릅니다.

text
수신 요청
┌─────────────────┐
│   Predicates    │ → 조건 평가(path, method, header...)
└────────┬────────┘
         │ 일치 발견
┌─────────────────┐
│  Pre-Filters    │ → 라우팅 전에 요청 수정
└────────┬────────┘
┌─────────────────┐
│   HTTP Proxy    │ → 대상 서비스로 전달
└────────┬────────┘
┌─────────────────┐
│  Post-Filters   │ → 클라이언트에 반환하기 전에 응답 수정
└────────┬────────┘
   클라이언트로 응답

질문 3: 가장 자주 사용하는 predicate는 무엇입니까?

Spring Cloud Gateway는 다양한 라우팅 조건을 위한 다수의 내장 predicate를 제공합니다. 여러 predicate를 결합하면 정교한 라우팅 규칙을 만들 수 있습니다.

yaml
# application.yml
# 자주 쓰이는 predicate 예시
spring:
  cloud:
    gateway:
      routes:
        # 변수 캡처가 있는 path 라우팅
        - id: user-details
          uri: http://user-service
          predicates:
            - Path=/users/{userId}

        # HTTP 메서드 기반 라우팅
        - id: user-create
          uri: http://user-service
          predicates:
            - Path=/users
            - Method=POST

        # 헤더 기반 라우팅
        - id: mobile-api
          uri: http://mobile-service
          predicates:
            - Header=X-Client-Type, mobile

        # 쿼리 파라미터 기반 라우팅
        - id: search-api
          uri: http://search-service
          predicates:
            - Query=q

        # 호스트 기반 라우팅
        - id: admin-portal
          uri: http://admin-service
          predicates:
            - Host=admin.example.com

        # 시간 기반 라우팅
        - id: maintenance-mode
          uri: http://maintenance-service
          predicates:
            - Between=2026-03-20T02:00:00Z,2026-03-20T04:00:00Z
CustomPredicateFactory.javajava
// 커스텀 predicate 생성
@Component
public class ApiKeyRoutePredicateFactory
    extends AbstractRoutePredicateFactory<ApiKeyRoutePredicateFactory.Config> {

    public ApiKeyRoutePredicateFactory() {
        super(Config.class);
    }

    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
        return exchange -> {
            // API 키의 존재와 유효성을 확인합니다
            String apiKey = exchange.getRequest()
                .getHeaders()
                .getFirst("X-Api-Key");

            return apiKey != null && config.getValidKeys().contains(apiKey);
        };
    }

    @Validated
    public static class Config {
        private List<String> validKeys = new ArrayList<>();

        public List<String> getValidKeys() {
            return validKeys;
        }

        public void setValidKeys(List<String> validKeys) {
            this.validKeys = validKeys;
        }
    }
}
predicate의 순서

predicate의 순서는 평가에 영향을 주지 않지만, 라우트의 순서는 중요합니다. 라우트는 순차적으로 평가되며 첫 번째로 일치하는 라우트가 사용됩니다.

필터와 요청 변환

질문 4: pre/post 처리 필터는 어떻게 동작합니까?

GatewayFilter 필터는 정렬된 체인에서 실행됩니다. "pre" 필터는 라우팅 전에 요청을 수정하고, "post" 필터는 대상 서비스로부터 받은 응답을 수정합니다.

LoggingFilter.javajava
// 글로벌 로깅 필터
@Component
@Slf4j
public class LoggingFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // PRE-FILTER: 라우팅 전
        String requestId = UUID.randomUUID().toString();
        long startTime = System.currentTimeMillis();

        log.info("Request {} started: {} {}",
            requestId,
            exchange.getRequest().getMethod(),
            exchange.getRequest().getPath());

        // 요청 ID를 헤더에 추가합니다
        ServerHttpRequest modifiedRequest = exchange.getRequest()
            .mutate()
            .header("X-Request-Id", requestId)
            .build();

        // 체인을 계속하고 응답을 처리합니다
        return chain.filter(exchange.mutate().request(modifiedRequest).build())
            .then(Mono.fromRunnable(() -> {
                // POST-FILTER: 응답 후
                long duration = System.currentTimeMillis() - startTime;
                HttpStatusCode status = exchange.getResponse().getStatusCode();

                log.info("Request {} completed: status={}, duration={}ms",
                    requestId, status, duration);
            }));
    }

    @Override
    public int getOrder() {
        // 음수 순서 = 가장 먼저 실행
        return -1;
    }
}
AuthenticationFilter.javajava
// JWT 인증 필터
@Component
@RequiredArgsConstructor
public class AuthenticationFilter implements GatewayFilter {

    private final JwtTokenValidator tokenValidator;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        // 토큰 존재 여부를 확인합니다
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return handleUnauthorized(exchange, "Missing or invalid Authorization header");
        }

        String token = authHeader.substring(7);

        // 토큰을 리액티브하게 검증합니다
        return tokenValidator.validate(token)
            .flatMap(claims -> {
                // 사용자 정보로 요청을 보강합니다
                ServerHttpRequest enrichedRequest = exchange.getRequest()
                    .mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Roles", String.join(",", claims.getRoles()))
                    .build();

                return chain.filter(exchange.mutate().request(enrichedRequest).build());
            })
            .onErrorResume(e -> handleUnauthorized(exchange, e.getMessage()));
    }

    private Mono<Void> handleUnauthorized(ServerWebExchange exchange, String message) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        String body = String.format("{\"error\": \"%s\"}", message);
        DataBuffer buffer = exchange.getResponse()
            .bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));

        return exchange.getResponse().writeWith(Mono.just(buffer));
    }
}

질문 5: 가장 유용한 내장 필터는 무엇입니까?

Spring Cloud Gateway는 URL 재작성, 헤더 수정, 재시도, 서킷 브레이커 등 흔한 사용 사례를 다루는 다수의 내장 필터를 제공합니다.

yaml
# application.yml
# 자주 쓰이는 내장 필터
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            # 경로 재작성
            - RewritePath=/api/orders/(?<segment>.*), /orders/${segment}

            # 요청 헤더 추가
            - AddRequestHeader=X-Gateway-Version, 1.0

            # 민감한 응답 헤더 제거
            - RemoveResponseHeader=X-Powered-By
            - RemoveResponseHeader=Server

            # 경로 접두사
            - PrefixPath=/v2

            # 접두사 제거
            - StripPrefix=1

            # 오류 시 자동 재시도
            - name: Retry
              args:
                retries: 3
                statuses: BAD_GATEWAY,SERVICE_UNAVAILABLE
                methods: GET
                backoff:
                  firstBackoff: 100ms
                  maxBackoff: 500ms
                  factor: 2

            # 처리율 제한
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"
RateLimiterConfiguration.javajava
// 사용자별 레이트 리미터 구성
@Configuration
public class RateLimiterConfiguration {

    @Bean
    public KeyResolver userKeyResolver() {
        // 인증된 사용자 단위로 제한
        return exchange -> Mono.just(
            exchange.getRequest()
                .getHeaders()
                .getFirst("X-User-Id")
        ).defaultIfEmpty("anonymous");
    }

    @Bean
    public KeyResolver ipKeyResolver() {
        // IP 주소 단위로 제한
        return exchange -> Mono.just(
            Objects.requireNonNull(exchange.getRequest()
                .getRemoteAddress())
                .getAddress()
                .getHostAddress()
        );
    }
}

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

질문 6: 본문(body)을 변경하는 필터는 어떻게 구현합니까?

요청이나 응답의 본문을 수정하려면 ModifyRequestBodyGatewayFilterFactory 또는 ModifyResponseBodyGatewayFilterFactory를 사용하는 특별한 접근이 필요합니다.

RequestBodyModificationFilter.javajava
// 요청 본문 수정 필터
@Component
@RequiredArgsConstructor
public class RequestBodyModificationFilter implements GlobalFilter, Ordered {

    private final ObjectMapper objectMapper;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // JSON을 포함한 POST/PUT 요청만 수정합니다
        if (!isJsonRequest(exchange)) {
            return chain.filter(exchange);
        }

        return DataBufferUtils.join(exchange.getRequest().getBody())
            .flatMap(dataBuffer -> {
                byte[] bytes = new byte[dataBuffer.readableByteCount()];
                dataBuffer.read(bytes);
                DataBufferUtils.release(dataBuffer);

                try {
                    // JSON을 파싱하고 수정합니다
                    Map<String, Object> body = objectMapper.readValue(
                        bytes,
                        new TypeReference<Map<String, Object>>() {}
                    );

                    // 메타데이터를 추가합니다
                    body.put("processedAt", Instant.now().toString());
                    body.put("gatewayVersion", "1.0");

                    byte[] modifiedBytes = objectMapper.writeValueAsBytes(body);

                    // 수정된 본문으로 새 요청을 만듭니다
                    ServerHttpRequest modifiedRequest = new ServerHttpRequestDecorator(
                        exchange.getRequest()
                    ) {
                        @Override
                        public Flux<DataBuffer> getBody() {
                            return Flux.just(
                                exchange.getResponse()
                                    .bufferFactory()
                                    .wrap(modifiedBytes)
                            );
                        }

                        @Override
                        public HttpHeaders getHeaders() {
                            HttpHeaders headers = new HttpHeaders();
                            headers.putAll(super.getHeaders());
                            headers.setContentLength(modifiedBytes.length);
                            return headers;
                        }
                    };

                    return chain.filter(exchange.mutate().request(modifiedRequest).build());

                } catch (IOException e) {
                    return Mono.error(new RuntimeException("Failed to parse request body", e));
                }
            });
    }

    private boolean isJsonRequest(ServerWebExchange exchange) {
        MediaType contentType = exchange.getRequest().getHeaders().getContentType();
        return contentType != null &&
               contentType.isCompatibleWith(MediaType.APPLICATION_JSON);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}
ResponseBodyModificationConfig.javajava
// 응답 수정 구성
@Configuration
public class ResponseBodyModificationConfig {

    @Bean
    public RouteLocator responseModifyingRoutes(
        RouteLocatorBuilder builder,
        ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilter
    ) {
        return builder.routes()
            .route("modify-response", r -> r
                .path("/api/users/**")
                .filters(f -> f.modifyResponseBody(
                    String.class,
                    String.class,
                    MediaType.APPLICATION_JSON_VALUE,
                    (exchange, responseBody) -> {
                        // 응답을 표준 형식으로 감쌉니다
                        return Mono.just(String.format(
                            "{\"success\": true, \"data\": %s, \"timestamp\": \"%s\"}",
                            responseBody,
                            Instant.now()
                        ));
                    }
                ))
                .uri("lb://user-service"))
            .build();
    }
}

로드 밸런싱과 회복력

질문 7: Spring Cloud LoadBalancer로 로드 밸런싱을 어떻게 구성합니까?

Spring Cloud Gateway는 Spring Cloud LoadBalancer와 통합되어 서비스 인스턴스 간 트래픽을 분산합니다. URI 스킴 lb://를 사용하면 자동 로드 밸런싱이 활성화됩니다.

yaml
# application.yml
# 로드 밸런싱 구성
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          # lb:// 가 로드 밸런싱을 활성화
          uri: lb://user-service
          predicates:
            - Path=/api/users/**

    # 로드 밸런서 구성
    loadbalancer:
      ribbon:
        enabled: false  # Spring Cloud LoadBalancer 사용(Ribbon 아님)

      # 서비스별 구성
      configurations: default

      # 로드 밸런싱용 헬스 체크
      health-check:
        path:
          user-service: /actuator/health
        interval: 10s

# 서비스 디스커버리(Eureka 등)
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
LoadBalancerConfiguration.javajava
// 로드 밸런서 커스텀 구성
@Configuration
@LoadBalancerClients(defaultConfiguration = CustomLoadBalancerConfig.class)
public class LoadBalancerConfiguration {
}

// CustomLoadBalancerConfig.java
// 커스텀 로드 밸런싱 전략
public class CustomLoadBalancerConfig {

    @Bean
    public ReactorLoadBalancer<ServiceInstance> loadBalancer(
        Environment environment,
        LoadBalancerClientFactory clientFactory
    ) {
        String serviceId = environment.getProperty(
            LoadBalancerClientFactory.PROPERTY_NAME
        );

        // 기본은 라운드 로빈
        return new RoundRobinLoadBalancer(
            clientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class),
            serviceId
        );
    }

    @Bean
    public ServiceInstanceListSupplier serviceInstanceListSupplier(
        ConfigurableApplicationContext context
    ) {
        // 인스턴스에 헬스 체크를 추가
        return ServiceInstanceListSupplier.builder()
            .withDiscoveryClient()
            .withHealthChecks()
            .withCaching()
            .build(context);
    }
}
WeightedLoadBalancer.javajava
// 가중치 기반 로드 밸런서
@Component
@RequiredArgsConstructor
public class WeightedLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final String serviceId;
    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
    private final Random random = new Random();

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        return supplierProvider.getIfAvailable()
            .get()
            .next()
            .map(instances -> {
                if (instances.isEmpty()) {
                    return new EmptyResponse();
                }

                // 메타데이터로부터 가중치를 계산합니다
                List<WeightedInstance> weighted = instances.stream()
                    .map(instance -> {
                        int weight = Integer.parseInt(
                            instance.getMetadata().getOrDefault("weight", "1")
                        );
                        return new WeightedInstance(instance, weight);
                    })
                    .toList();

                // 가중치 기반 선택
                int totalWeight = weighted.stream()
                    .mapToInt(WeightedInstance::weight)
                    .sum();

                int randomWeight = random.nextInt(totalWeight);
                int currentWeight = 0;

                for (WeightedInstance wi : weighted) {
                    currentWeight += wi.weight();
                    if (randomWeight < currentWeight) {
                        return new DefaultResponse(wi.instance());
                    }
                }

                return new DefaultResponse(weighted.get(0).instance());
            });
    }

    private record WeightedInstance(ServiceInstance instance, int weight) {}
}

질문 8: 게이트웨이에서 서킷 브레이커는 어떻게 구현합니까?

서킷 브레이커는 연쇄 장애를 방지합니다. Spring Cloud Gateway는 Resilience4j와 통합되어 고급 장애 관리를 제공합니다.

yaml
# application.yml
# Resilience4j 서킷 브레이커 구성
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            # fallback이 있는 서킷 브레이커
            - name: CircuitBreaker
              args:
                name: orderServiceCB
                fallbackUri: forward:/fallback/orders

resilience4j:
  circuitbreaker:
    configs:
      default:
        # 평가되는 요청 수
        slidingWindowSize: 10
        # 회로를 여는 실패율 임계값
        failureRateThreshold: 50
        # 회로가 열린 채 유지되는 시간
        waitDurationInOpenState: 30s
        # half-open에서 허용되는 요청 수
        permittedNumberOfCallsInHalfOpenState: 3
        # 자동 상태 전이
        automaticTransitionFromOpenToHalfOpenEnabled: true

    instances:
      orderServiceCB:
        baseConfig: default
        failureRateThreshold: 60

  timelimiter:
    configs:
      default:
        timeoutDuration: 5s

    instances:
      orderServiceCB:
        timeoutDuration: 3s
FallbackController.javajava
// fallback 컨트롤러
@RestController
@RequestMapping("/fallback")
@Slf4j
public class FallbackController {

    @GetMapping("/orders")
    public Mono<ResponseEntity<Map<String, Object>>> ordersFallback(
        ServerWebExchange exchange
    ) {
        log.warn("Circuit breaker activated for orders service");

        Map<String, Object> response = Map.of(
            "success", false,
            "error", "Service temporarily unavailable",
            "code", "SERVICE_UNAVAILABLE",
            "retryAfter", 30
        );

        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(response));
    }

    @PostMapping("/orders")
    public Mono<ResponseEntity<Map<String, Object>>> ordersPostFallback() {
        Map<String, Object> response = Map.of(
            "success", false,
            "error", "Order creation temporarily unavailable",
            "code", "SERVICE_UNAVAILABLE",
            "message", "Please try again later"
        );

        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(response));
    }
}
CircuitBreakerEventListener.javajava
// 서킷 브레이커 이벤트 모니터링
@Component
@Slf4j
@RequiredArgsConstructor
public class CircuitBreakerEventListener {

    private final MeterRegistry meterRegistry;

    @EventListener
    public void onCircuitBreakerStateTransition(
        CircuitBreakerOnStateTransitionEvent event
    ) {
        CircuitBreaker.StateTransition transition = event.getStateTransition();

        log.info("Circuit breaker {} state changed: {} -> {}",
            event.getCircuitBreakerName(),
            transition.getFromState(),
            transition.getToState());

        // Micrometer 메트릭
        meterRegistry.counter(
            "circuit_breaker.state_transition",
            "name", event.getCircuitBreakerName(),
            "from", transition.getFromState().name(),
            "to", transition.getToState().name()
        ).increment();
    }

    @EventListener
    public void onCircuitBreakerFailure(CircuitBreakerOnErrorEvent event) {
        log.error("Circuit breaker {} error: {}",
            event.getCircuitBreakerName(),
            event.getThrowable().getMessage());
    }
}
타임아웃과 서킷 브레이커

서킷 브레이커와 HTTP 클라이언트 사이의 타임아웃을 일관되게 설정하십시오. 너무 긴 타임아웃은 스레드를 점유하고, 너무 짧은 타임아웃은 오탐을 유발합니다.

질문 9: 지수 백오프를 적용한 재시도는 어떻게 구현합니까?

지수 백오프를 활용한 똑똑한 재시도는 어려움을 겪는 서비스에 부담을 주지 않으면서 성공 가능성을 극대화합니다.

yaml
# application.yml
# 백오프를 적용한 재시도 구성
spring:
  cloud:
    gateway:
      routes:
        - id: payment-service
          uri: lb://payment-service
          predicates:
            - Path=/api/payments/**
          filters:
            - name: Retry
              args:
                # 시도 횟수
                retries: 3
                # 재시도를 유발하는 HTTP 코드
                statuses: BAD_GATEWAY,SERVICE_UNAVAILABLE,GATEWAY_TIMEOUT
                # 멱등 HTTP 메서드만
                methods: GET,PUT
                # 재시도를 유발하는 예외
                exceptions:
                  - java.io.IOException
                  - java.net.ConnectException
                  - org.springframework.cloud.gateway.support.TimeoutException
                # 지수 백오프
                backoff:
                  firstBackoff: 100ms
                  maxBackoff: 2000ms
                  factor: 2
                  basedOnPreviousValue: true
CustomRetryFilter.javajava
// 비즈니스 로직이 포함된 커스텀 재시도
@Component
@Slf4j
public class CustomRetryFilter implements GatewayFilter, Ordered {

    private static final int MAX_RETRIES = 3;
    private static final Duration INITIAL_BACKOFF = Duration.ofMillis(100);
    private static final double BACKOFF_MULTIPLIER = 2.0;
    private static final double JITTER_FACTOR = 0.1;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return Mono.defer(() -> attemptRequest(exchange, chain, 0));
    }

    private Mono<Void> attemptRequest(
        ServerWebExchange exchange,
        GatewayFilterChain chain,
        int attempt
    ) {
        return chain.filter(exchange)
            .onErrorResume(throwable -> {
                if (attempt >= MAX_RETRIES || !isRetryable(throwable, exchange)) {
                    return Mono.error(throwable);
                }

                Duration backoff = calculateBackoff(attempt);
                log.warn("Retry attempt {} after {}ms for {} {}",
                    attempt + 1,
                    backoff.toMillis(),
                    exchange.getRequest().getMethod(),
                    exchange.getRequest().getPath());

                return Mono.delay(backoff)
                    .then(Mono.defer(() ->
                        attemptRequest(exchange, chain, attempt + 1)
                    ));
            });
    }

    private boolean isRetryable(Throwable throwable, ServerWebExchange exchange) {
        // 멱등 메서드만 재시도
        HttpMethod method = exchange.getRequest().getMethod();
        if (!Set.of(HttpMethod.GET, HttpMethod.PUT, HttpMethod.DELETE)
            .contains(method)) {
            return false;
        }

        // 예외 타입을 확인합니다
        return throwable instanceof ConnectException ||
               throwable instanceof TimeoutException ||
               throwable instanceof ServiceUnavailableException;
    }

    private Duration calculateBackoff(int attempt) {
        // 지터가 적용된 지수 백오프
        long baseBackoff = (long) (
            INITIAL_BACKOFF.toMillis() * Math.pow(BACKOFF_MULTIPLIER, attempt)
        );

        // 무작위 지터 추가(±10%)
        double jitter = 1.0 + (Math.random() - 0.5) * 2 * JITTER_FACTOR;
        long finalBackoff = (long) (baseBackoff * jitter);

        return Duration.ofMillis(finalBackoff);
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 1;
    }
}

고급 패턴과 모범 사례

질문 10: 요청 집계는 어떻게 구현합니까?

집계는 여러 마이크로서비스 호출을 클라이언트를 위한 단일 응답으로 묶어, 지연과 프런트엔드 복잡도를 줄입니다.

AggregationController.javajava
// 멀티 서비스 집계
@RestController
@RequestMapping("/api/aggregate")
@RequiredArgsConstructor
@Slf4j
public class AggregationController {

    private final WebClient.Builder webClientBuilder;
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    @GetMapping("/user-dashboard/{userId}")
    public Mono<DashboardResponse> getUserDashboard(@PathVariable Long userId) {
        // 서로 다른 서비스로의 병렬 호출
        Mono<UserProfile> userMono = fetchUserProfile(userId);
        Mono<List<Order>> ordersMono = fetchRecentOrders(userId);
        Mono<NotificationCount> notificationsMono = fetchNotificationCount(userId);

        // 결과 집계
        return Mono.zip(userMono, ordersMono, notificationsMono)
            .map(tuple -> DashboardResponse.builder()
                .user(tuple.getT1())
                .recentOrders(tuple.getT2())
                .notificationCount(tuple.getT3())
                .generatedAt(Instant.now())
                .build())
            .timeout(Duration.ofSeconds(5))
            .onErrorResume(this::handleAggregationError);
    }

    private Mono<UserProfile> fetchUserProfile(Long userId) {
        return webClientBuilder.build()
            .get()
            .uri("lb://user-service/users/{id}", userId)
            .retrieve()
            .bodyToMono(UserProfile.class)
            .transform(CircuitBreakerOperator.of(
                circuitBreakerRegistry.circuitBreaker("user-service")
            ))
            .onErrorReturn(new UserProfile(userId, "Unknown", null));
    }

    private Mono<List<Order>> fetchRecentOrders(Long userId) {
        return webClientBuilder.build()
            .get()
            .uri("lb://order-service/orders?userId={id}&limit=5", userId)
            .retrieve()
            .bodyToFlux(Order.class)
            .collectList()
            .transform(CircuitBreakerOperator.of(
                circuitBreakerRegistry.circuitBreaker("order-service")
            ))
            .onErrorReturn(Collections.emptyList());
    }

    private Mono<NotificationCount> fetchNotificationCount(Long userId) {
        return webClientBuilder.build()
            .get()
            .uri("lb://notification-service/notifications/count/{id}", userId)
            .retrieve()
            .bodyToMono(NotificationCount.class)
            .transform(CircuitBreakerOperator.of(
                circuitBreakerRegistry.circuitBreaker("notification-service")
            ))
            .onErrorReturn(new NotificationCount(0, 0));
    }

    private Mono<DashboardResponse> handleAggregationError(Throwable error) {
        log.error("Dashboard aggregation failed: {}", error.getMessage());
        return Mono.just(DashboardResponse.builder()
            .error("Partial data available")
            .generatedAt(Instant.now())
            .build());
    }
}
DashboardResponse.javajava
// 집계된 응답 DTO
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DashboardResponse {
    private UserProfile user;
    private List<Order> recentOrders;
    private NotificationCount notificationCount;
    private Instant generatedAt;
    private String error;
}

질문 11: OAuth2로 게이트웨이를 어떻게 보호합니까?

OAuth2 통합은 인증을 게이트웨이 수준에서 중앙 집중화하여 각 마이크로서비스의 로직 중복을 피합니다.

yaml
# application.yml
# OAuth2 리소스 서버 구성
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/myrealm
          jwk-set-uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certs
SecurityConfig.javajava
// 게이트웨이 보안 구성
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
            // 무상태 API이므로 CSRF 비활성화
            .csrf(ServerHttpSecurity.CsrfSpec::disable)

            // 인가 구성
            .authorizeExchange(exchanges -> exchanges
                // 공개 엔드포인트
                .pathMatchers("/actuator/health", "/actuator/info").permitAll()
                .pathMatchers("/api/public/**").permitAll()
                .pathMatchers("/api/auth/**").permitAll()

                // 역할별 엔드포인트
                .pathMatchers("/api/admin/**").hasRole("ADMIN")
                .pathMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")

                // 그 외에는 인증 필요
                .anyExchange().authenticated()
            )

            // JWT 기반 OAuth2 리소스 서버
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )

            .build();
    }

    @Bean
    public ReactiveJwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
            new JwtGrantedAuthoritiesConverter();
        // "roles" 클레임에서 역할 추출
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        ReactiveJwtAuthenticationConverter converter =
            new ReactiveJwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(
            new ReactiveJwtGrantedAuthoritiesConverterAdapter(grantedAuthoritiesConverter)
        );

        return converter;
    }
}
TokenRelayFilter.javajava
// 다운스트림 서비스로 토큰 전달
@Component
public class TokenRelayFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return ReactiveSecurityContextHolder.getContext()
            .map(SecurityContext::getAuthentication)
            .filter(auth -> auth instanceof JwtAuthenticationToken)
            .cast(JwtAuthenticationToken.class)
            .map(JwtAuthenticationToken::getToken)
            .map(jwt -> {
                // 다운스트림 서비스로 토큰 전달
                ServerHttpRequest request = exchange.getRequest()
                    .mutate()
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt.getTokenValue())
                    // 토큰에서 추출한 사용자 정보를 추가
                    .header("X-User-Id", jwt.getSubject())
                    .header("X-User-Email", jwt.getClaimAsString("email"))
                    .build();

                return exchange.mutate().request(request).build();
            })
            .defaultIfEmpty(exchange)
            .flatMap(chain::filter);
    }

    @Override
    public int getOrder() {
        // 인증 후, 라우팅 전
        return SecurityWebFiltersOrder.AUTHENTICATION.getOrder() + 1;
    }
}

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

질문 12: 모니터링과 옵저버빌리티의 모범 사례는 무엇입니까?

게이트웨이 모니터링은 마이크로서비스 아키텍처의 성능 및 가용성 문제를 빠르게 식별하기 위해 매우 중요합니다.

yaml
# application.yml
# 옵저버빌리티 구성
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,gateway
  metrics:
    tags:
      application: api-gateway
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.95, 0.99
  tracing:
    sampling:
      probability: 1.0

spring:
  cloud:
    gateway:
      metrics:
        enabled: true
        tags:
          path:
            enabled: true
MetricsFilter.javajava
// 커스텀 메트릭 필터
@Component
@RequiredArgsConstructor
@Slf4j
public class MetricsFilter implements GlobalFilter, Ordered {

    private final MeterRegistry meterRegistry;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        long startTime = System.nanoTime();
        String path = exchange.getRequest().getPath().value();
        String method = exchange.getRequest().getMethod().name();

        // 라우트에서 대상 서비스를 추출
        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        String routeId = route != null ? route.getId() : "unknown";

        return chain.filter(exchange)
            .doOnSuccess(v -> recordMetrics(exchange, startTime, routeId, "success"))
            .doOnError(e -> recordMetrics(exchange, startTime, routeId, "error"));
    }

    private void recordMetrics(
        ServerWebExchange exchange,
        long startTime,
        String routeId,
        String outcome
    ) {
        long duration = System.nanoTime() - startTime;
        HttpStatusCode status = exchange.getResponse().getStatusCode();
        String statusCode = status != null ? String.valueOf(status.value()) : "0";

        // 지연 시간을 위한 타이머
        Timer.builder("gateway.request.duration")
            .tag("route", routeId)
            .tag("method", exchange.getRequest().getMethod().name())
            .tag("status", statusCode)
            .tag("outcome", outcome)
            .register(meterRegistry)
            .record(duration, TimeUnit.NANOSECONDS);

        // 요청 카운터
        meterRegistry.counter(
            "gateway.requests.total",
            "route", routeId,
            "status", statusCode
        ).increment();

        // 느린 요청에 대한 로그
        if (duration > TimeUnit.SECONDS.toNanos(1)) {
            log.warn("Slow request: {} {} - {}ms (route: {})",
                exchange.getRequest().getMethod(),
                exchange.getRequest().getPath(),
                TimeUnit.NANOSECONDS.toMillis(duration),
                routeId);
        }
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}
TracingFilter.javajava
// 트레이싱 컨텍스트 전파
@Component
@RequiredArgsConstructor
public class TracingFilter implements GlobalFilter, Ordered {

    private final Tracer tracer;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 트레이싱 스팬을 생성하거나 가져옵니다
        Span span = tracer.nextSpan()
            .name("gateway-request")
            .tag("http.method", exchange.getRequest().getMethod().name())
            .tag("http.url", exchange.getRequest().getURI().toString())
            .start();

        // 트레이싱 헤더 주입
        ServerHttpRequest request = exchange.getRequest()
            .mutate()
            .header("X-Trace-Id", span.context().traceId())
            .header("X-Span-Id", span.context().spanId())
            .build();

        return chain.filter(exchange.mutate().request(request).build())
            .doOnSuccess(v -> {
                HttpStatusCode status = exchange.getResponse().getStatusCode();
                span.tag("http.status_code",
                    status != null ? String.valueOf(status.value()) : "0");
                span.end();
            })
            .doOnError(e -> {
                span.tag("error", e.getMessage());
                span.end();
            });
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }
}

핵심 메트릭 표:

text
| 메트릭                              | 설명                              |
|-----------------------------------|----------------------------------|
| gateway.request.duration          | 라우트/상태별 지연 시간             |
| gateway.requests.total            | 요청 카운터                        |
| resilience4j.circuitbreaker.state | 서킷 브레이커 상태                  |
| http.server.requests              | 표준 HTTP 메트릭                   |
| spring.cloud.gateway.routes       | 활성 라우트                        |
권장 대시보드

메트릭 시각화를 위해 Spring Cloud Gateway와 Resilience4j 대시보드를 갖춘 Grafana를 사용하십시오. P99 지연과 오류율에 대한 알림을 설정합니다.

결론

Spring Cloud Gateway는 현대 마이크로서비스 아키텍처의 핵심 구성 요소입니다. 면접에서 기억해야 할 주요 사항은 다음과 같습니다.

아키텍처와 개념:

  • ✅ Routes, Predicates, Filters가 기본 모델을 형성
  • ✅ WebFlux와 Netty 기반의 리액티브 아키텍처
  • ✅ Spring Cloud 생태계와의 네이티브 통합

핵심 기능:

  • ✅ 다양한 기준에 기반한 동적 라우팅
  • ✅ 요청과 응답 변환을 위한 pre/post 필터
  • ✅ Spring Cloud LoadBalancer를 활용한 로드 밸런싱

회복력과 보안:

  • ✅ Resilience4j와 fallback을 활용한 서킷 브레이커
  • ✅ 지수 백오프와 지터를 적용한 재시도
  • ✅ 중앙 집중식 OAuth2/JWT 인증

옵저버빌리티:

  • ✅ 라우트별 태그가 있는 Micrometer 메트릭
  • ✅ 컨텍스트 전파가 있는 분산 트레이싱
  • ✅ 헬스 체크와 actuator 엔드포인트

Spring Cloud Gateway에 대한 숙련도는 마이크로서비스 패턴과 확장성 과제에 대한 깊은 이해를 보여줍니다. 이러한 역량은 견고하고 고성능의 API 게이트웨이를 설계하는 데 필수적입니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#spring cloud gateway
#microservices
#api gateway
#routing
#technical interview

공유

관련 기사