5개의 완전히 다른 과목을 위한 하나의 AI 튜터를 구축했습니다 — 시스템을 안정적으로 유지하는 라우팅 레이어(routing layer)를
요약
다양한 도메인을 지원하는 AI 튜터 시스템을 구축하며 겪은 엔지니어링 도전 과제와 해결책을 다룹니다. 기능 레지스트리를 통해 모델 체인, 비용, 폴백 로직을 중앙 집중식으로 관리하여 시스템 안정성과 비용 효율성을 최적화하는 방법을 설명합니다.
핵심 포인트
- 기능 레지스트리를 통한 모델 체인 및 생성 설정의 중앙 집중식 관리
- 비용과 가용성 사이의 트레이드오프를 고려한 상향/하향 폴백 전략
- 사고 토큰(thinking tokens) 관리 및 비용 누수 방지
- 결정론적 작업에만 적용하는 화이트리스트 방식의 응답 캐싱
- 멀티모달 기능의 품질 보장을 위한 명확한 실패 처리
대부분의 "AI 튜터" 앱은 시스템 프롬프트(system prompt)에 채팅창을 단순히 붙여놓은 형태입니다. 우리는 실제로 상태(state)를 유지하는 것 — 즉, 사용자가 무엇을 마스터했는지 알고, 난이도를 조절하며, 내일도 사용자를 기억하는 것 — 을 원했습니다. 이 기능은 매우 다른 다섯 가지 도메인에 걸쳐 구현되었습니다: 일반 과목 튜터, 인도의 JEE/NEET/UPSC 시험 준비(각기 다른 공식 커리큘럼과 감점 규칙 보유), CEFR A0부터 C1까지 이어지는 언어 학습 트랙, 모의 면접을 제공하는 AI 커리어 코치, 그리고 경쟁적인 "아레나(arena)" 모드입니다.
이러한 범위의 차이가 실제 엔지니어링 문제로 드러났습니다. "LLM이 광합성을 설명할 수 있는가"가 아니라 — 모든 모델이 할 수 있는 일입니다 — 다음과 같은 문제가 발생했습니다: 비용과 품질 사이의 트레이드오프(tradeoff)가 각각 다른 60개 이상의 고유한 AI 기반 기능이 있고, 그중 하나가 새벽 2시에 작동을 멈추기 시작한다면 어떻게 될까요?
실제로 중요했던 것: 프롬프트 라이브러리가 아닌 기능 레지스트리(feature registry)
초기에는 모델 호출이 흩어져 있었습니다. 각 기능은 자신만의 모델, 자신만의 재시도 로직(retry logic, 종종 없음), 그리고 자신만의 "사고 예산(thinking budget)" 개념을 선택했습니다. 비용 감사 결과, (출력 요율로 청구되는) 사고 토큰(thinking tokens)이 전체 시스템에서 가장 큰 비용 누수 지점임을 발견했습니다. 이것이 재설계를 강제한 원인이었습니다.
이제 모든 AI 기반 기능은 단일 레지스트리 파일의 하나의 항목이 됩니다:
export const FEATURES = {
chat_message: { sparks: 2, chain: LITE_FIRST, stream: true },
generate_quiz: { sparks: 1, chain: LITE_FIRST, cacheTtlMs: 30 * 60_000 },
km_build_concepts: { sparks: 8, chain: FLASH_FIRST },
// ...60개 이상의 항목이 각각 한 줄씩 존재
};
각 항목은 가격, 모델 체인(model chain, 기본 모델 + 폴백(fallbacks)), 그리고 생성 설정(generation config)을 선언합니다. 라우터(router)는 실패 시 체인을 따라 이동합니다:
- Lite-primary 기능은 동일한 가격으로 Flash로 상향 (up) 폴백(fallback)됩니다 — Lite 모델 장애 발생 시에는 마진보다 가용성이 우선입니다.
- Flash-primary 기능은 동일한 가격으로 Lite로 하향 (down) 폴백됩니다 — 오류 페이지를 보여주는 것보다는 품질이 저하된 답변이라도 제공하는 것이 낫고, 그 가치 차이가 회계 장부의 변동을 감수할 만큼 크지 않습니다.
- 이미지/PDF/비디오를 전송하는 모든 기능은 폴백이 전혀 없습니다 — 앱 내의 어떤 기능도 더 작은 모델로 미디어를 보낸 적이 없으므로, 해당 모델의 멀티모달 (multimodal) 품질은 검증되지 않았습니다. 쓰레기 같은 결과를 조용히 반환하는 것보다 (자동 환불과 함께) 명확하게 실패를 알리는 것이 낫습니다.
체인의 더 저렴한 단계에서 답변이 이루어질 경우, 라우터(router)는 가격 차액을 자동으로 환불합니다. 응답 캐싱 (Response caching)은 휴리스틱 (heuristic)이 아니라 화이트리스트 (allowlist) 방식입니다. 즉, 결정론적 (deterministic)에 가까운 생성기(퀴즈, 복습, 커리큘럼)에만 TTL(Time To Live)이 부여되며, 개인화된 채팅은 명시적으로 절대 캐싱되지 않습니다. 또한 클라이언트 측 페이월 (paywall)은 가격 정보를 자체적으로 보유합니다 — 테스트를 통해 두 정보가 바이트 단위로 일치하는지 확인하므로, 데이터 드리프트 (drift)가 발생하면 고객 지원 티켓이 생성되는 대신 CI (지속적 통합) 실패로 처리됩니다.
이 중 어느 것도 생소한 것이 아닙니다. 이는 모든 AI 제품이 결국 필요로 하지만, 출시 첫날에는 거의 아무도 구현하지 않는 지루하고 화려하지 않은 배관 작업 (plumbing)입니다 — 왜냐하면 출시 첫날의 핵심은 프롬프트 (prompt)이지, 프롬프트의 실패 모드 (failure mode)가 아니기 때문입니다.
우리에게 가장 큰 교훈을 준 버그: 0%에 멈춰버린 숙달도 (mastery)
가장 뼈아팠던 사례를 소개하겠습니다. 우리는 간격 반복 (spaced repetition), 진행 바 (progress bars) 등 모든 것을 갖춘
해결책은 영리한 알고리즘이 아니었습니다. 그것은 보이지 않던 계약을 가시화하는 것이었습니다. 즉, 공유된 masteryRules + pathProgress 엔진을 구축하고, 아무도 알지 못하는 마법의 단어 대신 실제 "내 이해도 확인하기 (Check my understanding)" 버튼을 만드는 것이었습니다. 이 교훈은 단순히 이 버그 하나를 넘어 일반화될 수 있습니다. 만약 핵심적인 피드백 루프 (feedback loop)가 문서화되지 않은 트리거를 사용자가 알고 있어야 한다는 점에 의존한다면, 그것은 존재하지 않는 것이나 다름없습니다.
실패를 숨기는 대신 정직하게 드러내기
언급할 가치가 있는 또 다른 패턴은 다음과 같습니다. 한동안 오류가 발생하는 모든 AI 호출은 console.error(e)로 잡히고 null을 렌더링하며 그대로 삼켜졌습니다. 사용자 입장에서는 아무런 설명 없이 빈 화면만 보일 뿐이며, 이는 앱이 고장 난 것과 구분할 수 없습니다. 우리는 이러한 모든 호출 지점을 사용자에게 무슨 일이 일어났는지 실제로 알려주고 재시도 경로를 제공하는 공유 에러 상태 (error-state) 컴포넌트로 교체했습니다. 작은 변화이지만, 사용자의 머릿속에서 "이 앱은 고장 났다"와 "이 앱은 잠시 문제가 생겼다"의 차이를 만들어내며, 이 둘은 매우 다른 반응을 불러일으킵니다.
현재 상태
- 63개 파일에 걸친 693개의 테스트가 모든 변경 사항마다 실행됨
- React 19 + Firebase/Firestore + Cloud Run 백엔드, 위에서 언급한 라우터 (router) 뒤에 Gemini 2.5 Flash/Pro/Lite 배치
- 하나의 크레딧 시스템, 하나의 숙달 엔진 (mastery engine), 기기 간 하나의 동기화 레이어 (sync layer)를 공유하는 5가지 학습 모드
직접 살펴보시려면 vararuchi.com에서 확인하실 수 있습니다.
저는 침묵하는 가입자 100명보다 무엇이 잘못되었는지 말해주는 3명을 진심으로 더 선호합니다. 그러니 — 무엇이든 망가뜨려 보시고, 무엇을 발견했는지 저에게 알려주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기