Code › tail-villain

모델 이름만으로는 부족했다

Gemini와 Ollama를 함께 다루며 드러난 provider, latency, failure mode 설계 문제

처음에는 단순한 비용 문제처럼 보였다.

로드맵 분석과 토픽 생성을 Gemini로 돌리기 시작하자 결과 품질은 좋아졌지만, 자연스럽게 다음 질문이 따라왔다. 이걸 계속 외부 모델로만 돌려도 괜찮을까. 개발 중인 기능을 테스트할 때마다 유료 모델을 호출하고, 긴 생성 작업이 쌓이고, 실패할 때마다 다시 호출하는 구조가 마음에 걸렸다.

그래서 로컬 모델을 붙여보기로 했다. Ollama를 띄운 뒤 llama3:8bgemma2:9b를 테스트했다.

이론적으로는 모델 이름만 바꾸면 되는 간단한 일처럼 보였지만, 실제로는 그렇지 않았다.


LLM을 하나 더 붙이는 일은 API endpoint를 하나 더 추가하는 정도로 끝나지 않았다. Gemini와 Ollama는 같은 “AI 모델”처럼 보이지만, 애플리케이션 입장에서는 서로 다른 실패 방식을 가진 별개의 공급자(provider)에 가까웠다.

Gemini는 외부 API라 네트워크와 과금, 타임아웃을 신경 써야 하고, Ollama는 로컬이라 비용 부담은 줄지만 모델마다 속도와 출력 안정성이 다르게 나타난다. 특히 구조화된 JSON을 요구하면 차이가 더 크게 드러났다. 사람처럼 답변하는 것과, 백엔드가 바로 파싱할 수 있는 정확한 스키마로 답변하는 것은 다른 문제였기 때문이다.

gemma2:9b는 기대보다 느렸고, 엄격한 출력 형식에서는 더 자주 흔들렸다. 반면 llama3:8b는 적어도 분석과 토픽 생성 흐름을 끝까지 통과시키는 데 더 현실적이었다. 그 시점에는 “더 좋아 보이는 모델”보다 “끝까지 돌아가는 모델”이 먼저 필요했다.

이때부터 AI 호출을 한 군데에 모으는 레이어가 필요해졌다. 로드맵 분석, 토픽 생성, 인터뷰 턴마다 어떤 provider와 model을 쓸지 따로 정하고, 타임아웃도 다르게 잡아야 했다. 분석은 긴 입력을 읽어야 하고, 토픽 생성은 구조화된 결과를 내야 하며, 인터뷰 턴은 대화 흐름을 끊지 않아야 한다. 같은 AI 호출이라도 요구사항은 꽤 다르다.


중간에 버그도 하나 나왔다.

대시보드에 테스트용 모델 선택기를 붙이고 Gemini 2.5 Flash를 골랐는데, 백엔드는 그 모델 이름을 Ollama로 보냈다. Ollama 입장에서는 당연히 그런 모델이 없으니 model not found가 났다.

이건 사소한 연결 실수가 아니었다. 내가 모델 이름만 넘기면 충분하다고 생각했다는 증거에 가까웠다. 하지만 여러 공급자가 있는 순간, 모델 이름은 혼자 의미를 갖지 못한다. gemini-2.5-flash라는 이름은 Gemini provider 안에서만 유효하고, llama3:8b라는 이름은 Ollama provider 안에서만 유효하다.

그래서 model override와 provider override를 함께 보내도록 바꿨다. 테스트 UI에서 모델을 고를 때도 “무슨 모델인가”뿐 아니라 “어느 공급자에게 보낼 것인가”가 같이 결정되어야 했기 때문이다.

생각해보면 당연한 일인데, 당연한 건 대개 한 번 깨지고 나서야 선명해진다.


로컬 모델을 붙이면서 속도 문제도 더 분명해졌다.

이전 글에서 로드맵 분석을 백그라운드로 옮겼다. 버튼을 누른 뒤 사용자를 30초 동안 붙잡아두지 않기 위해서였다. 그런데 토픽 생성은 아직 동기 요청에 가까웠고, 사용자가 기다리는 동안 모델이 토픽을 만들고 응답이 돌아와야 다음 단계로 넘어갈 수 있었다.

Gemini로도 긴 작업인데, 로컬 모델로 돌리면 더 위험했다. 느린 모델 하나 때문에 요청 시간이 길어지고, 인프라 타임아웃에 걸리고, 사용자는 또 멈춘 화면을 보게 된다. 비용을 줄이려고 붙인 로컬 모델이 오히려 UX 부채를 키울 수 있는 구조였다.

그래서 토픽 생성도 상태 기반 백그라운드 작업으로 바꿨다. 요청을 보내면 서버는 작업을 시작하고, 로드맵에는 토픽 생성 상태가 남는다. 프론트엔드는 그 상태를 폴링하면서 진행 중인지, 실패했는지, 완료됐는지를 보여준다. 정확한 퍼센트를 아는 척하지 않고, 애플리케이션이 실제로 아는 만큼만 보여주는 방식이다.

재생성 경고도 같이 넣었다. 토픽을 다시 만들면 기존 토픽에 연결된 모의면접 기록도 지워질 수 있기 때문이다. 데이터베이스 cascade는 개발자에게는 자연스러운 제약이지만, 사용자에게는 “내 기록이 왜 사라졌지?”라는 경험이 된다. 이런 건 버튼을 누르기 전에 말해줘야 한다.


그날 처음으로 인터뷰 세션도 제대로 형태를 갖추기 시작했다.

토픽 카드 안에서 대충 대화를 붙이는 대신, 전용 인터뷰 페이지를 만들었다. 대화 영역과 토픽 정보 패널을 나누고, 답변 입력창을 고정한 뒤 Enter로 전송하고 Shift Enter로 줄바꿈하는 기본적인 채팅 감각을 맞췄다. 사용자 메시지는 낙관적으로 먼저 보여주고, AI 답변은 로딩 버블로 기다리게 했다.

작은 차이지만 중요했다. 면접 연습은 문서를 작성하는 경험이 아니라 대화하는 경험이어야 한다. 화면이 폼처럼 느껴지면 사용자는 답안을 제출하는 사람이 되고, 화면이 대화처럼 느껴지면 면접관 앞에 앉은 사람이 된다.

그래서 대화 중 실시간 채점도 뺐다. 매 턴마다 점수와 피드백이 튀어나오면 실제 면접의 흐름이 깨진다. 질문을 받고, 답하고, 다시 파고드는 압박이 먼저 있어야 하니까. 자세한 평가는 마지막 리포트에서 하면 된다.

첫 질문도 따로 다뤘다. 처음 만난 면접관이 갑자기 중간 평가처럼 말하면 이상하다. 그래서 opening turn과 follow-up turn을 분리했고, 첫 메시지는 인사와 첫 질문으로 시작하게 했다. 답변을 바탕으로 파고드는 건 그 다음부터가 맞다.


언어 설정도 이 시점에 정리했다.

AI는 아무 말도 안 해주면 언어를 자주 흔든다. JD가 영어면 영어로 답하기도 하고, 이력서가 한국어면 한국어로 돌아오기도 하며, 프롬프트의 문장 하나에 따라 결과가 바뀐다. 사용자는 그냥 “내가 알아들을 수 있는 언어로 일관되게 나와야 한다”고 느끼는데, 모델은 그런 맥락을 자동으로 안정적으로 지켜주지 않는다.

그래서 우선순위를 정했다. 사용자가 직접 언어를 고르면 그 선택이 이긴다. 자동 모드에서는 JD를 먼저 보고, 그 다음 이력서, 마지막으로 사용자 기본 설정을 본다. 면접 준비에서는 JD의 언어가 실제 평가 환경에 가까운 경우가 많기 때문이다.

이것도 결국 같은 문제였다. AI 기능은 모델에게 맡기는 부분과 제품이 결정해야 하는 부분을 나눠야 한다. 언어는 모델이 “대충 알아서” 정할 일이 아니라, 제품 경험의 일부로 정리해야 하는 정책이었다.


그날 작업을 돌아보면 하나의 교훈으로 모인다.

AI 기능은 모델 이름 하나로 추상화되지 않는다. provider가 다르면 실패 방식이 다르고, 모델이 다르면 속도와 출력 안정성이 다르며, 작업 종류가 다르면 UX도 달라진다. 분석, 토픽 생성, 인터뷰 대화, 언어 선택을 전부 같은 “LLM 호출”로 보면 중요한 차이를 놓친다.

처음에는 비용을 줄이려고 Ollama를 붙였다. 그런데 실제로 얻은 건 비용 절감보다 더 중요한 구조였다. 어떤 작업은 Gemini가 맞고, 어떤 작업은 로컬 모델로 충분하고, 어떤 작업은 백그라운드로 보내야 한다. 반대로 어떤 작업은 대화 흐름을 위해 즉시성 자체가 품질이 된다.

모델을 바꿀 수 있게 만드는 것보다 중요한 건, 모델이 바뀌어도 제품의 흐름이 무너지지 않게 만드는 일이었다.