Files
sharepoint/Verify-SPOBackup.ps1
2025-09-15 13:37:28 +09:00

402 lines
17 KiB
PowerShell
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ==================================================
# SharePoint 백업 검증 및 복구 스크립트 (최종판)
# ==================================================
<#
.SYNOPSIS
로컬 백업과 SharePoint Online 원본을 비교하고, 누락된 파일을 다운로드하여 복구.
.DESCRIPTION
SharePoint Online 사이트와 로컬 백업 폴더를 스캔하여 파일 목록/크기 비교.
검증 후, -DownloadMissingFiles 스위치를 사용하면 누락된 파일만 PoshRSJob을 통해
병렬로 다운로드하여 백업 보완.
.PARAMETER SiteUrl
검증 및 복구할 원본 SharePoint Online 사이트 URL.
.PARAMETER BackupPath
(선택) 검증할 로컬 백업 폴더 경로. 지정하지 않으면 폴더 선택창이 팝업.
.PARAMETER DownloadMissingFiles
(선택) 이 스위치를 사용하면, 검증 후 로컬에 누락된 파일을 서버에서 다운.
**기존 백업시 다운로드 실패 파일이 존재해야함.
.PARAMETER ThrottleLimit
(선택) 누락 파일 다운로드 시 병렬 처리 개수 (기본값 1). 대용량 파일 복구 시 1로 사용할 것!
.NOTES
실행: PowerShell 7 이상 권장.
모듈: Microsoft.Graph, PoshRSJob 필요.
#>
param(
[Parameter(Mandatory = $true)]
[string]$SiteUrl,
[Parameter(Mandatory = $false)]
[ValidateScript({
if (Test-Path $_ -PathType Container) {
return $true
}
else {
throw "지정한 경로 '$_'를 찾을 수 없거나 폴더가 아닙니다."
}
})]
[string]$BackupPath,
[Parameter(Mandatory = $false)]
[switch]$DownloadMissingFiles,
[Parameter(Mandatory = $false)]
[int]$ThrottleLimit = 1
)
# ==================================================
# region: 도우미 함수
# ==================================================
function Select-FolderDialog {
param(
[string]$Title = "폴더 선택",
[string]$InitialDirectory = [Environment]::GetFolderPath('Desktop')
)
try {
Add-Type -AssemblyName System.Windows.Forms
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = $Title
$dlg.SelectedPath = $InitialDirectory
if ($dlg.ShowDialog((New-Object System.Windows.Forms.NativeWindow))) {
return $dlg.SelectedPath
}
}
catch {
return Read-Host "$Title"
}
return $null
}
function Format-FileSize {
param([long]$bytes)
$suf = "B", "KB", "MB", "GB", "TB", "PB"
if ($bytes -le 0) { return "0 B" }
$i = [math]::Floor([math]::Log($bytes, 1024))
if ($i -ge $suf.Length) { $i = $suf.Length - 1 }
"{0:N2} {1}" -f ($bytes / [math]::Pow(1024, $i)), $suf[$i]
}
function Sanitize-FileName {
param([string]$FileName)
$invalidChars = '[\\/:"*?<>|]'
return ($FileName -replace $invalidChars, '_')
}
function Get-AllFilesRecursive {
param(
[string]$DriveId,
[string]$RootItemId
)
$filesToScan = [System.Collections.Generic.List[object]]::new()
$folderCount = 0
$folderQueue = [System.Collections.Queue]::new()
$folderQueue.Enqueue(@{ ItemId = $RootItemId; RelativePath = "" })
$consoleWidth = if ($Host.UI.RawUI) { $Host.UI.RawUI.WindowSize.Width } else { 80 }
while ($folderQueue.Count -gt 0) {
$currentFolder = $folderQueue.Dequeue()
if ([string]::IsNullOrWhiteSpace($currentFolder.ItemId)) { continue }
$originalRelativePath = $currentFolder.RelativePath
if ($null -eq $originalRelativePath) { $originalRelativePath = "" }
$sanitizedRelativePath = ($originalRelativePath -split '[\\/]' | ForEach-Object { Sanitize-FileName -FileName $_ }) -join '\'
$statusText = "발견: 폴더 $folderCount 개, 파일 $($filesToScan.Count)"
$pathText = "탐색 중: $originalRelativePath"
$maxLength = $consoleWidth - $statusText.Length - 15
if ($pathText.Length -gt $maxLength) { $pathText = "..." + $pathText.Substring($pathText.Length - $maxLength) }
Write-Progress -Activity "SharePoint 원본 스캔 중" -Status $statusText -CurrentOperation $pathText -Id 0
$items = @()
try {
$page = Get-MgDriveItemChild -DriveId $DriveId -DriveItemId $currentFolder.ItemId -PageSize 999 -ErrorAction Stop
if ($null -ne $page) { $items += $page }
$next = $page.AdditionalProperties.'@odata.nextLink'
while ($null -ne $next) {
Write-Progress -Activity "SharePoint 원본 스캔 중" -Status $statusText -CurrentOperation "탐색 중 (페이징): $originalRelativePath" -Id 0
$page = Get-MgDriveItemChild -Uri $next -ErrorAction Stop
if ($null -ne $page) {
$items += $page
$next = $page.AdditionalProperties.'@odata.nextLink'
}
else {
$next = $null
}
}
}
catch {
Write-Warning "폴더 '$originalRelativePath' 스캔 실패: $($_.Exception.Message)"
continue
}
foreach ($item in $items) {
if (-not $item.Name) { continue }
$nextOriginalPath = if ([string]::IsNullOrEmpty($originalRelativePath)) { $item.Name } else { Join-Path $originalRelativePath $item.Name }
if ($null -ne $item.Folder.ChildCount) {
$folderCount++
$folderQueue.Enqueue(@{ ItemId = $item.Id; RelativePath = $nextOriginalPath })
}
elseif ($null -ne $item.File.MimeType -and $item.Size -gt 0) {
$sanitizedFileName = Sanitize-FileName -FileName $item.Name
$itemRelativePath = if ([string]::IsNullOrEmpty($sanitizedRelativePath)) { $sanitizedFileName } else { Join-Path $sanitizedRelativePath $sanitizedFileName }
$filesToScan.Add([PSCustomObject]@{
RelativePath = $itemRelativePath
Size = [long]$item.Size
DownloadUrl = $item.AdditionalProperties.'@microsoft.graph.downloadUrl'
})
}
}
}
return @{
Files = $filesToScan
FolderCount = $folderCount
}
}
# endregion
# ==================================================
# 메인 스크립트
# ==================================================
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Write-Host "--- SharePoint 백업 검증 스크립트 시작 ---" -ForegroundColor Yellow
# 1) 연결 및 설정
Write-Host "`n[1/6] Microsoft Graph 연결 및 설정 확인..." -ForegroundColor Cyan
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) {
Write-Host "[ERROR] 'Microsoft.Graph' 모듈이 필요합니다." -ForegroundColor Red
exit
}
if ($DownloadMissingFiles -and (-not (Get-Module -ListAvailable -Name PoshRSJob))) {
Write-Host "[ERROR] 다운로드 기능을 사용하려면 'PoshRSJob' 모듈이 필요합니다. (Install-Module PoshRSJob)" -ForegroundColor Red
exit
}
$requiredScopes = "Sites.Read.All", "Files.Read.All"
if (-not (Get-MgContext)) {
Connect-MgGraph -Scopes $requiredScopes
}
Write-Host "✅ 연결 성공: $(Get-MgContext).Account" -ForegroundColor Green
# 2) 백업 경로 확인
Write-Host "`n[2/6] 검증 대상 경로 확인..." -ForegroundColor Cyan
if ([string]::IsNullOrWhiteSpace($BackupPath)) {
$BackupPath = Select-FolderDialog -Title "검증할 로컬 백업 폴더를 선택하세요"
}
if ([string]::IsNullOrWhiteSpace($BackupPath) -or -not (Test-Path $BackupPath -PathType Container)) {
Write-Host "❌ 유효한 백업 경로가 지정되지 않았습니다. 스크립트를 종료합니다." -ForegroundColor Red
exit
}
Write-Host "✅ 검증 대상 로컬 경로 확인: $BackupPath" -ForegroundColor Green
# 3) SharePoint 원본 스캔
Write-Host "`n[3/6] SharePoint 원본 사이트 스캔 중..." -ForegroundColor Cyan
$uri = [Uri]$SiteUrl
$site = Get-MgSite -SiteId "$($uri.Host):$($uri.AbsolutePath)"
$drive = Get-MgSiteDrive -SiteId $site.Id | Where-Object Name -in @("Documents", "문서") | Select-Object -First 1
if (-not $drive) {
Write-Host "❌ 문서 라이브러리 없음" -ForegroundColor Red
exit
}
$spoScan = Get-AllFilesRecursive -DriveId $drive.Id -RootItemId 'root'
Write-Progress -Id 0 -Completed
$spoFiles = $spoScan.Files
Write-Host "✅ SharePoint 스캔 완료: $($spoFiles.Count)개 파일 발견" -ForegroundColor Green
# 4) 로컬 백업 스캔
Write-Host "`n[4/6] 로컬 백업 폴더 스캔 중..." -ForegroundColor Cyan
$localFiles = Get-ChildItem -Path $BackupPath -Recurse -File | ForEach-Object {
[PSCustomObject]@{
RelativePath = $_.FullName.Substring($BackupPath.Length + 1)
Size = $_.Length
}
}
Write-Host "✅ 로컬 스캔 완료: $($localFiles.Count)개 파일 발견" -ForegroundColor Green
# 5) 원본과 백업 비교
Write-Host "`n[5/6] 원본과 백업 비교 및 검증 중..." -ForegroundColor Cyan
$localFilesHashTable = @{}
$localFiles | ForEach-Object { $localFilesHashTable[$_.RelativePath] = $_.Size }
$matchCount = 0
$mismatchList = [System.Collections.Generic.List[object]]::new()
$missingList = [System.Collections.Generic.List[object]]::new()
$totalSpoFiles = $spoFiles.Count
$progress = 0
foreach ($spoFile in $spoFiles) {
$progress++
Write-Progress -Activity "파일 비교 중" -Status "$progress / $totalSpoFiles" -PercentComplete (($progress / $totalSpoFiles) * 100) -Id 1
if ($localFilesHashTable.ContainsKey($spoFile.RelativePath)) {
if ($spoFile.Size -eq $localFilesHashTable[$spoFile.RelativePath]) {
$matchCount++
}
else {
$mismatchList.Add([PSCustomObject]@{
File = $spoFile.RelativePath
SpoSize = (Format-FileSize $spoFile.Size)
LocalSize = (Format-FileSize $localFilesHashTable[$spoFile.RelativePath])
})
}
$localFilesHashTable.Remove($spoFile.RelativePath)
}
else {
$missingList.Add($spoFile)
}
}
Write-Progress -Id 1 -Completed
$extraList = $localFilesHashTable.GetEnumerator() | ForEach-Object {
[PSCustomObject]@{
File = $_.Name
Size = (Format-FileSize $_.Value)
}
}
Write-Host "✅ 비교 완료!" -ForegroundColor Green
# 6) 누락된 파일 다운로드 (기능 추가)
if ($DownloadMissingFiles -and $missingList.Count -gt 0) {
Write-Host "`n[6/6] 누락된 파일 $($missingList.Count)개 다운로드 시작... (병렬 $ThrottleLimit)" -ForegroundColor Cyan
$downloadScriptBlock = {
param($File, $BackupPath)
try {
$localPath = Join-Path $BackupPath $File.RelativePath
$localDir = Split-Path -Path $localPath -Parent
if (-not (Test-Path -LiteralPath $localDir)) {
New-Item -Path $localDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
if (-not $File.DownloadUrl) { throw "DownloadUrl이 없어 다운로드 불가" }
Invoke-WebRequest -Uri $File.DownloadUrl -OutFile $localPath -TimeoutSec 7200 -UseBasicParsing -ErrorAction Stop
return [PSCustomObject]@{ Status = 'Success'; File = $File.RelativePath; Message = '다운로드 성공' }
}
catch {
return [PSCustomObject]@{ Status = 'Failure'; File = $File.RelativePath; Message = $_.Exception.Message.Trim() }
}
}
Get-RSJob | Remove-RSJob
$jobs = $missingList | Start-RSJob -ScriptBlock $downloadScriptBlock -ArgumentList $BackupPath -Throttle $ThrottleLimit
$totalDownloads = $missingList.Count
$completedCount = 0
$downloadStartTime = Get-Date
while ($completedCount -lt $totalDownloads) {
$completedCount = ($jobs | Where-Object State -in 'Completed', 'Failed').Count
$percent = [math]::Round(($completedCount / $totalDownloads) * 100)
$elapsed = (Get-Date) - $downloadStartTime
$statusText = "$percent% 완료 ($completedCount/$totalDownloads) | 경과: $($elapsed.ToString('hh\:mm\:ss'))"
Write-Progress -Id 2 -Activity "누락 파일 다운로드 중" -Status $statusText -PercentComplete $percent
Start-Sleep -Seconds 1
}
$downloadResults = $jobs | Wait-RSJob | Receive-RSJob
Write-Progress -Id 2 -Completed
$downloadSuccessCount = ($downloadResults | Where-Object Status -eq 'Success').Count
$downloadFailureCount = ($downloadResults | Where-Object Status -eq 'Failure').Count
Write-Host "✅ 다운로드 완료! (성공: $downloadSuccessCount, 실패: $downloadFailureCount)" -ForegroundColor Green
if ($downloadFailureCount -gt 0) {
Write-Warning "일부 파일 다운로드에 실패했습니다. 상세 내용은 로그를 확인하세요."
}
}
$stopwatch.Stop()
# 최종 보고서 생성
$mismatchCount = $mismatchList.Count
$missingCount = $missingList.Count
$extraCount = $extraList.Count
$isPerfect = ($mismatchCount -eq 0) -and ($missingCount -eq 0)
$report = New-Object System.Text.StringBuilder
$null = $report.AppendLine("==================================================")
$null = $report.AppendLine(" SharePoint 백업 검증 보고서")
$null = $report.AppendLine("==================================================")
$null = $report.AppendLine(" > 검증 일시 : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$null = $report.AppendLine(" > 대상 사이트 : $SiteUrl")
$null = $report.AppendLine(" > 로컬 경로 : $BackupPath")
$null = $report.AppendLine(" > 총 소요 시간 : $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))")
$null = $report.AppendLine("--------------------------------------------------")
$null = $report.AppendLine(" [ 검증 요약 ]")
$null = $report.AppendLine(" - 원본 파일 (SharePoint) : $($spoFiles.Count)")
$null = $report.AppendLine(" - 백업 파일 (로컬) : $($localFiles.Count)")
$null = $report.AppendLine(" - ✅ 일치 : $matchCount")
$null = $report.AppendLine(" - ❌ 크기 불일치 : $mismatchCount")
$null = $report.AppendLine(" - ❌ 누락된 파일 : $missingCount")
$null = $report.AppendLine(" - 추가된 파일 (로컬만) : $extraCount")
$null = $report.AppendLine("--------------------------------------------------")
if ($isPerfect) {
$null = $report.AppendLine(" [ 최종 판정: ✅ 완벽한 백업 ]")
$null = $report.AppendLine(" 모든 원본 파일이 로컬 백업에 정확히 일치합니다.")
}
else {
$null = $report.AppendLine(" [ 최종 판정: ❌ 불완전한 백업 ]")
$null = $report.AppendLine(" 백업이 원본과 일치하지 않습니다. 아래 상세 내용을 확인하세요.")
}
$null = $report.AppendLine("==================================================")
if ($mismatchCount -gt 0) {
$null = $report.AppendLine(" [ ❌ 크기 불일치 상세 (원본 vs 로컬) ]")
foreach ($item in $mismatchList) {
$null = $report.AppendLine(" - $($item.File) `t ($($item.SpoSize) vs $($item.LocalSize))")
}
$null = $report.AppendLine("==================================================")
}
if ($missingCount -gt 0) {
$null = $report.AppendLine(" [ ❌ 누락된 파일 상세 (로컬에 없음) ]")
foreach ($item in $missingList) {
$null = $report.AppendLine(" - $($item.RelativePath) `t ($(Format-FileSize $item.Size))")
}
$null = $report.AppendLine("==================================================")
}
if ($extraCount -gt 0) {
$null = $report.AppendLine(" [ 추가된 파일 상세 (로컬에만 존재) ]")
foreach ($item in $extraList) {
$null = $report.AppendLine(" - $($item.File) `t ($($item.Size))")
}
$null = $report.AppendLine("==================================================")
}
if ($DownloadMissingFiles -and $missingList.Count -gt 0) {
$null = $report.AppendLine(" [ 누락 파일 다운로드 결과 ]")
$null = $report.AppendLine(" - ✅ 성공: $downloadSuccessCount 개, ❌ 실패: $downloadFailureCount")
if ($downloadFailureCount -gt 0) {
$null = $report.AppendLine(" [ 실패 항목 ]")
($downloadResults | Where-Object Status -eq 'Failure') | ForEach-Object {
$null = $report.AppendLine(" - $($_.File): $($_.Message)")
}
}
$null = $report.AppendLine("==================================================")
}
$finalReport = $report.ToString()
$finalReport.Split([Environment]::NewLine) | ForEach-Object {
if ($_ -like "*✅*") { Write-Host $_ -ForegroundColor Green }
elseif ($_ -like "*❌*") { Write-Host $_ -ForegroundColor Red }
elseif ($_ -like "**") { Write-Host $_ -ForegroundColor Yellow }
else { Write-Host $_ }
}
$logPath = Join-Path $BackupPath "verification_report_$(Get-Date -Format yyyyMMdd_HHmmss).log"
$finalReport | Out-File -FilePath $logPath -Encoding UTF8
Write-Host "`n검증 보고서가 다음 위치에 저장되었습니다." -ForegroundColor Green
Write-Host $logPath