카테고리 없음

systemd 타이머로 자동화 굴리기(n8n 없이): OnCalendar · Persistent · flock 실전 설정과 운영 기록

코드짜는아빠 2026. 10. 7. 16:00

사람이 기억해야만 돌던 검사

「사람이 node --test 를 기억해야만 방어가 작동한다.」 8월 29일 QA 리포트에 적힌 문장이다. 작업 명세와 문서 형식을 검사하는 스크립트 여섯 개 가운데 그때 자동으로 도는 건 두 개뿐. 나머지는 누가 「돌려야지」 하고 기억해야 돌았다.

지금은 이 검사들을 스크립트 하나로 묶어, 매일 아침 9시에 systemd 타이머가 돌린다. 이 노트북의 사용자 타이머는 9월 30일 기준 25개, 그중 켜진 건 19개.

하는 일 켜진 타이머 수
서비스 생존 · 연동 감시(1시간 · 15분 간격) 3
SNS 예약 게시 · 지표 수집 · 토큰 갱신 3
개인 프로젝트 보고 · 감시 5
로그 정리(매월 · 매일) 2
콘텐츠 생성 1
승인 대기 확인(5분 간격) 1
대시보드 자동화(15분 간격) 1
DB 백업 1
도구 업데이트 감시 1
저장소 자체 점검 1

꺼 둔 6개 중 2개는 8월에 한 번만 돌고 끝난 일회성 타이머, 4개는 9월 28일에 멈춘 콘텐츠 생성 타이머다(맨 끝 절).

n8n은 설치해 본 적이 없다

n8n을 깔아서 이 구성과 견줘 본 기록은 나한테 없다. 필요할 때마다 스크립트와 유닛 파일을 만들어 systemd에 하나씩 걸었고, 그게 25개까지 늘었다. 그러니 아래 비교에서 n8n 쪽은 전부 공식 문서(docs.n8n.io, 2026-09-30 조회)에 적힌 내용. 항목은 일정 · 겹침 · 실패 · 로그, 네 가지다.

1. systemd timer OnCalendar 문법과 Persistent=

매월 1일 새벽 4시에 요청 로그를 정리하는 타이머.

[Timer]
# 매월 1일 04:00. 감사 로그 회전(04:30)과 겹치지 않게 앞에 둔다.
OnCalendar=*-*-01 04:00:00
# 그 시각에 기기가 꺼져 있었으면 다음 부팅 때 실행한다 — 한 달을 통째로 건너뛰지 않기 위해서다.
Persistent=true

OnCalendar 식이 맞는지는 systemd-analyze 로 미리 확인.

$ systemd-analyze calendar --iterations=3 "*-*-01 04:00:00"
Normalized form: *-*-01 04:00:00
    Next elapse: Thu 2026-10-01 04:00:00 KST
   Iteration #2: Sun 2026-11-01 04:00:00 KST
   Iteration #3: Tue 2026-12-01 04:00:00 KST

Persistent=true 는 꺼져 있던 동안 놓친 실행을 다음에 켜질 때 한 번 돌려 준다(man systemd.timer: 「This is useful to catch up on missed runs of the service when the system was powered down」, https://man7.org/linux/man-pages/man5/systemd.timer.5.html). 기본값은 false 이고, OnCalendar 로 잡은 타이머에만 효과가 있다. 내 타이머 중 OnCalendar 로 잡은 22개 가운데 21개가 Persistent=true(9월 30일 기준).

나머지 하나가 SNS 예약 게시 타이머고, 여기엔 일부러 Persistent=false 가 들어 있다. 8월 22일 작업 명세에 적힌 이유는 이렇다. 놓친 게시 슬롯을 부팅 때 따라 돌리면 새벽 슬롯 글이 엉뚱한 시각에 올라간다는 것.

그 선택의 대가도 기록에 남아 있다. 9월 7일, 노트북이 4시간(13:53~17:56) 꺼져 있었고 그 사이 오후 4시 게시 1건이 큐에 갇혔다. 작업이 아예 실행되지 않았으니 「큐가 비었다」 경고조차 나갈 수 없었던 거다.

로그아웃해 있어도 사용자 타이머가 돌게 하는 loginctl enable-linger 는 2편(https://code-nara.tistory.com/3)에서 다뤘다.

n8n에서는 Schedule Trigger 노드가 이 역할. 초 · 분 · 시 · 일 · 주 · 월 간격과 cron 식을 받고, 문서(https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.scheduletrigger/)도 「Unix의 Cron과 비슷하게 동작한다」고 설명한다. 놓친 실행은 self-hosted에서 durable scheduler(2.36.0부터, 기본 꺼짐)를 켰을 때 트리거마다 If Execution Is Missed 옵션으로 정하는 방식. 기본은 놓친 실행을 버리는 것(Don't Run Missed Executions)이고, 가장 최근 것 하나(또는 규칙마다 하나)만 따라 돌리게 바꿀 수 있다. 게시처럼 따라잡으면 안 되는 작업과 로그 정리처럼 따라잡아야 하는 작업을 따로 설정할 수 있다는 점은 systemd와 같다.

2. 겹침 방지: flock 한 줄

같은 큐에 결과를 쓰는 작업이 둘 이상이면, 동시에 돌지 않게 막는 장치가 필요. 내 유닛에는 ExecStart 앞에 flock 이 붙어 있어서, 두 작업이 잠금 파일 하나를 같이 쓴다.

ExecStart=/usr/bin/flock -n %t/content.lock node generate_daily.mjs

-n 은 잠금을 못 잡으면 기다리지 않고 바로 끝낸다. %t 는 systemd가 채워 주는 사용자 런타임 폴더(/run/user/<UID>)라 UID를 직접 안 적어도 된다(유닛 이름과 경로는 바꿨다). 주 작업과 재시도 작업에 -E 를 다르게 주는 이유, 「오늘 결과물이 이미 있으면 건너뛴다」는 확인을 넣어 두고도 잠금을 추가한 사연과 재현 결과는 6편(「Claude Code · Codex로 만든 자동화가 조용히 틀린 3가지」, https://code-nara.tistory.com/7)에.

n8n self-hosted 문서의 동시 실행 제어는 성격이 다르다. N8N_CONCURRENCY_PRODUCTION_LIMIT 로 인스턴스 전체의 production 실행 수에 상한을 두고, 넘치는 실행은 큐에 넣어 FIFO로 처리하는 방식. 기본은 꺼져 있다. 내게 필요했던 건 특정 두 작업 사이의 규칙, 곧 한쪽은 실패로 드러내고 한쪽은 조용히 건너뛰는 규칙. n8n에서 그 규칙을 어떻게 만드는지는 확인하지 못했다. 그래서 이 항목은 비교에서 뺐다.

3. 실패 경보: OnFailure= 대신 아침 9시 점검

systemd에는 유닛이 failed 상태가 되면 다른 유닛을 띄우는 OnFailure= 가 있다(man systemd.unit). 그런데 내 유닛 파일에는 이 줄이 하나도 없다. 경보가 나가는 곳은 두 군데.

하나는 작업 스크립트 자신. 콘텐츠 생성 작업은 실패하면 텔레그램으로 「실패 단계 · 다음 재시도 시각 · 남은 시도 횟수」를 보내고, 재시도를 systemd-run 으로 예약한다. 20분 간격이고, 마감 90분 전에서 끊는 식.

systemd-run --user --unit <작업명>-retry-<시각> --on-calendar=@<유닉스시각> \
  /usr/bin/flock -n /run/user/<UID>/<작업명>.lock node run_episode.mjs ...

--on-calendar=@<유닉스시각> 은 그 시각에 한 번만 도는 임시 타이머를 만든다. 재시도도 원래 작업과 같은 잠금으로. 재시도 코드는 잠금 파일을 %t 없이 런타임 폴더 경로(/run/user/<UID>)로 그대로 넘긴다.

다른 하나는 매일 09:00 점검. 실패 상태로 남은 유닛을 모아 알리는데, 핵심은 이 명령이다.

systemctl --user list-units --state=failed --no-legend --plain

결과는 어제와 비교해 「신규 · 연속 N일째 · 복구」로 나눠 텔레그램으로. 텔레그램 메시지 길이 제한이 4,096자라 알림은 3,500자에서 자르는 거다.

점검 서비스 자신도 경고가 하나라도 있으면 일부러 종료 코드 1로 끝나 failed 로 남는다. 그래서 목록을 만들 때 자기 자신은 뺀다. 같은 경고가 두 번 오지 않게 하려는 것.

이 점검 유닛은 9월 2일 전까지 알림을 보내지 않았다. 그날 한꺼번에 드러난 사고가 셋. 점검 유닛 자신이 이틀간 죽어 있었고(2편), 콘텐츠 생성이 나흘간 0건이었고, 도메인 하나가 이틀간 끊겨 있었다. 알림은 그날 켰다.

모든 걸 알리지는 않는다. 토큰 갱신 작업이 받는 「24시간 안에는 다시 갱신할 수 없다」는 응답이 그 예. 설계상 정상인 일에 알림을 붙이면 알림을 무시하는 습관만 생긴다는 게 8월 22일 작업 명세에 적힌 이유다.

작업이 아예 실행되지 않은 경우는 이 구성으로도 못 잡는다. 1번의 9월 7일이 그랬다. 실패한 유닛이 없으니 목록에도 안 뜬다.

이 부분은 n8n 쪽이 간단. 워크플로 설정(Workflow Settings)에서 error workflow를 지정해 두면, 실행이 실패할 때 그 워크플로가 돈다. error workflow는 Error Trigger 노드로 시작하고, 하나를 여러 워크플로가 같이 써도 되는 구조. 넘겨받는 데이터에 실행 ID · URL · 오류 메시지 · 마지막으로 실행된 노드가 들어 있고, 문서는 이걸로 이메일이나 Slack 알림을 보내는 예를 든다. 내 구성에는 같은 일을 하려고 알림 코드와 점검 스크립트가 따로 있는 거다.

4. 로그: journalctl

로그 도구는 따로 두지 않았다. 내 유닛 파일에는 출력 경로를 바꾸는 줄이 없어서, 표준 출력이 전부 journald로 간다.

systemctl --user list-timers --all                     # 다음 · 지난 실행 시각
journalctl --user -u request-log-rotate -n 50          # 최근 50줄
journalctl --user -u claude-stack-watch --since today  # 오늘 것만

list-timers 를 치면 타이머마다 다음 실행까지 남은 시간과 마지막 실행 시각이 한 줄씩.

NEXT                        LEFT LAST                              PASSED UNIT
Thu 2026-10-01 01:00:00 KST  11h Wed 2026-09-30 01:00:00 KST      12h ago claude-stack-watch.timer
Thu 2026-10-01 04:00:00 KST  14h Tue 2026-09-01 04:00:00 KST            - request-log-rotate.timer

n8n에는 워크플로별 · 전체 Executions 목록이 있고, 지난 실행의 데이터를 편집기로 불러와 디버깅할 수 있다. 명령어 대신 화면에서 실행 기록을 본다는 점이 다르다.

Claude Code가 붙는 자리

유닛 파일은 텍스트. 저장소 ops/systemd/ 에 사본 60개(타이머 25개)가 있고, 이 폴더를 건드린 커밋 22건 중 13건에 Claude Code 공동 작성 표시가 붙어 있었다(9월 30일 기준). 위에 인용한 주석들처럼, 값을 바꾼 날짜와 이유가 유닛 파일 안에 같이 남는다.

텍스트라서 스크립트로 검사도 된다. SNS 예약 게시는 타이머 파일의 OnCalendar 줄과 코드 안의 슬롯 목록이 짝이 맞아야 하는 구조. 8월 25일에는 코드에만 있고 타이머에는 없는 슬롯이 생겼고, 나흘 동안 아무 신호가 없었다. 지금은 아침 점검이 두 목록을 비교한다.

타이머 중 하나는 Claude Code 자체를 부른다. 새벽 1시에 claude -p 로 도구 업데이트를 살피는 작업이고, 설정은 2편에.

n8n 문서에서 편집기와 Git 을 잇는 버전 관리(Version control using Git)는 유료 플랜 기능 목록에. self-hosted n8n 의 명령줄 도구로는 워크플로를 하나씩 JSON 파일로 내보낼 수 있고(n8n export:workflow --backup), 문서는 이걸 버전 관리에 쓸 만하다고 적는다.

한눈에 비교: systemd timer · n8n

항목 내 구성(systemd) n8n 문서(2026-09-30 조회)
일정 OnCalendar, systemd-analyze calendar 로 확인 Schedule Trigger(간격 · cron)
놓친 실행 Persistent=true/false 를 타이머마다 durable scheduler의 If Execution Is Missed(2.36.0+, 기본 꺼짐)
겹침 방지 ExecStart 앞 flock -n (-E 로 주 작업 · 재시도를 나누는 법은 6편) 인스턴스 전체 동시 실행 상한(기본 꺼짐). 두 작업 사이 규칙은 확인 못 함
실패 알림 스크립트 직접 알림 + 09:00 --state=failed 점검 error workflow + Error Trigger
로그 journalctl Executions 목록, 지난 실행 데이터 불러오기
변경 기록 유닛 파일을 Git에 편집기 연동 Git 은 유료 플랜, 명령줄 JSON 내보내기는 self-hosted 에서
설치 · 운영 리눅스 한 대, 추가 설치 없음 Cloud(설치 없음) 또는 self-hosted(npm · Docker)

n8n이 더 맞는 경우

리눅스를 직접 관리하고 싶지 않다면 n8n이 낫다. n8n 문서의 비교표에도 self-hosted는 「설치 · 설정에 기술 지식 필요」, Cloud는 「설치 필요 없음」. systemd 구성에서는 PATH 한 줄을 빠뜨려 서비스가 이틀간 조용히 죽는 일(2편)까지 전부 내 몫이다.

컴퓨터가 꺼지면 안 되는 작업이라면 n8n Cloud 처럼 늘 켜져 있는 곳이 낫다. 같은 노트북에 n8n 을 깔면 마찬가지로 멈춘다는 얘기. 내 타이머는 노트북 한 대에서 돈다. 9월 7일처럼 4시간 꺼져 있으면 그동안은 아무것도 돌지 않는다.

여러 서비스를 이어 붙이는 일이 많을 때도 n8n 쪽이 편할 거다. n8n에는 앱 연동 노드와 트리거 노드가 따로 준비돼 있고, 여기서는 연동마다 스크립트. 실행 기록을 화면으로 보고 싶다면 앞의 Executions 목록이 있다.

내 쪽 조건. 켜 둔 리눅스가 이미 한 대 있고, 작업이 대부분 스크립트이고, 그 스크립트를 AI 코딩 도구로 쓰고 고친다. 이 조건이면 systemd는 따로 설치할 게 없다.

자동화하기 전에: 9월 28일에 타이머 4개를 끈 이유

8월 11일부터 9월 26일까지 7주 동안 SNS 계정 세 곳에 콘텐츠를 자동으로 올렸다(게시가 정상이 아니던 기간과 멈춘 기간 포함). 결과는 도달 3,045 → 프로필 방문 53 → 웹 클릭 12. 8월 11일 이후 커밋 1,269건 중 358건(28%)이 홍보 자동화였고, 수요와 유입을 재는 작업은 16건(1.3%)이었다.

도달이 낮을 때마다 게시 쪽 버그를 찾아 고쳤다. 실제로 버그도 있었고. 그러는 동안 「이걸 원하는 사람이 있나」는 묻지 않았다. 9월 28일, 계정 두 곳의 생성 타이머 4개를 멈췄다.

systemctl --user disable --now <생성 작업>.timer   # 되돌릴 때는 enable --now

타이머는 한 줄로 꺼진다. 자동화를 만들기 전에 원하는 사람이 있는지부터 확인할 것. n8n을 쓰든 systemd를 쓰든 같은 이야기다.

이 글로 처음 준비한 8편을 마칩니다. 다음에 다뤘으면 하는 주제나 글에서 막힌 부분이 있으면 codenara.contact@gmail.com 으로 보내 주세요. 받은 질문은 다음 글감으로 씁니다.

이 블로그에 쓴 글들을 바탕으로 전자책과 템플릿 묶음을 만들어 볼까 고민하고 있어요. 낼지, 낸다면 언제일지는 아직 미정.

나오면 알림을 받고 싶은 분은 제목을 [출시 알림]으로 해서 codenara.contact@gmail.com 으로 메일 주세요.

개인정보 안내: 받은 메일 주소는 출시 알림 한 번 보내는 데만 쓰고, 보내고 나면 지워요. 내지 않기로 하면 그때 지우고, 요청하시면 그 전에라도 바로 지웁니다.

이런 자동화를 직접 세팅하고 싶거나 팀 환경에 맞게 설계해 보고 싶다면 codenara.contact@gmail.com 으로 연락 주세요. 교육·세팅 문의는 따로 받고 있어요.