[AI 협업 노트] gRPC 전송 최적화: Flattening 및 Bitset 기반 고효율 솔루션
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가 데이터베이스 적용 시 가지는 근본적인 제한을 최소 비용으로 해결했다.