수정 이력(8건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
- 격리 스크립트 대장을 두 가지 고쳤습니다. 하나는 고정 이름 `quarantine-log.csv`로 내보내는 방식입니다. 격리는 부서별·분기별로 반복하므로, 두 번째 배치를 돌리는 순간 첫 번째 대장이 사라집니다. 옮긴 파일은 격리 위치에 있는데 어디서 왔는지 기록이 어디에도 없어 복구할 수 없습니다. 실행마다 `-$runId`를 붙인 별도 파일로 바꿨습니다. 다른 하나는 내보내기 위치입니다. 루프를 나온 뒤에 한꺼번에 쓰면, 중간에 세션이 끊겼을 때 이미 이동한 파일이 한 건도 기록되지 않습니다. `-Append`로 한 건씩 추가하도록 바꿨습니다. 아울러 권한을 좁히지 못한 건도 한 건씩 별도 CSV에 추가합니다.
- 격리 스크립트가 이동 원본·대상의 범위를 확인하지 않고 돌아가던 점을 고쳤습니다. 승인된 CSV는 사람이 편집하는 파일이라 `$root` 밖을 가리키는 행이 섞여 들어갈 수 있습니다. `$src.Substring($root.Length)`는 글자 수만 세어 앞을 잘라낼 뿐이라 예외를 내지 않고, 전혀 무관한 파일의 이동 대상을 만듭니다. 같은 이유로 `..`가 들어간 행은 `Join-Path`가 해석하지 않아 격리 위치 밖을 가리킬 수 있습니다. `Resolve-Path`로 실제 경로로 고친 뒤 `$root` 안쪽인지 확인하고, 이동 대상도 `GetFullPath`로 접은 다음 `$quarantine` 안쪽인지 확인하도록 했습니다. 비교용 경로 끝에 `\\`를 붙여 `D:\\share2`가 `D:\\share` 안쪽으로 판정되는 것도 막고 있습니다.
- 격리 스크립트 기록을 두 가지 고쳤습니다. 하나는 시험 실행입니다. `$dryRun`일 때도 본실행과 같은 `$moved`에 쌓아 같은 CSV로 내보내서, 본실행 뒤에 시험 실행만 해도 유효한 대장이 「존재하지 않는 이동 대상」 목록으로 덮였습니다. 시험 실행은 다른 파일로 나눴습니다. 다른 하나는 이동은 성공했지만 `icacls /reset`이 실패한 경우입니다. 이를 실패로 다루면 기록상 원래 위치에 있는 것이 되고, 다시 실행해도 원래 경로가 없어 집어내지 못합니다. 격리 위치에 넓은 권한 그대로, 어느 대장에도 없이 남습니다. 이동이 끝난 것은 반드시 대장에 올리고, 권한을 좁혔는지는 열로 두며, 좁히지 못한 분은 별도 CSV와 경고로 내도록 했습니다.
- 격리 절차를 두 가지 고쳤습니다. 하나는 권한입니다. 권하는 「같은 볼륨으로의 이동」에서는 원래 권한이 그대로 따라오므로, 격리 폴더를 좁혀 두어도 안의 파일은 원래대로 읽을 수 있습니다. 이동 직후 `icacls /reset`으로 상속 기본값으로 되돌리도록 했습니다. 다른 하나는 대장입니다. `Move-Item` 오류는 기본이 비종료라, 잠금이나 액세스 거부로 실패해도 「이동됨」이라고 쓰고 있었습니다. `-ErrorAction Stop`으로 잡아, 성공한 것만 대장에, 실패는 별도 CSV에 남깁니다. 시험 실행도 `-WhatIf`가 아니라 플래그로 나눠, 대장이 시험용과 본실행용으로 섞이지 않게 했습니다.
- 보안 설명자·DACL·ACE·SID·SDDL의 관계도와 설명을 추가했습니다. 산출물 4종의 열 구성 표, 지우기 전에 격리하는 절차(다른 볼륨으로 옮기면 ACL이 이동 대상의 상속으로 바뀐다는 주의를 포함), 오류 1219 개요, 소요 시간을 대표 폴더에서 실측해 외삽하는 절차를 추가했습니다. 실측값은 측정하지 않았으므로, 숫자가 아니라 측정 방법을 제시합니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174723)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「PowerShell로 파일 서버를 실사하기 ── 용량 조사와 액세스 권한(ACL) 감사」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/powershell-file-server-acl-audit/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22174723
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22174724
「파일 서버 여유 공간이 10% 아래로 떨어졌습니다」── 이 경고로 시작하는 일은 대개 우울합니다. 어디가 용량을 차지하는지 모릅니다. 아무도 손대지 않은 10년 전 폴더를 지워도 되는지 판단할 수 없습니다. 누가 어디에 액세스할 수 있는지, 퇴직자 권한이 남아 있지는 않은지 물어도 바로 답하지 못합니다. 탐색기에서 속성을 하나씩 열어 가며 조사하는 방식은, 수 테라바이트 공유 앞에서는 끝나지 않습니다.
이런 의뢰로 손을 움직일 때마다 느끼는 점은, 파일 서버 정리는 「삭제의 기술」이 아니라 「실사의 기술」이라는 것입니다. 숫자와 목록 없이 「지워도 될까요」라고 물어도 부서는 절대 고개를 끄덕이지 않습니다. 반대로 「이 폴더만 800GB이고, 그중 3년 이상 수정되지 않은 파일이 620GB」라는 표가 있으면 이야기는 단숨에 앞으로 갑니다.
이 글에서는 중소기업의 정보시스템·운영 담당자를 대상으로, PowerShell로 파일 서버의 용량과 액세스 권한(ACL)을 실사하고 CSV 리포트로 만들기까지의 실무 절차를 정리합니다. 방침은 처음부터 끝까지 「먼저 읽고, 변경은 마지막에, 백업과 -WhatIf를 붙인 채」입니다.
1. 먼저 결론
- 용량 조사의 기본형은 Get-ChildItem -Recurse + Measure-Object -Sum입니다. 처음부터 전체를 집계하지 말고, 1계층 폴더 단위로 용량이 큰 곳을 가늠합니다.12
- 액세스 거부는 삼키지 말고 기록합니다. -ErrorAction SilentlyContinue로 스캔을 멈추지 않는 대신, -ErrorVariable로 거부된 위치를 반드시 남겨 둡니다. 세지 못한 위치는 「0」이 아니라 「불명」입니다.3
- 오래된 파일 판별은 LastWriteTime을 기준으로 합니다. NTFS의 마지막 액세스 시각은 기본으로 갱신이 꺼져 있거나 시스템 관리로 맡기는 환경이 많아, 판단 재료로 믿을 수 없습니다.45
- 중복 파일은 Get-FileHash(기본 SHA256)로 「후보」를 뽑습니다. 먼저 크기로 좁힌 뒤 해시를 계산하는 것이 I/O를 아끼는 정석입니다.6
- ACL 실사는 Get-Acl의 Access 속성입니다. IdentityReference(누구), FileSystemRights(무엇을), IsInherited(상속인지 직접인지)를 CSV로 만들고, AreAccessRulesProtected로 상속이 끊긴 위치를 찾습니다.78
- 이 글에서 실사하는 대상은 NTFS 액세스 허용입니다. 공유(SMB)를 통한 실효 액세스는 공유 쪽 액세스 허용과 NTFS ACL의 조합으로 정해집니다. 공유 쪽은 Get-SmbShareAccess로 따로 목록화해, NTFS 대장과 짝으로 보세요.9
- CSV 리포트의 문자 코드는 명시합니다. Windows PowerShell 5.1의 Export-Csv 기본값은 ASCII라 한글이 깨지고, PowerShell 7의 기본값은 BOM 없는 UTF-8이라 Excel 기본 동작과 맞지 않는 경우가 있습니다.1011
- 변경(Set-Acl)은 실사와 부서 확인이 끝난 뒤에. icacls /save로 백업을 받고, -WhatIf로 대상을 확인한 뒤 적용합니다.1213
이 글의 절차를 한 바퀴 돌리면, 산출물로 다음 네 가지가 손에 남습니다. 완성된 모습을 먼저 보여 드립니다.
| 산출물 | 만드는 장 | 열 | 무엇을 읽을 수 있는가 |
|---|---|---|---|
| 폴더별 용량 순위 | 2장 | Folder / SizeGB / FileCount | 어느 폴더가 큰가. 「공유 전체에서 ◯GB, 그중 영업부가 ◯GB」라고 말할 때 필요한 숫자가 여기서 나옵니다 |
| old-files.csv | 3장 | FullName / LastWriteTime / SizeMB | 3년 이상 수정되지 않은 파일 목록. SizeMB 내림차순이므로, 위에서 수십 행만 봐도 「효과가 큰 용량」이 보입니다 |
| duplicate-candidates.csv | 3장 | Hash / Path | 내용이 같은 파일 묶음. Hash 열로 정렬하면 같은 내용끼리 모여 늘어섭니다 |
| acl-report.csv | 4장 | Path / Identity / Rights / Type / IsInherited / Inheritance / InheritanceBroken | 누가 어디에 어떤 권한을 갖는지의 대장. ACE 한 건이 한 행입니다 |
여기에 더해, 스캔하지 못한 위치를 기록하는 텍스트(denied-paths.txt, old-files-uninspected.txt, hash-uninspected.txt, acl-uninspected.txt)가 나옵니다. 이 파일들이 비어 있지 않다면, 위의 네 가지는 「구멍이 있는 리포트」입니다. 그 사실을 붙여서 전달하세요.
맨 앞에서 쓴 「이 폴더만 800GB이고, 그중 3년 이상 수정되지 않은 파일이 620GB」라는 한 문장은, 첫 번째와 두 번째를 맞대면 만들 수 있습니다. 뒷자리 숫자는 old-files.csv를 다시 읽어 합산하기만 하면 됩니다.
# old-files.csv에서 「3년 이상 수정되지 않은 파일의 합계 용량」을 낸다
$old = Import-Csv .\\old-files.csv
$sumMB = ($old | ForEach-Object { [double]$_.SizeMB } | Measure-Object -Sum).Sum
'3년 이상 수정 없음: {0} 건 / {1:N1} GB' -f $old.Count, ($sumMB / 1024)
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 23건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 용량 조사 ── 어디가 큰지를 숫자로 만들기
가장 먼저 할 일은 공유 루트 바로 아래 폴더별 합계 크기입니다. Get-ChildItem으로 파일을 재귀 열거하고, Measure-Object로 Length를 합산합니다.12
$root = 'D:\share' # 파일 서버에서 실행하는 전제. UNC 경유 주의는 6장
$denied = @()
# 1계층 폴더 단위로 집계한다 ── 전체를 한 번에 하기보다 「가늠」이 먼저
# 루트 자체의 열거 실패도 $denied에 들어가도록, 바깥 Get-ChildItem에도 같은 지정을 붙인다
$report = foreach ($dir in Get-ChildItem -LiteralPath $root -Directory `
-ErrorAction SilentlyContinue -ErrorVariable +denied) {
# 액세스 거부로 멈추지 않도록 SilentlyContinue, 다만 거부는 $denied에 쌓는다
# 열거는 파이프로 바로 Measure-Object에 흘린다(변수에 받으면 FileInfo 전체를
# 메모리에 붙들게 되어, 파일 수백만 건급 폴더에서 힘들어진다)
$stats = Get-ChildItem -LiteralPath $dir.FullName -Recurse -File `
-ErrorAction SilentlyContinue -ErrorVariable +denied |
Measure-Object -Property Length -Sum
[PSCustomObject]@{
Folder = $dir.Name
SizeGB = [math]::Round([double]$stats.Sum / 1GB, 2)
FileCount = $stats.Count
}
}
# 루트 바로 아래에 직접 놓인 파일도 한 행으로 센다(폴더 단위 집계에서 빠지기 때문)
$rootStats = Get-ChildItem -LiteralPath $root -File `
-ErrorAction SilentlyContinue -ErrorVariable +denied |
Measure-Object -Property Length -Sum
if ($rootStats.Count -gt 0) {
$report += [PSCustomObject]@{
Folder = '(루트 바로 아래)'
SizeGB = [math]::Round([double]$rootStats.Sum / 1GB, 2)
FileCount = $rootStats.Count
}
}
$report | Sort-Object SizeGB -Descending | Format-Table -AutoSize
# 「세지 못한 위치」를 반드시 남긴다 ── 여기가 비어 있지 않으면 집계는 불완전하다
$denied | ForEach-Object { $_.TargetObject } | Sort-Object -Unique |
Set-Content -Path .\denied-paths.txt
요점은 두 가지입니다. 첫째, -ErrorAction SilentlyContinue는 「오류를 못 본 척하는」 스위치가 아닙니다. 표시를 줄이고 계속할 뿐이며, 오류 자체는 발생합니다.3 그래서 -ErrorVariable로 받습니다. 변수 이름 앞에 +를 붙이면 덮어쓰기가 아니라 추가가 되므로, 루프 전체의 거부를 한 변수에 모을 수 있습니다.3 액세스가 거부된 폴더는 집계에서 빠져 있고, 리포트상으로는 실제보다 작게 보입니다. denied-paths.txt가 비어 있지 않다면 그 사실을 반드시 리포트에 붙이세요. 오류 처리 설계를 한 단계 더 파고든다면, 함께 공개한 「PowerShell의 오류 처리와 재실행 설계」가 참고가 됩니다.
둘째는 깊은 계층의 문제입니다. 오래 운영된 공유에는 경로가 260자(MAX_PATH)를 넘는 위치가 거의 반드시 있고, 도구에 따라서는 거기서 열거가 실패합니다.14 스캔 결과에 부자연스러운 구멍이 있으면 먼저 경로 길이를 의심하세요. 이 제한의 전체 그림과 대응은 「MAX_PATH와 Windows 경로·파일명의 함정」에 정리해 두었습니다.
3. 오래된 파일과 중복 후보 ── 삭제가 아니라 목록부터
3.1. 「3년 동안 손대지 않은 파일」을 목록으로 만들기
용량이 큰 곳을 알았다면, 다음은 「지울 후보」의 재료를 모으는 일입니다. 기준으로 쓰는 것은 LastWriteTime(마지막 수정 시각)입니다.
$cutoff = (Get-Date).AddYears(-3)
# 3년 이상 수정되지 않은 파일 목록. 삭제는 하지 않는다 ── 먼저 부서에 보여줄 재료를 만든다
# 열거에 실패한 위치도 나중에 공개하려고 -ErrorVariable로 기록한다
Get-ChildItem -LiteralPath $root -Recurse -File `
-ErrorAction SilentlyContinue -ErrorVariable oldEnumErrors |
Where-Object LastWriteTime -lt $cutoff |
Select-Object FullName, LastWriteTime,
@{ Name = 'SizeMB'; Expression = { [math]::Round($_.Length / 1MB, 2) } } |
Sort-Object SizeMB -Descending |
Export-Csv -Path .\old-files.csv -NoTypeInformation -Encoding utf8BOM # 5.1이라면 UTF8(둘 다 BOM 포함)
# 열거하지 못한 위치는 목록이 「완전한 것처럼 보이는」 일을 막기 위해 반드시 공개한다
$oldEnumErrors | ForEach-Object { $_.TargetObject } |
Set-Content -Path .\old-files-uninspected.txt
「마지막 액세스 시각(LastAccessTime)을 쓰면 『읽히지도 않았다』는 것까지 알 수 있지 않을까」라고 생각하고 싶어지지만, 여기는 함정입니다. NTFS는 마지막 액세스 시각 갱신이 성능에 영향을 주므로, fsutil behavior 명령과 레지스트리(NtfsDisableLastAccessUpdate)로 갱신의 켜짐/꺼짐이 제어되며4, Windows Vista 이후에는 기본으로 꺼져 있습니다(최근 Windows 10 이후는 시스템 관리이고, 서버에서는 꺼짐).5 즉 실제로는 열려 있는데도 시각이 오래된 채인 파일이 흔합니다. 실사 설명 자료에는 「마지막으로 내용이 바뀐 날」인 LastWriteTime을 쓰고, 그 뜻도 적어 두면 부서와의 대화가 꼬이지 않습니다.
3.2. 중복 파일의 「후보」를 꼽기
공유 폴더에는 「최종판」, 「최종판_수정」, 「복사본 〜」이 대량으로 쌓입니다. 내용이 같은지는 Get-FileHash로 판별할 수 있습니다. 기본 알고리즘은 SHA256이고, 해시 값이 일치하면 파일 내용도 같다고 판단할 수 있습니다.6
# 모든 파일의 해시 계산은 I/O가 무겁다. 먼저 「같은 크기의 파일」만으로 좁힌다
# 열거에 실패한 위치(액세스 거부 등)도 나중에 공개하려고 -ErrorVariable로 기록한다
$candidates = Get-ChildItem -LiteralPath $root -Recurse -File `
-ErrorAction SilentlyContinue -ErrorVariable enumErrors |
Group-Object -Property Length |
Where-Object { $_.Count -ge 2 -and [long]$_.Name -gt 0 } | # 크기 0은 제외
ForEach-Object { $_.Group }
# 좁힌 후보만 해시를 계산하고, 일치하는 그룹을 중복 「후보」로 출력한다
$candidates |
Get-FileHash -ErrorAction SilentlyContinue -ErrorVariable hashErrors | # 기본은 SHA256
Group-Object -Property Hash |
Where-Object Count -ge 2 |
ForEach-Object { $_.Group } |
Select-Object Hash, Path |
Export-Csv -Path .\duplicate-candidates.csv -NoTypeInformation -Encoding utf8BOM # 5.1이라면 UTF8
# 열거하지 못한 폴더와, 잠김·읽기 권한 없음 등으로 해시를
# 계산하지 못한 파일은 「조사하지 못함」으로 반드시 목록에 남긴다
# (조용히 빼면 중복 0건과 구별할 수 없다)
@($enumErrors) + @($hashErrors) | ForEach-Object { $_.TargetObject } |
Set-Content -Path .\hash-uninspected.txt
일부러 「후보」라고 부르는 이유는, 무엇을 남길지의 판단이 기술이 아니라 업무의 문제이기 때문입니다. 내용이 같아도 「부서 A의 원본」과 「부서 B의 참조 복사본」은 의미가 다를 수 있습니다. 기계적으로 지우지 말고, 목록을 들고 관계자와 이야기하는 데까지가 이 스크립트의 범위입니다. 해시라는 도구 자체의 성질(무엇이 보장되고 무엇이 보장되지 않는지)은 「해시 문자열로 방식을 가려내는 실무 절차」에서 정리했습니다.
3.3. 지우기 전에 격리하기 ── 「격리 → 관찰 → 삭제」 절차
목록을 부서에 확인받은 뒤에도 바로 삭제로 가지 않습니다. 7장의 판단표에 적은 「격리→관찰→삭제」의 내용은 다음 절차입니다.
- 격리 위치를 정합니다. 같은 서버에 날짜가 들어간 폴더를 하나 만듭니다. 가능하면 원본과 같은 볼륨으로 두세요(이유는 뒤에 나옵니다). 권한은 관리자와 담당자만으로 좁힙니다. 다만 폴더 권한만 좁혀서는 안의 파일에는 효과가 없습니다. 같은 볼륨 안 이동에서는 원래 권한이 그대로 따라오기 때문이며, 옮긴 각 파일을 상속 기본값으로 되돌리는 절차까지가 한 세트입니다(이것도 뒤에 나옵니다).
- 원래 계층을 유지한 채로 옮깁니다. 평평하게 옮기면 같은 이름 파일이 충돌하고, 되돌릴 때도 원래 위치를 알 수 없게 됩니다.
- 이동 대장을 남깁니다. 원래 경로와 이동 대상 경로의 대응표가 없으면 「그 파일이 어디로 갔나요?」에 답할 수 없습니다. 대장은 실제로 움직인 것만 적습니다. 시험 실행 결과와 움직이지 못한 것은 다른 파일로 나누세요. 섞으면 복구 때 존재하지 않는 위치를 찾게 됩니다.
- 관찰 기간을 정합니다. 「한 달」이 아니라 「분기 마감을 한 번 넘길 때까지」처럼 업무 주기에 맞추는 편이 실무적입니다. 문의가 오면 대장을 보고 되돌립니다.
- 기간이 지나면 삭제합니다. 삭제 직전에 대장을 관련 부서에 다시 공유합니다.
# 부서 확인이 끝난 목록(old-files.csv에서 대상 행만 남긴 것)을 격리 폴더로 옮긴다
$root = 'D:\share'
$quarantine = 'D:\quarantine\2026-07'
$list = Import-Csv .\old-files-approved.csv # 열은 old-files.csv와 같다
$dryRun = $true # 먼저 $true로 대상을 확인하고, 문제 없으면 $false로 본실행한다
$planned = @() # 시험 실행 결과. 본실행 대장과는 다른 파일로 낸다
$moved = @() # 실제로 이동한 것(건수 집계용. 대장 실체는 아래 파일)
$failed = @() # 이동하지 않은 것. 원래 위치에 있다
$needsAcl = @() # 이동은 됐지만 권한을 좁히지 못한 것
# 출력 파일은 실행마다 다른 이름으로 한다. 고정 이름이면 다음 배치를 돌리는 순간에
# 이전 대장이 덮여, 그때 옮긴 파일을 되돌릴 수 없게 된다
$runId = Get-Date -Format 'yyyyMMdd-HHmmss'
$ledger = ".\quarantine-log-$runId.csv" # 복구용 대장. 이동할 때마다 추가한다
$planFile = ".\quarantine-plan-$runId.csv"
$failFile = ".\quarantine-failed-$runId.csv"
$aclFile = ".\quarantine-needs-acl-$runId.csv"
$enc = 'utf8BOM' # 5.1이라면 'UTF8'
# 범위 판정에 쓰는 절대 경로. 끝을 \ 로 맞춰 두면,
# D:\share2 가 D:\share 안쪽으로 오판정되는 것을 막을 수 있다
$rootFull = [System.IO.Path]::GetFullPath($root).TrimEnd('\') + '\'
$quarantineFull = [System.IO.Path]::GetFullPath($quarantine).TrimEnd('\') + '\'
foreach ($row in $list) {
$src = $row.FullName
if (-not (Test-Path -LiteralPath $src)) { continue } # 이미 없는 것은 건너뛴다
# 이 CSV는 사람이 편집한 것이라 $root 밖을 가리키는 행이 섞일 수 있다.
# 아래 Substring은 그 경우에도 조용히 돌아가, 전혀 다른 위치의 파일을 격리한다.
# 실제 경로로 고친 뒤, 감사 대상 안쪽인지 확인하고 진행한다
$srcFull = (Resolve-Path -LiteralPath $src).ProviderPath
if (-not $srcFull.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) {
$failed += [PSCustomObject]@{ Source = $src; Destination = ''
Error = "감사 대상($root) 밖을 가리킵니다" }
continue
}
# 원래 계층을 유지한 채로 옮긴다(같은 이름 충돌과 「되돌릴 수 없음」을 막는다)
$relative = $srcFull.Substring($rootFull.Length)
# Join-Path 는 '..' 를 해석하지 않는다. GetFullPath 로 접은 뒤,
# 격리 위치 안쪽에 들어가는지 확인한다
$dest = [System.IO.Path]::GetFullPath((Join-Path $quarantine $relative))
if (-not $dest.StartsWith($quarantineFull, [StringComparison]::OrdinalIgnoreCase)) {
$failed += [PSCustomObject]@{ Source = $src; Destination = $dest
Error = "격리 위치($quarantine) 밖으로 나갑니다" }
continue
}
if ($dryRun) {
# 시험 실행 행을 $moved 에 섞으면 안 된다. 같은 대장으로 내면,
# 본실행 뒤에 시험 실행만 다시 해도 유효한 대장이
# 「존재하지 않는 이동 대상」 목록으로 덮인다
$planned += [PSCustomObject]@{ Source = $src; Destination = $dest }
continue
}
try {
New-Item -ItemType Directory -Path (Split-Path -Parent $dest) -Force -ErrorAction Stop | Out-Null
# Move-Item 오류는 기본이 비종료. -ErrorAction Stop 을 붙이지 않으면,
# 잠금·액세스 거부·충돌로 실패해도 다음 행으로 가고,
# 「원래 위치에 남아 있는데 대장에는 이동됨이라고 쓴다」가 된다
Move-Item -LiteralPath $srcFull -Destination $dest -ErrorAction Stop
}
catch {
# 여기서 실패한 파일은 움직이지 않았다. 원래 위치에 있다
$failed += [PSCustomObject]@{ Source = $src; Destination = $dest; Error = $_.Exception.Message }
continue
}
# 여기부터 파일은 $dest 에 있다. 이 뒤에서 무엇이 실패해도,
# 「$dest 에 있다」는 것은 대장에 반드시 남긴다. 여기를 failed 쪽으로 넣으면,
# 기록은 「원래 위치」로 읽히는데 실물은 격리 위치라는 최악의 어긋남이 되고,
# 다시 실행해도 Test-Path $src 가 거짓이라 다시는 집어내지 못한다.
#
# 그리고 루프를 나온 뒤에 한꺼번에 내보내면 안 된다. 중간에 전원이 나가면,
# 이미 이동한 파일이 한 건도 기록되지 않은 상태가 된다. 한 건씩 추가한다
$record = [PSCustomObject]@{ Source = $srcFull; Destination = $dest; MovedAt = Get-Date }
$record | Export-Csv -Path $ledger -NoTypeInformation -Encoding $enc -Append
$moved += $record
# 같은 볼륨 안 이동에서는 원래 권한이 그대로 따라온다(후술).
# 상속 기본값으로 되돌려, 격리 폴더 권한만 적용되는 상태로 만든다
try {
& icacls $dest /reset /q
if ($LASTEXITCODE -ne 0) { throw "icacls /reset 에 실패했습니다 (exit $LASTEXITCODE)" }
}
catch {
# 이동은 됐지만 권한을 좁히지 못했다. 격리 위치에 원래의 넓은 권한 그대로 있다.
# 이것도 한 건씩 추가한다
$aclRow = [PSCustomObject]@{ Destination = $dest; Error = $_.Exception.Message }
$aclRow | Export-Csv -Path $aclFile -NoTypeInformation -Encoding $enc -Append
$needsAcl += $aclRow
}
}
if ($dryRun) {
$planned | Export-Csv -Path $planFile -NoTypeInformation -Encoding $enc
Write-Host "시험 실행: $($planned.Count) 건이 대상입니다. 이동하지 않았습니다 ($planFile)"
return
}
$failed | Export-Csv -Path $failFile -NoTypeInformation -Encoding $enc
# 권한을 좁히지 못한 분은 조용히 흘리지 않는다. 여기가 0이 될 때까지 관찰 기간을 시작하지 않는다
if ($needsAcl.Count -gt 0) {
Write-Warning "권한을 좁히지 못한 격리 파일이 $($needsAcl.Count) 건 있습니다. $aclFile 을 보고 조치하세요"
}
Write-Host "이동 $($moved.Count) 건 / 실패 $($failed.Count) 건 / 권한 미처리 $($needsAcl.Count) 건"
Write-Host "대장: $ledger"
옮기기 전에, 그 경로가 정말 감사 대상 안쪽인지 확인하세요. 이 스크립트가 읽는 것은 old-files-approved.csv ── 사람이 열어 행을 지우고, 때로는 손으로 덧쓰는 파일입니다. 여기에 $root 밖을 가리키는 FullName이 한 행 섞여 들어가도 $src.Substring($root.Length)는 예외를 내지 않습니다. 글자 수만 세어 앞을 잘라낼 뿐이라, 전혀 무관한 파일의 이동 대상을 태연히 만듭니다. 오래된 CSV를 재사용했다, 경로를 잘못 복사했다, 대상을 「겸사겸사」 덧썼다 ── 모두 일어납니다. 같은 이유로 Join-Path는 ..를 해석하지 않으므로, 상대 구분자가 들어간 행은 격리 폴더 밖을 가리키는 $dest가 될 수 있습니다.
그래서 Resolve-Path로 실제 경로로 고친 뒤 $root 안쪽인지 확인하고, $dest도 GetFullPath로 접은 다음 $quarantine 안쪽에 들어가는지 봅니다. 비교용 경로 끝에 \\를 붙이는 것은 D:\\share2가 D:\\share 안쪽으로 판정되는 일을 막기 위해서입니다. 범위 밖 행은 이동하지 않고 $failed에 넣어, CSV 쪽을 고치게 합니다. 이 두 확인은, 격리 작업이 「대상을 정한 뒤에 움직인다」는 것을 코드 쪽에서도 담보하기 위한 것입니다.
스크립트 안에서 icacls /reset을 끼운 것은 권한 때문입니다. 공식 문서에 명시된 대로, 개체는 기본으로 이동 대상의 부모에서 권한을 상속하지만, 유일한 예외가 「같은 볼륨 안 다른 폴더로의 이동」이며, 이 경우에만 원래 권한이 유지됩니다.15
즉 1단계에서 권한 「원본과 같은 볼륨으로 옮기기」 형태는, 격리 폴더의 권한이 안의 파일에 적용되지 않는 형태이기도 합니다. 부서 공유 그룹에 열려 있던 파일은, 격리 폴더를 관리자만으로 좁혀 두어도 옮긴 뒤에도 그 그룹에서 그대로 읽을 수 있습니다. 「지운 것은 아닌데, 격리한 것으로 되어 있지 않다」는, 이 작업에서 가장 나쁜 실패가 여기서 납니다. icacls <path> /reset은 기본 상속 ACL로 ACL을 교체하므로, 이동 직후에 통과시키면 격리 폴더 권한만 적용되는 상태가 됩니다.13
다른 볼륨(다른 드라이브·다른 서버)으로 옮긴 경우에는 그 시점에 이동 대상 폴더의 권한을 상속합니다.15 이때는 /reset이 필요 없지만, 격리 폴더 자체의 권한을 먼저 좁혀 두는 것이 전제입니다. 어느 경로든 이동 후 몇 건을 Get-Acl로 뽑아, 의도한 권한인지 확인해 두면 확실합니다.
이동은 됐는데 /reset이 실패한 분의 처리에도 주의하세요. 이때 파일은 이미 격리 위치로 옮겨져 있고, 권한만 원래처럼 넓습니다. 이를 「실패」로 대장에서 빼면 기록상 원래 위치에 있는 것이 되고, 다시 실행해도 원래 경로가 없어 집어내지 못합니다. 격리 위치에, 넓은 권한 그대로, 누구의 대장에도 없이 남는 가장 나쁜 형태입니다. 위 스크립트가 이동이 끝난 시점에 대장에 올리고, 권한을 좁히지 못한 분을 별도 CSV로 내는 것은 이 때문입니다. 건수가 0이 아니면 그곳을 정리할 때까지 관찰 기간을 시작하지 마세요.
대장 파일 이름을 고정하지 마세요. 격리는 한 번으로 끝나지 않습니다. 부서별, 분기별로 여러 번 돕니다. 고정 이름 quarantine-log.csv로 내보내는 형태면 두 번째 배치를 돌리는 순간 첫 번째 대장이 사라집니다. 그때 옮긴 파일은 격리 위치에 있는데 어디서 왔는지 알 기록이 어디에도 없습니다 ── 복구할 수 없습니다. 위 스크립트가 실행마다 -$runId를 붙이는 것은 이 때문입니다. 되돌릴 때는 Import-Csv .\\quarantine-log-*.csv로 전부 읽습니다.
대장은 한 건씩 추가합니다. 루프를 나온 뒤에 한꺼번에 Export-Csv하는 형태면, 중간에 세션이 끊기거나 서버가 다시 시작되면 이미 이동한 파일이 한 건도 기록되지 않은 상태가 됩니다. 이동은 실제로 일어났으므로 이는 「작업 전」이 아니라 「기록만 없는」 상태입니다. -Append로 한 건씩 쓰면 어디서 멈춰도 거기까지는 되돌릴 수 있습니다. 파일을 다시 여는 만큼 느려지지만, 대장은 속도보다 확실함이 필요한 곳입니다.
스크립트가 내는 파일은 역할별로 나뉩니다($runId는 20260718-143052 같은 실행 시각).
| 파일 | 내용 | 언제 나오는가 |
|---|---|---|
quarantine-plan-$runId.csv |
앞으로 움직일 예정 목록 | 시험 실행($dryRun = $true)일 때 |
quarantine-log-$runId.csv |
실제로 움직인 것. 되돌릴 때 쓰는 대장. 한 건씩 추가됨 | 본실행일 때 |
quarantine-failed-$runId.csv |
움직이지 못한 것. 원래 위치에 있음 | 본실행일 때 |
quarantine-needs-acl-$runId.csv |
움직였지만 권한을 좁히지 못한 것 | 해당이 있을 때만 |
4. 액세스 권한(ACL) 실사 ── 누가 무엇을 할 수 있는지를 표로 만들기
용량과 나란히 또 하나의 실사 대상이 권한입니다. 여기서부터 용어가 한꺼번에 늘어나므로, 먼저 관계를 정리합니다.
flowchart TB
SD["보안 설명자<br/>파일/폴더마다 붙어 있습니다<br/>Get-Acl이 가져오는 것이 이것입니다"]
OWNER["소유자<br/>SID로 기록됩니다"]
DACL["DACL<br/>누구에게 무엇을 허용/거부하는지의 목록<br/>Get-Acl의 Access 속성으로 보이는 곳이 여기입니다"]
SACL["SACL<br/>감사 설정. 이 글에서는 다루지 않습니다"]
ACE1["ACE ── DACL의 한 행<br/>IdentityReference: 누구인지(SID 또는 해석된 이름)<br/>FileSystemRights: 무엇을 할 수 있는지<br/>AccessControlType: 허용인지 거부인지<br/>IsInherited: 상속인지 직접 부여인지"]
ACE2["ACE"]
ACE3["ACE ..."]
SDDL["SDDL<br/>이 설명자 전부를 한 줄 문자열로 나타낸 형식"]
SD --> OWNER
SD --> DACL
SD --> SACL
DACL --> ACE1
DACL --> ACE2
DACL --> ACE3
SD -. "같은 내용의 다른 표현" .- SDDL
말로 하면, 보안 설명자 안에 DACL이 있고, DACL 안에 ACE가 늘어서 있으며, ACE가 가리키는 상대는 SID라는 중첩입니다. 실무에서 「ACL」이라고 부르는 것은 거의 이 DACL을 가리킵니다. 뒤에 나오는 표와 CSV도 가장 안쪽 ACE를 한 행씩 쓴 것이라고 읽고 가세요. SDDL은 같은 내용의 문자열 표현이며, 실사 단계에서는 읽을 필요가 없습니다.
그 전제로 본론입니다. Get-Acl은 파일이나 폴더의 보안 설명자를 가져오며, Access 속성에서 DACL의 액세스 제어 항목(ACE) 목록을 읽을 수 있습니다.7
# 공유 루트에서 2계층분 폴더의 ACL을 실사한다
# 권한 설계의 「줄기」는 얕은 계층에 모여 있으므로, 먼저 거기를 확인한다
$aclErrors = @() # +가 붙은 -ErrorVariable은 추가이므로, 이전 실행분을 넘기지 않게 매번 초기화한다
$targets = @(Get-Item -LiteralPath $root `
-ErrorAction SilentlyContinue -ErrorVariable +aclErrors) +
@(Get-ChildItem -LiteralPath $root -Directory -Recurse -Depth 1 `
-ErrorAction SilentlyContinue -ErrorVariable +aclErrors)
$aclReport = foreach ($t in $targets) {
# 취득 실패(읽기 불가·스캔 중 삭제 등)도 나중에 공개할 수 있게 기록한다
$acl = Get-Acl -LiteralPath $t.FullName -ErrorAction SilentlyContinue -ErrorVariable +aclErrors
if (-not $acl) { continue }
if (@($acl.Access).Count -eq 0) {
# DACL이 비어 있는(명시 항목 0) 폴더도 행으로 남긴다. 상속 끊김 정보가 대장에서 사라지지 않게
[PSCustomObject]@{
Path = $t.FullName; Identity = '(항목 없음)'; Rights = $null; Type = $null
IsInherited = $null; Inheritance = $null
InheritanceBroken = $acl.AreAccessRulesProtected
}
continue
}
foreach ($ace in $acl.Access) {
[PSCustomObject]@{
Path = $t.FullName
Identity = $ace.IdentityReference # 누구(사용자/그룹)
Rights = $ace.FileSystemRights # 무엇을 할 수 있는지
Type = $ace.AccessControlType # Allow / Deny
IsInherited = $ace.IsInherited # 부모로부터의 상속인지, 직접 부여인지
# 같은 권한이라도 「이 폴더만」인지 「아래 파일/폴더에도 미치는지」를 구분한다
Inheritance = "$($ace.InheritanceFlags)/$($ace.PropagationFlags)"
InheritanceBroken = $acl.AreAccessRulesProtected # 이 폴더에서 상속을 끊었는지
}
}
}
$aclReport | Export-Csv -Path .\acl-report.csv -NoTypeInformation -Encoding utf8BOM # 5.1이라면 UTF8
# ACL을 가져오지 못한 대상도 대장의 구멍으로 반드시 공개한다(다른 조사와 같은 방식)
$aclErrors | ForEach-Object { $_.TargetObject } |
Set-Content -Path .\acl-uninspected.txt
이 CSV를 열면 다음 관점으로 필터해 이상을 찾습니다.
| 볼 열 | 이상의 징후 | 전형적인 배경 |
|---|---|---|
| Identity | 개인 사용자 이름이 직접 실려 있다 | 「일단 이 사람에게 권한을」의 누적. 이동·퇴직으로 썩는다 |
| Identity | S-1-5-21-... 같은 SID 그대로 표시된다 |
삭제된 계정의 권한이 남아 있다(정리 1순위) |
| IsInherited = False | 깊은 계층에 직접 부여 ACE가 점점이 있다 | 그때그때 개별 부여. 설계에서 벗어난 예외 |
| InheritanceBroken = True | 상속이 끊긴 폴더 | 과거의 「이 폴더만 보여 주기 싫다」 대응. 실사의 중점 대상 |
| Type = Deny | 거부 ACE가 있다 | 같은 조건이면 거부가 허용보다 우선한다. 다만 명시 ACE는 상속 ACE보다 먼저 평가되므로, 명시 허용이 상속 거부보다 이기는 순서도 있다. 영향은 ACE의 유래까지 보고, 최종적으로는 실효 액세스로 확인한다 |
IsInherited가 False인 항목은 「그 폴더에서 누군가가 손으로 더한 권한」이고, AreAccessRulesProtected가 True인 폴더는 「부모의 권한 변경이 닿지 않는 섬」입니다.78 권한 문제의 대부분은 이 둘에 모이므로, 먼저 여기서 대장으로 만듭니다. 한계도 하나 밝힙니다. 이 예는 -Depth 1의 얕은 스캔이라 그보다 깊은 계층에서 상속을 끊은 폴더는 대상에 들어가지 않고, 오류도 나지 않습니다. 상속 끊김을 전수 조사하고 싶다면 시간은 걸리지만 -Depth 제한을 빼고 같은 스크립트를 돌리거나, 의심되는 부서 폴더를 루트로 지정해 다시 실행하세요. 참고로 Get-Acl은 SDDL이라는 문자열 형식으로도 보안 설명자를 표시할 수 있지만7, 실사 목적이라면 Access 속성의 표로 충분합니다. SDDL 해석에 들어가는 것은 이 표로 설명할 수 없는 현상을 만난 뒤여도 늦지 않습니다.
실무적인 주의를 하나. CSV 문자 코드는 반드시 명시하세요. Windows PowerShell 5.1의 Export-Csv는 기본이 ASCII 출력이라 한글이 깨집니다.10 PowerShell 7계열 기본값은 utf8NoBOM(BOM 없는 UTF-8)이며11, Excel에서 그대로 열면 깨지는 환경이 있습니다. 부서에 넘기는 파일이라면 -Encoding UTF8BOM(5.1에서는 -Encoding UTF8)처럼 BOM 있는 UTF-8로 맞추는 편이 무난합니다.
5. 변경은 어떻게 하는가 ── icacls와 Set-Acl의 역할 분담, 백업과 -WhatIf
실사와 부서 확인이 끝난 뒤에야 변경 단계입니다. 여기서 도구가 두 계통 있습니다.
| 용도 | 도구 | 이유 |
|---|---|---|
| 조사·리포트 | Get-Acl | 결과가 객체라 필터·CSV화가 쉽습니다7 |
| 변경 전 백업 | icacls /save | ACL을 파일에 저장하고 /restore로 그대로 되돌릴 수 있습니다13 |
| 정형적인 권한 부여·삭제 | icacls /grant, /remove | 한 줄로 끝나고, /t로 재귀 적용, 상속 재활성화(/inheritancelevel)도 가능합니다13 |
| 복잡한 조건부 변경 | Set-Acl | 규칙을 코드로 조립할 수 있습니다. -WhatIf 대응12 |
| 깨진 ACL 재구성 | icacls /reset | 기본 상속 ACL로 교체합니다(영향이 큽니다. 마지막 수단)13 |
어느 쪽을 쓰든 순서는 고정입니다. 백업 → -WhatIf(또는 대상 확인) → 적용 → 사후 확인.
# 1. 변경 전에 ACL을 파일로 저장한다(/t로 아래 전부, /c로 오류가 있어도 계속)
icacls "D:\share\sales" /save "C:\aclbackup\sales-acl.txt" /t /c
# 백업이 안 되었는데 변경으로 가지 않는다. icacls 성패는 종료 코드로 확인한다
if ($LASTEXITCODE -ne 0) {
throw "ACL 백업에 실패했습니다 (icacls ExitCode=$LASTEXITCODE). 변경 작업을 중지합니다"
}
# 2. Set-Acl로 변경하는 경우: 가져오기 → 규칙 편집 → 적용의 3단계
$path = 'D:\share\sales\estimate'
$acl = Get-Acl -LiteralPath $path
# 영업 그룹에 변경 권한을 부여하는 규칙(하위 폴더·파일로 상속)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
'CONTOSO\SalesTeam', 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
# 추가 부여는 AddAccessRule로 한다. SetAccessRule은 같은 사용자/그룹의 기존 Allow 규칙을
# 바꾸므로, 그 그룹에 이미 있던 세분 권한이 조용히 사라질 수 있다
$acl.AddAccessRule($rule)
# 3. 먼저 -WhatIf로 「어디에 적용되는지」를 확인한 뒤 본실행으로 간다
Set-Acl -LiteralPath $path -AclObject $acl -WhatIf
# 문제 없으면: Set-Acl -LiteralPath $path -AclObject $acl
Set-Acl은 「Get-Acl로 가져온 보안 설명자를 모델로 적용하는」 동작이라12, 가져오기와 적용 사이에 대상을 잘못 고르면 의도하지 않은 ACL 통째 교체가 됩니다. 위처럼 가져오기 경로와 적용 경로를 같은 변수로 두고, 적용 전에 -WhatIf를 끼우는 수수한 습관이 사고를 막습니다. 상속을 끊는 조작(SetAccessRuleProtection)도 같은 틀에서 할 수 있지만12, 4장에서 본 대로 상속 끊김은 장래 관리 비용이 되므로, 신설은 정말 필요한 위치로 한정하세요.
이 일련의 작업을 정기 실행 스크립트로 키울 때의 설계(SupportsShouldProcess, 증적 남기는 법, 작업 스케줄러 등록)는 「PowerShell 스크립트 응용 ── 로그 조사·아카이브·리포트화를 안전하게 자동화하기」에서 다룬 형을 그대로 쓸 수 있습니다.
6. 어디서 실행하는가 ── UNC 경유 스캔과 실행 계정
마지막으로, 의외로 결과를 좌우하는 「실행 위치와 실행자」 이야기입니다.
- 대규모 스캔은 서버에서 실행하는 것이 원칙입니다. UNC 경로(
\\\\fs01\\share) 경유의 재귀 열거는 파일 하나마다 메타데이터 취득이 네트워크 왕복이 되므로, 수십만 파일 규모에서는 소요 시간이 자릿수로 달라집니다. 관리 단말에서 하고 싶다면 PowerShell Remoting으로 스크립트를 서버 쪽에서 돌리고, 결과 CSV만 회수하는 구성이 실무적입니다. 방법은 함께 공개한 「PowerShell Remoting(WinRM) 입문」을 보세요. - 결과는 실행 계정 권한의 스냅샷입니다. 액세스하지 못하는 위치는 열거에서도 Get-Acl에서도 빠집니다. 조사는 관리자 권한 계정으로 하고, 그래도 거부된 위치(2장의 denied-paths.txt)를 「조사하지 못한 위치」로 보고서에 넣습니다.
- UNC 경유로 실행할 때는 자격 증명과 세션의 함정에도 주의가 필요합니다. 드라이브 문자는 로그온 세션 단위라, 대화형 로그온 중에 할당한 Z:는 작업 스케줄러 작업에서는 보이지 않습니다. 또한 오류 1219(
ERROR_SESSION_CREDENTIAL_CONFLICT)는 같은 서버(또는 공유)에 대해 같은 사용자가 여러 사용자 이름을 써서 연결하는 것은 허용되지 않는다는 사양의 오류입니다.16 「조사용 공유만 관리자 계정으로, 나머지는 자기 계정으로」처럼 같은 파일 서버에 두 종류의 자격 증명으로 붙으려 하면 두 번째가 이것으로 실패합니다. UNC 경유로 조사한다면 돌리기 전에 기존 연결을 하나의 자격 증명으로 맞춰 두세요. 함정 전체 그림은 「네트워크 드라이브와 UNC 경로의 함정」을 참고하세요. - 소요 시간은 「남의 숫자」가 아니라 자기 환경에서 잽니다. 디스크 종류, 파일 수, 바이러스 백신 실시간 검사, 계층 깊이에 따라 결과가 크게 달라지므로, 전체를 돌리기 전에 대표적인 부서 폴더를 하나 골라 실측하고, 공유 전체 파일 수로 외삽하는 것이 확실합니다. 같은 측정을 UNC 경유와 서버에서 둘 다 하면 「서버에서 돌려야 하는지」도 숫자로 제시할 수 있습니다.
# 대표적인 부서 폴더를 하나 골라, 스캔 속도를 실측한 뒤 전체 견적을 세운다
# (파일이 한 건도 없는 폴더를 고르면 마지막 나눗셈이 실패하므로, 실제 데이터가 있는 위치를 고른다)
$sample = 'D:\share\sales'
$start = Get-Date
$count = (Get-ChildItem -LiteralPath $sample -Recurse -File `
-ErrorAction SilentlyContinue | Measure-Object).Count
$sec = ((Get-Date) - $start).TotalSeconds
'{0} 파일을 {1:N1} 초에 열거 → 1만 파일당 {2:N1} 초' -f `
$count, $sec, ($sec / $count * 10000)
이 견적이 유효한 것은 열거가 중심인 처리(2장의 용량 집계, 3장의 오래된 파일 목록, 4장의 ACL 취득)입니다. 3장의 해시 계산만은 파일 수가 아니라 총 바이트 수로 시간이 정해지므로 별도로 견적하세요.
7. 실무의 정석(판단표)
| 논점 | 선택지 | 판단 기준 |
|---|---|---|
| 용량 조사 범위 | 전체 일괄 / 1계층마다 | 먼저 얕은 계층에서 가늠하고, 큰 곳만 깊게 봅니다12 |
| 오류 처리 | 중지 / 무시 / 기록하고 계속 | SilentlyContinue + ErrorVariable로 「계속하면서 전부 기록」. 거부 지대는 리포트에 명시3 |
| 오래된 파일 기준 | LastAccessTime / LastWriteTime | 액세스 시각은 갱신이 꺼진 환경이 많아 채택하지 않습니다. 수정 시각+기간으로 부서와 합의45 |
| 중복 검출 | 모든 파일 해시 / 크기로 좁힌 뒤 해시 | 해시는 I/O가 무겁습니다. 같은 크기 그룹만 계산합니다6 |
| ACL 조사 깊이 | 모든 폴더 / 얕은 계층+상속이 끊긴 위치 | 상속이 살아 있으면 부모를 보면 압니다. IsInherited=False와 Protected=True를 중점으로78 |
| 권한 변경 도구 | icacls / Set-Acl | 정형 변경과 백업은 icacls. 조건 분기를 동반한 일괄 처리는 Set-Acl+-WhatIf1312 |
| 삭제 실행 | 즉시 삭제 / 격리→관찰→삭제 | 목록을 부서 확인한 뒤, 먼저 격리 폴더로 옮기고, 일정 기간 문제가 없으면 삭제 |
8. 정리
- 파일 서버 정리는 삭제가 아니라 실사부터입니다. 1계층 용량 순위, 오래된 파일 목록, 중복 후보, ACL 대장의 4점 세트를 CSV로 만들고, 그것을 재료로 부서와 이야기합니다.
- 스캔은 SilentlyContinue로 멈추지 않는 대신, ErrorVariable로 거부된 위치를 반드시 기록합니다. 세지 못한 위치는 「0」이 아니라 「불명」입니다.
- 오래됨 판별은 LastWriteTime 기준입니다. NTFS의 마지막 액세스 시각은 갱신이 꺼진 환경이 많아 믿을 수 없습니다.
- 중복은 크기로 좁힌 뒤 Get-FileHash(기본 SHA256). 일치는 「후보」이며, 남길지 판단은 업무 측과 합니다.
- ACL은 Get-Acl의 Access를 CSV로 만들고, 개인 직접 부여·SID만 있는 항목·상속 끊김(AreAccessRulesProtected)을 중점 확인합니다. SDDL까지 깊이 들어갈 필요는 없습니다.
- 변경은 icacls /save로 백업 → -WhatIf나 대상 확인 → 적용 → 사후 확인 순서입니다. 대규모 스캔은 서버에서(또는 Remoting 경유로), 관리자 권한으로 실행합니다.
관련 글
- 네트워크 드라이브와 UNC 경로의 함정 ── 업무 앱에서 파일 서버(공유 폴더)를 다루는 실무
- MAX_PATH와 Windows 경로·파일명의 함정 ── 260자 제한, 예약 이름, 끝의 점, 대소문자
- PowerShell Remoting(WinRM) 입문 ── 여러 대의 Windows를 일괄 관리하기
- PowerShell의 오류 처리와 재실행 설계
- PowerShell 스크립트 응용 ── 로그 조사·아카이브·리포트화를 안전하게 자동화하기
- 해시 문자열로 방식을 가려내는 실무 절차
관련 상담 영역
합동회사 고무라소프트에서는 파일 서버의 용량·권한 실사 스크립트 작성 지원, 권한 설계 재검토에 따른 조사, 정기 리포트 자동화(작업 스케줄러 운영까지)를 다루고 있습니다. 「먼저 현황을 숫자로 만들고 싶다」는 단계부터의 상담도 환영합니다.
참고 링크
-
Microsoft Learn, Get-ChildItem. Get-ChildItem으로 항목을 열거하는 것, -Recurse로 재귀하고 -Depth로 깊이를 제한하는 것, -Recurse 사용 시 와일드카드 해석을 피하려고 -LiteralPath로 대상을 지정하는 것이 권장된다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Measure-Object. Measure-Object가 -Property Length -Sum 등으로 파일 크기의 합계·최대·최소·평균을 계산할 수 있다는 것, Get-ChildItem과 조합한 디렉터리 안 파일 집계 예에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, about_CommonParameters. -ErrorAction SilentlyContinue가 오류 표시를 억제한 채 실행을 계속한다는 것, -ErrorVariable로 지정 변수에 오류 레코드가 담긴다는 것, 변수 이름 앞에 +를 붙이면 덮어쓰기가 아니라 추가가 된다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, fsutil behavior. NTFS의 마지막 액세스 시각(Last Access Time) 갱신의 켜짐/꺼짐이 fsutil behavior의 disablelastaccess 매개 변수와 NtfsDisableLastAccessUpdate 레지스트리 값으로 제어된다는 것, 갱신 끄기가 파일/디렉터리 액세스 속도 향상을 위해 마련되어 있다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, [MS-FSA]: Appendix A: Product Behavior. Windows Vista 이후 NTFS/ReFS의 마지막 액세스 시각 갱신이 기본으로 꺼져 있다는 것, Windows 10 v1803 이후는 시스템 관리가 되며, 서버 시스템에서는 마지막 액세스 시각 갱신이 항상 꺼진다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Get-FileHash. Get-FileHash의 기본 알고리즘이 SHA256이라는 것, 해시 값이 일치하는 두 파일은 내용도 같다고 판단할 수 있다는 것, 파일 이름이나 확장자를 바꿔도 해시 값은 바뀌지 않는다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Get-Acl. Get-Acl이 파일이나 리소스의 보안 설명자를 가져온다는 것, 기본으로 DACL의 액세스 제어 항목 목록(Access)을 표시한다는 것, SDDL 형식(Sddl 속성)으로도 가져올 수 있다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, ObjectSecurity.AreAccessRulesProtected Property. AreAccessRulesProtected 속성이 보안 설명자의 DACL이 보호되어 있는지(부모로부터 상속을 받지 않는지)를 반환한다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, about_Character_Encoding. Windows PowerShell(5.1)에서는 cmdlet마다 기본 인코딩이 통일되어 있지 않고 Export-Csv는 ASCII로 파일을 만든다는 것, PowerShell 6 이후 기본값이 utf8NoBOM으로 통일되었다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Export-Csv. Export-Csv가 객체의 각 속성을 열로 한 CSV 파일을 만든다는 것, PowerShell 7계열에서 -Encoding 기본값이 UTF8NoBOM이며 UTF8BOM 등을 명시할 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Set-Acl. Set-Acl이 AclObject로 넘긴 보안 설명자를 모델로 대상 ACL을 바꾼다는 것, -WhatIf/-Confirm에 대응한다는 것, FileSystemAccessRule을 만들어 SetAccessRule()로 추가하는 절차, SetAccessRuleProtection()으로 상속을 끄는 예(기존 상속 규칙 유지 선택 포함)에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, icacls.
/reset이 「대상 파일의 ACL을 기본 상속 ACL로 교체한다」는 것,/q가 성공 메시지를 억제한다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn, Maximum Path Length Limitation. Windows API의 경로 길이 상한 MAX_PATH가 260자라는 것, 레지스트리의 LongPathsEnabled와 앱 쪽 longPathAware 선언이 둘 다 갖춰진 경우에만 많은 Win32 함수에서 제한이 완화된다는 점에 대해. ↩
-
Microsoft Learn, Permissions on copying and moving files and folders. 개체가 기본으로 이동 대상·생성 대상의 부모에서 액세스 허용을 상속한다는 것, 유일한 예외가 같은 볼륨 안 다른 폴더로의 이동이며 그때는 원래 액세스 허용이 유지된다는 것, 다른 볼륨으로 복사 또는 이동하면 이동 대상 폴더의 액세스 허용을 상속한다는 점에 대해. ↩ ↩2
-
Microsoft Learn, System Error Codes (1000-1299). 오류 1219(ERROR_SESSION_CREDENTIAL_CONFLICT)가 「동일 사용자가 여러 사용자 이름을 써서 같은 서버 또는 공유 리소스에 여러 연결을 하는 것은 허용되지 않는다」는 오류라는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
PowerShell에서 자격 정보를 안전하게 다루기 ── 스크립트에서 평문 비밀번호를 없애기
PowerShell 스크립트의 평문 비밀번호를 안전한 보관으로 옮기는 절차를 정리합니다. SecureString의 실체와 한계, Export-Clixml을 통한 DPAPI 저장 구조, SecretManagement/SecretStore를 쓰는 지...
PowerShell Remoting(WinRM) 입문 ── 여러 대의 Windows를 일괄 관리하기
PowerShell Remoting(WinRM)으로 여러 대의 Windows를 일괄 관리하는 입문. 동작 원리와 포트 5985/5986, Enable-PSRemoting으로 일어나는 일, 워크그룹의 TrustedHosts, Invoke-Comma...
PowerShell의 실행 정책과 스크립트 서명 ── 'Bypass로 덮어 버리는' 운영에서 벗어나는 실무 가이드
PowerShell의 실행 정책은 '보안 경계가 아니라 안전장치'입니다. RemoteSigned 등의 차이, 범위의 우선순위, Mark of the Web과 Unblock-File, 스크립트 서명, 사내 배포의 현실적인 운영까지 정리합니다.
PowerShell 보안 강화 ── 로그·AMSI·언어 모드·JEA
PowerShell을 금지하지 않고 안전하게 쓰기 위한 실무를 정리합니다. 스크립트 블록 로그와 트랜스크립션 활성화, AMSI와 구버전 사용 중지, 언어 모드로 제한하기, JEA로 권한 위임하기까지 설명합니다.
PowerShell 스크립트가 느릴 때 볼 곳 ── 배열·파이프라인·매칭의 핵심
PowerShell 스크립트가 느려지는 대표적인 원인을 정리합니다. 배열의 +=가 O(n^2)가 되는 이유, 파이프라인과 foreach의 차이, 매칭의 해시 테이블화, 파일 I/O 개선, 올바른 측정 방법까지 실무 관점에서 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- PowerShell로 폴더별 합계 크기를 알아보려면 어떻게 하나요?
- Get-ChildItem에 -Recurse를 붙여 파일을 열거하고, Measure-Object의 -Property Length -Sum으로 합산합니다. 공유 전체를 한 번에 집계하기보다, 먼저 공유 루트 바로 아래의 1계층 폴더 단위로 집계해 용량이 큰 곳을 가늠하는 편이 실무적입니다. 이때 -ErrorAction SilentlyContinue와 -ErrorVariable을 함께 써서, 액세스 거부 때문에 세지 못한 위치를 반드시 기록하세요. 오류를 조용히 삼키면 집계에서 빠진 위치가 '크기 0'으로 보입니다.
- 오래된 파일을 판별할 때 LastAccessTime(마지막 액세스 시각)을 써도 되나요?
- 권장하지 않습니다. NTFS는 성능 때문에 마지막 액세스 시각 갱신이 기본으로 꺼져 있거나 시스템 관리로 맡기는 환경이 많아서, 실제로 파일을 읽었는데도 시각이 갱신되지 않는 경우가 있습니다. 실사 기준으로는 LastWriteTime(마지막 수정 시각)을 쓰고, '마지막으로 내용이 바뀐 뒤 지난 기간'으로 부서에 제시하는 편이 안전합니다. 보관(아카이브) 여부는 기계적으로 정하지 말고, 목록을 관계자에게 확인받은 뒤에 진행하세요.
- Get-Acl로 무엇을 알 수 있나요? SDDL을 읽어야 하나요?
- Get-Acl은 파일이나 폴더의 보안 설명자를 가져오며, Access 속성에서 '누구인지(IdentityReference)', '무엇을 할 수 있는지(FileSystemRights)', '허용인지 거부인지(AccessControlType)', '상속인지 직접 부여인지(IsInherited)'를 읽을 수 있습니다. AreAccessRulesProtected를 보면 그 폴더에서 상속이 끊겨 있는지도 알 수 있습니다. SDDL이라는 문자열 형식도 있지만, 실사 목적이라면 Access 속성을 CSV로 내보내는 편이 관계자와 공유하기 쉽고, SDDL까지 깊이 파고들 필요는 없습니다.
- ACL 변경은 PowerShell의 Set-Acl과 icacls 중 어느 쪽으로 해야 하나요?
- 조사와 리포트는 객체로 다룰 수 있는 Get-Acl, 변경 실무는 icacls를 첫 후보로 두는 것이 역할 분담의 기준입니다. icacls는 /save로 ACL을 파일에 저장하고 /restore로 그대로 되돌릴 수 있어, 변경 전 백업과 롤백이 간단합니다. Set-Acl을 쓸 때는 Get-Acl로 가져오기 → 규칙 편집 → 적용의 세 단계로 진행하며, -WhatIf로 대상을 확인한 뒤 실행합니다. 어느 쪽이든 실사와 관련 부서 확인이 끝난 뒤에 변경으로 넘어가는 것이 원칙입니다.
- 파일 서버 조사는 UNC 경로를 통해 실행해도 되나요?
- 소량이라면 문제없습니다. 다만 수십만 개 파일을 전부 스캔하면 네트워크 경유는 크게 느려집니다. 가능하면 파일 서버에서 직접(또는 PowerShell Remoting으로) 실행하고, 결과 CSV만 가져오는 구성이 효율적입니다. 스캔 결과는 실행 계정의 권한에 좌우됩니다. 액세스하지 못하는 폴더는 열거에서 빠지므로, 관리자 계정으로 실행한 뒤에도 거부된 위치를 기록해 '조사하지 못한 위치'로 보고서에 넣으세요.