1P by GN⁺ | ★ favorite | 댓글 1개
  • Postgres LISTEN/NOTIFY의 전역 배타적 잠금은 단순 구현의 처리량을 제한하지만, 알림을 버퍼링해 일괄 전송하면 단일 서버에서 초당 최대 6만 건의 스트림 쓰기를 처리할 수 있음
  • NOTIFY를 호출한 트랜잭션은 알림의 커밋 순서를 보장하기 위해 커밋과 fsync()가 끝날 때까지 전역 잠금을 유지하며, 이로 인해 커밋이 직렬화되고 그룹 커밋도 활용하지 못함
  • 스트림 테이블의 모든 쓰기마다 트리거로 NOTIFY를 호출한 초기 구현은 낮은 지연 시간을 제공했지만, CPU·메모리·IOPS를 충분히 사용하지 못한 채 초당 2,900건에서 병목을 맞음
  • 알림이 아닌 데이터베이스 테이블을 진실의 원천으로 삼고, 메모리에 모은 알림을 주기적으로 하나의 트랜잭션에서 전송하면 잠금 획득 횟수를 크게 줄일 수 있음
  • 프로세스 장애로 버퍼의 알림이 유실될 가능성은 낮은 빈도의 폴링으로 보완하며, 동시 읽기 환경에서도 15~100ms의 지연 시간과 기존 대비 20배의 처리량을 달성함

Postgres로 구현한 저지연 스트림

  • Postgres 기반 스트림은 각 스트림 조각을 streams 테이블의 새 행으로 저장하며, LLM 응답 토큰도 하나의 조각이 될 수 있음
  • 읽기 측에서는 다음 조각이 언제 도착할지 알 수 없어 단순 조회만으로 효율적으로 대기하기 어려움
  • 주기적 폴링은 간격이 길면 온라인 채팅 같은 대화형 용도에서 지연 시간이 커지고, 짧으면 동시 폴러가 데이터베이스를 압도함
  • LISTEN/NOTIFY를 사용하면 읽기 프로세스가 차단 상태로 기다리다가 새 조각이 기록됐다는 알림과 함께 즉시 깨어나므로 불필요한 폴링을 피할 수 있음

쓰기마다 보낸 NOTIFY의 병목

  • 초기 구현에서는 streams 테이블에 새 조각이 기록될 때마다 트리거가 함수를 실행해 NOTIFY를 한 번씩 전송하고, 읽기 프로세스는 알림을 기다린 뒤 새 조각을 읽음
  • 정확성과 낮은 지연 시간은 확보했지만, 큰 Postgres 데이터베이스에서도 초당 2,900건을 넘는 스트림 쓰기를 지속하지 못함
  • 병목이 발생하는 동안 CPU·메모리·IOPS 사용량은 눈에 띄게 높아지지 않았으며, 원인은 NOTIFY 커밋 경로의 전역 잠금이었음

커밋 순서를 보장하는 전역 잠금

  • NOTIFY를 호출한 트랜잭션은 커밋 시작 시 전역 배타적 잠금을 획득하고, 완전히 커밋되어 내용이 fsync()로 디스크에 기록될 때까지 해제하지 않음
  • Postgres는 알림이 트랜잭션 커밋 순서대로 전달되도록 보장하며, 모든 발신 알림을 커밋 순서와 정확히 일치해야 하는 전역 내부 큐에 저장함
  • 알림을 큐에 추가하는 작업도 커밋의 일부로 트랜잭션 방식으로 처리해야 하지만, 트랜잭션마다 커밋 시간이 달라 커밋 완료 전에는 순서를 정할 수 없음
  • 전역 잠금은 알림을 포함한 트랜잭션의 커밋을 직렬화해 순서를 미리 확정하고, 내부 알림 큐에도 같은 순서로 추가되도록 함

커밋 직렬화가 처리량을 제한하는 방식

  • 모든 스트림 쓰기가 트리거를 통해 NOTIFY를 호출하므로 각 쓰기 트랜잭션은 커밋 전체와 디스크 플러시 동안 전역 잠금을 보유함
  • 트랜잭션이 차례로 커밋되면서 여러 트랜잭션을 한 번의 fsync()로 처리하는 Postgres의 그룹 커밋을 활용할 수 없음
  • 처리량은 Postgres가 개별 트랜잭션을 커밋하는 속도를 넘을 수 없으며, 작업들이 잠금에서 대기해 CPU와 디스크도 충분히 사용되지 않음
  • Postgres 19에 포함될 패치는 전역 잠금을 제거하지 않으므로 이 병목을 해소하지 못함
    • 대신 다수의 알림 채널이 있고 각 리스너가 특정 채널 하나만 기다리는 제한적인 사례를 최적화함

알림 버퍼링과 일괄 전송

  • 스트림을 비롯한 여러 LISTEN/NOTIFY 용도에서 알림은 진실의 원천이 아니라 실제 데이터가 저장된 테이블을 확인하라는 신호에 불과함
  • 이런 구조에서는 알림 자체가 완벽한 전역 순서나 완전한 내구성을 갖출 필요가 없어, 메모리에 버퍼링한 뒤 주기적으로 하나의 배치 트랜잭션에서 전송할 수 있음
  • 전역 잠금은 개별 스트림 쓰기마다가 아니라 버퍼를 비울 때만 획득함
  • 개별 쓰기가 백그라운드 알림 전송과 분리되어 빠르게 진행되며, 그룹 커밋 같은 Postgres 최적화를 활용해 처리량을 높일 수 있음

알림 유실을 보완하는 저빈도 폴링

  • 알림이 메모리에 남아 있는 동안 프로세스가 중단되면 해당 알림이 전달되지 않을 수 있음
  • 읽기 프로세스는 알림을 기다리는 동시에 데이터베이스를 주기적으로 조회해 알림 없이 기록된 스트림 데이터가 있는지 확인함
  • 이 폴링은 유실된 알림만 복구하는 보조 수단이므로 낮은 빈도로 실행할 수 있고, 성능에 미치는 영향도 크지 않음

처리량과 지연 시간

  • 최적화된 구현은 동시 읽기 프로세스가 있는 환경에서 초당 최대 6만 건의 스트림 쓰기를 처리해 초기 구현보다 20배 높은 처리량을 기록함
  • 처리량을 높인 상태에서도 지연 시간은 15~100ms 범위를 유지함
  • 최대 처리량에서는 Postgres CPU가 완전히 사용되어 잠금 경합이 아니라 데이터베이스 자체의 실제 포화 상태에 도달했음을 보여줌
  • 전체 벤치마크 코드는 dbos-postgres-benchmark에서 확인할 수 있음

댓글과 토론

Hacker News 의견들
  • 확장성은 연속적인 척도이지 이분법이 아님. 초당 6만 건은 어떤 시스템에는 필요량보다 10만 배 많고, 다른 시스템에는 10만 배 부족할 수 있음. 흔한 개발자 실수로 “이른 최적화”보다 확장 특성이 맞지 않는 기술 선택을 꼽고 싶음
    지나치게 작은 기술은 한계를 넘으면 실패가 명확하지만, 과도하게 확장 가능한 기술도 운영 부담과 제약을 가져옴. 더 풍부한 모델로 개발 노력을 크게 줄일 수 있는 작은 시스템에 이런 기술을 도입하는 것 역시 나쁜 선택임
    LISTEN/NOTIFY의 한계는 주의해야 할 만큼 낮으므로 비관적인 최대 부하를 계산한 뒤에도 최소 10배의 여유를 두는 편이 좋지만, 많은 프로젝트에는 충분함. 데이터베이스와의 통합성, 가용성, 별도 서비스 운영이 필요 없다는 장점 때문에 무조건 배제할 선택지는 아니며, 기존에 제시된 초당 2천 건조차 메시지 하나를 초 단위로 처리하는 시스템에는 큰 수치임

    • 좋은 글에 대한 사소한 반론이지만, 초당 60억 요청을 처리하는 시스템은 거의 없으며 존재하더라도 목적에 맞게 자체 제작한 도구를 사용할 가능성이 큼
    • 초당 6만 건 이하를 예상했다가 2만 건에서 20만 건으로 급증하는 편이, 초당 100만 건을 목표로 구축했는데 실제 부하가 2만 건인 것보다 나은 문제임. 전자의 예상 밖 성공은 임시 조치와 확장 비용을 충당하지만, 후자는 높은 비용 구조와 선투자에 묶이게 됨
      실제 예상 규모에 적당한 여유를 더해 설계하고, 추가 확장성이 사실상 공짜일 때만 그 이상을 선택하는 편이 좋음. 수천 달러로 더 큰 하드웨어를 사거나 확장성 외에는 동등한 선택지라면 더 큰 쪽을 고르면 됨
    • 이 경우에는 하드웨어 최대치까지 도달하므로 확장된다고 볼 수 있음. 병목이 데이터베이스나 입출력이 아니라 하드웨어에 있으며, 최대 처리량에서 Postgres CPU가 완전히 사용돼 경합이 아닌 데이터베이스 자체의 포화를 보여줌
  • LISTEN/NOTIFY와 Rust GraphQL 구독 브로커를 조합해 큰 성공을 거뒀음. 구독은 수만 개였지만 LISTEN 연결은 호스트마다 하나씩 총 3~4개뿐이었음
    모든 변경을 각 호스트로 보내고, 호스트가 실제 사용자 구독을 관리하며 무엇을 발행할지 결정했음. Ruby나 Node 호스트 수백 대를 Rust 호스트 몇 대로 바꾸면 구조를 크게 단순화할 수 있고, 확장되지 않는다고 여겨지는 방식도 상당히 잘 작동함

    • Rust용으로 추천할 만한 GraphQL 라이브러리가 궁금함
  • CTO로 일할 당시 모든 서비스에서 하루 약 10만 건을 처리하다가 수백만, 결국 수천만 건까지 성장했음. 그 과정에서 한 엔지니어가 데이터 모델과의 강한 일관성을 활용하려고 LISTEN/NOTIFY 의미론 위에 큐를 구축했음. 이해하기 어렵지 않았고 별도 저장·전송 계층도 없앨 수 있어 당시에는 합리적으로 보였음
    하지만 직접 만든 기능을 확장하면서 PostgreSQL 내부 동작을 우회해야 해 매우 불편해졌고, 더 일찍 다른 시스템으로 옮겼어야 했음. 확장성도 좋지 않아 RDS에서 원인을 파악하기 어려운 디스크 경합이 심해졌고 해당 테이블의 VACUUM도 악몽이었음. 익숙한 큐를 PostgreSQL 내부 기능으로 낯설게 구현하니 다른 엔지니어들이 두려워하며 디버깅과 소유권을 꺼렸음
    스키마와 인덱스 같은 세부 사항을 제외한 핵심 교훈은 언제나 단순하고 예상 가능한 기술부터 선택하라는 것임. 매우 강한 데이터 일관성이 꼭 필요하지 않다면 인프라 구성 요소가 하나 늘더라도 SQS나 Redis 큐처럼 API 계약상 단순한 큐를 사용하고 나머지를 거기에 맞추는 편이 나음. 핵심 데이터 저장소 하나가 맡는 기계적 책임은 적을수록 좋았음

    • 결국 “작동 방식을 이해하지 못한 채 사용하면 확장되지 않는다”로 요약할 수 있음. 기본 LISTEN/NOTIFY는 확장되지 않지만 원문은 이를 확장하는 방법을 실제로 찾아냈으므로 각 팀이 다시 해결할 필요가 없음
      분산 시스템 생태계가 발전하면서 각 구성 요소가 무엇을 할 수 있는지 이해하게 됨. 처음에는 적은 구성 요소로 시작하고 실제로 필요할 때 추가하는 편이 더 건강한 시스템을 만듦
    • 새 구성 요소가 다른 요인을 압도하는 경우는 드물지만, 큐를 다른 아키텍처 구성 요소와 독립적으로 운영하면 큰 이점이 있음. 큐는 본래 저장 후 전달하도록 만들어졌으므로 수명 주기를 분리하면 업데이트, 장애 분석, 패치 시간 동안 다른 시스템끼리도 분리할 수 있음
    • 이는 관리 문제에 가깝고 인프라에 새 구성 요소를 추가해야 한다는 결론에는 동의하지 않음. 개발자들이 스택 일부를 다루고 싶어 하지 않는다는 이유로 새 네트워크 노드를 추가하는 것은 타당하지 않으며, 그 부분을 맡도록 하면 됨
  • Postgres와 이제 SQLite까지 제대로 활용하는 DBOS가 계속 마음에 듦. 기존 CRUD 스택에도 거의 노력 없이 도입할 수 있음
    내구성 있는 워크플로를 쓰기 시작하면 적용할 곳이 계속 보임. 최근에는 이메일 하나하나를 내구성 있는 워크플로로 보고, 사용자와 상대방, 에이전트, GitHub나 Attio 같은 도구가 차례로 흐름에 참여하도록 실험 중임
    https://housecat.com/blog/gmail-durable-workflows-sandbox-vm

  • 이런 글은 대체로 각자의 문제와 이해, 해결책을 독립적으로 평가한 결과임. 도구의 기본 설정으로 특정 성능을 기대했다는 이유만으로 전문성 부족이라 부르기는 애매하며, 누구나 실패를 통해 계속 학습함
    실험에 96코어·384GB RAM 데이터베이스 서버를 사용한 점(https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...)은 매우 중요한데 명확히 밝혔어야 함. 데이터베이스는 수직 확장이 가능하지만 그 역시 한계가 있음. 누가 어디에서 연결하는지도 성능과 전체 지연 시간에 영향을 줌
    초당 6만 건이 커 보일 수 있지만 실제 시스템을 무너뜨리는 것은 평상시 트래픽보다 순간적인 트래픽 폭증임. 대기업이 아니라면 이런 대형 서버로 시작하지 않을 것임. 읽기 복제본과 리전 간 이중화를 포함하면 프로덕션 데이터베이스 클러스터 하나에 10만 달러가 넘게 듦

  • 관련 글로 보임: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - 2025년 7월, 댓글 321개

  • 글에서 가장 중요한 부분인 소비자가 어디까지 읽었는지 추적하고 대체 경로의 새 메시지를 조회하게 해주는 오프셋 또는 시퀀스 번호 할당 방법이 빠진 듯함. 여러 방식이 있지만 복잡성이나 잠금 경합 없이 해결하기 쉽지 않고, 잘못 구현하면 소비자와 경쟁 상태도 생김. 대개 작성자가 상태 테이블의 단일 행 등에 잠금을 걸어 이벤트 주제의 다음 번호를 할당할 것임
    최선의 방법이 무엇인지 궁금함. 변경 데이터 캡처(CDC)를 읽어 할당된 이벤트 번호를 다른 테이블에 쓰는 도구가 괜찮을 수도 있지만 지연 시간이 길어질 수 있으며, 그 CDC 처리기가 NOTIFY도 수행해야 함
    소비자 측에서는 일괄 처리로 처리량을 크게 높일 수 있는 용도도 있음. 이때는 LISTEN/NOTIFY 없이 소비자를 반복 실행해 매번 처리되지 않은 새 메시지를 모두 처리하고, 반복 사이에 마지막 시퀀스 번호를 저장하면 됨

  • LISTEN/NOTIFY를 처음 지원한 릴리스에는 잠금 구현이 좋지 않아 성능 문제가 있었던 것으로 기억함. 여기서 비판받는 기존 글도 첫 문단 직후 정정문에서 이를 바로잡았음
    정정 날짜가 5월 8일이라면 7월 24일 글은 이 기능이 확장되지 않는다고 했던 유명 글이 악의적으로 작성된 것도 아니고 당시 기준으로 틀리지 않았을 수도 있음을 인정할 필요가 있음

    • Postgres 19에 들어갈 최적화를 뜻한다면 원문에서도 다룸. 해당 패치(https://github.com/postgres/postgres/commit/282b1cde9dedf456...)는 전역 잠금을 제거하거나 관찰된 병목을 해결하지 않음
      대신 알림 채널이 많고 각 수신자가 특정 채널 하나만 기다리는 더 제한적인 경우를 최적화함
  • 마지막으로 확인했을 때 LISTEN/NOTIFY는 알림 데이터에 8,000바이트 상한이 있어 명백히 확장되지 않는 측면이 있었음. 알림을 행으로 저장하고 ID만 전달할 수 없는 데이터라면 사용하기 어려움
    웹 게임의 이벤트는 상태 변경을 설명하는 일시적인 데이터라 데이터베이스에 저장할 이유가 없었고 8,000바이트를 넘을 수도 있어 이 용도에는 맞지 않았음

    • 확장 가능한 알림 시스템을 구현한다면 알림 크기에 상한을 두겠음. 메시지 크기를 O(1)로 유지해 알림 개수 확장에 집중할 수 있고, 임의로 큰 메시지는 성능을 멈추게 할 수 있으며 알림 시스템을 잘못 사용하고 있을 가능성도 알려줌
    • 특정 행을 가리키지 않더라도 변경된 상태를 참조하는 메시지를 보낼 수 있지 않을지 궁금함
  • 글에서는 전역 큐의 잠금 경합을 다루지만, 고정 크기 전역 큐의 또 다른 문제는 언급하지 않는 듯함. 한 채널의 느린 수신자 하나가 모든 채널에 대한 쓰기를 막을 수 있었음. 적어도 몇 년 전에는 그런 장애 형태가 가능했으며 지금은 바뀌었을 수도 있음