.NET Generic Host와 BackgroundService를 데스크톱 앱에서 쓰는 이유
· 업데이트: · Go Komura · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows 개발, 설계
수정 이력(9건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635119)
- 추가한 그림의 Mermaid 소스 들여쓰기를 글 안의 규칙에 맞추고, 리뷰에서 지적된 그림 표현을 본문 서술에 맞게 조정했습니다. 본문 문장은 바꾸지 않았습니다.
- 리뷰 지적에 따라, 오늘 추가한 그림 가운데 폭이 너무 컸던 것을 세로 구성으로 고치고, 일부 그림과 캡션 표현을 본문 서술에 맞게 바로잡았습니다. 본문 문장은 바꾸지 않았습니다.
- 본문의 흐름·구조를 그림으로도 따라갈 수 있도록 Mermaid 그림 17개를 추가했습니다(본문 500~750자당 그림 1개의 규칙에 맞춘 것입니다). 기존 전체 그림에는 캡션을 추가했습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 '이 글의 지식 맵' 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 모은 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
- 그림과 같은 내용의 글머리 기호를 함께 적고, 파일 구성 표를 추가했습니다. `ShutdownTimeout` 기본값에 대해 리뷰는 5초로 보았지만, 실제로는 .NET 6이 5초이고 .NET 7 이후는 30초입니다. 런타임 소스를 버전마다 확인한 뒤 이 버전 차이를 본문에 적었습니다. 파괴적 변경이 .NET 10의 것임도 명시했습니다.
- 본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635118)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「.NET Generic Host와 BackgroundService를 데스크톱 앱에서 쓰는 이유」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635118 https://comcomponent.com/ko/blog/generic-host-backgroundservice-desktop-app/
- DOI(최신 버전)
- 10.5281/zenodo.21635118
- DOI(이 버전)
- 10.5281/zenodo.22217426
Windows 도구나 상주형 앱을 조금만 키워도, UI 바깥의 처리가 조금씩 늘어납니다.
주기 폴링, 파일 감시, 재연결, 큐 처리, 시작 시 초기화, 종료 시 flush.
처음에는 Form_Load나 OnStartup이나 Task.Run으로 버틸 수 있지만, 그대로 커지면 누가 시작하고, 누가 멈추고, 누가 예외를 보는지가 모호해집니다.
async / await 작성법 자체보다 먼저, 처리의 수명을 누가 쥘지를 정하는 편이 나은 상황입니다.
그때 효과가 있는 것이 .NET의 Generic Host와 BackgroundService입니다.
UI 스레드 쪽의 async / await에 대해서는,
WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
나
C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
와 이어지는 이야기입니다.
이번에는 그보다 바깥에 있는 앱 전체의 시작과 중지를 정리하는 데 초점을 둡니다.
실무에서 조금씩 무너지기 쉬운 지점은 대체로 이 근처입니다.
- 폼이나 ViewModel 여기저기에서
Task.Run이 생긴다 - 상주 루프의 중지 조건이
bool플래그로 흩어진다 - 종료 시에도 아직 돌아가는 처리가 있어, 가끔 닫히지 않는다
- 로그 / 설정 / DI의 입구가 기술마다 따로 논다
Environment.Exit로 정리하고 싶어져서finally가 건너뛴다
이 글에서는 주로 .NET 6 이후의 WPF / WinForms / 상주형 Windows 앱을 전제로, 왜 Generic Host / BackgroundService가 눈에 잘 띄지 않아도 효과가 있는지, 어디까지 들여오면 이득인지, 어디서 대충 하면 나중에 타격이 오는지를 정리합니다.
대상 독자는 BackgroundService를 아는지보다, 상주 처리를 어디에 두고 수명을 어떻게 쥘지에서 막혀 있는 단계입니다. 이름을 처음 보는 분도 읽을 수 있도록 다음 장에서 용어부터 잡고, 이미 쓰고 있는 분은 2.2의 판단표와 6장의 나누는 법부터 읽어도 됩니다.
flowchart TB
accTitle: 조금씩 무너져 가는 형태
accDescr: 여기저기에서 생기는 Task.Run, bool 플래그로 흩어지는 중지 조건, 종료 시에 닫히지 않는 처리, 기술마다 다른 입구라는 무너지는 방식은, 누가 시작하고 누가 멈추고 누가 예외를 보는지가 모호하다는 한 점으로 귀결된다.
s1["흩어지는 Task.Run"] --> core["수명의 주인이 모호하다"]
s2["bool 플래그의 중지 조건"] --> core
s3["닫히지 않는 종료 처리"] --> core
s4["기술마다 다른 입구"] --> core
core --> fix["수명을 누가 쥘지를 먼저 정한다"]
그림1: 무너지는 모양은 달라도, 뿌리는 “처리의 수명을 누가 쥘지”가 정해져 있지 않은 것입니다.
이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 전체(라이브러리, 시작부터 graceful shutdown까지를 보여주는 콘솔 데모, 유닛 테스트)로 GitHub에 공개하고 있습니다.
generic-host-backgroundservice-desktop-app - komurasoft-blog-samples (GitHub)
용어를 먼저 맞춥니다
이런 이야기는 단어의 의미가 흐릿한 채로는 갑자기 읽기 어려워집니다. 그래서 이 글에서 쓰는 말을 처음에 대략 고정합니다.
- Generic Host
- .NET 앱의 “시작” “의존성” “설정” “로그” “중지”를 한꺼번에 맡는 기반입니다.
- ASP.NET Core만의 구조가 아니라, 콘솔, worker, 데스크톱 앱에서도 쓸 수 있습니다.
- Host /
IHost- build한 뒤의 객체입니다.
- 이것을
StartAsync로 시작하고,StopAsync로 멈춥니다.
- Hosted Service
- host 수명에 묶여 시작·중지되는 상주 처리입니다.
IHostedService를 구현하거나, 보통은BackgroundService를 상속해 작성합니다.
BackgroundServiceIHostedService를 쓰기 쉽게 돕는 구현입니다.- 오래 실행되는 본체를
ExecuteAsync에 작성할 수 있으므로, 감시 루프나 주기 처리를 정리하기 쉬워집니다.
- lifetime
- 이 글에서는 “그 처리가 언제 시작되고, 언제 끝나며, 누가 멈출 책임을 지는지”라는 의미로 씁니다.
- 단순한 생존 시간이라기보다, 시작 책임과 중지 책임을 포함한 수명 관리입니다.
- graceful shutdown
- 강제 종료가 아니라, 멈출 신호를 내고 진행 중인 처리를 가능한 한 정리한 뒤 종료하는 것입니다.
- 예를 들어 “다음 주기를 시작하지 않는다” “큐를 어디까지 처리할지 정한다” “close나 flush를 기다린다”가 여기에 들어갑니다.
- DI
- Dependency Injection의 약자로, 의존 객체의 조립을 호출 쪽에 그대로 쓰지 않고 컨테이너를 통해 받는 방식입니다.
- 이 글에서는 “logger나 설정이나 reader를 여기저기에서 new하지 않고, 입구에서 한꺼번에 구성한다” 정도의 이해면 충분합니다.
이 이야기는 “BackgroundService라는 편리한 클래스 소개”에 머물지 않고,
앱 전체의 시작과 중지를 host에 모아, 상주 처리의 lifetime을 설계로 갖는 이야기
로 읽으면 따라가기 쉽습니다.
flowchart TB
accTitle: 이 글에서 쓰는 말의 관계
accDescr: Generic Host가 시작·의존성·설정·로그·중지의 기반이고, build한 객체가 IHost, 그 수명에 묶이는 것이 Hosted Service이며, BackgroundService는 그 쓰기 쉬운 구현이라는 관계를 나타낸다.
gh["Generic Host〔기반〕"] --> ihost["IHost〔build한 객체〕"]
ihost --> hs["Hosted Service"]
hs --> bs["BackgroundService"]
bs --> exec["오래 실행되는 본체를 ExecuteAsync에"]
hs -.-> lt["lifetime = 시작 책임과 중지 책임"]
그림2: 용어의 계층. host 수명에 Hosted Service가 묶이고, BackgroundService는 그 구현을 돕습니다.
이 글의 지식 맵
이 글은 WPF나 WinForms 데스크톱 앱에서 .NET의 Generic Host와 BackgroundService를 쓰는 이유를 정리한 것입니다. Generic Host는 의존성 주입과 로그, 정지 처리를 한데 관리하는 기동의 토대이며, BackgroundService는 Hosted Service의 쓰기 쉬운 구현으로서 상주 루프를 Task.Run으로 던져놓는 대신 관리된 수명에 싣습니다. PeriodicTimer나 Channel
flowchart LR
accTitle: Generic Host와 BackgroundService
accDescr: 데스크톱 앱이 Generic Host를 기동의 토대로 삼고, BackgroundService가 Hosted Service로서 상주 처리를 CancellationToken과 ShutdownTimeout으로 관리되는 graceful shutdown에 싣으며, Environment.Exit이 그 경로와 양립하지 않는다는 것을 보여주는 그림.
generic_host["Generic Host"]
backgroundservice["BackgroundService"]
hosted_service["Hosted Service (IHostedService)"]
dependency_injection_dotnet["의존성 주입(DI)"]
wpf["WPF"]
windows_forms["Windows Forms"]
periodictimer["PeriodicTimer"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
channel_t["Channel<T>"]
graceful_shutdown["graceful shutdown"]
shutdowntimeout_hostoption["HostOptions.ShutdownTimeout"]
environment_exit["Environment.Exit"]
ihostapplicationlifetime["IHostApplicationLifetime"]
ihostedlifecycleservice["IHostedLifecycleService"]
taskrun_dotnet["Task.Run"]
dotnet[".NET(Core 이후)"]
backgroundservice -->|"구현을 담당한다"| hosted_service
generic_host -->|"이용한다"| hosted_service
generic_host -->|"이용한다"| dependency_injection_dotnet
wpf -.->|"이용한다"| generic_host
windows_forms -.->|"이용한다"| generic_host
backgroundservice -.->|"이용한다"| periodictimer
backgroundservice -->|"이용한다"| cancellationtoken_dotnet
backgroundservice -->|"이용한다"| channel_t
backgroundservice -->|"이용한다"| dependency_injection_dotnet
graceful_shutdown -->|"전제로 한다"| cancellationtoken_dotnet
generic_host -->|"에서 구성할 수 있다"| shutdowntimeout_hostoption
environment_exit -->|"양립하지 않는다"| graceful_shutdown
ihostapplicationlifetime -->|"권장되는 대응"| graceful_shutdown
generic_host -.->|"이용한다"| ihostedlifecycleservice
taskrun_dotnet -->|"사용은 비권장"| hosted_service
backgroundservice -->|"전제로 한다"| dotnet
generic_host -->|"전제로 한다"| dotnet
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 17건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
목차
- 먼저 결론 (한 줄로)
- 먼저 한 장으로 정리
- 2.1. 전체 모습
- 2.2. 어디에 둘지 판단표
- 왜 데스크톱 앱에서 효과가 있는가
- 3.1. UI와 상주 처리의 책임을 나누기 쉽다
- 3.2. 시작·중지·예외의 입구를 한곳에 모을 수 있다
- 3.3. graceful shutdown을 설계에 넣기 쉽다
- 3.4. DI / 로그 / 설정이 처음부터 갖춰진다
- 잘 맞는 경우
- 최소 구성 예 (WPF 예)
StartAsync/ExecuteAsync/StopAsync의 나누는 법- 6.1.
StartAsync - 6.2.
ExecuteAsync - 6.3.
StopAsync - 6.4. .NET 10 이후의 주의
- 6.1.
- 흔한 안티패턴
- 리뷰 체크리스트
- 대략적인 구분
- 정리
- 참고 자료
1. 먼저 결론 (한 줄로)
- Generic Host는 데스크톱 앱에서도 시작과 lifetime 관리의 기반으로서 꽤 유력합니다.
BackgroundService는 “오래 살아 있는 처리”를Task.Run으로 던져 놓고 끝내는 대신 관리되는 수명에 올리기 위한 틀입니다.- 실무에서 가장 효과가 큰 점은 시작 책임 / 중지 책임 / 예외 감시 / 로그 / DI / 설정을 한곳의 설계로 모을 수 있다는 것입니다.
StartAsync는 짧게, 오래 실행되는 본체는ExecuteAsync, 종료 시의 뒷정리는StopAsync에 나누면 꽤 읽기 쉬워집니다.- 상주 앱, 트레이 앱, 장치 감시, 주기 동기화, 순서가 있는 후처리, 재연결 루프는 특히 잘 맞습니다.
- 반대로, 버튼을 눌렀을 때만 한 번 도는 처리까지 전부
BackgroundService에 넣으면 조금 거창해집니다. StopAsync는 편리하지만, 프로세스 크래시나 강제 종료의 보험은 아닙니다. 거기에 뒷정리를 너무 몰아넣지 않는 것도 중요합니다.
결국 데스크톱 앱에서 Generic Host / BackgroundService가 효과가 있는 이유는,
“백그라운드 처리가 있어서”라기보다,
“그 백그라운드 처리의 수명을 UI의 부수 작업이 아니라 설계로서 갖고 싶어서”입니다.
flowchart TB
accTitle: 한곳의 설계로 모이는 것
accDescr: 시작 책임, 중지 책임, 예외 감시, 로그·DI·설정처럼 흩어지기 쉬운 요소를 한곳의 설계로 모을 수 있는 것이 실무에서 가장 효과가 큰 점임을 나타낸다.
a["시작 책임"] --> one["한곳의 설계로 모은다"]
b["중지 책임"] --> one
c["예외 감시"] --> one
d["로그·DI·설정"] --> one
one --> win["관리되는 수명에 오른다"]
그림3: BackgroundService의 가치는 흩어지기 쉬운 책임이 한곳의 설계로 모인다는 것입니다.
2. 먼저 한 장으로 정리
2.1. 전체 모습
우선 이 그림으로 보면 이야기가 꽤 빨라집니다.
flowchart LR
A["데스크톱 앱 시작<br/>(WPF / WinForms)"] --> B["Host를 Build / StartAsync"]
B --> C["DI / Logging / Configuration 준비"]
B --> D["HostedService.StartAsync"]
D --> E["BackgroundService.ExecuteAsync"]
E --> F["PeriodicTimer / Queue / 재연결 / 감시 루프"]
C --> G["MainWindow / MainForm을 표시"]
F --> H["상태 갱신 / 로그 / 외부 I/O"]
H --> I["UI는 필요한 곳만 Dispatcher / Invoke"]
J["사용자 종료 / Fatal error / StopApplication"] --> K["IHost.StopAsync"]
K --> L["CancellationToken 알림"]
L --> M["HostedService.StopAsync"]
M --> N["연결 close / flush / graceful shutdown"]
그림4: host 시작부터 UI 표시, 상주 루프, 중지 알림, graceful shutdown까지의 전체 모습입니다.
그림이 표시되지 않는 환경을 위해, 같은 흐름을 글로도 적어 둡니다.
- 앱 시작 (WPF라면
App.OnStartup, WinForms라면Main) Host.CreateApplicationBuilder로 서비스를 등록하고BuildIHost.StartAsync로 host를 시작한다 (여기서 DI / 로그 / 구성이 확정된다)- 등록된
HostedService.StartAsync가 호출된다 BackgroundService.ExecuteAsync가 달리기 시작한다 (감시 루프,PeriodicTimer, 큐 처리 등의 본체)- UI (
MainWindow/MainForm)를 표시한다. worker는 상태 스토어나 로그를 갱신하고, UI는 자신의 컨텍스트에서 그것을 읽는다 - 사용자의 종료 조작이나 치명적 오류로
IHostApplicationLifetime.StopApplication을 호출하고,IHost.StopAsync로 진행한다 - 중지가
CancellationToken(stoppingToken)으로 알려지고,ExecuteAsync의 루프를 빠져나온다 HostedService.StopAsync에서 연결 close나 로그 flush를 하고 종료한다
UI 앱에서 흔한 형태는 Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / static singleton에 책임이 조금씩 흩어지는 것입니다.
Host를 넣으면 대략 다음 분담이 됩니다.
- UI: 화면, 입력, 표시
- HostedService / BackgroundService: 상주 처리, 감시, 큐 처리, 주기 처리
- DI 서비스: 실제 업무 로직, 외부 연결, 설정, 로그
이 나누기만으로도 리뷰하기 쉬움이 꽤 달라집니다.
flowchart TB
accTitle: host를 넣은 뒤의 3분담
accDescr: UI는 화면·입력·표시, HostedService와 BackgroundService는 상주 처리·감시·큐 처리·주기 처리, DI 서비스는 업무 로직·외부 연결·설정·로그라는 분담을 할 수 있다.
app["데스크톱 앱"] --> ui["UI: 화면·입력·표시"]
app --> hs["HostedService: 상주·감시"]
app --> di["DI 서비스: 업무 로직"]
hs -.-> note["큐 처리나 주기 처리도 여기"]
그림5: 책임이 흩어지는 형태에서 UI·상주 처리·실제 처리의 3분담으로 모읍니다.
2.2. 어디에 둘지 판단표
| 하고 싶은 일 | 어디에 둘지의 첫 후보 | 이유 |
|---|---|---|
| 시작 직후의 가벼운 초기화 | StartAsync |
시작에 참여하는 짧은 처리로서 의미가 분명하다 |
| 오래 살아 있는 감시 / 폴링 / 재연결 | ExecuteAsync |
서비스 수명과 함께 돌리기 쉽다 |
| 종료 시의 중지 알림 / flush / close | StopAsync |
CancellationToken과 맞춰 graceful shutdown을 쓰기 쉽다 |
| 의존성의 구성, 설정, 로그 | Host.CreateApplicationBuilder |
입구를 한곳에 모을 수 있다 |
| 화면 갱신 | UI 쪽 | worker에서 UI를 직접 건드리지 않는 편이 사고가 적다 |
| 버튼 누를 때마다의 1회 처리 | 일반 async 메서드 |
HostedService로 만들지 않아도 되는 경우가 많다 |
| 순서가 있는 백그라운드 후처리 | Channel<T> + BackgroundService |
던져 놓고 끝내는 것보다 수명과 상한을 관리하기 쉽다 |
Host를 넣는 가치는 무엇을 “비동기로 할 수 있다”는 것보다, 어디에 둘지의 판단이 분명해진다는 데 있습니다.
3. 왜 데스크톱 앱에서 효과가 있는가
3.1. UI와 상주 처리의 책임을 나누기 쉽다
데스크톱 앱은 UI가 주인공처럼 보이지만, 실무에서 무거워지는 것은 대체로 UI 바깥입니다.
예를 들면:
- 10초마다의 상태 동기화
- 장치나 서버와의 재연결
- 파일 감시와 가져오기
- 큐에 쌓인 후처리
- 로그 전송이나 메트릭 송신
- 시작 시의 캐시 warm-up
이것들은 “화면의 이벤트”가 아니라, 앱 전체의 수명에 묶이는 처리입니다.
여기를 폼이나 윈도우의 코드 비하인드에 두면, 화면을 닫을 때 멈출 책임, 예외를 받을 책임, 재시도나 backoff를 정할 책임이 UI 사정과 섞이기 시작합니다.
BackgroundService를 쓰면,
“이 처리는 앱이 동작하는 동안 계속 살아 있다”
는 선언이 코드 형태로 드러납니다.
이것이 눈에 잘 띄지 않아도 강합니다.
flowchart TB
accTitle: 상주 처리를 어디에 둘지의 대비
accDescr: 코드 비하인드에 상주 처리를 두면 중지 책임·예외 책임·재시도 판단이 UI 사정과 섞이지만, BackgroundService에 올리면 앱 수명에 묶인다는 선언이 코드 형태로 나온다.
q{"상주 처리를 어디에 둘까"}
q -->|"코드 비하인드"| mix["중지·예외·재시도가 UI와 섞인다"]
q -->|"BackgroundService"| decl["계속 산다는 선언이 형태로 나온다"]
그림6: 같은 처리라도 두는 곳에 따라 책임이 섞이는 방식이 전혀 달라집니다.
3.2. 시작·중지·예외의 입구를 한곳에 모을 수 있다
Host를 쓰지 않는 desktop app에서도 ServiceCollection, ConfigurationBuilder, LoggerFactory를 따로 늘어놓으면 비슷한 일은 할 수 있습니다.
다만 그 형태는 대체로 조금씩 흩어집니다.
- DI는
Program.cs - 설정은 자체 static
- 로그는 별 factory
- 종료 처리는
ApplicationExit - 상주 처리는
Task.Run
이 상태에서도 처음에는 돌아갑니다. 그러나 몇 달 뒤에 다시 보면, 누가 앱의 수명을 쥐고 있는지가 보이기 어려워집니다.
Generic Host를 쓰면,
- 서비스 등록
- 구성 읽기
- 로그 구성
- hosted service 시작
- 중지 알림
IHostApplicationLifetime에 의한 전체 중지
가 같은 틀에 들어갑니다.
즉 “이 앱은 어떻게 시작하고, 어떻게 멈추는가”의 입구를 한곳에 모으기 쉽습니다. 상주형 앱에서는 여기가 나중에 효과를 냅니다.
flowchart TB
accTitle: 흩어지는 입구를 host에 모은다
accDescr: DI·설정·로그·종료 처리·상주 처리가 서로 다른 곳에 흩어지는 형태에서, 서비스 등록·구성 읽기·로그 구성·hosted service 시작·중지 알림·전체 중지가 같은 틀에 들어가는 형태로 바뀐다.
before["입구가 기술마다 흩어진다"] --> pain["누가 수명을 쥐는지 불명해진다"]
host["Generic Host에 모은다"] --> one["시작과 중지의 입구가 한곳에"]
one -.-> items["등록·구성·로그·중지 알림"]
그림7: 따로 늘어놓아도 돌아가지만, 같은 틀에 들어 있는지가 몇 달 뒤에 효과를 냅니다.
3.3. graceful shutdown을 설계에 넣기 쉽다
상주 처리는 시작하기보다 멈추기가 더 어렵습니다. 시작은 3줄로 써도, 종료는 생각할 것이 한꺼번에 늘어납니다.
예를 들어 종료 시에는:
- 진행 중인 I/O를 취소하고 싶다
- 다음 주기를 시작하지 않게 하고 싶다
- 큐에 남은 항목을 어디까지 처리할지 정하고 싶다
- 소켓이나 COM 객체를 닫고 싶다
- 로그 flush나 상태 저장을 기다리고 싶다
이 근처를 FormClosing에 모으면 화면 사정과 섞여 힘들어집니다.
Host / BackgroundService라면 CancellationToken과 StopAsync가 있으므로,
“멈추기 위한 경로”가 처음부터 있습니다.
물론 마법은 아닙니다.
크래시나 kill에서는 StopAsync가 호출되지 않는 경우도 있습니다.
그래도 “정상 종료 때는 이 루트로 멈춘다”는 설계가 있는 것만으로 꽤 조용해집니다.
flowchart TB
accTitle: 멈추기 위한 경로
accDescr: 종료 시에는 진행 중 I/O 취소, 다음 주기 중지, 큐 남은 항목의 처리, close나 flush 대기가 필요하고, CancellationToken과 StopAsync라는 멈추는 경로가 처음부터 있는 것이 효과가 있다.
stopreq["멈출 신호"] --> token["CancellationToken이 알림"]
token --> loop["다음 주기를 시작하지 않는다"]
token --> io["진행 중인 I/O를 취소"]
stopreq --> sa["StopAsync로 close와 flush"]
sa -.-> limit["크래시나 kill에서는 지나가지 않는다"]
그림8: 상주 처리는 멈추기가 더 어렵기 때문에, 멈추는 경로가 처음부터 있는 것이 효과가 있습니다.
3.4. DI / 로그 / 설정이 처음부터 갖춰진다
Generic Host의 장점은 BackgroundService만이 아닙니다.
Host.CreateApplicationBuilder로 DI / 구성 / 로그의 기반이 갖춰진다appsettings.json이나 환경 변수를 그대로 쓰기 쉽다ILogger<T>를 UI도 worker도 같은 방식으로 쓸 수 있다- 필요하면
IOptions<T>계열로 설정을 모을 수 있다
특히 Windows 도구 프로젝트에서는, “처음에는 작아서 static으로 대충 쥐고 있던 설정이나 logger가, 나중에 힘들어진다” 는 일이 꽤 흔합니다.
여기를 처음부터 host에 올려 두면, 앱이 조금 커졌을 때의 버거움이 줄어듭니다.
4. 잘 맞는 경우
Generic Host / BackgroundService가 특히 효과를 내기 쉬운 것은 이런 경우입니다.
- 트레이 상주 앱 주기 동기화, 감시, 알림, 재연결이 있다
- 장치 / 카메라 / 소켓 연결 앱 연결 유지, 감시, 재시도, 상태 취득이 있다
- 파일 연계 도구 감시, 가져오기 큐, 순서가 있는 처리가 있다
- 사내 도구가 커지는 것을 막는 용도 처음에는 작지만, 설정·로그·외부 I/O가 늘 것 같다
- 종료 품질이 중요한 앱 닫을 때 어중간한 상태를 남기고 싶지 않다
반대로, 처음부터 host를 넣지 않아도 되는 경우도 있습니다.
- 단발로 시작해 한 번만 처리하고 끝나는 작은 도구
- 배경 처리가 거의 없고, UI 이벤트만으로 끝나는 화면
- 의존성이나 설정이 거의 늘지 않는, 정말 작은 사내 보조 도구
Host는 “필수”가 아닙니다.
다만 상주 처리가 2개 이상 보이기 시작하면, 꽤 적극적으로 검토해도 됩니다.
나중에 흩어진 Task.Run을 치우는 것보다 훨씬 쌉니다.
flowchart TB
accTitle: host를 넣을지의 기준
accDescr: 상주 처리가 2개 이상 보이기 시작하면 적극적으로 검토하고, 단발 시작의 작은 도구나 UI 이벤트만으로 끝나는 화면이라면 처음부터 넣지 않아도 된다는 기준을 나타낸다.
q{"상주 처리가 2개 이상 보이는가"}
q -->|"예"| yes["적극적으로 host를 검토"]
q -->|"아니요"| no["처음부터 넣지 않아도 된다"]
yes -.-> why["나중에 Task.Run을 치우는 것보다 싸다"]
그림9: host는 필수는 아니지만, 상주 처리가 늘기 시작했을 때의 도입은 뒷정리보다 쌉니다.
5. 최소 구성 예 (WPF 예)
예로서 WPF에서 host를 시작하고, 5초마다 외부 상태를 읽는 BackgroundService를 돌리는 최소 구성을 적습니다.
WinForms에서도 입구가 Main / ApplicationContext로 바뀔 뿐 생각은 거의 같습니다.
코드가 세 덩어리로 나오므로, 먼저 파일 구성을 적어 둡니다.
| 파일 | 내용 | 게재 |
|---|---|---|
App.xaml.cs |
host 생성, DI 등록, StartAsync / StopAsync, MainWindow 표시 |
5.1 |
DevicePollingBackgroundService.cs |
5초마다 상태를 읽는 상주 루프 | 5.2 |
StatusStore.cs |
worker와 UI가 공유하는 상태. DeviceStatus 레코드도 여기에 둡니다 |
5.3 |
IDeviceStatusReader.cs / DeviceStatusReader.cs |
실제로 외부에서 상태를 읽는 처리 | 본문에서는 생략. GitHub 샘플에 구현이 있습니다 |
MainWindow.xaml / MainWindow.xaml.cs |
화면. StatusStore를 읽어 표시한다 |
본문에서는 생략. WPF의 일반 화면 코드입니다 |
GitHub 샘플은 이 구성을 콘솔에서 돌릴 수 있는 형태로 만든 것으로, BackgroundService와 StatusStore 구현에 더해 시작부터 graceful shutdown까지를 통과하는 데모와 유닛 테스트가 들어 있습니다.
5.1. App.xaml.cs
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace DesktopHostSample;
public partial class App : Application
{
private IHost? _host;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
HostApplicationBuilder builder = Host.CreateApplicationBuilder(e.Args);
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddSingleton<MainWindow>();
builder.Services.AddSingleton<StatusStore>();
builder.Services.AddScoped<IDeviceStatusReader, DeviceStatusReader>();
builder.Services.AddHostedService<DevicePollingBackgroundService>();
_host = builder.Build();
await _host.StartAsync();
MainWindow mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
protected override async void OnExit(ExitEventArgs e)
{
if (_host is not null)
{
await _host.StopAsync();
_host.Dispose();
}
base.OnExit(e);
}
}
이 형태의 핵심은 세 가지입니다.
- host 시작을 UI 표시 전에 한다
- 종료 시에
StopAsync를 명시적으로 await한다 - DI / hosted service / shutdown timeout을 입구에서 모은다
ShutdownTimeout은 IHost.StopAsync가 종료 처리를 기다리는 기본 상한입니다. 기본값은 버전마다 다르며, .NET 6에서는 5초, .NET 7 이후는 30초입니다. 여기서 15초로 쓴 것은 가장 느린 종료 처리에 맞춰 상한을 스스로 정하기 위해서입니다. 기준은 “진행 중 I/O의 타임아웃 + close / flush에 걸리는 시간”에 여유를 조금 더한 근처입니다. 너무 짧으면 flush 도중에 잘리고, 너무 길면 “닫히지 않는 앱”처럼 보이므로, 기본값을 그대로 두지 않고 한 번 정해 두면 사고가 줄어듭니다.
flowchart TB
accTitle: ShutdownTimeout을 정하는 방법
accDescr: 종료 처리를 기다리는 상한은 기본값을 그대로 두지 않고, 가장 느린 종료 처리에 맞춰 스스로 정한다. 너무 짧으면 flush 도중에 잘리고, 너무 길면 닫히지 않는 앱처럼 보인다.
base["가장 느린 종료 처리를 파악"] --> calc["I/O 타임아웃에 flush분을 더한다"]
calc --> setv["상한을 스스로 정해 설정"]
setv -.-> short["너무 짧음: flush가 잘린다"]
setv -.-> longw["너무 김: 닫히지 않는 앱처럼 보인다"]
그림10: ShutdownTimeout은 기본값을 그대로 두지 않고, 가장 느린 종료 처리에서 역산해 정합니다.
OnExit를 async로 하는 것 자체는 UI 프레임워크 사정으로 조금 신경을 쓰지만,
“종료 시에 host를 멈춘다”는 흐름을 분명히 써 두는 의미는 큽니다.
5.2. BackgroundService
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DesktopHostSample;
public sealed class DevicePollingBackgroundService(
IServiceScopeFactory scopeFactory,
StatusStore statusStore,
ILogger<DevicePollingBackgroundService> logger) : BackgroundService
{
public override async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Device polling service is starting.");
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Device polling loop started.");
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using IServiceScope scope = scopeFactory.CreateScope();
IDeviceStatusReader reader =
scope.ServiceProvider.GetRequiredService<IDeviceStatusReader>();
DeviceStatus status = await reader.ReadAsync(stoppingToken);
statusStore.Update(status);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Device polling failed.");
}
}
logger.LogInformation("Device polling loop finished.");
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Device polling service is stopping.");
await base.StopAsync(cancellationToken);
logger.LogInformation("Device polling service stopped.");
}
}
여기서 중요한 것은 ExecuteAsync를
“관리되는 while 루프”로 단순하게 작성하는 것입니다.
- 주기는
PeriodicTimer - 중지는
stoppingToken - 예외는 로깅
scoped한 의존이 필요하면 매번 scope를 만든다
이 형태로 두면, “지금 이 상주 처리는 어디서 시작하고, 어디서 멈추며, 어디서 실패가 보이는가” 가 꽤 읽기 쉬워집니다.
flowchart TB
accTitle: 관리되는 while 루프의 형태
accDescr: PeriodicTimer로 주기를 기다리고, scoped한 의존은 매번 scope를 만들어 읽고, 상태 스토어를 갱신하고, 예외는 로그에 남겨 루프를 계속하며, stoppingToken 취소로 루프를 빠져나온다.
tick["PeriodicTimer의 주기 대기"] --> scope["scope를 만들어 의존을 가져온다"]
scope --> read["상태를 읽어 스토어를 갱신"]
read --> tick
read -.->|"실패"| logx["예외를 로그에 남기고 계속"]
tick -.->|"stoppingToken"| exitx["루프를 빠져나와 종료"]
그림11: ExecuteAsync는 “관리되는 while 루프”입니다. 주기·중지·예외·scope 처리가 한곳에서 읽힙니다.
5.3. 상태 공유는 UI에 직접 연결하지 않습니다
worker에서 UI 객체를 직접 건드리면, 결국 거기서 UI 스레드 문제가 다시 납니다.
그래서 우선:
- worker는 상태 스토어나 메시징 계층을 갱신한다
- UI는 자신의 컨텍스트에서 그 상태를 읽는다 / 반영한다
는 분리가 더 안전합니다.
StatusStore는 예를 들어 이런 얇은 공유 계층으로 둘 수 있습니다.
namespace DesktopHostSample;
public sealed class StatusStore
{
private readonly object _gate = new();
private DeviceStatus _current = DeviceStatus.Empty;
public DeviceStatus Current
{
get
{
lock (_gate)
{
return _current;
}
}
}
public void Update(DeviceStatus next)
{
lock (_gate)
{
_current = next;
}
}
}
public sealed record DeviceStatus(string Message)
{
public static readonly DeviceStatus Empty = new("No Data");
}
UI로의 즉시 알림이 필요하면 Dispatcher / BeginInvoke / 이벤트 / messenger 등을 씁니다.
다만 그 책임은 UI 경계에서 지는 편이 섞이기 어렵습니다.
flowchart TB
accTitle: 상태 공유를 UI에 직접 연결하지 않는 분리
accDescr: worker는 UI 객체를 직접 건드리지 않고 상태 스토어나 메시징 계층을 갱신하고, UI는 자신의 컨텍스트에서 그 상태를 읽어 반영한다. 즉시 알림의 책임은 UI 경계에서 진다.
worker["worker〔상주 루프〕"] --> store["상태 스토어를 갱신"]
ui["UI"] --> readq["자신의 컨텍스트에서 읽는다"]
store --> readq
readq -.-> notify["즉시 알림은 UI 경계의 책임으로"]
그림12: worker와 UI 사이에 얇은 공유 계층을 두면 UI 스레드 문제의 재발을 막을 수 있습니다.
6. StartAsync / ExecuteAsync / StopAsync의 나누는 법
이 셋이 섞이면 읽는 사람 머리가 바로 흐려집니다. 우선 다음 나누기가 꽤 안정적입니다.
6.1. StartAsync
StartAsync는 시작에 참여하는 짧은 처리를 두는 곳입니다.
맞는 것:
- 시작 로그
- 가벼운 구독 시작
- 바로 끝나는 초기 상태 준비
base.StartAsync전후의 최소한의 정돈
맞지 않는 것:
- 수십 초 걸리는 warm-up
- 무한 루프
- 무거운 I/O를 늘어놓는 본체 처리
StartAsync를 무겁게 하면 앱 전체의 시작까지 둔해 보입니다.
여기는 “시작의 신호”를 쓰는 곳, 정도로 생각하면 사고가 적습니다.
flowchart TB
accTitle: StartAsync에 둘 것의 가름
accDescr: 시작 로그나 가벼운 구독 시작처럼 시작에 참여하는 짧은 처리는 StartAsync에 맞지만, 긴 warm-up이나 무한 루프나 무거운 I/O를 두면 앱 전체의 시작이 둔해 보인다.
q{"시작에 참여하는 짧은 처리인가"}
q -->|"예"| ok["StartAsync에 둔다"]
q -->|"아니요"| ng["ExecuteAsync 등 본체 쪽으로"]
ng -.-> why["무거우면 앱 시작이 둔해진다"]
그림13: StartAsync는 “시작의 신호”를 쓰는 곳이지, 무거운 처리를 두는 곳이 아닙니다.
6.2. ExecuteAsync
ExecuteAsync는 서비스 수명의 본체입니다.
맞는 것:
- 폴링
- 감시 루프
- 재연결 루프
Channel<T>를 읽는 컨슈머- 주기 처리
- “중지까지 살아 있는” 처리 전반
여기에서의 요령은 세 가지입니다.
CancellationToken을 처음부터 끝까지 통과시킨다- 예외로 루프 전체가 말없이 죽지 않게 한다
- 재시도나 backoff를 임시방편으로 너무 늘리지 않는다
BackgroundService는 편리하지만, 내버려 두면 “무엇이든 빨아들이는 거대한 루프”가 되기도 합니다.
실제 처리는 다른 서비스로 잘라 내고, ExecuteAsync 자체는 수명 관리와 오케스트레이션으로 모으는 편이 읽기 쉽습니다.
flowchart TB
accTitle: ExecuteAsync를 본체로 유지하는 요령
accDescr: CancellationToken을 처음부터 끝까지 통과시키고, 예외로 루프가 말없이 죽지 않게 하고, 재시도나 backoff를 임시방편으로 늘리지 않는다는 세 가지 요령을 지키며, 실제 처리는 다른 서비스로 잘라 낸다.
exec["ExecuteAsync"] --> c1["token을 끝까지 통과시킨다"]
exec --> c2["말없이 죽이지 않는다"]
exec --> c3["재시도를 너무 늘리지 않는다"]
exec -.-> role["수명 관리로 모은다"]
그림14: ExecuteAsync를 거대한 루프로 만들지 않고 수명 관리의 자리로 유지하기 위한 세 가지 요령입니다.
6.3. StopAsync
StopAsync는 정상 종료 시의 정리를 하는 곳입니다.
맞는 것:
- 중지 로그
- 타이머 / 구독 / 감시의 해제
- 명시적으로 close / flush하고 싶은 리소스의 정리
base.StopAsync를 통과한 종료 대기
다만 StopAsync에 전부를 기대하지 않는 것도 중요합니다.
- 프로세스가 죽었다
- 강제 종료되었다
- OS 쪽에서 kill되었다
이런 종료에서는 애초에 지나가지 않는 경우가 있습니다.
그래서,
- 영속화는 가능한 한 평상시에 작게 끝낸다
- 종료 시에만 정합이 맞는 설계로 만들지 않는다
- cleanup은 idempotent로 둔다
이 근처가 중요합니다. 종료 시에만 한 번에 다 해결하려고 하면 대체로 흐려집니다.
flowchart TB
accTitle: StopAsync에 기대해도 되는 범위
accDescr: 정상 종료에서는 StopAsync로 정리할 수 있지만, 프로세스 크래시나 강제 종료나 OS의 kill에서는 지나가지 않는 경우가 있으므로, 영속화는 평상시에 작게 끝내고 cleanup은 idempotent로 둔다.
endkind{"어떤 종료인가"}
endkind -->|"정상 종료"| sa["StopAsync로 정리할 수 있다"]
endkind -->|"크래시나 kill"| skip["StopAsync는 지나가지 않는 경우가 있다"]
skip --> ready["평상시 영속화로 대비한다"]
ready -.-> idem["cleanup은 idempotent로"]
그림15: StopAsync는 정상 종료의 도움이지, 비정상 종료의 보험이 아닙니다.
6.4. .NET 10 이후의 주의
.NET 10(2025년 11월 릴리스)의 파괴적 변경으로, BackgroundService.ExecuteAsync 전체가 백그라운드 태스크로 실행되는 동작으로 바뀌었습니다.
이전에는 첫 await 전의 동기 부분이 시작 시 다른 서비스의 시작을 막는, 조금 알기 어려운 동작이 있었습니다.
이 변경으로 ExecuteAsync의 “첫 몇 줄이 시작을 무겁게 하던” 사고는 줄어들기 쉬워졌습니다.
반대로 말하면, 타깃이 .NET 9 이전이면 아직 바뀌지 않은 쪽의 동작입니다. 자기 프로젝트가 어느 쪽인지 먼저 확인하세요.
다만 그래도 설계상으로는
- 시작에 참여하는 짧은 처리 →
StartAsync - 오래 실행되는 본체 →
ExecuteAsync
로 나눠 두는 편이 읽기 쉽습니다.
시작 타이밍을 더 엄밀히 제어하고 싶다면 IHostedLifecycleService까지 시야에 들어갑니다.
이 근처는 상주 앱이 커졌을 때 효과가 나는, 눈에 잘 띄지 않는 논점입니다.
flowchart TB
accTitle: ExecuteAsync의 버전별 동작 차
accDescr: .NET 9 이전에는 첫 await 전의 동기 부분이 다른 서비스의 시작을 막을 수 있지만, .NET 10 이후는 ExecuteAsync 전체가 백그라운드 태스크로 실행된다. 어느 쪽이든 시작의 짧은 처리는 StartAsync로 나눠 두는 편이 읽기 쉽다.
v{"타깃의 .NET은?"}
v -->|".NET 9 이전"| oldb["await 전 동기 부분이 시작을 막을 수 있다"]
v -->|".NET 10 이후"| newb["전체가 백그라운드 실행"]
oldb --> split["시작의 짧은 처리는 StartAsync로"]
newb --> split
그림16: 버전으로 동작은 바뀌어도, StartAsync와 ExecuteAsync를 나누는 설계는 바뀌지 않습니다.
7. 흔한 안티패턴
7.1. Window_Loaded / Form_Shown에서 무한 루프를 시작한다
처음에는 편합니다. 하지만 중지 책임과 예외 책임이 UI 쪽에 그대로 붙습니다.
“화면이 닫히면 멈춘다” “최소화 to tray에서는 멈추지 않는다” “설정 변경 때는 재시작한다” 같은 조건이 늘어나기 시작하면 바로 힘들어집니다.
7.2. Task.Run을 던져 놓고 끝낸다
Task.Run 자체가 나쁜 것은 아닙니다.
나쁜 것은 수명과 예외의 주인이 없는 것입니다.
특히 상주 처리를 Task.Run(async () => { while (...) { ... } })로 시작하면,
- 언제 끝나는가
- 누가 기다리는가
- 예외는 어떻게 보는가
- 종료 시에 어디까지 기다리는가
가 모호해집니다.
이것이 BackgroundService에 오르는 것만으로 꽤 정리하기 쉬워집니다.
7.3. BackgroundService에서 UI를 직접 건드린다
이것은 지뢰입니다. UI 스레드 문제와 lifetime 문제가 한꺼번에 섞입니다.
worker는 UI를 직접 만지지 않고,
- 상태
- 이벤트
- 메시지
- queue
중 하나로 경계를 두는 편이 안전합니다.
7.4. StopAsync에만 중요한 저장 처리를 모은다
StopAsync는 정상 종료의 도움은 되지만, 최후의 심판은 아닙니다.
종료 시에만 저장한다, 종료 시에만 flush한다, 종료 시에만 일관성이 맞는다,
는 설계면 크래시에서 무너집니다.
7.5. host를 쓰면서 Environment.Exit로 함부로 종료한다
이것도 흔합니다.
“이제 귀찮으니 그냥 종료해 버릴까”
로 Environment.Exit를 호출하면,
host가 가진 graceful shutdown 경로를 스스로 잘라 버립니다.
치명적 오류로 전체를 종료하고 싶다면,
우선 IHostApplicationLifetime.StopApplication()을 써서
멈추기 위한 정규 루트를 통과하는 편이 솔직합니다.
flowchart TB
accTitle: 전체 종료의 두 루트
accDescr: Environment.Exit로 종료하면 host가 가진 graceful shutdown 경로를 스스로 잘라 버리므로, 치명적 오류로 전체를 종료하고 싶을 때는 IHostApplicationLifetime.StopApplication으로 정규 루트를 통과시킨다.
fatal["치명적 오류로 종료하고 싶다"] --> q{"어느 쪽으로 내릴까"}
q -->|"Environment.Exit"| cut["graceful shutdown 경로를 자른다"]
q -->|"StopApplication"| route["멈추기 위한 정규 루트"]
route --> clean["StopAsync까지 지나 종료"]
그림17: host를 쓰면서 Environment.Exit로 종료하면, 스스로 마련한 중지 경로를 스스로 자르는 것이 됩니다.
8. 리뷰 체크리스트
Generic Host / BackgroundService를 쓰는 desktop app 리뷰에서는 다음을 순서대로 보면 알기 쉽습니다.
- 그 처리는 앱 수명에 묶이는 처리인가, 단순한 UI 이벤트 처리인가
- 시작 책임이
StartAsync/ExecuteAsync/StopAsync에 적절히 나뉘어 있는가 StartAsync가 너무 무거워지지 않았는가ExecuteAsync가CancellationToken을 끝까지 넘기고 있는가scoped한 의존을 hosted service에서 직접 쥐고 있지 않은가- worker가 UI 객체를 직접 건드리고 있지 않은가
- 예외가 말없이 삼켜지지 않았는가
- 재시도 루프가 무한히 높은 빈도가 되지 않았는가
- 종료 시 대기 시간에 상한이 있는가
Environment.Exit나 프로세스 kill 전제의 종료가 섞여 있지 않은가
이 체크리스트로 보면, “일단 Host를 넣었습니다” 와 “수명을 설계로서 정리하고 있습니다” 의 차이가 꽤 보이기 쉬워집니다.
9. 대략적인 구분
| 하고 싶은 일 | 먼저 고르는 것 |
|---|---|
| 앱 전체의 DI / 로그 / 설정을 맞춘다 | Host.CreateApplicationBuilder |
| 상주 루프를 돌린다 | BackgroundService |
| 일정 간격으로 돌린다 | PeriodicTimer + BackgroundService |
| 순서가 있는 후처리를 돌린다 | Channel<T> + BackgroundService |
| scoped service를 쓴다 | IServiceScopeFactory.CreateScope() |
| 정상 종료를 전체에 알린다 | IHostApplicationLifetime.StopApplication() |
| UI 갱신 | UI 쪽에서 Dispatcher / Invoke |
| 한 번만의 화면 조작 | 일반 async 메서드 |
| 시작 시의 엄밀한 라이프사이클 제어 | IHostedLifecycleService를 검토 |
10. 정리
데스크톱 앱에 Generic Host / BackgroundService를 들여오는 이유는
“Web스러운 쓰는 법을 하고 싶어서”가 아닙니다.
정말 효과가 있는 것은 다음 세 가지입니다.
- 시작과 중지의 책임을 한곳에 모을 수 있다
- 오래 살아 있는 처리의 수명을 설계로서 가질 수 있다
- graceful shutdown을 나중에 붙이는 것이 아니라 입구부터 다룰 수 있다
Windows 도구나 상주형 앱은 처음에는 작아도, 감시, 동기화, 재연결, 큐, 로그, 설정이 조금씩 늘어갑니다. 그때 UI 코드의 부수 작업으로 운용하면, 나중에 조용히 힘들어집니다.
반대로,
- UI는 UI
- 상주 처리는 hosted service
- 실제 처리는 DI 서비스
- 종료는
StopAsync와CancellationToken
으로 나누는 것만으로 꽤 정돈됩니다.
flowchart TB
accTitle: 정리의 분담
accDescr: UI는 UI, 상주 처리는 hosted service, 실제 처리는 DI 서비스, 종료는 StopAsync와 CancellationToken이라는 분담으로 나누는 것만으로 꽤 정돈됨을 나타낸다.
all["앱 전체"] --> u["UI는 UI"]
all --> h["상주 처리는 hosted service"]
all --> d["실제 처리는 DI 서비스"]
all --> s["종료는 StopAsync와 token"]
그림18: 화려하지 않은 분담이지만, “닫으면 가끔 이상해진다”를 줄이는 것은 이 정리입니다.
화려함은 없습니다. 다만 이런, 눈에 잘 띄지 않는 설계는 실무에서 확실히 효과가 납니다. “닫으면 가끔 이상해진다” “어디서 멈추고 있는지 모르겠다” 같은, 불쾌하게 남는 문제를 줄여 줍니다.
Windows 도구나 상주형 앱에서 BackgroundService화, 시작 / 중지 설계, 감시 루프, COM / 소켓 / 파일 감시의 수명 정리, 종료 시 장애의 원인 분리 등으로 막혀 있는 경우에는, 설계 리뷰나 방침 정리부터 상담해 주세요.
11. 참고 자료
- 이 글의 샘플 코드 전체(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/generic-host-backgroundservice-desktop-app
- 관련 글: C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
- 관련 글: WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- .NET의 범용 호스트
- ASP.NET Core에서 호스티드 서비스를 사용하는 백그라운드 작업
- BackgroundService 클래스
- 파괴적 변경: BackgroundService는 모든 ExecuteAsync를 태스크로 실행합니다
- HostOptions.ShutdownTimeout 속성
- Logging in C# - .NET
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WinForms / WPF 앱의 CI/CD 실전 ── GitHub Actions로 빌드부터 서명·배포까지 자동화하기
WinForms / WPF 앱의 CI/CD를 GitHub Actions로 구성하는 실무 가이드입니다. windows-latest에서 빌드+테스트를 돌리는 최소 YAML, 태그 기반 버전 부여, signtool 서명 연동, MSI/MSIX/Clic...
Windows 앱의 작업 트레이 상주와 토스트 알림 ── NotifyIcon의 함정과 AppNotification 고르는 법
업무용 Windows 앱의 작업 트레이 상주와 토스트 알림 구현을 정리합니다. NotifyIcon의 올바른 사용법, Explorer 재시작 시 재등록, 토스트 API 3종의 선정 판단표, 알림이 도착하지 않는 경우까지 설명합니다.
Windows 앱 외주·수탁 개발을 의뢰하기 전에 정리할 점
Windows 앱 외주·수탁 개발을 의뢰하기 전에, 기존 소프트웨어 수정, 장치 연동, COM/ActiveX, 배포·업데이트, 유지보수를 정리하는 포인트를 설명합니다.
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
WinForms/WPF 앱의 다국어화 ── resx·satellite assembly·culture 전환의 실무
Windows 데스크톱 앱의 다국어화를 정리합니다. CurrentCulture와 CurrentUICulture의 차이, resx와 satellite assembly의 구조, WPF에서 현실적인 방식 선택, 런타임 언어 전환, 서식·RTL까지 설명...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
Generic Host & 앱 아키텍처
Generic Host, BackgroundService, DI, 구성, 로깅, 앱 수명 설계를 정리한 토픽 페이지입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
백그라운드 처리, 주기 처리, 재연결, 종료 처리까지 포함하는 데스크톱 앱 개발 그 자체에 가까운 주제입니다.
기술 상담 & 설계 리뷰
UI와 상주 처리의 책임 분리나 graceful shutdown 설계를 먼저 점검하고 싶을 때는 기술 상담·설계 리뷰로 정리할 수 있습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Generic Host는 데스크톱 앱에서도 쓸 수 있나요?
- 쓸 수 있습니다. Generic Host는 ASP.NET Core만의 구조가 아니라, 콘솔, worker, WPF / WinForms 데스크톱 앱에서도 시작·의존성·설정·로그·중지를 한꺼번에 맡는 기반으로 쓸 수 있습니다. 특히 상주 앱, 트레이 앱, 장치 감시, 주기 동기화, 순서가 있는 후처리, 재연결 루프와 잘 맞습니다.
- BackgroundService는 무엇을 위해 쓰나요?
- '오래 살아 있는 처리'를 Task.Run으로 던져 놓고 끝내는 대신, 관리되는 수명에 올리기 위한 틀입니다. IHostedService를 쓰기 쉽게 돕는 구현으로, 감시 루프나 주기 처리의 본체를 ExecuteAsync에 작성할 수 있습니다. '이 처리는 앱이 동작하는 동안 계속 살아 있다'는 선언이 코드 형태로 드러나고, 시작 책임·중지 책임·예외 감시·로그·DI·설정을 한곳의 설계로 모을 수 있습니다.
- StartAsync / ExecuteAsync / StopAsync는 어떻게 나누어야 하나요?
- StartAsync는 시작에 참여하는 짧은 초기화, 오래 실행되는 본체는 ExecuteAsync, 종료 시의 중지 알림이나 flush·close는 StopAsync에 나누면 읽기 쉽습니다. 한편 버튼을 눌렀을 때만 한 번 도는 처리는 일반 async 메서드로 충분하고, 무엇이든 BackgroundService에 넣으면 거창해집니다.
- 종료 처리는 StopAsync에 전부 모아도 되나요?
- 좋지 않습니다. StopAsync는 편리하지만 프로세스 크래시나 강제 종료의 보험이 아니므로, 뒷정리를 너무 몰아넣지 않는 것이 중요합니다. graceful shutdown(진행 중 I/O 취소, 다음 주기 중지, 큐에 남은 항목의 처리 방침, 연결 close, 로그 flush)은 CancellationToken과 StopAsync로 설계하되, 비정상 종료 때에도 깨지지 않는 전제는 따로 두어야 합니다.