
마이크로서비스 아키텍처(MSA)에서 서비스 안정성과 보안을 구축하는 핵심 패턴을 실습 코드와 함께 알아봅니다.
마이크로서비스 환경에서는 수십, 수백 개의 서비스가 서로를 호출합니다. 하나의 서비스가 느려지거나 장애가 발생하면, 그 서비스를 호출하는 다른 서비스까지 연쇄적으로 문제가 퍼지는 장애 전파(Cascading Failure) 가 일어날 수 있습니다.
Circuit Breaker는 이 문제를 해결하기 위한 패턴입니다. 전기 회로 차단기처럼, 문제가 발생하면 회로를 끊어 장애가 더 이상 퍼지지 않도록 차단합니다.
[정상 동작] [임계값 초과] [대기 후 재시도]
CLOSED ──────────────→ OPEN ──────────────→ HALF-OPEN
↑ │
└──────────── 성공 ────────────────────────────┘
실패 시 다시 OPEN ──────────┘
| 상태 | 설명 |
|---|---|
| Closed | 기본 상태. 모든 요청을 통과시키며 실패율을 카운트 |
| Open | 실패율이 임계값 초과 시 전환. 모든 요청을 즉시 차단 |
| Half-Open | 대기 시간 후 전환. 제한된 요청만 허용하여 복구 여부 확인 |
예시: 최근 5번 호출 중 3번 실패(60%) → 임계값 50% 초과 → Open 상태로 전환 → 20초 차단 → Half-Open에서 3번 성공 → Closed로 복귀
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는 추상화 계층을 통해 동작하므로, 직접 제어를 위해 사용하지 않습니다.
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 즉시 호출
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 대시보드와 연동해 실시간 시각화가 가능합니다.
클라이언트는 수십 개의 마이크로서비스 주소를 알 필요 없이, 단일 진입점(Single Entry Point) 인 게이트웨이만 호출합니다. 게이트웨이는 내부적으로 요청을 적절한 서비스로 라우팅합니다.
클라이언트
│
▼
[API Gateway :19091]
├── /order/** ──→ order-service
├── /product/** ─→ product-service (로드밸런싱)
└── /auth/** ───→ auth-service
주요 기능: 라우팅, 인증/인가, 로드밸런싱, 모니터링, 요청/응답 변환
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/
게이트웨이 필터는 요청 전후에 원하는 로직을 삽입할 수 있습니다.
@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; // 가장 먼저 실행
}
}
@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; // 가장 마지막에 실행
}
}
| 객체 | 역할 |
|---|---|
ServerWebExchange | HTTP 요청/응답을 캡슐화. 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"
1. 클라이언트 → Gateway → Auth Service: 로그인 요청
2. Auth Service → 클라이언트: JWT 토큰 발급
3. 클라이언트 → Gateway (Authorization: Bearer {token}): API 요청
4. Gateway Pre Filter: JWT 검증
5. 검증 성공 → 해당 서비스로 라우팅
6. 검증 실패 → 401 Unauthorized 반환
@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();
}
}
@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;
}
}
@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; // 만료, 위변조 등 모든 예외 → 인증 실패
}
}
}
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로 관리하세요.
1. Eureka Server (서비스 레지스트리)
2. Gateway Service (단일 진입점)
3. Auth Service (JWT 발급)
4. Order Service
5. Product Service × 2 (로드밸런싱)
| 서비스 | 포트 | 역할 |
|---|---|---|
| Eureka | 19090 | 서비스 등록/조회 |
| Gateway | 19091 | 단일 진입점, 라우팅, JWT 검증 |
| Order | 19092 | 주문 처리 |
| Product | 19093, 19094 | 상품 조회 (로드밸런싱) |
| Auth | 19095 | JWT 발급 |
# 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는 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.