gRPC 스트리밍으로 실시간 로그 전송 구현하기

배경

로그 수집 서버에서 여러 마이크로서비스의 로그를 실시간으로 받아야 했다. 기존에는 REST API로 배치 전송했지만, 디버깅 시 실시간 확인이 필요해 스트리밍 방식을 검토했다.

SSE도 고려했지만 이미 서비스 간 통신에 gRPC를 사용 중이라 gRPC 스트리밍으로 통일하기로 결정했다.

Proto 정의

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

message LogRequest {
  string service_name = 1;
  string level = 2;
}

message LogEntry {
  string timestamp = 1;
  string level = 2;
  string message = 3;
  map<string, string> metadata = 4;
}

서버 스트리밍 방식으로 정의했다. 클라이언트가 한 번 요청하면 서버가 연속으로 로그를 푸시한다.

Node.js 서버 구현

const streamLogs = (call) => {
  const { service_name, level } = call.request;
  
  const logStream = createLogStream(service_name, level);
  
  logStream.on('data', (log) => {
    call.write({
      timestamp: log.timestamp,
      level: log.level,
      message: log.message,
      metadata: log.metadata
    });
  });
  
  call.on('cancelled', () => {
    logStream.destroy();
  });
};

기존 로그 스트림을 gRPC 스트림에 연결하는 방식으로 구현했다. 클라이언트 연결 해제 시 리소스 정리도 중요했다.

성능 개선

초기에는 로그가 많을 때 메모리 사용량이 급증했다. 백프레셔 처리를 추가했다.

logStream.on('data', (log) => {
  const canWrite = call.write(log);
  if (!canWrite) {
    logStream.pause();
    call.once('drain', () => {
      logStream.resume();
    });
  }
});

결과

REST 배치 전송 대비 평균 레이턴시가 500ms에서 50ms로 감소했다. Protobuf 덕분에 페이로드 크기도 30% 줄었다. 타입 안정성도 보장되어 클라이언트 구현이 수월했다.

다만 gRPC 디버깅 도구가 REST만큼 편하지 않아 개발 초기엔 불편했다. grpcurl을 사용하면서 점차 익숙해졌다.