멀티 LLM 백엔드 스위칭 — 로컬에서 죽고 클라우드로 살린 이야기


처음에는 로컬 Ollama 하나면 충분하다고 생각했습니다. 신입·경력직이 특정 B2B 도메인을 롤플레이로 익히도록 만든 이 챗봇은 어차피 필자 혼자, 길어야 두세 명이 돌려 볼 물건이었으니까요. API 키를 발급받고 요금을 걱정할 것도 없이, 노트북에서 gemma3:12b 하나 띄워 두고 AI 고객과 대화하면 그만이라고 봤습니다. 그런데 어느 날 여러 대화 턴이 거의 동시에 들어오는 상황을 흉내 내다가, 모델이 통째로 죽었습니다. 이 글은 그 사건에서 출발해, LLM 백엔드를 하나로 못 박지 않고 Claude CLI·Gemini API·로컬 Ollama 세 갈래로 벌려 두고 런타임에 갈아끼우게 만든 과정을 트러블슈팅 로그로 정리합니다.

[시리즈] S2S·LLM 음성 에이전트 엔지니어링 63편 · 현재 10-4편

← 이전: 10-3 RAG 파이프라인과 로컬 임베딩 / 다음 → 10-5 AI 고객 페르소나 프롬프트 엔지니어링

전체 목차 펼치기 (63편)

Part 1 — 음성 AI가 느린 이유: 레이턴시 진단 (4편)

Part 2 — 텍스트를 넘어: End-to-End S2S의 원리 (4편)

Part 3 — 실시간 대화와 에이전트화: 인터럽트·MCP·도구 (5편)

Part 4 — 인프라·세션·보안: 프로덕션 백엔드 (5편)

Part 5 — 운영·품질·평가: 에이전틱 워크플로우와 신뢰성 (7편)

Part 6 — 음성 경험 고도화·실전 통합: 한국어·UX·전화망·엣지 (5편)

Part 7 — S2S 도입 전략·구매 의사결정 (4편)

Part 8 — LLM 콜봇 PoC 현장 (6편)

Part 9 — 상담어시스트: 상담사 화면을 바꾸는 실시간 AI (7편)

Part 10 — 직접 만든 롤플레이 챗봇: RAG 에이전트 구축기 (8편)

Part 11 — KMS 실무 — 지식관리시스템의 설계와 운영 현실 (8편)

왜 백엔드를 하나로 못 박지 않았나

백엔드를 하나만 고르라는 요구는 처음부터 무리였습니다. 이 챗봇은 성격이 다른 세 가지 상황에서 돌아가야 했기 때문입니다. 코드를 고치며 프롬프트를 수십 번 다시 던져 보는 개발 상황, 다른 사람에게 대화 흐름을 보여 주는 데모 상황, 그리고 자료가 밖으로 나가면 안 되는 프라이버시 우선 상황이었습니다. 이 세 가지가 요구하는 것이 서로 정반대였습니다.

개발 중에는 응답 품질이 가장 좋은 모델로 프롬프트를 다듬고 싶었습니다. 롤플레이의 AI 고객이 자연스럽게 반박하고, 힌트 큐카드가 어색하지 않게 나오는지를 보려면 추론이 튼튼한 모델이 필요했습니다. 반대로 데모할 때는 응답 속도와 안정성이 품질보다 중요했습니다. 그리고 도메인 자료가 민감할 때는 아무리 좋은 클라우드 모델이라도 쓸 수 없고, 오직 로컬에서만 돌려야 했습니다.

여기서 판단한 것은 단순했습니다. "가장 좋은 하나"를 고르는 게임이 아니라, 상황마다 다른 백엔드를 고를 수 있게 만드는 게임이라는 것이었습니다. 그래서 코드 어디에도 특정 provider 이름을 직접 박지 않기로 했습니다. LLM을 호출하는 지점은 provider와 model을 인자로만 받고, 실제로 무엇을 쓸지는 바깥에서 결정하도록 뺐습니다. 이 결정이 나중에 OOM 사건을 수습할 때 결정적인 여유를 만들어 줍니다.

세 백엔드를 나란히 놓고 비교하다

runtime_config 기반 멀티 LLM 라우팅 사용자 요청이 중앙의 runtime_config 라우터를 거친 뒤, 로컬 모델, 클라우드 A, 클라우드 B 세 백엔드 중 하나로 호출마다 갈아끼워진다. runtime_config 클라우드 A로컬 모델클라우드 B 요청

그림 1. 호출마다 runtime_config가 로컬·클라우드A·클라우드B 중 하나로 백엔드를 갈아끼운다.

후보는 세 개였습니다. 클라우드 두 개(Claude CLI, Gemini API)와 로컬 하나(Ollama)입니다. 각각 결이 완전히 달라서, 오히려 서로의 빈자리를 메워 주었습니다.

백엔드모델 예인증·비용강점주로 쓰는 상황
Claude CLIsonnet구독(Pro/Max) OAuth — API 키 불필요추론·한국어 자연스러움프롬프트 개발·품질 확인
Gemini APIgemini / gemma-cloud 계열API 키 + 사용량 추적빠른 응답·클라우드 안정성데모·다회 호출
로컬 Ollamagemma3:12b무료·오프라인·유출 0프라이버시·비용 0민감 자료·인터넷 없는 환경

세 후보 중 가장 반가웠던 건 Claude CLI였습니다. 이미 구독을 쓰고 있었는데, CLI가 OAuth 기반이라 별도 API 키를 코드에 넣을 필요가 없었습니다. 코드에서는 그냥 CLI 프로세스를 subprocess로 띄우고, 프롬프트를 표준입력으로 넘긴 뒤 표준출력을 받아 오면 끝이었습니다. 키 관리·요금 계량이라는 골칫거리를 통째로 건너뛴 셈입니다. Gemini는 google-genai 클라이언트로 붙였고, 사용량을 따로 집계하는 얇은 래퍼를 하나 뒀습니다. 데모에서 호출이 몰릴 때 얼마나 썼는지 눈에 보이게 하려는 목적이었습니다.

로컬 Ollama는 조금 더 손이 갔습니다. 그냥 gemma3:12b를 부르는 게 아니라, 롤플레이 전용으로 파라미터를 고정한 Modelfile.roleplay를 만들어 두고 그걸 올렸습니다. 컨텍스트 창은 num_ctx 4096, 온도는 temperature 0.8로 조금 높여 AI 고객이 판에 박힌 말만 하지 않게 했습니다. 같은 표현이 계속 반복되는 걸 막으려고 repeat_penalty 1.15를 걸었고, 응답이 장황하게 늘어지지 않도록 num_predict 256으로 출력 길이를 눌러 뒀습니다. 롤플레이는 한 턴이 짧고 리듬감이 있어야 훈련이 되기 때문에, 이 짧은 출력 제한이 의외로 중요했습니다.

runtime_config로 매 호출마다 갈아끼우다

세 백엔드를 벌려 놨으니, 이제 "지금 이 순간 무엇을 쓸지"를 정하는 스위치가 필요했습니다. 여기서 흔히 하는 실수가 서버 시작할 때 환경변수로 한 번 정해 버리는 것입니다. 그러면 백엔드를 바꾸려고 서버를 재시작해야 하는데, 개발 중에 이건 정말 성가십니다.

그래서 runtime_config라는 얇은 설정 지점을 두고, LLM을 부르는 매 호출마다 거기서 provider와 model을 읽어 오게 했습니다. 설정 값만 바꾸면 다음 호출부터 곧바로 다른 백엔드로 넘어갑니다. 재시작이 없습니다. 개발하다 "이 응답 로컬이 이상한데 sonnet은 뭐라고 할까" 싶으면 설정만 톡 바꾸고 같은 대화를 이어서 다시 던져 볼 수 있었습니다.

[참고] 프롬프트도 백엔드에 따라 달라야 했습니다. 같은 지시문이라도 모델마다 반응이 다르기 때문입니다. 그래서 프롬프트는 공통 BASE에 모델별 오버라이드를 얹는 구조로 관리했습니다. runtime_config가 model을 바꾸면, 그 model에 맞는 프롬프트 오버라이드가 함께 따라붙게 해 둔 것입니다. "백엔드 교체"는 provider만 바꾸는 게 아니라 프롬프트까지 세트로 갈아끼우는 일이었습니다.

이 구조의 진짜 값어치는 코드가 provider를 몰라도 된다는 데 있었습니다. 롤플레이 로직, 힌트 생성, 채점 로직 어느 곳도 "지금 Claude를 쓰는지 Gemini를 쓰는지"를 알 필요가 없었습니다. 그저 "LLM에게 이 프롬프트를 던져 응답을 받아 와"라고만 요청하면, 그 뒤에서 runtime_config가 실제 백엔드를 골라 줬습니다. 덕분에 백엔드를 하나 더 붙이거나 빼도 상위 로직은 손댈 필요가 없었습니다.

OOM 사건 — 로컬이 통째로 죽던 날

문제는 로컬 Ollama를 실제 사용 패턴으로 몰아붙였을 때 터졌습니다. 롤플레이 한 세션 안에서도 한 턴을 처리하려면 LLM 호출이 한 번으로 끝나지 않습니다. AI 고객의 응답을 만들고, 힌트 큐카드를 뽑고, 방금 사용자가 한 답변을 채점하는 것까지 여러 호출이 얽힙니다. 여기에 대화 여러 개가 겹쳐 들어오는 상황을 흉내 내자, 로컬 모델로 가는 호출이 한꺼번에 쏟아졌습니다.

그러자 노트북이 버티지 못했습니다. 12B 모델 하나를 메모리에 올리는 것만으로도 부담인데, 동시 호출이 폭주하자 Ollama가 여러 요청을 동시에 처리하려다 메모리를 다 먹고 OOM(Out Of Memory)으로 프로세스가 죽어 버렸습니다. 증상은 명확했습니다. 몇 개 요청은 정상 응답이 오는데, 어느 순간 응답이 뚝 끊기고 그 뒤 호출은 전부 연결이 끊긴 채 실패했습니다. Ollama 프로세스 자체가 내려간 것입니다. 클라우드 백엔드였다면 provider 쪽에서 큐잉하고 알아서 조절해 줬겠지만, 로컬은 모든 부하를 내 노트북 한 대가 그대로 받아 냅니다.

여기서 앞서 백엔드를 하나로 안 박아 둔 게 살렸습니다. 급할 때는 runtime_config를 클라우드로 돌려 버리면 그만이었습니다. 죽은 건 로컬 모델이지 애플리케이션 전체가 아니었으니까요. 다만 그건 응급조치였고, 원인은 따로 잡아야 했습니다. 원인은 뻔했습니다. 로컬 백엔드로 나가는 동시 호출 수에 상한이 없었던 것입니다. 클라우드는 몰라도, 로컬은 반드시 목줄을 채워야 했습니다.

해결 — asyncio.Semaphore로 동시성에 목줄을 채우다

해법은 요란하지 않았습니다. 동시에 진행되는 LLM 호출 수를 일정 개수 이하로 강제하는 세마포어를 하나 두는 것이었습니다. Python의 asyncio.Semaphore를 MAX_CONCURRENT_LLM = 5로 만들고, 모든 LLM 호출이 이 세마포어를 통과하도록 감쌌습니다. 다섯 개가 이미 처리 중이면 여섯 번째 호출은 자리가 날 때까지 await 지점에서 조용히 기다립니다. 요청을 버리는 게 아니라 줄을 세우는 것입니다.

요청 A ─┐
요청 B ─┤
요청 C ─┼─▸ [ Semaphore (5) ] ─▸ LLM 백엔드
요청 D ─┤        │
요청 E ─┘        │  ← 5칸이 다 차면
요청 F ─ ─ ─ ─ ─ ┘     자리 날 때까지 대기(await)

async with LLM_SEMAPHORE:      # 자리 하나 점유
    result = await call_llm(provider, model, prompt)
# 블록을 벗어나면 자리 반납 → 대기 중이던 요청이 진입

핵심은 이 세마포어를 백엔드 종류와 무관하게 LLM 호출 공통 경로에 걸었다는 점입니다. 애초에 병목은 로컬 Ollama였지만, 상한을 로컬에만 걸면 코드가 지저분해집니다. 어차피 클라우드도 무한정 병렬로 때릴 이유가 없으니, 모든 호출을 같은 문으로 통과시키는 편이 깔끔했습니다. 값을 5로 잡은 건 노트북 한 대가 12B 모델을 안정적으로 감당하는 선을 실측으로 더듬어 찾은 결과입니다. 더 키우면 다시 위태로웠고, 더 줄이면 응답이 굼떴습니다.

세마포어를 넣은 뒤로는 같은 동시 부하를 줘도 OOM이 재현되지 않았습니다. 여섯 번째, 일곱 번째 요청이 몰려도 다섯 개까지만 실제로 모델을 건드리고 나머지는 대기열에서 순서를 기다렸습니다. 응답이 조금 늦어질 수는 있어도, 프로세스가 죽어 전체 대화가 날아가는 참사는 사라졌습니다. 혼자 쓰는 도구에서 "조금 느림"과 "통째로 죽음"의 차이는 하늘과 땅입니다.

그래서 얻은 유연성

이 세 갈래 구조를 갖추고 나니, 그동안 서로 부딪히던 요구들이 각자 자리를 찾았습니다. 개발할 때는 Claude CLI(sonnet)로 프롬프트 품질을 다듬고, 데모할 때는 Gemini로 빠르고 안정적으로 돌리고, 민감한 자료를 다뤄야 할 때는 로컬 Ollama로 인터넷 없이 돌립니다. 같은 코드, 같은 챗봇인데 상황에 맞는 백엔드로 갈아입기만 하면 됐습니다.

비용 관점에서도 자유로웠습니다. 평소 개발·실험은 구독형 Claude CLI라 호출당 요금 걱정이 없었고, 로컬은 아예 0원이었습니다. Gemini만 사용량을 재는 래퍼로 눈에 보이게 관리하면 됐습니다. 만약 처음부터 한 백엔드에 코드를 묶어 뒀다면, 상황이 바뀔 때마다 로직을 뜯어고쳐야 했을 겁니다. 롤플레이의 다른 조각들 — AI 고객 페르소나 프롬프트나 대화 채점 로직 — 이 백엔드 교체에 전혀 흔들리지 않았던 것도 이 분리 덕분입니다.

정리하면, 로컬에서 죽은 사건이 오히려 설계를 단단하게 만들었습니다. "하나로 못 박지 않는다"는 초반의 결정이 응급조치의 여지를 줬고, OOM이라는 구체적 고통이 세마포어라는 구체적 해법을 불러왔습니다. 혼자 쓰는 챗봇이라 화려한 오케스트레이션은 필요 없었지만, provider 추상화 + 동시성 상한이라는 두 장치만으로도 세 가지 상황을 한 코드로 감당할 수 있었습니다. 아래는 이 과정에서 자주 받은 질문들입니다.

자주 묻는 질문(FAQ)

Q1. Claude CLI를 코드에서 어떻게 부르나요? API 키가 정말 필요 없나요?
네, 별도 API 키 없이 붙였습니다. 이미 구독(Pro/Max)을 쓰고 있으면 CLI가 OAuth로 인증되어 있어서, 코드에서는 그 CLI를 subprocess로 실행하고 프롬프트를 표준입력으로 넘긴 뒤 표준출력을 응답으로 받아 오는 식으로 감쌌습니다. 클라이언트 SDK에 API 키를 넣는 방식이 아니라, 이미 로그인된 CLI를 얇게 래핑하는 방식이라 키 관리·요금 계량 부담이 없었습니다. 다만 프로세스를 띄우는 오버헤드가 있어, 초저지연이 필요한 실시간 파이프라인보다는 이런 개발·연습용 도구에 잘 맞습니다.

Q2. 클라우드 모델이 있는데 로컬 Ollama는 왜 필요했나요?
두 가지 이유였습니다. 첫째는 프라이버시입니다. 도메인 자료가 민감할 때는 아무리 좋은 클라우드라도 데이터를 밖으로 내보낼 수 없어서, 오직 로컬에서만 돌려야 하는 시나리오가 있었습니다. 둘째는 비용과 오프라인 동작입니다. 인터넷이 없거나 요금을 아예 안 쓰고 싶을 때 gemma3:12b 하나면 노트북 안에서 완결됐습니다. 대신 로컬은 부하를 내 기기가 그대로 받기 때문에, 동시 호출 상한 같은 안전장치가 반드시 필요했습니다.

Q3. 세마포어 값(동시 5개)은 어떻게 정하나요?
공식이 있다기보다 실측으로 더듬어 찾았습니다. 병목은 로컬 12B 모델이 노트북 메모리를 얼마나 먹느냐였기 때문에, 값을 조금씩 올려 가며 OOM이 나기 직전 안정선을 찾았습니다. 너무 높이면 동시 호출이 몰릴 때 다시 메모리가 터지고, 너무 낮추면 요청이 대기열에서 오래 기다려 응답이 굼떠집니다. 결국 하드웨어(메모리·GPU)와 모델 크기, 예상 동시 사용량 세 가지의 함수입니다. 다른 기기·다른 모델이라면 값이 달라지는 게 정상이라, 저는 이 값을 코드 상수로 빼 두고 환경이 바뀌면 조정할 수 있게 했습니다. 끝으로 하나 밝혀 두면, 이 글은 2026년 7월 시점에 필자가 직접 만든 개인 연습용 챗봇의 구축 경험을 익명화·재구성해 쓴 것으로, 회사·고객사·인물·조직 규모를 특정할 수 있는 정보는 담지 않았고 구체 수치도 Modelfile 파라미터나 세마포어 상한처럼 공개 가능한 설정값 수준만 다뤘습니다.

댓글

이 블로그의 인기 게시물

STT 인식률 93%의 함정 — 왜 그 숫자는 콜봇 목표가 될 수 없는가

AI 보이스봇이 답답한 이유: Cascaded 구조 레이턴시 완전 분석

음성 토큰(Audio Token)이란? LLM이 소리를 이해하는 원리