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

인프라 일을 하다 보면 "처리량을 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
728x90

pg_durable: PostgreSQL 안에서 돌아가는 durable execution, 실무에서 쓸만한가

Microsoft가 pg_durable이라는 PostgreSQL 확장을 오픈소스로 풀었다. Hacker News에서 410점, 한국 커뮤니티(GeekNews)에도 바로 올라온 걸 보면 관심도가 꽤 높다. 한 줄로 요약하면 "Temporal이나 AWS Step Functions 같은 durable execution을 외부 인프라 없이 Postgres 안에서 처리한다"는 물건이다.

나처럼 인프라를 굴려본 사람 입장에서 이게 왜 흥미로운지, 그리고 어디서 발목을 잡힐지 실무 관점에서 정리해본다.

왜 지금 이게 화제인가

durable execution이라는 개념 자체는 새롭지 않다. 결제 처리, 회원가입 후 이메일 발송, 외부 API 여러 개를 순서대로 호출하는 작업 같은 걸 생각해보자. 중간에 워커가 죽거나 네트워크가 끊기면 어디까지 처리됐는지 알 수가 없다. 그래서 우리는 보통 이런 걸 만든다.

  • 작업 상태를 DB 테이블에 기록 (pending → processing → done)
  • Redis나 RabbitMQ로 큐를 깔고 워커를 띄움
  • 실패하면 재시도하는 로직, 중복 실행 막는 idempotency 키
  • 스케줄링 필요하면 cron이나 별도 스케줄러

이걸 제대로 하려면 Temporal, AWS Step Functions, Inngest 같은 솔루션을 도입하거나, 직접 상태머신을 짜야 한다. 문제는 이게 다 외부 인프라를 하나 더 추가한다는 거다. 운영 포인트가 늘고, 모니터링 대상이 늘고, 장애 지점이 늘어난다.

pg_durable의 핵심 주장은 이거다. "이미 Postgres 쓰고 있잖아? 컨테이너도 외부 서비스도 필요 없이 Postgres와 백그라운드 워커만으로 처리하자." 트랜잭션, 상태 저장, 재시도가 전부 DB 안에서 끝난다는 게 매력 포인트다.

동작 원리 — 체크포인트가 핵심이다

공식 저장소 설명에 따르면 작은 SQL DSL로 재시도, 스케줄링, 병렬 fan-out, 조건 분기를 표현한다. 그리고 가장 중요한 건 모든 단계가 PostgreSQL에 상태를 체크포인트로 저장한다는 점이다.

비유하자면 게임 세이브 포인트다. 보스전 직전에 세이브해두면, 죽어도 처음부터가 아니라 세이브 지점부터 다시 시작한다. durable execution도 똑같다. 단계마다 "여기까지 했다"를 DB에 기록하니까, 워커가 죽고 다시 살아나도 이미 끝낸 단계는 건너뛰고 다음부터 이어간다.

일반적인 durable function의 흐름은 대략 이런 모양이다. (정확한 문법은 저장소 문서 확인 필요)

-- durable function 정의 (개념적 예시)
-- step 1: 결제 호출
-- step 2: 재고 차감
-- step 3: 알림 발송
-- 각 step 결과가 체크포인트로 저장됨

SELECT durable.call('process_order', jsonb_build_object('order_id', 1234));

여기서 step 2를 처리하다가 워커가 죽었다고 하자. 재시작하면 엔진은 "step 1은 이미 완료(체크포인트 있음), step 2부터 다시"라고 판단한다. step 1의 결제 호출이 두 번 일어나지 않는다는 게 핵심 가치다.

외부 큐를 안 쓰는 대신 백그라운드 워커가 Postgres를 폴링하거나 알림(LISTEN/NOTIFY 추정)으로 깨어나서 다음 step을 진행하는 구조로 보인다. 이 부분 구현 방식은 직접 소스 확인이 필요하다.

실무 관점 — 좋은데, 만능은 아니다

매력적인 그림이지만 실무에 넣기 전에 따져봐야 할 게 있다.

1. DB가 워크로드 처리기까지 겸하게 된다

이게 가장 큰 트레이드오프다. 평소에 Postgres는 데이터 저장소다. 그런데 pg_durable을 쓰면 비즈니스 워크플로우 실행, 재시도, 스케줄링까지 DB가 떠안는다. 백그라운드 워커가 폴링을 돌리면 그만큼 DB 부하가 생긴다.

트래픽 작은 서비스에선 문제없다. 그런데 워크플로우가 초당 수천 건씩 돌아가는 규모라면, DB가 병목이 되는 순간 스토리지와 워크플로우 엔진이 동시에 죽는다. 관심사를 분리해놨으면 하나만 죽었을 일이다. 이건 "DB에 뭐든 넣자" 류 확장(pg_cron, pgmq 등)이 공통으로 갖는 숙명이다.

2. 운영 단순함 vs 확장성의 교환

스타트업 초기나 내부 도구, 트래픽 예측 가능한 백오피스 작업에는 정말 좋다. Temporal 클러스터 운영해본 사람은 알겠지만, 그거 띄우고 유지하는 것 자체가 일이다. 반면 pg_durable은 "extension 깔고 워커 띄우면 끝"에 가깝다. 인프라 한 덩어리가 통째로 사라진다.

하지만 트래픽이 커지고 워크플로우가 복잡해지면, 결국 전용 durable execution 플랫폼으로 갈아타야 하는 순간이 온다. 그때 마이그레이션 비용이 든다. 처음부터 "이 서비스는 절대 커질 일 없다" 또는 "커지면 그때 갈아탄다"는 판단이 서야 도입할 만하다.

3. 흔한 함정 — idempotency를 공짜로 주는 게 아니다

durable execution이 step 재실행을 건너뛰어준다고 해도, 외부 부수효과(side effect)의 멱등성은 여전히 네 책임이다. 예를 들어 step에서 외부 결제 API를 호출했는데 응답 받기 직전에 워커가 죽었다고 하자. 엔진 입장에선 "이 step 완료 기록이 없다 → 재시도"인데, 실제로는 결제가 이미 됐을 수 있다.

이런 케이스는 어떤 durable execution 솔루션을 써도 똑같이 발생한다. 외부 호출 쪽에 idempotency key를 같이 보내거나, 결제 상태를 먼저 조회하는 식으로 직접 방어해야 한다. "체크포인트 있으니 중복 안 일어나겠지"라고 믿으면 사고 난다.

4. 대안과 비교

  • Temporal / Cadence: 대규모·복잡한 워크플로우의 정석. 무겁고 운영 부담 큼.
  • AWS Step Functions: 매니지드라 운영 편하지만 AWS 락인, 비용, 디버깅 불편.
  • pgmq + 직접 구현: Postgres 기반 큐만 쓰고 워크플로우 로직은 직접. 더 가볍지만 손이 많이 감.
  • Inngest, Trigger.dev: 코드 중심 durable execution, DX 좋음. 외부 SaaS 의존.
  • pg_durable: 이미 Postgres 쓰고, 추가 인프라 없이 적당한 규모를 처리하고 싶을 때.

참고로 아직 초기 단계 프로젝트로 보이니, 프로덕션에 바로 넣기보단 사이드 워크로드에서 먼저 검증하길 권한다. 버전 안정성, 마이그레이션 호환성은 직접 확인이 필요하다.

정리

한 줄 요약: pg_durable은 외부 워크플로우 인프라 없이 Postgres만으로 durable execution을 처리하는 확장이고, 운영 단순함을 얻는 대신 DB에 부하를 몰아주는 트레이드오프가 있다.

  • 이럴 때 써라: 이미 Postgres 중심 스택이고, 트래픽 규모가 크지 않으며, Temporal까지 도입하긴 과한 백오피스/내부 작업/중소 서비스의 비동기 워크플로우.
  • 피해라: 초당 수천 건 워크플로우, DB가 이미 병목인 환경, 워크플로우 엔진 장애와 DB 장애를 분리해야 하는 미션 크리티컬 시스템.

개인적으론 방향성이 좋다고 본다. "또 인프라 하나 추가"에 지친 팀에겐 충분히 매력적이다. 다만 멱등성은 여전히 네가 챙겨야 하고, 규모가 커질 때의 출구 전략은 미리 그려두고 들어가는 게 안전하다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90
728x90

Postgres 하나로 Durable Workflow 짜기 — DBOS 방식이 실무에서 먹히는 이유

오늘 HN 목록 보다가 "Building durable workflows on Postgres"(DBOS) 글이 292점 받고 올라와 있길래 골랐다. AI 관련 글이 절반인 와중에 이건 인프라/백엔드 하는 사람한테 훨씬 실용적인 주제다. 워크플로우 오케스트레이션이라고 하면 보통 Temporal, Airflow, Step Functions 떠올리는데, "그거 그냥 Postgres로 다 되는 거 아니냐"는 주장이라 한번 정리해볼 만하다.

왜 지금 이게 화제인가

현업에서 결제, 주문, 정산 같은 워크플로우 짜본 사람은 다 안다. "여러 단계로 이뤄진 작업이 중간에 죽으면 어디부터 다시 해야 하나" 이게 진짜 골칫거리다.

예를 들어 결제 플로우가 이렇다고 치자.

1. 재고 차감
2. 결제 승인 (외부 PG 호출)
3. 포인트 적립
4. 알림 발송

2번에서 PG 호출하다가 서버 프로세스가 죽었다. 결제는 됐는데 포인트는 안 붙었다. 재배포로 인스턴스가 내려갔을 수도 있고, OOM으로 죽었을 수도 있다. 이 "중간에 죽어도 정확히 멈춘 지점부터 다시 이어서 한다"는 게 durable execution(내구성 있는 실행)의 핵심이다.

지금까지 이걸 제대로 하려면 Temporal 같은 별도 워크플로우 엔진을 띄워야 했다. 근데 그게 운영 부담이 만만치 않다. DBOS의 주장은 "이미 너희가 쓰는 Postgres 트랜잭션이면 충분하다"는 거다.

핵심: 어떻게 Postgres만으로 되나

아이디어 자체는 의외로 단순하다. 각 단계(step)의 실행 결과를 Postgres 테이블에 기록해두고, 워크플로우가 재시작되면 이미 완료된 단계는 건너뛰고 결과만 읽어온다. 이게 흔히 말하는 메모이제이션(memoization)이다.

DBOS 라이브러리 기준으로 쓰면 대충 이런 모양이다.

import { DBOS } from "@dbos-inc/dbos-sdk";

class Checkout {
  @DBOS.step()
  static async chargePayment(orderId: string) {
    // 외부 PG 호출
    return await pg.charge(orderId);
  }

  @DBOS.step()
  static async addPoints(userId: string, amount: number) {
    return await pointService.add(userId, amount);
  }

  @DBOS.workflow()
  static async run(orderId: string, userId: string) {
    const result = await Checkout.chargePayment(orderId);
    await Checkout.addPoints(userId, result.amount);
    return result;
  }
}

여기서 마법은 @DBOS.step()에 있다. 이 데코레이터가 붙은 함수는 실행되면 결과가 Postgres에 저장된다. 만약 addPoints 직전에 프로세스가 죽었다가 워크플로우가 재시작되면, chargePayment는 다시 호출되지 않고 저장된 결과를 그대로 반환한다. 이미 결제가 된 걸 또 긁는 사고를 막아주는 구조다.

비유하자면 게임 세이브 포인트다. 보스 잡고 세이브 찍었으면, 다음 구간에서 죽어도 보스부터 다시 잡진 않는다. 워크플로우 상태와 각 스텝 결과가 전부 DB 트랜잭션 안에서 원자적으로 커밋되니까, "절반만 저장된" 어정쩡한 상태가 안 생긴다. 이게 Postgres의 ACID를 그대로 빌려쓰는 부분이다.

그리고 워크플로우 실행 코드 자체가 애플리케이션 프로세스 안에서 돌아간다는 점이 Temporal과 가장 다르다. Temporal은 워커-서버 구조로 별도 클러스터가 필요한데, DBOS는 라이브러리 임베드 방식이라 "내 앱 + Postgres" 조합이면 끝난다.

실무 관점: 도입 전에 따져볼 것들

1) 스텝은 멱등(idempotent)하게 짜야 한다. 이건 durable execution 도구를 뭘 쓰든 공통이다. DBOS가 완료된 스텝을 건너뛴다고 해도, "정확히 한 번 실행됐는지" 보장은 스텝이 커밋되기 직전에 죽는 경계 케이스에서 까다롭다. 외부 PG 호출 같은 건 idempotency key를 같이 넘겨서 PG 쪽에서도 중복 승인을 막아야 안전하다. 라이브러리만 믿고 멱등성 설계를 생략하면 언젠가 중복 결제로 사고 난다.

2) Postgres가 단일 장애 지점이자 병목이 된다. 모든 워크플로우 상태가 한 DB로 몰린다. 워크플로우 처리량이 높아지면 스텝마다 INSERT/UPDATE가 발생하니 DB write 부하가 커진다. 트래픽 큰 서비스라면 워크플로우 전용 DB를 분리하거나, 어차피 쓰는 Postgres라도 커넥션 풀과 vacuum 전략을 미리 생각해둬야 한다. "Postgres is all you need"는 규모가 적당할 때 맞는 말이지, 무한정 확장된다는 뜻은 아니다.

3) 스텝 시그니처를 함부로 못 바꾼다. 진행 중인 워크플로우가 DB에 남아있는데 코드에서 스텝 순서를 바꾸거나 추가하면, 재시작 시 저장된 상태와 코드가 안 맞아 깨질 수 있다. 이건 Temporal의 versioning 이슈와 같은 종류의 함정이다. 워크플로우 코드 변경은 배포 전략을 따로 신경 써야 한다(공식 문서에서 버전 관리 정책 확인 필요).

대안과 비교

  • Temporal: 기능과 생태계는 가장 성숙. 대신 별도 클러스터 운영 부담. 규모 크고 워크플로우가 복잡하면 여전히 강력한 선택.
  • Airflow: 배치/데이터 파이프라인용. 실시간 트랜잭션성 워크플로우엔 결이 안 맞는다.
  • Step Functions: AWS 락인. 매니지드라 편하지만 로컬 개발/디버깅이 불편하고 상태 전이마다 비용이 붙는다.
  • 직접 구현: outbox 패턴 + 상태 테이블로 비슷하게 만들 수 있다. DBOS는 이 패턴을 라이브러리로 추상화해준 거라고 보면 된다.

개인적으로는 이미 Postgres 박혀 있는 모놀리식~중소 규모 서비스에서 "결제/정산 같은 단계형 작업의 신뢰성"만 올리고 싶을 때 가성비가 가장 좋아 보인다. 운영 컴포넌트를 안 늘려도 되는 게 제일 큰 메리트다.

정리

한 줄 요약: 이미 쓰는 Postgres 트랜잭션을 세이브 포인트 삼아, 중간에 죽어도 이어서 실행되는 워크플로우를 별도 인프라 없이 만든다.

누가 언제 쓰면 좋냐면 — 결제/주문/정산처럼 "중간에 끊기면 곤란한" 다단계 작업이 있고, Temporal 클러스터까지 띄우기는 부담스러운 팀. 반대로 초당 수천 건 워크플로우가 도는 대규모거나, 이미 워크플로우 엔진 운영 노하우가 있다면 굳이 갈아탈 이유는 없다. 도입하더라도 스텝 멱등성 설계와 DB 부하 모니터링은 처음부터 깔고 가야 나중에 안 운다.

참고 자료

※ 라이브러리 API와 버전 관리 정책은 빠르게 바뀔 수 있으니 도입 전 공식 문서로 최신 내용 확인 필요.

사진: Surface / Unsplash

728x90

+ Recent posts