Bleeding edge

RRD Viewer 로딩 줄이기: HTTP Range, Segment Manifest, 그리고 현실적인 한계 본문

CS

RRD Viewer 로딩 줄이기: HTTP Range, Segment Manifest, 그리고 현실적인 한계

codevil 2026. 7. 3. 17:18

Rerun 기반의 RRD viewer를 웹에서 사용하다 보면, 가장 먼저 체감되는 문제는 “데이터를 어떻게 그릴 것인가”보다 “첫 화면이 언제 뜨는가”였다.

기능 자체는 동작하고 있었다. 서버는 .rrd 파일을 만들고, 프론트는 그 파일을 받아 decode한 뒤 canvas에 그렸다. 하지만 세션 데이터가 커질수록 첫 로딩이 길어졌다. 사용자는 특정 구간만 보고 싶은데, viewer는 꽤 큰 RRD 데이터를 받은 뒤 decode해야 했다.

처음 떠올린 비교 대상은 MP4였다.

브라우저에서 MP4를 재생할 때는 전체 영상을 모두 다운로드한 뒤 재생하지 않는다. 브라우저는 필요한 byte 구간을 Range 요청으로 가져오고, 서버는 206 Partial Content로 일부 데이터만 내려준다. 그래서 큰 영상 파일도 첫 재생이 빠르고, seek도 자연스럽다.

그래서 이번 작업의 출발점은 단순했다.

RRD도 MP4처럼 HTTP 위에서 필요한 구간만 가져올 수 있지 않을까?

다만 결론부터 말하면, HTTP Range 지원만으로는 충분하지 않았다.

서버와 프록시는 Range 가능한 구조가 되었지만, 현재 custom RRD viewer는 아직 RRD 내부의 시간과 byte offset 관계를 이해하지 못한다. 그래서 체감 성능 개선은 HTTP byte range 자체보다는 segment manifest + 작은 RRD segment + background prefetch + decoded cache 정책에서 더 크게 나왔다.

목표

초기 목표는 다음과 같았다.

RRD 파일 전체 다운로드/로딩 병목을 줄이고,
MP4처럼 HTTP range/부분 전송 기반으로 RRD viewer가 필요한 구간을 가져오게 만들자.

조금 더 기술적으로 표현하면 다음과 같다.

HTTP byte-range streaming for RRD

또는 기능 이름처럼 말하면:

Range-based segmented RRD streaming

작업 당시 서버에는 이미 recording.index.json과 /data/segments/.../recording.rrd 구조가 있었고, 프론트에도 manifest → 첫 세그먼트 → 다음 세그먼트 prefetch 흐름이 일부 구현되어 있었다. 그래서 완전히 새 프로토콜을 만드는 것보다는, 기존 segment 기반 구조에 HTTP Range 계약을 추가하는 방향으로 진행했다.

HTTP Range Request란?

HTTP Range Request는 클라이언트가 서버에게 “파일 전체가 아니라 이 byte 구간만 주세요”라고 요청하는 방식이다.

예를 들어 클라이언트가 다음과 같이 요청한다.

GET /data/cell1/2026-07-03/session1/recording.rrd
Range: bytes=0-1023

그러면 서버는 전체 파일이 아니라 0번 byte부터 1023번 byte까지만 응답한다.

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 0-1023/104857600
Content-Length: 1024
Content-Type: application/octet-stream

여기서 중요한 헤더는 세 가지다.

Accept-Ranges: bytes
Content-Range: bytes 0-1023/104857600
Content-Length: 1024

Accept-Ranges는 서버가 byte range 요청을 지원한다는 뜻이고, Content-Range는 이번 응답이 전체 파일 중 어느 구간인지 알려준다. 응답 상태 코드는 200 OK가 아니라 206 Partial Content가 된다.

어떤 데이터가 Range Streaming에 잘 맞을까?

HTTP Range를 적용한다고 해서 모든 데이터가 갑자기 빠르게 열리는 것은 아니다. Range는 결국 “파일의 특정 byte 구간만 가져오는 기술”이다. 따라서 데이터 포맷이 byte 단위 부분 접근과 잘 맞아야 효과가 크다.

가장 잘 맞는 대표적인 예는 MP4 같은 미디어 파일이다.

MP4는 브라우저와 player가 포맷 구조를 이해한다. 파일 안에 시간 정보, keyframe, metadata가 있고, player는 특정 시점으로 이동할 때 어느 근처 byte를 읽어야 할지 판단할 수 있다. 그래서 사용자가 10분짜리 영상의 8분 지점으로 seek해도, 브라우저는 전체 파일을 처음부터 끝까지 다시 받지 않고 필요한 구간만 Range 요청으로 가져올 수 있다.

이런 데이터는 Range Streaming과 잘 맞는다.

- MP4, WebM 같은 미디어 파일
- 큰 로그 파일
- index가 있는 binary container
- chunk 단위로 독립 decode 가능한 데이터
- footer/header에 metadata가 있고, 일부 구간만 읽어도 의미가 있는 파일
- Parquet처럼 row group 단위 접근이 가능한 데이터
- MCAP처럼 message/chunk index를 통해 특정 시간대 접근이 가능한 데이터

반대로 Range와 잘 맞지 않는 데이터도 있다.

- 앞부분부터 순차적으로 모두 읽어야 의미가 생기는 데이터
- 압축 스트림 하나로 길게 묶여 있는 데이터
- 특정 byte 구간만 잘라 읽으면 decode가 불가능한 데이터
- 파일 전체 metadata를 읽어야만 해석할 수 있는 데이터
- 시간이나 record 위치와 byte offset의 관계를 알 수 없는 데이터

예를 들어 하나의 큰 gzip 파일은 Range와 궁합이 좋지 않다. 중간 byte부터 가져와도 압축 해제 상태가 없으면 바로 decode하기 어렵다. JSON도 비슷하다. 큰 JSON 파일에서 중간 byte만 가져와도 문법적으로 완전한 JSON 조각이 아닐 가능성이 높다. 결국 처음부터 파싱하거나 별도 index가 필요하다.

즉, Range가 잘 먹히는 데이터의 핵심 조건은 다음과 같다.

1. 파일의 일부 byte 구간만 읽어도 의미가 있어야 한다.
2. 클라이언트가 원하는 시간/record와 byte offset의 관계를 알 수 있어야 한다.
3. 해당 구간을 독립적으로 decode할 수 있어야 한다.
4. 서버가 파일 전체를 다시 만들지 않고, 저장된 파일에서 seek/read할 수 있어야 한다.

RRD는 왜 Range를 적용해볼 만하다고 봤나?

RRD는 MP4처럼 브라우저가 기본적으로 이해하는 포맷은 아니다. <video> 태그에 .rrd를 넣는다고 브라우저가 알아서 시간 seek를 해주지는 않는다.

그럼에도 RRD에 Range를 적용해볼 만하다고 본 이유가 있었다.

첫째, RRD는 결국 하나의 binary artifact다. 서버 입장에서는 .rrd 파일을 만들어 응답하고 있었고, 이 파일은 HTTP 위에서 application/octet-stream으로 전달되고 있었다. 즉, transport layer 관점에서는 MP4와 마찬가지로 “큰 binary file”이다.

둘째, 이미 cache layer가 있었다. 매 요청마다 RRD를 새로 만들기보다는 캐시된 .rrd 파일을 재사용할 수 있는 구조였다. 이 말은 서버가 bytes를 통째로 만들어 응답하지 않고, 파일 경로를 기준으로 seek해서 특정 byte만 읽는 구조로 바꿀 수 있다는 뜻이다.

셋째, 이미 segment 개념이 있었다. 서버에는 recording.index.json과 /data/segments/.../recording.rrd가 있었고, 프론트에도 manifest → 첫 세그먼트 → 다음 세그먼트 prefetch 흐름이 일부 구현되어 있었다.

이게 중요했다.

RRD 전체 파일 하나에 Range만 적용하면, 클라이언트가 “10초 지점은 몇 byte부터 읽어야 하는지” 알기 어렵다. 하지만 segment manifest가 있으면 이야기가 달라진다.

recording.index.json
  ├─ segment 0: 0초 ~ 1.1초
  ├─ segment 1: 1초 ~ 2.1초
  ├─ segment 2: 2초 ~ 3.1초
  └─ segment 3: 3초 ~ 4.1초

이 구조에서는 시간 구간과 파일 단위가 어느 정도 매핑된다. 클라이언트는 전체 episode를 몰라도, 우선 첫 번째 segment만 받아서 viewer를 띄울 수 있다. 그리고 남은 segment는 백그라운드에서 순차적으로 받아 cache에 채워 넣을 수 있다.

즉, RRD에 Range를 적용할 수 있을 것 같았던 이유는 “RRD 자체가 완벽히 random access 가능한 포맷이라서”라기보다는, 다음 조건들이 이미 어느 정도 맞아 있었기 때문이다.

- RRD가 하나의 binary file로 서빙되고 있었다.
- 서버에 RRD cache layer가 있었다.
- 캐시된 파일을 path 기반으로 다룰 수 있었다.
- 이미 segment RRD와 manifest 구조가 있었다.
- 프론트도 segment 기반 로딩 흐름을 일부 갖고 있었다.

그래서 이번 작업의 방향은 “RRD를 MP4처럼 완전히 자동 seek 가능한 포맷으로 만들자”가 아니었다.

더 정확한 목표는 이랬다.

RRD 파일도 HTTP 위에서 media-like file처럼 다룰 수 있게 만들고,
segment manifest와 조합해서 초기 로딩과 구간 이동 비용을 줄이자.

Range를 지원해도 Range 요청이 없으면 200 OK다

서버와 프록시에 Range 지원을 넣은 뒤 실제 브라우저 Network를 확인해보니 중요한 한계가 보였다.

응답 헤더에는 분명히 다음이 있었다.

Accept-Ranges: bytes

즉, 서버는 Range 요청을 받을 준비가 되어 있었다.

하지만 요청 헤더에는 이게 없었다.

Range: bytes=...

그래서 응답은 206 Partial Content가 아니라 여전히 200 OK였다.

정리하면 이렇다.

- 서버 응답 헤더에는 Accept-Ranges: bytes가 있음
- 하지만 브라우저 요청 헤더에는 Range: bytes=...가 없음
- 그래서 응답은 206 Partial Content가 아니라 200 OK
- 현재 custom RRD decoder는 fetch(...).arrayBuffer()로 segment 전체를 받은 뒤 decode함

이 지점이 중요했다.

Range 지원은 기반 계약일 뿐이다. 서버가 Accept-Ranges: bytes를 내려준다고 해서 클라이언트가 자동으로 byte range 요청을 보내는 것은 아니다.

MP4는 브라우저와 video element가 포맷을 이해하기 때문에 필요한 byte range를 알아서 요청한다. 하지만 현재 custom RRD viewer는 RRD 내부에서 시간과 byte offset이 어떻게 연결되는지 모른다. 따라서 viewer가 직접 Range 요청을 만들지 못하고, segment .rrd 전체를 받아서 WASM decoder로 decode한다.

즉, MP4 같은 효과가 나려면 다음이 필요하다.

1. viewer가 시간 → byte offset 관계를 알아야 한다.
2. 필요한 byte range만 요청해야 한다.
3. 받은 byte range만으로 독립 decode할 수 있어야 한다.

현재 구조는 아직 여기에 도달하지 않았다. 그래서 이번 작업은 backend와 proxy의 기반 계약을 만든 작업이었다.

다만 실제 3G 환경에서 체감 성능을 좌우한 것은 Range 헤더 자체보다 segment의 크기였다. 현재 viewer는 segment RRD를 통째로 받아 decode하기 때문에, segment를 얼마나 작게 자르느냐가 첫 화면 시간에 직접 영향을 줬다.

실제 병목은 전체 recording.rrd가 아니었다

초기에는 “전체 recording.rrd가 커서 느리다”라고 생각하기 쉬웠다. 하지만 segment manifest 구조를 쓰는 상태에서는 실제 첫 화면 병목이 조금 달랐다.

병목은 전체 episode RRD가 아니라 다음 흐름에 있었다.

첫 segment RRD 전체 다운로드
  → WASM decoder가 segment 전체 decode
  → JPEG bitmap 생성
  → canvas에 첫 frame 그리기

초기 segment 정책은 다음과 같았다.

segmentSeconds: 10.0
overlapSeconds: 1.0

이 정책에서는 첫 segment가 샘플 기준 약 15MB 수준까지 나왔다. 3G 환경에서 테스트해보면 첫 화면까지 여전히 오래 걸렸다. 이유는 단순했다. 첫 화면을 그리려면 첫 segment 전체를 다운로드해야 했고, WASM decoder가 그 segment 전체를 decode해야 했기 때문이다.

즉, 서버가 Range를 지원하더라도 viewer가 Range 요청을 보내지 않는 현재 구조에서는 첫 segment 크기가 곧 첫 화면 대기 시간에 직접 영향을 줬다.

Segment 정책 줄이기

그래서 추가 개선은 segment를 더 작게 자르는 방향으로 진행했다.

첫 번째 개선은 다음과 같았다.

기존:
segmentSeconds: 10.0
overlapSeconds: 1.0
cache key suffix: policy-s10-o1

변경:
segmentSeconds: 2.0
overlapSeconds: 0.25
cache key suffix: policy-s2-o0p25

이때 샘플 첫 segment 크기는 대략 다음처럼 줄었다.

약 15.5MB → 약 4.3MB

이후 한 번 더 줄였다.

변경:
segmentSeconds: 1.0
overlapSeconds: 0.1
cache key suffix: policy-s1-o0p1

이 정책에서는 샘플 첫 segment 크기가 약 2.1MB 수준까지 줄었다.

정리하면 다음과 같다.

10.0s + 1.0s overlap
  → policy-s10-o1
  → 첫 segment 약 15.5MB

2.0s + 0.25s overlap
  → policy-s2-o0p25
  → 첫 segment 약 4.3MB

1.0s + 0.1s overlap
  → policy-s1-o0p1
  → 첫 segment 약 2.1MB

cache key suffix가 정책별로 달라지는 것도 중요하다. 기존 캐시와 새 정책의 캐시가 같은 이름을 쓰면, segment 정책을 바꿔도 예전 segment가 재사용될 수 있다.

그래서 정책을 cache key에 포함했다.

기존: policy-s10-o1
중간: policy-s2-o0p25
현재: policy-s1-o0p1

이렇게 하면 기존 캐시와 충돌하지 않고 새 정책의 segment가 생성된다.

overlapSeconds 0.1의 의미

여기서 헷갈리기 쉬운 부분이 있다.

overlapSeconds: 0.1이라고 해서 viewer가 0.1초만 받고 그리는 것은 아니다.

현재 구조에서 첫 segment 범위는 대략 다음과 같다.

0s ~ 1.1s

즉, segmentSeconds: 1.0에 overlapSeconds: 0.1이 붙어서 첫 segment는 대략 1.1초 구간을 포함한다.

그리고 현재 custom RRD viewer는 이 segment .rrd 파일 전체를 받은 뒤 decode한다.

따라서 첫 화면 대기 단위는 “0.1초”가 아니다.

첫 화면 대기 단위 = 1초 segment + 0.1초 overlap 전체

즉, 현재 구조에서 overlapSeconds는 “받는 최소 단위”가 아니라 segment 경계에서 데이터가 끊기지 않도록 조금 겹쳐 담는 보정값에 가깝다.

Backend 구현 방향

기존 구조에서는 .rrd 파일을 요청하면 서버가 bytes를 만들고, 그 bytes를 응답하는 흐름이었다.

개념적으로 보면 이런 식이다.

data = cache.get_or_build(...)
return Response(data)

이 방식은 단순하지만 파일이 커질수록 부담이 커진다.

문제는 크게 세 가지다.

첫째, 서버가 큰 RRD 파일을 메모리에 올릴 수 있다. 둘째, 클라이언트도 전체 파일을 받은 뒤 decode해야 하므로 첫 화면이 늦어진다. 셋째, 사용자는 일부 구간만 보고 싶은데 전체 데이터를 전송하게 된다.

그래서 캐시 계층을 bytes 중심에서 file path 중심으로 바꿨다.

path = cache.get_or_build_path(...)
return range_file_response(path, request)

이렇게 하면 서버는 캐시된 파일 경로를 얻고, 실제 응답은 파일 스트림으로 처리할 수 있다. Range 요청이 없으면 전체 파일을 스트리밍하고, Range 요청이 있으면 해당 byte 구간만 읽어서 내려준다.

서버에서 필요한 핵심 함수는 세 가지다.

첫 번째는 Range 헤더를 파싱하는 함수다.

_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")

def parse_range(range_header: str, size: int) -> tuple[int, int] | None:
    match = _RANGE_RE.match(range_header.strip())
    if not match or size <= 0:
        return None

    start_raw, end_raw = match.groups()

    if start_raw == "" and end_raw == "":
        return None

    if start_raw == "":
        suffix_length = int(end_raw)
        if suffix_length <= 0:
            return None
        start = max(0, size - suffix_length)
        end = size - 1
    else:
        start = int(start_raw)
        end = int(end_raw) if end_raw else size - 1

        if start >= size or end < start:
            return None

        end = min(end, size - 1)

    return start, end

두 번째는 파일의 특정 구간만 읽는 generator다.

def iter_file_range(path: Path, start: int, end: int) -> Iterator[bytes]:
    remaining = end - start + 1

    with path.open("rb") as handle:
        handle.seek(start)

        while remaining > 0:
            chunk = handle.read(min(1024 * 1024, remaining))
            if not chunk:
                break

            remaining -= len(chunk)
            yield chunk

세 번째는 실제 응답을 만드는 함수다.

def rrd_file_response(path: Path, etag: str, request: Request) -> Response:
    size = path.stat().st_size

    headers = {
        "Accept-Ranges": "bytes",
        "Cache-Control": "public, max-age=3600",
        "ETag": f'"{etag}"',
        "Content-Disposition": f'inline; filename="{path.name}"',
    }

    range_header = request.headers.get("range")

    if not range_header:
        headers["Content-Length"] = str(size)
        return StreamingResponse(
            iter_file_range(path, 0, max(0, size - 1)),
            media_type="application/octet-stream",
            headers=headers,
        )

    byte_range = parse_range(range_header, size)

    if byte_range is None:
        return Response(
            status_code=416,
            headers={
                **headers,
                "Content-Range": f"bytes */{size}",
            },
        )

    start, end = byte_range

    headers["Content-Range"] = f"bytes {start}-{end}/{size}"
    headers["Content-Length"] = str(end - start + 1)

    return StreamingResponse(
        iter_file_range(path, start, end),
        status_code=206,
        media_type="application/octet-stream",
        headers=headers,
    )

이렇게 하면 .rrd endpoint는 더 이상 bytes 응답을 직접 만들지 않고, 캐시된 파일을 HTTP Range 가능한 파일처럼 제공한다.

@router.get("/data/{cell}/{date}/{session}/recording.rrd")
def get_recording(
    request: Request,
    cell: str,
    date: str,
    session: str,
):
    path = disk.get_or_build_path(
        cell,
        date,
        session,
        builder=lambda: build_rrd_bytes(cell, date, session),
    )

    return rrd_file_response(
        path,
        disk.key(cell, date, session),
        request,
    )

416도 중요하다

Range 요청을 구현할 때 206만 생각하면 안 된다. 클라이언트가 파일 크기를 벗어난 구간을 요청할 수도 있다.

예를 들어 파일 크기가 1000 bytes인데 다음과 같이 요청하면 유효하지 않다.

Range: bytes=1000-

이 경우 서버는 416 Range Not Satisfiable로 응답해야 한다.

HTTP/1.1 416 Range Not Satisfiable
Accept-Ranges: bytes
Content-Range: bytes */1000

이 처리를 해두면 클라이언트나 프록시가 잘못된 Range를 보냈을 때도 명확하게 실패한다.

Next.js Proxy에서 Range를 잃지 않기

이번 구조에서는 backend 앞에 tdp-web의 /api/cache 프록시가 있었다.

이때 backend만 Range를 지원한다고 끝이 아니다. 중간의 Next.js route handler가 Range, If-Range 같은 헤더를 upstream으로 전달하지 않으면 브라우저 입장에서는 Range가 깨진다.

따라서 프록시에서도 요청 헤더를 보존해야 한다.

function forwardedRequestHeaders(request: NextRequest) {
  const headers = new Headers();

  headers.set("accept", request.headers.get("accept") ?? "*/*");

  for (const name of ["range", "if-range", "if-none-match"]) {
    const value = request.headers.get(name);
    if (value) headers.set(name, value);
  }

  return headers;
}

그리고 upstream 응답의 Range 관련 헤더도 브라우저까지 그대로 내려줘야 한다.

for (const name of [
  "accept-ranges",
  "cache-control",
  "content-disposition",
  "content-length",
  "content-range",
  "content-type",
  "etag",
]) {
  const value = response.headers.get(name);
  if (value) headers.set(name, value);
}

또 하나 중요한 점은 proxy에서 upstream body를 arrayBuffer()로 읽지 않는 것이다.

피해야 하는 방식은 이렇다.

return new Response(await response.arrayBuffer(), {
  status: response.status,
  headers,
});

이렇게 하면 프록시가 응답을 다시 통째로 메모리에 올리게 된다.

대신 다음처럼 stream body를 그대로 전달한다.

return new Response(response.body, {
  status: response.status,
  headers,
});

이 차이가 꽤 중요하다. Range를 지원한다고 해놓고 프록시에서 다시 전체 버퍼링을 해버리면, 구조상 이점이 줄어든다.

UI에서 Loading을 언제 풀 것인가

성능을 줄이는 것만큼 중요했던 문제가 하나 더 있었다. 바로 stale frame 문제였다.

처음에는 manifest가 도착하거나 첫 HTTP 응답이 시작되면 Loading episode를 풀려고 했다. 이렇게 하면 UI상으로는 로딩이 빨리 끝난 것처럼 보인다.

하지만 문제가 있었다.

새 episode의 첫 화면이 아직 실제로 canvas에 그려지기 전에 Loading episode가 사라지면, 이전 episode의 마지막 화면이 잠깐 남아 보일 수 있었다.

데이터 viewer에서 이것은 꽤 위험한 UX다. 사용자는 새 episode를 보고 있다고 생각하지만, 실제 화면에는 이전 episode의 frame이 남아 있을 수 있기 때문이다.

그래서 최종 정책은 다음처럼 바꿨다.

- episode가 바뀌면 기존 canvas를 즉시 검은 화면으로 비움
- Loading episode는 manifest 도착 시점에 풀지 않음
- HTTP 응답 시작 시점에도 풀지 않음
- 새 episode의 첫 decoded frame들이 실제 canvas에 그려진 뒤에만 Loading episode를 해제함

즉, 빠르게 로딩을 푸는 것보다 stale frame을 보여주지 않는 것이 더 중요했다.

이 정책 덕분에 사용자는 조금 더 명확한 상태를 보게 된다.

아직 새 episode가 그려지지 않았으면 검은 화면 + Loading episode
새 frame이 실제로 그려진 뒤에만 Loading 해제

Loading segment의 의미

Loading segment는 사용자가 아직 로드되지 않은 시간대로 seek하거나, 재생이 다음 segment로 넘어갔는데 해당 segment가 아직 준비되지 않았을 때 뜬다.

사라지는 시점은 단순히 HTTP 응답이 시작됐을 때가 아니다. 현재 구조에서는 다음 단계가 모두 끝나야 한다.

1. 해당 timestamp가 포함된 segment를 찾음
2. segment .rrd fetch
3. segment 전체 다운로드 완료
4. WASM decoder가 segment 전체 decode
5. decoded recording이 segment cache에 저장됨
6. 해당 timestamp의 frame을 canvas에 그림
7. 그 뒤 Loading segment가 사라짐

즉, Loading segment도 byte 일부가 도착했다고 사라지는 것이 아니다. 현재 custom RRD viewer는 segment 전체 다운로드와 decode가 끝나야 해당 timestamp를 그릴 수 있다.

다만 background prefetch가 진행 중이면 시간이 지나면서 buffered range가 뒤쪽으로 계속 확장된다. 사용자가 아직 prefetch되지 않은 구간으로 seek하면 Loading segment가 뜨고, 해당 segment가 다운로드/디코드되면 사라진다. 이후에는 그 seek 지점부터 뒤쪽 segment들을 다시 백그라운드에서 순차 prefetch한다.

반대로 이미 background prefetch로 받아둔 구간은 다시 seek해도 재다운로드 없이 바로 그릴 수 있다. 이 구조에서는 Range보다 segment cache와 prefetch 정책이 실제 viewer UX에 더 직접적인 영향을 준다.

추천 구조: Range + 작은 Segment + Background Prefetch

결국 현재 RRD viewer에서 가장 현실적인 구조는 Range만 단독으로 쓰는 것이 아니라, 작은 segment와 background prefetch를 함께 쓰는 방식이다.

최종 구조는 다음에 가깝다.

1. recording.index.json으로 전체 segment 목록을 제공한다.
2. 첫 화면에는 첫 번째 segment.rrd만 우선 로드한다.
3. segment 크기는 1초 단위로 작게 유지한다.
4. 첫 segment가 준비되면 남은 segment를 백그라운드에서 순차 prefetch한다.
5. 사용자가 중간으로 seek하면 해당 segment를 우선 로드하고, 그 지점부터 뒤쪽 segment를 다시 순차 prefetch한다.
6. 한 번 decode한 segment는 episode 전체 길이만큼 브라우저 메모리에 유지한다.
7. 각 segment.rrd 응답은 HTTP Range를 지원한다.

초기에는 다음 segment 1개 정도를 미리 가져오는 정도의 prefetch로 생각했다. 하지만 1초 segment로 잘게 나누고 나니, 사용자가 기다리는 동안 뒤쪽 segment들을 계속 채워두는 편이 더 자연스러웠다.

현재 구조는 다음처럼 볼 수 있다.

다운로드 단위: 1초 segment + 0.1초 overlap
초기 로딩: 첫 segment만 우선
background prefetch: 남은 segment를 순차적으로 끝까지
seek 시: seek된 segment 우선, 이후 그 지점부터 끝까지 순차 prefetch
decoded cache: episode 전체 segment 유지
bitmap cache: 별도 제한 유지

중요한 점은 전체 segment를 한꺼번에 병렬 요청하지 않았다는 것이다. 느린 네트워크에서는 과한 병렬 prefetch가 오히려 현재 필요한 segment 다운로드를 방해할 수 있다. 3G 같은 환경에서 첫 화면이나 seek 지점의 segment가 가장 중요한데, 모든 segment를 동시에 요청하면 네트워크 대역폭을 서로 경쟁하게 된다.

WASM decoder도 마찬가지다. 여러 segment를 동시에 많이 decode하면 UI thread나 worker 부하가 커질 수 있다. 그래서 background prefetch는 현재 필요한 segment를 먼저 처리한 뒤, 남은 segment는 백그라운드에서 하나씩 순차적으로 채우는 쪽이 더 안정적이었다.

이 구조의 장점은 명확하다.

- 첫 화면은 작은 segment만 받아 빠르게 뜬다.
- 사용자가 멈춰 있어도 뒤쪽 구간이 백그라운드에서 계속 준비된다.
- 이미 prefetch된 구간으로 seek하면 재다운로드 없이 바로 그릴 수 있다.
- episode 전환 시 stale frame 방지 정책도 유지된다.

물론 trade-off도 있다.

- 사용자가 바로 다른 episode로 넘어가면 일부 prefetch는 낭비될 수 있다.
- episode 전체 decoded segment를 유지하므로 긴 episode에서는 메모리 사용량이 늘 수 있다.
- 1초 segment는 요청 수와 decoder 호출 수를 늘린다.
- 아주 긴 영상이나 일반 미디어 플레이어라면 LRU eviction이나 adaptive prefetch가 필요하다.

하지만 이번 viewer에서 다루는 episode는 길이가 아주 길지 않을 예정이었다. 그래서 다운로드 단위는 작게 가져가되, 한 번 decode한 segment는 episode 전체 길이만큼 유지하는 쪽이 더 단순하고 UX도 자연스러웠다.

즉, 현재 단계의 타협점은 다음과 같다.

작게 받아서 첫 화면을 빨리 띄우고,
기다리는 동안 뒤쪽을 순차적으로 채우고,
한 번 decode한 segment는 episode 안에서 계속 재사용한다.

테스트와 검증

이번 변경에서는 backend, proxy, frontend viewer를 각각 확인했다.

Backend에서는 다음을 검증했다.

- 일반 recording.rrd 요청이 Accept-Ranges: bytes를 반환하는지
- Range: bytes=0-15 요청 시 206 Partial Content를 반환하는지
- Content-Range가 bytes 0-15/{전체크기} 형태인지
- 응답 content가 실제 전체 파일의 앞 16 bytes와 같은지
- 유효하지 않은 Range 요청에 416 Range Not Satisfiable을 반환하는지

Proxy에서는 다음을 검증했다.

- 클라이언트의 Range 헤더가 upstream fetch에 전달되는지
- If-Range 헤더가 전달되는지
- upstream의 206 status가 유지되는지
- Content-Range, Accept-Ranges, Content-Length가 브라우저까지 보존되는지
- response.body를 arrayBuffer로 다시 버퍼링하지 않고 stream으로 전달하는지

Frontend viewer에서는 다음을 확인했다.

- frontend viewer 테스트 통과
- episode 변경 시 기존 canvas를 즉시 비우는지
- 첫 decoded frame이 실제로 그려진 뒤 Loading episode가 해제되는지
- 아직 준비되지 않은 구간으로 이동하면 Loading segment가 표시되는지
- segment decode와 frame render 이후 Loading segment가 사라지는지
- paused 상태에서도 남은 segment가 background prefetch되는지
- seek 이후 background prefetch가 seek된 segment 기준으로 재시작되는지
- 전체 segment를 decoded cache에 유지하는지
- buffered range가 segment load에 따라 확장되는지

또한 실제 manifest와 네트워크 동작도 확인했다.

- segmentSeconds: 1.0
- overlapSeconds: 0.1
- cache key suffix: policy-s1-o0p1
- 샘플 첫 segment가 policy-s1-o0p1로 내려오는 것 확인
- 첫 segment 크기가 약 2.1MB 수준으로 감소
- maxResidentSegments가 전체 segment 수 기준으로 내려오는 것 확인

작업 로그 기준으로 backend Range 테스트, proxy Range header forwarding 테스트, frontend viewer 테스트가 통과했고, 프록시 관련 ESLint도 통과했다.

마무리

이번 작업은 RRD viewer의 로딩 병목을 줄이기 위해, .rrd 파일을 일반 다운로드 파일이 아니라 HTTP 위에서 range 가능한 media-like file로 다루는 시도였다.

하지만 최종 결론은 조금 더 현실적이다.

Backend와 proxy는 Range 가능한 구조가 되었다.
하지만 custom RRD viewer는 아직 RRD byte offset index를 이해하지 못한다.
그래서 현재 체감 개선은 HTTP byte range 자체보다
segment manifest + 작은 RRD segment + background prefetch + decoded cache 정책에서 나온다.

진짜 MP4처럼 동작하려면 viewer가 시간 → byte offset 관계를 알고, 필요한 byte range만 요청하고, 그 range를 독립 decode할 수 있어야 한다.

현재 단계에서는 1초 단위 segment RRD가 가장 현실적인 타협이었다.

segmentSeconds: 1.0
overlapSeconds: 0.1
cache key suffix: policy-s1-o0p1

최종적으로는 Range 자체보다 segment 단위 설계가 더 중요했다. 작은 segment로 첫 화면 단위를 줄이고, background prefetch로 뒤쪽 구간을 계속 채우며, decoded cache를 episode 전체로 유지하는 방식이 현재 custom RRD viewer에서 가장 현실적인 절충점이었다.

이 구조는 완벽한 random access streaming은 아니지만, 큰 RRD를 한 번에 받는 방식보다는 낫다. 첫 화면에 필요한 단위를 줄이고, 사용자가 보는 동안 뒤쪽 데이터를 조용히 준비하며, 이미 본 구간은 다시 다운로드하지 않고 재사용할 수 있었다.

이번 범위에서는 썸네일 sidecar는 제외했다. 썸네일을 별도로 두면 첫 화면 placeholder나 timeline preview를 더 빠르게 만들 수 있겠지만, 이번 작업의 핵심은 RRD 파일 서빙 계약과 segment 로딩 정책을 정리하는 것이었다.

결국 이번 작업을 한 문장으로 정리하면 이렇다.

RRD를 MP4처럼 만들기 위해 HTTP Range를 붙였지만,
실제 체감 성능은 Range 자체보다 segment를 얼마나 작게 자르고,
어떻게 미리 채우고,
얼마나 오래 재사용하느냐에 달려 있었다.