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