PowerShell 스크립트 응용 ── 로그 조사·아카이브·리포트를 안전하게 자동화하기

· 업데이트: · · PowerShell, Windows, 자동화, 로그 조사, 운영 개선, 기존 자산 활용

수정 이력(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를 이용한 안전 확인을 정리했습니다. 이번에는 그 후속으로, 실무에서 쓰기 좋은 조금 더 큰 스크립트를 만듭니다. 다루는 주제는 다음과 같은 운영 작업입니다.

  1. 로그를 조사한다
  2. 오류 줄을 CSV로 정리한다
  3. 오래된 로그를 아카이브 후보로 목록화한다
  4. 필요하면 오래된 로그를 이동한다
  5. 실행 결과를 증적으로 남긴다

PowerShell 응용에서 중요한 것은 명령을 길게 만드는 것이 아니라, 다음 흐름을 만드는 것입니다.

  1. 설정을 분리한다
  2. 읽기 처리를 만든다
  3. 출력을 남긴다
  4. 변경 처리를 함수로 나눈다
  5. -WhatIf로 예행한다
  6. 마지막으로 자동 실행을 검토한다

자동화란 ‘사람의 확인을 생략하는 것’이 아닙니다. 확인할 지점을 고정하고, 매번 같은 증적이 남도록 하는 것입니다.

참고로, 이 글에 나오는 코드는 그대로 실행할 수 있는 샘플 일체(완성판 스크립트, 설정 파일, 더미 로그 환경 생성 스크립트, 조사·예행·이동을 검증하는 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에 의존하지 않는 알림 방식 선정에도 주의를 당부합니다.

PowerShell 로그 운영 자동화의 지식 맵PowerShell로 로그 조사·아카이브·증적 보존·정기 실행까지 안전하게 자동화하는 구조와, 정규식의 함정이나 사용 중단된 명령의 취급을 보여주는 그림.이용한다이용한다이용한다이용한다이용한다이용한다이용한다전제로 한다에서 구성할 수 있다에서 구성할 수 있다권장되는 대응이용한다에서 구성할 수 있다이용한다원인이 될 수 있다사용은 비권장로그 운영 자동화(조사·아카이브)SupportsShouldProcess와 -WhatIf/-ConfirmSelect-StringGet-ChildItemExport-CsvConvertFrom-JsonConvertTo-JsonMove-ItemStart-TranscriptJSON 구성 파일작업 스케줄러의 무인 실행ScheduledTasks 모듈 cmdlet군정규 표현식의도하지 않은 정규식 매치Send-MailMessage cmdlet

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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개’라는 뜻이 되어, ERRORSERROR:에도 맞습니다. 반대로 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.csv
  • archive-targets.csv
  • archive-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.csvPath / 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.csvFullName / Length / LastWriteTime의 3열입니다.

"FullName","Length","LastWriteTime"
"C:\App\Logs\old\app-202401.log","10485760","2024/01/31 23:59:58"

archive-result.csvSource / Destination / Status / Message의 4열입니다. -Preview를 붙인 때는 StatusPreview가 되고, 실행한 때는 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

LastTaskResult0이 아니면 먼저 리포트 폴더의 error.txttranscript.txt를 봅니다.

다만 작업 자체가 시작되지 못한 경우에는 스크립트가 한 줄도 돌지 않으므로 리포트 폴더조차 만들어지지 않습니다. 그때는 작업을 마우스 오른쪽 단추로 눌러 연 속성이 아니라, 목록에서 작업을 골랐을 때 하단에 나오는 ‘기록’ 탭을 봅니다. 기록은 기본적으로 사용하지 않는 경우가 있으므로, 오른쪽 창의 ‘모든 작업 기록 사용’으로 켠 뒤, 마우스 오른쪽 단추의 ‘실행’으로 수동 시작해 확인합니다.

프로덕션 운영에서는 다음도 확인합니다.

  • 실행 계정에 로그 폴더 읽기 권한이 있다
  • 아카이브 경로에 쓰기 권한이 있다
  • pwsh.exe 경로가 잡혀 있다
  • 스크립트 실행 정책이나 서명 규칙에 맞다
  • 수동 실행과 작업 실행에서 같은 결과가 나온다
  • 실패 시 error.txt나 작업 기록을 확인할 수 있다

12. 자주 걸리는 지점

증상 원인 대처
로그를 찾지 못한다 LogPath가 잘못되었다 Test-PathGet-ChildItem으로 확인한다
CSV가 비게 된다 대상 기간에 해당 로그가 없다 Days를 넓혀 확인한다
일본어가 깨진다 로그 문자 코드가 예상과 다르다 입출력 문자 코드를 확인한다
작업에서는 동작하지 않는다 실행 계정이나 작업 폴더가 다르다 WorkingDirectory와 권한을 확인한다
아카이브 대상이 너무 많다 ArchiveDays가 너무 짧다 archive-targets.csv를 보고 조정한다
이동 위치가 예상과 다르다 상대 경로 유지 규칙을 이해하지 못했다 -PreviewDestination을 확인한다
프로덕션에서만 실패한다 권한·정책·잠긴 파일의 차이 error.txttranscript.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.csvDestination이 예상과 같았다
  • 출력 폴더에 날짜·시각이 붙은 증적이 남았다
  • 오류 시 error.txt가 남는 것을 확인했다
  • 작업 실행 계정의 권한을 확인했다
  • 실행 정책, 서명, 사내 규칙을 확인했다
  • 처음부터 삭제가 아니라, 먼저 이동으로 운영한다
  • 복구할 때 되돌릴 위치를 정해 두었다

15. 정리

PowerShell 응용이라고 해서 어려운 구문을 많이 쓸 필요는 없습니다. 실무에서 효과가 있는 것은, 이런 형태를 만들어 두는 것입니다.

  • 설정을 JSON으로 분리한다
  • 읽기 처리를 먼저 만든다
  • CSV와 JSON으로 증적을 남긴다
  • 변경 처리는 함수로 나눈다
  • -WhatIf에 해당하는 예행을 준비한다
  • transcript와 error.txt로 추적할 수 있게 한다
  • 수동 실행으로 확인한 뒤에 정기 실행한다

이번 스크립트는 로그 조사와 아카이브를 주제로 했지만, 이 접근은 다른 업무에도 쓸 수 있습니다.

  • 파일 정리
  • 장표 출력
  • CSV 집계
  • 배치 교체
  • 오래된 자산 재고 파악
  • 일일·월간 운영 확인

PowerShell은 한 줄 명령으로도 편리하지만, 업무에서 쓴다면 다음 순서를 지키는 편이 안전합니다.

본다 → 기록한다 → 예행한다 → 실행한다 → 증적을 남긴다

이 형태로 두면 PowerShell은 단순한 작업 단축 도구가 아니라, 운영을 안정시키기 위한 작은 업무 앱으로 쓸 수 있게 됩니다.

참고 링크

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

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 같은 증적을 보고 문제가 없다고 판단한 뒤에 삭제 자동화를 검토합니다. 복구할 때 되돌릴 위치도 미리 정해 두는 것이 중요합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기