환경 cron · util-linux flock · MariaDB 10.6 InnoDB · 2026-09-18 확인
매시간 도는 배치가 어느 새벽 한 시간을 넘겼고 cron 은 그대로 다음 회차를 띄웠습니다. 두 프로세스가 같은 대기 행을 쥐고 각자 보낸 사고를 시간순으로 되짚고, flock 과 SKIP LOCKED 로 어디를 막아야 하는지 정리했습니다.
그 새벽에 일어난 일을 순서대로 세우면
02시 정각, 평소처럼 배치가 뜹니다. 대기 상태인 행을 한 번에 읽어 목록을 만들고, 한 건씩 외부 API 로 보낸 뒤 보냈다고 표시합니다. 여기까지는 늘 하던 그대로입니다.
02시 20분쯤부터 외부 API 가 느려집니다. 건당 0.2초이던 응답이 3초, 5초로 늘어나고, 배치는 오류 없이 그저 천천히 갑니다. 타임아웃에 걸리지 않을 만큼만 느려서 재시도 로직도 발동하지 않습니다. 로그에는 성공 줄만 띄엄띄엄 찍힙니다.
03시 정각, cron 이 다음 회차를 띄웁니다. cron 은 앞 회차가 끝났는지 보지 않습니다. 시간표에 적힌 시각이 오면 명령을 실행할 뿐입니다. 두 번째 프로세스도 대기 행을 읽는데, 첫 번째가 아직 못 보낸 행이 그대로 대기 상태라 같은 목록을 손에 쥡니다. 이때부터 같은 행을 두 프로세스가 각자 보내기 시작합니다.
04시에 세 번째가 뜹니다. 셋이 같은 외부 API 를 두드리니 응답은 더 느려지고, 각자 쥔 목록은 더 오래 살아남습니다. 05시가 지나서야 API 가 정상으로 돌아오고, 셋은 남은 목록을 빠르게 비우며 차례로 끝납니다. 아침에 로그를 열면 회차마다 성공으로 끝나 있고, 문의함에만 사고가 남아 있습니다.
# crontab 에 있던 줄
0 * * * * /srv/app/bin/send-pending.sh >> /var/log/send-pending.log 2>&1
# 아침에 확인해 보니
ps -eo pid,lstart,etime,cmd | grep '[s]end-pending'
# 4812 Thu Sep 18 02:00:01 2026 03:41:07 /bin/bash /srv/app/bin/send-pending.sh
# 5390 Thu Sep 18 03:00:01 2026 02:41:07 /bin/bash /srv/app/bin/send-pending.sh
# 6017 Thu Sep 18 04:00:01 2026 01:41:07 /bin/bash /srv/app/bin/send-pending.sh
정각마다 하나씩, 앞 회차가 살아 있는데도 다음 회차가 떠 있었습니다
cron 은 시각만 봅니다. 앞 회차가 끝났는지는 스크립트가 스스로 알아야 합니다.
무엇이 망가졌고 왜 아침까지 몰랐나
가장 큰 피해는 중복 발송입니다. 02시 회차가 쥔 목록과 03시 회차가 쥔 목록이 거의 같았으니, 그 시간대에 대기하던 행은 최소 두 번, 04시 회차까지 겹친 행은 세 번 나갔습니다. 받는 쪽에서는 같은 문구가 몇 분 간격으로 반복해 도착한 셈입니다.
두 번째는 API 쪽 부담입니다. 느려진 외부 서비스에 프로세스 셋이 동시에 붙었으니 회복이 더 늦어졌습니다. 혼자 돌았다면 05시 전에 끝났을 회차가 셋이 얽히며 오히려 길어졌습니다. 느려서 겹쳤고, 겹쳐서 더 느려진 겁니다.
발견이 늦은 이유는 모든 지표가 정상이었기 때문입니다. 각 회차는 종료 코드 0 으로 끝났고, 로그에는 실패 줄이 없습니다. 보낸 건수를 세는 지표는 있었지만 평소보다 많이 나갔을 뿐 오류로 잡히지 않았습니다. 같은 행이 몇 번 나갔는지를 세는 지표는 애초에 없었습니다.
상태 표시가 늦게 바뀐 것도 한몫했습니다. 스크립트는 목록을 처음에 한 번 읽고 보낸 뒤에야 행마다 상태를 바꿉니다. 그 사이 다른 프로세스가 같은 행을 읽으면 아직 대기 상태로 보입니다. 처리 중이라는 상태가 없었으니 겹친 걸 알아챌 방법이 데이터에도 없었습니다.
회차마다 종료 코드 0. 성공했다는 뜻이지 한 번만 보냈다는 뜻은 아니었습니다.
원인은 겹침을 막는 장치가 어디에도 없었다는 것
첫 번째 빈틈은 실행 단위에 있습니다. cron 은 같은 항목이 아직 도는 중이어도 다음 시각에 또 실행합니다. 이 동작은 결함이 아니라 설계입니다. 앞 회차를 기다릴지 건너뛸지 죽일지는 전부 스크립트 쪽 몫인데, 그 판단을 아무 데도 적어 두지 않았습니다.
두 번째 빈틈은 데이터 단위에 있습니다. 대기 행을 읽는 SELECT 와 상태를 바꾸는 UPDATE 사이가 길게 벌어져 있었습니다. 그 사이에 들어온 다른 프로세스는 같은 행을 정당하게 읽습니다. 한 프로세스만 돈다는 가정 위에서만 안전한 구조였고, 그 가정은 cron 이 언제든 깰 수 있는 것이었습니다.
세 번째는 시간에 대한 가정입니다. 15분짜리 작업을 한 시간 간격으로 돌리니 겹칠 리 없다고 여겼습니다. 하지만 작업 시간은 외부 API 응답에 달려 있고, 그건 우리가 정하는 값이 아닙니다. 네 배 느려지는 날은 언젠가 옵니다.
정리하면 겹치지 않는다는 믿음이 세 겹으로 쌓여 있었고, 어느 하나도 코드로 굳혀 두지 않았습니다. 그래서 외부 서비스가 느려진 하룻밤에 세 겹이 한꺼번에 무너졌습니다.
겹치지 않는다는 건 가정이었습니다. 가정은 코드가 아니어서 어느 밤에 깨집니다.
그래서 두 군데에 자물쇠를 걸었다
첫 조치는 실행 단위입니다. crontab 의 명령 앞에 flock 을 붙였습니다. flock 은 파일에 잠금을 걸고 명령을 그 안에서 실행하는 도구라, 잠금 파일을 하나 정해 두면 앞 회차가 살아 있는 동안 다음 회차는 그 파일을 잡지 못합니다. -n 을 붙이면 잠금을 바로 못 잡을 때 기다리지 않고 실패로 끝납니다. 기본 종료 코드는 1 인데, -E 로 바꿀 수 있습니다.
기다리게 하고 싶으면 -w 로 초를 줍니다. 그 시간 안에 잠금을 못 잡으면 실패로 끝납니다. 이번 배치는 매시간 다시 뜨니 기다릴 이유가 없어 -n 으로 건너뛰게 했고, 건너뛴 사실은 로그에 남기도록 했습니다. 잠금은 명령이 끝나 파일이 닫히면 풀리니 따로 지울 일이 없습니다. 스크립트가 죽어도 커널이 정리합니다.
한 가지 조심할 점이 있습니다. 잠금은 파일 디스크립터에 걸리고, 스크립트가 띄운 자식 프로세스가 그 디스크립터를 물려받으면 자식이 살아 있는 동안 잠금도 살아 있습니다. 백그라운드로 무언가를 띄우고 스크립트만 먼저 끝나는 구조라면 -o 로 잠금 디스크립터를 닫고 명령을 실행하게 두거나, 자식이 그 디스크립터를 닫도록 해야 합니다. 이번 배치는 자식이 없어 그대로 뒀습니다.
두 번째 조치는 데이터 단위입니다. 대기 행을 읽을 때 트랜잭션 안에서 FOR UPDATE SKIP LOCKED 를 붙였습니다. 다른 트랜잭션이 이미 잠근 행은 결과에서 빠지므로, 설령 두 프로세스가 동시에 돌아도 같은 행을 둘이 쥐지 못합니다. MariaDB 10.6 부터 쓸 수 있고 InnoDB 테이블에서만 동작하며 다른 엔진에서는 조용히 무시됩니다. 여기에 처리 중 상태를 하나 더 두어 읽자마자 표시를 바꾸게 했습니다. 실행 단위 자물쇠가 어떤 이유로 풀려도 데이터 쪽에서 한 번 더 막히는 구조입니다.
# 1) crontab — 앞 회차가 살아 있으면 이번 회차는 건너뛴다
0 * * * * flock -n /var/lock/send-pending.lock /srv/app/bin/send-pending.sh >> /var/log/send-pending.log 2>&1
# 잠금이 잡혀 있는지 손으로 확인해 본다 (1 이면 누가 쥐고 있다)
flock -n /var/lock/send-pending.lock true; echo $?
# 건너뛴 걸 로그에 남기고 싶으면 래퍼 스크립트 안에서 종료 코드를 따로 받는다
# (crontab 줄에 직접 쓰면 % 가 줄바꿈으로 읽히니 date 는 -Iseconds 로)
flock -n -E 75 /var/lock/send-pending.lock /srv/app/bin/send-pending.sh \
|| { [ $? -eq 75 ] && echo "$(date -Iseconds) skipped: previous run still holds the lock"; }
# 2) 대기 행 읽기 — 남이 잠근 행은 건너뛴다 (MariaDB 10.6+, InnoDB)
# START TRANSACTION;
# SELECT id, payload FROM notifications
# WHERE status = 'pending' ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED;
# UPDATE notifications SET status = 'sending' WHERE id IN (...);
# COMMIT;
실행 단위는 flock, 데이터 단위는 SKIP LOCKED. 어느 한쪽이 무너져도 다른 쪽이 막습니다
flock 은 두 번째 프로세스가 뜨는 걸 막고, SKIP LOCKED 는 떴더라도 같은 행을 못 쥐게 합니다.
이번에 남은 것
첫째는 cron 에 올리는 모든 스크립트를 겹쳐 돌 수 있는 것으로 보게 된 점입니다. 짧은 작업이라도 언젠가는 길어집니다. 새 항목을 crontab 에 넣을 때 flock 을 앞에 붙이는 걸 기본으로 두면 나중에 작업이 길어져도 사고가 아니라 건너뜀 로그 한 줄로 끝납니다.
둘째는 상태를 읽는 시점과 바꾸는 시점 사이를 의심하게 된 점입니다. 그 사이가 길수록 겹침에 약합니다. 읽자마자 처리 중으로 바꾸고, 읽을 때부터 남이 잠근 행은 건너뛰면 실행 단위가 어떻게 되든 데이터는 한 번만 나갑니다. 자물쇠는 두 겹이어야 한 겹이 풀린 밤을 버팁니다.
셋째는 지표입니다. 회차의 성공 여부와 보낸 건수만으로는 이 사고가 보이지 않았습니다. 회차가 시작한 시각과 끝난 시각을 로그에 남기고, 직전 회차 길이가 간격에 가까워지면 알리게 했습니다. 겹치기 전에 느려지는 걸 먼저 보자는 뜻입니다.
돌아보면 고친 건 crontab 한 줄과 SELECT 한 줄, 로그 두 줄입니다. 그 몇 줄이 없어서 하룻밤 사이에 같은 알림이 세 번 나갔습니다. 배치를 새로 만들 때보다, 잘 돌던 배치가 오래됐을 때 한 번 더 볼 곳이 여기입니다.
조치 후 확인할 것
- crontab -l 로 항목을 훑어 flock 없이 도는 스크립트를 찾습니다. 작업 시간이 길어질 수 있는 항목은 잠금 파일을 정해 flock -n 을 앞에 붙입니다.
- flock -n <잠금파일> true; echo $? 로 잠금이 제대로 잡히고 풀리는지 확인합니다. 스크립트가 끝났는데도 1 이 나오면 자식 프로세스가 디스크립터를 물고 있는 겁니다.
- 대기 행을 읽는 SELECT 에 FOR UPDATE SKIP LOCKED 를 붙이고, 읽은 직후 처리 중 상태로 바꾸는지 봅니다. 테이블 엔진이 InnoDB 인지도 함께 확인합니다.
- 회차 시작·종료 시각을 로그에 남기고, 직전 회차 길이가 실행 간격의 절반을 넘으면 알리도록 합니다. 겹치기 전에 느려지는 걸 먼저 잡습니다.
- 같은 대상에게 같은 내용이 짧은 간격으로 두 번 나갔는지를 세는 지표를 하나 둡니다. 성공 건수만으로는 중복이 보이지 않습니다.
cron 은 시각이 오면 실행합니다. 앞 회차가 끝났는지는 묻지 않고, 그건 앞으로도 바뀌지 않을 겁니다. 그러니 겹치지 않는다는 가정을 코드로 바꿔 둡니다. 실행은 flock 이, 데이터는 SKIP LOCKED 가 막게 하고, 회차 길이를 기록해 두면 다음 느린 밤은 로그 한 줄로 지나갑니다.
'복사금지 블로그 짜증나서 만든 개발문서' 카테고리의 다른 글
| 재부팅하면 서비스가 안 올라오는 서버, 내리기 전에 훑는 목록 (0) | 2026.09.08 |
|---|---|
| 리눅스 load average 정리 — CPU 사용률이 아니라 줄 선 프로세스 수입니다 (0) | 2026.09.05 |
| docker compose down을 stop 대신 쓰면 무엇이 사라질까 (0) | 2026.08.14 |
| npm 배포 전에 어떤 파일이 올라가는지 미리 확인하는 방법 (0) | 2026.08.07 |
| 맥에서 파일명 대소문자만 바꿨더니 git이 못 잡을 때 (0) | 2026.08.05 |