KoderKoder.ai
가격엔터프라이즈교육투자자용
로그인시작하기

제품

가격엔터프라이즈투자자용

리소스

문의하기지원교육블로그

법적 고지

개인정보 처리방침이용 약관보안허용 사용 정책악용 신고

소셜

LinkedInTwitter
Koder.ai
언어

© 2026 Koder.ai. All rights reserved.

홈›블로그›Cron + 데이터베이스 패턴: 큐 없이 예약 작업 실행
2025년 10월 23일·6분

Cron + 데이터베이스 패턴: 큐 없이 예약 작업 실행

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

Cron + 데이터베이스 패턴: 큐 없이 예약 작업 실행

문제: 추가 인프라 없이 예약 작업 처리

대부분의 앱은 나중에 실행되거나 일정에 따라 실행되어야 하는 작업이 필요합니다: 후속 이메일 전송, 야간 청구 확인, 오래된 레코드 정리, 리포트 재생성 또는 캐시 갱신 등입니다.

초기에는 백그라운드 작업에 대해 "올바른" 방법처럼 느껴져서 전체 큐 시스템을 도입하고 싶어질 수 있습니다. 하지만 큐는 또 다른 서비스가 되어 운영, 모니터링, 배포, 디버깅의 부담을 추가합니다. 작은 팀(또는 단독 창업자)에게 그 무게는 발목을 잡을 수 있습니다.

그렇다면 실제 질문은 이겁니다: 더 많은 인프라를 세우지 않고도 어떻게 안정적으로 예약 작업을 실행할 수 있을까요?

흔한 첫 시도는 단순합니다: cron 항목을 추가해 엔드포인트를 호출하고 그 엔드포인트가 작업을 수행하게 합니다. 잘 동작하다가도, 더 이상 잘 동작하지 않을 때가 옵니다. 서버가 두 대 이상이 되거나 잘못된 시점에 배포가 일어나거나 작업이 예상보다 오래 걸리면 혼란스러운 실패가 발생합니다.

예약 작업은 보통 몇 가지 예측 가능한 방식으로 깨집니다:

  • 중복 실행: 두 대의 서버가 같은 작업을 실행해 송장이나 이메일이 두 번 생성됨.
  • 누락된 실행: 배포 중 cron 호출이 실패하고 아무도 눈치채지 못함.
  • 묵시적 실패: 작업이 한 번 에러로 실패한 뒤 재시도 계획이 없어 다시 실행되지 않음.
  • 부분 완료: 작업이 중간에 크래시되어 데이터가 이상한 상태로 남음.
  • 감사 기록 부재: "마지막으로 언제 실행되었나?"나 "어젯밤에 무슨 일이 있었나?"를 답할 수 없음.

Cron + 데이터베이스 패턴은 중간 경로입니다. 여전히 cron으로 "깨워" 주지만, 작업 의도와 상태를 데이터베이스에 저장해 시스템이 조정하고 재시도하며 무슨 일이 일어났는지 기록할 수 있게 합니다.

이미 하나의 데이터베이스(대개 PostgreSQL)를 사용 중이고, 작업 유형이 많지 않으며 최소한의 운영으로 예측 가능한 동작을 원할 때 적합합니다. React + Go + PostgreSQL 같은 현대적 스택으로 빠르게 구축한 앱에도 자연스러운 선택입니다.

높은 처리량, 진행 상황을 스트리밍해야 하는 장기 작업, 여러 작업 유형 간 엄격한 순서 보장, 또는 분산 작업 수천 건/분 같은 경우에는 전용 큐와 워커가 더 적합합니다.

핵심 아이디어(평범한 언어로)

Cron + 데이터베이스 패턴은 전체 큐 시스템 없이도 예약된 백그라운드 작업을 실행합니다. cron(또는 다른 스케줄러)을 여전히 사용하지만, cron이 무엇을 실행할지 결정하지는 않습니다. 단지 워커를 자주 깨웁니다(보통 1분마다). 데이터베이스가 어떤 작업이 예정되었는지 결정하고, 각 작업을 단 한 워커만 가져가도록 보장합니다.

화이트보드에 걸린 공유 체크리스트를 생각하세요. Cron은 매분 방에 들어와 "지금 할 일이 있나요?"라고 묻는 사람이고, 데이터베이스는 무슨 일이 예정되어 있고 누가 이미 가져갔고 무엇이 완료되었는지를 보여주는 화이트보드입니다.

구성 요소는 단순합니다:

  • 단일 스케줄러 트리거가 자주 실행됩니다.
  • jobs 테이블은 "무엇"과 "언제"(due time), 상태와 시도 횟수를 저장합니다.
  • 하나 이상의 워커가 테이블을 폴링해 작업을 클레임하고 일을 수행합니다.
  • 클레임은 데이터베이스 락(리스)을 사용해 두 워커가 같은 행을 가져가지 못하게 합니다.
  • 데이터베이스는 어떤 작업이 실행되었는지, 실패했는지, 재시도할지의 진실의 소스로 남습니다.

예: 매일 아침 송장 알림을 보내고 싶고, 캐시를 10분마다 갱신하고, 밤마다 오래된 세션을 정리한다고 합시다. 세 개의 별도 cron 명령(각각 중복 및 실패 모드가 있음) 대신 하나의 장소에 작업 항목을 저장합니다. Cron은 동일한 워커 프로세스를 시작하고, 워커는 Postgres에 "지금 어떤 작업이 예정되어 있나요?"라고 묻습니다. Postgres는 워커에게 정확히 하나의 작업을 안전하게 클레임하도록 허용합니다.

이 방식은 점진적으로 확장됩니다. 한 서버에 한 워커로 시작할 수 있고, 나중에 여러 서버에서 다섯 개의 워커를 실행할 수 있습니다. 계약은 동일합니다: 테이블이 계약입니다.

사고 방식의 전환은 간단합니다: cron은 단지 깨우는 신호일 뿐이며 데이터베이스가 교통 정리 역할을 해 무엇이 실행될 수 있는지 결정하고, 무슨 일이 일어났는지 기록하며 문제가 생겼을 때 명확한 이력을 제공합니다.

jobs 테이블 설계(실용적 스키마)

이 패턴은 데이터베이스가 무엇을 실행해야 하는지, 언제 실행해야 하는지, 마지막에 무슨 일이 있었는지에 대한 진실의 소스가 될 때 가장 잘 작동합니다. 스키마 자체는 화려하지 않지만(락 필드와 올바른 인덱스 같은) 작은 세부가 로드가 증가할 때 큰 차이를 만듭니다.

한 테이블 또는 두 테이블?

두 가지 일반적 접근이 있습니다:

  • 하나의 통합 테이블: 각 작업의 최신 상태만 신경 쓰면(단순, 조인이 적음).
  • 두 테이블: "이 작업이 무엇인지"와 "각 실행/시도"를 분리하면(히스토리 보관, 디버깅 쉬움).

실패를 자주 디버깅할 것 같으면 히스토리를 보관하세요. 가장 작은 설정을 원하면 하나의 테이블로 시작한 뒤 나중에 히스토리를 추가하세요.

실용적 스키마(두 테이블 버전)

다음은 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()
);

나중에 고생을 줄여주는 몇 가지 세부:

  • job_type은 라우팅할 수 있는 짧은 문자열로 유지하세요(예: send_invoice_emails).
  • payload는 jsonb로 저장해 마이그레이션 없이 진화시킬 수 있게 하세요.
  • run_at은 "다음 예정 시간"입니다. Cron(또는 스케줄러 스크립트)이 설정하고 워커가 소비합니다.
  • locked_by와 locked_until은 워커가 서로 방해하지 않고 작업을 클레임하도록 합니다.
  • last_error는 짧고 사람이 읽기 쉬운 메시지로 두세요. 스택 트레이스가 필요하면 따로 보관하세요.

필요한 인덱스

인덱스가 없으면 워커가 너무 많은 스캔을 하게 됩니다. 우선 다음을 추가하세요:

  • 예정된 작업을 빠르게 찾기 위한 인덱스: (status, run_at)
  • 만료된 락을 찾는 데 도움이 되는 인덱스: (locked_until)
  • 선택 사항: 활성 작업만 위한 부분 인덱스(예: status가 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을 연장하세요(여전히 해당 작업을 소유하고 있다면).

  • 리스 길이: 일반 작업 시간의 5배 ~ 20배
  • 하트비트 간격: 리스의 1/4 ~ 1/2
  • 갱신 업데이트는 WHERE id = $job_id AND locked_by = $worker_id 조건을 포함하세요

마지막 조건은 중요합니다. 워커가 더 이상 소유하지 않은 작업의 리스를 연장하지 못하게 합니다.

예측 가능한 재시도 및 백오프

운영 부담을 줄여 예약 작업 배포
간단한 Go + Postgres 구성으로 큐 서비스를 추가하지 않고 예약 작업을 구축하세요.
앱 만들기

재시도는 이 패턴이 차분하게 느껴질지 소란스러워질지를 결정합니다. 목표는 간단합니다: 작업이 실패하면 나중에 다시 시도하되, 설명 가능하고 측정 가능하며 중단할 수 있게 하세요.

우선 작업 상태를 명시적이고 유한하게 만드세요: queued, running, succeeded, failed, dead. 실무에서는 failed를 "실패했지만 재시도 예정"으로, dead를 "포기함"으로 쓰는 경우가 많습니다. 이 한 가지 구분이 무한 루프를 막습니다.

시도 횟수 카운팅도 두 번째 안전장치입니다. attempts(시도한 횟수)와 max_attempts(허용할 최대 시도 횟수)를 저장하세요. 워커가 에러를 잡으면 다음을 해야 합니다:

  • attempts를 증가시키기
  • attempts < max_attempts이면 상태를 failed로, 그렇지 않으면 dead로 설정하기
  • 다음 시도 시간을 계산해 run_at에 설정(단, failed일 때만)

백오프는 다음 run_at을 결정하는 규칙입니다. 하나를 고르고 문서화해 일관되게 유지하세요:

  • 고정 딜레이: 항상 1분 대기
  • 지수적: 1m, 2m, 4m, 8m
  • 캡이 있는 지수적: 지수적이지만 최대 30m로 제한
  • 지터 추가: 재시도 시간을 약간 무작위화해 동시에 재시도하는 일을 줄임

지터는 의존성이 내려갔다가 복구될 때 중요합니다. 지터가 없으면 수백 개의 작업이 같은 초에 재시도해 다시 실패할 수 있습니다.

실패를 디버깅할 수 있게 충분한 오류 정보를 저장하세요. 전체 로깅 시스템은 필요 없지만 기본은 필요합니다:

  • last_error (관리 화면에 안전하게 표시 가능한 짧은 메시지)
  • error_code 또는 error_type (그룹핑에 도움)
  • failed_at 및 next_run_at
  • 선택적 last_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 프로세스 하나면 충분한 경우가 많습니다.

다섯 단계로 빌드하기

  1. 테이블과 인덱스 생성. jobs 테이블(및 나중에 쓸 조회 테이블)을 추가하고 run_at을 인덱싱하며 워커가 사용 가능한 작업을 빠르게 찾을 수 있게 (status, run_at) 같은 인덱스를 추가하세요.

  2. 작은 enqueue 함수 작성. 앱은 run_at을 now 또는 미래 시간으로 설정해 행을 삽입해야 합니다. 페이로드는 작고 예측 가능하게(큰 블롭이 아니라 ID와 작업 타입) 유지하세요.

INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
  1. 클레임 루프 구현. 트랜잭션 내에서 실행하세요. 몇 개의 예정 작업을 선택하고 다른 워커가 건너뛰도록 락한 뒤 같은 트랜잭션에서 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 *;
  1. 처리 및 최종화. 클레임한 각 작업에 대해 일을 수행한 뒤 finished_at과 함께 done으로 업데이트합니다. 실패하면 오류 메시지를 기록하고 백오프로 새 run_at을 설정해 queued로 되돌립니다. 최종화 업데이트는 작게 유지하고 프로세스 종료시에도 반드시 실행하세요.

  2. 설명 가능한 재시도 규칙 추가. run_at = now() + (attempts^2) * interval '10 seconds' 같은 단순한 공식을 사용하고 max_attempts를 초과하면 status = 'dead'로 멈추게 하세요.

기본 가시성 추가

첫날부터 완전한 대시보드를 만들 필요는 없지만 문제를 알아차릴 수 있을 정도의 가시성은 필요합니다.

  • 작업마다 한 줄 로깅: claimed, succeeded, failed, retried, dead.
  • "dead jobs"와 "오래된 running 작업"을 위한 간단한 관리자 쿼리나 뷰.
  • 증가하는 실패 수에 대한 알림(예: 지난 한 시간에 N개 이상 dead jobs).

Go + PostgreSQL 스택이면 단일 워커 바이너리와 cron으로 깔끔하게 매핑됩니다.

복사해 쓸 수 있는 현실적인 예

작고 현실적인 SaaS 앱을 상상해보세요. 두 가지 예약 작업이 있습니다:

  • 만료된 세션과 임시 파일을 제거하는 야간 정리 작업.
  • 매주 월요일 아침에 각 사용자에게 보내는 주간 활동 리포트 이메일.

단순히 한 PostgreSQL 테이블에 작업을 저장하고, cron으로 분 단위로 실행되는 워커가 예정된 작업을 클레임해 실행하고 성공/실패를 기록합니다.

무엇을 언제 enqueue하나

작업은 몇 군데에서 enqueue할 수 있습니다:

  • 매일 02:00: 하루치 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를 증가시키고
  • 디버깅용 오류 메시지를 저장하고
  • 백오프로 다음 시도를 스케줄합니다(예: +1분, +5분, +30분, +2시간)

만약 제한(예: 10회)을 지나면 dead로 표시하고 재시도를 멈춥니다. 작업은 단 한 번 성공하거나, 명확한 스케줄로 재시도되며 멱등성으로 재시도가 안전해집니다.

흔한 실수와 함정

재시도에 안전한 작업 만들기
재시도로 중복이 생기지 않도록 멱등성 키와 고유 제약을 추가하세요.
무료 시작

Cron + 데이터베이스 패턴은 단순하지만 작은 실수가 중복, 멈춘 작업, 또는 갑작스러운 부하로 이어질 수 있습니다. 대부분의 문제는 첫 크래시, 배포, 또는 트래픽 스파이크 이후에 드러납니다.

중복이나 멈춘 작업을 유발하는 실수

실제 사고 대부분은 몇 가지 함정에서 옵니다:

  • 락 없이 여러 cron 항목에서 같은 작업을 실행함. 두 서버가 같은 분에 tick하면, 클레임 단계가 원자적이고 락(또는 리스)을 같은 DB 트랜잭션에서 설정하지 않으면 둘 다 같은 작업을 가져갈 수 있습니다.
  • locked_until을 생략함. 워커가 클레임한 뒤 크래시하면 그 행이 영원히 "진행 중"으로 남을 수 있습니다. 리스 타임스탬프가 있으면 다른 워커가 안전하게 다시 가져갈 수 있습니다.
  • 실패 시 즉시 재시도. API가 다운되면 즉시 재시도는 스파이크를 만들고 레이트 리밋을 소모하며 계속 실패합니다. 항상 다음 시도를 미래 시점으로 스케줄하세요.
  • "최소 한 번(at least once)" 실행을 "정확히 한 번(exactly once)"으로 취급함. 타임아웃, 워커 재시작, 네트워크 문제로 작업이 두 번 실행될 수 있습니다. 두 번 실행해도 해가 된다면 부작용을 멱등 있게 만드세요.
  • 작업 행에 거대한 페이로드 저장. 큰 JSON 블롭은 테이블을 부풀리고 인덱스를 느리게 하며 락 비용을 키웁니다. user_id, invoice_id 또는 파일 키 같은 참조를 저장하고 실행 시 나머지를 가져오세요.

예: 주간 송장 이메일을 보낼 때 워커가 이메일을 보낸 직후에 타임아웃되어 작업을 "완료"로 표시하지 못하면 동일한 작업이 재시도되어 중복 이메일이 전송될 수 있습니다. 이 패턴에서는 멱등성 같은 안전장치가 없다면 중복은 정상적일 수 있습니다(예: invoice id로 고유 이벤트 기록).

덜 명백한 함정

스케줄링과 실행을 같은 장기 트랜잭션에서 섞지 마세요. 네트워크 호출을 하면서 트랜잭션을 열어두면 락을 불필요하게 오래 유지해 다른 워커를 막습니다.

머신 간 시계 차이를 주의하세요. run_at과 locked_until의 진실은 데이터베이스 시간(NOW() in PostgreSQL)으로 삼고 앱 서버의 시계를 사용하지 마세요.

최대 실행 시간을 명확히 설정하세요. 작업이 30분 걸릴 수 있다면 리스를 그보다 길게 설정하고 필요하면 갱신하세요. 그렇지 않으면 다른 워커가 작업을 중간에 가져가 버릴 수 있습니다.

작업 테이블을 건강하게 유지하세요. 완료된 작업이 영원히 쌓이면 쿼리가 느려지고 락 경쟁이 증가합니다. 테이블이 커지기 전에 보관하거나 삭제하는 단순한 보존 규칙을 정하세요.

빠른 체크리스트 및 다음 단계

빠른 체크리스트

이 패턴을 배포하기 전에 기본을 확인하세요. 여기서의 작은 누락이 보통 작업 정지, 놀라운 중복 또는 DB에 과도한 부하를 만듭니다.

  • jobs 테이블에 필수 항목이 있는가: run_at, status, attempts, locked_until, max_attempts(그리고 last_error 같은 가시성 필드).
  • 각 작업이 두 번 실행되어도 안전한가? 확실하지 않다면 멱등성 키나 부작용에 대한 고유 규칙을 추가하세요(예: invoice_id당 하나의 송장).
  • 실패를 관찰하고 조치할 곳이 있는가: 실패 작업 보기, 작업 재실행, 영구 중단 처리.
  • 리스(락) 타임아웃이 작업에 합당한가? 정상 실행보다 길되, 크래시 시 작업을 몇 시간 동안 막지 않게 설정하세요.
  • 재시도 백오프가 예측 가능한가? 반복 실패를 늦추고 max_attempts 이후 중단되게 하세요.

이 항목들이 충족되면 Cron + 데이터베이스 패턴은 실제 워크로드에 대해 보통 충분히 안정적입니다.

다음 단계

체크리스트가 만족스러우면 일상 운영에 집중하세요.

  • 두 가지 작은 관리자 액션 추가: "지금 재시도"(run_at = now()로 설정하고 락 해제)와 "취소"(종료 상태로 이동). 이는 사고 시 시간을 절약합니다.
  • 워커가 작업당 한 줄을 로깅하게 하세요: 작업 타입, 작업 id, 시도 번호, 결과. 실패 증가에 대한 알림을 추가하세요.
  • 현실적인 스파이크로 부하 테스트: 같은 분에 많은 작업이 예약된 경우. 클레임이 느려지면 올바른 인덱스(보통 status, run_at)를 추가하세요.

이런 설정을 빠르게 만들고 싶다면 Koder.ai (koder.ai)가 스키마에서 배포된 Go + PostgreSQL 앱까지 수동 배선 없이 빠르게 도와줄 수 있습니다. 락, 재시도, 멱등성 규칙에 집중하세요.

나중에 이 설정을 초과하게 되더라도 작업 라이프사이클에 대해 명확하게 학습했을 것이고, 같은 아이디어를 전체 큐 시스템으로 옮길 수 있습니다.

목차
문제: 추가 인프라 없이 예약 작업 처리핵심 아이디어(평범한 언어로)jobs 테이블 설계(실용적 스키마)안전하게 락을 걸고 작업을 클레임하기예측 가능한 재시도 및 백오프멱등성: 작업이 반복되어도 중복을 방지하기단계별: 최소 워커와 스케줄러 구축하기복사해 쓸 수 있는 현실적인 예흔한 실수와 함정빠른 체크리스트 및 다음 단계
공유
Koder.ai
Koder로 나만의 앱을 만들어 보세요 지금!

Koder의 힘을 이해하는 가장 좋은 방법은 직접 체험하는 것입니다.

무료로 시작데모 예약