TuBrief
구독 채널
비디오
커뮤니티

1인 개발자가 슈퍼톤 3를 2 vCPU 서버에 올릴 때 잡아야 하는 메모리 누수와 지연 시간

TuBrief 편집팀
2026년 8월 24일
0
컴퓨터/소프트웨어

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

한국어EnglishEspañol中文العربيةहिन्दीDeutschFrançaisPortuguêsРусский日本語Bahasa Indonesia

관련 영상

개발자들이 드디어 쓸만한 로컬 TTS 모델을 찾은 것 같습니다7:58

개발자들이 드디어 쓸만한 로컬 TTS 모델을 찾은 것 같습니다

Better Stack

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

1인 개발자가 슈퍼톤 3를 2 vCPU 서버에 올릴 때 잡아야 하는 메모리 누수와 지연 시간

월 50만 원 미만 예산으로 1인 SaaS를 굴릴 때, 유료 TTS API 청구서는 매달 부담스러운 짐입니다. 네트워크 지연 시간도 문제입니다. 화면에서 버튼을 눌렀는데 음성이 나오기까지 1~2초씩 걸리면 사용자는 바로 탭을 닫아버립니다.

9,900만 개 파라미터를 가진 오픈소스 모델 슈퍼톤 3(Supertonic 3)는 매력적인 대안입니다. 로컬 서버에서 직접 돌리면 API 비용을 0원으로 만들 수 있습니다.

다만 파이썬 예제 코드 몇 줄 돌려보는 것과 실제 프로덕션 배포는 완전히 다른 이야기입니다. 저사양 서버에서 이 모델을 띄우는 순간 마주치는 메모리 병목과 비동기 처리 문제를 직접 풀었던 방식을 정리했습니다.

1. 1GB 램 서버에서 프로세스가 죽는 이유와 ONNX 세션 튜닝

슈퍼톤 3의 ONNX 가중치 파일 크기는 약 305MB입니다. 모델을 처음 메모리에 올리면 상주 메모리(RSS)는 350MB 안팎을 유지합니다.

문제는 사용자가 요청을 보내 44.1kHz 오디오 텐서를 연산하기 시작할 때 발생합니다. 순간 피크 메모리가 900MB를 넘깁니다. 1 vCPU / 1GB RAM 사양의 최저가 인스턴스를 쓰면 리눅스 OOM Killer가 작동해 파이썬 프로세스를 바로 강제 종료합니다.

안정적인 운영을 위한 마지노선은 2 vCPU / 2GB RAM 인스턴스입니다.

서버 인스턴스 스펙 유휴 상태 RAM 연산 피크 RAM 평균 CPU 점유율 리얼타임 팩터 (RTF) 월 예상 비용 절감
1 vCPU / 1GB RAM 280 MB 890 MB (강제 종료 위험) 98% 0.85 (1초 음성 생성에 0.85초 소요) $130 (상용 API 대비)
2 vCPU / 2GB RAM 320 MB 920 MB (안정권) 48% (스레드 제한 시) 0.28 (1초 음성 생성에 0.28초 소요) $120 (GPU 인스턴스 대비)
4 vCPU / 4GB RAM 350 MB 950 MB 25% 0.15 $90 (과할당 인스턴스 조정)

저사양 CPU 서버에서 여러 요청이 들어올 때 CPU 점유율이 100%로 치솟는 현상을 막으려면 ONNX 런타임 스레드 풀을 수동으로 제어해야 합니다.

  • intra_op_num_threads를 서버의 물리 코어 수(2개)에 맞춥니다.
  • execution_mode를 ORT_SEQUENTIAL로 두고 inter_op_num_threads를 1로 지정해 불필요한 컨텍스트 스위칭을 막습니다.
  • enable_cpu_mem_arena를 켜서 잦은 힙 메모리 재할당을 방지하고, allow_spinning을 0으로 설정해 CPU가 빈 루프를 돌며 대기하는 현상을 끕니다.
import onnxruntime as ort

def get_optimized_session_options(cpu_cores: int = 2) -> ort.SessionOptions:
    options = ort.SessionOptions()
    options.intra_op_num_threads = cpu_cores
    options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
    options.inter_op_num_threads = 1
    options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
    options.enable_cpu_mem_arena = True
    options.add_session_config_entry("session.dynamic_block_base", "4")
    options.add_session_config_entry("session.intra_op.allow_spinning", "0")
    return options

이 옵션을 먹이고 워커를 띄우면 2 vCPU 환경에서 평균 CPU 점유율을 50% 아래로 통제할 수 있습니다. 비싼 GPU 서버를 쓰지 않아도 월 120달러 수준의 인프라 비용을 아끼게 됩니다.

2. C++ 라이브러리 충돌과 텍스트 전처리 예외 처리

로컬 환경이나 배포 컨테이너에서 SDK를 불러올 때 C++ 동적 라이브러리 충돌이 자주 일어납니다.

  • 윈도우 환경: ImportError: DLL load failed가 뜨면 Microsoft Visual C++ 2015-2022 재배포 가능 패키지를 깔고, 파이썬이 64비트 가상환경인지 확인합니다.
  • 맥 환경: Clang 컴파일러에 OpenMP가 없어서 libomp.dylib 에러가 납니다. 터미널에서 brew install libomp를 실행하고 환경변수에 lib 경로(export DYLD_LIBRARY_PATH="$(brew --prefix libomp)/lib:$DYLD_LIBRARY_PATH")를 추가합니다.
  • 도커 환경: python:3.10-slim 이미지를 쓸 때 build-essential과 libgomp1 패키지를 apt로 미리 설치해 둡니다.

환경을 맞춘 다음에는 입력 텍스트 정제기를 달아야 합니다. 영어 약어, 숫자, 기호가 섞여 들어오면 모델이 발음을 뭉개거나 이상한 기계음을 뱉기 때문입니다.

import re
from typing import Dict

class SupertonicTextNormalizer:
    def __init__(self):
        self.lexicon_map: Dict[str, str] = {
            "FastAPI": "패스트 에이피아이",
            "SaaS": "새스",
            "TTS": "티티에스",
            "ONNX": "온닉스",
            "Python": "파이썬",
            "SDK": "에스디케이",
            "API": "에이피아이",
        }
        self.currency_pattern = re.compile(r'(\d+)\s*원')
        self.date_pattern = re.compile(r'(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일')
        self.time_pattern = re.compile(r'(\d{1,2}):(\d{2})')
        self.special_char_pattern = re.compile(r'[^\w\s\.\,\!\?\~\<\>]')

    def normalize(self, text: str) -> str:
        if not text or not text.strip():
            raise ValueError("입력 텍스트가 비어 있습니다.")

        for word, pronunciation in self.lexicon_map.items():
            text = re.sub(rf'\b{re.escape(word)}\b', pronunciation, text, flags=re.IGNORECASE)

        text = self.date_pattern.sub(r'\1년 \2월 \3일', text)
        text = self.time_pattern.sub(r'\1시 \2분', text)

        tags = re.findall(r'<[^>]+>', text)
        text_placeholder = re.sub(r'<[^>]+>', ' ___TAG___ ', text)
        text_cleaned = self.special_char_pattern.sub('', text_placeholder)
        for tag in tags:
            text_cleaned = text_cleaned.replace('___TAG___', tag, 1)

        return re.sub(r'\s+', ' ', text_cleaned).strip()

C++ 추론 모듈이 특정 텍스트 패턴에서 무한 대기에 빠지는 상황을 막으려면 asyncio.wait_for로 타임아웃을 걸고, 실패 시 준비된 에러 안내 음성을 반환하는 방어 코드를 둡니다.

import asyncio
import logging

logger = logging.getLogger("TTSPipeline")

async def synthesize_with_fallback(tts_engine, text: str, voice_style, timeout_sec: float = 3.0) -> bytes:
    try:
        normalizer = SupertonicTextNormalizer()
        cleaned_text = normalizer.normalize(text)
        
        loop = asyncio.get_running_loop()
        wav_data = await asyncio.wait_for(
            loop.run_in_executor(
                None, 
                lambda: tts_engine.synthesize(text=cleaned_text, lang="ko", voice_style=voice_style)
            ),
            timeout=timeout_sec
        )
        return wav_data
    except Exception as err:
        logger.error(f"TTS 추론 실패 또는 타임아웃: {err}")
        with open("static/audio/fallback_system_error.wav", "rb") as f:
            return f.read()

이 전처리 파이프라인을 거치면 잘못된 발음으로 인한 음성 재생 오류가 크게 줄어듭니다. QA 단계에서 발음 이슈를 일일이 확인하고 고치는 시간을 매주 대여섯 시간씩 아낄 수 있습니다.

3. FastAPI 이벤트 루프를 막지 않는 프로세스 풀과 인메모리 스트리밍

FastAPI 비동기 라우터 안에서 동기 함수인 슈퍼톤 3 추론기를 그대로 실행하면 문제가 생깁니다. C++ 연산이 끝날 때까지 단일 이벤트 루프 전체가 멈춰 서서, 다른 사용자의 가벼운 API 요청까지 전부 대기 상태에 빠집니다.

연산 집약적인 추론 작업은 별도의 ProcessPoolExecutor로 밀어내야 합니다.

from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from concurrent.futures import ProcessPoolExecutor
import asyncio
import io
import os

app = FastAPI()
process_pool = ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 1))

def sync_tts_inference(text: str, voice_style_name: str):
    from supertonic import TTS
    tts = TTS(auto_download=False)
    style = tts.get_voice_style(voice_style_name)
    wav, _ = tts.synthesize(text=text, lang="ko", voice_style=style)
    return wav.tobytes()

@app.post("/api/v1/tts/realtime")
async def generate_speech_realtime(text: str, voice: str = "M1"):
    loop = asyncio.get_running_loop()
    try:
        audio_bytes = await loop.run_in_executor(process_pool, sync_tts_inference, text, voice)
        return StreamingResponse(io.BytesIO(audio_bytes), media_type="audio/wav")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

생성된 오디오를 디스크에 파일로 쓰고 다시 읽어 반환하는 구조는 저사양 서버의 디스크 I/O를 갉아먹습니다. 파일 저장을 거치지 않고 io.BytesIO로 메모리 상에서 바로 스트리밍하는 편이 훨씬 빠릅니다.

트래픽이 몰려 대기열이 길어지거나 긴 문장을 처리해야 한다면 Redis 큐와 Celery 워커로 요청을 분리합니다.

  • 사용자가 텍스트를 보내면 서버는 task_id를 즉시 발행하고 HTTP 202로 응답을 끝냅니다.
  • Celery 워커가 백그라운드 프로세스에서 모델을 돌려 음성을 생성합니다.
  • 생성이 완료되면 Redis Pub/Sub을 거쳐 웹소켓으로 클라이언트에 바이너리 데이터를 넘깁니다.

임시 파일 캐시를 어쩔 수 없이 디스크에 남겨야 하는 구조라면 백그라운드 정리 태스크를 돌려 디스크 풀(Disk Full) 장애를 방지합니다.

import os
import time
import glob

AUDIO_CACHE_DIR = "/tmp/supertonic_audio_cache"
MAX_FILE_AGE_SECONDS = 600

def cleanup_ephemeral_audio_files():
    now = time.time()
    if not os.path.exists(AUDIO_CACHE_DIR):
        return
    for filepath in glob.glob(os.path.join(AUDIO_CACHE_DIR, "*.wav")):
        try:
            if now - os.path.getmtime(filepath) > MAX_FILE_AGE_SECONDS:
                os.remove(filepath)
        except Exception:
            pass

프로세스 격리와 인메모리 스트리밍을 갖추면 2 vCPU 인스턴스에서도 동시 요청 처리 시 p95 지연 시간을 200밀리초 내외로 묶어둘 수 있습니다. 외부 API 요금 폭탄 걱정 없이 독립적인 온디바이스 음성 서비스를 안정적으로 서비스에 붙일 수 있습니다.