프로세스 종료와 시그널

2025. 8. 2. 22:33·Server

 

[ 프로세스 종료가 중요한 이유 ]


프로세스가 우아하게 종료되는 일은 서비스 품질과 시스템 안정성 두 가지 측면에서 매우 중요합니다.

먼저, 운영체제 관점에서 보면 애플리케이션이 열어 둔 파일 디스크립터나 네트워크 소켓, 메모리 맵과 같은 커널 자원을 스스로 해제하지 않으면 커널이 이를 회수할 때까지 자원이 묶여 있어 FD 고갈이나 메모리 누수로 이어질 수 있습니다. 이는 장시간 실행되는 서버라면 특히 치명적이며, 자원 부족 때문에 신규 프로세스가 기동 되지 못하거나 예기치 않은 OOM(Out Of Memory) 오류로 서비스 전체가 중단될 위험을 높입니다.

 

또한 데이터 무결성 측면에서도 올바른 종료 절차가 필수적입니다. 프로세스가 트랜잭션을 진행 중이거나 버퍼에 기록을 쌓아 두고 있을 때 강제 종료가 발생하면, 커밋되지 않은 상태가 데이터베이스에 남거나 로그가 완전히 기록되지 않아 장애 원인을 추적하기가 어려워집니다. 반대로 애플리케이션이 종료 신호를 감지해 남은 작업을 마무리하고 버퍼를 플러시 한다면, 데이터의 일관성과 추적 가능성이 확보되어 복구 작업이 훨씬 수월해집니다.

 

서비스 가용성 역시 무시할 수 없습니다. 웹 서버처럼 클라이언트와 장시간 커넥션을 유지하는 애플리케이션이라면 종료 절차를 밟는 순간부터 로드 밸런서에게 “더 이상 트래픽을 보내지 말라”는 신호를 주고, 이미 수신한 요청은 모두 처리한 뒤 소켓을 닫아야 합니다. 이 과정을 생략하면 연결이 갑자기 RST로 끊어지면서 클라이언트가 오류 페이지를 보거나 재시도를 일으켜 사용자 경험이 나빠집니다. 배치 프로그램 역시 마찬가지로, 진행 중이던 작업을 어디까지 수행했는지 체크포인트를 남기거나 마지막 작업을 끝낸 뒤 종료해야 재시작 시 중복 처리나 데이터 손실을 방지할 수 있습니다.

 

결국 “프로세스 종료가 중요한 이유”는 단순히 프로그램을 끄는 행위가 아니라, 자원 회수·데이터 보호·서비스 연속성이라는 세 가지 핵심 가치를 지켜 내기 위한 필수 절차라는 데 있습니다. 개발 단계에서부터 종료 시그널 처리와 정리 로직을 설계해 두면, 운영 환경에서 예측하지 못한 장애를 만나더라도 피해를 최소화하고 신뢰할 수 있는 서비스를 유지하실 수 있습니다.

 

 

[ 시그널 이란? ]


https://aandds.com/blog/signals.html

평소에는 프로세스가 정해 둔 순서대로 코드를 차례로 실행하지만, 사용자가 프로세스를 직접 종료시키거나, 운영체제가 오류를 감지하는 순간, 커널이 시그널을 그 프로세스에 전달합니다. 시그널을 받은 프로세스는 잠시 하던 일을 멈추고 미리 약속해 둔 대응 동작을 수행한 뒤 다시 본래 흐름으로 돌아가거나, 필요하다면 완전히 종료합니다. 덕분에 우리는 프로그램을 강제 종료하거나 설정을 즉시 반영하는 등 외부에서 흐름을 깔끔하게 조정할 수 있습니다.

 

시그널은 유닉스·리눅스 계열 운영체제에서 커널이 프로세스의 컨텍스트를 인터럽트(interrupt) 방식으로 가로채 실행 지점을 변경하는 비동기 인터페이스입니다. 커널은 시그널 번호와 송신자 PID를 PCB(Process Control Block)에 기록하고, 다음 스케줄 시점에 해당 프로세스의 사용자 공간 스택에 시그널 핸들러 주소를 푸시합니다. 프로세스는 커널 모드에서 사용자 모드로 복귀하자마자 이 핸들러를 먼저 실행하며, 기본 동작(종료·코어덤프·무시) 또는 사용자가 시그널을 받았을 때 동작하도록 등록한 함수가 수행됩니다.

 

이처럼 시그널은 외부 이벤트를 프로세스 제어 흐름에 결합해 주는 핵심 수단이며, 우아한 종료(Graceful Shutdown) 같은 운영 패턴이 이 메커니즘 위에서 구현됩니다.

 

 

[ 시그널 종류 ]


시그널은 SIGTERM·SIGINT·SIGKILL·SIGHUP·SIGCHLD·SIGSTOP/CONT 처럼 여러 가지가 있고, 각각 우아한 종료·즉시 종료·강제 종료·리로드·자식 회수·작업 일시정지/재개라는 분명한 용도를 갖고 있습니다.

 

이 중 가장 많이 쓰이는 것은 SIGTERM과 SIGKILL 두 가지입니다. 나머지는 궁금하시면 직접 찾아보시는걸 추천드립니다.

운영자가 kill <PID>를 치면(옵션 없이–15번) SIGTERM이 먼저 날아가 “정리하고 얌전히 끝내 달라”는 부탁을 합니다. 보통의 서버는 이 신호를 받으면 새 요청을 받지 않고, 남은 작업을 다 처리한 뒤 스스로 종료합니다.

반면 사용자가 터미널에서 Ctrl-C를 누르면 SIGINT가 전송돼 “지금 즉시 멈춰!”라는 뜻을 전달합니다. 둘 다 소프트한 방식이지만, 프로그램이 먹통이어서 말을 안 들을 때는 kill -9 <PID>로 SIGKILL을 보냅니다. 이 신호를 받으면 커널이 곧바로 프로세스를 제거하므로, 데이터가 저장되지 못하거나 파일이 깨질 위험이 있습니다.

각각의 시그널에는 “기본 동작”이 미리 정해져 있는데, SIGTERM·SIGINT·SIGHUP은 기본적으로 정상 종료, SIGKILL·SIGSTOP은 강제 종료·강제 정지, SIGCHLD는 무시가 기본입니다. 

SIGKILL의 경우 커널이 즉시 프로세스를 리스트에서 제거하므로 핸들러를 등록할 수도, 마스킹할 수도 없습니다. 따라서 데이터베이스 트랜잭션 중에 SIGKILL을 맞으면 롤백 없이 중단될 수 있고, 메모리 매핑이 해제되지 못해 다음 실행 때 복구 작업이 필요할 수 있습니다.

 

 

[ 시그널 핸들러 ] 


시그널을 ‘받아들이고 대처하는 방법’이 바로 시그널 핸들러입니다.

예를 들어 웹 서버는 SIGTERM을 받으면 “더 이상 새 요청을 받지 마!”라고 표시하고, 남은 요청을 마무리한 다음 프로세스를 종료하도록 핸들러를 짜 둡니다. 다만 SIGKILL과 SIGSTOP은 예외라서, 프로세스가 어떤 핸들러도 달 수 없고 무조건 커널이 강제 종료 또는 정지시킵니다.

핸들러 안에서는 간단하고 안전한 함수(비동기 안전 함수)만 호출하도록 권장합니다. 잘못하면 데드락상태가 될수도 있으며 무거운 함수를 호출하면 다른 시그널의 처리가 밀릴 수 있기 때문입니다.

시그널을 받기 전 프로세스는 네트워크를 기다리거나 파일을 읽는 등 긴 시간 대기 상태에 있을 수 있습니다. 이때 시그널이 도착하면 이런 블로킹된 작업은 EINTR에러(Interrupted system call)가 발생하며 중단됩니다.

 

 (다만 SA_RESTART 플래그를 사용하면 블로킹 시스템 호출이 EINTR로 깨진 뒤 자동 재시도되지만, 네트워크 서버처럼 이벤트 루프를 갖춘 프로그램은 보통 EINTR을 감지해 루프를 빠져나와 우아한 종료 절차를 시작합니다.)

 

 

[ 자바와 시그널 ] 


자바 애플리케이션은 셧다운 훅(Shutdown Hook)을 제공하며 아래처럼 사용합니다.

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    System.out.println("Shutdown Hook 실행 중...");
    // 종료 직전에 필요한 정리 작업 수행
    saveLogs();
    releaseConnections();
    deleteTempFiles();
    System.out.println("정리 작업 완료!");
}));

자바 프로그램이 SIGTERM 시그널을 받거나 처리하지 못한 예외 때문에 종료가 결정되면 JVM은 미리 등록해 둔 셧다운 훅 스레드를 깨워 마지막 정리 작업을 시킬 수 있습니다

예컨대 남은 로그 플러시, 데이터베이스 커넥션 반환, 임시 파일 삭제을 실행하게 합니다. 덕분에 개발자는 복잡한 시그널 번호나 핸들러 함수를 기억하지 않아도 됩니다.

다만 셧다운 훅 SIGTERM을 받는 경우나 handle 되지 않은 Exception 이 발생하여 프로세스가 종료 같은 정상적인 종료에서만 호출됩니다 커널이 kill -9(SIGKILL)처럼 즉시 강제 종료 신호를 보내거나, JVM 내부 버그로 프로세스가 비정상 종료되는 상황에서는 셧다운 훅이 실행되지 않습니다

Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("Hook #1")));
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("Hook #2")));
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("Hook #3")));

셧다운훅은 위처럼 여러 개를 등록할 수 있으며, JVM 내부에서 하나의 집합(리스트) 형태로 관리됩니다

다만 셧다운 훅의 실행 순서는 보장되지 않으며, JVM이 종료 절차에 진입하면 등록된 훅들이 병렬로 시작될 수 있고 어떤 훅이 먼저 끝날지는 알 수 없습니다. 훅 간의 작업이 서로 충돌하거나 데이터 경쟁이 발생하지 않도록 주의해야 합니다.

 

 

 

[ 스프링 부트의 종료 ]


SpringApplication.run()이 시작할 때 SpringApplicationShutdownHook을 JVM에 등록합니다.

이 훅은 ‘컨텍스트 닫기’(ApplicationContext.close())를 호출해 모든 @PreDestroy 메서드, DisposableBean 로직, 스레드 풀 종료 등을 실행합니다.

 

스프링 부트는 server.shutdown 설정값에 따라 종료 동작이 달라집니다.

server.shutdown은 immediate와 graceful 두 값을 가집니다. 스프링 부트는 JVM이 SIGTERM·SIGINT를 받거나 위에 설명한 대로 ApplicationContext.close()가 호출되면 셧다운 로직을 시작하는데, 이때 해당 값에 따라 내장 톰캣 커넥터(connector)의 정지 방식이 달라집니다.

 

[ server.shutdown = immediate ] 

스프링이 컨텍스트를 닫자마자 Connector.stop()을 호출해 모든 Keep-Alive 커넥션을 즉시 닫습니다. 아직 처리 중이던 요청이 있다면 커널이 연결에 RST 패킷을 날리므로, 브라우저·프록시·로드밸런서는 “Connection reset” 오류를 볼 수 있습니다. 간단한 배치 애플리케이션이나 개발용 서버처럼 “빨리 꺼지는 것”이 중요한 상황에 적합합니다.

 

[ server.shutdown = graceful ]

스프링 부트 2.3 이상에서 지원하며, 컨텍스트가 내려가는 순간 내부적으로 톰캣 커넥터를 pause() 상태로 전환합니다.

pause()는 새 TCP accept를 중지해 입장이 막히지만, 이미 맺어진 소켓은 그대로 유지합니다.

톰캣은 각 워커 스레드가 맡고 있던 요청을 끝까지 처리한 뒤 클라측에 Connection: close(HTTP/1.1) 또는 GOAWAY(HTTP/2)를 보내어 “이번 응답까지만 사용하고 끊어 달라”는 신호를 보냅니다. 응답을 플러시한 직후 서버 쪽에서 소켓을 닫습니다. 따라서 남은 Keep-Alive 타임아웃이나 요청 수와 무관하게 해당 연결은 즉시 종료됩니다.

또한 만약 설정한 최대 대기 시간을 초과하면 커넥션을 종결합니다. 대기 시간은 spring.lifecycle.timeout-shutdown-phase(Boot 3.x)에 지정합니다. 예를 들어 30s로 두면, 30초 안에 끝나지 못한 요청은 서블릿 컨테이너가 강제로 끊습니다.

 

 

[ 쿠버네티스와 시그널 ]


[ 쿠버네티스 종료 사이클 ]

쿠버네티스에서 파드를 “삭제”한다고 해서 컨테이너가 바로 죽는 것은 아닙니다. 먼저 SIGTERM을 보내고, 일정 시간 안에 정리하지 못하면 SIGKILL로 마무리하는 두 단계 절차를 밟습니다.

사용자가 kubectl delete pod를 실행하면, 각 노드의 kubelet이 이를 감지해 파드 안 모든 컨테이너의 PID 1(첫 번째 프로세스)에게 SIGTERM을 전달합니다. 애플리케이션이 30초(기본 terminationGracePeriodSeconds 설정) 안에 연결을 끊고 파일·커넥션을 정리하면 정상 종료가 완료되고, 그렇지 못하면 kubelet이 SIGKILL을 발사해 커널이 즉시 프로세스를 제거합니다.

 

여기에 preStop 훅을 정의해 두면 흐름이 한 단계 늘어납니다. 파드 삭제 요청이 오면 kubelet이 먼저 컨테이너 안에서 preStop 스크립트나 HTTP 요청을 수행하고(로드밸런서에 자기 자신을 제외하라 같은 작업), 그 preStop이 끝난 뒤에야 SIGTERM을 보냅니다.

만약 preStop이 20초, 애플리케이션 종료가 20초 걸리도록 설정해두고, terminationGracePeriodSeconds가 30초이면 preStop이 끝나고 10초 안에 SIGKILL이 날아와 버립니다.

즉 preStop 실행 시간 + SIGTERM 처리 시간 < terminationGracePeriodSeconds 공식을 반드시 지켜야 합니다.

 

[ 쿠버네티스 pid 1번 문제 ] 

컨테이너 안에는 ‘나만의 작은 리눅스’가 하나 더 있습니다. 이 작은 리눅스는 자체적인 PID 네임스페이스를 갖고, 그 안에서 가장 먼저 시작한 프로세스가 PID 1을 차지합니다. 쿠버네티스(정확히는 kubelet)는 파드를 지울 때 컨테이너 런타임에 “이 컨테이너를 멈춰라”라고 요청하면서 PID 1에만 SIGTERM을 보냅니다. 이유는 간단합니다. 전통적인 리눅스에서도 커널은 모든 시그널을 init(PID 1)에게 모아 주고, init이 자식들에게 적절히 전파·정리하는 구조이기 때문입니다. 컨테이너 내부에서 프로세스 트리를 샅샅이 검사해 일일이 SIGTERM을 보내는 것보다 “뿌리(PID 1)에게 한 번만 말하고 알아서 가족을 챙기라”는 방식이 훨씬 단순하고, 런타임과 커널 모두 익숙하게 다룰 수 있습니다.

 

문제는 PID 1이 ‘시그널 전달자’ 역할을 하지 못할 때 생깁니다. 가장 흔한 사례가 다음과 같습니다.

스프링 프로세스를 쿠버네티스 환경에서 실행 시 보통 Dockerfile을 아래처럼 스크립트를 실행시키는 패턴을 많이 사용합니다.

...
CMD /bin/start.sh

이 컨테이너가 뜨면 start.sh 가 PID 1이 되고, 실제 애플리케이션은 그 밑의 자식이 됩니다. 셸은 기본적으로 “자식들에게 SIGTERM을 중계”하지 않습니다. 따라서 kubelet SIGTERM을 날려도 PID 1인 셸만 종료하려 할 뿐, 정작 자식인 자바 프로세스는 SIGTERM을 받지 못하고 결과적으로 셧다운훅이 동작하지 않아 우아한 종료가 이루어지지 않습니다. grace period가 끝나면 kubelet이 SIGKILL을 보내면서 자바 프로세스는 강제 종료됩니다.

 

부득이하게 start.sh 같은 쉘 스크립트를 PID 1로 써야 할 때는 아래와 같이 만들어 줍니다.

#!/usr/bin/env bash
###내용###

terminate() {
  # 프로세스 ID가 $child_pid인 자식에게 TERM 신호를 보냅니다. 
  kill -TERM "$child_pid"
}

# TERM(15번)과 INT(2번) 시그널이 오면 terminate()를 호출하라
trap terminate TERM INT

# ── 2. Spring Boot 실행 (백그라운드로) $! 는 방금 백그라운드로 띄운 프로세스의 PID를 의미
java -jar /app/myapp.jar &
child_pid=$!

# ── 3. 자식 프로세스가 끝날 때까지 대기
wait "$child_pid"

trap은 쉘의 시그널 훅 명령입니다.

등록 후 PID 1인 스크립트가 kill -15(SIGTERM)이나 kill -2(SIGINT)를 받으면, 커널 → bash → terminate() 순서로 제어 흐름이 이동합니다.

 

위처럼 start.sh 스크립트를 작성하면 아래와 같은 정상적인 종료 흐름이 완성됩니다.

  • 컨테이너 기동 → start.sh가 PID 1이 됨.
  • 쿠버네티스가 파드 삭제 → kubelet이 컨테이너의 PID 1(= start.sh)에게 SIGTERM을 보냄.
  • trap 함수가 실행돼 자식(Spring Boot)에게도 SIGTERM을 전달.
  • Spring Boot의 Shutdown Hook이 동작 → HTTP 수신 차단·요청 마무리·빈 소멸.
  • 자식이 종료되면 wait이 풀리고, start.sh도 종료
  • 지정된 terminationGracePeriodSeconds 내에 모두 끝났으므로 SIGKILL은 발사되지 않음.

'Server' 카테고리의 다른 글

kotlin coroutine 에 대하여  (3) 2025.07.26
Java의 BLOCKED 상태와 I/O 대기로 인한 일시정지 상태  (0) 2025.07.12
java 다이렉트 버퍼에 대하여  (2) 2025.07.11
Multi Plexing 에 대하여  (0) 2025.07.07
'Server' 카테고리의 다른 글
  • kotlin coroutine 에 대하여
  • Java의 BLOCKED 상태와 I/O 대기로 인한 일시정지 상태
  • java 다이렉트 버퍼에 대하여
  • Multi Plexing 에 대하여
WildDevmon
WildDevmon
『앗! 야생의 개발몬(이)가 나타났다!』
  • WildDevmon
    야생의 개발몬
    WildDevmon
  • 전체
    오늘
    어제
    • 분류 전체보기 (35)
      • Server (5)
      • 알고리즘 문제풀이 (27)
      • 회고 (3)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    자바 최대공약수
    백준 어린 왕자
    direct buffer
    백준 18405
    소가 길을 건너간 이유 5
    백준15954
    백준 1004
    파이썬
    백준1188
    카카오 필요스펙
    네이티브 메모리
    백준1238
    백준 17281
    2020 카카오 인턴
    ai개발자
    자바 물리 메모리
    통나무 자르기
    소수&팰린드롬
    terminationGracePeriodSeconds
    백준1747
    백준 1114 파이썬
    백준 파티
    코틀린 코루틴
    백준 5347
    커널 스레드 상태
    백준6980
    백준 30
    백준14465
    백준 경쟁적 전염
    백준 1034
    우아한 종료
    네카라쿠배 취업
    백준 램프
    선발 명단
    자바 프로세스 종료
    백준 1114
    자바 최소공배수
    컴공 취준
    백준 1027
    백준 고층 건물
    graceful shutdown
    다이렉트 버퍼
    백준 인형들
    백준 10610
    합승 택시 요금
    경력 서류
    1114백준
    백준2262
    preStop
    자바 스레드 상태
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
WildDevmon
프로세스 종료와 시그널
상단으로

티스토리툴바