728x90

1. DB 스케일링은 왜 항상 커넥션을 끊었나

운영하다 보면 이런 새벽을 한 번씩 겪는다. 배치가 몰려서 CPU가 90%를 치고, 인스턴스 타입을 한 단계 올리기로 한다. 그런데 RDS 인스턴스 클래스 변경은 결국 재시작이다. Multi-AZ면 스탠바이를 먼저 올리고 페일오버로 넘기니 다운타임은 짧아지지만, 그 순간 기존 TCP 커넥션은 전부 끊긴다. 애플리케이션 로그에는 이런 게 우수수 찍힌다.

org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend.
Caused by: java.net.SocketException: Connection reset

# 또는 Node.js pg
Error: Connection terminated unexpectedly
    at Connection.<anonymous> (/app/node_modules/pg/lib/client.js:132:73)

문제는 단순히 "잠깐 끊겼다"가 아니다. 커넥션 풀이 죽은 커넥션을 붙잡고 있으면 요청마다 타임아웃까지 기다리고, HikariCP 같은 풀은 재검증하며 몰려드는 신규 연결로 max_connections를 다시 때린다. 끊김 자체보다 그 뒤에 오는 커넥션 스톰이 진짜 장애다.

그래서 이번에 화제가 된 Databricks Lakebase(Neon 기반) 글이 눈에 들어온다. 요지는 이렇다. 실행 중인 VM의 CPU와 메모리를 조정해서, 데이터베이스 연결을 끊지 않고 용량을 늘리거나 줄인다. 그리고 Neon의 프로덕션 데이터베이스는 평균적으로 월 32,016회, 약 81초마다 컴퓨트 크기가 바뀐다고 한다. 재시작 없이 1분 20초마다 사이즈가 변한다는 뜻이다.

월 3만 회라는 숫자가 왜 중요하냐면, 우리가 RDS에서 인스턴스 타입을 바꾸는 빈도를 생각해보면 된다. 잘해야 분기에 한 번, 그것도 변경 작업 공지 띄우고 새벽에 한다. 스케일링이 "이벤트"인 세계와 "상시 동작"인 세계는 운영 모델 자체가 다르다.

2. 컴퓨트-스토리지 분리와 핫 리사이즈의 원리

스토리지를 떼어내면 컴퓨트가 가벼워진다

Neon 아키텍처의 출발점은 컴퓨트와 스토리지의 분리다. 전통적인 Postgres는 하나의 프로세스가 WAL도 쓰고 데이터 파일도 로컬 디스크에 관리한다. 그래서 인스턴스를 바꾸려면 데이터가 붙어 있는 디스크를 새 인스턴스에 재부착하거나 복제본으로 넘겨야 한다. 이 과정이 곧 재시작이다.

컴퓨트-스토리지가 분리되면 Postgres 프로세스는 WAL을 스토리지 계층으로 흘려보내고, 페이지가 필요하면 스토리지에서 받아온다. 컴퓨트 노드는 상태를 거의 안 들고 있는 "계산기"에 가까워진다. 상태가 적으니 크기를 바꾸는 비용도 작다.

비유하자면 이렇다. 기존 RDS는 집 안에 창고가 붙어 있는 구조라 집을 넓히려면 이사를 가야 한다. 분리 구조는 창고가 별도 건물이라 사는 집만 방을 하나 더 트면 된다. 짐을 옮길 필요가 없다.

CPU 핫플러그와 메모리 벌루닝

여기서 "재시작 없이 CPU/메모리를 바꾼다"는 게 마법은 아니다. 리눅스와 하이퍼바이저에 원래 있던 기능이다.

CPU 핫플러그: 리눅스 커널은 실행 중에 CPU를 온라인/오프라인으로 전환할 수 있다. VM에 vCPU를 미리 붙여두고 필요한 만큼만 online으로 올리는 방식이다. 컨테이너/VM 안에서 직접 확인해보자.

$ ls /sys/devices/system/cpu/
cpu0  cpu1  cpu2  cpu3  online  offline  possible  present

$ cat /sys/devices/system/cpu/online
0-1
$ cat /sys/devices/system/cpu/possible
0-3

# 루트 권한이 있다면 온라인 전환 (실습용 VM에서만)
$ echo 1 | sudo tee /sys/devices/system/cpu/cpu2/online
1
$ cat /sys/devices/system/cpu/online
0-2

possible은 4개인데 online은 2개다. 이게 핫플러그가 동작하는 모습이다. 프로세스는 죽지 않고, 커널 스케줄러가 쓸 수 있는 코어만 늘어난다.

메모리 벌루닝: 게스트 VM 안에 "풍선" 드라이버를 두고, 호스트가 메모리를 회수하고 싶으면 풍선을 부풀려 게스트 메모리를 점유한다. 반대로 게스트에 메모리를 더 주고 싶으면 풍선을 수축시킨다. VM 자체를 재부팅하지 않고 실효 메모리를 조절하는 고전적인 기법이다.

Lakebase/Neon이 이 두 가지를 어떤 방식으로 조합하고, 어느 수준까지 자동화했는지의 세부 구현(예: 오토스케일러의 판단 주기, 상한/하한 계산 로직)은 공식 문서 확인이 필요하다. 다만 큰 그림은 "Postgres 프로세스는 그대로 두고, 그 아래 자원만 갈아끼운다"이다. TCP 소켓이 유지되니 커넥션이 안 끊긴다.

Postgres 쪽 제약도 같이 봐야 한다

여기가 실무자가 놓치기 쉬운 지점이다. VM 메모리가 늘어났다고 Postgres가 자동으로 그만큼 쓰는 건 아니다. shared_buffers는 서버 재시작이 필요한 파라미터다.

postgres=# SELECT name, setting, unit, context FROM pg_settings
postgres-#  WHERE name IN ('shared_buffers','work_mem','max_connections');
      name       | setting | unit |  context
-----------------+---------+------+------------
 max_connections | 100     |      | postmaster
 shared_buffers  | 16384   | 8kB  | postmaster
 work_mem        | 4096    | kB   | user
(3 rows)

context가 postmaster면 재시작이 필요하고, user면 세션 단위로 바꿀 수 있다. 즉 메모리를 늘려도 즉시 이득을 보는 건 OS 페이지 캐시와 work_mem/정렬 작업 쪽이지, shared_buffers가 실시간으로 따라 커지는 건 아니다. Lakebase가 이 부분을 어떻게 처리하는지는 공식 문서 확인 필요. 어쨌든 "메모리 오토스케일 = 모든 메모리 파라미터 자동 확장"이라고 기대하면 안 된다.

3. 실무 관점: 트레이드오프, 함정, 그리고 대안

81초마다 스케일링된다는 게 운영에 주는 의미

월 32,016회라는 숫자를 뒤집어 보면 몇 가지가 드러난다.

  • 워크로드는 원래 들쭉날쭉하다. 우리가 고정 인스턴스로 운영할 때는 피크에 맞춰 사이즈를 잡고 나머지 시간은 놀린다. 그 낭비가 이 숫자만큼 존재했다는 뜻이다.
  • 스케일링이 흔해지면 알람 설계가 바뀐다. "인스턴스 리사이즈 발생" 같은 이벤트 알람은 의미가 없어진다. 대신 "스케일 상한에 붙어 있는 시간 비율", "스케일 요청 실패율"을 봐야 한다.
  • 비용이 시간 단위가 아니라 초/분 단위로 움직인다. 예측 가능성이 떨어진다. 상한을 반드시 걸어야 한다.

흔한 함정 1: 커넥션은 살아있는데 쿼리가 느려진다

커넥션이 안 끊긴다는 건 좋은데, 그게 곧 "SLA가 보장된다"는 뜻은 아니다. 스케일 업이 실제로 반영되기까지의 지연 동안 쿼리는 여전히 자원 부족 상태다. 트래픽이 계단식으로 튀는 워크로드(광고 집행, 푸시 발송, 티켓 오픈)에서는 스케일링이 따라오기 전에 이미 타임아웃이 난다.

ERROR:  canceling statement due to statement timeout
CONTEXT:  SQL statement "SELECT ... FROM orders WHERE ..."

# 애플리케이션 쪽
HikariPool-1 - Connection is not available, request timed out after 30000ms.

대응은 뻔하지만 확실하다. 예측 가능한 피크는 사전에 최소 용량을 올려두는 프리워밍을 하고, 오토스케일링은 예측 못 한 변동을 흡수하는 안전망으로 쓴다. 이건 Aurora Serverless v2에서도 똑같이 하던 일이다.

흔한 함정 2: 스케일 투 제로와 콜드 스타트

컴퓨트-스토리지 분리의 부산물이 스케일 투 제로다. 아무도 안 쓰면 컴퓨트를 아예 내려버린다. 개발/스테이징 환경에서는 비용이 극적으로 줄지만, 첫 요청이 콜드 스타트를 맞는다.

psql: error: connection to server at "ep-xxxx.region.aws.neon.tech" (10.0.0.1), port 5432 failed:
        Connection timed out
        Is the server running on that host and accepting TCP/IP connections?

여기서 자주 나오는 실수가 커넥션 타임아웃을 2초 같은 값으로 짧게 잡아둔 경우다. 헬스체크나 라이브니스 프로브가 이 짧은 타임아웃으로 DB를 찌르면, 콜드 스타트 한 번에 파드가 재시작되는 어이없는 상황이 생긴다. 스케일 투 제로를 쓰는 환경에서는 커넥션 타임아웃을 넉넉히 잡고, 프로브에서 DB 의존성을 빼는 게 낫다. 실제 콜드 스타트 소요 시간은 플랜과 리전에 따라 다르므로 직접 측정해야 한다.

흔한 함정 3: 커넥션이 안 끊긴다고 풀 설정을 방치

오히려 반대다. 컴퓨트가 작아지면 max_connections 여유도 작아질 수 있다. 커넥션이 유지된다는 이유로 애플리케이션이 수백 개 커넥션을 붙잡고 있으면, 스케일 다운이 막히거나 아래 에러를 본다.

FATAL:  remaining connection slots are reserved for non-replication superuser connections
FATAL:  sorry, too many clients already

Postgres에서 커넥션은 프로세스 하나다. 커넥션 수 자체가 메모리 압력이 된다. 서버리스/오토스케일 Postgres일수록 PgBouncer 같은 풀러를 앞에 두는 게 필수에 가깝다. 트랜잭션 풀링 모드 기본형은 이렇다.

[databases]
app = host=ep-xxxx.neon.tech port=5432 dbname=app

[pgbouncer]
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction     ; 세션 상태 안 쓰는 경우만
max_client_conn = 2000
default_pool_size = 25      ; 실제 DB로 나가는 커넥션 수
server_idle_timeout = 60

주의할 점. pool_mode = transaction에서는 세션 레벨 기능이 깨진다. SET으로 세션 변수 잡기, prepared statement, advisory lock, LISTEN/NOTIFY, 임시 테이블이 대표적이다. JDBC를 쓴다면 prepareThreshold=0을 붙이거나 드라이버 설정을 맞춰야 한다. 안 그러면 이런 게 나온다.

org.postgresql.util.PSQLException: ERROR: prepared statement "S_1" does not exist

풀러 상태는 관리 콘솔로 바로 확인된다.

$ psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
 database | user | cl_active | cl_waiting | sv_active | sv_idle | pool_mode
----------+------+-----------+------------+-----------+---------+-----------
 app      | app  |       180 |         12 |        25 |       0 | transaction
 pgbouncer| pgbouncer |    1 |          0 |         0 |       0 | statement

cl_waiting이 계속 쌓이면 default_pool_size가 모자라거나 뒤쪽 DB가 느린 것이다. 오토스케일 환경에서는 후자일 확률이 높으니 스케일 상한부터 확인하는 게 순서다.

RDS·Aurora Serverless v2와 비교하면

정리하자면 이렇게 갈린다.

  • RDS 인스턴스 클래스 변경: 재시작/페일오버 동반. 커넥션 끊김 확정. 변경은 이벤트성.
  • Aurora Serverless v2: ACU 단위로 용량을 조절하며 커넥션을 유지한다. 스토리지 분리 아키텍처라는 점도 비슷하다. 다만 Aurora 특유의 클러스터 모델과 과금 구조를 따라간다.
  • Lakebase/Neon: VM의 CPU·메모리를 직접 핫 리사이즈하고, 스케일 투 제로까지 간다. 브랜칭 같은 개발 워크플로우 기능이 따라붙는 것도 특징이다.

"어느 쪽이 더 빠르다/싸다"는 각자 워크로드와 리전, 요금제에 따라 완전히 달라진다. 두 제품의 최신 스케일링 지연 수치나 ACU 단가 비교는 각 벤더 공식 문서를 직접 확인해야 한다. 확실히 말할 수 있는 건, "커넥션을 유지한 채 용량을 바꾸는 것"이 이제 특수 기능이 아니라 기본 기대치가 됐다는 점이다.

도입 전 체크리스트

  1. 애플리케이션이 커넥션 재시도 로직을 갖고 있는가. 오토스케일이 커넥션을 지켜준다 해도 네트워크는 언제든 끊긴다.
  2. 커넥션 풀 최대치를 DB의 max_connections 대비 안전하게 잡았는가. (앱 인스턴스 수 × 풀 사이즈를 계산해보자.)
  3. 스케일 상한을 걸었는가. 비효율 쿼리 하나가 상한까지 밀어올리면 비용 사고다.
  4. 헬스체크 타임아웃이 콜드 스타트를 견디는가.
  5. 규제 산업이라면 리전·데이터 거주 요건을 만족하는가. 국내 서비스는 리전 위치와 망분리 요건을 먼저 확인하는 게 순서다.
  6. 기존 확장(pg_stat_statements, PostGIS 등) 지원 여부. 이건 반드시 공식 문서에서 확인해야 한다.

4. 정리

한 줄 요약: Lakebase Postgres는 컴퓨트-스토리지 분리 구조 위에서 실행 중인 VM의 CPU·메모리를 핫 리사이즈해, 커넥션을 끊지 않고 용량을 조절한다. Neon 기준 평균 월 32,016회, 약 81초마다 일어난다.

누가 쓰면 좋은가. 트래픽 변동이 크고 피크와 평시 격차가 큰 서비스, 개발/스테이징 DB가 수십 개라 대부분의 시간을 놀리는 조직, 그리고 브랜칭 기반 DB 워크플로우를 원하는 팀이다.

반대로 신중해야 할 쪽. 24시간 평평한 부하로 돌아가는 시스템은 고정 인스턴스가 더 싸고 예측 가능하다. 밀리초 단위 지연에 민감한 워크로드라면 스케일링 과도기의 흔들림을 감당할 수 있는지 먼저 측정해야 한다. 그리고 어떤 경우든, 커넥션 풀 설계를 먼저 정리하지 않고 오토스케일링만 도입하면 문제는 그대로 남는다. 스케일링은 자원 문제를 풀지, 커넥션 관리 문제를 풀어주지는 않는다.

참고 자료

728x90
728x90

백엔드 좀 만져본 사람이라면 Postgres LISTEN/NOTIFY라는 게 있다는 건 다들 안다. 근데 실무에서 실제로 쓰냐고 물어보면 열에 아홉은 "그거 안 쓰죠, 확장 안 되잖아요"라고 답한다. 나도 그랬다. 실시간 이벤트 뿌릴 일 있으면 반사적으로 Redis Pub/Sub이나 Kafka부터 떠올렸다.

그런데 최근 DBOS 엔지니어링 블로그에서 나온 글(GeekNews 링크)이 이 편견을 정면으로 반박한다. 요지는 간단하다. 단순 구현은 초당 2,900건에서 막히는 게 맞다. 하지만 알림을 버퍼링해서 배치로 보내면 단일 서버에서 초당 최대 6만 건까지 뚫린다는 것. 20배 차이다. 오늘은 이게 왜 그런지, 그리고 실무에서 언제 이 카드를 꺼내야 하는지를 정리해본다.

1. Postgres LISTEN/NOTIFY란 무엇인가

구조 자체는 정말 단순하다. 한 세션이 특정 채널을 LISTEN하고, 다른 세션이 그 채널로 NOTIFY를 보내면, 대기 중이던 세션이 즉시 깨어난다. 별도 브로커도, 별도 프로세스도 필요 없다. DB 하나로 끝난다.

직접 두 개의 psql 세션을 띄워서 확인해보자. 첫 번째 터미널:

-- 세션 A (리스너)
LISTEN chat_events;

두 번째 터미널:

-- 세션 B (발신자)
NOTIFY chat_events, 'new message id=42';

그러면 세션 A에서 이런 게 뜬다:

Asynchronous notification "chat_events" with payload
"new message id=42" received from server process with PID 12345.

실무에서 자주 쓰는 패턴은 테이블에 트리거를 걸어서 INSERT가 일어날 때마다 자동으로 NOTIFY를 쏘는 방식이다. 예를 들어 LLM 응답 토큰을 스트리밍하거나, 채팅 메시지가 들어올 때 읽기 프로세스를 즉시 깨우는 용도다.

CREATE OR REPLACE FUNCTION notify_stream_insert()
RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('stream_' || NEW.stream_id, NEW.id::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER stream_insert_notify
AFTER INSERT ON streams
FOR EACH ROW EXECUTE FUNCTION notify_stream_insert();

이렇게 하면 읽기 프로세스는 폴링 없이 대기하다가 새 조각이 기록되는 순간 깨어나서 읽는다. 지연 시간도 낮고 정확성도 확보된다. 여기까지는 아무 문제 없다.

2. 왜 '느리다'는 오해가 생겼나 — 전역 배타적 잠금의 정체

문제는 부하가 올라갈 때 터진다. 위 트리거 방식으로 스트림 쓰기를 계속 밀어넣으면, 큰 Postgres 서버에서도 초당 2,900건 언저리에서 병목이 걸린다. 그런데 이상한 건, 이때 CPU도 메모리도 IOPS도 널널하다는 점이다. 리소스는 남아도는데 처리량이 안 나온다. 전형적인 잠금 경합 증상이다.

원인은 NOTIFY의 커밋 경로에 있는 전역 배타적 잠금이다. 왜 이런 잠금이 필요할까? 핵심은 커밋 순서 보장이다.

Postgres는 알림이 반드시 트랜잭션 커밋 순서대로 전달되도록 보장한다. 이걸 위해 모든 발신 알림을 커밋 순서와 정확히 일치하는 전역 내부 큐에 넣는다. 그런데 트랜잭션마다 커밋에 걸리는 시간이 제각각이라, 커밋이 완료되기 전에는 순서를 확정할 수 없다. 그래서 Postgres는 NOTIFY를 포함한 트랜잭션이 커밋을 시작하면 전역 잠금을 잡고, 커밋이 완전히 끝나고 fsync()로 디스크에 내려갈 때까지 놓지 않는다.

비유하자면 이렇다. 은행 창구가 여러 개 있는데(그룹 커밋), NOTIFY가 붙은 손님은 "내 순번을 정확히 지켜야 한다"는 이유로 창구 하나를 혼자 독점하면서 서류에 도장 찍고 금고에 넣는 것까지 다 끝나야 다음 사람이 들어온다. 나머지 창구는 놀고 있다. 이게 그룹 커밋을 못 쓰게 만드는 지점이다.

결과적으로 처리량은 "Postgres가 개별 트랜잭션을 하나씩 순차 커밋하는 속도"를 절대 넘을 수 없다. 여러 트랜잭션을 한 번의 fsync로 묶는 그룹 커밋 최적화가 무력화되니까. CPU와 디스크가 놀고 있는데도 처리량이 안 오르는 이유가 바로 이거다.

참고로 Postgres 19에 들어갈 관련 패치가 있긴 한데, 원문에 따르면 이 패치는 전역 잠금을 제거하지 않는다. 대신 "채널이 많고 각 리스너가 특정 채널 하나만 기다리는" 제한적 케이스를 최적화하는 것이라, 위에서 말한 병목 자체를 해소하진 못한다.

3. 배치 버퍼링으로 처리량 끌어올리기 — 초당 6만 건의 원리

여기서 관점 전환이 나온다. 핵심 통찰은 이거다: 대부분의 LISTEN/NOTIFY 용도에서 알림은 '진실의 원천'이 아니다.

무슨 말이냐면, 실제 데이터는 streams 테이블에 이미 저장되어 있다. 알림은 그냥 "테이블 확인해봐"라는 신호일 뿐이다. 그렇다면 알림 자체가 완벽한 전역 순서나 완전한 내구성을 가질 필요가 없다. 순서가 조금 뒤바뀌거나 알림 몇 개가 유실돼도, 데이터베이스 테이블만 정확하면 복구할 수 있다.

이 발상에서 나오는 구조가 알림 버퍼링 + 배치 전송이다. 개별 쓰기마다 NOTIFY를 쏘지 않는다. 대신:

  • 스트림 쓰기는 그냥 테이블에 INSERT만 하고 빠르게 커밋한다 (전역 잠금 안 잡음 → 그룹 커밋 활용 가능)
  • 보내야 할 알림은 메모리 버퍼에 모아둔다
  • 백그라운드에서 주기적으로 버퍼를 비우면서, 모아둔 알림을 하나의 배치 트랜잭션으로 한 번에 NOTIFY한다

이렇게 하면 전역 잠금을 "개별 쓰기마다"가 아니라 "버퍼 플러시할 때만" 잡는다. 잠금 획득 횟수가 확 줄어든다. 개별 쓰기 트랜잭션은 알림 전송과 분리되어 빠르게 진행되고, 그룹 커밋 최적화도 살아난다.

의사코드로 표현하면 이런 그림이다:

# 애플리케이션 레벨 배치 버퍼 (개념 예시)
buffer = []

def on_stream_write(stream_id, chunk_id):
    # 쓰기 자체는 알림과 분리 - 빠르게 커밋
    db.insert("streams", stream_id=stream_id, id=chunk_id)
    buffer.append(f"stream_{stream_id}")

# 백그라운드에서 주기적 플러시 (예: 수 ms 간격)
def flush_notifications():
    if not buffer:
        return
    channels = set(buffer)   # 중복 채널 제거
    buffer.clear()
    with db.transaction() as tx:   # 하나의 배치 트랜잭션
        for ch in channels:
            tx.execute("SELECT pg_notify(%s, '')", (ch,))
    # 이 트랜잭션 커밋 때만 전역 잠금 1회 획득

원문 벤치마크에 따르면 이 최적화 구현은 동시 읽기 프로세스가 있는 환경에서 초당 최대 6만 건의 스트림 쓰기를 처리했다. 초기 구현 대비 20배다. 그리고 중요한 건 이때 Postgres CPU가 완전히 포화됐다는 점이다. 즉 병목이 잠금 경합이 아니라 데이터베이스 자체의 실제 처리 한계로 옮겨갔다는 뜻이다. 잠금 때문에 리소스가 놀던 상태에서, 리소스를 다 쓰는 상태가 됐다.

4. 놓치기 쉬운 함정 — 알림 유실과 커밋 순서

여기서 "어? 알림을 메모리에 모아두면 프로세스 죽으면 날아가는 거 아냐?"라는 의문이 당연히 든다. 맞다. 버퍼에 남아있는 상태에서 프로세스가 죽으면 그 알림들은 전달되지 않는다.

원문의 해법은 저빈도 폴링을 보조 수단으로 병행하는 것이다. 읽기 프로세스는 알림을 기다리는 동시에, 낮은 빈도로 테이블을 주기적으로 조회해서 "알림 없이 기록된 데이터"가 있는지 확인한다. 알림은 즉시성을 위한 것이고, 폴링은 유실 복구용 안전망이다. 유실된 것만 건지면 되니까 폴링 빈도가 낮아도 되고, 그래서 DB에 부담도 크지 않다.

이 이중 구조 덕분에 처리량을 6만 건까지 끌어올린 상태에서도 지연 시간은 15~100ms 범위를 유지한다. 정상 경로는 알림으로 빠르게, 예외 경로는 폴링으로 안전하게. 이게 핵심 설계 포인트다.

실무에서 진짜 마주치는 함정들

함정 1: 8,000바이트 페이로드 상한. LISTEN/NOTIFY 페이로드에는 크기 제한이 있다. 큰 JSON을 통째로 알림에 실으려다가 이걸 만난다:

ERROR:  payload string too long

이게 뜨면 설계를 잘못한 거다. 애초에 알림은 신호일 뿐이니, 페이로드에는 행 ID나 시퀀스 번호만 넣고 실제 데이터는 테이블에서 읽어야 한다. HN 댓글에서도 지적됐듯, 알림에 임의 크기 데이터를 실으려 한다면 그건 알림 시스템을 잘못 쓰고 있다는 신호다. 웹 게임의 일시적 상태 이벤트처럼 8KB를 넘는 데이터를 그대로 뿌려야 하는 용도라면 이 방식 자체가 안 맞는다.

함정 2: 소비자 오프셋 추적 누락. 원문에서 다루지 않은, 하지만 실무에서 반드시 부딪히는 부분이다. "소비자가 어디까지 읽었는지"를 어떻게 추적할 것인가? 알림이 유실될 수 있으니, 소비자는 시퀀스 번호나 오프셋 기반으로 "내가 마지막으로 읽은 지점 이후의 새 메시지"를 조회할 수 있어야 한다. 이걸 잘못 구현하면 소비자끼리 경쟁 상태(race condition)가 생기거나, 시퀀스 번호 할당 지점에 또 다른 잠금 경합이 생긴다. 배치 버퍼링으로 NOTIFY 병목은 풀었는데, 정작 시퀀스 발급에서 다시 직렬화되는 자충수를 두지 않도록 조심해야 한다.

함정 3: VACUUM과 디스크 경합. HN에 올라온 실전 경험담이 뼈아프다. 어떤 CTO가 LISTEN/NOTIFY 위에 자체 큐를 올렸다가, 확장하면서 Postgres 내부 동작을 우회해야 했고, RDS에서 원인 파악이 어려운 디스크 경합이 심해졌으며, 해당 테이블의 VACUUM이 악몽이 됐다고 한다. 스트림 테이블처럼 INSERT가 폭발적으로 일어나는 테이블은 dead tuple이 빠르게 쌓이므로, autovacuum 설정과 파티셔닝/주기적 정리 전략을 반드시 함께 설계해야 한다.

5. 실무 아키텍처 패턴 — 언제 쓰고 언제 Kafka·Redis로 가야 하나

이 글의 진짜 가치는 "6만 건 되니까 무조건 써라"가 아니다. HN 최고 추천 댓글이 정곡을 찌른다. 확장성은 이분법이 아니라 연속적인 척도다. 초당 6만 건은 어떤 시스템엔 10만 배 과하고, 어떤 시스템엔 10만 배 부족하다.

내가 정리한 판단 기준은 이렇다.

LISTEN/NOTIFY로 충분한 경우:

  • 이미 Postgres가 진실의 원천이고, 실시간 알림이 "테이블 확인해라" 신호 수준인 경우
  • 비관적 최대 부하를 계산했을 때 10배 여유를 둬도 6만 건 안에 들어오는 경우
  • 별도 인프라(Redis/Kafka/SQS)를 운영할 팀 여력이 없고, DB와의 트랜잭션 일관성이 중요한 경우
  • 채팅, LLM 토큰 스트리밍, 실시간 대시보드 갱신처럼 지연 100ms 이내면 충분한 대화형 용도

외부 브로커로 가야 하는 경우:

  • 메시지 자체가 진실의 원천이고, 강한 순서 보장과 내구성이 필수인 경우 (→ Kafka)
  • 저장 후 전달(store-and-forward), 재처리, 컨슈머 그룹 같은 큐 본연의 기능이 필요한 경우 (→ SQS, Kafka)
  • 순간적 트래픽 폭증(스파이크)을 흡수해야 하는 경우 — HN 댓글 지적대로 시스템을 무너뜨리는 건 평상시 트래픽이 아니라 갑작스러운 폭증이다. 큐는 버퍼 역할을 하지만 LISTEN/NOTIFY는 그렇지 않다
  • 큐의 수명 주기를 DB와 독립적으로 운영하고 싶은 경우 (패치/장애 격리)

한 가지 꼭 짚고 넘어갈 점. 원문 벤치마크는 96코어·384GB RAM짜리 대형 서버에서 나온 수치다(HN 댓글에서 지적됨). 이건 원문이 더 명확히 밝혔어야 하는 부분이다. 당신의 db.t3.medium에서 6만 건이 나올 거라고 기대하면 안 된다. 데이터베이스는 수직 확장이 가능하지만 그 자체도 한계가 있고, 읽기 복제본과 리전 간 이중화까지 넣으면 프로덕션 클러스터 하나에 연 10만 달러가 넘어가기도 한다. 하드웨어 스펙과 처리량은 세트로 봐야 한다.

실제로 잘 굴린 사례도 HN에 있다. LISTEN/NOTIFY와 Rust GraphQL 구독 브로커를 조합한 케이스인데, 사용자 구독은 수만 개였지만 LISTEN 연결은 호스트당 하나씩 총 3~4개뿐이었다. 모든 변경을 각 호스트로 보내고, 실제 사용자 구독 관리는 호스트가 담당하게 한 것이다. "확장 안 된다"고 여겨지는 방식도 연결 구조를 잘 설계하면 충분히 잘 돌아간다는 좋은 예다.

6. 벤치마크 재현과 운영 모니터링 포인트

원문 저자들이 전체 벤치마크 코드를 dbos-postgres-benchmark 저장소에 공개해뒀다. 직접 재현해보고 싶다면 이걸 돌려보는 게 제일 확실하다. 다만 앞서 말했듯 결과는 하드웨어 스펙에 크게 좌우되니, 자기 환경 스펙에서 돌려봐야 의미가 있다.

운영에 들어갔다면 이런 것들을 봐야 한다. 잠금 경합이 병목인지 확인하는 쿼리:

-- NOTIFY 관련 대기 이벤트 확인
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
GROUP BY 1, 2
ORDER BY 3 DESC;

여기서 NotifyQueue나 커밋 관련 잠금 대기가 상위에 계속 잡힌다면, 아직 개별 NOTIFY 방식으로 병목에 걸려 있다는 신호다. 배치 버퍼링 전환을 검토할 시점이다.

또 하나, NOTIFY 큐가 얼마나 차 있는지도 봐야 한다. 느린 소비자 하나가 큐를 막을 수 있다는 HN 지적이 있었는데(고정 크기 전역 큐 특성), Postgres는 이 사용률을 함수로 제공한다:

-- 비동기 알림 큐 사용률 (0.0 ~ 1.0)
SELECT pg_notification_queue_usage();
 pg_notification_queue_usage
-----------------------------
                        0.02
(1 row)

이 값이 1.0에 가까워지면 큐가 꽉 차서 NOTIFY 자체가 막힐 수 있다. 느린 리스너가 있는지, LISTEN 걸어놓고 실제로 소비 안 하는 유령 세션이 있는지 점검해야 한다. 정상 운영 환경에서는 이 값이 0에 가깝게 유지되는 게 맞다. 슬금슬금 올라간다면 소비 쪽에 문제가 있는 거다.

정리

한 줄 요약: 기본 LISTEN/NOTIFY는 커밋 순서 보장을 위한 전역 잠금 때문에 초당 2,900건에서 막히지만, 알림을 신호로만 쓰고 배치 버퍼링 + 저빈도 폴링을 병행하면 대형 서버 기준 초당 6만 건까지 뚫린다.

누가 언제 써야 하나. Postgres가 이미 진실의 원천이고, 예상 부하에 10배 여유를 둬도 감당 가능하며, 별도 브로커 운영 부담을 지기 싫은 팀이라면 충분히 좋은 선택지다. 채팅이나 LLM 스트리밍 같은 대화형 실시간 용도에 특히 잘 맞는다. 반대로 메시지가 진실의 원천이거나, 강한 내구성·순서·재처리가 필요하거나, 트래픽 스파이크를 버퍼로 흡수해야 한다면 그건 Kafka나 SQS의 영역이다.

728x90

+ Recent posts