KODI에서 영화 이어보기가 안 되고 멈추는 이유? 60GB짜리 가짜 용량(0x00 더미) 0.1초 만에 판별하기
처음부터 재생하면 잘 나오는데, 중간 탐색(Seek)이나 이어보기만 하면 NAS 하드만 긁고 멈추는 미스터리 해결기
1. 답답했던 증상: "왜 이어보기만 하면 무한 로딩일까?"
NAS에 저장된 4K HDR 고화질 영화(약 62GB)를 KODI로 시청하다가 중간에 끄고, 다음 날 '이어보기(Resume)'를 눌렀을 때 이상한 현상을 겪었습니다.
- 화면은 까맣게 멈추고 버퍼링 로딩 휠만 계속 돕니다.
- NAS 하드디스크는 미친 듯이 드르륵거리며 파일 읽는 소리만 납니다.
- 수분을 기다려도 재생되지 않거나 결국 재생 실패 오류가 뜹니다.
- 그런데 '처음부터 재생'을 누르면 앞부분은 완벽하게 잘 나옵니다!
윈도우 탐색기에서 봐도 61.9GB로 용량이 꽉 차 있고, MediaInfo로 확인해 봐도 코덱과 영상 길이가 정상적으로 인식되어 원인을 찾기 매우 어려웠습니다.
2. 원인 분석: 껍데기만 60GB, 속은 84%가 빈 데이터(0x00)
파일의 바이너리(Hex Data)를 직접 뜯어 이분 탐색으로 정밀 분석한 결과, 충격적인 사실이 밝혀졌습니다.
| 분석 항목 | 상세 내용 | 판별 결과 |
|---|---|---|
| 표기 파일 용량 | 66,532,099,453 바이트 | 약 61.96 GB |
| 실제 유효 데이터 | 10,475,143,168 바이트 | 약 9.76 GB (15.7%) |
| 빈 데이터 (0x00 더미) | 56,056,956,285 바이트 | 약 52.2 GB (84.3% 허깨비) 🚨 |
💡 왜 이런 파일이 생겼을까요?
토렌트 클라이언트(Transmission, qBittorrent 등)는 다운로드를 시작할 때 디스크 단편화를 방지하기 위해 전체 파일 크기만큼 디스크 공간을 미리 0으로 할당(Pre-allocation)합니다.
이 영화는 다운로드가 약 15%쯤 되다가 시더(배포자)가 끊겨 멈췄는데, 파일 시스템상에는 62GB짜리 완전한 파일처럼 껍데기만 남아 있었던 것입니다.
토렌트 클라이언트(Transmission, qBittorrent 등)는 다운로드를 시작할 때 디스크 단편화를 방지하기 위해 전체 파일 크기만큼 디스크 공간을 미리 0으로 할당(Pre-allocation)합니다.
이 영화는 다운로드가 약 15%쯤 되다가 시더(배포자)가 끊겨 멈췄는데, 파일 시스템상에는 62GB짜리 완전한 파일처럼 껍데기만 남아 있었던 것입니다.
왜 이어보기만 안 되었을까요?
MKV 파일은 영상 중간 위치로 점프할 때 필요한 인덱스 테이블(Cues)이 보통 파일 맨 뒷부분에 위치합니다. 하지만 그 위치가 순수 0x00으로 비어 있으니, KODI가 인덱스를 찾으려고 수십 GB의 빈 데이터를 네트워크 너머로 계속 훑느라 무한 로딩에 빠졌던 것입니다.
3. 0.1초 만에 가짜 용량을 잡아내는 원리
MediaInfo는 헤더만 읽어서 속고, 일반 영상 검사기는 60GB를 다 읽느라 몇 시간이 걸립니다. 이를 해결하기 위해 초고속 이분 탐색 알고리즘을 적용했습니다.
- 끝단 64KB 고속 체크 (1밀리초): 파일 맨 끝부분을 살짝 찍어보고, 0x00으로만 가득 차 있다면 즉시 '미완료 의심 파일'로 판단.
- 이분 탐색 (50밀리초): 파일 전체를 읽지 않고 위치만 반씩 건너뛰며(Seek) 10~15번만 확인하여, 실제 데이터가 몇 GB까지 들어있는지 정확한 완성도(%)를 0.1초 만에 계산.
4. 무료 배포: 윈도우용 초고속 판별기 (무설치 GUI)
아무것도 추가 설치할 필요 없이 윈도우 기본 기능(PowerShell + WPF)만으로 동작하는 단일 실행 배치 파일(.bat)입니다. 마우스로 파일이나 폴더를 끌어다 놓기만 하면 됩니다.
🛠️ 30초 만에 프로그램 만드는 법
- 바탕화면 빈 곳 우클릭 → [새로 만들기] → [텍스트 문서] 생성
- 파일 이름을
VideoZeroChecker.bat으로 변경 (확장자가 .txt가 아닌 .bat이어야 함) - 메모장으로 열어서 아래의 전체 코드를 붙여넣고 저장
VideoZeroChecker.bat
<# :
@echo off
setlocal
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-Expression ([System.IO.File]::ReadAllText('%~f0'))"
exit /b
#>
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Windows.Forms
[xml]$xaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="동영상 가짜 용량(0x00 더미) 판별기 v1.0" Height="580" Width="920"
WindowStartupLocation="CenterScreen" Background="#F8F9FA" AllowDrop="True">
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="4,0"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="White"/>
</Style>
</Window.Resources>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#1E293B" CornerRadius="8" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="🎬 동영상 가짜 용량(토렌트 미완료 패딩) 초고속 판별기" FontSize="18" FontWeight="Bold" Foreground="White"/>
<TextBlock Text="KODI 등에서 재생이 멈추거나 이어보기가 안 되는 0x00 더미 파일들을 1초 만에 감지합니다." FontSize="12" Foreground="#94A3B8" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Grid Grid.Row="1" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button Name="BtnAddFiles" Content="📄 파일 추가" Background="#2563EB"/>
<Button Name="BtnAddFolder" Content="📁 폴더 추가" Background="#475569"/>
<Button Name="BtnClear" Content="🗑️ 목록 비우기" Background="#64748B"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button Name="BtnStartCheck" Content="▶️ 검사 시작" Background="#16A34A" MinWidth="110"/>
</StackPanel>
</Grid>
<Border Grid.Row="2" BorderBrush="#E2E8F0" BorderThickness="1" CornerRadius="6" Background="White">
<ListView Name="ListResults" BorderThickness="0" Background="Transparent" AlternationCount="2">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="Padding" Value="4,6"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<Trigger Property="ItemsControl.AlternationIndex" Value="1">
<Setter Property="Background" Value="#F8FAFC"/>
</Trigger>
</Style.Triggers>
</Style>
</ListView.ItemContainerStyle>
<ListView.View>
<GridView>
<GridViewColumn Header="파일명" Width="300" DisplayMemberBinding="{Binding FileName}"/>
<GridViewColumn Header="판별 상태" Width="140">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Status}" FontWeight="Bold" Foreground="{Binding StatusColor}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="표기 용량" Width="90" DisplayMemberBinding="{Binding TotalSize}"/>
<GridViewColumn Header="실제 데이터" Width="90" DisplayMemberBinding="{Binding ActualSize}"/>
<GridViewColumn Header="완성도 (%)" Width="85" DisplayMemberBinding="{Binding ValidPercent}"/>
<GridViewColumn Header="파일 경로" Width="280" DisplayMemberBinding="{Binding FilePath}"/>
</GridView>
</ListView.View>
</ListView>
</Border>
<Grid Grid.Row="3" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<TextBlock Name="TxtStatus" Text="파일이나 폴더를 이곳으로 드래그 앤 드롭하세요." VerticalAlignment="Center" FontSize="12" Foreground="#475569"/>
<ProgressBar Name="ProgBar" Grid.Column="1" Height="14" Minimum="0" Maximum="100" Value="0" Visibility="Collapsed"/>
</Grid>
</Grid>
</Window>
"@
$reader = (New-Object System.Xml.XmlNodeReader $xaml)
$window = [System.Windows.Markup.XamlReader]::Load($reader)
$btnFiles = $window.FindName("BtnAddFiles")
$btnFolder = $window.FindName("BtnAddFolder")
$btnClear = $window.FindName("BtnClear")
$btnStart = $window.FindName("BtnStartCheck")
$listView = $window.FindName("ListResults")
$txtStatus = $window.FindName("TxtStatus")
$progBar = $window.FindName("ProgBar")
$videoExtensions = @(".mkv", ".mp4", ".ts", ".m2ts", ".avi", ".mov", ".wmv", ".iso")
$fileList = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$listView.ItemsSource = $fileList
function Add-FileToList($path) {
if (-not (Test-Path -LiteralPath $path)) { return }
$ext = [System.IO.Path]::GetExtension($path).ToLower()
if ($videoExtensions -contains $ext) {
foreach ($item in $fileList) {
if ($item.FilePath -eq $path) { return }
}
$info = Get-Item -LiteralPath $path
$fileList.Add([PSCustomObject]@{
FileName = $info.Name
Status = "대기 중"
StatusColor = "#64748B"
TotalSize = "$([Math]::Round($info.Length / 1GB, 2)) GB"
ActualSize = "-"
ValidPercent = "-"
FilePath = $info.FullName
RawLength = $info.Length
})
}
}
$window.Add_Drop({
param($s, $e)
if ($e.Data.GetDataPresent([System.Windows.DataFormats]::FileDrop)) {
$dropped = $e.Data.GetData([System.Windows.DataFormats]::FileDrop)
foreach ($item in $dropped) {
if ((Get-Item -LiteralPath $item) -is [System.IO.DirectoryInfo]) {
Get-ChildItem -LiteralPath $item -Recurse -File | ForEach-Object { Add-FileToList $_.FullName }
} else {
Add-FileToList $item
}
}
$txtStatus.Text = "총 $($fileList.Count)개 파일이 등록되었습니다. '검사 시작'을 누르세요."
}
})
$btnFiles.Add_Click({
$dlg = New-Object Microsoft.Win32.OpenFileDialog
$dlg.Multiselect = $true
$dlg.Filter = "동영상 파일|*.mkv;*.mp4;*.ts;*.m2ts;*.avi;*.mov;*.wmv;*.iso|모든 파일|*.*"
if ($dlg.ShowDialog() -eq $true) {
foreach ($file in $dlg.FileNames) { Add-FileToList $file }
$txtStatus.Text = "총 $($fileList.Count)개 파일이 대기 중입니다."
}
})
$btnFolder.Add_Click({
$fbd = New-Object System.Windows.Forms.FolderBrowserDialog
$fbd.Description = "검사할 동영상 폴더를 선택하세요."
if ($fbd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Get-ChildItem -LiteralPath $fbd.SelectedPath -Recurse -File | ForEach-Object { Add-FileToList $_.FullName }
$txtStatus.Text = "총 $($fileList.Count)개 파일이 대기 중입니다."
}
})
$btnClear.Add_Click({
$fileList.Clear()
$txtStatus.Text = "목록을 비웠습니다."
$progBar.Value = 0
$progBar.Visibility = "Collapsed"
})
$btnStart.Add_Click({
if ($fileList.Count -eq 0) {
[System.Windows.MessageBox]::Show("검사할 파일이나 폴더를 먼저 추가해 주세요.", "알림", "OK", "Information")
return
}
$btnStart.IsEnabled = $false
$progBar.Visibility = "Visible"
$progBar.Maximum = $fileList.Count
$progBar.Value = 0
$fakeCount = 0
for ($idx = 0; $idx -lt $fileList.Count; $idx++) {
$item = $fileList[$idx]
$filePath = $item.FilePath
$fileSize = $item.RawLength
$txtStatus.Text = "검사 중 ($($idx + 1)/$($fileList.Count)): $($item.FileName)"
$progBar.Value = $idx + 1
[System.Windows.Forms.Application]::DoEvents()
try {
$stream = [System.IO.File]::Open($filePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
$sample = [Math]::Min($fileSize, 65536)
$stream.Seek($fileSize - $sample, [System.IO.SeekOrigin]::Begin) | Out-Null
$buf = New-Object byte[] $sample
$read = $stream.Read($buf, 0, $sample)
$isTailZero = $true
for ($k = 0; $k -lt $read; $k++) {
if ($buf[$k] -ne 0) { $isTailZero = $false; break }
}
if (-not $isTailZero) {
$stream.Close()
$fileList[$idx] = [PSCustomObject]@{
FileName = $item.FileName
Status = "정상"
StatusColor = "#16A34A"
TotalSize = $item.TotalSize
ActualSize = $item.TotalSize
ValidPercent = "100 %"
FilePath = $item.FilePath
RawLength = $item.RawLength
}
} else {
$lo = [long]0
$hi = $fileSize
$chk = 4096
$cbuf = New-Object byte[] $chk
while (($hi - $lo) -gt $chk) {
$mid = $lo + (($hi - $lo) / 2)
$mid = [long]($mid - ($mid % $chk))
$stream.Seek($mid, [System.IO.SeekOrigin]::Begin) | Out-Null
$r = $stream.Read($cbuf, 0, $chk)
$isZ = $true
for ($z = 0; $z -lt $r; $z++) {
if ($cbuf[$z] -ne 0) { $isZ = $false; break }
}
if ($isZ) { $hi = $mid } else { $lo = $mid + $chk }
}
$stream.Close()
$actualBytes = $lo
$validPct = [Math]::Round(($actualBytes / $fileSize) * 100, 2)
if ((100 - $validPct) -ge 3.0) {
$fakeCount++
$fileList[$idx] = [PSCustomObject]@{
FileName = $item.FileName
Status = "🚨 가짜 용량 (미완료)"
StatusColor = "#DC2626"
TotalSize = $item.TotalSize
ActualSize = "$([Math]::Round($actualBytes / 1GB, 2)) GB"
ValidPercent = "$validPct %"
FilePath = $item.FilePath
RawLength = $item.RawLength
}
} else {
$fileList[$idx] = [PSCustomObject]@{
FileName = $item.FileName
Status = "정상"
StatusColor = "#16A34A"
TotalSize = $item.TotalSize
ActualSize = $item.TotalSize
ValidPercent = "100 %"
FilePath = $item.FilePath
RawLength = $item.RawLength
}
}
}
}
catch {
$fileList[$idx] = [PSCustomObject]@{
FileName = $item.FileName
Status = "❌ 접근 실패"
StatusColor = "#EA580C"
TotalSize = $item.TotalSize
ActualSize = "-"
ValidPercent = "-"
FilePath = $item.FilePath
RawLength = $item.RawLength
}
}
}
$btnStart.IsEnabled = $true
if ($fakeCount -gt 0) {
$txtStatus.Text = "검사 완료: 총 $($fileList.Count)개 중 $fakeCount개의 미완료/손상 파일이 발견되었습니다!"
[System.Windows.MessageBox]::Show("검사가 완료되었습니다.`n발견된 가짜/미완료 파일: ${fakeCount}개", "검사 완료", "OK", "Warning")
} else {
$txtStatus.Text = "검사 완료: 모든 파일이 100% 정상입니다."
[System.Windows.MessageBox]::Show("모든 파일이 정상입니다!", "검사 완료", "OK", "Information")
}
})
$window.ShowDialog() | Out-Null
댓글 없음:
댓글 쓰기