다운로드 엔진 아키텍처

FlowPick 다운로드 엔진의 기술적 아키텍처 상세 설명 — 세그먼트 동시 다운로드, AES-128 복호화, 3단계 쓰기 전략, 메모리 안전 관리 및 진행률 추적.

FlowPick의 다운로드 엔진은 전체 시스템의 핵심으로, 스트리밍 세그먼트를 다운로드, 복호화, 병합하여 디스크에 기록하는 역할을 담당합니다. 본 문서는 내부 메커니즘을 깊이 이해하고자 하는 개발자와 고급 사용자를 대상으로 합니다.


아키텍처 개요

다운로드 엔진은 세 개의 핵심 모듈로 구성되며, 데이터가 파이프라인 방식으로 각 모듈을 순차적으로 통과합니다:

┌─────────────────────────────────────────────────┐
│                   다운로드 엔진                    │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 세그먼트   │→│ 세그먼트   │→│ 파일 쓰기      │  │
│  │ 다운로드  │  │ 처리     │  │ 모듈          │  │
│  │ 모듈     │  │ 모듈     │  │               │  │
│  │          │  │          │  │               │  │
│  │ · 동시   │  │ · 복호화  │  │ · FSA 스트림  │  │
│  │ · 재시도  │  │ · 연결   │  │ · StreamSaver │  │
│  │ · 속도 제한│  │ · 리무싱 │  │ · Blob 최후   │  │
│  └──────────┘  └──────────┘  └───────────────┘  │
│                                                  │
│  ┌─────────────────────────────────────────────┐ │
│  │              메모리 안전 관리자                │ │
│  │  · 크기 예측  · 임계값 검사  · 전략 선택       │ │
│  └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

데이터 흐름 전경

사용자가 다운로드 클릭부터 파일 디스크 기록까지의 완전한 체인:

사용자 다운로드 클릭
    │
    ▼
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ 1. 플레이리스트 │ ──→ │ 2. 세그먼트   │ ──→ │ 3. 세그먼트   │
│ 파싱         │     │ 다운로드     │     │ 처리         │
│              │     │              │     │              │
│ · M3U8 다운로드│     │ · Worker Pool│     │ · AES 복호화  │
│ · 세그먼트 리스트│     │ · 동시 제어   │     │ · TS 연결    │
│ 파싱         │     │ · 지수 백오프 │     │ · FFmpeg 리무 │
│ · 키 추출   │     │ · 진행률 보고 │     │ · 오디오/비디오│
│ · 크기 예측  │     │              │     │   병합        │
└──────────────┘     └──────────────┘     └──────┬───────┘
                                                  │
                                                  ▼
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ 6. 완료 알림   │ ←── │ 5. 정리 회수   │ ←── │ 4. 파일 쓰기   │
│              │     │              │     │              │
│ · 데스크톱 알림│     │ · 메모리 해제 │     │ · FSA 스트림  │
│ · 경로 복사   │     │ · 임시 파일   │     │ · StreamSaver│
│ · 큐 진행    │     │   정리       │     │ · Blob 최후  │
│              │     │ · 상태 리셋   │     │              │
└──────────────┘     └──────────────┘     └──────────────┘
플레이리스트 파싱 단계의 구체적 구현(Master Playlist vs Media Playlist, 암호화 감지)은 비디오 스니핑 — HLS 스트림을 참조하세요. 세그먼트 처리의 FFmpeg 리무싱 상세는 포맷 변환을 참조하세요. 엔진의 전체 시스템 내 위치는 프로젝트 아키텍처를 참조하세요.

엔진 생명주기

다운로드 엔진의 한 번의 완전한 실행은 다음 상태 전환을 거칩니다:

  [유휴]
    │ 사용자 다운로드 트리거
    ▼
  [초기화] ──── WASM 로딩, API 가용성 확인, 쓰기 전략 선택
    │
    ▼
  [플레이리스트 파싱] ── M3U8/MPD 다운로드 및 파싱, 세그먼트 리스트와 키 추출
    │
    ▼
  [세그먼트 다운로드] ── Worker Pool 동시 다운로드, 실시간 진행률 보고
    │
    ▼
  [세그먼트 처리] ── 복호화 → 연결/리무싱 → 디스크 쓰기
    │
    ├── 성공 → [정리] → [완료] → [유휴]
    │
    ├── 취소 → [정리] → [유휴]
    │
    └── 실패 → [정리] → [에러] → [유휴]

각 상태 전환은 해당하는 생명주기 훅을 트리거하며, UI 계층은 이 훅들을 감시하여 인터페이스 상태를 업데이트합니다. UI와 엔진의 상호작용 상세는 프로젝트 아키텍처 — 데이터 흐름을 참조하세요.


세그먼트 다운로드 모듈

동시 다운로더

세그먼트 다운로드는 Worker Pool 패턴으로 동시 제어를 구현합니다:

const downloadSegmentsConcurrently = async (
  segments: SegmentInfo[],
  onSegmentDownloaded: (buffer: ArrayBuffer, index: number) => void
) => {
  const concurrency = Math.min(config.concurrency, 8)
  let nextIndex = 0

  const worker = async () => {
    while (nextIndex < segments.length) {
      const index = nextIndex++
      const buffer = await downloadWithRetry(segments[index]!)
      onSegmentDownloaded(buffer, index)
    }
  }

  const workers = Array.from(
    { length: Math.min(concurrency, segments.length) },
    () => worker()
  )
  await Promise.all(workers)
}

설계 포인트:

  • 공유 nextIndex 카운터로 작업 할당, 미리 분할导致的 부하 불균형 방지
  • Worker 수가 세그먼트 총수를 초과하지 않아 유휴 Worker 생성 방지
  • 각 Worker가 독립 실행, 단일 세그먼트 실패가 다른 Worker에 영향 없음

Worker Pool 심층

Worker Pool 패턴의 핵심 장점은 동적 부하 균형에 있습니다. 미리 세그먼트 배열을 N등분하는 것과 달리, 공유 카운터는 다음을 보장합니다:

미리 분할(비권장):
Worker 1: [세그먼트 0-49]   ← 이 세그먼트들이 크면 Worker 1이 병목이 됨
Worker 2: [세그먼트 50-99]  ← 조기 완료 후 유휴 대기

공유 카운터(FlowPick 채택):
Worker 1: 세그먼트 0 → 세그먼트 3 → 세그먼트 5 → ...
Worker 2: 세그먼트 1 → 세그먼트 4 → 세그먼트 7 → ...
Worker 3: 세그먼트 2 → 세그먼트 6 → 세그먼트 8 → ...

각 Worker는 현재 세그먼트를 완료한 즉시 다음 미할당 세그먼트를 획득하며, 모든 세그먼트 처리 완료까지 반복합니다. 이 패턴은 세그먼트 크기가 불균일한 시나리오(HLS 스트림의 처음/끝 세그먼트는 보통 작고, 중간 세그먼트는 큼)에 자연스럽게 적응합니다.

동시 수는 config.concurrency로 제어되며, 상한은 8입니다. 사용자는 설정 참조에서 기본값을 조정하거나, 다운로드 인터페이스에서 실시간 수정할 수 있습니다. 일괄 다운로드 시나리오의 큐 스케줄링에 대해서는 일괄 다운로드 — 동시 수 선택 가이드를 참조하세요.

비동기 생성자 패턴

초대용량 파일(세그먼트 수 > 500)의 경우, 엔진은 비동기 생성자(AsyncGenerator) 패턴을 사용하여 모든 세그먼트 데이터를 한 번에 메모리에 로드하는 것을 피합니다:

async function* segmentGenerator(
  segments: SegmentInfo[],
  signal?: AbortSignal
): AsyncGenerator<ArrayBuffer> {
  for (const segment of segments) {
    if (signal?.aborted) break
    const buffer = await downloadWithRetry(segment)
    yield buffer
  }
}

배열 패턴과의 차이:

차원배열 패턴생성자 패턴
메모리 피크모든 세그먼트가 동시에 메모리에 존재현재 처리 중인 세그먼트만 메모리에 존재
적합 시나리오세그먼트 수 < 500세그먼트 수 > 500
진행률 추적총수 알려짐, 정확한 퍼센트총수 알려짐, 정확한 퍼센트
취소 응답현재 배치 완료 대기 필요즉시 응답

엔진은 세그먼트 수에 따라 자동으로 패턴을 선택하며, 사용자 개입 불필요합니다. 초대용량 파일 다운로드의 실제 시나리오는 라이브 방송 재생 저장를 참조하세요.

재시도 메커니즘

세그먼트 다운로드 실패 시 지수 백오프 재시도를 채택합니다:

const downloadWithRetry = async (
  segment: SegmentInfo,
  maxRetries = 3
): Promise<ArrayBuffer> => {
  let lastError: unknown
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await downloadAndDecryptSegment(segment)
    } catch (e) {
      lastError = e
      if (attempt < maxRetries - 1) {
        // 지수 백오프: 400ms, 800ms, 1600ms
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 400))
      }
    }
  }
  throw lastError
}

재시도 전략:

시도 횟수지연누적 대기
1차 실패400ms400ms
2차 실패800ms1200ms
3차 실패1600ms2800ms

재시도 트리거하지 않는 경우:

  • HTTP 4xx 클라이언트 에러(403, 404 등) — 재시도 무의미
  • CORS 에러 — 정책 문제, 재시도로 결과 변경 불가
  • 암호화 미지원 에러 — 재시도로 해결 불가능
재시도 메커니즘은 온라인 도구 — 에러 처리의 에러 분류표와 함께 사용됩니다. HTTP 4xx와 CORS 에러는 직접 throw되며, 상위 UI에서 해당하는 사용자 프롬프트를 표시합니다. 다운로드 중간 실패의 해결은 일반적인 문제 해결 — 다운로드 중간 실패를 참조하세요.

에러 분류

class FetchError extends Error {
  constructor(
    message: string,
    public status: number,
    public url: string
  ) {
    super(message)
    this.name = 'FetchError'
  }
}

에러는 HTTP 상태 코드별로 분류 처리됩니다:

상태 코드에러 유형사용자 프롬프트
403인증/인증 실패"접근 거부됨, URL 유효성 확인하세요"
404리소스 없음"세그먼트를 찾을 수 없음, 스트림이 만료되었을 수 있음"
502/503/504서버 에러"서버를 일시적으로 사용할 수 없음, 나중에 다시 시도하세요"
CORS크로스 오리진 제한"크로스 오리진 요청이 차단됨, 확장 버전 사용 권장"
Network네트워크 단절"네트워크 연결 실패, 네트워크 확인하세요"
CORS 에러를 만났을 때, 브라우저 확장 설치를 권장합니다. 확장은 webRequest 권한으로 동일 출처 정책을 우회할 수 있습니다. 더 많은 해결 방법은 일반적인 문제 해결를 참조하세요. CORS의 기술적 원리와 제한에 대해서는 알려진 제한 — 브라우저 제한를 참조하세요.

세그먼트 처리 모듈

AES-128 복호화

암호화된 HLS 스트림의 경우, FlowPick은 Web Crypto API를 사용하여 브라우저에서 복호화합니다:

const decryptAES128 = async (
  encryptedData: ArrayBuffer,
  key: ArrayBuffer,
  iv?: Uint8Array
): Promise<ArrayBuffer> => {
  const keyBytes = await crypto.subtle.importKey(
    'raw', key,
    { name: 'AES-CBC' },
    false,
    ['decrypt']
  )

  const ivBuffer = iv ? new Uint8Array(iv) : new Uint8Array(16)

  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-CBC', iv: ivBuffer },
    keyBytes,
    encryptedData
  )

  return decrypted
}

복호화 흐름:

  1. M3U8의 #EXT-X-KEY 태그에서 키 URI와 IV 추출
  2. 키 파일 다운로드(보통 16바이트 이진 파일)
  3. AES-128-CBC 모드로 각 세그먼트 복호화
  4. IV가 지정되지 않은 경우, 세그먼트 순번을 IV로 사용(HLS 규격 기본 동작)

성능 고려사항:

  • Web Crypto API는 하드웨어 가속을 사용하므로, 복호화 속도는 보통 병목이 아님
  • 각 세그먼트가 독립적으로 복호화되므로, 다운로드와 병행 가능
  • 키는 한 번만 다운로드하면 되며, 메모리에 캐싱
모든 복호화 작업은 브라우저 로컬에서 완료되며, 키와 세그먼트 데이터는 어떤 서버에도 업로드되지 않습니다. 암호화 감지의 더 많은 상세는 비디오 스니핑 — 암호화된 스트림을 참조하세요. 개인정보 보호 정책은 개인정보 보호와 보안을 참조하세요. 지원하지 않는 암호화 유형(Widevine, PlayReady 등)에 대해서는 알려진 제한 — DRM 보호 콘텐츠를 참조하세요.

TS 세그먼트 연결

TS 출력 포맷의 경우, 세그먼트를 직접 이진 연결합니다:

// 의사 코드
const merged = new Uint8Array(totalSize)
let offset = 0
for (const segment of segments) {
  merged.set(new Uint8Array(segment), offset)
  offset += segment.byteLength
}

이 방식은 CPU 오버헤드가 제로이며, 속도는 메모리 복사 속도에만 의존합니다.

FFmpeg WASM 리무싱

MP4 출력 포맷의 경우, FFmpeg WASM을 사용하여 컨테이너 변환을 수행합니다. 상세 내용은 포맷 변환 문서를 참조하세요.

TS 포맷만 필요하다면, TS 출력을 선택하여 FFmpeg를 완전히 건너뛰고 병합 속도를 대폭 향상시킬 수 있습니다. TS 파일은 VLC, PotPlayer 등 플레이어에서 직접 재생 가능합니다. 출력 포맷 선택 제안은 포맷 변환 — 출력 포맷 선택을 참조하세요.

DASH 스트림의 특수 처리

DASH 스트림은 HLS 스트림과 세그먼트 처리에서 현저한 차이가 있습니다:

HLS 스트림 처리:
  세그먼트 0 → 세그먼트 1 → 세그먼트 2 → ... → 연결 → 출력

DASH 스트림 처리(오디오/비디오 분리):
  초기화 세그먼트 → 비디오 세그먼트 0 → 비디오 세그먼트 1 → ... ─┐
                                              ├→ FFmpeg 병합 → 출력
  초기화 세그먼트 → 오디오 세그먼트 0 → 오디오 세그먼트 1 → ... ─┘

DASH의 FMP4(Fragmented MP4) 포맷은 특수 처리가 필요합니다:

  1. 초기화 세그먼트(ftyp + moov box)는 반드시 파일 선두에 위치해야 함
  2. 미디어 세그먼트(moof + mdat box)는 순서대로 추가
  3. 오디오/비디오 분리 시, 비디오와 오디오 트랙을 각각 다운로드한 후 마지막에 FFmpeg로 병합
DASH 스트림의 플레이리스트 파싱 상세는 비디오 스니핑 — DASH 스트림을 참조하세요. FMP4 재조립의 구체적 구현은 포맷 변환 — FMP4 재조립을 참조하세요. DASH 오디오/비디오 분리의 알려진 문제는 알려진 제한 — 분리 오디오/비디오 스트림을 참조하세요.

파일 쓰기 모듈

쓰기 모듈은 우선순위로 전략을 선택하여, 가능한 많은 브라우저에서 작동하도록 보장합니다.

전략 1: File System Access API(최적)

async function createFSAStream(
  filename: string,
  dirHandle?: FileSystemDirectoryHandle | null
): Promise<WritableStream<Uint8Array>> {
  let handle: FileSystemFileHandle

  if (dirHandle) {
    // 이미 디렉토리 권한 있음, 바로 파일 생성
    handle = await dirHandle.getFileHandle(filename, { create: true })
  } else {
    // 팝업으로 사용자에게 저장 위치 선택 요청
    handle = await window.showSaveFilePicker!({
      suggestedName: filename,
      types: [{
        description: 'Video',
        accept: { 'video/mp4': ['.mp4'] }
      }]
    })
  }

  const writable = await handle.createWritable()

  return new WritableStream<Uint8Array>({
    async write(chunk) { await writable.write(chunk) },
    async close() { await writable.close() },
    async abort(reason) { await writable.abort(reason) }
  }, {
    highWaterMark: 16 * 1024 * 1024 // 16MB 버퍼
  })
}

장점:

  • 스트리밍 쓰기, 메모리 점유 일정(단 16MB 버퍼만)
  • 임의 크기의 파일 지원
  • 디렉토리 영속화 후 반복 팝업 불필요

제한:

  • Chrome 86+ 및 Edge 86+ 만 지원
  • 사용자 제스처 필요(showDirectoryPicker는 사용자 클릭 이벤트에서 호출해야 함)

전략 2: StreamSaver.js(대안)

async function createStreamSaverStream(
  filename: string,
  fileSize?: number
): Promise<WritableStream<Uint8Array>> {
  const ss = await getStreamSaver()
  const fileStream = ss.createWriteStream(filename, { size: fileSize })
  return fileStream
}

장점:

  • 스트리밍 쓰기, 메모리 점유 낮음
  • FSA API보다 호환성 양호

제한:

  • Service Worker 지원 필요
  • mitm.htmlstreamsaver-sw.js 올바른 배포 필요
  • 일부 기업 네트워크 환경에서 Service Worker 차단 가능

전략 3: Blob 최후 수단

// 의사 코드
const blob = new Blob([mergedData], { type: 'video/mp4' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)

제한:

  • 전체 파일이 메모리에 로드됨
  • 하드 제한 1.5GB(MEMORY_CONFIG.maxBlobSize)
  • 800MB 초과 시 콘솔 경고(MEMORY_CONFIG.warnBlobSize)

전략 비교 요약

차원FSA APIStreamSaver.jsBlob
메모리 점유16MB(일정)낮음(스트리밍)파일 크기와 동일
파일 크기 상한무제한무제한1.5GB
브라우저 요구사항Chrome/Edge 86+Service Worker 지원모든 브라우저
디렉토리 영속화지원지원 안 함지원 안 함
다운로드 경험최상양호일반
각 브라우저의 이 세 가지 전략 지원 현황은 브라우저 호환성 — 기능 강등 전략을 참조하세요. 온라인 도구에서의 쓰기 전략 사용은 온라인 도구 — 쓰기 전략 우선순위를 참조하세요. Blob 모드의 크기 제한과 해결 방법은 알려진 제한 — 파일 크기 제한를 참조하세요.

메모리 안전 관리

크기 예측

다운로드 시작 전, 엔진은 샘플링으로 총 파일 크기를 예측합니다:

async function estimateSegmentsSize(
  segments: ArrayBuffer[] | AsyncGenerator<ArrayBuffer>,
  totalCount: number
): Promise<{ estimatedSize: number; isEstimate: boolean }> {
  // 샘플링 수량: min(5, max(1, totalCount * 0.1))
  const sampleCount = Math.min(5, Math.max(1, Math.floor(totalCount * 0.1)))

  // 처음 몇 세그먼트 다운로드 및 평균 크기 계산
  let sampledTotal = 0
  for (let i = 0; i < sampleCount; i++) {
    sampledTotal += sampleSegment.byteLength
  }

  const avgSample = sampledTotal / sampleCount
  return {
    estimatedSize: Math.round(avgSample * totalCount),
    isEstimate: true
  }
}

샘플링 전략 설명:

  • 샘플링 수량은 min(5, max(1, 세그먼트 총수 × 10%))를 취함, 작은 스트림은 최소 1개 샘플링, 큰 스트림은 최대 5개 샘플링 보장
  • 샘플링 결과는 isEstimate: true로 표시, UI 계층은 이에 따라 "약 XX MB"를 정확한 값 대신 표시
  • HLS 스트림의 경우, Master Playlist에 BANDWIDTH 속성이 선언되어 있다면, 엔진은 이 값을 우선 예측 참조로 사용

전략 선택 논리

예상 크기 < 800MB  → 임의 전략 가능
예상 크기 800MB-1.5GB → 우선 스트리밍 쓰기, Blob 모드 경고 표시
예상 크기 > 1.5GB → 강제 스트리밍 쓰기, Blob 모드 거부

메모리 설정 상수

const MEMORY_CONFIG = {
  maxBlobSize: 1500 * 1024 * 1024,    // 1.5GB 하드 제한
  warnBlobSize: 800 * 1024 * 1024,    // 800MB 경고 임계값
}

런타임 메모리 모니터링

다운로드 전 예측 외에, 엔진은 실행 중에도 지속적으로 메모리 압력을 모니터링합니다:

const checkMemoryPressure = (): 'normal' | 'warning' | 'critical' => {
  // 현재 할당된 세그먼트 버퍼 총 크기 확인
  const allocatedMB = allocatedBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) / 1048576

  if (allocatedMB > 1200) return 'critical'
  if (allocatedMB > 600) return 'warning'
  return 'normal'
}

메모리 압력이 critical 레벨에 도달하면, 엔진은:

  • 새로운 세그먼트 다운로드 요청 일시 중지
  • 이미 다운로드된 세그먼트를 우선 디스크에 기록
  • 쓰기 완료된 세그먼트 버퍼 해제
메모리 안전은 FlowPick 다운로드 엔진의 핵심 설계 고려사항 중 하나입니다. 초대용량 파일(예: 20GB+ 라이브 방송 재생)의 경우, 엔진은 강제로 스트리밍 쓰기 전략을 사용하여, 브라우저가 메모리 부족으로 크래시되지 않도록 보장합니다. 대용량 파일 다운로드의 실제 시나리오는 라이브 방송 재생 저장를 참조하세요. 대용량 파일 다운로드 실패의 해결은 일반적인 문제 해결 — 대용량 파일 다운로드 실패를 참조하세요.

진행률 추적

진행률 계산 모델

엔진은 다단계 진행률 모델을 사용하여 사용자에게 정확한 피드백을 제공합니다:

총 진행률 = (파싱 단계 % × 파싱 가중치) 
          + (다운로드 단계 % × 다운로드 가중치) 
          + (처리 단계 % × 처리 가중치) 
          + (쓰기 단계 % × 쓰기 가중치)

기본 가중치 배분:

단계가중치비율설명
플레이리스트 파싱0.055%빠르게 완료, 상대적으로 적은 시간 소요
세그먼트 다운로드0.8080%가장 시간 소요, 네트워크 대역폭 의존
세그먼트 처리0.1010%복호화/병합, CPU 성능 의존
파일 쓰기0.055%스트리밍 쓰기는 빠름, Blob은 일시적 정체 가능

실시간 속도 추정

다운로드 속도는 슬라이딩 윈도우(Sliding Window) 알고리즘으로 추정합니다:

class SpeedEstimator {
  private samples: Array<{ timestamp: number; bytes: number }> = []
  private windowSize = 10000 // 10초 윈도우

  addSample(bytes: number): void {
    const now = Date.now()
    this.samples.push({ timestamp: now, bytes })

    // 윈도우 외 샘플 제거
    while (this.samples.length > 0 && now - this.samples[0]!.timestamp > this.windowSize) {
      this.samples.shift()
    }
  }

  getSpeed(): number {
    if (this.samples.length < 2) return 0

    const oldest = this.samples[0]!
    const newest = this.samples[this.samples.length - 1]!
    const timeDiff = newest.timestamp - oldest.timestamp
    const totalBytes = newest.bytes - oldest.bytes

    return (totalBytes / timeDiff) * 1000 // bytes/sec
  }
}

알고리즘 특징:

  • 최근 10초 데이터만 사용, 과거 데이터의 영향 제거
  • 순간 급증/급감 평활화
  • 네트워크 변동에 민감하게 반응

남은 시간 계산

남은 시간(ETA)은 현재 속도와 남은 데이터량으로 계산:

ETA = (총 바이트 - 다운로드된 바이트) / 현재 속도

특수 처리:

  • 초기 다운로드 단계(샘플 부족): "속도 산출 중..." 표시
  • 속도가 0에 가까울 때: ETA 표시 안 함("계산 중...")
  • 네트워크 중단 시: 마지막 유효 ETA 유지, "네트워크 불안정" 표시

UI 업데이트 주기

진행률 데이터의 UI 업데이트는 요청 애니메이션(requestAnimationFrame)으로 제어:

function updateProgressUI(progress: ProgressData): void {
  requestAnimationFrame(() => {
    progressBar.value = progress.percentage
    speedEl.textContent = formatSpeed(progress.speed)
    etaEl.textContent = formatTime(progress.eta)
  })
}

설계 이유:

  • UI 새로고침 빈도 제어(보통 60fps)
  • 과도한 DOM 조작 방지
  • 배터리 절약(특히 노트북)
진행률 표시의 사용자 경험 최적화에 대해서는 온라인 도구 — 진행 정보를 참조하세요. 대용량 파일의 정확한 진행률 계산 문제는 알려진 제한 — 진행률 정확도를 참조하세요.

취소 및 정리

취소 메커니즘

사용자가 다운로드를 취소하면, 엔진은 즉시 정리 프로세스를 시작합니다:

async function cancelDownload(abortController: AbortController): Promise<void> {
  // 1. AbortSignal 발신
  abortController.abort()

  // 2. 진행 중인 fetch 요청 취소
  // (AbortSignal이 fetch에 전달되어 있음)

  // 3. Worker Pool 중지
  // (다음 세그먼트 획득 시 aborted 체크)

  // 4. 임시 파일 정리
  await cleanupTempFiles()

  // 5. 메모리 해제
  releaseBuffers()
}

취소 보장:

  • AbortController를 통해 진행 중인 네트워크 요청 즉시 취소
  • Worker Pool은 다음 작업 획득 시 취소 상태 확인
  • 이미 다운로드된 세그먼트는 폐기(미완성 파일로 남지 않음)
  • 디스크 공간 회수

정리 프로세스

다운로드 완료 또는 취소 후, 엔진은 다음 정리를 실행:

  1. 메모리 해제: 모든 세그먼트 버퍼 배열 참조 삭제(GC 허용)
  2. 임시 파일 삭제: 부분 다운로드된 파일 제거
  3. WASM 인스턴스 해제: FFmpeg WASM 메모리 회수
  4. 상태 리셋: 엔진 상태를 [유휴]로 복귀
  5. 이벤트 발생: UI 계층에 완료/취소/실패 이벤트 통지
취소 및 정리의 UI 상호작용에 대해서는 프로젝트 아키텍처 — 상태 관리를 참조하세요. 일괄 다운로드 시의 취소 동작은 일괄 다운로드 — 큐 관리를 참조하세요.

성능 최적화

네트워크 계층 최적화

HTTP 연결 재사용

엔진은 동일 출처 세그먼트에 대해 HTTP/2 멀티플렉싱을 활용하여 연결 오버헤드를 줄입니다:

// 같은 도메인의 세그먼트는 자동으로 연결 공유
// (브라우저 fetch API 내장 최적화)

병렬 다운로드 제어

동시 수는 자동 조정됩니다:

  • 기본값: 6개 동시 연결
  • 상한: 8개(브라우저 동일 도메인 제한 고려)
  • 사용자 설정 가능: 설정 참조에서 수정

속도 제한

선택적 속도 제한 기능:

const throttleDownload = async (
  url: string,
  maxBytesPerSec: number
): Promise<ArrayBuffer> => {
  const response = await fetch(url)
  const reader = response.body!.getReader()

  const chunks: Uint8Array[] = []
  let totalBytes = 0
  const startTime = Date.now()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    chunks.push(value!)
    totalBytes += value!.length

    // 속도 초과 시 대기
    const elapsed = Date.now() - startTime
    const expected = (totalBytes / maxBytesPerSec) * 1000
    if (elapsed < expected) {
      await sleep(expected - elapsed)
    }
  }

  return concatenate(chunks)
}

메모리 최적화

스트리밍 처리

가능한 한 세그먼트를 스트리밍 방식으로 처리:

다운로드 → [버퍼] → 복호화 → [버퍼] → 디스크 쓰기 → 버퍼 해제

각 세그먼트 처리 완료 후 즉시 메모리 해제, 누적 방지.

WASM 메모리 관리

FFmpeg WASM의 메모리 사용 최적화:

// FFmpeg 입력용 원시 버퍼 직접 사용
// (불필요한 복사 방지)
const ffmpeg = createFFmpeg({ log: true })

// TS → MP4 변환 시:
// 1. 세그먼트를 FFmpeg의 virtual filesystem에 쓰기
// 2. FFmpeg 실행
// 3. 출력 파일 읽기
// 4. virtual filesystem 정리

CPU 최적화

Web Worker 오프로ading

무거운 작업을 메인 스레드에서 분리:

작업실행 위치이유
세그먼트 다운로드Web Worker메인 스레드 차단 방지
AES 복호화Web WorkerCrypto API는 Worker에서도 사용 가능
FFmpeg 실행Web Worker (WASM)WASM은 기본적으로 Worker에서 실행
진행률 계산메인 스레드UI 업데이드 필요

비차단 UI

모든 장기 작업은 await와 비동기 패턴을 사용하여 UI 응답성 유지:

// 좋음: 비차단
async function startDownload() {
  updateUI('downloading')
  await engine.download()
  updateUI('complete')
}

// 나쁨: 차단
function startDownload() {
  updateUI('downloading')
  engine.download() // 메인 스레드 차단!
  updateUI('complete')
}
성능 최적화의 실제 효과 측정은 포맷 변환 — 성능 비교를 참조하세요. 대용량 파일 다운로드의 메모리 사용 분석은 라이브 방송 재생 — 메모리 관리를 참조하세요.

로깅 및 디버깅

로그 레벨

엔진은 다음 로그 레벨을 지원합니다:

레벨용도예시
error복구 불가능한 에러세그먼트 다운로드 3회 실패
warn복구 가능한 문제세그먼트 재시도 1회
info중요 이벤트다운로드 시작/완료
debug상세 정보각 세그먼트 다운로드 시작

개발자 도구 통합

브라우저 개발자 도구 Console에서 엔진 상태 조회 가능:

// FlowPick DevTools 확장(있는 경우)
window.__FLOWPICK_ENGINE__.getStatus()
// => { state: 'downloading', progress: 45, speed: '2.3 MB/s' }

// 또는 Console 직접 로그 확인
// [FlowPick] [INFO] Download started: video.m3u8
// [FlowPick] [WARN] Segment #42 retry 1/3
// [FlowPick] [ERROR] Segment #42 failed after 3 retries
문제 해결 시, 브라우저 개발자 도구(F12)의 Console 탭에서 FlowPick 로그를 확인하세요. 로그에는 다운로드 진행 상황, 에러 상세, 성능 데이터가 포함됩니다. 더 많은 디버깅 정보는 일반적인 문제 해결 — 로그 수집를 참조하세요.

관련 문서