# 운영 런북 — 06:00 스케줄링 · 재부팅 내성 · 워치독 > **이 문서의 역할**: DMF Crawler 를 Windows 11 PC 에 올려 **매일 06:00 에 사람 없이 실행**시키고, 재부팅·절전·Windows Update·프로세스 강제 종료를 견디게 만들고, 죽었을 때 **사람이 15분 안에 알아채고 복구**하게 만드는 **실행 런북**이다. 이 문서의 명령은 복사·붙여넣기하면 그대로 동작해야 한다. 설계 근거는 `docs/design/01-architecture.md`(정본)에, 스케줄러 옵션의 원문 근거는 `docs/research/08-windows-scheduling-and-resilience.md`에 있다. **충돌하면 아키텍처 정본이 이긴다.** --- ## 0. 한눈에 보기 이 문서가 확정하는 것: - **실행 컨테이너는 Windows 작업 스케줄러다.** Windows 서비스·WSL2 cron·Docker Desktop 은 모두 기각한다. 결정적 이유는 성능이 아니라 **`agy` OAuth 토큰이 `~/.gemini/antigravity-cli/antigravity-oauth-token` 평문 파일로 사용자 프로필 안에 있다**는 사실이다 — 배치는 **반드시 그 사용자 계정 컨텍스트**에서 돌아야 한다. - **작업은 3종이다**: `DMF_Crawler_Daily`(배치, S4U, 06:00 + 부팅), `DMF_Crawler_Agent`(알림·워치독, Interactive, 로그온 + 15분 반복), `DMF_Crawler_AgyUpdate`(주간 `agy update`, 일요일 14:00). 전부 `\DMF_Crawler\` 폴더에 등록한다. - **워치독은 4번째 작업이 아니다.** heartbeat 신선도 판정은 `DMF_Crawler_Agent` 안(`watchdog.py`)에서 돈다. 알림을 띄울 수 있는 세션에서 판정해야 "판정은 됐는데 화면에 못 띄운다"는 공백이 사라지기 때문이다(아키텍처 ADR-10·기각 기록). - **기본값이 우리를 배신하는 4개를 반드시 뒤집는다**: `DisallowStartIfOnBatteries`(기본 true→false), `StopIfGoingOnBatteries`(true→false), `StartWhenAvailable`(false→true), `WakeToRun`(false→true). 손대지 않으면 노트북에서 06:00 에 **안 돈다**. - **`AtStartup` 트리거는 보조 수단이지 안전망이 아니다.** Fast Startup(빠른 시작) 때문에 "종료 → 켜기"는 실제로는 커널 세션 최대 절전 복귀라서 부팅 트리거가 안 뜬다. 진짜 캐치업 안전망은 **`StartWhenAvailable`** 이다. - **`-LogonType S4U` 가 기본이지만, DPAPI 복호화가 S4U 세션에서 실패하면 즉시 `Password` 로 전환한다.** 이 분기는 추측하지 말고 §2.8 의 **프로브 절차로 실측**한다. 판정이 갈리는 유일한 설치 시점 결정이다. - **중복 실행 방어는 3중이다**: 코드 idempotency 가드(오늘 이미 SUCCESS면 종료 0) → `state\run.lock` 파일 락(`msvcrt`) → 스케줄러 `MultipleInstances=IgnoreNew`. 스케줄러 설정만으로는 "06:00 실행 후 재부팅 캐치업" 같은 **순차 재실행**을 못 막는다. - **한국은 DST 가 없다(KST=UTC+9 고정).** 작업 트리거에 "표준 시간대에 맞춰 동기화(Synchronize across time zones)"를 **켜지 않는다**. DST 전환 버그의 사정권 밖이다. - **Windows Update 자동 재시작은 활성 시간(Active hours) 05:00–23:00 으로 06:00 을 보호한다.** 최대 범위 18시간 제한 안에 들어간다. - **로그는 실행 1회당 디렉터리 하나**(`logs\run_\`)로 떨어진다. 조사 시작점이 "가장 최근 디렉터리를 연다" 하나로 고정된다. --- ## 1. 스케줄링 방식 확정 ### 1.1 워크로드 성질 | 항목 | 값 | 스케줄러 선택에 주는 함의 | |---|---|---| | 실행 빈도 | 하루 1회 06:00 (KST) | 상주 프로세스 불필요 | | 실행 시간 | 정상 2~6분, 상한 30분(`ExecutionTimeLimit`) | 타임아웃 여유 필요 | | 네트워크 | 공개 Open API 로의 **아웃바운드 HTTPS** 만 | 네트워크 드라이브·UNC·도메인 인증 불필요 | | 자격증명 | ① 공공데이터포털 API 키(DPAPI 사용자 범위 암호화) ② `agy` OAuth 토큰(사용자 프로필 평문 파일) | **사용자 프로필이 살아 있어야 한다** | | 산출물 | `reports\*.xlsx`, `data\dmf.sqlite3` | 로컬 디스크 쓰기 | | 알림 | 실패 시 토스트·강제 모달 | **데스크톱이 있는 세션**이 필요 | | 재부팅 내성 | 필수 | 부팅 트리거 + 놓친 작업 캐치업 | ### 1.2 후보 4종 평가 | 방식 | agy 토큰 접근 | 부팅 내성 | 놓친 실행 캐치업 | 알림 표시 | 설치·형상관리 | 판정 | |---|---|---|---|---|---|---| | **작업 스케줄러** | ✅ 사용자 계정으로 실행 가능 | ✅ `BootTrigger` | ✅ `StartWhenAvailable` 내장 | △ 배치는 불가 → 별도 Interactive 작업으로 분리 | ✅ XML export/import | **채택** | | Windows 서비스(NSSM/WinSW/pywin32) | ⚠️ SYSTEM 이면 **불가**. 사용자 계정으로 돌리면 가능하나 이점 소멸 | ✅ | ❌ 직접 구현 | ❌ **Session 0 격리** — UI 절대 불가 | ❌ 관리자 설치·제거 | 기각 | | WSL2 cron | ❌ 토큰이 Windows 프로필에 있음(교차 접근 지저분) | ❌ 배포판이 떠 있어야 cron 이 돈다 | ❌ | ❌ | ❌ | 기각 | | Docker Desktop + cron | ❌ 토큰·DPAPI 모두 컨테이너 밖 | ❌ "Start Docker Desktop when you sign in" — **로그인 없이는 안 뜬다** | ❌ | ❌ | △ | 기각 | ### 1.3 결정과 그 근거 세 줄 1. **`agy` 토큰이 사용자 프로필 평문 파일**이라는 실측 사실(agy SSOT §4.2)이 SYSTEM 계정을 원천 배제한다. 서비스의 유일한 장점(SYSTEM 무인성)이 여기서는 장점이 아니라 **결함**이다. 2. 하루 1회 5분짜리 배치를 위해 24시간 상주 프로세스를 띄우면 "그 프로세스는 누가 감시하나"라는 감시 대상이 하나 더 생긴다. 작업 스케줄러는 OS 가 이미 감시한다. 3. 서비스는 Session 0 격리 때문에 **요구 R7.2(강제 창)를 구조적으로 만족할 수 없다.** 작업 스케줄러는 Interactive 로그온 타입 작업으로 이 문제를 정면 해결한다. ### 1.4 작업 3종 세트 ``` ┌──────────────────────────────────────────────────────────────────────────┐ │ ① \DMF_Crawler\DMF_Crawler_Daily [배치 · UI 없음] │ │ 트리거 : 매일 06:00 (RandomDelay PT4M) + 시스템 시작 (Delay PT5M) │ │ 보안 : <도메인>\<사용자>, LogonType S4U(기본), RunLevel Highest │ │ 설정 : StartWhenAvailable / WakeToRun / 배터리 조건 해제 / │ │ RestartCount 3 · RestartInterval PT10M / │ │ ExecutionTimeLimit PT30M / MultipleInstances IgnoreNew │ │ 액션 : .venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled│ │ 결과 : 성공 → state\heartbeat.json 갱신 │ │ 실패 → alerts 테이블 + state\alerts.json + 이벤트 로그 │ │ ★ 화면에는 아무것도 띄우지 않는다 (S4U 에는 데스크톱이 없다) │ └──────────────────────────────────────────────────────────────────────────┘ │ (파일·DB 를 통한 비동기 전달) ▼ ┌──────────────────────────────────────────────────────────────────────────┐ │ ② \DMF_Crawler\DMF_Crawler_Agent [알림 · 워치독 · UI 소유자] │ │ 트리거 : 로그온 시 + 15분마다 무한 반복 │ │ 보안 : 동일 사용자, LogonType Interactive, RunLevel Limited │ │ 설정 : ExecutionTimeLimit PT10M / MultipleInstances IgnoreNew │ │ 액션 : .venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once │ │ 동작 : watchdog 판정 → WARN/INFO 토스트 → CRITICAL 복구 GUI(모달) │ └──────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────┐ │ ③ \DMF_Crawler\DMF_Crawler_AgyUpdate [주간 유지보수] │ │ 트리거 : 매주 일요일 14:00 │ │ 보안 : 동일 사용자, LogonType S4U, RunLevel Limited │ │ 액션 : %LOCALAPPDATA%\agy\bin\agy.exe update │ │ 이유 : 배치 중 자동 업데이트가 바이너리를 교체하면 실행이 깨진다. │ │ 배치는 AGY_CLI_DISABLE_AUTO_UPDATE=true 로 자동 업데이트를 │ │ 끄고(코드에서 주입), 교체는 사람이 깨어 있는 시간에 몰아서 한다.│ └──────────────────────────────────────────────────────────────────────────┘ ``` **핵심 설계 원칙 한 줄**: *알림을 발생시키는 주체(배치)와 알림을 표시하는 주체(에이전트)를 분리한다.* 배치는 "알림 의도"만 남기고, 데스크톱을 가진 에이전트가 그것을 읽어 띄운다. ### 1.5 워치독을 별도 작업으로 만들지 않는 이유 `DMF_Crawler_Agent` 가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 **작업이 하나 줄고** "판정한 세션이 곧 표시 가능한 세션"이라는 성질이 공짜로 따라온다. 07:00 짜리 별도 워치독 작업을 두면 판정은 S4U 세션에서 되고 표시는 다른 세션에서 되어, 두 세션 사이에 또 큐를 놓아야 한다. 기각한다. > **예외**: 사람이 며칠씩 로그오프해 두는 PC 라면 §5.8 의 자동 로그온 대안 또는 부록의 웹훅 확장을 검토하라. 기본 구성은 "다음 로그온 시 밀린 알림을 전부 표시"로 흡수한다(아키텍처 실패 경로 [F]). --- ## 2. 주 작업 등록 스크립트 ### 2.1 파일 이름과 호출 관계 | 파일 | 역할 | |---|---| | `scripts\install_tasks.ps1` | **작업 3종 idempotent 등록.** 이 절의 전문 | | `scripts\uninstall_tasks.ps1` | 작업 3종 제거 | | `python -m dmf_crawler install-task --time 06:00 --user <계정>` | 위 스크립트를 `config.toml` 값으로 호출하는 얇은 래퍼 | > 파일명은 **복수형 `install_tasks.ps1`** 이다(아키텍처 디렉터리 트리 §2). 작업이 3개이므로 단수형은 쓰지 않는다. ### 2.2 설정 키 → 스크립트 파라미터 매핑 PowerShell 은 TOML 을 파싱하지 못한다. 그래서 **`config.toml` 을 읽는 쪽은 Python CLI 이고, PS 스크립트는 파라미터만 받는다.** 스크립트의 기본값은 `config.toml` 기본값과 **정확히 같게** 유지한다. | `config.toml` 키 | 스크립트 파라미터 | 기본값 | |---|---|---| | `schedule.daily_time` | `-Time` | `06:00` | | `schedule.jitter_seconds` | `-JitterSeconds` | `240` | | `schedule.startup_delay_minutes` | `-StartupDelayMinutes` | `5` | | `schedule.execution_time_limit_minutes` | `-ExecutionTimeLimitMinutes` | `30` | | `schedule.restart_count` | `-RestartCount` | `3` | | `schedule.restart_interval_minutes` | `-RestartIntervalMinutes` | `10` | | `schedule.agent_repeat_minutes` | `-AgentRepeatMinutes` | `15` | | `schedule.agy_update_weekday` | `-AgyUpdateWeekday` | `Sunday` | | `schedule.agy_update_time` | `-AgyUpdateTime` | `14:00` | | — | `-User` | `$env:USERDOMAIN\$env:USERNAME` | | — | `-LogonType` | `S4U` | ### 2.3 `scripts\install_tasks.ps1` 전문 ```powershell #Requires -Version 5.1 <# .SYNOPSIS DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate)을 등록한다. .DESCRIPTION 멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다. 관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요). .EXAMPLE powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 .EXAMPLE # DPAPI 프로브 결과 S4U 가 실패했을 때 powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password .EXAMPLE # 등록 직후 보안 컨텍스트 프로브까지 수행 powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify #> [CmdletBinding()] param( [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), [string]$TaskPath = '\DMF_Crawler\', [string]$Time = '06:00', [string]$User = "$env:USERDOMAIN\$env:USERNAME", [ValidateSet('S4U', 'Password', 'Interactive')] [string]$LogonType = 'S4U', [System.Security.SecureString]$Password, [ValidateRange(0, 300)] [int]$JitterSeconds = 240, [ValidateRange(0, 60)] [int]$StartupDelayMinutes = 5, [ValidateRange(5, 720)] [int]$ExecutionTimeLimitMinutes = 30, [ValidateRange(0, 255)] [int]$RestartCount = 3, [ValidateRange(1, 1440)] [int]$RestartIntervalMinutes = 10, [ValidateRange(1, 1440)] [int]$AgentRepeatMinutes = 15, [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')] [string]$AgyUpdateWeekday = 'Sunday', [string]$AgyUpdateTime = '14:00', [switch]$SkipAgyUpdateTask, [switch]$Verify ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ---------------------------------------------------------------- 유틸 function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" } function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow } function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green } function Assert-Administrator { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $pr = [Security.Principal.WindowsPrincipal]::new($id) if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)' } } function ConvertTo-PlainText { param([System.Security.SecureString]$Secure) if (-not $Secure) { return $null } $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } function Get-TimeOfDay { param([string]$Text, [string]$Label) $parsed = [datetime]::MinValue $ok = [datetime]::TryParseExact( $Text, 'HH:mm', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsed) if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" } return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute) } # ---------------------------------------------------------------- 0. 사전 점검 Assert-Administrator $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path $PythonExe = Join-Path $ProjectRoot '.venv\Scripts\python.exe' $PythonwExe = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe' $AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe' Write-Step "프로젝트 루트 : $ProjectRoot" Write-Step "실행 계정 : $User (LogonType=$LogonType)" foreach ($exe in @($PythonExe, $PythonwExe)) { if (-not (Test-Path -LiteralPath $exe)) { throw "가상환경 실행 파일이 없습니다: $exe`n → bootstrap.cmd 를 먼저 실행하세요." } } if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) { Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.' } foreach ($dir in @('state', 'logs', 'reports', 'data')) { $p = Join-Path $ProjectRoot $dir if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null } } $plainPassword = $null if ($LogonType -eq 'Password') { if (-not $Password) { $Password = Read-Host -AsSecureString "«$User» 계정의 Windows 로그인 암호" } $plainPassword = ConvertTo-PlainText -Secure $Password if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' } } # ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화 # 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다. Write-Step '작업 스케줄러 Operational 로그 활성화' & wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet & wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864 if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." } # ---------------------------------------------------------------- 2. 공통 등록 함수 function Register-DmfTask { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Action, [Parameter(Mandatory)]$Trigger, [Parameter(Mandatory)]$Settings, [Parameter(Mandatory)]$Principal, [Parameter(Mandatory)][string]$Description, [string]$PlainPassword ) $existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue if ($existing) { Write-Step "기존 작업 제거: $Path$Name" Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false } $definition = New-ScheduledTask ` -Action $Action ` -Trigger $Trigger ` -Settings $Settings ` -Principal $Principal ` -Description $Description if ($PlainPassword) { Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition ` -User $Principal.UserId -Password $PlainPassword | Out-Null } else { Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null } Write-Good "등록 완료: $Path$Name" } # ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily Write-Step '① DMF_Crawler_Daily 구성' $dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time' $actionDaily = New-ScheduledTaskAction ` -Execute $PythonExe ` -Argument '-m dmf_crawler run --trigger scheduled' ` -WorkingDirectory $ProjectRoot $trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt ` -RandomDelay (New-TimeSpan -Seconds $JitterSeconds) # AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다. $trgBoot = New-ScheduledTaskTrigger -AtStartup $trgBoot.Delay = "PT${StartupDelayMinutes}M" $setDaily = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -WakeToRun ` -DontStopOnIdleEnd ` -RunOnlyIfNetworkAvailable ` -ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) ` -RestartCount $RestartCount ` -RestartInterval (New-TimeSpan -Minutes $RestartIntervalMinutes) ` -MultipleInstances IgnoreNew ` -Priority 5 ` -Compatibility Win8 $prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest Register-DmfTask ` -Name 'DMF_Crawler_Daily' ` -Path $TaskPath ` -Action $actionDaily ` -Trigger @($trgDaily, $trgBoot) ` -Settings $setDaily ` -Principal $prcDaily ` -Description "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." ` -PlainPassword $plainPassword # ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent Write-Step '② DMF_Crawler_Agent 구성' $actionAgent = New-ScheduledTaskAction ` -Execute $PythonwExe ` -Argument '-m dmf_crawler notify-pump --once' ` -WorkingDirectory $ProjectRoot # (a) 로그온 시. (b) 지금부터 15분마다 무한 반복. $trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User $repeatStart = (Get-Date).AddMinutes(2) try { $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` -RepetitionDuration ([TimeSpan]::MaxValue) } catch { # 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다. Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체' $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` -RepetitionDuration (New-TimeSpan -Days 3650) } # 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게). $trgLogon.Repetition = $trgRepeat.Repetition $setAgent = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -DontStopOnIdleEnd ` -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` -MultipleInstances IgnoreNew ` -Priority 7 ` -Compatibility Win8 # Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업). $prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited Register-DmfTask ` -Name 'DMF_Crawler_Agent' ` -Path $TaskPath ` -Action $actionAgent ` -Trigger @($trgLogon, $trgRepeat) ` -Settings $setAgent ` -Principal $prcAgent ` -Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다." # ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate if ($SkipAgyUpdateTask) { Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.' } elseif (-not (Test-Path -LiteralPath $AgyExe)) { Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe" Write-Warn ' → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.' } else { Write-Step '③ DMF_Crawler_AgyUpdate 구성' $agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time' $actionAgy = New-ScheduledTaskAction ` -Execute $AgyExe ` -Argument 'update' ` -WorkingDirectory $ProjectRoot $trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 ` -DaysOfWeek $AgyUpdateWeekday -At $agyAt ` -RandomDelay (New-TimeSpan -Minutes 10) $setAgy = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RunOnlyIfNetworkAvailable ` -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` -MultipleInstances IgnoreNew ` -Priority 7 ` -Compatibility Win8 $prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited Register-DmfTask ` -Name 'DMF_Crawler_AgyUpdate' ` -Path $TaskPath ` -Action $actionAgy ` -Trigger $trgAgy ` -Settings $setAgy ` -Principal $prcAgy ` -Description "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." ` -PlainPassword $plainPassword } # ---------------------------------------------------------------- 6. 등록 결과 요약 Write-Host '' Write-Step '등록 결과' Get-ScheduledTask -TaskPath $TaskPath | Select-Object TaskName, State, @{ n = 'LogonType'; e = { $_.Principal.LogonType } }, @{ n = 'RunLevel'; e = { $_.Principal.RunLevel } }, @{ n = 'UserId'; e = { $_.Principal.UserId } } | Format-Table -AutoSize Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object { $info = $_ | Get-ScheduledTaskInfo [pscustomobject]@{ TaskName = $_.TaskName NextRunTime = $info.NextRunTime LastRunTime = $info.LastRunTime LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult) } } | Format-Table -AutoSize # ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify) if ($Verify) { Write-Host '' Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)' $probeName = 'DMF_Crawler_Probe' $probeOut = Join-Path $ProjectRoot 'state\probe.json' if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force } # doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다. $probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut $actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot $setProbe = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8 $trgProbe = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10) # 자동 실행은 절대 안 함 $prcProbe = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger $trgProbe ` -Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' ` -PlainPassword $plainPassword try { Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath $deadline = (Get-Date).AddMinutes(3) do { Start-Sleep -Seconds 2 $state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) if (-not (Test-Path -LiteralPath $probeOut)) { Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.' Write-Warn ' → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인' } else { $raw = Get-Content -LiteralPath $probeOut -Raw try { $doc = $raw | ConvertFrom-Json $bad = @($doc.checks | Where-Object { -not $_.ok }) if ($bad.Count -eq 0) { Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다." } else { Write-Warn "프로브 실패 항목 $($bad.Count) 개:" $bad | ForEach-Object { Write-Warn (" - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) } if ($bad.key -contains 'api_key') { Write-Warn '' Write-Warn ' ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.' Write-Warn ' 다음 명령으로 암호 저장 방식으로 다시 등록하세요:' Write-Warn " .\scripts\install_tasks.ps1 -LogonType Password -Verify" } } } catch { Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:' Write-Host $raw } } } finally { Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue Write-Step '프로브 작업 제거 완료' } } Write-Host '' Write-Good '작업 등록이 끝났습니다. 다음 단계: §2.7 검증 명령을 실행하세요.' ``` ### 2.4 `scripts\uninstall_tasks.ps1` 전문 ```powershell #Requires -Version 5.1 <# .SYNOPSIS DMF Crawler 작업 3종을 제거한다. 데이터·로그·리포트는 건드리지 않는다. #> [CmdletBinding()] param( [string]$TaskPath = '\DMF_Crawler\', [switch]$RemoveFolder ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $names = @('DMF_Crawler_Daily', 'DMF_Crawler_Agent', 'DMF_Crawler_AgyUpdate', 'DMF_Crawler_Probe') foreach ($n in $names) { $t = Get-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue if ($t) { if ($t.State -eq 'Running') { Write-Host "[uninstall_tasks] 실행 중 → 중지: $n" Stop-ScheduledTask -TaskName $n -TaskPath $TaskPath } Unregister-ScheduledTask -TaskName $n -TaskPath $TaskPath -Confirm:$false Write-Host "[uninstall_tasks] 제거: $TaskPath$n" } else { Write-Host "[uninstall_tasks] 없음(건너뜀): $TaskPath$n" } } if ($RemoveFolder) { try { $svc = New-Object -ComObject 'Schedule.Service' $svc.Connect() $root = $svc.GetFolder('\') $root.DeleteFolder($TaskPath.Trim('\'), 0) Write-Host "[uninstall_tasks] 폴더 제거: $TaskPath" } catch { Write-Host "[uninstall_tasks] 폴더 제거 실패(무해): $($_.Exception.Message)" } } Write-Host '[uninstall_tasks] 완료. data\ logs\ reports\ backup\ state\ 는 그대로 남아 있습니다.' ``` ### 2.5 실행 방법 ```powershell # 관리자 권한 PowerShell 에서 cd D:\workspace\DMF_Crawler powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify ``` `ExecutionPolicy` 를 영구히 바꾸지 않는다. 호출 시점의 `-ExecutionPolicy Bypass` 로 충분하다. ### 2.6 왜 액션이 `powershell.exe` 가 아니라 `python.exe` 인가 | 이유 | 설명 | |---|---| | **종료 코드 보존** | 중간에 `powershell.exe` 를 끼우면 스크립트가 `$LASTEXITCODE` 를 명시적으로 `exit` 하지 않는 한 종료 코드가 뭉개진다. `RestartCount` 는 종료 코드에 반응하므로 치명적이다. | | **콘솔 창** | S4U 세션에는 데스크톱이 없어 `python.exe` 라도 창이 뜨지 않는다. 반대로 Interactive 로 도는 Agent 는 `pythonw.exe` 를 써야 검은 창이 깜빡이지 않는다. | | **레이어 하나 제거** | 인코딩(코드페이지 949 vs UTF-8), 실행 정책, 프로필 로딩 같은 PowerShell 고유 변수를 제거한다. | > **`AGY_CLI_DISABLE_AUTO_UPDATE` 는 작업에 설정하지 않는다.** 작업 스케줄러 액션은 환경변수를 직접 넣을 수 없다. 이 변수는 `agy/client.py` 가 `subprocess` 를 띄울 때 자식 환경에 주입한다 — 배치가 아니라 **코드의 책임**이다. ### 2.7 등록 후 검증 명령 ```powershell # (1) 작업 3종이 Ready 상태로 보이는가 Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-Table TaskName, State -AutoSize # (2) 다음 실행 시각이 내일 06:00 근처인가 Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo | Format-List TaskName, NextRunTime, LastRunTime, LastTaskResult, NumberOfMissedRuns # (3) 보안 컨텍스트가 의도대로인가 (SYSTEM 이면 즉시 실패로 간주하라) (Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Principal | Format-List UserId, LogonType, RunLevel # (4) 배터리·캐치업·절전 해제 4종이 제대로 뒤집혔는가 (Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings | Format-List DisallowStartIfOnBatteries, StopIfGoingOnBatteries, StartWhenAvailable, WakeToRun, RunOnlyIfNetworkAvailable, ExecutionTimeLimit, MultipleInstances, Priority # (5) 재시작 정책 (Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings | Format-List RestartCount, RestartInterval # (6) 트리거 2개(Daily + Boot)가 다 붙었는가. BootTrigger 의 Delay 가 PT5M 인가 (Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Triggers | Format-List CimClass, Enabled, StartBoundary, RandomDelay, Delay # (7) 절전 해제 타이머로 등록됐는가 (WakeToRun 의 실제 효과 확인) powercfg /waketimers # (8) XML 전문을 눈으로 확인 Export-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' # (9) 고전 도구로도 교차 확인 schtasks /Query /TN "\DMF_Crawler\DMF_Crawler_Daily" /V /FO LIST ``` **자동 검증 스니펫** — 하나라도 어긋나면 붉게 출력한다. 설치 직후 그대로 붙여넣어라. ```powershell $t = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' $s = $t.Settings $p = $t.Principal $exp = [ordered]@{ 'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false) 'StopIfGoingOnBatteries = False' = ($s.StopIfGoingOnBatteries -eq $false) 'StartWhenAvailable = True' = ($s.StartWhenAvailable -eq $true) 'WakeToRun = True' = ($s.WakeToRun -eq $true) 'MultipleInstances = IgnoreNew' = ($s.MultipleInstances -eq 'IgnoreNew') 'RestartCount = 3' = ($s.RestartCount -eq 3) 'RestartInterval = PT10M' = ($s.RestartInterval -eq 'PT10M') 'ExecutionTimeLimit = PT30M' = ($s.ExecutionTimeLimit -eq 'PT30M') 'RunLevel = Highest' = ($p.RunLevel -eq 'Highest') 'UserId != SYSTEM' = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE') '트리거 2개(Daily + Boot)' = ($t.Triggers.Count -eq 2) } $fail = 0 foreach ($k in $exp.Keys) { if ($exp[$k]) { Write-Host (" OK {0}" -f $k) -ForegroundColor Green } else { Write-Host (" FAIL {0}" -f $k) -ForegroundColor Red; $fail++ } } if ($fail -gt 0) { Write-Host "`n$fail 개 항목이 어긋났습니다. install_tasks.ps1 을 다시 실행하세요." -ForegroundColor Red } else { Write-Host "`n전 항목 통과." -ForegroundColor Green } ``` ### 2.8 ★ S4U vs Password — 설치 시점에 반드시 실측할 단 하나의 분기 **사실 관계부터 정확히.** Microsoft 문서의 `TASK_LOGON_S4U` 설명 원문: > "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**." 여기서 "no access to the network" 는 **네트워크 자원에 사용자 자격증명으로 인증하는 것**(UNC 공유, 매핑 드라이브, Kerberos 위임)을 말한다. **공개 HTTPS 엔드포인트로의 아웃바운드 요청은 막히지 않는다.** 우리 크롤러는 `https://apis.data.go.kr/...` 하나만 호출하므로 이 제약에 걸리지 않는다. **진짜 위험은 "encrypted files" 쪽이다.** S4U 로그온에는 사용자 암호가 개입하지 않으므로 **DPAPI 사용자 마스터 키를 풀지 못해 `CryptUnprotectData` 가 실패할 수 있다.** 우리는 공공데이터포털 API 키를 `secrets_dpapi.py` 로 DPAPI 암호화해 저장한다 → **정면 충돌 가능성이 있다.** | | `S4U` | `Password` | |---|---|---| | 암호 저장 | 안 함 | Task Scheduler 자격증명 저장소에 저장 | | 로그오프 상태 실행 | ✅ | ✅ | | 아웃바운드 HTTPS | ✅ | ✅ | | UNC·매핑 드라이브 | ❌ | ✅ | | **DPAPI 사용자 범위 복호화** | **⚠️ 실패할 수 있음 — 실측 필요** | ✅ | | 사용자 암호 변경 시 | 영향 없음 | **작업이 깨짐 → 재등록 필요** | | 필요 권한 | 해당 계정에 `Logon as Batch` | 동일 | **판정 절차(추측 금지):** ```powershell # 1) S4U 로 등록하면서 프로브까지 수행 .\scripts\install_tasks.ps1 -LogonType S4U -Verify # 2) 출력의 api_key 체크를 본다. # "OK" → S4U 그대로 간다. 끝. # "FAIL: DPAPI ..." → 3) 으로. # 3) 암호 저장 방식으로 재등록 .\scripts\install_tasks.ps1 -LogonType Password -Verify ``` **`Password` 로 갔다면 반드시 기록할 것**: 이 PC 는 **Windows 로그인 암호를 바꾸는 순간 06:00 배치가 죽는다.** 암호 변경 후 `install_tasks.ps1 -LogonType Password` 재실행이 필수 절차다. `docs/ops/02-failure-alerting.md` 의 복구 안내와 온보딩 GUI 체크 ⑨ 에 이 문구가 들어가야 한다. > **`Logon as Batch` 권한**: "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." 개인 PC 의 관리자 계정이면 기본으로 있다. 없으면 `secpol.msc → 로컬 정책 → 사용자 권한 할당 → 일괄 작업으로 로그온`에 계정을 추가한다. --- ## 3. 동등한 schtasks XML 전문 작업 스케줄러 XML 은 **형상관리 가능한 정본**이다. `scripts\tasks\` 아래에 두고 Git 에 올린다. `` 와 경로만 PC 에 맞게 치환한다. > **인코딩 주의**: `schtasks /Create /XML` 은 **UTF-16 LE** 파일을 기대한다. PowerShell 에서 저장할 때 `Out-File -Encoding Unicode` 를 쓰거나, 인코딩 문제를 피하려면 `Register-ScheduledTask -Xml (Get-Content -Raw -Encoding UTF8 ...)` 를 쓴다. ### 3.1 `scripts\tasks\DMF_Crawler_Daily.xml` ```xml 2026-09-02T00:00:00 DMF Crawler DMF 일일 수집·비교·리포트 배치. 매일 06:00 + 부팅 후 5분. UI 를 띄우지 않는다. \DMF_Crawler\DMF_Crawler_Daily 2026-09-02T06:00:00 true PT4M 1 true PT5M DESKTOP-XXXXXXX\encep S4U HighestAvailable IgnoreNew false false true true true false false true true false false false true true PT30M 5 PT10M 3 D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled D:\workspace\DMF_Crawler ``` **XSD 상 유효성 근거 3가지** (이걸 모르면 "왜 임포트가 거부되는지" 를 못 찾는다): - `` 은 `PT1M` 이상 `P31D` 이하로 제한된다. `PT10M` 은 유효. - `` 는 `unsignedByte` 이고 최소 1. `3` 은 유효. - `` 에 `PT0S` 를 주면 **무제한**이 된다. 값을 아예 생략하면 기본 3일이다. 우리는 폭주 방지를 위해 `PT30M` 을 명시한다. ### 3.2 `scripts\tasks\DMF_Crawler_Agent.xml` ```xml 2026-09-02T00:00:00 DMF Crawler DMF 알림 에이전트. 로그온 시 + 15분마다 heartbeat 를 점검하고 밀린 알림을 표시한다. \DMF_Crawler\DMF_Crawler_Agent true DESKTOP-XXXXXXX\encep PT15M false 2026-09-02T00:05:00 true PT15M false DESKTOP-XXXXXXX\encep InteractiveToken LeastPrivilege IgnoreNew false false true false false false false true true false false false true false PT10M 7 D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once D:\workspace\DMF_Crawler ``` `` 에서 `` 을 **생략하면 무기한 반복**이다. `false` 와 함께 쓴다. `WakeToRun` 은 **false** — 알리미가 새벽 3시에 PC 를 깨우면 안 된다. ### 3.3 `scripts\tasks\DMF_Crawler_AgyUpdate.xml` ```xml 2026-09-02T00:00:00 DMF Crawler agy CLI 주간 업데이트. 배치 시간대를 피해 일요일 14:00 에 돈다. \DMF_Crawler\DMF_Crawler_AgyUpdate 2026-09-06T14:00:00 true PT10M 1 DESKTOP-XXXXXXX\encep S4U LeastPrivilege IgnoreNew false false true true true false false true true false false false true false PT30M 7 C:\Users\encep\AppData\Local\agy\bin\agy.exe update D:\workspace\DMF_Crawler ``` > `%LOCALAPPDATA%` 같은 환경변수는 `` 에서 **전개되지 않는다.** 절대 경로를 써야 한다. ### 3.4 XML 임포트 · 익스포트 명령 ```powershell # ── 임포트 (schtasks, UTF-16 파일 전제) ────────────────────────────── schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` /XML "D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml" /F # S4U(암호 저장 안 함)로 계정을 지정해 임포트 schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /F # 암호 저장 방식으로 임포트 (실행 시 암호를 물어본다) schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /RP * /F # ── 임포트 (PowerShell, 인코딩 걱정 없음) ──────────────────────────── $xml = Get-Content -Raw -Encoding UTF8 ` 'D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml' Register-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' ` -Xml $xml -User 'DESKTOP-XXXXXXX\encep' -Force # ── 익스포트 (현재 PC 의 실제 상태를 정본으로 되돌려 받기) ──────────── New-Item -ItemType Directory -Force -Path 'D:\workspace\DMF_Crawler\scripts\tasks' | Out-Null foreach ($n in 'DMF_Crawler_Daily','DMF_Crawler_Agent','DMF_Crawler_AgyUpdate') { $t = Get-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' -ErrorAction SilentlyContinue if ($t) { Export-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' | Out-File -Encoding Unicode "D:\workspace\DMF_Crawler\scripts\tasks\$n.xml" Write-Host "exported: $n" } } ``` --- ## 4. 워치독과 heartbeat ### 4.1 heartbeat 파일 규약 | 항목 | 값 | |---|---| | 경로 | `D:\workspace\DMF_Crawler\state\heartbeat.json` | | 인코딩 | UTF-8 (BOM 없음), LF | | 쓰는 주체 | `pipeline.py` 의 `finalize` 스테이지 (**성공 · 부분성공일 때만**) | | 읽는 주체 | `watchdog.py`(에이전트 안), `checks.py` 체크 ⑫, 운영자의 `check_heartbeat.ps1` | | 쓰기 방식 | 임시 파일 → `os.replace` 원자 교체. **SQLite 잠금을 요구하지 않는다** | | 갱신 시점 | 파이프라인 종료 직전. `status ∈ {SUCCESS, PARTIAL}` 일 때만 `updated_at` 을 현재 시각으로 밀어 올린다 | | **갱신하지 않는 경우** | `FAILED`, `BLOCKED`, `ExecutionTimeLimit` 강제 종료, 프로세스 크래시, 작업이 아예 시작되지 못함 | **★ 설계상 가장 중요한 한 줄**: **실패했을 때 heartbeat 를 갱신하면 dead-man switch 가 설계상 무력화된다.** "돌긴 돌았다"를 기록하고 싶은 유혹을 버려라. 실행 시도 자체는 `runs` 테이블과 `events.jsonl` 이 이미 남긴다. ### 4.2 `state\heartbeat.json` 스키마와 예시 ```json { "schema": 1, "updated_at": "2026-09-02T06:04:37+09:00", "run_id": "20260902_060012", "run_date": "2026-09-02", "trigger": "scheduled", "status": "SUCCESS", "exit_code": 0, "started_at": "2026-09-02T06:00:12+09:00", "duration_seconds": 265, "records_total": 12874, "events": { "new": 3, "changed": 1, "withdrawn": 0 }, "integrity": { "passed": true, "blocked_gate": null }, "report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_리포트_2026-09-02.xlsx", "log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012", "agy": { "used": true, "status": "OK", "tokens": 31245 }, "consecutive_failures": 0, "host": "DESKTOP-XXXXXXX", "user": "encep", "version": "0.1.0" } ``` | 필드 | 타입 | 의미 | |---|---|---| | `schema` | int | 파일 포맷 버전. 읽는 쪽은 모르는 버전이면 WARN 후 무시 | | `updated_at` | ISO8601 (오프셋 포함) | **워치독 판정의 유일한 기준** | | `run_id` | str | `YYYYMMDD_HHMMSS` (KST). 로그 디렉터리 이름과 1:1 | | `run_date` | `YYYY-MM-DD` | 실행 대상 일자. idempotency 가드가 쓰는 키 | | `trigger` | `scheduled` \| `startup` \| `manual` | 어느 트리거로 돌았나 | | `status` | `SUCCESS` \| `PARTIAL` | `PARTIAL` = 리포트는 나왔지만 무결성 게이트 차단 또는 AI 실패 | | `exit_code` | int | 0 / 1 / 2 / 130 | | `duration_seconds` | int | 사람이 "느려졌다"를 감지하는 지표 | | `records_total` | int | 오늘 수집 총 건수 | | `events` | object | 신규/변경/취하 건수 | | `integrity.blocked_gate` | str \| null | 차단한 게이트 이름(있으면 PARTIAL 사유) | | `report_path` | str | 알림에서 "리포트 열기" 버튼이 쓴다 | | `log_dir` | str | 알림에서 "로그 폴더 열기" 버튼이 쓴다 | | `agy.status` | `OK` \| `SKIPPED` \| `AUTH` \| `QUOTA` \| `ERROR` | AI 계층 결과 | | `consecutive_failures` | int | 마지막 성공 이후 누적 실패 횟수. `notify.consecutive_failure_critical`(3) 승격 판정에 쓴다 | ### 4.3 heartbeat 기록기 — `src\dmf_crawler\watchdog.py` 의 쓰기 부분 ```python """heartbeat 기록과 신선도 판정. 이 모듈은 SQLite 를 열지 않는다. 알림 에이전트가 배치와 락 경쟁을 하지 않고 상태를 읽을 수 있어야 하기 때문이다(아키텍처 §2 state/ 설명). """ from __future__ import annotations import json import os import socket import tempfile from dataclasses import dataclass from datetime import datetime, time, timedelta from pathlib import Path from typing import Any, Literal from zoneinfo import ZoneInfo HEARTBEAT_SCHEMA = 1 Status = Literal["SUCCESS", "PARTIAL"] def write_heartbeat(path: Path, payload: dict[str, Any]) -> None: """heartbeat.json 을 원자적으로 교체한다. 성공·부분성공일 때만 호출한다. 실패 시 호출하면 dead-man switch 가 무력화된다. """ payload = {"schema": HEARTBEAT_SCHEMA, **payload} payload.setdefault("host", socket.gethostname()) payload.setdefault("user", os.environ.get("USERNAME", "")) path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp( dir=str(path.parent), prefix=".heartbeat-", suffix=".tmp" ) tmp = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh: json.dump(payload, fh, ensure_ascii=False, indent=2) fh.write("\n") fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) except BaseException: tmp.unlink(missing_ok=True) raise ``` ### 4.4 워치독 판정 로직 — 같은 파일의 읽기·판정 부분 **나이브한 구현이 반드시 틀리는 지점**: "heartbeat 가 120분보다 오래됐으면 경보" 라고 쓰면, 새벽 3시에는 어제 06:00 heartbeat 가 21시간 묵어 있으므로 **매일 밤 오탐이 뜬다.** 올바른 기준은 **"가장 최근에 지나간 예정 실행 시각(expected_last_run) 이후에 갱신됐는가"** 이다. ```python @dataclass(frozen=True, slots=True) class Heartbeat: updated_at: datetime run_id: str run_date: str status: str exit_code: int report_path: str log_dir: str consecutive_failures: int raw: dict[str, Any] @classmethod def load(cls, path: Path, tz: ZoneInfo) -> "Heartbeat | None": """읽지 못하면 None. 예외를 밖으로 던지지 않는다 — 진단기가 죽어서 진단이 안 되는 일이 없어야 한다.""" try: doc = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None try: updated = datetime.fromisoformat(str(doc["updated_at"])) except (KeyError, ValueError): return None if updated.tzinfo is None: updated = updated.replace(tzinfo=tz) return cls( updated_at=updated.astimezone(tz), run_id=str(doc.get("run_id", "")), run_date=str(doc.get("run_date", "")), status=str(doc.get("status", "")), exit_code=int(doc.get("exit_code", -1)), report_path=str(doc.get("report_path", "")), log_dir=str(doc.get("log_dir", "")), consecutive_failures=int(doc.get("consecutive_failures", 0)), raw=doc, ) @dataclass(frozen=True, slots=True) class Verdict: stale: bool code: str # OK | NEVER_RAN | STALE | GRACE | DEGRADED expected_at: datetime | None age_minutes: int | None detail: str def expected_last_run(now: datetime, daily_time: time) -> datetime: """now 기준으로 '가장 최근에 지나간 예정 실행 시각'.""" today = now.replace( hour=daily_time.hour, minute=daily_time.minute, second=0, microsecond=0 ) return today if now >= today else today - timedelta(days=1) def judge( hb: Heartbeat | None, *, now: datetime, daily_time: time, stale_minutes: int, ) -> Verdict: """워치독 판정. stale_minutes 는 '예정 시각 이후 이만큼 지나도 갱신이 없으면 경보'라는 유예 시간이다(config: notify.watchdog_stale_minutes, 기본 120). 지터 4분 + 재시작 3회 × 10분 = 최악 34분 을 충분히 덮는다. """ expected = expected_last_run(now, daily_time) deadline = expected + timedelta(minutes=stale_minutes) if hb is None: if now < deadline: return Verdict(False, "GRACE", expected, None, "아직 한 번도 실행되지 않았지만 유예 시간 안입니다.") return Verdict(True, "NEVER_RAN", expected, None, "성공 기록이 한 번도 없습니다. 최초 설치가 끝나지 않았을 수 있습니다.") age = int((now - hb.updated_at).total_seconds() // 60) if hb.updated_at >= expected: code = "DEGRADED" if hb.status == "PARTIAL" else "OK" detail = ( f"마지막 성공 {hb.updated_at:%Y-%m-%d %H:%M} " f"(run_id={hb.run_id}, status={hb.status})" ) return Verdict(False, code, expected, age, detail) if now < deadline: return Verdict(False, "GRACE", expected, age, f"{expected:%H:%M} 실행이 아직 진행 중이거나 재시도 중일 수 있습니다.") return Verdict( True, "STALE", expected, age, f"{expected:%Y-%m-%d %H:%M} 예정 실행의 성공 기록이 없습니다. " f"마지막 성공은 {hb.updated_at:%Y-%m-%d %H:%M} ({age}분 전)입니다.", ) ``` ### 4.5 판정 → 알림 연결 `notify/pump.py` 가 `judge()` 결과를 받아 다음 표대로 행동한다. 알림 문구 4요소(무엇/왜/어떻게/다음 행동)는 `alerts.raise_alert()` 가 **계약으로 강제**한다 — 하나라도 비면 `ValueError`. | `code` | 등급 | 표시 방식 | 알림 코드 | 다음 행동 버튼 | |---|---|---|---|---| | `OK` | — | 표시 없음 | — | — | | `GRACE` | — | 표시 없음 | — | — | | `DEGRADED` | WARN | 자동소멸 토스트 | `RUN_PARTIAL` | [리포트 열기] [로그 폴더 열기] | | `STALE` | CRITICAL | **강제 모달** (`gui.launch(mode="recover", focus_key="last_run")`) | `WATCHDOG_STALE` | [지금 실행] [작업 상태 확인] [로그 폴더 열기] | | `NEVER_RAN` | CRITICAL | **강제 모달** (`mode="setup"`) | `WATCHDOG_NEVER_RAN` | [설치 마법사 열기] | 중복 억제: `dedup_key = code + run_date`, 쿨다운 `notify.cooldown_minutes`(기본 240분). 즉 STALE 상태가 지속돼도 하루에 최대 몇 번만 창이 뜬다. ### 4.6 운영자용 즉석 점검 스크립트 — `scripts\check_heartbeat.ps1` Python 환경이 깨졌을 때도 상태를 볼 수 있어야 한다. **읽기 전용**이며 아무것도 등록하지 않는다. ```powershell #Requires -Version 5.1 <# .SYNOPSIS state\heartbeat.json 을 읽어 06:00 배치의 최근 상태를 판정한다. 읽기 전용. .OUTPUTS 종료 코드 0 = 정상, 1 = 열화(PARTIAL), 2 = 경보(STALE / 파일 없음) #> [CmdletBinding()] param( [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), [string]$DailyTime = '06:00', [int]$StaleMinutes = 120 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $hbPath = Join-Path $ProjectRoot 'state\heartbeat.json' $now = Get-Date if (-not (Test-Path -LiteralPath $hbPath)) { Write-Host "[heartbeat] 파일이 없습니다: $hbPath" -ForegroundColor Red Write-Host ' → 성공 실행이 한 번도 없습니다. 다음을 실행하세요:' -ForegroundColor Red Write-Host ' .\.venv\Scripts\python.exe -m dmf_crawler doctor' exit 2 } $hb = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8 | ConvertFrom-Json $upd = [datetime]::Parse($hb.updated_at) $parts = $DailyTime.Split(':') $todayRun = $now.Date.AddHours([int]$parts[0]).AddMinutes([int]$parts[1]) $expected = if ($now -ge $todayRun) { $todayRun } else { $todayRun.AddDays(-1) } $deadline = $expected.AddMinutes($StaleMinutes) $ageMin = [int]($now - $upd).TotalMinutes Write-Host '' Write-Host ' DMF Crawler heartbeat' -ForegroundColor Cyan Write-Host ' ---------------------------------------------------------------' Write-Host (" 마지막 성공 : {0:yyyy-MM-dd HH:mm:ss} ({1}분 전)" -f $upd, $ageMin) Write-Host (" run_id : {0}" -f $hb.run_id) Write-Host (" 상태 : {0} (exit={1})" -f $hb.status, $hb.exit_code) Write-Host (" 수집 건수 : {0:N0}" -f $hb.records_total) Write-Host (" 변경 : 신규 {0} / 변경 {1} / 취하 {2}" -f $hb.events.new, $hb.events.changed, $hb.events.withdrawn) Write-Host (" 소요 : {0}초" -f $hb.duration_seconds) Write-Host (" AI : {0} (토큰 {1:N0})" -f $hb.agy.status, $hb.agy.tokens) Write-Host (" 리포트 : {0}" -f $hb.report_path) Write-Host (" 로그 : {0}" -f $hb.log_dir) Write-Host (" 연속 실패 : {0}" -f $hb.consecutive_failures) Write-Host (" 기준 예정시각 : {0:yyyy-MM-dd HH:mm} (유예 {1}분 → {2:HH:mm})" -f $expected, $StaleMinutes, $deadline) Write-Host ' ---------------------------------------------------------------' if ($upd -ge $expected) { if ($hb.status -eq 'PARTIAL') { Write-Host ' 판정: DEGRADED — 리포트는 나왔지만 일부 단계가 실패했습니다.' -ForegroundColor Yellow Write-Host ' → 로그 폴더의 pipeline.log 에서 WARN 을 확인하세요.' exit 1 } Write-Host ' 판정: OK — 최근 예정 실행이 성공했습니다.' -ForegroundColor Green exit 0 } if ($now -lt $deadline) { Write-Host ' 판정: GRACE — 아직 유예 시간 안입니다(실행 중이거나 재시도 중).' -ForegroundColor Yellow exit 0 } Write-Host ' 판정: STALE — 예정 실행의 성공 기록이 없습니다!' -ForegroundColor Red Write-Host '' Write-Host ' 복구 순서:' -ForegroundColor Red Write-Host ' 1) Get-ScheduledTask -TaskPath "\DMF_Crawler\" | Get-ScheduledTaskInfo' Write-Host ' 2) .\.venv\Scripts\python.exe -m dmf_crawler doctor' Write-Host ' 3) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual' exit 2 ``` --- ## 5. 재부팅 · 전원 · 시각 시나리오 전체 ### 5.1 시나리오 표 — "이 상황에서 06:00 배치는 어떻게 되는가" | # | 상황 | 06:00 에 실행되는가 | 무엇이 구해주는가 | 사람이 할 일 | |---|---|---|---|---| | 1 | PC 켜져 있고 로그온 | ✅ 06:00±4분 | Daily 트리거 | 없음 | | 2 | PC 켜져 있고 **로그오프** | ✅ | S4U/Password 로그온 타입 | 없음. 알림만 다음 로그온까지 지연 | | 3 | PC 켜져 있고 **화면 잠금** | ✅ | 잠금은 세션 종료가 아니다 | 없음 | | 4 | **절전(S3/모던 대기)** | ✅ 깨워서 실행 | `WakeToRun` + 전원 관리의 절전 해제 타이머 허용 | §5.4 의 powercfg 1회 설정 | | 5 | **최대 절전(S4)** | △ 하드웨어 의존 | `WakeToRun` 은 S4 에서도 시도하지만 보장 없음 | §5.4 로 최대 절전 자체를 끄는 것이 확실 | | 6 | **완전히 꺼짐** | ❌ 06:00 에는 못 돎 | 켜지면 `StartWhenAvailable` 이 즉시 캐치업 + 부팅 트리거(+5분) | 없음 | | 7 | 06:00 직전 재부팅 중 | ❌ 그 순간엔 못 돎 | `StartWhenAvailable` 캐치업 | 없음 | | 8 | Windows Update 재시작이 06:00 에 걸림 | ❌ | 활성 시간 05:00–23:00 으로 **예방** + `StartWhenAvailable` | §5.6 1회 설정 | | 9 | BitLocker **PIN** 입력 대기 | ❌ 부팅이 멈춰 있음 | 없음 — OS 가 아직 안 떴다 | §5.9 판단 | | 10 | 네트워크가 아직 안 올라옴(부팅 직후) | △ | `BootTrigger Delay PT5M` + `RunOnlyIfNetworkAvailable` + 코드의 재시도 4회 | 없음 | | 11 | 06:00 실행 중 배터리로 전환 | ✅ 계속 실행 | `StopIfGoingOnBatteries=false` | 없음 | | 12 | 배터리로만 구동 중 | ✅ | `DisallowStartIfOnBatteries=false` | 없음 | | 13 | 실행이 30분 초과 | ❌ 강제 종료 | `RestartCount 3` × `PT10M` 재시도 + 스테이지 체크포인트 재개 | 반복되면 `source.page_size` 조정 | | 14 | 06:00 실행 성공 후 07:00 재부팅 | 재실행 안 함 | **idempotency 가드**(오늘 SUCCESS → 종료 0) | 없음 | | 15 | 여러 날 꺼져 있다가 켜짐 | 당일분 1회만 | `StartWhenAvailable` 은 **놓친 실행을 한 번만** 몰아 실행한다 | 과거분은 `backfill` 로 | ### 5.2 부팅 후 네트워크 대기 부팅 트리거가 뜨는 시점에 네트워크 스택이 준비돼 있다는 보장이 없다. 3중으로 막는다. 1. **`PT5M`** — 부팅 후 5분 대기. `New-ScheduledTaskTrigger -AtStartup` 에는 `-Delay` 파라미터가 **없어서** CIM 인스턴스의 `Delay` 속성을 직접 채운다(§2.3 코드 참조). 2. **`RunOnlyIfNetworkAvailable=true`** — 네트워크가 없으면 아예 시작하지 않는다. 이때 이벤트 ID **112 (`JobNoStartWithoutNetwork`)** 가 기록된다. 3. **코드의 재시도** — `source.max_attempts=4`, `backoff_base_seconds=5.0`, `Retry-After` 절대 우선. DNS 가 잠깐 안 되는 정도는 여기서 흡수된다. `config.toml` 의 `schedule.startup_delay_minutes` 를 늘리면 1번이 함께 늘어난다. 무선 랜만 쓰는 PC 에서 5분이 부족하면 10분으로 올린다. ### 5.3 놓친 작업 실행(캐치업)의 정확한 의미 `StartWhenAvailable=true` 의 공식 정의는 "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed." 실무적으로 알아야 할 것: - **여러 번 놓쳐도 몰아서 여러 번 돌지 않는다.** 3일 꺼져 있다가 켜면 1회 실행된다. 과거 3일치 데이터가 필요하면 `backfill` 을 쓴다. - 캐치업 실행은 즉시가 아니라 **최대 10분 이내**에 트리거된다(스케줄러 내부 동작). 이벤트 ID **114 (`MissedTaskLaunched`)** 로 확인할 수 있다. - 캐치업과 부팅 트리거가 겹쳐 **두 번 뜰 수 있다.** `MultipleInstances=IgnoreNew`(동시) + idempotency 가드(순차)가 둘 다 흡수한다. 이벤트 ID **322 (`NewInstanceIgnored`)** 가 보이면 정상 동작이다. ### 5.4 절전 · 최대 절전 (powercfg) **관리자 권한 명령 프롬프트/PowerShell 에서 1회 실행.** ```powershell # ── (0) 현재 상태 확인 ───────────────────────────────────────────── powercfg /a # 이 PC 가 지원하는 절전 상태 (S0 모던 대기 여부 확인) powercfg /waketimers # 현재 등록된 절전 해제 타이머 powercfg /devicequery wake_armed # 깨울 수 있는 장치 powercfg /lastwake # 마지막에 무엇이 깨웠는가 # ── (1) 절전 해제 타이머 '사용'으로 (WakeToRun 이 실제로 동작하려면 필수) ── # SUB_SLEEP = 238c9fa8-0aad-41ed-83f4-97be242c8f20 # 절전 해제 타이머 허용 = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 powercfg /setactive SCHEME_CURRENT # ── (2) 데스크톱 PC 권장: AC 전원에서는 아예 안 잔다 ─────────────── powercfg /change standby-timeout-ac 0 # 0 = 사용 안 함 powercfg /change hibernate-timeout-ac 0 powercfg /change monitor-timeout-ac 15 # 화면만 끈다 (전기·수명) powercfg /change disk-timeout-ac 0 # ── (3) 노트북: 배터리에서도 06:00 을 지키고 싶다면 ──────────────── powercfg /change standby-timeout-dc 0 powercfg /change hibernate-timeout-dc 0 # → 배터리 소모가 커진다. 그래도 실행이 우선이면 이렇게 한다. # ── (4) 적용 확인 ───────────────────────────────────────────────── powercfg /query SCHEME_CURRENT SUB_SLEEP powercfg /waketimers ``` **`powercfg /waketimers` 결과 읽는 법**: 작업을 등록하고 `WakeToRun=true` 라면 다음 06:00 을 가리키는 항목이 하나 보여야 한다. 아무것도 안 보이면 (1) 단계를 안 했거나, 이 PC 가 **모던 대기(S0)** 라서 표기가 다를 수 있다. `powercfg /a` 의 출력에 `대기 (S0 짧은 지연 사용 가능)` 이 있으면 모던 대기 기기다. > **모던 대기(S0) 주의**: S0 기기에서는 "절전" 이 사실상 저전력 유지 상태라 예약 작업이 대체로 잘 돈다. 다만 네트워크가 오프로드 상태일 수 있어 첫 요청이 실패할 수 있다 — 코드의 재시도 4회가 흡수한다. ### 5.5 Fast Startup(빠른 시작)과 `AtStartup` 트리거 **핵심 사실**: 종료(Shutdown)를 눌러도 Windows 는 실제로 완전히 끄지 않는다. 커널 세션을 `hiberfil.sys` 에 저장하는 **하이브리드 종료**를 하고, 다음 켜기는 **최대 절전 복귀**다. 그래서 **"시스템 시작 시" 트리거가 뜨지 않는다.** - **재시작(Restart)에는 Fast Startup 이 적용되지 않는다.** 재시작은 진짜 부팅이므로 `AtStartup` 이 뜬다. - Fast Startup 은 **Windows 기본값으로 켜져 있고, Microsoft 는 끄는 것을 권장하지 않는다.** **우리의 방침**: 1. **Fast Startup 을 끄지 않는다.** 부팅 트리거를 안전망으로 삼지 않기 때문이다. 진짜 안전망은 `StartWhenAvailable` 이고, 그것은 Fast Startup 과 무관하게 동작한다. 2. 부팅 트리거는 **"재시작 후 빠른 복귀"** 용 보조 장치로만 취급한다. 3. 그래도 끄고 싶다면(예: 이 PC 가 무인 서버 용도): ```powershell # 최대 절전 파일을 없애면 Fast Startup 도 함께 꺼진다 powercfg /h off # 확인 — HiberbootEnabled 가 0 이면 Fast Startup 꺼짐 Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' | Select-Object HiberbootEnabled # 되돌리기 powercfg /h on ``` > `powercfg /h off` 는 **최대 절전 기능 자체를 없앤다.** 노트북에서 최대 절전을 쓰고 있다면 부작용을 감수해야 한다. 그 경우 `HiberbootEnabled` 만 0 으로 두는 방법도 있으나, 최대 절전과 Fast Startup 의 상호작용이 기기마다 달라 ⚠️ 실측을 권한다. ### 5.6 Windows Update 재시작과 06:00 충돌 회피 Windows Update 는 설치 후 **활성 시간(Active hours) 밖에서** 자동 재시작한다. 기본 활성 시간은 08:00–17:00 이므로 **06:00 이 재시작 창에 정통으로 들어간다.** - 활성 시간 **최대 범위는 18시간**(Windows 10 1607/Server 2016 은 12시간). - 05:00 시작 → 23:00 종료 = 18시간. **06:00 을 보호하면서 최대 범위에 딱 맞는다.** ```powershell # ── 관리자 PowerShell ────────────────────────────────────────────── # (A) 정책 경로 (권장 · 사용자가 UI 에서 못 바꾸게 고정) $policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' New-Item -Path $policy -Force | Out-Null Set-ItemProperty -Path $policy -Name 'SetActiveHours' -Value 1 -Type DWord Set-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5 -Type DWord Set-ItemProperty -Path $policy -Name 'ActiveHoursEnd' -Value 23 -Type DWord # (B) 사용자 설정 경로 (UI 의 '활성 시간'과 같은 값) $ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' New-Item -Path $ux -Force | Out-Null Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord # ── 확인 ────────────────────────────────────────────────────────── Get-ItemProperty -Path $policy | Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd Get-ItemProperty -Path $ux | Select-Object ActiveHoursStart, ActiveHoursEnd ``` GUI 경로: **설정 → Windows Update → 고급 옵션 → 활성 시간**. > **완벽하지는 않다.** 마감 기한(deadline)을 넘긴 강제 재시작은 활성 시간을 무시할 수 있다. 그래서 `StartWhenAvailable` 이 여전히 필요하다. 재시작으로 06:00 을 놓치면 부팅 후 5분(부팅 트리거) 또는 캐치업으로 자동 회복된다. ### 5.7 시간대 · DST ```powershell tzutil /g # 반드시 "Korea Standard Time" 이어야 한다 w32tm /query /status w32tm /resync # 시각이 틀어졌으면 ``` - **한국은 1988년 이후 서머타임을 시행하지 않는다.** KST = UTC+9 고정. DST 전환 시 작업이 1시간 일찍/늦게 도는 문제는 이 PC 에서 발생하지 않는다. - 트리거 속성의 **"표준 시간대에 맞춰 동기화(Synchronize across time zones)" 를 켜지 마라.** 켜면 `StartBoundary` 가 UTC 로 해석되어(`...Z` 접미사) 로컬 06:00 의 의미가 흔들린다. 우리 XML 의 `2026-09-02T06:00:00` 에는 **의도적으로 `Z` 나 오프셋이 없다** — 로컬 시각이라는 뜻이다. - 코드 쪽 일자 판정은 `general.timezone = "Asia/Seoul"` 로 못박혀 있어 OS 시간대가 바뀌어도 `run_date` 는 흔들리지 않는다. - 노트북을 해외에 들고 나가 OS 시간대를 바꾸면 배치는 **그 지역의 06:00** 에 돈다. `run_date` 는 KST 기준이므로 하루에 두 번 돌거나 건너뛸 수 있다 — idempotency 가드가 중복은 막지만, 장기 해외 체류라면 시간대를 바꾸지 않는 편이 낫다. ### 5.8 자동 로그온이 필요한가 — 결론: **배치는 불필요, 알림은 조건부** | 대상 | 자동 로그온 필요? | 이유 | |---|---|---| | `DMF_Crawler_Daily`(배치) | **불필요** | S4U/Password 로그온 타입은 사용자가 로그오프 상태여도 실행된다 | | `DMF_Crawler_AgyUpdate` | **불필요** | 동일 | | `DMF_Crawler_Agent`(알림·워치독) | **필요할 수도** | Interactive 는 로그온한 세션에서만 돈다. 로그오프 상태면 알림이 **지연**된다 | **로그오프 상태에서 실패했을 때 무슨 일이 일어나는가** (아키텍처 실패 경로 [F]): 1. 배치가 `alerts` 테이블 + `state\alerts.json` + Windows 이벤트 로그에 기록한다. 2. 화면에는 아무것도 안 뜬다. 3. 다음 로그온 순간 `DMF_Crawler_Agent` 의 로그온 트리거가 즉시 발화해 **밀린 알림을 전부 표시**한다. 즉 **알림이 사라지는 게 아니라 늦어질 뿐이다.** 대부분의 개인 PC 운용에서는 이걸로 충분하다. **그래도 즉시 알림이 필요하다면** — 세 가지 대안, 위험도 순: | 대안 | 방법 | 위험 | |---|---|---| | **A. 로그온한 채 화면만 잠금** (권장) | `Win+L`. 세션은 살아 있으므로 Agent 가 계속 돈다 | 없음. 물리 보안은 잠금 화면이 유지 | | **B. 자동 로그온 + 즉시 잠금** | `netplwiz` 또는 Sysinternals `Autologon.exe` 로 자동 로그온 설정 후, 시작 프로그램에 `rundll32.exe user32.dll,LockWorkStation` 등록 | 자동 로그온은 자격증명을 레지스트리에 남긴다(`DefaultPassword`). **Autologon.exe 는 LSA 비밀에 저장해 그나마 낫다.** BitLocker PIN 과 병용 시 무의미(§5.9) | | **C. 웹훅 알림 추가** | 배치가 실패 시 디스코드/슬랙 웹훅 호출 | 의존성·비밀 관리 증가. **현재 범위 밖(부록 참조)** | **기본 권고는 A 다.** B 는 이 PC 에 다른 민감 데이터가 없고 물리적으로 안전한 위치일 때만. ### 5.9 BitLocker · PIN 의 영향 | 구성 | 무인 재부팅 후 06:00 배치 | 판정 | |---|---|---| | BitLocker 미사용 | ✅ 정상 | 문제 없음 | | **TPM 전용** (PIN 없음) | ✅ 정상 — TPM 이 자동으로 볼륨을 해제하고 OS 가 부팅된다 | **이 프로젝트에 권장** | | **TPM + PIN**(사전 부팅 PIN) | ❌ **부팅이 PIN 입력 화면에서 멈춘다.** 사람이 PIN 을 넣기 전까지 OS 자체가 뜨지 않으므로 작업 스케줄러도 없다 | 자동화와 정면 충돌 | | TPM + 시작 키(USB) | ❌ 동일 | 동일 | > Microsoft 문서: "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." **PIN 을 유지해야 한다면**: 재부팅 후 사람이 PIN 을 넣는 순간 OS 가 뜨고, 그때 `StartWhenAvailable` 이 놓친 06:00 을 즉시 실행한다. 즉 **자동 복구는 되지만 시각은 밀린다.** 이 지연을 허용할 수 없다면 TPM 전용으로 바꾸거나(보안 팀 승인 필요), 06:00 을 사람이 PC 앞에 있는 시각으로 옮겨야 한다. 현재 PC 의 상태 확인: ```powershell manage-bde -status C: manage-bde -protectors -get C: # TpmPin 이 보이면 PIN 구성이다 ``` ### 5.10 재부팅 내성 실증 절차 (설치 후 1회 반드시 수행) ```powershell # [테스트 1] 캐치업이 실제로 도는가 — 가장 중요한 테스트 # 1) 작업의 Daily 트리거 시각을 '지금부터 5분 뒤'로 임시 변경 # 2) PC 를 종료하고 10분 대기 # 3) PC 를 켜고 로그온하지 않은 채 5분 대기 # 4) 로그온해서 확인: Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 | Where-Object { $_.Message -like '*DMF_Crawler_Daily*' } | Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List # → 이벤트 114(MissedTaskLaunched) 또는 118(BootTrigger) 이 보이면 성공 # 5) 트리거 시각을 06:00 으로 되돌린다 (install_tasks.ps1 재실행) # [테스트 2] 재시작(Restart)에서 부팅 트리거가 뜨는가 Restart-Computer # Fast Startup 이 적용되지 않는 경로 # → 부팅 후 5분 뒤 이벤트 118 확인 # [테스트 3] 절전에서 깨어나 실행하는가 # 1) 트리거를 '지금부터 10분 뒤'로 변경 # 2) 즉시 절전 진입 rundll32.exe powrprof.dll,SetSuspendState 0,1,0 # 3) 15분 뒤 확인 powercfg /lastwake # → "절전 해제 원본: 타이머 - ... DMF_Crawler_Daily" 가 보이면 성공 # [테스트 4] 로그오프 상태에서 도는가 (S4U/Password 검증) # 1) 로그오프 # 2) 트리거 시각 경과 후 다시 로그온 # 3) state\heartbeat.json 의 updated_at 이 갱신됐는지 확인 .\scripts\check_heartbeat.ps1 ``` --- ## 6. 중복 실행 방지 ### 6.1 3중 방어와 각 층이 막는 것 | 층 | 구현 | 막는 것 | 못 막는 것 | |---|---|---|---| | ① 스케줄러 | `MultipleInstances=IgnoreNew` | **동시** 실행(06:00 트리거와 부팅 트리거가 겹칠 때) | 순차 재실행 | | ② 파일 락 | `state\run.lock` + `msvcrt.locking` | 스케줄러 밖에서 시작된 동시 실행(수동 + 자동) | 순차 재실행 | | ③ 코드 가드 | `repo.last_success_run_on(오늘)` 조회 | **순차** 재실행(06:00 성공 후 07:00 재부팅 캐치업) | 의도적 재실행(`--force` 로 우회) | **③ 이 없으면 append-only 스키마가 오염된다.** 같은 날 스냅샷이 두 벌 들어가면 다음 날 diff 의 기준선이 어느 쪽인지 모호해진다. 스케줄러 설정만 믿는 설계는 여기서 무너진다. ### 6.2 `src\dmf_crawler\runlock.py` 전문 ```python """state\\run.lock 배타 락. Windows 전용(msvcrt). 파일 끝 멀찍한 오프셋의 1바이트를 잠그고, 사람이 읽을 메타데이터는 파일 앞쪽(잠그지 않은 영역)에 고정 길이로 쓴다. 잠근 영역을 truncate 하면 Windows 에서 오류가 나기 때문이다. """ from __future__ import annotations import contextlib import datetime as _dt import msvcrt import os import socket from collections.abc import Iterator from pathlib import Path from .errors import DmfError _LOCK_OFFSET = 1_000_000 # 이 위치의 1바이트를 잠근다 _META_SIZE = 256 # 파일 앞 256바이트에 메타데이터를 고정 길이로 기록 class LockBusy(DmfError): """다른 인스턴스가 이미 락을 쥐고 있다.""" def read_holder(lock_path: Path) -> str: """락 파일 앞부분의 메타데이터를 그대로 읽는다(진단용). 실패하면 빈 문자열.""" try: with open(lock_path, "rb") as fh: return fh.read(_META_SIZE).decode("utf-8", "replace").rstrip("\x00 \n") except OSError: return "" @contextlib.contextmanager def exclusive(lock_path: Path, *, run_id: str = "", trigger: str = "") -> Iterator[None]: """배타 락을 잡는다. 이미 잡혀 있으면 LockBusy 를 던진다(대기하지 않는다). 사용: try: with runlock.exclusive(paths.STATE / "run.lock", run_id=rid): ... except runlock.LockBusy: log.info("다른 인스턴스가 실행 중 — 종료 코드 0") return 0 """ lock_path.parent.mkdir(parents=True, exist_ok=True) # a+b: 없으면 만들고, 있으면 내용을 지우지 않는다. fh = open(lock_path, "a+b") try: # 파일이 오프셋보다 짧으면 잠글 바이트가 없다. 미리 늘려 둔다. fh.seek(0, os.SEEK_END) if fh.tell() <= _LOCK_OFFSET: fh.write(b"\x00" * (_LOCK_OFFSET + 1 - fh.tell())) fh.flush() fh.seek(_LOCK_OFFSET) try: msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) except OSError as exc: holder = read_holder(lock_path) raise LockBusy( f"이미 실행 중입니다. lock={lock_path} holder=[{holder}]" ) from exc # 여기부터 락 보유 구간 meta = ( f"pid={os.getpid()} host={socket.gethostname()} " f"user={os.environ.get('USERNAME', '')} " f"run_id={run_id} trigger={trigger} " f"acquired={_dt.datetime.now().astimezone().isoformat(timespec='seconds')}" ).encode("utf-8")[: _META_SIZE - 1] fh.seek(0) fh.write(meta.ljust(_META_SIZE, b" ") + b"\n") fh.flush() os.fsync(fh.fileno()) try: yield finally: fh.seek(_LOCK_OFFSET) with contextlib.suppress(OSError): msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) finally: fh.close() ``` **왜 뮤텍스(`CreateMutex`)가 아니라 파일 락인가** | | 파일 락 (채택) | 네임드 뮤텍스 | |---|---|---| | 세션 경계 | 파일이므로 **세션·로그온 타입 무관** | `Global\` 접두사가 없으면 세션마다 별개. S4U 세션과 Interactive 세션이 서로를 못 본다 | | 잔해 | 프로세스가 죽으면 OS 가 핸들을 닫아 락이 자동 해제 | 동일하나, 소유권 포기(abandoned) 처리를 코드가 다뤄야 함 | | 진단성 | **누가 잡고 있는지 파일을 열어보면 안다** | 밖에서 볼 방법이 사실상 없다 | | 의존성 | stdlib `msvcrt` | `ctypes` 로 Win32 직접 호출 | 세션 경계 문제 하나만으로 결정된다. 배치는 S4U 세션, 수동 실행은 Interactive 세션에서 뜨는데 뮤텍스는 `Global\` 을 빼먹는 순간 조용히 무력화된다. ### 6.3 idempotency 가드의 정확한 규칙 ``` run --trigger scheduled|startup 로 진입 └─ repo.last_success_run_on(today, tz="Asia/Seoul") 조회 ├─ status ∈ {SUCCESS, PARTIAL} 인 run 이 있다 → "SKIPPED" 기록 후 종료 코드 0 └─ 없다 → 정상 진행 --force 가 붙으면 이 조회를 건너뛴다. report-only / backfill / doctor 는 가드 대상이 아니다. ``` - **`PARTIAL` 도 "오늘은 이미 돌았다"로 친다.** 리포트가 나왔기 때문이다. 다시 돌리고 싶으면 `--force`. - `FAILED` / `BLOCKED` 는 가드에 걸리지 않는다 → 재시작(`RestartCount`)이 정상적으로 재시도한다. - 스테이지 체크포인트(`stage_status`)와는 다른 층이다. 체크포인트는 **같은 `run_id` 안에서** 성공한 스테이지를 건너뛰고, 가드는 **다른 `run_id` 의 재실행 자체**를 막는다. ### 6.4 락이 걸려 있을 때 사람이 하는 일 ```powershell # 1) 누가 잡고 있는지 본다 Get-Content -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -TotalCount 1 # 2) 그 PID 가 정말 살아 있는지 확인 Get-Process -Id -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime, Path # 3) 살아 있으면 기다린다(정상). ExecutionTimeLimit 30분 안에 끝난다. # 4) 죽어 있는데 락이 남아 있다면 → 있을 수 없는 상황이다. # OS 가 핸들을 닫으면서 락도 풀리기 때문이다. # 그래도 의심되면 파일을 지운다(실행 중이 아님을 확인한 뒤에만): Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -Force # 5) 작업 자체를 강제 종료해야 한다면 Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' ``` --- ## 7. 로그 규약 ### 7.1 실행 ID - 형식: **`YYYYMMDD_HHMMSS`** (KST, 프로세스 시작 시각). 예: `20260902_060012` - 생성: `run` 진입 직후, 로그 디렉터리를 만들기 전에 한 번. - 전파: `runs.run_id`(PK) · `stage_status` · `snapshots` · `events` · `agy_calls` · `heartbeat.json` · 로그 디렉터리 이름 · 알림 레코드에 **동일한 값**이 들어간다. - 지터로 06:00:00 이 아니라 06:04:12 에 시작해도 `run_date` 는 `2026-09-02` 다. **`run_id` 와 `run_date` 를 혼동하지 마라.** ### 7.2 디렉터리와 파일 ``` D:\workspace\DMF_Crawler\logs\ └── run_20260902_060012\ ├── pipeline.log 사람이 읽는 전체 로그 (텍스트) ├── events.jsonl 기계가 읽는 구조화 이벤트 (1줄 1 JSON) ├── agy.stdout.json agy JSON 봉투 원문 (감사용, 손대지 않은 원본) ├── agy.stderr.log agy 진단 출력 (stdout 과 절대 섞지 않는다) └── agy_cli.log agy --log-file 로 지정한 agy 내부 로그 ``` **실행 1회 = 디렉터리 1개.** 조사의 시작점이 "`logs` 에서 가장 최근 디렉터리를 연다" 하나로 고정된다. 롤링 단일 파일은 여러 실행이 뒤섞여 "어느 줄이 어제 것인지" 를 매번 다시 따져야 한다. `agy` 의 stdout 과 stderr 을 분리하는 것은 **agy SSOT §5.3 규약**이다. 섞으면 JSON 파싱이 오염된다. ### 7.3 `pipeline.log` 포맷 ``` 2026-09-02 06:00:12.431 +0900 | INFO | run | run_id=20260902_060012 trigger=scheduled 시작 2026-09-02 06:00:12.502 +0900 | INFO | runlock | 락 획득 state\run.lock 2026-09-02 06:00:12.610 +0900 | INFO | preflight | 스키마 버전 3, integrity_check ok, 여유 공간 214.6GB 2026-09-02 06:00:13.004 +0900 | INFO | fetch | totalCount=12874 pages=129 page_size=100 2026-09-02 06:01:47.882 +0900 | WARNING | http | 429 Retry-After=30 page=61 attempt=1 대기 30.0s 2026-09-02 06:02:31.115 +0900 | INFO | fetch | 완료 12874건 129페이지 138.1s 아카이브=data\raw\2026-09-02 2026-09-02 06:02:33.900 +0900 | INFO | normalize | 12874건 정규화, dmf_key 충돌 2건 결정론적 접미사 부여 2026-09-02 06:02:34.220 +0900 | INFO | integrity | 게이트 5/5 통과 (drop=0.001 null=0.000 dup=0.0002) 2026-09-02 06:02:34.905 +0900 | INFO | diff | new=3 changed=1 withdrawn=0 base_run=20260901_060008 2026-09-02 06:02:36.470 +0900 | INFO | persist | 스냅샷 12874행 · 이벤트 4행 커밋 2026-09-02 06:03:41.008 +0900 | INFO | enrich | agy exit=0 status=OK in=28914 out=1204 41.9s 2026-09-02 06:04:29.771 +0900 | INFO | report | 8시트 생성 → reports\DMF_리포트_2026-09-02.xlsx (원자 교체) 2026-09-02 06:04:35.310 +0900 | INFO | backup | VACUUM INTO backup\dmf_2026-09-02.sqlite3 (48.2MB), 보존 30개 유지 2026-09-02 06:04:37.002 +0900 | INFO | finalize | status=SUCCESS exit=0 265.6s heartbeat 갱신 ``` | 컬럼 | 내용 | |---|---| | 1 | 로컬 시각 + **UTC 오프셋**(`+0900`). 오프셋을 빼먹으면 로그를 나중에 못 믿는다 | | 2 | 레벨 (`DEBUG`/`INFO`/`WARNING`/`ERROR`) — `logging.level` 로 하한 조절 | | 3 | 스테이지 또는 모듈 이름 | | 4 | 메시지 | **마스킹**: `logging.mask_patterns`(기본 `["serviceKey", "access_token"]`)에 걸리는 키의 값은 기록 직전에 `***` 로 치환한다. API 키가 URL 쿼리에 들어가므로 **요청 URL 로깅은 항상 마스킹을 통과해야 한다.** ### 7.4 `events.jsonl` 스키마 한 줄에 JSON 객체 하나. 기계가 읽는다. 필드는 다음을 **항상** 포함한다. | 필드 | 타입 | 설명 | |---|---|---| | `ts` | ISO8601+오프셋 | 이벤트 시각 | | `run_id` | str | 실행 ID | | `stage` | str | `run` \| `preflight` \| `fetch` \| … \| `finalize` | | `event` | str | 이벤트 이름(아래 표) | | `level` | str | `INFO` \| `WARN` \| `ERROR` | | `data` | object | 이벤트별 페이로드 | 주요 `event` 값과 `data` 필드: | `event` | `data` 주요 키 | |---|---| | `run_started` | `trigger`, `pid`, `version`, `python`, `run_date` | | `stage_started` / `stage_finished` | `stage`, `status`, `duration_ms` | | `fetch_page` | `page`, `http_status`, `bytes`, `elapsed_ms`, `attempt` | | `http_retry` | `page`, `reason`, `retry_after_s`, `sleep_s`, `attempt` | | `fetch_summary` | `total_count`, `pages`, `records`, `duration_ms`, `archive_dir` | | `integrity_gate` | `gate`, `passed`, `observed`, `threshold` | | `diff_summary` | `new`, `changed`, `withdrawn`, `base_run_id` | | `agy_call` | `exit_code`, `status`, `model`, `tokens_in`, `tokens_out`, `duration_ms`, `error_class` | | `report_written` | `path`, `sheets`, `bytes`, `fallback_name` | | `alert_raised` | `code`, `severity`, `dedup_key`, `suppressed` | | `run_finished` | `status`, `exit_code`, `duration_ms`, `records_total` | 예시 3줄: ```jsonl {"ts":"2026-09-02T06:00:12.431+09:00","run_id":"20260902_060012","stage":"run","event":"run_started","level":"INFO","data":{"trigger":"scheduled","pid":18244,"version":"0.1.0","python":"3.12.6","run_date":"2026-09-02"}} {"ts":"2026-09-02T06:01:47.882+09:00","run_id":"20260902_060012","stage":"fetch","event":"http_retry","level":"WARN","data":{"page":61,"reason":"429","retry_after_s":30,"sleep_s":30.0,"attempt":1}} {"ts":"2026-09-02T06:04:37.002+09:00","run_id":"20260902_060012","stage":"finalize","event":"run_finished","level":"INFO","data":{"status":"SUCCESS","exit_code":0,"duration_ms":265571,"records_total":12874}} ``` **필수 기록 필드 체크리스트** (요구 7항): - [x] 시작 — `run_started`(트리거·PID·버전) - [x] 종료 — `run_finished`(상태·종료 코드) - [x] 건수 — `fetch_summary.records`, `diff_summary.{new,changed,withdrawn}` - [x] 소요 — 각 `stage_finished.duration_ms` + `run_finished.duration_ms` - [x] 토큰 사용량 — `agy_call.{tokens_in,tokens_out}` - [x] 오류 — `level:"ERROR"` 이벤트 + `alert_raised` ### 7.5 로테이션·보존 | 대상 | 설정 키 | 기본 | 정리 시점 | |---|---|---|---| | 로그 디렉터리 `logs\run_*` | `logging.retain_days` | 90일 | `finalize` 스테이지 | | API 원문 아카이브 `data\raw\<날짜>` | `source.archive_retain_days` | 180일 | `finalize` 스테이지 | | 리포트 `reports\*.xlsx` | `report.retain_days` | 365일 | `finalize` 스테이지 | | DB 백업 `backup\*.sqlite3` | `backup.keep_count` | 30개 | `backup` 스테이지 | | Task Scheduler Operational 로그 | `wevtutil /maxsize` | 64MB | OS 가 순환 | **파일 단위 롤링(`RotatingFileHandler`)을 쓰지 않는 이유**: 실행 1회당 로그가 수십 KB 로 작고, 디렉터리 단위 보존이 훨씬 단순하다. 롤링은 "이 줄이 어느 실행 것인가" 를 매번 되묻게 만든다. 수동으로 지금 정리하려면: ```powershell $root = 'D:\workspace\DMF_Crawler' $cut = (Get-Date).AddDays(-90) Get-ChildItem -LiteralPath "$root\logs" -Directory -Filter 'run_*' | Where-Object { $_.LastWriteTime -lt $cut } | Remove-Item -Recurse -Force -WhatIf # 확인 후 -WhatIf 를 떼고 재실행 ``` ### 7.6 디스크 사용량 감각 | 항목 | 1일 | 90일 | 비고 | |---|---|---|---| | 로그 디렉터리 | ~60KB | ~5MB | agy 봉투 포함 | | API 원문 아카이브 | ~4MB | ~360MB(180일) | 페이지 129개 × ~30KB | | 리포트 xlsx | ~1.5MB | ~550MB(365일) | 시트 8종 | | DB 증분 | ~3MB | ~270MB | append-only 스냅샷 | | DB 백업 | ~50MB | ~1.5GB(30개) | `VACUUM INTO` 압축본 | `backup.min_free_gb`(기본 2.0) 미만이면 백업을 건너뛰고 WARN 을 남긴다. 여유가 10GB 미만으로 떨어지면 `source.archive_retain_days` 를 줄이는 것이 가장 효과가 크다. --- ## 8. 일상 운영 절차 ### 8.1 정상 동작 확인 (아침 30초) ```powershell cd D:\workspace\DMF_Crawler # (1) 가장 빠른 방법 — heartbeat 한 방 .\scripts\check_heartbeat.ps1 # (2) 오늘 리포트가 나왔는가 Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" | Format-List Name, Length, LastWriteTime # (3) 스케줄러가 보는 마지막 결과 (0x0 이면 정상) Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo | Select-Object TaskName, LastRunTime, @{ n='LastResult'; e={ '0x{0:X}' -f $_.LastTaskResult } }, NextRunTime | Format-Table -AutoSize # (4) 전체 진단 (느리지만 확실) .\.venv\Scripts\python.exe -m dmf_crawler doctor # (5) 오늘 로그를 눈으로 $last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1 Get-Content "$($last.FullName)\pipeline.log" -Tail 40 ``` ### 8.2 수동 재실행 ```powershell cd D:\workspace\DMF_Crawler # (A) 스케줄러를 통해 실행 — 실제 배치와 100% 같은 보안 컨텍스트로 돈다. # "스케줄에서만 실패하는" 문제를 재현하는 유일한 방법이다. Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' # 완료 대기 while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') { Start-Sleep -Seconds 5; Write-Host '.' -NoNewline } Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo # (B) 콘솔에서 직접 실행 — 로그를 눈으로 보며 디버깅할 때 .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual # (C) 오늘 이미 성공했는데 그래도 다시 돌리고 싶다 .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --force # (D) 네트워크를 건드리지 않고 흐름만 점검 .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --dry-run # (E) AI 단계를 건너뛰고 실행 (토큰 절약 / agy 장애 시) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --skip-agy # (F) 수집·비교는 그대로 두고 리포트 파일만 다시 만든다 # (엑셀로 열어둬서 저장이 실패했을 때가 대표적) .\.venv\Scripts\python.exe -m dmf_crawler report-only .\.venv\Scripts\python.exe -m dmf_crawler report-only --date 2026-09-01 ``` > **(A) 와 (B) 의 차이를 항상 의식하라.** (B) 는 당신의 Interactive 세션에서 돈다. DPAPI·프로필·PATH 가 전부 다르다. "손으로는 되는데 06:00 에는 안 된다" 의 원인은 거의 항상 여기다. ### 8.3 특정 날짜 백필 ```powershell # 원문 아카이브(data\raw\<날짜>)가 남아 있는 구간만 대상이다. # 파서를 고친 뒤 과거를 다시 해석할 때 쓴다. # (1) 아카이브가 어느 날짜까지 있는지 확인 Get-ChildItem .\data\raw -Directory | Select-Object -ExpandProperty Name # (2) 원문 재파싱 + diff 재계산 + 리포트 재생성 .\.venv\Scripts\python.exe -m dmf_crawler backfill ` --from 2026-08-25 --to 2026-08-31 --reparse --rediff --rereport # (3) 리포트만 다시 만들기(파서·diff 는 그대로) .\.venv\Scripts\python.exe -m dmf_crawler backfill ` --from 2026-08-25 --to 2026-08-31 --rereport # (4) 백필 전 반드시 백업 .\.venv\Scripts\python.exe -m dmf_crawler backup --now ``` **백필로 할 수 없는 것**: 아카이브가 없는 날짜는 복원할 수 없다. 공식 API 는 "특정 과거 시점의 스냅샷" 을 제공하지 않기 때문이다. 그래서 `data\raw\` 보존이 중요하다(`source.archive_retain_days=180`). ### 8.4 일시 중지 · 재개 ```powershell # 배치만 멈춘다 (알림 에이전트는 계속 돈다 → 곧 STALE 경보가 뜬다) Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' # 장기 휴지(휴가 등)라면 알림도 함께 멈춰 오탐을 막는다 Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' # 상태 확인 — State 가 Disabled 로 보인다 Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-Table TaskName, State -AutoSize # 재개 Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' # 재개 직후 확인: NextRunTime 이 다음 06:00 을 가리키는가 Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo | Select-Object NextRunTime ``` > **중지 중에 지나간 06:00 은 재개해도 캐치업하지 않는다.** `StartWhenAvailable` 은 "작업이 활성인데 못 돈 경우" 를 다룬다. 비활성 기간의 데이터가 필요하면 `backfill` 을 쓰거나, 그날 데이터는 없는 것으로 확정된다(공식 API 에 과거 스냅샷이 없으므로). ### 8.5 제거 ```powershell # (1) 작업만 제거 — 데이터는 남는다 powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 # (2) 작업 + 폴더까지 제거 powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 -RemoveFolder # (3) 바로가기 제거 Remove-Item "$env:USERPROFILE\Desktop\DMF 설정.lnk" -ErrorAction SilentlyContinue Remove-Item "$env:USERPROFILE\Desktop\지금 실행.lnk" -ErrorAction SilentlyContinue # (4) 저장된 API 키(DPAPI) 제거 .\.venv\Scripts\python.exe -m dmf_crawler doctor --json # 무엇이 남아있는지 먼저 확인 # GUI: "DMF 설정" → API 키 → 삭제 # (5) 완전 삭제 (되돌릴 수 없다. 백업을 먼저 다른 곳으로 옮겨라) Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler' -Recurse -Force # (6) 이 프로젝트가 바꾼 시스템 설정 되돌리기 (선택) powercfg /change standby-timeout-ac 30 # 활성 시간 정책 제거 Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' ` -Name 'SetActiveHours','ActiveHoursStart','ActiveHoursEnd' -ErrorAction SilentlyContinue ``` > `agy` 자체는 이 프로젝트 소유가 아니다. 다른 용도로도 쓴다면 지우지 마라. 지우려면 `%LOCALAPPDATA%\agy` 와 `~\.gemini\antigravity-cli` 를 삭제한다. **토큰 파일이므로 삭제 = 로그아웃이다.** --- ## 9. 점검 체크리스트 ### 9.1 설치 직후 (1회, 전부 통과해야 운영 개시) - [ ] `python --version` 이 3.12 이상 - [ ] `.venv\Scripts\python.exe -m dmf_crawler version` 이 패키지·의존성 3종·agy 버전을 출력 - [ ] `.venv\Scripts\python.exe -m dmf_crawler doctor` 가 **12종 전부 통과**(종료 코드 0) - [ ] `Get-ScheduledTask -TaskPath '\DMF_Crawler\'` 가 **3개**를 `Ready` 로 보여줌 - [ ] `DMF_Crawler_Daily` 의 `Principal.UserId` 가 **SYSTEM 이 아님**, `RunLevel=Highest` - [ ] §2.7 자동 검증 스니펫이 **전 항목 통과** - [ ] §2.8 프로브(`-Verify`)에서 **api_key 체크 통과** — 실패했다면 `-LogonType Password` 로 재등록했고 다시 통과 - [ ] `powercfg /waketimers` 에 다음 06:00 항목이 보임 - [ ] 활성 시간이 **05–23** 으로 설정됨(§5.6 확인 명령) - [ ] `tzutil /g` 가 `Korea Standard Time` - [ ] `wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"` 이 `enabled: true` - [ ] `Start-ScheduledTask` 로 **스케줄러 경유 1회 실행 성공** → `reports\` 에 오늘 xlsx 생성 - [ ] `state\heartbeat.json` 이 생성되고 `status=SUCCESS` - [ ] `.\scripts\check_heartbeat.ps1` 이 종료 코드 0 - [ ] `backup\` 에 `dmf_<날짜>.sqlite3` 생성 (`backup.dir` 이 **다른 드라이브**를 가리키면 더 좋다) - [ ] `DMF 설정.lnk` / `지금 실행.lnk` 더블클릭 시 **콘솔 창 없이** GUI 가 뜸 - [ ] §5.10 테스트 1(캐치업)을 실제로 수행하고 이벤트 114 또는 118 확인 - [ ] BitLocker 구성 확인 — TPM+PIN 이면 §5.9 의 지연을 이해관계자가 수용 ### 9.2 매주 (5분, 월요일 권장) - [ ] `.\scripts\check_heartbeat.ps1` — 판정 OK, `consecutive_failures = 0` - [ ] 최근 7일 리포트가 7개 있는가: `Get-ChildItem .\reports -Filter '*.xlsx' | Sort-Object LastWriteTime -Descending | Select-Object -First 8` - [ ] `duration_seconds` 추이 — 지난주 대비 2배 이상 늘었으면 원인 확인 - [ ] `Get-ScheduledTaskInfo` 의 `LastTaskResult` 가 7일 내내 `0x0` - [ ] `agy` 상태: 최근 7일 `heartbeat.agy.status` 가 `AUTH` 로 바뀐 적 없는가 (있으면 재로그인 필요) - [ ] 일일 토큰 사용량이 `agy.daily_token_cap`(300000) 대비 여유 있는가 - [ ] 디스크 여유: `Get-PSDrive D | Select-Object Used, Free` - [ ] 백업 개수: `(Get-ChildItem .\backup -Filter '*.sqlite3').Count` 가 30 이하 - [ ] `DMF_Crawler_AgyUpdate` 가 지난 일요일에 돌았고 `agy --version` 이 갱신됐는가 - [ ] 로그에 신규 WARN 패턴이 있는가: ```powershell Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 7 | ForEach-Object { Select-String -Path "$($_.FullName)\pipeline.log" -Pattern 'WARNING|ERROR' } | Group-Object { ($_.Line -split '\|')[3].Trim() } | Sort-Object Count -Descending | Format-Table Count, Name -AutoSize ``` ### 9.3 장애 시 (STALE 경보가 떴을 때 순서대로) - [ ] **1단계 — 무엇이 죽었나** ```powershell .\scripts\check_heartbeat.ps1 Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo | Select-Object TaskName, LastRunTime, @{n='R';e={'0x{0:X}' -f $_.LastTaskResult}}, NextRunTime ``` - [ ] **2단계 — 작업이 시작조차 못 했나, 시작했다가 실패했나** ```powershell Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 | Where-Object { $_.Message -like '*DMF_Crawler*' } | Select-Object TimeCreated, Id, LevelDisplayName, @{n='Msg';e={ ($_.Message -split "`n")[0] }} | Format-Table -AutoSize ``` → 이벤트 ID 로 §11.2 표를 참조한다. `101`/`104`/`332` 면 **시작 실패**(보안·전원 문제), `102`/`201` 이 있으면 **시작은 했다**(코드 문제). - [ ] **3단계 — 시작했다면 로그를 본다** ```powershell $last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1 Get-Content "$($last.FullName)\pipeline.log" -Tail 60 Get-Content "$($last.FullName)\events.jsonl" | Select-Object -Last 20 ``` - [ ] **4단계 — 전면 진단** ```powershell .\.venv\Scripts\python.exe -m dmf_crawler doctor ``` - [ ] **5단계 — 스케줄러 경유로 재현** ```powershell Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' ``` - [ ] **6단계 — 그래도 안 되면 컨텍스트 차이를 의심**하고 §2.8 프로브를 다시 돌린다 ```powershell .\scripts\install_tasks.ps1 -Verify ``` - [ ] **7단계 — 작업 정의가 손상됐다면 재등록** ```powershell .\scripts\install_tasks.ps1 # 또는 GUI 의 "작업 다시 등록" 버튼 / .\.venv\Scripts\python.exe -m dmf_crawler doctor --fix-tasks ``` - [ ] **8단계 — DB 가 의심되면** ```powershell .\.venv\Scripts\python.exe -m dmf_crawler db check .\.venv\Scripts\python.exe -m dmf_crawler db version # 손상 확인 시: backup\ 의 최신본을 data\dmf.sqlite3 로 복사 후 report-only ``` **자동 재등록을 하지 않는 이유**: 사용자가 의도적으로 작업을 껐을 수 있다. 진단은 자동, **복구는 사람의 클릭 한 번**이 원칙이다(아키텍처 실패 시나리오 #29). --- ## 10. 완전한 설치 절차 (새 PC, 0부터) 소요 20~30분. **관리자 권한 PowerShell** 을 기본으로 한다. ### 단계 0 — 전제 확인 ```powershell # Windows 버전 (Windows 10 1809 이상 / Windows 11 권장) [System.Environment]::OSVersion.Version winver # 아키텍처 (agy 는 windows_amd64) $env:PROCESSOR_ARCHITECTURE # AMD64 여야 한다 # 관리자 권한인가 ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrator') # 이 계정이 배치를 돌릴 계정인가 (agy 토큰이 이 프로필에 저장된다) whoami ``` **준비물**: ① 공공데이터포털 계정과 **DMF 서비스 활용 신청 승인**(활용 신청 직후 키가 즉시 유효하지 않을 수 있다) ② Google 계정(agy 로그인용) ③ 디스크 여유 10GB 이상. ### 단계 1 — Python 설치 ```powershell # winget 이 있으면 (권장) winget install --id Python.Python.3.12 --scope machine --silent ` --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0" # 새 셸을 열고 확인 python --version # Python 3.12.x py -3.12 --version ``` 수동 설치라면 python.org 에서 **3.12 64-bit** 를 받고 설치 시 **"Add python.exe to PATH"** 를 반드시 체크한다. > 3.11 이상이 필요하다(`tomllib`). 3.12 를 권장한다. ### 단계 2 — 프로젝트 배치 ```powershell # Git 으로 받는 경우 git clone <저장소 URL> D:\workspace\DMF_Crawler # 압축본을 푸는 경우 Expand-Archive .\DMF_Crawler.zip -DestinationPath D:\ cd D:\workspace\DMF_Crawler Get-ChildItem # bootstrap.cmd, pyproject.toml, src\, scripts\, config\ 가 보여야 한다 ``` **경로 규칙**: 공백·한글이 없는 짧은 경로를 쓴다. OneDrive 동기화 폴더(`%USERPROFILE%\OneDrive\...`) 아래에 두지 마라 — SQLite WAL 과 충돌하고, 리포트 파일이 동기화 중 잠긴다. ### 단계 3 — 부트스트랩 (venv + 의존성 + 바로가기) ```cmd :: 프로젝트 루트에서 더블클릭하거나 D:\workspace\DMF_Crawler\bootstrap.cmd ``` `bootstrap.cmd` 가 하는 일: 1. `python -m venv .venv` 2. `.venv\Scripts\python.exe -m pip install --upgrade pip` 3. `.venv\Scripts\python.exe -m pip install -e .` → 런타임 의존성 **3개**(`httpx`, `XlsxWriter`, `jsonschema`) 설치 4. `scripts\make_shortcuts.ps1` 호출 → 바탕화면에 `DMF 설정.lnk` / `지금 실행.lnk` 생성 5. 온보딩 GUI 기동 (`pythonw -m dmf_crawler onboard --mode setup`) 수동으로 같은 일을 하려면: ```powershell cd D:\workspace\DMF_Crawler python -m venv .venv .\.venv\Scripts\python.exe -m pip install --upgrade pip .\.venv\Scripts\python.exe -m pip install -e . .\.venv\Scripts\python.exe -m dmf_crawler version ``` ### 단계 4 — agy 부트스트랩 ```powershell # 자동 powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1 # 스크립트가 하는 일: # 1) %LOCALAPPDATA%\agy\bin\agy.exe 존재 확인 # 2) 없으면 공식 설치 스크립트 무인 실행 # 3) agy --version 출력 ``` 수동 설치: ```powershell irm https://antigravity.google/cli/install.ps1 | iex # 확인 & "$env:LOCALAPPDATA\agy\bin\agy.exe" --version ``` **로그인** — 반드시 **배치를 돌릴 그 계정**으로: ```powershell & "$env:LOCALAPPDATA\agy\bin\agy.exe" login # 브라우저가 열린다. Google 계정으로 로그인. # 토큰 파일이 생겼는지 확인 (이 파일이 배치 인증의 전부다) Get-Item "$env:USERPROFILE\.gemini\antigravity-cli\antigravity-oauth-token" | Format-List FullName, Length, LastWriteTime # 헤드리스 왕복 실측 (30초 이상 걸리는 게 정상 — 첫 호출 오버헤드) & "$env:LOCALAPPDATA\agy\bin\agy.exe" -p "Reply with exactly: PONG" ` --output-format json --print-timeout 90s $LASTEXITCODE # 0 이어야 한다 ``` > **`antigravity-oauth-token` 은 평문 JSON 이다. 유출 = 계정 탈취다.** 이 파일을 백업·공유·Git 에 올리지 마라. `.gitignore` 에 `*oauth-token*` 이 들어 있는 이유다. ### 단계 5 — 설정과 API 키 ```powershell # (1) 설정 파일 확인 Get-Content .\config\config.toml | Select-Object -First 20 # (2) PC 별 오버라이드가 필요하면 (백업을 다른 드라이브로 등) Copy-Item .\config\config.local.toml.example .\config\config.local.toml notepad .\config\config.local.toml # [backup] # dir = "E:/DMF_Backup" # (3) API 키 등록 — GUI 가 DPAPI 로 암호화 저장한다 .\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup # 또는 바탕화면의 "DMF 설정" 더블클릭 # (4) 연락처 이메일도 넣는다 (User-Agent 에 들어간다. 정중한 접근 원칙) # config.toml 의 general.contact_email ``` **API 키를 config.toml 에 직접 쓰지 마라.** 그 파일은 Git 에 올라간다. 온보딩 GUI 를 통하면 DPAPI 사용자 범위로 암호화되어 저장된다. ### 단계 6 — 첫 실행 (스케줄러 없이) ```powershell # (1) 진단부터 .\.venv\Scripts\python.exe -m dmf_crawler doctor # (2) DB 초기화 .\.venv\Scripts\python.exe -m dmf_crawler db migrate .\.venv\Scripts\python.exe -m dmf_crawler db version # (3) 실제 1회 실행 (첫 실행은 기준선 수립 — diff 는 비어 있는 게 정상) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual # (4) 결과 확인 Get-ChildItem .\reports Get-Content .\state\heartbeat.json ``` **첫 실행에서 diff 가 0 인 것은 정상이다.** 비교 대상(전일 스냅샷)이 없기 때문이다. 진짜 검증은 **둘째 날**에 이루어진다. ### 단계 7 — 시스템 설정 (전원 · 업데이트 · 시간대) ```powershell # 절전 해제 타이머 허용 powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 powercfg /setactive SCHEME_CURRENT # AC 전원에서는 안 자게 powercfg /change standby-timeout-ac 0 powercfg /change hibernate-timeout-ac 0 # Windows Update 활성 시간 05–23 (06:00 보호) $ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' New-Item -Path $ux -Force | Out-Null Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord # 시간대 확인 tzutil /g # Korea Standard Time w32tm /resync ``` ### 단계 8 — 작업 등록 ```powershell cd D:\workspace\DMF_Crawler powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify ``` 프로브에서 `api_key` 가 실패하면: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify ``` ### 단계 9 — 등록 검증 §2.7 의 **자동 검증 스니펫**을 그대로 붙여넣어 전 항목 통과를 확인한다. 이어서: ```powershell # 스케줄러 경유 실행이 성공하는가 (이게 진짜 검증이다) Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') { Start-Sleep -Seconds 5 } Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo .\scripts\check_heartbeat.ps1 ``` ### 단계 10 — 알림 경로 검증 ```powershell # 에이전트를 즉시 한 번 돌린다 Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' # 인위적으로 STALE 을 만들어 강제 모달이 실제로 뜨는지 본다 Copy-Item .\state\heartbeat.json .\state\heartbeat.json.bak # heartbeat.json 의 updated_at 을 이틀 전으로 편집 notepad .\state\heartbeat.json Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' # → 복구 GUI 가 떠야 한다 # 원상복구 Move-Item .\state\heartbeat.json.bak .\state\heartbeat.json -Force ``` **이 테스트를 건너뛰지 마라.** 알림이 안 뜨는 시스템은 감시 없는 시스템과 같다. ### 단계 11 — 다음 날 아침 확인 (설치 완료 판정) ```powershell .\scripts\check_heartbeat.ps1 # 판정 OK + run_id 가 오늘 06:0x + status=SUCCESS Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" ``` **여기까지 통과해야 설치 완료다.** 손으로 돌려본 것만으로는 완료가 아니다. ### 설치 요약 카드 (복사용) ```powershell # 관리자 PowerShell, 순서대로 winget install --id Python.Python.3.12 --scope machine --silent git clone D:\workspace\DMF_Crawler ; cd D:\workspace\DMF_Crawler .\bootstrap.cmd powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1 & "$env:LOCALAPPDATA\agy\bin\agy.exe" login .\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup # API 키 입력 .\.venv\Scripts\python.exe -m dmf_crawler db migrate .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual powercfg /change standby-timeout-ac 0 powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' .\scripts\check_heartbeat.ps1 ``` --- ## 11. 트러블슈팅 레퍼런스 ### 11.1 `LastTaskResult` 값 읽는 법 `Get-ScheduledTaskInfo` 의 `LastTaskResult` 는 **작업이 실행한 프로그램의 종료 코드** 또는 **스케줄러 자체의 HRESULT** 다. | 값 | 의미 | 우리 시스템에서의 해석 | |---|---|---| | `0x0` | 성공 | 정상 (SUCCESS / PARTIAL / SKIPPED) | | `0x1` | 프로그램이 1 로 종료 | **FAILED** — 리포트 미생성. 재시도 가치 있음 | | `0x2` | 프로그램이 2 로 종료 | **BLOCKED** — 전제조건 미충족(API 키·설정·DB). 재시도해도 같다 | | `0x82` (130) | 사용자 중단 | 콘솔에서 Ctrl+C | | `0x41300` | `SCHED_S_TASK_READY` — 다음 예정 시각에 실행 대기 | 정상 | | `0x41301` | `SCHED_S_TASK_RUNNING` — 현재 실행 중 | 정상 (조회 시점에 돌고 있음) | | `0x41302` | `SCHED_S_TASK_DISABLED` — 비활성화됨 | 누가 껐다. `Enable-ScheduledTask` | | `0x41303` | `SCHED_S_TASK_HAS_NOT_RUN` — 한 번도 안 돎 | 등록 직후의 정상 상태 | | `0x41304` | `SCHED_S_TASK_NO_MORE_RUNS` | 트리거가 만료됐다. 재등록 필요 | | `0x41305` | `SCHED_S_TASK_NOT_SCHEDULED` — 예약에 필요한 속성 누락 | 트리거가 없거나 깨졌다. 재등록 | | `0x41306` | `SCHED_S_TASK_TERMINATED` — 마지막 실행이 종료됨 | `ExecutionTimeLimit` 초과 또는 `Stop-ScheduledTask` | | `0x41307` | `SCHED_S_TASK_NO_VALID_TRIGGERS` | 트리거가 없거나 전부 비활성 | | `0x41325` | `SCHED_S_TASK_QUEUED` | 큐에 대기 중 | | `0x8004130F` | `SCHED_E_ACCOUNT_INFORMATION_NOT_SET` | **자격증명 문제.** 암호 변경 후 재등록 안 했거나 S4U 등록 실패 | | `0x80041310` | `SCHED_E_ACCOUNT_NAME_NOT_FOUND` | `-User` 로 준 계정명이 틀렸다 | | `0x8004131F` | `SCHED_E_ALREADY_RUNNING` | 이미 실행 중 (IgnoreNew 정상 동작) | | `0x80041321` | `SCHED_E_INVALID_TASK_HASH` — 작업 이미지 손상 | **작업 정의가 손상됐다. 재등록이 유일한 해법** | | `0x800710E0` | 요청 거부 | 관리자 권한/RunLevel 문제 | > 시스템·네트워크 오류 코드(예: `64`)가 그대로 나올 수도 있다. `net helpmsg 64` 로 의미를 확인한다. ### 11.2 `Microsoft-Windows-TaskScheduler/Operational` 주요 이벤트 ID | ID | 이름 | 의미 | 우리 시스템에서 | |---|---|---|---| | 100 | JobStart | 작업 인스턴스 시작 | 정상 | | **101** | JobStartFailed | **작업 시작 실패** | 보안 컨텍스트·경로 문제 | | 102 | JobSuccess | 작업 완료 | 정상 | | **103** | JobFailure | **작업 실행 실패** | 종료 코드 비0 | | **104** | LogonFailure | **사용자 로그온 실패** | 암호 변경 / S4U 실패 → §2.8 | | 105 | ImpersonationFailure | 가장(impersonation) 실패 | 동일 계열 | | 106 | JobRegistered | 작업 등록됨 | `install_tasks.ps1` 실행 흔적 | | 107 | TimeTrigger | 시간 트리거로 실행 | **06:00 정상 발화** | | 108 | EventTrigger | 이벤트 트리거로 실행 | 미사용 | | 110 | Run | 사용자를 위해 실행 시작 | 정상 | | **111** | JobTermination | **시간 초과로 종료** | `ExecutionTimeLimit` 30분 초과 | | **112** | JobNoStartWithoutNetwork | **네트워크 없어 미시작** | `RunOnlyIfNetworkAvailable` 동작 | | **114** | MissedTaskLaunched | **놓친 작업을 실행** | `StartWhenAvailable` 캐치업 성공 | | 116 | TaskRegisteredWithoutCredentials | 자격증명 저장 실패 | Password 등록 실패 | | **118** | BootTrigger | **부팅 트리거로 실행** | 재시작 후 정상 발화 | | 119 | LogonTrigger | 로그온 트리거로 실행 | Agent 정상 | | 126 | FailedTaskRestart | 실패한 작업 재시작 시도 | `RestartCount` 동작 중 | | 129 | CreatedTaskProcess | 새 프로세스로 실행 | PID 확인용 | | 140 / 141 / 142 | TaskUpdated / TaskDeleted / TaskDisabled | 사람이 작업을 바꿨다 | **작업이 사라진 원인 추적** | | **145** | TaskStartedOnComputerWakeup | **절전 해제 후 실행** | `WakeToRun` 성공 증거 | | 200 / 201 | ActionStart / ActionSuccess | 액션 시작·완료 | `201` 의 `ResultCode` 가 종료 코드 | | **202** | ActionFailure | **액션 실패** | 프로그램이 비0 종료 | | **203** | ActionLaunchFailure | **액션 실행 실패** | 경로가 틀렸거나 실행 권한 없음 | | **322** | NewInstanceIgnored | **이미 실행 중이라 무시** | `IgnoreNew` 정상 동작 | | **326** | NoStartOnBatteries | **배터리라서 미시작** | 설정이 안 뒤집혔다! §2.7 재확인 | | 327 | StoppingOnBatteries | 배터리 전환으로 중단 | 동일 | | **329** | StoppingOnTimeout | **시간 초과로 중단** | 111 과 함께 본다 | | **332** | NoStartUserNotLoggedOn | **사용자 미로그온으로 미시작** | Interactive 작업의 정상 동작 | | 400 / 401 | ScheduleServiceStart / StartFailed | 스케줄러 서비스 상태 | 서비스 자체 문제 | 조회 명령: ```powershell # 우리 작업 관련만, 최근 100건 Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 | Where-Object { $_.Message -like '*DMF_Crawler*' } | Select-Object TimeCreated, Id, LevelDisplayName, @{ n='First'; e={ ($_.Message -split "`n")[0] } } | Format-Table -AutoSize # 특정 ID 만 (시작 실패 계열) Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-TaskScheduler/Operational' Id = 101, 103, 104, 111, 112, 202, 203, 326, 332 StartTime = (Get-Date).AddDays(-7) } | Format-List TimeCreated, Id, Message ``` ### 11.3 증상 → 원인 → 조치 빠른 표 | 증상 | 가장 흔한 원인 | 조치 | |---|---|---| | 06:00 에 아무 일도 안 일어남, `LastRunTime` 이 어제 | PC 가 꺼져 있었다 | 정상. 켜지면 캐치업. `powercfg /waketimers` 확인 | | 이벤트 326 이 보임 | `DisallowStartIfOnBatteries` 가 안 뒤집혔다 | `install_tasks.ps1` 재실행 | | 이벤트 104, `LastTaskResult=0x8004130F` | 사용자 암호를 바꿨다 | `install_tasks.ps1 -LogonType Password` 재실행 | | 손으로는 되는데 스케줄러로는 종료 코드 2 | **DPAPI 가 S4U 에서 실패** | §2.8 프로브 → `-LogonType Password` | | `agy` 만 실패, 나머지 정상 | OAuth 토큰 만료 | `agy login` 재실행. 리포트는 계속 나온다(설계상 정상) | | 이벤트 111 + 329 | 30분 초과 | `source.page_size` 하향 또는 `execution_time_limit_minutes` 상향 | | 리포트가 `_HHMMSS` 붙은 이름으로 저장됨 | Excel 로 파일을 열어뒀다 | Excel 닫고 `report-only` | | 이벤트 322 가 매일 2건 | 06:00 트리거와 부팅 트리거가 겹침 | **정상.** 방어가 작동 중 | | 작업이 통째로 사라짐 | 이벤트 141 확인 → 누가 지웠다 | `install_tasks.ps1` 재등록 | | `NumberOfMissedRuns` 가 계속 증가 | 작업이 실행 자체를 못 하고 있다 | 이벤트 101/104/332 확인 | | 알림이 전혀 안 뜸 | 로그오프 상태였거나 Agent 가 비활성 | `Get-ScheduledTask ... Agent` 상태 확인, §5.8 | | `doctor` 는 통과하는데 06:00 만 실패 | 컨텍스트 차이 | 반드시 `Start-ScheduledTask` 로 재현 | --- ## 부록. 미해결 / 실측 필요 - [ ] **S4U 로그온에서 DPAPI 사용자 범위 `CryptUnprotectData` 가 성공하는가.** 이 런북 전체에서 가장 중요한 미검증 항목이다. Microsoft 문서는 "no access to ... encrypted files" 만 말할 뿐 DPAPI 마스터 키에 대해 명시하지 않는다. §2.8 프로브로 설치 시점에 **반드시 실측**하고 결과를 이 문서에 기록할 것. - [ ] `New-ScheduledTaskTrigger -AtStartup` 이 반환하는 CIM 인스턴스에 `Delay` 속성을 대입하는 방식이 Windows 11 26xxx 빌드에서 정상 반영되는지. 등록 후 `Export-ScheduledTask` 로 `PT5M` 가 실제로 들어갔는지 눈으로 확인할 것. - [ ] `-RepetitionDuration ([TimeSpan]::MaxValue)` 가 이 빌드에서 예외 없이 통과하는지. 통과했다면 XML 에 `` 이 생략되는지(=무기한) 확인. 폴백(3650일) 경로가 실제로 필요한지 판정. - [ ] **모던 대기(S0) 기기에서 `WakeToRun` 이 실제로 06:00 에 깨우는가.** `powercfg /a` 로 이 PC 의 절전 상태 종류를 먼저 확정하고, §5.10 테스트 3 을 수행할 것. - [ ] `powercfg /waketimers` 출력에 `DMF_Crawler_Daily` 가 표시되는 정확한 문구. 표시되지 않는데 실제로는 깨우는 사례가 있으므로, 표시 유무만으로 판정하지 말 것. - [ ] Fast Startup 이 켜진 상태에서 "종료 → 켜기" 시 `StartWhenAvailable` 캐치업이 몇 분 안에 뜨는지. 이벤트 114 의 실제 지연 시간 측정. - [ ] Windows Update 마감 기한(deadline) 초과 시 활성 시간을 무시하고 05–23 사이에 재시작하는 사례가 이 PC 에서 발생하는지. 발생 빈도에 따라 06:00 을 다른 시각으로 옮길지 판단. - [ ] `agy update` 의 정확한 종료 코드와, 업데이트 중 다른 `agy` 인스턴스가 있을 때의 동작(agy SSOT 부록 미해결 항목과 동일). `DMF_Crawler_AgyUpdate` 실패 시 알림을 띄울지 여부는 이 결과에 달려 있다. - [ ] `DMF_Crawler_AgyUpdate` 를 S4U 로 등록했을 때 `agy update` 가 OAuth 토큰 파일을 정상적으로 읽는가. 읽지 못한다면 이 작업만 Interactive 로 내려야 한다. - [ ] 배치가 실행되는 30분 사이에 사용자가 리포트 xlsx 를 열어두는 빈도. 폴백 파일명 발생률이 높으면 `report.lock_retries` 상향 또는 저장 시각 조정 검토. - [ ] 로그오프 상태를 며칠 유지하는 운영 형태가 실제로 발생하는가. 발생한다면 §5.8 대안 B(자동 로그온) 또는 웹훅 확장(현재 범위 밖)을 다음 마일스톤 후보로 승격. - [ ] `state\run.lock` 의 `_LOCK_OFFSET = 1_000_000` 로 파일이 1MB 로 잡히는 것이 스파스 파일로 처리되는지(NTFS). 아니라면 오프셋을 4096 정도로 줄여도 되는지 확인. - [ ] `msvcrt.locking` 이 네트워크 드라이브·OneDrive 동기화 폴더에서 오동작하는 조건. 프로젝트 경로를 로컬 고정 디스크로 제한하는 것을 온보딩 체크에 추가할지 판단. - [ ] `wevtutil set-log ... /maxsize:67108864` 가 관리자 권한 없이도 성공하는지, 실패 시 무해한지. `install_tasks.ps1` 이 이미 관리자 전제이므로 실무상 문제는 없으나 문구를 확정할 것. - [ ] `doctor --json` 의 출력 스키마(`checks[].key/title/ok/detail`)가 `install_tasks.ps1 -Verify` 의 파싱과 일치하는지. **`checks.py` 구현 시 이 계약을 깨지 말 것** — 깨지면 프로브가 조용히 무력화된다.