TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 29 READS

'확장 안 된다'던 Postgres LISTEN/NOTIFY, 초당 6만 건까지 끌어올리기

'확장 안 된다'던 Postgres LISTEN/NOTIFY, 초당 6만 건까지 끌어올리기
SOURCE IMAGE · HACKER NEWS

Postgres의 LISTEN/NOTIFY는 데이터베이스만으로 저지연 알림과 스트림, 그리고 발행-구독(pub/sub) 구조를 구현할 수 있게 해 주는 기능이다. 별도의 메시지 브로커를 두지 않고도 쓰기 측이 이벤트를 알리면 읽기 측이 즉시 깨어나 반응할 수 있어, 실시간 채팅이나 LLM 응답 토큰 스트리밍처럼 지연에 민감한 작업에 매력적이다. 그러나 이 기능은 오랫동안 '확장이 안 된다'는 평판을 달고 다녔다. 한 유명한 블로그 글이 그런 인식을 굳혔는데, DBOS 팀은 이 통념을 정면으로 반박하며 단일 Postgres 서버에서 밀리초 단위 지연을 유지한 채 초당 6만 건의 쓰기를 달성한 과정을 공개했다.

병목의 정체는 전역 락이었다

Postgres 기반 스트림의 기본 설계는 단순하다. 스트림 조각(예: LLM 응답 토큰 하나)을 한 행으로 저장하는 테이블을 만들고, 새 조각이 생길 때마다 행을 삽입한다. 까다로운 부분은 읽기다. 다음 조각이 언제 도착할지 모르기 때문이다. 주기적으로 테이블 끝을 확인하는 폴링 방식은 확장성이 나쁘다. 간격을 길게 잡으면 대화형 용도에 쓰기엔 지연이 크고, 짧게 잡으면 동시에 폴링하는 클라이언트들이 데이터베이스를 짓누른다. 그래서 읽기 측이 새 조각이 발행됐다는 알림을 기다리며 대기하다가 즉시 깨어나는 LISTEN/NOTIFY가 더 나은 해법이 된다.

DBOS의 초기 구현은 스트림 테이블에 트리거를 걸어, 새 조각이 쓰일 때마다 알림을 보내는 함수를 실행하는 방식이었다. 동작은 정확했고 지연도 낮았지만, 규모를 키우자 처리량이 초당 2,900건에서 더 올라가지 않았다. 흥미롭게도 이 병목 상태에서 CPU, 메모리, IOPS 등 눈에 보이는 Postgres 자원은 거의 소모되지 않았다. 원인은 바로 그 악명 높은 전역 락이었다. NOTIFY를 호출하는 트랜잭션이 커밋하려면 전역 배타 락(global exclusive lock)을 잡아야 하는데, 이 락은 커밋이 시작될 때 획득되어 트랜잭션이 완전히 커밋되고 그 내용이 fsync()로 디스크에 기록될 때까지 풀리지 않는다.

왜 Postgres는 락을 잡는가

이 락이 필요한 이유는 Postgres가 알림을 트랜잭션 커밋 순서대로 보내겠다고 보장하기 때문이다. 이를 지키기 위해 나가는 알림을 전역 내부 큐에 담는데, 이 큐의 순서는 알림을 보낸 트랜잭션들의 커밋 순서와 정확히 일치해야 한다. 문제는 커밋에 걸리는 시간이 제각각이라, 트랜잭션의 커밋 순서가 커밋이 끝나기 전에는 정해지지 않는다는 점이다. 알림을 담는 트랜잭션은 커밋 순서대로 큐에 자신을 넣어야 하지만 그 순서 자체가 아직 없는 모순이 생긴다. 해법이 바로 전역 락이다. 알림을 포함한 트랜잭션들의 커밋을 직렬화해 순서를 미리 확정함으로써 큐에 올바른 순서로 넣을 수 있게 하는 것이다.

결국 스트림 쓰기마다 NOTIFY가 호출되니, 모든 쓰기가 커밋 내내 전역 락을 붙들고 순차적으로만 커밋될 수밖에 없다. 여러 트랜잭션을 한 번의 fsync()로 묶는 그룹 커밋 같은 Postgres의 일반적인 최적화도 이때는 봉쇄된다. 쓰기 속도가 트랜잭션 커밋 속도 이상으로는 빨라질 수 없으니 자원이 남아돌아도 병목이 걸린 것이다. 참고로 Postgres 19에 반영될 관련 패치가 온라인에서 거론되지만, 이 패치는 전역 락을 없애거나 이 병목을 해결하지 않는다. 채널이 여러 개이고 각 리스너가 특정 채널만 기다리는 좁은 경우를 최적화할 뿐이다.

알림은 진실의 원천이 아니다

돌파구는 알림의 역할을 다시 본 데서 나왔다. 스트림을 비롯한 많은 LISTEN/NOTIFY 활용에서 알림 자체는 진실의 원천이 아니다. 진짜 데이터는 테이블에 있고, 알림은 그저 '새 데이터가 있으니 테이블을 확인하라'는 신호일 뿐이다. 따라서 알림이 전역적으로 완벽히 정렬되거나 완전히 내구적일 필요가 없다. DBOS는 이 점을 이용해 알림을 메모리에 버퍼링했다가 주기적으로 한 번의 배치 트랜잭션으로 몰아서 내보냈다. 그러면 전역 락은 개별 쓰기마다가 아니라 버퍼를 비울 때만 잡히므로, 개별 스트림 쓰기는 그룹 커밋 같은 최적화를 누리며 빠르게 진행되고 알림은 백그라운드에서 묶여 나간다.

다만 버퍼를 두면 새로운 문제가 생긴다. 알림이 버퍼에 남아 있는 동안 프로세스가 죽으면 그 알림들은 영영 전달되지 않는다. DBOS는 읽기 측에 대비책을 추가해 이를 해결했다. 읽기 측은 알림을 기다리는 동시에, 알림 없이 스트림에 쓰기가 있었는지 주기적으로 데이터베이스를 폴링한다. 이 폴링은 어디까지나 누락된 알림을 보완하는 안전장치이므로 빈도를 낮게 잡아도 되고, 따라서 성능에 큰 영향을 주지 않는다.

실무자가 얻을 교훈과 한계

이 최적화 이후 벤치마크에서는 동시 읽기 부하 아래에서 초당 최대 6만 건의 스트림 쓰기를 달성했다. 기존의 20배에 해당하며 지연은 15~100밀리초 수준을 유지했다. 최대 처리량에서 Postgres CPU가 완전히 소진됐다는 점이 중요하다. 락 경합에 발이 묶인 게 아니라 데이터베이스가 실제로 포화 상태로 일하고 있다는 뜻이기 때문이다. 벤치마크 코드는 github.com/dbos-inc/dbos-postgres-benchmark에 공개돼 있다.

한국 실무자 입장에서 이 사례의 핵심은 특정 제품이 아니라 설계 원칙에 있다. 알림을 '반드시 정렬되고 반드시 도착해야 하는 메시지'가 아니라 '데이터를 확인하라는 힌트'로 취급하고, 그 힌트를 버퍼링·배치로 묶으면 전역 락 경합을 우회할 수 있다는 점이다. 대신 이 방식은 알림이 유실될 수 있음을 전제로 하므로, 진실의 원천이 되는 테이블과 그것을 확인하는 폴링 폴백이 반드시 짝을 이뤄야 한다. 알림 하나하나의 순서와 도달을 엄격히 보장해야 하는 시스템이라면 같은 트레이드오프를 그대로 적용하기 어렵고, 6만 건이라는 숫자도 하드웨어와 워크로드에 따라 달라지는 벤치마크 값이라는 점은 감안해 읽어야 한다. 그럼에도 별도 브로커 없이 Postgres만으로 밀리초 단위 스트림을 상당한 규모까지 밀어붙일 수 있다는 사실은, 아키텍처를 단순하게 유지하려는 팀에 충분히 검토할 가치가 있는 선택지다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://www.dbos.dev/blog/postgres-listen-notify-scalability
SHARE
처리 중...