발행일

주간 스케줄링 — 로컬 crontab vs Render Cron Job 비교

주간 스케줄링 — 로컬 crontab vs Render Cron Job 비교

PyNews 프로젝트에서는 매주 월요일 오전 9시에 RSS 피드를 크롤링해 DB에 적재해야 했습니다. 개발 초기에는 로컬 Mac에서 crontab으로 돌리다가 프로덕션 배포 시점에 Render Cron Job으로 옮겼습니다.

같은 목적을 두 방식으로 구현하면서 느낀 차이, 그리고 나중에 다시 읽으며 setup_cron.sh에서 찾은 결함 다섯 개를 적습니다.


1. 공통 부분 — 실행하는 명령은 동일

두 방식 모두 결국 같은 Django management command를 호출합니다.

python manage.py crawl_news --days=7

스케줄러는 "언제, 어디서, 어떤 환경에서" 이 명령을 실행할지만 정합니다. 크롤링 로직은 똑같아요.

이게 이관이 쉬웠던 유일한 이유입니다. 작업을 스케줄러가 아니라 명령에 담아뒀기 때문에, 스케줄러를 바꾸는 게 "누가 이 명령을 부르나"를 바꾸는 일로 끝났습니다. 크롤링 코드를 크론 스크립트 안에 인라인으로 썼다면 이관이 재작성이 됐을 거예요.

스케줄러는 갈아탈 것을 전제로 고른다. 그러려면 작업이 스케줄러와 독립적으로 실행 가능해야 합니다.


2. 후보를 넷 놓고 봤다

방법언제 도나기계가 꺼지면비용판단
① 로컬 crontab시스템 로컬타임안 돎. 지나간 실행은 건너뜀0개발용으로 채택
② macOS launchd로컬타임잠자기였다면 깨어날 때 실행됨(StartCalendarInterval)0밀린 실행을 되살리는 건 크롤러엔 오히려 애매
③ GitHub Actions scheduleUTC무관퍼블릭 무료DB 접속 정보를 GitHub에 넣어야 함. 기각
④ Render type: cronUTC무관유료 플랜프로덕션 채택

②는 실제로 검토하다 접었습니다. macOS에서 cron은 사실상 레거시고 launchd가 정석이에요. 그런데 launchd는 잠자기 동안 놓친 스케줄을 깨어날 때 한 번 실행합니다. 백업이라면 좋은 성질인데, 크롤러는 "월요일 아침 상태"를 찍는 작업이라 목요일에 뒤늦게 도는 게 딱히 의미가 없었어요. 그래서 밀린 실행을 안 되살리는 crontab의 성질이 오히려 맞았습니다.

③은 무료라 매력적이었지만, 크롤러가 프로덕션 DB에 써야 해서 DB 접속 정보를 GitHub Secrets에 올려야 합니다. 스케줄러 하나 아끼자고 자격증명이 사는 곳을 하나 늘리는 건 손해라고 봤습니다.


3. 로컬 crontab — setup_cron.sh

프로젝트 루트의 스크립트를 한 번 실행하면 사용자 crontab에 작업이 등록됩니다.

#!/bin/bash
# 매주 월요일 오전 9시에 크롤링 실행
# 사용법: bash setup_cron.sh

PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
PYTHON="$PROJECT_DIR/venv/bin/python"
MANAGE="$PROJECT_DIR/manage.py"
LOG="$PROJECT_DIR/crawl.log"

CRON_CMD="0 9 * * 1 cd $PROJECT_DIR && $PYTHON $MANAGE crawl_news --days=7 >> $LOG 2>&1"

# 기존 크론에 이미 등록되어 있는지 확인
if crontab -l 2>/dev/null | grep -q "crawl_news"; then
    echo "이미 등록된 크론 작업이 있습니다."
    crontab -l | grep "crawl_news"
else
    (crontab -l 2>/dev/null; echo "$CRON_CMD") | crontab -
    echo "크론 작업이 등록되었습니다:"
    echo "$CRON_CMD"
fi

의도한 설계 포인트는 셋이었습니다.

  • 절대경로. PYTHON="$PROJECT_DIR/venv/bin/python"으로 venv를 직접 가리킵니다. cron은 로그인 셸이 아니라 PATH가 최소한이라, which python을 기대하면 실패합니다.
  • 중복 등록 방지. grep -q "crawl_news"로 이미 있는지 먼저 봅니다.
  • 로그 리다이렉션. >> $LOG 2>&1. cron은 기본적으로 결과를 메일로 보내는데, 로컬 Mac에는 메일 서버가 없어서 파일이 실용적입니다.

4. 다시 읽으며 찾은 결함 다섯

① "중복 등록 방지"가 아니라 "갱신 불가"다

if crontab -l 2>/dev/null | grep -q "crawl_news"; then
    echo "이미 등록된 크론 작업이 있습니다."

스케줄을 바꾸거나 프로젝트를 다른 경로로 옮긴 다음 이 스크립트를 다시 돌리면, "이미 등록되어 있습니다"만 출력하고 옛 항목을 그대로 둡니다.

즉 이 스크립트로는 등록만 되고 수정이 안 됩니다. 사용자는 "등록됐다"는 메시지를 보고 새 설정이 반영됐다고 믿는데, 실제로 도는 건 옛날 경로예요. 프로젝트를 옮긴 뒤라면 없는 경로를 가리키는 크론이 매주 조용히 실패합니다.

원래 하려던 건 "같은 항목이 여러 줄 쌓이지 않게"였는데, 그건 지우고 다시 넣는 것으로 해야 합니다.

# 기존 항목을 지우고 다시 넣는다 (등록 = 갱신)
( crontab -l 2>/dev/null | grep -v "crawl_news"; echo "$CRON_CMD" ) | crontab -

멱등성을 "이미 있으면 아무것도 안 함"으로 구현하면, 값이 바뀌었을 때 조용히 옛 값이 남습니다. 멱등은 "여러 번 돌려도 결과가 같다"지 "두 번째부터는 안 한다"가 아니에요.

PATH만 없는 게 아니다

글에는 "PATH가 최소한이라"고만 적었는데, cron이 안 주는 건 PATH만이 아닙니다. ~/.zshrc~/.bash_profile을 아예 읽지 않습니다. 거기서 export 한 변수는 전부 없어요.

0 9 * * 1 cd $PROJECT_DIR && $PYTHON $MANAGE crawl_news --days=7 >> $LOG 2>&1

cd $PROJECT_DIR이 있어서 프로젝트 안의 .env 파일을 상대경로로 읽는 경로는 살아납니다. python-dotenvdjango-environ.env를 읽는다면 동작해요. 반대로 셸 rc에 export해둔 값에 의존하는 설정이 하나라도 있으면 크론에서만 죽습니다.

이게 "로컬에서는 되는데 크론에서만 안 되는" 사고의 대부분입니다. 확인은 간단해요.

# 크론과 비슷한 빈 환경에서 돌려본다
env -i HOME="$HOME" /bin/sh -c 'cd /path/to/project && ./venv/bin/python manage.py crawl_news --days=7'

크론 스크립트를 짰으면 이 한 줄로 먼저 때려봐야 합니다. 매주 월요일까지 기다려서 로그를 확인하는 건 피드백 루프가 일주일짜리예요.

③ 한글 출력이 안 터지는 건 우리 덕이 아니다

크롤러는 수집한 글 제목을 stdout에 찍고, 그게 crawl.log로 갑니다. 제목에는 한글이 섞여 있고요.

cron의 기본 로케일은 C(또는 미설정)입니다. 파이썬의 stdout 인코딩은 로케일을 따르기 때문에, 원래대로면 이렇게 됩니다.

$ env -u LANG -u LC_CTYPE LC_ALL=C PYTHONCOERCECLOCALE=0 PYTHONUTF8=0 \
    python3 -c "import sys; print(sys.stdout.encoding); print('한글 제목')"
ascii
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

지금 안 터지는 이유는 Python 3.7+가 C 로케일을 자동으로 UTF-8로 강제(PEP 538/540)해주기 때문입니다.

$ env -u LANG -u LC_CTYPE LC_ALL=C python3 -c "import sys; print(sys.stdout.encoding); print('한글 제목')"
utf-8
한글 제목

우리가 대비해서가 아니라 인터프리터가 막아주고 있는 것이고, 이 강제 동작은 환경변수로 꺼집니다. 한글을 찍는 크론 잡이라면 명시하는 게 맞습니다.

CRON_CMD="0 9 * * 1 cd $PROJECT_DIR && LANG=ko_KR.UTF-8 PYTHONIOENCODING=utf-8 $PYTHON $MANAGE crawl_news --days=7 >> $LOG 2>&1"

"지금 안 터진다"와 "안 터지게 해뒀다"를 구분해서 적어야 합니다. 전자는 런타임이 바뀌면 터져요.

④ 로그가 무한히 자란다

LOG="$PROJECT_DIR/crawl.log"
# ... >> $LOG 2>&1

>>로 계속 붙이기만 하고 자르는 곳이 없습니다. 주 1회에 몇 KB라 몇 년은 괜찮지만, 크롤링이 실패해서 스택 트레이스가 매주 쌓이기 시작하면 얘기가 달라집니다. 그리고 그런 상황이 정확히 로그를 안 보고 있는 상황이에요.

logrotate를 붙이거나, 최소한 크론 명령에서 잘라야 합니다.

# 실행 전에 1MB 넘으면 한 번 밀어둔다
[ -f "$LOG" ] && [ $(wc -c < "$LOG") -gt 1048576 ] && mv "$LOG" "$LOG.1"

⑤ 실패해도 아무도 모른다

>> $LOG 2>&1에러를 파일에 숨기는 것이기도 합니다. cron의 기본 동작(메일 발송)을 껐으니까요. 로컬 Mac에 메일 서버가 없다는 이유는 타당했지만, 대체 통보 수단을 안 만든 채로 끄기만 했습니다.

주 1회 작업이 3주 연속 실패해도 로그를 열어보기 전엔 모릅니다. 최소한 종료 코드를 보고 뭐라도 남겨야 해요.

... || echo "$(date '+%F %T') crawl_news FAILED (exit $?)" >> "$PROJECT_DIR/crawl-failures.log"

실패만 따로 모으는 파일 하나가, 전체 로그보다 실용적입니다. 열었을 때 비어 있으면 그게 정상이라는 신호니까요.


5. Render Cron Job — render.yaml

프로덕션에서는 Render의 type: cron 서비스로 대체했습니다.

- type: cron
  name: pynews-crawler
  runtime: python
  plan: starter
  schedule: "0 0 * * 1"
  buildCommand: pip install -r requirements.txt
  startCommand: python manage.py crawl_news --days=7
  envVars:
    - key: DATABASE_URL
      fromDatabase:
        name: pynews-db
        property: connectionString

구조는 crontab보다 오히려 단순합니다. "언제"와 "무엇을"만 있고, 4절의 결함 다섯 중 넷이 사라집니다.

setup_cron.sh의 문제Render에서는
① 갱신 불가render.yaml이 선언적이라 파일이 곧 상태. 커밋하면 반영
② 환경변수 부재envVars로 명시 주입
③ 로케일컨테이너 이미지가 UTF-8 기준
④ 로그 무한 증가실행별 로그를 플랫폼이 관리
⑤ 실패 통보여전히 없음. 대시보드를 봐야 함

⑤만 남습니다. Render도 실행 실패를 알아서 알려주진 않아요.

Render의 강점

  • 언제든 실행됩니다. 스케줄에 맞춰 컨테이너를 띄우고 끝나면 종료합니다. 내 노트북 상태와 무관해요.
  • 환경변수 주입이 선언적입니다. fromDatabase로 DB 문자열을 받습니다.
  • 수동 트리거가 됩니다. "Trigger Run"으로 즉시 실행할 수 있어서 배포 직후 확인이 쉽습니다. 이게 로컬 크론에는 없는 성질이에요 — 크론 항목을 손으로 실행하려면 명령을 복사해서 따로 쳐야 하고, 그건 크론이 도는 환경이 아닌 내 셸에서 도는 것이라 검증이 안 됩니다.

한계

  • 비용. free 플랜에서는 cron 서비스가 지원되지 않아 유료 플랜이 필요합니다.
  • UTC 기준. 다음 절.
  • 로그 접근성. 대시보드를 열어야 합니다. tail -f만큼 즉각적이진 않아요.
  • 재시도 없음. 실패하면 다음 주까지 그냥 안 돕니다.

6. 같은 크론 표현식이지만 의미가 다르다

이관할 때 제일 헷갈린 부분입니다.

# 로컬 crontab — 시스템 로컬타임 기준 (KST로 세팅된 Mac이면 월요일 9시)
0 9 * * 1

# Render cron — UTC 고정 (KST 월요일 9시를 만들려면 UTC 00:00)
0 0 * * 1

같은 "월요일 오전 9시 KST"인데 표현이 다릅니다. 로컬은 시스템 타임존을 따르고, Render는 UTC 고정이에요.

여기에 함정이 하나 더 있습니다. UTC로 고정하면 서머타임 때문에 현지 시각이 흔들리는 걸 막을 수 있는 대신, 현지 기준으로 "매주 같은 시각"이 아닌 지역이 생깁니다. 한국은 서머타임이 없어서 이 문제를 안 겪지만, 나중에 다른 타임존 사용자를 상대하게 되면 "월요일 오전"이라는 약속 자체를 다시 정의해야 해요.

지금 이 프로젝트에서는 주석 한 줄이 최선의 방어였습니다.

# UTC 기준. KST 월요일 09:00 = UTC 월요일 00:00
schedule: "0 0 * * 1"

7. 두 방식을 모두 유지한 이유

setup_cron.sh를 Render로 옮긴 뒤에도 지우지 않았습니다.

  • 로컬에서 크롤링 로직을 개발·테스트할 때 여전히 유용합니다.
  • Render cron은 주기가 길어서(주 1회) 개발 중에 반복 실행하기 어렵습니다.
  • 같은 명령을 로컬과 프로덕션 양쪽에서 돌려볼 수 있다는 것 자체가 management command 방식의 이점입니다.

다만 이건 "둘 다 유지"라기보다 "지우지 않았다"에 가깝습니다. 정직하게 말하면 setup_cron.sh는 4절의 결함 다섯을 안은 채로 저장소에 남아 있고, 다음에 이걸 쓰는 사람은 그걸 모릅니다. 남길 거면 고쳐서 남기든지, 개발용이라고 파일 상단에 쓰든지 해야 합니다.


8. 선택 기준

상황추천근거
개인 프로젝트, 항상 켜진 서버 없음Render Cron Job기계 상태와 무관
이미 24시간 운영 중인 VPS가 있음crontab(리눅스) / systemd timer인프라 추가 없음
macOS에서 상시 스케줄이 필요launchd잠자기 중 놓친 실행을 깨어날 때 처리
개발 중 반복 실행·디버깅수동 실행 + --dry-run크론을 거칠 필요 자체가 없음
프로덕션 데이터 적재Render Cron Job환경 선언적, 로그 보존
자격증명이 필요 없는 작업GitHub Actions schedule무료. DB 접속이 필요하면 부적합

마지막 두 줄이 이번에 배운 기준입니다. 스케줄러 선택은 "얼마나 자주 도나"가 아니라 "그 작업이 무엇에 접근해야 하나"로 갈립니다.


정리

crontab과 Render Cron Job은 같은 명령을 돌리지만, 내 머신의 상태에 의존하느냐가 가장 큰 차이였습니다. 로컬 crontab은 빠른 개발과 디버깅에, Render Cron Job은 프로덕션 안정성에 강점이 있고, Django management command로 로직을 분리해두면 두 방식 모두 같은 코드로 돌아갑니다.

다시 읽으며 얻은 것:

  • 멱등을 "이미 있으면 안 함"으로 구현하면 갱신이 막힌다. 지우고 다시 넣는 게 맞다.
  • cron은 PATH만 안 주는 게 아니라 셸 rc를 안 읽는다. env -i로 먼저 때려본다.
  • "지금 안 터진다"와 "안 터지게 해뒀다"는 다르다. 한글 출력은 인터프리터가 봐주고 있었다.
  • 로그를 파일로 돌렸으면 자르는 것과 실패 통보를 같이 만든다. 리다이렉션은 에러를 숨기는 일이기도 하다.
  • 스케줄러는 갈아탈 것을 전제로 고르고, 작업은 스케줄러 밖에 둔다.

crawl_news 명령이 실제로 어떻게 데이터를 수집하는지는 다음 글에 적었습니다. → feedparser + BeautifulSoup4로 RSS 크롤링 파이프라인 만들기