gRPC로 서비스 간 통신하기: Protocol Buffers, Unary·Streaming RPC, Node.js·Go 구현

이 글의 핵심

gRPC와 Protocol Buffers의 기본, 서비스 정의에서 코드 생성까지, Node.js와 Go로 Unary·Streaming RPC 구현하기, 에러 코드와 인증 처리, 배포 시 주의점을 다룹니다.

이 글의 핵심

gRPC로 고성능 API를 구축하는 글입니다. Protocol Buffers, 서비스 정의, Unary/Streaming RPC, Node.js/Go 구현까지 실전 예제로 정리했습니다.

실무에서 마주치는 문제들

JSON 직렬화 비용이 커요

서비스 사이에 큰 페이로드를 자주 주고받으면 JSON의 텍스트 인코딩과 필드 이름 반복이 대역폭과 CPU를 씁니다. gRPC가 쓰는 Protocol Buffers는 필드 이름 대신 번호를 쓰는 바이너리 형식이라 메시지가 작고 파싱이 빠릅니다.

API 스펙이 코드와 어긋나요

REST API는 문서(OpenAPI 등)와 실제 구현이 따로 관리되어 어긋나기 쉽습니다. gRPC는 .proto 파일 하나가 계약이자 코드 생성의 원천이라, 서버와 클라이언트가 같은 정의에서 만들어진 타입을 씁니다.

스트리밍이 필요해요

HTTP/1.1 기반 REST에서 서버가 여러 번 나눠 보내거나 양쪽이 동시에 메시지를 주고받으려면 SSE나 WebSocket을 따로 붙여야 합니다. gRPC는 HTTP/2 스트림 위에서 서버·클라이언트·양방향 스트리밍을 기본 기능으로 제공합니다.


gRPC란?

핵심 특징

gRPC는 Google이 만든 고성능 RPC 프레임워크입니다. 주요 장점:

  • 빠른 성능: 바이너리 프로토콜
  • 타입 안전성: Protocol Buffers
  • 스트리밍: 양방향 지원
  • 다국어: 10+ 언어 지원
  • HTTP/2: 멀티플렉싱

“얼마나 빠른가”는 페이로드와 환경에 따라 크게 달라서 한 가지 숫자로 말하기 어렵습니다. 숫자 필드가 많은 메시지는 Protobuf가 JSON보다 훨씬 작아지지만, 긴 문자열이 대부분인 메시지는 크기 차이가 작고 gzip을 켠 JSON과 비교하면 더 줄어듭니다. 실제 이득은 직렬화 속도보다 HTTP/2 연결 재사용, 스키마로 생성된 클라이언트, 데드라인·취소 전파 같은 기능에서 더 크게 느끼는 경우가 많습니다.

반대로 비용도 분명합니다. 바이너리라서 curl로 호출하거나 로그를 눈으로 읽기 어렵고(grpcurl 같은 도구가 필요합니다), 브라우저가 gRPC의 HTTP/2 트레일러를 직접 다룰 수 없어 gRPC-Web이나 게이트웨이가 필요하며, 스키마를 바꿀 때마다 코드 생성과 배포 순서를 신경 써야 합니다. 그래서 흔한 구성은 외부 공개 API는 REST(JSON), 내부 서비스 간 통신은 gRPC입니다.


Protocol Buffers

.proto 파일

// user.proto
syntax = "proto3";
package user;
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
}
message GetUserRequest {
  int32 id = 1;
}
message GetUserResponse {
  User user = 1;
}
message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}
message ListUsersResponse {
  repeated User users = 1;
  int32 total = 2;
}
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(User) returns (User);
}

= 1, = 2는 기본값이 아니라 필드 번호입니다. 직렬화된 메시지에는 필드 이름이 들어가지 않고 이 번호만 들어가므로, 한 번 배포한 필드 번호는 절대 바꾸거나 다른 필드에 재사용하면 안 됩니다. age를 삭제하고 나중에 4번을 phone으로 쓰면, 예전 클라이언트가 보낸 나이 값이 새 서버에서 전화번호로 해석됩니다. 필드를 지울 때는 reserved 4; reserved "age";로 번호와 이름을 막아 두는 것이 규칙입니다. 1~15번은 태그가 1바이트로 인코딩되므로 자주 쓰는 필드에 배정하는 것이 좋습니다.

proto3에서는 필드를 보내지 않으면 기본값(0, 빈 문자열, false)으로 읽힙니다. 그래서 age가 0인 것과 “나이를 입력하지 않은 것”을 구분할 수 없습니다. 이 구분이 필요하면 optional int32 age = 4;로 선언해 존재 여부를 확인하거나, 래퍼 타입(google.protobuf.Int32Value)을 씁니다. 또 CreateUser(User) returns (User)처럼 도메인 메시지를 요청·응답에 그대로 쓰면, 나중에 요청에만 필요한 필드(예: 비밀번호)나 응답에만 필요한 필드가 생겼을 때 분리하기 어렵습니다. Google의 API 설계 가이드가 RPC마다 CreateUserRequest, CreateUserResponse를 따로 두라고 권하는 이유입니다.


Node.js 구현

설치

npm install @grpc/grpc-js @grpc/proto-loader

서버

// server.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const PROTO_PATH = './user.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH);
const userProto = grpc.loadPackageDefinition(packageDefinition).user as any;
const users = [
  { id: 1, name: 'John', email: '[email protected]', age: 30 },
  { id: 2, name: 'Jane', email: '[email protected]', age: 25 },
];
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
  getUser: (call: any, callback: any) => {
    const user = users.find(u => u.id === call.request.id);
    
    if (user) {
      callback(null, { user });
    } else {
      callback({
        code: grpc.status.NOT_FOUND,
        message: 'User not found',
      });
    }
  },
  listUsers: (call: any, callback: any) => {
    callback(null, { users, total: users.length });
  },
  createUser: (call: any, callback: any) => {
    const newUser = {
      id: users.length + 1,
      ...call.request,
    };
    users.push(newUser);
    callback(null, newUser);
  },
});
server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),
  () => {
    console.log('gRPC server running on :50051');
    server.start();
  }
);

이 서버는 .proto를 런타임에 읽어 들이는 동적 로딩 방식입니다. 코드 생성 단계가 없어 빠르게 시작할 수 있지만, 핸들러의 call과 callback이 모두 any라서 타입 안전성이라는 gRPC의 장점을 TypeScript에서는 누리지 못합니다. 실무에서는 protoc와 ts-proto나 @bufbuild/protoc-gen-es 같은 플러그인으로 타입을 생성해 쓰는 경우가 많습니다.

동적 로딩에서 가장 흔히 겪는 함정은 필드 이름 변환입니다. protoLoader.loadSync는 기본 옵션에서 keepCase: false라 proto의 page_size를 JavaScript에서 pageSize로 바꿉니다. 아래 클라이언트처럼 { page_size: 10 }을 보내면 이 필드는 알 수 없는 속성으로 조용히 무시되고 서버는 기본값 0을 받습니다. 에러가 나지 않아 찾기 어려우므로, loadSync(PROTO_PATH, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true })처럼 옵션을 명시하는 것이 일반적입니다. longs: String은 int64 값이 JavaScript의 안전한 정수 범위를 넘어 정밀도를 잃는 것을 막아 줍니다.

bindAsync의 콜백은 (error, port)를 받는데 이 예제는 에러를 무시하고 있습니다. 포트가 이미 사용 중이면 로그는 “running”이라고 찍히지만 실제로는 요청을 받지 못합니다. 또 @grpc/grpc-js 1.10 이후로는 bindAsync가 끝나면 서버가 자동으로 시작되고 server.start()는 폐기 예정 경고를 냅니다. 최신 버전이라면 콜백에서 에러를 확인하고 start() 호출은 빼면 됩니다.

클라이언트

// client.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const PROTO_PATH = './user.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH);
const userProto = grpc.loadPackageDefinition(packageDefinition).user as any;
const client = new userProto.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);
// GetUser
client.getUser({ id: 1 }, (error: any, response: any) => {
  if (error) {
    console.error('Error:', error);
  } else {
    console.log('User:', response.user);
  }
});
// ListUsers
client.listUsers({ page: 1, page_size: 10 }, (error: any, response: any) => {
  if (error) {
    console.error('Error:', error);
  } else {
    console.log('Users:', response.users);
  }
});
// CreateUser
client.createUser(
  { name: 'Bob', email: '[email protected]', age: 35 },
  (error: any, response: any) => {
    if (error) {
      console.error('Error:', error);
    } else {
      console.log('Created:', response);
    }
  }
);

클라이언트 객체는 생성할 때마다 새 HTTP/2 연결을 만들 수 있으므로, 요청마다 new userProto.UserService(...)를 만들지 말고 애플리케이션 전체에서 재사용해야 합니다. 이 예제에서 빠진 가장 중요한 설정은 데드라인입니다. gRPC는 기본적으로 타임아웃이 없어서, 서버가 멈추면 호출이 영원히 대기합니다. client.getUser({ id: 1 }, { deadline: Date.now() + 2000 }, callback)처럼 모든 호출에 데드라인을 주는 것이 권장 사항이며, 초과하면 DEADLINE_EXCEEDED 상태 코드로 실패합니다. 서버가 다른 서비스를 호출할 때 남은 데드라인을 그대로 넘기면, 상위 요청이 포기한 작업을 하위 서비스가 계속하는 낭비도 막을 수 있습니다.

서버의 createUser에도 두 가지 문제가 있습니다. id: users.length + 1은 사용자가 삭제되면 중복 ID를 만들고, { id: ..., ...call.request } 순서라서 요청에 id 필드가 들어 있으면(proto3에서는 기본값 0이라도 들어옵니다) 새로 만든 ID를 덮어씁니다. 서버가 정하는 값은 spread 뒤에 두어야 합니다.


Streaming

Server Streaming

service LogService {
  rpc StreamLogs(StreamLogsRequest) returns (stream LogEntry);
}
// 서버
streamLogs: (call: any) => {
  const logs = [
    { message: 'Log 1', timestamp: Date.now() },
    { message: 'Log 2', timestamp: Date.now() },
    { message: 'Log 3', timestamp: Date.now() },
  ];
  logs.forEach((log) => {
    call.write(log);
  });
  call.end();
},
// 클라이언트
const call = client.streamLogs({});
call.on('data', (log: any) => {
  console.log('Log:', log);
});
call.on('end', () => {
  console.log('Stream ended');
});

서버 스트리밍은 대량의 결과를 한 번에 메모리에 올리지 않고 나눠 보내거나, 로그·이벤트처럼 계속 생기는 데이터를 구독할 때 씁니다. 클라이언트는 data 이벤트로 메시지를 하나씩 받고, 서버가 call.end()를 호출하면 end 이벤트가 옵니다. 예제에서 빠진 call.on('error', ...) 핸들러는 반드시 붙여야 합니다. 스트림 중간에 서버가 에러로 끝내거나 연결이 끊기면 error 이벤트가 발생하는데, 핸들러가 없으면 Node.js가 처리되지 않은 error 이벤트로 프로세스를 종료시킵니다.

또 call.write()는 반환값으로 흐름 제어 신호를 줍니다. 클라이언트가 받는 속도보다 서버가 빨리 쓰면 false를 반환하는데, 이를 무시하고 계속 쓰면 메시지가 서버 메모리에 쌓입니다. 수백만 건을 스트리밍한다면 false가 나왔을 때 drain 이벤트를 기다렸다가 이어서 쓰도록 구현해야 합니다.

Bidirectional Streaming

service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
// 서버
chat: (call: any) => {
  call.on('data', (message: any) => {
    console.log('Received:', message);
    
    // 이 스트림(보낸 클라이언트)에게만 응답을 돌려줌
    // 여러 클라이언트에 브로드캐스트하려면 연결된 call들을 따로 모아 관리해야 함
    call.write({
      user: message.user,
      text: message.text,
      timestamp: Date.now(),
    });
  });
  call.on('end', () => {
    call.end();
  });
},
// 클라이언트
const call = client.chat();
call.on('data', (message: any) => {
  console.log('Message:', message);
});
call.write({ user: 'John', text: 'Hello!' });

양방향 스트리밍에서 call은 이 클라이언트 한 명과의 스트림입니다. 그래서 서버의 call.write()는 메시지를 보낸 클라이언트에게만 돌아가며, 채팅방처럼 모두에게 보내려면 서버가 활성 스트림 목록을 직접 관리하고 스트림이 끝나거나(end, cancelled, error) 에러가 날 때 목록에서 제거해야 합니다. 제거를 빠뜨리면 끊긴 클라이언트에 계속 쓰려다 에러가 나거나 메모리가 새어 나갑니다. 클라이언트도 메시지를 다 보냈다면 call.end()를 호출해 쓰기 방향을 닫아야 서버의 end 이벤트가 발생합니다.

장시간 유지되는 스트림은 중간 장비의 유휴 타임아웃과도 싸워야 합니다. 로드 밸런서나 NAT가 몇 분간 트래픽이 없는 연결을 조용히 끊으면, 양쪽 모두 연결이 살아 있다고 믿은 채 메시지가 사라집니다. grpc.keepalive_time_ms 같은 keepalive 옵션으로 주기적인 ping을 보내고, 클라이언트는 스트림이 끊기면 재연결하는 로직을 두는 것이 일반적입니다.


Go 구현

서버

// server.go
package main
import (
	"context"
	"log"
	"net"
	pb "myapp/proto"
	"google.golang.org/grpc"
)
type server struct {
	pb.UnimplementedUserServiceServer
}
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
	user := &pb.User{
		Id:    req.Id,
		Name:  "John",
		Email: "[email protected]",
		Age:   30,
	}
	return &pb.GetUserResponse{User: user}, nil
}
func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("Failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterUserServiceServer(s, &server{})
	log.Println("gRPC server running on :50051")
	if err := s.Serve(lis); err != nil {
		log.Fatalf("Failed to serve: %v", err)
	}
}

Go에서는 protoc --go_out=. --go-grpc_out=. user.proto로 메시지 타입(user.pb.go)과 서비스 인터페이스(user_grpc.pb.go)를 생성합니다. proto의 id, page_size는 Go 관례에 맞춰 Id, PageSize가 되고, 필드 번호가 같다면 Node.js 서버와 Go 클라이언트, 혹은 그 반대로도 그대로 통신됩니다. 이것이 언어 중립적인 스키마의 실질적인 이점입니다. .proto에 option go_package = "myapp/proto";가 없으면 최신 protoc-gen-go는 패키지 경로를 정할 수 없다는 에러를 내므로 함께 추가해야 합니다.

pb.UnimplementedUserServiceServer를 구조체에 임베딩하는 것은 필수 관례입니다. proto에 새 RPC가 추가되어 코드를 다시 생성해도, 아직 구현하지 않은 메서드는 이 임베딩이 Unimplemented 에러를 반환하는 기본 구현을 제공하므로 컴파일이 깨지지 않습니다. 이 예제는 ListUsers와 CreateUser를 구현하지 않았기 때문에, 그 메서드를 호출하면 codes.Unimplemented가 돌아옵니다. Go 핸들러에서 에러를 돌려줄 때는 errors.New 대신 status.Error(codes.NotFound, "user not found")를 써야 클라이언트가 올바른 상태 코드를 받습니다. 일반 에러를 반환하면 모두 UNKNOWN으로 전달됩니다.


에러 처리

import * as grpc from '@grpc/grpc-js';
// 서버
getUser: (call: any, callback: any) => {
  const user = findUser(call.request.id);
  if (!user) {
    return callback({
      code: grpc.status.NOT_FOUND,
      message: 'User not found',
      details: 'No user with the given ID exists',
    });
  }
  callback(null, { user });
},
// 클라이언트
client.getUser({ id: 999 }, (error: any, response: any) => {
  if (error) {
    if (error.code === grpc.status.NOT_FOUND) {
      console.error('User not found');
    } else {
      console.error('Error:', error.message);
    }
  } else {
    console.log('User:', response.user);
  }
});

gRPC의 에러는 HTTP 상태 코드가 아니라 gRPC 상태 코드로 전달됩니다. HTTP 응답 자체는 거의 항상 200이고, 실제 결과는 응답 끝의 트레일러(grpc-status, grpc-message)에 담깁니다. 그래서 HTTP 상태 코드만 보는 일반적인 모니터링이나 프록시 설정으로는 gRPC 에러율을 볼 수 없다는 점을 알아 두어야 합니다.

상태 코드는 클라이언트의 대응 방식을 정하므로 정확히 골라야 합니다. NOT_FOUND, INVALID_ARGUMENT, PERMISSION_DENIED는 다시 보내도 결과가 같은 에러이고, UNAVAILABLE은 서버가 일시적으로 응답할 수 없어 재시도해도 되는 에러입니다. 모든 실패를 INTERNAL이나 UNKNOWN으로 돌려주면 클라이언트는 재시도 여부를 판단할 수 없습니다. 예제의 details 필드는 grpc-js에서 문자열 메시지로만 전달되는데, 필드별 검증 오류처럼 구조화된 정보를 보내려면 google.rpc.Status와 google.rpc.BadRequest 같은 표준 에러 상세 메시지를 쓰는 “richer error model”을 사용합니다.


인증

JWT Metadata

// 클라이언트
import * as grpc from '@grpc/grpc-js';
const metadata = new grpc.Metadata();
metadata.add('authorization', `Bearer ${token}`);
client.getUser({ id: 1 }, metadata, (error: any, response: any) => {
  // ...
});
// 서버
getUser: (call: any, callback: any) => {
  const metadata = call.metadata;
  const auth = metadata.get('authorization')[0];
  if (!auth || !verifyToken(auth)) {
    return callback({
      code: grpc.status.UNAUTHENTICATED,
      message: 'Invalid token',
    });
  }
  // ...
},

gRPC의 메타데이터는 HTTP/2 헤더로 전송되며, 키는 소문자여야 합니다. metadata.get()은 같은 키가 여러 번 올 수 있어 항상 배열을 반환하므로 [0]으로 꺼내는 것이고, Bearer 접두사를 떼지 않고 verifyToken에 넘기면 검증이 실패한다는 점도 주의해야 합니다.

이 방식은 모든 핸들러에 인증 코드를 복사해야 한다는 문제가 있습니다. Go의 grpc.UnaryInterceptor, grpc-js의 서버 인터셉터처럼 인터셉터로 한 곳에서 처리하는 것이 일반적입니다. 또 이 예제는 createInsecure()로 평문 연결을 쓰고 있어, 토큰이 네트워크에 그대로 노출됩니다. 실제 서비스에서는 grpc.credentials.createSsl()로 TLS를 켜거나, 서비스 메시가 mTLS를 처리하는 환경이어야 메타데이터에 토큰을 실어도 안전합니다. grpc-js는 평문 채널에서 CallCredentials(토큰 자동 첨부)를 조합하는 것을 막아 두었는데, 바로 이 위험 때문입니다.


배포

Docker

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 50051
CMD ["node", "server.js"]

이 Dockerfile은 앞의 server.ts를 그대로 실행할 수 없습니다. TypeScript를 먼저 tsc로 컴파일해 server.js를 만들어야 하고, 동적 로딩 방식이라면 user.proto 파일도 이미지 안의 PROTO_PATH 위치에 있어야 합니다. 멀티 스테이지 빌드로 빌드 단계와 실행 단계를 나누고 실행 이미지에는 npm ci --omit=dev로 운영 의존성만 설치하는 것이 일반적입니다.

배포에서 가장 많이 부딪히는 문제는 로드 밸런싱입니다. gRPC는 HTTP/2 연결 하나를 오래 유지하며 그 위에 모든 요청을 다중화하므로, Kubernetes의 기본 Service처럼 연결 단위(L4)로 분산하는 로드 밸런서 뒤에서는 클라이언트 하나의 요청이 전부 처음 연결된 파드 하나로 갑니다. 파드를 늘려도 새 파드에는 트래픽이 거의 가지 않고 기존 파드만 과부하가 걸리는 현상이 흔합니다. 제가 gRPC 서비스를 처음 쿠버네티스에 올렸을 때 부하 테스트 결과가 이상했던 것도 이 때문이었는데, 파드 CPU 그래프를 보니 한 파드만 높고 나머지는 놀고 있었습니다. 해결책은 Envoy·Linkerd·Istio처럼 요청 단위(L7)로 분산하는 프록시를 두거나, headless Service와 클라이언트 측 로드 밸런싱(round_robin 정책)을 쓰거나, 서버에서 grpc.max_connection_age_ms로 연결을 주기적으로 끊어 재분배를 유도하는 것입니다. 헬스 체크도 HTTP 엔드포인트 대신 표준 grpc.health.v1.Health 서비스를 구현하면 Kubernetes의 gRPC 프로브(1.24 이상)에서 바로 사용할 수 있습니다.

취업·면접과 연결하기

Proto·스트리밍·HTTP/2는 마이크로서비스·백엔드 면접 빈출 주제입니다. 개발자 기술 면접 준비: 알고리즘부터 시스템 설계까지와, 알고리즘·과제 병행은 코딩 테스트 준비 전략: 알고리즘 학습 순서와 시험장 실전 팁와 같이 잡아 두면 좋습니다.


배포 전에 다시 확인할 계약과 설정

  • Protocol Buffers의 필드 번호는 계약입니다. 바꾸거나 재사용하지 말고, 삭제한 번호는 reserved로 막습니다.
  • Node.js 동적 로딩에서는 keepCase 등 proto-loader 옵션을 명시해 필드 이름이 조용히 무시되는 문제를 피합니다.
  • 모든 호출에 데드라인을 주고, 재시도 가능 여부가 드러나도록 상태 코드를 정확히 고릅니다.
  • 스트리밍에는 error 핸들러와 흐름 제어, keepalive, 재연결을 함께 설계합니다.
  • 쿠버네티스에서는 L4 분산의 한계를 알고 L7 프록시나 클라이언트 측 로드 밸런싱을 준비합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. gRPC vs REST, 어떤 게 나은가요?

A. 페이로드가 작고 호출 빈도가 높은 내부 통신에서는 gRPC가 대체로 효율적이지만, 차이는 환경에 따라 다릅니다. REST는 도구와 디버깅이 쉽고 브라우저 친화적입니다. 마이크로서비스 간 통신은 gRPC, 외부 공개 API는 REST로 나누는 구성이 흔합니다.

Q. 브라우저에서 사용할 수 있나요?

A. gRPC-Web을 사용하면 가능하지만 제한적입니다. 브라우저는 REST나 GraphQL을 권장합니다.

Q. Protocol Buffers를 배워야 하나요?

A. 네, 하지만 간단합니다. JSON과 비슷하지만 타입이 명확합니다.