장애처리
통신 연쇄 오류
우리가 만들었던 시스템에서 사용자 정보를 조회하면, user-service 내에서 order-service를 호출하여 해당 사용자가 주문한 주문 내용까지 조회해온다.
하지만 order-serivce가 실행 중이지 않거나 정상 동작하지 못하는 상황이라면, 위와 같이 order-service에 조회 요청을 보내는 것으로 인해 500 Internal Server Error가 클라이언트에게 전달될 수 있다.
이러한 문제를 막기 위해 다른 서비스 호출 시 에러가 발생한 경우 해당 값을 임시로 대체해 클라이언트에 전달하여, 다른 서비스에 문제가 생겼더라도 user-serive 자체는 문제가 없는 것처럼 동작하도록 보이게 만들 필요가 있다.
CircuitBreaker
위 상황처럼 다른 서비스에 문제가 생기거나 특정 기능이 정상 동작하지 않는 경우, 다른 기능으로 대체 수행하여 연쇄적인 오류를 끊어주고 장애가 발생하는 요청을 반복적으로 호출하는 것을 차단시켰다가 해당 서비스가 정상 복구된 뒤에 다시 연결하는 장치를 CircuitBreaker라고 한다.
다른 서비스가 정상적으로 동작하는 경우에는 CircuitBreaker가 Closed 된 채로 유지되고, 만약 다른 서비스에서 일정 수치 이상 문제가 발생한다면 CircuitBreaker가 Open 상태가 되어 다른 서비스로 가는 요청을 차단하고 자체적으로 준비된 기본값이나 우회할 수 있는 대체값을 반환시켜준다.
Resilience4j
Spring에서는 CircuitBreaker로 Resilience4j 라이브러리를 지원하고 있다. Resilience4j의 기능은 다음과 같다.
•
resilience4j-circuitbreaker
•
resilience4j-ratelimiter
•
resilience4j-bulkhead
•
resilience4j-retry
•
resilience4j-timelimiter
•
resilience4j-cache
CircuitBreaker 추가하기
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
Java
복사
먼저 이와 같이 circuitbreaker에 대한 라이브러리 의존성을 추가해주고,
private final CircuitBreakerFactor circuitBreakerFactory;
public UserDto getUserDto(String userId) {
...
/* Using CircuitBreaker */
CircuitBreaker circuitbreaker = circuitBreakerFactory.create("circuitbreaker");
List<ResponseOrder> orderList = circuitbreaker.run(() -> orderServiceClient.getOrders(userId),
throwable -> new ArrayList<>());
...
}
Java
복사
CircuitBreakerFactory 자체가 라이브러리에 의해 빈으로 등록되기 때문에 이처럼 단순하게 가져다가 사용하면 된다. run 메서드를 통해 다른 서비스에 보내는 요청과 실패했을 때의 대체값을 넣어주면 된다.
order-service를 실행시키지 않은채 사용자 정보를 조회하면
기존에는 이와 같이 500 에러가 발생하여 전달되었지만,
CircuitBreaker를 적용하게 되면 이처럼 오류는 발생하지만 해당 오류가 사용자에게 전달되지 않고 대체값인 빈 컬렉션을 반환한다.
추가적으로 timeout이나 실패 비율 등 CircuitBreaker에 대해 구체적으로 설정하고 싶다면,
@Configuration
public class Resilience4JConfig {
private final CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(4)
.waitDurationInOpenState(Duration.ofMillis(1000))
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(2)
.build();
private final TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(4))
.build();
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.timeLimiterConfig(timeLimiterConfig)
.circuitBreakerConfig(circuitBreakerConfig)
.build());
}
}
Java
복사
이처럼 Configuration을 추가해주면 되고, 사용 시에는 별도의 추가 코드 없이 기존과 동일하게 circuitBreaker를 넣어 사용하면 된다. 위 코드의 설정 내용은 구체적으로 다음과 같다.
•
failureRateThreshold(4)
◦
실패 비율 설정
◦
100번 중 4번 실패하면 CircuitBreaker Open
◦
default = 50
•
waitDurationInOpenState(Duration.ofMillis(1000))
◦
CircuitBreaker가 Open된 상태에서 유지되는 시간
◦
default = 60s
•
slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASE)
◦
CircuitBreaker가 Close 상태일 때 호출되는 결과를 저장하는 것을 횟수로 할지 시간으로 할지 결정
◦
default = COUNT_BASE
•
slidingWindowSize(2)
◦
CircuitBreaker가 Close 상태일 때 호출되는 결과를 저장하는 것의 기준이 되는 수치(시간/횟수)
◦
2회까지만 저장
◦
default = 100
•
timeoutDuration(Duration.ofSecons(4))
◦
futuer supplier의 time limit을 결정
◦
default = 1s
분산 추적
Zipkin
MSA 환경에서는 여러 마이크로 서비스 간에 통신하기 때문에, 어떤 요청에 의해 다른 요청이 어떤게 발생했는지, 어떤 흐름으로 요청이 진행되는지에 대해 추적하는 과정이 어렵다. 이를 도와주는 것이 Zipkin으로, 이와 같은 분산 시스템에서 로그 트레이싱을 제공해주는 오픈 분산 추적 시스템이다.
Zipkin의 동작은 Zipkin Client Libaray에서 정보를 수집하여 Zipkin 서버의 Collector 모듈로 전송하고, Collector 모듈에서 전달된 트레이스 정보가 유효한지 검증한다. 검증 이후 트레이스 정보를 In-memory나 MySQL, ElasticSearch와 같은 저장소에 저장하고 색인화한다. Zipkin 서버에서는 이렇게 저장된 데이터를 검색하기 위해 JSON API를 제공하고 이를 Web UI에서 호출하여 서비스, 시간, 애노테이션 기반의 GUI로 제공한다.
Spring Cloud Sleuth
애플리케이션에서 정보를 수집하여 Zipkin 서버에 전달하는 Zipkin Client Library에는 많은 종류가 있고, 우리는 Spring 프레임워크를 사용하니 Spring Cloud Sleuth를 사용할 것이다. Spring Cloud Sleuth는 각 서비스 호출마다 ID를 자동으로 생성하여 기록을 로그에 추가해주는 역할을 수행한다.
Seluth는 Trace(추적)와 Span(구간)에 ID를 부여한다. Span은 하나의 요청에서 사용되는 작업 단위를 말하고, Trace는 트리 구조로 이루어진 Span의 집합을 말한다. HTTP 요청에 Trace ID가 부여되고 해당 Trace ID는 클라이언트의 호출이 끝나는 시점까지 유지되어 쉽게 추적할 수 있도록 도와준다. 같은 Trace 내에서 서비스 외부로 요청이 발생할 때마다 새로운 Span ID를 추가하여 트리 구조로 저장하여 하나의 요청에 어떤 추가적인 요청들이 발생했는지 추적할 수 있다.
이와 같이 A → B → C → E 과정으로 통신하는 요청이 있고 A → B → D 과정으로 통신하는 요청이 있다면,
이처럼 A → B → C → E 과정으로 통신하는 요청에서 AA라는 Trace ID를 부여받고, 각 서비스 요청마다 AA, BB, CC의 Span ID를 부여받아 이를 쉽게 추적할 수 있도록 도와 준다.
Zipkin 설치하기
curl -sSL https://zipkin.io/quickstart.sh | bash -s
Shell
복사
위 명령어를 입력하면,
이와 같이 zipkin이 다운로드 된다.
해당 jar 파일을 실행시키면 이와 같이 zipkin 서버를 실행시킬 수 있다.
서버 실행 시 나와있는 포트 번호로 접속을 하게 되면, 위와 같이 zipkin 서버에서 제공하는 GUI로 로그 트레이스를 확인할 수 있다.
로그 추가하기 및 zipkin 동작 확인
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-observation'
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
YAML
복사
Spring boot 3.0부터는 Sleuth를 지원하지 않기 때문에, actuator와 micrometer를 사용하여 Zipkin과 연동한다. 이를 위해 actuator와 micrometer-tracing, zipkin-reporter에 대한 라이브러리 의존성을 추가해준다.
management:
endpoints:
web:
exposure:
include: refresh, health, info, metrics, beans, busrefresh
tracing:
sampling:
probability: 1.0
propagation:
consume: b3
produce: b3_multi
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
logging:
pattern:
level: '%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]'
YAML
복사
그 후 application.yml 파일의 actuator 관리 부분에 위와 같이 tracing과 zipkin 설정을 추가해준다. 여기까지의 과정은 user-service와 order-service가 동일하다.
log.info("Before call orders microservice");
CircuitBreaker circuitbreaker = circuitBreakerFactory.create("circuitbreaker");
List<ResponseOrder> orderList = circuitbreaker.run(() -> orderServiceClient.getOrders(userId),
throwable -> new ArrayList<>());
log.info("After called orders microservice");
Java
복사
먼저 위와 같이 user-serivce에서는 order-service에 호출하여 주문 정보를 조회해오는 로직 전후로 로그를 추가해준다.
@PostMapping("/{userId}/orders")
public ResponseEntity<ResponseOrder> createOrder(@PathVariable String userId,
@RequestBody RequestOrder orderDetails) {
log.info("Before add orders data");
...
ResponseOrder responseOrder = modelMapper.map(orderDto, ResponseOrder.class);
log.info("After add orders data");
return ResponseEntity.status(HttpStatus.CREATED).body(responseOrder);
}
Java
복사
@GetMapping("/{userId}/orders")
public List<ResponseOrder> getOrder(@PathVariable String userId) {
log.info("Before retrieve orders data");
List<ResponseOrder> orders = orderService.getOrdersByUserId(userId).stream()
.map(order -> modelMapper.map(order, ResponseOrder.class)).toList();
log.info("After retrieve orders data");
return orders;
}
Java
복사
order-service에서는 주문을 받아 새로 저장하는 로직과 주문 조회를 하는 로직에 로그를 추가해준다.
그 후 새로 주문 요청을 넣고 사용자 정보를 조회하게 되면,
위와 같이 로그에 [trace id - span id] 형태로 트레이스 정보가 추가된다.
해당 trace id를 복사하여 zipkin의 web UI에서 확인해보면,
이와 같이 요청이 단계적으로 어떻게 처리 되었는지를 확인할 수 있다.
추가적으로 find trace 탭을 통해서 각 service별 요청 trace도 확인할 수 있다.
장애가 발생하는 경우를 확인하기 위해
@GetMapping("/{userId}/orders")
public List<ResponseOrder> getOrder(@PathVariable String userId) throws Exception {
log.info("Before retrieve orders data");
// List<ResponseOrder> orders = orderService.getOrdersByUserId(userId).stream()
// .map(order -> modelMapper.map(order, ResponseOrder.class)).toList();
throw new Exception("장애 발생");
// log.info("After retrieve orders data");
// return orders;
}
Java
복사
이와 같이 order-service에서 강제로 예외를 던지도록 만들고 다시 요청을 보내보면,
이처럼 circuitBreaker로 외부에 요청을 보내고 그 과정에서 장애가 발생했다는 사실도 알 수 있다.