앱에서 본 Windows 종료 ── 종료 알림·다시 시작·전원 차단을 올바르게 견디기

· 업데이트: · · Windows, 종료, Windows 개발, Windows 서비스, 장치 PC, 데이터 보전, 장시간 가동, UPS

수정 이력(2건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22176539)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「앱에서 본 Windows 종료 ── 종료 알림·다시 시작·전원 차단을 올바르게 견디기」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-shutdown-handling-for-apps/

DOI(등록된 아카이브)
10.5281/zenodo.22176539
DOI(마지막 등록 버전)
10.5281/zenodo.22176540

「Windows Update의 야간 다시 시작 뒤에, 장치 PC의 계측 앱이 쓰기 도중의 데이터째로 내려가 아침에는 측정 파일이 깨져 있었다」「공유 PC에서 로그오프했더니, 편집 중이던 내용이 저장되지 않고 사라졌다는 항의가 왔다」── 장시간 가동하는 Windows 앱 상담에서 이 두 가지는 단골입니다.

어느 현장에도 공통인 것은, 종료를 「일어나면 안 되는 이상 사태」로 다루고 있다는 점입니다. 그러나 실제로는 Windows Update의 자동 다시 시작, 사용자의 로그오프, UPS로부터의 종료 지시, 그리고 예고 없는 전원 차단까지, 앱 밖에서 실행이 잘리는 이벤트는 언젠가 반드시 옵니다. 오는 것 자체는 막을 수 없습니다. 막을 수 있는 것은 「왔을 때 데이터를 잃는 것」입니다.

다행히 Windows는 종료 전에 앱으로 알림을 보내는 장치를, GUI 앱·콘솔 앱·서비스 각각에 마련해 두었습니다. 이 글에서는 중소기업의 정보시스템 담당자와 Windows 앱 개발자(특히 장치 PC·장시간 가동 앱 개발자)를 대상으로, 알림을 받는 방법과 「수 초 안에 끝나는 정리」의 설계, 다시 시작 뒤의 자동 복귀, 알림이 오지 않는 전원 차단에의 대비까지를, 2026년 8월 시점의 Microsoft Learn 1차 정보를 바탕으로 정리합니다.

1. 먼저 결론

  • 종료는 「언젠가 반드시 오는 정상적인 이벤트」로 설계합니다. 알림을 받은 뒤 쓸 수 있는 시간은 원칙적으로 5초 정도뿐이므로, 그 자리에서 허둥지둥 전부를 저장하는 설계는 무너집니다. 자주 자동 저장해 「종료 때 저장해야 할 차이」를 작게 두는 것이 대전제입니다.1
  • Windows 8 이후 클라이언트 OS에서는, 빠른 시작이 켜진 구성(최대 절전을 지원하는 대부분의 PC에서 기본값)의 「종료」는 하이브리드 종료이며, 커널은 최대 절전만 하고 있습니다. 완전히 리셋되는 것은 「다시 시작」뿐입니다. 「종료했는데도 안 고쳐지고, 다시 시작하니 고쳐졌다」의 정체는 이것입니다.2
  • GUI 앱은 WM_QUERYENDSESSION에 즉시 TRUE를 반환하고, 정리는 WM_ENDSESSION에서 합니다. 원칙적으로 FALSE(거부)를 반환해서는 안 됩니다.1
  • 도저히 중단할 수 없는 처리가 있을 때만, ShutdownBlockReasonCreate로 이유를 게시합니다. 그래도 사용자와 OS는 강제 계속할 수 있으므로, 「막을 수 있다」는 전제의 설계는 성립하지 않습니다.34
  • 콘솔 앱은 SetConsoleCtrlHandler로 알림을 받습니다. 유예는 더 짧아, 콘솔을 닫을 때 기본은 5초입니다. gdi32.dll/user32.dll을 로드한 프로세스에서는 일부 이벤트가 오지 않는 함정도 있습니다.56
  • .NET의 AppDomain.ProcessExit에 의존하는 정리는, .NET 10 이후 「밖에서 종료되는」 경로에서는 동작하지 않습니다. Main에서 돌아오는 등 보통 종료에서는 예전처럼 동작하지만, 런타임이 콘솔 닫기나 종료 같은 종료 시그널에 대한 기본 처리를 제공하지 않게 되었으므로, 그 경로의 정리는 앱 모델에 맞는 알림으로 옮겨야 합니다.7
  • Windows 서비스는 SERVICE_ACCEPT_SHUTDOWN(유예 약 20초)보다, SERVICE_ACCEPT_PRESHUTDOWN 쪽이 먼저·구성 가능한 유예로 알림을 받을 수 있습니다. 다만 PRESHUTDOWN의 기본 타임아웃은 Windows 10 Creators Update 이후 10초로 짧아져 있으므로, 어느 쪽이든 유예에 너무 기대지 않는 설계가 필요합니다.89
  • 다시 시작 뒤의 자동 복귀는, RegisterApplicationRestart와 ARSO(자동 로그온)의 조합으로 실현할 수 있습니다. 크래시·응답 없음·업데이트에 의한 다시 시작 각각에 복귀 경로가 마련되어 있습니다.1011
  • 전원 차단에는 알림이 전혀 오지 않습니다. 임시 파일에 끝까지 쓴 뒤 플러시하고 ReplaceFile로 교체하는 것이 정석이지만, ReplaceFile도 전원 차단을 넘는 원자성까지는 보장하지 않으므로, 백업(.bak)+시작 시 검증의 복구 경로까지가 세트입니다. 사후의 원인 분리는 이벤트 로그(1074/41/6008)로 할 수 있습니다.121314

한 문장으로 정리하면, 「알림이 오면 수 초 안에 문을 닫을 수 있는 상태를 항상 유지하고, 알림이 오지 않는 전원 차단에서도 깨지지 않는 쓰기를 해 둔다」, 이것이 이 글의 결론입니다.

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 14건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 종료 때 무엇이 일어나는가 ── 네 가지 「끝나는 방식」

2.1. 로그오프·종료·다시 시작·전원 차단

앱의 관점에서 중요한 것은 「사용자 세션이 어떻게 끝나는가」와 「커널이 어떻게 되는가」의 두 축입니다.

조작 사용자 세션 커널·드라이버 앱으로의 알림
로그오프 종료 계속 동작 WM_QUERYENDSESSION(ENDSESSION_LOGOFF)→WM_ENDSESSION
종료(빠른 시작이 켜진 경우) 종료 최대 절전(hiberfil.sys에 저장) WM_QUERYENDSESSION→WM_ENDSESSION, 서비스에는 (PRE)SHUTDOWN
다시 시작 종료 완전히 종료하고, 다음은 풀 부팅 위와 같음
전원 차단 즉시 소멸 즉시 소멸 없음

로그오프와 종료는 앱에서 보면 거의 같은 이벤트입니다. WM_QUERYENDSESSION의 lParam에 ENDSESSION_LOGOFF 비트가 서 있으면 로그오프, 0이면 종료 또는 다시 시작입니다(둘은 구별할 수 없습니다).1 즉 「로그오프 정도면 괜찮다」는 방심은 통하지 않고, 같은 정리 코드가 불리도록 만드는 것이 정답입니다.

네 가지 「끝나는 방식」과 앱으로의 알림로그오프, 종료, 다시 시작에서는 WM_QUERYENDSESSION부터 WM_ENDSESSION의 알림이 오고, 수 초 안에 정리한다. 전원 차단만은 알림이 전혀 없으므로, 쓰기 설계로 대비하는 수밖에 없다로그오프알림 있음: WM_QUERYENDSESSION → WM_ENDSESSION(서비스에는 (PRE)SHUTDOWN)종료다시 시작전원 차단알림 없음 ── 8장의 쓰기 설계와 UPS로 대비수 초 안에 정리(3〜6장)

2.2. 「종료했는데도 안 고쳐진다」의 정체 ── 하이브리드 종료

놓치기 쉬운 것이 표의 둘째 행입니다. Windows 8 이후 클라이언트 OS에서는, 최대 절전을 지원하는 PC에서 빠른 시작(하이브리드 종료)이 기본으로 켜지며, 「종료」의 동작이 바뀌었습니다. 사용자 세션의 로그오프까지는 평소와 같이 이루어지지만, 커널 세션은 닫히지 않고, 디바이스 드라이버째로 최대 절전 파일(hiberfil.sys)에 저장되어, 다음 시작 때 그대로 복원됩니다. 이로써 시작이 빨라지는 한편, 커널이나 드라이버 상태는 전원을 꺼도 이어집니다.2 다만 이것은 조건부 동작입니다. 최대 절전 자체가 꺼진 환경(powercfg /hibernate off), 정책이나 전원 옵션에서 빠른 시작을 끈 환경, 그리고 Windows Server에서는, 종료는 예전처럼 완전 종료가 됩니다. 대상 PC가 어느 쪽으로 동작하는지는, 전원 옵션의 「빠른 시작 켜기」 체크, 또는 powercfg /a(사용 가능한 절전 상태에 「빠른 시작」이 나오는지)로 확인할 수 있습니다.

종료 조작에서 커널이 어떻게 되는가종료 조작은 빠른 시작의 켜짐/꺼짐에 따라 완전 종료인지 커널 최대 절전인지를 가르고, 다시 시작은 항상 풀 부팅이 된다빠른 시작 켜짐(클라이언트 기본값)최대 절전 꺼짐·정책으로 끔·Windows Server「종료」 조작「다시 시작」 조작사용자 세션 종료 + 커널은 hiberfil.sys로 최대 절전완전 종료다음 시작: 커널·드라이버 상태를 복원다음 시작: 풀 부팅으로 초기화

한편 「다시 시작」은 항상 완전한 부팅 사이클을 실행합니다. 드라이버 업데이트 뒤 등, 완전히 새로운 상태가 필요하기 때문입니다.2 여기서부터, 실무에서 자주 듣는 현상이 깔끔히 설명됩니다.

  • 「종료하고 전원을 다시 켰는데도, 디바이스 이상이 고쳐지지 않는다」── 커널과 드라이버는 최대 절전에서 복원된 것뿐이며, 리셋되지 않았습니다
  • 「다시 시작하니 고쳐졌다」── 풀 부팅으로 초기화되었기 때문입니다
  • 장치 PC의 장애 대응 절차에는 「전원을 끄고 다시 켠다」가 아니라 「다시 시작한다」고 써야 합니다

명령으로 완전 종료를 명시하려면 shutdown /s(Shutdown.exe의 기본은 완전 종료), 기본 하이브리드 동작을 재현하려면 shutdown /s /hybrid를 쓸 수 있습니다.2 참고로, 빠른 시작을 끄는 것은 권장되지 않습니다. 앱 쪽은 「종료에서는 커널이 최대 절전만 하고 있을 수 있다」는 전제로, 예를 들어 「누적 가동 시간」을 OS 시작 시각에서 추정하지 않는 식의 설계로 대응합니다(빠른 시작의 켜짐·꺼짐은 환경마다 다르므로, 어느 쪽이든 깨지지 않는 설계로 합니다).

3. GUI 앱이 지켜야 할 것 ── WM_QUERYENDSESSION과 WM_ENDSESSION

3.1. 두 메시지의 역할 분담

창과 메시지 큐를 가진 앱에는, 세션 종료가 2단계로 알려집니다.1

  1. WM_QUERYENDSESSION ── 「종료해도 되는가」의 조회. 앱은 즉시 TRUE를 반환해야 하며, DefWindowProc의 기본 응답도 TRUE입니다. 여기서 정리를 시작해서는 안 됩니다.
  2. WM_ENDSESSION(wParam=TRUE) ── 「세션은 정말로 끝난다」는 확정 알림. 정리는 여기서 합니다.

WM_QUERYENDSESSION에서 FALSE를 반환하면 종료를 중지시킬 수도 있지만, 문서는 「사용자의 의도를 존중해 TRUE를 반환해야 한다」고 명기하고 있으며, FALSE를 반환한 앱도 「종료를 막고 있는 앱」으로 전체 화면 UI에 노출됩니다. 또한 콘솔 앱이나 보이는 창이 없는 앱은 애초에 종료를 중지할 수 없고, 5초 안에 응답하지 않으면 자동으로 강제 종료됩니다.14

세션 종료의 2단계 알림 흐름WM_QUERYENDSESSION의 조회에 TRUE를 반환하면 WM_ENDSESSION으로 확정되고 정리를 한다. FALSE로 거부하면 막고 있는 앱으로 표시되며, 약 5초 응답하지 않으면 강제 계속될 수 있다TRUE(원칙)FALSE(예외적인 거부)약 5초 무응답사용자가 강제 계속사용자가 취소WM_QUERYENDSESSION(조회)WM_ENDSESSION wParam=TRUE(확정)「막고 있는 앱」으로 전체 화면 UI에 표시응답 없음으로 취급여기서 정리(저장·절단)프로세스 종료종료 중지 → 모든 앱 계속

3.2. 응답하지 않으면 어떻게 되는가 ── 5초의 벽

WM_QUERYENDSESSION에도 WM_ENDSESSION에도, 응답을 늦출 수 있는 것은 약 5초입니다. 이를 넘으면, 시스템은 「이 앱이 종료를 막고 있습니다」 화면을 표시하고, 사용자는 강제 계속(=앱의 강제 종료)을 고를 수 있습니다.4 강제 종료된 프로세스에 저장 처리의 나머지를 돌릴 기회는 없습니다.

따라서 설계의 요점은 다음 두 가지입니다.

  • 정리를 5초 안에 끝나는 양으로 유지한다. Microsoft 자신도, 데이터는 평소부터 자주 저장해 종료 때 저장하는 양을 줄일 것, 미저장 데이터는 임시 장소에 저장해 다음 시작 때 복원하는 방식을 권장하고 있습니다.1
  • 종료 중에 확인 대화 상자를 내지 않는다. 「저장하시겠습니까?」라고 묻고 기다리는 사이에 5초는 지나갑니다. 말없이 안전한 쪽(자동 저장)으로 기울입니다.

3.3. WinForms·WPF에서의 구현

.NET의 데스크톱 앱에서는, 이들 메시지는 프레임워크의 이벤트로 번역됩니다. WinForms에서는 FormClosing이 불리며, CloseReason으로 종료 기인인지 판별할 수 있습니다.

// WinForms: 종료/로그오프 때도 FormClosing이 불린다
private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
{
    if (e.CloseReason == CloseReason.WindowsShutDown)
    {
        // 멱등한 스냅샷 저장만 한다. 대화 상자는 내지 않는다.
        // e.Cancel = true(거부)도 설정하지 않는다.
        SaveWorkingStateToTempFile();
        return;
    }

    // 사용자가 × 버튼으로 닫은 경우 등, 평소에는 여기서 확인해도 된다
}

WPF에서는 Application.SessionEnding 이벤트(XAML의 SessionEnding 특성, 또는 OnSessionEnding의 재정의)가 대응합니다.

WinForms/WPF 이벤트와 메시지의 대응WM_QUERYENDSESSION의 조회 단계에는 WinForms의 FormClosing과 WPF의 SessionEnding이 대응하며, 여기서 하는 것은 멱등한 스냅샷 저장까지. 확정 알림의 WM_ENDSESSION에는 대응 이벤트가 없으므로, WndProc이나 훅으로 받아 확정 뒤의 정리를 한다WM_QUERYENDSESSION(조회)WinForms: FormClosing(WindowsShutDown)WPF: SessionEnding멱등한 스냅샷 저장까지WM_ENDSESSION(확정)대응 이벤트 없음 → WndProc/훅으로 받는다확정 뒤에만 할 수 있는 정리
// WPF: App.xaml.cs
protected override void OnSessionEnding(SessionEndingCancelEventArgs e)
{
    base.OnSessionEnding(e);

    // ReasonSessionEnding.Logoff / Shutdown을 구별할 수 있지만,
    // 어느 쪽이든 같은 스냅샷 저장을 실행하는 것이 기본
    SaveWorkingStateToTempFile();

    // e.Cancel = true는 상당한 이유가 없는 한 설정하지 않는다
}

여기에 주의할 점이 하나 있습니다. FormClosing(CloseReason.WindowsShutDown)도 WPF의 SessionEnding도, 대응하는 것은 조회 단계(WM_QUERYENDSESSION)입니다. 다른 앱이 거부하면 종료는 중지되고, 자기 앱은 그대로 계속 동작합니다. 따라서 이들 이벤트에서 해도 되는 것은, 중지되어도 해가 없고, 몇 번 실행해도 같은 결과가 되는(멱등한) 스냅샷 저장까지입니다. 「끝날 때만 해야 하는 정리」(연결 절단, 리소스 반납 등)가 필요하면, WndProc에서 확정 알림의 WM_ENDSESSION(wParam=TRUE)을 직접 훅해, 그쪽에서 합니다.

어느 경로든 내용은 공통의 「스냅샷 저장 함수」로 모으고, 평소의 종료·시스템 종료·(가능하면) 크래시 때의 복원용 데이터를 같은 형식으로 쓰면, 다음 시작 때의 복원 로직이 하나로 끝납니다. 크래시 때도 정보를 남기는 설계는 「Windows 앱의 크래시 때 로그와 덤프를 남기는 설계」에서 다루고 있습니다.

4. 굳이 막을 거라면 ── ShutdownBlockReasonCreate

CD나 펌웨어 쓰기처럼, 도중에 잘리면 물리적으로 깨지는 처리만은 예외입니다. 이 경우의 올바른 방법은, 중단할 수 없는 처리의 시작 때 ShutdownBlockReasonCreate로 이유 문자열을 등록하고, 끝나면 즉시 ShutdownBlockReasonDestroy로 해제하는 것입니다. 종료가 요청되면, 이 이유가 「이 앱이 종료를 막고 있습니다」 화면에 표시되어, 사용자가 계속할지 중지할지를 판단할 수 있습니다.3

ShutdownBlockReasonCreate에 의한 보호의 흐름중단할 수 없는 처리의 시작 때 이유를 등록하고, 보호 중에 종료 요청이 오면 이유가 전체 화면에 표시되며, WM_QUERYENDSESSION에 FALSE로 거부한다. 사용자는 중지도 강제 계속도 고를 수 있고, 처리가 끝나면 이유를 해제한다사용자가 중지사용자가 강제 계속중단할 수 없는 처리를 시작ShutdownBlockReasonCreate로 이유를 등록처리를 실행(워커 스레드)완료 → ShutdownBlockReasonDestroy로 해제이 사이에 종료 요청「막고 있습니다」 화면에 이유를 표시 + WM_QUERYENDSESSION에 FALSE프로세스 종료(그래도 끝까지 지키지 못함)
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool ShutdownBlockReasonCreate(IntPtr hWnd, string pwszReason);

[DllImport("user32.dll", SetLastError = true)]
static extern bool ShutdownBlockReasonDestroy(IntPtr hWnd);

// 메인 창을 만든 스레드에서 호출할 것(다른 스레드에서는 실패한다)
_criticalOperationInProgress = true;
ShutdownBlockReasonCreate(this.Handle, "측정 데이터를 파일에 쓰고 있습니다");
try
{
    // 중단할 수 없는 처리는 워커 스레드에서 실행한다. UI 스레드에서 동기 실행하면
    // 메시지 펌프가 멈추고, 아래의 WM_QUERYENDSESSION 거부 코드가 돌기 전에
    // 「응답 없음」으로 강제 계속되어 버린다
    await Task.Run(() => WriteMeasurementData());
}
finally
{
    ShutdownBlockReasonDestroy(this.Handle);
    _criticalOperationInProgress = false;
}

// 함께, 보호 중에만 WM_QUERYENDSESSION에 FALSE를 반환해 거부한다
protected override void WndProc(ref Message m)
{
    const int WM_QUERYENDSESSION = 0x0011;
    if (m.Msg == WM_QUERYENDSESSION && _criticalOperationInProgress)
    {
        m.Result = IntPtr.Zero;   // 거부. 등록한 이유 문자열이 전체 화면 UI에 표시된다
        return;
    }
    base.WndProc(ref m);
}

여기서 오해하기 쉬운 것이 역할 분담입니다. ShutdownBlockReasonCreate가 하는 것은 이유 문자열의 등록뿐이며, 이것 자체는 종료를 막지 않습니다. 실제로 붙잡는 것은, 위처럼 보호 중 플래그를 세워 WM_QUERYENDSESSION에 FALSE를 반환하는 자체 처리입니다. 둘을 세트로 쓰고, 처리가 끝나면 즉시 둘 다 해제합니다. 또한 보호 대상 처리 자체는 워커 스레드에서 돌리고, UI 스레드는 메시지를 계속 처리할 수 있는 상태로 유지합니다 ── 거부의 장치는 메시지가 도착해야 비로소 기능하기 때문입니다(그래도 사용자와 OS는 강제 계속할 수 있으므로, 「막히지 않은 경우」에도 깨지지 않는 쓰기 설계 ── 8장 ── 는 여전히 필요합니다).

운용상의 주의는 세 가지입니다.

  • 이유 문자열은 짧게, 구체적으로. 사용자는 급해서 수 초밖에 읽지 않습니다. 「CD를 쓰는 중입니다」 정도가 적절하다고 문서도 예시하고 있습니다.3
  • 앱이 떠 있는 내내 등록한 채로 두지 않는다. 「중단할 수 없는 처리 중에만」이 API의 전제입니다.
  • 막을 수 있다는 전제로 설계하지 않는다. 사용자는 강제 계속을 고를 수 있고, 강제 종료(ENDSESSION_CRITICAL)에서는 애초에 기다려 주지 않습니다. 「애플리케이션은 종료를 막을 수 있다는 것에 의존해서는 안 된다」고 명기되어 있습니다.4

5. 콘솔 앱·백그라운드 프로세스가 지켜야 할 것

5.1. SetConsoleCtrlHandler와 짧은 유예

콘솔 앱은 창 메시지를 받을 수 없으므로, SetConsoleCtrlHandler로 등록한 핸들러 함수에 제어 시그널이 도착합니다. 시그널별 기본 유예는 다음과 같습니다.5

시그널 발생 타이밍 기본 유예
CTRL_C_EVENT / CTRL_BREAK_EVENT Ctrl+C / Ctrl+Break 타임아웃 없음
CTRL_CLOSE_EVENT 콘솔을 닫는다, 작업 관리자의 「작업 끝내기」(「세부 정보」 탭에서의 프로세스 강제 종료는 알림 없는 즉시 종료이며, 이 표의 대상 밖) 약 5초
CTRL_SHUTDOWN_EVENT 시스템 종료(서비스 프로세스) 약 20초

주의할 점이 두 가지 있습니다. 먼저, CTRL_LOGOFF_EVENT와 CTRL_SHUTDOWN_EVENT를 받을 수 있는 것은 실질적으로 서비스로 동작하는 프로세스뿐입니다. 대화 세션 안의 앱은 로그오프 시점에 종료되므로, 이 시그널을 기다리는 설계는 성립하지 않습니다.5 다음으로, gdi32.dll 또는 user32.dll을 로드한 프로세스는, 콘솔 앱이라고 생각해도 Windows 앱으로 다루어져, LOGOFF/SHUTDOWN 계열 핸들러가 불리지 않습니다. 이 경우의 공식 우회는, 숨은 창을 만들어 WM_QUERYENDSESSION/WM_ENDSESSION을 받는 것입니다.6

콘솔 시그널별 유예Ctrl+C와 Ctrl+Break에는 명시 타임아웃이 없고, 콘솔 닫기는 약 5초, 서비스 프로세스로의 종료 시그널은 약 20초의 유예가 있으며, 넘으면 강제 종료된다타임아웃 없음약 5초약 20초CTRL_C / CTRL_BREAK등록한 HandlerRoutine으로 정리CTRL_CLOSE_EVENTCTRL_SHUTDOWN_EVENT(서비스 프로세스)유예를 넘으면 강제 종료
// 콘솔 앱: Ctrl+C와 콘솔 닫기에서 정리를 한다
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetConsoleCtrlHandler(HandlerRoutine handler, bool add);

delegate bool HandlerRoutine(int ctrlType);   // 2 = CTRL_CLOSE_EVENT

static readonly HandlerRoutine s_handler = OnCtrlEvent;  // GC 회수 방지로 유지

static bool OnCtrlEvent(int ctrlType)
{
    // 5초 안에 끝나는 정리만 한다
    FlushAndCloseDataFile();
    return false;   // 기본 핸들러로 진행하고, 프로세스는 종료한다
}

static void Main()
{
    SetConsoleCtrlHandler(s_handler, add: true);
    // ...
}

5.2. .NET의 함정 ── ProcessExit에 의존하지 않는다

.NET에서는 오랫동안 「AppDomain.ProcessExit에서 정리하면 된다」는 정석이 있었지만, .NET 10부터 런타임은 기본 종료 시그널 핸들러를 제공하지 않게 되어, CTRL_CLOSE_EVENT/CTRL_SHUTDOWN_EVENT에서는 ProcessExit도 AssemblyLoadContext.Unloading도 발생하지 않게 되었습니다. OS의 기본 핸들러가 프로세스를 즉시 종료시킬 뿐입니다.7

.NET 10에서 바뀐 ProcessExit의 동작.NET 9까지는 런타임의 기본 시그널 핸들러가 종료 시그널을 받아 ProcessExit를 발생시킨 뒤 종료했다. .NET 10 이후는 런타임이 기본 핸들러를 제공하지 않고, OS의 기본 처리가 프로세스를 즉시 종료시키므로, 스스로 핸들러를 등록한다CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT.NET 9까지: 런타임 기본 핸들러 → ProcessExit 발생 → 종료.NET 10 이후: 기본 핸들러 없음 → OS의 기본 처리로 즉시 종료(ProcessExit 없음)대책: SetConsoleCtrlHandler / PosixSignalRegistration을 스스로 등록

대신, 앱 모델별 정규 경로로 모읍니다.

  • GUI 앱: 앞 장의 FormClosing / SessionEnding
  • Generic Host(Worker Service 포함): IHostApplicationLifetime과 BackgroundService.StopAsync. 중지 유예는 HostOptions.ShutdownTimeout으로 명시합니다
  • 순수한 콘솔 앱: SetConsoleCtrlHandler(또는 PosixSignalRegistration으로 SIGINT/SIGTERM 상당을 구독)
앱 모델별 종료 알림의 받음점GUI 앱은 FormClosing과 SessionEnding에 더해 확정 처리는 WM_ENDSESSION 훅, Generic Host는 IHostApplicationLifetime과 StopAsync, 순수한 콘솔 앱은 SetConsoleCtrlHandler 또는 PosixSignalRegistration으로 받는다. ProcessExit 의존은 외부 시그널 경로에서는 발생하지 않는다GUI(WinForms/WPF)Generic Host / Worker Service순수한 콘솔어느 앱 모델인가FormClosing / SessionEnding(확정 처리는 WM_ENDSESSION 훅)IHostApplicationLifetime + StopAsync(ShutdownTimeout을 명시)SetConsoleCtrlHandler / PosixSignalRegistrationAppDomain.ProcessExit 의존✕ .NET 10 이후, 외부 시그널 경로에서는 발생하지 않음

유예는 경로마다 다릅니다 ── GUI나 콘솔 닫기는 약 5초, 서비스는 6장의 SCM 유예(약 20초, PRESHUTDOWN이면 구성한 값), Ctrl+C에는 명시 타임아웃이 없습니다. 다만 어느 경로든 유예는 한정이 있고 믿기도 어려우므로, 「종료 이벤트에서 애쓴다」가 아니라, 처리의 국면마다 저장되어 있는 것이 정상계, 가 설계의 축이 됩니다.

6. Windows 서비스가 지켜야 할 것 ── SHUTDOWN과 PRESHUTDOWN

6.1. 두 종류의 종료 알림

서비스는 로그오프의 영향을 받지 않지만, 종료·다시 시작에서는 멈춥니다. 알림은 서비스 제어 관리자(SCM)로부터 제어 코드로 도착하며, 받으려면 수락 플래그의 선언이 필요합니다.8

선언 도착하는 알림 타이밍과 유예
SERVICE_ACCEPT_SHUTDOWN SERVICE_CONTROL_SHUTDOWN 종료 처리 안에서 알림. 기본으로 약 20초, 상한은 WaitToKillServiceTimeout
SERVICE_ACCEPT_PRESHUTDOWN SERVICE_CONTROL_PRESHUTDOWN SHUTDOWN보다 먼저 알림. 서비스가 중지하거나 타임아웃까지 SCM이 기다림
서비스로의 종료 알림 순서종료가 시작되면, 먼저 PRESHUTDOWN을 선언한 서비스에 구성한 유예와 함께 알림되고, 그 후 SHUTDOWN 알림이 기본 약 20초의 유예로 보내지며, 유예가 끝나면 프로세스는 종료된다종료 시작SERVICE_CONTROL_PRESHUTDOWN(선언한 서비스만·구성한 유예)SERVICE_CONTROL_SHUTDOWN(기본 약 20초)유예 만료 → 프로세스 종료

PRESHUTDOWN의 타임아웃은 ChangeServiceConfig2(SERVICE_CONFIG_PRESHUTDOWN_INFO)로 구성할 수 있으며, 기본값은 Windows 10 Creators Update(빌드 15063) 이후는 10초, 그 이전은 3분입니다.9 「PRESHUTDOWN이면 3분을 받는다」는 옛 지식 그대로면, 현행 OS에서는 기대의 1/18밖에 유예가 없습니다. 또한 PRESHUTDOWN은 그 동안 시스템 전체의 종료를 기다리게 하므로, 문서도 「특별한 상황에서만 써야 한다」고 합니다.8

핸들러 쪽의 하는 법도 중요합니다. 제어 핸들러는 30초 안에 반환해야 하며, 시간이 걸리는 중지 처리는 다른 스레드에 맡기고, 핸들러는 SERVICE_STOP_PENDING을 보고한 뒤 바로 돌아갑니다.8

// Win32 서비스: PRESHUTDOWN을 받고, 중지 처리는 워커에 맡긴다
g_status.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_PRESHUTDOWN;

DWORD WINAPI ServiceHandlerEx(DWORD control, DWORD, LPVOID, LPVOID)
{
    switch (control)
    {
    case SERVICE_CONTROL_PRESHUTDOWN:
    case SERVICE_CONTROL_STOP:
        ReportStatus(SERVICE_STOP_PENDING, /*waitHintMs*/ 3000);
        SetEvent(g_stopEvent);   // 워커에 중지를 지시하고, 즉시 반환한다
        return NO_ERROR;
    }
    return ERROR_CALL_NOT_IMPLEMENTED;
}

// 워커 쪽: 정리가 waitHint보다 길어지면, dwCheckPoint를 늘리면서
// SERVICE_STOP_PENDING을 정기적으로 계속 보고한다. SCM은 waitHint와 체크포인트
// 의 전진으로 「아직 살아 진행 중」이라고 판단한다. 보고가 멈추면 행으로 다루어
// 종료가 앞으로 진행될 수 있다. 끝나면 반드시 SERVICE_STOPPED를 보고한다

6.2. 유예에 기대지 않는 설계

유예의 상한인 WaitToKillServiceTimeout을 서비스 쪽에서 바꿔 늘리는 것은, 명확히 비권장입니다. 문서는 오히려 반대 방향 ── UPS 구동 중인 머신이 전지가 떨어지기 전에 종료를 마칠 수 있도록, 서비스는 가능한 한 빨리 정리를 끝내야 한다 ── 을 요구합니다. 평소부터 자주 저장해 미저장 데이터를 최소화하고, 종료 때는 메모리 해제 등에 시간을 쓰지 않으며, 네트워크 상대의 알림은 응답을 너무 기다리지 않는다는 지침입니다. 또한 종료 때의 SCM은 기본으로 의존 관계를 고려하지 않고 알리므로, 「의존처 서비스가 먼저 내려가도 깨지지 않는」 중지 처리로 두어야 합니다.8

유예에 기대지 않는 중지 처리의 설계처리의 국면마다 저장해 미저장 데이터를 항상 최소로 유지하면, 중지 알림이 왔을 때의 정리는 수 초 안에 끝난다. 종료 때 한꺼번에 저장하는 설계는 유예에 들어가지 못해, 강제 종료로 데이터를 잃는다평소: 국면마다 저장(미저장 데이터를 항상 최소로)중지 알림 → 남은 조금을 저장 → 수 초 안에 완료평소: 메모리에 쌓아 두고 종료 때 한꺼번에 저장중지 알림 → 저장이 유예에 들어가지 않음강제 종료 → 데이터 상실

.NET의 Worker Service(UseWindowsService)에서는, SERVICE_CONTROL_STOP이나 SHUTDOWN이 호스트의 중지로 변환되어, BackgroundService.StopAsync가 불립니다. 이 글을 쓰는 시점의 표준 구현이 받는 것은 STOP/SHUTDOWN 계열이며, PRESHUTDOWN까지 필요하면 핸들러의 확장 구현이 필요합니다. 어느 쪽이든 HostOptions.ShutdownTimeout을 명시하고, StopAsync를 수 초 안에 끝내는 것이 기본입니다. 서비스 만드는 법 전반은 「Windows 서비스의 만드는 법과 운용」을 참조하십시오.

7. 다시 시작 뒤에 자동 복귀한다

장치 PC나 무인 운전 PC에서는, 「종료를 견딘다」뿐만 아니라 「다시 시작 뒤에 알아서 복귀한다」까지가 설계 범위입니다.

7.1. RegisterApplicationRestart와 회복 콜백

RegisterApplicationRestart를 호출해 두면, 앱이 크래시(미처리 예외)·응답 없음·업데이트에 의한 앱 다시 시작·업데이트에 의한 OS 다시 시작 각각에서 다시 시작 대상으로 등록됩니다. 다시 시작 때의 명령줄 인수를 등록할 수 있으므로, 「어느 파일을 열고 있었는지」「복원 지점은 어느 것인지」를 인수에 넣어 두면, 다시 시작 뒤에 이어서 재개할 수 있습니다.10

잡아 둘 사양은 다음과 같습니다.10

  • 등록은 문제가 일어나기 전에 마쳐 두어야 합니다(업데이트 시나리오에서는 WM_QUERYENDSESSION 처리 중이 마지막 기회)
  • 다시 시작 루프 방지 때문에, 시작부터 60초 미만의 프로세스는 다시 시작되지 않습니다
  • 관리자로 승격해 동작하는 프로세스는, 자동 다시 시작의 대상이 되지 않습니다(승격 동의 없이 프로세스를 다시 만들 수 없기 때문입니다). 승격이 필요한 앱의 자동 복귀는, UI를 표준 권한으로 두고 특권 작업을 서비스로 분리하거나, 작업 스케줄러의 「가장 높은 수준의 권한으로 실행」 작업 등 명시적 시작 경로로 설계합니다
  • 크래시·행 때의 다시 시작은 사용자의 동의를 거쳐 이루어지고, 업데이트에 의한 다시 시작은 자동입니다
  • OS 다시 시작을 넘어 복귀시키려면, 다시 시작을 지시하는 쪽(설치 프로그램 등)이 EWX_RESTARTAPPS / SHUTDOWN_RESTARTAPPS 플래그를 붙여 종료 API를 호출해야 합니다

함께 RegisterApplicationRecoveryCallback을 등록하면, 크래시 때 WER(Windows 오류 보고)이 콜백을 호출해, 작업 중 데이터를 저장할 유예를 줍니다. 다만 저장에 시간이 걸리면, 등록 때 지정한 ping 간격 안에 ApplicationRecoveryInProgress를 계속 호출하지 않으면 회복 처리가 도중에 잘립니다. 저장이 끝나면 ApplicationRecoveryFinished로 완료를 알립니다. 앱 업데이트 때의 「사용 중인 파일 교체와 다시 시작」은 Restart Manager의 영역이며, 「사용 중인 exe/DLL을 어떻게 교체하는가」에서 자세히 다루고 있습니다.

7.2. ARSO ── 업데이트 다시 시작 뒤의 자동 로그온

Windows Update에 의한 다시 시작 뒤, 아무도 로그온하지 않으면 사용자 세션의 앱은 돌아오지 않습니다. 여기를 메우는 것이 ARSO(Winlogon 자동 다시 시작 사인온)입니다. Windows Update가 다시 시작을 시작할 때, 마지막 대화 사용자의 자격 증명을 안전하게 저장해 Autologon을 구성하고, 다시 시작 뒤에 그 사용자를 자동 로그온시킨 뒤 화면을 잠급니다.11 shutdown /g처럼, 다시 시작+등록 앱의 재개를 지시하는 명령도 있습니다. 조직의 정책(DisableAutomaticRestartSignOn 등)으로 꺼져 있는 환경도 있으므로, 무인 복귀를 설계할 때는 이 설정과 세트로 확인하십시오. 참고로, 상시 필요한 백그라운드 처리를 사용자 세션의 자동 시작에 맡길 정도라면, 처음부터 Windows 서비스로 두는 것이 맞습니다.

다시 시작 뒤에 앱이 자동 복귀하기까지의 경로RegisterApplicationRestart로 사전 등록해 두면, 크래시나 응답 없음에서는 사용자의 동의를 거쳐, 업데이트에 의한 다시 시작에서는 ARSO의 자동 로그온과 화면 잠금을 거쳐 앱이 다시 시작된다. 시작 60초 미만의 프로세스와 승격 프로세스는 대상 밖사용자의 동의RegisterApplicationRestart로 등록(문제가 일어나기 전에)크래시·응답 없음업데이트에 의한 다시 시작앱 다시 시작ARSO: 자동 로그온 + 화면 잠금대상 밖: 시작 60초 미만(루프 방지), 승격 프로세스

8. 알림이 오지 않는 전원 차단에 견딘다 ── 쓰기 설계와 UPS

8.1. 「언제 끊겨도 깨지지 않는」 쓰기 ── 임시 파일+ReplaceFile

차단기 차단·전원 유닛 고장·플러그 뽑힘에는, WM_ENDSESSION도 PRESHUTDOWN도 없습니다. 설정 파일이나 측정 결과를 「원본 파일에 직접 덮어쓰기」하는 한, 쓰기 도중의 전원 차단으로 옛것과 새것이 섞인 깨진 파일이 남을 수 있습니다.

정석은, 같은 볼륨의 임시 파일에 끝까지 쓴 뒤 교체하는 것입니다. ReplaceFile은 「새 파일로의 저장→원본 파일의 대피→이름 바꾸기→삭제」라는 일련의 절차를 하나의 API로 모은 것이며, 만든 시각·ACL·대체 스트림 등 원본 파일의 속성도 이어받습니다(세 파일은 동일 볼륨에 있어야 합니다).12 .NET의 File.Replace가 이것을 그대로 호출합니다.

임시 파일과 ReplaceFile에 의한 저장과 복구의 흐름저장 때는 임시 파일에 끝까지 쓴 뒤 플러시하고 ReplaceFile로 교체해 옛 내용을 .bak에 남긴다. 시작 때는 본체를 검증하고, 깨져 있으면 .bak로 폴백한다다음 시작 때저장 때정상깨져 있음어느 순간에 전원 차단이 일어나도본체 파일을 검증그대로 사용.bak로 폴백플러시(FlushFileBuffers 상당)임시 파일에 끝까지 쓴다ReplaceFile로 교체(옛 내용은 .bak로)
// 설정·데이터 저장의 정석: 임시 파일에 끝까지 쓴 뒤 교체하고, 옛 내용도 남긴다
public static void SaveAtomically(string path, string content)
{
    string dir = Path.GetDirectoryName(path)!;
    string tmp = Path.Combine(dir, Path.GetRandomFileName());  // 동일 볼륨 위에 만든다

    try
    {
        using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write))
        using (var writer = new StreamWriter(fs))
        {
            writer.Write(content);
            writer.Flush();
            fs.Flush(flushToDisk: true);   // FlushFileBuffers 상당. OS의 버퍼를 디스크로
                                           // 내보낸다(디바이스 쪽 캐시의 한계는 8.2절)
        }

        if (File.Exists(path))
            File.Replace(tmp, path, path + ".bak");  // ReplaceFile을 호출. 옛 내용을 .bak로 남긴다
        else
            File.Move(tmp, path);
    }
    catch
    {
        // 도중에 실패하면 임시 파일을 남기지 않는다. 정기 저장에서 실패가 이어지면
        // 완전한 복사본이 볼륨을 채워 가기 때문에
        try { File.Delete(tmp); } catch { /* 삭제 실패는 원래 예외를 우선 */ }
        throw;
    }
}

이것으로, 보통 운용에서는 항상 「완전한 옛 파일」 또는 「완전한 새 파일」을 읽을 수 있는 상태가 됩니다. 다만 ReplaceFile은 여러 단계의 이름 공간 조작이며, 전원 차단을 넘는 원자성이 사양으로 보장되어 있는 것은 아닙니다. 그래서 위 예에서는 백업(.bak)을 남기고 있습니다 ── 읽기 쪽은 시작 때 본체를 검증하고, 깨져 있으면 백업으로 폴백하는 것까지를 세트로 구현합니다. 추기형 로그나 CSV에는 쓸 수 없으므로, 그쪽은 「1행=1레코드로 쓰고, 읽을 때 깨진 끝 행을 버린다」처럼, 깨지는 방식을 전제로 한 형식으로 합니다.

8.2. WriteFile의 성공은 디스크 도달이 아니다

또 하나의 전제는, WriteFile이 성공을 반환해도, 데이터는 아직 OS의 캐시에만 있을 수 있다는 점입니다. Windows는 파일의 읽고 쓰기를 시스템 버퍼에 올리고, 지연 쓰기로 정기적으로 디스크에 반영합니다. 확실히 디스크에 보내려면, FlushFileBuffers로 명시적으로 플러시하거나, CreateFile 때 FILE_FLAG_WRITE_THROUGH를 지정해 쓸 때마다 캐시를 통과시킵니다. 또한 파일 시스템의 메타데이터는 항상 캐시되므로, 메타데이터의 확정에도 플러시 또는 write-through가 필요합니다.13

다만 FlushFileBuffers를 매번 호출하는 것은 비효율이며, 문서도 잦은 호출 대신 FILE_FLAG_NO_BUFFERING+WRITE_THROUGH의 검토를 권합니다.13 실무에서는 「트랜잭션의 국면·파일을 닫기 직전에만 플러시한다」가 현실적인 타협점입니다. 이 층의 장치 ── 캐시 관리자, 지연 쓰기, 그리고 「플러시했는데 디스크에 닿지 않은」 일이 있을 수 있는 하드웨어 캐시 이야기 ── 는 「캐시 관리자: 당신의 WriteFile은 언제 디스크에 닿는가」에서 깊게 다룹니다.

8.3. UPS와 배터리 감시 ── 전원 차단을 종료로 바꾼다

장치 PC의 전원 차단 대책의 핵심은 UPS입니다. UPS의 역할은 「정전을 막는」 것이 아니라, 「알림 없는 전원 차단」을 「알림 있는 계획 종료」로 바꾸는 것으로 받아들이십시오. 설계는 다음의 2단 구성입니다.

  1. 유예의 설계: UPS의 배터리 유지 시간 >「배터리 전환 감지〜앱·서비스의 정리〜OS 종료 완료」의 합계 시간, 을 충족할 것. 서비스의 중지 처리가 느리면, 이 식이 성립하지 않게 됩니다(6.2절)
  2. 감지: AC 전원에서 배터리로의 전환이나 잔량 저하는, PBT_APMPOWERSTATUSCHANGE 이벤트로 알려집니다. 창을 가진 앱은 WM_POWERBROADCAST로 받고, 창이 없는 서비스는 SERVICE_ACCEPT_POWEREVENT를 선언한 뒤 HandlerEx의 SERVICE_CONTROL_POWEREVENT로 받습니다(WM_POWERBROADCAST는 서비스의 제어 핸들러에는 오지 않습니다). 받으면 GetSystemPowerStatus를 호출하고, ACLineStatus(AC 급전인지)나 BatteryLifePercent를 확인해, 계측의 중단·저장·종료 요청으로 이어갑니다15
UPS로 전원 차단을 계획 종료로 바꾸는 흐름정전으로 UPS가 배터리 급전으로 바뀌면 PBT_APMPOWERSTATUSCHANGE가 알림되고, 전원 상태를 확인해 저장과 종료 요청으로 이어가, 알림 없는 전원 차단이 알림 있는 계획 종료로 바뀐다정전·전원 차단UPS가 배터리 급전으로 전환PBT_APMPOWERSTATUSCHANGE 알림GetSystemPowerStatus로 상태 확인계측의 중단·저장OS로 종료 요청보통의 종료 알림 흐름(3〜6장)

USB 연결의 일반적인 UPS는 Windows에서 배터리로 보이므로, 이 표준 API로 감지할 수 있습니다. 벤더 관리 소프트웨어가 「잔량 N%에서 OS를 종료한다」 기능을 가진 경우는, 그 임계값과 자기 앱의 정리 시간의 정합도 확인하십시오. 참고로, 절전이나 최대 절전에서의 복귀와 장시간 가동의 문제는 다른 축의 이야기로서 「절전·최대 절전·Modern Standby와 장시간 가동 앱」에서 다루고 있습니다.

9. 검증 방법 ── 종료를 안전하게 시험한다

종료 처리는 「썼지만 한 번도 실제 운용 상당으로 시험하지 않은」 코드가 되기 쉽습니다. 안전하게 검증하는 절차를 마련해 둡니다.

  • 검증기·가상 머신에서 시험한다: 실제 운용의 장치 PC에서 갑자기 시험하지 않고, Hyper-V 등의 검사점(스냅샷)을 뜬 검증 환경에서, 종료·다시 시작·강제 전원 차단(VM의 전원 끄기)을 반복합니다. 다만 VM의 「전원 끄기」로 재현할 수 있는 것은 「게스트 OS가 예고 없이 멈춘다」까지이며, 물리 디스크의 휘발 캐시 소실이나 컨트롤러에 의존하는 깨짐까지는 재현하지 못합니다. 장치 PC로 출하한다면, 최종 확인은 실제 운용 상당의 하드웨어에서 실제로 전원을 끄는 테스트를 합니다
  • 로그오프로 간이 확인한다: WM_QUERYENDSESSION→WM_ENDSESSION의 경로는 로그오프에서도 통과하므로(lParam의 ENDSESSION_LOGOFF 비트가 서는 점만 다릅니다), 개발기에서 손쉽게 정리 코드의 동작을 확인할 수 있습니다1
  • 완전 종료와 하이브리드를 구분해 시험한다: shutdown /s /t 0(완전)과 shutdown /s /hybrid /t 0(기본 동작), shutdown /r /t 0(다시 시작)을 각각 시험합니다2
  • 정리에 걸린 시간을 잰다: 정리 함수의 선두와 끝에 로그로 시각을 쓰고, 5초(서비스라면 구성한 유예)에 들어가는지를 실측합니다
검증하는 조작과 확인할 수 있는 범위로그오프로 알림 경로를 손쉽게 확인하고, shutdown 명령의 완전·하이브리드·다시 시작으로 실제 운용의 알림 경로와 유예를 확인하며, VM의 전원 끄기로 갑작스러운 중지에의 내성을, 실기의 전원 차단 테스트로 물리 스토리지까지 포함한 내성을 최종 확인한다로그오프WM_QUERYENDSESSION → WM_ENDSESSION의 경로(손쉬운 확인)shutdown /s·/s /hybrid·/r실제 운용의 알림 경로와 유예VM의 전원 끄기게스트가 예고 없이 멈추는 내성실기의 전원 차단 테스트물리 스토리지까지 포함한 내성(최종 확인)

사후의 원인 분리에는 이벤트 로그(System)를 쓸 수 있습니다. 정상적인 종료·다시 시작에서는, 이벤트 ID 1074(어느 프로세스가 누구를 위해, 어떤 이유로 종료를 시작했는지)가 기록됩니다. 갑작스러운 전원 차단이나 크래시에서는 1074가 없고, 다음 시작 때 이벤트 ID 41(Kernel-Power)과 6008(직전 종료는 예기되지 않았습니다)이 기록됩니다.14 「한밤중에 무슨 일이 있었는지」는 먼저 여기를 봅니다.

# 종료 관련 이벤트의 최근 이력을 확인한다
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = 1074, 6008, 41 } -MaxEvents 20 |
    Select-Object TimeCreated, Id, ProviderName, Message |
    Format-List

1074가 「Windows Update에 의한 다시 시작」을 가리키는데 앱의 데이터가 깨져 있었다면, 정리 코드의 문제입니다. 6008/41이 가리키는 것은 「예기치 않은 종료」까지이며, 전원 차단 외에 블루스크린(크래시)이나 강제 리셋에서도 기록됩니다. 41의 BugcheckCode가 0이 아니면 크래시, 0이고 메모리 덤프도 남아 있지 않으면 전원 차단이 유력, 하는 식으로 주변 정보로 원인을 가르고, 전원 차단으로 밝혀지면 8장의 쓰기 설계와 UPS의 차례입니다.

10. 정리

  • 종료는 「언젠가 반드시 오는 정상적인 이벤트」입니다. 알림 뒤의 유예는 원칙적으로 5초 정도뿐이므로, 자주 자동 저장해 「종료 때 할 일」을 최소화해 두는 것이 대전제입니다.
  • Windows 8 이후 클라이언트 OS에서는, 빠른 시작이 켜져 있으면 「종료」는 하이브리드 종료이며, 커널은 최대 절전만 하고 있습니다. 완전 리셋은 「다시 시작」뿐 ── 장애 대응 절차에는 「다시 시작」이라고 적어 두십시오.
  • GUI 앱은 WM_QUERYENDSESSION에 즉시 TRUE를 반환하고, 확정 뒤의 정리는 WM_ENDSESSION에서 합니다. WinForms/WPF의 FormClosing·SessionEnding은 조회 단계에 대응하므로, 거기서 하는 것은 멱등한 스냅샷 저장까지입니다. 종료 중에 대화 상자를 내서는 안 됩니다.
  • 도저히 중단할 수 없는 처리는, ShutdownBlockReasonCreate로 이유를 게시해 지킵니다. 다만 막을 수 있다는 보장은 어디에도 없습니다.
  • 콘솔 앱은 SetConsoleCtrlHandler, 서비스는 SERVICE_ACCEPT_PRESHUTDOWN/SHUTDOWN으로 알림을 받습니다. PRESHUTDOWN의 기본 유예는 현행 OS에서 10초입니다. .NET에서는 ProcessExit 의존을 그만두고, 앱 모델의 정규 경로로 모읍니다.
  • 다시 시작 뒤의 복귀는, RegisterApplicationRestart(+회복 콜백)와 ARSO로 무인화할 수 있습니다.
  • 전원 차단에는 알림이 오지 않습니다. 임시 파일+ReplaceFile에 의한 교체(백업+시작 시 검증과 세트), 국면의 플러시, UPS에 의한 「전원 차단의 계획 종료화」로 대비합니다.
종료 대응의 전체 모습알림 있는 끝나는 방식에는 수 초 안에 문을 닫을 수 있는 정리로 응해 다시 시작 뒤의 자동 복귀로 이어가고, 알림 없는 전원 차단에는 언제 끊겨도 깨지지 않는 쓰기와 UPS로 대비해 실기까지 포함해 검증한다. 이 두 기둥이 글의 결론알림 있음(로그오프·종료·다시 시작)알림 없음(전원 차단)끝나는 방식수 초 안에 문을 닫을 수 있는 정리(3〜6장)언제 끊겨도 깨지지 않는 쓰기 + UPS(8장)다시 시작 뒤의 자동 복귀(7장)실기까지 포함한 검증(9장)
  • 검증은 가상 머신과 로그오프로 안전하게 하고, 사후는 이벤트 ID 1074/41/6008로 원인을 가릅니다.

다음에 앱에 기능을 더할 때, 한 번만 이렇게 자문해 보십시오. 이 처리의 한가운데에 WM_ENDSESSION이 오거나, 전원이 뽑히면, 다음 시작 때 무엇이 남는가. 그 답을 설계에 적어 두는 것이, 「아침, 장치 PC 앞에서 머리를 싸매는 날」을 없애는 가장 가까운 길입니다.

관련 기사

관련하는 상담 영역

합동회사 코무라소프트에서는, 장치 PC·장시간 가동 앱의 종료/전원 차단 대책의 설계와 구현, Windows Update의 다시 시작이나 로그오프를 기점으로 하는 데이터 손상·「아침에 멈춰 있었다」 장애의 원인 조사, Windows 서비스의 중지 처리·자동 복귀 주변의 설계 리뷰를 다루고 있습니다. 「종료할 때마다 무언가 깨지는 느낌이 있지만, 어디서부터 손대면 좋을지 모르겠다」는 단계부터여도 괜찮습니다.

참고 링크

  1. Microsoft Learn, WM_QUERYENDSESSION message. 세션 종료 때 WM_QUERYENDSESSION이 보내지고, 앱은 TRUE를 반환해 사용자의 의도를 존중해야 한다는 것(DefWindowProc의 기본도 TRUE), 정리는 WM_ENDSESSION까지 늦춰야 한다는 것, 5초 뒤 시스템이 종료를 막고 있는 앱의 UI를 표시하고 사용자가 강제 종료할 수 있다는 것, lParam의 ENDSESSION_LOGOFF/CLOSEAPP/CRITICAL 비트의 의미, 종료와 다시 시작은 구별할 수 없다는 것, 데이터를 자주 저장해 종료 때의 저장량을 줄여야 한다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. Microsoft Learn, Fast startup causes hibernation or shutdown to fail in Windows 10 or Windows 8.1. 빠른 시작에서는 커널 세션이 닫히지 않고 최대 절전으로 다루어져, 커널과 디바이스 드라이버 상태가 hiberfil.sys에 저장된다는 것, 「다시 시작」은 완전히 새로운 Windows 상태가 필요하므로 항상 풀 부팅을 한다는 것, 빠른 시작은 기본으로 켜져 있고 끄기는 권장되지 않는다는 것, Shutdown.exe의 기본이 완전 종료이고 /hybrid 옵션으로 하이브리드 동작이 된다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5

  3. Microsoft Learn, ShutdownBlockReasonCreate function (winuser.h). 중단할 수 없는 처리의 시작 때 호출해 이유 문자열을 등록하고, 완료 때 ShutdownBlockReasonDestroy를 호출할 것, 창을 만든 스레드에서만 호출할 수 있다는 것, 사용자는 수 초밖에 이유를 읽지 않으므로 짧고 명확한 문자열이어야 한다는 것에 대해. ↩ ↩2 ↩3

  4. Microsoft Learn, Shutdown Changes for Windows Vista. WM_QUERYENDSESSION/WM_ENDSESSION에의 응답을 늦출 수 있는 것은 각 5초이며 그 뒤에는 사용자가 계속·중지를 고를 수 있다는 것, 콘솔 앱이나 보이는 창이 없는 앱은 종료를 중지할 수 없고 5초 무응답 또는 FALSE 응답으로 자동 종료된다는 것, 막아야 하면 ShutdownBlockReasonCreate로 이유를 등록해야 한다는 것, 앱은 종료를 막을 수 있다는 것에 의존해서는 안 된다는 것에 대해. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, HandlerRoutine callback function. SetConsoleCtrlHandler로 등록하는 핸들러가 받는 CTRL_C/BREAK/CLOSE/LOGOFF/SHUTDOWN의 각 이벤트, CTRL_CLOSE_EVENT의 기본 타임아웃이 약 5000밀리초이고 서비스 프로세스의 CTRL_SHUTDOWN_EVENT가 약 20000밀리초인 것, CTRL_LOGOFF/SHUTDOWN_EVENT는 대화 앱이 로그오프 시점에 종료되므로 실질적으로 서비스만 받는다는 것, 핸들러는 다른 스레드에서 실행된다는 것에 대해. ↩ ↩2 ↩3

  6. Microsoft Learn, SetConsoleCtrlHandler function. gdi32.dll 또는 user32.dll을 로드한 프로세스는 Windows 앱으로 다루어져 CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT의 핸들러가 불리지 않는다는 것, 그 우회로 숨은 창을 만들어 WM_QUERYENDSESSION/WM_ENDSESSION을 처리해야 한다는 것, 시그널 처리 중에는 콘솔 함수가 정상 동작하지 않는 경우가 있다는 것에 대해. ↩ ↩2

  7. Microsoft Learn, .NET runtime no longer provides default termination signal handlers. .NET 10부터 런타임이 Windows의 CTRL_SHUTDOWN_EVENT/CTRL_CLOSE_EVENT(Unix의 SIGTERM/SIGHUP 상당)에의 기본 핸들러를 제공하지 않게 된 것, OS의 기본 처리는 앱을 즉시 종료시켜 AppDomain.ProcessExit나 AssemblyLoadContext.Unloading이 발생하지 않게 되는 것, 앱 모델에 맞는 시그널 처리는 상위 라이브러리나 앱 코드에서 등록해야 한다는 것에 대해. ↩ ↩2

  8. Microsoft Learn, Service Control Handler Function. SERVICE_ACCEPT_PRESHUTDOWN을 선언한 서비스가 먼저 SERVICE_CONTROL_PRESHUTDOWN을 받고, 그 후 SERVICE_ACCEPT_SHUTDOWN의 서비스가 SERVICE_CONTROL_SHUTDOWN을 받는다는 것, 종료 때의 기본 유예가 약 20초이고 OS 다시 시작 때의 상한이 WaitToKillServiceTimeout인 것, 이 값을 늘려서는 안 된다는 것, 제어 핸들러는 30초 안에 반환하고 STOP_PENDING과 대기 힌트를 보고해 긴 처리는 다른 스레드에 맡긴다는 것, UPS 구동을 고려해 정리를 가능한 한 빨리 끝내야 한다는 것, 종료 때의 SCM은 기본으로 의존 관계를 고려하지 않는다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5

  9. Microsoft Learn, SERVICE_PRESHUTDOWN_INFO structure (winsvc.h). PRESHUTDOWN 알림 뒤 SCM이 서비스 중지 또는 타임아웃까지 기다린다는 것, 기본 타임아웃이 Windows 10 Creators Update(빌드 15063) 이후는 10초, 그 이전은 3분인 것, ChangeServiceConfig2로 구성한다는 것, SERVICE_STOP_PENDING 중에는 상태 갱신을 계속할 수 있다는 것에 대해. ↩ ↩2

  10. Microsoft Learn, RegisterApplicationRestart function (winbase.h). 크래시·응답 없음·업데이트·업데이트에 따른 컴퓨터 다시 시작의 각 시나리오에서 다시 시작을 등록할 수 있다는 것, 다시 시작 때의 명령줄 인수를 지정할 수 있다는 것, 등록은 문제 발생 전에 하고 업데이트 시나리오에서는 WM_QUERYENDSESSION 처리 중이 마지막 기회라는 것, 시작 60초 미만의 프로세스는 다시 시작되지 않는다는 것, 크래시·행 때는 사용자의 동의를 거쳐 다시 시작된다는 것, OS 다시 시작을 넘기려면 EWX_RESTARTAPPS/SHUTDOWN_RESTARTAPPS로 종료해야 한다는 것에 대해. ↩ ↩2 ↩3

  11. Microsoft Learn, Winlogon automatic restart sign-on (ARSO). Windows Update가 자동 다시 시작을 시작할 때 마지막 대화 사용자의 자격 증명을 저장해 Autologon을 구성한다는 것, 다시 시작 뒤에 사용자를 자동 로그온시키고 세션을 잠근다는 것, 로그온 성공 뒤 저장된 자격 증명이 삭제된다는 것, 그룹 정책(DisableAutomaticRestartSignOn 등)으로 구성할 수 있다는 것에 대해. ↩ ↩2

  12. Microsoft Learn, ReplaceFileW function (winbase.h). ReplaceFile이 「새 파일로의 저장·원본 파일의 임시 이름 바꾸기·새 파일의 이름 바꾸기·원본 파일의 삭제」에 해당하는 여러 절차를 하나의 함수로 모은다는 것, 만든 시각·DACL·암호화·압축·명명된 스트림 등 원본 파일의 속성을 유지한다는 것, 백업·치환 대상·치환 파일이 동일 볼륨에 있어야 한다는 것에 대해. ↩ ↩2

  13. Microsoft Learn, File Caching. 쓰기가 기본으로 시스템 캐시에 올라 지연 쓰기로 디스크에 반영된다는 것, FILE_FLAG_WRITE_THROUGH로 쓰기를 즉시 디스크에 쓴다는 것, FlushFileBuffers로 명시적으로 플러시할 수 있다는 것, 파일 시스템의 메타데이터는 항상 캐시되므로 메타데이터의 확정에는 플러시 또는 write-through가 필요하다는 것에 대해. ↩ ↩2 ↩3

  14. Microsoft Learn, Troubleshoot unexpected reboots using system event logs. 정상적인 다시 시작에서는 이벤트 ID 1074(어느 프로세스가 누구를 위해 어떤 이유로 종료를 시작했는지)가 기록된다는 것, 예기치 않은 다시 시작에서는 이벤트 ID 41(Kernel-Power)과 6008(직전 종료는 예기되지 않았다)이 기록된다는 것, 이들 ID로 다시 시작의 종류를 가를 수 있다는 것에 대해. ↩ ↩2

  15. Microsoft Learn, PBT_APMPOWERSTATUSCHANGE event. 배터리와 AC 전원의 전환이나 잔량 저하 때 WM_POWERBROADCAST로 이 이벤트가 알려진다는 것, 받으면 GetSystemPowerStatus를 호출해 SYSTEM_POWER_STATUS의 ACLineStatus·BatteryFlag·BatteryLifePercent 등을 확인해야 한다는 것에 대해. ↩

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

「종료」로는 고쳐지지 않던 문제가 「다시 시작」하니 고쳐졌습니다. 왜인가요?
Windows 8 이후 클라이언트 OS에서 빠른 시작이 켜져 있으면(최대 절전을 지원하는 대부분의 PC에서 기본값) 「종료」는 하이브리드 종료로 동작합니다. 사용자는 로그오프되지만, 커널과 드라이버 상태는 최대 절전 파일에 저장되어 다음 시작 때 그대로 복원됩니다. 즉 OS의 핵심은 리셋되지 않습니다. 반면 「다시 시작」은 항상 완전 부팅을 하므로 드라이버와 서비스의 이상이 리셋됩니다. 원인 분리 절차에는 「종료했다가 전원을 다시 켠다」가 아니라 「다시 시작한다」고 적어 두십시오. 명령으로 완전 종료를 하려면 shutdown /s를 쓸 수 있습니다.
앱의 저장이 끝날 때까지 종료를 막을 수 있나요?
잠시 기다려 달라고 요청할 수는 있지만, 확실하게 막을 수는 없습니다. 중단할 수 없는 처리 중에만 ShutdownBlockReasonCreate로 이유 문자열을 등록하면, 「이 앱이 종료를 막고 있습니다」 화면에 그 이유가 표시되어 사용자가 계속할지 취소할지를 판단할 수 있습니다. 다만 사용자는 강제 계속을 고를 수 있고, 강제 종료나 업데이트에 의한 다시 시작에서는 기다려 주지 않는 경우도 있습니다. 따라서 정석은 「막는 것」이 아니라, 자주 자동 저장해 잃을 데이터를 줄이고, 종료 알림부터 수 초 안에 끝나는 정리를 설계하는 것입니다.
Windows 서비스의 중지 처리에 시간이 걸립니다. 종료 때의 유예를 늘릴 수 있나요?
SERVICE_CONTROL_SHUTDOWN으로 알림을 받는 기본 구성에서 유예는 대략 20초 정도이며, 레지스트리의 WaitToKillServiceTimeout에 따릅니다. 이 값을 앱 쪽에서 바꿔 늘리는 것은 권장되지 않습니다. 더 긴 유예가 필요하면 SERVICE_ACCEPT_PRESHUTDOWN을 선언하고 SERVICE_CONTROL_PRESHUTDOWN을 받는 방법이 있습니다. 다른 쪽보다 먼저 알림이 오고, 타임아웃은 ChangeServiceConfig2로 구성할 수 있습니다(기본값은 Windows 10 Creators Update 이후 10초, 그 이전은 3분). 다만 PRESHUTDOWN은 그 동안 종료 전체를 기다리게 하므로, 정말로 필요한 경우로 한정하고, 근본적으로는 중지 처리 자체를 수 초 안에 끝내는 설계여야 합니다.
.NET의 AppDomain.ProcessExit에서 종료 때의 정리를 해도 될까요?
의존하지 않는 쪽을 권합니다. 예전에는 런타임이 기본 시그널 핸들러를 등록해 두어, CTRL_CLOSE_EVENT와 CTRL_SHUTDOWN_EVENT에서 ProcessExit 이벤트가 발생했지만, .NET 10부터 런타임은 기본 종료 시그널 핸들러를 제공하지 않으며, 이런 장면에서 ProcessExit는 발생하지 않습니다. GUI 앱은 FormClosing이나 SessionEnding(다만 조회 단계의 알림이므로 멱등한 저장으로 한정하고, 확정 뒤에만 할 수 있는 정리는 WM_ENDSESSION 훅에서 합니다), Generic Host/Worker Service는 IHostApplicationLifetime과 StopAsync, 콘솔 앱은 SetConsoleCtrlHandler나 PosixSignalRegistration처럼, 앱 모델에 맞는 알림 경로에서 정리를 구현하십시오.
갑작스러운 전원 차단으로 파일이 깨지는 것을 막으려면 어떻게 하면 되나요?
전원 차단에는 알림이 전혀 오지 않으므로, 「언제 끊겨도 깨지지 않는 쓰기」를 해 두는 수밖에 없습니다. 기본은 원본 파일을 직접 덮어쓰지 않고, 같은 볼륨의 임시 파일에 끝까지 쓴 뒤 플러시하고, ReplaceFile(.NET이면 File.Replace)로 교체하는 것입니다. 보통 운용에서는 이것으로 옛 파일 또는 새 파일 중 완전한 쪽을 읽을 수 있는 상태가 되지만, ReplaceFile이 전원 차단을 넘는 원자성까지 사양으로 보장하는 것은 아니므로, 백업(세 번째 인수)을 남기고, 시작 때 본체를 검증해 깨져 있으면 백업으로 되돌리는 읽기 처리까지를 세트로 구현합니다. 게다가 WriteFile의 성공은 디스크 도달을 뜻하지 않으므로, 중요한 국면에서는 FlushFileBuffers나 FILE_FLAG_WRITE_THROUGH로 쓰기를 확정합니다. 장치 PC에서는 UPS를 함께 쓰고, 배터리 구동으로의 전환을 감지해 안전한 종료로 이어가는 구성이 정석입니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기