gRPC + Spring Boot — Interceptors

2026-05-03확률과 통계 마스터 노트

gRPC + Spring Boot 마스터 노트 시리즈 7편. Interceptor가 인증·로깅·메트릭 같은 횡단 관심사를 분리하는 메커니즘, ServerInterceptor·ClientInterceptor 양쪽 구현, 체인 순서와 우선순위, 글로벌·서비스별 인터셉터 등록, MDC tracing·Micrometer 메트릭 통합, 자주 쓰는 인터셉터 패턴까지.

이 글은 gRPC + Spring Boot 마스터 노트 시리즈의 일곱 번째 편입니다. 1~6편이 RPC 모드였다면, 이번엔 그 위에 횡단 관심사 — Interceptor.

인증·로깅·메트릭·재시도·tracing을 RPC 코드에서 분리. Spring AOP의 gRPC 버전. 잘 쓰면 깔끔, 잘못 쓰면 복잡.

처음 Interceptor가 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, ServerInterceptor vs ClientInterceptor 어느 쪽인지 헷갈립니다. 둘째, 체인 순서가 막연합니다.

해결법은 한 가지예요. "Interceptor = AOP, 서버·클라이언트 양쪽". 서버 = 들어오는 요청, 클라이언트 = 나가는 요청. 양쪽 모두 가능. 체인은 등록 순서 (가장 먼저 등록 = 가장 바깥).

ServerInterceptor 기본

public class LoggingServerInterceptor implements ServerInterceptor {
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
        ServerCall<ReqT, RespT> call,
        Metadata headers,
        ServerCallHandler<ReqT, RespT> next
    ) {
        log.info("Incoming: {}", call.getMethodDescriptor().getFullMethodName());
        
        return next.startCall(call, headers);
    }
}

핵심 — interceptCall 오버라이드. 메서드 이름·헤더 등 가로채기.

등록 — Spring Boot

글로벌 (모든 서비스)

@Configuration
public class GrpcConfig {
    
    @Bean
    @GrpcGlobalServerInterceptor
    public ServerInterceptor loggingInterceptor() {
        return new LoggingServerInterceptor();
    }
}

@GrpcGlobalServerInterceptor — 모든 @GrpcService에 자동 적용.

서비스별

@GrpcService(interceptors = {LoggingServerInterceptor.class})
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase { ... }

특정 서비스만.

ClientInterceptor

public class LoggingClientInterceptor implements ClientInterceptor {
    
    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
        MethodDescriptor<ReqT, RespT> method,
        CallOptions callOptions,
        Channel next
    ) {
        log.info("Outgoing: {}", method.getFullMethodName());
        return next.newCall(method, callOptions);
    }
}
@Bean
@GrpcGlobalClientInterceptor
public ClientInterceptor clientLogging() {
    return new LoggingClientInterceptor();
}

또는 명시적:

@GrpcClient(value = "user-service", interceptors = {LoggingClientInterceptor.class})
private UserServiceGrpc.UserServiceBlockingStub stub;

인증 인터셉터

public class AuthServerInterceptor implements ServerInterceptor {
    
    private static final Metadata.Key<String> TOKEN = 
        Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
    
    @Autowired
    private AuthService authService;
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
        ServerCall<ReqT, RespT> call,
        Metadata headers,
        ServerCallHandler<ReqT, RespT> next
    ) {
        String token = headers.get(TOKEN);
        
        if (token == null || !authService.verify(token)) {
            call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), new Metadata());
            return new ServerCall.Listener<ReqT>() {};
        }
        
        // 인증 성공 → Context에 사용자 정보 저장
        Context ctx = Context.current().withValue(USER_KEY, authService.getUser(token));
        return Contexts.interceptCall(ctx, call, headers, next);
    }
}

핵심 — Metadata에서 토큰 추출 → 검증 → 실패 시 call.close(), 성공 시 Context에 사용자 저장.

서비스에서 인증 정보 사용

public static final Context.Key<User> USER_KEY = Context.key("user");

@Override
public void getProfile(Empty req, StreamObserver<Profile> observer) {
    User user = USER_KEY.get();
    
    Profile profile = profileService.getByUser(user.getId());
    observer.onNext(profile);
    observer.onCompleted();
}

Context.Key로 인터셉터 ↔ 서비스 통신.

로깅 + 시간 측정

public class TimingServerInterceptor implements ServerInterceptor {
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
        ServerCall<ReqT, RespT> call,
        Metadata headers,
        ServerCallHandler<ReqT, RespT> next
    ) {
        long start = System.currentTimeMillis();
        String method = call.getMethodDescriptor().getFullMethodName();
        
        ServerCall<ReqT, RespT> wrappedCall = new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
            @Override
            public void close(Status status, Metadata trailers) {
                long duration = System.currentTimeMillis() - start;
                log.info("{} {} {}ms", method, status.getCode(), duration);
                super.close(status, trailers);
            }
        };
        
        return next.startCall(wrappedCall, headers);
    }
}

ForwardingServerCall로 응답 close 시점 후크.

Micrometer 메트릭

implementation 'net.devh:grpc-server-spring-boot-starter:3.x'
implementation 'io.micrometer:micrometer-registry-prometheus'
grpc:
  server:
    metric-server:
      enabled: true

자동 메트릭:

  • grpc.server.calls.duration
  • grpc.server.calls.received
  • 서비스별·메서드별·status별

Grafana 대시보드 자동.

MDC Tracing

public class MDCInterceptor implements ServerInterceptor {
    
    private static final Metadata.Key<String> TRACE_ID = 
        Metadata.Key.of("trace-id", Metadata.ASCII_STRING_MARSHALLER);
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
        ServerCall<ReqT, RespT> call,
        Metadata headers,
        ServerCallHandler<ReqT, RespT> next
    ) {
        String traceId = headers.get(TRACE_ID);
        if (traceId == null) {
            traceId = UUID.randomUUID().toString();
        }
        
        MDC.put("trace-id", traceId);
        try {
            return next.startCall(call, headers);
        } finally {
            MDC.clear();
        }
    }
}

여기서 정말 중요한 시험 함정 — MDC는 ThreadLocal. gRPC는 비동기 처리 시 다른 스레드 → MDC 전파 X. Reactor Context 또는 명시적 전파 필요.

클라이언트 — 인증 토큰 자동 첨부

public class AuthClientInterceptor implements ClientInterceptor {
    
    private static final Metadata.Key<String> TOKEN = 
        Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
    
    @Autowired
    private TokenProvider tokenProvider;
    
    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
        MethodDescriptor<ReqT, RespT> method,
        CallOptions options,
        Channel next
    ) {
        return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
            next.newCall(method, options)
        ) {
            @Override
            public void start(Listener<RespT> responseListener, Metadata headers) {
                String token = tokenProvider.getToken();
                headers.put(TOKEN, "Bearer " + token);
                super.start(responseListener, headers);
            }
        };
    }
}

모든 클라이언트 호출에 자동 토큰 첨부.

재시도 인터셉터

public class RetryClientInterceptor implements ClientInterceptor {
    
    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
        MethodDescriptor<ReqT, RespT> method,
        CallOptions options,
        Channel next
    ) {
        return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
            next.newCall(method, options)
        ) {
            // 재시도 로직
        };
    }
}

또는 gRPC 내장 retry policy 사용 권장:

grpc:
  client:
    user-service:
      address: static://localhost:9090
      method-config:
        - service: UserService
          method: GetUser
          retryPolicy:
            maxAttempts: 3
            initialBackoff: 1s
            maxBackoff: 10s
            backoffMultiplier: 2
            retryableStatusCodes:
              - UNAVAILABLE
              - DEADLINE_EXCEEDED

인터셉터 체인

여러 인터셉터 등록 시:

요청 들어옴
  ↓
[ServerInterceptor 1: 인증]
  ↓
[ServerInterceptor 2: 로깅]
  ↓
[ServerInterceptor 3: 시간 측정]
  ↓
[실제 서비스 메서드]
  ↑
[3] → [2] → [1] → 응답

여기서 시험 함정이 하나 있어요. 인터셉터 순서. 등록 순서대로. @Order 또는 List 명시.

@GrpcGlobalServerInterceptor
@Order(1)
public ServerInterceptor authInterceptor() { ... }

@GrpcGlobalServerInterceptor
@Order(2)
public ServerInterceptor loggingInterceptor() { ... }

낮은 순서 = 먼저 실행 (가장 바깥).

자주 쓰는 인터셉터 패턴

1. 인증 (Authorization 헤더 검증)
2. 인가 (RBAC·라우트별 권한)
3. 로깅 (요청·응답·시간)
4. Tracing (trace-id 전파, OpenTelemetry)
5. 메트릭 (Micrometer·Prometheus)
6. Rate Limit
7. 재시도 (클라이언트)
8. Circuit Breaker
9. MDC 컨텍스트
10. 입력 검증

대부분 라이브러리·내장 기능 활용 권장. 직접 구현은 특수 경우만.

OpenTelemetry 통합

implementation 'io.opentelemetry:opentelemetry-api'
implementation 'io.opentelemetry.instrumentation:opentelemetry-grpc-1.6'
@Bean
public ServerInterceptor otelInterceptor(OpenTelemetry otel) {
    return GrpcTelemetry.create(otel).newServerInterceptor();
}

자동 trace 전파. 분산 시스템 가시성.

디버깅

@Bean
public ServerInterceptor debugInterceptor() {
    return new ServerInterceptor() {
        @Override
        public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next
        ) {
            log.debug("Method: {}", call.getMethodDescriptor().getFullMethodName());
            log.debug("Headers: {}", headers);
            return next.startCall(call, headers);
        }
    };
}

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Interceptor = AOP, 서버·클라이언트 양쪽
  • ServerInterceptor — 들어오는 요청
  • ClientInterceptor — 나가는 요청
  • 등록 — @GrpcGlobalServerInterceptor (모든) / @GrpcService(interceptors = ...) (특정)
  • 인증 — Metadata에서 토큰 → 검증 → call.close(UNAUTHENTICATED) 또는 Context 저장
  • Context.Key = 인터셉터 ↔ 서비스 통신
  • 로깅·시간 — ForwardingServerCall.SimpleForwardingServerCall 후크
  • Micrometer 메트릭 — 자동 (grpc-server-spring-boot-starter)
  • duration·received·status 자동 분리
  • MDC 함정 — ThreadLocal, 비동기 시 전파 X
  • Reactor Context 또는 명시적 전파
  • 클라이언트 — ForwardingClientCall + headers.put(TOKEN)
  • 재시도 — gRPC 내장 retry policy 권장 (yaml)
  • retryableStatusCodes — UNAVAILABLE·DEADLINE_EXCEEDED
  • 인터셉터 체인 순서 — 등록 순서·@Order
  • 낮은 순서 = 먼저 실행 (바깥)
  • 자주 쓰는 — 인증·인가·로깅·tracing·메트릭·rate limit·재시도·MDC
  • OpenTelemetryGrpcTelemetry.newServerInterceptor
  • 자동 trace 전파

시리즈 다른 편

공식 문서: gRPC Interceptors 에서 더 깊이.

다음 글(8편)에서는 Error Handling — Status Code 16종, 에러 전파, 클라이언트 처리, ExceptionHandler까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!