본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 27. 23:14

두 개의 프로덕션 앱을 위해 Gemini를 셀프 호스팅 LLM으로 교체한 방법

요약

Gemini API 대신 Qwen 모델을 활용하여 두 개의 프로덕션 앱을 셀프 호스팅 LLM 환경으로 전환한 과정을 다룹니다. 제어권 확보, 개인정보 보호, 그리고 API 비용 절감을 위해 로컬 인프라를 구축한 아키텍처와 경험을 공유합니다.

핵심 포인트

  • Gemini에서 오픈 모델로 전환하여 모델 제어권 및 프롬프트 규약 유지
  • 고객 데이터 보호를 위한 개인정보 보호 및 보안 강화
  • 종량제 API 대신 공용 인프라 관점의 비용 효율적 운영
  • Cloudflare Tunnel과 리버스 프록시를 활용한 홈 서버 라우팅

얼마 전 저는 저의 터미널 스타일 포트폴리오와 그 포트폴리오가 인덱싱하는 제품들에 대해 글을 쓴 적이 있습니다. 그 제품 중 두 가지는 언어 모델 (Language Model)에 의존하고 있습니다. 하나는 질문을 던질 수 있는 smngvlkz.com의 포트폴리오 터미널이고, 다른 하나는 선택적인 결제 후속 이메일을 생성하는 PayChasers입니다. 두 제품 모두 처음에는 Google의 Gemini 3 Flash로 시작했습니다. 하지만 현재는 두 제품 모두 제가 직접 호스팅하는 모델로 구동되며, 제 하드웨어가 작동하지 않을 때를 대비한 폴백 체인 (Fallback chain)을 갖추고 있습니다.

이것은 그 전환 과정에 대한 이야기입니다. 이를 시작하게 된 실험, 제가 왜 이 방식에 전념하게 되었는지, 아키텍처 (Architecture)는 어떻게 구성되어 있는지, 시스템이 고장 났던 그날 밤의 이야기, 그리고 아직 해결하지 못한 부분들에 대해 다룹니다.

실험으로 시작되었습니다

Qwen 3.5가 발표되었을 때, 저는 오픈 모델 (Open models)이 실제로 어디까지 발전했는지 궁금해졌습니다. 벤치마크 (Benchmarks)를 읽는 대신, 제가 학습하는 방식대로 직접 실행해 보며 테스트했습니다.

기본 Mac mini에서 진행한 작은 실험으로 시작되었습니다. 모델이 로컬 머신 (Local machine)에서 직접 실행될 때 얼마나 유능할지 확인하기 위해 Ollama를 통해 Qwen을 불러왔습니다. 결과는 기대보다 훨씬 좋았습니다. 이것을 단순한 장난감으로 생각하는 것을 멈추고 프로덕션 (Production) 적용을 고민하기 시작할 정도로 충분히 훌륭했습니다.

왜 Gemini에서 벗어나려 했는가

Gemini 3 Flash는 잘 작동했습니다. 통합 (Integration) 과정은 몇 줄에 불과했고 품질도 좋았습니다. 따라서 이 이야기는

두 번째는 제어권과 개인정보 보호 (Privacy)였습니다. 저는 제공업체가 내부적으로 무언가를 중단(Deprecating)하더라도, 제가 직접 모델을 선택하고, 고정(Pin)하며, 프롬프트 규약 (Prompt contract)을 변경할 수 있기를 원했습니다. 또한, 불필요한 상황에서 고객 이름이나 결제 문맥 (Payment context)을 제3자에게 전송하는 것도 원치 않았습니다.

세 번째는 AI를 종량제 API (Metered API)가 아닌 인프라 (Infrastructure)로 취급할 때의 경제성이었습니다. 모델이 제가 제어하는 하드웨어에서 실행되면, 호출당 비용 (Per-call expense)이 아닌 여러 애플리케이션이 공유할 수 있는 공용 인프라가 됩니다. 이제 동일한 추론 서버 (Inference server)가 두 개의 서로 다른 제품을 구동합니다. 이러한 관점의 전환이 핵심입니다.

프로덕션 (Production) 환경 구축이 가장 어려운 부분이었습니다

원래 계획은 요하네스버그 리전의 무료 Ampere ARM 인스턴스를 사용하여 Oracle Cloud에 모델을 호스팅하는 것이었습니다. 만약 이를 시도해 본 적이 있다면, 그 고충을 잘 아실 겁니다. 프리 티어 (Free tier) ARM 용량은 매우 제한적이며, 이틀 동안 200번 이상의 자동 재시도 (Automated retry)를 거친 후에도 저는 여전히 인스턴스를 확보할 수 없었습니다.

그래서 방향을 틀었습니다. 가벼운 리버스 프록시 (Reverse proxy)를 작성하고, 제 도메인 중 하나에 Cloudflare Tunnel을 설정한 뒤, 프로덕션 트래픽을 집에 있는 제 Mac에서 실행 중인 모델로 라우팅했습니다. 홈 네트워크에 포트를 열 필요도, 고정 IP (Static IP)가 필요하지도 않았습니다. 그저 Cloudflare의 에지 (Edge)에서 책상 위의 머신으로 연결되는 터널만 있으면 되었습니다.

이는 임시적인 방편이었습니다. 결국 Oracle 인스턴스를 확보하긴 했지만, 그때쯤에는 홈 설정이 잘 작동하고 있었기에 이를 버리지 않았습니다. 대신 Mac mini를 기본 서버로 유지하고, Oracle에는 항상 켜져 있는 백업이라는 다른 역할을 맡겼습니다. 이에 대해서는 잠시 후에 더 자세히 설명하겠습니다.

이는 작은 '회귀 (Full-circle)'의 순간이었습니다. 부트캠프 시절과 수년간의 독학을 통해 익힌 Linux 및 인프라 기초 지식들이 실제 프로덕션 문맥에서 나타난 것입니다. 터널 프로비저닝 (Provisioning tunnels), DNS 구성, 프록시 서비스 작성, 지속적인 서비스 (Persistent services) 설정까지. 이 모든 것이 실제적인 무언가를 위해 하나로 모였습니다.

한 가지 의도적인 결정은 인프라를 단순하게 유지하는 것이었습니다. 현재 이 분야에는 수많은 프레임워크와 에이전트 시스템 (Agent systems)이 등장하고 있습니다. 저는 제가 실제로 겪고 있는 문제들을 해결해 주는 직관적인 도구들에 집중했습니다.

시스템의 형태

Cloudflare 터널을 통해 노출된 Mac mini가 기본 (Primary) 서버입니다. 속도는 빠르지만, 집에 있는 기기이기 때문에 항상 켜져 있지는 않습니다. Oracle Cloud VM은 폴백 (Fallback) 서버입니다. 더 느리고 규모도 작지만, 24시간 내내 가동됩니다.

모든 앱은 두 서버를 모두 알고 있는 얇은 클라이언트 (Thin client)와 통신합니다. 이 클라이언트는 먼저 빠른 서버를 시도하고, 문제가 생기면 조용히 신뢰할 수 있는 서버로 전환합니다.

Vercel app
   |
   v
...

페일오버 클라이언트 (Failover client)

이것이 하나의 함수에 담긴 전체 아이디어입니다. 타임아웃 (Timeout)을 설정하여 기본 서버에 요청을 보냅니다. 상태 문제, 타임아웃, 터널 끊김 등 어떤 문제라도 발생하면 폴백 서버로 넘어갑니다.

const PRIMARY_URL = process.env.OLLAMA_PRIMARY_URL || "http://localhost:11434";
const FALLBACK_URL = process.env.OLLAMA_FALLBACK_URL || PRIMARY_URL;

...

겉보기보다 더 중요한 몇 가지 작은 선택 사항들입니다:

  • 기본 서버에는 15초의 타임아웃을 설정했지만, 폴백 서버에는 설정하지 않았습니다. 폴백의 역할은 어떻게든 답변을 하는 것이라고 생각했기에, 충분한 시간을 허용했습니다. 실제로 이는 제한 없는 fetch를 의미하며, Oracle 서버에 접속은 가능하지만 먹통(wedged)인 상태라면 요청이 멈춰 있을 수 있습니다. 긴 타임아웃을 설정하는 것이 동일한 아이디어를 더 방어적으로 구현하는 방법이겠지만, 아직 추가하지는 않았습니다.
  • catch 구문은 기본 서버가 왜 실패했는지에 대한 이유를 삼켜버립니다. 로그도, 신호도 남지 않습니다. 페일오버(Failover)를 수행하는 데는 괜찮지만, 진단하기에는 나쁜 방식이며, 이를 프로덕션 수준으로 견고하다고 부르기 전에 반드시 개선해야 할 부분입니다.
  • 폴백 URL은 기본 URL을 기본값으로 사용하므로, 별도의 설정 없이 하나의 Ollama 인스턴스만 있는 로컬 환경에서도 동일한 코드가 실행됩니다.
  • 페일오버는 투명하게 (Transparent) 이루어집니다. 호출자는 어떤 머신이 응답했는지 알 수 없습니다.

부하 인지 모델 선택 (Load-aware model selection)

자체 모델을 실행한다는 것은 어떤 모델이 어떤 요청을 처리할지 직접 결정할 수 있음을 의미합니다. 저는 현재 처리 중인(in flight) 요청 수에 따라 라우팅(routing)을 결정하는 매우 단순한 버전을 구현했습니다.

let activeRequests = 0;

function selectModel(): string {
...

포트폴리오 사이트의 경우, 단일 방문자가 접속하면 더 성능이 좋은 모델인 qwen2.5:latest를 할당받습니다. 두 개의 요청이 겹치는 순간, 새로운 요청들은 더 가볍고 동시성(concurrency) 상황에서도 지연 시간(latency)을 안정적으로 유지할 수 있는 qwen2.5-coder:7b로 전환됩니다. 이는 정교한 방식은 아닙니다. 단 하나의 카운터와 삼항 연산자(ternary)를 사용할 뿐입니다. 하지만 이는 비용과 품질 사이의 실제적인 트레이드오프(tradeoff)를 축소해 놓은 형태이며, 단 한 대의 Mac mini 환경에서는 서비스가 우아하게 유지되느냐 아니면 실패하느냐를 결정짓는 차이입니다.

또한, 제 역할을 톡톡히 해내는 두 가지 Ollama 옵션을 사용합니다:

  • keep_alive: -1: 모델을 메모리에 상주시켜 다음 요청 시 콜드 로드(cold load) 비용이 발생하지 않도록 합니다.
  • think: false: 추론 토큰(reasoning tokens)을 비활성화합니다. 포트폴리오 터미널이나 이메일 초안 작성 시에는 모델의 독백이 아닌 결과값만을 원하기 때문입니다.

모든 것이 모델을 거칠 필요는 없다

가장 저렴한 추론(inference)은 실행하지 않는 것입니다. 이전에는 제 포트폴리오 터미널이 자연어 질의에는 Gemini 1.5 Flash를 사용하고, 일반적인 명령은 AI 없이 로컬에서 처리했습니다. 자연어 계층을 자체 인프라로 옮긴 후에도 이 분리 방식을 유지했습니다.

const lowerQuery = query.toLowerCase().trim();

if (lowerQuery === "help") { /* return static command list */ }
...

help, list, show, explain 같은 명령어들은 입력된 데이터에서 즉시 답변을 제공합니다. 오직 진정으로 개방형인 질문들만이 모델로부터 스트리밍됩니다. 이는 더 빠르고, 비용이 들지 않으며, 7B 모델에게 틀릴 수도 있는 리스트 형식을 맞추라고 요청하는 것보다 더 신뢰할 수 있는 방법입니다.

답변 스트리밍 (Streaming the answer)

개방형 경로의 경우, 포트폴리오는 서버 전송 이벤트(Server-Sent Events, SSE)를 통해 토큰을 스트리밍합니다. Ollama는 줄바꿈으로 구분된 JSON(newline-delimited JSON)을 반환하므로, 라우트(route)에서 본문을 읽어 줄바꿈 단위로 분리한 뒤 각 토큰을 SSE 프레임으로 다시 방출합니다.

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
...

두 제품 모두 응답을 토큰 (token) 단위로 스트리밍하며, 제가 제어하는 인프라 위에서 완전히 실행됩니다.

출력이 사용 가능하도록 모델을 제약하기

PayChasers는 프롬프트 (prompt) 작업이 실제로 이루어지는 곳입니다. 왜냐하면 출력물이 채팅 버블이 아니라 누군가의 고객에게 발송되는 이메일이기 때문입니다. 셀프 호스팅된 7B 모델이 이를 수행할 만큼 충분히 신뢰할 수 있게 만드는 두 가지 요소가 있습니다.

첫째, 모델은 실제 값을 절대 쓰지 않습니다. 모델은 플레이스홀더 (placeholder)를 작성하고, 앱이 이를 채웁니다. 이를 통해 모델이 금액이나 이름을 환각 (hallucination) 하는 것을 방지합니다.

CRITICAL: 실제 값 대신 반드시 다음의 정확한 플레이스홀더 변수를 사용해야 합니다:
- {clientName}: 수신자의 이름
- {dueDate}: 납기일
...

둘째, 어조 (tone)는 결제가 얼마나 연체되었는지에 따라 단계적으로 높아지며, 이는 모델의 기분에 맡기는 것이 아니라 코드로 결정됩니다.

function determineTone(daysOverdue: number) {
    if (daysOverdue >= 14) return "urgent";
    if (daysOverdue >= 7) return "firm";
...

그리고 로컬 모델은 아무리 단호하게 요청하더라도 가끔 JSON을 코드 펜스 (code fence)나 길을 잃은 <think> 블록으로 감싸기 때문에, 파서 (parser)는 신뢰하기보다는 방어적으로 설계되었습니다.

function extractJson(text: string) {
    const cleaned = text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
    try { return JSON.parse(cleaned); } catch {}
...
```(?:json)?\s*([\s\S]*?)```/);
    if (fence) { try { return JSON.parse(fence[1].trim()); } catch {} }

    const first = cleaned.indexOf("{");
...

더 작은 모델을 셀프 호스팅한다는 것은 제공업체의 정교함(polish) 중 일부를 포기하는 대신, 파싱 (parsing)을 직접 처리해야 함을 의미합니다. 통제권과 비용 절감이라는 이점이 있다면 이는 공정한 거래입니다.

정전이 발생했던 그날 밤

그 후 저는 모든 셀프 호스터가 결국 배우게 되는 교훈을 얻었습니다.

어느 날 밤 20:00경에 짧은 정전이 있었습니다. 저의 주요 추론 노드 (inference node)인 Mac mini가 꺼졌고, 다시 켜지지 않았습니다. 저는 다음 날 아침이 되어서야 이 사실을 깨달았습니다.

PayChasers는 마땅히 그래야 하듯 Oracle 백업으로 자동 페일오버 (failover)되었습니다. 하지만 제 포트폴리오에 있는 플로팅 터미널 (floating terminal)은 페일오버가 설정되어 있지 않았고, 그래서 밤새 죽은 상태로 그대로 있었습니다. 그날 밤 제 포트폴리오를 건드려 볼 만큼 심심했던 사람은 아무것도 얻지 못했습니다.

그날 아침 두 가지 교훈을 얻었습니다:

  1. 추론 (inference)이 필요한 모든 서비스에는 페일오버가 필요합니다. 제가 설정을 기억해둔 서비스들뿐만이 아닙니다. 포트폴리오 터미널에도 PayChasers가 이미 사용 중인 fetchWithFallback 클라이언트를 적용했습니다.
  2. 제가 인지조차 못한 12시간의 중단은 단순히 제가 건망증이 있는 것이 아니라, 모니터링 (monitoring)의 문제입니다. 대체로 말이죠. 부분적으로는 건망증 때문이기도 합니다.

자신만의 AI를 셀프 호스팅 (self-hosting)하는 것은 토요일 오전 8시에 온콜 (on call) 당번이 되었을 때, 그리고 본인이 직접 만든 것이라 에스컬레이션 (escalate)할 대상이 아무도 없을 때까지는 아주 좋습니다.

홈랩 (homelab)이 다운되었을 때를 아는 법

그래서 저는 가장 먼저 갖췄어야 할 모니터링을 구축했습니다. PayChasers는 Ollama 엔드포인트 (endpoint) 양쪽 모두의 상태를 체크하는 작은 크론 (cron) 작업을 실행하며, '상태 전환 (state transition)', 즉 '정상에서 장애' 또는 '장애에서 정상'으로 바뀔 때만 저에게 이메일을 보냅니다. mini가 잠들어 있는 동안 5분마다 스팸 메일을 보내지 않도록 Upstash Redis에 마지막으로 확인된 상태를 저장합니다.

const ENDPOINTS = [
    { name: "primary-mac", url: process.env.OLLAMA_PRIMARY_URL },
    { name: "fallback-oracle", url: process.env.OLLAMA_FALLBACK_URL },
...

이제 mini가 오프라인이 되면 트래픽은 조용히 Oracle로 전환되며, 저는 그 사실을 알려주는 이메일을 정확히 한 통 받습니다. 이것이 운영 (operations)의 전부이며, 사이드 프로젝트에 제가 원하는 운영의 수준입니다.

아직 해결하지 못한 것들

한계점에 대해서는 솔직해지고 싶습니다. 위의 아키텍처 (architecture)는 쉬운 부분이기 때문입니다.

제 평가는 여전히 바이브 (vibes, 느낌)에 의존합니다. 생성된 이메일을 읽고, 괜찮아 보이면 배포합니다. 고정된 케이스 세트에 대해 톤 (tone), 플레이스홀더 (placeholder)의 정확성, 또는 JSON 유효성을 점수화하는 평가 하네스 (eval harness)는 없습니다. 갖춰야 마땅합니다. 제가 특정 요청에 대해 qwen3.5가 qwen2.5-coder보다 "더 낫다"고 주장할 때, 그것은 벤치마크 (benchmark)가 아니라 직관입니다.

아이러니하게도 관련 인프라(plumbing)는 이미 갖춰져 있습니다. PayChasers는 제품 퍼널(product funnel), 가입, 추적 생성, 업그레이드 등을 위해 PostHog를 사용하고 있습니다. AI 이벤트를 캡처하는 것은 아주 사소한 작업일 것입니다. draft_generated, draft_accepted, draft_edited, draft_regenerated 퍼널을 구축한다면, 실제 사용자를 통해 생성된 이메일이 수정 없이 그대로 발송되는지, 아니면 다시 작성되는지를 얼마나 자주 발생하는지 파악할 수 있습니다. 그 수락률(acceptance rate)은 실제 품질 신호이며, 직관(vibes)에서 측정(measurement)으로 나아가는 가장 저렴한 첫 단계입니다. 단지 아직 연결(wired)하지 않았을 뿐입니다.

저의 모델 선택은 측정이 아닌 본능에 의존하고 있습니다. 제가 이 Qwen 모델들을 선택한 이유는 제 하드웨어에서 잘 작동했고, 실제 사용 시 성능이 좋았기 때문입니다. 체계적인 버전이라면 모델별 지연 시간(latency), 품질, 비용을 측정하고 데이터에 기반하여 라우팅(route)할 것입니다.

그리고 저는 검색(retrieval)은 아직 건드리지 않았습니다. 두 앱 모두 전체 컨텍스트(full context)를 시스템 프롬프트(system prompt)에 집어넣고 있는데, 현재 규모에서는 괜찮지만 데이터가 컨텍스트 창(window)을 넘어서는 순간 무너질 것입니다. 여기에는 RAG(Retrieval-Augmented Generation)가 없으며, 아직 그것을 사용할 필요를 느끼지 못했습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0