gRPC Complete Guide | Protocol Buffers, Streaming, Node.js & TypeScript

gRPC Complete Guide | Protocol Buffers, Streaming, Node.js & TypeScript

이 글의 핵심

gRPC is 5-10x faster than REST for service-to-service communication — binary Protocol Buffers, HTTP/2 multiplexing, and strongly typed contracts. This guide covers proto schema design, all four streaming patterns, and full Node.js TypeScript implementation.

gRPC vs REST

REST (JSON over HTTP/1.1):
  POST /users               Content-Type: application/json
  Body: {"name":"Alice","email":"[email protected]"}
  → JSON parsing, no schema enforcement, HTTP/1.1 per-request connection

gRPC (Protobuf over HTTP/2):
  rpc CreateUser(CreateUserRequest) returns (User)
  → Binary serialization, schema-enforced, HTTP/2 multiplexed streams
  → 5-10x smaller payload, 5-10x faster serialization

When to use gRPC:

  • Service-to-service communication (microservices)
  • High-throughput internal APIs
  • Real-time bidirectional streaming
  • Polyglot environments (client in Go, server in Node.js)

Setup

npm install @grpc/grpc-js @grpc/proto-loader
npm install -D grpc-tools grpc_tools_node_protoc_ts typescript ts-node

Protocol Buffer Schema

// proto/user.proto
syntax = "proto3";

package user;

// Message definitions
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  Role role = 4;
  int64 created_at = 5;   // Unix timestamp
}

enum Role {
  VIEWER = 0;   // First value must be 0 in proto3
  EDITOR = 1;
  ADMIN = 2;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
  Role role = 3;
}

message GetUserRequest {
  int32 id = 1;
}

message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  int32 total_count = 2;
  bool has_next_page = 3;
}

message DeleteUserRequest {
  int32 id = 1;
}

message Empty {}

// Service definition — the API contract
service UserService {
  // Unary RPC
  rpc CreateUser(CreateUserRequest) returns (User);
  rpc GetUser(GetUserRequest) returns (User);
  rpc DeleteUser(DeleteUserRequest) returns (Empty);

  // Server streaming — server sends a stream of responses
  rpc ListUsers(ListUsersRequest) returns (stream User);

  // Client streaming — client sends a stream of requests
  rpc BatchCreateUsers(stream CreateUserRequest) returns (ListUsersResponse);

  // Bidirectional streaming
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message ChatMessage {
  string user_id = 1;
  string text = 2;
  int64 timestamp = 3;
}

Code Generation

# Generate TypeScript types and JS stubs from .proto
protoc \
  --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
  --js_out=import_style=commonjs,binary:./src/generated \
  --ts_out=./src/generated \
  --grpc_out=grpc_js:./src/generated \
  --proto_path=./proto \
  proto/user.proto

Or use buf (modern protobuf toolchain):

npm install -D @bufbuild/buf @bufbuild/protoc-gen-es @connectrpc/protoc-gen-connect-es
# buf.gen.yaml
version: v1
plugins:
  - plugin: es
    out: src/generated
  - plugin: connect-es
    out: src/generated
buf generate

Server Implementation

// src/server.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import path from 'path';

// Load proto definition
const packageDef = protoLoader.loadSync(
  path.join(__dirname, '../proto/user.proto'),
  {
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true,
  }
);

const proto = grpc.loadPackageDefinition(packageDef) as any;

// In-memory store (replace with real DB)
const users = new Map<number, any>();
let nextId = 1;

// Service implementation
const userService = {
  // Unary RPC
  createUser(
    call: grpc.ServerUnaryCall<any, any>,
    callback: grpc.sendUnaryData<any>
  ) {
    const { name, email, role } = call.request;

    if (!name || !email) {
      return callback({
        code: grpc.status.INVALID_ARGUMENT,
        message: 'Name and email are required',
      });
    }

    const user = {
      id: nextId++,
      name,
      email,
      role: role || 'VIEWER',
      created_at: Date.now(),
    };
    users.set(user.id, user);

    callback(null, user);
  },

  getUser(
    call: grpc.ServerUnaryCall<any, any>,
    callback: grpc.sendUnaryData<any>
  ) {
    const user = users.get(call.request.id);

    if (!user) {
      return callback({
        code: grpc.status.NOT_FOUND,
        message: `User ${call.request.id} not found`,
      });
    }

    callback(null, user);
  },

  // Server streaming RPC — stream all users
  listUsers(call: grpc.ServerWritableStream<any, any>) {
    const { page = 1, page_size = 10 } = call.request;
    const allUsers = Array.from(users.values());
    const start = (page - 1) * page_size;
    const pageUsers = allUsers.slice(start, start + page_size);

    // Stream each user individually
    for (const user of pageUsers) {
      call.write(user);
    }
    call.end();  // Signal stream completion
  },

  // Client streaming RPC — receive stream of users to create
  batchCreateUsers(
    call: grpc.ServerReadableStream<any, any>,
    callback: grpc.sendUnaryData<any>
  ) {
    const created: any[] = [];

    call.on('data', (request) => {
      const user = {
        id: nextId++,
        name: request.name,
        email: request.email,
        role: request.role || 'VIEWER',
        created_at: Date.now(),
      };
      users.set(user.id, user);
      created.push(user);
    });

    call.on('end', () => {
      callback(null, {
        users: created,
        total_count: created.length,
        has_next_page: false,
      });
    });
  },

  // Bidirectional streaming — chat
  chat(call: grpc.ServerDuplexStream<any, any>) {
    call.on('data', (message) => {
      console.log(`[${message.user_id}]: ${message.text}`);
      // Echo back with server timestamp
      call.write({
        ...message,
        timestamp: Date.now(),
      });
    });

    call.on('end', () => call.end());
  },
};

// Start server
const server = new grpc.Server();
server.addService(proto.user.UserService.service, userService);

server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),  // Use createSsl() for production
  (err, port) => {
    if (err) throw err;
    console.log(`gRPC server running on port ${port}`);
  }
);

Client Implementation

// src/client.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import path from 'path';

const packageDef = protoLoader.loadSync(
  path.join(__dirname, '../proto/user.proto'),
  { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }
);
const proto = grpc.loadPackageDefinition(packageDef) as any;

// Create client
const client = new proto.user.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

// Promisify unary calls
function createUser(name: string, email: string): Promise<any> {
  return new Promise((resolve, reject) => {
    client.createUser({ name, email }, (err: any, user: any) => {
      if (err) reject(err);
      else resolve(user);
    });
  });
}

function getUser(id: number): Promise<any> {
  return new Promise((resolve, reject) => {
    client.getUser({ id }, (err: any, user: any) => {
      if (err) reject(err);
      else resolve(user);
    });
  });
}

// Server streaming client
async function listAllUsers(): Promise<any[]> {
  return new Promise((resolve, reject) => {
    const call = client.listUsers({ page: 1, page_size: 100 });
    const users: any[] = [];

    call.on('data', (user: any) => users.push(user));
    call.on('end', () => resolve(users));
    call.on('error', reject);
  });
}

// Client streaming
async function batchCreate(userList: { name: string; email: string }[]): Promise<any> {
  return new Promise((resolve, reject) => {
    const call = client.batchCreateUsers((err: any, response: any) => {
      if (err) reject(err);
      else resolve(response);
    });

    // Send each user as a stream message
    for (const user of userList) {
      call.write(user);
    }
    call.end();
  });
}

// Bidirectional streaming — chat
function startChat(userId: string) {
  const call = client.chat();

  call.on('data', (message: any) => {
    console.log(`Server echo: [${message.user_id}] ${message.text}`);
  });

  call.on('end', () => console.log('Chat ended'));

  // Send messages
  call.write({ user_id: userId, text: 'Hello!' });
  call.write({ user_id: userId, text: 'How are you?' });
  call.end();
}

// Usage
async function main() {
  const user = await createUser('Alice', '[email protected]');
  console.log('Created:', user);

  const fetched = await getUser(user.id);
  console.log('Fetched:', fetched);

  const allUsers = await listAllUsers();
  console.log('All users:', allUsers.length);
}

main().catch(console.error);

Error Handling

// gRPC status codes
import { status } from '@grpc/grpc-js';

// Server: return gRPC errors
callback({
  code: status.NOT_FOUND,          // 5
  message: 'User not found',
});

callback({
  code: status.INVALID_ARGUMENT,  // 3
  message: 'Email is required',
});

callback({
  code: status.ALREADY_EXISTS,    // 6
  message: 'Email already registered',
});

callback({
  code: status.PERMISSION_DENIED, // 7
  message: 'Admin access required',
});

callback({
  code: status.INTERNAL,          // 13
  message: 'Database error',
});

// Client: handle gRPC errors
try {
  const user = await getUser(999);
} catch (err: any) {
  if (err.code === status.NOT_FOUND) {
    console.log('User not found');
  } else if (err.code === status.UNAVAILABLE) {
    console.log('Service unavailable — retry');
  }
}

Metadata (Headers)

// Client: send metadata (like HTTP headers)
const metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer my-jwt-token');
metadata.add('x-request-id', 'abc123');

client.getUser({ id: 1 }, metadata, (err, user) => { /* ... */ });

// Server: read metadata
function getUser(call: grpc.ServerUnaryCall<any, any>, callback: any) {
  const token = call.metadata.get('authorization')[0];
  if (!token) {
    return callback({ code: grpc.status.UNAUTHENTICATED });
  }
  // Verify token...
}

Interceptors (Middleware)

// Client-side interceptor — add auth token to all calls
const authInterceptor: grpc.Interceptor = (options, nextCall) => {
  return new grpc.InterceptingCall(nextCall(options), {
    start(metadata, listener, next) {
      metadata.add('authorization', `Bearer ${getToken()}`);
      next(metadata, listener);
    },
  });
};

const client = new proto.user.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure(),
  { interceptors: [authInterceptor] }
);

TLS for Production

// Server with TLS
import fs from 'fs';

const credentials = grpc.ServerCredentials.createSsl(
  fs.readFileSync('ca.crt'),
  [{
    cert_chain: fs.readFileSync('server.crt'),
    private_key: fs.readFileSync('server.key'),
  }],
  true  // Require client cert (mutual TLS)
);

server.bindAsync('0.0.0.0:50051', credentials, callback);

// Client with TLS
const clientCredentials = grpc.credentials.createSsl(
  fs.readFileSync('ca.crt'),
  fs.readFileSync('client.key'),
  fs.readFileSync('client.crt'),
);

const client = new proto.user.UserService('api.example.com:50051', clientCredentials);

gRPC vs REST vs GraphQL

AspectRESTGraphQLgRPC
ProtocolHTTP/1.1HTTP/1.1HTTP/2
FormatJSONJSONProtobuf (binary)
PerformanceBaseline~Same as REST5-10x faster
Browser support✅ Native✅ Native⚠️ Needs grpc-web
SchemaOptional (OpenAPI)RequiredRequired (.proto)
Streaming❌ (SSE workaround)✅ Subscriptions✅ Native, 4 patterns
Best forPublic APIsFlexible client needsInternal microservices

Related posts: