gRPC 스트리밍으로 대용량 파일 업로드 구현하기

문제 상황

회사 프로젝트에서 100MB 이상의 대용량 파일을 업로드하는 API를 운영 중이었다. Express 기반 REST API로 구현했는데, 파일을 메모리에 전부 올린 후 처리하다 보니 동시 업로드가 많아지면 서버 메모리가 부족해지는 문제가 발생했다.

마침 마이크로서비스 간 통신을 gRPC로 전환하는 작업을 진행 중이었고, gRPC의 스트리밍 기능을 활용하면 이 문제를 해결할 수 있을 것 같았다.

gRPC 스트리밍 구현

proto 파일 정의부터 시작했다.

service FileService {
  rpc Upload(stream FileChunk) returns (UploadResponse);
}

message FileChunk {
  string filename = 1;
  bytes data = 2;
  int32 chunk_index = 3;
}

message UploadResponse {
  string file_id = 1;
  int64 total_size = 2;
}

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

function upload(call, callback) {
  let filename = '';
  let writeStream = null;
  let totalSize = 0;

  call.on('data', (chunk) => {
    if (!filename) {
      filename = chunk.filename;
      writeStream = fs.createWriteStream(`./uploads/${filename}`);
    }
    writeStream.write(chunk.data);
    totalSize += chunk.data.length;
  });

  call.on('end', () => {
    writeStream.end();
    callback(null, {
      file_id: generateId(),
      total_size: totalSize
    });
  });
}

결과

청크 단위로 스트리밍하면서 바로 파일로 쓰기 때문에 메모리 사용량이 대폭 줄었다. 500MB 파일 업로드 시 이전에는 서버 메모리가 800MB 가까이 증가했지만, 스트리밍 방식에서는 50MB 정도만 사용했다.

클라이언트에서도 진행률을 실시간으로 보여주기 쉬워졌다. 각 청크를 보낼 때마다 진행률을 계산해서 UI를 업데이트하면 된다.

gRPC는 HTTP/2 기반이라 멀티플렉싱도 지원된다. 하나의 커넥션으로 여러 파일을 동시에 업로드해도 성능 저하가 없었다.

주의사항

청크 크기는 64KB로 설정했다. 너무 작으면 오버헤드가 크고, 너무 크면 메모리 이점이 줄어든다. 테스트 결과 64KB가 가장 적절했다.

에러 처리도 신경 써야 한다. 스트림 중간에 끊기면 임시 파일을 정리하는 로직이 필요하다.