elsa in mac

[Linux 초보 탈출] systemd의 timer 유닛을 이용하여 주기적인 작업을 자동화 하자 본문

Linux/Linux 팁

[Linux 초보 탈출] systemd의 timer 유닛을 이용하여 주기적인 작업을 자동화 하자

elsa in mac 2025. 7. 19. 17:50

이 포스트는 fedora linux를 기준으로 작성되었습니다. 

이번 포스트에서는 systemctl의 timer service를 이용하여, 주기적인 반복작업을 자동화하는 방법을 알아보겠습니다. 

Linux에서 어떤 특정 작업을 매주 월요일, 혹은 매 시간, 또는 매 10분마다 반복하고 싶다면 어떻게 하면 될까 ? 이것이 이번 포스트에서 다루고자 하는 내용입니다. 방법은 다양하게 존재하지만, 이 번 포스트에서는 Systemd를 이용하여 이 주제를 다뤄 볼 것입니다. 

systemd ?

간단하게만 소개.
Linux는 시스템 부팅 과정에서 시스템이 필요로 하는 각종 서비스, 관리자들을 실행 시키게 되는데,  이를 담당하는 역할자가 필요합니다. 과거에는 SysV Init 같은 것이 사용되어 오다가 이를 대처하기 위한 것으로 systemd가 부각되었으며, 오늘날 대부분의 Linux 배포판들은 이를 기본으로 사용하고 있습니다. 모든지 변경하면 장/단점이 생기게 마련 입니다만, 지금은 컴퓨팅 자원이 그렇게 빡빡한 때도 아니기에 거의 표준으로 자리 잡았습니다. 정리하자면, 시스템 및 서비스 관리자라고 할 수 있겠고.. 주요 역할로 병렬 부팅, 서비스 시작/중지/상태 관리,  서비스 간 의존성 중제, 시스템 상태 모니터링, 로깅 등등을 담당합니다. 

Linux에서는 백그라운드로 실행되는 프로세스들을 통칭하여 "데몬" 이라고 부르며, 이러한 역할을 하는 프로세스들에 관례적으로 d를 붙입니다.(daemon), 배표적으로 httpd, sshd, syslogd 같은 것들이 있지요. 

그럼 왜 하필 daemon 이냐 ?
여러 가지 썰이 있지만, 고대 그리스어의 Daimon(다이몬) 에서 유래되었다는 설이 유력합니다. 신과 인간 사이의 중재자 역할을 하는 존재라고.. 

 

systemd timer 서비스

무엇인가를 가장 빠르게 배우는 방법은 use case를 통해 실전에서 경험하는 것입니다.  과정을 이어가면서 그때그때 필요한 것들을 강조해 보죠. 

systemd의 timer service는 사용자가 지정한 시간주기를 자동으로 반복하는 것입니다. 이번 포스트에서는 fedora linux의 package update를 10분마다 check 하고, 업데이트할 것이 있다면 이를 유저에게 통보하는 것을 예로 들어 보겠습니다. (실제로 제가 사용하는 것이기도 하구요..)

우선, systemd 서비스에는 root권한으로 관리되는 시스템 레벨과 사용자 레벨 둘로 나눠집니다. 우리는  사용자 레벨 서비스를 이용할 것입니다.  새로운 서비스를 만드는 방법은 단순하고 기계적인데, 우선 service에 대한 정의는 정해진 형식과 파일 naming 규칙을 따라야 하며, 해당 파일들이 위치할 곳은 ~/.config/systemd/user 라는 폴더입니다. 해당 폴더가 존재하지 않는다면 만들어 주면 됩니다. 

timer 서비스는 .timer 라는 파일로 정의되며, 사용자가 정의한 일정한 주기에 실행할 서비스인 .service 파일을 정의해야 합니다. 이러한 파일들은 systemd 유닛 파일(unit file)이라고 부릅니다. 예를 들어, check_dnf_update 라면, check_dnf_update.timer 와 check_dnf_update.service 파일을 만들어야 한다는 것입니다.  여기서 주의할 점은 .timer와 .service의 이름이 동일해야 합니다. (확장자만 다를 뿐)

 

check_dnf_update.timer 분석
# check_dnf_update.timer
[Unit]
Description=10분마다 dnf update 체크

[Timer]
# OnCalendar=Mon *-*-* 00:00:00

# root service라면 systemd가 활성화된 직후, user 라면, login 한 시점 부터 경과된 시간
OnBootSec=1min
OnUnitActiveSec=10min
Persistent=false

[Install]
WantedBy=timers.target

위의 소스코드는 check_dnf_update.timer 의 내용입니다. 각 category 에는 들어갈 수 있는 옵션이 매우 다양하지만, 우리의 목적에 맞게 필요한 것만 간단히 정의합니다.  unit file은 예약어에 대해 대/소문자를 엄격이 구분합니다. 띄어쓰기는 융통성이 있으니 꼭 붙여 쓸 필요는 없습니다. 

[Unit] 의 Description 은 해당 서비스가 무엇인지를 기술합니다. 이건 기능적인 것은 아니니 원하는 내용을 기술하면 됩니다. 

[Timer] 이 부분은 중요하겠죠. 
만일, 달력(Calendar)을 기준으로 반복되는 작업일 경우 OnCalendar= 를 사용합니다.  OnCalendar의 형식은 다음과 같습니다. 

# 기본 형식
DayOfWeek  Year-Month-Day Hour:Minute:Second

# DayOfWeek : 요일 - Mon, Tue, Wed, Thu, Fri, Sat, Sun 
  # * : 모든 요일 
  # Mon..Wed : 월요일부터 수요일까지의 의미 
  # Mon,Thu,Sat : 월요일,목요일,토요일 만 이라는 의미 
  # 정의하지 않으면 * 와 동일 
    
#Year : 년도 
  # YYYY (예 2025)
  # * : 모든 년도 
  # 정의하지 않으면 * 와 동일 

#Month, Day : 월, 일 
  # 두 자리수, Year와 동일한 형식 
    
#Hour   : 시간 (24시간 형식으로 정의, 00 ~ 23), 생략하면 00시 
#Minute : 분 (00 ~ 59) , 생략하면 00분 
#Second : 초 (00 ~ 50) , 생략하면 00초

 

다음은 예시입니다. 

*-*-* 08:00:00         : 매일 오전 8시 
*-*-* *:10:00          : 매시 10분
Mon *-*-* 12:00:00     : 매주 월요일 오전 12시 정각 
Sat,Sun *-*-* 14:30:00 : 매주 토요일과 일요일 오후 2시 30분 정각 
*-*-1 22:00:00         : 매월 1일 밤 10시 정각 
*-jul-16 14:00:00      : 매년 7월 16일 오후 2시 정각 
2025-10-11 15:30:0     : 2025년 10월 11일 오후 3시 30분 딱 1번만 실행

하지만, 단축어로 정의할 수도 있습니다. 

hourly  : 매 시각 정각
daily   : 매일 자정 
weekly  : 매주 월요일 자정 
monthly : 매월 1일 자정 
yearly  : 매년 1월 1일 자정

그러니까. OnCalendar로 timer를 정의한다는 것은 절대적인 시간의 기준에서 반복할 주기를 정의하는 것입니다. 

예를 들어, "컵퓨터가 부팅되고 나면 5분마다..."이런 요구사항은 절대적인 시간 기준이 없습니다. 컴퓨터가 부팅되는 것이 언제 될지는 아무도 모르는 것이기에 시간이 기준이 아니고 컴퓨터가 부팅되고 systemd가 해당 timer unit를 실행하면 그때부터 5분마다 이벤트가 발생하는 것입니다. 

이렇듯 이전에 발생한 이벤트 시간을 기준으로 다음 이벤트 발생 시간을 정하는 주기의 경우에는 OnCalendar를 사용할 수 없습니다.  그래서 이런 경우에는 OnUnitActiveSec= 을 사용합니다. 

# 단위는 초 입니다. 
60    : 60초 마다 
3600  : 1시간 마다 

# 단위를 변경하려면 접미사(suffix)를 붙여 명시적으로 정의하야 합니다. 
us,          : 마이크로 초 
ms           : 밀리 초 
s            : 초 (생략 가능) 
m, min       : 분 
h, hr, hours : 시간 
d, days      : 일 
w, weeks     : 주
M, months    : 월 
y, years     : 년

다음은 예시입니다. 

# 접미사(suffix)를 사용할 때는 숫자와 띄어쓰면 안됨.

10       : 10초마다 
2h       : 2시간 마다 (3600초 경과)
30min    : 30분 마다 
4weeks   : 4주 마다 
10min25s : 10분 25초마다. (띄어쓰면 안됨!)

다음은 OnBootSec= 에 대해 알아봅니다.  이것은 꼭 정의를 해야 하는 것은 아닙니다. 옵션 사항이죠. 로그인을 한 후에 첫 번째 이벤트 실행을 정의하는 것으로 해석하면 됩니다. OnBootSec=1m 하면, "로그인한 후 1분 후에 첫 번째 실행" 이라는 의미가 됩니다. OnCalendar 를 사용한다면 굳이 정의할 필요가 없겠죠.  OnUnitActiveSec 을 사용하는 경우라도 systemd의 timer 서비스가 시작되면 그 시각을 기준으로 첫 번째 이벤트 발생 시각을 결정하므로 굳이 필요하지 않습니다.  OnBootSec 이 필요한 경우라면, "주기적인 발생 시점과 첫 번째 발생 시점을 다르게 하고 싶을 때"가 되겠죠. 

다음으로 Persistent=true 에 대해 알아봅니다.  이것은 다음번 발생시각이 결정되고 다음 발생 시점을 대기하고 있는 과정에서 시스템이 중지되고 그 시간이 지났을 때에 어떻게 대처할 것이냐에 대한 정의입니다. 10분마다 이벤트를 발생하라고 설정을 해서, 첫 번째 이벤트가 발생하고 나면, timer는 다음 발생 시각을 계산해 놓습니다. 뭐.. 10분 후 이겠죠..  그런데, 만일 사용자가 컴퓨터를 끄거나 혹은 정전이 되거나 해서 해당 이벤트 시각이 지나버렸을 경우, 다시 시스템이 부팅되거나 정상이 되었을 때 Persistent가 true 였다면 즉시 해당 이벤트를 실행하고 그 실행 시점을 기준으로 다음번 이벤트 시각을 정합니다.  내가 수행할 이벤트가 그런 성격의 것이라면 Persistent를 true로 하고, 꼭 그럴 필요는 없다면 생략해도 무방 합니다.  기본 값은 false 입니다.

여기서 주의할 점은 Persistent = true 로 했을 경우, 놓친 이벤트들을 정상화되었을 때 모조리 다 수행한다는 것입니다. 예를 들어, 1시간마다 이벤트를 실행하라고 했는데, 정전이 되고 5시간이 경과했다면, 시스템이 정상화되었을 때 놓친 5번을 순차적으로 즉시 반복 실행 합니다.  저녁까지 컴퓨터를 사용하고 잠자기 위해 저녁 10시에 컴퓨터 끄고 난 후,  다음날 저녁 10시에 컴퓨터 켜면  24번을 즉시 실행합니다. ^^

자~, 이제 마지막 섹션인 [Install] 부분입니다. 

[Install] 은 해당 유닛 활성화와 관련된 것으로 시스템이 부팅되고 systemd 서비스가 개시되었을 때 해당 unit을 자동으로 실행하게 하려면 [install] 섹션을 정의해야 합니다. 

예제를 보면 WantedBy= 가 명시되어 있는데, 이는 .target 유닛에 의해 원해진다(wanted)는 것을 의미합니다. 추후에 해당 unit에 대해 유저가 enable 혹은 disable 명령을 내릴 경우 해당 target 폴더에 내 unit의 symbolic 링크가 생성되게 됩니다. 

예제에서는 WantedBy=timers.target 으로 정의했는데, timers.target은 시스템의 타이머 유닛을 관리하는 특수 타깃(Special Target)입니다 타이머 유닛을 시작하려면 반드시 이 target으로 정의를 해 주어야 합니다.  

이 경우, 나 자신이 어떤 오류로 정상적으로 동작을 하지 않더라도 timer는 정상적으로 계속 진행되게 됩니다. 나만 동작을 안 할 뿐...   

예제에서는 다루지 않았지만, WantedBy= 와 유사한 RequiredBy= 가 있습니다. 이 또한 WentedBy 와 기능은 같지만 의존성이 다릅니다. 말 그대로 target 이 해당 unit을 필요로 한다(Required)는 의미기 때문에 만일 어떠한 이유로든 내 Unit이 동작을 안 하거나 문제가 생기면 timers.target이 그 영향을 받고 , timers.target에 연결된 다른 유닛들도 영향을 받을 수 있습니다.  이건 그냥 참고로 알면 될 듯.

 

check_dnf_update.service 분석

자 이제, timer 유닛은 살펴봤고, 다음으로 service 유닛을 보겠습니다. 

# check_dnf_update.service 

[Unit]
Description=dnf 업데이트 체크
After=network.target

[Service]
Type=oneshot
ExecStart=/home/elsa/.config/kwc/script/check-dnf-update.sh

# Wayland 환경에서 notify-send가 동작하기 위한 환경 변수
Environment=WAYLAND_DISPLAY=wayland-0
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus

타이머 유닛은 사용자가 정의한 주기에 서비스 유닛을 호출하는 역할을 수행합니다. Timer 유닛에서 그냥 실행할 action을 명시하면 되는 거 아니냐 할 수도 있겠지만, systemd 디자인 철학이 그러하지 않습니다. systemd 는 유닛들을 기능별로 명확하게 분리하여 설계를 해 놓았기 때문에, 자신의 역할에 따라 정의를 해 줘야 합니다.  이렇게 한 이유는 재사용성을 높이고 로깅 및 상태관리의 모호성을 줄이기 위한 것 입니다. 

After= 지시어는 systemd 에게 해당 유닛이 어느 시점에 시작되어야 함을 알려주기 위한 것으로 dnf의 업데이트 정보를 확인하려면 무엇보다 네트워크가 사용가능한 상태이야야 하기 때문에 예시에서 처럼 network.target을 명시한 것입니다.  다만, 이것이 "반드시..." 라는 의미는 아닙니다. 즉, 종속성을 의미하지는 않는다는 의미입니다. network.target으로 networks가 활성화되든 말든 활성화되지 않았다고 내 할 일 하지 않겠다는 의미는 아닙니다. 다만, network.target이 나보다는 먼저 실행되어야 한다는 우선순위를 알려 주기 위한 것입니다. 보다 현실적으로 말하면, network.target 폴더에 등록된 service 유닛들을 systemd가 실행하지 않는 것입니다. 

만일 종속성이 강조되어야 한다면, After 말고 Requires= 를 사용하면 됩니다. 이는 내가 시작되기 위해서는 반드시 다른 어떤 유닛이 실행된 상태여야 한다는 조건을 붙이는 것입니다. 따라서, 이 경우라면 해당 유닛이 시작에 실패하면 나도 실패하게 됩니다. 

하지만, 실질적으로는 network가 준비가 되지 않으면, action을 하지 않습니다. 이유는 network.target이 network가 ready가 되어야 만 활성화되기 때문입니다. 즉, 네트워크가 사용가능 한 상태가 되기 전에는 after의 의미가 없기에 해당 서비스는 진행하지 않고 지연됩니다. 바꿔 말해 대기 상태에 있게 됩니다. 

또 한 가지,  service 유닛에는 timer 유닛에서와 같은 Persistent 지시어가 없습니다. Persistent는 timer 유닛에만 존재하는 지시어입니다. sevvice 입장에서는 network.target이 활성화 여부는 "네트워크가 아직 준비되지 않았다"는 정상상태의 범주에 있는 것이고, timer의 Persistent는 timer의 비정상여부에 대한 것만 관여하므로 service의 정상여부는 아무런 영향이 없습니다. 

결론적으로 네트워크가 아직 준비가 안되었다면 대기, 준비가 되면 그때부터 서비스 시작.. 이렇게 되는 것입니다. 

자, 이제 [Service] 섹션을 봅니다. 

Type= 지시어에 oneshot이라고 정의되어 있습니다. Type= 은 서비스의 시작 방법에 관한 것입니다. oneshot은 구동 방식 중 하나로 ExecStart= 지시어에 지정된 명령이나 스크립트가 메인 프로세스라는 것을 systemd에게 알려 줍니다. 해당 명령이나 스크립트가 실행 되면 activating 상태로 전환하며, 실행이 종료되면 active가 되고, 특별한 옵션설정이 없는 한 즉시 inactive로 전환합니다. 즉, 단발성 서비스의 경우 정상적으로 실행되는 자체가 중요하며 실행이 정상적으로 완료되면 성공적으로 잘 처리했다는 의미로 active가 되지만, 결국 한 번만 실행되고 프로세스가 없어져야 하므로 즉시, inactive로 전환합니다. 

반면, simpe typle 이 있는데, simple은 실행되면 계속 실행을 유지하는 프로세스에 적당합니다. 주로 백그라운드로 계속 실행되는 daemon 등이 이에 속한다고 할 수 있습니다. 따라서, simple은 실행되면 active가 되고, 종료되면 inactive 상태가 됩니다. 

systemd는 ExecStart=에 명시된 프로세스가 시작(folked)되는 순간부터 해당 프로세스를 직접 추적 관리 합니다.  즉, running, stopped, failed 등 프로세스의 현재 상태, 프로세스 ID(PID), CPU/Memory 사용량, 로그등을 실시간 관리하며, 서비스가 예기치 않게 종료된 경우 설정에 따라 특정 조건에서 자동으로 재시작되도록 관리합니다. 

ExecStart= 지시어는 말 그대로 실행할 대상입니다. 반드시 full path(절대 경로)로 정의해야 하며, 실행 권환이 잘 설정되어 있어야겠지요. 스크립트라면 첫 줄에 #!/bin.bash, #!/bin/python 등과 같은 shebang(서뱅)이 있어야 하며 없다면, python이나 lua 같은 스크립트를 실행할 때 /usr/bin/lua <lua script full path> 나 /usr/bin/python <python script full path> 처럼 해 줘야 합니다. 

만일 명령어나 스크립트를 실행할 때 공백이 포함된 인자(argument)를 부여해야 한다면  "(따움표) 를 사용하여 인자가 어디서부터 이디까지인지를 확실하게 정의해야 합니다. 

service 유닛은 제한된 환경 변수 세트에서 실행이 되기 때문에, 필요한 환경 변수가 있다면 넣어 줘야 합니다. 위의 예에서 도 notify-send 를 사용하기 위한 환경 변수를 지정한 것을 확인하실 수 있을 것입니다. 

 

timer 등록 및 실행

자 이제 코드 분석을 알아봤고, 이제 이렇게 만들어진 것을 어떻게 등록하고 활성화하는지를 마지막으로 알아봅니다. 

서두에 언급했듯이 user 영역에서 실행할 것이므로 ~/.config/systemd/user 폴더에 넣어야 합니다. 넣었다면 아래의 명령을 통해 enable 및 서비스 개시를 해 줍니다. 

가장 먼저 할 것은 systemd에게 새로운 서비스가 있고, 이것을 내부 케시에 등록시키기 위한 작업으로 아래와 같이 daemon-reload를 명령해 줘야 합니다. 

systemctl --user daemon-reload

systemd는 이 명령을 받아 user 레벨 시스템 서비스에 새로운 변화가 있다는 것을 알고, 새로운 유닛파일들을 검사하고 유효성을 판단하며 내부 케시에 등록을 합니다. 

만일, 등록 이후에 .timer나 .service 파일을 수정하거나 삭제했다면 그때마다 이 명령으로 내려 줘야 합니다. 

다음으로 할 것은 timer 유닛을 활성화 (enable) 시키는 것입니다. 

systemctl --user enable check_dnf_update.timer

이 명령을 내리면 systemd는 ~/.config/systemd/user/timers.target.wants 폴더 내에 symbolic link(symlink)를 생성하게 됩니다. 

마지막으로 timer 유닛을 실행해 줍니다. 

systemctl --user start check_dnf_update.timer

이 명령을 통해 timer는 내부 지시어들에 정의된 바에 따라 timer를 즉시 활성화 하게 타이머를 작동하게 되며 서비스 유닛을 트리거할 준비를 하게 됩니다. 

이 3단계에 대한 로그는 다음과 같습니다. 

장상적으로 실행이 되었는지 해당 timer의 상태를 확인하려면 다음과 같이 명령하면 됩니다. 

systemctl --user status check_dnf_update.timer

스샷을 보면, loaded 위치, active 상태, trigger 시각, tirigger 대상을 확인할 수 있습니다.  8분 남았다고 나와 있네요..

자 이렇게 해서 login 이 되고 나면 10분마다 dnf package manager의 새로운 업데이트 package 여부를 확인할 수 있는 timer 서비스를 성공적으로 완료했습니다.  특별한 일이 없는 이상 해당 서비스는 부팅이 되면 systemd에 의해서 자동으로 개시될 것입니다. 

끝으로 실제로 dnf update 를 체크하는 shell script 소스코드를 보도록 하겠습니다. 

#!/bin/bash

notify-send  -i /usr/share/icons/gnome/256x256/emotes/face-smile.png "dnf" "update를 확인합니다."

# dnf 명령어를 사용하여 업데이트 가능한 패키지 수를 체크합니다.
update_count=$(sudo dnf check-update --quiet | awk '{print NR}' | tail -n +2 | wc -l | awk '{$1=$1};1')

# update_count를 정수로 변환합니다.
update_count=$(echo "$update_count" | tr -d '[:space:]')
echo "${update_count}"

# 이전 값 파일 경로
update_count_file="/tmp/eww/update_count"
# 파일이 없으면 생성하고 초기값 0 설정
if [[ ! -f "$update_count_file" ]]; then
  mkdir -p "$(dirname "$update_count_file")" # 디렉터리 생성
  echo "0" > "$update_count_file"
fi

# 이전 값 읽기
previous_count=$(cat "$update_count_file")


# 값이 변경되었을 때만 사운드 재생
if [[ "$update_count" -ne "$previous_count" && "$update_count" -gt 0 ]]; then
  # 현재 값 저장
  echo "$update_count" > "$update_count_file"

  pw-play /home/elsa/.config/assets/incomming.mp3

  # 알림 표시
  # -t critical은 사용자가 클릭을 할 때까지 사라지니 않음
  # -i : icon 출력
  notify-send -u critical -i /usr/share/icons/gnome/256x256/apps/system-software-update.png "업데이트 알림" "${update_count}개의 패키지가 업데이트 가능합니다."
fi

이 코드가 실행되면, 우선 notification center로 "dnf update를 학인합니다" 라는 메시지를 전송합니다.

그리고, dnf 명령을 이용하여 실제로 새롭게 올라온 업데이트 패키지가 있는지를 검사합니다. 만일 새로운 패키지가 있다면 해당 항목이 몇 개인지를 출력합니다. 이 장보를 받아 /tmp/eww/update.count 파일에 기록합니다. 

이유는 10분마다 주기적으로 검사를 하기 때문에 값을 저장하고 비교하지 않으면 매 10분마다 새로운 패키지가 있다고 메시지가 울릴 것 이기 때문입니다. 

기록된 업데이트 개수와 방금 검사한 업데이트 개수를 비교하여 이전과 다르거나 0 이 아니라면 다시 notification center로 critical 메시지로 몇 개의 업데이트가 올라왔다고 메시지를 보내 줍니다. 

일반 notification message와 critical message의 차이는 일반 메시지는 5초 후에 자동으로 사라지지만, critical 메시지는 사용자가 메시지를 마우스로 클릭하지 않는 한 화면에서 사라지지 않습니다. 

그러면서 동시에 pw-play 명령을 이용하여, incoming.mp3 사운드 파일을 play 하여 청각적으로도 통지를 하게 합니다. 

끝으로, systemd의 timer 유닛 목록과 관련된 정보를 한눈에 확인하려먼, systemctl --user  list-timers --all 명령을 내리면 됩니다. 

공유하기 링크
Comments