commit 217f9dade3e5064b4337c17ae19e3dcd07d45563 Author: TigErJin Date: Mon Sep 15 13:37:28 2025 +0900 convert to gitea diff --git a/Backup-SPO-PoshRSJob.ps1 b/Backup-SPO-PoshRSJob.ps1 new file mode 100644 index 0000000..774596f --- /dev/null +++ b/Backup-SPO-PoshRSJob.ps1 @@ -0,0 +1,411 @@ +# ================================================== +# SharePoint 백업 스크립트 +# ================================================== + +using namespace System.Collections.Concurrent + +<# +.SYNOPSIS + 장시간 실행에도 안정적인 SharePoint Online 병렬 백업 스크립트. + +.DESCRIPTION + - 각 병렬 다운로드 작업(Job)이 시작 직전에 인증 토큰을 자동으로 갱신하여 + 초대용량 파일 다운로드 등으로 인해 발생하는 세션 만료 문제 해결. + - 지능형 재시도 기능으로 로컬에 이미 있거나 불완전한 파일을 자동으로 처리. + (정상 파일: 건너뛰기, 불완전한 파일: 덮어쓰기) + - PowerShell 7+ 권장. 모듈: Microsoft.Graph, PoshRSJob + +.PARAMETER SiteUrl + SharePoint Online 사이트 URL + +.PARAMETER ThrottleLimit + 병렬 다운로드 동시 처리 개수 (기본값 1). 초대용량 파일 백업으로 1개로 제한하여 안정성 확보 할 것! + +.EXAMPLE + # 기존에 실패한 파일들만 안정적으로 다시 받기 + .\Backup-SPO-Final-Enhanced.ps1 -SiteUrl "https://yoursite.sharepoint.com/sites/yoursite" -ThrottleLimit 1 +#> + +param( + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [Parameter(Mandatory = $false)] + [int]$ThrottleLimit = 1 # 기본값을 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 "백업 경로 입력" + } + 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( + [Parameter(Mandatory)] + [string]$DriveId, + [Parameter(Mandatory)] + [string]$RootItemId, + [Parameter(Mandatory)] + [string]$BackupBasePath + ) + + $filesToDownload = [System.Collections.Generic.List[object]]::new() + $folderCount = 0 + $totalScanSize = 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 개, 파일 $($filesToDownload.Count) 개" + $pathText = "탐색 중: $originalRelativePath" + $maxLength = $consoleWidth - $statusText.Length - 15 + if ($pathText.Length -gt $maxLength) { $pathText = "..." + $pathText.Substring($pathText.Length - $maxLength) } + + Write-Progress -Activity "파일 목록 스캔 중" -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 "파일 목록 스캔 중" -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 } + + # [수정] 스캔 시점에는 DownloadUrl을 가져오지 않고, 다운로드에 필수적인 정보만 저장 + $filesToDownload.Add([PSCustomObject]@{ + DriveId = $DriveId + Id = $item.Id + RelativePath = $itemRelativePath + LocalPath = Join-Path $BackupBasePath $itemRelativePath + Size = [long]$item.Size + }) + $totalScanSize += [long]$item.Size + } + } + } + + return @{ + Files = $filesToDownload + TotalSize = [long]$totalScanSize + FolderCount = $folderCount + } +} +# endregion + +# ================================================== +# 메인 스크립트 +# ================================================== + +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +Write-Host "--- SharePoint 백업 (최종 강화판) 시작 ---" -ForegroundColor Yellow + +# 1) 필수 모듈 확인 및 Microsoft Graph 연결 +Write-Host "`n[1/5] 연결 및 설정 확인..." -ForegroundColor Cyan +if (-not (Get-Module -ListAvailable -Name PoshRSJob)) { Write-Host "[ERROR] 'PoshRSJob' 모듈이 필요합니다. (Install-Module PoshRSJob)" -ForegroundColor Red; exit } +if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) { Write-Host "[ERROR] 'Microsoft.Graph' 모듈이 필요합니다." -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/5] 백업 대상 확인..." -ForegroundColor Cyan +$backupRootPath = Select-FolderDialog +if ([string]::IsNullOrWhiteSpace($backupRootPath)) { + Write-Host "❌ 백업 경로 미선택. 종료" -ForegroundColor Red + exit +} +$uri = [Uri]$SiteUrl +$site = Get-MgSite -SiteId "$($uri.Host):$($uri.AbsolutePath)" +$drive = Get-MgSiteDrive -SiteId $site.Id -Property "Id,Name,Quota" | + Where-Object Name -in @("Documents", "문서") | + Select-Object -First 1 + +if (-not $drive) { + Write-Host "❌ 문서 라이브러리 없음" -ForegroundColor Red + exit +} +Write-Host "✅ 라이브러리: $($drive.Name)" -ForegroundColor Green +$driveQuota = $drive.Quota +Write-Host "✅ 사이트 용량: $(Format-FileSize $driveQuota.Used) / $(Format-FileSize $driveQuota.Total) 사용 중" -ForegroundColor Green + +# 3) 전체 파일 스캔 또는 실패 목록 로드 +Write-Host "`n[3/5] 파일 목록 준비..." -ForegroundColor Cyan +$failedFilesLogPath = Join-Path $backupRootPath "_failed_files.json" +$isRetryMode = $false +if (Test-Path $failedFilesLogPath) { + Write-Host "⚠️ 이전 실행에서 실패한 파일 목록(_failed_files.json)을 발견했습니다." -ForegroundColor Yellow + $allFiles = Get-Content $failedFilesLogPath | ConvertFrom-Json + Write-Host "실패한 $($allFiles.Count)개의 파일에 대해 다운로드를 재시도합니다." -ForegroundColor Yellow + $isRetryMode = $true + $totalSize = ($allFiles | Measure-Object -Property Size -Sum).Sum + $folderCount = "N/A (재시도 모드)" +} +else { + $scan = Get-AllFilesRecursive -DriveId $drive.Id -RootItemId 'root' -BackupBasePath $backupRootPath + Write-Progress -Id 0 -Completed + $allFiles = $scan.Files + $totalSize = [long]$scan.TotalSize + $folderCount = $scan.FolderCount +} +Write-Host "✅ 준비 완료: $($allFiles.Count)개 파일, 총 $(Format-FileSize $totalSize)" -ForegroundColor Green +if ($allFiles.Count -eq 0) { + Write-Host "백업할 파일 없음. 종료." + exit +} + +# 4) 병렬 다운로드 (토큰 자동 갱신 로직 포함) +Write-Host "`n[4/5] 파일 다운로드 시작... (병렬 $ThrottleLimit)" -ForegroundColor Cyan +if ($ThrottleLimit -gt 3) { + Write-Warning "병렬 처리 개수가 너무 높으면 API 사용량 제한(429 오류)이 발생할 수 있습니다. 1~3 사이를 권장합니다." +} + +$scriptBlock = { + param($File) + + $status = "Unknown" + $message = "" + + try { + # 1. 로컬 파일 크기 비교를 통한 건너뛰기 로직 + if (Test-Path -LiteralPath $File.LocalPath -PathType Leaf) { + if ((Get-Item -LiteralPath $File.LocalPath).Length -eq $File.Size) { + throw "SKIP" # 사용자 지정 예외로 건너뛰기 처리 + } + } + + # 2. 다운로드 폴더 생성 + $localDir = Split-Path -Path $File.LocalPath -Parent + if (-not (Test-Path -LiteralPath $localDir)) { + New-Item -Path $localDir -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + + # 3. [핵심 로직] 다운로드 직전, 최신 DownloadUrl을 다시 요청하여 토큰을 갱신 + # Get-MgDriveItem cmdlet은 토큰이 만료되면 자동으로 갱신을 시도함. + $message = "인증 정보 갱신 중..." + $latestItem = Get-MgDriveItem -DriveId $File.DriveId -DriveItemId $File.Id + $downloadUrl = $latestItem.AdditionalProperties.'@microsoft.graph.downloadUrl' + + if (-not $downloadUrl) { throw "최신 DownloadUrl을 가져오는 데 실패했습니다." } + + # 4. 실제 다운로드 실행 + $message = "다운로드 시작..." + Invoke-WebRequest -Uri $downloadUrl -OutFile $File.LocalPath -TimeoutSec 7200 -UseBasicParsing -ErrorAction Stop # Timeout 2시간으로 증가 + + $status = "Success" + $message = "다운로드 성공" + } + catch { + if ($_.Exception.Message -eq "SKIP") { + $status = "Skipped" + $message = "파일 크기 동일, 건너뜀" + } + else { + $status = "Failure" + $message = $_.Exception.Message.Trim() + } + } + + return [PSCustomObject]@{ + Timestamp = Get-Date + Status = $status + File = $File.RelativePath + Size = [long]$File.Size + Message = $message + } +} + +Get-RSJob | Remove-RSJob # 이전 작업 정리 +$jobs = $allFiles | Start-RSJob -ScriptBlock $scriptBlock -Throttle $ThrottleLimit +$totalFiles = $allFiles.Count +$downloadStartTime = Get-Date +$completedCount = 0 + +while ($completedCount -lt $totalFiles) { + $completedCount = ($jobs | Where-Object State -in 'Completed', 'Failed').Count + + if ($totalFiles -gt 0) { + $percent = [math]::Round(($completedCount / $totalFiles) * 100) + $elapsedSec = ((Get-Date) - $downloadStartTime).TotalSeconds + $statusText = "$percent% 완료 ($completedCount/$totalFiles) | 경과 시간: $([TimeSpan]::FromSeconds($elapsedSec).ToString('hh\:mm\:ss'))" + $runningJobNames = ($jobs | Where-Object State -eq 'Running' | Select-Object -First 1).Name + $currentOperation = if ($runningJobNames) { "다운로드 중: $runningJobNames" } else { "마무리 중..." } + + Write-Progress -Id 1 -Activity "SharePoint 백업 진행 중" -Status $statusText -CurrentOperation $currentOperation -PercentComplete $percent + } + Start-Sleep -Seconds 1 +} + +$results = $jobs | Wait-RSJob | Receive-RSJob +Write-Progress -Id 1 -Completed +$stopwatch.Stop() +Write-Host "✅ 파일 다운로드 완료!" -ForegroundColor Green + +# ================================================== +# 5) 결과 보고서 생성/출력/저장 +# ================================================== +Write-Host "`n[5/5] 결과 보고서 생성..." -ForegroundColor Cyan + +$successArray = $results | Where-Object Status -in @('Success', 'Skipped') +$failureArray = $results | Where-Object Status -eq 'Failure' + +$successCount = ($successArray | Where-Object Status -eq 'Success').Count +$skippedCount = ($successArray | Where-Object Status -eq 'Skipped').Count +$failureCount = $failureArray.Count + +if ($isRetryMode) { + $previousSuccessLogPath = Join-Path $backupRootPath "_success_log.json" + if (Test-Path $previousSuccessLogPath) { + $previousSuccess = Get-Content $previousSuccessLogPath | ConvertFrom-Json + $successCount += ($previousSuccess | Where-Object Status -eq 'Success').Count + $skippedCount += ($previousSuccess | Where-Object Status -eq 'Skipped').Count + } +} + +$otherStorageBytes = [math]::Max([long]0, ([long]$driveQuota.Used - [long]$totalSize)) +$downloadedTotal = ($results | Where-Object Status -eq 'Success' | Measure-Object -Property Size -Sum).Sum +if ($null -eq $downloadedTotal) { $downloadedTotal = 0 } +$elapsedForDownload = $stopwatch.Elapsed.TotalSeconds +$avgBpsFinal = if ($elapsedForDownload -gt 0) { [double]$downloadedTotal / $elapsedForDownload } else { 0.0 } + +$report = New-Object System.Text.StringBuilder +$null = $report.AppendLine("==================================================") +$null = $report.AppendLine(" SharePoint 백업 결과 보고서 (PoshRSJob)") +$null = $report.AppendLine("==================================================") +$null = $report.AppendLine(" > 백업 일시 : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +$null = $report.AppendLine(" > 대상 사이트 : $SiteUrl") +$null = $report.AppendLine(" > 저장 위치 : $backupRootPath") +$null = $report.AppendLine("--------------------------------------------------") +$null = $report.AppendLine(" [ 사이트 저장소 현황 ]") +$null = $report.AppendLine((" - 전체 할당량 (Quota)".PadRight(25) + ": $(Format-FileSize $driveQuota.Total)")) +$null = $report.AppendLine((" - 실제 총 사용량".PadRight(25) + ": $(Format-FileSize $driveQuota.Used)")) +$null = $report.AppendLine((" - 현재 파일 총 용량".PadRight(25) + ": $(Format-FileSize $totalSize)")) +$null = $report.AppendLine((" - 기타 용량 (버전 기록 등)".PadRight(25) + ": $(Format-FileSize $otherStorageBytes)")) +$null = $report.AppendLine("--------------------------------------------------") +$null = $report.AppendLine(" [ 백업된 파일 정보 ]") +$null = $report.AppendLine(" - 스캔된 총 폴더 수 : $folderCount 개") +$null = $report.AppendLine(" - 스캔된 총 파일 수 : $($allFiles.Count) 개") +$null = $report.AppendLine("--------------------------------------------------") +$null = $report.AppendLine(" [ 백업 작업 결과 ]") +$null = $report.AppendLine(" - ✅ 성공 : $successCount 개") +$null = $report.AppendLine(" - ⏩ 건너뜀 : $skippedCount 개") +$null = $report.AppendLine(" - ❌ 실패 : $failureCount 개") +$null = $report.AppendLine(" - ⏱️ 총 소요 시간 : $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))") +$null = $report.AppendLine(" - ↓ 실제 평균 속도 : $(Format-FileSize([long][math]::Round($avgBpsFinal)))/s") +$null = $report.AppendLine("==================================================") + +if ($failureCount -gt 0) { + $failedFilesToSave = $failureArray | ForEach-Object { + $originalFile = $allFiles | Where-Object RelativePath -eq $_.File | Select-Object -First 1 + [PSCustomObject]@{ + DriveId = $originalFile.DriveId + Id = $originalFile.Id + RelativePath = $originalFile.RelativePath + LocalPath = $originalFile.LocalPath + Size = $originalFile.Size + } + } + $failedFilesToSave | ConvertTo-Json | Set-Content -Path $failedFilesLogPath -Encoding UTF8 + $successArray | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $backupRootPath "_success_log.json") -Encoding UTF8 + + $null = $report.AppendLine(" [ 실패 항목 상세 ]") + foreach ($f in ($failureArray | Sort-Object File)) { + $null = $report.AppendLine(" ❌ $($f.File)") + $null = $report.AppendLine(" └ 원인: $($f.Message)") + } + $null = $report.AppendLine("==================================================") + + Write-Warning "`n$failureCount 개의 파일 다운로드에 실패했습니다. 실패 목록을 '$failedFilesLogPath'에 저장했습니다. 스크립트를 다시 실행하면 실패한 파일만 재시도합니다." +} +else { + Remove-Item -Path $failedFilesLogPath -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $backupRootPath "_success_log.json") -ErrorAction SilentlyContinue +} + +$finalReport = $report.ToString() +$finalReport.Split([Environment]::NewLine) | ForEach-Object { + if ($_ -like "*✅*") { Write-Host $_ -ForegroundColor Green } + elseif ($_ -like "*❌*") { Write-Host $_ -ForegroundColor Red } + elseif ($_ -like "*⏩*" -or $_ -like "*⏱️*") { Write-Host $_ -ForegroundColor Yellow } + elseif ($_ -like "*[*]*" -or $_ -like "*>*") { Write-Host $_ -ForegroundColor Cyan } + else { Write-Host $_ } +} + +$logPath = Join-Path $backupRootPath "backup_report_$(Get-Date -Format yyyyMMdd_HHmmss).log" +$finalReport | Out-File -FilePath $logPath -Encoding UTF8 +Write-Host "`n보고서 저장 위치:" -ForegroundColor Green +Write-Host $logPath \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6266f9 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# SharePoint Online 고성능 백업 및 검증 스크립트 + +![PowerShell Version](https://img.shields.io/badge/PowerShell-7.2%2B-blue.svg) +![License](https://img.shields.io/badge/License-MIT-green.svg) + +Microsoft Graph API와 PoshRSJob 모듈을 활용하여 SharePoint Online 문서 라이브러리를 빠르고 안전하게 로컬로 백업하고, 백업된 데이터의 무결성을 검증하는 PowerShell 스크립트. + +--- + +## ✨ 주요 기능 + +- 🚀 **고성능 병렬 다운로드**: `PoshRSJob` 모듈을 사용해 다수의 파일을 동시에 다운로드, 백업 속도 극대화 +- 🔄 **지능형 재시도**: 실패 파일만 자동 재시도 → 전체 스캔 없이 빠른 복구 +- 🛡️ **API 사용량 제한 대응**: `429 Too Many Requests` 오류 시 `Retry-After` 헤더 기반 대기 및 재시도 +- 📊 **실시간 모니터링**: 진행률, 다운로드 속도, 경과 시간 표시 +- 🔍 **백업 무결성 검증**: 로컬 백업과 원본 SharePoint를 비교해 누락/손상 여부 확인 +- 📂 **파일/폴더명 정제**: 한글, 공백, 특수문자 포함된 이름 안전 변환 +- 📑 **종합 보고서**: 용량 분석 포함 결과 로그 생성 + +--- + +## 📋 필수 요구사항 + +1. PowerShell **7.2 이상** +2. PowerShell 모듈 + - `Microsoft.Graph` + - `PoshRSJob` +3. Microsoft Graph 권한: `Sites.Read.All` + +--- + +## 🛠️ 설치 및 초기 설정 + +### 1. PowerShell 7 설치 +[공식 PowerShell GitHub 릴리스 페이지](https://github.com/PowerShell/PowerShell/releases)에서 최신 버전 설치. + +### 2. 필수 모듈 설치 +```powershell +Install-Module Microsoft.Graph -Scope CurrentUser -Force +Install-Module PoshRSJob -Scope CurrentUser -Force +```` + +#### 🔹 PoshRSJob 모듈 설명 + +`ForEach-Object -Parallel` 대비 안정적인 병렬 작업 관리와 상태 추적을 지원하는 고성능 병렬 처리 모듈. 대규모 파일 다운로드에서 권장되는 사실상 표준. + +#### 🔹 PoshRSJob 수동 설치 + +네트워크/캐시 문제로 `Install-Module`이 실패할 경우: + +1. 캐시 삭제 후 재시도: + + ```powershell + Remove-Item -Path "$env:LOCALAPPDATA\NuGet\Cache\*" -Recurse -Force -ErrorAction SilentlyContinue + ``` +2. [PowerShell Gallery](https://www.powershellgallery.com/packages/PoshRSJob)에서 `.nupkg` 다운로드 +3. 파일 속성 → **차단 해제(Unblock)** 체크 +4. 확장자 `.zip`으로 변경 후 압축 해제 +5. 모듈 경로(`($env:PSModulePath -split ';')[0]`)에 `PoshRSJob` 폴더 생성 후 복사 + + * 예: `C:\Users\\Documents\PowerShell\Modules\PoshRSJob\` +6. 설치 확인: + + ```powershell + Get-Module -ListAvailable PoshRSJob + ``` + +### 3. Microsoft Graph 최초 연결 + +```powershell +Connect-MgGraph -Scopes "Sites.Read.All" +``` + +--- + +## 🚀 사용법 + +### 1. 백업 스크립트 + +```powershell +.\Backup-SPO-PoshRSJob.ps1 -SiteUrl "https://tenant.sharepoint.com/sites/SiteName" +``` + +* `ThrottleLimit`으로 병렬 다운로드 개수 조정 (기본값 3, 권장 1\~3) +* 실패 시 `_failed_files.json` 기반 자동 이어받기 지원 + +### 2. 검증 스크립트 + +```powershell +.\Verify-SPO-Backup.ps1 -SiteUrl "https://tenant.sharepoint.com/sites/SiteName" -BackupPath "D:\Backups\SharePoint" +``` + +--- + +## 📊 보고서 예시 + +``` +================================================== + SharePoint 백업 결과 보고서 (PoshRSJob) +================================================== + > 백업 일시 : 2025-08-20 13:31:22 + > 대상 사이트 : https://oneunivrs.sharepoint.com/sites/Anvil + > 저장 위치 : F:\action_anvil +-------------------------------------------------- + [ 사이트 저장소 현황 ] + - 전체 할당량 (Quota) : 100.00 GB + - 실제 총 사용량 : 101.01 GB + - 현재 파일 총 용량 : 98.53 GB + - 기타 용량 (버전 기록 등) : 2.48 GB +-------------------------------------------------- + [ 백업된 파일 정보 ] + - 스캔된 총 폴더 수 : 264 개 + - 스캔된 총 파일 수 : 2612 개 +-------------------------------------------------- + [ 백업 작업 결과 ] + - ✅ 성공 : 2612 개 + - ⏩ 건너뜀 : 0 개 + - ❌ 실패 : 0 개 + - ⏱️ 총 소요 시간 : 00:47:51 + - ↓ 실제 평균 속도 : 34.30 MB/s +================================================== +``` + +--- + +## 💡 문제 해결 및 고급 주제 (Troubleshooting & Advanced Topics) + +### 1. API 사용량 제한 (429 오류) + +* **문제:** 대용량 다운로드 시 `Too Many Requests` 발생 +* **해결:** + + * `Retry-After` 헤더 기반 대기 후 재시도 + * 점진적 대기 (5초, 10초, 15초...) 적용 +* **팁:** `-ThrottleLimit` 값은 1\~2 권장 + +### 2. 왜 PoshRSJob 인가? + +* `ForEach-Object -Parallel`은 공유 변수 전달 시 오류 발생 +* `PoshRSJob`은 Job 기반 격리 실행, 상태 추적과 안정성 우수 + +### 3. 파일 이름 문제 + +* Windows 불가 문자를 `_`로 치환 (예: `:` → `_`) +* 모든 파일 작업에 `-LiteralPath` 적용 + +### 4. 대용량(>2GB) 처리 + +* PowerShell 기본 `Int32` 한계로 오류 발생 +* 모든 파일 크기 계산을 `[long]` 타입으로 변환해 테라바이트 단위 지원 + +--- + +``` diff --git a/Verify-SPOBackup.ps1 b/Verify-SPOBackup.ps1 new file mode 100644 index 0000000..552c000 --- /dev/null +++ b/Verify-SPOBackup.ps1 @@ -0,0 +1,402 @@ +# ================================================== +# 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 \ No newline at end of file diff --git a/poshrsjob.1.7.4.4.zip b/poshrsjob.1.7.4.4.zip new file mode 100644 index 0000000..4a6c0ef Binary files /dev/null and b/poshrsjob.1.7.4.4.zip differ