Spring Cloud MSA 정리: Circuit Breaker부터 API Gateway, JWT 보안까지

한소연·2026년 3월 18일

내일배움캠프

목록 보기
10/21
post-thumbnail

마이크로서비스 아키텍처(MSA)에서 서비스 안정성과 보안을 구축하는 핵심 패턴을 실습 코드와 함께 알아봅니다.


목차

  1. Circuit Breaker와 Resilience4j
  2. Spring Cloud Gateway
  3. JWT 기반 인증 보안
  4. 전체 아키텍처 통합

1. Circuit Breaker와 Resilience4j

Circuit Breaker란?

마이크로서비스 환경에서는 수십, 수백 개의 서비스가 서로를 호출합니다. 하나의 서비스가 느려지거나 장애가 발생하면, 그 서비스를 호출하는 다른 서비스까지 연쇄적으로 문제가 퍼지는 장애 전파(Cascading Failure) 가 일어날 수 있습니다.

Circuit Breaker는 이 문제를 해결하기 위한 패턴입니다. 전기 회로 차단기처럼, 문제가 발생하면 회로를 끊어 장애가 더 이상 퍼지지 않도록 차단합니다.

3가지 상태

  [정상 동작]           [임계값 초과]         [대기 후 재시도]
  CLOSED ──────────────→ OPEN ──────────────→ HALF-OPEN
    ↑                                              │
    └──────────── 성공 ────────────────────────────┘
                         실패 시 다시 OPEN ──────────┘
상태설명
Closed기본 상태. 모든 요청을 통과시키며 실패율을 카운트
Open실패율이 임계값 초과 시 전환. 모든 요청을 즉시 차단
Half-Open대기 시간 후 전환. 제한된 요청만 허용하여 복구 여부 확인

예시: 최근 5번 호출 중 3번 실패(60%) → 임계값 50% 초과 → Open 상태로 전환 → 20초 차단 → Half-Open에서 3번 성공 → Closed로 복귀


Resilience4j 적용하기

의존성 추가

Spring Boot 3 환경에서는 Spring Starter의 Resilience4j가 아닌, 직접 라이브러리를 사용합니다.

// build.gradle
dependencies {
    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-aop'  // AOP 필수
}

⚠️ spring-cloud-starter-circuitbreaker-resilience4j는 추상화 계층을 통해 동작하므로, 직접 제어를 위해 사용하지 않습니다.

application.yml 설정

resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true       # 헬스체크에 상태 포함
        slidingWindowType: COUNT_BASED      # 호출 횟수 기반 판단
        slidingWindowSize: 5                # 최근 5번의 호출 기록
        minimumNumberOfCalls: 5             # 최소 5번 호출 후 판단 시작
        failureRateThreshold: 50            # 실패율 50% 초과 시 Open
        slowCallRateThreshold: 100          # 느린 호출 비율 임계값
        slowCallDurationThreshold: 60000    # 60초 이상이면 느린 호출
        permittedNumberOfCallsInHalfOpenState: 3  # Half-Open에서 허용 호출 수
        waitDurationInOpenState: 20s        # Open → Half-Open 대기 시간

서비스 코드 작성

@Service
@RequiredArgsConstructor
public class ProductService {

    private final Logger log = LoggerFactory.getLogger(getClass());
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    // 서킷브레이커 이벤트 리스너 등록
    @PostConstruct
    public void registerEventListener() {
        circuitBreakerRegistry.circuitBreaker("productService").getEventPublisher()
            .onStateTransition(event -> log.info("상태 전환: {}", event))
            .onFailureRateExceeded(event -> log.info("실패율 초과: {}", event))
            .onCallNotPermitted(event -> log.info("호출 차단됨: {}", event))
            .onError(event -> log.info("오류 발생: {}", event));
    }

    // @CircuitBreaker 어노테이션으로 보호, 실패 시 fallbackMethod 호출
    @CircuitBreaker(name = "productService", fallbackMethod = "fallbackGetProductDetails")
    public Product getProductDetails(String productId) {
        log.info("상품 조회 요청: {}", productId);
        if ("111".equals(productId)) {
            throw new RuntimeException("Empty response body");
        }
        return new Product(productId, "Sample Product");
    }

    // Fallback: 원본 메서드와 시그니처 동일 + Throwable 파라미터 추가
    public Product fallbackGetProductDetails(String productId, Throwable t) {
        log.error("Fallback 실행 - productId: {}, 원인: {}", productId, t.getMessage());
        return new Product(productId, "Fallback Product");
    }
}

이벤트 흐름 정리

요청 /product/111
    │
    ▼
getProductDetails() 호출
    │
    ├─ RuntimeException 발생
    │       │
    │       ▼
    │   CircuitBreaker Error 이벤트 기록
    │   실패 카운트 증가
    │
    ▼ (5번 중 3번 실패, 60% 도달)
실패율 초과 이벤트 발생
    │
    ▼
Closed → Open 상태 전환
    │
    ▼ (이후 요청)
Call Not Permitted → fallback 즉시 호출

Prometheus 모니터링 연동

dependencies {
    implementation 'io.micrometer:micrometer-registry-prometheus'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
management:
  endpoints:
    web:
      exposure:
        include: prometheus
  prometheus:
    metrics:
      export:
        enabled: true

http://localhost:19090/actuator/prometheus 에 접속하면 서킷브레이커 메트릭을 확인할 수 있으며, 이를 Grafana 대시보드와 연동해 실시간 시각화가 가능합니다.


2. Spring Cloud Gateway

API Gateway란?

클라이언트는 수십 개의 마이크로서비스 주소를 알 필요 없이, 단일 진입점(Single Entry Point) 인 게이트웨이만 호출합니다. 게이트웨이는 내부적으로 요청을 적절한 서비스로 라우팅합니다.

클라이언트
    │
    ▼
[API Gateway :19091]
    ├── /order/** ──→ order-service
    ├── /product/** ─→ product-service (로드밸런싱)
    └── /auth/** ───→ auth-service

주요 기능: 라우팅, 인증/인가, 로드밸런싱, 모니터링, 요청/응답 변환

Spring Cloud Gateway 설정

spring:
  main:
    web-application-type: reactive  # 리액티브 웹 애플리케이션
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service        # Eureka 서비스명으로 로드밸런싱
          predicates:
            - Path=/order/**
        - id: product-service
          uri: lb://product-service
          predicates:
            - Path=/product/**
      discovery:
        locator:
          enabled: true                  # 동적 라우트 생성

eureka:
  client:
    service-url:
      defaultZone: http://localhost:19090/eureka/

필터(Filter) 구현

게이트웨이 필터는 요청 전후에 원하는 로직을 삽입할 수 있습니다.

Pre Filter (요청 전 처리)

@Component
public class CustomPreFilter implements GlobalFilter, Ordered {

    private static final Logger logger = Logger.getLogger(CustomPreFilter.class.getName());

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 요청이 실제 서비스로 가기 전에 실행
        logger.info("Pre Filter: Request URI is " + exchange.getRequest().getURI());
        return chain.filter(exchange);  // 다음 필터로 전달
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;  // 가장 먼저 실행
    }
}

Post Filter (응답 후 처리)

@Component
public class CustomPostFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // then()을 사용해 응답이 완료된 후 실행
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            logger.info("Post Filter: Response status = " + exchange.getResponse().getStatusCode());
        }));
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;  // 가장 마지막에 실행
    }
}

핵심 객체 정리

객체역할
ServerWebExchangeHTTP 요청/응답을 캡슐화. getRequest(), getResponse()로 접근
GatewayFilterChain필터 체인. chain.filter(exchange)로 다음 필터 호출
Mono<Void>리액티브 스트림. 0~1개의 데이터를 비동기 처리

로드밸런싱 확인

Product 서비스를 2개 포트(19093, 19094)로 실행한 뒤, http://localhost:19091/product를 반복 호출하면 포트가 번갈아 바뀌는 것을 확인할 수 있습니다.

1번 호출 → "Product info!!!!! From port : 19093"
2번 호출 → "Product info!!!!! From port : 19094"
3번 호출 → "Product info!!!!! From port : 19093"

3. JWT 기반 인증 보안

전체 인증 흐름

1. 클라이언트 → Gateway → Auth Service: 로그인 요청
2. Auth Service → 클라이언트: JWT 토큰 발급
3. 클라이언트 → Gateway (Authorization: Bearer {token}): API 요청
4. Gateway Pre Filter: JWT 검증
5. 검증 성공 → 해당 서비스로 라우팅
6. 검증 실패 → 401 Unauthorized 반환

Auth Service 구현

JWT 토큰 발급

@Service
public class AuthService {

    @Value("${spring.application.name}")
    private String issuer;

    @Value("${service.jwt.access-expiration}")
    private Long accessExpiration;

    private final SecretKey secretKey;

    public AuthService(@Value("${service.jwt.secret-key}") String secretKey) {
        // Base64URL 인코딩된 비밀키를 HMAC-SHA 키로 변환
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

    public String createAccessToken(String userId) {
        return Jwts.builder()
                .claim("user_id", userId)
                .claim("role", "ADMIN")
                .issuer(issuer)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + accessExpiration))
                .signWith(secretKey, SignatureAlgorithm.HS512)
                .compact();
    }
}

Spring Security 설정 (Stateless)

@Configuration
@EnableWebSecurity
public class AuthConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeRequests(auth -> auth
                .requestMatchers("/auth/signIn").permitAll()  // 로그인은 인증 불필요
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // 세션 미사용
            );
        return http.build();
    }
}

로그인 엔드포인트

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @GetMapping("/auth/signIn")
    public ResponseEntity<?> signIn(@RequestParam String user_id) {
        return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id)));
    }

    @Data @AllArgsConstructor @NoArgsConstructor
    static class AuthResponse {
        private String access_token;
    }
}

Gateway JWT 검증 필터

@Slf4j
@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {

    @Value("${service.jwt.secret-key}")
    private String secretKey;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 로그인 경로는 필터 적용 제외
        if (path.equals("/auth/signIn")) {
            return chain.filter(exchange);
        }

        String token = extractToken(exchange);

        if (token == null || !validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();  // 요청 차단
        }

        return chain.filter(exchange);  // 검증 통과 → 다음 필터로
    }

    private String extractToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);  // "Bearer " 이후의 토큰 추출
        }
        return null;
    }

    private boolean validateToken(String token) {
        try {
            SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
            Jws<Claims> claimsJws = Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token);
            log.info("토큰 페이로드: {}", claimsJws.getPayload());
            return true;
        } catch (Exception e) {
            return false;  // 만료, 위변조 등 모든 예외 → 인증 실패
        }
    }
}

application.yml (Gateway + Auth 연동)

spring:
  cloud:
    gateway:
      routes:
        - id: auth-service
          uri: lb://auth-service
          predicates:
            - Path=/auth/signIn   # 로그인 요청을 Auth Service로 라우팅

service:
  jwt:
    secret-key: "401b09eab3c013d4ca54922bb802bec8fd..."  # Auth Service와 동일한 키

⚠️ 시크릿 키는 Auth Service와 Gateway가 반드시 동일해야 합니다. 프로덕션에서는 환경변수나 Vault로 관리하세요.


4. 전체 아키텍처 통합

실행 순서

1. Eureka Server (서비스 레지스트리)
2. Gateway Service (단일 진입점)
3. Auth Service (JWT 발급)
4. Order Service
5. Product Service × 2 (로드밸런싱)

포트 구성

서비스포트역할
Eureka19090서비스 등록/조회
Gateway19091단일 진입점, 라우팅, JWT 검증
Order19092주문 처리
Product19093, 19094상품 조회 (로드밸런싱)
Auth19095JWT 발급

API 호출 예시

# 1. 토큰 발급
GET http://localhost:19091/auth/signIn?user_id=alice
# 응답: { "access_token": "eyJhbGci..." }

# 2. 인증 없이 상품 조회 → 실패
GET http://localhost:19091/product
# 응답: 401 Unauthorized

# 3. 토큰 포함하여 상품 조회 → 성공
GET http://localhost:19091/product
Authorization: Bearer eyJhbGci...
# 응답: "Product info!!!!! From port : 19093"

Bearer 토큰이란?

Bearer는 OAuth 2.0에서 정의한 인증 토큰 유형입니다. 클라이언트는 서버로부터 받은 토큰을 HTTP 헤더에 포함하기만 하면 됩니다.

Authorization: Bearer {JWT 토큰}

서버는 이 토큰의 유효성, 서명, 만료 시간을 검증하여 요청을 허용하거나 거부합니다. HTTPS와 함께 사용해야 보안이 보장됩니다.


정리

기술문제 해결핵심 개념
Resilience4j장애 전파 방지Closed / Open / Half-Open 상태 전환
Spring Cloud Gateway단일 진입점 + 라우팅Pre/Post Filter, 로드밸런싱
JWT + Auth Service서비스 간 인증토큰 발급 → Gateway 검증 → 서비스 접근

세 가지 기술을 조합하면 안정성(Circuit Breaker), 확장성(Gateway + 로드밸런싱), 보안(JWT) 을 모두 갖춘 프로덕션 수준의 MSA를 구축할 수 있습니다.


Spring Cloud | Resilience4j | API Gateway | JWT | MSA


참고 출처

본 글은 아래 강의 자료를 바탕으로 작성되었습니다.

Copyright ⓒ TeamSparta All rights reserved.

profile
안 되면 될 때까지

0개의 댓글