← 개발 로그 목록

LearningCollector: AI 대화 다중 주제 분리 및 병렬 워커 풀 도입

/ 13분 분량 / 개발 로그

AI 대화 학습 초안을 생성할 때 하나의 대화 안에 서로 무관한 주제가 섞여 있으면 별도의 연재 포스팅으로 분리하도록 개선하고, 동시에 카테고리별 초안 생성 전체를 병렬 워커 풀 구조로 리팩토링했습니다. 여기에 Gemini 한도 초과 시 Claude Pro(claude CLI)로 자동 폴백하는 로직도 함께 다듬었습니다.

요약

2026년 7월 17일 커밋으로, PR #25 "claude/fix-study-multi-topic-drafts"를 머지한 기록입니다. 커밋 메시지 자체는 짧지만 실제 diff를 보면 core/gemini_draft_generator.py를 중심으로 상당히 큰 구조 변경이 들어갔습니다. 총 10개 파일이 바뀌었고 773줄이 추가되고 270줄이 삭제됐습니다. 특히 api/claude_code_client.py는 227줄이 통째로 새로 생긴 파일이라, 이번 작업이 단순 버그 수정이 아니라 AI 클라이언트 계층 자체를 다시 설계한 작업이었다는 걸 알 수 있습니다.

배경 및 목적

기존에는 AI 대화 하나를 통째로 하나의 학습 요약 포스팅으로 만들었습니다. 그런데 실제로 하루 동안 나눈 AI 대화에는 서로 관련 없는 주제(예: 알고리즘 질문 하다가 갑자기 인프라 세팅 얘기로 넘어가는 식)가 섞여 있는 경우가 많았고, 이걸 하나의 포스팅으로 뭉뚱그리면 글의 초점이 흐려지는 문제가 있었습니다. 브랜치명 fix-study-multi-topic-drafts가 이 문제를 정확히 짚어줍니다.

이 문제를 해결하려면 대화를 주제 단위로 먼저 분리하는 판단 단계가 필요했고, 이 판단과 이후 초안 생성 요청이 늘어나면 순차 처리 방식으로는 처리 시간이 너무 길어질 게 뻔했습니다. 그래서 자연스럽게 병렬 처리 구조도 함께 손보게 된 것 같습니다.

구현 내용

변경된 파일 목록

  • .env.example
  • api/ai_client.py (+132/-8)
  • api/claude_code_client.py (신규, +227)
  • api/gemini_client.py (+42/-7)
  • core/gemini_draft_generator.py (+301/-237)
  • core/json_draft_validator.py
  • core/orchestrator.py
  • policies/storage/draft_saver.py (+29/-14)
  • prompts/당일_공부_요약_프롬프트.md
  • prompts/대화_주제_분리_프롬프트.md

1. 대화 주제 분리 로직

핵심은 AIClient.split_topics()입니다. 대화 원문을 한 줄씩 번호를 매겨서 모델에 넘기고, 모델은 원문을 재생성하지 않고 주제 제목과 시작 줄 번호만 JSON으로 반환하도록 설계했습니다.

lines = conversation.split("\n")
numbered = "\n".join(f"{i}: {line}" for i, line in enumerate(lines))
topics = ai_client.split_topics(segmentation_prompt, numbered)

원문을 다시 뱉게 하면 출력 토큰도 많이 쓰고 누락·변형될 위험이 있다는 코멘트가 코드에 남아 있는데, 이 부분은 실제로 시행착오를 겪었을 것 같은 대목입니다. 분리 지점이 신뢰할 수 없는 형태(첫 시작 줄이 0이 아니거나 분리 지점이 1개뿐인 경우)면 그냥 원본 그대로 사용하도록 안전장치를 걸어뒀습니다.

주제가 2개 이상으로 분리되면 각 세그먼트마다 완전히 별도의 AI 요청을 보내고, 결과물 제목에 (연재 N/M) 표시를 붙이고 태그에도 "연재"를 추가합니다.

def _mark_as_series_part(self, content: str, idx: int, total: int) -> str:
    lines = content.split("\n")
    for i, line in enumerate(lines):
        if line.strip().startswith("# "):
            lines[i] = f"{line.rstrip()} (연재 {idx}/{total})"
            break
    ...

연재 중 일부 파트만 생성에 실패하면, 이미 저장된 파트를 지우고 다음 실행에서 처음부터 다시 시도하도록 처리했습니다. 그렇지 않으면 "일부만 저장된 상태"가 "이미 draft 있음"으로 오인되어 나머지 파트가 영영 생성되지 않는 문제가 생기기 때문입니다.

2. 워커 풀 기반 병렬 처리

기존에는 _generate_baekjoon_drafts, _generate_dev_drafts, _generate_pr_drafts가 거의 같은 코드를 복붙한 형태로 순차 처리하고 있었는데, 이번에 _generate_drafts_parallel() 하나로 통합했습니다. queue.Queue에 처리할 JSON 파일을 넣고, 워커 스레드 여러 개가 꺼내가며 처리하는 구조입니다.

self.max_workers = int(os.getenv("CLAUDE_PARALLEL_WORKERS", str(DEFAULT_PARALLEL_WORKERS)))
self.ai_clients = [
    AIClient(gemini_exhausted_flag=self.gemini_exhausted_flag)
    for _ in range(self.max_workers)
]

워커마다 전용 AIClient(=전용 claude CLI 영속 프로세스)를 하나씩 물려줬는데, 이건 claude CLI 프로세스 하나를 여러 스레드가 동시에 stdin/stdout으로 찌르면 서로 다른 요청의 응답이 뒤섞일 수 있기 때문입니다. 대신 "Gemini 일일 한도 초과 여부"는 SharedFlag라는 스레드 세이프 클래스로 모든 워커가 공유하게 해서, 한 워커가 한도 초과를 확인하면 나머지 워커들도 즉시 Claude Pro로만 처리하도록 했습니다.

class SharedFlag:
    def __init__(self):
        self._value = False
        self._lock = threading.Lock()

    def get(self) -> bool:
        with self._lock:
            return self._value

    def set(self):
        with self._lock:
            self._value = True

3. Claude Pro(claude CLI) 폴백 클라이언트 신설

api/claude_code_client.py가 새로 생겼습니다. Gemini 일일 한도를 넘으면 claude -p --input-format stream-json 세션을 영속 프로세스로 띄워서 재사용합니다. 매번 새 프로세스를 띄우면 claude CLI 자체 기동 오버헤드가 호출당 약 9초씩 발생한다는 코멘트가 있는데, 이 수치는 실측해서 남긴 것으로 보입니다. 항목 사이에는 /clear 명령으로 대화 맥락만 리셋해서 서로 다른 JSON 데이터가 섞이지 않게 했습니다.

인증은 CLAUDE_CODE_OAUTH_TOKEN 환경변수를 씁니다. 브라우저 OAuth 세션은 cron 같은 무인 환경에서 재인증이 필요해 쓸 수 없어서, claude setup-token으로 발급받는 장기 토큰을 채택한 것으로 보입니다.

Claude Pro 사용량 한도 초과 판단도 까다로웠던 부분 같습니다. 실제 한도에 걸리면 result 텍스트는 그냥 빈 문자열로 오기 때문에, 텍스트 키워드만으로는 진짜 한도 초과인지 구분할 수 없어서 rate_limit_event의 rate_limit_info.status가 "allowed"가 아닌지를 별도로 체크하도록 했습니다.

rate_limited = bool(rate_limit_info) and rate_limit_info.get("status") != "allowed"

시스템 프롬프트도 기본값을 쓰지 않고 직접 덮어썼는데, 기본 프롬프트를 쓰면 "메모리 파일을 읽겠다"는 식의 실행되지 않는 도구 호출을 텍스트로 narration해서 실제 초안 대신 엉뚱한 출력이 나오는 문제가 있었다고 합니다. 이 역시 실제로 겪은 뒤 고친 것으로 보이는 대목입니다.

4. Gemini 출력 토큰 한도 동적 조정

gemini_client.py에서 max_output_tokens를 입력 길이에 비례해서 추정하도록 바꿨습니다.

def _estimate_max_output_tokens(prompt_len_chars: int) -> int:
    estimated = prompt_len_chars // 2
    return max(MIN_OUTPUT_TOKENS, min(MAX_OUTPUT_TOKENS, estimated))

입력이 큰데 출력 한도가 고정값으로 작게 잡혀 있으면 모델이 뒷부분 내용을 반영하지 못하고 앞부분 위주로만 짧게 답을 끝내버리는 문제가 있었다는 설명이 코드에 남아 있습니다. 여기에 더해 thinking_budget=0으로 내부 추론 토큰을 꺼서, 단순 요약/분류 작업에는 한도 전부를 실제 출력에 쓰도록 조정했습니다.

또한 AI 대화 학습 초안 전용으로 gemini-2.5-flash(다른 카테고리는 비용 때문에 flash-lite 유지)를 쓰도록 모델을 분리했는데, 원본 분량이 크고 뒷부분 내용 반영이 중요한 카테고리라서 성능이 더 좋은 모델을 배정한 것으로 보입니다.

5. draft_saver의 파트 파일 지원

draft_saver.py도 여러 포스팅으로 분리되는 경우를 지원하도록 확장됐습니다. 파일명에 _partN 접미사를 붙일 수 있게 하고, 중복 체크 로직도 정규식으로 partN 형식까지 매칭하도록 바꿨습니다. 여러 파트 중 일부만 오류 draft인 경우 해당 파트만 지우고, 나머지 정상 파트가 남아있으면 여전히 중복으로 취급하는 세밀한 처리도 들어갔습니다.

기술적 의사결정

왜 워커마다 별도 claude CLI 프로세스를 두었는가: 하나의 프로세스를 여러 스레드가 공유하면 stdin/stdout 스트림에서 서로 다른 요청의 응답이 섞일 위험이 있습니다. 프로세스를 워커별로 분리하면 이 문제는 원천적으로 사라지지만, 그만큼 claude CLI 프로세스가 여러 개 떠 있어야 하니 리소스 비용은 늘어납니다. 그래도 안정성을 우선한 선택으로 보입니다.

왜 원문을 재생성하지 않고 줄 번호로 분리 지점만 받는가: 대화 주제 분리를 위해 모델에게 원문 전체를 다시 쓰게 할 수도 있었지만, 그러면 출력 토큰 소모가 크고 원문이 미묘하게 바뀌거나 누락될 위험이 있습니다. 대신 줄 번호가 매겨진 원문을 입력하고 "어디서부터 어디까지가 한 주제다"라는 메타데이터만 받아서, 실제 분리는 파이썬 코드에서 문자열 슬라이싱으로 처리하는 방식을 택했습니다. 데이터 무결성을 지키면서 비용도 아낄 수 있는 절충안입니다.

Gemini 우선 + Claude Pro 폴백 구조: Groq 폴백은 한국어 생성 시 한자·태국어가 섞이는 품질 문제로 이미 비활성화되어 있었는데, 이번에 그 자리를 Claude Pro 구독 기반 claude CLI로 대체했습니다. Gemini API는 무료 한도가 있어 비용 부담이 적지만 일일 한도가 존재하고, Claude Pro는 이미 구독 중인 리소스를 활용할 수 있다는 점에서 합리적인 조합으로 보입니다. 다만 claude CLI 프로세스 관리(영속 프로세스, /clear를 통한 컨텍스트 리셋, 타임아웃 처리)가 추가로 필요해진 만큼 코드 복잡도는 늘었습니다.

배운 점 및 개선점

이번 커밋에서 눈에 띄는 건 실패 처리를 상당히 촘촘하게 다뤘다는 점입니다. 일시적 오류와 영구 실패(한도 초과)를 구분해서 전자는 다음 항목으로 넘어가고 후자는 이번 실행 전체를 포기하는 로직, 연재 포스팅 중 일부만 실패했을 때 이미 저장된 파트를 지우고 재시도하게 만든 부분, Claude Pro의 빈 응답과 진짜 한도 초과를 구분하기 위해 rate_limit_info를 별도로 추적한 부분 모두 실제로 운영하면서 겪은 문제를 하나씩 고친 흔적으로 보입니다.

병렬 워커 풀로 바꾸면서 코드 중복(백준/개발/PR 초안 생성 함수가 거의 동일했던 부분)도 자연스럽게 정리된 점도 긍정적입니다. core/gemini_draft_generator.py에서 -237/+301이라는 변경 규모가 이 리팩토링의 크기를 잘 보여줍니다.

앞으로는 워커 개수(CLAUDE_PARALLEL_WORKERS)를 늘렸을 때 Gemini/Claude 양쪽 API 호출 빈도와 비용이 어떻게 변하는지 실측해볼 필요가 있어 보이고, 대화 주제 분리 정확도(모델이 잘못된 줄 번호를 주는 경우 등)도 실제 운영 데이터로 검증이 더 필요할 것 같습니다.

참고 자료

  • claude CLI --input-format stream-json / --output-format stream-json 옵션
  • Google GenAI SDK types.GenerateContentConfig, ThinkingConfig