0 9 * * 1-5 를 보고 "평일 오전 9시"가 바로 읽히면 크론은 다 아는 거예요. 그런데 막상 직접 쓰려고 하면 "요일 0이 일요일이었나 월요일이었나", "*/5 는 5분 뒤부터인가 정각부터인가" 같은 게 헷갈리고, 배포하고 나서야 아침 9시 알림이 저녁 6시에 오더라고요. 결론부터 말하면 크론 표현식은 분 시 일 월 요일 다섯 칸이고, 각 칸에 *(전부), ,(목록), -(범위), /(간격)를 조합해 써요. 그리고 실수의 대부분은 문법이 아니라 타임존(서버는 UTC, 나는 KST)과 "일·요일을 같이 쓰면 OR" 규칙에서 나와요.
이 글에서는 다섯 필드를 읽는 법, 특수 문자의 정확한 의미, 날짜와 요일 OR 규칙의 함정, UTC·KST 변환, 바로 쓰는 예제 20개, 크론 환경에서 자주 겪는 실수, 그리고 리눅스 crontab·systemd timer·GitHub Actions·쿠버네티스 CronJob이 어떻게 다른지 정리할게요. 표현식을 넣고 다음 실행 시각을 바로 확인하고 싶다면 크론 표현식 해석기를 같이 열어 두세요.
핵심 요약
크론은
분(0-59) 시(0-23) 일(1-31) 월(1-12) 요일(0-7)순서예요. 요일 0과 7은 둘 다 일요일이에요.
*/15는 0분부터 15분 간격(0·15·30·45분),1-5는 범위,1,15는 목록이에요.일과 요일을 둘 다 지정하면 AND가 아니라 OR이에요.
0 9 13 * 5는 "13일의 금요일"이 아니라 "매월 13일 또는 매주 금요일"이라 2026년에 61번 실행돼요.서버·컨테이너·GitHub Actions는 보통 UTC예요. KST 오전 9시는 UTC 0시라서
0 0 * * *로 적어야 해요.
크론 표현식 5필드 읽는 법
리눅스 crontab 한 줄은 시간 5칸과 실행할 명령으로 이뤄져요.
┌───────── 분 (0-59)
│ ┌─────── 시 (0-23)
│ │ ┌───── 일 (1-31)
│ │ │ ┌─── 월 (1-12 또는 JAN-DEC)
│ │ │ │ ┌─ 요일 (0-7 또는 SUN-SAT, 0과 7은 일요일)
│ │ │ │ │
30 9 * * 1-5 /home/smith/report.sh
읽을 때는 오른쪽에서 왼쪽으로, 큰 단위부터 읽으면 자연스러워요. 위 예는 "요일은 월~금, 월은 아무 때나, 일은 아무 날이나, 9시 30분"이니까 "평일 오전 9시 30분"이에요. 시는 24시간제라서 오후 6시는 18로 적고, 분 칸을 * 로 두면 그 시간 동안 매분 실행된다는 점을 꼭 기억하세요. * 9 * * * 는 9시에 한 번이 아니라 9:00부터 9:59까지 60번이에요.
특수 문자: *, */n, 범위, 목록
| 기호 | 의미 | 예 | 해석 |
|---|---|---|---|
* | 모든 값 | 시 칸 * | 매시 |
, | 목록 | 분 칸 0,30 | 0분과 30분 |
- | 범위 (양 끝 포함) | 시 칸 9-18 | 9시부터 18시까지 10번 |
/ | 간격 | 분 칸 */15 | 0·15·30·45분 |
a-b/n | 범위 안에서 간격 | 분 칸 5-59/10 | 5·15·25·35·45·55분 |
*/n은 "n분마다"가 아니라 "0에서 시작해 n의 배수"예요
*/15 는 "지금부터 15분 뒤마다"가 아니라 0분부터 시작해 15씩 더한 값이에요. 그래서 크론을 10시 7분에 등록해도 첫 실행은 10시 15분이에요. 60을 나누어떨어지게 하는 값(1·2·3·4·5·6·10·12·15·20·30)이면 문제가 없는데, 그렇지 않으면 간격이 고르지 않아요.
예를 들어 */7 은 0·7·14·21·28·35·42·49·56분에 실행되고, 다음은 다시 정각 0분이라 56분 → 0분 사이만 4분 간격이 돼요. 시 칸의 */5 도 0·5·10·15·20시 다음 0시로 넘어가서 20시 → 0시는 4시간 간격이에요. 정확히 7분마다가 필요하면 크론 대신 systemd timer나 앱 안의 스케줄러를 쓰는 게 맞아요.
숫자 대신 이름도 쓸 수 있어요
월과 요일 칸에는 JAN~DEC, SUN~SAT 같은 영문 약어를 쓸 수 있어요(대소문자 무관). 0 9 * * MON-FRI 가 0 9 * * 1-5 보다 읽기 쉬워서 저는 요일은 이름으로 적는 편이에요. 또 대부분의 구현이 @hourly(= 0 * * * *), @daily(= 0 0 * * *), @weekly(= 0 0 * * 0), @monthly(= 0 0 1 * *), @yearly(= 0 0 1 1 *), @reboot(부팅 시 한 번) 같은 단축어를 지원해요.
일과 요일을 같이 쓰면 OR, 가장 흔한 함정
크론에서 제일 많이 당하는 함정이에요. 표준 크론(Vixie cron, cronie, POSIX)에서는 일(3번째 칸)과 요일(5번째 칸)이 둘 다 * 가 아니면 둘 중 하나만 맞아도 실행해요. 다른 칸들은 전부 AND인데 이 둘만 OR이에요.
그래서 "13일의 금요일 오전 9시"를 0 9 13 * 5 로 적으면 실제로는 "매월 13일 오전 9시, 그리고 매주 금요일 오전 9시"가 돼요. 2026년 달력으로 실제 실행 횟수를 세어 보면 차이가 확 보여요.
"매월 마지막 금요일"도 같은 함정에 빠져요. 0 9 25-31 * 5 는 25~31일 매일 + 매주 금요일이에요. 이럴 땐 크론은 날짜만 걸고 요일 판단은 명령 쪽에서 해요.
# 매월 25~31일 9시에 실행하되, 금요일일 때만 실제 작업
0 9 25-31 * * [ "$(date +\%u)" = 5 ] && /home/smith/monthly.sh
여기서 \% 를 눈여겨보세요. crontab에서 % 는 줄바꿈으로 해석돼서 반드시 역슬래시로 이스케이프해야 해요. 이걸 모르고 date +%u 를 그대로 쓰면 명령이 조용히 잘려요.
참고로 Quartz나 스프링 같은 자바 스케줄러는 이 혼란을 피하려고 일과 요일 중 하나에 ? 를 쓰게 하고, 마지막 날 L, 몇째 주 요일 5#3(셋째 금요일) 같은 기호를 지원해요. 리눅스 crontab에는 이런 기호가 없어요.
크론 타임존 실수: 서버 UTC vs 한국 시간 KST
크론은 그 크론이 도는 곳의 시간대로 해석돼요. 그런데 클라우드 서버 이미지, 도커 컨테이너, GitHub Actions는 대부분 UTC예요. 한국은 UTC+9이고 서머타임이 없어서 변환은 늘 "9시간 빼기"예요.
| 원하는 시각 (KST) | UTC로 적을 표현식 | 주의 |
|---|---|---|
| 매일 오전 9시 | 0 0 * * * | — |
| 매일 오후 6시 | 0 9 * * * | — |
| 매일 자정 0시 | 0 15 * * * | UTC로는 전날 15시 |
| 평일 오전 8시 | 0 23 * * 0-4 | 요일도 하루 당겨짐 (일~목) |
| 매월 1일 오전 9시 | 0 0 1 * * | — |
| 매월 1일 자정 | 표현 불가 | UTC로는 전달 말일 15시라 28~31일이 바뀜 |
KST 오전 9시 이전 시각은 UTC로 전날이 돼서 요일과 날짜까지 하루씩 당겨야 해요. 평일 오전 8시를 0 23 * * 1-5 로 적으면 월~금 23시 UTC, 즉 화~토 오전 8시 KST에 돌아요. 특히 "매월 1일 자정(KST)"은 UTC로 "전달 마지막 날 15시"라서 표준 5필드로는 깔끔하게 적을 수 없어요.
해결 방법은 세 가지예요.
- 서버 시간대를 확인하고 UTC로 적기.
timedatectl이나date로 먼저 확인하고, crontab 맨 위에# 모든 시각은 UTC주석을 남겨 두세요. - 크론에 시간대 지정하기. cronie는 crontab 안에
CRON_TZ=Asia/Seoul을, 쿠버네티스 CronJob은spec.timeZone을, systemd timer는OnCalendar=*-*-* 09:00 Asia/Seoul처럼 끝에 시간대를 붙일 수 있어요. - 서버 시간대를 KST로 바꾸기. 가능은 하지만 로그·DB 시각까지 같이 바뀌어서 저는 권하지 않아요. 시간 저장은 UTC가 원칙인 이유는 타임존 처리 원칙 글에 정리했어요.
바로 쓰는 크론 표현식 예제 20개
| # | 표현식 | 의미 |
|---|---|---|
| 1 | * * * * * | 매분 |
| 2 | */5 * * * * | 5분마다 (0·5·10…55분) |
| 3 | */15 * * * * | 15분마다 |
| 4 | 0 * * * * | 매시 정각 |
| 5 | 30 * * * * | 매시 30분 |
| 6 | 0 */2 * * * | 2시간마다 정각 (0·2·4…22시) |
| 7 | 0 9 * * * | 매일 오전 9시 |
| 8 | 30 18 * * * | 매일 오후 6시 30분 |
| 9 | 0 9,18 * * * | 매일 오전 9시와 오후 6시 |
| 10 | 0 9-18 * * * | 매일 9시~18시 매 정각 (10회) |
| 11 | */10 9-17 * * 1-5 | 평일 9:00~17:50 10분마다 |
| 12 | 0 9 * * 1-5 | 평일 오전 9시 |
| 13 | 0 10 * * 6,0 | 주말 오전 10시 |
| 14 | 0 9 * * 1 | 매주 월요일 오전 9시 |
| 15 | 0 0 * * 0 | 매주 일요일 자정 |
| 16 | 0 0 1 * * | 매월 1일 자정 |
| 17 | 0 0 1,15 * * | 매월 1일과 15일 자정 |
| 18 | 0 0 1 */3 * | 분기 첫날 (1·4·7·10월 1일) |
| 19 | 0 0 1 1 * | 매년 1월 1일 자정 |
| 20 | 30 3 * * * | 매일 새벽 3시 30분 (백업 단골) |
모두 크론이 도는 곳의 시간대 기준이에요. 서버가 UTC라면 위 표의 시각에 9시간을 더한 게 한국 시간이에요. 11번처럼 "17:50까지"가 되는 이유는 시 칸 9-17이 17시대(17:00~17:59)까지 포함하고, 그 안에서 10분 간격이라 17:50이 마지막이라서예요.
"매월 말일"은 일 칸에 넣을 값이 없어서 이렇게 해요. 0 23 28-31 * * 로 28~31일에 돌리고, 명령 앞에 [ "$(date -d tomorrow +\%d)" = 01 ] && 를 붙여 내일이 1일일 때만 실행하는 방식이에요(GNU date 기준).
크론이 안 돌 때 확인할 것
표현식은 맞는데 작업이 안 돌거나 이상하게 돌 때는 대개 환경 문제예요.
- PATH가 짧아요. 크론은 로그인 셸 설정(.bashrc 등)을 읽지 않고, PATH가
/usr/bin:/bin정도라node,python3를 못 찾을 수 있어요. 명령은 절대 경로로 쓰거나 crontab 맨 위에 PATH를 지정하세요. - 출력이 사라져요. 에러 메시지가 메일로 가거나 버려져요.
>> /var/log/myjob.log 2>&1로 로그를 남기세요. %를 이스케이프하지 않았어요. 위에서 본\%문제예요.- 이전 실행이 안 끝났는데 다음 실행이 겹쳐요. 크론은 겹침을 막지 않아요.
flock -n /tmp/myjob.lock 명령으로 감싸면 이미 실행 중일 때 건너뛰어요. - 서버가 꺼져 있던 시간의 작업은 사라져요. 크론은 놓친 실행을 나중에 보충하지 않아요. 보충이 필요하면 anacron이나 systemd timer의
Persistent=true를 쓰세요.
systemd timer·GitHub Actions·k8s CronJob 차이
같은 "정해진 시각에 실행"이라도 도구마다 문법과 동작이 조금씩 달라요.
| 항목 | crontab | systemd timer | GitHub Actions | 쿠버네티스 CronJob |
|---|---|---|---|---|
| 문법 | 5필드 | OnCalendar= 고유 문법 | 5필드 | 5필드 |
| 시간대 | 시스템 시간대 | 시스템 (끝에 지정 가능) | UTC | 컨트롤러 시간대, spec.timeZone 지정 가능 |
| 놓친 실행 | 버림 | Persistent=true 면 부팅 후 실행 | 버림 | startingDeadlineSeconds 안이면 실행 |
| 중복 실행 | 막지 않음 | 이미 실행 중이면 새로 안 띄움 | 별도 설정 | concurrencyPolicy 로 제어 |
| 로그 | 직접 리다이렉트 | journalctl | Actions 실행 기록 | Pod 로그 |
systemd timer
.timer 와 .service 두 파일로 구성해요. OnCalendar=Mon..Fri *-*-* 09:00:00 이 평일 9시, OnCalendar=*:0/5 가 5분마다예요. 문법이 헷갈리면 systemd-analyze calendar "Mon..Fri 09:00" 로 다음 실행 시각을 바로 확인할 수 있어요. 기본 AccuracySec 가 1분이라 실행이 최대 1분 늦을 수 있고, 정확해야 하면 AccuracySec=1s 로 줄이세요.
GitHub Actions schedule
on.schedule.cron 에 5필드를 적고 UTC로 해석돼요. 최소 간격은 5분이고, 사용량이 몰리는 매시 정각에는 실행이 수십 분 늦거나 빠질 수 있어서 17 * * * * 처럼 정각을 피하라고 공식 문서가 권해요. 스케줄은 기본 브랜치의 최신 커밋 기준으로만 돌고, 공개 저장소는 60일 동안 활동이 없으면 예약 워크플로가 자동으로 꺼져요.
쿠버네티스 CronJob
spec.schedule 에 5필드를 쓰고, 1.27부터 정식 기능이 된 spec.timeZone: "Asia/Seoul" 로 시간대를 정할 수 있어요. 표현식 안에 CRON_TZ= 를 넣는 방식은 공식 지원이 아니니 쓰지 마세요. concurrencyPolicy 는 기본값 Allow 라 겹쳐 실행되니, 배치 작업이면 Forbid 로 바꾸는 게 안전해요.
자주 묻는 질문
크론에서 요일 0은 무슨 요일인가요?
일요일이에요. 7도 일요일이고, 1이 월요일, 6이 토요일이에요. 헷갈리면 SUN, MON 처럼 영문 약어를 쓰면 실수가 줄어요.
30초마다 실행하는 크론 표현식이 있나요?
표준 5필드 크론의 최소 단위는 1분이라 초 단위는 안 돼요. 꼭 필요하면 * * * * * 명령 과 * * * * * sleep 30; 명령 두 줄을 등록하거나, 초 단위를 지원하는 systemd timer나 앱 내부 스케줄러를 쓰세요.
*/5와 0/5는 같은가요?
리눅스 crontab에서는 0-59/5 로 써야 하고 0/5 는 구현에 따라 오류가 나거나 다르게 해석될 수 있어요. Quartz 같은 자바 스케줄러에서는 0/5 가 "0부터 5 간격"이라 */5 와 같아요. 이식성을 생각하면 */5 를 쓰세요.
크론 표현식이 6칸이나 7칸인 건 뭔가요?
스프링 @Scheduled 와 Quartz는 맨 앞에 초 칸이 붙은 6칸(Quartz는 연도까지 7칸), AWS EventBridge는 맨 뒤에 연도가 붙은 6칸이에요. 칸 수가 다르면 같은 문자열이 완전히 다른 뜻이 되니, 어느 도구용인지 먼저 확인하세요.
crontab을 수정했는데 바로 적용되나요?
crontab -e 로 저장하면 크론 데몬이 변경을 감지해 다음 분부터 적용해요. 따로 재시작할 필요는 없어요. 다만 /etc/cron.d/ 파일은 줄 끝 줄바꿈과 사용자 이름 칸이 필요하니 형식을 확인하세요.
정리
크론 표현식은 분 시 일 월 요일 다섯 칸이고, *·목록·범위·/n 간격만 알면 대부분 적을 수 있어요. */n 은 0부터 시작하는 배수라 60을 나누어떨어지지 않으면 간격이 고르지 않고, 일과 요일을 둘 다 지정하면 OR로 동작해요. 가장 흔한 사고는 타임존이라, 서버·컨테이너·GitHub Actions가 UTC인지 먼저 확인하고 KST에서 9시간을 빼서 적으세요.
표현식을 넣으면 사람이 읽는 설명과 다음 실행 시각을 보여 주는 크론 표현식 해석기로 등록 전에 한 번 확인해 보세요. 로그에 찍힌 유닉스 시각을 사람이 읽는 시간으로 바꿀 땐 타임스탬프 변환기가 편하고, 초·밀리초 단위 실수는 초와 밀리초 단위 버그 글에서 정리했어요.