
OpenRun은 오프라인 러닝 모임("벙")을 만들고 참여하는 서비스입니다. 서비스를 사용하는 사용자들이 늘어나게 되면 서비스에 대한 문의를 하는 사용자들이 늘어나게 될 것이고, CS를 담당하는 인원을 상주하게 하지 않는 이상 FAQ만으로는 한계가 있었습니다. 이에 단순 키워드 매칭이 아닌, 서비스 문서를 실제로 이해하고 답변하는 챗봇이 필요했습니다.
핵심 요구사항은 세 가지였습니다:
봇 서버는 Python 생태계를 중심으로 구성했습니다.
1bot/2├── main.py # FastAPI 서버 진입점, 모든 API 엔드포인트3├── schemas.py # Pydantic 요청/응답 모델4├── Dockerfile # Docker 이미지 빌드5├── docker-compose.yml # 로컬 개발용6├── requirements.txt # Python 의존성7│8├── rag/ # RAG 파이프라인 모듈9│ ├── chain.py # LangChain RAG 체인 (query, locate, edit)10│ ├── document_loader.py # 문서 로드 및 청크 분할 (500자, 50자 오버랩)11│ ├── vector_store.py # ChromaDB 벡터 저장소 (싱글톤)12│ └── watcher.py # knowledge/ 폴더 실시간 감시13│14├── knowledge/ # RAG 참조 문서 (마크다운)15│ ├── 01_서비스_개요.md16│ ├── 02_회원가입_및_로그인.md17│ ├── ... (총 10개 문서)18│ └── 10_용어_사전.md19│20├── static/manage.html # 문서 관리 UI (웹 에디터 + 챗봇)21├── scripts/generate_docs.py # 프론트엔드 변경 분석 및 문서 자동 업데이트22└── .github/workflows/23 ├── bot-deploy.yml # GCP Cloud Run 자동 배포24 └── doc-sync.yml # 문서 자동 동기화 (PR 생성)사용자 질문을 임베딩하여 ChromaDB에서 관련 문서 청크 3개를 검색하고, 이를 컨텍스트로 GPT-4o-mini가 답변을 생성합니다. 참고한 문서 출처도 함께 반환합니다.
질문과 관련된 내용이 어느 문서의 어느 섹션에 있는지 JSON으로 반환합니다. 관리 UI 챗봇에서 활용됩니다.
사용자의 수정 요청을 받아 AI가 수정안을 생성합니다. 원본 문서를 읽어 수정된 전체 문서를 반환하며, 관리 UI에서 리뷰 후 적용할 수 있습니다.
웹 기반 문서 편집기로, 다음 기능을 제공합니다:
watchfiles 라이브러리로 knowledge/ 폴더를 실시간 감시합니다. 파일이 추가/수정되면 기존 벡터를 삭제하고 새로 청크 분할 후 ChromaDB에 저장합니다. 서버 시작 시 전체 문서 동기화를 수행하며, 완료 전까지 RAG 요청은 503을 반환합니다.
사용자가 챗봇에 질문을 입력하면 다음과 같은 흐름으로 처리됩니다:
[사용자] → [Next.js 프론트엔드] → [/api/chat API Route (프록시)] → [FastAPI 봇 서버 /rag/query]
FastAPI 봇 서버 내부에서는 RAG 파이프라인이 동작합니다:
프론트엔드의 Next.js API Route(/api/chat)는 프록시 역할을 합니다. 환경변수 BOT_SERVER_URL로 봇 서버 주소를 설정하며, 로컬 개발 시에는 localhost:8000, 프로덕션에서는 Cloud Run URL을 사용합니다.
프론트엔드와 봇은 별도 리포지토리로 분리되어 있으며, GitHub Actions를 통해 자동으로 연동됩니다. 전체 플로우는 다음과 같습니다:
① 프론트엔드 코드 변경 (src/** 파일, CSS/테스트 제외)
↓
② main 브랜치에 push → notify-bot-repo.yml 트리거
↓
③ 의미 있는 변경이 있으면 → 봇 리포에 repository_dispatch 이벤트 전송 (type: frontend-changed)
↓
④ 봇 리포의 doc-sync.yml 트리거 → 프론트엔드 코드 체크아웃 + git diff 추출
↓
⑤ generate_docs.py로 AI 분석: 코드 변경이 사양 변경인지 판단
↓
⑥ 사양 변경이 있으면 → knowledge 문서 수정 → docs/auto-update 브랜치로 PR 자동 생성
↓
⑦ 팀원 리뷰 후 머지 → bot-deploy.yml 트리거 → Cloud Run 자동 배포
main 브랜치에 push되면 bot-deploy.yml이 자동 실행됩니다:
이 스크립트는 프론트엔드 코드 변경을 분석하여 문서를 자동 업데이트하는 핵심 도구입니다.
처리 과정:
AI 분석 기준:
OpenRun Bot은 단순한 챗봇을 넘어, 문서 관리의 전체 라이프사이클을 자동화하는 시스템입니다.
코드 변경부터 문서 업데이트, 배포, 사용자 답변까지 하나의 파이프라인으로 연결된 시스템입니다.
증상
원인
3가지 문제가 복합적으로 작용하고 있었습니다.
1. Cloud Run의 min-instances=0 + /tmp 휘발성 (근본 원인)
Cloud Run은 서버리스 플랫폼으로, 트래픽이 없으면 인스턴스를 0개로 스케일 다운합니다. 배포 설정에서 min-instances=0으로 설정되어 있었기 때문에 일정 시간 요청이 없으면 컨테이너가 완전히 종료됩니다. Cloud Run의 파일시스템은 읽기 전용이라 ChromaDB 데이터를 /tmp/chroma_db에 저장하고 있었는데, /tmp는 컨테이너의 임시 메모리 파일시스템이므로 컨테이너가 종료되면 모든 데이터가 사라집니다.
2. sync_all()이 매번 reset_db()를 호출
서버가 시작될 때마다 watch_knowledge_folder() → sync_all() → reset_db() 순서로 실행되어 컬렉션을 전체 삭제한 후 10개 문서를 OpenAI 임베딩 API로 처음부터 다시 처리했습니다. 이 과정이 느려서 "동기화 중..." 상태가 오래 지속되었습니다.
3. 동기화 중 서비스 완전 차단
sync_all() 시작 시 _sync_ready = False로 설정하여 동기화가 완료될 때까지 모든 RAG 엔드포인트가 503 에러를 반환했습니다.
결과적으로 다음 사이클이 반복되었습니다:
수동 동기화 → 정상 동작 → 트래픽 없음 → 인스턴스 종료 → /tmp 데이터 소멸 → 새 요청 → 새 인스턴스 생성 → reset_db() + 전체 재동기화 → "동기화 중..." → 503 에러
해결
증상
원인
해결
관련 코드 (lane 라우팅 핵심)
1def _plan_assistant_v2(message, history=None, pending_action=None, conversation_state=None):2 history = history or []34 deterministic = _deterministic_route(5 message=message,6 history=history,7 pending_action=pending_action,8 conversation_state=conversation_state,9 )10 if deterministic is not None:11 return deterministic1213 if not _is_openrun_domain_message(message, history, pending_action, conversation_state):14 return _general_chat_response(message, history)1516 if _should_shortcut_to_qa(message, history, pending_action, conversation_state):17 return _qa_response(message, history)1819 parsed = _plan_with_llm(20 message=message,21 history=history,22 pending_action=pending_action,23 conversation_state=conversation_state,24 )관련 코드 (conversationState/statePatch 병합)
1const requestBody = {2 message: question,3 history: buildHistory(),4 pendingAction: latestPendingAction,5 conversationState,6}78if (nextBotMessage.statePatch) {9 setConversationState((prev) =>10 mergeConversationState(prev, nextBotMessage.statePatch as ConversationStatePatch),11 )12}1314function mergeConversationState(base: ConversationState, patch: ConversationStatePatch): ConversationState {15 return {16 entities: { ...base.entities, ...patch.entities, bungs: patch.entities?.bungs ?? base.entities?.bungs },17 focus: { ...base.focus, ...patch.focus },18 lastReadResult: { ...base.lastReadResult, ...patch.lastReadResult },19 pendingClarification: { ...base.pendingClarification, ...patch.pendingClarification },20 }21}관련 코드 (index/name/deictic 참조 해석)
1def parse_bung_ref_from_message(message, conversation_state=None):2 normalized = _normalize_text(message)34 index_match = re.search(r"(\d+)\s*번", message)5 if index_match:6 return {"type": "index", "value": int(index_match.group(1))}78 if any(token in normalized for token in _DEICTIC_TOKENS):9 return {"type": "deictic", "value": "last"}1011 entities = get_bung_entities(conversation_state)12 ...13 return {"type": "name", "value": matched_names[0]}141516def resolve_bung_ref(bung_ref, conversation_state):17 entities = get_bung_entities(conversation_state)18 ...19 if ref_type == "index":20 ...21 if ref_type == "deictic":22 ...23 if ref_type == "name":24 ...관련 코드 (후속 질문 read 처리 + statePatch 병합)
1if (proposal.actionKey === 'read.my_bung.member_count' || proposal.actionKey === 'read.my_bung.members') {2 const target = await resolveTargetBung(proposal, message, scope, conversationState)3 if (!target.ok) {4 return {5 ...target.response,6 lane: target.response.lane ?? 'read',7 uiHints: target.response.uiHints ?? READ_UI_HINTS,8 statePatch: mergeStatePatch(target.response.statePatch, assistant.statePatch),9 }10 }1112 const memberResult = await getBungMemberCount(target.target.bungId)13 const patch = mergeStatePatch(14 mergeStatePatch(target.statePatch, assistant.statePatch),15 makeBungFocusPatch(target.target.bungId, scope, memberResult.count),16 )17 ...18}관련 코드 (execute allowlist 기반 navigation 매핑)
1function navigationOrDefault(proposal: ChatActionProposal): ChatActionNavigation | undefined {2 if (proposal.navigation) return proposal.navigation34 if (proposal.actionKey === 'avatar.open_page') {5 return { type: 'route', href: '/avatar' }6 }7 if (proposal.actionKey === 'profile.open_page') {8 return { type: 'route', href: '/profile' }9 }10 if (proposal.actionKey === 'profile.modify.open_page') {11 return { type: 'route', href: '/profile/modify-user' }12 }13 if (proposal.actionKey === 'challenge.open_page') {14 return { type: 'route', href: '/challenges?list=progress&category=general' }15 }16 if (proposal.actionKey === 'bung.search.open_page') {17 return { type: 'route', href: '/explore' }18 }19 ...20}관련 코드 (uiHints 규칙 + smoke test 케이스)
1# bot/rag/action_planner.py2lane = _result_lane(result)3show_sources = lane == "qa" and isinstance(sources, list) and len(sources) > 04show_action_buttons = (5 lane == "action"6 and isinstance(proposal, dict)7 and kind in {"action_collect", "action_ready", "action_navigate", "action_unavailable"}8)910# frontend/scripts/chat_bff_smoke.py11Case(12 name="chat_smalltalk",13 message="내 말이 들려?",14 expected_lane="chat",15 expected_kind="chat",16 expect_show_sources=False,17 expect_show_action_buttons=False,18)19Case(20 name="qa_spec_question",21 message="도전과제가 뭐야?",22 expected_lane="qa",23 expected_kind="qa",24 expect_show_sources=True,25 expect_show_action_buttons=False,26)27Case(28 name="action_navigate_profile",29 message="프로필 페이지로 이동시켜줘",30 expected_lane="action",31 expected_kind="action_navigate",32 expected_action="profile.open_page",33 expect_show_sources=False,34 expect_show_action_buttons=True,35)결과