gRPC로 서비스 간 통신을 구현하다 보면, "이 프레임워크가 내부적으로 네트워크를 어떻게 처리하는 걸까?" 하는 궁금증이 생깁니다. 답을 따라가다 보면 결국 Netty를 만나게 됩니다.

gRPC란 — Google이 만든 고성능 RPC 프레임워크

gRPC는 Google이 내부에서 사용하던 Stubby라는 RPC 시스템을 오픈소스화한 프레임워크입니다. 핵심 특징을 정리하면 이렇습니다.

  • Protocol Buffers(protobuf): 직렬화 포맷이자 IDL(Interface Definition Language). .proto 파일에 서비스와 메시지를 정의하면 코드가 자동 생성됩니다
  • **HTTP/2 기반 **: 텍스트 기반 HTTP/1.1이 아니라 바이너리 프레이밍의 HTTP/2 위에서 동작합니다
  • ** 다중 언어 지원 **: Java, Go, Python, C++ 등 다양한 언어의 클라이언트/서버를 자동 생성합니다
  • ** 네 가지 통신 패턴 **: Unary, Server Streaming, Client Streaming, Bidirectional Streaming

간단한 .proto 정의를 보면 구조가 명확해집니다.

PROTOBUF
// 서비스와 메시지를 .proto 파일에 정의
syntax = "proto3";

service GreeterService {
  // Unary RPC — 요청 하나, 응답 하나
  rpc SayHello (HelloRequest) returns (HelloReply);

  // Server Streaming — 요청 하나, 응답 여러 개
  rpc SayHelloStream (HelloRequest) returns (stream HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

.proto 파일을 컴파일하면 Java 클래스(스텁)가 생성되고, 개발자는 네트워크 코드를 직접 작성할 필요 없이 메서드 호출처럼 RPC를 사용할 수 있습니다. 그런데 이 "네트워크 코드"를 실제로 누가 처리하는 걸까요?


왜 gRPC가 Netty를 쓰는가

gRPC-Java의 전송 계층은 플러거블(pluggable) 구조입니다. 하지만 ** 기본이자 주력 전송 계층은 Netty**입니다. 그 이유를 하나씩 살펴보겠습니다.

HTTP/2가 필수다

gRPC 프로토콜은 HTTP/2 위에서 동작합니다. Java 표준 라이브러리의 HttpURLConnection은 HTTP/2를 지원하지 않고, java.net.http.HttpClient(Java 11+)는 HTTP/2를 지원하지만 gRPC가 필요로 하는 수준의 ** 프레임 레벨 제어 **가 어렵습니다.

Netty는 Http2FrameCodecHttp2MultiplexHandler를 통해 HTTP/2 프레임을 직접 다룰 수 있습니다.

비동기 논블로킹 I/O

gRPC 서버는 수천 개의 동시 RPC를 처리해야 합니다. 요청마다 스레드를 할당하는 전통적인 블로킹 모델로는 한계가 있습니다. Netty의 EventLoop 모델은 소수의 스레드로 수많은 연결을 효율적으로 처리합니다.

성숙한 생태계

SSL/TLS, 네이티브 트랜스포트(epoll/kqueue), 메모리 풀링 등 네트워크 프로그래밍에 필요한 거의 모든 것이 Netty에 이미 구현되어 있습니다. gRPC 팀이 이걸 처음부터 만들 필요가 없었습니다.

gRPC-Java는 Netty 외에도 OkHttp 기반 전송(Android용)과 in-process 전송(테스트용)을 제공합니다. 하지만 서버 측은 사실상 Netty 전용이고, 프로덕션 클라이언트도 대부분 Netty를 사용합니다.


ManagedChannel — gRPC 클라이언트의 연결 관리

gRPC 클라이언트에서 가장 중요한 개념이 ManagedChannel입니다. HTTP 클라이언트의 커넥션 풀과 비슷한 역할을 하는데, 내부를 들여다보면 Netty의 Bootstrap이 동작하고 있습니다.

JAVA
// gRPC 클라이언트 채널 생성
ManagedChannel channel = ManagedChannelBuilder
    .forAddress("localhost", 50051)
    .usePlaintext()  // 개발 환경에서만 사용 (TLS 비활성화)
    .build();

// 스텁 생성 — 이 스텁이 실제 RPC 호출을 수행
GreeterServiceGrpc.GreeterServiceBlockingStub stub =
    GreeterServiceGrpc.newBlockingStub(channel);

// RPC 호출 — 메서드 호출처럼 사용
HelloReply reply = stub.sayHello(
    HelloRequest.newBuilder().setName("Netty").build()
);

ManagedChannel 내부 구조

ManagedChannel이 관리하는 것들을 정리하면 이렇습니다.

역할설명
** 이름 해석 (Name Resolution)**dns:///service-name 같은 URI를 실제 IP 주소로 변환
** 로드 밸런싱**여러 서버 주소 중 어디로 요청을 보낼지 결정 (round-robin, pick-first 등)
** 서브채널 (Subchannel)**실제 TCP 연결을 담당. 내부적으로 Netty Channel을 감싸고 있음
** 연결 상태 관리**IDLE → CONNECTING → READY → TRANSIENT_FAILURE 상태 전이
** 재연결**연결이 끊기면 백오프(backoff) 전략으로 자동 재연결

핵심은 Subchannel 입니다. 각 Subchannel은 하나의 서버 주소에 대한 HTTP/2 연결을 나타내며, 이 연결이 바로 Netty의 Channel입니다.

PLAINTEXT
ManagedChannel
├── NameResolver (DNS, 정적 주소 등)
├── LoadBalancer (pick-first, round-robin)
└── Subchannel[]
    ├── Subchannel → Netty Channel (서버 A:50051)
    └── Subchannel → Netty Channel (서버 B:50051)
        └── Bootstrap으로 생성된 TCP 연결
            └── ChannelPipeline
                ├── SslHandler (TLS)
                ├── Http2FrameCodec
                └── gRPC 핸들러들

채널의 생명주기

ManagedChannel을 생성한다고 바로 TCP 연결이 맺어지는 건 아닙니다. 첫 번째 RPC 호출 시점에 연결 이 시작됩니다(Lazy Connection). 이건 Netty의 Bootstrap.connect()가 호출되는 시점이기도 합니다.

JAVA
// 채널을 다 쓴 후에는 반드시 종료
// 내부적으로 Netty의 EventLoopGroup.shutdownGracefully() 호출
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);

gRPC 채널을 닫지 않으면 Netty의 EventLoopGroup 스레드가 계속 살아있어서 JVM이 종료되지 않는 문제가 발생할 수 있습니다.


HTTP/2 프레이밍 — gRPC 메시지가 전송되는 과정

gRPC 메시지가 네트워크를 타고 전송될 때, 여러 단계의 변환을 거칩니다. 이 과정을 이해하면 gRPC의 성능 특성과 디버깅 방법이 훨씬 명확해집니다.

변환 과정

PLAINTEXT
Java 객체 (HelloRequest)
  ↓ protobuf 직렬화
바이트 배열 (Protocol Buffers 인코딩)
  ↓ gRPC 프레이밍
gRPC Length-Prefixed Message
  ↓ HTTP/2 인코딩
HTTP/2 HEADERS 프레임 + DATA 프레임
  ↓ Netty → TCP
네트워크 전송

gRPC 프레이밍 헤더

protobuf로 직렬화된 바이트 앞에 5바이트 gRPC 프레이밍 헤더 가 붙습니다.

PLAINTEXT
┌──────────────┬────────────────────────┐
│ Compressed   │ Message Length          │
│ Flag (1byte) │ (4 bytes, big-endian)  │
├──────────────┴────────────────────────┤
│ Serialized Protocol Buffers Message   │
│ (가변 길이)                            │
└───────────────────────────────────────┘
  • Compressed Flag: 0이면 비압축, 1이면 압축(gzip 등)
  • Message Length: 뒤따르는 메시지의 바이트 수

HTTP/2 프레임으로의 변환

gRPC 프레이밍된 메시지는 HTTP/2의 HEADERS 프레임과 DATA 프레임으로 변환됩니다.

PLAINTEXT
요청 시:
  HEADERS 프레임
  ┌─ :method = POST
  ├─ :path = /GreeterService/SayHello
  ├─ :scheme = http
  ├─ content-type = application/grpc
  ├─ te = trailers
  └─ grpc-encoding = identity

  DATA 프레임
  └─ [5바이트 gRPC 헤더 + protobuf 바이트]

응답 시:
  HEADERS 프레임
  ├─ :status = 200
  └─ content-type = application/grpc

  DATA 프레임
  └─ [5바이트 gRPC 헤더 + protobuf 바이트]

  HEADERS 프레임 (trailers)
  ├─ grpc-status = 0
  └─ grpc-message = (비어있음 = 성공)

여기서 중요한 점은 gRPC가 ** 응답의 상태 코드를 trailers에 담는다 **는 것입니다. HTTP 상태 코드 200은 "HTTP 레벨에서 통신이 성공했다"는 의미일 뿐, 실제 gRPC 결과는 grpc-status 트레일러에 들어 있습니다.

이 모든 HTTP/2 프레임 처리는 Netty의 Http2FrameCodec이 담당합니다.


스트림 멀티플렉싱 — 하나의 연결에서 여러 RPC

gRPC가 Netty + HTTP/2를 사용하는 가장 큰 실용적 이점이 바로 ** 스트림 멀티플렉싱 **입니다.

HTTP/1.1 vs gRPC(HTTP/2)

PLAINTEXT
HTTP/1.1 — 연결당 하나의 요청
┌──────────────────────────────────────┐
│ Connection 1: RPC-A 요청 → 응답 대기  │
│ Connection 2: RPC-B 요청 → 응답 대기  │
│ Connection 3: RPC-C 요청 → 응답 대기  │
│ (연결 3개 필요)                       │
└──────────────────────────────────────┘

gRPC (HTTP/2) — 하나의 연결에서 모든 요청
┌──────────────────────────────────────┐
│ Connection 1:                        │
│   Stream 1: RPC-A ─────────────────  │
│   Stream 3: RPC-B ──────────         │
│   Stream 5: RPC-C ────              │
│ (연결 1개로 충분)                     │
└──────────────────────────────────────┘

각 RPC 호출은 HTTP/2 ** 스트림 **에 매핑됩니다. 스트림은 고유한 ID를 가지며, 하나의 TCP 연결 위에서 프레임이 인터리빙(interleaving)됩니다.

Netty에서의 스트림 처리

Netty의 Http2MultiplexHandler는 각 HTTP/2 스트림을 ** 독립된 자식 Channel**(Http2StreamChannel)로 만들어 줍니다. gRPC는 이 구조를 활용합니다.

PLAINTEXT
Netty Channel (TCP 연결)
├── Http2FrameCodec        ← HTTP/2 프레임 인코딩/디코딩
├── Http2MultiplexHandler  ← 스트림을 자식 Channel로 분리

├── Http2StreamChannel (Stream 1) ← RPC-A
│   └── gRPC 스트림 핸들러
├── Http2StreamChannel (Stream 3) ← RPC-B
│   └── gRPC 스트림 핸들러
└── Http2StreamChannel (Stream 5) ← RPC-C
    └── gRPC 스트림 핸들러

각 스트림이 독립된 Channel이므로 하나의 RPC가 느려져도 다른 RPC에 영향을 주지 않습니다. 물론 TCP 레벨의 HOL Blocking은 여전히 존재합니다. TCP 패킷 하나가 유실되면 같은 연결의 모든 스트림이 재전송을 기다려야 하는 문제인데, 이건 HTTP/2의 한계이지 gRPC나 Netty의 한계는 아닙니다.

MAX_CONCURRENT_STREAMS

HTTP/2에는 하나의 연결에서 동시에 열 수 있는 스트림 수를 제한하는 MAX_CONCURRENT_STREAMS 설정이 있습니다. gRPC 서버의 기본값은 보통 100 입니다.

동시 RPC가 이 한도를 초과하면 gRPC는 자동으로 새로운 TCP 연결을 생성 하거나 요청을 큐에 대기시킵니다.


인터셉터와 Netty 핸들러 — 개념적 유사성

gRPC에는 인터셉터(Interceptor) 라는 개념이 있습니다. 이걸 처음 보면 "Netty의 ChannelHandler와 비슷한데?"라는 느낌이 들 수 있는데, 실제로 개념적으로 매우 유사합니다.

Netty ChannelHandler vs gRPC Interceptor

비교 항목Netty ChannelHandlergRPC Interceptor
위치ChannelPipelineChannel/Server에 등록
구조체인 형태로 연결체인 형태로 연결
방향Inbound / OutboundClient / Server
역할바이트 ↔ 메시지 변환, 로깅, 인증 등메타데이터 조작, 로깅, 인증 등
추상 수준네트워크 프레임 레벨RPC/애플리케이션 레벨

클라이언트 인터셉터 예시

JAVA
// gRPC 클라이언트 인터셉터 — 모든 요청에 인증 토큰 추가
public class AuthInterceptor implements ClientInterceptor {
    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
            MethodDescriptor<ReqT, RespT> method,
            CallOptions callOptions,
            Channel next) {

        return new ForwardingClientCall
                .SimpleForwardingClientCall<>(next.newCall(method, callOptions)) {

            @Override
            public void start(Listener<RespT> listener, Metadata headers) {
                // 메타데이터에 인증 토큰 추가
                headers.put(
                    Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER),
                    "Bearer my-token"
                );
                super.start(listener, headers);
            }
        };
    }
}

// 인터셉터를 채널에 등록
ManagedChannel channel = ManagedChannelBuilder
    .forAddress("localhost", 50051)
    .intercept(new AuthInterceptor())  // 체인에 추가
    .build();

서버 인터셉터 예시

JAVA
// gRPC 서버 인터셉터 — 요청 처리 시간 로깅
public class LoggingInterceptor implements ServerInterceptor {
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        long startTime = System.nanoTime();
        String methodName = call.getMethodDescriptor().getFullMethodName();

        // 다음 핸들러(또는 실제 서비스)로 전달
        ServerCall.Listener<ReqT> listener = next.startCall(call, headers);

        return new ForwardingServerCallListener
                .SimpleForwardingServerCallListener<>(listener) {

            @Override
            public void onComplete() {
                long elapsed = System.nanoTime() - startTime;
                // 처리 시간 로깅
                System.out.printf("%s 완료: %dms%n",
                    methodName, elapsed / 1_000_000);
                super.onComplete();
            }
        };
    }
}

인터셉터 체인과 ChannelPipeline의 동작 흐름을 비교하면 이렇습니다.

PLAINTEXT
gRPC 인터셉터 체인:
  클라이언트 코드
    → AuthInterceptor
    → LoggingInterceptor
    → 실제 RPC 전송 (여기서 Netty를 사용)

Netty ChannelPipeline:
  네트워크 바이트
    → SslHandler
    → Http2FrameCodec
    → gRPC 내부 핸들러
    → 비즈니스 로직

gRPC 인터셉터는 애플리케이션 레벨 에서 동작하고, Netty 핸들러는 네트워크 레벨 에서 동작합니다. gRPC를 사용할 때 보통 인터셉터만 다루면 되고, Netty 핸들러를 직접 건드릴 일은 거의 없습니다.


Netty 설정 커스터마이징 — NettyChannelBuilder

기본 ManagedChannelBuilder는 내부적으로 NettyChannelBuilder를 사용합니다. 세밀한 Netty 설정이 필요할 때는 NettyChannelBuilder를 직접 사용할 수 있습니다.

기본 설정

JAVA
import io.grpc.netty.NettyChannelBuilder;

ManagedChannel channel = NettyChannelBuilder
    .forAddress("localhost", 50051)
    .usePlaintext()
    .build();

EventLoopGroup 공유

여러 gRPC 채널이 같은 EventLoopGroup을 공유하면 스레드 수를 제어할 수 있습니다.

JAVA
// EventLoopGroup을 직접 생성하여 여러 채널이 공유
EventLoopGroup sharedGroup = new NioEventLoopGroup(4);

ManagedChannel channelA = NettyChannelBuilder
    .forAddress("service-a", 50051)
    .eventLoopGroup(sharedGroup)
    .channelType(NioSocketChannel.class)
    .usePlaintext()
    .build();

ManagedChannel channelB = NettyChannelBuilder
    .forAddress("service-b", 50052)
    .eventLoopGroup(sharedGroup)
    .channelType(NioSocketChannel.class)
    .usePlaintext()
    .build();

// 주의: 공유 EventLoopGroup은 채널 종료 시 자동으로 닫히지 않음
// 반드시 직접 종료해야 함
channelA.shutdown();
channelB.shutdown();
sharedGroup.shutdownGracefully();

KeepAlive 설정

HTTP/2 연결이 유휴 상태일 때 끊어지지 않도록 keepAlive 핑을 보내는 설정입니다. 로드 밸런서나 프록시 뒤에 있을 때 특히 중요합니다.

JAVA
ManagedChannel channel = NettyChannelBuilder
    .forAddress("localhost", 50051)
    // keepAlive 핑 간격 (기본값 없음 — 비활성)
    .keepAliveTime(30, TimeUnit.SECONDS)
    // keepAlive 핑 응답 대기 시간 (기본 20초)
    .keepAliveTimeout(10, TimeUnit.SECONDS)
    // 활성 RPC가 없어도 keepAlive 핑을 보낼지 (기본 false)
    .keepAliveWithoutCalls(true)
    .build();

서버 측에서 ENHANCE_YOUR_CALM(너무 잦은 핑에 대한 거부) 에러가 발생하면 keepAliveTime을 늘려야 합니다. 서버의 최소 허용 간격보다 짧으면 연결이 끊어집니다.

서버 측 Netty 설정

JAVA
import io.grpc.netty.NettyServerBuilder;

// Netty 기반 gRPC 서버 설정
Server server = NettyServerBuilder
    .forPort(50051)
    // 워커 EventLoopGroup 스레드 수 지정
    .workerEventLoopGroup(new NioEventLoopGroup(8))
    .channelType(NioServerSocketChannel.class)
    // 최대 동시 스트림 수 (HTTP/2 SETTINGS)
    .maxConcurrentCallsPerConnection(100)
    // 수신 메시지 최대 크기 (기본 4MB)
    .maxInboundMessageSize(16 * 1024 * 1024)
    // 연결 유지 설정
    .keepAliveTime(2, TimeUnit.HOURS)
    .keepAliveTimeout(20, TimeUnit.SECONDS)
    // 클라이언트의 최소 keepAlive 간격 허용치
    .permitKeepAliveTime(10, TimeUnit.SECONDS)
    .permitKeepAliveWithoutCalls(true)
    .addService(new GreeterServiceImpl())
    .build()
    .start();

자주 쓰는 NettyChannelBuilder 설정 정리

설정기본값설명
eventLoopGroup()자동 생성커스텀 EventLoopGroup 지정
channelType()NioSocketChannel채널 구현체 (epoll 사용 시 EpollSocketChannel)
keepAliveTime()비활성유휴 연결에 핑 보내는 간격
keepAliveTimeout()20초핑 응답 대기 시간
maxInboundMessageSize()4MB수신 메시지 최대 크기
negotiationType()TLS연결 보안 설정 (TLS / PLAINTEXT)
flowControlWindow()1MBHTTP/2 플로우 컨트롤 윈도우 크기

전체 흐름 정리 — RPC 호출이 Netty를 거치는 과정

마지막으로 gRPC 클라이언트가 RPC를 호출할 때 Netty 내부에서 일어나는 일을 순서대로 정리하겠습니다.

PLAINTEXT
1. stub.sayHello(request)
   └── gRPC 스텁이 RPC 호출 시작

2. 인터셉터 체인 통과
   └── AuthInterceptor → LoggingInterceptor → ...

3. ManagedChannel → Subchannel 선택
   └── LoadBalancer가 적절한 Subchannel(서버) 선택

4. Protocol Buffers 직렬화
   └── HelloRequest → 바이트 배열

5. gRPC 프레이밍
   └── 5바이트 헤더(compressed flag + length) + protobuf 바이트

6. HTTP/2 인코딩 (Netty ChannelPipeline)
   └── HEADERS 프레임 + DATA 프레임 생성

7. TLS 암호화 (SslHandler)
   └── HTTP/2 프레임을 암호화

8. TCP 전송
   └── Netty의 EventLoop가 소켓에 쓰기

응답은 이 과정의 역순으로 처리됩니다. Netty의 EventLoop가 소켓에서 바이트를 읽으면 ChannelPipeline을 통해 역직렬화되고, 최종적으로 stub.sayHello()의 반환값이 됩니다.


실무에서 기억할 포인트

  • **ManagedChannel은 무거운 객체입니다 **: 애플리케이션 전체에서 하나만 만들어 재사용하세요. RPC 호출마다 새로 만들면 매번 TCP 연결 + HTTP/2 핸드셰이크가 발생합니다
  • ** 채널을 닫지 않으면 스레드 누수가 발생합니다 **: shutdown()을 호출하지 않으면 Netty의 EventLoop 스레드가 계속 살아있습니다
  • **keepAlive 설정은 양쪽이 맞아야 합니다 **: 클라이언트의 keepAliveTime이 서버의 permitKeepAliveTime보다 짧으면 ENHANCE_YOUR_CALM 에러가 발생합니다
  • ** 디버깅할 때는 Wireshark로 HTTP/2 프레임을 확인하세요 **: gRPC 문제의 상당수는 HTTP/2 레벨에서 원인을 찾을 수 있습니다
댓글 로딩 중...