PowerShell
Step 10. ★ 프로젝트: 파일 자동 분류 — 미리 보고 옮기고 검증하기
Level 0 — 컴퓨터 조작과 구조의 이해 | 난이도 ★★★☆☆ | 예상 소요 시간 3–4시간
선수 학습: Step 9. Windows PowerShell 5.1 또는 Windows의 PowerShell 7. 파일·변수·반복·조건·스크립트를 조립하는 첫 프로젝트입니다.
- 준비: 개인 실습용 PC와 텍스트 편집기. 작성할 스크립트 파일은 UTF-8 BOM으로 저장합니다.
- 대상: 아래 명령으로 만든 새 폴더만 사용합니다. 개인 다운로드·바탕화면을 바로 정리하지 않습니다.
- 순서: 원본 복사 보관 → 미리보기 → 실제 이동 → 내용·건수 확인 → 재실행. 동기화나 다른 프로그램이 파일을 쓰는 폴더에는 적용하지 않습니다.
확장자별로 파일을 정리하는 로봇을 만듭니다. “실제 파일을 다룬다”는 것이 개인 자료를 위험에 노출해야 한다는 뜻은 아닙니다. 한글·공백·대괄호·확장자 없음·복합 확장자를 가진 파일을 직접 만들어 같은 문제를 재현합니다. 파일 이름의 확장자를 분류할 뿐, PDF나 이미지 형식의 진위는 검사하지 않습니다.
1. 학습 목표
- 처리 순서를 먼저 설계하고 Join-Path로 경로를 조합한다.
- 최상위 파일만 선택하고 0·1개 결과도 배열로 센다.
- -WhatIf의 적용 범위와 전체 스크립트 미리보기를 구분한다.
- 충돌을 건너뛰고 실제 성공한 이동만 센다.
- 내용 보존·재실행·대상 변경·복원을 실제 파일로 확인한다.
2. 설계부터 하기
알고리즘과 분류 기준
- 대상의 실제 절대 경로와 파일 목록을 확인한다.
- 각 파일의 목적지와 충돌 여부를 계산한다. 목적지가 파일이나 링크이면 이동 전에 중단한다.
- 기본 실행은 계획만 출력한다. -Apply를 붙여야 폴더 생성과 파일 이동을 수행한다.
- 이동한 파일과 건수를 출력하고 보관 복사본과 SHA256을 비교한다.
이 프로젝트는 .pdf → EXT_PDF, .JPG → EXT_JPG, .tar.gz → EXT_GZ로 분류합니다. 마지막 확장자만 보며 확장자가 없으면 NOEXT입니다. 1~20자의 영문·숫자·밑줄·하이픈 이외의 확장자는 OTHER로 모읍니다. EXT_ 접두사는 CON 같은 Windows 예약 이름을 폴더 이름으로 그대로 쓰지 않도록 합니다. 분류 기준은 정한 규칙이며 파일의 실제 형식을 판정하지 않습니다.
미리보기의 한계
Move-Item -WhatIf는 해당 이동을 실행하지 않습니다. 다른 줄의 New-Item까지 막지는 않으며, 권한·잠금·디스크 용량 문제의 성공 여부를 보증하지 않습니다. 완성본은 별도 -Apply 스위치를 두어 기본 미리보기에서는 폴더도 만들지 않습니다. 미리보기와 적용 사이에 파일이 바뀔 수 있으므로 단독 실습 폴더에서 사용합니다.
환경 변수와 실제 폴더 위치
$env:USERPROFILE는 사용자 프로필 경로입니다. $env:USERPROFILEDownloads는 Downloads를 붙이는 표기가 아니라 전혀 다른 환경 변수 이름입니다. Join-Path $env:USERPROFILE Downloads로 조합할 수 있지만 Downloads·Desktop은 이동되거나 OneDrive로 리디렉션될 수 있어 실제 위치를 보장하지 않습니다. 오늘은 $HOME 아래 새 실습 폴더를 만듭니다.
3. 따라 하기
3-1. 실제 파일과 보관 복사본 준비
아래 전체를 터미널에서 실행하세요. incoming에 여섯 파일, backup에 여섯 복사본, existing 하위 폴더에 별도 파일 하나를 만듭니다. 파일 내용은 모두 실습용 텍스트입니다. 같은 폴더의 파일을 다시 만드는 부분만 반복하지 말고 처음부터 실행하여 새 폴더를 만드세요.
$classlab = Join-Path $HOME ('infosec-step10-' + [guid]::NewGuid().ToString('N'))
$src = Join-Path $classlab 'incoming'
$backup = Join-Path $classlab 'backup'
$preview = Join-Path $classlab 'preview'
New-Item -ItemType Directory -Path $src, $backup, $preview -ErrorAction Stop
Set-Location -LiteralPath $classlab
foreach ($name in @('report.pdf','photo.JPG','archive.tar.gz','README','내 문서.txt','notes[1].txt')) {
$path = Join-Path $src $name
Set-Content -LiteralPath $path -Value "Practice fixture: $name" -Encoding UTF8
Copy-Item -LiteralPath $path -Destination $backup -ErrorAction Stop
}
$nested = Join-Path $src 'existing'
New-Item -ItemType Directory -Path $nested -ErrorAction Stop
Set-Content -LiteralPath (Join-Path $nested 'nested.txt') -Value 'Leave this nested file alone.' -Encoding UTF8
$src
3-2. 파일 목록과 확장자 확인
$files = @(Get-ChildItem -LiteralPath $src -File)
$files.Count
$files | Select-Object Name, Extension, Length
개수는 6입니다. -File은 폴더를 제외하고, -Recurse를 쓰지 않았으므로 nested.txt는 포함하지 않습니다. 기본 목록은 숨김·시스템 파일도 제외합니다. 파일 순서를 가정하여 첫 항목이 PDF라고 단정하지 말고 Name과 Extension을 함께 읽으세요. @()는 빈 결과와 한 개 결과도 배열로 다룹니다.
3-3. 한 파일의 -WhatIf
Move-Item -LiteralPath $files[0].FullName -Destination (Join-Path $preview $files[0].Name) -WhatIf
@(Get-ChildItem -LiteralPath $src -File).Count
@(Get-ChildItem -LiteralPath $preview -File).Count
안내 메시지 뒤의 개수는 6과 0입니다. preview 폴더는 앞에서 이미 만들었고 이 명령은 파일을 옮기지 않았습니다. 대상에 존재하지 않는 폴더 이름만 적으면 Move-Item이 새 파일 이름으로 해석할 수도 있습니다. 기존 폴더와 최종 파일 이름까지 조합했습니다.
3-4. organize.ps1 저장
다음은 파일에 저장할 전체 내용입니다. 현재 $classlab 폴더에 organize.ps1로 UTF-8 BOM 저장하세요. incoming 안에 두지 않습니다. 처음의 param은 -Source로 경로를 받고 -Apply의 유무를 참·거짓으로 받습니다. 경로·링크 검사 부분은 이 프로젝트에 필요한 확인 절차로 함께 읽어 보세요.
param(
[Parameter(Mandatory=$true)][string]$Source,
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$root = Get-Item -LiteralPath $Source -Force
if (-not $root.PSIsContainer -or $root.PSProvider.Name -ne 'FileSystem') {
throw 'Source must be a filesystem directory.'
}
# Reject links in the source path before using lexical path boundaries.
$ancestor = $root
while ($null -ne $ancestor) {
if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'Use a local practice path without junctions or symbolic links.'
}
$ancestor = $ancestor.Parent
}
$src = $root.FullName
$files = @(Get-ChildItem -LiteralPath $src -File |
Where-Object { $_.FullName -ne $PSCommandPath })
$plan = @()
foreach ($f in $files) {
if ($f.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'A file link was found. No files have been moved.'
}
$ext = $f.Extension.TrimStart('.').ToUpperInvariant()
if ($ext -eq '') { $folderName = 'NOEXT' }
elseif ($ext -match '^[A-Z0-9_-]{1,20}$') { $folderName = 'EXT_' + $ext }
else { $folderName = 'OTHER' }
$dest = [IO.Path]::GetFullPath((Join-Path $src $folderName))
$target = [IO.Path]::GetFullPath((Join-Path $dest $f.Name))
if ((Split-Path -Parent $dest) -ne $src -or (Split-Path -Parent $target) -ne $dest) {
throw 'Destination is outside the intended directory.'
}
if (Test-Path -LiteralPath $dest) {
$item = Get-Item -LiteralPath $dest -Force
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'A category destination is a file or a link. No files have been moved.'
}
}
$action = 'Move'
if (Test-Path -LiteralPath $target) { $action = 'Skip' }
$plan += [pscustomobject]@{Name=$f.Name;From=$f.FullName;To=$target;Folder=$dest;Action=$action}
}
if (-not $Apply) {
$plan | Select-Object Name, Action, To | Format-Table -AutoSize | Out-String -Width 4096
"PREVIEW: $($files.Count) files; no files or folders changed."
return
}
$moved = 0
$skipped = 0
$usedFolders = @()
foreach ($row in $plan) {
# Recheck the category and collision just before each move.
if (Test-Path -LiteralPath $row.Folder) {
$item = Get-Item -LiteralPath $row.Folder -Force
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'Destination changed. Stopping; earlier moves remain applied.'
}
} else {
New-Item -ItemType Directory -Path $row.Folder | Out-Null
}
if (Test-Path -LiteralPath $row.To) {
$skipped++
"SKIP: $($row.Name)"
continue
}
Move-Item -LiteralPath $row.From -Destination $row.To -ErrorAction Stop
$moved++
$usedFolders += $row.Folder
"MOVED: $($row.From) -> $($row.To)"
}
$folderCount = @($usedFolders | Select-Object -Unique).Count
"SUMMARY: scanned=$($files.Count); moved=$moved; skipped=$skipped; folders=$folderCount"
코드를 읽는 순서
- Get-Item으로 대상의 실제 경로를 받고, 경로의 링크·접합점을 확인합니다. 자동 변경되는 링크 경로는 사용하지 않습니다.
- foreach에서 확장자를 ToUpperInvariant로 대문자화하고 Join-Path로 목적지를 만듭니다. LiteralPath는 대괄호 같은 문자를 와일드카드로 해석하지 않습니다.
- plan 배열의 각 [pscustomobject]는 이름·출발·도착·동작을 묶은 한 행입니다. 기본 실행은 이 표와 PREVIEW만 출력합니다.
- 적용 시 기존 대상 이름은 SKIP으로 남깁니다. -Force로 덮어쓰지 않습니다. 이동이 성공한 뒤에만 moved를 늘립니다.
- SUMMARY의 folders는 실제 이동에 사용한 서로 다른 폴더 수입니다. 모든 기존 폴더 수나 새로 생성한 폴더 수와는 다릅니다.
3-5. 현재 창 정책 확인과 미리보기
Step 9처럼 현재 Process만 변경하고 마지막에 원래 값으로 복원합니다. 조직 정책이 있으면 여기서 중단합니다.
$previousProcessPolicy = Get-ExecutionPolicy -Scope Process
if ((Get-ExecutionPolicy -Scope MachinePolicy) -ne 'Undefined' -or
(Get-ExecutionPolicy -Scope UserPolicy) -ne 'Undefined') {
throw 'Use an approved personal lab.'
}
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop
.\organize.ps1 -Source $src
6개의 예정 목적지와 PREVIEW가 나와야 합니다. 아직 incoming의 여섯 파일과 backup 복사본은 그대로이며 EXT_*·NOEXT 폴더는 만들어지지 않습니다.
3-6. 적용하고 내용 검증
.\organize.ps1 -Source $src -Apply
MOVED 여섯 줄과 SUMMARY: scanned=6; moved=6; skipped=0; folders=5를 확인하세요. 출력이 없다는 이유만으로 정상이라고 결론 내리지 않습니다. 다음 명령은 결과 파일 목록과 복사본의 SHA256을 비교합니다.
Get-ChildItem -LiteralPath $src -Recurse -File | Select-Object FullName
$checked = 0
foreach ($original in Get-ChildItem -LiteralPath $backup -File) {
$matches = @(Get-ChildItem -LiteralPath $src -Recurse -File |
Where-Object { $_.Name -ceq $original.Name })
if ($matches.Count -ne 1) { throw 'Expected exactly one destination file.' }
$beforeHash = (Get-FileHash -LiteralPath $original.FullName -Algorithm SHA256).Hash
$afterHash = (Get-FileHash -LiteralPath $matches[0].FullName -Algorithm SHA256).Hash
if ($beforeHash -ne $afterHash) { throw 'File content changed.' }
$checked++
}
"Verified unchanged file contents: $checked"
마지막에 Verified unchanged file contents: 6이 나오고 existing/nested.txt는 원래 위치에 남아야 합니다. 이 검사는 파일 바이트가 같은지 확인하며, 파일이 안전한 내용인지는 판정하지 않습니다.
3-7. 재실행
.\organize.ps1 -Source $src -Apply
이번에는 scanned=0; moved=0; skipped=0; folders=0입니다. 최상위 일반 파일이 이미 이동되었기 때문입니다. 숨김 파일·하위 파일·충돌 파일이 있으면 “폴더에 파일이 전혀 없다”는 뜻은 아닙니다. 같은 입력 상태를 반복 적용해 파일 배치가 더 바뀌지 않는 성질을 확인한 것입니다.
4. 미션 — 충돌과 대상 변경
미션 1. 같은 이름, 다른 내용
$newReport = Join-Path $src 'report.pdf'
if (Test-Path -LiteralPath $newReport) { throw 'The new source name already exists.' }
Set-Content -LiteralPath $newReport -Value 'A different incoming report.' -Encoding UTF8
.\organize.ps1 -Source $src
.\organize.ps1 -Source $src -Apply
미리보기에서 report.pdf는 Skip, 적용에서는 SKIP: report.pdf와 scanned=1; moved=0; skipped=1; folders=0을 확인합니다. incoming/report.pdf와 EXT_PDF/report.pdf가 둘 다 남고 서로 다른 내용이어야 합니다. 이 단계 이후에는 이름이 중복되므로 앞의 “이름당 하나” 해시 검증 블록을 그대로 반복하면 의도적으로 실패합니다. 두 파일의 전체 경로를 지정하여 각각 확인하세요.
미션 2. 다른 폴더에 재사용
실제 바탕화면을 이동시키는 대신 새 desktop-demo 폴더를 만듭니다. -Source만 바꾸어 같은 스크립트를 재사용합니다.
$second = Join-Path $classlab ('desktop-demo-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $second -ErrorAction Stop
Set-Content -LiteralPath (Join-Path $second 'a.txt') -Value 'Second folder A' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $second 'b.pdf') -Value 'Second folder B' -Encoding UTF8
.\organize.ps1 -Source $second
.\organize.ps1 -Source $second -Apply
두 파일 이동, 충돌 0, 사용한 폴더 2개를 확인하세요. 첫 incoming 폴더는 이 명령의 대상이 아닙니다.
5. 복원 연습과 완료 기준
한 파일의 원래 위치 복원
폴더째 옮기는 것은 원래 파일 배치를 되돌리는 방법이 아닙니다. 보관 복사본과 이동 기록을 보고 정확한 출발·도착 경로를 지정해야 합니다. 여기서는 우리가 만든 README 한 파일만 원래 위치로 옮기는 계획을 확인합니다.
$from = [IO.Path]::GetFullPath((Join-Path $src 'NOEXT\README'))
$to = [IO.Path]::GetFullPath((Join-Path $src 'README'))
if ((Split-Path -Parent $from) -ne (Join-Path $src 'NOEXT') -or
(Split-Path -Parent $to) -ne $src) { throw 'Unexpected restore path.' }
if (Test-Path -LiteralPath $to) { throw 'Restore name already exists.' }
Get-Item -LiteralPath $from | Select-Object FullName
Move-Item -LiteralPath $from -Destination $to -WhatIf
원래 위치에 파일이 없고 위의 두 절대 경로가 실습 폴더 안인지 확인한 뒤 적용합니다.
Move-Item -LiteralPath $from -Destination $to -ErrorAction Stop
Get-Item -LiteralPath $to | Select-Object FullName
(Get-FileHash -LiteralPath $to).Hash -eq (Get-FileHash -LiteralPath (Join-Path $backup 'README')).Hash
마지막 True는 보관본과 복원한 파일의 내용이 같다는 뜻입니다. 빈 NOEXT 폴더는 남아도 괜찮습니다. 오류가 난 부분까지 이미 이동된 파일은 자동 복원되지 않습니다. 실제 데이터 적용에는 별도 백업·이동 로그·개별 복원 절차가 필요하며, 이 실습 스크립트를 곧바로 예약 작업에 등록하지 않습니다.
현재 창 정책 복원
Set-ExecutionPolicy -Scope Process -ExecutionPolicy $previousProcessPolicy -Force -ErrorAction Stop
Get-ExecutionPolicy -List
연습문제와 해설
- -WhatIf 하나가 전체 스크립트를 막나요? → 해당 명령의 작업만 미리 봅니다. 완성본 기본 모드는 별도로 쓰기를 하지 않게 분기합니다.
- USERPROFILE이면 다운로드 경로가 보장되나요? → 프로필 경로일 뿐이며 다운로드 폴더는 이동될 수 있습니다.
- 확장자가 없으면? → 이 코드에서는 NOEXT입니다. 빈 확장자를 그대로 경로에 붙이면 목적지가 원래 폴더와 같아질 수도 있어 명시적 규칙이 필요합니다.
- 충돌 시 Move-Item은 스크립트 전체를 멈추나요? → 기본 오류는 계속 진행할 수 있습니다. 이 코드는 사전 Skip 검사와 -ErrorAction Stop을 사용하며 오류 문구의 언어를 고정해서 판단하지 않습니다.
- 보고서에서 이동 수를 언제 올리나요? → 성공한 Move-Item 뒤에서만 올립니다. 미리보기·건너뛰기는 실제 이동이 아닙니다.
완료 체크리스트
- [ ] 원본 여섯 파일과 보관 복사본을 만들었다.
- [ ] 기본 미리보기에서 파일·폴더가 바뀌지 않음을 확인했다.
- [ ] 실제 6건 이동·5개 분류 폴더와 내용 보존을 확인했다.
- [ ] 재실행 0건과 충돌 시 두 원본 유지·이동 0건을 확인했다.
- [ ] 두 번째 폴더를 분류하고 README 한 파일을 복원했다.
- [ ] 원래 Process 정책으로 복원했다.
6. 막혔을 때 확인할 것
- 경로 오류: $src를 출력하고 LiteralPath로 실제 항목을 확인합니다. 경로 문자열을 직접 이어 붙이지 않습니다.
- 잘못된 폴더 이름: 대소문자 변환과 점 제거가 각각 무엇을 하는지 확인합니다. 두 연산의 순서만 바뀌었다고 항상 결과가 달라지는 것은 아닙니다.
- 대상 이름이 파일·링크: 해당 항목을 덮어쓰거나 제거하지 말고 새 실습 폴더에서 다시 시작합니다.
- 잠금·권한 오류: 이미 이동한 파일이 있을 수 있습니다. MOVED 출력과 실제 파일을 비교하세요. -Force로 해결하려 하지 않습니다.
- 스크립트가 사라짐: 정리 대상 안에 보관했는지 확인합니다. 이 완성본은 자신의 $PSCommandPath를 제외하지만 다른 .ps1 파일까지 제외하는 것은 아닙니다.
7. 정리
자동화의 완성은 오류 없이 끝나는 데 있지 않습니다. 어떤 파일을 어디로 옮겼는지, 어떤 것은 남겼는지, 내용이 보존됐는지 설명할 수 있어야 합니다. 이 분류기는 동시 작업이나 악의적인 경로 변경을 방어하는 보안 도구가 아니며, 오류 시 모든 변경을 되돌리는 트랜잭션도 아닙니다. 단독 실습 폴더에서 알고리즘과 검증 습관을 익히는 프로젝트입니다.
실습 검증 기록
2026-09-10, Windows PowerShell 5.1.26100.9444와 PowerShell 7.6.5에서 게시 코드로 실제 파일을 생성·미리보기·이동·비교·복원했습니다. 여섯 파일의 내용 보존, 충돌·빈 폴더·한 파일·숨김 파일·링크·자기 스크립트 제외를 검증했습니다. 개인 다운로드·바탕화면 파일은 이동하지 않았으며 .ps1은 파일 작성 도구로 저장했습니다.
Microsoft: Move-Item · Get-ChildItem · Test-Path · Windows file naming
한 단계 더 가까이
수업을 마쳤나요?
본문의 완료 기준을 확인한 뒤 직접 표시하세요.