[AI 협업 노트] gRPC 전송 최적화: Flattening 및 Bitset 기반 고효율 솔루션

발행: (2025년 12월 17일 오후 12:13 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

배경 및 기술 과제

API를 설계할 때, 데이터베이스 미들웨어는 보통 여러 행의 조회 결과를 반환해야 합니다. 전통적인 gRPC 정의 방식을 사용하면 다음과 같은 성능 및 구현상의 문제가 발생합니다.

1.1 Payload 중복 문제 (Key Repetition)

가장 직관적인 Protobuf 정의는 일반적으로 각 행 데이터를 Map 또는 Object로 정의합니다:

message Row {
    map data = 1;
}
message Response {
    repeated Row rows = 1;
}

문제 분석
이 구조는 심각한 Payload 팽창을 초래합니다. 예를 들어 조회 결과가 10,000건이고 customer_id, created_at, status 필드를 포함한다면, 전송 과정에서 이 필드명(Key)이 10,000번 반복 전송되어 대량의 대역폭을 차지합니다.

1.2 Protobuf의 NULL 값 제한

Protobuf (proto3)의 설계 철학은 스칼라 타입을 null 허용하지 않는 것입니다.

  • string 필드가 NULL이면 전송 시 빈 문자열 "" 로 직렬화됩니다.
  • 클라이언트는 이것이 “빈 값”인지 “원본 데이터베이스의 NULL”인지 구분할 수 없습니다.

비록 google.protobuf.StringValue 같은 Wrapper 타입을 사용할 수 있지만, 추가적인 Message 중첩 레벨과 처리 오버헤드가 증가합니다.

해결책: 데이터베이스 하위 구조와 유사

위의 도전에 대해 AI는 전통적인 API 객체 사고를 탈피하고, 데이터베이스 하위 구조 (Columnar Storage) 혹은 ODBC/JDBC 드라이버 구현 방식을 참고할 것을 제안합니다. 핵심 최적화 전략은 다음 두 부분을 포함합니다:

A. 배열 평탄화 (Flattening)

  • Header (Metadata):필드 정의(columns)를 한 번만 전송합니다.
  • Body (Values):모든 데이터 값을 하나의 거대한 1차원 배열(values)로 펼칩니다.

이 설계는 각 행 데이터 내의 키 전송을 완전히 제거하여 Payload 크기를 크게 줄입니다.

B. 비트셋 (Bitmap) 메커니즘

  • 각 값이 NULL인지 표시하기 위해 Bitset을 사용합니다.
  • 1개의 비트가 1개의 값에 대응: 비트 = 1이면 해당 값이 NULL, 비트 = 0이면 값이 유효합니다.

공간 효율성
8개의 필드 값당 1바이트의 추가 공간만 필요합니다. 1,000행 × 8열 데이터의 경우, 약 1 KB의 오버헤드만으로 모든 NULL 상태를 정확히 기록할 수 있습니다.

구현 세부 사항 (Implementation Essentials)

3.1 Protobuf 정의 (proto/query.proto)

message QueryResponse {
    repeated string values = 3;   // 평탄화된 값
    repeated Column columns = 4;  // 필드 정의
    bytes null_bitmap = 7;        // NULL 비트맵 스트림
    int32 row_count = 6;
}

3.2 서버 측: 인코딩 및 압축 (Encoding)

서버 측의 임무는 데이터베이스 결과를 한 번에 순회하면서 “값 평탄화”와 “Bitmap 생성”을 동시에 수행하는 것이다.

// $result['rows'] 은 데이터베이스가 반환한 2차원 배열
$values = [];
$packedBytes = "";
$currentByte = 0;
$bitIndex = 0;

foreach ($result['rows'] as $row) {
    foreach ($row as $value) {
        if ($value === null) {
            $values[] = "";                     // 값은 빈 문자열로 자리 채움
            $currentByte |= (1 << $bitIndex);   // 해당 비트를 1 로 설정
        } else {
            $values[] = (string)$value;
            // 비트는 0 (기본값) 유지
        }

        $bitIndex++;
        if ($bitIndex === 8) {
            $packedBytes .= chr($currentByte);
            $currentByte = 0;
            $bitIndex = 0;
        }
    }
}

// 마지막에 8비트 미만 남은 경우 처리
if ($bitIndex > 0) {
    $packedBytes .= chr($currentByte);
}

3.3 클라이언트 측: 디코딩 및 복원 (Decoding)

클라이언트는 데이터를 받은 후 columns 수에 따라 분할하고 null_bitmap을 참고해 NULL을 복원해야 한다.

$fetchedRows = [];
$columns = $response->getColumns();
$colCount = count($columns);
$values = $response->getValues();       // 평탄화된 배열 획득
$bitmap = $response->getNullBitmap();   // Bitmap 문자열 획득
$rowCount = $response->getRowCount();

$bytePos = 0;
$bitPos = 0;

for ($r = 0; $r < $rowCount; $r++) {
    $row = [];
    for ($c = 0; $c < $colCount; $c++) {
        $flatIndex = $r * $colCount + $c;

        // 해당 비트 가져오기
        $byte = ord($bitmap[$bytePos]);
        $isNull = ($byte >> $bitPos) & 1;

        $row[] = $isNull ? null : $values[$flatIndex];

        // 비트 포인터 이동
        $bitPos++;
        if ($bitPos === 8) {
            $bitPos = 0;
            $bytePos++;
        }
    }
    $fetchedRows[] = $row;
}

위와 같은 대칭 로직을 통해 우리는 매우 낮은 연산 비용으로 데이터 압축 및 복원을 수행할 수 있다.

최적화 효과 분석

이 구조를 도입한 뒤 다음과 같은 구체적인 효과를 얻었다:

  • 극한의 전송 효율
    평탄화 설계는 Payload 크기가 데이터 양에 선형적으로 증가하며, 필드 이름 길도의 영향을 받지 않는다. 대용량 데이터 조회 시 대역폭 절감 효과가 눈에 띈다.

  • 정확한 타입 복원
    클라이언트는 null_bitmap을 통해 데이터베이스의 NULL 상태를 정확히 복원할 수 있어 gRPC 기본 타입 제한을 해결한다.

  • 파싱 성능 향상
    PHP 및 기타 언어에서 1차원 배열을 처리하는 것이 다중 중첩 객체를 처리하는 것보다 CPU 캐시 적중률이 높고 메모리 파편화가 적다.

결론

이 최적화 사례는 현대 분산 시스템에서 하위 시스템 설계 사고를 적절히 도입하는 중요성을 보여준다. AI와 협업을 통해 우리는 단순 API 설계 프레임워크를 넘어 비트 연산데이터 구조 최적화를 활용하여 gRPC/Protobuf가 데이터베이스 적용 시 가지는 근본적인 제한을 최소 비용으로 해결했다.

Back to Blog

관련 글

더 보기 »

gRPC - Mock Server를 사용하는 이유

왜 Mock Server가 gRPC에 필요한가? gRPC는 컴팩트한 메시지, HTTP/2를 통한 효율적인 바이너리 전송, 그리고 다중 통신에 대한 일류 지원을 제공합니다.