TECH 으로 돌아가기
TECH HACKER NEWS 오늘 7분 읽기 44 READS

포스트그레스를 300배 빠르게: pgrust가 보여준 쿼리 엔진 최적화의 정석

포스트그레스를 300배 빠르게: pgrust가 보여준 쿼리 엔진 최적화의 정석
SOURCE IMAGE · HACKER NEWS

분석용 워크로드에서 PostgreSQL이 느리다는 이야기는 새롭지 않지만, 그 격차가 얼마나 벌어질 수 있는지를 구체적인 수치로 보여준 사례가 나왔다. 러스트로 다시 작성한 포스트그레스 호환 엔진 pgrust가 0.2 버전을 내놓으며 성능 개선을 전면에 내세웠다. 개발팀에 따르면 이번 버전은 이전 pgrust보다 10배 빠르고, OLTP 벤치마크에서는 포스트그레스보다 30% 빠르며, 분석 데이터베이스용 벤치마크인 Clickbench에서는 포스트그레스 대비 300배 빠른 것은 물론 Clickhouse까지 앞섰다. 이 300배 중 약 10배가 쿼리 엔진 하나에서 나왔다는 설명이 특히 눈길을 끈다.

왜 이렇게 큰 차이가 나는가

격차의 뿌리는 시대적 배경에 있다. 포스트그레스의 원형은 1980년대로 거슬러 올라가며, 당시 데이터베이스 성능의 병목은 디스크 I/O였다. 그러나 이후 하드웨어 환경이 바뀌면서 이제는 CPU와 메모리 대역폭이 훨씬 더 중요한 자원이 됐다. 쿼리 엔진은 데이터베이스에서 CPU를 가장 많이 쓰는 부분이고, pgrust 팀의 최적화도 바로 이 지점, 즉 같은 쿼리를 처리하면서 CPU와 메모리 대역폭을 덜 쓰도록 만드는 데 집중됐다.

차이의 규모를 체감하기 위해 개발팀은 1부터 5억까지의 숫자를 더하는 단순한 쿼리를 예로 든다. 병렬 쿼리를 끈 c8g.4xl 인스턴스에서 포스트그레스로 실행하면 약 20초가 걸리지만, 러스트로 짠 동등한 for 루프는 358밀리초에 끝난다. 약 55배 차이다. 물론 이것은 공정한 비교가 아니다. 포스트그레스는 잠금(locking) 처리와 저장 포맷 파싱 및 필요한 튜플 추출 같은 추가 작업을 함께 수행하기 때문이다. 하지만 데이터베이스 최적화란 결국 이런 부수적 오버헤드를 최대한 걷어내는 일이기도 하다.

볼케이노 모델에서 시작해 한 단계씩

쿼리 엔진만의 영향을 따로 떼어 보기 위해 글은 포스트그레스 쿼리 엔진의 축소판을 러스트로 만든다. 포스트그레스는 SQL을 받으면 실행 방법을 기술한 내부 표현인 쿼리 플랜으로 바꾸고, 이를 쿼리 엔진에 넘겨 실제로 행을 읽고 집계를 수행한다. 포스트그레스가 쓰는 방식은 이른바 볼케이노 모델로, 모든 플랜 노드가 한 번에 한 행을 반환하는 next() 메서드를 구현한다. 루트 노드에서 next()를 행이 없을 때까지 반복 호출하면 쿼리가 실행되는 셈이다. 노드마다 메서드 하나만 구현하면 되니 단순하다는 장점이 있지만, 포스트그레스에는 40종이 넘는 플랜 노드가 있을 만큼 구조가 방대하다. 이 축소판을 돌리면 1.3초가 나오는데, 포스트그레스보다는 훨씬 빠르지만 순수 for 루프보다는 여전히 느리다.

가장 큰 손실은 next()가 한 번에 한 행만 처리한다는 점이다. 배칭이 없으니 시퀀셜 스캔의 next()가 행마다 호출되고, 런타임에야 대상이 정해지는 함수 호출은 파이프라이닝 같은 CPU 최적화를 무력화한다. 첫 최적화인 배칭을 도입하면 1.3초가 약 480밀리초로 줄어든다. 여기서 중요한 세부 사항은 배치 버퍼를 스택에 할당했다는 것이다. 덕분에 집계 노드는 실행 중 메모리를 새로 할당하지 않는다. 메모리 할당은 대체로 느린 연산이므로, 초고속 코드를 짤 때는 할당 횟수를 최소화하는 것이 핵심이다.

배칭한 코드를 프로파일링하면 이번엔 버퍼로 값을 옮기는 copy_from_slice가 병목으로 떠오른다. 이는 오퍼레이터 퓨전으로 없앨 수 있다. 함께 수행될 것이 확실한 연산이라면 두 노드를 하나로 합쳐, 예컨대 시퀀셜 스캔과 합산을 한데 묶은 SumAggregateSequentialScan 노드를 만드는 식이다. 이렇게 하면 결국 for 루프와 동일한 코드가 되므로 성능도 같아진다. 다만 이는 특정 쿼리를 미리 알고 하드코딩한 것이어서, 가장 흔한 몇 가지 경우에만 유효하고 준비하지 못한 쿼리를 금방 만나게 된다. 글은 이 한계를 JIT 컴파일로 해결할 수 있다고 언급하며, 어떤 쿼리든 이상적인 코드를 생성해 항상 오퍼레이터 퓨전을 적용하는 방법은 다음 글로 미룬다.

SIMD, 그리고 실무자가 챙길 지점

마지막 최적화는 SIMD다. 하나의 연산을 여러 데이터에 동시에 적용하는 CPU 명령을 쓰면 여러 행을 한꺼번에 처리할 수 있어 훨씬 빠르다. SIMD를 적용한 코드는 135밀리초까지 내려가, for 루프보다 약 3배, 처음의 볼케이노 코드보다 10배 빠르다. 흥미로운 점은 컴파일러가 자동으로 for 루프를 SIMD로 바꿔 주는 경우가 많은데도 이 예제에서는 그렇게 되지 않았다는 것이다. 부동소수점 덧셈은 결합법칙이 성립하지 않아 연산 순서를 바꾸면 결과가 미세하게 달라지므로, 컴파일러는 대개 실수 연산에 SIMD 도입을 피한다.

이 글이 실무자에게 주는 함의는 단순 벤치마크 숫자를 넘어선다. 배칭으로 함수 호출 오버헤드를 줄이고, 스택 할당으로 메모리 할당을 없애며, 자주 쓰이는 연산을 퓨전하고, SIMD로 데이터 병렬성을 확보하는 흐름은 데이터베이스뿐 아니라 성능이 중요한 어떤 코드에도 적용되는 원칙이다. 동시에 한계도 분명하다. 여기 제시된 300배·55배 같은 수치는 병렬 쿼리를 끄고 데이터를 공유 버퍼에 올려 둔 채 단일 집계 쿼리를 5회 실행한 중앙값으로, AWS c8g.4xlarge(Graviton4, 16 vCPU)와 PostgreSQL 18.4라는 특정 조건에서 나온 것이다. 실제 운영 환경의 잠금 경합, 다양한 조인과 서브쿼리, 병렬 실행이 얽히면 결과는 달라질 수 있고, JIT 부분은 아직 후속 공개를 기다려야 한다. 그럼에도 포스트그레스의 오래된 실행 모델에 얼마나 큰 개선 여지가 남아 있는지를, 재현 가능한 방식으로 보여 준다는 점에서 참고할 가치가 있다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://malisper.me/how-we-made-postgres-hundreds-of-times-f...
SHARE
처리 중...