Protobuf를 걷어내고 5배 빨라진 Rust: PgDog 사례로 보는 성능 최적화의 본질


최근 엔지니어링 커뮤니티에서 PgDog 의 블로그 포스트가 꽤 화제가 되었습니다. 제목부터 자극적이었죠. “Replacing Protobuf with Rust to go 5 times faster”.

솔직히 이 제목을 처음 봤을 때, 저는 “또 Rust 만능주의인가?”라는 의구심부터 들었습니다. 하지만 내용을 뜯어보고 Hacker News의 반응을 살펴보니, 이 사례는 단순한 언어 전쟁이 아니라 시스템 아키텍처와 직렬화(Serialization) 비용 에 대한 아주 훌륭한 교훈을 담고 있었습니다.

오늘은 이 글을 바탕으로, 왜 이들이 Protobuf를 버려야 했는지, 그리고 그 과정에서 우리가 배울 수 있는 ‘진짜’ 성능 최적화의 핵심을 파고들어 보겠습니다.

1. 병목의 원인: FFI와 Protobuf의 잘못된 만남

PgDog는 PostgreSQL의 프록시/풀러(Pooler)입니다. SQL 쿼리를 파싱하고 이해해야 하는데, 이를 위해 C로 작성된 libpg_query를 사용합니다. PgDog 자체는 Rust로 작성되었으니, 필연적으로 C와 Rust 사이의 FFI(Foreign Function Interface) 가 필요하죠.

기존 아키텍처는 다음과 같았습니다:

  1. libpg_query (C)가 파싱 결과를 생성.
  2. 이를 Protobuf 로 직렬화.
  3. Rust 쪽에서 Protobuf를 역직렬화하여 사용.

여기서 시니어 엔지니어라면 미간을 찌푸려야 합니다. “왜 한 프로세스 내에서(In-process) 통신하는데 Protobuf를 쓰지?”

Protobuf는 서로 다른 언어 간의 통신이나 네트워크 전송(Wire Protocol)에는 훌륭하지만, 메모리 내에서 데이터를 주고받는 용도로는 과도한 오버헤드입니다. 직렬화/역직렬화 과정에서 CPU 사이클을 태우고, 불필요한 메모리 할당이 발생하기 때문이죠.

프로파일링 결과는 명확했습니다. 실제 파싱 로직보다 Protobuf 관련 함수들이 CPU를 다 잡아먹고 있었던 것 입니다.

Parameters everywhere

2. 해결책: Raw C 바인딩과 ‘Unsafe’의 미학

PgDog 팀의 해결책은 단순했습니다. “중간 상인을 없애자.”

Protobuf를 걷어내고, C의 구조체(struct)를 Rust의 구조체로 직접 매핑하는 방식을 택했습니다. 이 과정에서 bindgen을 사용해 C 타입을 Rust로 가져오고, unsafe 블록을 통해 포인터를 직접 다뤘습니다.

결과는 놀라웠습니다:

  • Parse: 5.45배 빨라짐
  • Deparse (AST -> SQL): 9.64배 빨라짐

이 성능 향상의 핵심은 Zero-copy 에 가까운 접근과 재귀(Recursion) 알고리즘에 있습니다. 흥미로운 점은 이들이 반복문(Iterative) 방식도 시도해봤지만, 오히려 재귀가 더 빨랐다는 것입니다. 재귀 호출은 스택(Stack) 메모리를 사용하므로 별도의 힙 할당(Heap Allocation)이 필요 없고, CPU 캐시 지역성(Cache Locality) 측면에서도 유리했기 때문입니다.

3. 6,000줄의 코드를 짠 ‘숨은 공신’

하지만 여기서 현실적인 문제에 부딪힙니다. Postgres의 AST 노드는 수백 개가 넘습니다. 이걸 사람이 일일이 C 구조체에서 Rust 구조체로 매핑하는 코드를 짠다? 끔찍한 일입니다. 유지보수도 지옥이죠.

PgDog 팀은 여기서 매우 실용적인 선택을 합니다. 바로 LLM(Claude) 을 활용한 것입니다.

“AI는 전체 아키텍처를 설계하는 데는 서툴지만, 명확하게 정의된 기계적인 작업(Machine-verifiable task)에는 탁월합니다.”

그들은 기존의 Protobuf 구현체와 새로 만든 Raw 구현체의 결과를 바이트 단위로 비교하는 테스트를 자동화했습니다. 결과가 다르면 AI에게 다시 시키는 식이었죠. 결과적으로 6,000줄에 달하는 재귀적인 변환 코드를 사람이 아닌 AI가 작성했고, 이는 완벽하게 동작했습니다.

이 부분은 우리에게 시사하는 바가 큽니다. FFI 바인딩처럼 지루하고 실수하기 쉬운(Error-prone) 작업이야말로 AI에게 맡겨야 할 영역입니다.

4. Hacker News의 반응: “제목이 낚시네”

이 글이 올라오자 Hacker News의 반응은 뜨거웠습니다. 특히 “Rust라서 빨라진 게 아니다” 라는 지적이 많았습니다.

  • User A: “제목이 아이러니하다. 사실 Rust 때문에 느려졌던 걸(Protobuf 바인딩 라이브러리 사용), 최적화해서 정상으로 돌려놓은 것뿐이다. C로 짰으면 애초에 변환도 필요 없었을 것.”
  • User B: “제목을 ‘Protobuf를 버리고 메모리를 직접 복사해서 5배 빨라짐’ 으로 고쳐야 한다.”
  • User C: “한 프로세스 내에서 Protobuf를 쓰는 것 자체가 성능 악몽이다. 구글에서도 이런 패턴은 지양한다.”

저도 이 의견들에 동의합니다. 이것은 Rust의 승리라기보다는, 불필요한 추상화 계층(Abstraction Layer) 제거의 승리 입니다. 개발 편의성을 위해 도입한 Protobuf가 시스템의 핵심 병목이 되었던 것이죠.

5. 마치며: 기술 선택의 트레이드오프

이번 사례는 “편의성 vs 성능” 이라는 고전적인 트레이드오프를 잘 보여줍니다.

Protobuf를 사용한 기존 바인딩(pg_query.rs)은 유지보수가 쉽고 여러 언어에서 재사용하기 좋았습니다. 하지만 PgDog처럼 쿼리 처리량이 핵심인 미션 크리티컬한 시스템에서는 그 오버헤드가 용납되지 않았습니다.

핵심 요약:

  • IPC(프로세스 내 통신)에 Protobuf를 쓰지 마십시오. 메모리 직접 접근이나 Zero-copy 직렬화(Cap’n Proto, FlatBuffers 등)를 고려하거나, 가능하다면 그냥 Native Struct를 쓰세요.
  • 재귀는 나쁘지 않습니다. 스택을 활용한 재귀는 현대 CPU 아키텍처에서 꽤 효율적일 수 있습니다.
  • AI를 도구로 쓰세요. 수천 줄의 FFI 바인딩 코드를 직접 짜는 건 미련한 짓입니다. 검증 가능한 테스트 케이스만 있다면, 이런 건 기계가 더 잘합니다.

결국 5배 빨라진 이유는 Rust라는 언어의 마법 때문이 아니라, “데이터가 어떻게 흐르는가” 를 이해하고 불필요한 복사 비용을 제거한 엔지니어링의 기본기 덕분이었습니다.