전체 큐 시스템을 도입하지 않고 재시도, 락, 멱등성을 갖춘 예약 백그라운드 작업을 실행하는 Cron + 데이터베이스 패턴을 알아보세요.

대부분의 앱은 나중에 실행되거나 일정에 따라 실행되어야 하는 작업이 필요합니다: 후속 이메일 전송, 야간 청구 확인, 오래된 레코드 정리, 리포트 재생성 또는 캐시 갱신 등입니다.
초기에는 백그라운드 작업에 대해 "올바른" 방법처럼 느껴져서 전체 큐 시스템을 도입하고 싶어질 수 있습니다. 하지만 큐는 또 다른 서비스가 되어 운영, 모니터링, 배포, 디버깅의 부담을 추가합니다. 작은 팀(또는 단독 창업자)에게 그 무게는 발목을 잡을 수 있습니다.
그렇다면 실제 질문은 이겁니다: 더 많은 인프라를 세우지 않고도 어떻게 안정적으로 예약 작업을 실행할 수 있을까요?
흔한 첫 시도는 단순합니다: cron 항목을 추가해 엔드포인트를 호출하고 그 엔드포인트가 작업을 수행하게 합니다. 잘 동작하다가도, 더 이상 잘 동작하지 않을 때가 옵니다. 서버가 두 대 이상이 되거나 잘못된 시점에 배포가 일어나거나 작업이 예상보다 오래 걸리면 혼란스러운 실패가 발생합니다.
예약 작업은 보통 몇 가지 예측 가능한 방식으로 깨집니다:
Cron + 데이터베이스 패턴은 중간 경로입니다. 여전히 cron으로 "깨워" 주지만, 작업 의도와 상태를 데이터베이스에 저장해 시스템이 조정하고 재시도하며 무슨 일이 일어났는지 기록할 수 있게 합니다.
이미 하나의 데이터베이스(대개 PostgreSQL)를 사용 중이고, 작업 유형이 많지 않으며 최소한의 운영으로 예측 가능한 동작을 원할 때 적합합니다. React + Go + PostgreSQL 같은 현대적 스택으로 빠르게 구축한 앱에도 자연스러운 선택입니다.
높은 처리량, 진행 상황을 스트리밍해야 하는 장기 작업, 여러 작업 유형 간 엄격한 순서 보장, 또는 분산 작업 수천 건/분 같은 경우에는 전용 큐와 워커가 더 적합합니다.
Cron + 데이터베이스 패턴은 전체 큐 시스템 없이도 예약된 백그라운드 작업을 실행합니다. cron(또는 다른 스케줄러)을 여전히 사용하지만, cron이 무엇을 실행할지 결정하지는 않습니다. 단지 워커를 자주 깨웁니다(보통 1분마다). 데이터베이스가 어떤 작업이 예정되었는지 결정하고, 각 작업을 단 한 워커만 가져가도록 보장합니다.
화이트보드에 걸린 공유 체크리스트를 생각하세요. Cron은 매분 방에 들어와 "지금 할 일이 있나요?"라고 묻는 사람이고, 데이터베이스는 무슨 일이 예정되어 있고 누가 이미 가져갔고 무엇이 완료되었는지를 보여주는 화이트보드입니다.
구성 요소는 단순합니다:
예: 매일 아침 송장 알림을 보내고 싶고, 캐시를 10분마다 갱신하고, 밤마다 오래된 세션을 정리한다고 합시다. 세 개의 별도 cron 명령(각각 중복 및 실패 모드가 있음) 대신 하나의 장소에 작업 항목을 저장합니다. Cron은 동일한 워커 프로세스를 시작하고, 워커는 Postgres에 "지금 어떤 작업이 예정되어 있나요?"라고 묻습니다. Postgres는 워커에게 정확히 하나의 작업을 안전하게 클레임하도록 허용합니다.
이 방식은 점진적으로 확장됩니다. 한 서버에 한 워커로 시작할 수 있고, 나중에 여러 서버에서 다섯 개의 워커를 실행할 수 있습니다. 계약은 동일합니다: 테이블이 계약입니다.
사고 방식의 전환은 간단합니다: cron은 단지 깨우는 신호일 뿐이며 데이터베이스가 교통 정리 역할을 해 무엇이 실행될 수 있는지 결정하고, 무슨 일이 일어났는지 기록하며 문제가 생겼을 때 명확한 이력을 제공합니다.
이 패턴은 데이터베이스가 무엇을 실행해야 하는지, 언제 실행해야 하는지, 마지막에 무슨 일이 있었는지에 대한 진실의 소스가 될 때 가장 잘 작동합니다. 스키마 자체는 화려하지 않지만(락 필드와 올바른 인덱스 같은) 작은 세부가 로드가 증가할 때 큰 차이를 만듭니다.
두 가지 일반적 접근이 있습니다:
실패를 자주 디버깅할 것 같으면 히스토리를 보관하세요. 가장 작은 설정을 원하면 하나의 테이블로 시작한 뒤 나중에 히스토리를 추가하세요.
다음은 PostgreSQL 친화적 레이아웃입니다. Go로 PostgreSQL을 사용해 빌드한다면 이 컬럼들은 구조체로 깔끔하게 매핑됩니다.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
나중에 고생을 줄여주는 몇 가지 세부:
send_invoice_emails).jsonb로 저장해 마이그레이션 없이 진화시킬 수 있게 하세요.인덱스가 없으면 워커가 너무 많은 스캔을 하게 됩니다. 우선 다음을 추가하세요:
(status, run_at)(locked_until)queued 또는 failed인 경우)이 인덱스들은 테이블이 커져도 "다음 실행 가능한 작업 찾기" 쿼리를 빠르게 유지합니다.
목표는 간단합니다: 여러 워커가 실행될 수는 있지만, 특정 작업을 한 워커만 가져가야 합니다. 두 워커가 같은 행을 처리하면 이메일 중복 발송, 중복 과금, 혹은 데이터 혼란이 생깁니다.
안전한 접근법은 작업 클레임을 "리스(lease)"로 취급하는 것입니다. 워커는 작업을 짧은 시간 동안 락합니다. 워커가 크래시하면 리스가 만료되어 다른 워커가 가져갈 수 있습니다. 이것이 locked_until의 목적입니다.
리스가 없으면 워커가 작업을 락한 뒤 절대 언락하지 못할 수 있습니다(프로세스 종료, 서버 재부팅, 잘못된 배포 등). locked_until이 있으면 시간이 지나면 작업이 다시 사용 가능해집니다.
보통 규칙은 locked_until이 NULL이거나 locked_until <= now()일 때 작업을 클레임할 수 있다는 것입니다.
핵심은 작업을 한 문장(또는 하나의 트랜잭션)으로 클레임하는 것입니다. 데이터베이스를 심판으로 사용하고 싶기 때문입니다.
다음은 PostgreSQL 패턴입니다: 하나의 예정 작업을 골라 락하고 워커에게 반환합니다. (이 예는 단일 jobs 테이블을 사용합니다; job_runs에서도 동일한 아이디어가 적용됩니다.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
이 방식이 작동하는 이유:
FOR UPDATE SKIP LOCKED는 여러 워커가 경쟁하더라도 서로 블로킹하지 않습니다.RETURNING은 경합에서 이긴 워커에게 행을 건네줍니다.리스는 정상 실행 시간보다 길되, 크래시 시 빠르게 복구될 수 있도록 짧아야 합니다. 대부분의 작업이 10초 내에 끝나면 2분 리스면 충분합니다.
긴 작업은 작업 중 리스를 갱신(하트비트)해야 합니다. 단순한 방법: 30초마다 locked_until을 연장하세요(여전히 해당 작업을 소유하고 있다면).
WHERE id = $job_id AND locked_by = $worker_id 조건을 포함하세요마지막 조건은 중요합니다. 워커가 더 이상 소유하지 않은 작업의 리스를 연장하지 못하게 합니다.
재시도는 이 패턴이 차분하게 느껴질지 소란스러워질지를 결정합니다. 목표는 간단합니다: 작업이 실패하면 나중에 다시 시도하되, 설명 가능하고 측정 가능하며 중단할 수 있게 하세요.
우선 작업 상태를 명시적이고 유한하게 만드세요: queued, running, succeeded, failed, dead. 실무에서는 failed를 "실패했지만 재시도 예정"으로, dead를 "포기함"으로 쓰는 경우가 많습니다. 이 한 가지 구분이 무한 루프를 막습니다.
시도 횟수 카운팅도 두 번째 안전장치입니다. attempts(시도한 횟수)와 max_attempts(허용할 최대 시도 횟수)를 저장하세요. 워커가 에러를 잡으면 다음을 해야 합니다:
attempts를 증가시키기attempts < max_attempts이면 상태를 failed로, 그렇지 않으면 dead로 설정하기run_at에 설정(단, failed일 때만)백오프는 다음 run_at을 결정하는 규칙입니다. 하나를 고르고 문서화해 일관되게 유지하세요:
지터는 의존성이 내려갔다가 복구될 때 중요합니다. 지터가 없으면 수백 개의 작업이 같은 초에 재시도해 다시 실패할 수 있습니다.
실패를 디버깅할 수 있게 충분한 오류 정보를 저장하세요. 전체 로깅 시스템은 필요 없지만 기본은 필요합니다:
last_error (관리 화면에 안전하게 표시 가능한 짧은 메시지)error_code 또는 error_type (그룹핑에 도움)failed_at 및 next_run_atlast_stack (크기를 관리할 수 있다면)실무 규칙 예: 10회 시도 후 dead로 표시하고, 지터가 있는 지수적 백오프를 사용하세요. 이렇게 하면 일시적 실패는 재시도되고, 계속 깨지는 작업이 CPU를 낭비하지 않습니다.
멱등성은 작업을 두 번 실행해도 최종 결과가 같게 만드는 것입니다. 이 패턴에서는 같은 행이 크래시, 타임아웃, 재시도로 인해 다시 선택될 수 있기 때문에 중요합니다. 예를 들어 "송장 이메일 전송" 같은 작업은 두 번 실행하면 안 됩니다.
실용적으로는 각 작업을 (1) 일을 수행하고 (2) 영향을 적용하는 단계로 분리하세요. 영향(예: 이메일 전송)은 실제로 한 번만 발생해야 합니다.
멱등성 키는 작업을 나타내는 것에서 유래해야 하며, 워커 시도에서 생성된 키여서는 안 됩니다. 좋은 키는 안정적이고 설명하기 쉬운 값입니다. 예: invoice_id, user_id + day, report_name + report_date. 동일한 실세계 이벤트를 가리키는 두 시도는 동일한 키를 공유해야 합니다.
예: "2026-01-14의 일일 매출 리포트 생성"은 sales_report:2026-01-14 같은 키를 가질 수 있습니다. "송장 812 결제"는 invoice_charge:812 같은 키.
가장 단순한 안전장치는 PostgreSQL이 중복을 거부하게 하는 것입니다. 멱등성 키를 인덱스 가능한 곳에 저장하고 고유 제약을 추가하세요.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
디자인이 여러 행을 허용하는 경우(히스토리 보관)에는 고유 제약을 "effects" 테이블에 두세요. 예: sent_emails(idempotency_key)나 payments(idempotency_key).
보호해야 할 일반적인 부작용:
sent_emails 행을 만들고 고유 키를 사용하거나, 전송 후 제공자 메시지 id를 기록.delivered_webhooks(event_id)를 저장하고 이미 존재하면 건너뜀.(type, date)로 키된 file_generated 레코드를 기록.Postgres 기반 스택(예: Go + PostgreSQL)에서는 이러한 고유성 검사가 빠르고 데이터에 가깝게 유지되기 쉽습니다. 핵심 아이디어는 간단합니다: 재시도는 정상이고, 중복은 선택 사항입니다.
하나의 무난한 런타임을 고르고 고집하세요. Cron + 데이터베이스 패턴의 요점은 움직이는 부품을 줄이는 것이므로 PostgreSQL과 통신하는 작은 Go, Node, Python 프로세스 하나면 충분한 경우가 많습니다.
테이블과 인덱스 생성. jobs 테이블(및 나중에 쓸 조회 테이블)을 추가하고 run_at을 인덱싱하며 워커가 사용 가능한 작업을 빠르게 찾을 수 있게 (status, run_at) 같은 인덱스를 추가하세요.
작은 enqueue 함수 작성. 앱은 run_at을 now 또는 미래 시간으로 설정해 행을 삽입해야 합니다. 페이로드는 작고 예측 가능하게(큰 블롭이 아니라 ID와 작업 타입) 유지하세요.
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running으로 표시합니다.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
처리 및 최종화. 클레임한 각 작업에 대해 일을 수행한 뒤 finished_at과 함께 done으로 업데이트합니다. 실패하면 오류 메시지를 기록하고 백오프로 새 run_at을 설정해 queued로 되돌립니다. 최종화 업데이트는 작게 유지하고 프로세스 종료시에도 반드시 실행하세요.
설명 가능한 재시도 규칙 추가. run_at = now() + (attempts^2) * interval '10 seconds' 같은 단순한 공식을 사용하고 max_attempts를 초과하면 status = 'dead'로 멈추게 하세요.
첫날부터 완전한 대시보드를 만들 필요는 없지만 문제를 알아차릴 수 있을 정도의 가시성은 필요합니다.
Go + PostgreSQL 스택이면 단일 워커 바이너리와 cron으로 깔끔하게 매핑됩니다.
작고 현실적인 SaaS 앱을 상상해보세요. 두 가지 예약 작업이 있습니다:
단순히 한 PostgreSQL 테이블에 작업을 저장하고, cron으로 분 단위로 실행되는 워커가 예정된 작업을 클레임해 실행하고 성공/실패를 기록합니다.
작업은 몇 군데에서 enqueue할 수 있습니다:
cleanup_nightly 작업을 하나 enqueue.send_weekly_report 작업 enqueue.send_weekly_report 작업 enqueue.페이로드는 워커가 필요한 최소한의 정보만 담으세요. 재시도가 쉬워지도록 작게 유지합니다.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
워커는 최악의 순간에 크래시할 수 있습니다: 이메일을 보낸 직후에 "완료"를 표시하기 전에 크래시가 나면 재시작 후 같은 작업을 다시 픽할 수 있습니다.
중복 발송을 막으려면 자연스러운 중복 제거 키를 부여하고 데이터베이스가 강제하도록 하세요. 주간 리포트의 경우 좋은 키는 (user_id, week_start_date)입니다. 전송 전에 "보고서 X를 보내려 한다"는 기록을 남기고, 해당 기록이 이미 있으면 전송을 건너뜁니다.
이는 sent_reports 테이블에 (user_id, week_start_date)로 고유 제약을 두거나, 작업 자체에 고유한 idempotency_key를 두는 간단한 방식이 될 수 있습니다.
이메일 제공자가 타임아웃한다고 가정합시다. 작업이 실패하면 워커는:
attempts를 증가시키고만약 제한(예: 10회)을 지나면 dead로 표시하고 재시도를 멈춥니다. 작업은 단 한 번 성공하거나, 명확한 스케줄로 재시도되며 멱등성으로 재시도가 안전해집니다.
Cron + 데이터베이스 패턴은 단순하지만 작은 실수가 중복, 멈춘 작업, 또는 갑작스러운 부하로 이어질 수 있습니다. 대부분의 문제는 첫 크래시, 배포, 또는 트래픽 스파이크 이후에 드러납니다.
실제 사고 대부분은 몇 가지 함정에서 옵니다:
locked_until을 생략함. 워커가 클레임한 뒤 크래시하면 그 행이 영원히 "진행 중"으로 남을 수 있습니다. 리스 타임스탬프가 있으면 다른 워커가 안전하게 다시 가져갈 수 있습니다.user_id, invoice_id 또는 파일 키 같은 참조를 저장하고 실행 시 나머지를 가져오세요.예: 주간 송장 이메일을 보낼 때 워커가 이메일을 보낸 직후에 타임아웃되어 작업을 "완료"로 표시하지 못하면 동일한 작업이 재시도되어 중복 이메일이 전송될 수 있습니다. 이 패턴에서는 멱등성 같은 안전장치가 없다면 중복은 정상적일 수 있습니다(예: invoice id로 고유 이벤트 기록).
스케줄링과 실행을 같은 장기 트랜잭션에서 섞지 마세요. 네트워크 호출을 하면서 트랜잭션을 열어두면 락을 불필요하게 오래 유지해 다른 워커를 막습니다.
머신 간 시계 차이를 주의하세요. run_at과 locked_until의 진실은 데이터베이스 시간(NOW() in PostgreSQL)으로 삼고 앱 서버의 시계를 사용하지 마세요.
최대 실행 시간을 명확히 설정하세요. 작업이 30분 걸릴 수 있다면 리스를 그보다 길게 설정하고 필요하면 갱신하세요. 그렇지 않으면 다른 워커가 작업을 중간에 가져가 버릴 수 있습니다.
작업 테이블을 건강하게 유지하세요. 완료된 작업이 영원히 쌓이면 쿼리가 느려지고 락 경쟁이 증가합니다. 테이블이 커지기 전에 보관하거나 삭제하는 단순한 보존 규칙을 정하세요.
이 패턴을 배포하기 전에 기본을 확인하세요. 여기서의 작은 누락이 보통 작업 정지, 놀라운 중복 또는 DB에 과도한 부하를 만듭니다.
run_at, status, attempts, locked_until, max_attempts(그리고 last_error 같은 가시성 필드).invoice_id당 하나의 송장).max_attempts 이후 중단되게 하세요.이 항목들이 충족되면 Cron + 데이터베이스 패턴은 실제 워크로드에 대해 보통 충분히 안정적입니다.
체크리스트가 만족스러우면 일상 운영에 집중하세요.
run_at = now()로 설정하고 락 해제)와 "취소"(종료 상태로 이동). 이는 사고 시 시간을 절약합니다.status, run_at)를 추가하세요.이런 설정을 빠르게 만들고 싶다면 Koder.ai (koder.ai)가 스키마에서 배포된 Go + PostgreSQL 앱까지 수동 배선 없이 빠르게 도와줄 수 있습니다. 락, 재시도, 멱등성 규칙에 집중하세요.
나중에 이 설정을 초과하게 되더라도 작업 라이프사이클에 대해 명확하게 학습했을 것이고, 같은 아이디어를 전체 큐 시스템으로 옮길 수 있습니다.