문제 정의
- 서버간 circuit breaker 적용의 이해
- gateway에서 통합적으로 서킷브레이커를 관리할 수 있다는 글을 보긴 했는데, 서킷브레이커에 대한 이해도가 낮아 gateway에서 모든 서비스의 개별 api에 대한 open상태를 감지하여 대응할 수 있는 줄로만 알았습니다. 처음에 이렇게만 생각하고 이것저것 해보다가 결국에는 뭔가 잘못 됐다는 것을 깨달았죠.
해결과정
- 제가 궁극적으로 하고 싶었던 목표는 다음과 같습니다.
- 각 서버별 api를 요청했을 때 서킷브레이커 open 상태를 감지하여 gateway 설정으로 간편하게 관리할 수 있다.
- 1번의 방법이 적용되면 각 서버별 api에 서킷브레이커를 적용할 필요가 없으니 팀원들의 시간이 절약된다.
결과
- 코드
- 크게 2가지만 보여드리려고 합니다. gateway에 적용한 서킷브레이커 그리고 auth 서버에 적용한 서킷브레이커를 코드를 보여드리겠습니다.
- gateway의 application.yml
다른 부분은 크게 볼 필요는 없고 gateway의 route에서 auth와 user서버에만 일단 filter를 적용시켜 놓았습니다. resilience4j: circuitbreaker: record-exceptions: 에서 예외를 잡도록 설정하였고 특정 서버의 상태에 따로 fallbackUri를 타도록 하였습니다. 추가로 각 route의 filter에서 응답에 500으로 올 경우에도 fallbackUri를 타도록 하였습니다.server: port: 19091 spring: main: web-application-type: reactive application: name: gateway-service profiles: active: local cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/api/orders/** - id: slack-msg-service uri: lb://slack-msg-service predicates: - Path=/api/slack/messages/** - id: company-service uri: lb://company-service predicates: - Path=/api/companies/** - id: delivery-service uri: lb://delivery-service predicates: - Path=/api/deliveries/** - id: auth-service uri: lb://auth-service predicates: - Path=/api/auth/**, /springdoc/openapi3-auth-service.json filters: - name: CircuitBreaker args: name: authCircuitBreaker fallbackUri: forward:/api/fallback statusCodes: - 500 - id: user-service uri: lb://user-service predicates: - Path=/api/users/**, /springdoc/openapi3-user-service.json filters: - name: CircuitBreaker args: name: userCircuitBreaker fallbackUri: forward:/api/fallback statusCodes: - 500 - id: hub-service uri: lb://hub-service predicates: - Path=/api/hubs/** discovery: locator: enabled: true # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정 resilience4j: circuitbreaker: configs: default: register-health-indicator: true # 서킷 브레이커의 상태를 헬스 인디케이터로 등록 allow-health-indicator-to-fail: false # 헬스 인디케이터가 실패할 수 있는지 여부 sliding-window-type: COUNT_BASED # 슬라이딩 윈도우 타입: COUNT_BASED는 호출 수를 기준으로 함 sliding-window-size: 5 # 슬라이딩 윈도우 크기: 10개의 호출 minimum-number-of-calls: 5 # 서킷 브레이커가 동작하기 위한 최소 호출 수 failure-rate-threshold: 50 # 실패율 임계값 (%) slow-call-rate-threshold: 50 # 느린 호출 비율 임계값 (%) slow-call-duration-threshold: 10s # 느린 호출의 기준 시간 (초) wait-duration-in-open-state: 10s # 서킷 브레이커가 오픈 상태에서 유지되는 시간 (초) automatic-transition-from-open-to-half-open-enabled: false # 오픈 상태에서 반 오픈 상태로 자동 전환 여부 permitted-number-of-calls-in-half-open-state: 5 # 반 오픈 상태에서 허용되는 호출 수 record-exceptions: # 서킷 브레이커가 예외로 간주할 예외 클래스들 - java.util.concurrent.TimeoutException # 타임아웃 예외 - org.springframework.cloud.gateway.support.NotFoundException # NotFound 예외 - io.github.resilience4j.circuitbreaker.CallNotPermittedException # 서킷 브레이커가 호출을 허용하지 않는 예외 - org.springframework.web.server.ResponseStatusException instances: defaultCircuitBreaker: baseConfig: default # 기본 설정을 상속받음 failure-rate-threshold: 50 # 실패율 임계값을 50%로 설정 userCircuitBreaker: baseConfig: default # 기본 설정을 상속받음 failure-rate-threshold: 50 # 실패율 임계값을 50%로 설정 authCircuitBreaker: baseConfig: default # 기본 설정을 상속받음 failure-rate-threshold: 50 # 실패율 임계값을 50%로 설정 springdoc: swagger-ui: urls[0]: name: auth-service url: /springdoc/openapi3-auth-service.json urls[1]: name: user-service url: /springdoc/openapi3-user-service.json eureka: client: service-url: defaultZone: <http://$>{host.url}:19090/eureka/ management: zipkin: tracing: endpoint: "<http://$>{host.url}:9411/api/v2/spans" tracing: sampling: probability: 1.0 endpoints: web: exposure: include: "*" health: components: circuitbreaker: enabled: true service: jwt: secret-key: "${application.secret-key}"
- auth 서버의 application.yml
auth서버의 application.yml의 일단 경우에도 gateway와 비슷하게 resilience4j circuitbreaker 설정을 해주었습니다. 다음으로 open 상태 전환을 감지하게 해줄 서비스 코드입니다.server: port: 19094 spring: application: name: auth-service profiles: active: local datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://${host.db.url}:5432/msa_delivery username: ${database.username} password: ${database.password} jpa: hibernate: ddl-auto: update properties: hibernate: # show_sql: true format_sql: true open-in-view: false resilience4j: circuitbreaker: configs: default: # 기본 구성 이름 registerHealthIndicator: true # 애플리케이션의 헬스 체크에 서킷 브레이커 상태를 추가하여 모니터링 가능 # 서킷 브레이커가 동작할 때 사용할 슬라이딩 윈도우의 타입을 설정 # COUNT_BASED: 마지막 N번의 호출 결과를 기반으로 상태를 결정 # TIME_BASED: 마지막 N초 동안의 호출 결과를 기반으로 상태를 결정 slidingWindowType: COUNT_BASED # 슬라이딩 윈도우의 타입을 호출 수 기반(COUNT_BASED)으로 설정 # 슬라이딩 윈도우의 크기를 설정 # COUNT_BASED일 경우: 최근 N번의 호출을 저장 # TIME_BASED일 경우: 최근 N초 동안의 호출을 저장 slidingWindowSize: 10 # 슬라이딩 윈도우의 크기를 5번의 호출로 설정 minimumNumberOfCalls: 10 # 서킷 브레이커가 동작하기 위해 필요한 최소한의 호출 수를 5로 설정 failureRateThreshold: 50 # 실패율이 이 임계값(50%)을 초과하면 서킷 브레이커가 동작 slowCallRateThreshold: 100 # 느린 호출의 비율이 이 임계값(100%)을 초과하면 서킷 브레이커가 동작 slowCallDurationThreshold: 10000 # 느린 호출의 기준 시간(밀리초)으로, 60초 이상 걸리면 느린 호출로 간주 permittedNumberOfCallsInHalfOpenState: 3 # 서킷 브레이커가 Half-open 상태에서 허용하는 최대 호출 수를 3으로 설정 # 서킷 브레이커가 Open 상태에서 Half-open 상태로 전환되기 전에 기다리는 시간 waitDurationInOpenState: 20s # Open 상태에서 Half-open 상태로 전환되기 전에 대기하는 시간을 20초로 설정 retry: instances: defaultRetry: maxAttempts: 3 waitDuration: 1000ms eureka: client: service-url: defaultZone: <http://$>{host.url}:19090/eureka/ management: zipkin: tracing: endpoint: "<http://$>{host.url}:9411/api/v2/spans" tracing: sampling: probability: 1.0 prometheus: metrics: export: enabled: true endpoints: web: exposure: include: "*" health: components: circuitbreaker: enabled: true service: jwt: access-expiration: 3600000 secret-key: ${security.secret-key}
- AuthService.java
서킷브레이커를 적용한 주메서드와 fallbackMethod는 파라미터로 받는 값과 반환타입이 반드시 일치(fallbackMethod의 경우 Throwable 객체는 추가 가능함)해야하기 때문에 코드가 조금 길어지는 단점이 있습니다. 이 부분은 추후 AOP를 적용하는 방법 등을 고려해야 봐야 될 것 같아요.@Slf4j @Service @RequiredArgsConstructor public class UserService { private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; private final AuthService authService; private final CompanyService companyService; private final DeliveryService deliveryService; private final HubService hubService; private final CircuitBreakerRegistry circuitBreakerRegistry; @PostConstruct public void registerEventListeners() { registerEventListener("searchUsersCircuitBreaker"); registerEventListener("getUserCircuitBreaker"); registerEventListener("updateUserCircuitBreaker"); registerEventListener("softDeleteUserCircuitBreaker"); } @CircuitBreaker(name = "updateUserCircuitBreaker", fallbackMethod = "fallbackUpdateUser") @Retry(name = "defaultRetry") @Transactional public ResponseEntity<ApiResponseDto<?>> updateUser(UserRequestDto userRequestDto, String username, String userId, String headerUsername, String role) { checkIsMaster(role); verifyUserToAuth(userId, headerUsername, role); User user = userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("user not exist.")); if (userRequestDto.getPassword() != null && !userRequestDto.getPassword().isEmpty()) { String password = userRequestDto.getPassword(); String encodedPassword = passwordEncoder.encode(password); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseDto.response(HttpStatus.OK.value(), "유저 정보 수정에 성공하였습니다.", UserResponseDto.fromEntity(user.updateIfPasswordIn(userRequestDto, headerUsername, encodedPassword)))); } else { return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseDto.response(HttpStatus.OK.value(), "유저 정보 수정에 성공하였습니다.", UserResponseDto.fromEntity(user.update(userRequestDto, headerUsername)))); } } public ResponseEntity<ApiResponseDto<?>> fallbackUpdateUser(UserRequestDto userRequestDto, String username, String userId, String headerUsername, String role, Throwable throwable) { HttpStatus status = throwable instanceof CallNotPermittedException ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST; return ResponseEntity.status(status) .body(ApiResponseDto.response(status.value(), throwable.getMessage(), null)); public void registerEventListener(String circuitBreakerName) { circuitBreakerRegistry.circuitBreaker(circuitBreakerName).getEventPublisher() .onStateTransition(event -> log.info("###CircuitBreaker State Transition: {}", event)) // 상태 전환 이벤트 리스너 .onFailureRateExceeded(event -> log.info("###CircuitBreaker Failure Rate Exceeded: {}", event)) // 실패율 초과 이벤트 리스너 .onCallNotPermitted(event -> log.info("###CircuitBreaker Call Not Permitted: {}", event)) // 호출 차단 이벤트 리스너 .onError(event -> log.info("###CircuitBreaker Error: {}", event)); // 오류 발생 이벤트 리스너 } private void checkIsMaster(String role) { if (!role.equals(UserRoleEnum.MASTER.toString())) { throw new IllegalArgumentException("appropriate role required."); } } private void verifyUserToAuth(String userId, String username, String role) { Boolean verifiedUser = authService.verifyUser(VerifyUserDto.builder() .userId(userId) .username(username) .role(role) .build()); if (!verifiedUser) { throw new IllegalArgumentException("invalid user."); } }
- 크게 2가지만 보여드리려고 합니다. gateway에 적용한 서킷브레이커 그리고 auth 서버에 적용한 서킷브레이커를 코드를 보여드리겠습니다.
- 요청을 보내게 되면 특정 서비스가 아직 로딩이 안될 시 gateway의 fallbackUri로 redirect가 되고, auth 서비스 update api의 비즈니스 로직에서 예외가 10번이상 발생하게 될 경우 서킷브레이커가 open 상태가 되어 자동으로 fallbackMethod를 타는 것까지 확인 되었습니다.
꺠달은 내용
- 처음엔 gateway를 통해 모든 서버의 서킷브레이커 open 상태를 감지할 수 있을 것이라 생각하고 있었습니다. 하지만 gateway에서 설정한 서킷브레이커의 경우 특정 서버가 아직 실행되지 않거나 서비스 통신이 불가능할 경우에 대해 잡아내고, 추가로 특정 상태코드로 응답이 돌아올 시 fallUri를 적용하여 redirect 해줄 수 있는 개념이었습니다. 물론 서버간 통신에 대한 예외도 적용하여 failCall을 증가시킬 수도 있죠. 각 서버에서 서킷브레이커를 적용할 경우 특정 api에서 예외가 발생했을 경우 failCall이 증가함과 동시에 fallbackMethod가 실행되며 특정 failCall을 넘어설 경우 open 상태가 되며 설정한 시간동안 요청이 들어올 경우 fallbackMethod만 실행되게 하여 각 서비스별 안정성을 높이는 개념이었습니다. 이 경험도 제가 좁게 생각하고 있었던 부분이었던 것 같네요. 항상 제가 틀릴 수도 있다는 걸 명심하게 넓은 시야를 가져야겠습니다.
최종 정리
1. Gateway에서의 서킷 브레이커 동작
Gateway에서의 서킷 브레이커: Spring Cloud Gateway에서 서킷 브레이커는 주로 외부 서비스와의 통신에서 발생하는 실패를 감지합니다. Gateway는 각 서비스의 응답을 기반으로 다음과 같은 방식으로 동작합니다:
응답 상태 코드 감지: 설정된 상태 코드(예: 500, 400 등)에 따라 요청이 실패로 간주되고, 이를 기반으로 fallbackUri가 호출됩니다.
서킷 브레이커 상태: Gateway는 서킷 브레이커의 상태를 관리하고, OPEN 상태일 때는 모든 요청이 fallbackUri로 포워딩됩니다.
2. 각 서비스에서의 서킷 브레이커 동작
서비스 내 서킷 브레이커 구현: 각 서비스에서도 서킷 브레이커를 별도로 구현할 수 있습니다. 이 경우, 서비스 내부의 메서드에서 발생하는 예외나 실패를 감지하고 처리합니다.
예외 처리: 서비스 메서드 내에서 발생하는 예외를 처리하여, 특정 예외가 발생했을 때 서킷 브레이커가 실패로 간주하도록 설정할 수 있습니다.
서킷 브레이커 설정: 각 서비스에서 Resilience4j와 같은 라이브러리를 사용하여 서킷 브레이커를 설정하고, 성공/실패를 기록하게 됩니다.
요약
Gateway: 외부 서비스와의 통신에서 발생하는 실패를 감지하고, 특정 상태 코드에 따라 fallbackUri 호출.
서비스: 내부 메서드에서의 예외 및 실패를 감지하여 별도로 서킷 브레이커를 구현하고 관리.
서킷 브레이커 구현 메소드는 주 메소드와 매개변수, 반환타입이 같아야 한다.(throwable만 추가됨)
**Fallback은 Circuit Breaker 오픈 여부와 상관 없습니다. 지정된 조건에 실패하면 무조건 호출되는겁니다.**
fallBackUri는 각 Http메서드마다 설정을 해주어야 한다는점(GET, POST, PUT, DELETE..)
fallbackMethod는 예외 발생하면 무조건 실행되긴 합니다. 대신 open이면 해당 함수를 가기 전에 바로 fallbackMethod로 가고, close상태면 해당 함수에서 돌다가 예외 발생시 fallbackmethod로 진행하게 됩니다.
'트러블 슈팅' 카테고리의 다른 글
gateway에서의 FeignClient 사용 (0) | 2024.12.18 |
---|---|
gateway에서 exchange를 사용하여 값을 전달할 때 주의점 (1) | 2024.12.18 |
@ModelAttribute 매핑 시 null값에 대한 이해 (0) | 2024.12.18 |
MSA 구성 시 데이터 정합성의 문제 (0) | 2024.12.18 |
RestTemplate 사용시 주의점(4xx, 5xx 응답 반환) (0) | 2024.12.13 |