From 217f9dade3e5064b4337c17ae19e3dcd07d45563 Mon Sep 17 00:00:00 2001 From: TigErJin Date: Mon, 15 Sep 2025 13:37:28 +0900 Subject: [PATCH] convert to gitea --- Backup-SPO-PoshRSJob.ps1 | 411 +++++++++++++++++++++++++++++++++++++++ README.md | 154 +++++++++++++++ Verify-SPOBackup.ps1 | 402 ++++++++++++++++++++++++++++++++++++++ poshrsjob.1.7.4.4.zip | Bin 0 -> 33462 bytes 4 files changed, 967 insertions(+) create mode 100644 Backup-SPO-PoshRSJob.ps1 create mode 100644 README.md create mode 100644 Verify-SPOBackup.ps1 create mode 100644 poshrsjob.1.7.4.4.zip 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 0000000000000000000000000000000000000000..4a6c0ef088d3087302ac99e5630337a475503a27 GIT binary patch literal 33462 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