본문 바로가기
어플리케이션

백그라운드 잡 분열의 끝, Postgres 위 분산 큐 Hatchet

by forward error correction Circle 2026. 7. 3.
반응형

Ⅰ. Hatchet 기술이란?

왜 필요한가 — 우리는 이미 큐를 4개 운영 중이다

웬만한 SaaS 백엔드를 1년만 굴려보면 같은 풍경이 펼쳐집니다. 이메일 발송은 Python에서 Celery+Redis로, 이미지 리사이즈는 Node 쪽 BullMQ로, 결제 webhook 재시도는 SQS+Lambda로, 야간 배치는 cron 컨테이너로 각각 돌고 있습니다. 큐가 4개, 대시보드가 4개, 죽었을 때 재시도하는 방법이 4개, "지금 큐에 뭐 쌓였지?"라는 질문에 답하는 방법도 4개입니다. 거기에 워크플로우(여러 잡을 순서대로 묶고 실패 시 보상하기)가 필요해지면, Airflow/Step Functions/Temporal까지 다섯 번째 시스템이 들어옵니다.

기존 방식 손으로 하면 현실의 비용
Celery + Redis/RabbitMQ Python 전용, 결과 백엔드 분리, 재시도는 데코레이터 언어 락인, Redis OOM·visibility timeout 함정, "잃어버린 잡"이 남
BullMQ + Redis Node 전용, Redis Streams 기반 Python·Go 워커는 끼어들 수 없음, 멀티 언어 시 두 번째 큐 필연
SQS + Lambda 매니지드, 무한 확장 15분 실행 한도, 워크플로우는 Step Functions로 다시, 로컬 개발 어려움
Sidekiq Ruby 전용 사실상의 표준 Pro/Enterprise 라이선스, Ruby 외엔 사용 불가
Airflow / Step Functions DAG 워크플로우 1급 "한 잡 1초 처리"용으로는 과체중, 스케줄러 락 경합·UI 무거움
Temporal Durable execution 1급, 장기 실행 워크플로우 강함 Cassandra/MySQL 의존, 운영 복잡도 큼(615 Restate 글 참조)
cron 컨테이너 + DB 플래그 "가장 단순"하다는 환상 중복 실행, 워커 죽으면 잡 영구 정지, 가시성 0

모두 같은 본질을 다른 방식으로 풉니다. 그러나 운영자 입장에서 진짜 비용은 "백오피스 시스템이 5개"라는 점입니다. 잡이 안 돌면 어디부터 봐야 할지 슬랙에 던지는 질문이 매주 같은 모양으로 반복됩니다. Hatchet의 출발점은 단순합니다 — 이미 운영 중인 Postgres 한 대에 큐와 워크플로우를 같이 얹고, 워커는 어떤 언어로든 같은 잡을 받게 하자.

 

기술 정의

Hatchet은 PostgreSQL을 단일 소스로 사용하는 분산 백그라운드 태스크 큐 겸 워크플로우 엔진입니다(MIT 라이선스, hatchet-dev/hatchet, 미국 Hatchet Inc., 2024~). Go로 작성된 엔진(api·engine)이 잡 디스패치·스케줄·상태 머신을 담당하고, Python·TypeScript·Go SDK가 워커와 클라이언트를 제공합니다. 잡은 단일 함수일 수도, 여러 step의 DAG 워크플로우일 수도 있으며, 호출은 RPC 한 번·cron·외부 이벤트(JSON payload)·다른 워크플로우의 결과 등 4가지 방식으로 트리거됩니다. at-least-once 전달, 재시도·timeout·rate limit·concurrency limit·streaming 결과·durable execution이 1급 시민이며, 모든 잡 상태는 Postgres 테이블에 영속화되어 SQL로 직접 감사할 수 있습니다.

Ⅱ. Hatchet 기술 특징

특징 의미와 실전 효과
Postgres 단일 의존 Redis·Cassandra·Kafka 추가 없음. RDS·Supabase·셀프호스트 Postgres 어디서든 동작. 백업·복제·관측이 기존 DB 운영과 동일.
언어 중립 워커 Python·TypeScript·Go SDK가 같은 워크플로우를 스텝 단위로 나눠 실행. Step1=Python, Step2=Go도 자연스럽게 가능.
Push-based dispatch 워커가 polling하지 않고 gRPC long-stream으로 잡을 push 받음. 빈 큐일 때 DB 부하 0에 수렴.
Durable execution step 결과·체크포인트가 Postgres에 영속. 워커가 죽어도 다른 워커가 다음 step부터 이어 받음.
Rate limit / concurrency "이 사용자당 1초에 5건"·"이 워크플로우는 동시 10건"을 데코레이터로 선언. 토큰 버킷이 엔진에서 강제됨.
Cron + 이벤트 트리거 Crontab·event payload(user.created 등) 두 트리거가 1급. cron 컨테이너 별도 운영 불필요.
DAG 워크플로우 step 간 의존 관계 선언, 부모 결과를 자식 입력으로 자동 전달. fan-out/fan-in과 조건부 분기 모두 지원.
Streaming 결과 스텝이 부분 결과를 yield하면 클라이언트가 SSE/gRPC stream으로 실시간 수신. LLM 토큰 스트리밍·진행률 바에 적합.
웹 대시보드 실시간 워크플로우 그래프·재시도 횟수·실행 시간·로그·재실행 버튼이 한 화면. Celery flower의 상위호환.
OSS + 매니지드 코드는 MIT, Hatchet Cloud는 매니지드 옵션. 셀프호스트와 SaaS 사이 마이그레이션이 양방향(같은 SDK).

Ⅲ. Hatchet 기술 동작방식

ⅰ. 구성 요소

컴포넌트 위치 / 구현 역할
API server Go, REST + gRPC 잡 등록·조회, 토큰 발급, 워크플로우 정의 동기화
Engine Go, 다중 컨트롤러 스케줄러·디스패처·재시도·rate limit·heartbeat 모니터를 분리된 controller로 운영
PostgreSQL 14+, pg_partman 권장 단일 소스. SKIP LOCKED·advisory lock으로 큐 잠금
Worker Python/TS/Go SDK, 사용자 프로세스 엔진과 long-stream gRPC 유지. 잡을 받아 실행하고 결과·heartbeat 송신
Dashboard React + Vite 실시간 워크플로우 시각화, 재실행, 로그·이벤트 검색
RabbitMQ(선택) 단일 인스턴스 초고처리량 모드용 컨트롤러 fan-out 채널. 작은 운영에선 비활성화 가능
Prometheus exporter 엔진 내장 queue depth·step latency·worker count 지표를 /metrics로 노출

 

ⅱ. 데이터 흐름 — 잡 하나가 클라이언트 호출부터 결과까지

  1. 클라이언트가 SDK로 workflow.run(input) 호출 → API server가 workflow_runs·step_runs 행 INSERT.
  2. Engine 스케줄러가 SELECT ... FOR UPDATE SKIP LOCKED로 다음 실행 가능한 step을 집고, 적합한 워커를 매칭.
  3. 해당 워커의 long-stream gRPC로 step 페이로드를 push. 워커는 함수를 실행하고 진행 상황을 heartbeat로 보고.
  4. 워커가 결과/예외 반환 → engine이 UPDATE step_runs SET status='SUCCEEDED', 의존하는 다음 step을 enqueue.
  5. 실패 시 retry 정책 평가 → 백오프 지연 후 재 enqueue. 최대 횟수 초과 시 FAILED로 마킹.
  6. 모든 step 종료 시 워크플로우 결과 확정. 클라이언트는 .result() await 또는 stream subscription으로 수신.
  7. 대시보드는 Postgres NOTIFY 기반으로 변화를 구독해 실시간 그래프 갱신.

Ⅳ. Hatchet 기술 구성 및 흐름도

                  +-------------------+
                  |  Client (SDK/CLI) |
                  +---------+---------+
                            | gRPC
                            v
        +-------------------+--------------------+
        |          Hatchet API + Engine          |
        |                                        |
        |  +-----------+   +-------------------+ |
        |  | Scheduler |-->|    Dispatcher     | |
        |  |  (SKIP    |   | (long-stream gRPC)| |
        |  |  LOCKED)  |   +---------+---------+ |
        |  +-----+-----+             |           |
        |        |                   |           |
        |        v                   v           |
        |   PostgreSQL          Worker Pool      |
        |   (single source)     (Py / TS / Go)   |
        +--------+-----------------------+-------+
                 ^                       |
                 |  status / results     |
                 +-----------------------+

   +----------------+    +-----------------+    +--------------+
   | Cron Trigger   |    | Event Trigger   |    |  Dashboard   |
   | (engine inner) |    | (POST /events)  |    | (React, NTFY)|
   +----------------+    +-----------------+    +--------------+

단계별 설명

# 단계 설명
1 워크플로우 등록 워커 부팅 시 SDK가 워크플로우 정의(이름·step·재시도·rate limit)를 API에 동기화. Postgres에 spec 저장.
2 트리거 발생 RPC 호출 / cron 만기 / 이벤트 publish 중 하나로 workflow_runs 행이 PENDING으로 INSERT.
3 스케줄러 picking Engine이 FOR UPDATE SKIP LOCKED로 다음 실행 가능 step을 집음. rate limit·concurrency 위반이면 보류.
4 워커 디스패치 매칭된 워커의 gRPC stream으로 step 페이로드 push. 워커는 즉시 RUNNING으로 마킹.
5 Heartbeat 추적 워커가 N초마다 heartbeat 송신. 누락 시 step을 다른 워커로 재할당(at-least-once).
6 결과 영속화 반환값을 step_run_outputs에 JSONB로 저장. 의존 step의 입력으로 자동 전달.
7 완료 / 실패 처리 DAG 종료 시 결과 통지(NOTIFY). 실패는 retry 백오프 후 재 enqueue 또는 FAILED 확정.

Ⅴ. Hatchet 기술 설치 방법

ⅰ. Docker Compose (권장 셀프호스트)

# 공식 quickstart compose
git clone https://github.com/hatchet-dev/hatchet.git
cd hatchet/hack/dev
docker compose up -d

# 헬스체크
curl http://localhost:8080/api/ready

# 대시보드
open http://localhost:8080
# 초기 로그인: admin@example.com / Admin123!!

이 compose는 Postgres·RabbitMQ·API·engine·dashboard·migrate 컨테이너를 한 번에 띄웁니다. 개발/POC 용으로 충분하며, 운영에서는 Postgres만 외부로 분리하고 나머지를 Helm/K8s로 옮기는 패턴이 표준입니다.

 

ⅱ. Helm (운영 K8s)

helm repo add hatchet https://hatchet-dev.github.io/hatchet-charts
helm repo update

# values.yaml 핵심 항목
# postgres:
#   external:
#     uri: postgres://hatchet:***@db.internal:5432/hatchet
# rabbitmq:
#   enabled: true
# api:
#   replicas: 2
# engine:
#   replicas: 2

helm install hatchet hatchet/hatchet-stack -n hatchet --create-namespace -f values.yaml

 

ⅲ. SDK 설치 (워커/클라이언트)

# Python
pip install hatchet-sdk

# TypeScript
npm install @hatchet-dev/typescript-sdk

# Go
go get github.com/hatchet-dev/hatchet/pkg/client

# 공통 환경변수
export HATCHET_CLIENT_TOKEN="ey..."           # 대시보드 → API tokens 에서 발급
export HATCHET_CLIENT_TLS_STRATEGY=none       # 셀프호스트 평문일 때

Ⅵ. Hatchet 기술 사용 방법

ⅰ. 첫 워크플로우 — 이메일 발송 단일 스텝(Python)

from hatchet_sdk import Hatchet, Context

hatchet = Hatchet()

@hatchet.workflow(on_events=["user:signed_up"])
class SendWelcome:
    @hatchet.step(retries=3, timeout="30s")
    def send(self, ctx: Context) -> dict:
        user = ctx.workflow_input()["user"]
        # 실제 메일 발송...
        return {"sent_to": user["email"]}

worker = hatchet.worker("welcome-worker", max_runs=10)
worker.register_workflow(SendWelcome())
worker.start()

이벤트 트리거가 1급이므로, API 쪽에서는 hatchet.event.push("user:signed_up", {"user": {...}}) 한 줄이면 끝입니다. 워커가 죽었다 살아나도 push된 이벤트는 큐에 남아 다음 워커가 받습니다.

 

ⅱ. DAG 워크플로우 — 영수증 OCR → 분류 → 통지

@hatchet.workflow(on_events=["receipt:uploaded"])
class ProcessReceipt:
    @hatchet.step()
    def ocr(self, ctx: Context) -> dict:
        url = ctx.workflow_input()["url"]
        return {"text": run_ocr(url)}

    @hatchet.step(parents=["ocr"], retries=2)
    def classify(self, ctx: Context) -> dict:
        text = ctx.step_output("ocr")["text"]
        return {"category": classify_llm(text)}

    @hatchet.step(parents=["classify"])
    def notify(self, ctx: Context) -> dict:
        cat = ctx.step_output("classify")["category"]
        slack_post(f"#fin {cat} 영수증 한 건 도착")
        return {"posted": True}

parents=["ocr"] 한 줄로 의존 관계가 만들어집니다. 별도 DAG YAML이 없고, 부모 step의 결과는 ctx.step_output()로 전달됩니다. 중간 step 하나만 죽으면 그 step만 재시도되고 OCR은 다시 돌지 않습니다.

 

ⅲ. Rate limit / Concurrency / Cron

from hatchet_sdk import RateLimit, ConcurrencyLimitStrategy

@hatchet.workflow(
    on_crons=["0 * * * *"],                      # 매시 정각
    schedule_timeout="5m",
)
class HourlyReport:
    @hatchet.concurrency(
        max_runs=5,
        limit_strategy=ConcurrencyLimitStrategy.GROUP_ROUND_ROBIN,
        expression="input.tenant_id",            # 테넌트별 동시 5건
    )
    @hatchet.step(rate_limits=[RateLimit(static_key="openai", units=1)])
    def render(self, ctx: Context) -> dict:
        ...

 

ⅳ. 운영 시 고려사항

  • Postgres 사이즈: 잡 100만/일 기준 step_runs 테이블이 빠르게 커짐. pg_partman으로 일자 파티셔닝 + 30일 보존이 안전.
  • 커넥션 풀: API/engine/모든 워커가 동시에 Postgres에 붙음. PgBouncer transaction pooling 모드 권장. max_connections 200 이상.
  • 잡 페이로드 크기: workflow_input은 JSONB. 1MB 이상 바이너리는 S3에 두고 키만 전달.
  • 토큰 회수: 워커별로 다른 token을 발급하고, 폐기 시 대시보드에서 revoke. 토큰을 컨테이너 환경변수가 아닌 K8s Secret으로 주입.
  • 관측: /metrics를 Prometheus로 긁고 queue depth·dispatch latency·worker heartbeat miss를 알람. VictoriaMetrics(637) 조합이 가볍다.
  • 재시도 멱등성: at-least-once이므로 step 함수는 idempotent해야 함. 외부 호출은 idempotency-key를 명시.

Ⅶ. Hatchet 기술 자주 쓰는 명령어

ⅰ. CLI / 운영

명령 용도
docker compose up -d 스택 전체 기동(Postgres·API·engine·dashboard)
curl localhost:8080/api/ready API 헬스체크
curl localhost:8080/api/v1/metrics Prometheus 지표 노출
docker compose logs -f engine 엔진(스케줄러·디스패처) 로그 추적
docker exec -it postgres psql -U hatchet DB 직접 조회
SELECT status, count(*) FROM step_runs GROUP BY 1; 상태별 잡 카운트(가장 빠른 디버깅)
helm upgrade hatchet hatchet/hatchet-stack -f values.yaml K8s 운영 업그레이드

ⅱ. SDK (Python) — 자주 쓰는 호출

# 워크플로우 트리거
ref = hatchet.client.admin.run_workflow("ProcessReceipt", {"url": "s3://..."})
result = ref.sync_result()                    # 블로킹 대기

# 이벤트 푸시(매칭되는 워크플로우 모두 트리거)
hatchet.event.push("receipt:uploaded", {"url": "s3://..."})

# 진행 상황 스트리밍
async for chunk in ref.stream():
    print(chunk)

# 잡 재실행(대시보드의 replay 버튼 동등)
hatchet.client.admin.replay_workflow_run(ref.workflow_run_id)

# Cron 스케줄 추가/삭제(동적)
hatchet.client.admin.create_cron("HourlyReport", expression="*/15 * * * *")

사례 — 멀티 언어 SaaS의 단일 큐 통합

시나리오: API는 TypeScript(Node), 머신러닝은 Python, 결제 정산 배치는 Go로 짜인 30명 규모 SaaS. 현재 BullMQ + Celery + cron 컨테이너 + GitHub Actions 야간 잡이 따로 돌고 있음.
설계: RDS Postgres에 Hatchet 스택을 얹고, 세 언어 워커를 같은 워크플로우 정의에 등록. 결제 정산은 Go step, ML 추론은 Python step, 알림 발송은 TS step의 DAG 한 개로 묶음. cron은 Hatchet에 위임, GitHub Actions는 그대로 코드 빌드만 담당.
효과: 큐 4개 → 1개. "지금 안 돌고 있는 잡이 뭐냐"는 질문에 대시보드 한 화면으로 답이 나옴. 야간 알람 호출 빈도 60% 감소.

Ⅷ. Hatchet 기술 활용방안

ⅰ. 대안 기술 비교

대안 강점 Hatchet 대비 한계
Celery + Redis Python 생태계 사실상의 표준, 풍부한 플러그인 Python 락인, Redis OOM/visibility timeout 함정, DAG·rate limit 약함
BullMQ Node 생태계 표준, Redis Streams 기반의 단순함 Node 락인, 멀티 언어 워커 부재, 워크플로우 1급 아님
SQS + Lambda 매니지드, 무한 확장, 운영 부담 0 15분 한도, AWS 락인, Step Functions 별도, 로컬 개발 어려움
Temporal Durable execution 1급, 성숙한 워크플로우 모델, 다언어 SDK Cassandra/MySQL 의존, 운영 무거움, 단순 잡 큐로는 과체중
Inngest 이벤트 1급, 서버리스 친화, 좋은 DX 매니지드 중심, 셀프호스트 OSS는 더 어림, 가격 모델 종속
River (Go) Postgres 큐, Go 단일 바이너리, 매우 가벼움 Go 전용, 워크플로우/DAG·rate limit·UI는 자체 추가 필요
Airflow 데이터 파이프라인의 표준, 풍부한 operator 초당 잡·실시간 트리거에 약함, 스케줄러 락 경합
Restate (615) 임베디드 Durable Execution, 단일 바이너리 대시보드·rate limit·cron의 1급 시민화 정도가 다름. 사용 결이 다름.

 

ⅱ. 언제 쓰면 안 되는가

  • 잡 < 100건/일, 워커 1대: cron + DB 플래그 한 줄로 충분. Hatchet 스택 운영 비용이 잡 처리 비용보다 큼.
  • 밀리초급 fan-out + 결과 미사용: Kafka·NATS 같은 브로커가 본래 목적. Hatchet은 "결과를 추적"하는 잡 큐.
  • 대용량 스트림 처리(초당 수십만 이벤트, ETL): Flink·RisingWave(584)·AutoMQ(581)가 적합. Hatchet은 "함수 실행" 추상화.
  • 강한 결정론·재현성이 우선: Temporal/Restate의 정형화된 모델이 더 엄격. Hatchet은 실용 위주.
  • Postgres가 운영상 금지된 환경: MySQL/Mongo만 허용된 회사라면 Postgres 추가 운영 비용을 별도 검토.
  • 매우 긴 외부 동기 호출(>1시간): step 단위로 잘게 쪼개거나 외부 폴링으로 분리. 한 step이 너무 길면 워커 재시작 시 영향이 큼.

Ⅸ. 주의할 점 & 트러블슈팅

증상 원인 해결
잡이 PENDING에 멈춘 채 워커가 못 받음 워커가 register한 워크플로우 이름·버전이 트리거 측과 불일치 대시보드의 Workers 탭에서 register된 워크플로우 목록 확인. 동일 이름으로 재등록 후 워커 재시작.
같은 step이 두 번 실행됨 at-least-once 보장의 정상 케이스. 워커 SIGKILL 직후 heartbeat miss로 재할당 step 함수를 idempotent하게 작성. 외부 API는 Idempotency-Key 헤더로 중복 가드.
"too many connections" Postgres 에러 워커 수 × 풀 크기 + engine + API 합계가 max_connections 초과 PgBouncer를 transaction pooling으로 앞에 두기. 워커 SDK의 풀을 5~10으로 제한.
step_runs 테이블이 수십 GB로 커짐 완료 잡을 보존만 하고 정리 안 함 pg_partman로 일자 파티셔닝 + 30일 retention. 또는 야간 잡으로 SUCCEEDED 상태 30일 이전 행 삭제.
대시보드가 비어 보임(잡은 도는데) 대시보드는 tenant 컨텍스트 기반. 다른 tenant의 토큰으로 보고 있음 대시보드 우상단 tenant 셀렉터 확인. 환경변수 토큰과 같은 tenant인지 점검.
Rate limit이 걸려 잡이 무한 보류 정적 키(openai 등) 토큰 충전이 0이거나 새로 등록한 키와 일치하지 않음 대시보드 Rate Limits 탭에서 잔량 확인. put_rate_limit API로 충전량/주기 재설정.
Cron이 정시에 안 돔 엔진 컨테이너 시간대가 UTC인데 표현식을 KST 기준으로 작성 cron 표현식에 timezone 명시(CRON_TZ=Asia/Seoul). 컨테이너 TZ 환경변수도 점검.
버전 업그레이드 후 워커가 enrich 에러 SDK 버전 < 엔진 버전. proto 정의 호환성 깨짐 엔진과 모든 SDK를 동일 마이너 버전으로 맞춤. 점진적 롤아웃 시 임시로 옛 엔진 버전 유지.
한 잡이 워커 메모리를 누수 SDK max_runs 설정 없이 장기 워커가 누적 max_runs=N으로 워커당 동시 잡 한도. K8s에서 resources.limits.memory + OOMKill 후 자동 재시작.
Webhook 트리거 늦게 처리 이벤트 push가 동기 HTTP인데 대량 부하로 API server에 적체 API server replicas 증설, 또는 webhook 수신을 가벼운 게이트웨이로 받아 비동기 push로 위임.
로컬 개발에서 워커 연결 안 됨 HATCHET_CLIENT_TLS_STRATEGY 미설정 + 셀프호스트 평문 HATCHET_CLIENT_TLS_STRATEGY=none + 호스트가 localhost가 아니라 127.0.0.1인지 확인. WSL/Docker 네트워크 분리 점검.
반응형