cron 골칫거리 없이 백그라운드 작업 스케줄링하기: 패턴
워크플로와 작업 테이블을 사용해 알림, 일일 요약, 정리를 신뢰성 있게 실행하는 백그라운드 작업 스케줄링 패턴을 알아보세요.

왜 cron은 간단해 보이다가 문제를 일으킬까
cron은 첫날에 훌륭합니다: 한 줄을 쓰고, 시간을 정하고, 잊어버리면 됩니다. 서버 하나와 작업 하나에는 종종 잘 작동하죠.
진짜 제품 동작(알림, 일일 요약, 정리, 동기화 작업 등)에 스케줄링을 의존하면 문제가 드러납니다. 대부분의 “실행 누락” 이야기는 cron 자체의 실패가 아닙니다. 재부팅, crontab을 덮어쓴 배포, 예상보다 오래 걸린 작업, 또는 시계나 시간대 불일치 같은 주변 요인이 원인입니다. 여러 앱 인스턴스를 운영하면 반대 모드의 실패도 발생합니다: 두 대의 머신이 같은 작업을 실행한다고 생각해서 중복이 생기는 경우입니다.
테스트도 약점입니다. cron 한 줄로는 “내일 오전 9시에 무슨 일이 일어나는지”를 반복 가능한 방식으로 실행하는 깔끔한 방법이 없습니다. 그래서 스케줄링은 수동 확인, 프로덕션의 놀라움, 로그 추적으로 변합니다.
접근 방식을 고르기 전에 무엇을 스케줄할지 명확히 하세요. 대부분의 백그라운드 작업은 몇 가지 범주에 속합니다:
- 알림(특정 시간에 한 번만 전송)
- 일일 요약(데이터 집계 후 전송)
- 정리 작업(삭제, 아카이브, 만료)
- 주기적 동기화(업데이트를 가져오거나 푸시)
때로는 스케줄링을 완전히 건너뛸 수 있습니다. 어떤 일이 이벤트(사용자 가입, 결제 성공, 티켓 상태 변경) 발생 직후에 일어날 수 있다면, 이벤트 기반 작업이 시간 기반 작업보다 보통 더 간단하고 안정적입니다.
시간이 필요할 때 신뢰성은 주로 가시성과 제어에 달려 있습니다. 무엇이 실행되어야 하는지, 무엇이 실행되었는지, 무엇이 실패했는지를 기록할 장소와 중복을 만들지 않고 안전하게 재시도할 방법이 필요합니다.
기본 패턴: 스케줄러, 작업 테이블, 워커
cron 골칫거리를 피하는 간단한 방법은 책임을 분리하는 것입니다:
- 스케줄러는 무엇을 언제 실행할지 결정합니다.
- 워커는 실제 작업을 수행합니다.
이 역할을 분리하면 타이밍을 비즈니스 로직과 분리해서 바꿀 수 있고, 비즈니스 로직을 수정해도 스케줄이 깨지지 않습니다.
작업 테이블은 진실의 출처가 됩니다. 서버 프로세스나 cron 라인 안에 상태를 숨기지 말고, 작업 단위마다 한 행으로 남기세요: 무엇을 할지, 누구를 위한지, 언제 실행될지, 마지막에 무슨 일이 있었는지. 문제가 생기면 추적하고, 재시도하거나, 취소할 수 있습니다.
일반적인 흐름은 다음과 같습니다:
- 스케줄러가 예정된 작업을 스캔합니다(예:
run_at <= now및status = queued). - 한 워커가 작업을 단독으로 가져가도록 작업을 획득(claim)합니다.
- 워커가 작업 세부 정보를 읽고 동작을 수행합니다.
- 워커는 결과를 같은 행에 기록합니다.
핵심 아이디어는 작업을 마법처럼 처리하지 않고 재개 가능하게 만드는 것입니다. 워커가 도중에 크래시하더라도 작업 행은 여전히 무슨 일이 있었는지와 다음에 무엇을 해야 할지 알려줘야 합니다.
유용한 상태를 유지하는 작업 테이블 설계
작업 테이블은 두 가지 질문에 빠르게 답할 수 있어야 합니다: 다음에 무엇을 실행해야 하나, 그리고 지난번에는 무슨 일이 있었나.
정체성, 타이밍, 진행을 다루는 작은 필드 집합으로 시작하세요:
- id, type: 고유 id와
send_reminder나daily_summary같은 짧은 타입. - payload: 워커가 필요한 것만 담은 검증된 JSON(예: 전체 사용자 객체가 아닌
user_id). - run_at: 작업이 실행될 자격이 되는 시점.
- status:
queued,running,succeeded,failed,canceled. - attempts: 시도할 때마다 증가.
운영상 안전성과 사고 대응을 돕는 몇 가지 컬럼을 추가하세요. locked_at, locked_by, locked_until은 한 워커가 작업을 획득하도록 해 두 번 실행되지 않게 합니다. last_error는 긴 스택 트레이스 대신 짧은 메시지(선택적으로 오류 코드)를 저장하세요.
마지막으로 지원과 리포팅에 도움이 되는 타임스탬프를 유지하세요: created_at, updated_at, finished_at. 이를 통해 “오늘 몇 개의 알림이 실패했나?” 같은 질문에 로그를 뒤지지 않고 답할 수 있습니다.
인덱스는 시스템이 끊임없이 "다음은 무엇인가?"를 묻기 때문에 중요합니다. 보통 유용한 두 가지 인덱스:
(status, run_at)— 예정된 작업을 빠르게 가져오기 위해(type, status)— 한 작업군을 검사하거나 이슈 발생 시 일시 중지하기 위해
payload는 작고 집중된 JSON을 선호하고 삽입 전에 검증하세요. 식별자와 매개변수를 저장하고 비즈니스 데이터의 스냅샷은 저장하지 마세요. payload 형태는 API 계약처럼 취급해 앱이 변경되어도 오래 대기 중인 작업이 여전히 실행되게 하세요.
작업 수명주기: 상태, 잠금, 멱등성
작업 실행기가 신뢰성을 유지하려면 모든 작업이 작은 예측 가능한 수명주기를 따라야 합니다. 이 수명주기는 두 워커가 동시에 시작되거나 서버가 중간에 재시작되거나 중복 없이 재시도해야 할 때 안전망이 됩니다.
간단한 상태 머신이면 충분한 경우가 많습니다:
- queued:
run_at이후 실행될 준비가 된 상태 - running: 워커가 획득한 상태
- succeeded: 완료되어 다시 실행할 필요 없음
- failed: 오류로 종료되어 주의가 필요함
- canceled: 의도적으로 중지된 상태(예: 사용자가 옵트아웃)
이중 작업 없이 작업 획득하기
중복을 방지하려면 작업 획득은 원자적이어야 합니다. 일반적인 접근법은 타임아웃(리스)을 가진 잠금입니다: 워커가 status=running을 설정하고 locked_by, locked_until을 기록해 작업을 획득합니다. 워커가 크래시하면 잠금이 만료되고 다른 워커가 재획득할 수 있습니다.
실용적인 획득 규칙 예:
run_at <= now인 queued 작업만 획득status,locked_by,locked_until을 같은 업데이트에서 설정locked_until < now일 때만 running 작업 재획득- 작업이 길면 리스(lease)를 짧게 잡고 연장
멱등성(항상 지켜야 하는 습관)
멱등성이란 같은 작업이 두 번 실행되더라도 결과가 여전히 올바른 것을 의미합니다.
가장 단순한 도구는 고유 키입니다. 예를 들어 일일 요약은 summary:user123:2026-01-25 같은 키로 사용자별 날짜당 하나의 작업을 강제할 수 있습니다. 중복 삽입이 발생하면 두 번째 작업이 생기는 대신 같은 작업을 가리키게 됩니다.
부작용(이메일 전송, 레코드 업데이트 등)이 진짜로 완료되었을 때만 성공으로 표시하세요. 재시도 시 두 번째 이메일이나 중복 쓰기가 생기지 않도록 재시도 경로를 설계해야 합니다.
재시도와 실패 처리
재시도는 작업 시스템이 신뢰 가능해지느냐 아니면 소음으로 전락하느냐를 가르는 곳입니다. 목표는 명확합니다: 일시적인 실패에는 재시도하고, 영구적인 실패에는 중단하세요.
기본 재시도 정책에는 보통 다음이 포함됩니다:
- 최대 시도 횟수(예: 총 5회)
- 지연 전략(고정 지연 또는 지수 백오프)
- 중지 조건(예: "잘못된 입력" 같은 오류는 재시도하지 않음)
- 지터(재시도 스파이크를 피하기 위한 작은 무작위 오프셋)
재시도를 위한 새로운 상태를 발명하는 대신 queued를 재사용할 수 있습니다: 다음 시도 시간으로 run_at을 설정하고 작업을 다시 큐에 넣으세요. 이렇게 하면 상태 머신이 간단하게 유지됩니다.
작업이 부분적으로 진행될 수 있다면 그것을 정상으로 취급하세요. 체크포인트를 저장해 재시도가 안전하게 이어지게 하세요. 체크포인트는 작업 payload(예: last_processed_id)에 두거나 관련 테이블에 둘 수 있습니다.
예: 일일 요약 작업이 500명의 사용자에게 메시지를 생성합니다. 320번째 사용자에서 실패했다면 마지막으로 성공한 사용자 ID를 저장하고 321부터 재시도합니다. 사용자가 하루별로 요약 전송 기록(summary_sent)을 저장하면 재실행 시 이미 처리된 사용자는 건너뛸 수 있습니다.
실제로 도움이 되는 로깅
몇 분 안에 디버그할 수 있도록 충분히 로깅하세요:
- 작업 id, 타입, 시도 번호
- 주요 입력 값(사용자/팀 id, 날짜 범위)
- 타이밍(시작 시각, 종료 시각, 다음 실행 시각)
- 짧은 오류 요약(가능하면 스택 트레이스 포함)
- 부작용 카운트(전송된 이메일 수, 업데이트된 행 수)
단계별: 간단한 스케줄러 루프 만들기
스케줄러 루프는 일정한 리듬으로 깨어나 예정된 작업을 확인하고 넘겨주는 작은 프로세스입니다. 목표는 완벽한 타이밍이 아니라 지루한 수준의 신뢰성입니다. 많은 앱에서는 "1분마다 깨어나기"면 충분합니다.
작업의 시간 민감도와 데이터베이스에 걸리는 부하를 기준으로 깨어나는 빈도를 정하세요. 알림이 거의 실시간이어야 하면 30~60초 간격을 권장합니다. 일일 요약은 약간 밀려도 괜찮으니 5분 간격으로 돌리면 비용이 적게 듭니다.
간단한 루프:
- 깨어나 현재 시간 가져오기(UTC 사용).
status = 'queued'및run_at <= now인 예정 작업 선택.- 안전하게 작업을 획득해 한 워커만 처리하도록 함.
- 획득한 각 작업을 워커에 전달.
- 다음 틱까지 잠자기.
획득 단계에서 많은 시스템이 깨집니다. 작업을 running으로 표시하고 locked_by, locked_until을 기록하는 것을 선택(select)과 같은 트랜잭션에서 처리하려고 합니다. 많은 데이터베이스는 "skip locked" 읽기를 지원해 여러 스케줄러가 동시에 실행되어도 서로 방해하지 않게 해줍니다.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
배치 크기는 작게 유지하세요(예: 50~200). 큰 배치는 데이터베이스를 느리게 하고 크래시 시 더 큰 피해를 줍니다.
스케줄러가 배치 중간에 크래시하더라도 리스가 구해줍니다. running에 갇힌 작업은 locked_until 이후 다시 실행 자격이 생깁니다. 워커는 멱등성을 가져야 재획득된 작업이 중복 이메일이나 중복 요금 청구를 만들지 않습니다.
알림, 일일 요약, 정리를 위한 패턴
대부분의 팀은 같은 세 가지 유형의 백그라운드 작업을 사용하게 됩니다: 제때 발송되어야 하는 메시지, 정기적으로 실행되는 리포트, 저장공간과 성능을 유지하기 위한 정리 작업. 같은 작업 테이블과 워커 루프가 이 모두를 처리할 수 있습니다.
알림
알림에는 메시지를 보내는 데 필요한 모든 것을 작업 행에 저장하세요: 대상, 채널(이메일, SMS, Telegram, 인앱), 템플릿, 정확한 전송 시간 등. 워커는 추가 컨텍스트를 "찾아다니지" 않고도 작업을 실행할 수 있어야 합니다.
많은 알림이 동시에 몰리면 레이트 리미팅을 추가하세요. 채널별 분당 전송 한도를 두고 초과 작업은 다음 실행으로 미루게 하세요.
일일 요약
일일 요약은 시간 창이 불명확할 때 실패합니다. 안정된 컷오프 시간을 하나 정하세요(예: 사용자 로컬 시간으로 08:00)와 창의 정의(예: "어제 08:00부터 오늘 08:00까지"). 재실행 시 같은 결과가 나오도록 컷오프와 사용자 시간대를 작업에 저장하세요.
각 요약 작업을 작게 유지하세요. 수천 건을 처리해야 하면 팀별, 계정별, ID 범위별로 나누어 후속 작업을 큐에 넣으세요.
정리 작업
정리는 "삭제"와 "아카이브"를 분리하면 더 안전합니다. 영구적으로 제거해도 되는 것(임시 토큰, 만료된 세션)과 아카이브해야 하는 것(감사 로그, 청구서)을 결정하세요. 긴 락과 갑작스러운 부하 스파이크를 피하려면 예측 가능한 배치로 정리 작업을 실행하세요.
시간과 시간대: 버그의 숨은 근원
많은 실패는 시간 관련 버그에서 옵니다: 알림이 한 시간 일찍 가거나, 일일 요약이 월요일을 건너뛰거나, 정리가 두 번 실행되는 등.
좋은 기본은 스케줄 타임스탬프를 UTC로 저장하고 사용자의 시간대는 따로 저장하는 것입니다. run_at은 하나의 UTC 시점이어야 합니다. 사용자가 "내 시간으로 오전 9시"라고 하면 스케줄링할 때 이를 UTC로 변환하세요.
서머타임(DST)은 순진한 설정을 깨뜨립니다. "매일 오전 9시"는 "매 24시간마다"와 같지 않습니다. DST 전환 시에는 오전 9시가 다른 UTC 시점에 대응하거나(봄 앞으로) 존재하지 않거나(가을 뒤로) 두 번 발생할 수 있습니다. 더 안전한 방법은 재스케줄할 때마다 다음 로컬 발생 시점을 계산하고 다시 UTC로 변환하는 것입니다.
일일 요약의 경우 "하루"가 무엇을 의미하는지 먼저 결정하세요. 달력 기준 하루(사용자 시간대의 자정부터 자정)가 사람의 기대에 맞습니다. "지난 24시간"은 구현은 간단하지만 시간이 지남에 따라 어긋나고 놀라움을 줄 수 있습니다.
늦게 도착하는 데이터는 피할 수 없습니다: 이벤트가 재시도 이후 도착하거나 자정 몇 분 뒤에 노트가 추가될 수 있습니다. 늦은 이벤트를 "어제"에 포함할지(여유 기간을 두고) 아니면 "오늘"에 포함할지 결정하고 규칙을 일관되게 유지하세요.
실용적인 버퍼는 누락을 막아줄 수 있습니다:
- 지금으로부터 2~5분 전까지의 작업도 스캔
- 작업을 멱등하게 만들어 재실행이 안전하게 함
- 페이로드에 커버된 시간 범위를 기록해 요약 일관성 유지
놓치거나 중복 실행을 유발하는 흔한 실수
대부분의 문제는 몇 가지 예측 가능한 가정에서 옵니다.
가장 큰 것은 "정확히 한 번(exactly once)" 실행이 된다고 가정하는 것입니다. 현실 시스템에서는 워커가 재시작하고 네트워크 호출이 타임아웃되며 잠금이 풀릴 수 있습니다. 보통은 "최소 한 번(at least once)" 전달을 얻기 때문에 중복은 정상이며 코드가 이를 견딜 수 있어야 합니다.
또 다른 실수는 효과를 먼저 수행(이메일 전송, 카드 청구 등)하고 중복 방지 체크를 하지 않는 것입니다. 간단한 가드가 자주 해결책입니다: sent_at 타임스탬프, (user_id, reminder_type, date)와 같은 고유 키, 또는 저장된 중복 방지 토큰.
가시성 부족도 큰 문제입니다. "무엇이 멈춰 있고, 언제부터, 왜"를 답할 수 없다면 추측하게 됩니다. 최소한 가까이 두어야 할 데이터는 상태, 시도 횟수, 다음 예정 시간, 마지막 오류, 워커 id입니다.
자주 보이는 실수들:
- 작업이 정확히 한 번 실행된다고 설계하고 중복에 놀람
- 부작용을 중복 검사 없이 수행
- 모든 것을 하려는 하나의 거대한 작업을 만들어 중간에 타임아웃 발생
- 중지 없이 무한 재시도
- 대기열 가시성 건너뜀(백로그, 실패, 장기 실행 항목을 확인할 수 없음)
구체적 예: 일일 요약 작업이 50,000명의 사용자를 순회하다가 20,000명에서 타임아웃이 납니다. 재시도 시 처음부터 다시 시작하면 처음 20,000명에게 요약을 중복 전송할 수 있습니다. 사용자별 완료를 추적하거나 작업을 사용자별로 분할하면 이런 문제를 피할 수 있습니다.
신뢰할 수 있는 작업 시스템을 위한 빠른 체크리스트
작업 실행기는 새벽 2시에 신뢰할 수 있을 때만 "완료"입니다.
다음이 있는지 확인하세요:
- 큐 가시성: 대기, 실행, 실패 건수와 가장 오래된 대기 작업
- 기본 멱등성: 모든 작업은 두 번 실행될 수 있다고 가정해 고유 키나 "이미 처리됨" 표시 사용
- 작업 타입별 재시도 정책: 재시도, 백오프, 명확한 중지 조건
- 일관된 시간 저장:
run_at은 UTC로 저장; 변환은 입력과 표시 시에만 수행 - 복구 가능한 잠금: 크래시가 작업을 영구적으로 잠그지 않도록 하는 리스
또한 배치 크기(한 번에 획득하는 작업 수)와 워커 동시성(동시에 실행되는 작업 수)을 제한하세요. 제한이 없으면 한 번의 스파이크로 데이터베이스가 과부하되거나 다른 작업이 굶주릴 수 있습니다.
현실적인 예: 작은 팀을 위한 알림과 요약
한 작은 SaaS 툴에 고객 계정이 30개 있습니다. 각 계정은 두 가지를 원합니다: 오전 9시의 열린 작업 알림과 오늘 변경된 내용을 정리한 오후 6시 일일 요약. 또한 오래된 로그와 만료된 토큰으로 DB가 가득 차지 않도록 주간 정리가 필요합니다.
이들은 작업 테이블과 예정 작업을 폴링하는 워커를 사용합니다. 새 고객이 가입하면 백엔드는 고객의 시간대를 기준으로 첫 알림과 요약 작업을 스케줄합니다.
작업은 몇 가지 공통 시점에 생성됩니다: 가입 시(반복 일정 생성), 특정 이벤트 발생 시(일회성 알림 큐잉), 스케줄 틱(다가오는 실행 삽입), 유지보수일(정리 작업 큐잉).
어느 화요일 오전 8:59에 이메일 공급자가 일시적으로 다운되었다고 합시다. 워커가 알림을 보내려다 타임아웃을 받아 백오프 방식으로 run_at을 재설정(예: 2분, 그다음 10분, 그다음 30분)하고 attempts를 증가시킵니다. 각 알림 작업에 account_id + date + job_type 같은 멱등성 키가 있으므로 공급자가 중간에 복구되더라도 재시도로 인한 중복은 발생하지 않습니다.
정리는 작은 배치로 주간 실행해 다른 작업을 막지 않습니다. 백만 건을 한 번에 삭제하는 대신 한 실행에 N개까지 삭제하고 완료될 때까지 스스로 재스케줄합니다.
고객이 "요약을 못 받았어요"라고 불평하면 팀은 해당 계정과 날짜의 작업 테이블을 확인합니다: 작업 상태, 시도 횟수, 현재 잠금 필드, 공급자가 반환한 마지막 오류 등. 그러면 "보냈어야 한다"는 추측에서 "무슨 일이 있었는지 정확히 여기 있다"로 바뀝니다.
다음 단계: 구현하고 관찰한 뒤 확장하세요
한 작업 타입을 골라 엔드투엔드로 먼저 빌드한 뒤 다른 작업을 추가하세요. 단일 알림 작업이 시작하기에 좋습니다. 스케줄링, 예정 작업 획득, 메시지 전송, 결과 기록이라는 모든 요소를 포함하기 때문입니다.
신뢰할 수 있는 버전으로 시작하세요:
- 작업 테이블과 한 작업 타입을 처리하는 워커 하나 생성
- 예정 작업을 획득하고 실행하는 스케줄러 루프 추가
- 워커가 추가 조회 없이 작업을 실행할 수 있도록 충분한 페이로드 저장
- 모든 시도와 결과를 로깅해 "실행됐나?"를 10초 만에 답할 수 있게 함
- 실패한 작업을 수동으로 재실행할 수 있는 경로 추가해 복구에 배포가 필요 없게 함
작동하면 사람이 관찰할 수 있게 만드세요. 기본적인 관리자 뷰만으로도 빠르게 효과를 봅니다: 상태로 작업 검색, 시간으로 필터링, 페이로드 검사, 멈춘 작업 취소, 특정 작업 id 재실행.
이런 스케줄러와 워커 흐름을 시각적 백엔드 로직으로 구축하는 것을 선호한다면 AppMaster (appmaster.io)는 PostgreSQL에 작업 테이블을 모델링하고 claim-process-update 루프를 Business Process로 구현하면서도 실제 배포 가능한 소스 코드를 생성해줄 수 있습니다.


