분산 시스템에서 "모든 서비스가 항상 정상 동작한다"고 가정하는 건 위험하다. 네트워크는 끊기고, 서비스는 느려지고, 컨테이너는 죽는다. 이런 장애를 어떻게 견디는 시스템을 만들 수 있을까?

SmallRye Fault Tolerance — 장애 내성 패턴

Quarkus는 MicroProfile Fault Tolerance 표준의 구현체인 SmallRye Fault Tolerance를 사용합니다. Spring 생태계의 Resilience4j와 같은 역할이지만, 어노테이션 기반으로 더 선언적입니다.

XML
<!-- pom.xml -->
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>

@Retry — 재시도

일시적인 장애(네트워크 타임아웃, 일시적 서버 오류)에 대응합니다.

JAVA
@ApplicationScoped
public class PaymentService {

    @Retry(maxRetries = 3, delay = 1000, retryOn = TimeoutException.class)
    public PaymentResult processPayment(PaymentRequest request) {
        return paymentGateway.charge(request);
    }
}
  • maxRetries: 최대 재시도 횟수
  • delay: 재시도 간격 (밀리초)
  • retryOn: 특정 예외에서만 재시도
  • abortOn: 특정 예외에서는 재시도하지 않음

재시도는 멱등성(idempotent)이 보장되는 작업에만 적용해야 합니다. 결제 같은 비멱등 작업에 무작정 재시도를 걸면 중복 결제가 발생할 수 있습니다.


@Timeout — 시간 제한

응답이 너무 오래 걸리는 호출을 강제로 중단합니다.

JAVA
@Timeout(value = 3, unit = ChronoUnit.SECONDS)
public InventoryStatus checkInventory(String productId) {
    return inventoryClient.check(productId);
}

타임아웃이 발생하면 TimeoutException이 던져집니다. 보통 @Retry@Fallback과 함께 사용합니다.


@CircuitBreaker — 서킷 브레이커

연속적인 실패가 감지되면 ** 일정 시간 동안 호출 자체를 차단 **합니다. 전기 회로의 차단기와 같은 개념입니다.

JAVA
@CircuitBreaker(
    requestVolumeThreshold = 10,    // 최소 요청 수
    failureRatio = 0.5,             // 실패율 50% 이상이면 OPEN
    delay = 5000,                    // OPEN 상태 유지 시간 (5초)
    successThreshold = 3             // HALF-OPEN에서 성공 3번이면 CLOSED
)
public RecommendationList getRecommendations(String userId) {
    return recommendationService.fetch(userId);
}

서킷 브레이커의 세 가지 상태를 정리하면 이렇습니다.

  • CLOSED (정상): 모든 요청이 통과. 실패를 카운팅
  • OPEN (차단): 요청을 즉시 실패 처리. CircuitBreakerOpenException 발생
  • HALF-OPEN (탐색): 일부 요청만 통과시켜서 서비스 복구 여부 확인
PLAINTEXT
정상 동작        실패 누적         시간 경과         성공 확인
  CLOSED ──→ OPEN ──────→ HALF-OPEN ──────→ CLOSED
                ↑                    │
                └────────────────────┘
                    실패 시 다시 OPEN

@Fallback — 대체 응답

장애 시 미리 정의해둔 대체 로직을 실행합니다.

JAVA
@Retry(maxRetries = 2)
@Fallback(fallbackMethod = "getDefaultRecommendations")
public RecommendationList getRecommendations(String userId) {
    return recommendationService.fetch(userId);
}

// 시그니처가 원본 메서드와 동일해야 함
public RecommendationList getDefaultRecommendations(String userId) {
    return RecommendationList.of("인기 상품 TOP 10");
}

@Fallback은 다른 어노테이션(@Retry, @CircuitBreaker, @Timeout)과 조합해서 사용합니다. 모든 재시도가 실패하거나 서킷이 열려 있을 때 Fallback이 호출됩니다.

Fallback 클래스를 별도로 분리할 수도 있습니다. @Fallback(value = MyFallbackHandler.class)FallbackHandler<T> 인터페이스 구현체를 지정하면 됩니다.


@Bulkhead — 격벽

동시 실행 수를 제한해서 하나의 느린 서비스가 전체 시스템의 스레드를 고갈시키는 것을 방지합니다.

JAVA
@Bulkhead(value = 5)  // 동시 최대 5개 요청만 처리
@Fallback(fallbackMethod = "bulkheadFallback")
public Report generateReport(String reportId) {
    return reportService.generate(reportId);
}

public Report bulkheadFallback(String reportId) {
    return Report.queued("리포트 생성 대기 중입니다. 잠시 후 다시 시도해주세요.");
}

비동기 방식에서는 대기 큐를 설정할 수도 있습니다.

JAVA
@Bulkhead(value = 5, waitingTaskQueue = 10)
@Asynchronous
public CompletionStage<Report> generateReportAsync(String reportId) {
    return reportService.generateAsync(reportId);
}

어노테이션 조합 — 실전 패턴

실무에서는 이 어노테이션들을 조합해서 사용합니다.

JAVA
@ApplicationScoped
public class OrderService {

    @Timeout(value = 5, unit = ChronoUnit.SECONDS)
    @Retry(maxRetries = 3, delay = 500, retryOn = TimeoutException.class)
    @CircuitBreaker(requestVolumeThreshold = 20, failureRatio = 0.5, delay = 10000)
    @Fallback(fallbackMethod = "orderFallback")
    @Bulkhead(value = 10)
    public OrderResult placeOrder(OrderRequest request) {
        return orderGateway.submit(request);
    }

    public OrderResult orderFallback(OrderRequest request) {
        // 주문을 메시지 큐에 넣고 나중에 처리
        orderQueue.enqueue(request);
        return OrderResult.pending("주문이 접수되었습니다. 잠시 후 확인해주세요.");
    }
}

실행 순서는 다음과 같습니다.

PLAINTEXT
요청 → Bulkhead(동시성 제한) → Timeout(시간 제한) → CircuitBreaker(차단 여부)
    → Retry(재시도) → 실제 메서드 호출
    → 모두 실패 시 → Fallback

Resilience4j(Spring) vs SmallRye(Quarkus) 비교

구분Resilience4jSmallRye Fault Tolerance
표준독자적 APIMicroProfile 표준
설정 방식프로퍼티 + 빌더 패턴** 어노테이션 중심**
서킷 브레이커슬라이딩 윈도우 기반롤링 윈도우 기반
비동기 지원CompletableFutureCompletionStage + Mutiny
메트릭Micrometer 연동MicroProfile Metrics 자동
런타임 설정 변경Registry를 통해 가능프로퍼티로 오버라이드 가능

SmallRye는 MicroProfile 표준이라 벤더 종속성이 없고, 다른 MicroProfile 런타임(Open Liberty, Payara 등)에서도 같은 코드가 동작합니다.


Stork — 서비스 디스커버리

마이크로서비스 간 통신에서 상대방 서비스의 주소를 어떻게 알아낼까? Quarkus는 SmallRye Stork 를 사용해서 서비스 디스커버리와 로드 밸런싱을 제공합니다.

XML
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-stork</artifactId>
</dependency>

Consul을 사용한 서비스 디스커버리

PROPERTIES
# application.properties
quarkus.stork.order-service.service-discovery.type=consul
quarkus.stork.order-service.service-discovery.consul-host=localhost
quarkus.stork.order-service.service-discovery.consul-port=8500
quarkus.stork.order-service.load-balancer.type=round-robin
JAVA
@RegisterRestClient(baseUri = "stork://order-service")
public interface OrderClient {

    @GET
    @Path("/orders/{id}")
    Order getOrder(@PathParam("id") Long id);
}

stork:// 프로토콜을 사용하면 Stork가 자동으로 서비스 디스커버리를 수행하고, 여러 인스턴스 중 하나를 로드 밸런싱 전략에 따라 선택합니다.

지원하는 디스커버리 소스

  • Consul: HashiCorp Consul
  • Kubernetes: k8s DNS/API를 통한 서비스 조회
  • Eureka: Netflix Eureka (Spring Cloud와 호환)
  • Static List: 고정 주소 목록 (개발/테스트용)
PROPERTIES
# Kubernetes 환경
quarkus.stork.order-service.service-discovery.type=kubernetes
quarkus.stork.order-service.service-discovery.k8s-namespace=production

# 개발 환경 — 고정 주소
quarkus.stork.order-service.service-discovery.type=static
quarkus.stork.order-service.service-discovery.address-list=localhost:8081,localhost:8082

SmallRye Reactive Messaging — 메시징

마이크로서비스 간의 비동기 통신은 메시지 브로커를 통해 이루어집니다. Quarkus는 SmallRye Reactive Messaging 을 통해 Kafka, AMQP, MQTT 등을 통일된 API로 다룹니다.

XML
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-messaging-kafka</artifactId>
</dependency>

메시지 발행 (Producer)

JAVA
@ApplicationScoped
public class OrderEventProducer {

    @Channel("order-events")
    Emitter<OrderEvent> emitter;

    public void publishOrderCreated(Order order) {
        OrderEvent event = new OrderEvent("CREATED", order.id, order.totalAmount);
        emitter.send(event);
    }
}

메시지 소비 (Consumer)

JAVA
@ApplicationScoped
public class OrderEventConsumer {

    @Incoming("order-events")
    public void onOrderEvent(OrderEvent event) {
        switch (event.type()) {
            case "CREATED" -> inventoryService.reserve(event.orderId());
            case "CANCELLED" -> inventoryService.release(event.orderId());
        }
    }
}

스트림 변환 (Processor)

JAVA
@ApplicationScoped
public class OrderEventProcessor {

    @Incoming("raw-orders")
    @Outgoing("processed-orders")
    public ProcessedOrder process(RawOrder raw) {
        // 변환 로직
        return new ProcessedOrder(raw.id(), calculateTotal(raw));
    }
}

Kafka 설정

PROPERTIES
# application.properties
mp.messaging.outgoing.order-events.connector=smallrye-kafka
mp.messaging.outgoing.order-events.topic=orders
mp.messaging.outgoing.order-events.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer

mp.messaging.incoming.order-events.connector=smallrye-kafka
mp.messaging.incoming.order-events.topic=orders
mp.messaging.incoming.order-events.value.deserializer=io.quarkus.kafka.client.serialization.ObjectMapperDeserializer
mp.messaging.incoming.order-events.group.id=inventory-service

Spring Kafka에서는 @KafkaListenerKafkaTemplate을 사용합니다. Quarkus의 Reactive Messaging은 @Incoming/@Outgoing으로 통일되어 있어서, Kafka를 AMQP로 바꿀 때 설정만 변경하면 코드는 동일 하게 유지됩니다.


MicroProfile 표준 — Health, Config, OpenTelemetry

Quarkus의 마이크로서비스 지원은 개별 라이브러리가 아니라 MicroProfile 표준 을 기반으로 합니다.

Health Check

JAVA
@Liveness
@ApplicationScoped
public class DatabaseHealthCheck implements HealthCheck {

    @Inject
    EntityManager em;

    @Override
    public HealthCheckResponse call() {
        try {
            em.createNativeQuery("SELECT 1").getSingleResult();
            return HealthCheckResponse.up("Database");
        } catch (Exception e) {
            return HealthCheckResponse.down("Database");
        }
    }
}

/q/health/live/q/health/ready 엔드포인트가 자동으로 생성됩니다. Kubernetes의 Liveness/Readiness Probe와 바로 연동됩니다.

MicroProfile Config

JAVA
@ApplicationScoped
public class PaymentConfig {

    @ConfigProperty(name = "payment.timeout", defaultValue = "5000")
    int timeout;

    @ConfigProperty(name = "payment.retry.max", defaultValue = "3")
    int maxRetries;
}

환경 변수, 시스템 프로퍼티, application.properties 등 여러 소스에서 설정을 통합해서 가져옵니다. Spring의 @Value와 비슷하지만 MicroProfile 표준입니다.

OpenTelemetry

PROPERTIES
quarkus.otel.exporter.otlp.endpoint=http://localhost:4317
quarkus.otel.service.name=order-service

별도 코드 없이 설정만으로 분산 추적이 활성화됩니다. HTTP 요청, DB 쿼리, 메시징 등에 자동으로 Span이 생성됩니다.


정리

Quarkus의 마이크로서비스 패턴을 한눈에 보면 이렇습니다.

패턴QuarkusSpring 대응
Fault ToleranceSmallRye (MicroProfile)Resilience4j
서비스 디스커버리StorkSpring Cloud Discovery
메시징Reactive MessagingSpring Kafka / Spring AMQP
Health CheckMicroProfile HealthActuator
설정 관리MicroProfile ConfigSpring Cloud Config
분산 추적OpenTelemetryMicrometer Tracing

공통점은 모두 MicroProfile 표준 을 기반으로 한다는 것입니다. 벤더 종속이 적고, 어노테이션 기반으로 선언적으로 사용할 수 있습니다. Spring Cloud와 비교했을 때 설정이 더 간결하고, 빌드 타임 최적화의 이점을 그대로 받습니다.

댓글 로딩 중...