# ================================================== # 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