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
4 changes: 4 additions & 0 deletions common-module/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ dependencies {
// feign
api 'org.springframework.cloud:spring-cloud-starter-openfeign'

// 서킷브레이커 (Resilience4j) — Feign 내부 호출 보호.
// 설정은 feign-resilience.yml, 브레이커 이름 규칙은 InternalFeignConfig 참고.
api 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'

// kafka
api 'org.springframework.kafka:spring-kafka'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.comatching.common.config;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;

import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

/**
* 서킷브레이커가 씌운 래퍼를 벗겨 Feign 원본 예외를 복원한다.
*
* spring.cloud.openfeign.circuitbreaker.enabled 를 켜면 폴백 없는 Feign 호출의
* 모든 예외가 NoFallbackAvailableException 으로 래핑되어 나온다. 그대로 두면
* 기존 호출부의 catch (FeignException) 분기(예: 404 를 비즈니스 에러로 변환)가
* 전부 조용히 무력화된다 — 컴파일 에러도 없이 의미가 바뀌는 종류의 회귀다.
*
* 그래서 Feign 클라이언트 빈을 한 겹 감싸 래퍼의 cause(FeignException,
* CallNotPermittedException 등)를 그대로 다시 던진다. 호출부 입장에서는
* 서킷브레이커 도입 전과 같은 예외 계약이 유지되고, 브레이커 open 만
* CallNotPermittedException 이라는 새 예외로 추가된다.
*/
@Component
public class FeignCircuitBreakerExceptionUnwrapper implements BeanPostProcessor {

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (!isFeignClient(bean)) {
return bean;
}

return Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, method, args) -> {
try {
return method.invoke(bean, args);
} catch (InvocationTargetException e) {
Throwable thrown = e.getTargetException();
if (thrown instanceof NoFallbackAvailableException && thrown.getCause() != null) {
throw thrown.getCause();
}
throw thrown;
}
}
);
}

private boolean isFeignClient(Object bean) {
for (Class<?> itf : bean.getClass().getInterfaces()) {
if (itf.isAnnotationPresent(FeignClient.class)) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.comatching.common.config;

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;
Expand All @@ -22,4 +23,18 @@ public RequestInterceptor internalServiceTokenRequestInterceptor(
}
};
}

/**
* 서킷브레이커를 Feign 클라이언트(name) 단위로 묶는다.
*
* 기본 리졸버는 메서드 시그니처까지 붙여 브레이커를 만드는데, 그러면
* 트래픽이 메서드별로 쪼개져 minimum-number-of-calls(feign-resilience.yml)에
* 도달하지 못한 브레이커는 영영 열리지 않는다. 장애는 엔드포인트가 아니라
* 프로세스 단위로 오므로(피호출 서비스의 GC 멈춤·커넥션 고갈은 그 서비스의
* 모든 엔드포인트를 함께 느리게 만든다) 서비스 단위 판단이 맞다.
*/
@Bean
public CircuitBreakerNameResolver feignClientCircuitBreakerNameResolver() {
return (feignClientName, target, method) -> feignClientName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public enum GeneralErrorCode implements ErrorCode {
UNAUTHORIZED("GEN-010", HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."),
FORBIDDEN("GEN-011", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),

// 503 Service Unavailable
INTERNAL_SERVICE_UNAVAILABLE("GEN-105", HttpStatus.SERVICE_UNAVAILABLE, "내부 서비스가 일시적으로 응답할 수 없습니다. 잠시 후 다시 시도해주세요."),

// 500 Internal Server Error
INTERNAL_SERVER_ERROR("GEN-099", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
DATABASE_ERROR("GEN-100", HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 오류가 발생했습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.HashMap;
import java.util.Map;

import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindingResult;
Expand All @@ -23,6 +24,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;

import feign.FeignException;
import feign.RetryableException;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

Expand Down Expand Up @@ -138,6 +141,61 @@ public ResponseEntity<ApiResponse<Void>> handleNotFoundException(Exception e) {
.body(ApiResponse.errorResponse(GeneralErrorCode.NOT_FOUND));
}

/**
* 서킷브레이커 open (503)
*
* 피호출 서비스가 장애 판정을 받아 호출이 차단된 상태. 서버 오류(500)가
* 아니라 일시적 불가(503)로 내려야 클라이언트·게이트웨이가 재시도 판단을
* 할 수 있다.
*/
@ExceptionHandler(CallNotPermittedException.class)
public ResponseEntity<ApiResponse<Void>> handleCallNotPermittedException(CallNotPermittedException e) {
log.error("[Circuit Breaker Open] Breaker: {}", e.getCausingCircuitBreakerName());

return ResponseEntity
.status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus())
.body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE));
}

/**
* Feign 연결 실패·타임아웃 (503)
*
* FeignException 의 하위 타입이지만 더 구체적이라 이 핸들러가 먼저 잡는다.
* HTTP 응답을 받은 게 아니므로 아래 핸들러처럼 응답 본문을 전달할 수 없고,
* 피호출 서비스의 일시적 불가(503)로 내린다.
*/
@ExceptionHandler(RetryableException.class)
public ResponseEntity<ApiResponse<Void>> handleFeignRetryableException(RetryableException e) {
log.error("[Feign Timeout] {}", e.getMessage());

return ResponseEntity
.status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus())
.body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE));
}

/**
* 서킷브레이커 래퍼 언랩 (방어선)
*
* 정상 경로에서는 FeignCircuitBreakerExceptionUnwrapper 가 프록시 단계에서
* 원본 예외로 복원하므로 여기까지 오지 않는다. 그 층을 우회한 호출이
* 생기더라도 원인에 맞는 응답이 나가도록 한 번 더 푼다.
*/
@ExceptionHandler(NoFallbackAvailableException.class)
public ResponseEntity<?> handleNoFallbackAvailableException(NoFallbackAvailableException e) {
Throwable cause = e.getCause();

if (cause instanceof CallNotPermittedException callNotPermitted) {
return handleCallNotPermittedException(callNotPermitted);
}
if (cause instanceof RetryableException retryable) {
return handleFeignRetryableException(retryable);
}
if (cause instanceof FeignException feignException) {
return handleFeignException(feignException);
}
return handleException(e);
}

/**
* Feign Client 예외 처리
*/
Expand Down
36 changes: 36 additions & 0 deletions common-module/src/main/resources/feign-resilience.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
spring:
cloud:
openfeign:
# 모든 Feign 호출을 서킷브레이커로 감싼다. 브레이커 이름은 메서드 단위가
# 아니라 Feign 클라이언트(name) 단위로 묶는다 — InternalFeignConfig 의
# CircuitBreakerNameResolver 참고.
circuitbreaker:
enabled: true
client:
config:
default:
Expand All @@ -31,3 +36,34 @@ spring:
user-service-admin: # 관리자 페이징 조회
connect-timeout: 1000
read-timeout: 5000
circuitbreaker:
resilience4j:
# 호출 스레드에서 그대로 실행한다(세마포어 방식). 스레드풀 실행을 켜두면
# ① Spring Cloud 기본 TimeLimiter(1s)가 위의 read-timeout 보다 먼저
# 호출을 끊고 ② EC2 한 대에 6개 JVM 이 뜨는 전제에서 서비스마다
# 스레드풀이 하나씩 늘어난다. 끄면 Feign read-timeout 이 유일한
# 시간 상한이 된다.
disable-thread-pool: true

resilience4j:
circuitbreaker:
configs:
# 모든 브레이커(= Feign 클라이언트 단위)의 공통값. 클라이언트별로 바꾸려면
# resilience4j.circuitbreaker.instances.<Feign name> 으로 오버라이드한다.
default:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
# 내부 호출은 클라이언트별 트래픽이 적다. 이 값에 못 미치면 브레이커가
# 영영 안 열리므로 낮게 잡는다.
minimum-number-of-calls: 5
failure-rate-threshold: 50
# read-timeout(3s)까지 가기 전에 "느려짐"을 먼저 감지한다.
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 80
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
# 4xx 는 피호출 서비스가 정상 동작하며 내린 판정(없는 리소스, 잘못된
# 요청)이라 장애 신호가 아니다. 실패율에 넣으면 비즈니스 에러가
# 브레이커를 열어 멀쩡한 서비스로 가는 호출까지 차단한다.
ignore-exceptions:
- feign.FeignException$FeignClientException
Loading
Loading