pg_cron으로 배치 잡을 PostgreSQL 안에 묻어두면 외부 스케줄러가 사라집니다
사이드 프로젝트를 운영하다 보면 매일 새벽에 만료된 세션을 지워야 하고, 매 시간마다 매출을 집계해야 하고, 매주 쌓인 감사 로그를 정리해야 합니다. 팀 프로젝트라면 거기에 Materialized View 갱신, 결제 실패 알림, 파티션 유지보수까지 추가되죠. 이걸 해결하는 방법으로 보통 세 가지 길이 있었습니다. OS crontab에 스크립트 걸기, AWS Lambda나 Cloud Functions에 scheduled trigger 달기, 아니면 Airflow 같은 오케스트레이터 올리기. 저도 처음엔 이 세 가지 중 하나를 골랐고, 매번 "이게 최선인가?"를 자문하게 됐습니다.
pg_cron은 이 고민을 데이터베이스 레이어에서 끝냅니다. PostgreSQL 내부에서 cron 표현식으로 SQL 명령을 직접 스케줄링하는 익스텐션인데, 별도 데몬도 없고, 서버도 없고, IAM 역할도 없습니다. 잡 정의가 DB 안에 살기 때문에 백업에 자동으로 포함되고, 장애조치 이후에도 그대로 살아납니다.
이 글에서는 pg_cron이 왜 지금 다시 관심을 받는지, 어떤 상황에서 쓰면 좋고 어떤 상황에서는 쓰지 말아야 하는지, 실제로 자주 쓰이는 패턴들을 코드와 함께 살펴봅니다.
pg_cron이 지금 다시 주목받는 이유
외부 스케줄러의 숨겨진 비용
crontab 기반 스크립트는 단순하지만 문제가 있습니다. DB 접속 정보를 어딘가에 관리해야 하고, 서버가 다르면 잡 정의가 흩어지고, 실패 이력을 직접 로깅하지 않으면 언제 실패했는지 알 수가 없습니다.
Lambda나 Cloud Functions는 더 복잡합니다. 함수 코드를 따로 배포해야 하고, DB 접근을 위해 VPC 설정이 필요하고, cold start 때문에 첫 실행이 느릴 수 있습니다. Airflow는 강력하지만 그 자체가 운영 대상이 됩니다.
pg_cron은 이 모든 추가 비용 없이 "SQL로 SQL을 예약한다"는 단순한 원칙을 따릅니다.
| 방식 | 잡 정의 위치 | 추가 인프라 | 접속 정보 관리 | 실행 이력 |
|---|---|---|---|---|
| crontab + 스크립트 | 서버 파일시스템 | 스케줄러용 서버 | 별도 필요 | 직접 로깅 |
| Lambda / Cloud Functions | 함수 코드 저장소 | 함수 런타임, VPC 설정 | Secret Manager 등 | CloudWatch 등 |
| Airflow / Temporal | DAG 코드 | 오케스트레이터 클러스터 | Connection 관리 | 자체 UI |
| pg_cron | DB 내부 (cron.job) |
없음 | 필요 없음 | cron.job_run_details |
매니지드 DB에서 표준이 되고 있다
2026년 기준으로 AWS RDS/Aurora PostgreSQL, Supabase, Neon, Aiven, pgEdge, Google Cloud SQL 모두 pg_cron을 공식 지원합니다. RDS PostgreSQL에서 지원되는 최소 엔진 버전은 AWS 공식 문서에서 확인할 수 있고, 대부분의 매니지드 서비스에서는 파라미터 그룹 설정 한두 개로 켤 수 있는 수준입니다. 이제 "마이너 플러그인"이라는 인상은 지워도 될 것 같습니다.
설치: 생각보다 간단합니다
셀프 호스팅 PostgreSQL이라면 postgresql.conf에 한 줄 추가하고 재시작하면 됩니다.
# postgresql.conf
shared_preload_libraries = 'pg_cron'
# 선택: pg_cron이 사용할 데이터베이스 지정 (기본값: postgres)
cron.database_name = 'myapp'재시작 후 익스텐션을 활성화합니다.
CREATE EXTENSION pg_cron;
-- pg_cron 워커가 사용하는 DB에서 실행해야 함
-- 다른 DB 사용자에게 잡 등록 권한을 주고 싶다면
GRANT USAGE ON SCHEMA cron TO myapp_user;AWS RDS나 Supabase 같은 매니지드 환경에서는 콘솔에서 파라미터 그룹 설정 후 재부팅하고, 그다음 CREATE EXTENSION pg_cron;을 실행하면 됩니다.
자주 쓰이는 시나리오별 코드
데이터 정리: 가장 먼저 달게 되는 잡
-- 만료된 세션 토큰 매일 새벽 2시에 삭제
SELECT cron.schedule(
'expire-sessions',
'0 2 * * *',
$$DELETE FROM user_sessions WHERE expires_at < now()$$
);
-- 감사 로그 90일 보관, 매주 일요일 새벽 1시
SELECT cron.schedule(
'purge-audit-logs',
'0 1 * * 0',
$$DELETE FROM audit_log WHERE created_at < now() - interval '90 days'$$
);
-- 30일 이전 일반 로그 정리
SELECT cron.schedule(
'cleanup-old-logs',
'0 0 * * *',
$$DELETE FROM user_logs WHERE created_at < now() - interval '30 days'$$
);cron 표현식이 헷갈리면 분 시 일 월 요일 순서로 기억하면 됩니다. 저도 처음엔 0 2 * * *이 "2시 0분인지 0시 2분인지" 계속 헷갈렸는데, 왼쪽부터 분이라고 외워두니 해결됐습니다.
Materialized View 갱신: 대시보드 성능의 핵심
-- 5분마다 매출 요약 뷰 갱신 (CONCURRENTLY 옵션으로 락 최소화)
SELECT cron.schedule(
'refresh-sales-summary',
'*/5 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY sales_summary'
);
-- 15분마다 대시보드 통계 갱신
SELECT cron.schedule(
'refresh-dashboard',
'*/15 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY dashboard_stats'
);
-- 매 정시마다 시간별 매출 집계 (upsert 방식)
SELECT cron.schedule(
'aggregate-hourly-sales',
'0 * * * *',
$$
INSERT INTO sales_hourly (hour, total_amount, order_count)
SELECT date_trunc('hour', created_at), SUM(amount), COUNT(*)
FROM orders
WHERE created_at >= now() - interval '2 hours'
GROUP BY 1
ON CONFLICT (hour) DO UPDATE
SET total_amount = EXCLUDED.total_amount,
order_count = EXCLUDED.order_count
$$
);REFRESH MATERIALIZED VIEW CONCURRENTLY를 쓰려면 해당 뷰에 유니크 인덱스가 있어야 합니다. 유니크 인덱스가 없는 상태에서 CONCURRENTLY를 실행하면 PostgreSQL은 조용히 폴백하지 않고 ERROR를 반환합니다. 즉, 잡이 매 실행마다 실패하게 되므로 유니크 인덱스를 먼저 붙이거나, 그럴 수 없는 뷰라면 CONCURRENTLY 없이 별도 잡으로 등록하고 락 경합을 감안한 스케줄을 잡아야 합니다.
알림 트리거: DB에서 직접 이벤트 발행
-- 전날 가입자에게 웰컴 이메일 배치 (매일 오전 9시)
SELECT cron.schedule(
'send-welcome-emails',
'0 9 * * *',
$$
SELECT notify_new_users(user_id)
FROM users
WHERE created_at::date = current_date - 1
AND welcome_sent = false
$$
);
-- 결제 실패 알림 매 10분마다 확인
SELECT cron.schedule(
'alert-failed-payments',
'*/10 * * * *',
'CALL send_payment_failure_alerts()'
);notify_new_users()나 send_payment_failure_alerts() 같은 함수는 DB 함수로 정의하고, 그 안에서 pg_notify를 호출하거나 outbox 테이블에 레코드를 삽입하는 방식으로 외부 시스템과 연결하는 패턴이 많이 쓰입니다.
아웃박스 패턴 + pg_cron: 이벤트 전달의 신뢰성 확보
마이크로서비스 환경에서 별도 Kafka나 SQS 없이 이벤트 전달 신뢰성을 확보하고 싶을 때 Transactional Outbox 패턴을 pg_cron과 조합하는 방식이 많이 쓰입니다.
여기서 짚어둘 점이 하나 있습니다. pg_cron은 순수 SQL 실행기이기 때문에 HTTP 요청 자체를 직접 보내지 못합니다. 아래 다이어그램에서 외부 시스템으로 나가는 경로는 두 가지 중 하나를 선택해야 합니다. (1) DB 안에서 pg_net 익스텐션으로 HTTP를 쏘거나, (2) 외부 애플리케이션 폴러가 outbox를 읽어가는 방식입니다. 여기서는 외부 폴러 방식을 전제로 그렸습니다.
만약 DB 안에서 직접 HTTP를 쏘고 싶다면 process_outbox_batch() 내부에서 pg_net을 호출하는 형태가 되고, 이 경우 pg_cron + pg_net 조합으로 폴러 없이도 처리할 수 있습니다.
-- 30초마다 outbox 배치 처리 (pg_cron 1.4+)
SELECT cron.schedule(
'process-outbox',
'30 seconds',
'CALL process_outbox_batch()'
);초 단위 인터벌은 '30 seconds'처럼 1~59 사이의 정수를 붙여 지정할 수 있습니다. 다만 이 sub-minute 문법은 pg_cron 1.4부터 지원되므로, 구버전에서는 syntax error가 납니다. 매니지드 서비스에서 제공하는 pg_cron 버전을 먼저 확인하시는 게 좋습니다.
DB 유지보수 자동화
-- 주요 테이블 VACUUM ANALYZE (매주 토요일 새벽 3시)
-- 다중 테이블 VACUUM 구문은 PostgreSQL 14+에서 공식 지원
-- 13 이하라면 테이블별로 잡을 나누거나 DO $$ ... $$ 블록을 사용
SELECT cron.schedule(
'weekly-vacuum',
'0 3 * * 6',
'VACUUM ANALYZE orders, order_items, products'
);
-- 커넥션 현황 스냅샷 (5분마다)
SELECT cron.schedule(
'snapshot-connections',
'*/5 * * * *',
$$
INSERT INTO conn_stats
SELECT now(), count(*), state
FROM pg_stat_activity
GROUP BY state
$$
);
-- cron 실행 이력 자동 정리 (매일 자정)
-- 이 잡을 등록 안 하면 job_run_details가 무한정 쌓입니다
SELECT cron.schedule(
'purge-cron-history',
'0 0 * * *',
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'$$
);솔직히 마지막 purge-cron-history 잡은 처음 pg_cron을 쓸 때 등록을 잊어서 나중에 cron.job_run_details 테이블이 조용히 커진 걸 뒤늦게 발견한 적이 있습니다. 처음 설치할 때 같이 등록해 두시면 좋습니다.
잡 모니터링: SQL로 실행 이력을 직접 들여다보기
pg_cron의 가장 편한 점 중 하나는 모든 실행 이력이 DB 안에 있다는 겁니다.
-- 최근 실패한 잡 확인
SELECT jobid, runid, return_message, start_time
FROM cron.job_run_details
WHERE status = 'failed'
ORDER BY start_time DESC;
-- 1일 이상 성공하지 못한 잡 탐지 (잡이 조용히 죽었을 때)
SELECT j.jobname, max(d.end_time) AS last_success
FROM cron.job j
LEFT JOIN cron.job_run_details d
ON d.jobid = j.jobid AND d.status = 'succeeded'
GROUP BY j.jobname
HAVING max(d.end_time) < now() - interval '1 day'
OR max(d.end_time) IS NULL;
-- 등록된 잡 목록 확인
SELECT jobid, jobname, schedule, command, active
FROM cron.job
ORDER BY jobname;이 쿼리들을 모니터링 대시보드나 알림 시스템과 연결해 두면, 특정 잡이 하루 이상 성공하지 못했을 때 슬랙 알림을 받는 것도 어렵지 않습니다.
트레이드오프: pg_cron이 맞는 자리와 아닌 자리
장단점 한눈에
| 항목 | 내용 |
|---|---|
| 운영 단순화 | 별도 데몬, 서버, 에이전트 없음. 인프라 추가 관리 포인트 제거 |
| 잡 영속성 | cron.job이 DB에 저장되므로 재시작·장애조치 후에도 유지. 백업에 자동 포함 |
| SQL 접근성 | 잡 등록·조회·삭제·이력 확인을 모두 SQL로 수행 가능 |
| 이력 감사 | cron.job_run_details로 모든 실행 결과를 SQL로 조회 |
| 단일 DB 제약 | 기본적으로 pg_cron이 설치된 DB에서만 동작. 다른 DB는 cron.schedule_in_database() 필요 |
| 중첩 실행 시 리소스 경합 | 이전 실행이 끝나기 전 다음 스케줄이 오면 병렬로 새 워커가 뜸. max_running_jobs(기본 5) 초과 시 대기, 동일 테이블 락 경합 유의 |
| 복잡한 워크플로 불가 | DAG 기반 순서 의존성, 조건부 실행, 재시도 로직 지원 안 함 |
| 외부 시스템 호출 불가 | HTTP 요청은 직접 불가. pg_net 익스텐션과 조합 필요 |
job_run_details 증가 |
실행 이력이 무제한 축적되므로 별도 정리 잡 필요 |
| 타임존 제한 | 기본 UTC 기준. 타임존 세밀 제어는 pg_timetable이 더 유연 |
도구 선택 기준
스케줄러 비교
| 도구 | 특징 | 적합한 상황 |
|---|---|---|
| pg_cron | DB 내장, 설정 최소, SQL 기반 | 단순 반복 SQL 작업 |
| pg_timetable | Go 기반 외부 바이너리, DAG 체인·조건부 실행·타임존 지원 | DB 내 복잡한 워크플로 |
| pgAgent | pgAdmin 프로젝트, GUI 기반, 외부 데몬 필요 | 레거시 환경 (새 프로젝트엔 비권장) |
| Apache Airflow | Python DAG, 대규모 파이프라인 표준 | 멀티 시스템 오케스트레이션 |
| Temporal | 워크플로 내구성 보장 | 마이크로서비스 오케스트레이션 |
실무에서 흔한 실수
1. 무거운 ETL을 pg_cron에 걸기
pg_cron은 가벼운 SQL 작업에 적합합니다. 수백만 건을 처리하는 배치가 다음 실행 주기보다 오래 걸리면, 두 번째 인스턴스가 병렬로 시작되면서 동일 테이블에 대한 락 경합이 발생하거나 max_running_jobs 한도를 소진할 수 있습니다. 이런 경우 배치 크기를 작게 나눠서 여러 번 실행하거나, 처음부터 외부 오케스트레이터를 고려하는 편이 낫습니다.
2. job_run_details 정리 잡 미등록
앞에서도 언급했지만, 이 잡은 반드시 같이 등록해야 합니다. 빠뜨리면 이력 테이블이 조용히 커집니다.
3. 타임존 착각
pg_cron은 기본적으로 UTC 기준으로 동작합니다. 한국 시간 오전 9시에 실행하고 싶다면 cron 표현식에 0 0 * * *(UTC 0시 = KST 9시)를 써야 합니다. 서버 타임존 설정에 의존하지 않는 게 안전합니다.
마무리
pg_cron은 "외부에 있던 걸 DB 안으로 가져온다"는 단순한 아이디어입니다. 그 단순함 덕분에 운영 복잡도가 줄고, 잡 정의가 데이터와 같은 생명주기를 가지게 됩니다.
정리하자면 pg_cron이 제 역할을 하는 곳은 단순한 SQL 반복 작업입니다. 데이터 정리, Materialized View 갱신, 통계 집계, DB 유지보수, 경량 알림 트리거 같은 것들이죠. 반대로 DAG 의존성이 있는 복잡한 파이프라인, 멀티 시스템 오케스트레이션, 정교한 재시도 정책이 필요한 상황에서는 처음부터 Airflow나 Temporal을 보는 게 맞습니다.
이미 PostgreSQL을 쓰고 있다면, 외부 스케줄러를 추가하기 전에 pg_cron이 충분한지 먼저 확인하세요.
참고 자료
- GitHub - citusdata/pg_cron — 공식 소스 코드 및 README
- pg_cron - PostgreSQL Extension Analysis, CMU VLDB 2025
- Scheduling maintenance with pg_cron - Amazon RDS Docs
- The pg_cron extension - Neon Docs
- Postgres as a CRON Server - Supabase Blog
- How to Schedule Jobs in PostgreSQL with pg_cron - freeCodeCamp
- Evolution of PostgreSQL Job Schedulers — PGConf 2025
- PostgreSQL schedulers: comparison table - CYBERTEC
- Using and monitoring pg_cron - Postgres Hashnode
- How to Use pg_cron in Cloud SQL PostgreSQL (2026) - OneUptime