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
728x90

인프라 일을 하다 보면 "처리량을 10배 올려야 한다"는 요구를 받는 순간이 온다. 보통 첫 반응은 "서버 늘려주세요"다. 그런데 Cloudflare가 Security Insights 스캔 처리량을 초당 10건에서 120건 이상으로 끌어올린 과정은 하드웨어 한 대도 안 늘리고 끝냈다. 코드와 아키텍처만 손봤다는 얘기다.

이 글은 그 원문(Dave Baxter, Cloudflare Blog)을 실무자 시선으로 다시 뜯어본 것이다. Kafka 컨슈머, Postgres 벌크 인서트, API 레이턴시 — 우리가 국내 실무에서도 매일 부딪히는 지점들이라 그대로 가져다 쓸 만한 게 많다.

1. 왜 하드웨어 증설 없이 10x를 목표로 했나

Cloudflare의 Security Insights는 모든 계정·존·DNS 레코드를 주기적으로 스캔해서 보안 오설정을 찾아준다. 문제는 두 가지였다.

  • 스캔 주기가 너무 길었다. 1~2주에 한 번. 새로 생긴 보안 위험이 최대 2주간 방치된다는 뜻이다.
  • 무료 플랜은 스캔이 opt-in이라 아예 스캔을 안 받는 계정이 수두룩했다.

이걸 다 커버하려면 평균 처리량을 약 10배(초당 10건 → 100건) 올려야 한다는 계산이 나왔다. 그런데 당시 시스템은 이미 부하에 허덕였다. 백로그에 수백만 이벤트가 쌓이고, API는 타임아웃 나고, 프로세스가 죽었다.

여기서 "Kafka 파티션 늘리고 DB 인스턴스 키우자"가 자연스러운 선택지였지만, Cloudflare는 그걸 마지막 수단으로 미뤘다. 이유가 현실적이다. Kafka 브로커는 다른 여러 서비스가 공유하는 자원이라, 파티션을 늘리면 브로커 자체 리소스 사용량이 올라가 옆 팀까지 영향을 받는다. 비용도 비용이지만, 공유 인프라를 함부로 건드리면 책임 범위가 넓어진다. 그래서 "코드와 구조부터 고치자"가 된 거다.

실무 포인트: 처리량 문제를 만나면 "병목이 진짜 어디인가"를 먼저 찾아라. 하드웨어 증설은 병목을 못 찾았을 때 도망치는 선택지일 때가 많다. 증설로 가린 병목은 트래픽이 더 늘면 다시 터진다.

2. 병목 탐색과 핵심 동작 원리

Kafka는 큐가 아니다 — 이게 모든 것의 출발점

먼저 구조를 보자. 스케줄러가 스캔 대상을 Kafka에 메시지로 발행하고, 그 메시지가 여러 checker(특정 자산을 스캔하는 Go 마이크로서비스)로 팬아웃된다. 각 checker는 결과를 내부 API로 보내고, API가 Postgres에 저장한다.

여기서 가장 중요한 개념. Kafka는 큐가 아니라 파티션된 이벤트 스트림이다. 한 파티션 안에서 메시지는 순서대로 소비·처리돼야 하고, 컨슈머 그룹 내에서 파티션 하나당 활성 컨슈머는 하나뿐이다.

이게 무슨 의미냐. 일반 큐(RabbitMQ 등)는 컨슈머를 늘리면 메시지를 나눠 가져가서 병렬로 처리한다. 그런데 Kafka는 파티션 수가 곧 병렬성의 상한이다. 파티션이 30개면 컨슈머는 최대 30개. 게다가 한 파티션 안에서 느린 메시지 하나가 걸리면 그 뒤 메시지들이 전부 막힌다. 이걸 head-of-line blocking이라고 한다.

비유하자면 Kafka 파티션은 단일 차선 도로다. 앞차가 느리면 뒤차가 다 막힌다. 차선(파티션)을 늘리는 게 아니라, 차 한 대가 여러 작업을 동시에 처리하게 만드는 게 Cloudflare의 접근이었다.

해법 1: 배치 소비 + goroutine 병렬 처리

순서대로 "소비"해야 한다는 제약은 있지만, "한 번에 여러 개를 소비"하는 건 막혀 있지 않다. 그래서 메시지를 배치로 가져와서 각각을 별도 goroutine으로 처리하도록 바꿨다. Go에서 이건 대략 이런 모양이 된다.

// 배치로 가져온 메시지를 goroutine으로 병렬 처리
func processBatch(ctx context.Context, msgs []Message) error {
    var wg sync.WaitGroup
    errCh := make(chan error, len(msgs))

    for _, msg := range msgs {
        wg.Add(1)
        go func(m Message) {
            defer wg.Done()
            if err := handle(ctx, m); err != nil {
                errCh <- err
            }
        }(msg)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        if err != nil {
            return err // 배치 중 하나라도 실패하면 재처리
        }
    }
    return nil
}

트레이드오프는 명확하다. 배치 중간에 프로세스가 죽으면 그 배치 전체를 다시 처리해야 한다(오프셋 커밋을 배치 단위로 하니까). 메모리 사용량도 약간 늘어난다. Cloudflare는 이 둘 다 감수할 만하다고 판단했다.

해법 2: 슬로우 레인 / 패스트 레인 분리

일부 checker는 메시지마다 처리 시간 편차가 극심했다. 자산이 엄청 많은 계정 하나가 몇 분~몇 시간씩 걸리는 반면, 평균은 밀리초~초 단위였다. 큰 메시지 하나가 파티션을 점유하면 작은 계정들이 줄줄이 밀린다.

해법은 단순했다. 컨슈머 그룹과 checker를 둘로 쪼갰다. '느린 레인'과 '빠른 레인'. 메시지가 느릴지 빠를지는 빠르게 판단할 수 있었고, 빠른 레인 checker가 느린 메시지를 만나면 그냥 건너뛴다. 느린 건 전용 자원을 받은 느린 레인이 처리한다.

이건 우선순위 큐를 직접 구현하는 것보다 훨씬 단순하고 운영하기 쉽다. 메시지 특성으로 "빠른지/느린지" 사전 판별이 가능하다면 레인 분리가 가성비 좋은 선택이다.

해법 3: Postgres 벌크 인서트 — N+1 round trip 제거

insight를 저장하는 API 엔드포인트가 원래 이렇게 생겼다.

for _, issue := range issues {
    _, err = tx.Exec(ctx, `INSERT INTO table ... VALUES ($1, $2, ...)
        ON CONFLICT DO UPDATE ...`, ...)
    if err != nil {
        return err
    }
}

insight 하나당 DB 왕복 한 번. 관측된 최대치가 50만 건이었으니, API 호출 한 번에 50만 번의 왕복·쿼리·트랜잭션이 발생했다. 이건 레이턴시가 낮아도 답이 없는 구조다.

처음엔 Postgres 벌크 인서트의 정석인 COPY into temp table을 썼는데, Postgres 시스템 테이블에 bloat(비대화)가 생겼다. temp table을 빈번하게 만들고 지우면 pg_class, pg_attribute 같은 카탈로그가 부풀어서 오히려 성능이 나빠진다.

그래서 하이브리드로 갔다.

  • 건수가 임계값 이하UNNEST 사용
  • 건수가 임계값 초과COPY 사용

UNNEST 방식은 배열을 통째로 넘겨서 한 번의 쿼리로 여러 행을 처리한다.

-- UNNEST로 여러 행을 한 번에 insert (작은 배치용)
INSERT INTO insights (account_id, zone_id, kind)
SELECT * FROM unnest(
    $1::bigint[],   -- account_id 배열
    $2::bigint[],   -- zone_id 배열
    $3::text[]      -- kind 배열
)
ON CONFLICT (account_id, zone_id, kind) DO UPDATE
    SET updated_at = now();

결과적으로 작은 배치는 밀리초, 50만 건 같은 거대한 배치는 초 단위로 처리됐다. 양쪽 다 챙긴 셈이다.

해법 4: API를 active-active에서 active-passive로

이 부분이 개인적으로 가장 무릎을 치게 만든다. API를 스케일하려는데 이상한 증상이 나타났다.

  • 상당수 요청이 클라이언트 측 타임아웃
  • 많은 checker가 처리 시간의 20~90%를 단 한 번의 API 호출에 소비
  • 대량 스캔을 트리거하면 처리량이 처음엔 높다가 점점 떨어짐

근본 원인은 전부 레이턴시 하나였다. 주 DB는 Portland(오리건)에 있는데, API는 Portland와 Amsterdam에 active-active로 떠 있었다. 빛의 속도로도 Portland-Amsterdam 왕복은 50ms. Amsterdam API 인스턴스가 Portland DB로 쿼리를 날리면 매 쿼리가 50ms씩 더 먹는다.

그 결과 Amsterdam 인스턴스는 커넥션 풀의 커넥션을 오래 붙잡고 있게 되고, 대량 요청 상황에서 풀이 금방 고갈됐다. Portland에서 평균 10ms로 끝나던 API 호출이 Amsterdam에선 거의 3초가 걸렸다.

처리량이 점점 떨어지던 이유도 여기서 풀린다. 로드밸런서가 트래픽을 반반 나누니, 30개 파티션 중 정확히 15개(Amsterdam에 붙은 프로세스가 소비하던)가 뒤처졌다. Portland에 붙은 파티션은 빠르게 처리되고, Amsterdam에 붙은 파티션은 lag이 쌓였다.

해법은 허무할 만큼 단순했다. API를 active-passive로 전환해서 active API가 항상 주 DB를 따라가게 했다. "레이턴시 문제가 하룻밤 사이에 사라졌다"고 한다.

교훈: 멀티 리전에 앱을 active-active로 띄울 때, DB가 단일 리전에 있으면 먼 쪽 인스턴스는 모든 쿼리에 RTT를 더 문다. 커넥션 풀은 이런 상황에서 조용히 고갈된다. "앱은 멀티 리전인데 DB는 싱글 리전"인 구성이 가장 흔한 함정이다.

해법 5: 스케줄러 재설계

Kafka, DB, API를 다 고쳤는데도 문제가 남았다. 스캔이 시간상 고르게 퍼지지 않았다. Kafka 토픽은 시간 기반 retention 정책을 쓰는데, 스캔을 한꺼번에 몰아넣으면 처리되기 전에 삭제될 수 있다.

원래 스케줄러는 이런 로직이었다.

Loop forever:
  Find accounts where last_scheduled_at + scanning_frequency = now
  For each account:
    Trigger scan for account
    Trigger scan for all zones in the account
    Update last_scheduled_at = now

문제가 둘이었다. 첫째, 많은 계정의 last_scheduled_at이 비슷해서 특정 시점에 수십만 건이 몰렸다. 둘째, 존이 엄청 많은 계정이 스케줄되면 그 존 스캔이 캐스케이드로 쏟아져 Kafka 파티션을 포화시키고 작은 계정들을 밀어냈다.

세 가지로 고쳤다.

  1. 존을 계정과 독립적으로 스케줄 — 존마다 자기 last_scheduled_at을 가져서 캐스케이드 제거
  2. 기존 계정·존의 last_scheduled_at을 랜덤화 — 몰림 현상 해소(이때 어떤 스캔도 지연되지 않게 처리)
  3. 적응형 레이트 리미팅 도입

적응형 부분이 핵심이다. 7일 주기에 5천만 계정이면 초당 약 83건으로 제한하면 7일에 고르게 퍼진다. 그런데 천만 계정이 더 늘면? 고정 레이트로는 8일이 걸려버린다. 그래서 레이트 리밋을 총 계정·존 수와 스캔 주기로 30분마다 비동기 재계산한다.

func computeRate(free, pro, biz, ent int64) rate.Limit {
    r := float64(free)/freeScanInterval.Seconds() +
        float64(pro)/proScanInterval.Seconds() +
        float64(biz)/bizScanInterval.Seconds() +
        float64(ent)/entScanInterval.Seconds()

    // 0 방어: 최소 초당 1건은 스케줄
    if r < 1 {
        r = 1
    }

    // 다운타임/스파이크 대비 버퍼
    r *= rateLimitBufferFactor
    return rate.Limit(r)
}

이렇게 하면 수백만 계정을 더 온보딩해도 제때 스캔이 돈다. "스케일에 따라 알아서 적응하는 레이트 리밋"이라는 발상이 좋다.

3. 실무 관점: 트레이드오프와 흔한 함정

흔한 함정 1: 커넥션 풀 고갈

active-passive로 안 바꿨더라도, 커넥션 풀 고갈은 멀티 리전이 아니어도 만난다. Go의 pgx 풀에서 커넥션이 모자라면 이런 류의 에러를 본다.

error: timeout: context deadline exceeded
  acquiring connection from pool

// HikariCP(Java)라면 더 명시적이다:
java.sql.SQLTransientConnectionException: HikariPool-1 - 
  Connection is not available, request timed out after 30000ms.

이 에러가 뜨면 "DB 느려서 그렇다"고 단정하기 쉬운데, 실제로는 느린 쿼리 하나가 커넥션을 오래 붙잡아서 풀이 빈다는 경우가 훨씬 많다. Cloudflare 사례처럼 레이턴시가 범인일 수도 있다. 풀 크기를 무작정 키우기 전에 "커넥션 보유 시간"부터 봐라.

흔한 함정 2: Kafka 컨슈머 lag과 리밸런싱

배치 처리로 바꾸면 한 배치 처리가 너무 길어질 때 컨슈머가 죽은 것으로 오해받아 리밸런싱이 터진다.

// 컨슈머 그룹에서 흔히 보는 로그
[Consumer] Member consumer-1 failed to heartbeat,
  removing from group. max.poll.interval.ms exceeded.
  Rebalancing...

배치 크기를 늘리거나 메시지당 처리가 무거워지면 max.poll.interval.ms 안에 다음 poll을 못 하고, 브로커가 그 컨슈머를 그룹에서 쫓아낸다. 리밸런싱이 도미노로 일어나면 처리량이 오히려 폭락한다. 배치 크기와 max.poll.interval.ms는 같이 튜닝해야 한다. lag은 다음처럼 확인한다.

$ kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
    --describe --group security-insights-checker

GROUP                       TOPIC        PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
security-insights-checker   scans        0          1052340         1052355         15
security-insights-checker   scans        1          984120          1284900         300780
security-insights-checker   scans        2          1050010         1050010         0

위 출력에서 파티션 1만 LAG이 30만이라면, 그 파티션을 소비하는 컨슈머가 막혀 있다는 신호다. Cloudflare 사례에선 "정확히 절반의 파티션만 lag"이 active-active 레이턴시의 결정적 단서였다. lag을 파티션별로 보는 습관이 병목 탐색의 시작이다.

흔한 함정 3: COPY로 temp table 남용

벌크 인서트 검색하면 십중팔구 "COPY 써라"가 나온다. 맞는데, 작은 배치마다 temp table을 만들고 지우면 카탈로그 bloat가 쌓인다. Cloudflare도 이걸 겪고 하이브리드로 갔다. 작은 건 UNNEST, 큰 건 COPY — 이 임계값 기반 분기가 현실적인 정답에 가깝다.

트레이드오프 정리

  • 배치 + goroutine: 처리량↑, 그러나 crash 시 재처리 비용↑, 메모리↑
  • 슬로우/패스트 레인: head-of-line blocking 해소, 그러나 컨슈머 그룹·인프라 2벌 운영 비용
  • UNNEST/COPY 하이브리드: 양쪽 케이스 다 빠름, 그러나 코드 분기와 임계값 튜닝 필요
  • active-passive: 레이턴시 일관성↑, 그러나 passive 리전은 평소 놀고 장애 시 페일오버 검증 필요

4. 정리: 누가 언제 써야 하나

한 줄 요약: 처리량 10배는 하드웨어가 아니라 병목 탐색으로 달성한다. Kafka는 큐가 아니라는 점, DB 왕복 횟수, 그리고 "앱 멀티 리전 + DB 싱글 리전"의 레이턴시 함정 — 이 셋을 점검하면 대부분의 스케일링 문제가 풀린다.

이런 사람에게 유용하다:

  • Kafka 컨슈머 lag이 특정 파티션에만 쌓여서 원인을 못 찾는 사람
  • 벌크 인서트 API가 건수 많을 때 타임아웃 나는 사람
  • 멀티 리전에 앱을 띄웠는데 한쪽이 유독 느린 사람
  • 스케줄러 기반 작업이 특정 시간에 몰려서 다운스트림을 터뜨리는 사람

실무 체크리스트:

  1. 처리량 문제 → 증설 전에 병목부터 찾는다 (파티션별 lag, 쿼리 round trip 수, API 레이턴시 분포)
  2. Kafka 병렬성 상한 = 파티션 수. 파티션 늘리기 전에 배치+goroutine으로 단일 컨슈머 처리량을 먼저 짠다
  3. 처리 시간 편차가 크면 슬로우/패스트 레인 분리를 검토
  4. 벌크 쓰기는 건수 기반으로 UNNEST/COPY 분기. COPY
728x90

+ Recent posts