
Cron Job과 백그라운드 작업: 매일 새벽 3시에 알아서 돌아가는 코드
매일 수동으로 데이터를 정리하다가 지쳤다. 서버리스 환경에서 크론잡과 백그라운드 작업을 구성하면서 배운 것들.

매일 수동으로 데이터를 정리하다가 지쳤다. 서버리스 환경에서 크론잡과 백그라운드 작업을 구성하면서 배운 것들.
ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

서버를 직접 사거나 관리하지 마세요. 코드가 실행되는 0.1초만큼만 돈을 내는 클라우드의 혁명. FaaS의 원리부터 Cold Start 문제 해결, 그리고 비용 절감 효과까지.

코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.

한동안 매일 아침 9시에 스크립트를 손으로 실행했다. 전날 쌓인 임시 데이터를 정리하고, 만료된 세션을 삭제하고, 통계 집계를 새로 돌리는 작업이었다. 터미널 열고, node scripts/cleanup.js 입력하고, 완료 메시지 확인하고, 닫는다. 5분짜리 루틴이었다.
별거 아닌 것 같았다. 그런데 어느 날 깜빡했다. 미팅이 있었고, 점심을 먹고 오후 내내 다른 작업에 빠져 있었다. 그날 저녁 늦게 데이터베이스 용량 알림이 왔다. 임시 데이터가 하루치 더 쌓였고, 거기다 집계 오류까지 났다. 사용자 몇 명이 이상한 숫자를 봤다고 문의를 남겼다.
그때 처음 제대로 깨달았다. 자동화되지 않은 반복 작업은 언젠가 반드시 실패한다. 사람이 하는 일이라서 그렇다. 집중력이 흐트러지고, 일정이 바뀌고, 그냥 잊어버린다. 이건 내가 게으른 게 아니라 시스템의 문제였다.
그래서 크론잡을 공부했다. 처음엔 "그냥 타이머 아닌가?"라고 생각했는데, 서버리스 환경에서 제대로 구성하려면 생각보다 챙길 게 많았다. 정리해본다.
크론잡의 핵심은 크론 표현식(cron expression)이다. 언제 실행할지를 5개 또는 6개의 숫자와 기호로 표현한다.
┌───────────── 분 (0–59)
│ ┌─────────── 시 (0–23)
│ │ ┌───────── 일 (1–31)
│ │ │ ┌─────── 월 (1–12)
│ │ │ │ ┌───── 요일 (0–7, 0과 7은 일요일)
│ │ │ │ │
* * * * *
비유하면 알람 시계의 설정 화면이다. 다만 일반 알람은 "매일 오전 7시"처럼 단순하지만, 크론 표현식은 "매월 첫 번째 월요일 새벽 3시 30분"처럼 정밀하게 표현할 수 있다.
자주 쓰는 패턴 몇 가지를 정리하면 이렇다.
| 표현식 | 설명 |
|---|---|
0 3 * * * | 매일 새벽 3시 |
0 */6 * * * | 6시간마다 (0시, 6시, 12시, 18시) |
30 9 * * 1-5 | 평일 오전 9시 30분 |
0 0 1 * * | 매월 1일 자정 |
*/15 * * * * | 15분마다 |
*는 "모든"을 뜻한다. */6은 "6으로 나뉘는 모든 값"이다. -는 범위, ,는 목록이다. 처음엔 외계어처럼 보이지만 익숙해지면 직관적이다.
crontab.guru 사이트에서 표현식을 입력하면 사람 말로 풀어서 설명해준다. 새로운 표현식을 쓸 때마다 거기서 확인하는 편이다.
서버리스 환경에서 크론잡을 돌리는 방법은 여러 가지다. 각각 장단점이 달라서 상황에 맞게 골라야 한다.
Next.js + Vercel 조합을 쓰고 있다면 가장 자연스러운 선택이다. vercel.json에 크론 설정을 추가하면 Vercel이 지정한 시간에 API 라우트를 호출해준다. 별도 인프라 없이 코드 배포와 함께 크론잡이 배포된다.
다만 Free 플랜은 하루 1회 실행으로 제한되고, Pro 플랜부터 최소 1시간 단위 이상으로 쓸 수 있다. 1분 단위나 15분 단위 크론이 필요하다면 유료 플랜이 필요하다.
GitHub Actions의 schedule 트리거를 쓰면 크론잡처럼 동작한다. 장점은 이미 GitHub을 쓰고 있다면 추가 비용이 없다는 것이다. 코드베이스와 함께 버전 관리되고, 실행 로그도 Actions 탭에서 바로 볼 수 있다.
단점은 레포지토리가 오랫동안 비활성화 상태면 스케줄 실행이 자동으로 중단된다는 것이다. 그리고 무료 플랜에는 월 2,000분 제한이 있다. 실행 시간이 긴 작업을 자주 돌리면 한계에 부딪힌다.
Supabase를 데이터베이스로 쓰고 있다면 pg_cron 확장을 활성화할 수 있다. PostgreSQL 안에서 직접 SQL 함수나 프로시저를 스케줄링한다. 데이터 정리, 집계, 만료 처리처럼 데이터베이스 수준의 작업에 특히 적합하다.
네트워크를 타지 않고 데이터베이스 내부에서 실행되니 속도가 빠르다. 반면 복잡한 비즈니스 로직이나 외부 API 호출은 어색하다.
cron-job.org는 무료로 HTTP 엔드포인트를 주기적으로 호출해주는 서비스다. 플랫폼에 종속되지 않고 어떤 서버든 호출할 수 있다. 설정이 간단하고, 실행 이력 관리도 된다.
Upstash QStash는 HTTP 메시지 큐인데, 지연 실행이나 재시도 로직이 필요할 때 강력하다. 서버리스 환경에서 신뢰성 높은 백그라운드 작업이 필요하다면 고려할 만하다.
| 서비스 | 최소 주기 | 무료 한도 | 적합한 경우 |
|---|---|---|---|
| Vercel Cron | 1분 (Pro) | 하루 1회 (Free) | Next.js + Vercel 스택 |
| GitHub Actions | 5분 | 월 2,000분 | CI/CD와 통합, 간단한 작업 |
| Supabase pg_cron | 1분 | 플랜에 따라 | DB 수준 데이터 작업 |
| cron-job.org | 1분 | 무제한 (무료) | 플랫폼 무관, 간단한 HTTP 호출 |
| Upstash QStash | 1초 | 500회/일 (무료) | 재시도·지연 실행이 필요한 경우 |
내가 실제로 쓰는 방식을 정리했다. Next.js App Router + Vercel Cron 조합이다.
{
"crons": [
{
"path": "/api/cron/cleanup",
"schedule": "0 3 * * *"
},
{
"path": "/api/cron/aggregate-stats",
"schedule": "0 */6 * * *"
}
]
}
path는 Vercel이 호출할 API 라우트 경로다. schedule은 크론 표현식이다. 이렇게만 설정하면 Vercel이 배포할 때 크론잡을 등록한다. 별도 서버나 데몬 프로세스가 필요 없다.
// src/app/api/cron/cleanup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export const maxDuration = 60; // 최대 실행 시간 60초
export async function GET(request: NextRequest) {
// 1. 보안: Vercel이 보내는 크론 시크릿 검증
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const startedAt = new Date().toISOString();
const supabase = createClient();
try {
// 2. 만료된 임시 데이터 삭제 (7일 이상 된 것)
const { count: deletedTemp, error: tempError } = await supabase
.from('temp_data')
.delete({ count: 'exact' })
.lt('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());
if (tempError) throw tempError;
// 3. 만료된 세션 삭제
const { count: deletedSessions, error: sessionError } = await supabase
.from('sessions')
.delete({ count: 'exact' })
.lt('expires_at', new Date().toISOString());
if (sessionError) throw sessionError;
// 4. 실행 로그 기록
await supabase.from('cron_logs').insert({
job_name: 'cleanup',
started_at: startedAt,
completed_at: new Date().toISOString(),
status: 'success',
metadata: { deletedTemp, deletedSessions },
});
return NextResponse.json({
success: true,
deletedTemp,
deletedSessions,
});
} catch (error) {
// 5. 실패도 로그에 남긴다
await supabase.from('cron_logs').insert({
job_name: 'cleanup',
started_at: startedAt,
completed_at: new Date().toISOString(),
status: 'error',
metadata: { error: String(error) },
});
console.error('[cron/cleanup] Failed:', error);
return NextResponse.json(
{ error: 'Cleanup failed' },
{ status: 500 }
);
}
}
몇 가지 포인트가 있다.
보안: Vercel은 크론 요청에 Authorization: Bearer <CRON_SECRET> 헤더를 자동으로 붙여준다. CRON_SECRET 환경변수는 Vercel 대시보드에서 자동 생성된다. 이 검증을 빠뜨리면 누구나 크론 엔드포인트를 직접 호출할 수 있다.
maxDuration: Vercel 서버리스 함수의 기본 실행 시간 제한은 10초다. maxDuration = 60으로 올려야 오래 걸리는 작업이 중간에 잘리지 않는다. Pro 플랜은 최대 300초까지 가능하다.
로그 기록: 크론잡이 돌았는지, 뭘 했는지 반드시 로그로 남긴다. 나중에 "어제 왜 데이터가 이상하지?" 할 때 로그가 없으면 디버깅이 불가능하다.
서버리스 환경에서 크론잡을 돌릴 때 반드시 알아야 할 세 가지 문제가 있다.
서버리스 함수는 무한정 실행되지 않는다. Vercel Free는 10초, Pro는 60초, Enterprise는 최대 900초다. AWS Lambda도 최대 15분이다.
데이터가 100만 건인 테이블을 한 번에 처리하려 했다가 함수가 중간에 종료된 경험이 있다. 해결책은 배치 처리다. 한 번에 1,000건씩 처리하고, 다음 실행에서 이어서 처리하도록 설계한다. 또는 작업을 여러 엔드포인트로 나눠서 각각 별도의 크론으로 돌린다.
서버리스 함수는 요청이 없으면 인스턴스가 종료된다. 크론잡이 실행될 때 인스턴스가 없으면 새로 띄우는데, 이게 콜드 스타트다. 수백 밀리초에서 수 초까지 걸릴 수 있다.
대부분의 크론잡은 실시간 응답이 필요 없으니 콜드 스타트 자체가 큰 문제는 아니다. 다만 실행 시간 제한에 콜드 스타트 시간이 포함된다는 걸 기억해야 한다. 10초 제한인데 콜드 스타트에 3초 걸리면 실제 작업에는 7초밖에 없다.
이게 가장 중요하다. 멱등성(idempotency)이란 같은 작업을 여러 번 실행해도 결과가 동일해야 한다는 성질이다.
비유하면 엘리베이터 버튼이다. 이미 누른 버튼을 열 번 더 눌러도 엘리베이터는 한 번만 온다. 결과가 같다. 이게 멱등성이다.
반면 이메일 발송은 멱등하지 않다. 같은 크론잡을 실수로 두 번 실행하면 같은 이메일이 두 통 간다. 사용자는 황당하다.
크론잡이 멱등하지 않으면 어떤 일이 생기냐. Vercel이 네트워크 문제로 같은 크론을 두 번 실행하거나, 내가 디버깅하다가 수동으로 엔드포인트를 한 번 더 호출하거나, 배포 타이밍과 겹쳐서 중복 실행이 생길 수 있다.
설계 원칙은 단순하다. 이미 처리된 건 건너뛰어야 한다. DELETE WHERE expires_at < NOW()는 멱등하다. 두 번 실행해도 결과가 같다. 하지만 INSERT INTO daily_stats SELECT COUNT(*) FROM orders WHERE date = TODAY()는 두 번 실행하면 같은 날짜 통계가 두 행 생긴다. UPSERT(INSERT ... ON CONFLICT DO UPDATE)로 바꿔야 멱등해진다.
// 멱등하지 않은 방식 ❌
await supabase.from('daily_stats').insert({
date: today,
order_count: orderCount,
});
// 멱등한 방식 ✅
await supabase.from('daily_stats').upsert(
{ date: today, order_count: orderCount },
{ onConflict: 'date' }
);
코드를 짜는 것보다 운영하는 게 더 어렵다. 처음에 놓쳐서 나중에 고생한 것들을 정리했다.
크론잡이 조용히 실패하는 건 가장 위험한 상태다. 내가 모르는 채로 데이터가 쌓이고, 문제가 커진 다음에야 알게 된다. Vercel 대시보드에서 크론잡 실패 알림을 이메일로 받도록 설정하거나, API 라우트에서 status: 500을 반환할 때 Slack 웹훅을 쏘는 코드를 넣는다.
// 실패 시 Slack 알림
async function notifyFailure(jobName: string, error: unknown) {
if (!process.env.SLACK_WEBHOOK_URL) return;
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `크론잡 실패: *${jobName}*\n\`\`\`${String(error)}\`\`\``,
}),
});
}
크론잡 실행 시간이 다음 실행 주기보다 길면 어떻게 될까. 이전 실행이 끝나기도 전에 다음 실행이 시작된다. 둘이 같은 데이터를 동시에 처리하면 충돌이 생긴다.
서버리스 환경에서는 함수 인스턴스 자체가 격리되어 있어서 일반적인 잠금(lock)을 쓸 수 없다. 대신 데이터베이스에 "실행 중" 플래그를 기록하는 방식을 쓴다.
// 실행 중 체크
const { data: runningJob } = await supabase
.from('cron_logs')
.select('id')
.eq('job_name', 'cleanup')
.eq('status', 'running')
.single();
if (runningJob) {
return NextResponse.json({ message: 'Already running, skipping' });
}
// 실행 시작 기록
await supabase.from('cron_logs').insert({
job_name: 'cleanup',
status: 'running',
started_at: new Date().toISOString(),
});
크론잡을 로컬에서 테스트할 때는 그냥 API 라우트를 직접 호출하면 된다. 단, 인증 헤더를 함께 보내야 한다.
# .env.local에 CRON_SECRET 설정 후
curl -H "Authorization: Bearer your-cron-secret" \
http://localhost:3000/api/cron/cleanup
프로덕션에서 수동으로 실행할 때도 같은 방법이다. Vercel의 크론 스케줄을 기다리지 않고 즉시 확인할 수 있다.
반복 작업은 자동화해야 한다. 사람이 하는 일은 언젠가 실패한다. 크론잡은 지치지도, 잊지도 않는다.
크론 표현식은 알람 시계의 언어다. 5개 필드로 언제 실행할지를 정확하게 표현한다. crontab.guru에서 확인하면 편하다.
서버리스 크론은 실행 시간 제한을 항상 염두에 둬야 한다. 배치 처리로 나누고, maxDuration을 적절히 설정한다.
멱등성은 선택이 아니다. 같은 작업이 두 번 실행돼도 결과가 같아야 한다. UPSERT, DELETE WHERE, UPDATE WHERE처럼 재실행해도 안전한 쿼리를 쓴다.
로그와 알림 없는 크론잡은 시한폭탄이다. 실행 결과를 항상 기록하고, 실패 시 즉시 알림을 받도록 설정한다.
데이터베이스 용량 알림을 받은 그날 밤 이후로, 크론잡 덕분에 단 한 번도 같은 실수를 반복하지 않았다. 매일 새벽 3시에 코드가 알아서 돌아가고 있다.