DMF_Crawler/docs/research/08-windows-scheduling-and-resilience.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

290 KiB
Raw Permalink Blame History

Windows 06:00 스케줄링 · 재부팅 내성 · 장애 알림 정본

이 문서의 역할: DMF_Crawler 를 Windows 11 PC 에서 매일 06:00 에 확실히 실행하고, 재부팅·절전·업데이트·실패를 견디며, 죽었을 때 사람이 알아채고 복구할 수 있게 만드는 운영 정본(SSOT) 이다. 이 문서 하나만 읽고 스케줄러 등록·워치독·알림·복구 절차 전부를 구현할 수 있어야 한다.


0. 한눈에 보기

이 문서에서 내린 결론:

  • 서비스(NSSM/WinSW/pywin32)로 만들지 마라. Windows 작업 스케줄러 작업 3개 조합이 정답이다. 하루 1회 배치 워크로드에 상주 서비스는 과잉이며, 서비스는 Session 0 격리 때문에 토스트 알림·헤드풀 브라우저·사용자 프로필 접근이 모두 깨진다. Microsoft 공식 문서가 "Services cannot directly interact with a user as of Windows Vista" 라고 못박고 있다.
  • 작업 3종 세트: ① DMF_Crawler_Daily (매일 06:00 + 시스템 시작 시 트리거 + StartWhenAvailable), ② DMF_Crawler_Watchdog (매일 07:00, heartbeat 검증 + 알림), ③ DMF_Crawler_Notify (알림 전용, 로그온한 사용자 세션에서 실행 — 토스트를 띄우기 위한 유일한 합법적 경로).
  • 주 작업의 principal 은 -LogonType S4U + -RunLevel Highest 를 기본으로 하라. S4U 는 비밀번호를 저장하지 않지만 "no password is stored by the system and there is no access to either the network or encrypted files" — 즉 네트워크 드라이브·UNC 경로·DPAPI 암호화 파일 접근이 막힌다. 크롤러가 keyring(Windows Credential Manager) 이나 EFS 파일에서 API 키를 읽는다면 S4U 는 실패하고 -LogonType Password + 저장된 암호로 가야 한다. 이 선택이 이 문서에서 가장 중요한 분기점이다.
  • WakeToRun + AllowStartIfOnBatteries + DontStopIfGoingOnBatteries + StartWhenAvailable 4종은 필수. 기본값이 각각 false / (배터리 시 시작 금지 DisallowStartIfOnBatteries=true) / (배터리 전환 시 중단 StopIfGoingOnBatteries=true) / false 라서, 손대지 않으면 06:00 에 안 돈다.
  • AtStartup 트리거는 Fast Startup(빠른 시작) 때문에 신뢰할 수 없다. 종료(Shutdown)는 실제로는 커널 세션 최대 절전이라 "시스템 시작 시" 트리거가 안 뜬다. StartWhenAvailable(놓친 작업 즉시 실행)을 진짜 안전망으로 삼고, 필요하면 powercfg /h off 로 Fast Startup 을 끈다(단, 재시작(Restart)에는 Fast Startup 이 적용되지 않는다).
  • 토스트 알림은 "안 뜰 수 있다"를 전제로 설계하라. 안 뜨는 조건: 로그온 전, Session 0(서비스), "로그온 여부에 관계없이 실행" 비대화형 세션, 집중 지원(방해 금지) 모드, AppId(AUMID) 미등록. 따라서 알림은 3단 폴백: ① BurntToast 토스트(버튼 2개: 로그 열기 / 재실행) → ② msg.exe * 세션 메시지 → ③ 웹훅(디스코드/텔레그램/슬랙) + healthchecks.io dead-man switch. 웹훅만이 PC 가 꺼져 있어도 사람에게 닿는다.
  • dead-man switch 를 반드시 붙여라. healthchecks.io 를 Period=24h / Grace=2h 로 설정하고, 06:00 작업 시작 시 /start, 성공 시 루트 URL, 실패 시 /fail 을 ping 한다. PC 가 통째로 꺼져 있거나 작업 스케줄러 자체가 죽어도 이것만은 알려준다.
  • heartbeat 는 파일 + 이벤트 로그 이중 기록. state\heartbeat.json 에 실행 ID·시작/종료 시각·건수·exit code 를 쓰고, 동시에 DMFCrawler 이벤트 소스로 Windows 이벤트 로그에 남긴다. 이벤트 로그에 남기면 "특정 이벤트가 기록될 때" 트리거로 즉시 알림 작업을 띄울 수 있다.
  • Write-EventLog/New-EventLog 은 PowerShell 7 에 없다. PowerShell 5.1(powershell.exe)을 쓰거나, PS7 에서는 [System.Diagnostics.EventLog]::WriteEntry(...) 를 직접 호출하라. New-WinEvent 는 ETW 전용이라 대체재가 아니다.
  • WSL2 cron / Docker Desktop 은 이 프로젝트에 부적합. WSL 은 배포판이 살아 있어야만 cron 이 돌고 "systemd services will NOT keep your WSL instance alive", Docker Desktop 은 "Start Docker Desktop when you sign in to your computer" — 즉 로그인 없이는 컨테이너가 안 뜬다. 둘 다 로그인 의존성이 추가되므로 채택하지 않는다.

1. 목차

# 섹션 무엇을 결정하는가
0 한눈에 보기 최종 권고 요약
2 아키텍처 결정: 서비스 vs 작업 스케줄러 실행 컨테이너 선택
3 작업 스케줄러 완전 명세 트리거·설정·principal 전 항목
4 보안 컨텍스트: S4U vs Password vs Interactive 자격증명 접근 가능 여부
5 PowerShell 작업 등록 완전 스크립트 실제 등록 코드
6 schtasks XML 정본 형상관리용 XML
7 서비스화 옵션 비교(NSSM/WinSW/pywin32/sc.exe) 왜 안 쓰는가
8 Session 0 격리 headless 브라우저·토스트 영향
9 워치독 · 헬스체크 · dead-man switch 죽었을 때 감지
10 이벤트 로그와 이벤트 트리거 작업 즉시 반응 알림
11 Windows 알림 전 방식 비교 토스트 라이브러리 선택
12 토스트가 안 뜨는 조건과 폴백 알림 신뢰성
13 복구 안내 메시지 설계 실제 문구·버튼 코드
14 재부팅 · 전원 · 시간대 물리 환경 대응
15 Windows Update 재부팅 회피 06:00 충돌 방지
16 WSL2 / Docker Desktop 옵션과 한계 대안 평가
17 로그 로테이션 · 실행 ID · 실패 스크린샷 사후 진단
18 실행 결과 코드 · 이벤트 ID 레퍼런스 트러블슈팅
19 agy(Antigravity CLI) headless 실행 통합 AI CLI 연동
20 최종 배치 절차 체크리스트 설치 순서
부록 A 출처 목록 전체 URL
부록 B 미해결 질문 실측 필요 항목

2. 아키텍처 결정: 서비스 vs 작업 스케줄러

2.1 워크로드 특성

특성 함의
실행 빈도 하루 1회 06:00 상주 프로세스 불필요
예상 실행 시간 수 분 ~ 수십 분 ExecutionTimeLimit 여유 필요
필요 리소스 네트워크, headless Chromium(Playwright), xlsx 쓰기, AI CLI(agy -p) 사용자 프로필·브라우저 캐시 경로 의존
실패 시 요구 사람이 즉시 인지 + 재실행 안내 대화형 세션 알림 필요
재부팅 내성 필수 부팅 트리거 + 놓친 작업 실행

2.2 결론 — 작업 스케줄러 + 워치독 작업 조합

권고: Windows 작업 스케줄러 작업 3개. 근거:

  1. 하루 1회 배치에 24시간 상주 서비스는 낭비이자 위험. 서비스는 항상 떠 있어야 하므로 메모리 누수·좀비 상태·재시작 폭풍 관리 비용이 붙는다. 작업 스케줄러는 실행하고 죽으면 끝이다.
  2. Session 0 격리가 치명적. 서비스로 만들면 Playwright 헤드풀 디버깅 불가, 토스트 알림 불가, %USERPROFILE%\AppData\Local\ms-playwright 브라우저 경로 불일치가 발생한다(→ §8).
  3. 작업 스케줄러가 이미 재시작·놓친 작업·전원 조건·부팅 트리거를 전부 내장한다. 서비스로 만들면 sc failure 로 일부를 흉내내야 하는데, sc failure프로세스가 죽었을 때만 동작하지 "정상 종료했지만 exit code 가 1" 인 경우는 잡지 못한다(그건 AppExit 같은 NSSM 확장이 필요).
  4. 서비스는 관리자 권한 설치·제거가 필요하고 형상관리가 어렵다. 작업 스케줄러 작업은 XML 하나로 export/import 되어 Git 에 올릴 수 있다.

단, 서비스가 정답인 경우도 명시한다: 상시 폴링(예: 1분마다 변경 감지)이 필요해지거나, 웹 대시보드를 24시간 띄워야 하면 그때 WinSW 로 서비스화하라(§7.2).

2.3 작업 3종 세트 설계

┌─────────────────────────────────────────────────────────────────┐
│ ① DMF_Crawler_Daily                                             │
│    트리거: 매일 06:00 (RandomDelay PT2M) + AtStartup (Delay PT3M)│
│    principal: S4U 또는 Password, RunLevel Highest               │
│    설정: StartWhenAvailable, WakeToRun, 배터리 무시,             │
│          RestartCount 3 / RestartInterval PT10M,                │
│          ExecutionTimeLimit PT2H, MultipleInstances IgnoreNew   │
│    액션: powershell.exe -File scripts\run-daily.ps1             │
│    → 성공 시 state\heartbeat.json 갱신 + 이벤트 ID 1000          │
│    → 실패 시 이벤트 ID 1001 + notify 큐 파일 작성                │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│ ② DMF_Crawler_Watchdog                                          │
│    트리거: 매일 07:00 (+ 시스템 시작 후 PT10M)                    │
│    principal: 동일                                              │
│    액션: powershell.exe -File scripts\watchdog.ps1              │
│    → heartbeat 가 오늘 것이 아니면 알림 큐 작성 + 웹훅 발사       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│ ③ DMF_Crawler_Notify                                            │
│    트리거: (a) 사용자 로그온 시, (b) 이벤트 로그 ID 1001 기록 시  │
│    principal: -LogonType Interactive (반드시!) , RunLevel Limited│
│    액션: powershell.exe -File scripts\notify.ps1                │
│    → 알림 큐 파일이 있으면 BurntToast 토스트 표시                 │
└─────────────────────────────────────────────────────────────────┘

핵심 설계 원칙: 알림을 띄우는 주체를 크롤러 작업에서 분리한다. 크롤러는 "알림 큐 파일"만 쓰고, 대화형 세션에서 도는 별도 작업이 그것을 읽어 토스트를 띄운다. 이것이 Microsoft 가 문서에서 권장하는 패턴("Create a separate hidden GUI application and use the CreateProcessAsUser function to run the application within the context of the interactive user ... communicate with the service through some method of interprocess communication (IPC)")의 작업 스케줄러 버전이다.


3. 작업 스케줄러 완전 명세

3.1 트리거 (New-ScheduledTaskTrigger)

New-ScheduledTaskTrigger 의 파라미터 세트는 5개다.

# Once (기본)
New-ScheduledTaskTrigger -At <DateTime> [-RandomDelay <TimeSpan>] [-Once]
    [-RepetitionDuration <TimeSpan>] [-RepetitionInterval <TimeSpan>]

# Daily
New-ScheduledTaskTrigger -At <DateTime> [-Daily] [-DaysInterval <UInt32>]
    [-RandomDelay <TimeSpan>]

# Weekly
New-ScheduledTaskTrigger -At <DateTime> [-RandomDelay <TimeSpan>]
    [-DaysOfWeek <DayOfWeek[]>] [-Weekly] [-WeeksInterval <UInt32>]

# Startup
New-ScheduledTaskTrigger [-RandomDelay <TimeSpan>] [-AtStartup]

# Logon
New-ScheduledTaskTrigger [-RandomDelay <TimeSpan>] [-AtLogOn] [-User <String>]
파라미터 타입 의미
-At DateTime 트리거 날짜/시각. calendar-based 트리거(Once, Daily, Weekly)에서만 유효하며 해당 세트에서 Mandatory.
-AtLogOn SwitchParameter 사용자가 로그온할 때 시작
-AtStartup SwitchParameter 시스템이 시작될 때 시작
-Daily SwitchParameter 매일 반복
-DaysInterval UInt32 일 간격. "An interval of 1 produces a daily schedule. An interval of 2 produces an every-other day schedule."
-DaysOfWeek DayOfWeek[] Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
-Once SwitchParameter -At 시각에 1회
-RandomDelay TimeSpan 트리거 시작 시각에 더할 랜덤 지연
-RepetitionInterval / -RepetitionDuration TimeSpan Once 세트에서 반복
-User String Logon 세트에서 특정 사용자
-Weekly / -WeeksInterval Switch / UInt32 주간 반복

공식 예제(원문 그대로):

# Example 1: Register a scheduled task that starts a task once
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stt = New-ScheduledTaskTrigger -Once -At 3am
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt

# Example 2: Register a scheduled task that starts every day
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stt = New-ScheduledTaskTrigger -Daily -At 3am
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt

# Example 3: Register a scheduled task that starts every 3 days
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stt = New-ScheduledTaskTrigger -Daily -DaysInterval 3 -At 3am
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt

# Example 4: Register a scheduled task that starts every-other week
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stt = New-ScheduledTaskTrigger -Weekly -WeeksInterval 2 -DaysOfWeek Sunday -At 3am
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt

# Example 5: Register a scheduled task that starts when a user logs on
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stt = New-ScheduledTaskTrigger -AtLogon
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt

한 작업에 트리거 여러 개를 붙일 수 있다. 공식 설명: "Each task can contain one or more triggers, which means there are many ways that you can start a task. If a task has multiple triggers, Task Scheduler starts the task when any of the triggers occur." → 06:00 Daily 와 AtStartup 을 배열로 함께 넘긴다.

부팅 트리거 지연(Delay): New-ScheduledTaskTrigger -AtStartup 에는 -Delay 파라미터가 없고 -RandomDelay 만 있다. 부팅 후 고정 지연을 원하면 XML 의 <BootTrigger><Delay>PT3M</Delay></BootTrigger> 를 직접 써야 한다. 스키마:

<xs:element name="Delay" type="duration" />

Delay (bootTriggerType): "Specifies the amount of time between when the system is booted and when the task is started. The format for this string is PnYnMnDTnHnMnS ... (for example, PT5M specifies 5 minutes and P1M4DT2H5M specifies one month, four days, two hours, and five minutes)." 기본값은 PT0M.

부팅 직후 네트워크 스택이 아직 안 올라온 경우를 대비해 PT3M 정도의 Delay + RunOnlyIfNetworkAvailable 조합을 쓴다(§14.6).

3.2 설정 (New-ScheduledTaskSettingsSet)

전체 구문(원문):

New-ScheduledTaskSettingsSet
    [-DisallowDemandStart]
    [-DisallowHardTerminate]
    [-Compatibility <CompatibilityEnum>]
    [-DeleteExpiredTaskAfter <TimeSpan>]
    [-AllowStartIfOnBatteries]
    [-Disable]
    [-MaintenanceExclusive]
    [-Hidden]
    [-RunOnlyIfIdle]
    [-IdleWaitTimeout <TimeSpan>]
    [-NetworkId <String>]
    [-NetworkName <String>]
    [-DisallowStartOnRemoteAppSession]
    [-MaintenancePeriod <TimeSpan>]
    [-MaintenanceDeadline <TimeSpan>]
    [-StartWhenAvailable]
    [-DontStopIfGoingOnBatteries]
    [-WakeToRun]
    [-IdleDuration <TimeSpan>]
    [-RestartOnIdle]
    [-DontStopOnIdleEnd]
    [-ExecutionTimeLimit <TimeSpan>]
    [-MultipleInstances <MultipleInstancesEnum>]
    [-Priority <Int32>]
    [-RestartCount <Int32>]
    [-RestartInterval <TimeSpan>]
    [-RunOnlyIfNetworkAvailable]
    [-CimSession <CimSession[]>]
    [-ThrottleLimit <Int32>]
    [-AsJob]
    [<CommonParameters>]

Description(원문): "The New-ScheduledTaskSettingsSet cmdlet creates an object that contains scheduled task settings. Each scheduled task has one set of task settings. Use this cmdlet to configure options to manage the behavior of the task upon completion, to manage the behavior of the task if a problem occurs, or to manage the behavior of the task if an instance of the task is already running."

주요 파라미터 상세:

파라미터 타입 설명 (공식 문구) DMF_Crawler 값
-AllowStartIfOnBatteries SwitchParameter "Indicates that Task Scheduler starts if the computer is running on battery power." 켠다
-DontStopIfGoingOnBatteries SwitchParameter 배터리로 전환되어도 작업을 중단하지 않음 켠다
-StartWhenAvailable SwitchParameter 예약 시각을 놓쳤을 때 가능한 한 빨리 실행 켠다
-WakeToRun SwitchParameter 작업 실행을 위해 컴퓨터를 절전에서 깨움 켠다
-ExecutionTimeLimit TimeSpan "specify if the task is not finished after one hour, it is considered as failed" / "Without the ExecutionTimeLimit setting defined, the time limit set to it's default of three days" PT2H
-MultipleInstances MultipleInstancesEnum 이미 실행 중일 때 정책 IgnoreNew
-RestartCount Int32 재시작 시도 횟수 3
-RestartInterval TimeSpan 재시작 간격 PT10M
-RunOnlyIfNetworkAvailable SwitchParameter 네트워크 사용 가능할 때만 실행 켠다(부팅 트리거 대비)
-Priority Int32 우선순위(기본 7). 예제: New-ScheduledTaskSettingsSet -Priority 5 5
-Compatibility CompatibilityEnum 허용값: At, V1, Vista, Win7, Win8 Win8
-Hidden SwitchParameter UI 에서 숨김
-DisallowDemandStart SwitchParameter "Indicates that the task cannot be started by using either the Run command or the Context menu." (수동 재실행이 필요하므로)
-DisallowHardTerminate SwitchParameter 강제 종료 금지
-DeleteExpiredTaskAfter TimeSpan 만료 후 삭제 대기 시간 미지정
-RunOnlyIfIdle / -IdleDuration / -IdleWaitTimeout Switch / TimeSpan 유휴 조건. 예제: -RunOnlyIfIdle -IdleDuration 00:02:00 -IdleWaitTimeout 02:30:00 (06:00 에 유휴 대기하면 안 됨)
-DontStopOnIdleEnd SwitchParameter 유휴 종료 시에도 중단 안 함 켬(안전)
-NetworkId / -NetworkName String RunOnlyIfNetworkAvailable 시 확인할 네트워크 프로필 미지정(아무 네트워크)
-DisallowStartOnRemoteAppSession SwitchParameter RAIL 세션에서 시작 금지
-MaintenanceExclusive / -MaintenancePeriod / -MaintenanceDeadline Switch / TimeSpan 자동 유지 관리 창에서 실행 미사용

재시작 설정 공식 예제(원문):

PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$Stset = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 60)
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Settings $Stset

"The second command creates scheduled task settings that specify that Task Scheduler attempts three restarts of the task at sixty minute intervals."

RestartInterval 제약(XSD 실측)restartType 복합 타입:

<xs:complexType name="restartType">
    <xs:all>
        <xs:element name="Interval">
            <xs:simpleType>
                <xs:restriction base="duration">
                    <xs:minInclusive value="PT1M" />
                    <xs:maxInclusive value="P31D" />
                </xs:restriction>
            </xs:simpleType>
        </xs:element>
        <xs:element name="Count">
            <xs:simpleType>
                <xs:restriction base="unsignedByte">
                    <xs:minInclusive value="1" />
                </xs:restriction>
            </xs:simpleType>
        </xs:element>
    </xs:all>
</xs:complexType>

IntervalPT1M 이상 P31D 이하, CountunsignedByte(1~255). PT10M / 3 은 유효.

ExecutionTimeLimit 정의(원문):

"Amount of time allowed to complete the task. The format for this string is PnYnMnDTnHnMnS ... A value of PT0S will enable the task to run indefinitely."

→ 무제한을 원하면 PT0S. 우리는 폭주 방지를 위해 PT2H 를 쓴다.

3.3 settingsType XSD 전문과 기본값

이것이 XML 로 작업을 정의할 때의 정본이다. 기본값을 모르면 "왜 06:00 에 안 도는지" 를 영원히 못 찾는다.

<xs:complexType name="settingsType">
    <xs:all>
        <xs:element name="AllowStartOnDemand"              type="boolean" default="true"        minOccurs="0" />
        <xs:element name="RestartOnFailure"                type="restartType"                   minOccurs="0" />
        <xs:element name="MultipleInstancesPolicy"         type="multipleInstancesPolicyType" default="IgnoreNew" minOccurs="0" />
        <xs:element name="DisallowStartIfOnBatteries"      type="boolean" default="true"        minOccurs="0" />
        <xs:element name="StopIfGoingOnBatteries"          type="boolean" default="true"        minOccurs="0" />
        <xs:element name="AllowHardTerminate"              type="boolean" default="true"        minOccurs="0" />
        <xs:element name="StartWhenAvailable"              type="boolean" default="false"       minOccurs="0" />
        <xs:element name="NetworkProfileName"              type="string"                        minOccurs="0" />
        <xs:element name="RunOnlyIfNetworkAvailable"       type="boolean" default="false"       minOccurs="0" />
        <xs:element name="WakeToRun"                       type="boolean" default="false"       minOccurs="0" />
        <xs:element name="Enabled"                         type="boolean" default="true"        minOccurs="0" />
        <xs:element name="Hidden"                          type="boolean" default="false"       minOccurs="0" />
        <xs:element name="DeleteExpiredTaskAfter"          type="duration" default="PT0S"       minOccurs="0" />
        <xs:element name="IdleSettings"                    type="idleSettingsType"              minOccurs="0" />
        <xs:element name="NetworkSettings"                 type="networkSettingsType"           minOccurs="0" />
        <xs:element name="ExecutionTimeLimit"              type="duration"                      minOccurs="0" />
        <xs:element name="Priority"                        type="priorityType" default="7"      minOccurs="0" />
        <xs:element name="RunOnlyIfIdle"                   type="boolean" default="false"       minOccurs="0" />
        <xs:element name="UseUnifiedSchedulingEngine"      type="boolean" default="false"       minOccurs="0" />
        <xs:element name="DisallowStartOnRemoteAppSession" type="boolean" default="false"       minOccurs="0" />
    </xs:all>
</xs:complexType>

자식 요소 설명(공식 표 원문 번역·병기):

요소 타입 설명
AllowHardTerminate boolean "Specifies if the Task Scheduler service allows hard termination of the task."
AllowStartOnDemand boolean "Specifies that the task can be started by using either the Run command or the Context menu."
DeleteExpiredTaskAfter duration "Specifies the amount of time that the Task Scheduler will wait before deleting the task after it expires. If no value is specified for this element, then the Task Scheduler service will not delete the task."
DisallowStartIfOnBatteries boolean "Specifies that the task will not be started if the computer is running on battery power." 기본 true — 반드시 false 로 바꿀 것
DisallowStartOnRemoteAppSession boolean "Specifies that the task should not start if the task is triggered to run in a Remote Applications Integrated Locally (RAIL) session."
Enabled boolean "Specifies that the task is enabled. The task can be performed only when this setting is True."
ExecutionTimeLimit duration "Specifies the amount of time allowed to complete the task."
Hidden boolean "Specifies, by default, that the task will not be visible in the user interface (UI)."
IdleSettings idleSettingsType "Specifies how the Task Scheduler performs tasks when the computer is in an idle state."
MultipleInstancesPolicy multipleInstancesPolicyType "Specifies the policy that defines how the Task Scheduler deals with multiple instances of the task."
NetworkProfileName string "Specifies the name of a network profile. The Task Scheduler service verifies the availability of this network when the RunOnlyIfNetworkAvailable element is set to True. The name is used for display purposes."
NetworkSettings networkSettingsType "Specifies the settings that the Task Scheduler service uses to obtain a network profile. The Task Scheduler service checks the availability of this network when the RunOnlyIfNetworkAvailable element is set to True."
Priority priorityType "Specifies the priority level for the task."
RestartOnFailure restartType "Specifies that the Task Scheduler will attempt to restart the task if it fails for any reason."
RunOnlyIfIdle boolean "Specifies that the task is run only when the computer is in an idle state."
RunOnlyIfNetworkAvailable boolean "Specifies that the Task Scheduler will run the task only when a network is available."
StartWhenAvailable boolean "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed."
StopIfGoingOnBatteries boolean "Specifies that the task will be stopped if the computer switches to battery power." 기본 true — false 로
UseUnifiedSchedulingEngine boolean "Specifies that the task is run by using the Unified Scheduling Engine."
WakeToRun boolean "Specifies that Task Scheduler will wake the computer before it runs the task."

NetworkSettings 요소 XSD:

<xs:element name="NetworkSettings" type="networkSettingsType" minOccurs="0" />

"Contains the settings that the Task Scheduler service uses to obtain a network profile. The Task Scheduler service checks the availability of this network when the RunOnlyIfNetworkAvailable element is set to True."

요구사항: Minimum supported client — Windows Vista [desktop apps only] / Minimum supported server — Windows Server 2008 [desktop apps only].

3.4 다중 인스턴스 정책 (MultipleInstances)

multipleInstancesPolicyType 기본값은 IgnoreNew. 선택지와 의미:

동작 DMF_Crawler 적합성
IgnoreNew (기본) 이미 실행 중이면 새 인스턴스 무시 채택 — 06:00 작업이 아직 돌고 있는데 부팅 트리거로 또 뜨는 사고 방지
Parallel 병렬 실행 xlsx 파일 경합 발생
Queue 대기 후 순차 실행 △ 이론상 가능하나 2시간 뒤 실행돼도 의미 없음
StopExisting 기존 인스턴스 중단 후 새로 시작 크롤링 중간에 끊김

관련 이벤트 ID: 322 NewInstanceIgnored ("Task instance already running"), 324 NewInstanceQueued, 323 RunningInstanceStopped.

3.5 작업 기록(History) 활성화

Microsoft-Windows-TaskScheduler/Operational 채널은 기본적으로 비활성화되어 있다. 이 채널이 꺼져 있으면 작업 스케줄러 UI 의 "기록(History)" 탭이 비어 있고, 이벤트 트리거 작업도 동작하지 않는다.

# 관리자 PowerShell — 작업 기록 켜기
wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet

# 최대 크기 64MB 로 확대(기본 1MB 라 며칠이면 덮어씀)
wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /maxsize:67108864

# 상태 확인
wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"

GUI 경로: 작업 스케줄러 → 우측 작업(Action) 창 → 모든 작업 기록 사용(Enable All Tasks History).


4. 보안 컨텍스트: S4U vs Password vs Interactive — 이 문서에서 가장 중요한 결정

4.1 TASK_LOGON_TYPE 전체 표 (공식 원문)

Value Meaning
TASK_LOGON_NONE — 0 The logon method is not specified. Used for non-NT credentials.
TASK_LOGON_PASSWORD — 1 Use a password for logging on the user. The password must be supplied at registration time.
TASK_LOGON_S4U — 2 Use an existing interactive token to run a task. The user must log on using a service for user (S4U) logon. When an S4U logon is used, no password is stored by the system and there is no access to either the network or encrypted files.
TASK_LOGON_INTERACTIVE_TOKEN — 3 User must already be logged on. The task will be run only in an existing interactive session.
TASK_LOGON_GROUP — 4 Group activation. The userId field specifies the group.
TASK_LOGON_SERVICE_ACCOUNT — 5 Indicates that a Local System, Local Service, or Network Service account is being used as a security context to run the task.
TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD — 6 First use the interactive token. If the user is not logged on (no interactive token is available), then the password is used. The password must be specified when a task is registered. This flag is not recommended for new tasks because it is less reliable than TASK_LOGON_PASSWORD.

Remarks(원문):

  • "This property is valid only when a user identifier is specified by the UserId property."
  • "When reading or writing XML for a task, the logon type is specified in the <LogonType> element of the Task Scheduler schema."
  • "For a task, that contains a message box action, the message box will be displayed if the task is activated and the task has an interactive logon type. To set the task logon type to interactive, specify 3 (TASK_LOGON_INTERACTIVE_TOKEN) or 4 (TASK_LOGON_GROUP) in the LogonType property of the task principal, or in the logonType parameter of TaskFolder.RegisterTask or TaskFolder.RegisterTaskDefinition."

PowerShell New-ScheduledTaskPrincipal -LogonType 허용값: None, Password, S4U, Interactive, Group, ServiceAccount, InteractiveOrPassword.

4.2 New-ScheduledTaskPrincipal 전체 명세

# User (기본)
New-ScheduledTaskPrincipal
    [[-Id] <String>]
    [[-RunLevel] <RunLevelEnum>]
    [[-ProcessTokenSidType] <ProcessTokenSidTypeEnum>]
    [[-RequiredPrivilege] <String[]>]
    [-UserId] <String>
    [[-LogonType] <LogonTypeEnum>]
    [-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]

# Group
New-ScheduledTaskPrincipal
    [-GroupId] <String>
    [[-Id] <String>] [[-RunLevel] <RunLevelEnum>]
    [[-ProcessTokenSidType] <ProcessTokenSidTypeEnum>]
    [[-RequiredPrivilege] <String[]>]
    [-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]
파라미터 설명
-UserId String (User 세트에서 Mandatory) "Specifies the user ID that Task Scheduler uses to run the tasks that are associated with the principal."
-GroupId String (Group 세트에서 Mandatory) "Specifies the ID of a user group that Task Scheduler uses to run the tasks that are associated with the principal."
-LogonType None / Password / S4U / Interactive / Group / ServiceAccount / InteractiveOrPassword 위 표 참조
-RunLevel Limited, Highest "Highest. Tasks run by using the highest privileges." / "Limited. Tasks run by using the least-privileged user account (LUA)."
-ProcessTokenSidType None, Unrestricted, Default "Specifies the security ID (SID) type of the process token."
-RequiredPrivilege String[] "Specifies an array of user rights that Task Scheduler uses to run the tasks that are associated with the principal. Specify the constant name that is associated with a user right."
-Id String "Specifies the ID of a scheduled task principal." (XML 의 <Principal id="...">)

공식 예제(원문):

# Example 1: Local Service 계정 + ServiceAccount 로그온
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
PS C:\>$STPrin = New-ScheduledTaskPrincipal -UserId "LOCALSERVICE" -LogonType ServiceAccount
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Principal $STPrin

# Example 2: Administrators 그룹 + 최고 권한
PS C:\>$Sta = New-ScheduledTaskAction cmd
PS C:\>$STPrin = New-ScheduledTaskPrincipal -GroupId "BUILTIN\Administrators" -RunLevel Highest
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Principal $STPrin

4.3 S4U 의 치명적 제약과 우리 프로젝트에 대한 영향

공식 문장을 다시 인용한다: "When an S4U logon is used, no password is stored by the system and there is no access to either the network or encrypted files."

크롤러가 하는 일 S4U 에서 되는가 비고
HTTPS 로 nedrug.mfds.go.kr 크롤링 된다 여기서 "network" 는 네트워크 자원 인증(SMB/UNC/Kerberos 위임) 을 뜻한다. 아웃바운드 TCP/HTTP 는 정상 동작
로컬 디스크(D:\workspace\DMF_Crawler) 읽기/쓰기 된다
UNC 경로 \\NAS\reports 에 xlsx 저장 안 된다 네트워크 자격증명 없음
매핑된 네트워크 드라이브(Z:\) 안 된다 비대화형 세션에는 드라이브 매핑 자체가 없음
EFS 로 암호화된 파일 읽기 안 된다 "encrypted files" 로 명시
Windows Credential Manager / python keyring (DPAPI 사용자 키) ⚠️ 불확실 — 실측 필요 DPAPI 사용자 마스터 키는 로그온 시 암호에서 파생된다. S4U 토큰은 대화형 토큰을 흉내내지만 암호를 모르므로 DPAPI 복호화 실패 가능성이 높다. keyring(Windows 백엔드 = Credential Manager)을 쓴다면 반드시 실측하라 → 부록 B-1
환경변수 %USERPROFILE% 확장 된다 다만 프로필 미로드 시 HKCU 접근 실패 가능
HKCU 레지스트리 읽기/쓰기 ⚠️ 불안정 비대화형 세션에서 사용자 하이브가 로드되지 않을 수 있음
토스트 알림 표시 절대 안 된다 대화형 데스크톱이 없음 (→ §12)

4.4 결정 트리 — 어느 LogonType 을 쓸 것인가

크롤러가 API 키/비밀번호를 어디서 읽는가?
│
├─ .env 파일 또는 평문/커스텀 암호화 파일 (로컬 디스크)
│   └─ ✅ S4U 사용. 비밀번호 저장 불필요, 가장 안전.
│      New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
│          -LogonType S4U -RunLevel Highest
│
├─ Windows Credential Manager / python keyring / DPAPI
│   └─ ⚠️ 먼저 S4U 로 실측(부록 B-1). 실패하면 ↓
│
├─ UNC/네트워크 드라이브에 결과물 저장, 또는 위 실측 실패
│   └─ ✅ Password 사용. 계정 암호를 Task Scheduler 자격증명 저장소에 저장.
│      Register-ScheduledTask -User "DOMAIN\user" -Password "..." -RunLevel Highest
│      (LogonType 은 자동으로 Password 가 됨)
│      ⚠️ 계정 암호를 바꾸면 작업이 깨진다 → 변경 절차를 운영 문서에 명시
│
└─ 알림 표시 전용 작업 (DMF_Crawler_Notify)
    └─ ✅ Interactive 사용. 반드시.
       New-ScheduledTaskPrincipal -UserId "..." -LogonType Interactive -RunLevel Limited

4.5 Log on as a batch job 권한 — 잊으면 조용히 실패한다

공식 문서(Security Contexts for Tasks) 원문:

"Tasks registered with the TASK_LOGON_PASSWORD or TASK_LOGON_S4U flag will only launch if the specified user has the Logon as Batch privilege enabled. Administrators and Backup Operators group users have this privilege enabled by default."

S4U 든 Password 든 SeBatchLogonRight 가 필요하다. Administrators 그룹 멤버면 기본 보유. 아니면:

secpol.msc → 로컬 정책 → 사용자 권한 할당 →
"배치 작업으로 로그온(Log on as a batch job)" 에 계정 추가

권한이 없으면 등록은 되지만 실행에 실패하고, 등록 시점에 SCHED_S_BATCH_LOGON_PROBLEM (0x0004131C) — "The task is registered, but may fail to start. Batch logon privilege needs to be enabled for the task principal." 가 반환된다.

4.6 UAC / RunLevel 규칙 (공식 원문 요약)

  • "By default, a task runs with low level privileges when UAC is turned on."
  • "If a task's actions must have elevated privileges to run, then you must set the RunLevel property to TASK_RUNLEVEL_HIGHEST."
  • "If a task is registered using the Administrators group for the security context of the task, then you must also set the RunLevel property to TASK_RUNLEVEL_HIGHEST if you want to run the task."
  • "If a task is registered using the Builtin\Administrator account or the Local System or Local Service accounts, then the RunLevel property will be ignored." (UAC 가 꺼져 있어도 무시된다.)
  • "From a low privilege process, you cannot register a task with the RunLevel property equal to TASK_RUNLEVEL_HIGHEST, but you can register a task with the RunLevel property equal to TASK_RUNLEVEL_LUA." → 작업 등록 스크립트는 반드시 관리자 권한 PowerShell 에서 실행해야 한다.
  • "You are not allowed to register the task as Builtin/Administrator, Local System, or for a group" (저권한 프로세스에서).
  • "The value of the RunLevel property doesn't affect the permissions needed to run or delete a task."

4.7 등록 시 암호가 필요한 경우 (공식 원문)

Administrators 그룹 멤버 계정으로 등록할 때 암호가 필요한 상황:

  • "If you register the task to run under the security context of your account or a different user's account and you use the TASK_LOGON_PASSWORD flag in the RegisterTask or RegisterTaskDefinition method."
  • "If you register the task to run under the security context of a different user's account and you use the TASK_LOGON_S4U flag."

자기 자신 계정 + S4U 로 등록하면 암호 입력이 필요 없다. 이것이 S4U 를 선호하는 실무적 이유.

또한: "You cannot use a user group as the security context of a task when you register the task using the TASK_LOGON_S4U flag or the TASK_LOGON_PASSWORD flag."

비관리자 계정에서 등록할 때: "you do not need to specify a password when registering the task if you register the task to run under the security context of your account and you use the S4U or interactive logon type. Otherwise, you need to specify a password ... Also, you cannot register the task using the Local Service account or by using a group for the task's security context."

4.8 작업 읽기/수정/삭제/실행 권한 (공식 원문 요약)

  • "By default, a user who creates a task can read, update, delete, and run the task."
  • "Members of the Administrators group or the SYSTEM account can read, update, delete, and run any tasks."
  • "Members of the Users group, the LocalService account, and the NetworkService account can only read, update, delete, and run the tasks that they have created."
  • "A user must have WriteDAC permission in addition to the read/write permissions to update a task if the task update requires a change to the DACL for the task."

4.9 "Run whether user is logged on or not" 이 깨뜨리는 것들

실제 사례에서 반복 확인된 실패 패턴:

증상 원인 해결
exit 0x1, 로그 없음 상대 경로 사용, 작업 디렉터리 미지정 -WorkingDirectory (XML 의 <WorkingDirectory>) 명시 + 스크립트 내 전 경로 절대경로화
매핑된 드라이브 Z:\ 없음 비대화형 세션에 드라이브 매핑 없음 UNC 절대경로 + Password 로그온, 또는 로컬 경로로 변경
HKCU 값이 비어 있음 사용자 하이브 미로드 설정을 HKLM 또는 파일로 이전
작업이 계속 "큐에 대기 중(Queued)" 로그온한 사용자가 한 번도 없는 상태 + Interactive 토큰 Password 또는 S4U 로 전환
Playwright 브라우저 못 찾음 %USERPROFILE%\AppData\Local\ms-playwright 가 다른 계정 프로필 PLAYWRIGHT_BROWSERS_PATH 를 고정 경로로(§8.3)
토스트가 안 뜸 비대화형 세션 별도 Interactive 작업으로 분리(§12)
GUI 앱이 실행은 되는데 창이 없음 "the app was in fact running, but its window was nowhere to be seen because it runs in a non-interactive session" 설계상 불가. 분리 필수

권고 조치(공식 답변에서 정리):

  1. 작업 기록(History) 활성화 — Action pane → Enable All Tasks History
  2. 기록 탭에서 예약 시각 이후 launch failure 확인
  3. 보안 이벤트 로그에서 해당 계정 관련 이벤트 확인
  4. "Log on as a batch job" 권한 확인
  5. stdout/stderr 캡처 활성화 (§17)
  6. 절대 경로 사용 / Start in 폴더 명시 / 작업 암호 저장 / UNC 는 명시적 자격증명 사용

5. PowerShell 작업 등록 완전 스크립트

5.1 Register-ScheduledTask 파라미터 세트

# User (기본)
Register-ScheduledTask [[-Password] <String>] [[-User] <String>] [-TaskName] <String>
    [[-TaskPath] <String>] [-Action] <CimInstance[]> [[-Description] <String>]
    [[-Settings] <CimInstance>] [[-Trigger] <CimInstance[]>] [[-RunLevel] <RunLevelEnum>]
    [-Force] [-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]

# Xml
Register-ScheduledTask [[-Password] <String>] [[-User] <String>] [-TaskName] <String>
    [[-TaskPath] <String>] [-Xml] <String> [-Force] [-CimSession <CimSession[]>]
    [-ThrottleLimit <Int32>] [-AsJob]

# Principal
Register-ScheduledTask [-TaskName] <String> [[-TaskPath] <String>] [[-Principal] <CimInstance>]
    [-Action] <CimInstance[]> [[-Description] <String>] [[-Settings] <CimInstance>]
    [[-Trigger] <CimInstance[]>] [-Force] [-CimSession <CimSession[]>]
    [-ThrottleLimit <Int32>] [-AsJob]

# Object
Register-ScheduledTask [-InputObject] <CimInstance> [[-Password] <String>] [[-User] <String>]
    [[-TaskName] <String>] [[-TaskPath] <String>] [-Force] [-CimSession <CimSession[]>]
    [-ThrottleLimit <Int32>] [-AsJob]

Description(원문): "You can register a task to run executable files (.exe and .com), batch files (.bat and .cmd), or any registered file type. However, this cmdlet does not check whether the file you intend it to run is compatible with your version, edition, or platform specialization of Windows."

주요 파라미터 원문:

  • -Action: "Specifies an array of one or more work items for the task to run. If you specify multiple actions, the computer runs them in order. You can specify up to 32 actions."
  • -Password: "Specifies a password for the user account in the context of which the task runs. The password is ignored for the well-known system accounts. Well-known accounts are: NT AUTHORITY\SYSTEM, NT AUTHORITY\LOCALSERVICE, NT AUTHORITY\NETWORKSERVICE, and the well-known security identifiers (SIDs) for all three accounts."
  • -Principal: "Specifies the security context in which a task is run."
  • -RunLevel: "Specifies the required privilege level to run tasks that are associated with the principal." (Limited, Highest)
  • -Settings: "Specifies a configuration that the Task Scheduler service uses to determine how to run a task."
  • -Description: "Briefly describes the task."
  • -TaskName: "Specifies the name of a scheduled task."
  • -TaskPath: "Specifies an array of one or more paths for scheduled tasks in Task Scheduler namespace. You can use \* for a wildcard character query. You can use \ for the root folder. To specify a full TaskPath you need to include the leading and trailing \. If you do not specify a path, the cmdlet uses the root folder."
  • -Force: "Instructs the cmdlet to perform the operation without prompting for confirmation."
  • -InputObject: "Specifies the input object that is used in a pipeline command."

공식 예제(원문):

PS C:\> $Time = New-ScheduledTaskTrigger -At 12:00 -Once
PS C:\> $User = "Contoso\Administrator"
PS C:\> $PS = New-ScheduledTaskAction -Execute "PowerShell.exe"
PS C:\> Register-ScheduledTask -TaskName "SoftwareScan" -Trigger $Time -User $User -Action $PS

5.2 스크립트 1 — scripts\register-tasks.ps1 (완결)

관리자 권한 PowerShell 에서 실행. 저권한 프로세스에서는 RunLevel Highest 등록이 거부된다(§4.6).

#Requires -RunAsAdministrator
<#
.SYNOPSIS
    DMF_Crawler 의 Windows 작업 스케줄러 작업 3종을 등록한다.
.DESCRIPTION
    (1) DMF_Crawler_Daily     : 매일 06:00 + 부팅 시. 크롤링 본체.
    (2) DMF_Crawler_Watchdog  : 매일 07:00 + 부팅 후 10분. heartbeat 검증.
    (3) DMF_Crawler_Notify    : 로그온 시 + 실패 이벤트 시. 토스트 알림(대화형).
.PARAMETER LogonMode
    'S4U'      : 암호 저장 안 함. 네트워크 자원/EFS/DPAPI 접근 불가.
    'Password' : 계정 암호를 저장. 네트워크 자원 접근 가능.
.EXAMPLE
    .\register-tasks.ps1 -LogonMode S4U
    .\register-tasks.ps1 -LogonMode Password
#>
[CmdletBinding()]
param(
    [ValidateSet('S4U', 'Password')]
    [string]$LogonMode = 'S4U',

    [string]$Root = 'D:\workspace\DMF_Crawler',

    [string]$TaskFolder = '\DMF_Crawler\',

    [string]$RunAsUser = "$env:USERDOMAIN\$env:USERNAME"
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# ---------------------------------------------------------------- 준비
$Scripts = Join-Path $Root 'scripts'
$Logs    = Join-Path $Root 'logs'
$State   = Join-Path $Root 'state'
$Ops     = Join-Path $Root 'ops'

foreach ($d in @($Scripts, $Logs, $State, $Ops)) {
    if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

# PowerShell 5.1 실행 파일을 명시적으로 사용한다.
# 이유: Write-EventLog / New-EventLog 가 PowerShell 7 에 없다(§10).
$PwshExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
if (-not (Test-Path $PwshExe)) { throw "powershell.exe 를 찾을 수 없습니다: $PwshExe" }

Write-Host "[*] Root       : $Root"
Write-Host "[*] LogonMode  : $LogonMode"
Write-Host "[*] RunAsUser  : $RunAsUser"
Write-Host "[*] TaskFolder : $TaskFolder"

# 작업 기록(History) 채널 활성화 + 크기 확대 (기본 비활성, 기본 1MB)
Write-Host "[*] TaskScheduler/Operational 채널 활성화..."
& wevtutil.exe set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet
& wevtutil.exe set-log "Microsoft-Windows-TaskScheduler/Operational" /maxsize:67108864

# 이벤트 로그 소스 등록 (§10). 관리자 권한 필요.
if (-not [System.Diagnostics.EventLog]::SourceExists('DMFCrawler')) {
    Write-Host "[*] 이벤트 소스 'DMFCrawler' 등록 (Application 로그)..."
    New-EventLog -LogName 'Application' -Source 'DMFCrawler'
} else {
    Write-Host "[=] 이벤트 소스 'DMFCrawler' 이미 존재"
}

# ---------------------------------------------------------------- 공통 설정
$CommonSettings = New-ScheduledTaskSettingsSet `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -StartWhenAvailable `
    -DontStopOnIdleEnd `
    -WakeToRun `
    -RunOnlyIfNetworkAvailable `
    -MultipleInstances IgnoreNew `
    -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
    -RestartCount 3 `
    -RestartInterval (New-TimeSpan -Minutes 10) `
    -Priority 5 `
    -Compatibility Win8

# ---------------------------------------------------------------- principal
function New-DmfPrincipal {
    param([ValidateSet('Batch','Interactive')][string]$Kind)

    if ($Kind -eq 'Interactive') {
        return New-ScheduledTaskPrincipal -UserId $RunAsUser `
                                          -LogonType Interactive `
                                          -RunLevel Limited
    }
    if ($LogonMode -eq 'S4U') {
        return New-ScheduledTaskPrincipal -UserId $RunAsUser `
                                          -LogonType S4U `
                                          -RunLevel Highest
    }
    # Password 모드는 Register-ScheduledTask -User/-Password 경로를 쓴다.
    return $null
}

# ---------------------------------------------------------------- (1) Daily
$DailyAction = New-ScheduledTaskAction `
    -Execute $PwshExe `
    -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$Scripts\run-daily.ps1`"" `
    -WorkingDirectory $Root

$DailyTriggers = @(
    (New-ScheduledTaskTrigger -Daily -At '06:00' -RandomDelay (New-TimeSpan -Minutes 2)),
    (New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes 3))
)

# ---------------------------------------------------------------- (2) Watchdog
$WatchdogAction = New-ScheduledTaskAction `
    -Execute $PwshExe `
    -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$Scripts\watchdog.ps1`"" `
    -WorkingDirectory $Root

$WatchdogTriggers = @(
    (New-ScheduledTaskTrigger -Daily -At '07:00'),
    (New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes 10))
)

# ---------------------------------------------------------------- (3) Notify (대화형)
$NotifyAction = New-ScheduledTaskAction `
    -Execute $PwshExe `
    -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Scripts\notify.ps1`"" `
    -WorkingDirectory $Root

$NotifyTriggers = @(
    (New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser -RandomDelay (New-TimeSpan -Seconds 30))
)

$NotifySettings = New-ScheduledTaskSettingsSet `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -StartWhenAvailable `
    -MultipleInstances IgnoreNew `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
    -Compatibility Win8

# ---------------------------------------------------------------- 등록 함수
$script:PlainPassword = $null

function Register-DmfTask {
    param(
        [string]$Name,
        [string]$Description,
        $Action,
        $Triggers,
        $Settings,
        [ValidateSet('Batch','Interactive')][string]$Kind
    )

    Write-Host "[*] 등록: $TaskFolder$Name"
    $principal = New-DmfPrincipal -Kind $Kind

    if ($null -ne $principal) {
        Register-ScheduledTask -TaskName $Name -TaskPath $TaskFolder `
            -Action $Action -Trigger $Triggers -Settings $Settings `
            -Principal $principal -Description $Description -Force | Out-Null
    }
    else {
        # Password 모드: 암호를 대화형으로 받는다(스크립트에 하드코딩 금지)
        if (-not $script:PlainPassword) {
            $sec  = Read-Host -AsSecureString "[$RunAsUser] 계정 암호 입력"
            $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
            $script:PlainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
        Register-ScheduledTask -TaskName $Name -TaskPath $TaskFolder `
            -Action $Action -Trigger $Triggers -Settings $Settings `
            -User $RunAsUser -Password $script:PlainPassword `
            -RunLevel Highest -Description $Description -Force | Out-Null
    }
}

Register-DmfTask -Name 'DMF_Crawler_Daily' `
    -Description 'MFDS DMF 공고/현황 크롤링 및 xlsx 리포트 생성 (매일 06:00)' `
    -Action $DailyAction -Triggers $DailyTriggers -Settings $CommonSettings -Kind Batch

Register-DmfTask -Name 'DMF_Crawler_Watchdog' `
    -Description 'DMF_Crawler_Daily heartbeat 검증 및 실패 알림 (매일 07:00)' `
    -Action $WatchdogAction -Triggers $WatchdogTriggers -Settings $CommonSettings -Kind Batch

Register-DmfTask -Name 'DMF_Crawler_Notify' `
    -Description 'DMF_Crawler 알림 큐를 읽어 토스트 표시 (대화형 세션 전용)' `
    -Action $NotifyAction -Triggers $NotifyTriggers -Settings $NotifySettings -Kind Interactive

# ---------------------------------------------------------------- 부팅 트리거 Delay 주입
# New-ScheduledTaskTrigger -AtStartup 에는 고정 -Delay 파라미터가 없다.
# XML 을 직접 수정하여 <BootTrigger><Delay>PT3M</Delay> 를 넣는다.
Write-Host "[*] BootTrigger Delay 주입..."
foreach ($pair in @(
        @{ Name = 'DMF_Crawler_Daily';    Delay = 'PT3M'  },
        @{ Name = 'DMF_Crawler_Watchdog'; Delay = 'PT10M' })) {

    $xml = Export-ScheduledTask -TaskName $pair.Name -TaskPath $TaskFolder
    if ($xml -notmatch '<BootTrigger>')        { continue }
    if ($xml -match '<BootTrigger>\s*<Delay>') { continue }   # 이미 적용됨

    $xml = $xml -replace '<BootTrigger>', "<BootTrigger><Delay>$($pair.Delay)</Delay>"
    Register-ScheduledTask -TaskName $pair.Name -TaskPath $TaskFolder -Xml $xml -Force | Out-Null
    Write-Host "    - $($pair.Name): Delay $($pair.Delay) 적용"
}

# ---------------------------------------------------------------- XML 백업(형상관리)
foreach ($n in @('DMF_Crawler_Daily','DMF_Crawler_Watchdog','DMF_Crawler_Notify')) {
    Export-ScheduledTask -TaskName $n -TaskPath $TaskFolder |
        Out-File -FilePath (Join-Path $Ops "$n.xml") -Encoding utf8
}
Write-Host "[=] XML 백업: $Ops"

# ---------------------------------------------------------------- 검증
Write-Host ""
Write-Host "[=] 등록 결과:"
Get-ScheduledTask -TaskPath $TaskFolder |
    Select-Object TaskName, State,
        @{n='LogonType'; e={ $_.Principal.LogonType }},
        @{n='RunLevel';  e={ $_.Principal.RunLevel  }} |
    Format-Table -AutoSize

Write-Host ""
Write-Host "[=] 다음 실행 예정 시각:"
Get-ScheduledTask -TaskPath $TaskFolder |
    Get-ScheduledTaskInfo |
    Select-Object TaskName, NextRunTime, LastRunTime, LastTaskResult |
    Format-Table -AutoSize

Write-Host ""
Write-Host "[!] 수동 테스트: Start-ScheduledTask -TaskPath '$TaskFolder' -TaskName 'DMF_Crawler_Daily'"
Write-Host "[!] 전체 삭제  : Get-ScheduledTask -TaskPath '$TaskFolder' | Unregister-ScheduledTask -Confirm:`$false"

5.3 등록 후 즉시 확인할 명령 모음

# 작업 상태 전체
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-List TaskName, State, Principal, Settings

# 다음 실행 시각 / 마지막 결과 코드
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo

# XML 로 내보내 형상관리에 커밋 (반드시 UTF-8)
Export-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' |
    Out-File -Encoding utf8 'D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml'

# 수동 실행 (조건 무시하고 즉시)
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

# 실행 중 중단
Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

# 일시 비활성화 / 재활성화
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
Enable-ScheduledTask  -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

# 최근 작업 기록 30건
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 |
    Where-Object { $_.Message -match 'DMF_Crawler' } |
    Select-Object -First 30 TimeCreated, Id, LevelDisplayName, Message |
    Format-List

# 우리 애플리케이션 이벤트만
Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='DMFCrawler' } -MaxEvents 20 |
    Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List

6. schtasks XML 정본

6.1 schtasks /create 파라미터 (공식)

schtasks /create /sc <scheduletype> /tn <taskname> /tr <taskrun>
    [/s <computer> [/u [<domain>\]<user> [/p <password>]]]
    [/ru {[<domain>\]<user> | system}] [/rp <password>]
    [/mo <modifier>] [/d <day>[,<day>...] | *] [/m <month>[,<month>...]]
    [/i <idletime>] [/st <starttime>] [/ri <interval>]
    [{/et <endtime> | /du <duration>} [/k]] [/sd <startdate>] [/ed <enddate>]
    [/it] [/np] [/z] [/xml <xmlfile>] [/v1] [/f] [/rl <level>]
    [/delay <delaytime>] [/hresult]

핵심 파라미터(공식 원문 발췌):

파라미터 설명
/sc <scheduletype> MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY, ONCE, ONSTART — "Specifies that the task runs every time the system starts. You can specify a start date, or run the task the next time the system starts." · ONLOGON — "Specifies that the task runs whenever a user (any user) logs on." · ONIDLE · ONEVENT — "Specifies that the task runs based on an event that matches information from the system event log including the EventID."
/tn <taskname> "Each task on the system must have a unique name and must conform to the rules for file names, not exceeding 238 characters. Use quotation marks to enclose names that include spaces. To store your scheduled task in a different folder, run /tn <folder name\task name>."
/tr <Taskrun> "Type the fully qualified path and file name of an executable file, script file, or batch file. The path name must not exceed 262 characters. If you don't add the path, schtasks assumes that the file is in the <systemroot>\System32 directory."
/s <computer> 원격 컴퓨터 이름/IP. 기본은 로컬
/u [<domain>\]<user> / /p <password> "The /u and /p parameters are valid only when you use /s."
/ru {user | system} "Runs the task with permissions of the specified user account." System = "the local System account, a highly privileged account used by the operating system and system services."
/rp <password> "Don't use the /rp parameter for tasks that run with System account credentials (/ru System). The System account doesn't have a password and SchTasks.exe doesn't prompt for one."
/mo <modifier> 스케줄 타입별 반복 배수. DAILY 는 1365, WEEKLY 는 152, MONTHLY 는 1~12 또는 LASTDAY/FIRST/SECOND/THIRD/FOURTH
/st <Starttime> "Specifies the start time for the task, using the 24-hour time format, HH:mm. The default value is the current time on the local computer. The /st parameter is valid with MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY, and ONCE schedules. It's required for a ONCE schedule."
/ri <interval> "Specifies the repetition interval for the scheduled task, in minutes. This isn't applicable for schedule types: MINUTE, HOURLY, ONSTART, ONLOGON, ONIDLE, and ONEVENT. Valid range is 1 - 599940 (599940 minutes = 9999 hours). If either the /et or /du parameters are specified, the default is 10 minutes."
/et <endtime> / /du <duration> / /k MINUTE/HOURLY 전용 종료 시각·최대 지속시간·종료 시 프로세스 kill
/sd / /ed 시작/종료 날짜. 형식은 로캘 종속("Only one format is valid for each locale")
/ec <channelname> "Specifies the event channel name triggered by the ONEVENT schedule type that matches a system event log criteria."
/it "Specifies to run the scheduled task only when the run as user is logged on to the computer. This parameter has no effect on tasks that run with system permissions or tasks that already have the interactive-only property set. You can't use a change command to remove the interactive-only property from a task." → 대화형 알림 작업용
/np "No password is stored. The task runs non-interactively as the given user. Only local resources are available." → S4U 에 해당
/z "Specifies to delete the task upon the completion of its schedule."
/rl <level> LIMITED 또는 HIGHEST
/delay <delaytime> ONSTART/ONLOGON/ONEVENT 트리거 지연
/xml <xmlfile> "Creates a task specified in the XML file. Can be combined with the /ru ..." — XML 이 이미 principal 을 포함하면 /rp 만 조합
/v1 Task Scheduler 1.0 호환 작업 생성
/f 강제(기존 작업 덮어쓰기)
/hresult 결과를 HRESULT 로 출력

XML 임포트 실전 명령:

schtasks /create /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml" /TN "\DMF_Crawler\DMF_Crawler_Daily" /F
schtasks.exe /create /RU DOMAIN\user /RP password /TN "\DMF_Crawler\DMF_Crawler_Daily" /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml"
schtasks /create /RU SYSTEM /SC ONSTART /TN "\DMF_Crawler\Boot" /TR "powershell.exe -File D:\workspace\DMF_Crawler\scripts\run-daily.ps1" /DELAY 0003:00 /RL HIGHEST /F

⚠️ 인코딩 함정: "Even though Task Scheduler exports files as UTF-16, it refuses to read them unless they're UTF-8 encoded." → Export-ScheduledTask ... | Out-File -Encoding utf8 로 저장한다. PowerShell 7 이면 -Encoding utf8BOM 을 명시하라(PS7 의 utf8 은 BOM 없음).

msg/메시지 상자 액션은 최신 Windows 에서 Task Scheduler UI 로는 만들 수 없다("Can't Create Tasks to Display Messages in Task Scheduler in Windows 8 and Later"). msg.exe/tr 로 실행하는 방식으로 우회한다(§12.4).

6.2 DMF_Crawler_Daily.xml — 완결 XML

ops\DMF_Crawler_Daily.xml 로 저장하고 Git 에 커밋한다. <UserId> 는 실제 DOMAIN\user 또는 SID 로 치환할 것.

<?xml version="1.0" encoding="UTF-8"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2026-09-02T00:00:00</Date>
    <Author>DMF_Crawler</Author>
    <Description>MFDS DMF 공고/현황 크롤링 및 xlsx 리포트 생성 (매일 06:00 + 부팅 시 보정)</Description>
    <URI>\DMF_Crawler\DMF_Crawler_Daily</URI>
  </RegistrationInfo>

  <Triggers>
    <!-- 매일 06:00, 최대 2분 랜덤 지연 -->
    <CalendarTrigger>
      <StartBoundary>2026-09-02T06:00:00</StartBoundary>
      <Enabled>true</Enabled>
      <RandomDelay>PT2M</RandomDelay>
      <ScheduleByDay>
        <DaysInterval>1</DaysInterval>
      </ScheduleByDay>
    </CalendarTrigger>

    <!-- 시스템 시작 3분 후. Fast Startup 시에는 발화하지 않을 수 있으므로
         StartWhenAvailable 이 진짜 안전망이다(§14.2). -->
    <BootTrigger>
      <Enabled>true</Enabled>
      <Delay>PT3M</Delay>
    </BootTrigger>
  </Triggers>

  <Principals>
    <Principal id="Author">
      <!-- S4U 모드. Password 모드로 바꾸려면 LogonType 을 Password 로 하고
           schtasks /RU /RP 또는 Register-ScheduledTask -User -Password 로 등록한다. -->
      <UserId>DESKTOP-XXXX\encep</UserId>
      <LogonType>S4U</LogonType>
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
  </Principals>

  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>

    <!-- 전원: 배터리에서도 시작하고, 배터리로 전환돼도 멈추지 않는다.
         둘 다 XSD 기본값이 true 이므로 명시적으로 false 로 뒤집어야 한다. -->
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>

    <AllowHardTerminate>true</AllowHardTerminate>

    <!-- 놓친 작업 즉시 실행: 재부팅/절전/전원차단 내성의 핵심. 기본값 false. -->
    <StartWhenAvailable>true</StartWhenAvailable>

    <!-- 네트워크가 준비된 뒤에만 실행. 기본값 false. -->
    <RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>

    <IdleSettings>
      <StopOnIdleEnd>false</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>

    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>

    <!-- 절전 상태에서 깨워서 실행. powercfg 로 wake timer 허용 필요(§14.5). 기본값 false. -->
    <WakeToRun>true</WakeToRun>

    <!-- 2시간 넘으면 폭주로 간주하고 종료. PT0S 면 무제한. 미지정 시 기본 3일. -->
    <ExecutionTimeLimit>PT2H</ExecutionTimeLimit>

    <Priority>5</Priority>

    <!-- 실패 시 10분 간격 3회 재시도.
         XSD 제약: Interval 은 PT1M ~ P31D, Count 는 unsignedByte 최소 1 -->
    <RestartOnFailure>
      <Interval>PT10M</Interval>
      <Count>3</Count>
    </RestartOnFailure>
  </Settings>

  <Actions Context="Author">
    <Exec>
      <Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
      <Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "D:\workspace\DMF_Crawler\scripts\run-daily.ps1"</Arguments>
      <WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
    </Exec>
  </Actions>
</Task>

6.3 DMF_Crawler_Notify.xml — 대화형 + 이벤트 트리거

토스트를 띄우려면 반드시 <LogonType>InteractiveToken</LogonType> 이어야 한다.

<?xml version="1.0" encoding="UTF-8"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Author>DMF_Crawler</Author>
    <Description>DMF_Crawler 알림 큐를 읽어 토스트를 표시한다 (대화형 세션 전용)</Description>
    <URI>\DMF_Crawler\DMF_Crawler_Notify</URI>
  </RegistrationInfo>

  <Triggers>
    <!-- (a) 사용자 로그온 30초 후: 로그온 전에 쌓인 알림 큐를 소화 -->
    <LogonTrigger>
      <Enabled>true</Enabled>
      <Delay>PT30S</Delay>
      <UserId>DESKTOP-XXXX\encep</UserId>
    </LogonTrigger>

    <!-- (b) DMFCrawler 소스가 Application 로그에 EventID 1001(실패)을 쓰면 즉시 -->
    <EventTrigger>
      <Enabled>true</Enabled>
      <Subscription>&lt;QueryList&gt;&lt;Query Id="0" Path="Application"&gt;&lt;Select Path="Application"&gt;*[System[Provider[@Name='DMFCrawler'] and (EventID=1001)]]&lt;/Select&gt;&lt;/Query&gt;&lt;/QueryList&gt;</Subscription>
    </EventTrigger>

    <!-- (c) 작업 스케줄러가 DMF_Crawler_Daily 를 시작하지 못했을 때
         101 JobStartFailed / 103 JobFailure(ActionStartFailed) /
         111 JobTermination / 203 ActionLaunchFailure / 331 TimeoutWontWork -->
    <EventTrigger>
      <Enabled>true</Enabled>
      <Subscription>&lt;QueryList&gt;&lt;Query Id="0" Path="Microsoft-Windows-TaskScheduler/Operational"&gt;&lt;Select Path="Microsoft-Windows-TaskScheduler/Operational"&gt;*[System[(EventID=101 or EventID=103 or EventID=111 or EventID=203 or EventID=331 or EventID=332)]] and *[EventData[Data[@Name='TaskName']='\DMF_Crawler\DMF_Crawler_Daily']]&lt;/Select&gt;&lt;/Query&gt;&lt;/QueryList&gt;</Subscription>
    </EventTrigger>
  </Triggers>

  <Principals>
    <Principal id="Author">
      <UserId>DESKTOP-XXXX\encep</UserId>
      <!-- 이 값이 InteractiveToken 이 아니면 토스트는 절대 안 뜬다 -->
      <LogonType>InteractiveToken</LogonType>
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>

  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <StartWhenAvailable>true</StartWhenAvailable>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>true</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
    <ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
    <Priority>7</Priority>
  </Settings>

  <Actions Context="Author">
    <Exec>
      <Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
      <Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "D:\workspace\DMF_Crawler\scripts\notify.ps1"</Arguments>
      <WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
    </Exec>
  </Actions>
</Task>

임포트:

schtasks /create /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Notify.xml" /TN "\DMF_Crawler\DMF_Crawler_Notify" /F

6.4 이벤트 트리거 XPath 작성법

EventTriggerSubscriptionXPath 쿼리 문자열이다. 공식: "The Subscription property gets or sets the XPath query string that identifies the event that fires the trigger." 또한 ValueQueries 는 "a collection of named XPath queries, with each query applied to the last matching event XML returned from the subscription query specified in the Subscription property" — 즉 이벤트에서 값을 뽑아 액션 인자로 넘길 수 있다($(EventValue) 형태).

GUI 의 "On an event" 트리거는 EventID 만 지정할 수 있지만, 사용자 지정 이벤트 필터(custom event filter) 로 전환하면 임의의 XPath 를 넣을 수 있다.

XPath 를 손으로 쓰지 말고 이벤트 뷰어에서 만들어 복사하는 것이 가장 안전하다:

  1. 이벤트 뷰어 → 해당 로그 → 현재 로그 필터링XML 탭 → 쿼리를 수동으로 편집
  2. 생성된 <QueryList>...</QueryList> 복사
  3. 작업 XML 에 넣을 때 <, >, &&lt;, &gt;, &amp; 로 이스케이프

7. 서비스화 옵션 비교 — 왜 채택하지 않는가

7.1 종합 비교표

항목 작업 스케줄러(권고) NSSM WinSW pywin32 (win32serviceutil) .NET BackgroundService
하루 1회 배치 적합성 최적 과잉 과잉 과잉 과잉
설치 방식 Register-ScheduledTask / schtasks /XML nssm install <svc> (GUI) 또는 nssm install <svc> <app> [options] winsw install myapp.xml python svc.py install sc.exe create
형상관리 XML export/import ⚠️ 레지스트리 값 XML 파일 ⚠️ 코드 ⚠️ 코드
실패 시 자동 재시작 RestartOnFailure (Interval/Count) AppExit = Restart/Ignore/Exit, AppRestartDelay, AppThrottle <onfailure action="restart" delay="10 sec"/> 다단계 ⚠️ sc failure 로 별도 설정 ⚠️ sc.exe failure
정상 종료 + exit code≠0 감지 감지 후 재시도 AppExit 로 처리 <onfailure> 서비스 프레임워크가 종료를 정상으로 봄 ⚠️ Environment.Exit(1) 명시 필요
로그 로테이션 내장 (직접 구현) AppRotateFiles / AppRotateOnline / AppRotateSeconds / AppRotateBytes <log mode="roll">
Session 0 격리 회피 대화형 작업 분리 가능 불가 불가(interactive 플래그는 Vista 이후 무의미) 불가 불가
토스트 알림 (대화형 작업으로)
유지보수 상태 OS 내장 ⚠️ 버전 확인 실패(§7.2 참조) v2.12.0 Latest, v3.0.0-alpha.11 pre-release 활발 MS 공식
런타임 요구 없음 없음 .NET Framework 4.6.1+ 또는 .NET 7 네이티브 Python + pywin32 .NET 8 SDK+
라이선스 Public domain(관례) MIT ("permissive") PSF/BSD 계열 MIT

7.2 NSSM 상세

nssm.cc/usage 에서 확인한 설정 파라미터:

설치/제거

nssm install <servicename>                       :: GUI 설치 마법사(탭 여러 개)
nssm install <servicename> <application> [options]
nssm remove <servicename>                        :: 확인 프롬프트 후 제거

재시작 동작

레지스트리 파라미터 의미
AppExit Restart, Ignore, Exit 중 하나. 설정 안 하면 기본 restart
AppRestartDelay 재시작 간 대기 밀리초. 대기 중 서비스는 "Paused" 로 표시됨
AppThrottle CPU 루프 방지. 앱이 임계값(기본 1500ms) 전에 종료하면 스로틀. 대기 시간을 2배씩 늘려 최대 256초까지

로깅 및 회전

파라미터 의미
AppStdout / AppStderr 앱 출력을 지정 파일로 리다이렉트
AppRotateFiles 회전 활성화. 기존 파일은 ISO8601 타임스탬프로 이름 변경
AppRotateOnline 실행 중에도 크기 기준으로 회전
AppRotateSeconds / AppRotateBytes 회전 임계값

종료 제어

파라미터 의미
AppStopMethodSkip 합산 값으로 종료 방법 비활성화: 1=skip Control-C, 2=skip WM_CLOSE, 4=skip WM_QUIT, 8=skip TerminateProcess
AppStopMethodConsole / AppStopMethodWindow / AppStopMethodThreads 각 방법의 타임아웃(기본 1500ms)

한계: nssm.cc 문서에는 Session 0 격리나 대화형 데스크톱 제약에 대한 언급이 전혀 없다. 그리고 nssm.cc/download 는 조사 시점에 timeout of 60000ms exceeded 로 응답하지 않아 최신 버전·릴리스 날짜를 확인하지 못했다 (⚠️ 미검증 — 부록 B-6). NSSM 이 수년간 릴리스가 없다는 것은 커뮤니티에서 널리 알려진 사실이나, 이 조사에서는 확인하지 못했다.

7.3 WinSW 상세 (서비스화가 필요해지면 이것을 써라)

릴리스 현황(GitHub Releases 페이지 실측):

Tag Date Status
v3.0.0-alpha.11 January 29 Pre-release
v2.12.0 January 28 Latest
v3.0.0-alpha.10 August 9 Pre-release
v3.0.0-alpha.9 April 9 Pre-release
v2.11.0 March 17 Standard
v3.0.0-alpha.8 March 16 Pre-release
v3.0.0-alpha.7 December 23 Pre-release
v3.0.0-alpha.6 November 15 Pre-release
v3.0.0-alpha.5 October 22 Pre-release
v2.10.3 October 17 Standard

⚠️ 위 표의 연도가 GitHub UI 에서 생략되어 연도 미확인. "v2.12.0 이 Latest 안정판, 3.x 는 alpha" 라는 사실만 확정.

"WinSW 3.x is in active development on the default v3 branch. GitHub Releases contain stable 2.x versions and 3.x pre-releases, while NuGet and Maven packages currently support 2.x."

런타임 요구: "WinSW 3 requires .NET Framework 4.6.1 or later or native executables based on .NET 7." — .NET Framework 4.6.1 은 "preinstalled since Windows 10, version 1511 and Windows Server 2016" 이므로 Windows 11 에서는 추가 설치 불필요.

라이선스: MIT ("permissive").

설치/시작

winsw install myapp.xml [options]
winsw start myapp.xml

공식 샘플 XML

<service>
  <id>jenkins</id>
  <name>Jenkins</name>
  <description>This service runs Jenkins continuous integration system.</description>
  <env name="JENKINS_HOME" value="%BASE%"/>
  <executable>java</executable>
  <arguments>-Xrs -Xmx256m -jar "%BASE%\jenkins.war" --httpPort=8080</arguments>
  <log mode="roll"></log>
</service>

XML 요소 레퍼런스

요소 설명(원문)
executable (필수) 실행 파일. 절대 경로 또는 PATH 에서 검색 가능한 이름
arguments "The <arguments> element specifies the arguments to be passed to the executable."
workingdirectory "Some services need to run with a working directory specified."
env <env name="HOME" value="c:\abc" />
log <logpath> 와 시작 모드: append(기본), reset, ignore, roll
onfailure action = restart / reboot / none, 선택적 delay 속성. 여러 개를 순서대로 나열 가능
resetfailure "Controls the timing in which Windows SCM resets the failure count." 기본 1 day
startmode Automatic 또는 Manual. 기본 Automatic
delayedAutoStart Automatic + 지연 시작. "Will not take affect on old Windows versions older than Windows 7."
stoptimeout 정상 종료 대기 시간. 기본 15초
serviceaccount <username>, <password>, 선택 <allowservicelogon>
interactive "If specified, the service will be allowed to interact with the desktop." ⚠️ 경고: Since Windows Vista/UAC, services cannot truly interact with the desktop.

onfailure + 로그 롤링 완전 예제

<service>
  <id>myservice</id>
  <executable>java</executable>
  <arguments>-jar myapp.jar</arguments>
  <workingdirectory>C:\app</workingdirectory>
  <env name="JAVA_HOME" value="C:\Java"/>
  <log mode="roll"/>
  <onfailure action="restart" delay="10 sec"/>
  <onfailure action="restart" delay="20 sec"/>
  <onfailure action="reboot"/>
  <resetfailure>1 hour</resetfailure>
  <stoptimeout>10sec</stoptimeout>
  <startmode>Automatic</startmode>
</service>

7.4 sc.exe failure — 서비스 복구 옵션 정확한 명령줄

공식 구문:

sc [<ServerName>] failure [<ServiceName>] [reset= <ErrorFreePeriod>] [reboot= <BroadcastMessage>]
   [command= <CommandLine>] [actions= {"" | {[run/<MS>] | [restart/<MS>] | [reboot/<MS>]}[/...]]
파라미터 설명(원문)
<ServerName> "Specifies the name of the remote server on which the service is located. The name must use the Universal Naming Convention (UNC) format (for example, \\myserver). To run SC.exe locally, omit this parameter."
<ServiceName> "Specifies the service name returned by the getkeyname operation."
reset= <ErrorFreePeriod> "Specifies the length of the period (in seconds) with no failures after which the failure count should be reset to 0 (zero). Note that this parameter requires the actions= parameter."
reboot= <BroadcastMessage> "Specifies the message to be broadcast when a service fails."
command= <CommandLine> "Specifies the command-line command to be run when the specified service fails."
actions= "Specifies one or more failure actions and their delay times (in milliseconds), separated by a forward slash (/). Valid actions are run, restart, and reboot. If more than one action is specified, each action must be separated by a forward slash. Use actions= "" to take no action a service fails. Note that this parameter requires the reset= parameter."

Remarks(원문, 중요):

  • "Not all services allow changes to their failure options. Some run as part of a service set."
  • "To run a batch file when a service fails, specify command=Cmd.exe <Drive>:\<FileName>.bat, where <Drive>:\<FileName>.bat is the fully qualified name of the batch file."
  • "To run a VBS file when a service fails, specify command=Cscript <Drive>:\<MyScript>.vbs"
  • "You can specify up to three separate actions with the actions= parameter, to be used the first, second, and third times that a service fails."
  • "For each command-line option (parameter), the equal sign is part of the option name."
  • "A space is required between an option and its value (for example, actions= restart). If the space is omitted, the operation will fail."

공식 예제(원문 그대로):

sc failure msftpsvc reset= 30 actions= restart/5000
sc failure dfs reset= 60 command= c:\windows\services\restart_dfs.exe actions= run/5000
sc failure dfs reset= 60 actions= reboot/30000
sc failure dfs reset= 60 reboot= "The Distributed File System service has failed. Because of this, the computer will reboot in 30 seconds."  actions= reboot/30000
sc failure myservice reset= 3600 reboot= "MyService crashed -- rebooting machine" command= "%windir%\MyServiceRecovery.exe" actions= restart/5000/run/10000/reboot/60000

.NET 공식 튜토리얼의 예시:

sc.exe failure ".NET Joke Service" reset= 0 actions= restart/60000/restart/60000/run/1000

DMF_Crawler 가 굳이 서비스가 된다면 쓸 명령:

sc.exe failure "DMFCrawler" reset= 86400 actions= restart/60000/restart/60000/restart/60000
sc.exe failureflag "DMFCrawler" 1

⚠️ sc failureflag(정상 종료(non-crash)에도 복구 동작을 적용하는 플래그)는 조사한 공식 Sc failure 문서에 언급되어 있지 않다. 실존하는 서브커맨드이지만 이 문서에서는 검증되지 않았다 — ⚠️ 미검증.

sc failure 의 근본 한계: 프로세스가 비정상 종료(crash) 할 때만 트리거된다. 크롤러가 "정상적으로 실행되어 exit code 1 로 종료" 하면 SCM 은 실패로 보지 않는다(그래서 .NET 예제가 Environment.Exit(1) 을 명시적으로 호출하며 주석에 "In order for the Windows Service Management system to leverage configured recovery options, we need to terminate the process with a non-zero exit code" 라고 적었다). 작업 스케줄러의 RestartOnFailure 는 이런 제약이 없다.

7.5 pywin32 서비스 (참고)

win32serviceutil.ServiceFramework 요구사항:

  • 필수 속성: _svc_name_ (서비스 이름), _svc_display_name_ (표시 이름)
  • 선택 속성: _svc_deps_ (의존 서비스), _svc_description_
  • 필수 메서드: SvcDoRun ("starts the service and doesn't return until stopped"), SvcStop ("stops the service")
  • 진입점: 인자가 없으면 servicemanager.Initialize()servicemanager.PrepareToHostSingle()servicemanager.StartServiceCtrlDispatcher(), 인자가 있으면 install/start/stop 처리

채택하지 않는 이유: 파이썬 인터프리터·pywin32 버전 결합, PyInstaller 로 단일 파일화하지 않으면 배포가 취약, 그리고 위 모든 Session 0 문제를 그대로 물려받는다.

7.6 .NET BackgroundService (참고)

Microsoft 공식 워커 서비스 튜토리얼의 핵심 코드 패턴:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = ".NET Joke Service";
});

로깅 설정(appsettings.{Environment}.json) — 이벤트 로그 심각도 기본은 Warning:

{
  "Logging": {
    "LogLevel": { "Default": "Warning" },
    "EventLog": {
      "SourceName": "The Joke Service",
      "LogName": "Application",
      "LogLevel": {
        "Microsoft": "Information",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    }
  }
}

ExecuteAsync 의 예외 처리 패턴(주석 원문 포함):

catch (Exception ex)
{
    logger.LogError(ex, "{Message}", ex.Message);
    // Terminates this process and returns an exit code to the operating system.
    // This is required to avoid the 'BackgroundServiceExceptionBehavior', which
    // performs one of two scenarios:
    // 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
    // 2. When set to "StopHost": will cleanly stop the host, and log errors.
    //
    // In order for the Windows Service Management system to leverage configured
    // recovery options, we need to terminate the process with a non-zero exit code.
    Environment.Exit(1);
}

프로젝트 파일 예시:

<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net10.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>true</ImplicitUsings>
    <RootNamespace>App.WindowsService</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
  </ItemGroup>
</Project>

채택하지 않는 이유: 크롤러가 Python 기반인데 .NET 런타임을 하나 더 끌어들이는 것은 무의미하다.

7.7 최종 권고 (근거 정리)

DMF_Crawler 는 서비스로 만들지 않는다. Windows 작업 스케줄러 작업 3개 + 파일 기반 heartbeat + 외부 dead-man switch 로 간다.

근거 5가지:

  1. 워크로드가 배치형이다. 하루 1회, 수십 분. 상주가 필요 없다.
  2. Session 0 격리가 요구사항("죽으면 Windows 알림")과 정면충돌한다. 서비스에서는 토스트를 못 띄운다(§8, §12).
  3. 작업 스케줄러가 필요한 복원 기능을 이미 전부 내장한다. StartWhenAvailable(놓친 작업), RestartOnFailure(재시도), WakeToRun(절전 해제), BootTrigger(부팅), 배터리 조건. 서비스로 이걸 재현하려면 NSSM/WinSW 설정 + sc failure + 별도 스케줄러가 필요하고, 그래도 StartWhenAvailable 에 해당하는 기능은 없다.
  4. exit code≠0 을 실패로 인식한다. sc failure 는 크래시만 잡는다.
  5. 형상관리와 재현성. 작업 XML 3개를 Git 에 커밋하면 새 PC 에서 schtasks /create /XML 3번으로 복원된다.

서비스로 전환해야 하는 시점: 폴링 주기가 시간 단위 미만으로 내려가거나, 상시 웹 대시보드/큐 컨슈머가 필요해질 때. 그때는 WinSW 2.12.0(XML 형상관리 + <onfailure> 다단계 + <log mode="roll">)를 쓰고, 알림은 여전히 별도 대화형 작업으로 분리하라.


8. Session 0 격리 — headless 브라우저와 토스트에 미치는 영향

8.1 무엇이 일어나는가 (공식 원문)

Interactive Services 문서:

"Services cannot directly interact with a user as of Windows Vista. Therefore, the techniques mentioned in the section titled Using an Interactive Service should not be used in new code."

"By default, services use a noninteractive window station and cannot interact with the user."

"All services run in Terminal Services session 0. Therefore, if an interactive service displays a user interface, it is visible only to the user who connected to session 0. Because there is no way to guarantee that the interactive user is connected to session 0, do not configure a service to run as an interactive service under Terminal Services or on a system that supports fast user switching."

NoInteractiveServices 레지스트리 값:

레지스트리 키: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Windows "The NoInteractiveServices value defaults to 1, which means that no service is allowed to run interactively, regardless of whether it has SERVICE_INTERACTIVE_PROCESS. When NoInteractiveServices is set to a 0, services with SERVICE_INTERACTIVE_PROCESS are allowed to run interactively." (Windows 7 / Server 2008 R2 / XP / Server 2003 에서는 기본값이 0 이었다.)

경고(원문):

"Services running in an elevated security context, such as the LocalSystem account, should not create a window on the interactive desktop because any other application that is running on the interactive desktop can interact with this window. This exposes the service to any application that a logged-on user executes."

역사적 배경:

"Until Windows Server 2003, services and the first logged on user used to run in the same session, Session 0 ... However, in Windows Vista, Windows Server 2008, and later versions of Windows, the operating system isolates services in Session 0 and runs applications in other sessions, so services are protected from attacks that originate in application code."

그리고 Session 0 데스크톱으로 전환할 방법조차 사라졌다:

"When you update to or install Windows 10 Version 1803 or later or Server 2019, the Interactive Services Detection Service (UI0Detect) will no longer be present, which means you can no longer switch desktop to Session 0."

8.2 서비스에서 사용자와 상호작용하는 유일한 합법적 방법 (공식)

"You can use the following techniques to interact with the user from a service on all supported versions of Windows:

  • Display a dialog box in the user's session using the WTSSendMessage function.
  • Create a separate hidden GUI application and use the CreateProcessAsUser function to run the application within the context of the interactive user. Design the GUI application to communicate with the service through some method of interprocess communication (IPC), for example, named pipes. ... Note that IPC can expose your service interfaces over the network unless you use an appropriate access control list (ACL)."

"If this service runs on a multiuser system, add the application to the following key so that it is run in each session: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run."

Windows Server 2003 / XP 한정(현재는 무의미):

"Display a message box by calling the MessageBox function with MB_SERVICE_NOTIFICATION."

우리 설계(§2.3)의 "알림 큐 파일 + 대화형 작업" 은 위 두 번째 기법의 파일 기반 IPC 구현이다. 명명된 파이프 대신 JSON 파일을 쓰므로 네트워크 노출 위험도 없다.

8.3 Playwright / Chromium 에 미치는 영향

보고된 실제 증상(GitHub microsoft/playwright#20242):

"the playwright browser runs planned and everything goes fine except the window doesn't show up. But I need to see the window."

사용자가 한 일: .bat 파일(@python a.py + @pause)을 NSSM 으로 서비스 등록 → 스크립트는 정상 실행되지만 브라우저 창이 보이지 않음. 직접 실행하면 창이 보인다.

원인: Session 0 격리. 서비스는 Session 0 의 비대화형 윈도우 스테이션에서 돌기 때문에 창이 있는 것 자체가 다른 세션에서 보이지 않는다. 이는 버그가 아니라 설계다.

관련 이슈: #20242, #12174 ("Headful chromium on Windows does not launch without '--single-process'"), #3191, #34306 ("Unable to launch chromium in headless mode in win11 with v1.49.1"), #34508 ("chromium-headless-shell"), playwright-python#2498.

우리 프로젝트에 대한 실무 결론:

  • headless 로 돌린다면 Session 0 자체는 문제가 아니다. 헤드리스 Chromium 은 데스크톱이 필요 없다.
  • 진짜 문제는 브라우저 바이너리 경로다. Playwright 기본 다운로드 위치는 Windows 에서 %USERPROFILE%\AppData\Local\ms-playwright 인데, 작업이 SYSTEM 이나 다른 계정으로 돌면 그 계정의 프로필을 보게 되어 브라우저를 못 찾는다.

해결: PLAYWRIGHT_BROWSERS_PATH 를 고정 경로로 못박는다.

# 설치 시 (관리자 PowerShell) — 시스템 환경변수로 고정
[Environment]::SetEnvironmentVariable(
    'PLAYWRIGHT_BROWSERS_PATH',
    'D:\workspace\DMF_Crawler\.playwright-browsers',
    'Machine')

# 현재 세션에도 반영
$env:PLAYWRIGHT_BROWSERS_PATH = 'D:\workspace\DMF_Crawler\.playwright-browsers'

# 브라우저 설치 (헤드리스만 쓰면 --only-shell 로 용량 절감)
python -m playwright install chromium
# 또는
python -m playwright install chromium --only-shell

공식 문서(playwright.dev/python/docs/browsers) 확인 사항:

  • "On Windows systems, Playwright automatically stores browser binaries in %USERPROFILE%\AppData\Local\ms-playwright unless configured otherwise."
  • 설치 시: PLAYWRIGHT_BROWSERS_PATH=$HOME/pw-browsers python -m playwright install
  • 런타임 시: PLAYWRIGHT_BROWSERS_PATH=$HOME/pw-browsers python playwright_script.py설치와 실행 양쪽에서 같은 값을 써야 한다.
  • playwright install chromium : Chromium 만 설치
  • playwright install --with-deps chromium : 브라우저 + 시스템 의존성
  • --only-shell : "Playwright ships separate builds for headed and headless modes. To reduce download size when running tests only in headless mode, use the --only-shell flag to avoid downloading the full Chromium browser."

8.4 Session 0 회피 체크리스트

하고 싶은 것 Session 0(서비스)에서 우리 방식
headless Chromium 크롤링 가능 (경로만 고정하면) 작업 스케줄러 배치 작업
headed 브라우저 디버깅 불가 개발 시 수동 실행
토스트 알림 불가 대화형 작업(DMF_Crawler_Notify)
MessageBox 팝업 불가(Vista 이후) 대화형 작업
msg.exe * △ 세션이 존재해야 함 폴백 2단계
웹훅(디스코드/슬랙) 가능 폴백 3단계 — 항상 동작

9. 워치독 · 헬스체크 · dead-man switch

9.1 3중 감시 구조

[1] 로컬 heartbeat 파일   state\heartbeat.json     ← 워치독 작업이 07:00 에 검사
[2] Windows 이벤트 로그   Application/DMFCrawler   ← 이벤트 트리거가 즉시 반응
[3] 외부 dead-man switch  healthchecks.io          ← PC 가 꺼져 있어도 알려준다

[3] 이 없으면 "PC 가 통째로 꺼져 있는 상황"을 절대 감지할 수 없다. 로컬 워치독은 PC 가 켜져 있을 때만 돈다.

9.2 heartbeat 파일 스키마

D:\workspace\DMF_Crawler\state\heartbeat.json

{
  "run_id": "20260902-060003-a7f31c",
  "task": "DMF_Crawler_Daily",
  "started_at": "2026-09-02T06:00:03+09:00",
  "finished_at": "2026-09-02T06:11:47+09:00",
  "duration_sec": 704,
  "status": "success",
  "exit_code": 0,
  "counts": { "new": 12, "changed": 3, "withdrawn": 1, "total_scanned": 4187 },
  "report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_2026-09-02.xlsx",
  "log_path": "D:\\workspace\\DMF_Crawler\\logs\\run-20260902-060003-a7f31c.log",
  "error": null,
  "host": "DESKTOP-XXXX",
  "schema_version": 1
}

실패 시:

{
  "run_id": "20260902-060003-a7f31c",
  "status": "failed",
  "exit_code": 3,
  "error": "PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'",
  "failed_stage": "crawl:list_page",
  "screenshot_path": "D:\\workspace\\DMF_Crawler\\logs\\shots\\20260902-060003-a7f31c-fail.png"
}

9.3 이벤트 로그 ID 규약 (자체 정의)

Event ID EntryType 의미
1000 Information 실행 시작 (run_id 포함)
1001 Error 실행 실패 — DMF_Crawler_Notify 이벤트 트리거의 대상
1002 Information 실행 성공 (건수 포함)
1003 Warning 부분 성공 (일부 페이지 실패했으나 리포트는 생성)
1010 Error 워치독: heartbeat 미갱신 감지
1011 Warning 워치독: heartbeat 는 있으나 status=failed
1012 Information 워치독: 정상 확인
1020 Warning 알림 전송 실패(토스트/웹훅 모두)

9.4 스크립트 2 — scripts\run-daily.ps1 (본체 래퍼, 완결)

크롤러 본체(Python)는 별도로 두고, 이 래퍼가 실행 ID·로깅·heartbeat·이벤트 로그·healthchecks ping·실패 큐 작성을 전담한다.

<#
.SYNOPSIS
    DMF_Crawler 일일 실행 래퍼. 작업 스케줄러 DMF_Crawler_Daily 가 호출한다.
.DESCRIPTION
    - 실행 ID 생성, 로그 파일 분리, heartbeat 갱신
    - Windows 이벤트 로그 기록 (Application / DMFCrawler)
    - healthchecks.io dead-man switch ping (/start, 성공, /fail)
    - 실패 시 알림 큐 파일 작성 + 웹훅 발사
    - 오래된 로그 정리
.NOTES
    반드시 PowerShell 5.1(powershell.exe)로 실행한다. Write-EventLog 가 PS7 에 없다.
#>
[CmdletBinding()]
param(
    [string]$Root = 'D:\workspace\DMF_Crawler'
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# ---------------------------------------------------------------- 경로
$Scripts   = Join-Path $Root 'scripts'
$Logs      = Join-Path $Root 'logs'
$Shots     = Join-Path $Logs 'shots'
$State     = Join-Path $Root 'state'
$Reports   = Join-Path $Root 'reports'
$QueueDir  = Join-Path $State 'notify-queue'
$Heartbeat = Join-Path $State 'heartbeat.json'
$ConfigPs1 = Join-Path $Root 'config\ops.config.ps1'

foreach ($d in @($Logs, $Shots, $State, $Reports, $QueueDir)) {
    if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

# ---------------------------------------------------------------- 설정 로드
# config\ops.config.ps1 예시:
#   $HealthcheckUrl = 'https://hc-ping.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
#   $DiscordWebhook = 'https://discord.com/api/webhooks/.../...'
#   $TelegramToken  = '...'
#   $TelegramChatId = '...'
#   $OwnerName      = '홍길동 (010-0000-0000)'
$HealthcheckUrl = $null
$DiscordWebhook = $null
$TelegramToken  = $null
$TelegramChatId = $null
$OwnerName      = '담당자 미설정'
if (Test-Path $ConfigPs1) { . $ConfigPs1 }

# ---------------------------------------------------------------- 실행 ID / 로그
$RunId   = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmss'),
                        ([guid]::NewGuid().ToString('N').Substring(0, 6))
$LogFile = Join-Path $Logs ("run-$RunId.log")
$Started = Get-Date

function Write-Log {
    param([string]$Level, [string]$Message)
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Level, $Message
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    Write-Host $line
}

function Write-DmfEvent {
    param(
        [int]$EventId,
        [ValidateSet('Information','Warning','Error')][string]$EntryType,
        [string]$Message
    )
    try {
        # PowerShell 5.1 경로
        Write-EventLog -LogName 'Application' -Source 'DMFCrawler' `
                       -EventId $EventId -EntryType $EntryType -Message $Message
    } catch {
        # PowerShell 7 폴백: Write-EventLog 가 없다.
        try {
            $type = [System.Diagnostics.EventLogEntryType]::$EntryType
            [System.Diagnostics.EventLog]::WriteEntry('DMFCrawler', $Message, $type, $EventId)
        } catch {
            Write-Log 'WARN' "이벤트 로그 기록 실패: $($_.Exception.Message)"
        }
    }
}

function Invoke-Ping {
    param([string]$Url, [string]$Body = $null)
    if (-not $Url) { return }
    try {
        # TLS 1.2 강제 (구형 .NET 기본값 대응)
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        if ($Body) {
            Invoke-RestMethod -Uri $Url -Method Post -Body $Body -TimeoutSec 20 | Out-Null
        } else {
            Invoke-RestMethod -Uri $Url -TimeoutSec 20 | Out-Null
        }
        Write-Log 'INFO' "ping OK: $Url"
    } catch {
        Write-Log 'WARN' "ping 실패($Url): $($_.Exception.Message)"
    }
}

function Save-Heartbeat {
    param([hashtable]$Data)
    $Data['host']           = $env:COMPUTERNAME
    $Data['schema_version'] = 1
    $json = $Data | ConvertTo-Json -Depth 6
    # 원자적 쓰기: 임시 파일에 쓰고 교체
    $tmp = "$Heartbeat.tmp"
    Set-Content -Path $tmp -Value $json -Encoding UTF8
    Move-Item -Path $tmp -Destination $Heartbeat -Force
}

function Add-NotifyQueue {
    param([hashtable]$Payload)
    $file = Join-Path $QueueDir ("notify-$RunId.json")
    ($Payload | ConvertTo-Json -Depth 6) | Set-Content -Path $file -Encoding UTF8
    Write-Log 'INFO' "알림 큐 작성: $file"
}

function Send-Webhook {
    param([string]$Title, [string]$Text)
    # healthchecks 와 별개로, 사람에게 즉시 닿는 채널
    if ($DiscordWebhook) {
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            $body = @{ content = "**$Title**`n$Text" } | ConvertTo-Json -Depth 3
            Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
                -ContentType 'application/json; charset=utf-8' `
                -Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20 | Out-Null
            Write-Log 'INFO' 'Discord 웹훅 전송 완료'
        } catch { Write-Log 'WARN' "Discord 웹훅 실패: $($_.Exception.Message)" }
    }
    if ($TelegramToken -and $TelegramChatId) {
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            $uri = "https://api.telegram.org/bot$TelegramToken/sendMessage"
            Invoke-RestMethod -Uri $uri -Method Post -TimeoutSec 20 -Body @{
                chat_id = $TelegramChatId
                text    = "$Title`n$Text"
            } | Out-Null
            Write-Log 'INFO' 'Telegram 전송 완료'
        } catch { Write-Log 'WARN' "Telegram 실패: $($_.Exception.Message)" }
    }
}

# ---------------------------------------------------------------- 시작
Write-Log 'INFO' "=== DMF_Crawler 실행 시작 run_id=$RunId host=$env:COMPUTERNAME user=$env:USERNAME ==="
Write-DmfEvent -EventId 1000 -EntryType Information -Message "DMF_Crawler 실행 시작`nrun_id=$RunId`nlog=$LogFile"

# healthchecks: /start (rid 로 start-success 를 짝지어 소요시간 계산)
$Rid = [guid]::NewGuid().ToString()
if ($HealthcheckUrl) { Invoke-Ping -Url ("{0}/start?rid={1}" -f $HealthcheckUrl, $Rid) }

Save-Heartbeat @{
    run_id      = $RunId
    task        = 'DMF_Crawler_Daily'
    started_at  = $Started.ToString('o')
    finished_at = $null
    status      = 'running'
    exit_code   = $null
    log_path    = $LogFile
}

# ---------------------------------------------------------------- 본체 실행
$ExitCode  = 0
$ErrorText = $null
$Counts    = @{ new = 0; changed = 0; withdrawn = 0; total_scanned = 0 }
$ReportPath = $null

try {
    # Playwright 브라우저 경로 고정 (§8.3)
    $env:PLAYWRIGHT_BROWSERS_PATH = Join-Path $Root '.playwright-browsers'
    $env:DMF_RUN_ID   = $RunId
    $env:DMF_LOG_FILE = $LogFile
    $env:PYTHONIOENCODING = 'utf-8'

    $py = Join-Path $Root '.venv\Scripts\python.exe'
    if (-not (Test-Path $py)) { $py = 'python' }

    Write-Log 'INFO' "python: $py"
    Write-Log 'INFO' 'crawler 시작...'

    # stdout/stderr 를 모두 로그로 캡처. 2>&1 로 병합.
    & $py -m dmf_crawler.main --run-id $RunId --out $Reports 2>&1 |
        ForEach-Object { Add-Content -Path $LogFile -Value $_ -Encoding UTF8 }

    $ExitCode = $LASTEXITCODE
    Write-Log 'INFO' "crawler 종료 exit_code=$ExitCode"

    if ($ExitCode -ne 0) { throw "크롤러가 exit code $ExitCode 로 종료했습니다." }

    # 크롤러가 남긴 결과 요약 읽기 (Python 쪽에서 써 준다)
    $summaryFile = Join-Path $State ("summary-$RunId.json")
    if (Test-Path $summaryFile) {
        $s = Get-Content $summaryFile -Raw -Encoding UTF8 | ConvertFrom-Json
        $Counts = @{
            new           = [int]$s.new
            changed       = [int]$s.changed
            withdrawn     = [int]$s.withdrawn
            total_scanned = [int]$s.total_scanned
        }
        $ReportPath = [string]$s.report_path
    }
}
catch {
    $ExitCode  = if ($ExitCode -ne 0) { $ExitCode } else { 1 }
    $ErrorText = $_.Exception.Message
    Write-Log 'ERROR' $ErrorText
    Write-Log 'ERROR' ($_.ScriptStackTrace)
}

# ---------------------------------------------------------------- 마무리
$Finished = Get-Date
$Duration = [int]($Finished - $Started).TotalSeconds

if ($ExitCode -eq 0) {
    Save-Heartbeat @{
        run_id      = $RunId
        task        = 'DMF_Crawler_Daily'
        started_at  = $Started.ToString('o')
        finished_at = $Finished.ToString('o')
        duration_sec= $Duration
        status      = 'success'
        exit_code   = 0
        counts      = $Counts
        report_path = $ReportPath
        log_path    = $LogFile
        error       = $null
    }

    $msg = @"
DMF_Crawler 실행 성공
run_id=$RunId
소요=${Duration}신규=$($Counts.new) / 변경=$($Counts.changed) / 취하=$($Counts.withdrawn) / 스캔=$($Counts.total_scanned)
report=$ReportPath
"@
    Write-Log 'INFO' '=== 성공 ==='
    Write-DmfEvent -EventId 1002 -EntryType Information -Message $msg

    if ($HealthcheckUrl) {
        Invoke-Ping -Url ("{0}?rid={1}" -f $HealthcheckUrl, $Rid) -Body $msg
    }

    # 성공 시에도 신규/변경/취하가 있으면 알림 큐에 넣는다(정보성)
    if (($Counts.new + $Counts.changed + $Counts.withdrawn) -gt 0) {
        Add-NotifyQueue @{
            level    = 'info'
            run_id   = $RunId
            title    = "DMF 변동 $($Counts.new + $Counts.changed + $Counts.withdrawn)건"
            body     = "신규 $($Counts.new) · 변경 $($Counts.changed) · 취하 $($Counts.withdrawn)"
            report   = $ReportPath
            log      = $LogFile
            created  = $Finished.ToString('o')
        }
    }
}
else {
    Save-Heartbeat @{
        run_id       = $RunId
        task         = 'DMF_Crawler_Daily'
        started_at   = $Started.ToString('o')
        finished_at  = $Finished.ToString('o')
        duration_sec = $Duration
        status       = 'failed'
        exit_code    = $ExitCode
        counts       = $Counts
        report_path  = $null
        log_path     = $LogFile
        error        = $ErrorText
    }

    $msg = @"
DMF_Crawler 실행 실패
run_id  = $RunId
exit    = $ExitCode
error   = $ErrorText
log     = $LogFile
host    = $env:COMPUTERNAME
"@
    Write-Log 'ERROR' '=== 실패 ==='
    # EventId 1001 은 DMF_Crawler_Notify 의 이벤트 트리거가 감시한다
    Write-DmfEvent -EventId 1001 -EntryType Error -Message $msg

    if ($HealthcheckUrl) {
        Invoke-Ping -Url ("{0}/fail?rid={1}" -f $HealthcheckUrl, $Rid) -Body $msg
    }

    Add-NotifyQueue @{
        level   = 'error'
        run_id  = $RunId
        title   = 'DMF 크롤러 실패'
        body    = $ErrorText
        log     = $LogFile
        owner   = $OwnerName
        created = $Finished.ToString('o')
        retry   = 'Start-ScheduledTask -TaskPath ''\DMF_Crawler\'' -TaskName ''DMF_Crawler_Daily'''
    }

    Send-Webhook -Title '🚨 DMF 크롤러 실패' -Text $msg
}

# ---------------------------------------------------------------- 로그 정리 (§17.2)
try {
    Get-ChildItem -Path $Logs -Filter 'run-*.log' -File |
        Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
        Remove-Item -Force -ErrorAction SilentlyContinue
    Get-ChildItem -Path $Shots -Filter '*.png' -File |
        Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-14) } |
        Remove-Item -Force -ErrorAction SilentlyContinue
    Get-ChildItem -Path $QueueDir -Filter 'notify-*.json' -File |
        Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
        Remove-Item -Force -ErrorAction SilentlyContinue
} catch { Write-Log 'WARN' "로그 정리 실패: $($_.Exception.Message)" }

Write-Log 'INFO' "=== 종료 exit_code=$ExitCode duration=${Duration}s ==="
exit $ExitCode

9.5 스크립트 3 — scripts\watchdog.ps1 (완결)

<#
.SYNOPSIS
    DMF_Crawler_Daily 의 heartbeat 를 검증하고, 미갱신/실패 시 알림을 발생시킨다.
.DESCRIPTION
    매일 07:00 (그리고 부팅 후 10분) 에 실행.
    - heartbeat.json 의 finished_at 이 오늘(또는 지정 시간 창) 안인지 확인
    - status 가 success 인지 확인
    - 작업 스케줄러의 LastTaskResult 도 교차 확인
    - 문제가 있으면 이벤트 로그 1010/1011 + 알림 큐 + 웹훅
    - 선택: 자동 1회 재실행(-AutoRetry)
.NOTES
    PowerShell 5.1 로 실행할 것.
#>
[CmdletBinding()]
param(
    [string]$Root = 'D:\workspace\DMF_Crawler',
    [int]$MaxAgeHours = 26,      # 06:00 실행 + 여유. 26시간 지나면 이상.
    [switch]$AutoRetry           # 미갱신 감지 시 Daily 작업을 1회 강제 실행
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

$State     = Join-Path $Root 'state'
$Logs      = Join-Path $Root 'logs'
$QueueDir  = Join-Path $State 'notify-queue'
$Heartbeat = Join-Path $State 'heartbeat.json'
$ConfigPs1 = Join-Path $Root 'config\ops.config.ps1'
$WdLog     = Join-Path $Logs ('watchdog-{0}.log' -f (Get-Date -Format 'yyyyMM'))

foreach ($d in @($State, $Logs, $QueueDir)) {
    if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

$HealthcheckWatchdogUrl = $null
$DiscordWebhook = $null
$TelegramToken  = $null
$TelegramChatId = $null
$OwnerName      = '담당자 미설정'
if (Test-Path $ConfigPs1) { . $ConfigPs1 }

function Write-Log {
    param([string]$Level, [string]$Message)
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
    Add-Content -Path $WdLog -Value $line -Encoding UTF8
    Write-Host $line
}

function Write-DmfEvent {
    param([int]$EventId, [string]$EntryType, [string]$Message)
    try {
        Write-EventLog -LogName 'Application' -Source 'DMFCrawler' `
                       -EventId $EventId -EntryType $EntryType -Message $Message
    } catch {
        try {
            $t = [System.Diagnostics.EventLogEntryType]::$EntryType
            [System.Diagnostics.EventLog]::WriteEntry('DMFCrawler', $Message, $t, $EventId)
        } catch { Write-Log 'WARN' "이벤트 로그 기록 실패: $($_.Exception.Message)" }
    }
}

function Add-NotifyQueue {
    param([hashtable]$Payload)
    $id   = (Get-Date -Format 'yyyyMMdd-HHmmss')
    $file = Join-Path $QueueDir ("notify-watchdog-$id.json")
    ($Payload | ConvertTo-Json -Depth 6) | Set-Content -Path $file -Encoding UTF8
    Write-Log 'INFO' "알림 큐 작성: $file"
}

function Send-Webhook {
    param([string]$Title, [string]$Text)
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    if ($DiscordWebhook) {
        try {
            $body = @{ content = "**$Title**`n$Text" } | ConvertTo-Json -Depth 3
            Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
                -ContentType 'application/json; charset=utf-8' `
                -Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20 | Out-Null
        } catch { Write-Log 'WARN' "Discord 실패: $($_.Exception.Message)" }
    }
    if ($TelegramToken -and $TelegramChatId) {
        try {
            Invoke-RestMethod -Uri "https://api.telegram.org/bot$TelegramToken/sendMessage" `
                -Method Post -TimeoutSec 20 -Body @{ chat_id = $TelegramChatId; text = "$Title`n$Text" } | Out-Null
        } catch { Write-Log 'WARN' "Telegram 실패: $($_.Exception.Message)" }
    }
}

Write-Log 'INFO' '=== 워치독 시작 ==='

# ---------------------------------------------------------------- 1. 작업 존재/상태 확인
$problems = New-Object System.Collections.Generic.List[string]

try {
    $task = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
    $info = $task | Get-ScheduledTaskInfo
    Write-Log 'INFO' ("작업 상태: State={0} LastRunTime={1} LastTaskResult=0x{2:X} NextRunTime={3}" -f `
        $task.State, $info.LastRunTime, $info.LastTaskResult, $info.NextRunTime)

    if ($task.State -eq 'Disabled') {
        $problems.Add('작업 DMF_Crawler_Daily 가 [사용 안 함(Disabled)] 상태입니다.')
    }
    if ($info.LastTaskResult -ne 0 -and $info.LastTaskResult -ne 267009) {
        # 267009 = 0x41301 SCHED_S_TASK_RUNNING
        $problems.Add(("마지막 실행 결과 코드가 0x{0:X} 입니다 (0 이 아님)." -f $info.LastTaskResult))
    }
}
catch {
    $problems.Add("작업 DMF_Crawler_Daily 를 찾을 수 없습니다: $($_.Exception.Message)")
}

# ---------------------------------------------------------------- 2. heartbeat 검증
$hb = $null
if (-not (Test-Path $Heartbeat)) {
    $problems.Add("heartbeat 파일이 없습니다: $Heartbeat")
}
else {
    try {
        $hb = Get-Content $Heartbeat -Raw -Encoding UTF8 | ConvertFrom-Json
    } catch {
        $problems.Add("heartbeat 파일 파싱 실패: $($_.Exception.Message)")
    }
}

if ($hb) {
    $finishedAt = $null
    if ($hb.finished_at) { $finishedAt = [datetime]::Parse($hb.finished_at) }

    if (-not $finishedAt) {
        $problems.Add("heartbeat 에 finished_at 이 없습니다 (status=$($hb.status)). 실행 중 중단된 것으로 보입니다. run_id=$($hb.run_id)")
    }
    else {
        $ageH = [math]::Round(((Get-Date) - $finishedAt).TotalHours, 1)
        Write-Log 'INFO' "heartbeat: status=$($hb.status) finished_at=$finishedAt (${ageH}시간 전) run_id=$($hb.run_id)"

        if ($ageH -gt $MaxAgeHours) {
            $problems.Add("마지막 성공 실행이 ${ageH}시간 전입니다 (임계 ${MaxAgeHours}시간). run_id=$($hb.run_id)")
        }
        if ($hb.status -ne 'success') {
            $problems.Add("마지막 실행 status=$($hb.status) exit_code=$($hb.exit_code) error=$($hb.error)")
        }
    }
}

# ---------------------------------------------------------------- 3. 판정 및 조치
if ($problems.Count -eq 0) {
    Write-Log 'INFO' '정상. 이상 없음.'
    Write-DmfEvent -EventId 1012 -EntryType Information `
        -Message "워치독 정상 확인. 마지막 run_id=$($hb.run_id) status=$($hb.status)"
    if ($HealthcheckWatchdogUrl) {
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            Invoke-RestMethod -Uri $HealthcheckWatchdogUrl -TimeoutSec 20 | Out-Null
        } catch { Write-Log 'WARN' "워치독 ping 실패: $($_.Exception.Message)" }
    }
    Write-Log 'INFO' '=== 워치독 종료 (정상) ==='
    exit 0
}

$detail = ($problems | ForEach-Object { "  - $_" }) -join "`n"
$body = @"
DMF 크롤러 이상 감지 ($(Get-Date -Format 'yyyy-MM-dd HH:mm'))
호스트: $env:COMPUTERNAME

감지된 문제:
$detail

로그 폴더 : $Logs
heartbeat : $Heartbeat
담당자     : $OwnerName

재실행 명령:
  Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
"@

Write-Log 'ERROR' $body
Write-DmfEvent -EventId 1010 -EntryType Error -Message $body

Add-NotifyQueue @{
    level   = 'error'
    source  = 'watchdog'
    title   = 'DMF 크롤러 이상 감지'
    body    = ($problems -join ' / ')
    detail  = $body
    log     = $Logs
    owner   = $OwnerName
    created = (Get-Date).ToString('o')
    retry   = "Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'"
}

Send-Webhook -Title '⚠️ DMF 크롤러 워치독 경보' -Text $body

# 로그온 세션이 있으면 즉시 알림 작업을 깨운다
try {
    Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Notify' -ErrorAction Stop
    Write-Log 'INFO' 'DMF_Crawler_Notify 작업을 트리거했습니다.'
} catch {
    Write-Log 'WARN' "Notify 작업 트리거 실패(로그온 세션 없음 가능): $($_.Exception.Message)"
}

# ---------------------------------------------------------------- 4. 자동 재시도(옵션)
if ($AutoRetry) {
    try {
        Write-Log 'INFO' 'AutoRetry: DMF_Crawler_Daily 를 강제 실행합니다.'
        Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
    } catch {
        Write-Log 'ERROR' "AutoRetry 실패: $($_.Exception.Message)"
    }
}

Write-Log 'INFO' '=== 워치독 종료 (이상) ==='
exit 1

9.6 healthchecks.io — dead-man switch

개념: "Healthchecks.io works as a dead man's switch for processes that need to run continuously or on a regular, known schedule. This monitoring technique, sometimes called 'heartbeat monitoring', is a type of dead man's switch." / "Your cron job sends an HTTP request ('ping') to Healthchecks.io every time it completes. When Healthchecks.io does not receive the HTTP request at the expected time, it notifies you."

핑 API 엔드포인트 전체(공식):

Action URL
Success https://hc-ping.com/<uuid>
Start https://hc-ping.com/<uuid>/start
Failure https://hc-ping.com/<uuid>/fail
Log https://hc-ping.com/<uuid>/log
Exit Status https://hc-ping.com/<uuid>/<exit-status>
  • Slug 기반: <uuid> 자리에 <ping-key>/<slug> 를 넣을 수 있다 → https://hc-ping.com/<ping-key>/<slug>
  • Run ID (rid): "Optional UUID parameter matching start and completion pings to calculate accurate job duration." 형식: rid=123e4567-e89b-12d3-a456-426614174000
  • Create Flag (slug 전용): create=1 — slug 가 없으면 자동으로 체크를 생성
  • Exit Status: 정수 0255. "Healthchecks.io interprets 0 as a success and all other values as a failure."
  • POST Body Limit: 응답에 Ping-Body-Limit: <n> 헤더가 있으며 "typically 100 kB limit per ping"
  • Rate Limiting: "Do not ping a check more frequently than is necessary. If you ping a check more than 5 times per minute, some of the requests may get rate limited."

Period 와 Grace Time (공식):

  • Period: "the expected time between pings" — 일일 작업이면 24시간
  • Grace Time: "the additional time to wait before sending an alert when a check is late" — 일일 작업이면 1~2시간이 일반적
  • "if you use start signals to measure job duration, grace time also becomes the maximum allowed time gap between 'start' and 'success' signals." ⚠️/start 를 쓰면 Grace Time 이 작업 최대 허용 실행시간이 되므로, ExecutionTimeLimit PT2H 보다 넉넉하게 잡아야 한다.
  • Cron mode / OnCalendar mode: cron 표현식 또는 systemd timer 형식. 지정 항목은 ① 스케줄 표현식, ② 서버의 시간대("the cron daemon typically uses the system's local time"), ③ 늦은 핑에 대한 grace time. 예: 0 6 * * * + Asia/Seoul

DMF_Crawler 권장 설정:

항목 비고
Schedule mode Cron 0 6 * * *
Time zone Asia/Seoul DST 없음 → 안전
Grace Time 3시간 /start 를 쓰므로 ExecutionTimeLimit(2H)보다 크게
알림 채널 이메일 + 디스코드/텔레그램 이중화

PowerShell 핑 예제(공식 문서 원문):

Invoke-RestMethod https://hc-ping.com/your-uuid-here
Invoke-RestMethod -Uri https://hc-ping.com/your-uuid-here -Method Post -Body "temperature=-7"

작업 스케줄러에서 직접 실행하는 명령(공식):

powershell.exe -ExecutionPolicy bypass -File C:\Scripts\healthchecks.ps1
powershell.exe -Command "&{Invoke-RestMethod https://hc-ping.com/your-uuid-here}"

⚠️ 조사 시점의 healthchecks.io/docs/powershell/ 페이지에는 /start, /fail, /{exit-status} 엔드포인트, 재시도/타임아웃 가이드, 무료 플랜 한도가 포함되어 있지 않았다. 이들은 docs/http_api/docs/configuring_checks/ 에서 확인했다. 무료 플랜의 체크 개수 한도는 이 조사에서 확인하지 못했다⚠️ 미검증(부록 B-4).

9.7 Uptime Kuma — 자체 호스팅 대안

개념: "Push monitoring flips normal monitoring logic: instead of Uptime Kuma checking your service, your service sends a heartbeat to Uptime Kuma. If the heartbeat stops arriving within a defined timeout, the monitor is marked down and notifications fire." / "This is called a dead man's switch, by analogy with the train brake that engages automatically if the driver lets go: the absence of signal is the alert signal."

설정 절차: "Click on Monitor Type and select Push from the dropdown. Set a heartbeat interval. If your service does not 'check in' within this time period of its last 'check in', then Uptime Kuma will assume the service is down and send you an alert."

  • 최소 heartbeat interval: 20초
  • 재시도 횟수와 재시도 간격을 설정하면 "a 'grace period' for if your service missed a 'check in' or two" 를 얻는다.

Push URL 형식: https://DOMAIN/api/push/CODE?status=up&msg=OK&ping=

PowerShell 핑:

Invoke-WebRequest -Uri "https://uptimekuma.example.com/api/push/XXXXXXXX?status=up&msg=OK&ping=704" -UseBasicParsing

Python 예제(문서 원문):

import requests
r = requests.get("http://uptimekuma.mydomain.com/api/push/xyz")

언제 Uptime Kuma 를 쓰나: 외부 SaaS 에 데이터를 보내기 어려운 조직(제약사 내부망 등)이거나 이미 Uptime Kuma 를 운영 중일 때. 그 외에는 healthchecks.io 가 설정이 간단하다.

⚠️ Uptime Kuma 는 자체 호스팅이므로 Uptime Kuma 서버 자체가 죽으면 감시가 사라진다. 크롤러와 같은 PC 에 올리면 dead-man switch 로서 의미가 없다. 반드시 다른 호스트에 두어라.


10. 이벤트 로그 기록과 이벤트 트리거 작업

10.1 New-EventLog (PowerShell 5.1 전용)

New-EventLog
    [-LogName] <string>
    [-Source] <string[]>
    [[-ComputerName] <string[]>]
    [-CategoryResourceFile <string>]
    [-MessageResourceFile <string>]
    [-ParameterResourceFile <string>]
    [<CommonParameters>]

Description(원문): "This cmdlet creates a new classic event log on a local or remote computer. It can also register an event source that writes to the new log or to an existing log. The cmdlets that contain the EventLog noun (the Event log cmdlets) work only on classic event logs. To get events from logs that use the Windows Event Log technology in Windows Vista and later versions of Windows, use Get-WinEvent."

파라미터:

파라미터 별칭 설명
-LogName LN "Specifies the name of the event log. If the log does not exist, New-EventLog creates the log ... If the log exists, New-EventLog registers a new source for the event log."
-Source SRC "Specifies the names of the event log sources, such as application programs that write to the event log. This parameter is required."
-ComputerName CN 기본은 로컬 컴퓨터. "This parameter does not rely on PowerShell remoting."
-CategoryResourceFile CRF 카테고리 문자열 파일
-MessageResourceFile MRF 이벤트 메시지 파일
-ParameterResourceFile PRF 파라미터 치환 문자열 파일

공식 예제:

# Example 1 - create a new event log
New-EventLog -Source TestApp -LogName TestLog -MessageResourceFile C:\Test\TestApp.dll

# Example 2 - add a new event source to an existing log
$file = "C:\Program Files\TestApps\NewTestApp.dll"
New-EventLog -ComputerName Server01 -Source NewTestApp -LogName Application -MessageResourceFile $file -CategoryResourceFile $file

Notes(원문):

  • "To use New-EventLog on Windows Vista and later versions of Windows, open PowerShell with the Run as administrator option."
  • "To create an event source ... you must be a member of the Administrators group on the computer."
  • "When you create a new event log and a new event source, the system registers the new source for the new log, but the log is not created until the first entry is written to it."
  • "When you create a new event log, the associated file is stored in the $Env:SystemRoot\System32\Config directory ... The file name is the first eight characters of the Log property with an .evt file name extension."

10.2 Write-EventLog (PowerShell 5.1 전용)

Write-EventLog
    [-LogName] <String>
    [-Source] <String>
    [[-EntryType] <EventLogEntryType>]
    [-Category <Int16>]
    [-EventId] <Int32>
    [-Message] <String>
    [-RawData <Byte[]>]
    [-ComputerName <String>]
    [<CommonParameters>]

"To write an event to an event log, the event log must exist on the computer and the source must be registered for the event log."

파라미터:

파라미터 별칭 값/제약
-LogName LN 필수. "The log name is the value of the Log property, not the LogDisplayName. Wildcard characters are not permitted."
-Source SRC 필수. "typically the name of the application that is writing the event to the log"
-EventId ID, EID 필수. "The maximum value for the EventId parameter is 65535."
-EntryType ET 허용값: Error, Information, FailureAudit, SuccessAudit, Warning. 기본 Information
-Message MSG 필수
-Category Int16. "Enter an integer that is associated with the strings in the category message file"
-RawData RD Byte[]
-ComputerName CN 원격 컴퓨터

공식 예제:

Write-EventLog -LogName "Application" -Source "MyApp" -EventID 3001 -EntryType Information -Message "MyApp added a user-requested feature to the display." -Category 1 -RawData 10,20
Write-EventLog -ComputerName "Server01" -LogName Application -Source "MyApp" -EventID 3001 -Message "MyApp added a user-requested feature to the display."

Notes: "For some Windows event logs, writing events requires administrator rights. You must start PowerShell using the Run as Administrator option."

10.3 PowerShell 7 문제와 해결

문제: "PowerShell 7 doesn't include Write-EventLog, and New-EventLog is not recognised as a name of a cmdlet in PowerShell 7." / "The Write-EventLog cmdlet is deprecated in PowerShell 7+ because it relies on unsupported APIs."

New-WinEvent 는 대체재가 아니다:

  • "It creates Event Tracing for Windows (ETW) events, not traditional Windows Event Log entries"
  • "It's designed for ETW channels, not the classic Application/System/Security logs"
  • "Not a true replacement for Write-EventLog"
  • "you need to register a separate event provider, which can be complicated"
  • "You cannot freely set the log name and entry type or easily create custom sources like the classic cmdlets."

해결 1 (권장) — .NET 클래스 직접 호출:

[System.Diagnostics.EventLog]::WriteEntry("MySource", "Message text", [System.Diagnostics.EventLogEntryType]::Information, 1000)
  • "Works in both PowerShell 5 and 7"
  • "Provides direct .NET access to event logging functionality"
  • "Requires the event source to be pre-registered on the system"

소스 등록도 .NET 으로 가능(관리자 권한 필요):

if (-not [System.Diagnostics.EventLog]::SourceExists('DMFCrawler')) {
    [System.Diagnostics.EventLog]::CreateEventSource('DMFCrawler', 'Application')
}

해결 2 — PowerShell 5.1 을 쓴다. 우리 설계는 이쪽을 택했다: 작업 액션의 실행 파일을 C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe 로 고정. 이유: 클래식 cmdlet 이 그대로 동작하고, Windows 11 에 항상 존재하며, PowerShell 7 설치 여부에 의존하지 않는다.

해결 3 — 호환 모드로 모듈 임포트: "you can import the classic Microsoft.PowerShell.Management module in compatibility mode to use the legacy cmdlets." (Import-Module Microsoft.PowerShell.Management -UseWindowsPowerShell)

해결 4 — System.Diagnostics.TraceSource + EventLogTraceListener: app.config 기반. 복잡하지만 다중 리스너 시나리오에 유리.

우리 스크립트는 해결 1 + 해결 2 를 모두 구현(try/catch 폴백)했다(§9.4 Write-DmfEvent).

10.4 이벤트 트리거 작업 — 실패를 즉시 알림으로

DMF_Crawler_Notify 는 세 종류의 이벤트를 감시한다(§6.3 XML 참조):

소스 EventID 언제
Application / DMFCrawler 1001 크롤러가 스스로 실패를 선언
Microsoft-Windows-TaskScheduler/Operational 101 Task Scheduler 가 작업 시작에 실패
103 작업 액션 실행 실패
111 시간 초과로 작업 종료(ExecutionTimeLimit)
203 액션 실행 실패(Failed to launch action)
331 타임아웃 메커니즘 생성 실패
332 사용자 미로그온으로 작업 미시작

PowerShell 로 이벤트 트리거를 만들려면 CIM 클래스를 직접 다뤄야 하므로, XML import 방식(§6.3)이 실무상 유일하게 깔끔한 경로다.

수동으로 이벤트 트리거를 테스트하려면:

# 실패 이벤트를 강제로 발생시켜 Notify 가 뜨는지 확인
Write-EventLog -LogName Application -Source DMFCrawler -EventId 1001 -EntryType Error `
    -Message "TEST: 이벤트 트리거 동작 확인 (무시해도 됩니다)"

11. Windows 알림 전 방식 비교

11.1 종합 비교표

라이브러리 언어 설치 버튼/액션 콜백 AppId(AUMID) 이미지/진행바 유지보수 이 프로젝트 적합성
BurntToast PowerShell Install-Module -Name BurntToast 또는 choco install burnttoast-psmodule New-BTButton (Protocol/Snooze/Dismiss, 색상) △ 이벤트 핸들링은 PowerShell 7.1+ 필요 ⚠️ v1.0.0 에서 AppId 커스터마이징 제거. 대신 "Windows shortcut creation with proper AppUserModelID" 도입 AppLogo, Header, Urgent v1.1.0 1순위 채택
win11toast Python pip install win11toast buttons=[{activationType, arguments, content}] on_click=lambda args: ... ⚠️ 문서에 app_id 언급 없음 image, progress, dialogue(TTS) ○ 파이썬에서 직접 띄울 때
windows-toasts Python python -m pip install windows-toasts ToastButton on_activated "Custom AUMIDs need registration via PowerShell or the registry" Python 3.9+ ○ 가장 정석적
winotify Python pip install winotify 클릭 가능한 액션 아이콘/오디오
plyer Python pip install plyer ⚠️ "For Windows it uses win10toast (an old version of win11toast)" 부적합
toasted Python pip install toasted "supports all notification elements provided by Windows, such as inputs, selects, buttons, images, and different text styles" △ 기능은 가장 풍부

결론: BurntToast 를 1순위로 채택한다. 이유:

  1. 알림 작업이 이미 PowerShell 로 돌고 있으므로 파이썬 런타임 의존이 없다.
  2. 버튼·색상·Header·Urgent 를 모두 지원한다.
  3. PowerShell Gallery 로 설치·업데이트가 간단하다.

파이썬 코드 안에서 직접 띄워야 할 상황이 생기면 windows-toasts(AUMID 등록 지원, Python 3.9+, Windows SDK 바인딩 사용 — "avoiding workarounds like Powershell hacks")를 쓴다.

11.2 BurntToast 상세

설치

Install-Module -Name BurntToast -Scope CurrentUser -Force
# 또는
choco install burnttoast-psmodule

지원 환경: "Windows 10 and Windows Server 2019 and above." / "on all supported versions of PowerShell, including Windows PowerShell" — 단 "certain event handling requires PowerShell 7.1+".

주요 cmdlet: New-BurntToastNotification, New-BTButton, New-BTAction, New-BTHeader, Submit-BTNotification, New-BTText, New-BTVisual, New-BTBinding, New-BTContent.

주요 파라미터: -Button, -Header, -AppLogo. v1.1.0 에서 "support for Important Notifications using the Urgent switch" 및 버튼 색상 설정 추가.

v1.0.0 breaking changes(중요): "Significant removals include custom audio path support, AppId customization, and shoulder tap notifications. The update introduced Windows shortcut creation with proper AppUserModelID to enable 'full toast branding when launching PowerShell.'"

New-BTButton 완전 명세(공식 Help 원문):

New-BTButton [-Snooze] [-Dismiss] [-Content <String>] [-Arguments <String>]
    [-ActivationType <Microsoft.Toolkit.Uwp.Notifications.ToastActivationType>]
    [-ImageUri <String>] [-Id <String>] [-Color <String>]
파라미터 타입 설명(원문)
Snooze Switch "Creates a system-handled snooze button"
Dismiss Switch "Creates a system-handled dismiss button"
Content String "The text to display on this button"
Arguments String "App-defined string to pass when the button is pressed"
ActivationType ToastActivationType "Defines the activation type that triggers when the button is pressed. Defaults to Protocol"
ImageUri String "Path or URI of an image icon to display next to the button label"
Id String "Specifies an ID associated with another toast control"
Color String 허용값: Green 또는 Red

공식 예제(원문):

New-BTButton -Dismiss
New-BTButton -Snooze
New-BTButton -Snooze -Content 'Sleep' -Id 'TimeSelection'
New-BTButton -Content 'Blog' -Arguments 'https://king.geek.nz'
$pic = 'C:\temp\example.png'; New-BTButton -Content 'View Picture' -Arguments $pic -ImageUri $pic
New-BTButton -Content 'Approve' -Arguments 'approve' -Color Green
New-BTButton -Content 'Delete' -Arguments 'delete' -Color Red

-ActivationType Protocol 이 기본값이라는 점이 핵심이다. -Arguments 에 URL·파일 경로·커스텀 프로토콜을 넣으면 Windows 셸이 그것을 연다. 즉 "로그 열기" 버튼-Arguments 'D:\...\run-xxx.log' 만으로 동작한다(연결 프로그램으로 열림). "재실행" 버튼은 실행 파일을 직접 호출할 수 없으므로 커스텀 프로토콜 핸들러를 등록해야 한다(§13.3).

11.3 win11toast 상세

설치: pip install win11toast

최소 예제:

from win11toast import toast
toast('Hello Python🐍')

제목 + 본문 + 클릭 시 URL:

toast('Hello Python', 'Click to open url', on_click='https://www.python.org')

프로토콜 버튼:

buttons = [
    {'activationType': 'protocol', 'arguments': 'https://google.com', 'content': 'Open Google'},
    {'activationType': 'protocol', 'arguments': 'file:///C:/Windows/Media', 'content': 'Open Folder'}
]
toast('Title', 'Body', buttons=buttons)

클릭 콜백:

toast('Hello', 'Message', on_click=lambda args: print('clicked!', args))

이미지:

toast('Hello', 'Body', image='https://example.com/image.png')
toast('Hello', 'Body', image={'src': 'url', 'placement': 'hero'})

진행 바:

from win11toast import notify, update_progress
notify(progress={'title': 'Title', 'status': 'Downloading...', 'value': '0'})
update_progress({'value': 0.5, 'valueStringOverride': '50%'})

TTS:

toast('Hello Python🐍', dialogue='Hello world')

의존성: "WinRT libraries for Windows 10/11 integration".

⚠️ win11toast 문서에는 서비스/작업 스케줄러 비대화형 실행 시의 제약이 언급되어 있지 않다. 그래도 §12 의 제약은 그대로 적용된다(OS 레벨 제약이므로).

11.4 windows-toasts 상세

요구사항: "Python 3.9 or later", "supports Windows 10 and 11". "A Python library that uses Windows SDK bindings to create and deliver notifications, avoiding workarounds like Powershell hacks."

설치:

python -m pip install windows-toasts

최소 예제:

from windows_toasts import WindowsToaster, Toast

toaster = WindowsToaster('Python')
newToast = Toast()
newToast.text_fields = ['Hello, World!']
toaster.show_toast(newToast)

구성 요소:

  • WindowsToaster : app identifier 로 초기화
  • Toast : 알림 객체
  • text_fields : 본문(리스트)
  • show_toast() : 표시

인터랙티브: InteractableWindowsToaster 클래스 + ToastButton + on_activated 콜백. AUMID: "Custom AUMIDs need registration via PowerShell or the registry for proper branding and notification delivery."

⚠️ 조사 시점에 readthedocs 랜딩/getting_started 페이지에서 "Custom AUMIDs", "Advanced usage", "Problem solving" 섹션의 상세 내용을 확인하지 못했다. Task Scheduler/서비스 관련 caveat 는 그 "Problem solving" 페이지에 있을 가능성이 높다 — ⚠️ 미검증(부록 B-5).


12. 토스트가 안 뜨는 조건과 폴백 설계

12.1 안 뜨는 조건 전체

# 조건 근거 대응
1 작업이 "사용자의 로그온 여부에 관계없이 실행"(S4U/Password) "Tasks that run when the user is not logged on do not have access to the user's interactive desktop" / "A task launching a GUI app was registered as 'run regardless.' The app was in fact running, but its window was nowhere to be seen because it runs in a non-interactive session — anything that requires an interactive display fundamentally cannot work under this configuration." / "This is not a bug — it's by design." 알림 전용 작업을 InteractiveToken 으로 분리(§2.3, §6.3)
2 Windows 서비스(Session 0) "Services cannot directly interact with a user as of Windows Vista." / NoInteractiveServices 기본값 1 서비스화하지 않는다(§7.7)
3 아무도 로그온하지 않은 상태 대화형 세션이 존재하지 않음 알림 큐 파일에 쌓아두고 로그온 트리거로 나중에 표시 + 웹훅 즉시 발사
4 집중 지원 / 방해 금지(Do not disturb) "Starting with Windows 11 build 22557, Microsoft reimagined focus assist experiences and it is now called Do not disturb." / "When Focus turns on Do Not Disturb, only apps in the priority list can send notifications. Suppressed notifications produce no sound, vibration, or visual pop-up when they arrive. The notification silently queues in the Action Center with its original timestamp preserved." 우선 순위 알림 목록에 PowerShell 추가(§12.2) + 웹훅 폴백
5 AppId(AUMID) 미등록 BurntToast v1.0.0 이 AppId 커스터마이징을 제거하고 "Windows shortcut creation with proper AppUserModelID" 를 도입 BurntToast 가 만드는 바로가기를 유지 / windows-toasts 는 AUMID 를 레지스트리에 등록
6 잠금 화면 상태 잠금 화면 알림 설정에 종속 웹훅 폴백
7 전체 화면(게임/프레젠테이션) Windows 자동 억제 Action Center 에 남음 + 웹훅
8 Windows 알림 전체 OFF 설정 → 시스템 → 알림 설치 시 점검 항목(§20)

12.2 집중 지원 / 방해 금지 대응

GUI 경로: 설정 → 시스템 → 알림 → 우선 순위 알림 설정(Set priority notifications)앱 추가(Add apps) → PowerShell(또는 알림을 보내는 앱) 선택.

레지스트리 경로(자동화용):

  • 메인 설정: HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings
  • 전역 토스트 활성화 키: NOC_GLOBAL_SETTING_TOASTS_ENABLED
  • 앱별 설정: HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings\<App ID> — "each application will have its own unique AppID and settings in this location"

PowerShell 명령(출처 원문):

# 방해 금지 비활성화 (= 토스트 허용)
New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings" `
    -Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -Value 0 -PropertyType DWord -Force

# 방해 금지 활성화
New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings" `
    -Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -Value 1 -PropertyType DWord -Force

주의사항(원문): "Settings apply per-user and don't require administrator rights unless restricted by enterprise policy" / "This still applies per-user, so you'll need to run any scripts that modify priority notification behavior at logon." / "Some applications use independent notification systems that won't be affected by Windows 11's built-in Do Not Disturb feature."

⚠️NOC_GLOBAL_SETTING_TOASTS_ENABLED 의 값 의미(0=허용/1=금지)는 출처 문서의 서술을 그대로 옮긴 것이며, 실제 동작 방향은 실측 확인이 필요하다⚠️ 미검증(부록 B-3). 우리 스크립트는 이 값을 건드리지 않는다. 사용자 설정을 임의로 바꾸는 것은 위험하고, 우리에겐 웹훅 폴백이 있다.

12.3 폴백 3단계 설계

[1단계] BurntToast 토스트 (대화형 세션)
        ├─ 성공 → 끝
        └─ 실패/세션 없음 ↓
[2단계] msg.exe * (활성 세션에 시스템 메시지 박스)
        ├─ 성공 → 끝
        └─ 실패(Home 에디션 / 세션 없음 / 권한 없음) ↓
[3단계] 웹훅 (Discord / Telegram / Slack) + healthchecks.io
        └─ PC 가 꺼져 있어도, 사용자가 자리에 없어도 도달

추가 보조: [System.Windows.Forms.MessageBox] 팝업 — 대화형 세션에서 토스트가 무시될 때 무조건 화면 중앙에 뜬다(단, 사용자가 닫아야 하므로 남용 금지).

12.4 msg.exe 상세 (공식)

msg {<username> | <sessionname> | <sessionID> | @<filename> | *}
    [/server:<servername>] [/time:<seconds>] [/v] [/w] [<message>]

Note: "You must have Message special access permission to send a message."

파라미터 설명(원문)
<username> "Specifies the name of the user that you want to receive the message. If you don't specify a user or a session, this command displays an error message. When specifying a session, it must be an active one."
<sessionname> 세션 이름
<sessionID> 세션 숫자 ID
@<filename> "Identifies a file containing a list of user names, session names, and session IDs"
* "Sends the message to all user names on the system."
/server:<servername> "If unspecified, /server uses the server to which you are currently logged on."
/time:<seconds> "Specifies the amount of time that the message you sent is displayed on the user's screen. ... If no time limit is set, the message defaults to 60 seconds and disappears."
/v "Displays information about the actions being performed."
/w "Waits for an acknowledgment from the user that the message has been received. Use this parameter with /time:<seconds> to avoid a possible long delay if the user does not immediately respond."
<message> "If no message is specified, you will be prompted to enter a message. To send a message that is contained in a file, type the less than (<) symbol followed by the file name."

공식 예제:

msg User1 Let's meet at 1PM today
msg modem02 Let's meet at 1PM today
msg @userlist Let's meet at 1PM today
msg * Let's meet at 1PM today
msg * /time:10 Let's meet at 1PM today

작업 스케줄러 조합 예(출처 원문):

schtasks /create /sc WEEKLY /tn "BackupReminder" /tr "msg.exe * 'Backup scheduled at 7:00PM. Be sure Passport drive and AC power are plugged in!'" /d THU /st 18:45

제약 (중요):

  • "The user must have Message access permission for the session to be able to send messages via msg command."
  • 레지스트리 AllowRemoteRPC 값이 1 이어야 한다("if the value is not 1, it should be changed to 1"). 경로는 HKLM\SYSTEM\CurrentControlSet\Control\Terminal ServerAllowRemoteRPC.
  • "The MSG command is only available to versions of Windows that are NOT the 'Home' version, such as Pro/Enterprise." → Windows 11 Home 에서는 msg.exe 가 없다. 이 프로젝트의 대상 PC 는 Windows 11 Pro 이므로 사용 가능하다.
  • 활성 세션이 없으면 전송되지 않는다.

12.5 웹훅 폴백 — 유일하게 항상 닿는 채널

Discord

$body = @{ content = "**🚨 DMF 크롤러 실패**`n로그: D:\workspace\DMF_Crawler\logs\run-xxx.log" } | ConvertTo-Json -Depth 3
Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
    -ContentType 'application/json; charset=utf-8' `
    -Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20

한글이 깨지지 않도록 반드시 [Text.Encoding]::UTF8.GetBytes() 로 바이트 배열을 넘긴다.

Telegram

Invoke-RestMethod -Uri "https://api.telegram.org/bot$TelegramToken/sendMessage" -Method Post -Body @{
    chat_id = $TelegramChatId
    text    = "🚨 DMF 크롤러 실패`nrun_id=$RunId`n$ErrorText"
} -TimeoutSec 20

Slack (Incoming Webhook)

$payload = @{ text = "🚨 DMF 크롤러 실패`nrun_id=$RunId" } | ConvertTo-Json -Depth 3
Invoke-RestMethod -Uri $SlackWebhook -Method Post `
    -ContentType 'application/json; charset=utf-8' `
    -Body ([Text.Encoding]::UTF8.GetBytes($payload)) -TimeoutSec 20

이메일 (SMTP)

Send-MailMessage -SmtpServer 'smtp.gmail.com' -Port 587 -UseSsl `
    -Credential $cred -From 'bot@example.com' -To 'yunchanpaca@gmail.com' `
    -Subject '[DMF] 크롤러 실패 알림' -Body $msg -Encoding UTF8

⚠️ Send-MailMessage 는 PowerShell 팀이 공식적으로 obsolete 로 표시한 cmdlet이다(대안: MailKit). 새로 짤 때는 웹훅을 우선하라.


13. 복구 안내 메시지 설계

13.1 메시지에 반드시 담을 4가지

요소 왜 필요한가 예시
무엇이 실패했는지 사람이 판단할 최소 정보 "06:00 DMF 크롤링이 실패했습니다 (목록 페이지 타임아웃)"
로그 경로 즉시 원인 확인 D:\workspace\DMF_Crawler\logs\run-20260902-060003-a7f31c.log
재실행 명령 복붙으로 즉시 복구 Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
담당자 에스컬레이션 "담당: 홍길동 (010-0000-0000)"

추가 권장: run_id(로그·리포트·이벤트 로그를 관통하는 상관 키), 호스트명, 실패 단계(failed_stage), 다음 자동 재시도 시각.

13.2 실제 문구 예시

토스트(짧게 — 토스트는 2~3줄이 한계)

제목 : 🚨 DMF 크롤러 실패 (06:00)
본문 : 목록 페이지 타임아웃 · run 20260902-060003
       로그를 열어 원인을 확인하거나 지금 재실행하세요.
버튼 : [로그 열기]  [지금 재실행]  [닫기]

웹훅(길게 — 전체 맥락)

🚨 DMF 크롤러 실패

실행 ID : 20260902-060003-a7f31c
호스트   : DESKTOP-XXXX
시작     : 2026-09-02 06:00:03
실패     : 2026-09-02 06:00:37 (34초)
단계     : crawl:list_page
오류     : PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'
exit     : 3

로그     : D:\workspace\DMF_Crawler\logs\run-20260902-060003-a7f31c.log
스크린샷 : D:\workspace\DMF_Crawler\logs\shots\20260902-060003-a7f31c-fail.png

▶ 재실행
  Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

▶ 상태 확인
  Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo

▶ 자동 재시도
  작업 스케줄러가 10분 간격으로 최대 3회 재시도합니다 (다음 06:10 예정).

담당: 홍길동 (010-0000-0000) / yunchanpaca@gmail.com

워치독 경보(PC 는 켜져 있는데 06:00 실행 흔적이 없음)

⚠️ DMF 크롤러 워치독 경보 (07:00)

07:00 시점에 오늘자 실행 기록을 찾지 못했습니다.

감지된 문제:
  - 마지막 성공 실행이 27.4시간 전입니다 (임계 26시간). run_id=20260901-060002-3b91de
  - 마지막 실행 결과 코드가 0x41306 (SCHED_S_TASK_TERMINATED) 입니다.

가능한 원인:
  1) 06:00 에 PC 가 꺼져 있었고 StartWhenAvailable 도 동작하지 않았다
  2) 작업이 ExecutionTimeLimit(2시간)을 넘겨 강제 종료되었다
  3) 작업이 [사용 안 함] 상태로 바뀌었다
  4) 계정 암호가 변경되어 Password 로그온이 실패했다

▶ 즉시 조치
  Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

▶ 진단
  Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo
  Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 |
      Where-Object { $_.Message -match 'DMF_Crawler' } | Format-List TimeCreated, Id, Message

로그 폴더: D:\workspace\DMF_Crawler\logs
담당: 홍길동 (010-0000-0000)

13.3 재실행 버튼을 위한 커스텀 프로토콜 핸들러

BurntToast 버튼의 -ActivationType 기본값이 Protocol 이므로, -Arguments 에 넣은 문자열은 셸이 URL 로 해석해 연다. 실행 파일을 바로 못 부르므로, dmfcrawler: 프로토콜을 레지스트리에 등록해 우회한다.

설치 스크립트 scripts\register-protocol.ps1 (관리자 권한 1회 실행)

#Requires -RunAsAdministrator
<#
.SYNOPSIS
    dmfcrawler: 커스텀 URL 프로토콜을 등록한다.
    토스트 버튼에서 dmfcrawler://retry 또는 dmfcrawler://openlog?path=... 를 호출할 수 있게 된다.
#>
param([string]$Root = 'D:\workspace\DMF_Crawler')

$handler = Join-Path $Root 'scripts\protocol-handler.ps1'
$pwsh    = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"

$key = 'HKCU:\Software\Classes\dmfcrawler'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name '(Default)'    -Value 'URL:DMF Crawler Protocol'
Set-ItemProperty -Path $key -Name 'URL Protocol' -Value ''

New-Item -Path "$key\shell\open\command" -Force | Out-Null
Set-ItemProperty -Path "$key\shell\open\command" -Name '(Default)' `
    -Value "`"$pwsh`" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$handler`" `"%1`""

Write-Host "[=] dmfcrawler: 프로토콜 등록 완료"
Write-Host "    테스트: Start-Process 'dmfcrawler://retry'"

핸들러 scripts\protocol-handler.ps1

<#
.SYNOPSIS
    dmfcrawler:// URL 을 처리한다.
.DESCRIPTION
    dmfcrawler://retry            → DMF_Crawler_Daily 작업을 즉시 실행
    dmfcrawler://openlog          → 최신 로그 파일을 메모장으로 열기
    dmfcrawler://openlogdir       → 로그 폴더를 탐색기로 열기
    dmfcrawler://openreport       → 최신 xlsx 리포트 열기
#>
param([string]$Url)

$Root    = 'D:\workspace\DMF_Crawler'
$Logs    = Join-Path $Root 'logs'
$Reports = Join-Path $Root 'reports'
$State   = Join-Path $Root 'state'

# dmfcrawler://retry/  형태에서 명령 추출
$cmd = ($Url -replace '^dmfcrawler:/*', '') -replace '/.*$', ''
$cmd = $cmd.ToLowerInvariant().Trim()

switch ($cmd) {
    'retry' {
        try {
            Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
            [System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
            [System.Windows.Forms.MessageBox]::Show(
                'DMF 크롤러를 재실행했습니다.' + "`n" +
                '진행 상황은 로그 폴더에서 확인하세요:' + "`n" + $Logs,
                'DMF Crawler', 'OK', 'Information') | Out-Null
        } catch {
            [System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
            [System.Windows.Forms.MessageBox]::Show(
                "재실행 실패:`n$($_.Exception.Message)", 'DMF Crawler', 'OK', 'Error') | Out-Null
        }
    }
    'openlog' {
        $latest = Get-ChildItem -Path $Logs -Filter 'run-*.log' -File -ErrorAction SilentlyContinue |
                  Sort-Object LastWriteTime -Descending | Select-Object -First 1
        if ($latest) { Start-Process notepad.exe $latest.FullName }
        else         { Start-Process explorer.exe $Logs }
    }
    'openlogdir'  { Start-Process explorer.exe $Logs }
    'openreport'  {
        $latest = Get-ChildItem -Path $Reports -Filter '*.xlsx' -File -ErrorAction SilentlyContinue |
                  Sort-Object LastWriteTime -Descending | Select-Object -First 1
        if ($latest) { Start-Process $latest.FullName }
        else         { Start-Process explorer.exe $Reports }
    }
    default { Start-Process explorer.exe $Root }
}

프로토콜 없이 가는 간단한 대안: 버튼 -Arguments 에 로그 파일 경로(→ 연결 프로그램으로 열림)나 폴더 경로(→ 탐색기로 열림)를 그대로 넣으면 프로토콜 등록 없이도 "로그 열기"는 동작한다. "재실행"만 프로토콜이 필요하다.

13.4 스크립트 4 — scripts\notify.ps1 (완결)

<#
.SYNOPSIS
    알림 큐(state\notify-queue\*.json)를 읽어 Windows 토스트로 표시한다.
.DESCRIPTION
    DMF_Crawler_Notify 작업(대화형, LogonType=InteractiveToken)이 호출한다.
    - 큐 파일을 최신순으로 읽어 표시하고, 표시한 파일은 archive 로 이동
    - BurntToast 실패 시 msg.exe → MessageBox 순으로 폴백
    - 큐가 비어 있으면 아무것도 하지 않고 조용히 종료
.NOTES
    이 스크립트는 반드시 대화형 세션에서 실행되어야 한다.
    S4U/Password 컨텍스트에서 실행하면 토스트가 표시되지 않는다.
#>
[CmdletBinding()]
param(
    [string]$Root = 'D:\workspace\DMF_Crawler',
    [int]$MaxToasts = 3,          # 한 번에 표시할 최대 토스트 수(폭주 방지)
    [switch]$TestMode             # 큐와 무관하게 샘플 토스트 1개 표시
)

$ErrorActionPreference = 'Continue'
Set-StrictMode -Version Latest

$State    = Join-Path $Root 'state'
$Logs     = Join-Path $Root 'logs'
$Reports  = Join-Path $Root 'reports'
$QueueDir = Join-Path $State 'notify-queue'
$Archive  = Join-Path $QueueDir 'archive'
$NfLog    = Join-Path $Logs ('notify-{0}.log' -f (Get-Date -Format 'yyyyMM'))

foreach ($d in @($QueueDir, $Archive, $Logs)) {
    if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

function Write-Log {
    param([string]$Level, [string]$Message)
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
    Add-Content -Path $NfLog -Value $line -Encoding UTF8
}

# ---------------------------------------------------------------- BurntToast 준비
$HasBurntToast = $false
try {
    if (-not (Get-Module -ListAvailable -Name BurntToast)) {
        Write-Log 'INFO' 'BurntToast 미설치 → 설치 시도'
        Install-Module -Name BurntToast -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
    }
    Import-Module BurntToast -ErrorAction Stop
    $HasBurntToast = $true
} catch {
    Write-Log 'WARN' "BurntToast 사용 불가: $($_.Exception.Message)"
}

# ---------------------------------------------------------------- 폴백들
function Show-MsgExe {
    param([string]$Text)
    try {
        # /time:0 은 사용자가 닫을 때까지 표시. 여기서는 120초.
        & "$env:SystemRoot\System32\msg.exe" '*' '/time:120' $Text 2>$null
        if ($LASTEXITCODE -eq 0) { Write-Log 'INFO' 'msg.exe 전송 성공'; return $true }
    } catch { }
    Write-Log 'WARN' 'msg.exe 실패 (Home 에디션 / 활성 세션 없음 / 권한 없음)'
    return $false
}

function Show-MessageBox {
    param([string]$Title, [string]$Text, [string]$Icon = 'Error')
    try {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
        [System.Windows.Forms.MessageBox]::Show($Text, $Title, 'OK', $Icon) | Out-Null
        Write-Log 'INFO' 'MessageBox 표시 성공'
        return $true
    } catch {
        Write-Log 'WARN' "MessageBox 실패: $($_.Exception.Message)"
        return $false
    }
}

# ---------------------------------------------------------------- 토스트 표시
function Show-DmfToast {
    param([psobject]$Item)

    $level  = if ($Item.PSObject.Properties.Name -contains 'level') { [string]$Item.level } else { 'info' }
    $title  = if ($Item.PSObject.Properties.Name -contains 'title') { [string]$Item.title } else { 'DMF Crawler' }
    $body   = if ($Item.PSObject.Properties.Name -contains 'body')  { [string]$Item.body  } else { '' }
    $logRef = if ($Item.PSObject.Properties.Name -contains 'log')   { [string]$Item.log   } else { $Logs }
    $owner  = if ($Item.PSObject.Properties.Name -contains 'owner') { [string]$Item.owner } else { $null }
    $runId  = if ($Item.PSObject.Properties.Name -contains 'run_id'){ [string]$Item.run_id} else { '' }

    $icon  = if ($level -eq 'error') { '🚨' } elseif ($level -eq 'warn') { '⚠️' } else { '' }
    $head  = "$icon $title"
    $lines = @()
    if ($body)  { $lines += $body }
    if ($runId) { $lines += "run $runId" }
    if ($owner) { $lines += "담당: $owner" }
    $text = $lines -join "`n"

    if ($HasBurntToast) {
        try {
            $buttons = @()

            # 로그 열기: ActivationType 기본값이 Protocol 이므로 파일/폴더 경로를 그대로 넘긴다
            $logTarget = if (Test-Path $logRef) { $logRef } else { $Logs }
            $buttons += New-BTButton -Content '로그 열기' -Arguments $logTarget

            if ($level -eq 'error') {
                # 재실행: dmfcrawler: 프로토콜이 등록되어 있어야 동작(§13.3)
                $buttons += New-BTButton -Content '지금 재실행' -Arguments 'dmfcrawler://retry' -Color Red
            } else {
                $rep = Get-ChildItem -Path $Reports -Filter '*.xlsx' -File -ErrorAction SilentlyContinue |
                       Sort-Object LastWriteTime -Descending | Select-Object -First 1
                if ($rep) { $buttons += New-BTButton -Content '리포트 열기' -Arguments $rep.FullName -Color Green }
            }

            $buttons += New-BTButton -Dismiss -Content '닫기'

            $params = @{
                Text   = @($head, $text)
                Button = $buttons
            }
            # 오류는 Urgent(중요 알림)로 — 방해 금지 상태에서도 뚫을 확률을 높인다
            if ($level -eq 'error' -and (Get-Command New-BurntToastNotification).Parameters.ContainsKey('Urgent')) {
                $params['Urgent'] = $true
            }

            New-BurntToastNotification @params
            Write-Log 'INFO' "토스트 표시: $head"
            return $true
        }
        catch {
            Write-Log 'WARN' "BurntToast 표시 실패: $($_.Exception.Message)"
        }
    }

    # 폴백 1: msg.exe
    $flat = "$head`n$text`n로그: $logTarget"
    if (Show-MsgExe -Text $flat) { return $true }

    # 폴백 2: MessageBox
    $ic = if ($level -eq 'error') { 'Error' } else { 'Information' }
    if (Show-MessageBox -Title $head -Text $flat -Icon $ic) { return $true }

    Write-Log 'ERROR' '모든 알림 경로 실패'
    return $false
}

# ---------------------------------------------------------------- 실행
if ($TestMode) {
    Show-DmfToast ([pscustomobject]@{
        level  = 'error'
        title  = 'DMF 크롤러 실패 (테스트)'
        body   = '목록 페이지 타임아웃 · 이것은 테스트 알림입니다'
        log    = $Logs
        owner  = '테스트 담당자'
        run_id = 'TEST-000000'
    })
    exit 0
}

$items = Get-ChildItem -Path $QueueDir -Filter 'notify-*.json' -File -ErrorAction SilentlyContinue |
         Sort-Object LastWriteTime -Descending

if (-not $items) {
    Write-Log 'INFO' '알림 큐 비어 있음 — 종료'
    exit 0
}

$shown = 0
foreach ($f in $items) {
    try {
        $obj = Get-Content $f.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
    } catch {
        Write-Log 'WARN' "큐 파일 파싱 실패($($f.Name)): $($_.Exception.Message)"
        Move-Item -Path $f.FullName -Destination (Join-Path $Archive $f.Name) -Force -ErrorAction SilentlyContinue
        continue
    }

    if ($shown -lt $MaxToasts) {
        [void](Show-DmfToast -Item $obj)
        $shown++
        Start-Sleep -Milliseconds 800   # 토스트가 겹치지 않도록 간격
    } else {
        Write-Log 'INFO' "표시 한도 초과 — 건너뜀: $($f.Name)"
    }

    Move-Item -Path $f.FullName -Destination (Join-Path $Archive $f.Name) -Force -ErrorAction SilentlyContinue
}

# archive 정리 (30일)
Get-ChildItem -Path $Archive -Filter '*.json' -File -ErrorAction SilentlyContinue |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
    Remove-Item -Force -ErrorAction SilentlyContinue

Write-Log 'INFO' "완료 — 표시 $shown 건 / 처리 $($items.Count) 건"
exit 0

테스트:

# 대화형 세션에서 직접
.\scripts\notify.ps1 -TestMode

# 작업으로
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Notify'

14. 재부팅 · 전원 · 시간대

14.1 자동 로그온이 필요한가?

결론: 필요 없다. 그리고 쓰지 마라.

크롤링 본체는 S4U/Password 로 로그온 없이도 실행된다. 자동 로그온이 필요한 유일한 이유는 "재부팅 직후에도 토스트를 띄우고 싶다" 인데, 그것은 웹훅으로 대체된다(§12.5).

Sysinternals Autologon 을 굳이 쓴다면(공식 문서):

"Autologon enables you to easily configure Windows' built-in autologon mechanism. Instead of waiting for a user to enter their name and password, Windows uses the credentials you enter with Autologon, which are encrypted in the Registry, to log on the specified user automatically."

⚠️ WARNING: "Although the password is encrypted in the registry as an LSA secret, a user with administrative rights can easily retrieve and decrypt it."

사용법:

  • GUI: autologon.exe 실행 → 다이얼로그 입력 → Enable
  • 명령줄: autologon user domain password
  • 해제: Disable 버튼, 또는 부팅 시 Shift 키를 누르고 있으면 그 회차의 autologon 이 비활성화됨
  • 다운로드: https://download.sysinternals.com/files/AutoLogon.zip (495 KB) / Sysinternals Live: https://live.sysinternals.com/Autologon.exe
  • 주의: "Autologon does not verify the submitted credentials, nor does it verify that the specified user account is allowed to log on to the computer."
  • 주의: "When Exchange Activesync password restrictions are in place, Windows will not process the autologon configuration."

보안 판단: 제약사 DMF 데이터를 다루는 PC 에 평문에 준하는 자동 로그온 자격증명을 남기는 것은 부적절하다. 채택하지 않는다.

14.2 Fast Startup(빠른 시작)과 AtStartup 트리거

Fast Startup 이 무엇을 하는가(공식 커널 문서 원문):

"To prepare for a fast startup, Windows performs a full shutdown sequence and saves a hibernation file.

  1. First, as in a full shutdown, Windows closes all applications and logs off all user sessions. At this stage, no applications are running, but the Windows kernel is loaded and the system session is running.
  2. Next, the power manager sends system power IRPs to device drivers to tell them to prepare their devices to enter hibernation.
  3. Finally, Windows saves the kernel memory image (including the loaded kernel-mode drivers) in Hiberfil.sys and shuts down the computer."

"During a cold startup, the boot loader constructs a kernel memory image by loading the sections of the Windows kernel file into memory and linking them. ... In contrast, a fast startup simply loads the hibernation file (Hiberfil.sys) into memory."

Windows 의 시작 모드는 3가지: Cold(traditional), Wake-from-hibernation, Fast(앞의 둘을 결합, Windows 8 도입).

Fast Startup 여부는 드라이버 수준에서 SYSTEM_POWER_STATE_CONTEXTTargetSystemState / EffectiveSystemState 비트필드로 구분한다:

  • TargetSystemState = PowerSystemHibernate AND EffectiveSystemState = PowerSystemHibernatewake-from-hibernation
  • TargetSystemState = PowerSystemShutdown AND EffectiveSystemState = PowerSystemHibernatefast startup

AtStartup 이 안 뜨는가: "when Fast Startup is turned on and user shuts down system, the system goes to hibernation and then wakes up instead of booting from scratch. Since the system is resuming from hibernation rather than performing an actual boot/startup, the 'At startup' trigger may not fire as expected."

중요 예외: "The Fast Startup setting doesn't apply to Restart." — 즉 다시 시작(Restart) 은 항상 완전 부팅이므로 AtStartup 이 발화한다. 종료(Shutdown) 후 켜기만 문제다.

Fast Startup 관련 공식 문서의 추가 정보:

  • 활성 위치: Control Panel\All Control Panel Items\Power Options\System Settings
  • "Fast Startup is enabled by default in Windows."
  • "Disabling Fast Startup is not recommended."
  • 하이브리드 종료를 피하려면: Shutdown /s /t 0 (전체 종료가 기본), 하이브리드를 쓰려면 Shutdown /s /hybrid /t 0
  • 관련 트러블슈팅 레지스트리: HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\CrashControl\DumpFilters — "Remove everything and make sure that dumpfve.sys is the only value listed." (이벤트 ID 45 가 System 로그에 있을 때)

대응 3가지 (우선순위 순):

  1. StartWhenAvailable=true 로 충분하다 (권장). 06:00 을 놓쳤으면 부팅/복귀 후 곧바로 실행된다. AtStartup 트리거는 보조일 뿐이다.
  2. 이벤트 트리거로 대체. 로그: Microsoft-Windows-Diagnostics-Performance, 소스: PowerTroubleshooter, Event ID 1 — 절전/최대 절전 복귀를 감지한다. (Fast Startup 도 하이버네이션 복귀이므로 이 이벤트가 발생한다.)
  3. Fast Startup 을 끈다 (마지막 수단):
    # 최대 절전 자체를 끄면 Fast Startup 도 함께 꺼진다 (hiberfil.sys 삭제됨)
    powercfg /hibernate off
    
    # 또는 최대 절전은 유지하고 Fast Startup(하이버부트)만 끈다
    Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' `
        -Name 'HiberbootEnabled' -Value 0 -Type DWord
    

    ⚠️ HiberbootEnabled 레지스트리 값 이름과 경로는 널리 알려진 값이나, 이번 조사에서 인용한 Microsoft 문서에는 명시되어 있지 않았다⚠️ 미검증(부록 B-7). 공식 문서가 확인해 준 것은 "Fast Startup 은 Control Panel...\Power Options\System Settings 에서 켜고 끈다" 와 "Shutdown /s /t 0 가 완전 종료" 뿐이다.

참고 워크어라운드(커뮤니티): 모든 Startup 트리거 작업을 로그온 시 강제로 한 번 돌리는 방법

Get-ScheduledTask | Where-Object {$_.Triggers.TriggerType -eq 'Startup'} | Start-ScheduledTask

(HKLM\...\Run 이나 바로가기로 트리거) — 우리는 StartWhenAvailable 로 충분하므로 쓰지 않는다.

14.3 BitLocker 사전 부팅 PIN 과 무인 재부팅

핵심 사실(Microsoft BitLocker countermeasures 문서):

"Preboot authentication can make it more difficult to update unattended or remotely administered devices because a PIN must be entered when a device reboots or resumes from hibernation."

"The only supported silent configuration for BitLocker involves the TPM only."

Network Unlock: "Network Unlock allows systems that meet the hardware requirements and have BitLocker enabled with TPM+PIN to boot into Windows without user intervention. It requires direct ethernet connectivity to a Windows Deployment Services (WDS) server."

우리 상황에 대한 결론:

구성 재부팅 후 자동 복구 권고
BitLocker 미사용 완전 자동 물리 보안이 확보된 사내 PC 라면 허용 가능
BitLocker TPM-only 완전 자동 권장 — 디스크 도난 대비 + 무인 부팅 양립
BitLocker TPM+PIN 사람이 PIN 입력해야 부팅 이 프로젝트에는 부적합
TPM+PIN + Network Unlock 자동 (WDS 서버 필요) 단독 PC 에 WDS 를 세울 이유가 없음

또한 "conflicts can occur when using MDM and GPO settings together" — 회사 정책이 MDM 으로 TPM+PIN 을 강제한다면 재부팅 후 자동 복구는 포기하고, 대신 워치독 + 웹훅으로 "PC 가 PIN 입력 대기 중" 을 사람이 알아채게 설계해야 한다.

현재 상태 확인:

manage-bde -status C:
Get-BitLockerVolume | Select-Object MountPoint, ProtectionStatus, KeyProtector

KeyProtectorTpmPin 이 있으면 무인 재부팅 불가.

14.4 Windows Update 재부팅과 06:00 충돌 회피

→ §15 에서 상세히 다룬다.

14.5 절전 · 최대 절전 · powercfg

WakeToRun 이 동작하려면 3가지가 모두 필요하다:

  1. 작업 설정에 <WakeToRun>true</WakeToRun>
  2. 전원 관리 옵션에서 "절전 모드 해제 타이머 허용(Allow wake timers)" 이 켜져 있어야 함
  3. 하드웨어/BIOS 가 웨이크 타이머를 지원해야 함

권장 전원 설정 (AC 전원 기준):

# 관리자 권한 필수 ("To make changes to power settings, Powercfg must be run from an elevated command prompt.")

# AC 전원에서 절전/최대 절전 진입 안 함
powercfg /change standby-timeout-ac 0
powercfg /change hibernate-timeout-ac 0

# 화면만 끄기 (10분)
powercfg /change monitor-timeout-ac 10

# 디스크는 끄지 않음
powercfg /change disk-timeout-ac 0

# 웨이크 타이머 허용 (현재 활성 전원 구성표, AC)
# GUID: SUB_SLEEP = 238c9fa8-0aad-41ed-83f4-97be242c8f20
#       RTCWAKE   = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
powercfg /setactive SCHEME_CURRENT

# 현재 웨이크 타이머 목록 확인 (우리 작업이 보여야 한다)
powercfg /waketimers

# 무엇이 PC 를 깨우는지 조사
powercfg /lastwake
powercfg /devicequery wake_armed

공식/출처 확인 사항:

  • "The /change parameter allows you to modify settings in the current power scheme, including standby-timeout-ac, hibernate-timeout-ac and other timeout settings, with values specified in minutes."
  • "-ac refers to AC power (plugged-in), while DC refers to battery power"
  • "standby-timeout-ac 0: Sets standby timeout to 0 minutes when plugged in (prevents sleep)"
  • "hibernate-timeout-ac 0: Sets hibernation timeout to 0 minutes when plugged in (prevents hibernation)"
  • "/hibernate off: Disables hibernation functionality"
  • "Wake timers can be enumerated with /WakeTimers and are typically used to run scheduled tasks. The /waketimers command helps locate which schedules are authorized to wake the computer from sleep or hibernation."

⚠️ 위 GUID 두 개(238c9fa8-... = 절전 하위 그룹, bd3b718a-... = 절전 모드 해제 타이머 허용)는 널리 통용되는 표준 값이지만 이번 조사에서 Microsoft 문서로 직접 확인하지 못했다⚠️ 미검증(부록 B-8). 확실한 검증 방법은 powercfg /query SCHEME_CURRENT SUB_SLEEP 출력에서 실제 GUID 를 읽는 것이다.

가장 안전한 운영 방침: 이 PC 는 24시간 켜 두고 절전에 들어가지 않게 한다. 그러면 WakeToRun 에 의존할 필요조차 없다. WakeToRun 은 사용자가 실수로 절전을 켰을 때의 보험이다.

14.6 부팅 후 네트워크 대기

부팅 직후에는 네트워크 스택·DNS·프록시가 아직 준비되지 않았을 수 있다. 3중으로 방어한다:

  1. <BootTrigger><Delay>PT3M</Delay> — 부팅 후 3분 대기(§3.1)
  2. RunOnlyIfNetworkAvailable=true — "Specifies that the Task Scheduler will run the task only when a network is available." 특정 프로필만 보려면 NetworkSettings/NetworkProfileName 을 지정
  3. 스크립트 내부 재시도 루프 — 가장 확실하다:
# run-daily.ps1 본체 실행 직전에 삽입
function Wait-Network {
    param([string]$TestHost = 'nedrug.mfds.go.kr', [int]$MaxTries = 20, [int]$DelaySec = 15)
    for ($i = 1; $i -le $MaxTries; $i++) {
        try {
            $ok = Test-NetConnection -ComputerName $TestHost -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
            if ($ok) { Write-Log 'INFO' "네트워크 준비 완료 (시도 $i)"; return $true }
        } catch { }
        Write-Log 'WARN' "네트워크 대기 중... ($i/$MaxTries)"
        Start-Sleep -Seconds $DelaySec
    }
    Write-Log 'ERROR' "네트워크 준비 실패 (${MaxTries}회 시도, 총 $($MaxTries*$DelaySec)초)"
    return $false
}

if (-not (Wait-Network)) { throw '네트워크를 사용할 수 없어 크롤링을 중단합니다.' }

(최대 20회 × 15초 = 5분. ExecutionTimeLimit PT2H 안에서 충분히 여유롭다.)

14.7 시간대 · DST · "Synchronize across time zones"

"Synchronize across time zones" 가 하는 일: "When you check the 'Synchronize across time zones' option, the task is scheduled by UTC. If the computer's time zone is changed, the task will continue to run according to the UTC time recorded in the task's XML file."

중요한 기본값 차이:

"When creating a scheduled task trigger using the Task Scheduler UI, the setting 'Synchronize Across Time Zones' is disabled by default. However, the PowerShell cmdlet New-ScheduledTaskTrigger exhibits the opposite behavior."

New-ScheduledTaskTrigger 로 만든 트리거는 기본적으로 UTC 기준(동기화 ON)이 된다. XML 의 <StartBoundary> 에 타임존 오프셋(2026-09-02T06:00:00+09:00)이 붙으면 UTC 동기화, 오프셋이 없으면(2026-09-02T06:00:00) 로컬 시간이다.

우리 프로젝트에 대한 결론:

  • 한국(Asia/Seoul, KST, UTC+9)은 DST 를 시행하지 않는다. 따라서 DST 전환 문제는 발생하지 않는다.
  • 그럼에도 <StartBoundary> 에 오프셋을 넣지 않고 로컬 시간으로 두는 것을 권장한다. "매일 아침 06:00" 이라는 업무 요구를 그대로 표현하며, PC 시간대가 바뀌더라도 "그 PC 의 아침 6시" 라는 의미가 유지된다.
  • §6.2 XML 은 <StartBoundary>2026-09-02T06:00:00</StartBoundary> — 오프셋 없음(로컬 시간)으로 작성했다.

DST 지역에서의 알려진 동작(참고): "on the day that daylight savings time starts in a year, for a Pacific time zone (time shifted from 02:00 AM to 03:00 AM in 2022), any task that's scheduled to run between 02:00 AM and 02:59:59:999 AM will run at the earliest possible time that exists in that given day — that is, at 03:00 AM. This applies to all DST time zones based on the various times at which they observe DST." (주간/월간 캘린더 트리거 기준)

알려진 이슈: "After the end of DST, jobs may run one hour early, but after removing 'Synchronize across time zones' from the schedule, the start time becomes the current local non-DST time." / "there appears to be a bug when using the 'once per week on a specific day' option with DST changes." / 서버 재부팅 + DST 전환이 겹치면 작업이 예상치 못한 시각에 실행되는 사례도 보고되었다.

시간대 확인/설정:

Get-TimeZone
Set-TimeZone -Id 'Korea Standard Time'
w32tm /query /status          # 시간 동기화 상태
w32tm /resync                 # 강제 동기화

15. Windows Update 재부팅과 06:00 충돌 회피

15.1 활성 시간(Active hours)

공식 문서(Manage device restarts after updates):

"Active hours identify the period of time when you expect the device to be in use. Automatic restarts after an update occur outside of the active hours." "By default, active hours are from 8 AM to 5 PM on PCs. Users can manually change the active hours." "The max active hours length for Windows 10, version 1607 and Windows Server 2016 is 12. Later versions support max active hours length of 18 hours."

문제: 기본 활성 시간이 08:00~17:00 이므로 06:00 은 활성 시간 밖이다. → Windows Update 가 정확히 우리 실행 시각에 재부팅할 수 있다.

해결: 활성 시간을 05:00 ~ 22:00 (17시간) 으로 설정한다. 06:00 이 활성 시간 안에 들어가 자동 재부팅이 억제된다. 최대 18시간 제한 안에 있다.

그룹 정책 경로:

컴퓨터 구성\관리 템플릿\Windows 구성 요소\Windows 업데이트
→ "활성 시간 동안 업데이트에 대한 자동 다시 시작 해제"
   (Turn off auto-restart for updates during active hours)
   → 사용 → 시작/종료 시각 설정

최대 범위 정책:

컴퓨터 구성\관리 템플릿\Windows 구성 요소\Windows 업데이트
→ "자동 다시 시작에 대한 활성 시간 범위 지정"
   (Specify active hours range for auto-restarts)

MDM (Update Policy CSP): ActiveHoursStart, ActiveHoursEnd, ActiveHoursMaxRange

레지스트리 (공식 문서가 권장하지 않는 방법):

"Note: Directly editing the Windows registry isn't recommended." "This method isn't recommended, and should only be used when you can't use group policy or MDM. Any settings configured through the registry might conflict with any existing configuration that uses any of the other methods."

정책 경로 (공식):

HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate
    SetActiveHours       (활성 시간 기능 켜기/끄기)
    ActiveHoursStart     (시작 시각)
    ActiveHoursEnd       (종료 시각)

사용자 설정 경로 (비정책, 커뮤니티 출처):

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings
    ActiveHoursStart     REG_DWORD  (24시간제)
    ActiveHoursEnd       REG_DWORD  (24시간제)

"Time values can range from integer values 0-24 (0-12 meaning 12:00 A.M. to 12:00 P.M. and 13-24 meaning 1:00 P.M. to 12:00 A.M.). For example, 8 = 8:00 AM."

커뮤니티에서 통용되는 명령 예시(원문):

reg add "HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" /v "ActiveHoursStart" /t REG_DWORD /d 5 /f
reg add "HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" /v "ActiveHoursEnd"   /t REG_DWORD /d 16 /f

DMF_Crawler 권장 스크립트 (scripts\set-active-hours.ps1, 관리자 권한):

#Requires -RunAsAdministrator
<#
.SYNOPSIS
    Windows Update 활성 시간을 05:00~22:00 으로 설정해 06:00 크롤링과의 재부팅 충돌을 막는다.
.NOTES
    정책 경로(HKLM\Software\Policies\...)를 우선 사용한다.
    최대 활성 시간 길이는 18시간이므로 05→22(17시간)는 유효하다.
#>
$policy = 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate'
if (-not (Test-Path $policy)) { New-Item -Path $policy -Force | Out-Null }

New-ItemProperty -Path $policy -Name 'SetActiveHours'   -Value 1  -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5  -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path $policy -Name 'ActiveHoursEnd'   -Value 22 -PropertyType DWord -Force | Out-Null

Write-Host '[=] Windows Update 활성 시간을 05:00~22:00 으로 설정했습니다.'
Write-Host '    확인: 설정 > Windows 업데이트 > 고급 옵션 > 활성 시간'
gpupdate /target:computer /force | Out-Null

GUI 확인 경로: "To manually configure active hours on a device, go to Settings > Windows Update > Advanced options and select Active hours."

15.2 로그온한 사용자가 있을 때 재부팅 안 함

정책: "No auto-restart with logged on users for scheduled automatic updates installations"

"prevents automatic restart when a user is signed in. If a user schedules the restart in the update notification, the device restarts at the time the user specifies even if a user is signed in at the time. This policy only applies when Configure Automatic Updates is set to option 4 - Auto download and schedule the install."

레지스트리:

HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU
    AuOptions                    = 4
    NoAutoRebootWithLoggedOnUsers = 1

공식 주의사항(중요):

"When using Remote Desktop Protocol (RDP) connections, only active RDP sessions are considered signed-in users. Devices that don't have locally signed-in users, or active RDP sessions, are restarted."

"The No auto-restart with logged on users policy was never created as a CSP. In Group Policy this policy doesn't work exactly as per description. This policy can result in no quality update reboots period, given many users never log off. The recommendation to replace this would be to leverage compliance deadline and then to configure no-auto reboot to prevent non-user aware reboots prior to the deadline being reached. For server devices, leverage Configure Automatic Updates options 7 - notify to install and notify to reboot."

이 정책 하나에 의존하지 마라. 우리 PC 는 무인 운영이므로 "로그온한 사용자" 가 없을 가능성이 높고, 그러면 이 정책은 무의미하다. 활성 시간(§15.1)이 주 방어선이다.

15.3 설치 시각 예약과 강제 재부팅

HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU
    AuOptions                              = 4    (Auto download and schedule the install)
    ScheduledInstallTime                   = <시각>
    AlwaysAutoRebootAtScheduledTime        = 0/1
    AlwaysAutoRebootAtScheduledTimeMinutes = <분>  (로그온 사용자에게 경고할 타이머)

"The setting to Always automatically restart at the scheduled time forces a restart after the specified installation time. It lets you configure a timer to warn a signed-in user that a restart is going to occur. This policy is a legacy policy and isn't applicable for Windows 11."

"Turn off auto-restart for updates during active hours prevents automatic restart during active hours."

15.4 재시작 지연 한도

"After Windows installs an update, it attempts to automatically restart outside of active hours. If the restart doesn't succeed after a default period of seven days, the user sees a notification that a restart is required. To change the delay, use the setting to Specify deadline before auto-restart for update installation. The minimum value is two days and the maximum value is two weeks (14 days). This policy is a legacy policy and isn't applicable for Windows 11."

15.5 재부팅 후 자기 치유 확인

Windows Update 로 재부팅되었더라도 다음이 보장된다:

  1. StartWhenAvailable=true → 06:00 을 놓쳤으면 부팅 후 즉시 실행
  2. <BootTrigger><Delay>PT3M</Delay> → 부팅 3분 후에도 트리거(Restart 는 완전 부팅이므로 확실히 발화)
  3. MultipleInstancesPolicy=IgnoreNew → 위 둘이 동시에 걸려도 중복 실행되지 않음
  4. 07:00 워치독이 최종 확인

재부팅 이력 확인:

# 최근 시스템 시작/종료 이벤트
Get-WinEvent -FilterHashtable @{ LogName='System'; Id=6005,6006,6008,1074,41 } -MaxEvents 30 |
    Select-Object TimeCreated, Id, Message | Format-List

# Windows Update 설치 이력
Get-WinEvent -FilterHashtable @{ LogName='System'; ProviderName='Microsoft-Windows-WindowsUpdateClient' } -MaxEvents 20 |
    Select-Object TimeCreated, Id, Message | Format-List

16. WSL2 / Docker Desktop 옵션과 한계

16.1 WSL2 + cron / systemd

systemd 활성화 방법(공식 문서):

  1. WSL 버전이 0.67.6 이상이어야 한다.
    • 확인: wsl --version — "if the command throws Invalid command line option: --version error, you must update WSL"
    • 업데이트: wsl --update 또는 Microsoft Store 에서 최신 버전 설치(https://aka.ms/wslstorepage)
  2. /etc/wsl.conf 편집:
    [boot]
    systemd=true
    
  3. wsl.exe --shutdown 으로 전체 WSL 인스턴스 재시작
  4. 확인: systemctl status / systemctl list-unit-files --type=service

Debian/Ubuntu/Kali Rolling 계열은 systemd-sysv 패키지도 필요:

sudo apt-get update -y && sudo apt-get install systemd systemd-sysv -y

"Systemd is now the default for the current version of Ubuntu that will be installed using the wsl --install command default."

wsl.conf[boot] 섹션(공식 표, Windows 11 및 Server 2022 전용):

Key Value Default Notes
command string Null "A string of the command that you would like to run when the WSL instance starts. This command is run as the root user. e.g: service docker start."
protectBinfmt boolean true "Prevents WSL from generating systemd units when systemd is enabled."
systemd boolean (배포판 종속) systemd 를 init 으로 사용

wsl.conf 전체 섹션: automount, network, interop, user, boot, gpu, time. ([time] useWindowsTimezone 기본 true — Windows 시간대를 따라간다.)

설정 반영 규칙(중요): "The 8 second rule for configuration changes — You must wait until the subsystem running your Linux distribution completely stops running and restarts for configuration setting updates to appear. This typically takes about 8 seconds after closing ALL instances of the distribution shell." 확인: wsl --list --running.

치명적 한계 (공식 원문):

"It is also important to note that with these changes, systemd services will NOT keep your WSL instance alive. Your WSL instance will stay alive in the same way it did previous to this update."

WSL 배포판이 살아 있지 않으면 systemd 서비스도 cron 도 돌지 않는다.

커뮤니티에서 확인된 추가 사실:

  • "Cron inside WSL will not run until the WSL distro starts. WSL doesn't start cron automatically, meaning that your automated tasks aren't getting executed by default."
  • "if you setup a Task Scheduler event to start the WSL cron service on bootup, the cron does not run automatically."
  • 해결: "create a Windows Scheduled Task that starts WSL at boot or at user logon" + 배포판을 살려두는 프로세스:
    systemctl start cron; nohup sleep infinity >/dev/null 2>&1
    
    "this command starts the cron service while keeping a lightweight process running so the distro doesn't immediately exit."
  • "WSL can stop when no Linux processes are running. If cron is running under systemd, that may be enough. If your distro still exits, keep one harmless long-running process active."
  • "The key issue is that you need a process running that's a child of the Microsoft init (like an interactive shell) to keep WSL from idle-terminating."

관련 이슈: microsoft/WSL#9072("Cron will not keep running in background and does not log to cron.log"), microsoft/WSL#10732("WSL cannot be run by scheduled task").

결론: 채택하지 않는다.

  • 어차피 Windows 작업 스케줄러로 WSL 을 띄워야 하므로, 작업 스케줄러 의존을 제거하지 못한다. 계층만 하나 늘어난다.
  • WSL 배포판 유지를 위한 sleep infinity 같은 인위적 장치가 필요하다.
  • Playwright 브라우저를 WSL 안에 별도 설치해야 하고, xlsx 결과물을 /mnt/d/... 로 쓰면 성능이 나쁘다.
  • 토스트 알림은 여전히 Windows 쪽에서 처리해야 한다.

만약 그래도 WSL 을 써야 한다면 (예: 크롤러가 Linux 전용 도구에 의존):

# 작업 스케줄러 액션
# Execute : C:\Windows\System32\wsl.exe
# Argument: -d Ubuntu -u root -- bash -lc "cd /mnt/d/workspace/DMF_Crawler && ./run.sh"
New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wsl.exe" `
    -Argument '-d Ubuntu -u root -- bash -lc "cd /mnt/d/workspace/DMF_Crawler && ./run.sh"'

cron 을 쓰지 말고 Windows 작업 스케줄러가 직접 wsl.exe 를 호출하게 하라. 이러면 WSL 상주 문제가 사라진다.

16.2 Docker Desktop + restart: always

Docker 재시작 정책 4종(공식):

정책 동작(원문)
no "Don't automatically restart the container. (Default)"
on-failure[:max-retries] 0 이 아닌 종료 코드에서만 재시작, 재시도 횟수 제한 가능. "Does not restart if the daemon restarts."
always "Always restart the container if it stops" — 단, 수동 정지한 경우에는 데몬 재시작 후에만 다시 시작
unless-stopped always 와 유사하나, 수동 정지한 컨테이너는 데몬 재시작 후에도 정지 상태 유지

데몬 재시작 시: "containers with always and unless-stopped policies will resume running. The on-failure policy does not trigger on daemon restart. The no policy never auto-restarts."

결정적 한계 — Docker Desktop 설정:

"Start Docker Desktop when you sign in to your computer" — "This setting automatically launches Docker Desktop upon user login. It's disabled by default but recommended for those who use Docker frequently."

"Open Docker Dashboard when Docker Desktop starts" — 기본 비활성.

Docker Desktop 은 사용자 로그인 이후에 뜬다. 로그인하지 않은 상태에서는 데몬이 없으므로 restart: always 컨테이너도 뜨지 않는다.

⚠️ Docker 공식 문서에는 "Docker Desktop on Windows requires user sign-in / cannot run as a service before login" 이라는 명시적 문장은 없었다. 다만 "Start Docker Desktop when you sign in to your computer" 라는 설정 문구 자체가 로그인 의존성을 드러낸다 — ⚠️ 부분 검증.

결론: 채택하지 않는다. 로그인 의존성이 생기면 §14.1 에서 배제한 자동 로그온을 다시 끌어와야 하고, 그것은 보안상 부적절하다.

대안적 사용법: 크롤러를 컨테이너화하고 싶다면, Docker Desktop 대신 Windows 작업 스케줄러가 docker run --rm 을 호출하게 하라. 그래도 데몬 기동 문제는 남는다. 이 규모의 프로젝트에는 과한 복잡도다.


17. 로그 관리 · 실행 ID · 실패 스크린샷

17.1 디렉터리 구조

D:\workspace\DMF_Crawler\
├─ scripts\
│   ├─ register-tasks.ps1        # 작업 3종 등록 (관리자)
│   ├─ register-protocol.ps1     # dmfcrawler: 프로토콜 등록 (관리자, 1회)
│   ├─ protocol-handler.ps1      # dmfcrawler:// URL 처리
│   ├─ run-daily.ps1             # 크롤링 래퍼 (작업 ① 이 호출)
│   ├─ watchdog.ps1              # heartbeat 검증 (작업 ② 가 호출)
│   ├─ notify.ps1                # 토스트 표시 (작업 ③ 이 호출)
│   ├─ set-active-hours.ps1      # Windows Update 활성 시간 (관리자, 1회)
│   └─ preflight.ps1             # 설치 전 환경 점검 (§20)
├─ config\
│   └─ ops.config.ps1            # 웹훅 URL, healthchecks UUID, 담당자 (Git 제외!)
├─ ops\
│   ├─ DMF_Crawler_Daily.xml     # 작업 XML (Git 커밋)
│   ├─ DMF_Crawler_Watchdog.xml
│   └─ DMF_Crawler_Notify.xml
├─ state\
│   ├─ heartbeat.json            # 최근 실행 상태
│   ├─ summary-<run_id>.json     # 크롤러가 남기는 실행 요약
│   └─ notify-queue\
│       ├─ notify-<run_id>.json  # 미표시 알림
│       └─ archive\              # 표시 완료 알림 (30일 보관)
├─ logs\
│   ├─ run-<run_id>.log          # 실행별 로그 (30일 보관)
│   ├─ watchdog-YYYYMM.log       # 워치독 월별 로그
│   ├─ notify-YYYYMM.log         # 알림 월별 로그
│   └─ shots\
│       └─ <run_id>-<stage>.png  # 실패 스크린샷 (14일 보관)
├─ reports\
│   └─ DMF_YYYY-MM-DD.xlsx       # 결과물
└─ .playwright-browsers\         # PLAYWRIGHT_BROWSERS_PATH 고정 위치

config\ops.config.ps1 은 반드시 .gitignore 에 넣어라. 웹훅 URL 과 healthchecks UUID 는 그 자체가 인증 토큰이다.

17.2 로그 로테이션 정책

대상 보관 방식
logs\run-*.log 30일 실행별 파일 분리 → run-daily.ps1 종료 시 30일 초과분 삭제
logs\watchdog-YYYYMM.log 12개월 월별 파일. 별도 정리 작업
logs\notify-YYYYMM.log 12개월
logs\shots\*.png 14일 용량이 크므로 짧게
state\notify-queue\archive\*.json 30일
reports\*.xlsx 무기한 업무 산출물. 삭제 금지
Microsoft-Windows-TaskScheduler/Operational 64MB 순환 wevtutil set-log ... /maxsize:67108864
Application 로그 OS 기본 필요 시 Limit-EventLog 로 조정

실행별 파일 분리 방식을 택한 이유: 단일 파일 + 크기 기반 회전은 PowerShell 로 구현하면 파일 잠금 경합이 생긴다. 하루 1회 실행이므로 파일이 하루 하나씩만 늘어나 관리가 단순하다.

월별 정리 작업(선택, 작업 스케줄러에 등록):

# scripts\rotate-logs.ps1
param([string]$Root = 'D:\workspace\DMF_Crawler')
$Logs  = Join-Path $Root 'logs'
$Shots = Join-Path $Logs 'shots'

$rules = @(
    @{ Path = $Logs;  Filter = 'run-*.log';      Days = 30  },
    @{ Path = $Logs;  Filter = 'watchdog-*.log'; Days = 365 },
    @{ Path = $Logs;  Filter = 'notify-*.log';   Days = 365 },
    @{ Path = $Shots; Filter = '*.png';          Days = 14  }
)

foreach ($r in $rules) {
    if (-not (Test-Path $r.Path)) { continue }
    Get-ChildItem -Path $r.Path -Filter $r.Filter -File |
        Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$r.Days) } |
        ForEach-Object {
            Write-Host "삭제: $($_.FullName)"
            Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
        }
}

# 오래된 로그를 지우는 대신 압축하고 싶다면:
# Compress-Archive -Path (Join-Path $Logs 'run-2026*.log') -DestinationPath (Join-Path $Logs 'archive-2026.zip') -Update

17.3 실행 ID(run_id) 규약

형식: yyyyMMdd-HHmmss-<6자리 hex> 예: 20260902-060003-a7f31c

이 값이 모든 산출물을 관통하는 상관 키다:

  • 로그 파일명: logs\run-20260902-060003-a7f31c.log
  • 스크린샷: logs\shots\20260902-060003-a7f31c-crawl_list_page.png
  • heartbeat: state\heartbeat.jsonrun_id
  • 요약: state\summary-20260902-060003-a7f31c.json
  • 알림 큐: state\notify-queue\notify-20260902-060003-a7f31c.json
  • 이벤트 로그 메시지 본문에 run_id=...
  • healthchecks 의 rid 파라미터(별도 UUID지만 로그에 함께 기록)
  • 크롤러 프로세스에 --run-id 인자 + DMF_RUN_ID 환경변수로 전달

Python 쪽에서 사용:

import os, sys, json, pathlib, datetime

RUN_ID = os.environ.get("DMF_RUN_ID") or datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
ROOT   = pathlib.Path(r"D:\workspace\DMF_Crawler")
SHOTS  = ROOT / "logs" / "shots"
STATE  = ROOT / "state"
SHOTS.mkdir(parents=True, exist_ok=True)

def shot_path(stage: str) -> pathlib.Path:
    """실패 스크린샷 경로. stage 는 crawl_list_page 같은 슬러그."""
    return SHOTS / f"{RUN_ID}-{stage}.png"

def write_summary(new: int, changed: int, withdrawn: int, total: int, report: str) -> None:
    (STATE / f"summary-{RUN_ID}.json").write_text(
        json.dumps({
            "run_id": RUN_ID,
            "new": new, "changed": changed, "withdrawn": withdrawn,
            "total_scanned": total, "report_path": report,
        }, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

17.4 실패 스크린샷 (Playwright)

from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout

def crawl_stage(page, stage: str, fn):
    """각 단계를 감싸고, 실패하면 스크린샷 + HTML 덤프를 남긴다."""
    try:
        return fn()
    except Exception:
        try:
            page.screenshot(path=str(shot_path(stage)), full_page=True)
            html = SHOTS / f"{RUN_ID}-{stage}.html"
            html.write_text(page.content(), encoding="utf-8")
            print(f"[FAIL] stage={stage} shot={shot_path(stage)} html={html}", file=sys.stderr)
        except Exception as e2:
            print(f"[WARN] 스크린샷 저장 실패: {e2}", file=sys.stderr)
        raise


with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    ctx = browser.new_context(
        viewport={"width": 1600, "height": 1200},
        locale="ko-KR",
        timezone_id="Asia/Seoul",
        # 실패 재현을 위한 트레이스/비디오는 필요할 때만 켠다(용량 큼)
    )
    # 전체 트레이스가 필요하면:
    # ctx.tracing.start(screenshots=True, snapshots=True, sources=True)
    page = ctx.new_page()
    page.set_default_timeout(30_000)

    try:
        crawl_stage(page, "list_page", lambda: page.goto("https://nedrug.mfds.go.kr/..."))
        # ...
    finally:
        # ctx.tracing.stop(path=str(SHOTS / f"{RUN_ID}-trace.zip"))
        ctx.close()
        browser.close()

17.5 로그 라인 포맷

2026-09-02 06:00:03.117 [INFO ] === DMF_Crawler 실행 시작 run_id=20260902-060003-a7f31c host=DESKTOP-XXXX user=encep ===
2026-09-02 06:00:03.240 [INFO ] python: D:\workspace\DMF_Crawler\.venv\Scripts\python.exe
2026-09-02 06:00:04.882 [INFO ] 네트워크 준비 완료 (시도 1)
2026-09-02 06:00:05.010 [INFO ] crawler 시작...
2026-09-02 06:00:37.442 [ERROR] PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'
2026-09-02 06:00:37.501 [INFO ] crawler 종료 exit_code=3
2026-09-02 06:00:37.610 [ERROR] === 실패 ===
2026-09-02 06:00:38.220 [INFO ] Discord 웹훅 전송 완료
2026-09-02 06:00:38.310 [INFO ] === 종료 exit_code=3 duration=35s ===

원칙:

  • 밀리초까지 기록 (성능 병목 추적)
  • 레벨은 INFO / WARN / ERROR 3단계로 단순화
  • 시작·종료 라인에 === 구분자 → grep 이 쉽다
  • stdout 과 stderr 를 모두 캡처해야 한다. 파이썬 예외 트레이스백은 stderr 로 나간다. 2>&1 필수.
  • 크롤러 exit code 를 의미 있게 설계하라: 0=성공, 1=일반 오류, 2=설정/환경 오류, 3=크롤링 실패, 4=리포트 생성 실패

18. 실행 결과 코드 · 이벤트 ID 레퍼런스

18.1 Task Scheduler LastTaskResult (HRESULT) — 공식 상수 전체

"The constants that begin with SCHED_S_ are success constants, and the constants that begin with SCHED_E_ are error constants."

상수 의미
SCHED_E_SERVICE_NOT_LOCALSYSTEM 6200L The Task Scheduler service must be configured to run in the System account to function properly. Individual tasks may be configured to run in other accounts.
SCHED_S_TASK_READY 0x00041300 The task is ready to run at its next scheduled time.
SCHED_S_TASK_RUNNING 0x00041301 The task is currently running.
SCHED_S_TASK_DISABLED 0x00041302 The task will not run at the scheduled times because it has been disabled.
SCHED_S_TASK_HAS_NOT_RUN 0x00041303 The task has not yet run.
SCHED_S_TASK_NO_MORE_RUNS 0x00041304 There are no more runs scheduled for this task.
SCHED_S_TASK_NOT_SCHEDULED 0x00041305 One or more of the properties that are needed to run this task on a schedule have not been set.
SCHED_S_TASK_TERMINATED 0x00041306 The last run of the task was terminated by the user. (ExecutionTimeLimit 초과나 Stop-ScheduledTask 로도 나타남)
SCHED_S_TASK_NO_VALID_TRIGGERS 0x00041307 Either the task has no triggers or the existing triggers are disabled or not set.
SCHED_S_EVENT_TRIGGER 0x00041308 Event triggers don't have set run times.
SCHED_E_TRIGGER_NOT_FOUND 0x80041309 Trigger not found.
SCHED_E_TASK_NOT_READY 0x8004130A One or more of the properties that are needed to run this task have not been set.
SCHED_E_TASK_NOT_RUNNING 0x8004130B There is no running instance of the task.
SCHED_E_SERVICE_NOT_INSTALLED 0x8004130C The Task Scheduler Service is not installed on this computer.
SCHED_E_CANNOT_OPEN_TASK 0x8004130D The task object could not be opened.
SCHED_E_INVALID_TASK 0x8004130E The object is either an invalid task object or is not a task object.
SCHED_E_ACCOUNT_INFORMATION_NOT_SET 0x8004130F No account information could be found in the Task Scheduler security database for the task indicated. (계정 암호 변경 후 흔함)
SCHED_E_ACCOUNT_NAME_NOT_FOUND 0x80041310 Unable to establish existence of the account specified.
SCHED_E_ACCOUNT_DBASE_CORRUPT 0x80041311 Corruption was detected in the Task Scheduler security database; the database has been reset.
SCHED_E_NO_SECURITY_SERVICES 0x80041312 Task Scheduler security services are available only on Windows NT.
SCHED_E_UNKNOWN_OBJECT_VERSION 0x80041313 The task object version is either unsupported or invalid.
SCHED_E_UNSUPPORTED_ACCOUNT_OPTION 0x80041314 The task has been configured with an unsupported combination of account settings and run time options. (S4U + 대화형 요구 조합 등)
SCHED_E_SERVICE_NOT_RUNNING 0x80041315 The Task Scheduler Service is not running.
SCHED_E_UNEXPECTEDNODE 0x80041316 The task XML contains an unexpected node.
SCHED_E_NAMESPACE 0x80041317 The task XML contains an element or attribute from an unexpected namespace.
SCHED_E_INVALIDVALUE 0x80041318 The task XML contains a value which is incorrectly formatted or out of range.
SCHED_E_MISSINGNODE 0x80041319 The task XML is missing a required element or attribute.
SCHED_E_MALFORMEDXML 0x8004131A The task XML is malformed.
SCHED_S_SOME_TRIGGERS_FAILED 0x0004131B The task is registered, but not all specified triggers will start the task, check task scheduler event log for detailed information.
SCHED_S_BATCH_LOGON_PROBLEM 0x0004131C The task is registered, but may fail to start. Batch logon privilege needs to be enabled for the task principal. (→ §4.5)
SCHED_E_TOO_MANY_NODES 0x8004131D The task XML contains too many nodes of the same type.
SCHED_E_PAST_END_BOUNDARY 0x8004131E The task cannot be started after the trigger's end boundary.
SCHED_E_ALREADY_RUNNING 0x8004131F An instance of this task is already running.
SCHED_E_USER_NOT_LOGGED_ON 0x80041320 The task will not run because the user is not logged on. (Interactive 로그온 타입인데 세션이 없음)
SCHED_E_INVALID_TASK_HASH 0x80041321 The task image is corrupt or has been tampered with.
SCHED_E_SERVICE_NOT_AVAILABLE 0x80041322 The Task Scheduler service is not available.
SCHED_E_SERVICE_TOO_BUSY 0x80041323 The Task Scheduler service is too busy to handle your request. Please try again later.
SCHED_E_TASK_ATTEMPTED 0x80041324 The Task Scheduler service attempted to run the task, but the task did not run due to one of the constraints in the task definition. (배터리·유휴·네트워크 조건 미충족)
SCHED_S_TASK_QUEUED 0x00041325 The Task Scheduler service has asked the task to run.
SCHED_E_TASK_DISABLED 0x80041326 The task is disabled.
SCHED_E_TASK_NOT_V1_COMPAT 0x80041327 The task has properties that are not compatible with previous versions of Windows.
SCHED_E_START_ON_DEMAND 0x80041328 The task settings do not allow the task to start on demand.
SCHED_E_TASK_NOT_UBPM_COMPAT 0x80041329 The combination of properties that task is using is not compatible with the scheduling engine.
SCHED_E_DEPRECATED_FEATURE_USED 0x80041330 The task definition uses a deprecated feature.

기타 자주 보는 값:

코드 의미
0x0 정상 완료
0x1 "Incorrect function called or unknown function called" — 실무상 스크립트가 exit 1 로 끝났거나 경로/권한 문제
0x2 파일을 찾을 수 없음
0x41301 실행 중(십진 267009)
0x41306 종료됨(십진 267014)
0x800710E0 "The operator or administrator has refused the request"

Note(공식): "Some Task Scheduler APIs can return system and network error codes (64 for example). You can check the definition of these types of error codes by using the net helpmsg command in the command prompt window. For example, the command net helpmsg 64 returns the message: The specified network name is no longer available."

HRESULT 해석 헬퍼:

function Get-TaskResultText {
    param([int]$Code)
    $map = @{
        0          = '성공'
        1          = '일반 오류 (0x1) — 스크립트 exit 1 / 경로 / 권한 확인'
        2          = '파일을 찾을 수 없음 (0x2)'
        267008     = 'SCHED_S_TASK_READY — 다음 예정 시각 대기 중'
        267009     = 'SCHED_S_TASK_RUNNING — 실행 중'
        267010     = 'SCHED_S_TASK_DISABLED — 사용 안 함'
        267011     = 'SCHED_S_TASK_HAS_NOT_RUN — 아직 실행된 적 없음'
        267014     = 'SCHED_S_TASK_TERMINATED — 강제 종료됨(시간 초과 가능)'
        -2147216615 = 'SCHED_E_ACCOUNT_INFORMATION_NOT_SET (0x8004130F) — 계정 정보 없음'
        -2147216602 = 'SCHED_E_USER_NOT_LOGGED_ON (0x80041320) — 사용자 미로그온'
        -2147216598 = 'SCHED_E_TASK_ATTEMPTED (0x80041324) — 조건 미충족으로 미실행'
    }
    if ($map.ContainsKey($Code)) { return $map[$Code] }
    return ('알 수 없음 (0x{0:X8}) — net helpmsg {1} 로 확인' -f $Code, $Code)
}

Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
    Select-Object TaskName, LastRunTime,
        @{n='Result'; e={ '0x{0:X}' -f $_.LastTaskResult }},
        @{n='Meaning'; e={ Get-TaskResultText $_.LastTaskResult }}

18.2 Microsoft-Windows-TaskScheduler/Operational 이벤트 ID 전체

StandardTaskEventId 열거형(dahall/TaskScheduler 소스에서 추출한 완전 목록):

ID Name 의미
100 JobStart Task Scheduler started an instance of a task
101 JobStartFailed Task Scheduler failed to start a task
102 JobSuccess Task completed successfully
103 JobFailure Task execution failed
104 LogonFailure Failed to log on the user
105 ImpersonationFailure Failed to impersonate a user
106 JobRegistered User registered a task
107 TimeTrigger Task launched due to time trigger
108 EventTrigger Task launched due to event trigger
109 ImmediateTrigger Task launched due to registration trigger
110 Run Task launched for a user
111 JobTermination Task terminated for exceeding time allocation (= ExecutionTimeLimit 초과)
112 JobNoStartWithoutNetwork Network unavailable, task not started
113 TaskRegisteredWithoutSomeTriggers Some triggers won't start the task
114 MissedTaskLaunched Missed task started on-demand (= StartWhenAvailable 동작 확인용)
115 TransactionRollbackFailure Failed to roll back transaction
116 TaskRegisteredWithoutCredentials Credentials couldn't be stored
117 IdleTrigger Task launched due to idle condition
118 BootTrigger Task launched due to system startup
119 LogonTrigger Task launched due to user logon
120 ConsoleConnectTrigger Task launched on console connection
121 ConsoleDisconnectTrigger Task launched on console disconnection
122 RemoteConnectTrigger Task launched on remote connection
123 RemoteDisconnectTrigger Task launched on remote disconnection
124 SessionLockTrigger Task launched on computer lock
125 SessionUnlockTrigger Task launched on computer unlock
126 FailedTaskRestart Failed task restart attempt
127 RejectedTaskRestart Shutdown race condition restart attempt
128 IgnoredTaskStart Task not launched—end time exceeded
129 CreatedTaskProcess Task launched in new process
130 TaskNotRunServiceBusy Service busy, task not started
131 TaskNotStartedTaskQueueQuotaExceeded Task queue quota exceeded
132 TaskQueueQuotaApproaching Queue quota approaching limit
133 TaskNotStartedEngineQuotaExceeded Engine quota exceeded
134 EngineQuotaApproaching Engine quota approaching limit
135 NotStartedWithoutIdle Machine not idle, task not launched
140 TaskUpdated User updated a task
141 TaskDeleted User deleted a task
142 TaskDisabled User disabled a task
145 TaskStartedOnComputerWakeup Task started on computer wakeup (= WakeToRun 동작 확인용)
150 TaskEventSubscriptionFailed Failed to subscribe event trigger
200 ActionStart Task action launched
201 ActionSuccess Task action completed successfully
202 ActionFailure Task action failed
203 ActionLaunchFailure Failed to launch action (경로 오류·권한 오류의 전형)
204 EventRenderFailed Failed to retrieve event values
205 EventAggregateFailed Failed to match event pattern
301 SessionExit Task engine shutting down
303 SessionError Task engine shutting down due to error
304 SessionSentJob Task sent to engine
305 SessionSentJobFailed Task not sent to engine
306 SessionFailedToProcessMessage Thread pool failed to process message
307 SessionManagerConnectFailed Failed to connect to engine process
308 SessionConnected Connected to engine process
309 SessionJobsOrphaned Tasks orphaned during shutdown
310 SessionProcessStarted Engine process started
311 SessionProcessLaunchFailed Failed to start engine process
312 SessionWin32ObjectCreated Win32 job object created
313 SessionChannelReady Channel ready for messages
314 SessionIdle No tasks running, idle timer started
315 SessionProcessConnectFailed Engine failed to connect to service
316 SessionMessageSendFailed Engine failed to send message
317 SessionProcessMainStarted Engine process started
318 SessionProcessMainShutdown Engine process shut down
319 SessionProcessReceivedStartJob Engine received task launch request
320 SessionProcessReceivedStopJob Engine received task stop request
322 NewInstanceIgnored Task instance already running (= MultipleInstances IgnoreNew 동작)
323 RunningInstanceStopped Running instance stopped for new launch
324 NewInstanceQueued Task queued for later launch
325 InstanceQueued Task queued for immediate launch
326 NoStartOnBatteries Computer on batteries, task not started (= DisallowStartIfOnBatteries)
327 StoppingOnBatteries Instance stopped, switched to battery (= StopIfGoingOnBatteries)
328 StoppingOffIdle Instance stopped, computer no longer idle
329 StoppingOnTimeout Instance stopped due to timeout
330 StoppingOnRequest Instance stopped by user request
331 TimeoutWontWork Timeout mechanism creation failed
332 NoStartUserNotLoggedOn User not logged on, task not started
400 ScheduleServiceStart Task Scheduler service started
401 ScheduleServiceStartFailed Service startup failed
402 ScheduleServiceStop Service shutting down
403 ScheduleServiceError Service encountered error
404 ScheduleServiceRpcInitError RPC initialization error
405 ScheduleServiceComInitError COM initialization failed
406 ScheduleServiceCredStoreInitError Credentials store initialization failed
407 ScheduleServiceLsaInitError LSA initialization failed
408 ScheduleServiceIdleServiceInitError Idle detection module initialization failed
409 ScheduleServiceTimeChangeInitError Time change notification initialization failed
411 ScheduleServiceTimeChangeSignaled Service received time change notification
412 ScheduleServiceRunBootJobsFailed Failed to launch boot-triggered tasks
700 CompatStart Compatibility module started
701 CompatStartFailed Compatibility module startup failed
702 CompatStartRpcFailed RPC server initialization failed
703 CompatStartNetscheduleFailed Net Schedule API initialization failed
704 CompatStartLsaFailed LSA initialization failed
705 CompatDirectoryMonitorFailed Directory monitoring startup failed
706 CompatTaskStatusUpdateFailed Task status update failed
707 CompatTaskDeleteFailed Task deletion failed
708 CompatTaskSetSdFailed Security descriptor assignment failed
709 CompatTaskUpdateFailed Task update failed
710 CompatUpgradeStartFailed Upgrade initialization failed
711 CompatUpgradeNsAccountFailed NetSchedule account upgrade failed
712 CompatUpgradeStoreEnumFailed Store enumeration failed
713 CompatUpgradeTaskLoadFailed Task load for upgrade failed
714 CompatUpgradeTaskRegistrationFailed Task registration during upgrade failed
715 CompatUpgradeLsaCleanupFailed LSA store deletion failed
716 CompatUpgradeFailed Task upgrade failed
717 CompatUpgradeNeedNotDetermined Upgrade need determination failed
718 VistaBeta2CredstoreUpgradeFailed Beta 2 credential store upgrade failed
-2 Unknown Unknown/undefined value

⚠️ 조사한 다른 출처는 채널이 기록하는 이벤트를 "100, 102, 103, 106, 107, 108, 110, 118, 119, 129, 140, 141, 200, 201" 로, 그리고 다른 곳은 "101 = 작업 시작 실패, 102 = 정상 완료, 107 = 트리거로 실행" 으로 설명한다. 위 표(소스 코드 유래)가 가장 완전하다.

진단용 쿼리 모음:

# 우리 작업의 최근 실패만
Get-WinEvent -FilterHashtable @{
    LogName = 'Microsoft-Windows-TaskScheduler/Operational'
    Id      = 101,103,111,203,326,327,329,331,332,412
    StartTime = (Get-Date).AddDays(-7)
} -ErrorAction SilentlyContinue |
Where-Object { $_.Message -match 'DMF_Crawler' } |
Select-Object TimeCreated, Id, Message | Format-List

# StartWhenAvailable 이 실제로 동작했는지 (놓친 작업 실행)
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=114 } -MaxEvents 20 -ErrorAction SilentlyContinue

# WakeToRun 이 동작했는지
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=145 } -MaxEvents 20 -ErrorAction SilentlyContinue

# 부팅 트리거가 발화했는지 (Fast Startup 검증)
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=118 } -MaxEvents 20 -ErrorAction SilentlyContinue

19. AI 에이전트 CLI headless 실행 통합

이 프로젝트의 AI CLI 는 Google Antigravity CLI (agy) 를 headless(agy -p) 로 사용한다 (Claude Code 가 아니다). 아래 §19.1 은 원 조사에서 확인된 Claude Code claude -p 의 공식 명세로, agy -p 의 동작을 설계할 때 참고할 레퍼런스 모델로만 남긴다. agy 자체의 플래그 명세는 이 조사 범위 밖이며 미검증이다(부록 B-9).

19.1 참고 — Claude Code headless 모드 공식 명세

기본 사용(공식 문서 원문):

claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash"
claude -p "What does the auth module do?"

"Add the -p (or --print) flag to any claude command to run it non-interactively." "Claude Code exits with code 0 on success and a non-zero code when the run fails, so your scripts can branch on the exit status. If you pass an invalid flag, Claude Code reports the error to stderr before the run starts. When a failure happens inside the run, such as missing authentication, Claude Code prints the failure as the result on stdout."

--bare 모드 (CI/스케줄 실행에 중요):

"Add --bare to reduce startup time by skipping auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, and CLAUDE.md." "Bare mode is useful for CI and scripts where you need the same result on every machine." "Without --bare, a -p session runs the hooks in a project's .claude/settings.json and connects the servers in its .mcp.json, even in a folder you've never trusted. A -p session shows no workspace trust dialog and no per-server approval prompt." "In bare mode, Claude Code never reads OAuth credentials or the system keychain. For the Anthropic API, set ANTHROPIC_API_KEY in the environment" "--bare is the recommended mode for scripted and SDK calls, and will become the default for -p in a future release."

claude --bare -p "Summarize README.md" --allowedTools "Read"

bare 모드에서 컨텍스트를 주입하는 플래그:

로드 대상 플래그
System prompt additions --append-system-prompt, --append-system-prompt-file
Settings --settings <file-or-json>
MCP servers --mcp-config <file-or-json>
Custom agents --agents <json>
A plugin --plugin-dir <path>, --plugin-url <url>

출력 형식 --output-format:

  • text (기본): plain text output
  • json: "structured JSON with result, session ID, and metadata"
  • stream-json: "newline-delimited JSON for real-time streaming"
claude -p "Summarize this project" --output-format json
claude -p "Extract the main function names from auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'

"The response includes metadata about the request (session ID, usage, etc.) with the structured output in the structured_output field." "If the value isn't a valid JSON Schema, claude exits with Error: --json-schema is not a valid JSON Schema followed by the validator's diagnostic." "With --output-format json, the response payload includes total_cost_usd and a per-model cost breakdown ... Both figures are client-side estimates and can differ from your actual bill."

jq 로 파싱:

claude -p "Summarize this project" --output-format json | jq -r '.result'
claude -p "Extract function names from auth.py" --output-format json \
  --json-schema '{...}' | jq '.structured_output'

스트리밍:

claude -p "Explain recursion" --output-format stream-json --verbose --include-partial-messages
claude -p "Write a poem" --output-format stream-json --verbose --include-partial-messages | \
  jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'

"The last line of the stream is a result message with the final response text, cost, and session metadata."

stdin 파이프:

cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt

"Piped stdin is capped at 10MB. If you exceed the cap, Claude Code exits with a clear error and a non-zero status. To work with larger inputs, write the content to a file and reference the file path in your prompt instead of piping it." "If Claude Code can't read stdin, for example because the process that started it disconnected its end, Claude Code prints a warning to stderr and continues with the prompt from the command line. Before v2.1.211, an unreadable stdin on Windows crashed the session or made it exit silently with no output." ← Windows 스케줄 실행에서 특히 중요한 항목

SIGTERM 처리:

"If you stop a claude -p run with SIGTERM, for example with kill or from a process supervisor, Claude Code exits with code 143. Claude Code leaves the turn that was in progress unfinished and records no result for it. To end the turn instead, send SIGINT, or call the Agent SDK's interrupt(), before you stop the process." "On SIGTERM, Claude Code terminates the process tree of any Bash command that is still running. Claude Code then runs SessionEnd hooks and exits."

작업 스케줄러가 ExecutionTimeLimit 초과로 작업을 종료할 때 이 경로를 탄다. exit 143 을 실패로 처리하되 "시간 초과" 로 구분하라.

백그라운드 작업 종료 처리:

"If Claude starts a background Bash task during a claude -p run ... that shell is terminated about five seconds after Claude has returned its final result and stdin has closed." "Background subagents and workflows are exempt from the five-second grace ... From v2.1.182, that wait is capped at ten minutes of continuous idle waiting by default ... Adjust the cap with CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, or set it to 0 to wait without a limit."

스트림 배출 지연:

"If your consumer reads the stream slowly, Claude Code waits for the queued output to drain before exiting, scaling the wait with how much is still queued, capped at 30 seconds. Before v2.1.214 the exit wait was capped at about two seconds, which could cut off the end of a large response."

서브에이전트 메시지 추적: --forward-subagent-text 또는 CLAUDE_CODE_FORWARD_SUBAGENT_TEXT 로 서브에이전트 텍스트/사고 블록까지 스트림에 포함(요구: v2.1.211 이상).

-p 와 조합 불가한 플래그: "Claude Code rejects --bg, and rejects --cloud with a task description, with an error naming the conflict; --cloud with a session ID and -p instead queues a message into that cloud session and exits."

npm 스크립트 예시(Windows 이식성 고려, 원문):

{
  "scripts": {
    "lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\""
  }
}

19.2 agy -p 통합 설계 (이 프로젝트의 실제 구현)

설계 원칙 — AI CLI 를 감싸는 방식:

  1. AI CLI 호출은 크롤링 성공 이후의 별도 단계로 둔다. AI 요약이 실패해도 xlsx 리포트는 나와야 한다.
  2. 타임아웃을 반드시 건다. AI CLI 가 무한 대기하면 ExecutionTimeLimit 까지 작업이 붙잡힌다.
  3. 구조화 출력(JSON)을 요구하고, 파싱 실패를 허용한다.
  4. agy 부재 시 자동 부트스트랩하되, 부트스트랩 자체를 실패로 간주하지 않고 "AI 요약 없이 진행" 으로 강등한다.

scripts\invoke-agy.ps1 (완결):

<#
.SYNOPSIS
    Google Antigravity CLI(agy)를 headless(-p) 로 호출해 DMF 변동사항 요약을 얻는다.
.DESCRIPTION
    - agy 가 없으면 부트스트랩을 시도하고, 실패하면 요약 없이 진행(비치명적)
    - 타임아웃(기본 10분) 안에 끝나지 않으면 강제 종료
    - stdout/stderr 를 로그에 남기고, JSON 파싱을 시도
.OUTPUTS
    성공: 요약 문자열 / 실패: $null
.NOTES
    agy 의 정확한 플래그는 이 조사에서 검증되지 않았다(부록 B-9).
    아래 -p / --output-format 은 Claude Code 의 관례를 따른 가정이며,
    실제 배포 전에 `agy --help` 로 반드시 확인하고 수정할 것.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$PromptFile,   # 프롬프트 텍스트 파일
    [Parameter(Mandatory)][string]$InputFile,    # 변동 데이터(JSON/CSV)
    [string]$Root      = 'D:\workspace\DMF_Crawler',
    [int]$TimeoutSec   = 600,
    [switch]$AllowPrompt                          # agy 미설치 시 Windows 창을 띄워 설치 안내
)

$ErrorActionPreference = 'Continue'
$Logs = Join-Path $Root 'logs'
$Log  = Join-Path $Logs ('agy-{0}.log' -f ($env:DMF_RUN_ID ?? (Get-Date -Format 'yyyyMMdd-HHmmss')))

function W { param($m) Add-Content -Path $Log -Value ("{0} {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 }

# ---------------------------------------------------------------- agy 탐색
$agy = (Get-Command 'agy' -ErrorAction SilentlyContinue)?.Source
if (-not $agy) {
    $candidates = @(
        (Join-Path $env:LOCALAPPDATA 'Programs\antigravity\agy.exe'),
        (Join-Path $env:ProgramFiles 'Antigravity\agy.exe'),
        (Join-Path $env:APPDATA 'npm\agy.cmd')
    )
    $agy = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
}

if (-not $agy) {
    W '[WARN] agy 를 찾을 수 없습니다. 부트스트랩을 시도합니다.'
    try {
        # 부트스트랩 방법은 배포 형태에 따라 다르다. npm 배포를 가정한 예시:
        & npm install -g @google/antigravity-cli 2>&1 | ForEach-Object { W $_ }
        $agy = (Get-Command 'agy' -ErrorAction SilentlyContinue)?.Source
    } catch {
        W "[WARN] 부트스트랩 실패: $($_.Exception.Message)"
    }
}

if (-not $agy) {
    W '[ERROR] agy 사용 불가 — AI 요약 없이 진행합니다.'
    if ($AllowPrompt) {
        # 대화형 세션에서만 의미가 있다. 비대화형이면 조용히 실패한다.
        try {
            Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
            [System.Windows.Forms.MessageBox]::Show(
                "Antigravity CLI(agy)가 설치되어 있지 않습니다.`n" +
                "AI 요약 없이 리포트를 생성했습니다.`n`n" +
                "설치 후 다시 실행하세요.",
                'DMF Crawler', 'OK', 'Warning') | Out-Null
        } catch { }
    }
    return $null
}

W "[INFO] agy: $agy"

# ---------------------------------------------------------------- 호출
$prompt = Get-Content $PromptFile -Raw -Encoding UTF8
$outFile = Join-Path $Logs ('agy-out-{0}.json' -f ($env:DMF_RUN_ID ?? 'manual'))
$errFile = Join-Path $Logs ('agy-err-{0}.txt'  -f ($env:DMF_RUN_ID ?? 'manual'))

# 프롬프트에 입력 파일 경로를 넣는다(파이프 대신 파일 참조 — 대용량 안전)
$fullPrompt = "$prompt`n`n입력 데이터 파일: $InputFile"
$tmpPrompt  = Join-Path $env:TEMP ("agy-prompt-{0}.txt" -f ([guid]::NewGuid().ToString('N')))
Set-Content -Path $tmpPrompt -Value $fullPrompt -Encoding UTF8

$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName               = $agy
# ⚠️ 아래 인자는 가정이다. 배포 전 `agy --help` 로 검증할 것.
$psi.Arguments              = '-p --output-format json'
$psi.RedirectStandardInput  = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError  = $true
$psi.UseShellExecute        = $false
$psi.CreateNoWindow         = $true
$psi.WorkingDirectory       = $Root
$psi.StandardOutputEncoding = [Text.Encoding]::UTF8
$psi.StandardErrorEncoding  = [Text.Encoding]::UTF8

$proc = [System.Diagnostics.Process]::Start($psi)
$proc.StandardInput.Write($fullPrompt)
$proc.StandardInput.Close()

$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()

if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
    W "[ERROR] agy 타임아웃(${TimeoutSec}초) — 프로세스 트리를 종료합니다."
    try { $proc.Kill($true) } catch { try { $proc.Kill() } catch { } }
    Remove-Item $tmpPrompt -Force -ErrorAction SilentlyContinue
    return $null
}

$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
$exit   = $proc.ExitCode

Set-Content -Path $outFile -Value $stdout -Encoding UTF8
if ($stderr) { Set-Content -Path $errFile -Value $stderr -Encoding UTF8 }
Remove-Item $tmpPrompt -Force -ErrorAction SilentlyContinue

W "[INFO] agy exit=$exit stdout=$($stdout.Length)B stderr=$($stderr.Length)B"

# 143 = SIGTERM (Claude Code 규약). agy 도 유사할 수 있으므로 구분해 기록한다.
if ($exit -eq 143) { W '[WARN] agy 가 SIGTERM 으로 종료되었습니다(외부 종료/타임아웃).'; return $null }
if ($exit -ne 0)   { W "[ERROR] agy 비정상 종료 exit=$exit"; return $null }

# ---------------------------------------------------------------- 파싱
try {
    $obj = $stdout | ConvertFrom-Json -ErrorAction Stop
    if ($obj.PSObject.Properties.Name -contains 'structured_output') { return $obj.structured_output }
    if ($obj.PSObject.Properties.Name -contains 'result')            { return $obj.result }
    return $obj
} catch {
    W "[WARN] JSON 파싱 실패 — 원문을 그대로 사용합니다: $($_.Exception.Message)"
    return $stdout
}

run-daily.ps1 에서의 호출 위치: 크롤링 성공 → xlsx 생성 이전 또는 이후. 실패해도 리포트는 나와야 하므로 반드시 try/catch 로 감싸고 $null 을 허용한다.

# run-daily.ps1 안, 크롤링 성공 직후
$aiSummary = $null
try {
    $aiSummary = & (Join-Path $Scripts 'invoke-agy.ps1') `
        -PromptFile (Join-Path $Root 'prompts\dmf-summary.md') `
        -InputFile  (Join-Path $State "summary-$RunId.json") `
        -Root $Root -TimeoutSec 600
} catch {
    Write-Log 'WARN' "AI 요약 실패(비치명적): $($_.Exception.Message)"
}
if ($aiSummary) { Write-Log 'INFO' 'AI 요약 생성 완료' }
else            { Write-Log 'WARN' 'AI 요약 없이 리포트를 생성합니다' }

19.3 AI CLI 를 스케줄 실행할 때의 함정

함정 증상 대응
인증 자격증명이 비대화형 세션에서 안 읽힘 인증 실패 결과가 stdout 으로 출력됨 환경변수(API 키)를 시스템 환경변수로 등록. S4U 라면 DPAPI/키체인 의존 금지(§4.3)
stdin 이 닫혀 있음 Windows 에서 세션 크래시 또는 무출력 종료(구버전) 프롬프트를 인자나 파일로 넘기고 stdin 파이프를 피한다
무한 대기 작업이 ExecutionTimeLimit 까지 잡혀 있음 스크립트 레벨 타임아웃 + Kill($true) (프로세스 트리 종료)
터미널 색상 이스케이프가 로그에 섞임 로그 판독 불가 NO_COLOR=1, TERM=dumb 환경변수 설정
한글 깨짐 mojibake $psi.StandardOutputEncoding = [Text.Encoding]::UTF8, PYTHONIOENCODING=utf-8, 콘솔 chcp 65001
프로젝트 훅/MCP 서버가 자동 로드됨 예측 불가한 동작·지연 --bare 에 해당하는 옵션이 있으면 사용
비용 폭주 매일 자동 실행이므로 누적 JSON 출력의 비용 필드를 로그에 남기고 임계치 알림

20. 최종 배치 절차 체크리스트

20.1 사전 점검 스크립트 scripts\preflight.ps1

<#
.SYNOPSIS
    DMF_Crawler 운영 환경을 점검한다. 설치 전과 정기 점검에 사용.
#>
param([string]$Root = 'D:\workspace\DMF_Crawler')

$ok = 0; $warn = 0; $fail = 0
function Check {
    param([string]$Name, [scriptblock]$Test, [string]$Fix = '')
    try {
        $r = & $Test
        if ($r -eq $true)      { Write-Host "[ OK ] $Name" -ForegroundColor Green;  $script:ok++ }
        elseif ($r -eq 'warn') { Write-Host "[WARN] $Name" -ForegroundColor Yellow; $script:warn++; if ($Fix) { Write-Host "       → $Fix" } }
        else                   { Write-Host "[FAIL] $Name" -ForegroundColor Red;    $script:fail++; if ($Fix) { Write-Host "       → $Fix" } }
    } catch {
        Write-Host "[FAIL] $Name : $($_.Exception.Message)" -ForegroundColor Red; $script:fail++
        if ($Fix) { Write-Host "       → $Fix" }
    }
}

Write-Host "=== DMF_Crawler 환경 점검 ===" -ForegroundColor Cyan

Check 'OS 가 Windows 10/11 인가' { [Environment]::OSVersion.Version.Major -ge 10 }

Check '관리자 권한으로 실행 중인가' {
    ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
    ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} '관리자 권한 PowerShell 로 다시 실행하세요.'

Check '프로젝트 루트 존재' { Test-Path $Root } "New-Item -ItemType Directory $Root"

Check 'PowerShell 5.1 존재' {
    Test-Path "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
}

Check 'Task Scheduler 서비스 실행 중' {
    (Get-Service -Name 'Schedule').Status -eq 'Running'
} 'Start-Service Schedule'

Check 'TaskScheduler/Operational 채널 활성' {
    $l = & wevtutil.exe get-log 'Microsoft-Windows-TaskScheduler/Operational' 2>$null
    if ($l -match 'enabled:\s*true') { $true } else { 'warn' }
} 'wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet'

Check '이벤트 소스 DMFCrawler 등록' {
    [System.Diagnostics.EventLog]::SourceExists('DMFCrawler')
} "New-EventLog -LogName Application -Source DMFCrawler"

Check '계정에 Log on as a batch job 권한' {
    # 정확한 확인은 secedit export 가 필요. 여기서는 Administrators 멤버 여부로 근사한다.
    $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
              ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    if ($isAdmin) { $true } else { 'warn' }
} 'secpol.msc → 로컬 정책 → 사용자 권한 할당 → 배치 작업으로 로그온'

Check '시간대가 Korea Standard Time' {
    if ((Get-TimeZone).Id -eq 'Korea Standard Time') { $true } else { 'warn' }
} "Set-TimeZone -Id 'Korea Standard Time'"

Check '시간 동기화 정상' {
    $s = & w32tm /query /status 2>$null
    if ($LASTEXITCODE -eq 0) { $true } else { 'warn' }
} 'w32tm /resync'

Check 'AC 전원에서 절전 안 함' {
    $out = & powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 2>$null
    if ($out -match 'AC Power Setting Index:\s*0x00000000') { $true } else { 'warn' }
} 'powercfg /change standby-timeout-ac 0'

Check 'Windows Update 활성 시간에 06:00 포함' {
    $p = 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate'
    if (Test-Path $p) {
        $s = (Get-ItemProperty $p -Name ActiveHoursStart -ErrorAction SilentlyContinue).ActiveHoursStart
        $e = (Get-ItemProperty $p -Name ActiveHoursEnd   -ErrorAction SilentlyContinue).ActiveHoursEnd
        if ($null -ne $s -and $s -le 6 -and $e -ge 6) { $true } else { 'warn' }
    } else { 'warn' }
} '.\scripts\set-active-hours.ps1 실행'

Check 'BitLocker 가 TPM+PIN 이 아님' {
    try {
        $v = Get-BitLockerVolume -MountPoint 'C:' -ErrorAction Stop
        if ($v.ProtectionStatus -eq 'Off') { $true }
        elseif ($v.KeyProtector.KeyProtectorType -contains 'TpmPin') { 'warn' }
        else { $true }
    } catch { $true }   # BitLocker 미사용
} 'TPM+PIN 이면 무인 재부팅 후 자동 복구가 불가합니다(§14.3)'

Check 'BurntToast 모듈 설치' {
    if (Get-Module -ListAvailable -Name BurntToast) { $true } else { 'warn' }
} 'Install-Module -Name BurntToast -Scope CurrentUser -Force'

Check 'msg.exe 존재 (Pro/Enterprise)' {
    if (Test-Path "$env:SystemRoot\System32\msg.exe") { $true } else { 'warn' }
} 'Home 에디션에는 msg.exe 가 없습니다. 웹훅 폴백에 의존하세요.'

Check 'Python 가상환경' {
    Test-Path (Join-Path $Root '.venv\Scripts\python.exe')
} "python -m venv $Root\.venv"

Check 'PLAYWRIGHT_BROWSERS_PATH 시스템 변수 설정' {
    $v = [Environment]::GetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH', 'Machine')
    if ($v) { $true } else { 'warn' }
} "[Environment]::SetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','$Root\.playwright-browsers','Machine')"

Check 'Playwright 브라우저 설치됨' {
    $p = [Environment]::GetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','Machine')
    if ($p -and (Test-Path $p) -and (Get-ChildItem $p -Directory -ErrorAction SilentlyContinue)) { $true } else { 'warn' }
} 'python -m playwright install chromium'

Check '네트워크로 대상 사이트 접근 가능' {
    Test-NetConnection -ComputerName 'nedrug.mfds.go.kr' -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
} '방화벽/프록시 확인'

Check 'config\ops.config.ps1 존재' {
    Test-Path (Join-Path $Root 'config\ops.config.ps1')
} '웹훅/healthchecks 설정 파일을 만드세요(§17.1)'

Check 'ops.config.ps1 이 .gitignore 에 있음' {
    $gi = Join-Path $Root '.gitignore'
    if ((Test-Path $gi) -and ((Get-Content $gi -Raw) -match 'ops\.config\.ps1')) { $true } else { 'warn' }
} '.gitignore 에 config/ops.config.ps1 을 추가하세요 (웹훅 URL 유출 방지)'

Check 'dmfcrawler: 프로토콜 등록' {
    if (Test-Path 'HKCU:\Software\Classes\dmfcrawler') { $true } else { 'warn' }
} '.\scripts\register-protocol.ps1 실행 (토스트 재실행 버튼용)'

Write-Host ""
Write-Host ("=== 결과: OK {0} / WARN {1} / FAIL {2} ===" -f $ok, $warn, $fail) -ForegroundColor Cyan
if ($fail -gt 0) { exit 1 }
exit 0

20.2 설치 순서

# 0) 관리자 권한 PowerShell 을 연다.

# 1) 프로젝트 배치
cd D:\workspace\DMF_Crawler

# 2) Python 환경
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt

# 3) Playwright 브라우저 경로 고정 후 설치
[Environment]::SetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','D:\workspace\DMF_Crawler\.playwright-browsers','Machine')
$env:PLAYWRIGHT_BROWSERS_PATH = 'D:\workspace\DMF_Crawler\.playwright-browsers'
python -m playwright install chromium

# 4) 알림 모듈
Install-Module -Name BurntToast -Scope CurrentUser -Force

# 5) 운영 설정 파일 작성 (Git 제외)
#    config\ops.config.ps1 에 웹훅 URL, healthchecks UUID, 담당자 기입

# 6) 전원 설정
powercfg /change standby-timeout-ac 0
powercfg /change hibernate-timeout-ac 0
powercfg /change monitor-timeout-ac 10
powercfg /change disk-timeout-ac 0

# 7) Windows Update 활성 시간
.\scripts\set-active-hours.ps1

# 8) 커스텀 프로토콜 등록 (토스트 "재실행" 버튼용)
.\scripts\register-protocol.ps1

# 9) 작업 등록
.\scripts\register-tasks.ps1 -LogonMode S4U
#    (keyring/UNC 가 필요하면 -LogonMode Password)

# 10) 사전 점검
.\scripts\preflight.ps1

# 11) 알림 경로 테스트
.\scripts\notify.ps1 -TestMode

# 12) 실제 실행 테스트 (수동 트리거)
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo

# 13) 워치독 테스트
.\scripts\watchdog.ps1

# 14) 실패 시나리오 테스트 (§20.3)

20.3 반드시 실행해야 할 실패 시나리오 테스트

# 시나리오 방법 기대 결과
1 크롤러가 exit 1 로 실패 크롤러에 --simulate-failure 플래그 추가 또는 임시로 exit 1 heartbeat status=failed, 이벤트 1001, 알림 큐 생성, 토스트, 웹훅
2 로그온 전 실패 로그아웃 상태에서 작업 강제 실행 토스트는 안 뜨지만 웹훅은 도착. 로그인 시 큐에서 토스트 표시
3 heartbeat 미갱신 state\heartbeat.jsonfinished_at 을 3일 전으로 수정 → watchdog.ps1 실행 이벤트 1010, 알림, 웹훅
4 작업 비활성화 Disable-ScheduledTask → 워치독 실행 "Disabled 상태" 감지
5 네트워크 차단 방화벽으로 outbound 443 차단 → 작업 실행 네트워크 대기 5분 후 실패, 알림
6 시간 초과 ExecutionTimeLimitPT1M 으로 임시 변경 후 긴 작업 실행 이벤트 111/329, LastTaskResult=0x41306
7 재부팅 06:00 직전에 PC 종료 → 07:00 에 부팅 StartWhenAvailable 로 즉시 실행(이벤트 114)
8 계정 암호 변경 (Password 모드만) 암호 변경 후 작업 실행 0x8004130F — 작업 재등록 필요. 이 절차를 운영 문서에 적을 것
9 healthchecks 미도달 크롤러 작업을 하루 비활성화 Period+Grace 경과 후 healthchecks 알림 도착
10 디스크 가득 참 임시로 큰 파일 생성 명확한 오류 메시지 + 알림

20.4 운영 중 정기 점검 (월 1회)

# 1) 작업 상태
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
    Format-Table TaskName, LastRunTime, LastTaskResult, NextRunTime -AutoSize

# 2) 최근 30일 실패 이력
Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='DMFCrawler'; Level=2 } -MaxEvents 50 -ErrorAction SilentlyContinue |
    Select-Object TimeCreated, Id, Message | Format-List

# 3) heartbeat 신선도
Get-Content 'D:\workspace\DMF_Crawler\state\heartbeat.json' -Raw | ConvertFrom-Json |
    Select-Object run_id, status, finished_at, duration_sec, exit_code

# 4) 디스크 사용량
Get-ChildItem 'D:\workspace\DMF_Crawler' -Recurse -File |
    Group-Object { $_.Directory.Name } |
    Select-Object Name, Count, @{n='MB'; e={ [math]::Round(($_.Group | Measure-Object Length -Sum).Sum / 1MB, 1) }} |
    Sort-Object MB -Descending

# 5) 사전 점검 재실행
.\scripts\preflight.ps1

# 6) 활성 시간 정책이 유지되는지
Get-ItemProperty 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate' |
    Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd

부록 A. 출처 목록

확인 여부 표기:

  • F = WebFetch 로 실제 페이지를 열어 내용을 확인함 (verified)
  • S = 검색 결과 목록에만 등장 (제목/URL 은 확인, 본문 미확인)
  • X = 열었으나 실패 (404 / 타임아웃 등)

A.1 Microsoft Learn — PowerShell ScheduledTasks 모듈

제목 URL 확인
New-ScheduledTaskSettingsSet (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtasksettingsset?view=windowsserver2025-ps F
New-ScheduledTaskPrincipal (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal?view=windowsserver2025-ps F
New-ScheduledTaskTrigger (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtasktrigger?view=windowsserver2025-ps F
Register-ScheduledTask (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/register-scheduledtask?view=windowsserver2025-ps F
New-ScheduledTask (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtask?view=windowsserver2025-ps S
New-ScheduledTaskAction (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskaction?view=windowsserver2025-ps S
Set-ScheduledTask (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/set-scheduledtask?view=windowsserver2025-ps S
Set-ClusteredScheduledTask (ScheduledTasks) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/set-clusteredscheduledtask?view=windowsserver2022-ps&viewFallbackFrom=win10-ps S
new scheduledtaskprincipal (버전 미지정) https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal S
new scheduledtask (de-de) https://learn.microsoft.com/de-de/powershell/module/scheduledtasks/new-scheduledtask S
PowerShell scripting jj649824 (it-it) https://learn.microsoft.com/it-it/previous-versions/windows/powershell-scripting/jj649824(v=wps.620) S
PowerShell scripting jj649825 (zh-cn) https://learn.microsoft.com/zh-cn/previous-versions/windows/powershell-scripting/jj649825(v=wps.620) S
New-ScheduledTaskPrincipal — PDQ https://www.pdq.com/powershell/new-scheduledtaskprincipal/ S
Scheduling a script in Task Scheduler using PowerShell (Jana's blog) https://scripting4ever.wordpress.com/2020/09/21/scheduling-a-script-in-task-scheduler-using-powershell/ S
Powershell — create scheduled task in Windows Task Scheduler part 3 https://ciysys.com/blog/powershell-schedule-task-part3.htm S

A.2 Microsoft Learn — Task Scheduler Win32 스키마 / API

제목 URL 확인
settingsType Complex Type https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-settingstype-complextype F
restartType Complex Type https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-restarttype-complextype F
ExecutionTimeLimit (settingsType) Element https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-executiontimelimit-settingstype-element F
NetworkSettings (settingsType) Element https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-networksettings-settingstype-element F
Delay (bootTriggerType) Element https://learn.microsoft.com/en-us/windows/win32/TaskSchd/taskschedulerschema-delay-boottriggertype-element F
Principal.LogonType property https://learn.microsoft.com/en-us/windows/win32/taskschd/principal-logontype F
Security Contexts for Tasks https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks F
Task Scheduler error and success constants (WinError.h) https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-error-and-success-constants F
Task security context (구 URL) https://learn.microsoft.com/en-us/windows/win32/taskschd/task-security-context X (404)
DisallowStartIfOnBatteries (settingsType) Element https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-disallowstartifonbatteries-settingstype-element S
ITaskSettings::get_DisallowStartIfOnBatteries https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itasksettings-get_disallowstartifonbatteries S
Task Scheduler Schema https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema S
Task Scheduler Schema Elements https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema-elements S
TaskSettings https://learn.microsoft.com/windows/win32/taskschd/tasksettings S
Task idle conditions https://learn.microsoft.com/en-us/windows/win32/taskschd/task-idle-conditions S
BootTrigger.Delay property https://learn.microsoft.com/en-us/windows/win32/taskschd/boottrigger-delay S
BootTrigger.Delay (el-gr) https://learn.microsoft.com/el-gr/windows/win32/taskschd/boottrigger-delay S
BootTrigger object https://learn.microsoft.com/en-us/windows/win32/taskschd/boottrigger S
BootTrigger (triggerGroup) Element https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-boottrigger-triggergroup-element S
BootTrigger (triggerGroup) Element (en-gb) https://learn.microsoft.com/en-gb/windows/win32/taskschd/taskschedulerschema-boottrigger-triggergroup-element S
Delay (bootTriggerType) Element (en-gb) https://learn.microsoft.com/en-gb/windows/win32/taskschd/taskschedulerschema-delay-boottriggertype-element S
Delay (registrationTriggerType) Element https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-delay-registrationtriggertype-element S
IRegistrationTrigger::get_Delay https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iregistrationtrigger-get_delay S
EventTrigger object https://learn.microsoft.com/en-us/windows/win32/taskschd/eventtrigger S
EventTrigger object (cs-cz) https://learn.microsoft.com/cs-cz/windows/desktop/TaskSchd/eventtrigger S
IEventTrigger interface (taskschd.h) https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nn-taskschd-ieventtrigger S
eventTriggerType Complex Type https://learn.microsoft.com/en-us/windows/win32/TaskSchd/taskschedulerschema-eventtriggertype-complextype S
EventTrigger (triggerGroup) Element (en-au) https://learn.microsoft.com/en-au/windows/win32/taskschd/taskschedulerschema-eventtrigger-triggergroup-element S
eventtrigger.md (GitHub 원본) https://github.com/MicrosoftDocs/win32/blob/docs/desktop-src/TaskSchd/eventtrigger.md S
MonthlyDOWTrigger object https://learn.microsoft.com/en-us/windows/win32/TaskSchd/monthlydowtrigger S
MonthlyDOWTrigger (th-th) https://learn.microsoft.com/th-th/windows/win32/taskschd/monthlydowtrigger S
MonthlyTrigger object (sv-se) https://learn.microsoft.com/sv-se/windows/win32/taskschd/monthlytrigger S
WeeklyTrigger (en-au) https://learn.microsoft.com/en-au/windows/win32/taskschd/weeklytrigger S
Weekly Trigger Example (XML) https://learn.microsoft.com/en-us/windows/win32/taskschd/weekly-trigger-example--xml- S
Time Trigger Example (XML) https://learn.microsoft.com/en-us/windows/win32/taskschd/time-trigger-example--xml- S
Logon Trigger Example (XML) https://learn.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--xml- S
Schtasks.exe (Win32) https://learn.microsoft.com/en-us/windows/win32/taskschd/schtasks S
[MS-TSCH]: BootTrigger https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/dfcb1665-2a76-4fa7-b4d4-fdb5387d5d7c S
[MS-TSCH]: 2.5.3.6 EventTrigger https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/34e05890-8338-408e-a87d-81534898126a S
[MS-GPPREF]: ScheduledTasks XML Example https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gppref/850b333b-9336-496a-bf93-a20f33748454 S
Task Properties (WS2008 R2) https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/cc775003(v=ws.10) S
Reference The Event That Triggered Your Task (it-it) https://learn.microsoft.com/it-it/archive/blogs/otto/reference-the-event-that-triggered-your-task S
Running a scheduled task after another https://learn.microsoft.com/en-us/archive/blogs/davethompson/running-a-scheduled-task-after-another S
Importing XML into Task Scheduler https://learn.microsoft.com/en-us/archive/msdn-technet-forums/cdc10106-11b4-4ed4-b637-b33f0c1ce01c S
Import Scheduled Task with Powershell https://learn.microsoft.com/en-us/archive/msdn-technet-forums/34517e40-a827-41b2-b361-254894d80404 S
Run a script as hidden task scheduler with powershell https://learn.microsoft.com/en-us/archive/msdn-technet-forums/d6701df5-db2f-42e5-a790-ce2fc39f853b S
Register Scheduled task with S4U Logon type (TechNet Wiki 40309) https://learn.microsoft.com/en-us/archive/technet-wiki/40309.register-scheduled-task-with-s4u-logon-type S
schtasks create (Windows Commands) https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create F
schtasks (ja-jp, XP) https://learn.microsoft.com/ja-jp/previous-versions/windows/it-pro/windows-xp/bb490996(v=technet.10) S

A.3 Microsoft Learn — Task Scheduler Q&A / 트러블슈팅

제목 URL 확인
Scheduled Tasks with "Startup" trigger not firing https://learn.microsoft.com/en-us/answers/questions/1180993/scheduled-tasks-with-startup-trigger-not-firing F
Scheduled Tasks with "Startup" trigger not firing (en-gb) https://learn.microsoft.com/en-gb/answers/questions/1180993/scheduled-tasks-with-startup-trigger-not-firing S
Task Scheduler is not working with option Run whether user logged on or not https://learn.microsoft.com/en-gb/answers/questions/2141588/task-scheduler-is-not-working-with-option-run-whea F
Why does my scheduled task sit queued until the first time someone logs in? https://learn.microsoft.com/en-us/answers/a/1082877 F
Scheduled "At Startup" task that worked in Windows 7 no longer works after upgrade to Windows 10 https://learn.microsoft.com/en-us/answers/questions/3254425/scheduled-at-startup-task-that-worked-in-windows-7 S
Task Scheduler Configuration https://learn.microsoft.com/en-us/answers/questions/3310309/task-scheduler-configuration S
Task Scheduler Error (page 2) https://learn.microsoft.com/en-us/answers/questions/2730255/task-scheduler-error?page=2 S
Can't register scheduled task with managed service account https://learn.microsoft.com/en-us/answers/questions/607773/cant-register-sheduled-task-with-managed-service-a?orderby=newest%2Chelpful&orderBy=Helpful S
Windows Task Scheduler Error (one or more of the specified arguments are not valid) https://learn.microsoft.com/en-us/answers/questions/2820575/windows-task-scheduler-error-(one-or-more-of-the-s S
Task scheduler error 267014 "process terminated by user" https://learn.microsoft.com/en-us/answers/questions/d8563660-4111-4c83-88a6-f28b72309b6e/task-scheduler-error-267014-process-terminated-by?forum=windows-all S
scheduler tasks with security options "Run whether user is logged on or not" https://learn.microsoft.com/en-us/answers/questions/5789906/scheduler-tasks-with-security-options-run-whether S
Import Scheduled Task with Powershell and S4U https://learn.microsoft.com/en-us/answers/questions/184857/import-scheduled-task-with-powershell-and-s4u S
task scheduler error a specified logon session does not exist https://learn.microsoft.com/en-us/archive/blogs/supportingwindows/task-scheduler-error-a-specified-logon-session-does-not-exist S
Schedule not working well on Task Scheduler https://learn.microsoft.com/en-us/answers/questions/472121/schedule-not-working-well-on-task-scheduler S
task scheduler task not running on triggered time https://learn.microsoft.com/answers/questions/333001/task-scheduler-task-not-running-on-triggerd-time.html?orderby=oldest S
Powershell: New-ScheduledTaskTrigger cmdlet with indefinite duration https://learn.microsoft.com/en-us/answers/questions/145419/powershell-new-scheduledtasktrigger-cmdlet-with-in S
Powershell: New-ScheduledTaskTrigger cmdlet AtLogon and Repetition https://learn.microsoft.com/en-us/answers/questions/573477/powershell-new-scheduledtasktrigger-cmdlet-atlogon S
Windows Task Scheduler "Synchronize across time zones" does not work as expected https://learn.microsoft.com/en-us/answers/questions/790592/windows-task-scheduler-synchronize-accross-time-zo S
Task Scheduler run unexpectedly after server reboot following DST change https://learn.microsoft.com/en-us/answers/a/1978310 S
Task Scheduler did not start monthly task after daylight saving change (page 2) https://learn.microsoft.com/en-us/answers/questions/340419/task-scheduler-did-not-start-monthly-task-after-da?page=2 S
Task Scheduler did not start monthly task after DST change (answer) https://learn.microsoft.com/en-us/answers/a/341974 S
windows scheduler — tasks are running at the same time despite different time settings https://learn.microsoft.com/en-us/answers/questions/1032006/windows-scheduler-tasks-are-running-at-the-same-ti S
Windows 10 — Task Scheduler — "Author" field in XML https://learn.microsoft.com/en-us/answers/questions/370031/windows-10-task-scheduler-author-field-in-xml S
Run Scheduled Task as SYSTEM only when user is logged in https://learn.microsoft.com/en-us/answers/questions/259563/run-scheduled-task-as-system-only-when-user-is-log S
Why is my Windows pc automatically locking after startup? https://learn.microsoft.com/en-us/answers/questions/2286178/why-is-my-windows-pc-automatically-locking-after-s S
windows service not really running after shutdown https://learn.microsoft.com/en-us/answers/questions/1275681/windows-service-not-really-running-after-shutdown S

A.4 Microsoft Learn — 서비스 / Session 0

제목 URL 확인
Interactive Services (Win32) https://learn.microsoft.com/en-us/windows/win32/services/interactive-services F
Sc failure (WS2012 R2) https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/cc742019(v=ws.11) F
SC (Windows XP) https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-xp/bb490995(v=technet.10) S
SC (WS2008 R2) cc753662 https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-r2-and-2008/cc753662(v=ws.11) S
Create Windows Service using BackgroundService (.NET) https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service F
Troubleshooting System Services (TechNet Wiki 14774) https://learn.microsoft.com/en-us/archive/technet-wiki/14774.troubleshooting-system-services S
Guidelines for Services (Win32 rstmgr) https://learn.microsoft.com/en-us/windows/win32/rstmgr/guidelines-for-services S
Agent Service Fails to Start on Standalone Server (SQL) https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/startup-shutdown/agent-service-fails-start-stand-alone-server S
How to show details of service https://learn.microsoft.com/en-us/answers/questions/917245/how-to-show-details-of-service S
Windows Services (blogs/hanybarakat) https://learn.microsoft.com/en-us/archive/blogs/hanybarakat/windows-services S
Launching an interactive process from Windows Service in Windows Vista and later https://learn.microsoft.com/en-us/archive/blogs/winsdk/launching-an-interactive-process-from-windows-service-in-windows-vista-and-later S
How to launch a process interactively from a Windows Service? https://learn.microsoft.com/en-us/archive/blogs/winsdk/how-to-launch-a-process-interactively-from-a-windows-service S
Services and Session Zero in Vista and Windows Server 2008 https://learn.microsoft.com/en-us/archive/blogs/brad_rutkowski/services-and-session-zero-in-vista-and-windows-server-2008 S
Do you still use the MessageBox API in your Windows Service? https://learn.microsoft.com/en-us/archive/blogs/yvesdolc/do-you-still-use-the-messagebox-api-in-your-windows-service S
Creating a user-interactive Task Sequence experience https://learn.microsoft.com/en-us/archive/blogs/cameronk/creating-a-user-interactive-task-sequence-experience S
Interactive Services Detection service removed from Windows 10 1803 https://learn.microsoft.com/en-us/answers/questions/fad0c42f-9d12-4cf6-a54a-2f9cf8731e4f/we-noticed-that-the-interactive-services-detection?forum=windows-all S
Screen recording from a Windows service running under local system account https://learn.microsoft.com/en-us/answers/questions/5875437/screen-recording-from-a-windows-service-which-is-r S
dn653293 (previous-versions hardware design) https://learn.microsoft.com/en-us/previous-versions/windows/hardware/design/dn653293(v=vs.85) S
WTSSendMessage 함수 https://learn.microsoft.com/en-us/windows/desktop/api/wtsapi32/nf-wtsapi32-wtssendmessagea S
CreateProcessAsUser 함수 https://learn.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-createprocessasusera S
MessageBox 함수 https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebox S
CreateService 함수 https://learn.microsoft.com/en-us/windows/desktop/api/Winsvc/nf-winsvc-createservicea S
Window Stations https://learn.microsoft.com/en-us/windows/desktop/winstation/window-stations S
LocalSystem account https://learn.microsoft.com/en-us/windows/win32/services/localsystem-account S

A.5 Microsoft Learn — 이벤트 로그

제목 URL 확인
New-EventLog (PowerShell 5.1) https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/new-eventlog?view=powershell-5.1 F
Write-EventLog (PowerShell 5.1) https://learn.microsoft.com/en-us/powershell/module/Microsoft.powershell.management/write-eventlog?view=powershell-5.1 F
How to write windows event logs in PowerShell 7 https://learn.microsoft.com/en-us/answers/questions/593292/how-to-write-windows-event-logs-in-powershell-7 F
New-EventLog (PowerShell 5.0, previous) https://learn.microsoft.com/en-us/previous-versions/powershell/module/microsoft.powershell.management/new-eventlog?view=powershell-5.0 S
New-WinEvent (PowerShell 3.0, previous) https://learn.microsoft.com/en-us/previous-versions/powershell/module/Microsoft.PowerShell.Diagnostics/new-winevent?view=powershell-3.0 S
dd347687 (previous-versions) https://learn.microsoft.com/en-us/previous-versions/dd347687(v=technet.10) S
dd315363 (previous-versions) https://learn.microsoft.com/en-us/previous-versions//dd315363(v=technet.10)?redirectedfrom=MSDN S
EventLogEntryType Enumeration (.NET) https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventlogentrytype S
TaskScheduler/TaskService/TaskEvent.cs (dahall) https://github.com/dahall/TaskScheduler/blob/master/TaskService/TaskEvent.cs S
TaskEvent.cs raw (StandardTaskEventId 열거형) https://raw.githubusercontent.com/dahall/TaskScheduler/master/TaskService/TaskEvent.cs F
EventTracker KB — Event Id 101 Microsoft-Windows-TaskScheduler https://kb.eventtracker.com/evtpass/evtpages/EventId_101_Microsoft-Windows-TaskScheduler_61809.asp S
Event ID 101 Source Microsoft-Windows-TaskScheduler/Operational https://www.myeventlog.com/search/show/855 S
Fix Task Scheduler failed to start, Event ID 101 https://www.thewindowsclub.com/fix-task-scheduler-failed-to-start-event-id-101 S
Task Scheduler Event IDs (mnaoumov.NET) https://mnaoumov.wordpress.com/2014/05/15/task-scheduler-event-ids/ S
ETW — Windows Scheduled Tasks (artifacts.help) https://artefacts.help/windows_etw_scheduled_task.html S
Monitor Task Scheduler? (KS-Soft) https://www.ks-soft.net/phpBB/viewtopic.php?t=8251 S
How to Write to the Windows Event Log Using PowerShell? (SharePoint Diary) https://www.sharepointdiary.com/2022/08/powershell-write-to-event-log.html S
How to Write Logs to the Windows Event Viewer from PowerShell/CMD (Windows OS Hub) https://woshub.com/write-logs-event-viewer-powershell-cmd/ S
PowerShell and the Windows Event Log (cyberfella) https://www.cyberfella.co.uk/2022/05/16/powershell-and-the-windows-event-log/ S
PowerShell Windows Event Log: Create Custom Log Entries (Command in Line) https://www.commandinline.com/powershell-write-eventlog-custom/ S
[BUG] win_task KeyError exception for LastTaskResult (saltstack) https://github.com/saltstack/salt/issues/66441 S
All Task Scheduler Errors and Success Codes (TechDirectArchive) https://techdirectarchive.com/2020/03/24/task-scheduler-errors-and-success-code-what-does-code-0x41301-mean/ S
Task Scheduler Error and Success Codes explained (TheWindowsClub) https://www.thewindowsclub.com/task-scheduler-error-and-success-code-explained S
Scheduled Tasks — Result Codes (Lakshmikanth) https://www.lakshmikanth.com/scheduled-tasks-result-codes/ S
How to analyze Task Scheduler 0x41301 error code and fix it https://www.get-itsolutions.com/task-scheduler-0x41301-error-code-fix/ S
Windows Scheduled tasks result codes (Starbeam Systems) https://starbeamsystems.com/knowledge-base/16-windows/82-windows-scheduled-tasks-result-codes S
Windows Task Scheduler Last Run Result 0x2 (Qlik community) https://community.qlik.com/t5/Talend-Studio/Windows-Task-Scheduler-Last-Run-Result-0x2/td-p/2331821 S

A.6 Microsoft Learn — 전원 / Fast Startup / BitLocker / Windows Update

제목 URL 확인
Fast startup causes hibernation or shutdown to fail in Windows 10 or 8.1 https://learn.microsoft.com/en-us/troubleshoot/windows-client/deployment/fast-startup-causes-system-hibernation-shutdown-fail F
Fast startup causes… (canonical URL) https://learn.microsoft.com/en-us/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail F
Fast startup causes… (pt-br) https://docs.microsoft.com/pt-br/troubleshoot/windows-client/deployment/fast-startup-causes-system-hibernation-shutdown-fail S
Distinguishing Fast Startup from Wake-from-Hibernation https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/distinguishing-fast-startup-from-wake-from-hibernation F
Supporting Windows 8 Fast Startup with Group Policy https://learn.microsoft.com/en-us/archive/blogs/keithmayer/supporting-windows-8-fast-startup-with-group-policy S
SYSTEM_POWER_STATE_CONTEXT 구조체 https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_system_power_state_context S
SYSTEM_POWER_STATE 열거형 https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_system_power_state S
Manage device restarts after updates https://learn.microsoft.com/en-us/windows/deployment/update/waas-restart F
Policy CSP — Update (ActiveHoursStart/End/MaxRange) https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-update S
Specify deadlines for automatic updates and restarts (WUfB) https://learn.microsoft.com/en-us/windows/deployment/update/wufb-compliancedeadlines S
Windows Update: FAQ https://support.microsoft.com/windows/windows-update-faq-8a903416-6f45-0718-f5c7-375e92dddeb2 S
BitLocker countermeasures https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/countermeasures S
Troubleshooting BitLocker policies from the client side (Intune) https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-protection/troubleshoot-bitlocker-policies S
Notes on BitLocker and the TPM and the pre-boot password or PIN (The Old New Thing) https://devblogs.microsoft.com/oldnewthing/20220412-00/?p=106468 S
Autologon — Sysinternals https://learn.microsoft.com/en-us/sysinternals/downloads/autologon F
Autologon 다운로드 (ZIP) https://download.sysinternals.com/files/AutoLogon.zip S
Autologon (Sysinternals Live) https://live.sysinternals.com/Autologon.exe S
Protecting the Automatic Logon Password https://learn.microsoft.com/en-us/windows/win32/secauthn/protecting-the-automatic-logon-password/ S
Hibernate Enabled but Timed Hibernate Not Working https://learn.microsoft.com/en-us/answers/questions/3853365/hibernate-enabled-but-timed-hibernate-not-working S
hh875530 (WS2012 R2, 전원 관련) https://learn.microsoft.com/nb-no/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/hh875530(v=ws.11) S
How to back up and restore the registry in Windows https://support.microsoft.com/help/322756 S

A.7 Microsoft Learn — WSL

제목 URL 확인
Use systemd to manage Linux services with WSL https://learn.microsoft.com/en-us/windows/wsl/systemd F
Advanced settings configuration in WSL (wsl.conf / .wslconfig) https://learn.microsoft.com/en-us/windows/wsl/wsl-config F
wsl2 can't run in the background after enabling systemd https://learn.microsoft.com/en-us/answers/questions/1192206/wsl2-cant-run-in-the-background-after-enabling-sys S
systemd support is now available in WSL (devblogs) https://devblogs.microsoft.com/commandline/systemd-support-is-now-available-in-wsl/ S
Background Task Support in WSL (devblogs, 2017) https://devblogs.microsoft.com/commandline/background-task-support-in-wsl/ S
WSL in Microsoft Store https://aka.ms/wslstorepage S
Ubuntu Desktop 23.04 release roundup (systemd default on WSL) https://canonical.com/blog/ubuntu-desktop-23-04-release-roundup S
Enable systemd on Ubuntu WSL (Ubuntu blog) https://ubuntu.com/blog/ubuntu-wsl-enable-systemd S
Run a .Net Echo Bot as a systemd service on Ubuntu WSL https://ubuntu.com/tutorials/run-dotnet-echo-bot-with-systemd-on-ubuntu-wsl#1-overview S
microk8s demo (craigloewen-msft) https://github.com/craigloewen-msft/microk8sdemo S
Cron will not keep running in background (microsoft/WSL#9072) https://github.com/microsoft/WSL/issues/9072 S
WSL cannot be run by scheduled task (microsoft/WSL#10732) https://github.com/microsoft/WSL/issues/10732 S
How to Launch Cron Automatically in WSL (How-To Geek) https://www.howtogeek.com/746532/how-to-launch-cron-automatically-in-wsl-on-windows-10-and-11/ S
cron on Windows Subsystem for Linux (Open Water Foundation) https://learn.openwaterfoundation.org/owf-learn-linux-shell/appendix-cron/cron-wsl/cron-wsl/ S
Use WSL Cron Jobs to Run Windows Scheduled Tasks (pwshtips) https://pwshtips.com/posts/wsl-cron-run-windows-scheduled-tasks/ S
snapcraft https://snapcraft.io/ S
microk8s https://microk8s.io/ S
Install MicroK8s on WSL2 https://microk8s.io/docs/install-wsl2 S
systemd.io https://systemd.io S

A.8 알림 (BurntToast / Python 토스트 / msg.exe / 집중 지원)

제목 URL 확인
Windos/BurntToast (GitHub) https://github.com/Windos/BurntToast F
BurntToast README (raw) https://raw.githubusercontent.com/Windos/BurntToast/main/README.md F
BurntToast New-BTButton Help (raw) https://raw.githubusercontent.com/Windos/BurntToast/main/Help/New-BTButton.md F
BurntToast New-BTButton docs (raw, 구 경로) https://raw.githubusercontent.com/Windos/BurntToast/main/docs/New-BTButton.md X (404)
PowerShell Gallery — BurntToast 0.4 New-BurntToastNotification.ps1 https://www.powershellgallery.com/packages/BurntToast/0.4/Content/New-BurntToastNotification.ps1 S
Display toast notifications with PowerShell's BurntToast module (PDQ) https://www.pdq.com/blog/display-toast-notifications-with-powershell-burnt-toast-module/ S
Create pop-up notifications from PowerShell with BurntToast (Luis Llamas) https://www.luisllamas.es/en/burnttoast/ S
Generate Windows toast notifications with BurntToast (4sysops) https://4sysops.com/archives/generate-windows-toast-notifications-with-the-powershell-module-burnttoast/ S
Windows Toast Notifications With PowerShell (Wit IT) https://witit.blog/windows-toast-notifications-with-powershell/ S
Reboot Notifications with BurntToast (Joshua Dearing) https://www.dearing.dev/posts/Reboot-Notifications-with-BurntToast-A-Simple-Guide/ S
Efficient Notifications Using PowerShell BurntToast (psplaybook) https://www.psplaybook.com/2024/12/03/custom-powershell-notification-script-using-burnttoast-module/ S
Powershell: burnt toast notification (PowerShell Forums) https://forums.powershell.org/t/powershell-burnt-toast-notification/24222 S
powershell script for system reboot (MS Q&A) https://learn.microsoft.com/en-us/answers/questions/1339894/powershell-script-for-system-reboot S
GitHub30/win11toast https://github.com/GitHub30/win11toast F
win11toast (PyPI) https://pypi.org/project/win11toast/ S
DatGuy1/Windows-Toasts https://github.com/DatGuy1/Windows-Toasts S
Windows-Toasts 문서 (readthedocs) https://windows-toasts.readthedocs.io/ F
Windows-Toasts Getting started https://windows-toasts.readthedocs.io/en/latest/getting_started.html F
Windows-Toasts (PyPI) https://pypi.org/project/Windows-Toasts S
toasted (PyPI) https://pypi.org/project/toasted S
toasted 0.2.0 (PyPI) https://pypi.org/project/toasted/0.2.0 S
ysfchn/toasted (GitHub) https://github.com/ysfchn/toasted S
Python: Windows Toast Notifications (Tongere) https://tongere.hashnode.dev/python-windows-toast-notifications S
Notificaciones de Windows con Python (DEV) https://dev.to/asjordi/notificaciones-de-windows-con-python-5cdk S
msg (Windows Commands) https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msg F
The Windows Process Journey — msg.exe (Medium) https://medium.com/@boutnaru/the-windows-process-journey-msg-exe-message-utility-a241640c365c S
msg.exe (STRONTIC xcyclopedia) https://strontic.github.io/xcyclopedia/library/msg.exe-200850BBE6A2DE88A212E0E86C3FF845.html S
Send Messages to all currently logged on Users (SID-500) https://sid-500.com/2017/10/07/active-directory-send-messages-to-all-currently-logged-on-users-msg-exe/comment-page-1/ S
Display message on screen as was done in Win7 thru Task Scheduler (Eleven Forum) https://www.elevenforum.com/t/display-message-on-screen-as-was-done-in-win7-thru-task-scheduler.16861/ S
[Fix] Can't Create Tasks to Display Messages in Task Scheduler (AskVG) https://www.askvg.com/fix-cant-create-tasks-to-display-messages-in-windows-8-task-scheduler/ S
Msg command how to (cezeo) https://www.cezeo.com/tips-and-tricks/msg-command/ S
Windows XP in a Nutshell — msg (O'Reilly) https://www.oreilly.com/library/view/windows-xp-in/0596009003/re122.html S
How to Set Priority Notifications for Do Not Disturb in Windows 11 (NinjaOne) https://www.ninjaone.com/blog/priority-notifications-for-do-not-disturb/ F
Turn On or Off Focus Assist in Windows 11 (Eleven Forum) https://www.elevenforum.com/t/turn-on-or-off-focus-assist-in-windows-11.1351/ S
How to use Focus assist to avoid distractions in Windows 11 (Windows Central) https://www.windowscentral.com/how-use-focus-assist-avoid-distractions-windows-11 S
How to Use Focus Assist on Windows 11 (Nerds Chalk) https://nerdschalk.com/how-to-use-focus-assist-on-windows-11/ S
How to Use Focus Assist on Windows 11 (groovyPost) https://www.groovypost.com/howto/use-focus-assist-on-windows-11/ S
Master Windows Notifications: Do Not Disturb & Focus Assist (Windows Forum) https://windowsforum.com/threads/master-windows-notifications-silence-noise-with-do-not-disturb-and-focus-assist.404264/ S
Windows 11 Focus Assist Explained (onewebcare) https://onewebcare.com/windows/windows-11-focus-assist/ S
How to reduce distractions in Windows 11 with Focus Assist (TechRadar) https://www.techradar.com/how-to/how-to-reduce-distractions-in-windows-11 S
Show App UI at Logon with Windows 11 Task Scheduler (Windows Forum) https://windowsforum.com/threads/show-app-ui-at-logon-with-windows-11-task-scheduler.392418/ S

A.9 헬스체크 / dead-man switch

제목 URL 확인
Monitor PowerShell Scripts with Healthchecks.io https://healthchecks.io/docs/powershell/ F
Healthchecks.io Pinging API (HTTP API) https://healthchecks.io/docs/http_api/ F
Healthchecks.io Configuring checks (Period/Grace) https://healthchecks.io/docs/configuring_checks/ F
How to Monitor Cron Jobs with Healthchecks.io https://healthchecks.io/docs/monitoring_cron_jobs/ S
Healthchecks.io Documentation https://healthchecks.io/docs/ S
PowerShell — devroom.io Healthchecks (미러) https://healthchecks.devroom.io/docs/powershell/ S
Documentation — WMIT Watchdog (미러) https://healthchecks.it.wm.edu/docs/ S
PowerShell — Healthchecks (verbis.dkfz.de 미러) https://healthchecks.verbis.dkfz.de/docs/powershell/ S
Documentation — Async Healthchecks (미러) https://hc.async.com.br/docs/ S
PowerShell — RDSec Healthchecks.io (미러) https://healthchecks.cloud.rdsec.nl/docs/powershell/ S
PowerShell — Health Checks (iqusong 미러) https://healthchecks.iqusong.com/docs/powershell/ S
Healthchecks.io: The Ultimate Guide (Medium) https://medium.com/@nisheet110/healthchecks-io-the-ultimate-guide-to-application-and-cron-job-monitoring-fd1b6bf311fc S
Uptime Kuma — Configure Push Monitor (Programster) https://blog.programster.org/uptime-kuma-configure-push-monitor F
Using Push heartbeat monitoring in Uptime Kuma (SmartxTechnologies Wiki) https://wiki.smartxtechnologies.com/uptime-kuma/monitor-push-heartbeat X (본문 없음)
jmclaren7/uptime-kuma-push (PowerShell) https://github.com/jmclaren7/uptime-kuma-push S
JPVenson/uptime-kuma-pushr https://github.com/JPVenson/uptime-kuma-pushr S
Monitoring sites and backups with Uptime Kuma (Symfolidity) https://symfolidity.com/en/articles/monitoring-sites-and-backups-with-uptime-kuma/ S
Self-Host Uptime Kuma on a VPS (RDP.sh) https://rdp.sh/blog/self-host-uptime-kuma-on-a-vps-for-free-status-monitoring S
The Dead Man's Switch: Backup Monitoring with Duplicati and Uptime Kuma (tywer.dev) https://tywer.dev/the-dead-mans-switch-foolproof-backup-monitoring-with-duplicati-and-uptime-kuma S
Self-Hosted Cron Job Monitoring: Healthchecks vs Uptime Kuma vs Prometheus 2026 (Pi Stack) https://www.pistack.xyz/posts/self-hosted-cron-job-monitoring-healthchecks-uptime-kuma-prometheus-guide-2026/ S
Uptime Kuma: The Monitoring Tool That Actually Makes Sense (Substack) https://dataengineeringtoolkit.substack.com/p/uptime-kuma-the-monitoring-tool-that S

A.10 서비스 래퍼 (NSSM / WinSW / pywin32)

제목 URL 확인
NSSM Usage https://nssm.cc/usage F
NSSM Download (버전 확인 실패) https://nssm.cc/download X (timeout 60000ms)
winsw/winsw (GitHub) https://github.com/winsw/winsw F
WinSW XML config file (v3 docs) https://github.com/winsw/winsw/blob/v3/docs/xml-config-file.md F
WinSW Releases https://github.com/winsw/winsw/releases F
pywin32 win32serviceutil.py (mhammond) https://github.com/mhammond/pywin32/blob/main/win32/Lib/win32serviceutil.py S
Pywin32 win32serviceutil.py (SublimeText 미러) https://github.com/SublimeText/Pywin32/blob/master/lib/x32/win32/lib/win32serviceutil.py S
An example Windows service implemented with pywin32 wrappers (gist) https://gist.github.com/drmalex07/10554232 S
Python as a Windows Service Example (MSSQLTips) https://www.mssqltips.com/sqlservertip/7318/python-as-a-windows-service/ S
Creating a one-file Windows service in Python with pywin32 and PyInstaller (Metallapan) https://metallapan.se/post/windows-service-pywin32-pyinstaller/ S
Developing a python based Windows Service (Nathan Sanders) https://www.nathanasanders.com/2022/04/09/developing-a-python-based-windows-service/ S
Building a Robust Windows Service in Python with win32serviceutil (DEV) https://dev.to/demola12/building-a-robust-windows-service-in-python-with-win32serviceutil-part-13-1k6k S
Python Programming on Win32 ch18 (O'Reilly) https://www.oreilly.com/library/view/python-programming-on/1565926218/ch18s05s04.html S
Microsoft.Extensions.Hosting.WindowsServices (NuGet) https://nuget.org/packages/Microsoft.Extensions.Hosting.WindowsServices S

A.11 Playwright / 브라우저

제목 URL 확인
Browsers (Playwright Python) https://playwright.dev/python/docs/browsers F
Browsers (Playwright) https://playwright.dev/docs/browsers S
microsoft/playwright (GitHub) https://github.com/microsoft/playwright S
[Question] chromium doesn't show up? (#20242, NSSM 서비스) https://github.com/microsoft/playwright/issues/20242 F
Headful chromium on Windows does not launch without '--single-process' (#12174) https://github.com/microsoft/playwright/issues/12174 S
[Bug] Unable to launch chromium in headless mode in win11 v1.49.1 (#34306) https://github.com/microsoft/playwright/issues/34306 S
[Bug] chromium-headless-shell (#34508) https://github.com/microsoft/playwright/issues/34508 S
[Bug] headless=True but XServer error log (playwright-python#2498) https://github.com/microsoft/playwright-python/issues/2498 S
Headless Chrome Explained (browserless.io) https://www.browserless.io/blog/headless-chrome S
How to Run Tests in Playwright Headless Chrome? (BrowserStack) https://www.browserstack.com/guide/playwright-headless-chrome S
Playwright (software) — Wikipedia https://en.wikipedia.org/wiki/Playwright_(software) S

A.12 Docker

제목 URL 확인
Start containers automatically (restart policies) https://docs.docker.com/engine/containers/start-containers-automatically/ F
Docker Desktop settings (General) https://docs.docker.com/desktop/settings-and-maintenance/settings/ F

A.13 AI CLI headless (참고 모델)

제목 URL 확인
Run Claude Code programmatically (headless) — 공식 https://code.claude.com/docs/en/headless F
Claude Code docs 인덱스 https://code.claude.com/docs/llms.txt S
Agent SDK overview https://code.claude.com/docs/en/agent-sdk/overview S
CLI reference https://code.claude.com/docs/en/cli-reference S
Environment variables https://code.claude.com/docs/en/env-vars S
Permissions https://code.claude.com/docs/en/permissions S
Sub-agents https://code.claude.com/docs/en/sub-agents S
Claude Console https://platform.claude.com S
jq https://jqlang.org/ S
JSON Schema https://json-schema.org/ S
What Is Claude Code Headless Mode? (MindStudio) https://www.mindstudio.ai/blog/claude-code-headless-mode-autonomous-agents S
Claude Code Can Work While You Sleep (wmedia.es) https://wmedia.es/en/tips/claude-code-headless-mode-autonomous-agent S
Background Claude https://backgroundclaude.com/ S
Claude Code Headless Mode: The Complete Self-Hosting Guide (2026) (amux) https://amux.io/guides/claude-code-headless/ S
How to Set Up Cron Jobs with Claude Code (Usagebar) https://usagebar.com/blog/how-to-do-cron-job-setup-on-claude-code S
Claude Code Automation: Non-Interactive Mode (DevShelfHub) https://www.devshelfhub.com/tutorials/claude-code/automation/ S
Claude Code Headless Mode Guide (2026) (Like One) https://likeone.ai/blog/claude-code-headless-mode-guide-2026/ S
Claude Code Headless Mode: The -p Flag (AI Skill Certs) https://aiskillcerts.com/concepts/claude-code-config/the-p-flag-for-non-interactive-mode S
Claude Code Headless Mode (Build This Now) https://www.buildthisnow.com/blog/guide/development/claude-code-headless-mode S

A.14 기타 (전원 / 활성 시간 / 시간대 / 커뮤니티)

제목 URL 확인
PowerCfg command (SS64) https://ss64.com/nt/powercfg.html S
PowerCFG Commands for Windows 11 and 10: Complete Guide (iTechGuides) https://www.itechguides.com/mastering-the-powercfg-command-on-windows-11-and-10/ S
How to Manage Windows 11 Power and Sleep Settings (cloudspress) https://www.cloudspress.com/how-to-manage-your-windows-11-power-and-sleep-settings-a-step-by-step-guide/ S
PowerCFG Command Guide for Windows 11 and 10 (cloudspress) https://www.cloudspress.com/mastering-the-powercfg-command-on-windows-11-and-10/ S
PowerCFG Tutorial: A Complete Guide (pchardwarepro) https://www.pchardwarepro.com/en/PowerCFG-tutorial:-A-complete-guide-to-mastering-power-management-in-Windows/ S
PowerShell on Windows 11 Home: cap display/sleep/hibernate (DEV) https://dev.to/teoman_egeselcuk_d962da6/title-powershell-on-windows-11-home-how-to-cap-displaysleephibernate-at-5-minutes-300-s-7ag S
Turn On or Off Fast Startup in Windows 10 (Ten Forums) https://www.tenforums.com/tutorials/4189-turn-off-fast-startup-windows-10-a.html S
Why I wish I had disabled Windows… (Yahoo Tech) https://tech.yahoo.com/computing/articles/why-wish-had-disabled-windows-163120947.html S
Set Active Hours for Windows Update in Windows 11 (Eleven Forum) https://www.elevenforum.com/t/set-active-hours-for-windows-update-in-windows-11.3436/ S
Script to update active hours on Windows 10/11 devices (Hexnode) https://www.hexnode.com/mobile-device-management/help/script-to-update-active-hours-on-windows-10-11-devices/ S
Set Active Hours To Avoid Random Restarts In Windows 11 (HTMD Blog) https://www.anoopcnair.com/set-active-hours-to-avoid-restarts-windows-11/ S
3 Ways to Change Windows 10 Active Hours (MajorGeeks) https://www.majorgeeks.com/content/page/3_ways_to_change_windows_10_active_hours.html S
How to configure and use Active Hours in Windows 11 (TheWindowsClub) https://www.thewindowsclub.com/configure-and-use-active-hours-in-windows-10 S
How to Disable Active Hours in Windows 11 (techradar.info) https://techradar.info/how-to-disable-active-hours-in-windows-11-the-ultimate-power-user-guide/ S
Maimer/update-active-hours (GitHub) https://github.com/Maimer/update-active-hours S
No Reboot (itch.io) https://blearychicken.itch.io/no-reboot S
MiWorkspace: Changing Active Hours on Windows 11 (UMich TeamDynamix) https://teamdynamix.umich.edu/TDClient/30/Portal/KB/PrintArticle?ID=13604 S
Scheduled Task Trigger — Synchronize Across Time Zones (The CLI Guy) https://www.thecliguy.co.uk/2020/02/09/scheduled-task-trigger-synchronize-across-time-zones/ S
xScheduledTask: Trigger has no option for Synchronize across time zones (ComputerManagementDsc#109) https://github.com/dsccommunity/ComputerManagementDsc/issues/109 S
Scheduled task timezone support (trigger.dev changelog) https://trigger.dev/changelog/scheduled-task-timezones S
Run whether user is logged on or not (bnosac/taskscheduleR#44) https://github.com/bnosac/taskscheduleR/issues/44 S
Task Scheduler (run whether user is logged on or not) (iditect) https://www.iditect.com/program-example/windows--task-scheduler-run-whether-user-is-logged-on-or-not.html S
Fixing Task Scheduler Tasks That Don't Run or Exit 0x1 (KomuraSoft) https://comcomponent.com/en/blog/windows-task-scheduler-reliable-scheduled-tasks/ S
How to Use BitLocker with PIN (Dell) https://www.dell.com/support/kbdoc/en-us/000142382/how-to-use-bitlocker-with-pin S
How to Enable a Pre-Boot BitLocker PIN on Windows (How-To Geek) https://www.howtogeek.com/262720/how-to-enable-a-pre-boot-bitlocker-pin-on-windows/ S
How to enable Pre-Boot BitLocker startup PIN with Intune (Oliver Kieselbach) https://oliverkieselbach.com/2019/08/02/how-to-enable-pre-boot-bitlocker-startup-pin-on-windows-with-intune/comment-page-1/ S
Silently enable BitLocker with PIN during Autopilot (Katy's Tech Blog) https://katystech.blog/mem/bitlocker-with-pin S
Fix BitLocker Endless Recovery Key Prompt (4idiotz) https://4idiotz.com/tech/computers-and-operating-systems/bitlocker-troubleshooting/fix-bitlocker-endless-recovery-key-prompt-ultimate-troubleshooting-guide-for-windows-users/ S
Introduction to systemctl (Linode) https://www.linode.com/docs/guides/introduction-to-systemctl/ S
Understanding and Using Systemd (Linux.com) https://www.linux.com/training-tutorials/understanding-and-using-systemd/ S
Systemd Essentials (DigitalOcean) https://www.digitalocean.com/community/tutorials/systemd-essentials-working-with-services-units-and-the-journal S
How To Sandbox Processes With Systemd (DigitalOcean) https://www.digitalocean.com/community/tutorials/how-to-sandbox-processes-with-systemd-on-ubuntu-20-04 S
about_CommonParameters https://go.microsoft.com/fwlink/?LinkID=113216 S
New-CimSession (fwlink) https://go.microsoft.com/fwlink/p/?LinkId=227967 S
Get-CimSession (fwlink) https://go.microsoft.com/fwlink/p/?LinkId=227966 S
XML duration type (fwlink 106886) https://go.microsoft.com/fwlink/p/?linkid=106886 S
Events and Errors Message Center https://www.microsoft.com/technet/support/ee/ee_advanced.aspx S

총 출처 수: 약 230개 (중복 URL 제외).


부록 B. 미해결 질문 / 실측 필요 항목

B.1 반드시 실측해야 하는 항목 (구현 전 차단 요소)

  • B-1. S4U 로그온에서 Windows Credential Manager / python keyring / DPAPI 복호화가 동작하는가? 공식 문서는 "no password is stored by the system and there is no access to either the network or encrypted files" 라고만 말한다. DPAPI 사용자 마스터 키 접근 가능 여부는 명시되어 있지 않다. 검증 방법: S4U 로 등록한 테스트 작업에서 python -c "import keyring; print(keyring.get_password('dmf','api'))" 를 실행하고 로그로 결과를 확인한다. 실패하면 §4.4 결정 트리에 따라 Password 모드로 전환하거나 자격증명을 파일 기반으로 옮긴다.
  • B-2. agy(Google Antigravity CLI)의 실제 헤드리스 플래그. -p, --output-format, --allowedTools 등은 Claude Code 의 관례를 차용한 가정이다. 이번 조사에서 agy 관련 공식 문서를 전혀 열람하지 못했다. 검증 방법: agy --help, agy -p --help 출력을 확보하고 §19.2 스크립트의 $psi.Arguments 를 수정한다. 또한 agy 의 설치 경로·부트스트랩 명령(npm install -g ... 가정)도 확인해야 한다.
  • B-3. NOC_GLOBAL_SETTING_TOASTS_ENABLED 의 값 방향(0=허용? 1=허용?). 출처가 "0 → 방해 금지 비활성화 / 1 → 방해 금지 활성화" 로 서술했으나 값 이름(TOASTS_ENABLED)과 반대 방향처럼 읽힌다. 사용자 설정을 잘못 바꾸면 알림이 완전히 죽는다. 검증 방법: 설정 UI 에서 방해 금지를 켜고/끄며 레지스트리 값 변화를 관찰한다. 검증 전에는 이 값을 프로그램으로 변경하지 마라.
  • B-4. healthchecks.io 무료 플랜의 체크 개수·보존 기간 한도. docs/powershell/docs/http_api/ 어디에도 플랜 한도가 없었다. 크롤러용 1개 + 워치독용 1개 = 2개면 충분할 것으로 보이나 확인 필요. 검증 방법: https://healthchecks.io/pricing/ 확인.
  • B-5. windows-toasts 의 "Problem solving" / "Custom AUMIDs" 페이지 내용. Task Scheduler·서비스에서의 caveat 와 AUMID 등록 절차가 이 페이지들에 있을 가능성이 높으나 열람하지 못했다. 검증 방법: https://windows-toasts.readthedocs.io/ 의 해당 섹션 직접 확인. (BurntToast 를 쓰기로 했으므로 우선순위는 낮다.)
  • B-6. NSSM 의 최신 릴리스 버전과 날짜. https://nssm.cc/download 이 60초 타임아웃으로 응답하지 않았다. NSSM 을 채택하지 않기로 했으므로 차단 요소는 아니다.
  • B-7. Fast Startup 비활성화 레지스트리 값 HiberbootEnabled 의 정확한 경로/이름. HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\PowerHiberbootEnabled 는 널리 통용되나 인용한 Microsoft 문서에는 없었다. 검증 방법: 제어판에서 "빠른 시작 켜기" 를 토글하며 레지스트리 값 변화를 관찰. 또는 powercfg /h off 만 사용(이쪽은 공식 확인됨).
  • B-8. powercfg 절전 하위 그룹/웨이크 타이머 GUID. 238c9fa8-0aad-41ed-83f4-97be242c8f20(SUB_SLEEP), bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d(RTCWAKE) 는 표준 값으로 알려져 있으나 Microsoft 문서로 확인하지 못했다. 검증 방법: powercfg /query SCHEME_CURRENT SUB_SLEEP 출력에서 실제 GUID 를 읽어 스크립트에 반영.
  • B-9. sc failureflag 서브커맨드의 존재와 구문. 공식 Sc failure 문서에는 언급이 없다. 서비스화를 하지 않기로 했으므로 차단 요소는 아니다. 검증 방법: sc.exe failureflag /? 실행.

B.2 이 프로젝트에서 결정해야 할 운영 사항

  • B-10. LogonType 최종 결정. B-1 실측 결과에 따라 S4U 또는 Password. Password 로 간다면 계정 암호 변경 시의 작업 재등록 절차를 운영 문서에 명시할 것(증상: 0x8004130F).
  • B-11. 리포트 저장 위치. 로컬(D:\workspace\DMF_Crawler\reports)로 끝낼 것인가, 네트워크 공유(UNC)로도 복사할 것인가? UNC 를 쓰면 S4U 가 불가능해진다(§4.3).
  • B-12. 알림 채널 확정. Discord / Telegram / Slack / 이메일 중 무엇을 쓸 것인가? 최소 1개는 반드시 있어야 한다(PC 전원 차단 시 유일한 경로).
  • B-13. healthchecks.io vs 자체 Uptime Kuma. 제약사 내부 정책상 외부 SaaS 로 ping 을 보내는 것이 허용되는가? (ping 자체에는 업무 데이터가 없지만 정책 확인 필요.)
  • B-14. PC 를 24시간 켜 둘 것인가? 켜 둔다면 WakeToRun 은 보험일 뿐이고 설계가 단순해진다. 꺼 둔다면 WakeToRun + BIOS 웨이크 타이머 지원 여부를 실측해야 한다.
  • B-15. BitLocker 구성. TPM-only 로 갈 것인가? 회사 정책이 TPM+PIN 을 강제한다면 재부팅 후 자동 복구를 포기하고 워치독 알림으로 대응한다(§14.3).
  • B-16. ExecutionTimeLimit 값. PT2H 는 가정이다. 실제 크롤링 소요 시간을 2주간 측정해 평균의 3배 정도로 조정하라. healthchecks 의 Grace Time 도 함께 조정해야 한다(§9.6).
  • B-17. 크롤러 exit code 규약 확정. 0=성공, 1=일반, 2=설정/환경, 3=크롤링 실패, 4=리포트 생성 실패 로 제안했다. Python 측 구현과 합의할 것.
  • B-18. 알림 큐 폭주 방지. 며칠 연속 실패하면 로그온 시 토스트가 여러 개 뜬다. notify.ps1-MaxToasts 3 으로 제한했으나, "3일 연속 실패" 를 하나로 묶어 요약하는 로직이 더 나을 수 있다.

B.3 확인되었으나 후속 실측이 권장되는 사항

  • B-19. Fast Startup 환경에서 <BootTrigger> 가 실제로 발화하는가? 문서상 "may not fire" 이므로, 대상 PC 에서 ① 종료 후 켜기 ② 다시 시작 두 경우를 각각 시험하고 이벤트 ID 118 (BootTrigger) 발생 여부를 확인하라.
  • B-20. StartWhenAvailable 이 실제로 언제 발화하는가? 06:00 을 놓쳤을 때 부팅 후 몇 분 뒤에 실행되는지 이벤트 ID 114 (MissedTaskLaunched) 로 측정하라. Microsoft 는 정확한 지연을 문서화하지 않는다.
  • B-21. RunOnlyIfNetworkAvailable 이 부팅 직후를 제대로 판별하는가? 오탐으로 작업이 아예 실행되지 않을 위험이 있다(이벤트 ID 112 JobNoStartWithoutNetwork). 문제가 있으면 이 설정을 끄고 스크립트 내부 Wait-Network 재시도(§14.6)에만 의존하라.
  • B-22. Microsoft-Windows-TaskScheduler/Operational 채널의 64MB 설정이 실제로 적용되는가? wevtutil get-logmaxSize 를 확인하라.
  • B-23. 토스트 버튼 -ActivationType Protocoldmfcrawler://retry 가 실제 동작하는가? §13.3 프로토콜 등록 후 Start-Process 'dmfcrawler://retry' 로 먼저 검증하고, 그 다음 토스트 버튼으로 검증하라.
  • B-24. Windows 11 Pro 에서 msg.exe * 가 비대화형 작업(S4U)에서 호출될 때 동작하는가? AllowRemoteRPC=1 설정이 필요한지 실측하라.
  • B-25. BurntToast 의 -Urgent 스위치가 이 버전에 존재하는가? v1.1.0 에서 추가되었다고 하나, 설치된 버전에 따라 없을 수 있다. notify.ps1(Get-Command New-BurntToastNotification).Parameters.ContainsKey('Urgent') 로 방어했다.

이 문서는 조사 raw dump agent-a99bc957be6fc45cc.md (7,111줄)를 전량 정독하여 작성되었다. 인용문 중 영문 원문은 출처 페이지의 문장을 그대로 옮긴 것이며, 한국어 서술은 그에 대한 해설과 이 프로젝트에 대한 적용 판단이다. ⚠️ 미검증 표시가 붙은 항목은 출처로 확인되지 않았으므로 구현 전 반드시 실측하라.