수정 이력(3건, 최종 수정 2026년 08월 02일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 글 맨 앞에 "이 글의 지식 맵" 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 모은 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대한 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참고하십시오.
- 3편 글의 역할과 읽기 구분 표를 추가했습니다. `Patterns`가 정규 표현식으로 다루어진다는 점(`ERROR.`의 의도하지 않은 매치, `[regex]::Escape()`, `-SimpleMatch`), 산출물의 기대 형태, 작업이 등록되었는지 확인하는 방법, 메일 알림 방식을 먼저 정해야 하는 이유(`Send-MailMessage`는 비권장)를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635284)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「PowerShell 스크립트 응용 ── 로그 조사·아카이브·리포트를 안전하게 자동화하기」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635284 https://comcomponent.com/ko/blog/powershell-script-log-maintenance-automation/
- DOI(최신 버전)
- 10.5281/zenodo.21635284
- DOI(이 버전)
- 10.5281/zenodo.21635285
1. 먼저 알아둘 점
이전 글에서는 PowerShell의 기본 명령, 파이프라인, CSV 출력, JSON, .ps1 스크립트, -WhatIf를 이용한 안전 확인을 정리했습니다. 이번에는 그 후속으로, 실무에서 쓰기 좋은 조금 더 큰 스크립트를 만듭니다. 다루는 주제는 다음과 같은 운영 작업입니다.
- 로그를 조사한다
- 오류 줄을 CSV로 정리한다
- 오래된 로그를 아카이브 후보로 목록화한다
- 필요하면 오래된 로그를 이동한다
- 실행 결과를 증적으로 남긴다
PowerShell 응용에서 중요한 것은 명령을 길게 만드는 것이 아니라, 다음 흐름을 만드는 것입니다.
- 설정을 분리한다
- 읽기 처리를 만든다
- 출력을 남긴다
- 변경 처리를 함수로 나눈다
-WhatIf로 예행한다- 마지막으로 자동 실행을 검토한다
자동화란 ‘사람의 확인을 생략하는 것’이 아닙니다. 확인할 지점을 고정하고, 매번 같은 증적이 남도록 하는 것입니다.
참고로, 이 글에 나오는 코드는 그대로 실행할 수 있는 샘플 일체(완성판 스크립트, 설정 파일, 더미 로그 환경 생성 스크립트, 조사·예행·이동을 검증하는 Pester 테스트)로 GitHub에 공개되어 있습니다.
powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)
이 글의 위치
PowerShell 글은 다음 3편이 이어지는 구성입니다. 번호가 붙은 연재는 아니지만, 이 순서로 읽으면 흐름이 이어집니다.
| 순서 | 글 | 다루는 범위 |
|---|---|---|
| 1 | PowerShell 명령의 기본 ── 먼저 익힐 조작과 안전한 사용법 | 명령 찾는 방법, 파이프라인, CSV / JSON, .ps1, -WhatIf |
| 2 | PowerShell 실용 명령 모음 ── 일상 작업에서 자주 쓰는 작은 기능을 늘리기 | 집계, 비교, 추출, 증적 출력의 부품 |
| 3 | 이 글 | 부품을 운영 스크립트 하나로 모아, 설정·증적·예행·정기 실행까지 만든다 |
전제로 필요한 것은 1의 내용뿐입니다. 2를 읽지 않아도 이 글은 따라갈 수 있습니다. 맨 앞의 ‘이전 글’은 1을 가리킵니다.
이 글의 지식 맵
이 글은 PowerShell로 로그 조사부터 아카이브, 증적 보존, 정기 실행까지 안전하게 자동화하는 실무 절차를 다룹니다. Get-ChildItem과 Select-String으로 로그를 검색·추출해 Export-Csv로 보고서로 만들고, 실행 조건은 ConvertFrom-Json으로 읽어 들이는 JSON 설정 파일로 분리하며, 실행 결과는 ConvertTo-Json으로 요약합니다. 오래된 로그의 이동 처리는 SupportsShouldProcess와 -WhatIf로 예행할 수 있는 함수로 분리하고, Start-Transcript로 세션 전체를 기록한 뒤 작업 스케줄러에 등록해 정기 실행합니다. 아울러 Select-String의 기본 정규식 해석으로 인한 의도하지 않은 패턴 매칭이나, 사용이 중단된 Send-MailMessage에 의존하지 않는 알림 방식 선정에도 주의를 당부합니다.
flowchart LR
accTitle: PowerShell 로그 운영 자동화의 지식 맵
accDescr: PowerShell로 로그 조사·아카이브·증적 보존·정기 실행까지 안전하게 자동화하는 구조와, 정규식의 함정이나 사용 중단된 명령의 취급을 보여주는 그림.
log_maintenance_automation["로그 운영 자동화(조사·아카이브)"]
shouldprocess_whatif["SupportsShouldProcess와 -WhatIf/-Confirm"]
select_string["Select-String"]
get_childitem["Get-ChildItem"]
export_csv["Export-Csv"]
convertfrom_json["ConvertFrom-Json"]
convertto_json["ConvertTo-Json"]
move_item["Move-Item"]
start_transcript["Start-Transcript"]
json_configuration_file["JSON 구성 파일"]
task_scheduler["작업 스케줄러의 무인 실행"]
scheduledtasks_cmdlets["ScheduledTasks 모듈 cmdlet군"]
regular_expression["정규 표현식"]
unintended_regex_match["의도하지 않은 정규식 매치"]
send_mailmessage["Send-MailMessage cmdlet"]
log_maintenance_automation -->|"이용한다"| select_string
log_maintenance_automation -->|"이용한다"| get_childitem
log_maintenance_automation -->|"이용한다"| export_csv
log_maintenance_automation -->|"이용한다"| convertfrom_json
log_maintenance_automation -->|"이용한다"| convertto_json
log_maintenance_automation -->|"이용한다"| move_item
log_maintenance_automation -->|"이용한다"| start_transcript
log_maintenance_automation -.->|"전제로 한다"| json_configuration_file
json_configuration_file -.->|"에서 구성할 수 있다"| convertfrom_json
move_item -->|"에서 구성할 수 있다"| shouldprocess_whatif
shouldprocess_whatif -->|"권장되는 대응"| move_item
log_maintenance_automation -->|"이용한다"| task_scheduler
task_scheduler -.->|"에서 구성할 수 있다"| scheduledtasks_cmdlets
select_string -->|"이용한다"| regular_expression
select_string -.->|"원인이 될 수 있다"| unintended_regex_match
send_mailmessage -->|"사용은 비권장"| log_maintenance_automation
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 16건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 이번에 만들 것
이번에는 Invoke-LogMaintenance.ps1이라는 스크립트를 만듭니다.
주요 기능은 다음과 같습니다.
| 기능 | 내용 |
|---|---|
| 로그 검색 | 지정 폴더 아래의 .log를 검색 |
| 기간 지정 | 최근 N일 이내에 갱신된 로그만 조사 |
| 오류 추출 | ERROR, WARN, FATAL 등의 줄을 추출 |
| CSV 출력 | 검색 결과를 log-hits.csv에 저장 |
| 오래된 로그 목록 | N일보다 오래된 로그를 archive-targets.csv에 저장 |
| 아카이브 | 오래된 로그를 다른 폴더로 이동 |
| 예행 실행 | -Preview 지정 시 이동하지 않고 예정만 표시 |
| 실행 기록 | transcript, summary JSON, 결과 CSV를 저장 |
참고로 삭제는 하지 않습니다. 이번 응용에서는 삭제보다 안전한 이동까지만 다룹니다.
3. 폴더 구성
예를 들면 다음과 같이 구성합니다.
C:\Ops
Invoke-LogMaintenance.ps1
log-maintenance.json
C:\App\Logs
app.log
batch.log
old
app-202401.log
C:\App\Reports
20260602-030000
log-hits.csv
archive-targets.csv
archive-result.csv
summary.json
transcript.txt
C:\App\Archive
20260602-030000
old
app-202401.log
스크립트 본문과 설정 파일을 나눠 두면, 환경마다 바꿔 넣기가 쉬워집니다. 개발 환경에서는 C:\Test\Logs, 프로덕션 환경에서는 D:\App\Logs처럼 경로만 바꾸는 운영이 가능합니다.
4. 설정 파일을 만든다
먼저 log-maintenance.json을 만듭니다.
{
"LogPath": "C:\\App\\Logs",
"OutputPath": "C:\\App\\Reports",
"Days": 7,
"Patterns": [
"ERROR",
"WARN",
"FATAL"
],
"ArchiveDays": 90,
"ArchivePath": "C:\\App\\Archive"
}
의미는 다음과 같습니다.
| 항목 | 의미 |
|---|---|
LogPath |
조사 대상 로그 폴더 |
OutputPath |
리포트 출력 위치 |
Days |
최근 며칠 동안의 로그를 조사할지 |
Patterns |
검색할 문자열·패턴 |
ArchiveDays |
며칠보다 오래된 로그를 아카이브 대상으로 할지 |
ArchivePath |
아카이브 저장 폴더 |
JSON으로 두면 스크립트 본문을 편집하지 않고 조건을 바꿀 수 있습니다.
PowerShell에서는 ConvertFrom-Json으로 JSON을 객체로 다룰 수 있습니다. 반대로 처리 결과를 JSON으로 남길 때는 ConvertTo-Json을 사용합니다. ConvertTo-Json은 객체를 JSON 문자열로 변환하는 cmdlet이며, 깊은 계층을 다룰 때는 -Depth 지정이 중요해집니다.
Patterns는 정규 표현식으로 다루어진다
한 곳만 먼저 주의합니다. Patterns 값은 최종적으로 Select-String -Pattern에 전달됩니다. Select-String은 기본적으로 이 값을 정규 표현식으로 해석합니다.
즉 ERROR.라고 쓰면 ‘ERROR 뒤에 임의의 문자 1개’라는 뜻이 되어, ERRORS나 ERROR:에도 맞습니다. 반대로 ERROR만 있는 줄에는 맞지 않습니다. C:\App처럼 백슬래시를 포함한 문자열도, 그대로 두면 정규 표현식 제어 문자로 취급됩니다.
- 정규 표현식으로 쓰고 싶다: 그대로 씁니다.
"ERROR|FATAL"이나"\[ERROR\]"처럼 쓸 수 있습니다 - 문자열 그대로 맞추고 싶다:
[regex]::Escape("ERROR.")결과를 JSON에 넣거나, 스크립트 쪽Select-String에-SimpleMatch를 추가합니다
또한 Select-String은 기본적으로 대소문자를 구분하지 않습니다. error 줄도 ERROR 줄도 찾습니다. 구분하려면 -CaseSensitive를 추가합니다.
5. 우선 읽기만 만든다
처음부터 이동 처리를 작성하지 말고, 처음에는 로그를 찾아 CSV로 만드는 것만 합니다.
$config = Get-Content .\log-maintenance.json -Raw -Encoding UTF8 | ConvertFrom-Json
$since = (Get-Date).AddDays(-[int]$config.Days)
$files = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
Where-Object { $_.LastWriteTime -ge $since }
$files |
Select-Object FullName, Length, LastWriteTime
이어서 로그 내용을 검색합니다.
$patterns = [string[]]$config.Patterns
Select-String -LiteralPath ($files | Select-Object -ExpandProperty FullName) -Pattern $patterns |
Select-Object Path, LineNumber, Pattern, Line |
Export-Csv .\log-hits.csv -NoTypeInformation -Encoding UTF8
여기까지는 읽기만입니다. 프로덕션 폴더에서 시험할 때도, 우선 이 단계에서 멈춥니다.
6. 오래된 로그를 목록화한다
다음으로 아카이브 대상을 목록화합니다.
$limit = (Get-Date).AddDays(-[int]$config.ArchiveDays)
$targets = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
Where-Object { $_.LastWriteTime -lt $limit } |
Sort-Object LastWriteTime
$targets |
Select-Object FullName, Length, LastWriteTime |
Export-Csv .\archive-targets.csv -NoTypeInformation -Encoding UTF8
이 단계에서도 아직 이동하지 않습니다. archive-targets.csv를 보고, 대상이 너무 많지 않은지, 폴더가 틀리지 않았는지를 확인합니다.
7. 변경 처리는 함수로 나눈다
이동처럼 상태를 바꾸는 처리는 읽기 처리와 분리합니다.
PowerShell에서는 함수에 SupportsShouldProcess를 지정하면 -WhatIf와 -Confirm을 다룰 수 있게 됩니다. -WhatIf는 실행하지 않고 ‘무엇을 변경할 예정인지’를 표시하고, -Confirm은 실행 전에 확인을 묻는 기능입니다. 자세한 내용은 Microsoft Learn의 about_Functions_CmdletBindingAttribute에서 확인할 수 있습니다.
function Move-OldLogFile {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo[]]$File,
[Parameter(Mandatory)]
[string]$SourceRoot,
[Parameter(Mandatory)]
[string]$ArchiveRoot
)
foreach ($item in $File) {
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
$destination = Join-Path $ArchiveRoot $relativePath
$destinationDirectory = Split-Path -Path $destination -Parent
if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
if (-not [System.IO.Directory]::Exists($destinationDirectory)) {
[System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
}
Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Moved"
Message = ""
}
}
else {
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Preview"
Message = ""
}
}
}
}
핵심은 Move-Item 바로 앞에 $PSCmdlet.ShouldProcess()를 둔 점입니다. 변경할지 여부의 판단은 함수 밖이 아니라, 변경 직전에 둡니다.
8. 완성판 스크립트
여기까지의 내용을 모은 완성판입니다. 파일 이름은 Invoke-LogMaintenance.ps1으로 합니다.
# Invoke-LogMaintenance.ps1
#Requires -Version 7.0
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()]
[string]$ConfigPath = ".\log-maintenance.json",
[switch]$Preview,
[switch]$SkipArchive,
[switch]$SkipTranscript
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Ensure-Directory {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
if (-not [System.IO.Directory]::Exists($Path)) {
[System.IO.Directory]::CreateDirectory($Path) | Out-Null
}
}
function Import-LogMaintenanceConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Config file not found: $Path"
}
$config = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($name in @("LogPath", "OutputPath", "Days", "Patterns", "ArchiveDays", "ArchivePath")) {
if (-not ($config.PSObject.Properties.Name -contains $name)) {
throw "Config value missing: $name"
}
}
if ([string]::IsNullOrWhiteSpace([string]$config.LogPath)) {
throw "LogPath is empty."
}
if (-not (Test-Path -LiteralPath $config.LogPath)) {
throw "LogPath not found: $($config.LogPath)"
}
if ([string]::IsNullOrWhiteSpace([string]$config.OutputPath)) {
throw "OutputPath is empty."
}
if ([string]::IsNullOrWhiteSpace([string]$config.ArchivePath)) {
throw "ArchivePath is empty."
}
if (@($config.Patterns).Count -eq 0) {
throw "Patterns is empty."
}
if ([int]$config.Days -lt 1) {
throw "Days must be 1 or greater."
}
if ([int]$config.ArchiveDays -lt 1) {
throw "ArchiveDays must be 1 or greater."
}
return $config
}
function Export-CsvWithHeader {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$InputObject,
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string[]]$Header
)
if ($InputObject.Count -gt 0) {
$InputObject |
Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
}
else {
($Header -join ",") |
Set-Content -LiteralPath $Path -Encoding UTF8
}
}
function Get-LogHit {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogPath,
[Parameter(Mandatory)]
[ValidateRange(1, 3650)]
[int]$Days,
[Parameter(Mandatory)]
[string[]]$Pattern
)
$since = (Get-Date).AddDays(-$Days)
$files = @(
Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
Where-Object { $_.LastWriteTime -ge $since }
)
Write-Verbose "Recent log files: $($files.Count)"
if ($files.Count -eq 0) {
return @()
}
$paths = $files | Select-Object -ExpandProperty FullName
Select-String -LiteralPath $paths -Pattern $Pattern -ErrorAction Stop |
ForEach-Object {
[pscustomobject]@{
Path = $_.Path
LineNumber = $_.LineNumber
Pattern = $_.Pattern
Line = $_.Line.Trim()
}
}
}
function Get-OldLogFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogPath,
[Parameter(Mandatory)]
[ValidateRange(1, 3650)]
[int]$ArchiveDays
)
$limit = (Get-Date).AddDays(-$ArchiveDays)
Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
Where-Object { $_.LastWriteTime -lt $limit } |
Sort-Object LastWriteTime
}
function Move-OldLogFile {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo[]]$File,
[Parameter(Mandatory)]
[string]$SourceRoot,
[Parameter(Mandatory)]
[string]$ArchiveRoot
)
foreach ($item in $File) {
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
$destination = Join-Path $ArchiveRoot $relativePath
$destinationDirectory = Split-Path -Path $destination -Parent
if (Test-Path -LiteralPath $destination) {
$name = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
$ext = $item.Extension
$destination = Join-Path $destinationDirectory ("{0}_{1:yyyyMMddHHmmss}{2}" -f $name, $item.LastWriteTime, $ext)
}
if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
Ensure-Directory -Path $destinationDirectory
Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Moved"
Message = ""
}
}
else {
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Preview"
Message = ""
}
}
}
}
$config = Import-LogMaintenanceConfig -Path $ConfigPath
$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportDir = Join-Path ([string]$config.OutputPath) $runStamp
Ensure-Directory -Path $reportDir
$transcriptStarted = $false
$transcriptPath = Join-Path $reportDir "transcript.txt"
try {
if (-not $SkipTranscript) {
Start-Transcript -Path $transcriptPath -Force | Out-Null
$transcriptStarted = $true
}
Write-Host "Report directory: $reportDir"
$hits = @(
Get-LogHit `
-LogPath ([string]$config.LogPath) `
-Days ([int]$config.Days) `
-Pattern ([string[]]$config.Patterns)
)
$hitCsv = Join-Path $reportDir "log-hits.csv"
Export-CsvWithHeader `
-InputObject $hits `
-Path $hitCsv `
-Header @("Path", "LineNumber", "Pattern", "Line")
$oldFiles = @(
Get-OldLogFile `
-LogPath ([string]$config.LogPath) `
-ArchiveDays ([int]$config.ArchiveDays)
)
$archiveTargets = @(
$oldFiles |
Select-Object FullName, Length, LastWriteTime
)
$archiveTargetCsv = Join-Path $reportDir "archive-targets.csv"
Export-CsvWithHeader `
-InputObject $archiveTargets `
-Path $archiveTargetCsv `
-Header @("FullName", "Length", "LastWriteTime")
$moveResults = @()
if ($SkipArchive) {
Write-Host "Archive skipped."
}
elseif ($oldFiles.Count -eq 0) {
Write-Host "No archive targets."
}
else {
$archiveRunRoot = Join-Path ([string]$config.ArchivePath) $runStamp
$moveResults = @(
Move-OldLogFile `
-File $oldFiles `
-SourceRoot ([string]$config.LogPath) `
-ArchiveRoot $archiveRunRoot `
-WhatIf:$Preview
)
}
$archiveResultCsv = Join-Path $reportDir "archive-result.csv"
Export-CsvWithHeader `
-InputObject $moveResults `
-Path $archiveResultCsv `
-Header @("Source", "Destination", "Status", "Message")
$summary = [pscustomobject]@{
CheckedAt = (Get-Date).ToString("s")
ComputerName = $env:COMPUTERNAME
LogPath = [string]$config.LogPath
ReportDirectory = $reportDir
HitCount = $hits.Count
ArchiveTargetCount = $oldFiles.Count
ArchiveResultCount = $moveResults.Count
Preview = [bool]$Preview
SkipArchive = [bool]$SkipArchive
}
$summaryPath = Join-Path $reportDir "summary.json"
$summary |
ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host "Finished."
Write-Host "Hits: $($hits.Count)"
Write-Host "Archive targets: $($oldFiles.Count)"
}
catch {
$errorPath = Join-Path $reportDir "error.txt"
$_ | Out-String | Set-Content -LiteralPath $errorPath -Encoding UTF8
Write-Error "Failed: $($_.Exception.Message)"
exit 1
}
finally {
if ($transcriptStarted) {
Stop-Transcript | Out-Null
}
}
9. 실행 예
우선 아카이브를 실행하지 않고 로그 조사만 합니다.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -SkipArchive
다음으로 아카이브 예정을 확인합니다.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview
-Preview를 붙이면 오래된 로그의 이동 처리는 -WhatIf로 취급됩니다.
이 시점에 확인할 파일은 다음 3개입니다.
log-hits.csvarchive-targets.csvarchive-result.csv
문제가 없으면 -Preview를 빼고 실행합니다.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json
상세를 보고 싶으면 -Verbose를 붙입니다.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview -Verbose
10. 출력되는 파일
실행하면 OutputPath 아래에 C:\App\Reports\20260602-030000처럼 날짜·시각이 붙은 폴더가 만들어집니다.
안에는 다음 파일이 출력됩니다.
| 파일 | 내용 |
|---|---|
log-hits.csv |
오류·경고로 검출된 줄 |
archive-targets.csv |
아카이브 대상이 된 오래된 로그 |
archive-result.csv |
이동 결과, 또는 Preview 결과 |
summary.json |
건수와 실행 조건의 요약 |
transcript.txt |
PowerShell 세션 기록 |
error.txt |
오류 발생 시의 상세 |
Start-Transcript는 PowerShell 세션의 명령과 콘솔 출력을 텍스트 파일에 기록하는 cmdlet입니다. 운영 스크립트에서는 나중에 ‘언제·어떤 조건으로·무엇이 나왔는지’를 확인하기 쉬워집니다.
산출물의 기대 형태
처음 한 번은 파일이 나왔는지보다, 내용이 예상과 같은지를 봅니다. 로그 내용은 더미이지만, 열과 항목 배치는 이 글의 스크립트가 출력하는 그대로입니다.
log-hits.csv는 Path / LineNumber / Pattern / Line의 4열입니다. Export-Csv는 기본적으로 모든 항목을 "로 감쌉니다.
"Path","LineNumber","Pattern","Line"
"C:\App\Logs\app.log","128","ERROR","2026-06-02 02:14:51 [ERROR] OrderService: timeout while calling /api/stock"
"C:\App\Logs\app.log","301","WARN","2026-06-02 02:41:03 [WARN] OrderService: retry 1/3"
"C:\App\Logs\batch.log","57","FATAL","2026-06-02 03:00:12 [FATAL] nightly batch aborted"
Pattern 열에는 맞은 쪽의 패턴 문자열이 그대로 들어갑니다. 설정 파일의 Patterns에 적은 값과 대응하므로, ‘어느 키워드로 몇 건이 나왔는지’ 집계에 쓸 수 있습니다.
archive-targets.csv는 FullName / Length / LastWriteTime의 3열입니다.
"FullName","Length","LastWriteTime"
"C:\App\Logs\old\app-202401.log","10485760","2024/01/31 23:59:58"
archive-result.csv는 Source / Destination / Status / Message의 4열입니다. -Preview를 붙인 때는 Status가 Preview가 되고, 실행한 때는 Moved가 됩니다.
"Source","Destination","Status","Message"
"C:\App\Logs\old\app-202401.log","C:\App\Archive\20260602-030000\old\app-202401.log","Moved",""
일시 표기는 Export-Csv가 OS 로케일 설정에 따라 문자열로 만들기 때문에, 환경에 따라 달라집니다. CSV를 후단 시스템에 넘긴다면 이 점은 먼저 확인하십시오.
summary.json은 건수와 실행 조건의 요약입니다. 경로는 JSON 규격대로 \\로 이스케이프됩니다.
{
"CheckedAt": "2026-06-02T03:00:07",
"ComputerName": "OPS-01",
"LogPath": "C:\\App\\Logs",
"ReportDirectory": "C:\\App\\Reports\\20260602-030000",
"HitCount": 3,
"ArchiveTargetCount": 1,
"ArchiveResultCount": 1,
"Preview": false,
"SkipArchive": false
}
감시나 일일 확인에서는 이 summary.json만 보면 되게 해 두면 편합니다. HitCount가 뛴 날, ArchiveTargetCount가 갑자기 늘어난 날만 CSV를 여는 운영이 가능합니다.
참고로 대상이 0건이어도 CSV는 만들어집니다. Export-CsvWithHeader가 헤더 줄만 있는 파일을 쓰도록 되어 있기 때문입니다. ‘파일이 없다’와 ‘해당이 0건이었다’를 혼동하지 않기 위한 구성입니다.
11. 작업 스케줄러로 정기 실행한다
수동 실행에서 문제가 없으면 작업 스케줄러로 정기 실행할 수 있습니다.
처음에는 매일 새벽 3시에 실행하는 예입니다.
$scriptPath = "C:\Ops\Invoke-LogMaintenance.ps1"
$configPath = "C:\Ops\log-maintenance.json"
$action = New-ScheduledTaskAction `
-Execute "pwsh.exe" `
-Argument "-NoProfile -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
-WorkingDirectory "C:\Ops"
$trigger = New-ScheduledTaskTrigger -Daily -At 3:00
Register-ScheduledTask `
-TaskName "AppLogMaintenance" `
-Action $action `
-Trigger $trigger `
-Description "Collect app log errors and archive old logs"
New-ScheduledTaskAction은 작업이 실행할 명령을 나타내는 객체를 만들고, New-ScheduledTaskTrigger는 매일·매주·로그온 시 같은 시작 조건을 만듭니다. 마지막으로 Register-ScheduledTask로 로컬 컴퓨터에 작업을 등록합니다.
등록되었는지 확인한다
등록 명령이 통과했다고 해서 아직 ‘동작한다’고 말할 수는 없습니다. 실행을 기다리기 전에 등록 내용을 눈으로 확인합니다.
GUI라면 작업 스케줄러(taskschd.msc)를 열고, 왼쪽 창의 ‘작업 스케줄러 라이브러리’를 선택하면 방금 등록한 AppLogMaintenance가 목록에 나타납니다. 볼 열은 다음입니다.
| 열 | 보는 방법 |
|---|---|
| 상태 | ‘준비됨’이면 사용하도록 설정된 상태입니다. ‘사용 안 함’이면 트리거가 와도 실행되지 않습니다 |
| 트리거 | ‘매일 3:00에’처럼 의도한 조건인지 |
| 다음 실행 시간 | 가장 가까운 예정 시각. 여기가 비어 있으면 트리거나 사용/사용 안 함을 의심합니다 |
| 마지막 실행 결과 | 실행 후에 들어갑니다. 0x0이 성공, 0x1은 일반적인 오류, 0x41301은 실행 중 |
목록에 이 열이 보이지 않으면 열 머리글을 마우스 오른쪽 단추로 눌러 표시할 열을 추가합니다.
같은 내용은 PowerShell로도 확인할 수 있습니다. GUI를 열 수 없는 컴퓨터에서는 이쪽이 빠릅니다.
# 등록 내용을 본다
Get-ScheduledTask -TaskName "AppLogMaintenance" |
Select-Object TaskName, TaskPath, State
# 실행 결과를 본다
Get-ScheduledTaskInfo -TaskName "AppLogMaintenance" |
Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime
LastTaskResult가 0이 아니면 먼저 리포트 폴더의 error.txt와 transcript.txt를 봅니다.
다만 작업 자체가 시작되지 못한 경우에는 스크립트가 한 줄도 돌지 않으므로 리포트 폴더조차 만들어지지 않습니다. 그때는 작업을 마우스 오른쪽 단추로 눌러 연 속성이 아니라, 목록에서 작업을 골랐을 때 하단에 나오는 ‘기록’ 탭을 봅니다. 기록은 기본적으로 사용하지 않는 경우가 있으므로, 오른쪽 창의 ‘모든 작업 기록 사용’으로 켠 뒤, 마우스 오른쪽 단추의 ‘실행’으로 수동 시작해 확인합니다.
프로덕션 운영에서는 다음도 확인합니다.
- 실행 계정에 로그 폴더 읽기 권한이 있다
- 아카이브 경로에 쓰기 권한이 있다
pwsh.exe경로가 잡혀 있다- 스크립트 실행 정책이나 서명 규칙에 맞다
- 수동 실행과 작업 실행에서 같은 결과가 나온다
- 실패 시
error.txt나 작업 기록을 확인할 수 있다
12. 자주 걸리는 지점
| 증상 | 원인 | 대처 |
|---|---|---|
| 로그를 찾지 못한다 | LogPath가 잘못되었다 |
Test-Path와 Get-ChildItem으로 확인한다 |
| CSV가 비게 된다 | 대상 기간에 해당 로그가 없다 | Days를 넓혀 확인한다 |
| 일본어가 깨진다 | 로그 문자 코드가 예상과 다르다 | 입출력 문자 코드를 확인한다 |
| 작업에서는 동작하지 않는다 | 실행 계정이나 작업 폴더가 다르다 | WorkingDirectory와 권한을 확인한다 |
| 아카이브 대상이 너무 많다 | ArchiveDays가 너무 짧다 |
archive-targets.csv를 보고 조정한다 |
| 이동 위치가 예상과 다르다 | 상대 경로 유지 규칙을 이해하지 못했다 | -Preview로 Destination을 확인한다 |
| 프로덕션에서만 실패한다 | 권한·정책·잠긴 파일의 차이 | error.txt와 transcript.txt를 확인한다 |
특히 작업 스케줄러로 돌릴 때는 ‘직접 실행했을 때’와 ‘작업의 실행 계정’이 다른 경우가 있습니다. 수동에서는 동작하는데 작업에서는 실패한다면, 먼저 권한과 작업 폴더를 의심하십시오.
13. 현장에 맞게 바꿀 때의 생각
이 스크립트는 그대로 쓰기보다, 현장에 맞춰 조금씩 바꾼다는 전제입니다. 흔한 변경 예를 듭니다.
| 하고 싶은 일 | 바꿀 곳 |
|---|---|
.txt도 대상으로 하고 싶다 |
Get-ChildItem -Filter *.log를 변경 |
ERROR 전후 몇 줄도 보고 싶다 |
Select-String 결과를 바탕으로 Get-Content로 주변 줄을 가져옴 |
| 압축한 뒤 아카이브하고 싶다 | Move-OldLogFile 앞에 Compress-Archive를 추가 |
| 메일 알림을 하고 싶다 | summary.json을 바탕으로 알림 처리를 추가 |
| 앱마다 설정을 나누고 싶다 | JSON을 여러 개 준비해 작업을 나눔 |
| 삭제까지 자동화하고 싶다 | 먼저 이동 운영으로 일정 기간 확인한 뒤에 검토 |
다만 처음부터 전부 넣지 않는 편이 안전합니다. 운영 스크립트는 기능이 많은 것보다, 실패했을 때 추적할 수 있는 것이 중요합니다.
메일 알림만은 먼저 방식을 정한다
‘메일 알림을 하고 싶다’는 요청은 많지만, 여기만은 구현 전에 방식을 정해 둘 필요가 있습니다.
PowerShell의 Send-MailMessage는 Microsoft Learn에서 obsolete(구식)로 명시되어 있으며, SMTP 서버로의 안전한 연결을 보장하지 않으므로 사용하지 말라고 안내되어 있습니다. PowerShell 안에 직접적인 후속 cmdlet은 없고, 대안으로 MailKit 같은 라이브러리, Exchange Online 환경이라면 Microsoft Graph PowerShell SDK의 Send-MgUserMail이 거론됩니다.
방식은 환경에 따라 갈립니다.
| 환경 | 알림 방식 |
|---|---|
| 사내 SMTP 릴레이가 있고, 인증도 TLS도 필요 없다 | Send-MailMessage로도 동작하지만, 장래 삭제에 대비해 함수 하나에 가둔다 |
| 인증 있는 SMTP나 TLS가 필요하다 | MailKit 같은 라이브러리를 사용한다 |
| Microsoft 365 환경 | Microsoft Graph 경유(Send-MgUserMail)로 한다 |
| 메일이 아니어도 된다 | Teams 등의 Webhook, 또는 모니터링 시스템으로의 파일 연동으로 한다 |
어느 방식이든 알림 처리는 summary.json을 읽어 보내기만 하고, 주 처리에서는 분리하십시오. 알림이 실패해도 로그 조사와 아카이브는 끝난 상태가 되게 해 두는 것이 실무에서 효과가 있습니다.
14. 현장 운영 체크리스트
PowerShell 스크립트를 정기 실행하기 전에 다음을 확인합니다.
- 먼저
-SkipArchive로 읽기 처리만 실행했다 - 이어서
-Preview로 이동 예정을 확인했다 archive-targets.csv의 대상이 타당했다archive-result.csv의Destination이 예상과 같았다- 출력 폴더에 날짜·시각이 붙은 증적이 남았다
- 오류 시
error.txt가 남는 것을 확인했다 - 작업 실행 계정의 권한을 확인했다
- 실행 정책, 서명, 사내 규칙을 확인했다
- 처음부터 삭제가 아니라, 먼저 이동으로 운영한다
- 복구할 때 되돌릴 위치를 정해 두었다
15. 정리
PowerShell 응용이라고 해서 어려운 구문을 많이 쓸 필요는 없습니다. 실무에서 효과가 있는 것은, 이런 형태를 만들어 두는 것입니다.
- 설정을 JSON으로 분리한다
- 읽기 처리를 먼저 만든다
- CSV와 JSON으로 증적을 남긴다
- 변경 처리는 함수로 나눈다
-WhatIf에 해당하는 예행을 준비한다- transcript와
error.txt로 추적할 수 있게 한다 - 수동 실행으로 확인한 뒤에 정기 실행한다
이번 스크립트는 로그 조사와 아카이브를 주제로 했지만, 이 접근은 다른 업무에도 쓸 수 있습니다.
- 파일 정리
- 장표 출력
- CSV 집계
- 배치 교체
- 오래된 자산 재고 파악
- 일일·월간 운영 확인
PowerShell은 한 줄 명령으로도 편리하지만, 업무에서 쓴다면 다음 순서를 지키는 편이 안전합니다.
본다 → 기록한다 → 예행한다 → 실행한다 → 증적을 남긴다
이 형태로 두면 PowerShell은 단순한 작업 단축 도구가 아니라, 운영을 안정시키기 위한 작은 업무 앱으로 쓸 수 있게 됩니다.
참고 링크
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
PowerShell 스크립트의 인수 설계와 모듈화 ── 「돌아가는 스크립트」에서 「남에게 넘길 수 있는 스크립트」로
PowerShell 스크립트를 다른 사람에게 넘길 수 있는 품질로 끌어올리는 절차를 정리합니다. param 블록과 [CmdletBinding()], 입력 검증, 파이프라인 입력, -WhatIf 지원, .psm1 모듈화, 사내 공유와 Git 관리의...
Windows PowerShell 5.1과 PowerShell 7의 차이 ── 사내 스크립트 마이그레이션 실무 가이드
Windows PowerShell 5.1과 PowerShell 7의 관계(공존과 pwsh.exe), 5.1에는 신기능을 추가하지 않는다는 공식 방침, 인코딩 차이로 인한 문자 깨짐, #Requires로 막는 방어, 작업 스케줄러 업데이트까지 마이...
winget + PowerShell로 PC 키팅을 자동화한다 ── 절차서를 실행 가능하게 만들기
신입 사원 PC 설정을 재현 가능하게 만드는 방법을 정리합니다. winget으로 앱을 도입하고 export/import하는 방법, WinGet Configuration의 선언적 구성, PowerShell로 보완하는 설정, 무인 실행 시 주의점까지...
PowerShell로 REST API와 연동하기 ── Invoke-RestMethod의 실무
PowerShell에서 사내 API나 SaaS REST API를 호출하는 실무를 정리합니다. 인증 헤더 전달 방법, 일본어 JSON 문자 깨짐 대책, 4xx/5xx 오류 처리, 429 재시도, 페이징, 프록시와 TLS의 함정까지 설명합니다.
PowerShell 스크립트가 느릴 때 볼 곳 ── 배열·파이프라인·매칭의 핵심
PowerShell 스크립트가 느려지는 대표적인 원인을 정리합니다. 배열의 +=가 O(n^2)가 되는 이유, 파이프라인과 foreach의 차이, 매칭의 해시 테이블화, 파일 I/O 개선, 올바른 측정 방법까지 실무 관점에서 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기존 자산 활용 & 이관 지원
COM / ActiveX / OCX 자산, 네이티브 코드, 32비트 의존성을 유지하면서 단계적인 이관 계획을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- PowerShell로 로그 조사를 자동화할 때, 무엇부터 만들기 시작하면 됩니까?
- 처음부터 이동이나 삭제 같은 변경 처리를 작성하지 말고, 먼저 읽기 처리만 만듭니다. Get-ChildItem으로 대상 로그를 찾고, Select-String으로 오류 줄을 추출해 CSV로 출력하는 부분까지 먼저 완성합니다. 프로덕션 폴더에서 시험할 때도 이 읽기 단계에서 한 번 멈추고 결과를 확인합니다. 그다음에 아카이브 대상 목록화, 이동 처리를 함수로 나누는 단계로 진행하는 것이 안전합니다.
- PowerShell의 -WhatIf는 스크립트에 어떻게 넣습니까?
- 함수의 CmdletBinding 특성에 SupportsShouldProcess를 지정하면, 그 함수에서 -WhatIf와 -Confirm을 사용할 수 있습니다. 실제로 변경하는 처리(Move-Item 등) 바로 앞에 $PSCmdlet.ShouldProcess()를 두고, 변경할지 여부를 변경 직전에 판단하는 것이 핵심입니다. 이 글의 스크립트에서는 -Preview 스위치를 -WhatIf:$Preview로 넘겨, 이동하지 않고 예정만 확인할 수 있게 합니다.
- PowerShell 스크립트가 수동으로는 동작하는데 작업 스케줄러에서는 실패하는 이유는 무엇입니까?
- 직접 실행할 때와 작업의 실행 계정이 다른 경우가 전형적인 원인입니다. 실행 계정에 로그 폴더 읽기 권한이나 아카이브 경로 쓰기 권한이 있는지, 작업 폴더(WorkingDirectory)가 맞는지, pwsh.exe 경로가 잡혀 있는지, 실행 정책이나 서명 규칙을 만족하는지를 확인합니다. 실패 시에는 error.txt나 transcript.txt, 작업 기록으로 추적할 수 있게 해 두는 것이 중요합니다.
- 오래된 로그의 삭제까지 자동화해도 됩니까?
- 처음부터 삭제를 자동화하는 것은 권장하지 않습니다. 이 글의 스크립트도 삭제가 아니라 안전한 이동까지만 다룹니다. 먼저 이동 운영으로 일정 기간 확인하고, archive-targets.csv나 archive-result.csv 같은 증적을 보고 문제가 없다고 판단한 뒤에 삭제 자동화를 검토합니다. 복구할 때 되돌릴 위치도 미리 정해 두는 것이 중요합니다.