crontab 명령어 사용법: cron 표현식으로 작업 예약하기

crontab 명령어 사용법: cron 표현식으로 작업 예약하기

설정 파일을 읽다가 30 4 1,15 * 5 같은 줄을 마주친 적 있으신가요? 별표와 숫자가 다섯 자리로 늘어선 이 표기법은 백업 스크립트, CI 워크플로우, 서버리스 스케줄러까지 안 끼는 데가 없는데, 정작 볼 때마다 검색창을 다시 여는 분이 많습니다. 저도 그랬어요 😅

이 표기법의 이름은 cron 표현식입니다. 1975년 유닉스의 cron 데몬에서 태어나 40년 넘게 살아남았고, 그 사이 원래 살던 유닉스 서버를 한참 벗어나 온갖 도구의 스케줄링 문법으로 퍼졌습니다. 이번 포스팅에서는 리눅스의 crontab 명령어로 작업을 예약하는 방법부터 시작해서, 다섯 자리를 읽는 법과 자주 발목 잡히는 함정, 그리고 요즘 도구가 이 문법을 어떻게 변형해서 쓰는지까지 정리해 보겠습니다.

cron과 crontab은 다릅니다

먼저 헷갈리기 쉬운 두 이름을 갈라놓고 가겠습니다.

cron은 백그라운드에서 도는 데몬(daemon) 프로세스입니다. 1분에 한 번씩 깨어나 “지금 실행할 작업이 있나?”를 확인하고, 있으면 실행한 뒤 다시 잠듭니다. ps 명령어로 확인해 보면 시스템에서 조용히 돌고 있는 걸 볼 수 있어요.

crontab은 그 데몬이 읽는 설정 파일의 이름이자, 그 파일을 편집하는 명령어의 이름입니다. cron table의 줄임말이고요. 즉 우리는 crontab 명령어로 표를 채워두고, cron 데몬이 그 표를 보고 대신 일해주는 구조입니다.

파일을 직접 열어 고치지 않고 굳이 명령어를 거치는 이유가 있습니다. crontab 명령어는 저장하기 전에 문법을 검사하고, 데몬에게 파일이 바뀌었다고 알려줍니다. 파일을 직접 편집하면 이 두 가지를 놓쳐서 조용히 아무 일도 일어나지 않는 상황을 만나게 돼요.

crontab 명령어로 작업 등록하기

실제로 쓰는 옵션은 세 개뿐입니다. 먼저 현재 등록된 작업을 확인해 보겠습니다.

crontab -l
결과
crontab: no crontab for dale

아직 아무것도 없네요. 등록하려면 -e 옵션으로 편집기를 엽니다.

crontab -e

편집기가 뜨면 한 줄에 작업 하나씩 적습니다. 앞의 다섯 자리가 시각이고, 여섯 번째 자리부터 줄 끝까지가 실행할 명령어입니다.

crontab
0 4 * * * /usr/local/bin/backup.sh

매일 새벽 4시 정각에 백업 스크립트를 돌리라는 뜻입니다. 저장하고 나오면 등록이 끝나요.

여기서 주의할 옵션이 하나 있습니다. -r은 등록된 작업을 전부 지우는데, 대부분의 시스템에서 확인 없이 즉시 지웁니다. -e와 -r은 키보드에서 바로 옆자리라 오타 한 번에 몇 년치 설정이 날아갈 수 있어요 💥 백업부터 해두는 습관을 들이는 편이 안전합니다.

crontab -l > ~/crontab.bak

다섯 필드 읽는 법

이제 본론입니다. cron 표현식은 공백으로 구분된 다섯 자리이고, 왼쪽부터 작은 단위에서 큰 단위로 올라갑니다.

* * * * *
│ │ │ │ │
│ │ │ │ └── 요일 (0-7)
│ │ │ └──── 월 (1-12)
│ │ └────── 일 (1-31)
│ └──────── 시 (0-23)
└────────── 분 (0-59)

각 필드가 받는 값의 범위를 정리하면 이렇습니다.

자리필드허용 값이름 별칭
1분0-59없음
2시0-23없음
3일1-31없음
4월1-12jan-dec
5요일0-7sun-sat

요일 필드가 유독 이상합니다. 0부터 7까지 여덟 개 값을 받는데 요일은 일곱 개니까요. 0과 7이 둘 다 일요일입니다. BSD 계열과 AT&T 계열이 서로 다르게 구현하는 바람에 양쪽을 모두 받아주게 된 흔적이에요.

읽는 순서는 오른쪽부터가 편합니다. 0 4 * * *이라면 요일과 월과 일이 모두 *니까 날짜 제한이 없고, 시가 4, 분이 0이니 “매일 4시 0분”이 됩니다.

네 가지 연산자

각 필드에 숫자 하나만 넣을 수 있다면 표현력이 형편없겠죠. 네 가지 연산자로 여러 값을 한 번에 지정할 수 있습니다.

*는 해당 필드의 모든 값을 뜻합니다. 시 필드에 쓰면 0시부터 23시까지 전부죠.

-는 범위이고 양 끝을 포함합니다. 시 필드의 9-18은 9, 10, …, 18시입니다.

,는 나열입니다. 분 필드의 0,30은 정각과 30분이고, 1-3,7-9처럼 범위와 섞어 쓸 수도 있습니다.

/는 간격입니다. */15는 0, 15, 30, 45가 되고, 0-23/2처럼 범위 뒤에 붙이면 그 범위 안에서만 건너뜁니다.

조합해 보면 이렇게 됩니다.

*/10 9-18 * * 1-5    평일 업무 시간에 10분마다
0 0,12 * * *         매일 자정과 정오
15 3 1 * *           매월 1일 새벽 3시 15분
0 22 * * 6           토요일 밤 10시

/ 연산자에는 함정이 하나 숨어 있습니다. */7을 분 필드에 쓰면 “7분마다”처럼 읽히지만 실제로는 그렇지 않아요. 0, 7, 14, …, 56까지 갔다가 다음 시간의 0분으로 넘어가는데, 56분에서 0분 사이는 4분밖에 안 됩니다. 간격이 매시간 한 번씩 어그러지는 셈이죠. 60의 약수(2, 3, 4, 5, 6, 10, 12, 15, 20, 30)를 쓰면 이런 일이 없습니다.

매크로로 짧게 쓰기

자주 쓰는 주기에는 다섯 자리 대신 @로 시작하는 별칭을 쓸 수 있습니다.

  • @yearly 또는 @annually — 0 0 1 1 *, 매년 1월 1일 자정
  • @monthly — 0 0 1 * *, 매월 1일 자정
  • @weekly — 0 0 * * 0, 매주 일요일 자정
  • @daily 또는 @midnight — 0 0 * * *, 매일 자정
  • @hourly — 0 * * * *, 매시 정각
  • @reboot — 시스템이 부팅될 때 한 번

앞의 다섯 개는 그냥 축약이라 취향껏 쓰면 되는데, @reboot은 성격이 다릅니다. 시각이 아니라 사건에 반응하는 유일한 항목이라 다른 표현식으로 바꿔 쓸 수 없어요. 대신 이식성이 떨어지니 서비스 자동 시작 용도라면 systemd 같은 전용 도구를 쓰는 편이 낫습니다.

함정 1: 일과 요일은 AND가 아니라 OR입니다

가장 많이 틀리는 지점입니다. 다섯 필드가 전부 AND로 묶인다고 생각하기 쉽지만, 일 필드와 요일 필드만은 예외예요.

man 5 crontab이 직접 못을 박아 둡니다. 분과 시, 월이 일치하고, 두 날짜 필드 중 적어도 하나가 일치하면 실행한다고요. 즉 일과 요일을 둘 다 *가 아니게 지정하는 순간 두 조건이 OR로 묶입니다.

맨 페이지가 드는 예가 이 글 맨 앞에 나왔던 그 표현식입니다.

30 4 1,15 * 5

“매월 1일과 15일 중에서 금요일인 날 새벽 4시 30분”으로 읽으셨다면 틀렸습니다. 실제로는 “매월 1일과 15일 새벽 4시 30분, 그리고 모든 금요일 새벽 4시 30분”입니다. 한 달에 두 번 돌 줄 알았던 작업이 예닐곱 번 돌게 되는 거죠.

그래서 “매월 첫 번째 월요일”처럼 두 조건을 동시에 만족시키는 일정은 cron 표현식만으로 표현할 수 없습니다. 날짜 필드를 넓게 잡아두고 명령어 쪽에서 걸러내는 게 정석입니다.

0 4 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/monthly.sh

1일부터 7일까지 매일 깨어나되, 그날이 월요일일 때만 실제로 실행하는 방식입니다. 매월 1일부터 7일 사이에는 월요일이 반드시 하나뿐이니 원하는 결과가 나옵니다.

그런데 이 줄에는 백슬래시가 하나 붙어 있죠. 이게 다음 함정입니다.

함정 2: %는 명령어에서 특별한 문자입니다

cron은 명령어 부분의 %를 줄바꿈으로 바꿔버립니다. 첫 번째 % 뒤의 내용은 명령어의 표준 입력으로 넘어가고요. 원래는 짧은 입력을 편하게 넘기라고 만든 기능인데, date 명령어와 만나면 사고가 납니다.

0 4 * * * echo "backup $(date +%Y-%m-%d)" >> /var/log/backup.log

터미널에서는 멀쩡히 도는 이 줄이 crontab 안에서는 date +에서 끊깁니다. 백슬래시로 이스케이프해야 의도대로 동작해요.

0 4 * * * echo "backup $(date +\%Y-\%m-\%d)" >> /var/log/backup.log

터미널에서 잘 되던 명령어를 그대로 붙여 넣었는데 이상하게 동작한다면 %부터 찾아보세요.

함정 3: cron의 환경은 로그인 셸이 아닙니다

cron이 명령어를 실행할 때 쓰는 환경은 우리가 터미널에서 쓰는 환경과 다릅니다. SHELL은 /bin/sh로 고정되고, HOME과 LOGNAME은 계정 정보에서 채워지며, PATH는 아주 짧게 잡힙니다. .zshrc나 .bashrc 같은 셸 설정 파일은 읽지 않아요.

그래서 터미널에서 node나 bun이 잘 돌아간다고 crontab에서도 돌 거라 기대하면 안 됩니다. 버전 관리자로 설치한 실행 파일은 PATH에 없어서 command not found로 끝나기 십상이거든요.

해결책은 두 가지입니다. 하나는 명령어를 절대 경로로 적는 방법이고,

0 4 * * * /Users/dale/.bun/bin/bun run /Users/dale/jobs/report.ts

다른 하나는 crontab 맨 위에서 PATH를 직접 지정하는 방법입니다. crontab 파일 안에서는 이름=값 형식으로 환경 변수를 정의할 수 있어요.

crontab
PATH=/usr/local/bin:/usr/bin:/bin:/Users/dale/.bun/bin
MAILTO=""

0 4 * * * bun run /Users/dale/jobs/report.ts

MAILTO도 알아두면 좋습니다. cron은 작업의 출력을 메일로 보내는데, 메일 설정이 없는 요즘 환경에서는 그 출력이 그냥 사라집니다. 작업이 실패해도 아무도 모르는 거죠. MAILTO=""로 메일을 끄고, 대신 출력 리다이렉션으로 로그 파일에 남기는 편이 훨씬 실용적입니다.

0 4 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

2>&1을 빼먹으면 표준 출력만 남고 에러 메시지는 사라지니 꼭 같이 적어주세요. 디버깅할 때 가장 아쉬운 게 바로 그 에러 메시지입니다.

함정 4: 클라우드의 cron은 대개 UTC로 돕니다

마지막은 시간대입니다. 리눅스 서버의 cron은 보통 시스템 시간대를 따르지만, 클라우드 환경에서는 대부분 UTC로 고정돼 있습니다.

한국(KST)은 UTC보다 9시간 빠르니, “매일 아침 9시에 돌려야지”라고 0 9 * * *을 적으면 실제로는 한국 시각 오후 6시에 돌아갑니다. 9를 빼서 0 0 * * *으로 적어야 아침 9시가 돼요. 자정을 넘어가면 날짜까지 하루 밀리니 일 필드를 함께 쓸 때는 더 조심해야 합니다.

서머타임을 쓰는 지역이라면 문제가 더 복잡합니다. 시계를 앞당기는 날에는 새벽 2시대가 통째로 사라져서 그 시간에 걸린 작업을 통째로 건너뛰고, 되돌리는 날에는 같은 시간이 두 번 와서 작업이 두 번 실행될 수 있어요. 한국은 서머타임이 없어서 겪을 일이 없지만, 해외 리전에 서버를 둔다면 새벽 1시에서 3시 사이는 피해서 잡는 게 안전합니다.

플랫폼마다 방언이 조금씩 다릅니다

여기까지가 원조 cron의 문법인데, 이 표기법이 워낙 널리 퍼지다 보니 도구마다 조금씩 다르게 구현했습니다. 아래 목록은 글을 쓴 뒤에 나온 도구까지 더해 가며 갱신하고 있어요. 다섯 자리 기본 골격은 어디서나 같으니 새 도구를 만날 때는 차이점만 확인하면 됩니다.

GitHub Actions의 schedule 트리거는 UTC로 고정되고 최소 간격이 5분입니다. */1 * * * *을 적어도 1분마다 돌지 않아요.

Cloudflare Cron Triggers도 UTC 기준이고, 상시 서버 없이 Workers 함수를 깨우는 방식이라 실행 시간에 제약이 따로 있습니다.

Claude Code의 예약 작업은 표준 다섯 자리를 쓰되 mon이나 jan 같은 이름 별칭을 받지 않습니다. 숫자로만 적어야 해요.

Bun 1.4의 Bun.cron은 표준 표현식을 그대로 쓰면서 실행만 운영체제에 맡깁니다. 리눅스에서는 crontab, 맥에서는 launchd에 등록하는 식이죠.

자바 진영에서 널리 쓰는 Quartz는 아예 여섯 자리입니다. 맨 앞에 초 필드가 하나 더 붙고, “그달의 마지막 날”을 뜻하는 L이나 “가장 가까운 평일”인 W 같은 확장 문자를 지원해요. Spring의 @Scheduled도 이 여섯 자리 형식을 씁니다. 앞서 본 OR 함정을 피하려고 ?로 “지정 안 함”을 표시하는 것도 Quartz 계열의 특징입니다.

이름 별칭도 이식성이 좋지 않습니다. 맥과 BSD의 cron은 mon 같은 낱개 이름은 받지만 mon-fri 같은 범위나 나열은 거부하는데, 리눅스에서 흔한 Vixie cron은 받아줍니다. 여러 환경에 같은 설정을 배포한다면 그냥 숫자로 1-5라고 적는 편이 마음 편합니다.

마치며

지금까지 cron 표현식을 읽는 법과 crontab 명령어 사용법을 알아봤습니다.

다섯 자리 자체는 30분이면 익히는 문법인데, 정작 사람을 괴롭히는 건 문법 바깥에 있다는 게 재미있는 지점입니다. 일과 요일이 OR로 묶인다는 사실, %가 명령어를 잘라먹는다는 사실, cron의 PATH가 우리 터미널과 다르다는 사실은 표현식을 아무리 들여다봐도 안 보이거든요. 새 작업을 등록했다면 표현식만 검토하지 말고, 로그 파일을 열어 실제로 그 시각에 돌았는지부터 확인하는 습관을 들이시면 좋겠습니다.

터미널에서 손에 익혀두면 좋은 다른 명령어가 궁금하시면 CLI 관련 글도 준비해 두었습니다.

더 자세한 명세는 man 5 crontab으로 확인할 수 있고, 웹에서는 crontab.guru에 표현식을 붙여 넣으면 사람이 읽는 문장으로 풀어서 보여줍니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord