Windows 서비스 만드는 방법과 운영 ── 태스크 스케줄러와의 구분 사용부터 BackgroundService의 서비스화까지

· · Windows 서비스, Windows, .NET, C#, BackgroundService, Generic Host, 상주 처리, 운영, 판단표, 기술 상담

“5분마다 도는 태스크 스케줄러로는 감당이 안 되기 시작했다”, “장치에서 오는 데이터를 상시 대기하는 감시 데몬을 상주시키고 싶다”, “폴더에 파일이 놓이면 몇 초 안에 처리해 줬으면 한다”. 정기 실행에 관한 상담을 계속하다 보면, 요건이 커진 끝에 반드시 이런 이야기가 나옵니다.

이 블로그에서는 Power Automate를 이용한 업무 자동화, 태스크 스케줄러의 안전한 운영 설계로 자동화 이야기를 이어서 써 왔습니다. 태스크 스케줄러 글의 8장에서 “분 단위 폴링이나 상시 감시가 필요해지면 상주 프로세스의 영역”이라는 선을 그었는데, 그럼 실제로 Windows 서비스를 어떻게 만들고, 어떻게 운영에 올려야 할까요. 여기를 모호하게 둔 채 “일단 서비스화”해 버리면, “멈출 수 없는 서비스”, “다운된 채로 아무도 모르는 서비스”, “LocalSystem으로 뭐든지 동작하는 서비스”가 태어납니다.

이 글에서는 상주 처리를 Windows 서비스로 만들어야 할지 판단하는 표, 서비스 메커니즘의 최소 지식(SCM・시작 유형・세션 0), .NET 8의 Worker Service를 이용한 구현, sc.exe를 이용한 등록과 복구 옵션, 실행 계정의 선택, 그리고 안전한 정지 처리──를 실무에서 결정하는 순서로 정리합니다.

1. 먼저 결론

  • 구분해서 사용하는 축은 단순합니다. 몇 분 이상의 간격으로 실행되는 정기 처리라면 태스크 스케줄러로 충분합니다. 상시 대기(소켓, 명명된 파이프, FileSystemWatcher)・초 단위 반응・다운되었을 때의 자동 복구가 요건에 들어가면 Windows 서비스입니다.
  • Windows Vista 이후로 서비스는 세션 0에서 동작하며, 사용자와 직접 대화할 수 없습니다(UI를 표시할 수 없습니다). UI가 필요하다면 서비스와 UI 앱을 분리해 프로세스 간 통신으로 대화하는 설계로 만듭니다.1
  • .NET에서 만드는 방법은 Worker Service 템플릿+Microsoft.Extensions.Hosting.WindowsServicesAddWindowsService(IHostBuilder 계열이라면 UseWindowsService)가 공식 경로입니다. 콘솔 앱으로 그대로 실행할 수 있기 때문에, 개발・디버깅이 기존의 서비스 개발보다 훨씬 편해졌습니다.2
  • .NET 6 이후로 BackgroundService 안의 처리되지 않은 예외는 기본값으로 호스트를 정지시킵니다(BackgroundServiceExceptionBehavior.StopHost). 다만 이것은 “정상적인 정지”이므로, SCM의 복구 옵션에 의한 재시작이 작동하지 않습니다. 재시작시키고 싶은 실패는 0이 아닌 종료 코드로 프로세스를 종료시킵니다.32
  • 실행 계정을 습관적으로 LocalSystem으로 하지 마세요. sc.exe create의 기본값이 LocalSystem이기 때문에, 의식하지 않으면 최강 권한으로 동작하기 시작합니다.4 로컬에서 완결되는 처리라면 LocalService나 가상 계정, 도메인에서 공유나 DB를 다루는 경우라면 gMSA가 본진입니다.5
  • 복구 옵션(sc.exe failure)과 정지 처리(HostOptions.ShutdownTimeout, 기본값 30초6)는 등록할 때 설계합니다. “정지에 응답하지 않는 서비스”는 운영에서 가장 기피되는 존재입니다.

2. 언제 서비스로 만들어야 하는가 ── 태스크 스케줄러・서비스・상주 앱의 판단표

상주하는 듯한 요건이 나왔을 때의 선택지는 3가지입니다. 태스크 스케줄러, Windows 서비스, 그리고 “시작 프로그램에 등록한 상주 앱”(알림 영역에 상주하는 타입). 특징을 나란히 놓아 봅니다.

관점 태스크 스케줄러 Windows 서비스 상주 앱(시작 프로그램 등록)
시작 시점 시각・이벤트를 계기로 매번 시작 OS 시작 시(로그온 전)부터 상주 사용자 로그온 시부터
로그온 불필요 여부 불필요하게 할 수 있음(비대화형 실행) 불필요 필요(로그오프하면 사라짐)
UI 표시 불가(비대화형 구성에서는) 표시 불가(세션 0) 표시 가능(알림 영역・대화상자)
상시인가 정기인가 정기(시간 단위~일 단위가 적합) 상시 상시(다만 사용자 세션 내)
권한 태스크마다 실행 계정 지정 서비스 계정(6장) 로그온 사용자의 권한
감시・복구 이력+자체 알림 SCM의 복구 옵션(자동 재시작) 없음(직접 만들어야 함)
배포 수고 XML / PowerShell로 등록 sc.exe / 설치 프로그램 Run 키 등에 등록

판단의 축은 이렇습니다.

  • 하루에 몇 번~시간 단위의 정기 처리이고, 처리 사이에 상태를 유지하지 않는다면 태스크 스케줄러입니다. 여기를 굳이 서비스화해서 직접 타이머를 관리하는 것은 과합니다. 태스크 스케줄러 글에서 쓴 운영 설계 쪽이 훨씬 저렴하게 끝납니다.
  • 상시 대기가 본질──TCP나 파이프로의 요청 대기, FileSystemWatcher를 이용한 폴더 감시, 큐의 순차 처리, 초 단위 반응──이라면 Windows 서비스입니다. “5분마다 도는 태스크”로 잘게 잘라 폴링하기 시작했다면, 그것은 상주 프로세스를 열화된 형태로 재구현하고 있다는 신호입니다.
  • UI가 본체(알림 영역에서의 조작, 사용자에게 표시하는 팝업)라면 상주 앱입니다. 다만 로그온되어 있지 않으면 동작하지 않으므로, 서버 성격의 용도에는 쓸 수 없습니다. “백그라운드 처리는 상시 필요하지만 UI도 필요하다”라면, 다음 장에서 설명하는 것처럼 서비스와 UI 앱의 2개 프로세스로 나눕니다.

또 하나, 놓치기 쉬운 판단 요소가 복구입니다. 태스크 스케줄러의 작업은 실패해도 다음 스케줄까지 방치되지만, 서비스는 SCM이 자동 재시작을 챙겨 줍니다(5장). “야간에 다운되어도 아침까지 스스로 복귀해 있었으면 좋겠다”는 요건은, 그 자체로 서비스화의 이유가 됩니다.

3. 서비스 메커니즘의 최소 지식 ── SCM・시작 유형・세션 0

3.1 SCM과 시작 유형

Windows 서비스의 주관자는 서비스 제어 관리자(SCM)입니다. 서비스의 등록 명부를 관리하고, 시작・정지 요청을 중개하며, 실패 시 복구 동작을 실행합니다. 서비스 프로세스는 SCM과 규약대로 대화할 수 있는 구조여야 하는데, 이 부분은 .NET 라이브러리가 대신 처리해 주므로(4장), 우리가 설계로서 결정하는 것은 “시작 유형”, “실행 계정”, “복구”, “정지”의 4가지입니다.

시작 유형(스타트업 유형)은 4가지 중 하나를 고릅니다.4

시작 유형 동작 쓰는 곳
자동(auto) OS 시작 시 시작 상시 가동 서비스의 기본
자동・지연 시작(delayed-auto) 다른 자동 서비스보다 약간 늦게 시작 업무 서비스는 우선 이것. 시작 직후의 혼잡과 의존 대상 미시작을 피함
수동(demand) 요청되었을 때만 시작 다른 앱에서 시작되는 보조 서비스
사용 안 함(disabled) 시작 불가 정지 조치・봉인

업무 서비스는 지연 시작을 기본값으로 합니다. OS 시작 직후에는 네트워크도 DB도 다른 서비스도 아직 갖춰지지 않았기 때문에, “자동”으로 최대한 빨리 시작하면 첫 연결에 실패하기 쉽습니다. 지연 시작으로 시간차를 두고, 연결 실패는 뒤에서 설명할 ExecuteAsync 안의 재시도로 흡수하는 2단 구성이 안정적입니다.

하나 더, 시작 타임아웃을 알아 두세요. SCM은 서비스의 시작 완료 보고를 무한정 기다리지 않습니다. 기본 타임아웃(ServicesPipeTimeout, 30초)을 넘기면 이벤트 7000 / 7011이 기록되고, 시작 실패로 처리됩니다.7 즉, DB 연결 재시도나 큰 캐시 구축 같은 무거운 초기화를 “시작 처리” 안에서 해서는 안 됩니다. 시작은 곧바로 완료시키고, 무거운 처리는 본체 루프 쪽(ExecuteAsync)에서 하는 것이 원칙입니다.

3.2 세션 0 분리 ── 서비스는 UI를 표시할 수 없다

Windows Vista 이후로 서비스는 세션 0이라는 격리된 세션에서 동작하며, 사용자와 직접 대화할 수 없습니다.1 서비스 안에서 MessageBox.Show나 폼 표시를 호출해도, 로그온 중인 사용자의 화면에는 아무것도 나타나지 않습니다. 오히려 아무도 누를 수 없는 OK 버튼을 계속 기다리며 처리 전체가 멈추는, 전형적인 행(hang)의 원인이 됩니다. 오래된 코드를 서비스로 옮길 때는 오류 표시용으로 남겨 둔 메시지 박스가 없는지 반드시 확인하세요.

UI가 필요한 경우의 정답은, 서비스(세션 0)와 UI 앱(사용자 세션)으로 프로세스를 나누고, 프로세스 간 통신(IPC)으로 대화하는 구성입니다. 이것은 Microsoft 스스로가 안내하는 설계로, 명명된 파이프 등의 IPC를 사용해 UI 쪽이 결과를 서비스로 돌려줍니다.1 어떤 IPC 수단을 선택할지는, 같은 날 공개된 자매글 “프로세스 간 통신 판단표“에서 정리하고 있습니다. UI 앱 쪽도 Generic Host에 올려 두면, 두 프로세스에서 DI・로그・설정 구조를 통일할 수 있습니다(“Generic Host와 BackgroundService를 데스크톱 앱에서 사용하기” 참조).

4. .NET에서 만드는 방법 ── Worker Service와 AddWindowsService

4.1 템플릿과 Program.cs

.NET에서의 서비스 개발은 Worker Service 템플릿에서 시작합니다. SDK에 동봉되어 있어서 dotnet new worker로 기본 골격이 만들어지고, 여기에 Microsoft.Extensions.Hosting.WindowsServices 패키지를 추가해 AddWindowsService를 호출하기만 하면, Windows 서비스로 동작할 준비가 끝납니다.2 토대가 되는 Generic Host의 메커니즘은 “.NET Generic Host란“에서 설명하고 있습니다.

using App.MonitorService;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Windows 서비스로 시작되었을 때 SCM과 대화하는 라이프타임을 등록한다.
// 콘솔로 시작되었을 때는 일반 ConsoleLifetime 그대로 동작한다
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "KsMonitor";
});

builder.Services.AddSingleton<MeasurementQueue>();
builder.Services.AddHostedService<MonitorWorker>();

IHost host = builder.Build();
host.Run();

Host.CreateDefaultBuilder를 사용하는 기존 스타일의 프로젝트라면, 대신 IHostBuilder 확장인 UseWindowsService()를 호출합니다. 역할은 같습니다.

하나, 태스크 스케줄러 글의 “시작(옵션)” 문제와 같은 유형의 함정이 있습니다. 서비스로 시작되었을 때의 현재 디렉터리는 C:\Windows\System32입니다. appsettings.json 등을 상대 경로 전제로 읽고 있으면 “콘솔에서는 동작하는데 서비스에서는 설정을 읽지 못한다”가 됩니다. 설정 파일은 실행 파일 기준으로 해석하거나, 공식 튜토리얼이 안내하는 대로 등록 시의 binpath--contentRoot를 넘겨 콘텐츠 루트를 명시하세요.2

4.2 ExecuteAsync ── stoppingToken과 예외 설계

본체는 BackgroundService를 상속한 ExecuteAsync에 작성합니다. 여기서 결정해야 할 것은 “정지 요청에 대한 응답”과 “예외를 어떻게 다룰 것인가”의 2가지로 요약됩니다.

namespace App.MonitorService;

public sealed class MonitorWorker(
    MeasurementQueue queue,
    ILogger<MonitorWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    // 큐에서 1건을 꺼내 처리한다. 대기에도 반드시 토큰을 전달한다
                    var item = await queue.DequeueAsync(stoppingToken);
                    await ProcessAsync(item, stoppingToken);
                }
                catch (Exception ex) when (ex is IOException or TimeoutException)
                {
                    // 계속해도 괜찮은 실패: 기록한 뒤 간격을 두고 재시도한다
                    logger.LogError(ex, "처리에 실패했습니다. 30초 후에 재시도합니다.");
                    await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
                }
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // SCM으로부터의 정지 요청. 정상적인 흐름이므로 아무것도 하지 않는다.
            // when 절에서 stoppingToken 기인으로 한정하는 이유는, 내부 처리의 타임아웃 등이
            // 던진 취소를 "정상 정지"로 오인해, 워커만 조용히
            // 멈춘 채로 호스트가 계속 살아 있는 것을 막기 위해서다
        }
        catch (Exception ex)
        {
            // 예상치 못한 실패: 기본값인 StopHost로는 호스트가 "정상적으로" 정지해 버려서,
            // SCM의 복구 옵션(자동 재시작)이 발동하지 않는다.
            // 0이 아닌 종료 코드로 종료시켜, SCM에 "실패했다"고 알린다
            logger.LogCritical(ex, "복구할 수 없는 오류이므로 서비스를 종료합니다.");
            Environment.Exit(1);
        }
    }

    private Task ProcessAsync(Measurement item, CancellationToken token)
        => throw new NotImplementedException();
}
  • stoppingToken을 모든 대기에 전달한다. Task.Delay, I/O, 큐 대기. 단 한 곳이라도 토큰을 무시한 긴 대기가 있으면, 그곳이 “정지에 응답하지 않는 서비스”의 온상이 됩니다(7장).
  • 계속 가능한 실패와 복구 불가능한 실패를 나눈다. 네트워크 단절이나 일시적인 타임아웃은 루프 안에서 붙잡아, 기록하고 재시도합니다. 그냥 삼켜서 continue만 하는 catch (Exception)은, 조용히 헛돌기만 하는 서비스를 만들 뿐입니다. 어느 쪽으로 분류할지에 대한 사고방식은 “예상치 못한 예외로 종료할지 계속할지 판단표“에서 정리하고 있습니다.
  • 처리되지 않은 예외의 기본 동작을 알아 둔다. .NET 6 이전에는 ExecuteAsync에서 새어나온 예외가 어둠 속으로 사라지고, 서비스는 동작하는 것처럼 보이지만 아무것도 하지 않는 “좀비”가 되었습니다. .NET 6 이후로는 기본값이 BackgroundServiceExceptionBehavior.StopHost가 되어, 예외가 로그에 기록된 뒤 호스트가 정지합니다.3 다만 이 정지는 “정상 정지”이므로, 복구 옵션을 설정해 두어도 재시작되지 않습니다. 복구 옵션으로 복귀시키고 싶은 실패는, 위의 코드처럼 Environment.Exit(1)로 명시적으로 비정상 종료시키는 것이 공식 튜토리얼대로의 방식입니다.2

4.3 콘솔 그대로 실행할 수 있다 ── 개발이 편해진 가장 큰 이유

AddWindowsService는 Windows 서비스로 시작되었을 때만 서비스용 라이프타임으로 전환됩니다. 즉 같은 exe가 Visual Studio의 F5나 dotnet run에서는 평범한 콘솔 앱으로 동작합니다. 브레이크포인트도 걸리고, Ctrl+C를 누르면 정지 요청부터 StopAsync의 흐름까지 로컬에서 검증할 수 있습니다.

다만, 콘솔에서 동작했다고 해서 서비스에서도 동작한다고는 할 수 없습니다. 실행 계정의 차이(6장), 세션 0, 현재 디렉터리──이 3가지만큼은 서비스로서 실제 환경에서 확인이 필요합니다. “콘솔에서는 동작하는데 서비스에서는 동작하지 않는다”의 원인은, 거의 이 3가지로 수렴됩니다.

5. 등록과 운영 ── sc.exe・복구 옵션・이벤트 로그

5.1 sc.exe create로 등록

게시(dotnet publish, 단일 파일 게시가 다루기 쉽습니다)한 exe를 sc.exe create로 SCM에 등록합니다. 관리자 권한의 PowerShell에서 실행합니다.

sc.exe create "KsMonitor" binpath= "C:\Services\KsMonitor\KsMonitor.exe" start= delayed-auto obj= "NT AUTHORITY\LocalService" displayname= "KS 계측 데이터 수집 서비스"
sc.exe description "KsMonitor" "계측 데이터를 수집해 DB에 등록하는 상주 서비스(담당: 정보시스템부)"

고전적인 함정을 먼저 말해 두겠습니다. binpath=start=의 등호 뒤에는 공백이 필요합니다. 등호까지가 옵션 이름이고, 값과의 사이에 있는 공백이 문법상 필수라는 sc.exe만의 독특한 사양입니다(생략하면 실패합니다).4 또한 obj=를 생략했을 때의 기본값은 LocalSystem입니다.4 아무 생각 없이 등록하면 최강 권한으로 동작하기 시작하므로, 6장의 선택을 등록 시에 끝내 두세요.

description 설정은 은근히 중요합니다. 몇 년 후 services.msc를 연 누군가가 “이게 무슨 서비스이고, 멈춰도 되는지, 누구에게 물어보면 되는지”를 판단할 수 있는 정보를 남겨 두면, 나중의 조사 비용이 사라집니다. 삭제는 정지시킨 뒤 sc.exe delete "KsMonitor"입니다.2

배포 대상이 여러 대이거나, 갱신(파일 교체)이 정기적으로 있다면, sc.exe를 수작업으로 다루지 말고 설치 프로그램(MSI / WiX의 ServiceInstall 등)으로 등록・갱신・삭제를 한 세트로 처리하는 구성으로 일찍 옮겨 두세요. 수작업 등록 절차서는, 절차를 하나 빠뜨린 단말을 반드시 만들어 냅니다.

5.2 복구 옵션 ── “다운되면 재시작”은 SCM에 맡긴다

서비스화의 큰 이점이 SCM 표준 복구 옵션입니다. 프로세스가 비정상 종료했을 때의 동작을 선언적으로 설정할 수 있습니다.2

sc.exe failure "KsMonitor" reset= 86400 actions= restart/60000/restart/60000/restart/300000

이 예시는 “1번째・2번째 실패는 60초 후 재시작, 3번째 이후는 5분 후 재시작, 실패 카운터는 24시간마다 리셋”입니다. GUI(services.msc의 속성 → “복구” 탭)에서도 같은 설정을 할 수 있습니다. 자체 “감시 태스크”나 모니터링 스크립트를 직접 만들기 전에, 먼저 이 표준 기능을 사용하세요.

2가지만 주의하겠습니다. 첫째, 앞 장에서 설명한 대로 프로세스가 0이 아닌 값으로 종료되지 않으면 발동하지 않습니다. StopHost에 의한 정상 정지는 복구 대상에서 제외됩니다. 둘째, 시작 직후에 반드시 다운되는 상태(설정 오류, DB 연결 불가)라면 재시작 루프가 됩니다. 3번째 이후의 간격을 길게 잡아 두는 것은 이 때문이며, 아울러 “왜 다운되었는지”가 이벤트 로그에 남아 있는 상태(다음 절)를 먼저 만들어 둡니다.

5.3 이벤트 로그와 파일 로그 병용

Host.CreateApplicationBuilder는 Windows에서 EventLog 로거 프로바이더를 자동으로 추가합니다. 그리고 이벤트 로그에 도달하는 것은 기본값으로 Warning 이상뿐입니다.2 “서비스로 만들었더니 Information 로그가 전부 사라졌다”며 혼란스러워하기 쉽지만, 사라진 게 아니라 이벤트 로그의 기본 필터에 걸린 것입니다. 시작・정지 같은 운영 이벤트도 기록하고 싶다면, appsettings.json에서 EventLog 프로바이더의 레벨을 명시합니다.

{
  "Logging": {
    "EventLog": {
      "SourceName": "KsMonitor",
      "LogLevel": {
        "Default": "Warning",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    }
  }
}

한 가지 주의할 점이 있습니다. 이벤트 소스는 사전에 등록되어 있지 않으면 쓸 수 없고, 등록에는 관리자 권한이 필요합니다. SourceName을 생략해도 피할 수 없습니다──AddWindowsService의 기본값에서는 애플리케이션 이름이 소스 이름이 되기 때문에2, 어차피 “미등록 소스”에서 시작합니다. LocalService처럼 최소 권한 계정은 실행 시점에 소스를 생성할 수 없고, 생성에 실패하면 이벤트 로그에는 아무것도 기록되지 않은 채로 운영이 시작되어 버립니다. 등록은 설치 프로그램(또는 관리자 권한의 설정 처리) 쪽에서 끝내 두세요. 태스크 스케줄러 글의 6장에서 쓴 “CreateEventSource는 설치 프로그램 쪽으로 분리한다”와 같은 이야기입니다.

구분해서 쓰는 기준은, 이벤트 로그에는 “운영자가 봐야 할 것”만(시작・정지・실패・복구), 처리의 상세 추적은 자체 파일 로그입니다. 파일 로그를 직접 갖추는 경우의 최소 요건(로테이션, 쓰기 실패 시의 동작)은 “자체 제작 로거의 최소 요건“에, 프로세스 전체가 다운되었을 때 증거를 남기는 설계는 “크래시 시 로그와 덤프를 남기는 설계“에 정리해 두었습니다. 복구 옵션으로 자동 재시작하는 구성에서는, 다운된 순간의 기록이 그 자리에 남아 있는 것이 유일한 힌트가 되기 때문입니다.

6. 실행 계정 ── LocalSystem을 습관적으로 고르지 않는다

서비스는 지정한 계정의 보안 컨텍스트에서 동작하며, SCM이 시작할 때 그 계정으로 로그온해 프로세스에 토큰을 붙입니다.8 태스크 스케줄러 글의 3장에서 쓴 “누구로서 실행되는가” 이야기의 서비스 버전입니다. 선택지를 나열해 보겠습니다.

계정 권한 네트워크상의 신원 비밀번호 관리 쓰는 곳
LocalSystem 매우 강함(OS 수준) 컴퓨터 계정 불필요 원칙적으로 피함. sc.exe create의 기본값이므로 주의
LocalService 최소한 익명(공유에 접근 불가) 불필요 로컬에서 완결되는 처리의 첫 번째 후보
NetworkService 최소한 컴퓨터 계정 불필요 도메인 내 리소스를 다루는 경미한 처리
가상 계정(NT SERVICE\서비스명) 부여한 만큼만 컴퓨터 계정 불필요 서비스 단위로 ACL을 부여할 수 있음. 로컬 리소스에 대한 최소 권한
gMSA 부여한 만큼만 gMSA 자신 DC가 자동 관리 도메인에서 공유・DB를 다루는 서비스의 본진

판단 포인트는 3가지입니다.

  • LocalSystem을 “동작하니까”라는 이유로 고르지 않는다. 서비스의 취약점이 그대로 컴퓨터 전체 장악으로 직결됩니다. 관리자급 권한이 정말로 필요한지는, “관리자 권한이 필요해지는 것은 언제인가“의 정리를 그대로 쓸 수 있습니다. 대부분의 경우 필요한 것은 “특정 폴더에 쓰기” 정도이며, 그것은 가상 계정(NT SERVICE\KsMonitor)에 폴더의 ACL을 부여하면 충분합니다. 가상 계정은 생성도 비밀번호 관리도 필요 없습니다.5
  • 네트워크 공유의 함정. LocalService는 네트워크상에서 익명이 되기 때문에, \\server\share로의 접근은 실패합니다. NetworkService・가상 계정・LocalSystem은 컴퓨터 계정(DOMAIN\머신이름$)으로 네트워크에 나가므로5, 공유 쪽에서 컴퓨터 계정에 대해 공유와 NTFS 양쪽의 권한이 필요합니다. “사용자에게는 권한을 줬는데 서비스에서만 읽지 못한다”의 정체는 대개 이것입니다. “누구로서 네트워크에 나갈 것인가”를 먼저 정한 뒤에, 공유 쪽 설정을 합니다.
  • 일반 사용자 계정으로 동작시킨다면, 비밀번호 운영까지 포함해서. 전용 계정을 사용하는 경우에는 “서비스로 로그온” 권한이 필요할 뿐만 아니라, 비밀번호가 만료되면 로그온에 실패해 서비스를 시작할 수 없게 됩니다.8 “비밀번호 변경으로 태스크가 조용히 죽는다” 문제의 서비스 버전입니다. 도메인 환경이라면, 비밀번호를 도메인 쪽이 자동으로 관리하는 gMSA로 이 문제 자체를 없애는 것이 정답입니다(태스크 스케줄러 글 3.2절과 같은 결론입니다).

7. 안전한 정지 ── ShutdownTimeout과 진행 중인 처리

정지의 흐름을 짚어 보겠습니다. services.msc나 sc.exe stop, OS 종료로 SCM이 정지 요청을 보내면, .NET의 서비스 라이프타임이 호스트의 정지(StopApplication)로 변환하고, stoppingToken이 취소되고, ExecuteAsync가 빠져나오고, 각 서비스의 StopAsync가 호출됩니다. 호스트가 이 일련의 정지 처리를 기다리는 시간이 HostOptions.ShutdownTimeout이며, 기본값은 30초입니다.69 시간 안에 끝나지 않으면 마무리 처리의 완료를 기다리지 않고 정지가 강행됩니다.

진행 중인 작업 1건을 다 처리하는 데 30초로 부족하다면, 시간을 거꾸로 계산해서 늘립니다.

builder.Services.Configure<HostOptions>(options =>
{
    // 진행 중인 처리를 끝까지 마치는 데 필요한 최대 시간에서 거꾸로 계산한다
    options.ShutdownTimeout = TimeSpan.FromSeconds(90);
});

그런 다음, 정지 처리의 설계는 다음 3가지입니다.

  • 정지 요청 이후에 할 일을 순서로 정해 둔다. 신규 접수를 멈춘다 → 진행 중인 처리를 완료시킨다(또는 안전한 지점에서 중단하고, 재개할 수 있는 형태로 기록한다) → 연결・임시 파일의 마무리 처리. 이 순서를 정해 두지 않으면, 토큰을 본 순간 전부 내던지거나, 전부 끝내려다 시간이 다 되어 버리는 양극단이 됩니다.
  • “정지에 응답하지 않는 서비스”를 만들지 않는다. 토큰을 무시한 긴 루프나 대기가 한 곳만 있어도, SCM상에서 “정지 중” 상태로 굳어 버리는 서비스가 됩니다. 이것은 Windows Update의 재시작을 방해하고, 서버의 계획 정지 때마다 수동 kill을 요구하는, 운영에서 가장 기피되는 결함입니다. 4장에서 설명한 대로, 처리의 구간마다 토큰을 확인하고, 긴 I/O에는 CancellationToken을 전달하세요. 콘솔 실행이라면 Ctrl+C로 이 정지 흐름을 그대로 시험해 볼 수 있으므로, “Ctrl+C부터 몇 초 안에 종료되는가”를 릴리스 전 테스트 항목에 넣어 두는 것을 추천합니다.
  • kill되어도 망가지지 않는 설계를, 정지 처리의 정성보다 우선한다. 전원 차단이나 강제 종료는, 정지 처리를 얼마나 정교하게 만들어도 일어납니다. 트랜잭션, 원자적인 파일 쓰기, 재실행 가능한 처리 단위──”중간에 죽어도 다음 시작 시에 정합성이 맞는” 구조가 본체이며, 정성스러운 정지 처리는 어디까지나 매너의 문제입니다.

8. 정리

판단은 단순합니다. 정기 처리는 태스크 스케줄러, 상시 대기・초 단위 반응・자동 복구가 필요하면 Windows 서비스, UI가 본체라면 상주 앱. 서비스로 만들기로 정했다면, .NET의 Worker Service와 AddWindowsService로 “콘솔로 개발하고, 서비스로 배포한다” 형태로 만드는 것이 현재의 표준 경로입니다.

등록할 때 설계로서 정하는 것은 4가지──시작 유형(업무 서비스는 지연 시작), 실행 계정(LocalSystem을 피하고, 가상 계정이나 gMSA), 복구 옵션(sc.exe failureEnvironment.Exit(1)의 세트), 정지(ShutdownTimeout과 진행 중인 처리의 마무리). 이 4가지와, stoppingToken을 존중하는 ExecuteAsync의 작성법만 붙잡으면, Windows 서비스는 “만들기가 특별히 어려운 것”이 아니게 됩니다. 반대로, 이 4가지를 정하지 않고 동작하기 시작한 서비스는, 몇 년 후 “멈출 수 없다・다운돼도 알아채지 못한다・권한이 너무 강해서 손댈 수 없다”의 삼중고로 돌아옵니다. 기존 서비스를 다시 세우는 작업도, 이 4가지를 점검하는 것부터 시작하는 것이 지름길입니다.

관련 글

관련 상담 영역

합동회사 코무라소프트는 상주 처리의 설계 리뷰(태스크 스케줄러로부터의 이행 판단을 포함)나, “정지에 응답하지 않는다”, “어느 순간 다운되어 있다” Windows 서비스의 조사・재정비 상담을 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Interactive Services. Windows Vista 이후 서비스는 사용자와 직접 대화할 수 없다는 것, 서비스는 세션 0에서 실행된다는 것, UI가 필요한 경우에는 별도 프로세스의 GUI 앱과 IPC로 연동하는 설계가 권장된다는 것에 대해.  2 3

  2. Microsoft Learn, Create Windows Service using BackgroundService. Worker Service를 Windows 서비스로 만드는 공식 튜토리얼. AddWindowsService, EventLog 프로바이더의 기본값이 Warning이라는 점, sc.exe를 이용한 등록・복구・삭제, 복구 옵션에는 0이 아닌 종료 코드가 필요하다는 점에 대해.  2 3 4 5 6 7 8 9

  3. Microsoft Learn, .NET 6 breaking change: Exception handling in hosting. .NET 6 이후로 BackgroundService.ExecuteAsync의 처리되지 않은 예외가 로그에 기록된 뒤 기본값으로 호스트를 정지시키는 것(BackgroundServiceExceptionBehavior.StopHost)에 대해.  2

  4. Microsoft Learn, sc.exe create. start=(auto / demand / delayed-auto 등)와 obj=(기본값은 LocalSystem)의 사양과, 옵션의 등호 뒤에 공백이 필수라는 것(생략하면 실패한다)에 대해.  2 3 4

  5. Microsoft Learn, Service Accounts in Windows Server. 가상 계정(NT SERVICE\서비스명)이 비밀번호 관리 없이 컴퓨터 계정의 자격 증명으로 네트워크에 접근한다는 것, gMSA의 비밀번호를 도메인 쪽이 자동으로 관리한다는 것에 대해.  2 3

  6. Microsoft Learn, .NET Generic Host. 호스트의 종료 처리 흐름과, 정지 시 설정 가능한 종료 타임아웃(기본값 30초)으로 기다린다는 것에 대해.  2

  7. Microsoft Learn, A service does not start, and events 7000 and 7011 are logged. SCM이 ServicesPipeTimeout으로 지정된 시간만큼만 서비스의 시작을 기다리고, 초과하면 이벤트 7000 / 7011을 기록한다는 것, 기본 타임아웃을 늘리는 절차에 대해. 

  8. Microsoft Learn, Service User Accounts. 서비스가 지정된 계정의 보안 컨텍스트에서 실행된다는 것, SCM이 시작할 때 로그온을 수행하며 비밀번호가 만료되면 서비스를 시작할 수 없다는 것, LocalService / NetworkService / LocalSystem이라는 특수 계정에 대해.  2

  9. Microsoft Learn, HostOptions.ShutdownTimeout Property. StopAsync의 기본 타임아웃을 제어하는 속성의 정의에 대해. 

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

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

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

자주 묻는 질문

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

태스크 스케줄러와 Windows 서비스는 어떻게 구분해서 사용하나요?
몇 분 이상의 간격으로 실행되는 정기 처리이고 처리 사이에 상태를 유지하지 않는다면 태스크 스케줄러로 충분합니다. TCP나 파이프로의 상시 대기, FileSystemWatcher를 이용한 폴더 감시, 초 단위의 반응, 다운되었을 때의 자동 복구가 요건에 들어가면 Windows 서비스입니다. "5분마다 실행되는 태스크"로 잘게 잘라 폴링하기 시작했다면, 상주 프로세스를 열화된 형태로 재구현하고 있다는 신호입니다. UI가 본체(알림 영역에서의 조작 등)라면, 로그온 중에만 동작하는 상주 앱이라는 세 번째 선택지가 됩니다.
Windows 서비스에서 UI(화면)를 표시할 수 있나요?
할 수 없습니다. Windows Vista 이후 서비스는 세션 0이라는 격리된 세션에서 동작하며, 사용자와 직접 대화할 수 없습니다. 서비스 안에서 MessageBox.Show를 호출해도 사용자의 화면에는 아무것도 나타나지 않고, 아무도 누를 수 없는 OK 버튼을 계속 기다리며 처리가 멈추는 행(hang)의 원인이 됩니다. UI가 필요한 경우에는 서비스와 UI 앱을 프로세스로 분리하고 명명된 파이프 같은 프로세스 간 통신으로 대화하는 구성이 정답입니다. 오래된 코드를 서비스로 옮길 때는 오류 표시용 메시지 박스가 남아 있지 않은지 반드시 확인하세요.
.NET로 Windows 서비스를 만들려면 어떻게 해야 하나요?
Worker Service 템플릿(dotnet new worker)에서 시작해, Microsoft.Extensions.Hosting.WindowsServices 패키지를 추가하고 AddWindowsService를 호출하는 것이 공식 경로입니다. 같은 exe가 Visual Studio의 F5나 dotnet run에서는 평범한 콘솔 앱으로 동작하기 때문에 개발・디버깅이 훨씬 편해집니다. 등록은 sc.exe create로 하는데, binpath=나 start=의 등호 뒤에 공백이 반드시 필요하다는 독특한 사양과, obj=를 생략하면 기본값이 LocalSystem(최강 권한)이 된다는 점에 주의해야 합니다. 서비스 시작 시 현재 디렉터리는 C:\Windows\System32이므로, 설정 파일은 실행 파일 기준으로 해석해야 합니다.
BackgroundService에서 예외가 발생하면 서비스는 어떻게 되나요?
.NET 6 이후로는 ExecuteAsync에서 새어나온 처리되지 않은 예외를 기본값(BackgroundServiceExceptionBehavior.StopHost)으로 로그에 기록한 뒤 호스트를 정지시킵니다. 다만 이것은 "정상적인 정지"로 취급되기 때문에 SCM의 복구 옵션(자동 재시작)이 발동하지 않습니다. 복구 옵션으로 재시작시키고 싶은 실패는 Environment.Exit(1)처럼 0이 아닌 종료 코드로 프로세스를 명시적으로 비정상 종료시켜야 합니다. 네트워크 단절처럼 계속할 수 있는 실패는 루프 안에서 붙잡아 기록・재시도하고, 복구 불가능한 실패와 구분해서 설계합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기