diff --git a/common-module/src/main/java/com/comatching/common/config/CircuitBreakerAlertNotifier.java b/common-module/src/main/java/com/comatching/common/config/CircuitBreakerAlertNotifier.java new file mode 100644 index 0000000..ab12185 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/config/CircuitBreakerAlertNotifier.java @@ -0,0 +1,166 @@ +package com.comatching.common.config; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent; +import lombok.extern.slf4j.Slf4j; + +/** + * 서킷브레이커 open 알림. + * + * 브레이커가 열리면 서비스는 스스로 버티지만(즉시 503), 원인 서비스를 + * 사람이 고치기 전까지는 복구되지 않는다. 열렸다는 사실을 사람이 모르면 + * 브레이커는 장애를 "견디는" 장치가 아니라 "숨기는" 장치가 된다. + * + * Prometheus + Alertmanager 가 아니라 발행 시점 직접 웹훅인 이유는 + * KafkaDltAlertNotifier 와 같다 — 모니터링 스택은 부하 테스트용으로 필요할 + * 때만 띄우는 구성이라 상시 알림의 기반이 못 된다. 웹훅 본문의 + * {"content","text"} 겸용 키 규칙도 같다(Discord/Slack 겸용). + * + * 억제 규칙: + * - open 알림은 브레이커별 쿨다운을 두고, 억제된 횟수는 다음 알림에 합산해 + * 보고한다. 장애가 이어지면 브레이커는 open → half-open → open 을 + * wait-duration(10s)마다 반복하므로, 쿨다운이 없으면 웹훅이 도배된다. + * - 복구(half-open → closed) 알림은 open 알림이 실제로 나간 경우에만 한 번 + * 보낸다. 억제된 open 에 복구 알림까지 짝지어 보내면 도배가 두 배가 된다. + * + * 전송은 별도 스레드에서 한다 — 상태 전이 이벤트는 그 전이를 일으킨 요청 + * 스레드에서 동기로 발화하므로, 웹훅 HTTP(최대 3s)가 사용자 응답을 붙잡으면 + * 안 된다. 전송 실패는 삼킨다. + */ +@Slf4j +public class CircuitBreakerAlertNotifier { + + private static final int HTTP_TIMEOUT_MS = 3_000; + + private final boolean enabled; + private final String serviceName; + private final long cooldownMs; + private final RestClient restClient; + private final ExecutorService deliveryExecutor; + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + + private static final class Window { + volatile long lastSentAt = -1; + volatile boolean openAlerted = false; + final AtomicLong suppressed = new AtomicLong(); + } + + public CircuitBreakerAlertNotifier(String webhookUrl, String serviceName, Duration cooldown) { + this.enabled = webhookUrl != null && !webhookUrl.isBlank(); + this.serviceName = serviceName; + this.cooldownMs = cooldown.toMillis(); + + if (enabled) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(HTTP_TIMEOUT_MS); + requestFactory.setReadTimeout(HTTP_TIMEOUT_MS); + this.restClient = RestClient.builder() + .baseUrl(webhookUrl) + .requestFactory(requestFactory) + .build(); + this.deliveryExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "circuit-breaker-alert"); + thread.setDaemon(true); + return thread; + }); + } else { + this.restClient = null; + this.deliveryExecutor = null; + log.info("서킷브레이커 알림 웹훅 미설정(comatching.feign.circuit-breaker-alert-webhook-url)" + + " - open 시 로그만 남는다"); + } + } + + public void onStateTransition(CircuitBreakerOnStateTransitionEvent event) { + CircuitBreaker.State toState = event.getStateTransition().getToState(); + String breakerName = event.getCircuitBreakerName(); + + if (toState == CircuitBreaker.State.OPEN) { + onOpen(breakerName); + return; + } + if (toState == CircuitBreaker.State.CLOSED) { + onRecovered(breakerName); + } + } + + private void onOpen(String breakerName) { + // 웹훅과 무관하게 항상 남긴다. 웹훅이 없거나 죽었을 때의 최후 흔적이다. + log.error("서킷브레이커 OPEN: {} (service={})", breakerName, serviceName); + + if (!enabled) { + return; + } + + Window window = windows.computeIfAbsent(breakerName, name -> new Window()); + long suppressedSoFar; + long now = nowMillis(); + synchronized (window) { + if (window.lastSentAt >= 0 && now - window.lastSentAt < cooldownMs) { + window.suppressed.incrementAndGet(); + return; + } + suppressedSoFar = window.suppressed.getAndSet(0); + window.lastSentAt = now; + window.openAlerted = true; + } + + StringBuilder message = new StringBuilder() + .append("🔴 [").append(serviceName).append("] 서킷브레이커 OPEN: ").append(breakerName).append('\n') + .append(breakerName).append(" 로 가는 내부 호출이 차단되고 있다 (503 응답)"); + if (suppressedSoFar > 0) { + message.append("\n(직전 쿨다운 동안 open 전이 ").append(suppressedSoFar).append("회 추가 발생)"); + } + deliverAsync(message.toString()); + } + + private void onRecovered(String breakerName) { + if (!enabled) { + return; + } + + Window window = windows.get(breakerName); + if (window == null) { + return; + } + synchronized (window) { + if (!window.openAlerted) { + return; + } + window.openAlerted = false; + } + + deliverAsync("🟢 [" + serviceName + "] 서킷브레이커 복구: " + breakerName); + } + + protected void deliverAsync(String message) { + deliveryExecutor.execute(() -> deliver(message)); + } + + protected void deliver(String message) { + try { + restClient.post() + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("content", message, "text", message)) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + log.warn("서킷브레이커 알림 웹훅 전송 실패 - 호출 흐름에는 영향 없음", e); + } + } + + protected long nowMillis() { + return System.currentTimeMillis(); + } +} diff --git a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java index d983d6a..db08342 100644 --- a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java +++ b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java @@ -1,5 +1,7 @@ package com.comatching.common.config; +import java.time.Duration; + import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.openfeign.CircuitBreakerNameResolver; import org.springframework.context.annotation.Bean; @@ -7,6 +9,8 @@ import org.springframework.util.StringUtils; import feign.RequestInterceptor; +import feign.Retryer; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; @Configuration public class InternalFeignConfig { @@ -37,4 +41,33 @@ public RequestInterceptor internalServiceTokenRequestInterceptor( public CircuitBreakerNameResolver feignClientCircuitBreakerNameResolver() { return (feignClientName, target, method) -> feignClientName; } + + /** + * Spring Cloud OpenFeign 기본은 NEVER_RETRY. 이 빈이 그 기본을 대체한다. + * 재시도 범위·근거는 InternalGetRetryer 참고. + */ + @Bean + public Retryer feignRetryer() { + return new InternalGetRetryer(); + } + + /** + * 브레이커 open/복구를 웹훅으로 알린다. 브레이커는 Feign 호출 시점에 + * 레지스트리에 늦게 만들어지므로, 기존 항목 순회가 아니라 onEntryAdded + * 훅으로 앞으로 만들어질 브레이커 전부에 리스너를 건다. + */ + @Bean + public CircuitBreakerAlertNotifier circuitBreakerAlertNotifier( + @Value("${comatching.feign.circuit-breaker-alert-webhook-url:}") String webhookUrl, + @Value("${spring.application.name:unknown}") String serviceName, + CircuitBreakerRegistry circuitBreakerRegistry + ) { + CircuitBreakerAlertNotifier notifier = + new CircuitBreakerAlertNotifier(webhookUrl, serviceName, Duration.ofMinutes(5)); + + circuitBreakerRegistry.getEventPublisher().onEntryAdded(event -> + event.getAddedEntry().getEventPublisher().onStateTransition(notifier::onStateTransition) + ); + return notifier; + } } diff --git a/common-module/src/main/java/com/comatching/common/config/InternalGetRetryer.java b/common-module/src/main/java/com/comatching/common/config/InternalGetRetryer.java new file mode 100644 index 0000000..d22b39b --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/config/InternalGetRetryer.java @@ -0,0 +1,53 @@ +package com.comatching.common.config; + +import feign.Request; +import feign.RetryableException; +import feign.Retryer; + +/** + * GET 한정 1회 재시도. + * + * RetryableException 은 HTTP 응답을 받지 못한 전송 실패(연결 거부·타임아웃)다. + * 배포 직후 재기동, 순간적인 커넥션 끊김 같은 일시 장애는 한 번만 다시 두드려도 + * 대부분 회복되므로, 안전한 요청에 한해 짧게 재시도한다. + * + * GET 으로 제한하는 이유: 전송 실패는 "서버가 처리를 했는지" 알 수 없는 + * 상태다. read-timeout 이라면 요청이 이미 서버에 도달해 처리됐을 수 있고, + * 그 상태에서 POST(아이템 차감, 방 생성 등)를 다시 보내면 중복 실행이 된다. + * GET 은 멱등이라 이 위험이 없다. + * + * 대가: GET 의 최악 지연이 read-timeout 두 배(기본 3s → 6.1s)가 된다. + * 지속 장애라면 재시도까지 실패가 쌓여 서킷브레이커가 열리므로 두 배 대기가 + * 무한정 반복되지는 않는다. 재시도 전체가 브레이커 안에서 한 번의 호출로 + * 집계된다는 점도 참고. + * + * feign 은 요청마다 clone() 으로 새 인스턴스를 만들어 쓰므로 attempt 는 + * 요청 단위 상태다. + */ +public class InternalGetRetryer implements Retryer { + + private static final int MAX_ATTEMPTS = 2; + private static final long BACKOFF_MILLIS = 100; + + private int attempt = 1; + + @Override + public void continueOrPropagate(RetryableException e) { + if (e.method() != Request.HttpMethod.GET || attempt >= MAX_ATTEMPTS) { + throw e; + } + attempt++; + + try { + Thread.sleep(BACKOFF_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw e; + } + } + + @Override + public Retryer clone() { + return new InternalGetRetryer(); + } +} diff --git a/common-module/src/main/resources/feign-resilience.yml b/common-module/src/main/resources/feign-resilience.yml index 7e77868..fb2e80a 100644 --- a/common-module/src/main/resources/feign-resilience.yml +++ b/common-module/src/main/resources/feign-resilience.yml @@ -45,6 +45,13 @@ spring: # 시간 상한이 된다. disable-thread-pool: true +comatching: + feign: + # 브레이커 open/복구 알림 웹훅(Discord/Slack 겸용). 비우면 로그만 남는다. + # 전용 env 가 없으면 DLT 알림과 같은 웹훅을 쓴다 — 두 알림 모두 + # "사람이 개입해야 하는 상태"라 받는 채널이 같은 게 자연스럽다. + circuit-breaker-alert-webhook-url: ${CIRCUIT_BREAKER_ALERT_WEBHOOK_URL:${DLT_ALERT_WEBHOOK_URL:}} + resilience4j: circuitbreaker: configs: diff --git a/common-module/src/test/java/com/comatching/common/config/CircuitBreakerAlertNotifierTest.java b/common-module/src/test/java/com/comatching/common/config/CircuitBreakerAlertNotifierTest.java new file mode 100644 index 0000000..79da46c --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/CircuitBreakerAlertNotifierTest.java @@ -0,0 +1,115 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent; + +/** + * open 알림 쿨다운·복구 짝 규칙 검증. + * + * 지속 장애 중 브레이커는 open → half-open → open 을 wait-duration(10s)마다 + * 반복한다. 쿨다운이 없으면 장애 5분에 알림 30건이 되고, 억제된 open 에 + * 복구 알림까지 짝지어 보내면 도배가 두 배가 된다. KafkaDltAlertNotifierTest + * 처럼 시계를 주입해 경계를 결정적으로 확인한다. + */ +@DisplayName("서킷브레이커 open 알림") +class CircuitBreakerAlertNotifierTest { + + private static final Duration COOLDOWN = Duration.ofMinutes(5); + private static final String BREAKER = "user-service"; + + static class TestNotifier extends CircuitBreakerAlertNotifier { + long now = 1L; + final List sent = new ArrayList<>(); + + TestNotifier(String webhookUrl) { + super(webhookUrl, "matching-service", COOLDOWN); + } + + @Override + protected void deliverAsync(String message) { + // 실제 구현은 별도 스레드로 넘기지만, 테스트는 동기로 받아 검증한다. + sent.add(message); + } + + @Override + protected long nowMillis() { + return now; + } + } + + private static CircuitBreakerOnStateTransitionEvent event(CircuitBreaker.StateTransition transition) { + return new CircuitBreakerOnStateTransitionEvent(BREAKER, transition); + } + + @Test + @DisplayName("open 전이는 알림을 보내고, 쿨다운 안의 재발은 억제 후 다음 알림에 합산한다") + void cooldownSuppressesRepeatedOpens() { + TestNotifier notifier = new TestNotifier("http://webhook.example"); + + notifier.onStateTransition(event(CircuitBreaker.StateTransition.CLOSED_TO_OPEN)); + assertThat(notifier.sent).hasSize(1); + assertThat(notifier.sent.get(0)).contains("OPEN", BREAKER, "matching-service"); + + // 쿨다운 안 재발(half-open 에서 다시 실패) 3회 - 억제 + notifier.now += COOLDOWN.toMillis() - 1; + for (int i = 0; i < 3; i++) { + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_OPEN)); + } + assertThat(notifier.sent).hasSize(1); + + // 쿨다운이 지나면 다시 보내고, 억제분을 합산 보고한다 + notifier.now += 1; + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_OPEN)); + assertThat(notifier.sent).hasSize(2); + assertThat(notifier.sent.get(1)).contains("3회"); + } + + @Test + @DisplayName("복구 알림은 open 알림이 실제로 나간 경우에만 한 번 보낸다") + void recoveryNotifiesOncePerSentOpenAlert() { + TestNotifier notifier = new TestNotifier("http://webhook.example"); + + // open 알림 없이 복구 전이만 오면(예: 재기동 직후) 보내지 않는다 + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_CLOSED)); + assertThat(notifier.sent).isEmpty(); + + notifier.onStateTransition(event(CircuitBreaker.StateTransition.CLOSED_TO_OPEN)); + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_CLOSED)); + assertThat(notifier.sent).hasSize(2); + assertThat(notifier.sent.get(1)).contains("복구", BREAKER); + + // 같은 복구가 또 와도(또는 open 알림이 억제된 채 복구되어도) 반복하지 않는다 + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_CLOSED)); + assertThat(notifier.sent).hasSize(2); + } + + @Test + @DisplayName("open/closed 외의 전이(half-open 진입)는 알리지 않는다") + void ignoresIntermediateTransitions() { + TestNotifier notifier = new TestNotifier("http://webhook.example"); + + notifier.onStateTransition(event(CircuitBreaker.StateTransition.OPEN_TO_HALF_OPEN)); + + assertThat(notifier.sent).isEmpty(); + } + + @Test + @DisplayName("웹훅 미설정이면 아무것도 보내지 않고 조용히 동작한다") + void disabledWithoutWebhook() { + TestNotifier notifier = new TestNotifier(""); + + notifier.onStateTransition(event(CircuitBreaker.StateTransition.CLOSED_TO_OPEN)); + notifier.onStateTransition(event(CircuitBreaker.StateTransition.HALF_OPEN_TO_CLOSED)); + + assertThat(notifier.sent).isEmpty(); + } +} diff --git a/common-module/src/test/java/com/comatching/common/config/FeignRetryTest.java b/common-module/src/test/java/com/comatching/common/config/FeignRetryTest.java new file mode 100644 index 0000000..e69bec4 --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignRetryTest.java @@ -0,0 +1,148 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import feign.RetryableException; + +/** + * GET 한정 1회 재시도(InternalGetRetryer) 검증. + * + * 핵심은 "몇 번 다시 보냈는가"이므로, 요청을 받고도 절대 응답하지 않는 + * 소켓 서버를 상대로 실제 연결 횟수를 센다. read-timeout 마다 연결이 + * 죽으므로 시도 1회 = 연결 1회다. + * + * - GET: 원 호출 + 재시도 1회 = 연결 2회 후 RetryableException + * - POST: 재시도 없이 연결 1회 후 즉시 RetryableException — + * 전송 실패는 서버가 처리를 했는지 알 수 없는 상태라, 비멱등 요청을 + * 다시 보내면 중복 실행(아이템 이중 차감 등)이 된다 + * + * 서킷브레이커 경로(feign-resilience.yml)를 그대로 물고 돌므로, 재시도가 + * 브레이커 안에서도 동작한다는 것까지 함께 검증된다. + */ +@SpringBootTest( + classes = FeignRetryTest.App.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.config.import=classpath:feign-resilience.yml", + // 기본 read-timeout(3s) x 시도 2회를 기다리지 않도록 짧게 줄인다 + "spring.cloud.openfeign.client.config.retry-get.connect-timeout=1000", + "spring.cloud.openfeign.client.config.retry-get.read-timeout=300", + "spring.cloud.openfeign.client.config.retry-post.connect-timeout=1000", + "spring.cloud.openfeign.client.config.retry-post.read-timeout=300" + } +) +@DisplayName("Feign GET 한정 재시도") +class FeignRetryTest { + + private static final ServerSocket SILENT_SERVER; + private static final AtomicInteger CONNECTIONS = new AtomicInteger(); + private static final List ACCEPTED = new CopyOnWriteArrayList<>(); + + static { + try { + SILENT_SERVER = new ServerSocket(0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + Thread acceptLoop = new Thread(() -> { + try { + while (true) { + Socket socket = SILENT_SERVER.accept(); + CONNECTIONS.incrementAndGet(); + ACCEPTED.add(socket); // 읽지도 답하지도 않는다 - 클라이언트는 read-timeout 으로 끊는다 + } + } catch (IOException e) { + // 서버 소켓 close 로 종료 + } + }, "silent-server-accept"); + acceptLoop.setDaemon(true); + acceptLoop.start(); + } + + @DynamicPropertySource + static void serverUrl(DynamicPropertyRegistry registry) { + registry.add("retry-it.base-url", () -> "http://localhost:" + SILENT_SERVER.getLocalPort()); + } + + @AfterAll + static void stopServer() throws IOException { + SILENT_SERVER.close(); + for (Socket socket : ACCEPTED) { + socket.close(); + } + } + + @FeignClient(name = "retry-get", url = "${retry-it.base-url}") + interface GetClient { + @GetMapping("/anything") + String call(); + } + + @FeignClient(name = "retry-post", url = "${retry-it.base-url}") + interface PostClient { + @PostMapping("/anything") + String call(); + } + + @SpringBootConfiguration + @ImportAutoConfiguration({ + org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class, + org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration.class, + org.springframework.cloud.openfeign.FeignAutoConfiguration.class, + io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration.class, + io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterAutoConfiguration.class, + org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JAutoConfiguration.class + }) + @EnableFeignClients(clients = {GetClient.class, PostClient.class}) + @Import({InternalFeignConfig.class, FeignCircuitBreakerExceptionUnwrapper.class}) + static class App { + } + + @Autowired + GetClient getClient; + + @Autowired + PostClient postClient; + + @Test + @DisplayName("GET 은 한 번 재시도한다 - 연결 2회 후 실패") + void getRetriesOnce() { + int before = CONNECTIONS.get(); + + assertThatThrownBy(getClient::call).isInstanceOf(RetryableException.class); + + assertThat(CONNECTIONS.get() - before).isEqualTo(2); + } + + @Test + @DisplayName("POST 는 재시도하지 않는다 - 연결 1회 후 즉시 실패") + void postDoesNotRetry() { + int before = CONNECTIONS.get(); + + assertThatThrownBy(postClient::call).isInstanceOf(RetryableException.class); + + assertThat(CONNECTIONS.get() - before).isEqualTo(1); + } +}