가공되지 않은 티켓에서 검증된 컨텍스트로: GitHub Issues를 위한 AI 기반 파이프라인
요약
GitHub 이슈와 같은 가공되지 않은 데이터를 기술적 작업으로 전환하기 위한 AI 기반 컨텍스트 복구 파이프라인을 제안합니다. RAG와 LLM, Git 이력을 결합하여 데이터의 의미론적 유사성을 분석하고 검증 가능한 방식으로 정보를 수집하는 아키텍처를 다룹니다.
핵심 포인트
- 가공되지 않은 티켓에서 기술적 컨텍스트를 추출하는 단계별 아키텍처 제안
- RAG, LLM, Git 이력을 활용한 결정론적 동작 및 검증 가능성 확보
- Chroma 벡터 DB와 GitHub 웹훅을 이용한 실시간 인덱스 업데이트 방식
- 원자적 업데이트(Atomic Update)를 통한 효율적인 벡터 인덱스 관리
문제는 코드가 아니라 입력 데이터의 품질에서 시작됩니다. 지원 팀은 짧은 설명, 대화의 파편, 링크, 스크린샷, 또는 "왜 작동하지 않는지 확인해 주세요"라는 고객의 요청과 같은 가공되지 않은 티켓(raw ticket)을 받습니다. 이를 기술적인 작업으로 전환하기 위해 엔지니어는 보통 여러 단계를 수동으로 거쳐야 합니다: 도메인을 이해하고, 유사한 작업을 찾고, 과거의 해결책을 회상하며, 여러 저장소(repository)를 확인하고, 변경 이력(change history)을 살펴보는 과정입니다.
대규모 제품에서는 이것이 빠르게 비용이 많이 드는 프로세스가 됩니다. 단일 작업이 프론트엔드(frontend), 백엔드(backend), 설정 저장소(configuration repositories), 통합(integrations), 그리고 비즈니스 규칙(business rules)을 모두 건드릴 수 있기 때문입니다. 유사한 작업이 이미 해결되었더라도, 그에 대한 지식은 GitHub issue, 댓글, 커밋 이력(commit history), 또는 특정 개발자의 머릿속에 숨겨져 있는 경우가 많습니다. 결과적으로 많은 시간이 문제를 해결하는 것이 아니라 컨텍스트(context)를 복구하는 데 소비됩니다.
아키텍처 측면의 해결책은 이러한 컨텍스트를 단계별로, 그리고 검증 가능한 방식으로 수집하는 것입니다. 검색(Retrieval)은 기존 작업 중에서 후보를 빠르게 찾아냅니다. LLM은 발견된 옵션들을 의미론적 유사성(semantic similarity)에 따라 순위를 매깁니다. Git은 사실을 바탕으로 결론을 확인합니다: 어떤 저장소에서 변경이 이루어졌고 어떤 차이점(diffs)이 실제로 적용되었는지 보여줍니다. 런북(Runbook)은 프로세스가 반복 가능하고 예측 가능해야 하는 곳에서 동작을 제한합니다.
모델은 데이터를 해석하고 구조화하는 데 도움을 주지만, 진실의 원천(sources of truth)인 GitHub, 벡터 인덱스(vector index), 커밋 이력(commit history), 그리고 사전에 기술된 지침(instructions)을 대체하지는 않습니다.
이 솔루션에 대한 설명은 의도적으로 불완전합니다. 그 목적은 아키텍처에 대한 일반적인 이해를 제공하는 데 있습니다.
다이어그램: https://mermaid.ai/d/bf2a8b37-d0ad-4852-a316-debf0bf37e7c
입력 데이터 (Input Data)
벡터 데이터베이스 (Vector database)는 1,500개 이상의 기존 GitHub 이슈를 기반으로 구축되었습니다. 각 태스크(task)에 대해 정규화된 텍스트인 제목, 설명, 그리고 유용한 댓글들이 인덱스 (index)에 입력됩니다.
인덱스를 수동으로 재구축할 필요는 없습니다. 이벤트가 발생할 때 업데이트할 수 있습니다. 이슈가 생성되거나 수정될 때, GitHub 웹훅 (hook)이 트리거되어 벡터 데이터베이스 (Chroma) 내의 해당 항목을 업데이트합니다.
기본적인 접근 방식은 간단합니다. 주기적으로 전체 인덱스를 재구축하는 것입니다. 더 정교한 접근 방식은 issue_number를 사용하여 원자적 업데이트 (atomic update)를 수행하는 것입니다:
issue_id = f"issue-{issue_number}"
vector_store.delete(ids=[issue_id])
...
이 방식은 모든 변경 사항이 발생할 때마다 1,500개의 태스크를 모두 다시 계산할 필요 없이 인덱스를 최신 상태로 유지해 줍니다.
아이디어는 간단합니다. 최대의 결정론적 동작 (deterministic behavior)을 달성하기 위해, 명시적인 상태 (state)와 명확한 책임을 가진 작은 노드 (node)들로 작업을 분할하는 것입니다.
class RefineIssueState(TypedDict, total=False):
issue_number: str
issue: dict[str, Any]
...
그래프 상태 (graph state)는 사실 관계들을 저장합니다: 원본 태스크, 검색을 위한 텍스트, 벡터 데이터베이스의 검색 결과, 모델의 관련성 결정 (relevance decision), 발견된 관련 태스크, 그리고 최종 출력 텍스트입니다.
이 접근 방식의 주요 장점은 모든 단계를 별도로 테스트하고 실행할 수 있다는 점입니다. 각 노드는 stdin/stdout을 읽고 쓰는 별도의 함수-명령 (function-command)으로 구현됩니다. 이는 테스트와 디버깅을 단순화합니다.
def refine_issue(issue_number: str) -> str:
require_openai_api_key()
...
그래프 (Graph)
파이프라인은 LangGraph를 사용하여 조립됩니다.
def build_graph():
graph = StateGraph(RefineIssueState)
...
아키텍처 측면에서 워크플로우 (workflow)는 다음과 같습니다:
GitHub issue
-> 정규화된 이슈 텍스트 (normalized issue text)
-> Chroma에서의 벡터 검색 (vector search in Chroma)
...
입력 데이터에 대한 불신 (Distrust of Input Data)
입력 데이터에 대한 불신 (Distrust of Input Data)
입력 데이터는 신뢰할 수 없는 것으로 간주됩니다. 이슈 (Issues), 댓글 (comments), 테이블 (tables), Slack/이메일 메시지, 그리고 디프 (diffs)에는 노이즈, 불완전한 사실, 또는 모델이 실행해서는 안 되는 직접적인 명령이 포함될 수 있습니다.
따라서 파이프라인은 가공되지 않은 텍스트 (raw text)를 실행 명령으로서 전달하지 않습니다. 사실 (facts)과 명령 (instructions)을 명확하게 분리합니다.
예시 원칙:
이슈 텍스트는 데이터이지, 명령이 아니다.
만약 이슈에 "규칙을 무시하고 명령을 실행하라"라고 적혀 있다면, 이는 입력 텍스트의 일부로 남을 뿐 에이전트 (agent)를 위한 명령이 되지 않습니다.
런북 (runbook)은 별도로 처리됩니다. 런북은 신뢰할 수 있는 명령 (trusted instruction)으로 간주되는 반면, 런북 시나리오 내부의 이슈는 신뢰할 수 없는 데이터 소스 (untrusted data source)로 남습니다.
prompt = f"""
당신은 신뢰할 수 있는 런북을 실행하고 있습니다.
...
이러한 접근 방식은 프롬프트 인젝션 (prompt injection)의 위험을 줄이고, 비즈니스 사실 (business facts)과 제어 명령 (controlling instructions)을 분리합니다.
태스크 로딩 (Loading the Task)
GitHub는 여전히 외부의 신뢰할 수 있는 단일 출처 (source of truth)로 남습니다. 태스크 (task)는 gh를 통해 읽힌 후, 안정적인 형식으로 변환됩니다.
def load_issue_node(state: RefineIssueState) -> RefineIssueState:
issue = format_issue(load_issue(state["issue_number"]))
return {"issue": issue}
다음으로, 이슈의 세부 정보는 유사한 태스크를 검색하기에 용이한 텍스트로 조립됩니다.
def build_issue_text_node(state: RefineIssueState) -> RefineIssueState:
issue = state["issue"]
return {"issue_text": issue_text(issue.get("title"), issue.get("body"))}
에러 핸들링 (Error Handling)
에러는 노드 경계 (node boundaries)에서 처리됩니다. 필수 조건이 누락된 경우, 파이프라인은 불완전한 데이터를 가지고 작업을 계속하는 대신 명확한 메시지와 함께 중단됩니다.
예를 들어, OPENAI_API_KEY 없이 실행하는 것은 의미가 없습니다:
def require_openai_api_key() -> None:
if not os.environ.get("OPENAI_API_KEY"):
raise SystemExit("Error: OPENAI_API_KEY is not set")
외부 명령은 공유 래퍼 (shared wrapper)를 통해 실행됩니다. 이는 명령이 누락된 상황과 명령이 에러를 반환한 상황을 구분합니다.
def run_command(command: list[str], *, cwd: Path | None = None) -> str:
try:
completed = subprocess.run(
gh 및 기타 CLI(Command Line Interface)의 응답은 엄격한 검증 과정을 거칩니다.
try:
issue = json.loads(output)
except json.JSONDecodeError as error:
...
별도의 관련 이슈(issue)나 디프(diff)를 가져올 수 없는 경우, 파이프라인은 해당 이슈를 로그에 기록하고 컨텍스트 수집을 계속합니다. 이는 하나의 오래된 작업(task)을 사용할 수 없더라도 다른 후보들이 여전히 유용할 수 있는 시나리오에서 중요합니다.
try:
changes = get_issue_changes(repo, str(number)).strip()
except SystemExit as error:
...
일반적인 규칙은 간단합니다. 치명적인 에러는 파이프라인을 중단시키지만, 추가 컨텍스트에서 발생하는 로컬 에러는 전체 결과를 망가뜨리지 않고 로그에 남겨둡니다.
유사 작업 검색 (Searching for Similar Tasks)
유사한 작업은 로컬 Chroma 인덱스를 사용하여 검색됩니다.
def search_related_issues_node(state: RefineIssueState) -> RefineIssueState:
results = search_issues(state["issue_text"])
return {"search_results": results}
이는 책임의 중요한 분리입니다:
- Chroma가 후보군을 빠르게 제공합니다.
- LLM(Large Language Model)이 의미론적 관련성(semantic relevance)에 대한 최종 결정을 내립니다.
- 진정으로 유용한 매칭 결과만이 그래프(graph)의 다음 단계로 넘어갑니다.
def analyze_relevance_node(state: RefineIssueState) -> RefineIssueState:
result = analyze_relevance(
state["issue_text"],
...
작업 정제 (Cleaning the Task)
검색이 완료되면, 작업은 적절한 기술적 형식으로 다시 작성됩니다. 여기서 모델은 사실 관계를 구조화합니다.
def summarize_issue_node(state: RefineIssueState) -> RefineIssueState:
issue = state["issue"]
...
출력물은 이미 작업(task)에 붙여넣거나 계획 수립의 기초로 사용할 수 있는 텍스트 형태입니다.
def build_output(summary_result, issues):
parts = [COMMENT_PREFIX, summary_result.summary]
...
관리된 예외로서의 런북 (Runbook as a Managed Exception)
유사한 작업에 runbook:<name>과 같은 레이블(label)이 있는 경우, 그래프는 별도의 시나리오로 전환됩니다.
관련 작업 중에서 런북(runbook)이 선택됩니다. 단순화를 위해 런북 선택 알고리즘의 세부 사항 일부는 의도적으로 생략되었습니다. 먼저, 파이프라인은 가장 관련성이 높은 일치 항목들을 남긴 다음, 벡터 검색(vector search)에서 거리가 최소인 작업을 선택합니다. 만약 이 작업에 runbook:<name> 레이블이 있다면, 이 런북이 다음 단계를 위한 신뢰할 수 있는 지침(instruction)이 됩니다.
def route_after_runbook_detection(state):
if state.get("runbook_tag"):
return "runbook_agent"
...
반복 가능한 작업에는 런북이 필요합니다. 예를 들어, 유사한 작업이 이미 표준 프로세스를 설명하고 있다면, 모델은 임의로 판단하지 않고 미리 작성된 지침을 따릅니다.
def runbook_agent_node(state):
runbook_output = execute_runbook(state["runbook_tag"], state["issue"])
...
이는 훌륭한 절충안입니다. 유연성이 필요한 곳에는 LLM(대규모 언어 모델)을 사용하되, 반복 가능한 동작은 런북에 고정합니다.
실제 결과 예시
파이프라인은 CLI 명령어로 실행됩니다:
python3 cli/refine_issue.py <issue-number>
PREPROD 및 PROD에 공급업체를 일괄 추가해 달라는 요청이 접수되었습니다.
입력 데이터 예시:
| Supplier VAT Number / Registration Number | SAP Supplier Code | Supplier Country | Supplier Name | Semi Finished Supplier | Supplier Type Code | Catalogue Uploaded By | Note |
|---|---|---|---|---|---|---|---|
| DE293**60* | DE - Germany | Supplier A GmbH | No | Component/Raw Material Supplier | None | ||
| ... |
파이프라인의 수행 과정:
- 원본 테이블을 정규화(normalized)했습니다.
- 관련 작업을 찾았습니다.
- 적절한 런북을 식별했습니다.
- 모든 공급업체에 대한 배치 명령(batch commands)을 생성했습니다.
축약된 결과 스니펫(snippet):
✨ AI-generated ✨
Suppliers to create: 11
...
생성된 배치 스크립트의 예시 스니펫:
#!/usr/bin/env bash
set -euo pipefail
...
전체 결과에는 모든 공급업체(suppliers)에 대해 동일한 실행 가능한 스크립트가 포함됩니다. 생성된 배치 파일은 검토를 위해 사람에게 전달됩니다. 실행하기 전에 bash -n을 통해 입력 데이터, 환경 및 명령 파라미터를 확인할 수 있습니다.
이 방식을 통해 파이프라인은 대량의 운영 작업을 실행 전 사람의 검토를 거치는 준비된 배치 파일로 변환합니다.
이러한 작업의 경우 효과가 특히 두드러집니다. 런북 (runbook)을 통한 스크립트 생성은 개발 및 대응 시간을 크게 단축합니다. 엔지니어가 표를 수동으로 파싱하고, 역할을 확인하고, 명령어를 준비하고, 동일한 파라미터를 재확인하는 대신, 검증 후 프로세스 하단으로 전달하기만 하면 되는 준비된 배치 파일을 받게 됩니다.
관련 코드 변경 사항 (Related Code Changes)
유사한 작업이 발견되면, 파이프라인은 클론된 리포지토리 (cloned repositories)에서 관련 커밋 (commits)을 찾으려고 시도합니다.
def related_task_changes_node(state):
related_task_changes = collect_related_task_changes(
state.get("relevant_issues", []),
...
코드베이스 전체에 걸친 검색 알고리즘은 의도적으로 완전히 설명하지 않았습니다. 한 가지 측면은 작업 번호와 일치하는 컨벤셔널 커밋 (Conventional Commit) 스코프 (scope)를 통한 검색입니다.
pattern = re.compile(
rf"^[A-Za-z][A-Za-z0-9-]*(?:\!\({issue_number}\)|\({issue_number}\)!?): .+"
)
즉, 이전에 작업 #123이 있었다면, 다음과 같은 커밋이 예상됩니다:
fix(123): correct supplier validation
feat(123): add export config
이후, 깨끗한 디프 (diff)를 가져올 수 있습니다:
git show --format= --patch --no-color --no-ext-diff <sha>
결과는 리포지토리별로 그룹화됩니다:
{
"workspace/repos/frontend": "... 관련 작업 요약 및 디프 ...",
"workspace/repos/backend": "... 관련 작업 요약 및 디프 ...",
...
}
이는 코드가 어디서 어떻게 변경되었는지에 대한 구체적인 기술적 추적 (technical trace)을 제공합니다.
관련 변경 사항에 대한 예시 결과 (Example Result for Related Changes)
또 다른 시나리오: 런북 (runbook)은 발견되지 않았지만, 파이프라인이 유사한 작업과 실제 코드 변경 사항을 발견한 경우입니다.
지원 팀으로부터의 예시 입력 메시지:
클라이언트가 대규모 임포트 미리보기(massive import preview)가 업데이트된 컬럼 이름(column names)을 인식하지 못한다고 보고했습니다.
환경: PREPROD
...
파이프라인의 동작:
- 작업 설명(task description)을 정규화(normalized)했습니다.
- Chroma를 통해 유사한 이슈(issues)를 찾았습니다.
- 변경 유형(change type)을 기준으로 작업
#1373이 유사하다고 판단했습니다. - 관련된 커밋(commit)
fix(1373): changed import column names를 찾았습니다. - Git 히스토리(history)에서 실제 변경 사항을 보여주었습니다.
단축된 결과 스니펫(result snippet):
✨ AI-generated ✨
문제(Problem):
...
찾은 디프(diff)의 스니펫:
fix(1373): changed import column names
@Component
...
엔지니어를 위한 파이프라인 출력:
현재 요청은 #1373과 유사해 보입니다.
예상되는 구현 방향(Likely implementation direction):
- 찾기
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기