Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String, Window> 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();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;

import feign.RequestInterceptor;
import feign.Retryer;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;

@Configuration
public class InternalFeignConfig {
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
7 changes: 7 additions & 0 deletions common-module/src/main/resources/feign-resilience.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading