ryong.log
OpenRun Bot — RAG 기반 챗봇 서버 구축기 배너
🤖
2026년 2월 16일·읽는 데 약 6분

OpenRun Bot — RAG 기반 챗봇 서버 구축기

관련 글

BottomSheet Drag Close Handler 적용 가이드

2026. 2. 27.

숫자 피커에 물리 법칙을 입히다 — React에서 관성 스크롤 구현기

2026. 2. 27.

OpenClaw를 활용한 hotfix 대응구축팀 설계

2026. 3. 6.

1. 왜 만들었는가

OpenRun은 오프라인 러닝 모임("벙")을 만들고 참여하는 서비스입니다. 서비스를 사용하는 사용자들이 늘어나게 되면 서비스에 대한 문의를 하는 사용자들이 늘어나게 될 것이고, CS를 담당하는 인원을 상주하게 하지 않는 이상 FAQ만으로는 한계가 있었습니다. 이에 단순 키워드 매칭이 아닌, 서비스 문서를 실제로 이해하고 답변하는 챗봇이 필요했습니다.

핵심 요구사항은 세 가지였습니다:

  • 서비스 문서(knowledge)를 기반으로 정확한 답변 생성
  • 프론트엔드 코드가 바뀌면 문서도 자동으로 따라가야 함
  • 비개발자도 문서를 쉽게 편집할 수 있는 관리 도구 제공

2. 어떤 기술을 썼나요?

봇 서버는 Python 생태계를 중심으로 구성했습니다.

  • 서버 프레임워크: FastAPI (Python 3.13)
  • RAG 파이프라인: LangChain
  • 벡터 DB: ChromaDB (PersistentClient)
  • LLM: OpenAI GPT-4o-mini
  • 임베딩: OpenAI text-embedding-3-small
  • 배포: Docker + GCP Cloud Run
  • CI/CD: GitHub Actions

3. 프로젝트 구조 살펴보기

shell
Copied
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 생성)

4. 핵심 기능들

  • RAG 기반 질문 답변 (POST /rag/query)

      사용자 질문을 임베딩하여 ChromaDB에서 관련 문서 청크 3개를 검색하고, 이를 컨텍스트로 GPT-4o-mini가 답변을 생성합니다. 참고한 문서 출처도 함께 반환합니다.

  • 문서 위치 검색 (POST /rag/locate)

      질문과 관련된 내용이 어느 문서의 어느 섹션에 있는지 JSON으로 반환합니다. 관리 UI 챗봇에서 활용됩니다.

  • 문서 수정 제안 (POST /rag/edit)

      사용자의 수정 요청을 받아 AI가 수정안을 생성합니다. 원본 문서를 읽어 수정된 전체 문서를 반환하며, 관리 UI에서 리뷰 후 적용할 수 있습니다.

  • 문서 관리 UI (GET /manage)

      웹 기반 문서 편집기로, 다음 기능을 제공합니다:

      • 좌측 사이드바에서 문서 선택 → 마크다운 에디터에서 편집 → 미리보기 탭으로 확인
      • 우측 하단 챗봇으로 문서 위치 검색 및 수정 제안 요청
      • 수정 제안 시 diff 미리보기 + 변경 블록(hunk)별 수락/거부 가능
      • Ctrl+S 또는 저장 버튼으로 즉시 저장 → 벡터 DB 자동 동기화

  • 실시간 문서 동기화

watchfiles 라이브러리로 knowledge/ 폴더를 실시간 감시합니다. 파일이 추가/수정되면 기존 벡터를 삭제하고 새로 청크 분할 후 ChromaDB에 저장합니다. 서버 시작 시 전체 문서 동기화를 수행하며, 완료 전까지 RAG 요청은 503을 반환합니다.

5. 요청이 어떻게 흐르나요?

사용자가 챗봇에 질문을 입력하면 다음과 같은 흐름으로 처리됩니다:

[사용자] → [Next.js 프론트엔드] → [/api/chat API Route (프록시)] → [FastAPI 봇 서버 /rag/query]

FastAPI 봇 서버 내부에서는 RAG 파이프라인이 동작합니다:

  • Retrieval: 질문 임베딩 → ChromaDB에서 관련 문서 청크 3개 검색
  • Augmentation: 검색된 문서를 시스템 프롬프트에 삽입
  • Generation: GPT-4o-mini가 문서 기반으로 답변 생성 → 응답 반환

프론트엔드의 Next.js API Route(/api/chat)는 프록시 역할을 합니다. 환경변수 BOT_SERVER_URL로 봇 서버 주소를 설정하며, 로컬 개발 시에는 localhost:8000, 프로덕션에서는 Cloud Run URL을 사용합니다.

6. GitHub 워크플로우 — 프론트엔드와 봇의 연동

프론트엔드와 봇은 별도 리포지토리로 분리되어 있으며, 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 자동 배포

7. 배포는 어떻게 하나요?

main 브랜치에 push되면 bot-deploy.yml이 자동 실행됩니다:

  • Docker 이미지 빌드 (python:3.13-slim 기반)
  • GCP Artifact Registry에 push (asia-northeast3 리전)
  • Cloud Run에 배포 (메모리 1Gi, CPU 1, 인스턴스 0~3개 자동 스케일링)
  • Cloud Run은 읽기 전용 파일시스템이므로 ChromaDB는 /tmp/chroma_db 경로 사용
  • CPU 부스트 활성화로 콜드 스타트 시간 단축

8. AI가 문서를 자동으로 업데이트해준다고요?

이 스크립트는 프론트엔드 코드 변경을 분석하여 문서를 자동 업데이트하는 핵심 도구입니다.

처리 과정:

  • git diff로 프론트엔드 변경사항 추출 (CSS, 테스트 파일 제외)
  • knowledge/ 폴더의 모든 문서 로드
  • diff + 문서를 OpenAI API에 전달하여 분석
  • 사양 변경이 있으면 수정된 문서 내용을 JSON으로 반환
  • --apply 옵션이 있으면 파일에 직접 저장

AI 분석 기준:

  • 사용자 경험이나 서비스 사양에 영향을 주는 변경만 반영
  • 단순 리팩토링, 스타일 변경, 버그 수정은 무시
  • 보수적으로 판단하여 확실한 사양 변경만 문서에 반영

  • 크로스 리포지토리 연동: 프론트엔드와 봇을 별도 리포로 분리하되, repository_dispatch로 자동 통신
  • AI 기반 문서 업데이트: 코드 변경을 AI가 분석해 문서 수정 제안 → 사람이 리뷰 후 머지
  • 실시간 문서 동기화: 파일 감시로 변경 즉시 벡터 DB에 반영
  • 관리 UI 통합: 웹 기반 편집기와 AI 챗봇을 하나의 인터페이스로 제공
  • Cloud Run 최적화: /tmp 사용, 읽기 전용 파일시스템 고려, CPU 부스트 활성화
  • 재시도 메커니즘: 초기 동기화 실패 시 최대 3회 재시도
  • 청크 분할 전략: 500자 단위, 50자 오버랩으로 문맥 유지

9. 설계하면서 신경 쓴 부분들

OpenRun Bot은 단순한 챗봇을 넘어, 문서 관리의 전체 라이프사이클을 자동화하는 시스템입니다.

  • 프론트엔드 코드가 바뀌면 → GitHub Actions가 봇 리포에 알림
  • AI가 코드 변경을 분석해 문서 수정 제안 → PR 자동 생성
  • 팀원이 리뷰하고 머지하면 → 자동 배포
  • 배포된 서버는 문서를 자동 동기화하여 최신 상태 유지
  • 관리 UI를 통해 수동 편집도 가능

10. 마무리

코드 변경부터 문서 업데이트, 배포, 사용자 답변까지 하나의 파이프라인으로 연결된 시스템입니다.

Trouble Shooting

1. Cloud Run에서 벡터 DB 데이터가 주기적으로 삭제되는 문제

증상

  • 수동 동기화를 하면 정상적으로 벡터 DB에 데이터가 저장되고 챗봇이 잘 동작함
  • 일정 시간이 지나면 벡터 DB 데이터가 전부 삭제되어 챗봇이 동작하지 않음 (503 에러)
  • 관리 UI에서 "동기화 중..." 상태가 오래 지속되고, 동기화 속도가 매우 느림
  • 다시 수동 동기화 버튼을 누르면 바로 정상 동작

원인

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 에러

해결

  • min-instances=0 → 1로 변경: 인스턴스가 항상 1개 이상 유지되어 /tmp 데이터가 보존됨
  • sync_all()을 증분 동기화로 변경: 기본적으로 reset_db()를 호출하지 않고 파일 단위로 delete + add 수행. 동기화 중에도 기존 데이터로 서비스 가능
  • 서버 시작 시 기존 데이터 확인: 벡터 DB에 데이터가 이미 있으면 초기 동기화를 건너뛰어 즉시 서비스 시작
  • 수동 동기화만 force_reset=True: 관리 UI에서 수동 동기화 버튼을 누를 때만 전체 초기화 후 재동기화 수행

2. 실행형 Agent/Read/QA 라우팅 오분류 및 후속 문맥 추론 실패

증상

  • "도전과제 페이지로 데려가줘" 같은 이동 요청이 문서 QA로 떨어지거나 일시적 오류로 실패했습니다.
  • "내 말이 들려?"는 응답하지만 "네 이름이 뭐야?"는 "제공된 문서에서 찾을 수 없음"으로 떨어져 일반 AI 대화감이 깨졌습니다.
  • "내 참여 벙 이름" 조회 후 "1번 벙 참여자가 몇 명?" 같은 후속 질문이 문맥을 잃고 실패했습니다.

원인

  • 기존 구조에서 QA 조기 분기가 먼저 타면서 라우터가 우회되어, read/action 의도 판별이 누락됐습니다.
  • read 스키마가 "내 벙 개수/이름" 중심으로 협소해 특정 벙 기준 질의를 표현하기 어려웠습니다.
  • 자연어 history만 있었고, "1번"/"그 벙"을 실제 bungId로 매핑하는 구조화 메모리가 없었습니다.
  • 실행기 쪽 navigation allowlist가 부족해 일부 페이지 이동 요청이 잘못 매핑됐습니다.

해결

  • 라우팅을 chat / qa / read / action 4-lane으로 분리하고, deterministic + LLM 2단 라우팅으로 재구성했습니다.

관련 코드 (lane 라우팅 핵심)

python
Copied
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(entities, focus, lastReadResult, pendingClarification)를 도입해 후속 질문 문맥을 구조적으로 유지했습니다.

관련 코드 (conversationState/statePatch 병합)

typescript
Copied
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}
  • entity_resolver를 추가해 index/name/deictic(1번/벙 이름/그 벙) 참조를 해석하고, 모호 시 선택 유도하도록 변경했습니다.

관련 코드 (index/name/deictic 참조 해석)

python
Copied
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 카탈로그를 도전과제/프로필/추천 러너/탐색 벙/날씨까지 확장하고, BFF에서 본인 데이터 조회로 연결했습니다.

관련 코드 (후속 질문 read 처리 + statePatch 병합)

typescript
Copied
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}
  • action navigation 카탈로그를 확장하고 execute allowlist를 강화해 페이지 이동/실행 매핑을 정합화했습니다.

관련 코드 (execute allowlist 기반 navigation 매핑)

typescript
Copied
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(showSources/showActionButtons)와 smoke test 스크립트를 추가해 UI 일관성과 회귀 점검 체계를 확보했습니다.

관련 코드 (uiHints 규칙 + smoke test 케이스)

python
Copied
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)

결과

  • 일반 대화는 문서 실패 문구로 즉시 떨어지지 않고 chat lane으로 자연스럽게 응답합니다.
  • "목록 → 1번/그 벙" 같은 후속 참조 질의의 성공률이 크게 개선되었습니다.
  • 실행형 요청은 버튼 확인 이후에만 수행되고, 모호/미지원 요청은 안전하게 안내/분기됩니다.
  • OpenRun Bot — RAG 기반 챗봇 서버 구축기
  • 1. 왜 만들었는가
  • 2. 어떤 기술을 썼나요?
  • 3. 프로젝트 구조 살펴보기
  • 4. 핵심 기능들
  • 5. 요청이 어떻게 흐르나요?
  • 6. GitHub 워크플로우 — 프론트엔드와 봇의 연동
  • 7. 배포는 어떻게 하나요?
  • 8. AI가 문서를 자동으로 업데이트해준다고요?
  • 9. 설계하면서 신경 쓴 부분들
  • 10. 마무리
  • Trouble Shooting
  • 1. Cloud Run에서 벡터 DB 데이터가 주기적으로 삭제되는 문제
  • 2. 실행형 Agent/Read/QA 라우팅 오분류 및 후속 문맥 추론 실패