서비스 메시 없이 gRPC 서비스 간 회로 차단기를 애플리케이션 레이어에서 직접 다루기
Istio를 도입했다가 메모리 사용량과 CRD 복잡도에 질려서 다시 애플리케이션 레이어로 돌아온 팀을 요즘 꽤 자주 만납니다. 저도 처음엔 "사이드카 하나 붙이면 다 해결되겠지"라고 생각했는데, 막상 운영해보면 회로 차단기 정책 하나 바꾸려고 Istio CRD 문서를 뒤지는 자신을 발견하게 됩니다. 그러다 보니 코드 안에서 직접 회로 차단기를 제어하는 방식이 다시 주목받고 있고, 특히 gRPC 기반 시스템에서는 이 접근이 생각보다 깔끔하게 자리를 잡습니다.
이 글에서는 sony/gobreaker(Go)와 Resilience4j(Java)를 gRPC 클라이언트 인터셉터에 얹어 회로 차단기를 구현하는 방법을 다룹니다. grpc-go 팀이 내장 구현을 제공하지 않고 "외부 라이브러리 + 클라이언트 인터셉터 조합"을 권고한다는 점, gRPC 고유의 상태 코드 매핑 문제, 그리고 Pod가 여러 개일 때 상태가 분산되는 함정까지 짚어보겠습니다.
회로 차단기가 gRPC 환경에서 까다로운 지점
유한 상태 기계로 이해하는 세 가지 상태
회로 차단기는 Michael Nygard가 《Release It!》에서 체계화한 패턴으로, 세 가지 상태를 오가는 유한 상태 기계(FSM)입니다. 개념은 단순하지만, gRPC에 적용할 때는 HTTP와 다른 결이 생깁니다.
Closed 상태에서는 모든 요청이 다운스트림으로 전달되고 실패 횟수가 쌓입니다. 임계치를 넘으면 Open 상태로 전환되어 이후 요청은 다운스트림을 아예 호출하지 않고 즉시 에러를 반환합니다. 설정된 타임아웃이 지나면 Half-Open 상태로 소수의 탐색 요청만 허용해서, 서비스가 살아났으면 Closed로, 아직 죽어있으면 다시 Open으로 돌아갑니다.
gRPC가 일반 HTTP와 다른 이유
HTTP/1.1 기반 서비스에서 쓰던 회로 차단기를 gRPC에 그대로 붙이면 잘 맞지 않습니다. 이유가 몇 가지 있습니다.
첫째, gRPC는 HTTP/2 멀티플렉싱 위에서 동작하기 때문에 단일 TCP 연결에 여러 RPC가 동시에 흐릅니다. 연결 수준의 실패 카운팅은 의미가 없고, RPC 호출 단위로 추적해야 합니다.
둘째, 실패 판정 기준이 다릅니다. gRPC 상태 코드 체계에서는 UNAVAILABLE, INTERNAL, UNKNOWN 같은 코드는 실패로 봐야 하지만, NOT_FOUND나 INVALID_ARGUMENT는 다운스트림이 정상 동작하면서 돌려준 응답이므로 실패로 계산하면 안 됩니다. DEADLINE_EXCEEDED는 팀마다 의견이 갈리는 지점인데, 저는 타임아웃도 실패로 집계하는 쪽을 선호합니다(클라이언트 입장에서 결과가 없으니까요).
셋째, grpc-go 팀은 내장 회로 차단기를 제공하지 않겠다는 입장을 GitHub 이슈 #5672에서 명시해두었습니다. 이 이슈는 gRPC Go에서 회로 차단기를 논의할 때 자주 인용되는 설계 레퍼런스입니다.
Go에서 sony/gobreaker와 Unary 인터셉터 조합하기
서비스별 독립 인스턴스 관리
다운스트림이 여러 개라면 서비스별로 독립된 회로 차단기 인스턴스를 유지하는 것이 중요합니다. Payment 서비스가 죽었다고 Inventory 서비스 회로까지 열려버리면 곤란하니까요.
// 개념적 예시 — sony/gobreaker 기준, import 경로는 github.com/sony/gobreaker
package circuitbreaker
import (
"sync"
"github.com/sony/gobreaker"
)
type MultiServiceBreaker struct {
mu sync.RWMutex
breakers map[string]*gobreaker.CircuitBreaker
settings gobreaker.Settings
}
func NewMultiServiceBreaker(settings gobreaker.Settings) *MultiServiceBreaker {
return &MultiServiceBreaker{
breakers: make(map[string]*gobreaker.CircuitBreaker),
settings: settings,
}
}
func (m *MultiServiceBreaker) Get(service string) *gobreaker.CircuitBreaker {
m.mu.RLock()
cb, ok := m.breakers[service]
m.mu.RUnlock()
if ok {
return cb
}
m.mu.Lock()
defer m.mu.Unlock()
if cb, ok = m.breakers[service]; ok {
return cb
}
s := m.settings
s.Name = service
cb = gobreaker.NewCircuitBreaker(s)
m.breakers[service] = cb
return cb
}gobreaker.Settings의 ReadyToTrip 콜백을 커스터마이징하면 Counts 집계값을 받아 "5회 연속 실패" 대신 "10회 중 6회 이상 실패" 같은 유연한 임계치를 만들 수 있습니다. 여기서 중요한 점은 ReadyToTrip은 오로지 트립 여부만 판단한다는 것입니다. 개별 오류를 실패로 셀지 말지는 다음 절에서 다룰 Execute 클로저의 반환값에서 갈립니다. 두 지점을 헷갈리면 실패 필터링이 엉뚱한 자리에 들어가게 됩니다.
실패 판정을 위한 gRPC 상태 코드 필터
package circuitbreaker
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func isFailure(err error) bool {
if err == nil {
return false
}
s, ok := status.FromError(err)
if !ok {
return true
}
switch s.Code() {
case codes.Unavailable, codes.Internal, codes.Unknown:
return true
case codes.DeadlineExceeded:
return true // 팀 정책에 따라 false로 변경 가능
default:
return false
}
}gRPC Unary 클라이언트 인터셉터
package circuitbreaker
import (
"context"
"errors"
"strings"
"github.com/sony/gobreaker"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// method 파라미터는 "/package.ServiceName/MethodName" 형태로 전달됩니다.
// 회로 차단기 키로 서비스 이름만 뽑아 씁니다.
func extractServiceName(fullMethod string) string {
trimmed := strings.TrimPrefix(fullMethod, "/")
if idx := strings.Index(trimmed, "/"); idx > 0 {
return trimmed[:idx]
}
return trimmed
}
func CircuitBreakerInterceptor(msb *MultiServiceBreaker) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
req, reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
svcName := extractServiceName(method)
cb := msb.Get(svcName)
var callErr error
_, cbErr := cb.Execute(func() (any, error) {
callErr = invoker(ctx, method, req, reply, cc, opts...)
if isFailure(callErr) {
// gobreaker에 실패로 집계시키기 위해 원본 에러를 그대로 반환
return nil, callErr
}
// NOT_FOUND 등 비즈니스 오류는 gobreaker에 성공으로 신고하고,
// 원본 에러는 밖에서 호출자에게 그대로 돌려줍니다.
return nil, nil
})
switch {
case errors.Is(cbErr, gobreaker.ErrOpenState):
return status.Errorf(codes.Unavailable, "circuit breaker open for %s", svcName)
case errors.Is(cbErr, gobreaker.ErrTooManyRequests):
// Half-Open 상태에서 허용 한도(MaxRequests)를 초과한 경우
return status.Errorf(codes.Unavailable, "circuit breaker probing limit for %s", svcName)
}
return callErr
}
}여기서 초안에 있던 두 가지 함정을 함께 고쳤습니다. 첫째, gobreaker는 Execute 클로저가 non-nil 에러를 돌려주면 무조건 실패로 집계하므로, NOT_FOUND 같은 비즈니스 오류는 클로저에서 nil, nil로 마감하고 실제 에러는 클로저 밖 변수(callErr)로 빼야 합니다. 둘째, Half-Open에서 MaxRequests를 초과하면 ErrOpenState가 아닌 ErrTooManyRequests가 돌아오므로 이 케이스를 분기해두지 않으면 raw 에러가 상위로 새어 나갑니다.
이 인터셉터는 grpc.NewClient 시점에 grpc.WithUnaryInterceptor(...) 옵션으로 주입합니다.
Java에서 Resilience4j와 GlobalClientInterceptor 조합하기
Spring Boot 환경에서는 @GrpcGlobalClientInterceptor를 이용해 모든 gRPC 클라이언트 호출에 회로 차단기를 한 번에 씌울 수 있습니다.
// 개념적 예시 — grpc-spring-boot-starter + Resilience4j
@Component
@GrpcGlobalClientInterceptor
public class CircuitBreakerClientInterceptor implements ClientInterceptor {
private final CircuitBreakerRegistry registry;
public CircuitBreakerClientInterceptor(CircuitBreakerRegistry registry) {
this.registry = registry;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions,
Channel next) {
String cbName = method.getServiceName();
CircuitBreaker cb = registry.circuitBreaker(cbName);
return new ForwardingClientCall.SimpleForwardingClientCall<>(
next.newCall(method, callOptions)) {
private long startNanos;
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
cb.acquirePermission(); // Open 상태면 CallNotPermittedException 발생
startNanos = System.nanoTime();
super.start(new ForwardingClientCallListener
.SimpleForwardingClientCallListener<>(responseListener) {
@Override
public void onClose(Status status, Metadata trailers) {
long elapsed = System.nanoTime() - startNanos;
if (isGrpcFailure(status)) {
cb.onError(elapsed, TimeUnit.NANOSECONDS, toException(status));
} else {
cb.onSuccess(elapsed, TimeUnit.NANOSECONDS);
}
super.onClose(status, trailers);
}
}, headers);
}
};
}
private boolean isGrpcFailure(Status status) {
return status.getCode() == Status.Code.UNAVAILABLE
|| status.getCode() == Status.Code.INTERNAL
|| status.getCode() == Status.Code.UNKNOWN
|| status.getCode() == Status.Code.DEADLINE_EXCEEDED;
}
// 독자가 조직 규약에 맞게 구현할 헬퍼입니다.
// 가장 단순하게는 status.asRuntimeException()을 그대로 반환하면 됩니다.
private Throwable toException(Status status) {
return status.asRuntimeException();
}
}onError/onSuccess의 duration 인자를 0으로 넘기면 Resilience4j의 시간 기반 슬라이딩 윈도우 통계가 죽습니다. System.nanoTime()으로 호출 시작 시각을 캡처해 경과 시간을 넘겨야, count-based뿐 아니라 time-based 윈도우 설정("최근 30초 창에서 오류율 50% 이상")까지 정상 동작합니다.
Resilience4j는 Count-based와 Time-based 두 가지 슬라이딩 윈도우를 지원하므로, "최근 100번의 호출 중 50% 이상 실패"처럼 비율 기반 임계치를 잡을 수 있습니다. gRPC 클라이언트 연동의 참고 구현으로는 Deep Network GmbH의 Resilience4j-gRPC 예제가 널리 인용됩니다.
요청 흐름과 회로 상태별 동작 시퀀스
Half-Open에서 허용할 탐색 요청 수와 성공 기준은 명시적으로 정하는 편이 안전합니다. 예를 들어 gobreaker의 MaxRequests를 2로 두고 두 번 모두 성공해야 Closed로 복귀하도록 설정할 수 있습니다.
트레이드오프와 실무에서 자주 만나는 함정
장단점 한눈에 보기
| 항목 | 애플리케이션 레이어 구현 | 서비스 메시(Istio 등) |
|---|---|---|
| 인프라 의존성 | 없음 | 사이드카 프록시 필수 |
| 정책 세밀도 | 서비스·메서드 단위 코드 제어 | CRD/YAML로 선언적 설정 |
| 디버깅 | 코드 추적·단위 테스트 가능 | 프록시 로그·대시보드 의존 |
| 진입 장벽 | 라이브러리 추가만으로 도입 | 운영 지식·인프라 셋업 필요 |
| 폴리글랏 일관성 | 언어별로 구현 상이 | 언어 무관 동일 정책 |
| Pod 간 상태 공유 | 불가 (각 Pod가 독립 상태) | 가능 (중앙화된 정책) |
Pod 분산으로 인한 상태 비공유
실무에서 가장 자주 놓치는 함정입니다. Pod가 3개라면 회로 차단기 인스턴스도 3개가 각자 살아 있습니다. 한 Pod에서 회로가 열려도 다른 두 Pod는 여전히 실패 요청을 다운스트림으로 흘려보냅니다.
이를 완화하는 방법은 두 가지입니다. 첫째, 임계치를 보수적으로 낮게 잡아서 각 Pod가 독립적으로도 빠르게 Open에 도달하도록 합니다. 둘째, OpenTelemetry 메트릭으로 회로 상태를 계측해서 Prometheus/Grafana에서 전체 Pod의 회로 상태를 통합 가시화합니다. 특정 Pod에서 회로가 반복적으로 열린다는 신호를 운영팀이 조기에 인지할 수 있게 됩니다.
gRPC 내장 Retry와의 충돌
gRPC 서비스 설정의 retryPolicy를 함께 사용하면 Open 상태에서 반환된 UNAVAILABLE 에러가 retryable 코드로 분류되어 재시도가 붙습니다. 회로를 열어서 fail-fast를 노리는데 재시도가 계속 들어오면 효과가 사라집니다. retryableStatusCodes에서 UNAVAILABLE을 제거하거나, 회로 차단기가 반환하는 에러에 별도 메타데이터를 붙여 재시도 로직이 구분하도록 만들어야 합니다.
스트리밍 RPC는 별도 설계가 필요
위 인터셉터는 Unary RPC 기준입니다. 양방향 스트리밍에서는 스트림이 열린 뒤 중간에 발생하는 실패를 어떻게 카운트할지 별도 설계가 필요합니다. 구현상으로는 StreamClientInterceptor를 새로 만들어야 하지만, 회로 차단기 관점에서 가장 안전한 시작 지점은 스트림 생성 시도 자체를 인터셉트하는 것입니다. 이는 Unary 인터셉터에서 invoker 호출을 감싸는 것과 개념적으로 같은 전략이고, 스트림 생성 단계에서 발생하는 UNAVAILABLE만 회로에 반영합니다. 반면 스트림 내부에서 오가는 개별 메시지 실패는 팀 정책에 따라 별개 지표(예: 스트림 지속 시간, 재연결 빈도)로 분리해 다루는 편이 오탐을 줄이는 데 도움이 됩니다.
라이브러리 선택 기준: gobreaker vs mercari/go-circuitbreaker
Go 진영에서 자주 비교되는 두 라이브러리입니다. sony/gobreaker는 API가 최소한이고 의존성이 가벼워서, 이번 글처럼 인터셉터 안에서 Execute 클로저 하나로 감싸는 경우에 잘 어울립니다. mercari/go-circuitbreaker는 context.Context를 1급 시민으로 다루고 Ignore/MarkAsSuccess 같은 결과 마킹 API가 명시적으로 노출되어 있어서, 컨텍스트 취소와 성공/실패 판정 로직을 세밀하게 갈라야 하는 코드베이스에 유리합니다. 한 프로젝트 안에서 두 라이브러리를 섞을 이유는 거의 없으니 조직 표준을 하나 정해두는 편이 낫습니다.
설계 결정 트리
어떤 방식으로 구현할지 고민될 때 참고할 수 있는 흐름입니다.
마무리
이 글에서 실제로 강조하고 싶었던 건 화려한 아키텍처가 아니라 아주 좁은 지점입니다. gobreaker Execute 클로저에서 gRPC 상태 코드를 걸러내는 방식을 잘못 짜면, 회로 차단기가 조용히 오작동합니다. NOT_FOUND를 실패로 세어 다운스트림이 멀쩡한데도 회로가 열리거나, ErrTooManyRequests를 놓쳐 Half-Open 문턱에서 raw 에러가 위로 새는 식으로요. 클로저 안에서는 실패로 삼을 것과 삼지 않을 것을 명확히 분리해서 gobreaker에 넘기고, 진짜 애플리케이션 에러는 클로저 밖 변수로 빼서 호출자에게 그대로 돌려주는 것이 이 패턴의 핵심입니다.
서비스 메시가 모든 팀에 맞는 답은 아닙니다. 라이브러리 하나와 인터셉터 하나로도 장애 격리는 시작할 수 있고, 그 시작을 안전하게 만드는 것은 이 좁은 두 줄의 반환값입니다.
참고 자료
- Circuit Breaker recommendations · grpc/grpc-go Issue #5672 (GitHub)
- sony/gobreaker — Circuit Breaker implemented in Go (GitHub)
- mercari/go-circuitbreaker — Context-aware circuit breaker (GitHub)
- Setting up Resilience4j Circuit Breaker for gRPC Java Client (Deep Network GmbH)
- Resilience4j CircuitBreaker 공식 문서
- Adding Circuit Breaker and Bulkheading Interceptor · go-grpc-middleware Issue #575 (GitHub)
- gRPC codes package 공식 문서 (Go)
- Michael Nygard, Release It! — Circuit Breaker 패턴 원문