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을 사용하면서 점차 익숙해졌다.