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

Spring Cloud Gateway는 Spring 마이크로서비스 아키텍처에서 API 게이트웨이를 구현하기 위한 표준 솔루션입니다. 기술 면접에서는 라우팅 구성, 커스텀 필터 작성, 효과적인 로드 밸런싱 관리 능력을 평가합니다.
면접관은 게이트웨이 패턴, 즉 중앙 집중식 인증, 레이트 리미팅, 서킷 브레이커에 대한 이해를 확인합니다. 다른 대안이 아닌 Spring Cloud Gateway를 선택해야 하는 이유를 설명할 수 있다면 차별화됩니다.
Spring Cloud Gateway의 아키텍처와 기본 원리
질문 1: Spring Cloud Gateway란 무엇이며 왜 사용합니까?
Spring Cloud Gateway는 Spring WebFlux와 Project Reactor 위에 구축된 리액티브 API 게이트웨이입니다. 마이크로서비스로 향하는 모든 요청의 단일 진입점 역할을 하며, 라우팅, 필터링, 로드 밸런싱 기능을 제공합니다.
// Spring Cloud Gateway 기본 구성
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
// 리액티브 Netty 서버를 시작합니다(Tomcat이 아닙니다)
SpringApplication.run(GatewayApplication.class, args);
}
}# 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는 요청과 응답을 수정합니다.
// 프로그래밍 방식의 라우트 구성
@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();
}
}처리 흐름은 다음 순서를 따릅니다.
수신 요청
│
▼
┌─────────────────┐
│ Predicates │ → 조건 평가(path, method, header...)
└────────┬────────┘
│ 일치 발견
▼
┌─────────────────┐
│ Pre-Filters │ → 라우팅 전에 요청 수정
└────────┬────────┘
│
▼
┌─────────────────┐
│ HTTP Proxy │ → 대상 서비스로 전달
└────────┬────────┘
│
▼
┌─────────────────┐
│ Post-Filters │ → 클라이언트에 반환하기 전에 응답 수정
└────────┬────────┘
│
▼
클라이언트로 응답질문 3: 가장 자주 사용하는 predicate는 무엇입니까?
Spring Cloud Gateway는 다양한 라우팅 조건을 위한 다수의 내장 predicate를 제공합니다. 여러 predicate를 결합하면 정교한 라우팅 규칙을 만들 수 있습니다.
# 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// 커스텀 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의 순서는 평가에 영향을 주지 않지만, 라우트의 순서는 중요합니다. 라우트는 순차적으로 평가되며 첫 번째로 일치하는 라우트가 사용됩니다.
필터와 요청 변환
질문 4: pre/post 처리 필터는 어떻게 동작합니까?
GatewayFilter 필터는 정렬된 체인에서 실행됩니다. "pre" 필터는 라우팅 전에 요청을 수정하고, "post" 필터는 대상 서비스로부터 받은 응답을 수정합니다.
// 글로벌 로깅 필터
@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;
}
}// 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 재작성, 헤더 수정, 재시도, 서킷 브레이커 등 흔한 사용 사례를 다루는 다수의 내장 필터를 제공합니다.
# 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}"// 사용자별 레이트 리미터 구성
@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를 사용하는 특별한 접근이 필요합니다.
// 요청 본문 수정 필터
@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;
}
}// 응답 수정 구성
@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://를 사용하면 자동 로드 밸런싱이 활성화됩니다.
# 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/// 로드 밸런서 커스텀 구성
@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);
}
}// 가중치 기반 로드 밸런서
@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와 통합되어 고급 장애 관리를 제공합니다.
# 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// 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));
}
}// 서킷 브레이커 이벤트 모니터링
@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: 지수 백오프를 적용한 재시도는 어떻게 구현합니까?
지수 백오프를 활용한 똑똑한 재시도는 어려움을 겪는 서비스에 부담을 주지 않으면서 성공 가능성을 극대화합니다.
# 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// 비즈니스 로직이 포함된 커스텀 재시도
@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: 요청 집계는 어떻게 구현합니까?
집계는 여러 마이크로서비스 호출을 클라이언트를 위한 단일 응답으로 묶어, 지연과 프런트엔드 복잡도를 줄입니다.
// 멀티 서비스 집계
@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());
}
}// 집계된 응답 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 통합은 인증을 게이트웨이 수준에서 중앙 집중화하여 각 마이크로서비스의 로직 중복을 피합니다.
# 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// 게이트웨이 보안 구성
@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;
}
}// 다운스트림 서비스로 토큰 전달
@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: 모니터링과 옵저버빌리티의 모범 사례는 무엇입니까?
게이트웨이 모니터링은 마이크로서비스 아키텍처의 성능 및 가용성 문제를 빠르게 식별하기 위해 매우 중요합니다.
# 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// 커스텀 메트릭 필터
@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;
}
}// 트레이싱 컨텍스트 전파
@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;
}
}핵심 메트릭 표:
| 메트릭 | 설명 |
|-----------------------------------|----------------------------------|
| 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 Kafka: 회복탄력성을 갖춘 컨슈머로 구축하는 이벤트 기반 아키텍처
이벤트 기반 아키텍처를 위한 완전한 Spring Kafka 가이드. 설정, 회복탄력성을 갖춘 컨슈머, 재시도 정책, Dead Letter Queue, 분산 애플리케이션을 위한 운영 패턴.

2026년 Spring Boot 로깅: Logback과 JSON으로 구현하는 운영 환경 구조화 로그
Spring Boot 구조화 로깅 완벽 가이드입니다. Logback JSON 설정, 추적용 MDC, 운영 환경 모범 사례, ELK Stack 연동을 다룹니다.

Spring GraphQL 면접: Resolver, DataLoader 및 N+1 문제 해결책
이 완전한 가이드로 Spring GraphQL 면접을 준비합니다. Resolver, DataLoader, N+1 문제 처리, mutation 및 기술 질문을 위한 모범 사례를 다룹니다.