convert to gitea

This commit is contained in:
2025-09-15 13:37:28 +09:00
commit 217f9dade3
4 changed files with 967 additions and 0 deletions

411
Backup-SPO-PoshRSJob.ps1 Normal file
View File

@ -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

154
README.md Normal file
View File

@ -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\<User>\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]` 타입으로 변환해 테라바이트 단위 지원
---
```

402
Verify-SPOBackup.ps1 Normal file
View File

@ -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

BIN
poshrsjob.1.7.4.4.zip Normal file

Binary file not shown.