시작하며
안녕하세요 개발자 stark입니다. 이번 포스팅은 gRPC 시리즈의 번외 편입니다. 관련 프로젝트 링크는 하단에 있습니다.
GitHub - wlsdks/grpc-client-example: SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확
SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확인해주세요) - wlsdks/grpc-client-example
github.com
gRPC 시리즈의 포스팅은 서버 구성으로부터 시작됩니다.
아래 포스팅을 통해 gRPC로 MSA 프로젝트 구성하기를 따라가다 보면 위의 링크의 프로젝트가 완성됩니다. 시간이 괜찮으시다면 한 번쯤 읽어보시는 것도 좋을 것 같습니다. 이번 포스팅을 위해서는 클라이언트 코드만 확인하셔도 됩니다.
[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기
시작하며안녕하세요. 개발자 stark입니다.최근 업무가 조금 바빠져서 블로그에 글을 작성하지 못했는데요. 설이기도 하고 정리할 시간이 생겨서 오랜만에 글을 적게 되었습니다. 제가 이번 연도
curiousjinan.tistory.com
바로 시작해 봅시다! Let's go~~
서킷 브레이커 커스텀 설정코드 작성 (Configuration)
이전 포스팅에 서킷 브레이커와 관련된 내용을 매우 자세히 작성해 두었습니다.
조금 귀찮더라도 한 번만 확인해 주세요 ㅎㅎ 정말 필요한 정보들만 적어두었습니다. 안에는 의존성 추가 방법과 기본 설정값 그리고 커스텀 설정을 하는 방법들이 모두 적혀있습니다. 목차를 확인하시고 선택해서 읽어주시면 됩니다.
[SpringBoot3] MSA에 Resilience4j 서킷 브레이커 & Fallback 적용하기
시작하며안녕하세요. 개발자 stark입니다. 오늘은 서킷 브레이커를 적용해 봅시다. 마이크로서비스 아키텍처(MSA)에서는 외부 API 호출 시 장애에 대한 대응이 매우 중요합니다. Spring Boot 애플리케
curiousjinan.tistory.com
코드 정리
서킷 브레이커에 대한 설명은 위의 글(링크)에 상세히 적어두었기에 설정에 필요한 코드만 나열하도록 하겠습니다. 먼저 저는 자바로 서킷 브레이커 설정을 했습니다. 아래와 같이 Config 클래스를 만들고 빈 등록을 해주시면 됩니다. 참고로 스프링 Resilience4j와 aop 의존성 추가는 필수입니다.
@Configuration
public class Resilience4jConfig {
@Bean
public CircuitBreakerConfig circuitBreakerConfig() {
return CircuitBreakerConfig.custom()
// 호출 횟수 기반 슬라이딩 윈도우 (최근 10회 호출 기준)
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
// 실패율 50% 이상이면 OPEN 상태로 전환
.failureRateThreshold(50)
// OPEN 상태에서 5초 동안 호출 차단 후 HALF_OPEN 상태로 전환
.waitDurationInOpenState(java.time.Duration.ofSeconds(5))
// HALF_OPEN 상태에서 최대 5개 호출 허용
.permittedNumberOfCallsInHalfOpenState(5)
// 최근 10회 호출을 기준으로 통계 집계
.slidingWindowSize(10)
// OPEN 상태에서 HALF_OPEN으로 자동 전환 활성화
.automaticTransitionFromOpenToHalfOpenEnabled(true)
// FeignException, ConnectException, RuntimeException을 실패 예외로 기록
.recordExceptions(FeignException.class, ConnectException.class, RuntimeException.class)
.build();
}
// 위 설정을 기반으로 CircuitBreakerRegistry를 생성
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry(CircuitBreakerConfig config) {
return CircuitBreakerRegistry.of(config);
}
}
만약 서킷 브레이커 설정을 application.yml로 하고 싶으시다면 아래와 같이 작성해 주시면 됩니다.
spring:
application:
name: grpc-client-server
resilience4j:
circuitbreaker:
instances:
member-service:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- feign.FeignException
- java.net.ConnectException
- java.lang.RuntimeException
서킷 브레이커 확인용 API 만들기
코드는 아래와 같이 컨트롤러 1개만 생성해 주시면 됩니다.
설정 클래스에서 빈 등록한 CircuitBreakerRegistry를 주입받아서 등록된 서킷 브레이커 정보를 확인합니다. 이것을 미리 만들어두시면 코드를 추가한 후에 서킷 브레이커를 검증하는데 큰 도움이 됩니다.
@RequestMapping("/api/circuit-breaker")
@RequiredArgsConstructor
@RestController
public class CircuitBreakerController {
private final CircuitBreakerRegistry circuitBreakerRegistry;
@GetMapping
public ResponseEntity<Map<String, Object>> getCircuitBreakerInfo() {
Map<String, Object> info = new HashMap<>();
// 등록된 모든 서킷 브레이커 정보 조회
for (CircuitBreaker cb : circuitBreakerRegistry.getAllCircuitBreakers()) {
Map<String, Object> cbInfo = new HashMap<>();
cbInfo.put("state", cb.getState().toString());
cbInfo.put("failureRate", cb.getMetrics().getFailureRate());
cbInfo.put("bufferedCalls", cb.getMetrics().getNumberOfBufferedCalls());
cbInfo.put("failedCalls", cb.getMetrics().getNumberOfFailedCalls());
info.put(cb.getName(), cbInfo);
}
return ResponseEntity.ok(info);
}
}
어노테이션 기반 서킷 브레이커 적용 (@CircuitBreaker)
코드는 매우 간단합니다. 이전에 Feign 요청에 어노테이션을 적용했던 것과 동일합니다.
@CircuitBreaker 어노테이션을 선언하고 서킷 브레이커의 인스턴스 name을 지정하고 fallback 메서드명을 적어주면 알아서 서킷 브레이커가 적용됩니다. 이것을 이해하기 위해서 꼭 위의 링크에 적어둔 글을 읽어주세요. (엄청 상세히 적어두었습니다)
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClientAnnotation {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
// Stub을 재사용하기 위해 필드로 선언
private MemberServiceGrpc.MemberServiceBlockingStub stub;
@PostConstruct
public void init() {
// 채널로부터 Stub을 한 번만 생성
stub = MemberServiceGrpc.newBlockingStub(channel);
}
/**
* 회원 ID로 회원 조회
*
* @param memberId 조회할 회원의 ID
* @return 조회된 회원 정보 DTO
*/
@CircuitBreaker(name = "grpc-circuit", fallbackMethod = "getFallbackResponse")
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
// gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
/**
* @return Fallback 응답
* @apiNote 서킷 브레이커가 열렸을 때 반환할 Fallback 응답
*/
public MemberProto.MemberResponse getFallbackResponse(Long memberId, Throwable t) {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return MemberProto.MemberResponse.newBuilder()
.setId(-1L) // 예시로 -1을 반환
.setName("Fallback Member")
.build();
}
}
서킷 브레이커가 적용되었는지 테스트를 해봅시다. (서킷 정보 조회 api 호출)
호출 api 코드(컨트롤러)는 생략하겠습니다. 대신 간략하게 설명드리면 회원 조회 api를 호출하면 서버 내부적으로는 gRPC 클라이언트를 사용해서 요청을 보내서 받아옵니다. 저는 서킷 브레이커 설정을 10번 요청을 기준으로 실패율을 측정하도록 해놨기에 일부로 9번의 실패 요청을 보냈습니다. 그러면 예상했던 대로 요청은 9번 실패하지만 아직 측정이 안되어서 서킷의 상태는 CLOSED로 정상 동작합니다.
"grpc-circuit": {
"failureRate": -1,
"failedCalls": 9,
"state": "CLOSED",
"bufferedCalls": 9
}
이제 대망의 10번째 요청을 보냅시다.
제 예상대로면 10번째 요청에서는 서킷의 상태값이 OPEN으로 바뀌어야 합니다. 그래야 이후의 요청이 차단되기 때문입니다. 그리고 멋지게도 제가 원했던 대로 동작했습니다. 매우 만족스럽군요.
"grpc-circuit": {
"failureRate": 100,
"failedCalls": 10,
"state": "OPEN",
"bufferedCalls": 10
}
그리고 OPEN 상태에서 5초가 지나면 자동으로 HALF_OPEN으로 바뀝니다.
이것 또한 잘 동작했습니다. 즉, 어노테이션 기반 서킷 브레이커 적용은 매우 성공적입니다.
"grpc-circuit": {
"failureRate": -1,
"failedCalls": 0,
"state": "HALF_OPEN",
"bufferedCalls": 0
}
그럼 로그를 통해 서킷이 요청을 잘 차단하고 있는지 확인해 봅시다.
먼저 10번째 요청까지는 서킷이 CLOSED상태라서 모든 요청을 보냈고 아래와 같이 gRPC를 직접 호출해서 전파받은 예외와 서킷 브레이커의 fallback이 동작해서 남은 예외 로그 2개가 계속해서 남았습니다.
2025-02-15T15:31:12.160+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-4] c.d.g.c.grpc.GrpcMemberClientAnnotation
: gRPC 호출 실패 - 상태: Status{code=UNAVAILABLE, description=io exception, cause=io.grpc.netty.shaded.io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused: localhost/127.0.0.1:50051
Caused by: java.net.ConnectException: Connection refused
at java.base/sun.nio.ch.Net.pollConnect(Native Method)
at java.base/sun.nio.ch.Net.pollConnectNow(Net.java:682)
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:973)
at io.grpc.netty.shaded.io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:337)
at io.grpc.netty.shaded.io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:334)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:776)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.grpc.netty.shaded.io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
at io.grpc.netty.shaded.io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.grpc.netty.shaded.io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1583)
}, 설명: io exception, 원인: {}
2025-02-15T15:31:12.163+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-4] c.d.g.c.grpc.GrpcMemberClientAnnotation
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
이제 11번째 요청부터는 어떻게 될까요?
11번째부터는 서킷이 OPEN 되니 연속으로 요청을 5번 보내봤습니다. 그랬더니 아래와 같은 로그만 남았습니다. 모두 fallback 메서드의 로그입니다. 즉, 실제 gRPC 요청을 보내지 않았다는 것입니다.
2025-02-15T15:33:49.305+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-6] c.d.g.c.grpc.GrpcMemberClientAnnotation :
gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:33:49.586+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-7] c.d.g.c.grpc.GrpcMemberClientAnnotation :
gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:33:49.836+09:00 ERROR 61914 --- [grpc-client-server] [io-8091-exec-10] c.d.g.c.grpc.GrpcMemberClientAnnotation :
gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:33:50.090+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-2] c.d.g.c.grpc.GrpcMemberClientAnnotation :
gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:33:50.349+09:00 ERROR 61914 --- [grpc-client-server] [nio-8091-exec-4] c.d.g.c.grpc.GrpcMemberClientAnnotation :
gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
로그를 보니 확실히 잘 동작하고 있습니다!
데코레이터 패턴을 적용한 gRPC 서킷 브레이커 코드 작성
먼저 작성한 코드의 최상단을 잘라왔습니다.
코드에서는 CircuitBreakerRegistry 인스턴스를 final 필드로 주입받고 있는 것을 확인하실 수 있습니다. 이렇게 하면 제가 설정 클래스에 직접 등록한 @Bean 인스턴스가 주입될 것입니다. (커스텀 설정 적용) 근데 하단의 CircuitBreaker 인스턴스를 보시면 스프링 빈을 주입받지 않습니다. 대신 @PostConstruct를 선언한 메서드 내부에서 직접 인스턴스를 만들어서 넣어줍니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClient {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
// gRPC 클라이언트는 Feign처럼 자동 구성되는 것이 아니라 직접 호출 시점에 서킷 브레이커를 적용하는 방식으로 구성해야 합니다.
private final CircuitBreakerRegistry circuitBreakerRegistry;
private CircuitBreaker grpcCircuitBreaker;
// Stub을 재사용하기 위해 필드로 선언
private MemberServiceGrpc.MemberServiceBlockingStub stub;
@PostConstruct
public void init() {
// "grpcCircuitBreaker" 이름으로 서킷 브레이커 생성 (설정은 기본값 또는 별도 구성한 값 사용)
grpcCircuitBreaker = circuitBreakerRegistry.circuitBreaker("grpcCircuitBreaker");
// 채널로부터 Stub을 한 번만 생성
stub = MemberServiceGrpc.newBlockingStub(channel);
}
// 메서드 코드는 하단에서 확인합니다.
}
근데 어노테이션 없이 어떻게 서킷 브레이커를 적용시키나요?
아래의 메서드는 위의 GrpcMemberClient 클래스 내부에 작성한 "회원 조회" 메서드입니다. 저는 이 메인 메서드를 함수형으로 작성해보고 싶어서 Vavr라는 라이브러리를 추가했습니다. 그렇다 보니 조금 생소한 코드(Try)가 보일 수도 있습니다. 그렇지만 여기서도 핵심은 매우 간단합니다. 바로 gRPC 서버를 호출하고 그 사이에 데코레이터 패턴을 적용하여 서킷 브레이커를 동작시키는 것입니다.
/**
* 회원 ID로 회원 조회
*
* @param memberId 조회할 회원의 ID
* @return 조회된 회원 정보 DTO
*/
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
Supplier<MemberProto.MemberResponse> decoratedSupplier =
CircuitBreaker.decorateSupplier(grpcCircuitBreaker, () -> performGrpcCall(memberId));
// Vavr 라이브러리의 Try 클래스를 사용하여 예외 처리
return Try.ofSupplier(decoratedSupplier)
.recover(throwable -> {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return getFallbackResponse();
})
.get();
}
/**
* @param memberId 조회할 회원 ID
* @return gRPC 호출 결과
* @apiNote gRPC 호출을 수행하는 메서드
*/
private MemberProto.MemberResponse performGrpcCall(Long memberId) {
// gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
// 현재 인증 정보 로깅 (SecurityContext에서 가져옴)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.trace("Current Authentication: {}", authentication);
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
/**
* @return Fallback 응답
* @apiNote 서킷 브레이커가 열렸을 때 반환할 Fallback 응답
*/
private MemberProto.MemberResponse getFallbackResponse() {
return MemberProto.MemberResponse.newBuilder()
.setId(-1L) // 예시로 -1을 반환
.setName("Fallback Member")
.build();
}
전체 코드는 다음과 같습니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClient {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
// gRPC 클라이언트는 Feign처럼 자동 구성되는 것이 아니라 직접 호출 시점에 서킷 브레이커를 적용하는 방식으로 구성해야 합니다.
private final CircuitBreakerRegistry circuitBreakerRegistry;
private CircuitBreaker grpcCircuitBreaker;
// Stub을 재사용하기 위해 필드로 선언
private MemberServiceGrpc.MemberServiceBlockingStub stub;
@PostConstruct
public void init() {
// "grpcCircuitBreaker" 이름으로 서킷 브레이커 생성 (설정은 기본값 또는 별도 구성한 값 사용)
grpcCircuitBreaker = circuitBreakerRegistry.circuitBreaker("grpcCircuitBreaker");
// 채널로부터 Stub을 한 번만 생성
stub = MemberServiceGrpc.newBlockingStub(channel);
}
/**
* 회원 ID로 회원 조회
*
* @param memberId 조회할 회원의 ID
* @return 조회된 회원 정보 DTO
*/
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
Supplier<MemberProto.MemberResponse> decoratedSupplier =
CircuitBreaker.decorateSupplier(grpcCircuitBreaker, () -> performGrpcCall(memberId));
// Vavr 라이브러리의 Try 클래스를 사용하여 예외 처리
return Try.ofSupplier(decoratedSupplier)
.recover(throwable -> {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return getFallbackResponse();
})
.get();
}
/**
* @param memberId 조회할 회원 ID
* @return gRPC 호출 결과
* @apiNote gRPC 호출을 수행하는 메서드
*/
private MemberProto.MemberResponse performGrpcCall(Long memberId) {
// gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
// 현재 인증 정보 로깅 (SecurityContext에서 가져옴)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.trace("Current Authentication: {}", authentication);
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
/**
* @return Fallback 응답
* @apiNote 서킷 브레이커가 열렸을 때 반환할 Fallback 응답
*/
private MemberProto.MemberResponse getFallbackResponse() {
return MemberProto.MemberResponse.newBuilder()
.setId(-1L) // 예시로 -1을 반환
.setName("Fallback Member")
.build();
}
}
데코레이터 패턴 이해하기 (feat. vavr 라이브러리)
제가 로직에서 사용한 CircuitBreaker.decorateSupplier() 메서드는 데코레이터(Decorator) 패턴의 개념을 함수형 프로그래밍에 적용한 형태라고 볼 수 있습니다. 조금 알아볼까요?
- 데코레이터 패턴: 기본 기능(여기서는 Supplier가 제공하는 로직)을 변경하지 않고, 추가 기능(서킷 브레이커 로직)을 동적으로 붙이는 디자인 패턴입니다.
- 함수형 프로그래밍: decorateSupplier는 원래의 Supplier를 감싸서, 호출 시 서킷 브레이커의 상태를 확인하고 필요시 원래 함수 실행 전후에 추가 로직을 수행하도록 하는 새로운 Supplier를 반환합니다.
즉, 이 메서드는 기존 기능에 서킷 브레이커의 로직을 "데코레이팅"하여, 별도의 호출 코드 변경 없이도 서킷 브레이커의 기능을 적용할 수 있게 합니다.
자 그럼 데코레이터 패턴이 적용된 메서드를 분석해 봅시다.
메서드에서는 먼저 로깅을 하고 지역변수로 선언된 decoratedSupplier의 인스턴스를 생성합니다. 이때 Supplier라는 타입이 생소하실 텐데 이것은 인터페이스이고 내부에는 get()이라는 메서드 1개 만을 가지고 있습니다. 그리고 get() 메서드의 응답은 제네릭으로 <T>을 반환하게 됩니다. (사실 이렇게만 설명해서는 무슨 말인지 전혀 모르시겠죠..? 그래서 아래에 추가 설명을 준비했습니다.)
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
Supplier<MemberProto.MemberResponse> decoratedSupplier =
CircuitBreaker.decorateSupplier(grpcCircuitBreaker, () -> performGrpcCall(memberId));
// Vavr 라이브러리의 Try 클래스를 사용하여 예외 처리
return Try.ofSupplier(decoratedSupplier)
.recover(throwable -> {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return getFallbackResponse();
})
.get();
}
Supplier 인터페이스부터 이해합시다.
Supplier 인터페이스는 아래와 같이 생겼습니다. 이것은 자바에서 공식적으로 지원하는 유틸 클래스입니다. 이 인터페이스의 get 메서드를 보면 T라는 값을 return 한다는 것을 알 수 있습니다. 그리고 제일 중요한 건 인터페이스 상단에 @FunctionalInterface가 적혀 있다는 것입니다. 즉, 이것은 함수형 프로그래밍을 지원하기 위한 인터페이스라는 것입니다.
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
이제 제가 호출한 CircuitBreaker 인터페이스의 decorateSupplier() 메서드를 봅시다. 이 메서드는 CircuitBreaker 인터페이스 내부에 static으로 선언된 메서드이며 2번째 매개변수로는 Supplier<T>를 받습니다. 그리고 내부 로직에서 get() 메서드를 호출함으로써 이 함수의 return값을 받습니다. 그럼 이 2번째 매개변수에는 반환값을 가지는 특정 함수를 넣어줘야겠죠? 그래서 저는 이런 함수를 넣어줬습니다. "() -> performGrpcCall(memberId))"
public interface CircuitBreaker {
// 코드 생략
static <T> Supplier<T> decorateSupplier(CircuitBreaker circuitBreaker, Supplier<T> supplier) {
return () -> {
circuitBreaker.acquirePermission();
long start = circuitBreaker.getCurrentTimestamp();
try {
T result = (T)supplier.get();
long duration = circuitBreaker.getCurrentTimestamp() - start;
circuitBreaker.onResult(duration, circuitBreaker.getTimestampUnit(), result);
return result;
} catch (Exception exception) {
long duration = circuitBreaker.getCurrentTimestamp() - start;
circuitBreaker.onError(duration, circuitBreaker.getTimestampUnit(), exception);
throw exception;
}
};
}
// 코드 생략
}
제가 넣어준 () -> performGrpcCall(memberId)) 이건 어떤 코드일까요?
다시 코드를 분석해야 합니다. Supplier 인터페이스 내부에는 get()이라는 메서드가 있었죠? 자바에서는 인터페이스에 1개의 함수만 선언되어 있다면 이름을 생략할 수 있습니다. 즉, "() ->" 이것은 "get() ->" 과 동일합니다. 단지 함수형 프로그래밍 스타일이 적용되어 메서드명이 생략된 것뿐입니다.
그래서 이게 무엇이냐면 get()이라는 메서드의 정의(실행되면 동작할 함수)를 필드에 직접 메서드로 선언한 게 아니라 특정 함수를 호출할 때 매개변수(인자)로 넣어준 것입니다. 이렇게 되면 이 Supplier를 매개변수로 받은 메서드에서 Supplier 타입을 받았으니 내부의 get() 메서드를 호출할 수 있을 텐데 이게 호출되면 제가 ()의 실행 메서드로 넣어준 performGrpcCall(memberId)가 호출됩니다.
즉, () -> performGrpcCall(memberId)는 Supplier의 get()이 호출되면 performGrpcCall(memberId)를 실행하라는 의미입니다. 참 쉽죠..?
그리고 performGrpcCall() 메서드는 이름만 봐도 예상하셨겠지만 바로 gRPC 서버에 회원 조회 요청을 보내는 메서드입니다. 아래 코드가 이 메서드인데 gRPC 요청 객체를 만들어서 try문 내부에서 stub.getMemberById(request)를 호출해서 실제 gRPC서버에 요청을 보냅니다. 그리고 응답으로 MemberProto.MemberResponse라는 값을 받게 됩니다.
private MemberProto.MemberResponse performGrpcCall(Long memberId) {
// gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
// 현재 인증 정보 로깅 (SecurityContext에서 가져옴)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.trace("Current Authentication: {}", authentication);
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
자 그럼 다시 메인 코드로 돌아갑시다.
아직 익숙지 않겠지만 이제는 조금 다르게 보이시지 않나요? 메서드 내부에 지역변수로 선언한 decoratedSupplier의 타입은 Supplier<MemberProto.MemberResponse>입니다. 왜냐? CircuitBreaker의 decorateSupplier 함수를 호출하면서 제가 넣어준 2번째 매개변수(Supplier)의 return 타입(T)이 바로 performGrpcCall()의 반환값인 MemberProto.MemberResponse 이기 때문입니다. (어렵습니다.. 말로 설명하는 것도 힘듭니다)
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
Supplier<MemberProto.MemberResponse> decoratedSupplier =
CircuitBreaker.decorateSupplier(grpcCircuitBreaker, () -> performGrpcCall(memberId));
// Vavr 라이브러리의 Try 클래스를 사용하여 예외 처리
return Try.ofSupplier(decoratedSupplier)
.recover(throwable -> {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return getFallbackResponse();
})
.get();
}
결국 이 로직을 확실하게 알기 위해서는 디자인 패턴을 이해해야 합니다.
제가 작성한 로직은 데코레이터 패턴에 의해 원래의 작업(gRPC 호출)에 CircuitBreaker의 작업이 추가된 것입니다. 코드를 보면 CircuitBreaker 클래스가 제공하는 decorateSupplier()를 호출하는데 이 메서드는 원래의 작업(gRPC 서버 호출)을 "감싸서" 메서드 실행 전후에 서킷 브레이커의 체크와 기록을 추가하는 "데코레이터" 역할을 한다고 볼 수 있습니다.
즉, 원래 작업을 변경하지 않고, gRPC 회원 조회 메서드를 호출하기 전에 요청 허용 여부를 체크하고, 호출 후 결과나 예외를 기록하는 부가적인 기능을 덧붙이는 것입니다.
부족한 제 설명을 보신 gpt 선생님께서는 이렇게 비유해 주셨네요.
- 당신이 어떤 일을 하기 전에 경비원(서킷 브레이커)에게 "내가 이 일을 해도 될까요?"라고 확인합니다.
- 허락을 받으면 일을 시작하고, 일을 마친 후 "얼마나 걸렸고, 성공했어요" 또는 "실패했어요"를 경비원에게 보고합니다.
- 이처럼 원래의 일을 변경하지 않고, 앞뒤에 추가 행동(검사와 기록)을 덧붙이는 것을 데코레이터 패턴이라고 합니다.
마지막으로 Vavr 코드를 분석해 봅시다.
저는 처음에 try-catch로 코드를 작성했었는데 좀 더 가독성 좋게 바꿔보고 싶다는 생각이 들었습니다. 그러다 이 라이브러리를 찾았는데 뭐랄까.. 오히려 좀 더 복잡해진 것 같기도 하고 기분이 묘합니다. 실무에도 적용할지에 대해서는 많은 고민이 필요할 것 같습니다. 아래에서 하나씩 분석해 봅시다.
// Vavr 라이브러리의 Try 클래스를 사용하여 예외 처리
return Try.ofSupplier(decoratedSupplier)
.recover(throwable -> {
log.error("gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId={}, fallback 응답 반환", memberId);
return getFallbackResponse();
})
.get();
Try란 무엇인가?
Try는 Vavr 라이브러리에서 제공하는 컨테이너 타입으로, 성공한 결과나 예외(실패)를 모두 캡슐화할 수 있습니다. 쉽게 말해, 어떤 작업을 수행할 때 발생할 수 있는 예외를 안전하게 처리하기 위해, 결괏값 또는 예외 정보를 담는 "박스"라고 생각할 수 있습니다. 이렇게 하면 예외 처리 코드를 간결하게 작성할 수 있고, 함수형 스타일로 체이닝(chaining)할 수 있습니다.
package io.vavr.control;
public interface Try<T> extends Value<T>, Serializable {
long serialVersionUID = 1L;
static <T> Try<T> of(CheckedFunction0<? extends T> supplier) {
Objects.requireNonNull(supplier, "supplier is null");
try {
return new Success<T>(supplier.apply());
} catch (Throwable t) {
return new Failure<T>(t);
}
}
static <T> Try<T> ofSupplier(Supplier<? extends T> supplier) {
Objects.requireNonNull(supplier, "supplier is null");
Objects.requireNonNull(supplier);
return of(supplier::get);
}
// 코드는 생략
}
ofSupplier()와 recover()의 역할
Try.ofSupplier(decoratedSupplier)는 주어진 decoratedSupplier(Supplier 인터페이스)를 실행하여 결과를 Try 객체에 담습니다. 만약 제가 인자로 넣어준 decoratedSupplier가 실행 중 예외가 발생하면, 해당 예외가 Try 객체에 저장되어 실패 상태가 됩니다.
static <T> Try<T> ofSupplier(Supplier<? extends T> supplier) {
Objects.requireNonNull(supplier, "supplier is null");
Objects.requireNonNull(supplier);
return of(supplier::get);
}
이후 호출되는 recover 메서드는 Try가 실패 상태일 때 동작하는데, 인자로 전달한 함수(여기서는 로깅 후 getFallbackResponse() 호출)를 이용해 예외를 복구하고, 새로운 값을 반환합니다. 이 과정에서 예외가 발생했다면 fallback 값이 대신 사용됩니다.
default Try<T> recover(Function<? super Throwable, ? extends T> f) {
Objects.requireNonNull(f, "f is null");
return this.isFailure() ? of(() -> f.apply(this.getCause())) : this;
}
마지막에 호출되는 .get()의 의미
체인 마지막에 호출되는 .get()은 Try 객체 내부에 담긴 값을 반환합니다. 만약 Try가 성공 상태라면 실제 결괏값(여기서는 gRPC 호출의 응답)을 반환하고, 만약 실패 상태인데 recover로 복구가 이루어지지 않았다면 예외를 다시 던집니다. 즉, 최종적으로 정상적인 결과가 있을 경우 그 값을, 그렇지 않으면 예외가 발생하도록 하는 역할을 합니다.
데코레이터 패턴이 적용된 서킷 브레이커 테스트
어노테이션 테스트를 했을 때와 동일하게 gRPC 회원 서버를 종료하고 요청을 9번 보내봤습니다. (서킷 정보 조회 api 호출)
예상대로 아직은 서킷이 열리지 않습니다. 왜냐하면 제가 10번까지를 기준으로 정해놨기 때문입니다. 즉, 정상 동작하고 있다는 것입니다.
"grpcCircuitBreaker": {
"failureRate": -1,
"failedCalls": 9,
"state": "CLOSED",
"bufferedCalls": 9
}
이제 10번째 요청을 보내봅시다.
예상대로 서킷이 잘 동작해서 상태가 OPEN으로 변경되었습니다.
"grpcCircuitBreaker": {
"failureRate": 100,
"failedCalls": 10,
"state": "OPEN",
"bufferedCalls": 10
}
5초가 지난 후 확인해 봤습니다.
설정값대로 자동으로 HALF_OPEN으로 변경되었습니다.
"grpcCircuitBreaker": {
"failureRate": -1,
"failedCalls": 0,
"state": "HALF_OPEN",
"bufferedCalls": 0
}
실제로 gRPC 요청을 차단했는지 로그를 확인해 봅시다.
10번째 요청까지는 아래의 로그가 계속 남았습니다. 먼저 gRPC 요청 메서드가 실행되었기에 catch에서 예외가 잡혀서 예외 로그가 작성되었습니다. 이후에 서킷브레이커에서도 예외를 잡아서 fallback 메서드를 호출하였습니다. 그래서 총 2개의 예외 로그가 적혀 있었습니다. (이걸 10번 반복적으로 출력합니다)
2025-02-15T15:17:24.067+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-8] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 - 상태: Status{code=UNAVAILABLE, description=io exception, cause=io.grpc.netty.shaded.io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused: localhost/127.0.0.1:50051
Caused by: java.net.ConnectException: Connection refused
at java.base/sun.nio.ch.Net.pollConnect(Native Method)
at java.base/sun.nio.ch.Net.pollConnectNow(Net.java:682)
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:973)
at io.grpc.netty.shaded.io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:337)
at io.grpc.netty.shaded.io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:334)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:776)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650)
at io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.grpc.netty.shaded.io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
at io.grpc.netty.shaded.io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.grpc.netty.shaded.io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1583)
}, 설명: io exception, 원인: {}
2025-02-15T15:17:23.510+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-4] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
11번째 요청부터는 서킷이 OPEN 상태입니다. 이 상태에서의 로그는 다음과 같습니다.
제가 일부로 서킷이 OPEN(열린) 상태에서 연속으로 5번 요청을 보내봤습니다. 그랬더니 실제로는 gRPC 요청을 보내지 않고 바로 fallback 메서드를 호출한 것을 확인할 수 있었습니다. 그래서 로그도 이것만 남았습니다.
2025-02-15T15:17:24.071+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-8] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:17:24.265+09:00 ERROR 58915 --- [grpc-client-server] [io-8091-exec-10] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:17:24.466+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-2] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:17:24.640+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-4] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
2025-02-15T15:17:24.820+09:00 ERROR 58915 --- [grpc-client-server] [nio-8091-exec-6] c.d.g.client.grpc.GrpcMemberClient
: gRPC 호출 실패 또는 서킷 브레이커 오픈 - memberId=1, fallback 응답 반환
이래서 서킷 브레이커를 사용하는 거죠!
마무리하며
gRPC에 대한 서킷 브레이커는 어노테이션 방식과 데코레이터 패턴 방식 둘 다 매우 잘 적용되었습니다. 어떤 방식을 사용할지는 사용자의 선택에 따라 달라질 것 같습니다. 빠르고 간단하게 적용하기 위해서는 어노테이션 방식을 택할 것이고 조금 더 섬세하게 다루기 위해서는 데코레이터 패턴을 선택하면 될 것입니다.
모두 긴 글 읽어주셔서 감사합니다 :)
'gRPC' 카테고리의 다른 글
[MSA] 스프링부트 gRPC vs FeignClient 성능 비교 (0) | 2025.02.12 |
---|---|
[MSA] SpringBoot에서 gRPC 인터셉터로 서버간 jwt 인증 구현 (0) | 2025.02.10 |
[MSA] SpringBoot에 gRPC 클라이언트 구성하기 (1) | 2025.02.02 |
[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기 (0) | 2025.01.30 |
gRPC 인터셉터를 사용한 JWT 인증과 Spring Security 연동하기 (0) | 2025.01.12 |