# application.ymlresilience4j:circuitbreaker:instances:orderService:sliding-window-type:COUNT_BASEDsliding-window-size:10# 최근 10개 호출 기준failure-rate-threshold:50# 실패율 50% 초과 시 OPENwait-duration-in-open-state:10s# OPEN 상태 유지 시간permitted-number-of-calls-in-half-open-state:3# HALF_OPEN 시 시험 호출 수minimum-number-of-calls:5# 최소 5번 호출 후 판단record-exceptions:# 실패로 기록할 예외-java.io.IOException-java.util.concurrent.TimeoutException-org.springframework.web.client.HttpServerErrorException
@Service@RequiredArgsConstructorpublicclassOrderService{privatefinalRestClientrestClient;@CircuitBreaker(name="orderService",fallbackMethod="getOrderFallback")publicOrderResponsegetOrder(LongorderId){returnrestClient.get().uri("http://order-service/api/orders/{id}",orderId).retrieve().body(OrderResponse.class);}// 서킷이 OPEN이거나 호출 실패 시 실행되는 fallbackprivateOrderResponsegetOrderFallback(LongorderId,Throwablethrowable){log.warn("Circuit Breaker fallback 실행: orderId={}, error={}",orderId,throwable.getMessage());// 캐시된 데이터 반환, 기본값 반환, 또는 대체 서비스 호출returnOrderResponse.builder().orderId(orderId).status("UNKNOWN").message("주문 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해주세요.").build();}}
Circuit Breaker + Retry + TimeLimiter 조합
실무에서는 Circuit Breaker를 단독으로 쓰지 않고, Retry와 TimeLimiter를 함께 사용한다.
@Component@RequiredArgsConstructorpublicclassCircuitBreakerMonitor{privatefinalCircuitBreakerRegistryregistry;@EventListener(ApplicationReadyEvent.class)publicvoidregisterEventListeners(){CircuitBreakercb=registry.circuitBreaker("orderService");cb.getEventPublisher().onStateTransition(event->log.warn("Circuit Breaker 상태 변경: {} → {}",event.getStateTransition().getFromState(),event.getStateTransition().getToState())).onFailureRateExceeded(event->log.error("실패율 임계치 초과: {}%",event.getFailureRate()));}}
2. API Gateway — 단일 진입점
문제: 클라이언트가 모든 서비스 주소를 알아야 한다
1
2
3
4
5
6
7
8
9
10
클라이언트가 직접 호출하면:
Mobile App ──→ user-service:8081/api/users
──→ order-service:8082/api/orders
──→ payment-service:8083/api/payments
──→ notification-service:8084/api/notify
문제:
- 서비스 주소/포트 변경 시 클라이언트 수정 필요
- 인증/로깅을 각 서비스에서 중복 구현
- CORS, Rate Limiting 등을 서비스마다 설정
// 커스텀 필터: JWT 인증@ComponentpublicclassJwtAuthFilterimplementsGatewayFilterFactory<JwtAuthFilter.Config>{@OverridepublicGatewayFilterapply(Configconfig){return(exchange,chain)->{Stringtoken=exchange.getRequest().getHeaders().getFirst("Authorization");if(token==null||!token.startsWith("Bearer ")){exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);returnexchange.getResponse().setComplete();}// JWT 검증 로직Stringjwt=token.substring(7);Claimsclaims=validateToken(jwt);// 검증된 사용자 정보를 헤더에 추가하여 하위 서비스로 전달ServerHttpRequestmodifiedRequest=exchange.getRequest().mutate().header("X-User-Id",claims.getSubject()).header("X-User-Role",claims.get("role",String.class)).build();returnchain.filter(exchange.mutate().request(modifiedRequest).build());};}publicstaticclassConfig{}}
3. Service Discovery — 동적 서비스 탐색
문제: IP 주소 하드코딩의 한계
컨테이너 환경에서는 서비스 인스턴스가 동적으로 생성·삭제된다. IP 주소를 하드코딩하면 인스턴스가 바뀔 때마다 설정을 수정해야 한다.
1
2
3
4
5
하드코딩:
order-service.url=http://192.168.1.10:8082 → 인스턴스 교체 시 수정 필요
Service Discovery:
order-service.url=http://ORDER-SERVICE → 자동으로 가용 인스턴스로 연결
Eureka 아키텍처
Spring Cloud Netflix Eureka는 가장 널리 사용되는 Service Discovery 솔루션이다.
spring:application:name:ORDER-SERVICE# Eureka에 등록될 이름eureka:client:service-url:defaultZone:http://localhost:8761/eureka/instance:prefer-ip-address:true# 호스트명 대신 IP 사용
이렇게 설정하면 lb://ORDER-SERVICE로 호출 시 Eureka에서 가용 인스턴스를 자동으로 찾아 로드밸런싱한다.
참고: Kubernetes 환경에서는 k8s Service가 Service Discovery 역할을 하므로 Eureka 없이도 동작한다. 다만 Spring Cloud와의 통합이 필요하면 Eureka 또는 Consul을 함께 사용하기도 한다.
4. Saga 패턴 — 분산 트랜잭션 관리
문제: 분산 트랜잭션
모놀리식에서는 하나의 DB 트랜잭션으로 여러 테이블을 원자적으로 업데이트할 수 있었다. 하지만 MSA에서는 서비스마다 독립된 DB를 가지므로 단일 트랜잭션으로 묶을 수 없다.
1
2
3
4
5
6
주문 생성 프로세스:
1. Order Service → 주문 생성 (Order DB)
2. Payment Service → 결제 처리 (Payment DB)
3. Inventory Service → 재고 차감 (Inventory DB)
결제는 성공했는데 재고 차감이 실패하면? → 결제를 취소해야 한다
2PC(Two-Phase Commit)는 분산 DB 간 원자적 커밋을 보장하지만, 성능이 낮고 단일 장애점이 생기며, NoSQL은 지원하지 않는 경우가 많아 MSA에서는 잘 사용하지 않는다.
Saga 패턴 개요
Saga는 로컬 트랜잭션의 시퀀스로 분산 트랜잭션을 구현한다. 각 단계가 성공하면 다음 단계를 실행하고, 실패하면 보상 트랜잭션(Compensating Transaction)을 역순으로 실행하여 이전 단계를 취소한다.
// Order Service — 주문 생성 후 이벤트 발행@Service@RequiredArgsConstructorpublicclassOrderService{privatefinalOrderRepositoryorderRepository;privatefinalKafkaTemplate<String,OrderEvent>kafkaTemplate;@TransactionalpublicOrdercreateOrder(CreateOrderRequestrequest){Orderorder=Order.create(request);order.setStatus(OrderStatus.PENDING);orderRepository.save(order);kafkaTemplate.send("order-events",order.getId(),newOrderCreatedEvent(order.getId(),order.getAmount(),order.getItems()));returnorder;}// Payment 실패 시 보상 트랜잭션@KafkaListener(topics="payment-events",groupId="order-service")publicvoidhandlePaymentEvent(PaymentEventevent){if(eventinstanceofPaymentFailedEventfailed){Orderorder=orderRepository.findById(failed.getOrderId()).orElseThrow();order.setStatus(OrderStatus.CANCELLED);order.setCancelReason("결제 실패: "+failed.getReason());orderRepository.save(order);}}}
// Payment Service — 결제 처리 후 이벤트 발행@Service@RequiredArgsConstructorpublicclassPaymentService{@KafkaListener(topics="order-events",groupId="payment-service")publicvoidhandleOrderCreated(OrderCreatedEventevent){try{Paymentpayment=processPayment(event.getOrderId(),event.getAmount());kafkaTemplate.send("payment-events",event.getOrderId(),newPaymentCompletedEvent(event.getOrderId(),payment.getId()));}catch(PaymentExceptione){kafkaTemplate.send("payment-events",event.getOrderId(),newPaymentFailedEvent(event.getOrderId(),e.getMessage()));}}}
Orchestration (중앙 조정자)
Saga Orchestrator가 전체 플로우를 관리하고, 각 단계의 성공/실패에 따라 다음 액션을 결정한다.