이메일 전송, 리포트 실행, 웹후크 전달을 재시도·백오프·데드레터 처리로 안정적으로 수행하는 간단한 데이터베이스 기반 백그라운드 작업 큐 패턴을 배우세요.

1~2초 이상 걸릴 수 있는 작업은 사용자 요청 안에서 처리하면 안 됩니다. 이메일 전송, 리포트 생성, 웹후크 전달은 네트워크, 서드파티 서비스, 느린 쿼리에 의존합니다. 때로는 일시 중단되거나 실패하거나 예상보다 오래 걸립니다.
사용자가 기다리는 동안 그런 작업을 하면 차이가 바로 드러납니다. 페이지가 멈추고, "저장" 버튼이 계속 돌며, 요청이 타임아웃납니다. 재시트가 잘못된 곳에서 발생할 수도 있습니다. 사용자가 새로고침하거나 로드밸런서가 재시도하거나 프론트엔드가 다시 제출하면 중복 이메일, 중복 웹후크 호출, 또는 서로 경쟁하는 두 건의 리포트 실행이 생길 수 있습니다.
백그라운드 작업은 요청을 작고 예측 가능하게 유지해 이 문제를 해결합니다: 작업을 수락하고 나중에 실행할 작업을 기록한 뒤 빠르게 응답합니다. 작업은 요청 밖에서, 여러분이 제어하는 규칙으로 실행됩니다.
문제는 신뢰성입니다. 작업이 요청 경로 밖으로 이동하면 다음과 같은 질문에 답해야 합니다:
많은 팀이 "무거운 인프라"를 추가해 대응합니다: 메시지 브로커, 별도 워커 플릿, 대시보드, 알림, 플레이북 등. 이런 도구는 진짜로 필요할 때 유용하지만 새로운 구성요소와 실패 모드를 추가합니다.
더 나은 시작 목표는 단순함입니다: 이미 가진 부품으로 신뢰성 있는 작업을 만드는 것. 대부분의 제품에서는 데이터베이스 기반 큐와 작은 워커 프로세스면 충분합니다. 명확한 재시도와 백오프 전략, 지속적으로 실패하는 작업을 위한 데드레터 패턴을 추가하면 복잡한 플랫폼으로 즉시 커밋하지 않고도 예측 가능한 동작을 얻을 수 있습니다.
Koder.ai 같은 채팅 기반 도구로 빠르게 개발하더라도 이 분리는 중요합니다. 사용자는 지금 빠른 응답을 받아야 하고, 시스템은 느리고 실패하기 쉬운 작업을 백그라운드에서 안전하게 마무리해야 합니다.
큐는 작업을 기다리는 줄입니다. 사용자 요청 동안 느리거나 신뢰할 수 없는 작업(이메일 전송, 리포트 생성, 웹후크 호출)을 수행하는 대신 작은 레코드를 큐에 넣고 빠르게 반환하세요. 이후 별도 프로세스가 그 레코드를 가져와 작업을 수행합니다.
자주 나오는 용어 몇 가지:
가장 단순한 흐름은 다음과 같습니다:
작업량이 적당하고 이미 데이터베이스를 가지고 있다면 데이터베이스 기반 큐가 충분한 경우가 많습니다. 이해하기 쉽고 디버그하기 쉬우며 이메일 작업 처리나 웹후크 전달 신뢰성 같은 일반적인 요구를 충족합니다.
스트리밍 플랫폼은 매우 높은 처리량, 많은 독립 소비자, 또는 여러 시스템에 걸친 큰 이벤트 이력을 재생할 필요가 있을 때 의미가 있습니다. 수십 개 서비스에서 시간당 수백만 이벤트를 처리한다면 Kafka 같은 도구가 도움이 됩니다. 그 전까지는 데이터베이스 테이블과 워커 루프가 많은 실제 큐 요구를 커버합니다.
데이터베이스 큐는 각 작업 레코드가 세 가지 질문에 빠르게 답할 수 있을 때만 건전하게 유지됩니다: 무엇을 할지, 다음에 언제 시도할지, 지난번에 무슨 일이 있었는지. 이 부분을 잘하면 운영은 지루해집니다(목표가 바로 그겁니다).
작업을 수행하는 데 필요한 가장 작은 입력만 저장하세요. 전체 렌더된 출력은 저장하지 마세요. 좋은 페이로드는 ID와 몇 가지 파라미터입니다, 예: { "user_id": 42, "template": "welcome" }.
큰 블롭(완성된 HTML 이메일, 큰 리포트 데이터, 거대한 웹후크 본문)을 저장하면 데이터베이스가 빨리 커지고 디버깅이 더 어려워집니다. 작업이 큰 문서를 필요로 하면 대신 참조를 저장하세요: report_id, export_id, 또는 파일 키. 워커가 실행될 때 전체 데이터를 가져오면 됩니다.
최소한 다음을 마련하세요:
job_type은 핸들러(send_email, generate_report, deliver_webhook)를 선택합니다. payload는 ID와 옵션 같은 작은 입력을 담습니다.queued, running, succeeded, failed, dead).attempt_count와 max_attempts로 더 이상 재시도하지 않을 시점을 정합니다.created_at과 next_run_at(언제 실행 자격이 생기는지). started_at과 finished_at을 추가하면 느린 작업 가시성이 좋아집니다.idempotency_key와 실패 이유를 바로 볼 수 있게 하는 last_error.아이덴포텐시는 겉보기에 복잡해 보일 수 있지만 아이디어는 단순합니다: 같은 작업이 두 번 실행되면 두 번째 실행은 위험한 동작을 하지 않도록 해야 합니다. 예를 들어 웹후크 전달 작업은 webhook:order:123:event:paid 같은 아이덴포텐시 키를 사용해 재시도나 타임아웃이 겹쳐도 동일한 이벤트를 두 번 전달하지 않게 할 수 있습니다.
또한 몇 가지 기본 숫자를 초기에 캡처하세요. 큰 대시보드가 필요하지 않습니다. 단지 다음을 알려주는 쿼리면 됩니다: 큐에 몇 개의 작업이 있는지, 몇 개가 실패 중인지, 가장 오래된 큐 작업의 연령은 얼마인지.
이미 데이터베이스가 있다면 새로운 인프라를 추가하지 않고 백그라운드 큐를 시작할 수 있습니다. 작업은 행이고 워커는 만료된 행을 계속 가져와 처리하는 프로세스입니다.
테이블은 작고 단순하게 유지하세요. 나중에 작업을 실행하고 재시도하며 디버그할 수 있을 정도의 필드는 필요합니다.
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
job_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'queued', -- queued, running, done, failed
attempts int NOT NULL DEFAULT 0,
next_run_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
locked_by text,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX jobs_due_idx ON jobs (status, next_run_at);
Postgres(Go 백엔드에서 흔함)를 사용한다면 jsonb는 { "user_id":123,"template":"welcome" } 같은 작업 데이터를 저장하는 현실적인 방법입니다.
사용자 액션이 작업을 트리거해야 한다면(이메일 전송, 웹후크 발송), 가능하면 메인 변경과 같은 데이터베이스 트랜잭션에서 작업 행을 작성하세요. 그러면 주요 쓰기 직후 크래시가 나서 "사용자 생성은 되었지만 작업이 없다"는 상황을 막을 수 있습니다.
예: 사용자가 가입할 때 사용자 행과 send_welcome_email 작업을 한 트랜잭션에서 삽입하세요.
워커는 동일한 사이클을 반복합니다: 만료된 한 작업을 찾아 원자적으로 클레임하고 처리한 뒤 완료 표시하거나 재시도를 예약합니다.
실무에서는 다음과 같습니다:
status='queued'이고 next_run_at <= now()인 작업을 한 건 선택SELECT ... FOR UPDATE SKIP LOCKED)status='running', locked_at=now(), locked_by='worker-1'로 설정done/succeeded)로 표시하거나 last_error를 기록하고 다음 시도를 예약여러 워커가 동시에 실행될 수 있습니다. 클레임 단계가 중복 선택을 막아줍니다.
종료할 때는 새 작업 수락을 중단하고 현재 작업을 마친 뒤 종료하세요. 프로세스가 작업 중 죽으면 간단한 규칙을 사용하세요: 일정 시간 이상 running 상태로 묶여 있는 작업은 주기적 "리퍼" 작업이 다시 큐에 넣도록 처리합니다.
Koder.ai로 개발한다면 이메일, 리포트, 웹후크 용도의 이 데이터베이스-큐 패턴은 특화된 큐 서비스를 추가하기 전의 안정적인 기본값입니다.
재시도는 현실 세계가 지저분할 때 큐가 차분하게 유지되는 방법입니다. 명확한 규칙이 없으면 재시도는 소음을 만들고 사용자에게 스팸을 보내며 API를 두들기고 실제 버그를 숨깁니다.
무엇을 재시도하고 무엇을 빠르게 실패 처리할지 결정하는 것부터 시작하세요.
일시적 문제를 재시도하세요: 네트워크 타임아웃, 502/503, 속도 제한, 또는 짧은 데이터베이스 연결 장애.
작업이 성공할 수 없는 경우에는 빠르게 실패 처리하세요: 이메일 주소 누락, 페이로드가 잘못되어 400을 반환한 웹후크, 삭제된 계정을 위한 리포트 요청 등.
백오프는 시도 사이의 대기입니다. 선형 백오프(5초, 10초, 15초)는 단순하지만 트래픽 파동을 만들 수 있습니다. 지수적 백오프(5초, 10초, 20초, 40초)는 부하를 더 잘 분산시키며 웹후크와 서드파티 공급자에 보통 더 안전합니다. 지터(작은 무작위 추가 지연)를 더해 수천 개 작업이 동시에 같은 초에 재시도하지 않도록 하세요.
운영에서 잘 동작하는 규칙들:
최대 시도 횟수는 피해를 제한하는 장치입니다. 많은 팀에서는 5~8회의 시도가 충분합니다. 그 이후에는 재시도를 중단하고 검토를 위한 데드레터 플로우로 옮기세요.
타임아웃은 "좀비" 작업을 막습니다. 이메일은 시도당 1020초, 웹후크는 수신자가 다운일 가능성이 크므로 510초 정도의 짧은 제한을 두는 것이 일반적입니다. 리포트 생성은 몇 분을 허용할 수 있지만 여전히 강제 종료 시간은 두어야 합니다.
Koder.ai에서 이 기능을 구현한다면 should_retry, next_run_at, 그리고 아이덴포텐시 키를 1등 시민처럼 다루세요. 이런 작은 디테일이 문제가 생겼을 때 시스템을 조용하게 유지합니다.
데드레터 상태는 재시도가 더 이상 안전하거나 유용하지 않을 때 작업이 가는 곳입니다. 침묵하는 실패를 검색하고 조치할 수 있는 항목으로 바꿉니다.
무슨 일이 있었는지 이해하고 재실행할 수 있을 만큼 충분히 저장하되, 비밀 정보는 주의하세요.
저장할 것:
페이로드에 토큰이나 개인 데이터가 포함되어 있으면 저장 전에 마스킹하거나 암호화하세요.
작업이 데드레터에 도달하면 빠르게 결정하세요: 재시도, 수정, 무시.
수동 재큐는 새로운 작업을 생성하고 옛 작업은 불변으로 유지하는 것이 가장 안전합니다. 데드레터 작업에 누가 재큐했는지, 언제, 왜 했는지를 기록하고 새 ID로 새 작업을 넣으세요.
알림용으로는 보통 진짜 문제를 의미하는 신호를 관찰하세요: 데드레터 건수가 빠르게 증가하거나 동일 오류가 여러 작업에서 반복되거나 오래된 큐 작업이 클레임되지 않는 경우 등.
Koder.ai를 사용하면 실패가 급증하는 나쁜 릴리스를 조사할 때 스냅샷과 롤백이 도움이 됩니다. 빠르게 되돌릴 수 있기 때문입니다.
마지막으로 공급자 다운을 대비한 안전장치를 더하세요. 공급자별 전송 속도 제한을 두고 회로 차단기(circuit breaker)를 사용하세요: 웹후크 엔드포인트가 심하게 실패하면 짧은 기간 동안 새 시도를 중단해 그들의 서버(그리고 여러분의 서버)를 과부하시키지 마세요.
각 작업 타입에 대해 무엇을 성공으로 볼지, 무엇을 재시도할지, 절대 두 번 일어나면 안 되는 것은 무엇인지 명확한 규칙을 두면 큐가 가장 잘 동작합니다.
이메일. 대부분의 이메일 실패는 일시적입니다: 제공자 타임아웃, 속도 제한, 짧은 장애 등. 이런 경우 재시도하고 백오프를 적용하세요. 더 큰 위험은 중복 전송이므로 이메일 작업은 아이덴포텐트하게 만드세요. user_id + template + event_id 같은 안정적 중복 키를 저장하고 이미 전송된 경우 전송을 거부하세요.
템플릿 이름과 버전(또는 렌더된 제목/본문의 해시)을 저장해두면 작업을 다시 실행할 때 동일한 내용을 다시 보낼지, 최신 템플릿으로 재생성할지 선택할 수 있습니다. 제공자가 메시지 ID를 반환하면 지원 추적을 위해 저장하세요.
리포트. 리포트 실패 양상은 다릅니다. 몇 분 걸릴 수 있고 페이지네이션 한계에 걸리거나 한 번에 모두 처리하면 메모리가 부족해질 수 있습니다. 작업을 작은 조각으로 분할하세요. 일반적인 패턴은 하나의 "리포트 요청" 작업이 여러 개의 "페이지"(또는 "청크") 작업을 생성해 각 데이터 조각을 처리하는 것입니다.
사용자를 기다리게 하지 말고 나중에 다운로드할 결과를 저장하세요. 이는 report_run_id로 키된 데이터베이스 테이블이 될 수도 있고 파일 참조와 메타데이터(상태, 행 수, 생성 시간)가 될 수도 있습니다. UI가 "처리 중"과 "준비됨"을 표시할 수 있도록 진행 필드를 추가하세요.
웹후크. 웹후크는 속도보다 전달 신뢰성이 중요합니다. 모든 요청에 서명(HMAC 등)하고 재재생을 막기 위해 타임스탬프를 포함하세요. 수신자가 나중에 성공할 가능성이 있을 때만 재시도하세요.
간단한 규칙 집합:
정렬(ordering) 및 우선순위. 대부분의 작업은 엄격한 정렬이 필요하지 않습니다. 순서가 중요할 때는 보통 키별(사용자별, 송장별, 웹후크 엔드포인트별)로 중요합니다. group_key를 추가하고 키당 한 건의 인플라이트 작업만 실행하도록 하세요.
우선순위가 필요하면 긴급 작업과 느린 작업을 분리하세요. 큰 리포트 대기열이 비밀번호 재설정 이메일을 지연시키면 안 됩니다.
예: 구매 후 (1) 주문 확인 이메일, (2) 파트너 웹후크, (3) 리포트 업데이트 작업을 큐에 넣습니다. 이메일은 빠르게 재시도하고, 웹후크는 더 긴 백오프와 재시도 정책을 적용하며, 리포트는 낮은 우선순위로 나중에 실행합니다.
사용자가 앱에 가입합니다. 세 가지 일이 발생해야 하지만 어느 것도 가입 페이지를 느리게 해서는 안 됩니다: 환영 이메일 전송, CRM에 웹후크로 알림 전송, 사용자를 야간 활동 리포트에 포함시키기.
사용자 레코드를 만든 직후 데이터베이스 큐에 세 개의 작업 행을 작성하세요. 각 행은 타입, 페이로드(예: user_id), 상태, 시도 횟수, next_run_at 타임스탬프를 가집니다.
전형적인 라이프사이클은 다음과 같습니다:
queued: 생성되어 워커를 기다리는 상태running: 워커가 클레임한 상태succeeded: 완료되어 더 이상 작업 없음failed: 실패해 나중에 재시도되거나 재시도 한도가 있음dead: 너무 여러 번 실패해 사람이 봐야 하는 상태환영 이메일 작업에는 welcome_email:user:123 같은 아이덴포텐시 키가 포함됩니다. 전송 전에 워커는 완료된 아이덴포텐시 키 테이블을 확인하거나 고유 제약을 통해 중복을 막습니다. 작업이 두 번 실행되면 두 번째 실행은 키를 보고 전송을 건너뜁니다. 이로써 중복 환영 이메일을 막을 수 있습니다.
CRM 웹후크 엔드포인트가 다운되었습니다. 웹후크 작업이 타임아웃으로 실패합니다. 워커는 백오프(예: 1분, 5분, 30분, 2시간)와 소량의 지터를 더한 재시도를 예약합니다.
최대 시도를 초과하면 작업은 dead가 됩니다. 사용자는 여전히 가입되었고 환영 이메일을 받았고 야간 리포트 작업은 정상적으로 실행될 수 있습니다. 문제가 있는 것은 CRM 알림 하나뿐이며 이는 가시화됩니다.
다음 날 아침 지원 담당자가 오래된 로그를 뒤지지 않고 처리할 수 있습니다:
webhook.crm 등)으로 필터링Koder.ai 기반 앱이라면 같은 패턴을 적용하세요: 사용자 흐름은 빠르게 유지하고 부수 효과는 작업으로 밀어 넣으며 실패를 검사하고 다시 실행하기 쉽게 만드세요.
큐를 망치는 가장 빠른 방법은 큐를 선택사항으로 여기는 것입니다. 팀들은 종종 "이번 한 번은 요청에서 이메일을 보내자"며 시작합니다. 그러다 비밀번호 재설정, 영수증, 웹후크, 리포트 내보내기가 번지고 앱이 느려지며 타임아웃이 늘어나고 서드파티의 작은 문제 하나가 여러분의 장애가 됩니다.
또 다른 흔한 함정은 아이덴포텐시를 생략하는 것입니다. 작업이 두 번 실행될 수 있다면 두 번째 실행이 두 개의 결과를 만들면 안 됩니다. 아이덴포텐시가 없으면 재시도는 중복 이메일, 반복된 웹후크 이벤트, 더 나쁜 결과를 만듭니다.
세 번째 문제는 가시성 부족입니다. 오직 지원 티켓으로만 실패를 알게 된다면 큐는 이미 사용자를 해치고 있는 상태입니다. 상태별 작업 수와 검색 가능한 last_error를 보여주는 기본 내부 뷰만 있어도 많은 시간을 절약할 수 있습니다.
간단한 큐에서도 빨리 보이는 몇 가지 문제:
백오프는 자체적으로 만든 장애를 막습니다. 1분, 5분, 30분, 2시간 같은 단순한 스케줄도 실패를 더 안전하게 만듭니다. 또한 최대 시도 제한을 설정해 깨진 작업이 멈추고 가시화되도록 하세요.
Koder.ai에서 개발한다면 이런 기본을 기능과 함께 바로 배포하는 것이 도움이 됩니다. 나중에 정리하는 것이 아니라 처음부터 적용하세요.
더 많은 도구를 추가하기 전에 기본이 튼튼한지 확인하세요. 각 작업을 안전하게 클레임하고 재시도하고 검사할 수 있다면 데이터베이스 기반 큐는 잘 작동합니다.
빠른 신뢰성 체크리스트:
다음으로 처음 세 가지 작업 타입을 정하고 규칙을 적어보세요. 예: 비밀번호 재설정 이메일(빠른 재시도, 짧은 최대 시도), 야간 리포트(낮은 재시도, 더 긴 타임아웃), 웹후크 전달(더 많은 재시도, 긴 백오프, 영구적 4xx일 때 중단).
데이터베이스 큐가 충분하지 않을 때를 판단하기 어렵다면 다음 신호를 보세요: 많은 워커의 행 수준 컨텐션, 여러 작업 타입에 걸친 엄격한 순서 요구, 하나의 이벤트가 수천 개 작업으로 팬아웃되는 경우, 또는 다른 팀이 소유한 여러 서비스가 소비하는 교차 서비스 소비.
빠른 프로토타입을 원하면 Koder.ai의 플래닝 모드에서 흐름을 스케치하고, jobs 테이블과 워커 루프를 생성하고 스냅샷과 롤백으로 배포 전 반복하세요. (koder.ai)
작업이 1~2초 이상 걸리거나 네트워크 호출(이메일 제공자, 웹후크 엔드포인트, 느린 쿼리)에 의존한다면 백그라운드 작업으로 옮기세요.
사용자 요청은 입력 검증, 주요 데이터 변경 기록, 작업 큐잉, 빠른 응답 반환에 집중하게 하세요.
다음과 같은 경우에는 데이터베이스 기반 큐로 시작하세요:
많은 처리량, 다수의 독립 소비자, 또는 여러 시스템에 걸친 이벤트 재생이 필요해지면 브로커/스트리밍 도구를 추가하세요.
다음 질문에 답할 최소한의 필드를 추적하세요: 무엇을 할지, 언제 다음 시도인지, 지난번에 무슨 일이 있었는지.
실용적 최소 구성:
작업에는 입력만 저장하고 큰 출력은 저장하지 마세요.
좋은 페이로드 예:
user_id, template, report_id)피해야 할 것:
핵심은 원자적(claim)한 클레임 단계로 두 워커가 같은 작업을 가져가지 못하게 하는 것입니다.
Postgres에서 흔한 접근법:
FOR UPDATE SKIP LOCKED)running으로 바꾸고 locked_at/locked_by를 설정작업은 언제든 두 번 실행될 수 있다고 가정하고 부작용을 안전하게 만드세요.
간단한 패턴:
welcome_email:user:123 같은 idempotency_key를 추가이 방법은 이메일과 웹후크의 중복 전송을 막는 데 특히 중요합니다.
명확하고 지루한 기본 정책을 사용하세요:
영구 오류(예: 없는 이메일 주소, 잘못된 페이로드, 대부분의 4xx 웹후크 응답)는 바로 실패 처리하세요.
데드레터는 “더 이상 재시도하지 말고 가시화하라”는 의미입니다. 다음 경우 데드레터를 사용하세요:
max_attempts를 초과했을 때행동을 취할 수 있도록 다음을 저장하세요:
“실행 중(running)”으로 남은 작업은 다음 두 규칙으로 처리하세요:
running 작업을 찾아 재큐하거나 실패로 표시이렇게 하면 워커 크래시에서 시스템이 수동 정리 없이 복구할 수 있습니다.
느린 작업이 중요한 작업을 막지 않도록 분리하세요:
순서(ordering)가 중요하면 보통 키별(사용자별, 웹후크 엔드포인트별)로 중요합니다. group_key를 추가하고 키당 하나의 인플라이트 작업만 허용해 지역적 순서를 보장하세요.
job_type, payloadstatus (queued, running, succeeded, failed, dead)attempts, max_attemptsnext_run_at, 그리고 created_atlocked_at, locked_bylast_erroridempotency_key (또는 다른 중복 방지 메커니즘)작업에 큰 데이터가 필요하면 report_run_id나 파일 키 같은 참조를 저장하고, 워커가 실행될 때 실제 콘텐츠를 가져오세요.
이렇게 하면 워커를 수평으로 확장해도 동일한 행을 중복 처리하지 않습니다.
last_error와 웹후크의 마지막 상태 코드재실행 시에는 기존 데드레터 항목을 불변으로 두고 새 작업을 생성하는 것이 안전합니다.