SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-27

systemd Type 설정 가이드: simple, forking, notify 차이점

systemd 서비스가 종료되었는데도 active 상태로 표시되는 문제를 해결합니다. simple, forking, notify, oneshot 등 Type 설정에 따른 메인 PID 감시 원리를 설명하며, 올바른 프로세스 추적을 위한 설정 방법을 상세히 안내합니다.

systemd가 프로세스 종료 후에도 유닛을 active로 보고하는 이유

systemd 서비스 유닛은 systemd가 메인 프로세스로 지정한 프로세스가 살아 있는 동안 active 상태를 유지하며, [Service] 섹션의 Type= 설정이 어떤 프로세스를 메인으로 삼을지 결정합니다. 이 값을 잘못 설정하면 systemd는 셸 래퍼나 수명이 짧은 부모 프로세스를 감시하게 되고, 정작 중요한 데몬은 유닛 내부에서 종료되어 버립니다. 유닛은 자신이 감시하도록 지시받은 프로세스의 상태를 정확히 보고하고 있는 것입니다.

restart 정책을 변경하는 것은 이 문제에 도움이 되지 않습니다. Restart=은 메인 프로세스가 종료될 때 작동하므로, 메인 PID(process identifier)가 여전히 실행 중인 무언가에 할당되어 있다면 Restart=always는 절대 실행되지 않습니다. 먼저 Type=을 수정하십시오. 메인 프로세스가 실제로 종료된 후 systemd가 수행할 동작은 별개의 결정 사항이며, 이는 Restart= 및 RestartSec= 가이드에서 다룹니다.

Type= 설정은 무엇을 결정하는가

모든 Type= 값은 두 가지 질문에 동시에 답합니다. systemd가 언제 이 유닛을 시작된 것으로 간주할 것인가, 그리고 어떤 프로세스가 메인 프로세스인가 하는 점입니다.

첫 번째 답변은 순서를 제어합니다. After=에 귀하의 유닛을 명시한 유닛은 systemd가 귀하의 유닛을 시작했다고 판단할 때까지 대기합니다. 너무 일찍 "시작됨"을 보고하는 Type=은 의존 유닛들이 귀하의 서비스가 응답할 준비가 되기 전에 실행되도록 만듭니다.

두 번째 답변은 감독을 제어합니다. systemd는 유닛이 생성하는 모든 프로세스를 cgroup(control group)에 배치합니다. 이는 프로세스를 그룹화하여 함께 제한하거나 종료할 수 있게 하는 커널 기능입니다. cgroup은 systemctl stop이 정리하는 방식입니다. KillMode=은 기본적으로 control-group로 설정되어 있으므로, 유닛을 중지하면 그 안의 모든 프로세스에 신호가 전달됩니다. 메인 PID는 더 좁은 개념입니다. 이는 종료 시 유닛을 끝내고, 그 종료 상태가 유닛의 결과가 되는 단일 프로세스를 의미합니다. cgroup을 메인 PID처럼 읽는 것이 혼란의 시작점입니다.

Type=simple은 바이너리 실행 전에 시작된 것으로 보고합니다

Type=simpleExecStart=이 설정되어 있고 Type=BusName=이 존재하지 않을 때의 기본값입니다. systemd는 프로세스를 생성하고 해당 유닛이 즉시 시작된 것으로 간주하며, 해당 프로세스를 메인 PID로 취급합니다. 따라서 서비스 바이너리가 실행되기도 전에 후속 유닛들이 즉시 시작됩니다.

마지막 세부 사항은 흔히 발생하는 의아한 상황을 설명해 줍니다. ExecStart= 경로에 오타가 있어도 시작 작업은 성공한 것으로 나타나며, 잠시 후 실행이 실패하면서 오류가 발생합니다. systemd는 이 경우를 종료 코드 203으로 기록하는데, 자체 테이블에서는 이를 EXEC라고 명명하며 서비스 바이너리 실행 실패로 정의합니다. 따라서 systemctl start이 오류 없이 반환된다고 해서 바이너리가 존재한다는 것이 증명되지는 않습니다.

포그라운드에서 유지되며 스스로 백그라운드로 전환되지 않는 프로그램에는 simple을 사용하십시오. 이는 대부분의 현대적인 데몬과 직접 작성하는 거의 모든 프로그램에 해당합니다.

Type=exec는 프로그램이 실제로 시작될 때까지 대기합니다

Type=exec은 한 단계가 더 추가된 simple입니다. systemd는 포크(fork)와 바이너리 실행이 모두 성공해야만 유닛이 시작된 것으로 간주합니다. 바이너리가 없거나 해결할 수 없는 User=이 있으면, 나중에 조용히 실패하는 대신 시작 작업 자체가 즉시 실패합니다.

Type=exec은 systemd 240부터 도입되었으므로 현재 사용 중인 모든 서버 배포판에서 지원합니다. 2026년 8월 기준으로 Ubuntu 24.04는 systemd 255를, Debian 13은 systemd 257을 탑재하고 있습니다. systemctl --version를 사용하여 버전을 확인하십시오.

이 방식은 시작 시 동기화 단계가 하나 추가되는 비용이 발생합니다. 대신 systemctl start으로부터 정확한 종료 상태를 얻을 수 있다는 이점이 있습니다. 포그라운드 프로그램의 경우 simple보다 exec를 사용하는 것을 권장합니다.

Type=forking 설정과 메인 PID를 잃어버리는 이유

Type=forkingExecStart=에 지정된 프로세스가 자식 프로세스를 생성한 뒤 의도적으로 종료될 것임을 systemd에 알립니다. systemd는 첫 번째 프로세스가 종료될 때까지 기다린 후 해당 유닛이 시작된 것으로 간주합니다. 이때 남겨진 자식 프로세스가 바로 데몬입니다. 이러한 방식은 init 스크립트가 종료된 후에는 데몬을 관리할 방법이 없었고 PID 파일만이 실행 중인 프로세스를 기록하던 SysV 시대의 관습에서 비롯되었습니다. 이는 systemd가 init 스크립트를 대체한 이유의 핵심적인 한계점 중 하나입니다.

문제는 식별입니다. systemd가 실행한 프로세스는 이미 사라졌으므로, systemd는 살아남은 프로세스 중 어느 것이 메인 프로세스인지 직접 파악해야 합니다. PIDFile=을 데몬이 PID를 기록하는 파일 경로(일반적으로 /run 하위 경로)로 설정하면, systemd가 해당 파일에서 PID를 읽어 들입니다. 또한 systemd는 해당 파일에 적힌 PID가 현재 서비스에 속한 프로세스인지 확인하므로, 관련 없는 프로세스를 가리키는 오래된 PID 파일은 신뢰하지 않고 거부합니다.

PIDFile=을 설정하지 않으면 GuessMainPID=이 적용되며, 기본값은 yes입니다. 이 추측 방식은 서비스가 단일 프로세스로 안정화될 때만 신뢰할 수 있습니다. 매뉴얼에는 한계가 명확히 명시되어 있습니다. 데몬이 둘 이상의 프로세스로 구성된 경우 추측이 틀릴 수 있으며, 이로 인해 장애 감지 기능이 작동하지 않게 됩니다. 유닛의 메인 PID가 0이 될 수도 있는데, 이는 systemd가 관리할 대상이 전혀 없음을 의미합니다.

포크(fork)를 사용하는 대부분의 데몬은 프로세스를 포그라운드에서 실행하도록 하는 옵션을 제공합니다. 해당 옵션을 Type=exec에 사용하고 PIDFile= 라인을 삭제하십시오. 관리할 요소가 줄어들면 PID를 잃어버릴 위험도 줄어듭니다.

작업 완료 후 종료되는 Type=oneshot

Type=oneshot는 프로세스가 실행된 뒤 종료되기를 기대합니다. systemd는 프로세스가 종료된 후에야 해당 유닛을 시작된 것으로 간주하므로, 다른 유닛이 대기해야 하는 작업에는 oneshot가 적합한 형태입니다. 또한 유닛에 Type=이나 ExecStart=이 지정되지 않았을 때의 암시적 기본값이기도 합니다.

oneshot에는 두 가지 고유한 동작이 있습니다. 여러 개의 ExecStart= 라인을 허용하는 유일한 타입이며, 해당 라인들은 순차적으로 실행됩니다. 또한 시작 타임아웃이 기본적으로 비활성화되어 있으므로, TimeoutStartSec=을 직접 설정하지 않으면 oneshot이 멈출 경우 무한정 대기하게 됩니다.

프로세스가 종료되면 유닛은 비활성 상태로 돌아갑니다. RemainAfterExit=yes는 프로세스가 전혀 실행되지 않는 상태에서도 유닛을 active으로 유지합니다. 이는 이 페이지 상단에서 언급한 증상을 의도적으로 구현한 것이며, 방화벽 규칙을 로드하거나 컨테이너 스택을 올리는 것처럼 무언가를 계속 실행하는 대신 상태를 남겨두어야 하는 작업에 적합합니다. 이는 재부팅 후 다시 시작되는 Docker Compose 스택의 기반이 되는 패턴으로, 유닛이 compose 명령을 실행하고 종료되더라도 유닛이 시작한 컨테이너들은 계속 살아있기 때문에 유닛은 활성 상태를 유지합니다. 또한 oneshot 유닛은 cron 대신 systemd 타이머로 작업 실행하기의 나머지 절반인 스케줄 트리거의 대상이기도 합니다.

Type=notify를 사용하면 서비스가 준비 완료 시점을 직접 알릴 수 있습니다.

Type=notify는 서비스가 시작 완료 여부를 결정하도록 합니다. 프로세스가 NOTIFY_SOCKET 환경 변수로 전달받은 Unix 소켓을 통해 READY=1 메시지를 보낼 때까지 systemd는 시작 작업을 대기 상태로 유지합니다. C 인터페이스는 sd_notify(3)이며, 많은 서버가 이미 이를 지원합니다.

이는 "서비스가 시작되었는가"라는 질문에 대한 정확한 답변입니다. simpleexec은 서비스가 설정을 읽거나 리스닝 소켓을 열기도 전에 시작된 것으로 보고하므로, 의존 유닛이 너무 일찍 시작되어 첫 연결에 실패할 수 있습니다. notify은 서비스가 스스로 준비되었다고 알리는 시점에 시작된 것으로 보고합니다.

systemd는 메인 프로세스가 보낸 메시지만 수락하며, 이것이 NotifyAccess=main의 의미이고 Type=notify이 이를 암시합니다. 메시지가 자식 프로세스나 보조 프로세스에서 온다면 NotifyAccess=all를 설정하십시오. 셸 스크립트는 systemd-notify --ready를 호출할 수 있지만, 이는 단기 실행되는 별도의 프로세스로 동작하므로 NotifyAccess=all이 필요하며, 메시지를 보낸 프로세스가 이미 종료된 경우 systemd가 메시지 출처를 식별하지 못할 수 있습니다. 프로토콜을 직접 구현한 서비스가 더 안정적입니다.

관련된 두 가지 설정을 알아두는 것이 좋습니다. systemd 253부터 사용 가능한 Type=notify-reload은 동일한 핸드셰이크를 리로드 작업으로 확장합니다. 따라서 systemctl reload은 신호가 전송된 시점이 아니라 서비스가 리로드를 완료했다고 보고할 때 반환됩니다. WatchdogSec=는 알림을 보내는 서비스가 일정 간격으로 keep-alive 메시지를 보내도록 요청하며, systemd는 마감 시간을 넘기면 실패로 간주합니다.

Type=dbus 및 Type=idle

Type=dbus은 서비스가 시스템 및 데스크톱 서비스 간 통신에 사용하는 메시지 버스인 D-Bus에 이름을 등록할 때까지 대기합니다. 이 설정은 BusName=을 필요로 하며, BusName=가 설정되는 즉시 기본값으로 적용됩니다. 실제로 버스 이름을 등록하는 서비스에만 이 설정을 사용하십시오.

Type=idlesimple와 유사하게 동작하지만, 대기 중인 작업이 모두 처리될 때까지 프로그램 실행을 지연시킵니다. 이때 최대 5초까지만 지연됩니다. 이 설정은 부팅 시 콘솔 출력이 상태 메시지와 섞이지 않도록 하기 위해 존재합니다. 이는 순서 제어 도구가 아니며, 일반적인 서비스에는 적합하지 않습니다.

래퍼 스크립트가 systemd의 PID를 잘못 인식하게 만드는 이유

원래의 증상을 유발하는 구조는 다음과 같습니다.

[Service]
Type=simple
ExecStart=/opt/app/run.sh
#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101

systemd는 셸을 메인 PID로 기록합니다. exporter가 포그라운드에서 실행되는 동안 셸은 계속 살아 있습니다. 만약 server이 종료되어도 셸은 이를 감지하지 못하므로, 메인 PID는 여전히 살아 있는 상태가 되고 유닛은 계속 active 상태로 남으며, Restart=은 조치를 취할 대상이 없게 됩니다. 두 프로세스 모두 유닛의 cgroup 내에 머물러 있으므로 systemctl stop는 여전히 정상적으로 정리 작업을 수행합니다. 즉, 정리 작업이 아닌 관리(supervision) 기능이 고장 난 것입니다.

해결 방법은 유닛이 실제로 실행하는 장기 실행 프로세스의 개수에 따라 달라집니다.

프로세스가 하나라면, 셸을 해당 프로세스로 대체하십시오.

#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yaml

exec은 셸을 지정된 프로그램으로 대체하고 동일한 PID를 유지하므로, systemd가 기록한 PID는 이제 데몬의 것이 됩니다. 더 나은 방법은 래퍼를 제거하는 것입니다. Environment=EnvironmentFile=는 변수를 전달하고, ExecStartPre=은 설정 단계를 수행하므로, systemd가 데몬을 직접 실행하여 PID를 구조적으로 파악할 수 있게 됩니다.

프로세스가 두 개라면, 단일 PID로 유닛을 대표할 수 없습니다. 이들을 두 개의 유닛으로 분리하고 After=Wants=를 사용하여 순서를 지정하십시오. 프로세스당 하나의 유닛을 할당하는 것이 systemd가 효율적으로 관리할 수 있는 방식이며, 각 프로세스가 고유한 재시작 동작을 가질 수 있는 유일한 방법입니다.

ExitType=cgroup의 변경 사항

ExitType=은 systemd 250 버전에서 추가되었습니다. 기본값은 main이며, 이는 메인 프로세스가 종료되면 유닛도 중지된 것으로 간주함을 의미합니다. ExitType=cgroup을 사용하면 cgroup 내의 프로세스가 하나라도 살아 있는 동안은 유닛이 실행 중인 것으로 간주합니다.

[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcher

이 설정은 특정 문제를 해결합니다. 실제 작업을 시작한 뒤 종료되는 런처(launcher)를 사용할 경우, ExitType=main 설정하에서는 systemd가 유닛을 중지된 것으로 판단하여 살아남은 프로세스들을 종료시킵니다. ExitType=cgroup을 사용하면 유닛이 전체 그룹의 상태를 따르게 됩니다.

이 설정이 해결하지 못하는 부분을 명확히 이해해야 합니다. ExitType=cgroup은 최소 하나의 프로세스가 살아 있는 동안 유닛을 활성 상태로 유지하므로, 두 개의 데몬을 관리하는 유닛에서 하나가 죽더라도 유닛은 계속 활성 상태로 남습니다. 이는 런처 문제를 해결하기 위한 것이지, 하나의 유닛을 여러 독립적인 프로세스의 관리자로 만들기 위한 것이 아닙니다. 또한 ExitType=Type=oneshot과 함께 사용할 수 없습니다.

cgroup은 자원 계측이 이루어지는 곳이기도 하므로, Type=이 메인 PID에 대해 무엇을 정의하든 관계없이 MemoryMax=CPUQuota=와 같은 제한 사항은 유닛이 생성한 모든 프로세스에 적용됩니다. 이와 관련된 내용은 systemd를 이용한 서비스의 메모리 및 CPU 제한에서 확인할 수 있습니다.

systemd가 실제로 감시 중인 프로세스를 찾는 방법

디버깅 중인 유닛에 대해 다음 순서대로 작업을 진행합니다. systemd가 로드한 내용을 먼저 읽고, 추적 중인 대상을 확인한 뒤, 프로세스 테이블과 비교하십시오.

systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.service

systemctl cat은 유닛 파일과 적용된 모든 드롭인(drop-in) 파일을 함께 출력하므로, 사용자가 편집했다고 기억하는 파일이 아닌 systemd가 실제로 로드한 내용을 확인할 수 있습니다. systemctl show은 사용자가 직접 작성하지 않은 기본값을 포함하여 유효한 설정값을 출력합니다. 다음 단계로 넘어가기 전에 MainPID의 값을 확인하십시오.

systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"

systemd-cgls은 해당 유닛의 cgroup에 속한 모든 프로세스를 나열합니다. ps 행은 systemd가 관리하는 단일 프로세스를 설명합니다. 이 두 가지를 함께 읽으십시오. MainPID가 0이면 systemd가 감시할 프로세스가 없다는 의미입니다. cgroup에는 데몬이 있는데 MainPID이 셸로 확인된다면 앞서 언급한 래퍼(wrapper) 사례입니다. 예상보다 많은 프로세스가 cgroup에 있다면 런처나 포킹(forking) 데몬이 관여하고 있는 것입니다.

systemctl status app.service
journalctl -u app.service -b

systemctl status는 상태 행과 cgroup 트리를 함께 출력하므로, 종종 두 가지 질문에 대한 답을 동시에 제공합니다. -b 옵션을 사용하여 현재 부팅 세션으로 제한한 journalctl -u는 systemd가 해당 유닛에 대해 기록한 시작 및 종료 이벤트와 확인된 종료 코드를 보여줍니다. 데몬이 journal이 아닌 자체 로그 파일에 기록한다면 해당 파일도 함께 읽으십시오. systemd는 자신에게 전달된 내용만 기록할 수 있기 때문입니다.

Type=을 변경하면 리로드(reload) 후 재시작(restart)하십시오.

systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.service

systemd-analyze verify은 파일을 구문 분석하여 허용되지 않는 설정을 보고합니다. daemon-reload는 systemd가 디스크에서 유닛 파일을 다시 읽도록 합니다. 변경된 Type=은 이미 실행 중인 유닛에 즉시 적용되지 않으므로, 재시작은 선택 사항이 아니라 필수입니다.

그 후 변경 사항을 테스트하십시오. systemd-cgls에서 실제로 관리하고자 하는 프로세스의 PID를 가져와 종료(kill)합니다. 그 직후 systemctl is-active app.service를 실행하십시오. Type=이 올바르게 설정되었다면 유닛은 활성 상태에서 벗어납니다. 만약 여전히 활성 상태라면, systemd가 다른 무언가를 여전히 감시하고 있는 것입니다.

어떤 systemd 서비스 Type=을 사용해야 하는가

  • 포그라운드에서 계속 실행되는 프로그램: Type=exec.
  • 준비 상태 알림을 지원하는 프로그램: Type=notify, 재시작 확인까지 지원한다면 notify-reload.
  • 백그라운드로 전환되는 데몬: PIDFile=과 함께 Type=forking, 또는 포그라운드 실행 옵션이 있다면 Type=exec.
  • 작업을 수행하고 종료되는 스크립트: Type=oneshot, 상태를 남겨야 하는 경우 RemainAfterExit=yes 추가.
  • 자식 프로세스를 남겨두고 종료되는 런처: ExitType=cgroup과 함께 Type=simple.

서드파티 데몬에 어떤 설정을 사용해야 할지 확실하지 않다면, 먼저 패키지에 포함된 유닛 파일을 읽어보십시오. 배포판에서 제공한 유닛 파일에 systemctl cat를 실행하면 업스트림에서 선택한 Type=를 확인할 수 있으며, 해당 설정은 이미 많은 사용자에게 검증된 상태입니다.

FAQ

프로세스가 종료되었는데도 systemd 유닛이 활성 상태로 유지되는 이유는 무엇입니까?

systemd가 메인 프로세스로 간주하는 프로세스가 여전히 살아 있기 때문입니다. systemd는 유닛의 cgroup에 속한 모든 프로세스가 아니라 Type=에 따라 선택된 서비스당 단일 PID를 감시합니다. Type=simple로 시작된 래퍼 스크립트가 일반적인 원인입니다. 셸이 메인 PID가 되므로, 셸이 백그라운드에서 실행한 데몬이 종료되어도 유닛은 활성 상태로 유지됩니다. systemctl show -p MainPID app.service을 실행한 다음 systemd-cgls --unit=app.service로 유닛의 cgroup을 나열하여 두 결과를 비교하십시오.

Type=simple과 Type=exec의 차이점은 무엇입니까?

Type=simple은 바이너리가 실행되기 전, systemd가 프로세스를 생성하자마자 유닛이 시작된 것으로 간주합니다. 따라서 ExecStart=의 경로가 잘못되어도 시작 작업은 성공한 것으로 처리된 뒤 실패가 발생합니다. Type=exec는 실행이 성공할 때까지 기다리므로, 시작 작업 자체에서 실패를 보고합니다. 두 설정 모두 동일한 프로세스를 메인 PID로 취급합니다. Type=exec을 사용하려면 systemd 240 버전 이상이 필요합니다.

Type=forking을 사용할 때 여전히 PIDFile=이 필요합니까?

네, 데몬이 PID 파일을 생성하는 경우 항상 필요합니다. 이 설정이 없으면 systemd는 GuessMainPID=로 대체하는데, 이는 추측에 기반하므로 단일 프로세스로 동작하는 서비스에서만 신뢰할 수 있습니다. 추측이 틀리거나 불가능한 경우, 해당 유닛에 대한 실패 감지 및 자동 재시작 기능이 작동하지 않습니다. PIDFile=를 데몬이 파일을 기록하는 정확한 경로(일반적으로 /run 아래)로 지정하십시오.

RemainAfterExit=yes는 언제 사용해야 합니까?

유닛의 목적이 프로세스를 계속 실행하는 것이 아니라 시스템 상태를 변경하는 것일 때 사용합니다. 방화벽 규칙을 로드하거나 컨테이너 스택을 시작하는 Type=oneshot 유닛은 작업이 완료되는 즉시 종료됩니다. RemainAfterExit=yes이 없으면 유닛은 비활성 상태가 되며, 이로 인해 systemctl stop는 중지할 대상이 없게 되고 ExecStop= 정리 작업을 실행할 방법도 사라집니다. 이 설정을 사용하면 프로세스가 없어도 유닛은 활성 상태로 유지되며, 이는 의도된 동작입니다.

Type=을 변경하면 daemon-reload가 필요합니까?

네, 유닛 재시작도 함께 수행해야 합니다. systemctl daemon-reload은 systemd가 디스크의 유닛 파일을 다시 읽게 만들지만, 실행 중인 인스턴스는 시작 당시의 Type=를 유지합니다. 테스트하기 전에 sudo systemctl daemon-reload을 실행한 다음 sudo systemctl restart app.service를 수행하십시오. 그렇지 않으면 이전의 관리 동작을 계속 지켜보게 됩니다.