수정 이력(14건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635203)
- 지식 맵에서 "Job Object는 프로세스 트리의 구현을 담당한다"는 관계의 확도를, 무조건에서 조건부로 바꿨습니다. breakaway를 허용한 경우에는 자식이 Job 바깥에서 태어나므로, Job 경계와 프로세스 트리의 일치는 기본 생성 경로에 한정된다는 본문 서술에 맞춘 것입니다. 본문 문장은 바꾸지 않았습니다.
- Job Object로 프로세스 트리를 묶는 방법, 3단계 종료 절차, 타임아웃 경계의 경합, watchdog 배치 위치 등을 그림으로도 따라갈 수 있도록 Mermaid 그림 18개를 추가했습니다(본문 500~750자당 그림 1개의 규약에 맞춘 것입니다). 기존 그림 2개에도 캡션을 달았습니다. 본문 문장은 바꾸지 않았습니다.
- 글 앞에 "이 글의 지식 맵" 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조합니다.
- 타임아웃 시 `Kill`이 경계 경합으로 다른 예외로 바뀌는 문제를 고쳤습니다. `WaitForExit(30_000)`이 `false`를 반환한 뒤 `Kill`을 호출하기까지의 짧은 사이에 자식이 스스로 끝나는 경우가 있고, 그때 `Kill`은 성공하지 않습니다(.NET에서는 종료 처리 중에 `Win32Exception`, .NET Framework에서는 이미 종료된 대상에 `InvalidOperationException`). 그냥 통과시키면 던져야 했던 `TimeoutException` 대신 뒷정리 실패가 나가고, 마지막 `WaitForExit()`에 의한 출력 읽기도 건너뜁니다. `HasExited`로 정말로 끝났는지를 확인한 뒤에 삼키고, 아직 살아 있으면 다시 던지도록 바꿨습니다. `AggregateException`(자손 일부를 멈추지 못함)은 삼키지 않습니다.
- 자식 프로세스 시작을 실행 파일 이름이 아니라 절대 경로로 가리키도록 고쳤습니다. `CreateProcessW`의 `lpApplicationName`에 `nullptr`을 넘기면 명령줄의 첫 단어가 모듈 이름이 되고, 경로가 없을 때의 탐색 대상에 "부모 프로세스의 현재 디렉터리"와 "`PATH`"가 들어갑니다. `helper.exe`가 자기 폴더에 없는 상황에서 쓰기 가능한 장소에 같은 이름 실행 파일이 놓이면, 그것이 부모와 같은 권한으로 동작합니다. Microsoft 레퍼런스도 독립된 "보안에 관한 주의 사항"에서 `NULL`을 넘기지 말라고 명시합니다. C++ 쪽은 `GetModuleFileNameW`로 자기 폴더를 구해 절대 경로를 조립하고 명령줄도 따옴표로 감싸는 형태로, C# 쪽은 `AppContext.BaseDirectory`에서 만든 절대 경로를 `ProcessStartInfo`에 넘기는 형태로 바꿨습니다(`UseShellExecute = false`일 때 .NET은 `lpApplicationName`에 `null`을 넘기므로 같은 탐색이 일어납니다). 탐색 순서 설명도 본문에 추가했습니다.
- `AssignProcessToJobObject`가 실패했을 때의 뒷정리를 고쳤습니다. `Kill` 실패를 모두 삼킨 데다가, `Kill`은 비동기라 기다리지 않고 빠져 있었고, Job은 비어 있어 `job.Dispose()`로도 정리되지 않았습니다. 재시도한 두 번째 helper와 겹쳐 동작하거나, 멈추지 않은 채로 남습니다. `HasExited`를 본 뒤 `Kill`하고, `WaitForExit`로 종료를 기다리며, 멈추지 못한 경우에는 원래 예외와 합쳐 `AggregateException`으로 던지도록 바꿨습니다.
- Job handle을 raw `IntPtr`이 아니라 `SafeHandle`로 들도록 바꿨습니다. 호출 쪽에서 `Process.Start`가 실패하거나 `Add`가 실패했을 때 Job을 닫을 수단이 없어, 초기화를 다시 할 때마다 커널 핸들이 하나씩 남았습니다. 아울러 `KILL_ON_JOB_CLOSE`가 붙은 핸들에 `using`을 붙이면 안 된다(스코프를 나가는 시점에 자식이 전부 죽는다)는 점도 명시했습니다.
- C# Job 생성에서 `CreateJobObject`는 성공했지만 `SetInformationJobObject`가 실패한 경로가 핸들을 닫지 않고 예외를 던지던 문제를 고쳤습니다. 호출 쪽이 초기화 오류를 잡아 재시도하면 시도마다 커널 핸들이 하나씩 샙니다(C++ 판은 이 경로에서 `CloseHandle`을 합니다).
- 타임아웃 시 `Kill` 뒤에 `WaitForExit`를 넣었습니다. `Kill`은 종료를 요청하고 즉시 반환하므로, 기다리지 않고 예외를 던지면 `using` 폐기가 도는 시점에 아직 자식이 살아 있을 수 있어, "타임아웃 예외가 나왔다 = 프로세스 트리는 정리됐다"가 성립하지 않습니다.
- C# 호출 쪽에서 `AssignProcessToJobObject`가 실패했을 때 자식 프로세스가 남는 문제를 고쳤습니다. `using`의 `Dispose`는 `Process` 래퍼만 버리고 OS 프로세스는 끝나지 않으므로, Job 바깥에 남은 자식을 명시적으로 멈추도록 바꿨습니다.
- 글머리에 Job 안과 밖을 나타내는 전체 그림, 6.1에 stdout/stderr 데드락이 나는 흐름 그림을 추가했습니다. Job Object 최소 코드를 C++와 C#으로 보이고(시작부터 Assign까지의 틈에 손자가 태어난다는 점도 명시), 표준 출력과 표준 에러를 병렬로 읽어 비우는 예, 언어별 대응표, 종료 코드 259의 이유를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635202)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「Windows 앱에서 자식 프로세스를 안전하게 다루는 체크리스트」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635202 https://comcomponent.com/ko/blog/windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/
- DOI(최신 버전)
- 10.5281/zenodo.21635202
- DOI(이 버전)
- 10.5281/zenodo.22217473
변환 도구, 업데이터, 분석 워커, 외부 CLI, PowerShell, ffmpeg, 사내 유틸리티. Windows 앱은 생각보다 쉽게 자식 프로세스에 의존합니다.
다만 문제가 나는 지점은 “시작에 성공했는가”가 아닙니다.
- 부모는 종료됐는데 자식만 남는다
- 손자 프로세스만 살아남는다
stdout/stderr가 막혀WaitForExit가 돌아오지 않는다- watchdog이 감시 대상과 함께 죽는다
Kill(entireProcessTree: true)로 끝난 줄 알았는데, 관측만 먼저 끝난다
Windows에서 자식 프로세스를 안전하게 다루는 요령은 시작 API를 고르는 것이 아니라 프로세스 트리의 소유자를 정하고, 종료 절차와 I/O를 설계하는 것입니다.
이 글에서는 Job Object, 종료 전파, 표준 입출력, watchdog을 한 장의 설계로 정리합니다.
flowchart TB
accTitle: 문제는 시작의 바깥에서 난다
accDescr: 자식 프로세스 사고는 시작 성공 여부가 아니라, 부모가 종료됐는데 자식만 남거나 stdout이 막히는 형태로 일어나며, 요령은 시작 API를 고르는 것이 아니라 프로세스 트리의 소유자를 정하고 종료 절차와 I/O를 설계하는 것임을 나타내는 그림.
a1["시작 API를 고른다"] -.-> a2["여기는 사고의 본체가 아니다"]
a3["프로세스 트리의 소유자를 정한다"] --> a6["안전한 자식 프로세스 다루기"]
a4["종료 절차를 설계한다"] --> a6
a5["I/O를 설계한다"] --> a6
그림1: 자식 프로세스의 안전은 시작 API가 아니라 소유·종료·I/O 설계로 결정됩니다.
이 글에서 쓰는 용어
영어로 그대로 나오는 말을 먼저 한 줄씩 정리합니다.
| 용어 | 한 줄 의미 |
|---|---|
| process tree | 프로세스 트리. 부모가 시작한 자식, 그 자식이 시작한 손자까지 포함한 일족입니다 |
| graceful shutdown | 협조 종료. “종료해 주세요”라고 요청하고, 상대가 뒷정리를 한 뒤 스스로 끝나는 방식. 강제 종료의 반대입니다 |
| I/O completion port | Windows의 비동기 I/O 완료 알림 메커니즘. Job Object에 연결하면 프로세스 시작·종료 알림을 받을 수 있습니다 |
| message pump | 메시지 루프. 윈도우를 가진 스레드가 OS 메시지를 꺼내 계속 처리하는 구조입니다. 여기가 멈추면 화면이 굳습니다 |
| heartbeat | 생존 확인을 위해 자식 프로세스가 주기적으로 내는 신호. 프로세스가 “살아 있지만 진행하지 않는” 상태를 감지할 때 씁니다 |
| restart budget | 재시작 예산. 일정 시간 안에 몇 번까지 재시작해도 되는지의 상한. crash loop를 멈추기 위해 둡니다 |
| drain | 읽어 비우기. pipe에 쌓인 출력을 읽어 내보내 쓰기 쪽이 막히지 않게 하는 것입니다 |
전체 그림
등장인물 관계를 먼저 한 장으로 둡니다.
flowchart TB
W["watchdog<br/>Job 바깥에 둔다"]
subgraph JOB["JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE가 붙은 Job Object"]
P["부모 앱 / worker 본체<br/>job handle의 최종 소유자"]
C["자식 helper.exe"]
G1["손자 converter.exe"]
G2["손자 ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"exit handle로 종료를 감지"| P
W -.->|"heartbeat로 hang을 감지"| P
W -.->|"restart budget 범위에서 다시 만든다"| JOB
그림2: 전체 그림. Job 경계가 프로세스 트리 경계이고, watchdog만 바깥에 있습니다.
볼 지점은 두 가지입니다.
- Job 경계가 프로세스 트리 경계입니다. 부모의 생사가 아니라 Job 소속으로 묶으므로, 손자가 늘어도 회수 누락이 나지 않습니다
- watchdog만 Job 바깥에 있습니다. 안에 넣으면 감시 대상과 함께 정리됩니다
1. 먼저 결론
실무에서 가장 효과가 큰 지점만 먼저 나열합니다.
- 부모의 생사와 자식 프로세스 트리의 수명을 묶고 싶다면, 기준점은 Job Object입니다
- console에 대한 종료 요청과 프로세스 트리 회수는 별개입니다
- 전자는 process group과
GenerateConsoleCtrlEvent - 후자는 Job Object
- 전자는 process group과
- 시작 시점부터 Job에 넣고 싶다면
STARTUPINFOEX와PROC_THREAD_ATTRIBUTE_JOB_LIST를 쓰는 설계가 자연스럽습니다 - 표준 출력 / 표준 에러는 병렬로 읽어 비우는 것이 기본입니다
stdin을 쓴다면, 쓰기를 마친 뒤 close해서 EOF를 전달하는 것까지 설계합니다- watchdog은 감시 대상 Job 바깥에 두는 편이 안전합니다
.NET의Kill(entireProcessTree: true)는 명시적으로 멈추는 API로는 편리하지만, 부모 크래시 시의 자동 회수나 graceful shutdown까지 포함한 설계의 대체는 아닙니다
이 글의 지식 맵
Windows 앱에서 자식 프로세스를 안전하게 다루는 설계의 기준점은 Job Object이며, KILL_ON_JOB_CLOSE를 붙이면 부모 크래시 시에도 프로세스 트리째 자동 회수할 수 있어 Process.Kill(entireProcessTree: true)만으로는 대체할 수 없습니다. 종료는 협조 종료(graceful shutdown)를 의뢰한 뒤 짧은 timeout으로 기다리고 마지막에 Job째 강제 종료하는 3단계가 사고 나기 어려우며, console 자식에 대한 협조 종료는 process group과 console 제어 이벤트로 수행합니다. stdout과 stderr를 병렬로 drain하지 않으면, Windows의 pipe가 유한 버퍼이기 때문에 부모와 자식이 읽기 대기·쓰기 대기로 굳어 버리는 stdio deadlock이 일어납니다. watchdog은 감시 대상과 같은 Job에 넣지 않고 바깥에 두어, heartbeat로 hang을 검지하고 restart budget으로 crash loop를 막는 설계가 안정적입니다.
flowchart LR
accTitle: Windows 자식 프로세스 안전 관리 지식 맵
accDescr: Job Object에 의한 프로세스 트리 묶기와 부모 크래시 시의 자동 회수, 협조 종료에서 강제 종료까지의 종료 절차, stdout/stderr의 병렬 drain에 의한 stdio deadlock 회피, watchdog을 Job 밖에 두고 heartbeat와 restart budget으로 감시하는 설계의 관계를 보여주는 그림입니다.
job_object["Job Object"]
stdio_deadlock["표준 입출력 데드락"]
watchdog_process["감시 프로세스(watchdog)"]
kill_on_job_close["JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
orphaned_process["부모 잃은 자식 프로세스의 잔존"]
parent_crash_recovery["부모 크래시 시 자동 정리"]
entire_process_tree_kill["Process.Kill(entireProcessTree: true)"]
proc_thread_attribute_job_list["PROC_THREAD_ATTRIBUTE_JOB_LIST"]
process_group["프로세스 그룹(콘솔)"]
console_ctrl_event["콘솔 제어 이벤트"]
graceful_shutdown["graceful shutdown"]
heartbeat_monitoring["heartbeat를 통한 생존 확인"]
restart_budget["재시작 예산(restart budget)"]
crash_loop["크래시 루프"]
parallel_stdio_drain["stdout/stderr의 병렬 drain"]
iocp["I/O 완료 포트(IOCP)"]
process_tree["프로세스 트리(process tree)"]
breakaway_option["JOB_OBJECT_LIMIT_BREAKAWAY_OK"]
job_object -->|"에서 구성할 수 있다"| kill_on_job_close
kill_on_job_close -.->|"방지한다"| orphaned_process
job_object -->|"권장되는 대응"| parent_crash_recovery
entire_process_tree_kill -->|"사용은 비권장"| parent_crash_recovery
proc_thread_attribute_job_list -->|"전제로 한다"| job_object
process_group -->|"이용한다"| console_ctrl_event
console_ctrl_event -->|"전제로 한다"| process_group
graceful_shutdown -->|"보다 먼저 해야 한다"| entire_process_tree_kill
graceful_shutdown -.->|"이용한다"| console_ctrl_event
watchdog_process -->|"사용은 비권장"| job_object
watchdog_process -.->|"이용한다"| heartbeat_monitoring
watchdog_process -->|"자동화한다"| restart_budget
restart_budget -->|"방지한다"| crash_loop
parallel_stdio_drain -->|"방지한다"| stdio_deadlock
job_object -.->|"이용한다"| iocp
job_object -.->|"구현을 담당한다"| process_tree
breakaway_option -.->|"원인이 될 수 있다"| orphaned_process
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 17건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 무엇이 위험한가
자식 프로세스 시작 구현은 처음에는 대략 10줄 전후로 쓸 수 있습니다. 하지만 문제가 나는 지점은 그 10줄의 바깥입니다.
- 부모가 종료된 뒤 자식이나 손자가 계속 남는다
- helper가 또 다른 helper를 시작해, 바로 아래 자식만 기다리고 만족한다
stdout/stderr한쪽이 막혀 부모도 자식도 서로 기다린다- UI thread에서 대기해 화면도 COM도 굳는다
- watchdog이 감시 대상과 같은 운명 공동체가 되어, 이상 시에 함께 죽는다
여기서 중요한 점은 “자식 프로세스 관리”는 하나의 API 이야기가 아니다라는 것입니다.
적어도 다음 네 가지는 나눠 생각하는 편이 전체 구조가 잘 보입니다.
- 프로세스 트리를 누가 소유하는가
- 어떻게 협조 종료를 요청하는가
- 표준 입출력을 어떻게 흘리는가
- 이상 종료와 hang을 어떻게 감시하는가
flowchart TB
accTitle: 나눠 생각할 네 가지 질문
accDescr: 자식 프로세스 관리는 하나의 API 이야기가 아니라, 프로세스 트리를 누가 소유하는지, 어떻게 협조 종료를 요청하는지, 표준 입출력을 어떻게 흘리는지, 이상 종료와 hang을 어떻게 감시하는지의 네 가지를 나눠 생각하면 전체 구조가 잘 보인다는 것을 나타내는 그림.
b1["1. 프로세스 트리의 소유자"] --> b2["2. 협조 종료 요청 방법"]
b2 --> b3["3. 표준 입출력의 흘리는 방법"]
b3 --> b4["4. 이상 종료와 hang 감시"]
b1 -.-> b5["하나의 API 이야기가 아니다"]
그림3: 자식 프로세스 관리는 이 네 가지 질문으로 분해해 설계합니다.
3. 메커니즘의 역할을 섞지 않기
process handle / process group / Job Object는 비슷해 보여도 역할이 다릅니다.
| 메커니즘 | 주요 역할 | 맞는 장면 | 이것만으로는 부족한 점 |
|---|---|---|---|
| process handle | 프로세스 하나의 종료 대기, exit code 취득 | 단발 도구의 완료 대기 | 손자 프로세스 회수 |
| process group | console에 Ctrl+Break 전파 | console child의 협조 종료 | 부모 크래시 시 cleanup, GUI 자식 프로세스 |
| Job Object | 프로세스 트리 묶기, 제한, 한꺼번에 종료 | worker tree, updater, helper chain | 앱 고유의 “저장한 뒤 닫기” |
process group은 console signal을 어디로 보낼지를 정하는 메커니즘이지, 부모가 죽으면 트리 단위로 정리하는 메커니즘이 아닙니다. 반면 Job Object는 프로세스 무리를 한 단위로 관리하는 Windows 쪽 메커니즘입니다.
3.1 언어별 대응표
이 글은 Win32와 .NET 이야기가 섞입니다. 자기 언어 열만 집어 갈 수 있도록 대응을 먼저 나열합니다.
| 하고 싶은 일 | Win32 / C++ | .NET / C# |
|---|---|---|
| 프로세스를 시작한다 | CreateProcessW |
Process.Start |
| Job을 만들어 제한을 붙인다 | CreateJobObjectW + SetInformationJobObject |
같은 API를 P/Invoke합니다. 표준 라이브러리에 Job Object 래퍼는 없습니다 |
| 시작 시점부터 Job에 넣는다 | STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST |
동일합니다. ProcessStartInfo에서는 지정할 수 없습니다 |
| 나중에 Job에 넣는다 | AssignProcessToJobObject |
같은 API를 P/Invoke하고 Process.Handle을 넘깁니다 |
| 종료를 기다린다 | WaitForSingleObject |
Process.WaitForExit, 비동기면 WaitForExitAsync(.NET 5 이후) |
| exit code를 가져온다 | GetExitCodeProcess |
Process.ExitCode |
| stdout / stderr를 읽는다 | 익명 pipe를 만들고 별도 스레드에서 읽습니다 | RedirectStandardOutput과 BeginOutputReadLine |
| GUI 자식에게 닫게 한다 | WM_CLOSE를 보냅니다 |
Process.CloseMainWindow |
| console 자식에게 Ctrl+Break를 보낸다 | CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent |
해당하는 API가 없어 P/Invoke합니다 |
| 트리 단위로 강제 종료한다 | TerminateJobObject, 또는 마지막 job handle을 닫습니다 |
Process.Kill(entireProcessTree: true)(.NET Core 3.0 이후), 또는 위와 같은 P/Invoke |
| 다수 자식의 종료를 기다린다 | RegisterWaitForSingleObject / SetThreadpoolWait |
Process.Exited 이벤트, 또는 WaitForExitAsync |
여기서 알 수 있는 점은 Job Object 주변만은 .NET에서도 Win32 API를 그대로 호출한다는 것입니다. .NET 쪽에 준비된 것은 어디까지나 프로세스 하나 단위 조작까지입니다.
flowchart TB
accTitle: .NET의 담당 범위와 Job의 경계
accDescr: .NET 표준 라이브러리에 있는 것은 시작이나 종료 대기 등 프로세스 하나 단위 조작까지이고, Job Object 주변 조작에는 래퍼가 없어 Win32 API를 P/Invoke로 그대로 호출하게 됨을 나타내는 그림.
c1["프로세스 하나 단위 조작"] --> c2[".NET 표준 Process로 충분하다"]
c3["Job Object 주변 조작"] --> c4["래퍼가 없다"]
c4 --> c5["Win32 API를 P/Invoke로 호출한다"]
그림4: .NET에서도 프로세스 트리를 묶는 부분만은 Win32 API를 직접 호출합니다.
4. Job Object를 기준으로 둔다
Job Object의 가장 강한 점은 “누구의 자식인가”가 아니라 “어느 Job에 속하는가”로 process tree를 묶을 수 있다는 것입니다. Job에 들어간 프로세스가 CreateProcess로 만드는 자식은 기본값으로 그 Job에 들어갑니다.
나아가 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE를 붙이면, 마지막 job handle이 닫혔을 때 Job에 연결된 모든 프로세스가 종료됩니다.
flowchart TB
accTitle: Job으로 묶으면 회수 누락이 사라진다
accDescr: Job Object는 누구의 자식인가가 아니라 어느 Job에 속하는가로 프로세스 트리를 묶고, Job에 들어간 프로세스가 만드는 자식은 기본값으로 같은 Job에 들어가며, KILL_ON_JOB_CLOSE를 붙이면 마지막 job handle이 닫혔을 때 모든 프로세스가 종료됨을 나타내는 그림.
d1["'누구의 자식인가'로 따라간다"] -.-> d2["손자가 늘면 빠진다"]
d3["'어느 Job에 속하는가'로 묶는다"] --> d4["자식이 만드는 자식도 같은 Job으로"]
d4 --> d5["KILL_ON_JOB_CLOSE"]
d5 --> d6["마지막 handle이 닫히면 전부 종료"]
그림5: 부모-자식 관계가 아니라 Job 소속으로 묶기 때문에 손자까지 회수할 수 있습니다.
4.1 먼저 잡아 둘 네 가지
1. 부모 종료 시 트리 단위로 정리하고 싶다면 KILL_ON_JOB_CLOSE
이것은 Windows 앱에서 helper / worker를 다룰 때의 토대입니다. TerminateJobObject를 명시적으로 호출하는 설계도 가능하지만, 부모의 이상 종료까지 포함해 cleanup을 부모의 수명으로 모으고 싶다면 KILL_ON_JOB_CLOSE가 이해하기 쉽습니다.
2. BREAKAWAY를 가볍게 붙이지 않기
JOB_OBJECT_LIMIT_BREAKAWAY_OK나 JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK는 편리해 보이지만, cleanup할 수 있다고 생각한 트리에서 일부가 빠져나가는 원인이 되기도 합니다. 의도가 없다면 breakaway는 붙이지 않는 편이 사고율이 내려갑니다.
3. 시작 시점부터 Job에 넣고 싶다면 PROC_THREAD_ATTRIBUTE_JOB_LIST
AssignProcessToJobObject로 나중에 묶을 수도 있습니다.
다만 시작 직후부터 Job 소속을 전제로 하고 싶은 장면에서는, STARTUPINFOEX와 PROC_THREAD_ATTRIBUTE_JOB_LIST로 생성 시 Job을 지정하는 편이 맞습니다.
4. job handle의 소유자를 모호하게 두지 않기
KILL_ON_JOB_CLOSE는 마지막 handle이 닫혔을 때 동작합니다.
거꾸로 말하면, job handle을 다른 프로세스로 복제하거나 의도 없이 상속시키면, 부모가 죽어도 기대한 대로 cleanup되지 않습니다. 누가 job handle의 최종 소유자인지는 먼저 정해야 합니다.
flowchart TB
accTitle: job handle의 소유자를 모호하게 두지 않기
accDescr: KILL_ON_JOB_CLOSE는 마지막 handle이 닫혔을 때 동작하므로, job handle을 다른 프로세스로 복제하거나 의도 없이 상속시키면 부모가 죽어도 기대한 대로 cleanup되지 않으며, 누가 최종 소유자인지를 먼저 정해야 함을 나타내는 그림.
e1["job handle을 복제·상속시킨다"] --> e2["마지막 handle이 닫히지 않는다"]
e2 --> e3["부모가 죽어도 cleanup되지 않는다"]
e3 -.->|"그래서"| e4["최종 소유자를 먼저 정한다"]
그림6: KILL_ON_JOB_CLOSE는 “마지막 handle”이 닫혀야 비로소 동작합니다.
4.2 Job Object는 observability에도 쓸 수 있지만, 알림은 만능이 아니다
Job Object에는 I/O completion port를 연결해 알림을 받는 메커니즘이 있습니다. 다만 completion port 알림을 모든 경우에서 완전 보장되는 알림으로 보지 않는 편이 안전합니다.
그래서 completion port는
- 감시
- 집계
- 로그
- 메트릭
에는 편리하지만, 그것만으로 correctness를 짜지 않는 편이 좋습니다.
flowchart TB
accTitle: completion port 알림의 쓰임새
accDescr: Job Object에는 I/O completion port를 연결해 알림을 받는 메커니즘이 있지만 모든 경우에서 완전 보장되는 알림으로 보지 않고, 감시·집계·로그·메트릭에 쓰며 그것만으로 correctness를 짜지 않는 편이 좋음을 나타내는 그림.
f1["Job의 completion port 알림"] --> f2["감시·집계·로그에는 편리하다"]
f1 -.-> f3["완전 보장 알림으로 보지 않는다"]
f3 -.-> f4["이것만으로 correctness를 짜지 않는다"]
그림7: 알림은 관측에 쓰고, 올바름의 근거로는 쓰지 않습니다.
4.3 최소 코드로 본다
말보다 짧으므로 두 언어의 최소 형태를 둡니다.
C++ 쪽은 Job을 만든다 → KILL_ON_JOB_CLOSE를 붙인다 → 시작 시점에 Job을 지정한다의 세 수입니다.
// Windows 10 이후 / C++17. helper.exe를 Job에 넣어 시작하고, 부모 종료 시 트리 단위로 정리한다
#include <windows.h>
#include <memory>
#include <string>
int wmain()
{
// 0. 시작할 파일을 절대 경로로 확정한다.
// lpApplicationName을 nullptr로 두고 명령줄 앞부터 찾게 하면,
// 탐색 대상에 "부모 프로세스의 현재 디렉터리"와 "PATH"가 들어간다.
// helper.exe가 자기 폴더에 없는 상황에서, 쓰기 가능한 장소에
// 같은 이름 실행 파일이 놓이면, 그것이 부모 권한으로 동작한다
wchar_t modulePath[MAX_PATH]{};
DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
if (moduleLen == 0 || moduleLen >= MAX_PATH) // MAX_PATH에서 잘린 경우도 실패로 본다
{
return 1;
}
std::wstring application(modulePath, moduleLen);
application.resize(application.find_last_of(L'\\') + 1); // 자기 실행 파일이 있는 폴더
application += L"helper.exe";
// 1. Job을 만들고, 마지막 handle이 닫히면 내용을 전부 종료시킨다
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job == nullptr)
{
return 1;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
{
CloseHandle(job);
return 1;
}
// 2. 시작 시점부터 Job 소속으로 만들기 위한 특성 목록을 만든다
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); // 필요 크기를 얻기 위한 빈 호출
auto storage = std::make_unique<BYTE[]>(attributeSize);
auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
{
CloseHandle(job);
return 1;
}
// job 값은 DeleteProcThreadAttributeList를 호출할 때까지 살려 두어야 한다
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
&job, sizeof(job), nullptr, nullptr))
{
DeleteProcThreadAttributeList(attributes);
CloseHandle(job);
return 1;
}
// 3. 시작한다
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION info{};
// CreateProcessW는 다시 쓸 수 있는 버퍼를 요구한다.
// argv[0]에도 같은 경로를 둔다. 공백이 포함되므로 반드시 따옴표로 감싼다
std::wstring commandLine = L"\"" + application + L"\" --input data.bin";
BOOL created = CreateProcessW(
application.c_str(), commandLine.data(), nullptr, nullptr,
FALSE, // 상속시킬 handle은 좁힌다
EXTENDED_STARTUPINFO_PRESENT,
nullptr, nullptr,
&startup.StartupInfo, &info);
DeleteProcThreadAttributeList(attributes);
if (!created)
{
CloseHandle(job);
return 1;
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD exitCode = 0;
GetExitCodeProcess(info.hProcess, &exitCode);
CloseHandle(info.hThread);
CloseHandle(info.hProcess);
CloseHandle(job); // 마지막 job handle. 여기서 남은 자손은 한꺼번에 종료한다
return static_cast<int>(exitCode);
}
이 코드는 UpdateProcThreadAttribute 문서에 적힌 제약을 두 가지 반영합니다. 둘 다 놓치기 쉬운 지점입니다.
PROC_THREAD_ATTRIBUTE_JOB_LIST를 쓸 수 있는 것은 Windows 10 / Windows Server 2016 이후입니다. 그 이전을 상대한다면AssignProcessToJobObject로 내려가야 합니다UpdateProcThreadAttribute에 넘긴 값은DeleteProcThreadAttributeList를 호출할 때까지 살아 있어야 합니다. 지역 변수를 넘기고 바로 스코프를 나가는 쓰기는 깨집니다
시작하는 파일은 반드시 절대 경로로 가리킨다
맨 앞 “0.”에서 GetModuleFileNameW로 경로를 조립하는 것은 쓰기 취향이 아니라 어느 실행 파일이 동작할지를 확정하기 위해서입니다.
lpApplicationName에 nullptr을 넘기면 명령줄의 첫 단어가 모듈 이름이 됩니다. 거기에 경로가 없으면 Windows는 다음 순서로 찾습니다.
- 애플리케이션이 로드된 디렉터리
- 부모 프로세스의 현재 디렉터리
- 32bit 시스템 디렉터리
- 16bit 시스템 디렉터리
- Windows 디렉터리
PATH환경 변수에 나열된 디렉터리
문제는 2와 6입니다. helper.exe가 1에 없을 때 ── 배치 누락, 다른 구성의 빌드, 언인스톨 잔여 ── 탐색은 2로 진행합니다. 현재 디렉터리가 쓰기 가능한 장소(사용자 다운로드 폴더에서 그대로 시작했거나, 공유 폴더를 작업 디렉터리로 쓰는 경우)라면, 거기에 놓인 helper.exe가 부모와 같은 권한으로 동작합니다. PATH를 바꿀 수 있는 환경이면 6도 같은 이야기입니다.
Microsoft 문서도 이 점을 “보안에 관한 주의 사항”으로 독립된 절로 두고, “이 문제를 피하려면 lpApplicationName에 NULL을 넘기지 마세요”라고 명시합니다. 공백이 포함된 경로를 따옴표로 감싸지 않으면 C:\Program.exe가 시작될 수 있다는 유명한 예도 같은 절에 있습니다. 그래서 명령줄 쪽도 "..."로 감쌉니다.
C#의 ProcessStartInfo도 같습니다. UseShellExecute = false일 때 .NET은 FileName과 인수를 한 줄 명령줄로 조립하고 lpApplicationName에는 null을 넘기므로, 파일 이름만 넘기면 위의 탐색이 그대로 일어납니다. AppContext.BaseDirectory에서 조립한 절대 경로를 넘깁니다.
“그런 배치 실수는 일어나지 않는다”고 보이는 환경에서도 쓰는 비용은 거의 0입니다. 자식 프로세스를 시작하는 코드에서 실행 파일을 상대 이름으로 쓸 이유는 기본적으로 없습니다.
flowchart TB
accTitle: 상대 이름 시작이 부르는 사고
accDescr: lpApplicationName에 NULL을 넘기고 파일 이름만으로 시작하면 탐색 대상에 부모 프로세스의 현재 디렉터리와 PATH가 들어가고, 원래 helper.exe가 없을 때 쓰기 가능한 장소에 놓인 같은 이름 실행 파일이 부모와 같은 권한으로 동작하므로 반드시 절대 경로로 가리켜야 함을 나타내는 그림.
g1["파일 이름만으로 시작한다"] --> g2["탐색에 현재 디렉터리와 PATH가 들어간다"]
g2 --> g3["원래 장소에 helper.exe가 없을 때"]
g3 --> g4["놓인 같은 이름 EXE가 부모 권한으로 동작한다"]
g4 -.->|"막으려면"| g5["절대 경로로 가리키고 따옴표로 감싼다"]
그림8: 시작할 파일은 탐색에 맡기지 말고 절대 경로로 확정합니다.
.NET에는 Job Object 래퍼가 없으므로 P/Invoke가 됩니다. 구조체 정의가 길어 보이지만, 실제로 호출하는 것은 두 함수뿐입니다.
// .NET 8 / C# 12. Job을 만들어 KILL_ON_JOB_CLOSE를 붙이고, 시작된 프로세스를 넣는다
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
internal static class KillOnCloseJob
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public nuint MinimumWorkingSetSize;
public nuint MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public nuint Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
public nuint PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);
/// <summary>Job을 만든다. 반환한 handle은 앱 수명 동안 계속 열어 둔다.</summary>
public static SafeJobHandle Create()
{
var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
if (job.IsInvalid)
{
throw new InvalidOperationException($"CreateJobObject에 실패했습니다. code={Marshal.GetLastWin32Error()}");
}
var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
{
// 만들었지만 설정하지 못한, 어중간한 Job을 그대로 버리지 않는다.
// 호출 쪽이 초기화 오류를 잡아 재시도하는 구조라면,
// 시도마다 커널 핸들이 하나씩 샌다(위의 C++ 판은
// 이 경로에서 CloseHandle을 한다)
var error = Marshal.GetLastWin32Error();
job.Dispose();
throw new InvalidOperationException($"SetInformationJobObject에 실패했습니다. code={error}");
}
return job;
}
public static void Add(SafeJobHandle job, Process process)
{
if (!AssignProcessToJobObject(job, process.Handle))
{
throw new InvalidOperationException($"AssignProcessToJobObject에 실패했습니다. code={Marshal.GetLastWin32Error()}");
}
}
}
// raw IntPtr로 들고 있으면, 초기화에 실패한 경로에서 아무도 닫지 못한다.
// SafeHandle로 두면 실패 경로는 Dispose를 한 번 호출하면 된다
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// P/Invoke 반환값으로 마샬러가 생성하므로, 인수 없이 만들 수 있어야 한다
private SafeJobHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}
호출 쪽은 이렇게 됩니다. Create만 호출하고 Add를 잊으면 Job은 있는데 자식이 들어 있지 않은, 가장 알아채기 어려운 상태가 됩니다.
// job handle은 필드 등에 들고, 앱이 끝날 때까지 닫지 않는다.
// KILL_ON_JOB_CLOSE가 붙어 있으므로, 닫는 순간에 Job 안의 자식이 전부 끝난다.
// 여기서 using을 붙이면 안 된다(스코프를 나가는 시점에 자식이 죽는다)
SafeJobHandle job = KillOnCloseJob.Create();
try
{
// 시작할 파일은 절대 경로로 넘긴다. 파일 이름만 넘기면,
// CreateProcess의 탐색 대상에 현재 디렉터리와 PATH가 들어간다
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false,
CreateNoWindow = true,
};
using var child = Process.Start(startInfo)
?? throw new InvalidOperationException("helper.exe를 시작할 수 없었습니다.");
try
{
KillOnCloseJob.Add(job, child); // 여기를 잊으면 Job은 빈 채로 남는다
}
catch (Exception assignFailed)
{
// Add가 실패하는 것은 예를 들어 부모 쪽에 양립할 수 없는 Job 제한이 걸린 경우.
// 이때 helper.exe는 이미 돌아가고 있다. `using`의 Dispose는 Process
// 래퍼만 버리고 OS 프로세스는 끝나지 않으며, Job은 비어 있으므로
// job.Dispose()로도 정리되지 않는다. 여기서 직접 멈추고 끝날 때까지 기다린다
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill은 종료를 요청하고 즉시 반환한다. 기다리지 않고 throw하면,
// 초기화를 다시 한 두 번째 helper와 겹쳐 동작할 수 있다
child.WaitForExit();
}
catch (Exception killFailed)
{
// 멈추지 못한 것은 Add 실패보다 무겁다. 삼키면
// "Job에도 들어가지 않고, 멈추지도 않은 자식"을 남긴 채 앞으로 간다
throw new AggregateException(
"Job 할당에 실패했고, helper.exe도 정지할 수 없었습니다.",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// 시작에도 Job 투입에도 실패했다면, 이 Job은 더 이상 쓰지 않는다.
// 닫지 않고 나가면, 초기화를 다시 할 때마다 커널 핸들이 하나씩 남는다.
// 이 시점의 Job은 비어 있거나(또는 위 catch에서 자식을 멈춘 뒤이므로)
// 닫아도 멈춰서 곤란한 것은 없다
job.Dispose();
throw;
}
다만 이 .NET 판에는 시작한 뒤 Job에 넣기까지의 틈이 있습니다. 그 사이에 자식이 다시 손자를 만들면 손자는 Job 바깥에서 태어납니다. C++ 판이 PROC_THREAD_ATTRIBUTE_JOB_LIST를 쓰는 것은 바로 이 틈을 없애기 위해서입니다. 손자를 만드는 helper를 상대한다면 .NET에서도 STARTUPINFOEX를 쓰는 P/Invoke까지 들어가는 가치가 있습니다.
flowchart TB
accTitle: 시작에서 Assign까지의 틈
accDescr: 시작한 뒤 AssignProcessToJobObject로 넣기까지의 사이에 자식이 다시 손자를 만들면 손자는 Job 바깥에서 태어나므로, 손자를 만드는 helper를 상대한다면 생성 시 Job을 지정하는 PROC_THREAD_ATTRIBUTE_JOB_LIST로 틈을 없앨 가치가 있음을 나타내는 그림.
h1["시작한 뒤 나중에 Job에 넣는다"] --> h2["시작과 Assign 사이에 틈"]
h2 --> h3["그 사이에 태어난 손자는 Job 바깥"]
h3 -.->|"틈을 없애려면"| h4["생성 시 Job을 지정해 시작한다"]
그림9: 나중에 Job에 넣는 방식에는 손자가 빠져나가는 틈이 있습니다.
5. 종료 전파를 protocol과 timeout으로 설계한다
자식 프로세스 종료는 한 방의 kill API로 끝나는 이야기가 아닙니다. 가장 사고가 덜 나는 형태는 이 3단계를 밟는 것입니다.
- 협조 종료를 요청한다
- 짧은 timeout으로 기다린다
- 마지막에 Job 단위로 강제 종료한다
이 순서로 두면 정상 종료 경로는 지키면서 hang 때는 회수할 수 있습니다.
flowchart TB
accTitle: 3단계 종료 절차
accDescr: 자식 프로세스 종료는 한 방의 kill API로 끝나는 이야기가 아니라, 협조 종료를 요청하고 짧은 timeout으로 기다린 뒤 마지막에 Job 단위로 강제 종료하는 3단계를 밟으면 정상 종료 경로를 지키면서 hang 때도 회수할 수 있음을 나타내는 그림.
i1["1. 협조 종료를 요청한다"] --> i2["2. 짧은 timeout으로 기다린다"]
i2 --> i3["3. 마지막에 Job 단위로 강제 종료"]
i2 -.-> i4["정상 경로는 지키고 hang은 회수한다"]
그림10: 종료는 요청·대기·강제 3단계로 설계합니다.
5.1 GUI child
GUI를 가진 자식 프로세스라면 .NET에서는 CloseMainWindow가 close message 송신이 됩니다.
다만 이것은 종료 요청이지 강제 종료가 아닙니다. 그래서
CloseMainWindow- 일정 시간 기다린다
- 안 되면 Job 단위로 kill
이라는 흐름이 자연스럽습니다.
5.2 Console child
Console child에서는 GUI의 close message를 쓸 수 없습니다. 이때는 process group과 console signal을 씁니다.
CREATE_NEW_PROCESS_GROUP으로 시작하고 GenerateConsoleCtrlEvent로 CTRL_BREAK_EVENT를 보내는 흐름입니다.
여기서 중요한 점은
CTRL_C_EVENT는 특정 group으로 한정하기에 맞지 않다- signal을 받을 수 있는 것은 console을 공유하는 프로세스뿐이다
CREATE_NEW_PROCESS_GROUP을 쓰면CTRL+C의 의미도 바뀐다
는 것입니다.
5.3 Worker / headless child
Worker나 headless child는 GUI도 console도 아닌 경우가 많습니다. 이 경우에는 자식 프로세스 전용 종료 protocol을 가진 편이 안전합니다.
stdin에quit을 보낸다- named pipe / socket / RPC로 shutdown command를 보낸다
- event object로 정지 요청을 전한다
Windows 쪽에서는 Job Object가 tree cleanup을 맡고, 앱 쪽에서는 pipe나 stdin이 graceful shutdown을 맡는 분리가 사고가 덜 납니다.
flowchart TB
accTitle: 자식 종류에 따라 협조 종료를 나눈다
accDescr: GUI 자식에는 CloseMainWindow 같은 close message, console 자식에는 CREATE_NEW_PROCESS_GROUP과 CTRL_BREAK_EVENT, worker에는 stdin이나 pipe를 통한 종료 protocol처럼 협조 종료 요청 수단을 자식 종류에 따라 나눔을 나타내는 그림.
j0["협조 종료 요청"] --> j1["GUI 자식: close message"]
j0 --> j2["console 자식: CTRL_BREAK_EVENT"]
j0 --> j3["worker: stdin이나 pipe의 종료 protocol"]
j3 -.-> j4["tree cleanup은 Job Object가 맡는다"]
그림11: 협조 종료 수단은 자식 종류로 고르고, 회수는 Job에 맡깁니다.
6. 표준 입출력을 막히게 하지 않기
6.1 stdout / stderr는 병렬 drain
첫 기본은 이것입니다.
stdout과 stderr는 병렬로 읽어 비운다. 한쪽을 다 읽은 뒤 다른 쪽은 막히기 쉽습니다.
Windows의 pipe는 무한 버퍼가 아닙니다. 자식이 stderr를 대량 출력하고 부모가 stdout만 읽고 있으면, 자식은 write에서 멈추고 부모는 종료 대기에서 멈추는 형태가 흔히 납니다.
그림으로 보면 이 형태입니다.
sequenceDiagram
participant P as 부모 프로세스
participant SO as stdout의 pipe
participant SE as stderr의 pipe
participant C as 자식 프로세스
P->>SO: stdout만 계속 읽는다
C->>SO: 조금만 쓴다
SO-->>P: 읽었다
C->>SE: 경고를 대량으로 쓴다
Note over SE: pipe 버퍼가 가득 찬다
C->>SE: 더 쓰려 한다
Note over C: write가 돌아오지 않는다. 자식은 여기서 정지
P->>SO: 나머지를 읽으려 한다
Note over P: 자식이 멈춰 있어 아무것도 오지 않는다
Note over P,C: 부모는 읽기 대기, 자식은 쓰기 대기. WaitForExit도 돌아오지 않는다
그림12: stdout만 읽으면 stderr pipe가 가득 차 부모·자식이 서로 기다립니다.
멈춰 있는 장소가 부모도 자식도 아니라 pipe이므로, 어느 쪽 로그를 봐도 원인이 비치지 않습니다. “stderr를 읽고 있지 않다”는 한 줄의 빠짐이 그대로 hang이 됩니다.
stdout과 stderr를 별도 핸들러로 받아 각각 독립적으로 읽어 나가면 이 고리는 성립하지 않습니다. .NET이라면 다음 형태입니다.
// .NET 8 / C# 12. stdout과 stderr를 병렬로 drain하고, 출력 읽기를 마칠 때까지 기다린다
using System;
using System.ComponentModel; // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;
// 시작할 파일은 절대 경로로 넘긴다(이유는 Job Object 절을 참조)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false, // 리다이렉트를 쓰려면 필수
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
// 한쪽을 다 읽은 뒤 다른 쪽으로 하지 않는다. 둘 다 이벤트로 받는다
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stderr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine(); // 등록만 해서는 읽기 시작하지 않는다. 반드시 둘 다 호출한다
process.BeginErrorReadLine();
if (!process.WaitForExit(30_000))
{
// 여기는 "기다리기를 그만둔다"는 판단이지, cleanup의 대체가 아니다
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
{
// 30초 대기가 끝난 "직후"에 자식이 스스로 끝나는 경합이 있다.
// .NET에서는 종료 처리 중의 Kill이 Win32Exception("The process is
// terminating."), .NET Framework에서는 이미 종료된 Kill이
// InvalidOperationException이 된다.
// 이미 끝났다면 이것은 실패가 아니므로 삼키고 아래
// TimeoutException으로 진행한다. 아직 살아 있다면 정말로 멈추지 못했다는
// 뜻이므로 그대로 다시 던진다
if (!process.HasExited)
{
throw;
}
}
// AggregateException(자손 일부를 멈추지 못함)은 쥐지 않는다.
// 그것은 "트리가 정리되지 않았다"는 것 자체이므로 밖으로 낸다
// Kill은 종료를 요청하고 즉시 반환한다. 여기서 기다리지 않고 throw하면,
// using의 Dispose가 도는 시점에 아직 자식이 살아 있을 수 있어
// "타임아웃 예외가 나왔다=트리는 정리됐다"가 성립하지 않는다
process.WaitForExit();
throw new TimeoutException("helper.exe가 30초 안에 종료하지 않았습니다.");
}
// timeout이 있는 WaitForExit가 true를 반환해도, 비동기 출력 처리는 아직 끝나지 않았을 수 있다.
// 인수 없는 WaitForExit를 한 번 더 호출해 출력 읽기를 마칠 때까지 기다린다.
process.WaitForExit();
Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout : {stdout.Length} 문자");
Console.WriteLine($"stderr : {stderr.Length} 문자");
타임아웃 경계에는 반드시 경합이 있습니다. WaitForExit(30_000)이 false를 반환한 뒤 Kill을 호출하기까지의 짧은 사이에 자식이 스스로 끝날 수 있습니다. 그때 Kill은 성공하지 않습니다 ── .NET에서는 종료 처리 중에 Win32Exception(“The process is terminating.”), .NET Framework에서는 이미 종료된 대상에 InvalidOperationException이 됩니다. 여기를 그냥 통과시키면, 던져야 했던 TimeoutException 대신 뒷정리 실패가 튀어나옵니다. 호출 쪽은 “타임아웃했다”가 아니라 “알 수 없는 오류가 났다”를 받고, 마지막 WaitForExit()에 의한 출력 읽기도 건너뜁니다. 위처럼 HasExited로 “정말로 끝났는지”를 확인한 뒤에 삼킵니다. 아직 살아 있다면 멈추지 못했다는 뜻이므로 그대로 다시 던집니다. 또한 Kill(entireProcessTree: true)가 던지는 AggregateException(자손 일부를 멈추지 못함)은 쥐지 않습니다. 그것은 “트리가 정리되지 않았다”는 것 자체이고, 이 절이 막으려는 상태이기 때문입니다.
flowchart TB
accTitle: 타임아웃 경계의 경합
accDescr: WaitForExit가 false를 반환한 뒤 Kill을 호출하기까지의 짧은 사이에 자식이 스스로 끝날 수 있고, 그대로면 뒷정리 실패가 원래 TimeoutException을 덮으므로, HasExited로 정말로 끝났는지를 확인한 뒤 삼키고 아직 살아 있으면 다시 던짐을 나타내는 그림.
k1["대기가 끝난 직후 자식이 스스로 끝난다"] --> k2["Kill이 실패한다"]
k2 --> k3{"HasExited로 확인한다"}
k3 -->|"끝났다"| k4["실패가 아니므로 삼킨다"]
k3 -->|"아직 살아 있다"| k5["멈추지 못했으므로 다시 던진다"]
k4 --> k6["원래 TimeoutException을 던진다"]
그림13: 경계 경합에서는 Kill 실패가 진짜 실패인지 HasExited로 가립니다.
마지막 WaitForExit()는 깜빡 잊은 호출이 아니라 필수입니다.
WaitForExit(int) 문서에는 표준 출력을 비동기 이벤트 핸들러로 리다이렉트하는 경우, 이 오버로드가 반환한 시점에는 출력 처리가 완료되지 않았을 수 있다고 적혀 있고, true를 받은 뒤에 인수 없는 WaitForExit()를 호출하라고 안내합니다. 이것을 빼면 출력 끝부분만 빠지는, 재현하기 어려운 형태로 깨집니다.
6.2 stdin을 쓴다면 EOF까지 설계한다
stdin에 쓸 수 있는 것과 자식이 끝날 수 있는 것은 같지 않습니다.
- 입력을 쓴 뒤 close하지 않는다
- 부모는 ‘이미 넘겼다’고 생각한다
- 자식은 “아직 이어질 것”이라고 생각하고 계속 기다린다
라는 상태가 납니다. stdin을 쓴다면 쓰기를 마친 뒤 close해서 EOF를 전달하는 것까지 포함해 설계해야 합니다.
flowchart TB
accTitle: stdin은 EOF까지 설계한다
accDescr: stdin에 입력을 쓴 뒤 close하지 않으면 부모는 이미 넘겼다고 생각하고 자식은 아직 이어질 것이라고 기다려, 쓰기를 마친 뒤 close해서 EOF를 전달하는 것까지 포함해 설계해야 함을 나타내는 그림.
m1["입력을 쓴 뒤 close하지 않는다"] --> m2["부모는 '이미 넘겼다'고 생각한다"]
m1 --> m3["자식은 '아직 이어질 것'이라고 기다린다"]
m2 --> m4["쓰기를 마친 뒤 close해서 EOF를 전달한다"]
m3 --> m4
그림14: stdin은 쓸 수 있는 것이 아니라 EOF가 전달되는 것까지가 설계입니다.
6.3 쓰지 않는 pipe end는 반드시 닫는다
부모 쪽·자식 쪽의 미사용 end를 닫지 않으면 EOF가 전달되지 않아 종료 조건이 무너집니다. 단순하지만 실무에서 꽤 많은 사고입니다.
6.4 UseShellExecute=false와 handle 상속 다루기를 모호하게 두지 않기
표준 입출력 리다이렉트를 쓴다면 .NET에서는 UseShellExecute=false가 전제입니다.
Win32에서도 무엇을 상속시킬지를 가능한 한 좁히는 편이 안전합니다. bInheritHandles=TRUE 그대로 전부 상속시키면 예상 못한 handle leak의 원인이 됩니다.
7. watchdog은 “바깥”에 둔다
watchdog을 넣을 때 가장 중요한 것은 감시 대상과 같은 Job에 넣지 않는 것입니다. worker가 죽으면 재시작하고 싶은데, 그 재시작 역할까지 함께 죽으면 의미가 없습니다.
flowchart TB
accTitle: watchdog은 감시 대상 바깥에 둔다
accDescr: watchdog을 감시 대상과 같은 Job에 넣으면 worker가 죽어 정리할 때 재시작 역할까지 함께 죽으므로, watchdog은 감시 대상 Job 바깥에 두어야 함을 나타내는 그림.
n1["watchdog을 같은 Job에 넣는다"] --> n2["정리할 때 재시작 역할도 함께 죽는다"]
n2 -.->|"그래서"| n3["watchdog은 Job 바깥에 둔다"]
n3 --> n4["worker가 죽어도 재시작할 수 있다"]
그림15: 재시작 역할을 운명 공동체에 넣지 않는 것이 watchdog 배치의 첫째 조건입니다.
7.1 exit 감시는 wait handle 기반으로 한다
프로세스는 종료하면 signaled 상태가 됩니다.
그래서 exit 감시는 원래 polling loop로 HasExited를 100ms마다 볼 필요가 없습니다.
Win32라면
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
이 정공법입니다. 여러 child를 다룬다면 timer polling보다 wait handle 기반이 자연스럽습니다.
7.2 UI thread에서 무한 대기하지 않기
WaitForSingleObject(INFINITE)는 편리하지만, window를 가진 thread에서 쓰면 message pump를 멈추기 쉽습니다.
UI thread, COM apartment thread, message pump를 가진 thread에서는 대기 위치를 먼저 생각하는 편이 안전합니다.
flowchart TB
accTitle: exit 감시는 wait handle 기반으로
accDescr: 프로세스는 종료하면 signaled 상태가 되므로 exit 감시는 HasExited를 주기적으로 보는 polling이 아니라 wait handle 기반으로 하고, UI 스레드에서의 무한 대기는 message pump를 멈추므로 피해야 함을 나타내는 그림.
p1["HasExited를 주기적으로 polling한다"] -.-> p2["원래는 필요 없다"]
p3["종료로 signaled가 되는 handle을 기다린다"] --> p4["wait handle 기반 감시"]
p4 -.-> p5["UI 스레드에서의 무한 대기는 화면을 굳힌다"]
그림16: 종료 감지는 폴링이 아니라 wait handle에 맡깁니다.
7.3 hang watchdog에는 heartbeat가 필요하다
exit watchdog은 process handle로 충분합니다. 하지만 hang watchdog은 다릅니다.
- CPU 100%로 굳어 있다
- deadlock 상태다
- event loop는 살아 있지만 진척이 없다
- 입력 대기로 멈춰 있다
이런 상태는 “프로세스가 살아 있는지”만으로는 판정할 수 없습니다. 그래서 hang까지 보고 싶다면
- heartbeat
- progress sequence
- last successful work timestamp
- health probe
같은 앱 계층의 생존 확인이 필요합니다.
flowchart TB
accTitle: hang 감지에는 heartbeat가 필요하다
accDescr: CPU 100%로 굳어 있거나 deadlock이거나 진척이 없는 상태는 프로세스가 살아 있는지만으로는 판정할 수 없으므로, hang까지 보려면 heartbeat나 진척 등 앱 계층의 생존 확인이 필요함을 나타내는 그림.
q1["프로세스는 살아 있다"] --> q2["하지만 진행하지 않을 수 있다"]
q2 --> q3["exit 감시만으로는 판정할 수 없다"]
q3 -.->|"그래서"| q4["heartbeat나 진척 등 앱 계층 확인"]
그림17: “살아 있는지”와 “진행 중인지”는 다른 감시가 됩니다.
7.4 재시작 역할은 감시 대상 바깥에 둔다
실무에서 흔한 것은 이 두 패턴입니다.
- 부모 앱이 일시적으로 helper만 시작한다
- 부모가 Job을 들고, 부모 종료 시 helper tree를 회수한다
- 장시간 worker를 상주시키고, 죽으면 재시작하고 싶다
- 외부 watchdog process / service가 worker generation마다 Job을 만든다
후자에서는 worker tree와 restart authority를 분리하는 편이 설계가 안정됩니다.
7.5 restart policy는 budget으로 둔다
watchdog을 넣으면 다음은 crash loop가 시작됩니다.
- 즉시 재시작
- 또 즉시 죽는다
- 로그만 대량으로 나온다
이것을 피하려면
- backoff
- 일정 시간 안의 restart 횟수 상한
- 연속 실패 시에는 정지하고 알린다
라는 restart budget을 가진 편이 좋습니다.
flowchart TB
accTitle: restart budget으로 crash loop를 멈춘다
accDescr: 즉시 재시작해 또 즉시 죽는 crash loop를 피하려면 backoff, 일정 시간 안의 재시작 횟수 상한, 연속 실패 시에는 정지하고 알린다는 restart budget을 둠을 나타내는 그림.
r1["즉시 재시작→또 즉시 죽는다"] --> r2["crash loop와 로그 홍수"]
r2 -.->|"막으려면"| r3["backoff를 넣는다"]
r3 --> r4["일정 시간 안의 횟수 상한"]
r4 --> r5["연속 실패면 정지하고 알린다"]
그림18: 재시작은 예산으로 관리하고, 다 쓰면 멈춰 사람에게 알립니다.
8. 전형 패턴별 권장 구성
| 장면 | 권장 구성 |
|---|---|
| 데스크톱 앱이 단발 CLI helper를 시작한다 | 시작 1회 = Job 1개. KILL_ON_JOB_CLOSE를 붙이고 stdout / stderr를 병렬 drain. 취소 시에는 협조 종료 → timeout → Job kill |
| helper가 다시 손자 프로세스를 시작한다 | Job Object를 전제로 하고 breakaway를 허용하지 않는다. 시작 시부터 고정하고 싶다면 PROC_THREAD_ATTRIBUTE_JOB_LIST |
| service / watchdog이 장시간 worker tree를 감시한다 | watchdog은 외부 process / service. worker generation마다 Job을 만들고, exit handle + heartbeat로 감시 |
| console tool을 깔끔하게 멈추고 싶다 | CREATE_NEW_PROCESS_GROUP으로 시작하고 CTRL_BREAK_EVENT로 협조 종료. 그다음 timeout으로 Job kill |
| GUI helper를 닫고 싶다 | CloseMainWindow / WM_CLOSE 상당 → timeout → Job kill |
| 다수 자식 프로세스를 감시하고 싶다 | blocking thread를 늘리기보다 RegisterWaitForSingleObject / SetThreadpoolWait를 쓴다 |
여기서 가장 중요한 것은 graceful shutdown 메커니즘과 cleanup 메커니즘을 나누는 것입니다.
flowchart TB
accTitle: 요청하는 메커니즘과 정리하는 메커니즘
accDescr: 어느 전형 패턴에서도 가장 중요한 것은 close message나 종료 protocol 같은 graceful shutdown 메커니즘과 Job Object에 의한 cleanup 메커니즘을 따로 가지는 것임을 나타내는 그림.
s1["graceful shutdown 메커니즘"] --> s3["둘을 따로 가진다"]
s2["cleanup 메커니즘(Job)"] --> s3
s3 -.-> s4["정상 시에는 전자, 이상 시에는 후자가 동작한다"]
그림19: 어느 패턴에서도 요청 경로와 회수 경로는 따로 마련합니다.
9. 해서는 안 되는 일
각 장에서 다룬 주의점을 리뷰에서 그대로 쓸 수 있는 형태로 다시 정리합니다. “무엇이 일어나는가”와 “어디에 적혀 있는가”를 나란히 두었으므로, 걸린 행에서 본문으로 돌아갈 수 있습니다.
| 해서는 안 되는 일 | 무엇이 일어나는가 | 본문 |
|---|---|---|
Kill(entireProcessTree: true)만으로 graceful shutdown이나 부모 크래시 시 회수까지 풀렸다고 생각한다 |
명시적으로 멈출 때만 동작한다. 부모가 죽은 뒤의 회수와, 자식에게 뒷정리를 시키는 경로가 빠진다 | 5장 |
bInheritHandles=TRUE 그대로 전부 상속한다 |
의도하지 않은 handle이 자식으로 넘어가 handle leak과 EOF 미도달의 원인이 된다 | 6.4 |
stdout을 다 읽은 뒤 stderr를 읽는다 |
다른 쪽 pipe가 가득 차 부모는 읽기 대기, 자식은 쓰기 대기로 멈춘다 | 6.1 |
| pipe의 미사용 end를 닫지 않는다 | EOF가 전달되지 않아 읽기 쪽 종료 조건이 성립하지 않는다 | 6.3 |
UI thread에서 WaitForSingleObject(INFINITE)를 한다 |
message pump가 멈추고 화면과 COM이 굳는다 | 7.2 |
| watchdog을 감시 대상과 같은 Job에 넣는다 | 감시 대상을 정리할 때 재시작 역할까지 함께 사라진다 | 7장 |
| 259를 평범한 exit code로 쓴다 | GetExitCodeProcess는 실행 중에 STILL_ACTIVE, 즉 259를 반환한다. 자식이 259로 정상 종료하면, 끝났는데도 실행 중이라고 오판한다 |
7.1 |
| Job completion port 알림을 유일한 진실로 삼는다 | 알림은 감시나 집계용이며, 이것만으로 correctness를 짜면 놓친다 | 4.2 |
10. 정리
Windows 앱에서 자식 프로세스를 안전하게 다룰 때 가장 효과가 큰 것은 이 정리입니다.
누가 process tree를 소유하는가 어떻게 종료 요청을 전하는가 표준 입출력을 어떻게 흘려 마치는가 watchdog을 어디에 두는가
이 네 가지를 먼저 정합니다.
그 위에서 거칠게 말하면 이렇습니다.
- tree cleanup의 기준점은 Job Object
- graceful shutdown은 GUI / console / worker로 나눈다
- stdio는 병렬 drain과 EOF까지 포함해 설계한다
- watchdog은 감시 대상 바깥에 두고, polling이 아니라 wait handle과 heartbeat로 본다
CreateProcess나 Process.Start 자체는 입구에 지나지 않습니다.
사고율에 진짜로 영향을 주는 것은 종료 책임의 소재와 I/O를 흘려 마치는 것입니다.
flowchart TB
accTitle: 먼저 정할 네 가지
accDescr: 누가 process tree를 소유하는지, 어떻게 종료 요청을 전하는지, 표준 입출력을 어떻게 흘려 마치는지, watchdog을 어디에 두는지의 네 가지를 먼저 정하는 것이 자식 프로세스 사고율에 가장 크게 영향을 줌을 나타내는 그림.
t1["트리의 소유자"] --> t5["먼저 정한다"]
t2["종료 전하는 방법"] --> t5
t3["stdio 다루기"] --> t5
t4["감시 위치"] --> t5
t5 --> t6["시작 API는 입구에 지나지 않는다"]
그림20: 사고율에 영향을 주는 것은 이 네 가지를 시작보다 먼저 정하는 것입니다.
11. 참고 자료
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
spurious wakeup ── 조건 변수가 「알림 없이 깨어나는」 이유와 Windows에서 올바르게 기다리는 방법
조건 변수의 wait는 알림이 오지 않아도 깨어날 수 있습니다(spurious wakeup). 사양이 이를 허용하는 이유를 Windows 구현에서 밝히고, while과 predicate로 쓰는 올바른 대기 방법을 Win32·C++·C# 코드로 보...
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
MSMQ는 언제까지 쓸 수 있는가 ── 「deprecated조차 아닌」 레거시 큐의 마이그레이션 판단
MSMQ는 공식 deprecated 목록에 없는 한편, System.Messaging은 .NET Framework에만 있어 .NET으로의 이전을 가로막습니다. 폐지 소문과 실제 현재 위치를 사실로 정리하고, 계속 쓸지·옮길지의 판단 기준과 이전 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
외부 CLI, 변환 도구, worker, updater를 다루는 Windows 앱에서는 시작 방법보다 프로세스 트리 관리와 종료 설계가 안정성을 좌우합니다.
장애 조사 & 원인 분석
부모가 죽은 뒤 자식만 남거나, stdout이 막히거나, watchdog까지 함께 죽는 것처럼 재현하기 어려운 운영 장애는 프로세스 관리 설계를 다시 보면 개선하기 쉽습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 부모 프로세스가 종료됐는데 자식 프로세스가 남는 이유는 무엇인가요?
- process handle이나 process group만으로는, 부모가 크래시했을 때 프로세스 트리를 회수하는 구조가 없기 때문입니다. 부모의 생사와 자식 프로세스 트리의 수명을 묶고 싶다면 기준점은 Job Object입니다. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE를 붙이면, 마지막 job handle이 닫혔을 때 Job에 속한 모든 프로세스가 종료되므로, 부모의 이상 종료까지 포함해 cleanup을 부모의 수명으로 모을 수 있습니다.
- WaitForExit가 돌아오지 않는 이유는 무엇인가요?
- 표준 출력·표준 에러 pipe가 막혀 있을 가능성이 높습니다. Windows의 pipe는 무한 버퍼가 아니므로, 자식이 stderr에 대량 출력하는데 부모가 stdout만 읽고 있으면 자식은 write에서 멈추고 부모는 종료 대기에서 멈춥니다. stdout과 stderr는 병렬로 읽어 비우는 것이 기본이며, 한쪽을 다 읽은 뒤 다른 쪽을 읽는 구현은 막히기 쉽습니다. 또한 쓰지 않는 pipe end를 닫지 않으면 EOF가 전달되지 않아 종료 조건이 무너집니다.
- .NET의 Kill(entireProcessTree: true)만으로 충분하지 않나요?
- 충분하지 않습니다. 명시적으로 멈출 때는 편리하지만, 부모 크래시 시의 자동 회수나 graceful shutdown까지 포함한 설계의 대체는 되지 않습니다. 사고가 덜 나는 흐름은, 협조 종료를 요청하고 짧은 timeout으로 기다린 뒤 마지막에 Job 단위로 강제 종료하는 3단계입니다. 협조 종료 수단은 GUI 자식이면 CloseMainWindow, console 자식이면 CREATE_NEW_PROCESS_GROUP과 CTRL_BREAK_EVENT, worker면 stdin이나 pipe를 통한 종료 protocol처럼 자식 종류에 따라 나눕니다.
- watchdog 프로세스는 어디에 두어야 하나요?
- 감시 대상과 같은 Job에 넣지 않는 것이 가장 중요합니다. worker가 죽으면 재시작하고 싶은데 그 재시작 역할까지 함께 죽으면 의미가 없기 때문입니다. 장시간 worker를 상주시키는 경우에는, 외부 watchdog 프로세스나 서비스가 worker 세대마다 Job을 만드는 구성이 안정적입니다. exit 감시는 polling이 아니라 wait handle 기반으로 하고, hang 감지까지 필요하면 heartbeat 같은 앱 계층의 생존 확인을 함께 씁니다.