Files
sharepoint/Backup-SPO-PoshRSJob.ps1
2025-09-15 13:37:28 +09:00

411 lines
18 KiB
PowerShell

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