← all posts
DEV 2026.05.02 · 13 min read Intermediate

Spring에서 gRPC를 제대로 쓰려면 무엇이 필요한가

설정 자동화부터 보안 컨텍스트 전파, 예외 매핑, Reactive 통합, 테스트 격리까지 — grpc-spring-boot-starter의 전체 동작 원리를 추적한다.


gRPC와 Spring Boot를 연결하는 일은 의존성 하나 추가로 끝나지 않는다. @GrpcService가 서버에 등록되는 방식, SecurityContext가 interceptor를 통해 흘러가는 방식, RuntimeExceptionStatus.INTERNAL로 둔갑하는 방식 — 이것들을 이해하지 못하면 연결 실패와 보안 우회, 그리고 이벤트 루프 블로킹이 서로 구분되지 않는 혼란에 빠진다. 이 다섯 챕터에서 반복적으로 나타나는 질문은 하나다: Spring의 각 레이어가 gRPC의 어느 지점에 끼어들며, 그 경계에서 무엇이 깨지는가?

설정은 Bean이 아니라 YAML에서 시작한다

grpc-spring-boot-starter의 핵심 약속은 “설정 파일에서 모든 것을 제어한다”는 것이다. @GrpcService를 달면 Spring Boot 시작 시 classpath 스캔이 해당 클래스를 찾아 GrpcServiceRegistry에 등록하고, 이후 bindService()를 호출해 grpc.server.port에서 리스닝을 시작한다. 반대로 @GrpcClient를 달면 GrpcClientBeanPostProcessorgrpc.client.* 설정을 읽어 ManagedChannel을 빌드한 뒤 stub을 주입한다.

이 흐름에서 흔한 실수는 ManagedChannel@Bean으로 직접 정의하는 것이다. 그렇게 하면 grpc.client.* 설정이 무시되고, 채널 정리도 자동으로 되지 않는다. 또 다른 실수는 클라이언트 주소를 http://localhost:9090으로 지정하는 것이다. gRPC는 이 문자열 전체를 호스트명으로 해석해 DNS 조회에 실패한다. 올바른 형식은 dns:///service-name:9090이고, 이 형태는 로드밸런싱과 서비스 디스커버리도 함께 지원한다.

keepAlive 설정은 클라우드 환경에서 별도로 다뤄야 한다. AWS ALB를 포함한 대부분의 로드밸런서는 60초 이상 유휴 상태인 연결을 끊는다. enable-keep-alive: truekeep-alive-time: 30s를 함께 설정하지 않으면, 서비스는 간헐적인 “Connection refused”를 마주하게 된다.

SecurityContext는 interceptor에서 직접 심어야 한다

gRPC는 Spring Security와 기본적으로 통합되지 않는다. @PreAuthorize가 작동하려면 ServerInterceptor가 JWT를 파싱해 SecurityContextHolderAuthentication 객체를 설정해야 한다. 이 과정이 빠지면 SecurityContextHolder.getContext().getAuthentication()null을 반환하고, @PreAuthorize("hasRole('ADMIN')")은 항상 PERMISSION_DENIED를 뱉는다.

@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
        ServerCall<ReqT, RespT> call,
        Metadata headers,
        ServerCallHandler<ReqT, RespT> next) {

    String authHeader = headers.get(AUTHORIZATION_KEY);
    if (authHeader != null && authHeader.startsWith("Bearer ")) {
        try {
            Authentication auth = jwtProvider.getAuthentication(
                authHeader.substring(7)
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (JwtException e) {
            call.close(
                Status.UNAUTHENTICATED.withDescription("Invalid JWT"),
                new Metadata()
            );
            return new ServerCall.Listener<ReqT>() {};
        }
    }
    return next.startCall(call, headers);
}

비동기 스트리밍이 개입하면 복잡해진다. SecurityContextHolderThreadLocal 기반이므로, subscribeOn(Schedulers.boundedElastic())으로 스레드를 전환하면 컨텍스트가 사라진다. 해결책은 단순하다 — 메인 스레드에서 SecurityContext를 변수에 캡처하고, 각 비동기 콜백 안에서 복원한 뒤, 처리가 끝나면 clearContext()를 호출한다.

예외는 반드시 Status로 번역되어야 한다

처리되지 않은 RuntimeException은 gRPC 런타임이 Status.INTERNAL(13)으로 처리한다. 클라이언트 입장에서는 “서버 내부 오류”로만 보인다. UserNotFoundException이 404인지, 검증 실패가 400인지, 권한 부족이 403인지 — 이 정보는 전부 사라진다.

@GrpcExceptionHandler는 이 번역 계층이다. Spring MVC의 @ExceptionHandler와 유사하게, 예외 타입을 StatusRuntimeException으로 매핑하는 메서드를 선언할 수 있다.

@GrpcExceptionHandler
public StatusRuntimeException handleUserNotFound(UserNotFoundException ex) {
    return Status.NOT_FOUND.withDescription(ex.getMessage()).asException();
}

@GrpcExceptionHandler
public StatusRuntimeException handleGenericException(Exception ex) {
    log.error("Unhandled exception", ex);
    return Status.INTERNAL.withDescription("An internal error occurred").asException();
}
스택 트레이스는 클라이언트에 보내지 마라

ex.getStackTrace().toString()을 description에 포함하면 클래스명, 파일명, 라인 번호가 외부로 노출된다. 공격자가 시스템 구조를 파악하는 데 직접적인 단서가 된다. 상세 로그는 서버 내부에만, 클라이언트 응답에는 사람이 읽을 수 있는 짧은 메시지만 넣어라.

google.rpc.BadRequestErrorInfo 같은 ErrorDetails를 활용하면 구조화된 에러 정보를 클라이언트에 전달할 수 있다. Bean Validation의 ConstraintViolationExceptionBadRequest.FieldViolation 목록으로 변환하는 패턴이 특히 유용하다.

Blocking Stub은 WebFlux에서 쓸 수 없다

WebFlux는 소수의 이벤트 루프 스레드로 수많은 요청을 처리한다. BlockingStub은 응답이 올 때까지 스레드를 점유한다. 두 가지를 합치면 이벤트 루프가 gRPC 응답을 기다리는 동안 다른 모든 요청이 큐에 쌓인다.

reactor-grpc-stub은 이 문제를 해결한다. ReactorServiceStubStreamObserverMonoFlux로 감싼다. Unary RPC는 Mono<T>가 되고, Server Streaming은 Flux<T>가 된다. 구독이 발생할 때 비로소 gRPC 호출이 시작되며, I/O는 boundedElastic 스케줄러가 담당한다.

트레이드오프

BlockingStub은 코드가 단순하고 흐름이 명확하다. Spring MVC 환경에서는 충분히 쓸 만하다. WebFlux라면 ReactiveStub이 필수다. 혼합 환경이라면 처음부터 ReactiveStub으로 통일하는 것이 더 안전하다. Reactive 스트림의 Backpressure는 HTTP/2 Flow Control과 자동으로 연동되므로, 느린 구독자가 서버에 과부하를 주는 문제도 함께 해결된다.

테스트는 네트워크를 피하는 것에서 시작한다

실제 포트를 열고 테스트하면 병렬 실행 시 포트 충돌이 생기고, 테스트가 느려진다. InProcessChannel은 네트워크 레이어를 완전히 우회해 같은 JVM 내에서 서버와 클라이언트를 연결한다. 단위 테스트에서 1~5ms 안에 gRPC 호출이 완료된다.

String serverName = InProcessServerBuilder.generateName();

InProcessServerBuilder
    .forName(serverName)
    .directExecutor()
    .addService(new UserServiceImpl(mock(UserRepository.class)))
    .build().start();

ManagedChannel channel = InProcessChannelBuilder
    .forName(serverName)
    .directExecutor()
    .build();

비동기 Server Streaming 테스트는 CountDownLatch로 동기화한다. onCompleted()onError()가 호출될 때 latch.countDown()을 실행하고, 테스트 스레드는 latch.await(5, TimeUnit.SECONDS)로 대기한다. 래치 없이 assert를 실행하면 스트림이 완료되기 전에 검증이 끝나 항상 통과하는 거짓 양성이 나온다.

실제 환경을 검증해야 할 때는 Testcontainers로 gRPC 서버 컨테이너를 올린다. 단위 테스트로는 잡을 수 없는 직렬화 문제나 네트워크 타임아웃 동작을 확인할 수 있다.

정리

  • @GrpcService@GrpcClient는 Spring IoC가 빈을 생성할 때 자동 등록된다. ManagedChannel을 직접 @Bean으로 만들면 이 자동화와 충돌한다.
  • Spring Security는 gRPC에 자동 적용되지 않는다. ServerInterceptor에서 JWT를 파싱해 SecurityContextHolder에 직접 설정해야 @PreAuthorize가 작동한다.
  • 처리되지 않은 예외는 Status.INTERNAL이 된다. @GrpcExceptionHandler로 모든 예외를 명시적으로 Status로 번역하라.
  • WebFlux 환경에서 BlockingStub은 이벤트 루프를 블로킹한다. reactor-grpc-stubReactiveStub으로 교체하면 Backpressure도 HTTP/2 Flow Control과 자동으로 연동된다.
  • 단위 테스트는 InProcessChannel, 통합 테스트는 Testcontainers로 분리하라. 비동기 스트림은 CountDownLatch 없이 검증할 수 없다.