
Durable Streams를 통해 나의 Jetson에서 로컬 AI 서빙하기
요약
NVIDIA Jetson Orin Nano를 활용하여 Kokoro-82M 모델 기반의 로컬 TTS 서비스를 구축하는 방법을 소개합니다. Durable Streams 방식을 통해 추론 과정 중 생성되는 오디오를 실시간으로 스트리밍하여 사용자 경험을 개선하는 아키텍처를 제안합니다.
핵심 포인트
- NVIDIA Jetson Orin Nano를 활용한 저비용 로컬 AI 서빙 환경 구축
- Kokoro-82M 모델을 이용한 텍റ്റ-음성 변환(TTS) 구현
- Durable Streams를 통한 점진적 오디오 스트리밍 및 지연 시간 최적화
- 독립적인 API 서빙 레이어 구축을 통한 참조 아키텍처 설계

로컬 AI가 점점 더 실용적으로 느껴짐에 따라, 저는 제3자 제공업체를 거치지 않고 저만의 모델을 셀프 호스팅(self-host)하여 워크로드를 독립적으로 실행하고, 또한 로컬 모델을 일부 사용자들에게 안정적으로 서빙하는 방법을 알아보고 싶었습니다. NVIDIA의 Jetson 시리즈는 훌륭한 시작점이며, 저는 "가장 저렴한 생성형 AI 슈퍼컴퓨터"라고 불리는 Jetson Orin Nano Super 키트를 선택했습니다! 이 기기는 1024 CUDA 코어와 32 텐서 코어 (tensor cores)를 갖추고 있으며, 67 TOPS (초당 1조 회 연산)의 성능을 제공합니다. 이는 신경망 텍스트 음성 변환 (neural text-to-speech) 모델인 Kokoro-82M을 기반으로 하는 작은 텍스트 음성 변환 (text-to-speech) 앱이라는 저의 작은 실험을 수행하기에 충분할 것입니다.
이 프로젝트는 주로 많은 양의 텍스트를 매번 읽기보다는 듣고 싶다는 필요성에서 영감을 받았습니다. 그래서 텍스트를 선택하고 목소리를 고른 뒤, 나중에 다시 확인하거나 사람들과 공유할 수 있는 링크를 생성하는 무언가를 원합니다. 현재로서는 페이지에 텍스트를 붙여넣는 방식이지만, 궁극적으로는 동일한 핵심 앱 위에 더 멋진 프론트엔드 (frontend)를 구축하여 훨씬 더 게으른 방식(lazy-proof)으로 사용할 수 있기를 바랍니다. 앱 자체를 넘어, 저는 로컬 추론 (local inference)을 위한 작은 참조 아키텍처 (reference architecture)를 구축하고 싶습니다. 즉, 깨끗한 API를 노출하는 독립적인 서빙 레이어 (serving layer)를 만들어, 동일한 설정이 재작업 없이 웹 앱, CLI 또는 다른 서비스를 지원할 수 있도록 하는 것입니다.
streamtts.dev에서 직접 확인해 보세요 (제 Jetson에서 셀프 호스팅 중입니다! 😉):

이를 설계하는 가장 간단한 방법은 다음과 같습니다:
POST /generate
wait
return audio.mp3
추론 (Inference)은 일반적인 웹 요청보다 느립니다. 이 Jetson에서 실행되는 Kokoro는 실시간보다 빠르게 음성을 생성할 수 있지만, 여전히 GPU 작업입니다. 1분 분량의 오디오를 처리하는 데 수 초의 연산 시간이 걸릴 수 있습니다. 모델 스택 (model stack)이 예열되는 동안 첫 문장은 더 느릴 수 있습니다. 만약 여러 사용자가 동시에 요청을 제출하면, 차단형 요청 (blocking request)은 GPU를 기다리는 소켓 (socket) 대기열로 변하게 됩니다.
출력 또한 자연스럽게 점진적 (incremental)으로 이루어집니다. TTS (Text-to-Speech)는 청취자가 무언가를 듣기 전에 문단 전체를 완성할 필요가 없습니다. 모델은 한 문장을 생성하고, 그 문장을 MP3로 인코딩하여 어딘가에 추가한 뒤 다음으로 넘어갈 수 있습니다. 만약 제가 이 모든 것을 하나의 응답 본문 (response body)에 강제로 집어넣는다면, 해당 워크로드 (workload)의 가장 큰 장점을 버리는 셈이 됩니다.
그리고 저는 결과물이 공유 가능하기를 원합니다. 사용자는 모델이 모든 바이트 (bytes)를 생성할 때까지 "기다릴" 수 있는 링크로 즉시 안내되어야 합니다. 만약 Jetson이 여전히 작업 중인 동안 링크를 연다면, 사용자는 앞부분을 듣고 나서 실시간 진행 상황 (live edge)을 따라갈 수 있어야 합니다.
만약 요청-응답 (request-response) 방식으로 시작한다면, 결국 다음과 같은 인프라 더미를 추가하게 됩니다:
- 큐 (queue)
- 작업 장부 기록을 위한 데이터베이스 (database)
- 완료된 파일을 위한 객체 스토리지 (object storage)
- 재시도 로직 (retry logic)
- 중복 제거 로직 (dedupe logic)
- 정리 프로세스 (cleanup process)
이 모든 것은 합리적입니다. 하지만 이 모든 것을 합치면 단 하나의 기본적인 약속을 위해 너무 많은 비용이 듭니다:
지금 작업을 수락하고
나중에 출력을 생성하며
읽는 사람이 따라올 수 있게 한다
요청 (request)은 이를 처리하기에 적절한 생명 주기 (lifetime)가 아닌 것 같습니다. 저는 추론 (inference) 작업이 네트워크 중단 상황에서도 원활하게 작동하기를 원합니다. 또한 브라우저 탭이 닫힌다고 해서 실행 중인 생성 작업이 종료되는 것도 원치 않습니다. 따라서 출력은 완료되기 전에도 고유한 식별성 (identity)을 가져야 하며, 읽는 사람은 처음부터 시작하거나, 끝부분을 따라잡거나, 혹은 나중에 다시 돌아와 동일한 바이트를 재생할 수 있어야 합니다!
요약하자면, 제가 원하는 것은 다음과 같습니다:
작업을 제출하고
즉시 출력 스트림 (output stream)을 받으며
워커 (worker)가 모델 출력을 추가한다
...
이 모든 것은 Durable Streams를 통해 깔끔하게 추상화될 수 있습니다. 스트림 (stream)은 레코드 (record)의 순서 있는 시퀀스이며, 여기서 레코드는 단순히 약간의 메타데이터가 포함된 오디오 청크 (chunk)와 같은 바이트 (bytes)입니다. Durable (내구성)하다는 것은 모든 레코드가 영구 저장 (persisted)된다는 것을 의미하므로, 아무것도 손실되지 않으며 읽는 사람이 나중에 돌아와 정확히 동일한 바이트를 재생할 수 있습니다. 이 두 가지를 결합하면, 단순하지만 강력한 빌딩 블록 (building block)을 얻게 됩니다.
레코드(record)를 꼬리(tail)에 추가하면, 읽기(reader)는 머리(head)에서 시작하거나, 알려진 시퀀스 번호(sequence number)로 탐색(seek)하거나, 혹은 꼬리에 머물며 다음 레코드가 도착하기를 기다릴 수 있습니다. 스트림 스토어(stream store)는 다음과 같이 이름이 지정된 타임라인(timelines)을 제공합니다:
APPEND record
READ from seq_num=N
TAIL for live records
각 레코드는 진행(progress)의 단위입니다. 레코드는 시퀀스 번호(sequence number), 타임스탬프(timestamp), 헤더(headers), 그리고 바디(body)를 가집니다. StreamTTS는 그 이상의 복잡한 구조를 필요로 하지 않습니다. 우리는 레코드를 다음과 같이 표현합니다:
headers:
e: audio
i: 3
...
그리고 출력은 다음과 같은 형태를 띠게 됩니다:
pub/casts/4LwnHZDl_vFC
seq 0 meta
seq 1 start
...
해당 스트림이 곧 오디오 파일이자, 라이브 피드(live feed)이며, 재생 로그(replay log)이자, 진행 표시기(progress indicator)가 됩니다. 또한 이는 웹 서버(web server), GPU 워커(GPU worker), 그리고 링크를 여는 모든 브라우저(browser) 사이의 계약(contract)이기도 합니다. 쓰기(writer) 측은 누가 듣고 있는지 알 필요가 없습니다. 읽기(reader) 측은 쓰기 측이 여전히 살아있는지 알 필요가 없습니다. 양측은 그저 이름이 지정된 하나의 레코드 시퀀스(sequence of records)에 합의할 뿐입니다.
연결 전용 SSE 또는 WebSockets는 라이브 전송(live delivery)에는 훌륭하지만, 그 자체만으로는 내구성이 있는 재생(durable replay) 기능을 제공하지 않습니다. 이들은 현재 연결된 클라이언트(client)에게 바이트(bytes)를 전달할 뿐입니다. 늦게 도착하거나, 연결이 끊기거나, 페이지를 새로고침하는 클라이언트를 위해 스스로 바이트를 기억하지는 않습니다. 따라서 아무도 연결되어 있지 않다면, WebSocket 메시지가 갈 수 있는 내구적인 장소가 없습니다. 클라이언트가 이탈하면, 서버는 해당 클라이언트가 놓친 것을 기억하기 위해 다른 스토어(store)가 필요합니다. 만약 생성(generation)이 여전히 진행 중인 동안 두 번째 리스너(listener)가 동일한 링크를 연다면, WebSocket 연결만으로는 서버에 어떻게 처음부터 재생한 뒤 라이브 엣지(live edge)를 따라갈지 알려줄 수 없습니다. SSE/WebSockets 옆에 데이터베이스(database)나 오브젝트 스토어(object store)를 배치함으로써 이 문제를 완전히 해결할 수도 있습니다. 하지만 이 경우 라이브 전송과 재생은 서로 합의를 맞춰야 하는 두 개의 별개 조각이 됩니다.
내구성이 있는 스트림(durable stream)을 사용하면, 이 분리된 기능들을 하나로 통합할 수 있습니다! 워커(worker)는 출력을 한 번만 추가(append)하고, 라이브 리스너(live listener)는 스트림의 꼬리(tail)를 따라갑니다. 늦게 도착한 리스너는 seq_num=0부터 읽을 수 있습니다.
그리고 동일한 스트림의 꼬리(tail)를 따라갑니다. 재생(Replay)과 라이브 재생(live playback)은 서로 다른 오프셋(offset)에서 시작할 뿐, 동일한 읽기 경로(read path)를 사용합니다.
S2 Lite는 S2 Durable Streams API를 단일 바이너리로 구현한 오픈 소스 셀프 호스팅(self-hosted) 구현체입니다. 이 설정에서 S2 Lite는 로컬 디스크를 영구 저장소(durable storage)로 사용하여 로컬호스트(localhost)에서 실행되며, 추가(append), 읽기(read), 꼬리 추적(tail), 롱 폴링(long-polling) 의미론(semantics)을 가진 스트림을 제공합니다.
s2 lite --local-root var/s2lite-data --port 4002 --no-cors
먼저 네임스페이스(namespace) 역할을 하는 베이슨(basin)을 생성하고, 전체 서비스를 몇 개의 이름이 지정된 스트림(named streams)으로 모델링하는 것으로 시작합니다. 아래 화살표는 어떤 컴포넌트가 각 스트림에 데이터를 추가(append)하고 어떤 컴포넌트가 이를 읽는지 보여줍니다:
몇몇 스트림은 모든 캐스트(cast) 간에 공유됩니다:
jobs는 인테이크 로그(intake log)입니다: 추론(inference) 요청당 하나의 레코드(record)가 생성됩니다.
jobs/_cursor는 jobs에 대한 워커(worker)의 커밋된 읽기 오프셋(read offset)을 보유합니다.
jobs/dead는 재시도(retry) 횟수를 초과하여 실패한 작업들을 수집합니다.
progress/done은 완료된 캐스트당 하나의 영수증(receipt)을 받습니다.
그리고 각 캐스트는 자체적으로 두 개의 스트림을 추가합니다:
catalog/<id>는 비공개 레시피(private recipe)입니다: 전체 텍스트, 음성, 제목, 생성 시간을 포함합니다.
pub/casts/<id>는 공개 출력 스트림(public output stream)입니다: 메타데이터(meta), 시작(start), 오디오(audio)..., 종료(eos)를 포함합니다.
s2 = S2(
os.environ.get("S2_ACCESS_TOKEN", "local-token"),
endpoints=Endpoints(
...
각 audio 레코드는 헤더(header)에 문장 텍스트와 밀리초(ms) 단위의 지속 시간(duration)을 담고 있으며, 바디(body)에는 가공되지 않은 MP3 바이트(raw MP3 bytes)를 담고 있습니다. 텍스트는 브라우저의 자막(captions)과 탐색 지점(seek points)을 제공합니다. 지속 시간은 플레이어가 청크(chunk)를 스케줄링할 수 있게 해줍니다. 브라우저는 항상 seq_num=0부터 꼬리 추적(tailing)을 시작합니다.
스트림이 완료되면 브라우저는 eos를 읽고 멈춥니다. 워커가 여전히 데이터를 추가(append)하고 있다면, 브라우저는 기존 접두사(prefix)를 읽고 꼬리(tail)에 도달한 뒤 다음 레코드를 기다립니다. 브라우저 플레이어 또한 스트림 형태(stream shape)를 중심으로 구축되었습니다. 미디어 소스 확장(Media Source Extensions)을 사용하거나 계속 커지는 하나의 MP3 파일을 만들지 않습니다. 각 audio 레코드는 문장 크기의 완전한 MP3 청크(chunk)입니다. 브라우저는 각 문장 크기의 MP3 청크를 수신하여 Web Audio API로 디코딩(decode)한 후, 가상 타임라인(virtual timeline)에 배치합니다.
단일 Jetson은 탄력적인 추론 클러스터 (elastic inference cluster)처럼 동작할 수 없습니다 😅. 예를 들어 세 사람이 텍스트를 제출했을 때, 다른 모든 사람이 기다리는 동안 첫 번째 긴 문단이 완전히 끝날 때까지 기다리고 싶지는 않을 것입니다. 워커 (worker)는 여러 개의 캐스트 (casts)를 활성 상태로 유지하며, 각 스트림이 실제 재생 시간 (wall-clock playback) 대비 얼마나 앞서 있는지 추적합니다:
def lead(self) -> float:
return self.total_ms / 1000.0 - (time.monotonic() - self.started)
양수(+)의 리드 (lead) 값은 스트림이 재생보다 앞서 버퍼링된 오디오를 생성했음을 의미합니다. 음수(-)의 리드 값은 청취자가 실시간 끝부분 (live tail)을 따라잡고 있음을 의미합니다.
스케줄링 루프 (scheduling loop)는 다음과 같습니다:
동시성 제한 (concurrency cap)까지 작업 (jobs) 수락
리드 (lead) 값이 가장 낮은 활성 스트림 선택
해당 스트림에 대해 정확히 한 문장 생성
...
모든 활성 스트림이 여유 있게 앞서 있을 때, 워커는 하나의 스트림을 완료하기 위해 전력 질주하는 대신 아주 잠시 동안 휴면(sleep) 상태에 들어가며, 이를 통해 실시간 출력 스케줄링 (live-output scheduling)을 생성합니다. 목표는 여러 개의 공개 스트림을 재생 가능한 상태로 유지하는 것입니다. 공정성 (fairness)의 단위는 요청 (request)이 아니라, 추가된 한 문장입니다.
요청이 들어오면 웹 프로세스는 모델을 로드하지 않습니다. 대신 텍스트와 음성을 검증하고, 결정론적 ID (deterministic id)를 계산하며, 오디오가 나타날 위치를 생성합니다.
이 ID는 콘텐츠 주소 지정 (content-addressed) 방식입니다:
def content_id(text: str, voice: str) -> str:
h = hashlib.sha256(f"{voice}\x00{text.strip()}".encode()).digest()
return base64.urlsafe_b64encode(h).decode().rstrip("=")[:12]
동일한 음성과 동일한 텍스트는 동일한 스트림으로 매핑됩니다. 이는 반복된 제출을 캐시 히트 (cache hit)로 전환합니다.
쓰기 경로 (write path)는 다음과 같습니다:
- 전체 레시피와 함께
catalog/<id>를 claim - 메타 레코드와 함께
pub/casts/<id>를 claim jobs스트림에 하나의 작업 (job)을 append/c/<id>를 반환
중요한 작업은 claim입니다. S2는 match_seq_num을 통한 조건부 추가 (conditional append)를 지원합니다.
StreamTTS는 match_seq_num=0을 사용하는데, 이는 "이 스트림이 비어 있는 경우에만 추가하라"는 의미입니다.
payload = {
"records": [{"body": json.dumps(body, separators=(",", ":"))}],
"match_seq_num": 0,
...
}
두 사람이 동시에 동일한 텍스트를 제출하면, 정확히 하나의 요청만이 권한(claim)을 획득하고 작업을 큐(enqueue)에 넣습니다. 다른 요청은 동일한 링크를 받게 되며 동일한 출력 스트림(output stream)의 끝을 추적(tail)하게 됩니다.
이 단 한 번의 추가(append) 작업이 잠금 테이블(lock table), 유일성 제약 조건(uniqueness constraint), 그리고 중복 제거 캐시(dedupe cache)를 모두 대체합니다.
워커(worker)는 모델을 소유하고 GPU를 사용하는 유일한 프로세스입니다. 워커는 jobs 스트림을 읽고, Kokoro-82M을 실행하며, 오디오 레코드(audio records)를 cast 스트림에 추가(append)합니다.
시작 시, 워커는 jobs/_cursor에서 마지막으로 커밋된 오프셋(offset)을 읽습니다:
jobs/_cursor
{"offset": 123}
그런 다음 해당 오프셋부터 jobs를 읽습니다. 새로운 내용이 없다면, 끝부분에서 롱 폴링(long-polls)을 수행합니다.
미묘한 부분은 커서(cursor)를 커밋하는 것입니다. StreamTTS는 동시에 여러 개의 활성 cast를 가질 수 있으며, 이들이 반드시 작업 순서대로 종료되는 것은 아닙니다. 짧은 작업 10이 긴 작업 7보다 먼저 끝날 수 있습니다. 커서는 해당 시점까지의 모든 작업이 완료되었을 때만 앞으로 이동할 수 있습니다.
워커는 연속 완료 워터마크(contiguous-done watermark)를 사용합니다:
def advance_watermark():
nonlocal committed
moved = False
...
프로세스가 충돌(crash)하더라도 특별한 복구 프로토콜은 없습니다. 재시작 시, 워커는 마지막으로 커밋된 오프셋부터 재개합니다. 해당 오프셋 이후의 작업들은 다시 읽힙니다. 이미 완료된 cast는 출력 스트림이 eos로 끝나는지 확인하여 건너뜁니다. 완료되지 않은 cast는 다시 실행됩니다.
이는 멱등성(idempotent)을 가진 최소 한 번 전달(at-least-once delivery) 방식입니다. eos가 영구적인 완료 마커(durable completion marker)이기 때문에, 완료된 cast에 대해서는 정확히 한 번(exactly-once) 전달되는 것처럼 동작합니다. 우리는 또한 토큰을 터미널 마커(terminal marker)로 사용하여 cast를 완료된 것으로 표시하는 페이싱 토큰(fencing token)을 사용할 수도 있습니다.
재시도(Retries) 시 스트림에 부분적인 오디오가 남을 수 있습니다. 따라서 시작 레코드(start record)는 시도 경계(attempt boundary)가 됩니다:
seq 0 meta
seq 1 start attempt 1
seq 2 audio sentence 0
...
플레이어는 가장 최근의 start를 재생 가능한 시도의 시작으로 취급하고, 이전의 부분적인 오디오는 무시할 수 있습니다.
공개 읽기 경로(public read path)는 의도적으로 내부 S2 API보다 좁게 설계되었습니다. S2 Lite는 모든 스트림을 쓰고, 삭제하고, 읽을 수 있지만, 인증/인가(authentication/authorization)는 사용자의 판단에 맡깁니다.
따라서 브라우저는 공개 캐스트 스트림(public cast streams)만 허용하는 StreamTTS 게이트웨이를 통해 읽습니다:
GET /s2/records?stream=pub/casts/<id>&seq_num=0
게이트웨이는 jobs나 catalog/*와 같은 내부 스트림을 거부합니다.
또한 게이트웨이는 앱이 읽기 속도 제한(rate-limit)을 적용할 수 있는 공간을 제공합니다.
라이브 재생(live playback)의 경우, 동일한 게이트웨이가 SSE(Server-Sent Events)를 제공합니다. S2 Lite는 내부적으로 많은 읽기 사용자(readers)에 대해 단일 업스트림 테일(upstream tail)을 공유하므로(하나의 브로드캐스트 송신자가 모든 테일링 읽기 사용자에게 데이터를 공급함), 게이트웨이는 단순히 해당 테일을 브라우저로 전달하기만 하면 됩니다.
워밍 생성(warm generation) 중에는 tegrastats가 대략 다음과 같이 나타납니다:
GR3D_FREQ 0% VDD_IN 3295mW idle
GR3D_FREQ 99% VDD_IN 9911mW generating, gpu@45.9C
GR3D_FREQ 0% VDD_IN 6955mW done
GR3D_FREQ
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기