Node.js에서 gRPC 스트리밍으로 대용량 데이터 전송하기

문제 상황

로그 분석 서비스에서 클라이언트가 서버로부터 수십만 건의 로그를 조회하는 API가 있었다. REST API로 구현되어 있었는데, 전체 데이터를 메모리에 로드한 후 JSON으로 직렬화하다 보니 응답 시간이 길고 메모리 사용량도 높았다.

페이지네이션을 적용할 수도 있었지만, 클라이언트 측에서 전체 데이터를 순차적으로 처리해야 하는 요구사항이 있어서 gRPC 스트리밍을 도입하기로 했다.

gRPC 서버 스트리밍 구현

먼저 proto 파일을 정의했다.

syntax = "proto3";

service LogService {
  rpc StreamLogs (LogRequest) returns (stream LogEntry);
}

message LogRequest {
  string session_id = 1;
  int64 start_time = 2;
  int64 end_time = 3;
}

message LogEntry {
  string id = 1;
  int64 timestamp = 2;
  string level = 3;
  string message = 4;
}

Node.js 서버 구현은 다음과 같다.

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

function streamLogs(call) {
  const { session_id, start_time, end_time } = call.request;
  
  const stream = db.createLogStream({
    sessionId: session_id,
    startTime: start_time,
    endTime: end_time
  });
  
  stream.on('data', (log) => {
    call.write({
      id: log.id,
      timestamp: log.timestamp,
      level: log.level,
      message: log.message
    });
  });
  
  stream.on('end', () => {
    call.end();
  });
  
  stream.on('error', (err) => {
    call.destroy(err);
  });
}

결과

30만 건의 로그를 전송하는 테스트에서:

  • REST API: 메모리 2.1GB, 응답 완료까지 18초
  • gRPC 스트리밍: 메모리 600MB, 첫 데이터 수신까지 0.3초

메모리 사용량이 대폭 줄었고, 클라이언트가 전체 응답을 기다리지 않고 데이터가 도착하는 즉시 처리할 수 있게 되었다.

배운 점

gRPC 스트리밍은 대용량 데이터를 다룰 때 확실히 유리했다. 다만 HTTP/2 기반이라 기존 REST API 인프라와 혼용할 때 프록시 설정에 신경 써야 했다. Envoy를 사용해서 gRPC 트래픽을 라우팅했는데, 이 부분은 별도로 정리할 예정이다.