콘텐츠로 이동

개발 계획서 — 어떻게 만들 것인가

항목 내용
작성일 2026-07-03
상태 검토 중 — 승인 후 개발 착수
대상 독자 Java/Spring 경험자 (Python·LangGraph 경험 없음을 전제)

이 문서는 00~04 설계 문서를 실제 코드로 옮기는 방법을 설명합니다. 팀이 Java Spring에 익숙하므로, 모든 Python 개념을 Spring의 대응 개념과 함께 소개합니다.


1. 기술 스택 — Spring 세계와의 대응표

Python 쪽 역할 Spring 세계의 대응물
FastAPI HTTP API 프레임워크 Spring MVC (@RestController)
uvicorn FastAPI를 구동하는 ASGI 서버 내장 Tomcat/Netty
Pydantic 요청/응답 모델 정의 + 자동 검증 DTO + Bean Validation (@Valid)
LangGraph 에이전트 처리 흐름(그래프) 정의·실행 (가까운 것이 없지만) Spring Statemachine·Batch의 flow 정의와 유사한 발상
langgraph-checkpoint-redis 그래프 State를 Redis에 저장/복원 Spring Session (Redis)
openai SDK LLM chat/completions 호출 클라이언트 WebClient + 전용 SDK
pydantic-settings + .env 환경설정 application.yml + @ConfigurationProperties
pytest 테스트 JUnit + Mockito
uv 패키지·가상환경 관리 Maven/Gradle
pyproject.toml 프로젝트·의존성 정의 파일 pom.xml / build.gradle

Python이 처음인 분을 위한 최소 배경 두 가지 1. async/await: WebFlux처럼 논블로킹 I/O를 위한 문법입니다. LLM 응답을 수 초~수십 초 기다리는 동안 스레드를 점유하지 않기 위해 전 구간을 async로 작성합니다. 문법은 리액티브 체이닝보다 단순해서, 동기 코드에 await만 붙는 모양새입니다. 2. 가상환경(venv): 프로젝트별로 격리된 의존성 공간입니다. uv가 Gradle처럼 이를 자동 관리하므로 직접 만질 일은 거의 없습니다.


2. 프로젝트 구조

Spring의 레이어드 아키텍처 감각 그대로 갑니다.

langgraph-agent/
├── pyproject.toml            # pom.xml — 의존성·프로젝트 정의
├── .env.example              # application.yml 템플릿 — LLM URL, Redis 접속정보 등
├── app/
│   ├── main.py               # @SpringBootApplication — FastAPI 앱 생성·부트스트랩
│   ├── config.py             # @ConfigurationProperties — 환경설정 로드
│   ├── api/
│   │   ├── router.py         # @RestController — POST /v1/agent/chat, GET /health
│   │   └── schemas.py        # 요청/응답 DTO — 03 계약 문서와 1:1 대응
│   ├── graph/
│   │   ├── state.py          # State 정의 — 01 문서 4장의 "작업지시서"
│   │   ├── nodes.py          # 4개 노드 — load_context / understand / validate / respond
│   │   └── builder.py        # 그래프 조립 + Redis 체크포인터 연결
│   ├── llm/
│   │   ├── client.py         # LLM 호출 클라이언트 (structured output 처리 포함)
│   │   └── prompts.py        # 시스템 프롬프트 + 전문코드별 필드 메타데이터 (ADR-006)
│   └── core/
│       ├── validation.py     # 거래 ID 실재 검증·금액 재계산 (ADR-007)
│       └── errors.py         # 예외 정의
├── tests/
│   ├── test_api.py           # 계약 테스트 — 03 문서의 JSON 예시가 그대로 케이스
│   ├── test_graph.py         # 그래프 단위 테스트 (LLM은 mock)
│   ├── test_validation.py    # 환각 ID 차단 로직 테스트
│   └── fixtures/             # 스타벅스 예시 거래 데이터
└── docs/                     # 00~05 설계 문서 (본 문서 포함)

모듈 의존 관계

화살표는 "A가 B를 사용한다(A → B)"는 의미이고, 점선은 외부 시스템과의 통신입니다.

flowchart TD
    MAIN["main.py<br/>부트스트랩"] --> ROUTER["api/router.py<br/>엔드포인트"]
    MAIN --> CONFIG["config.py<br/>환경설정"]
    ROUTER --> SCHEMAS["api/schemas.py<br/>DTO — 03 계약과 1:1"]
    ROUTER --> BUILDER["graph/builder.py<br/>그래프 조립"]
    BUILDER --> NODES["graph/nodes.py<br/>4개 노드"]
    BUILDER --> STATE["graph/state.py<br/>State 정의"]
    NODES --> STATE
    NODES --> LLMC["llm/client.py<br/>LLM 클라이언트"]
    NODES --> PROMPTS["llm/prompts.py<br/>프롬프트 · 필드 메타데이터"]
    NODES --> VALID["core/validation.py<br/>ID 검증 · 금액 재계산"]
    LLMC --> CONFIG
    BUILDER --> CONFIG
    CONFIG -.->|"기동 시 1회 로드"| ENV[".env 파일 · OS 환경변수"]
    LLMC -.->|"HTTP"| SERVE["LLM 서빙 (외부)"]
    BUILDER -.->|"체크포인트 저장"| REDIS[("Redis (외부)")]

의존 규칙 네 가지 (Spring 레이어드 아키텍처와 같은 원칙):

  1. 의존 방향은 항상 한쪽: api → graph → llm·core. 역방향 import(예: nodes가 router를 참조)는 금지 — 서비스가 컨트롤러를 참조하지 않는 것과 같은 규칙입니다.
  2. schemas.py(DTO)와 state.py는 아무것도 의존하지 않는 최하층 — 계약과 데이터 구조는 순수하게 유지합니다.
  3. 외부 시스템은 각각 한 파일에만 연결: LLM 서빙은 client.py, Redis는 builder.py에서만 접촉합니다. 바꿔 끼울 일이 생기면 그 파일만 고치면 됩니다 — Q1(structured output 미지원) 폴백이 하루 작업인 이유입니다.
  4. .env를 직접 읽는 코드는 config.py 하나뿐: 앱 기동 시 pydantic-settings가 .env 파일과 OS 환경변수를 읽어 Settings 객체를 만들고(우선순위: OS 환경변수 > .env 파일), 다른 모듈은 전부 이 객체를 통해서만 설정에 접근합니다. Spring으로 치면 application.yml@ConfigurationProperties 클래스 하나가 바인딩하고 나머지는 그 빈을 주입받는 구조입니다. .env 파일은 로컬 개발 편의용이고, 운영 배포에서는 파일 없이 배포 시스템이 주입하는 환경변수(Q4)를 그대로 읽습니다.

3. 핵심 코드 미리보기

착수 전에 "완성 코드가 어떤 모양일지" 감을 잡기 위한 스케치입니다. 실제 구현 시 세부는 달라질 수 있습니다.

3.1 요청/응답 DTO — api/schemas.py

Pydantic 모델은 Java의 record + Bean Validation을 합친 것과 같습니다. FastAPI가 요청 JSON을 이 모델로 자동 변환·검증하고, 실패하면 알아서 400을 반환합니다 (@Valid + MethodArgumentNotValidException 처리에 해당).

class ChatRequest(BaseModel):                  # public record ChatRequest(...) + @Valid
    session_id: str
    message_id: str
    trace_id: str | None = None                # Optional<String> — 기본값 null
    user: UserInfo
    message: str
    intent: IntentInfo
    data: CoreData | None = None
    context: dict | None = None

class ChatResponse(BaseModel):
    session_id: str
    status: Status                             # enum — select_proposed | payment_requested | ...
    reply: str
    selection: Selection | None = None
    ui: UiHint | None = None
    meta: Meta

3.2 API 엔드포인트 — api/router.py

@router.post("/v1/agent/chat")                                  # @PostMapping("/v1/agent/chat")
async def chat(req: ChatRequest) -> ChatResponse:
    config = {"configurable": {"thread_id": req.session_id}}    # session_id = 그래프 thread 키
    state = await graph.ainvoke(initial_state(req), config)     # 그래프 실행 (01 문서 4장)
    return to_response(req, state)

컨트롤러는 이게 전부입니다. Spring에서 컨트롤러가 얇고 서비스 계층이 일하는 것처럼, 여기서는 그래프가 서비스 계층입니다.

3.3 State 정의 — graph/state.py

01 문서 4.1의 "작업지시서"입니다. 모든 노드가 이 구조를 읽고 씁니다.

class AgentState(TypedDict):
    message: str                    # 사용자 발화
    transactions: list[dict]        # 거래내역 (이번 턴 수신분 또는 Redis의 직전 데이터)
    presented: list[dict]           # 직전 턴에 제시한 목록 — 순서 포함 ("2번째" 해석의 근거)
    selection: list[str]            # LLM이 고른 거래 ID
    status: str                     # select_proposed | payment_requested | ...
    reply: str                      # 사용자 노출 문구
    retry_count: int                # validate 실패 시 재시도 횟수

3.4 그래프 조립 — graph/builder.py

Spring에는 정확한 대응물이 없는, 이 프로젝트의 심장입니다. 노드(함수)를 등록하고 조건 분기 엣지로 연결하면 LangGraph가 실행 순서·State 전달·체크포인트 저장을 담당합니다.

builder = StateGraph(AgentState)
builder.add_node("load_context", load_context)      # 노드 등록 — 각각은 그냥 async 함수
builder.add_node("understand", understand)
builder.add_node("validate", validate)
builder.add_node("respond", respond)

builder.add_edge(START, "load_context")
builder.add_edge("load_context", "understand")
builder.add_conditional_edges("understand", route_after_understand)
    # → 정보 부족이면 곧장 응답(clarification), 아니면 validate로
builder.add_conditional_edges("validate", route_after_validate)
    # → 통과: respond / 실패: understand 재시도 / 재시도 소진: failed
builder.add_edge("respond", END)

graph = builder.compile(checkpointer=redis_saver)   # 여기서 Redis 체크포인터 연결 (ADR-004)

checkpointer=redis_saver 한 줄이 멀티턴의 전부입니다 — 턴이 끝나면 State가 자동으로 Redis에 저장되고, 같은 thread_id(= session_id)로 다음 요청이 오면 자동 복원됩니다. Spring Session이 세션 저장을 투명하게 처리해주는 것과 같은 감각입니다.

3.5 LLM 호출 — llm/client.py

client = AsyncOpenAI(base_url=settings.llm_base_url, api_key=settings.llm_api_key)

resp = await client.chat.completions.create(
    model=settings.llm_model,   # 개발: openai/gpt-oss-120b (OpenRouter) / 운영: gpt-oss-120b (사내)
    messages=[
        {"role": "system", "content": build_system_prompt(source_code)},  # 필드 메타데이터 포함
        {"role": "user", "content": build_user_prompt(message, transactions)},
    ],
    response_format={"type": "json_schema", "json_schema": SELECTION_SCHEMA},  # Q1 확정 시
    temperature=0.1,
)

response_format이 미지원으로 확인되면(Q1) 이 부분만 "프롬프트로 JSON 강제 + 파싱 + 실패 시 재시도"로 교체합니다. client.py 안에 격리해두므로 다른 코드는 영향받지 않습니다 — 인터페이스 뒤에 구현을 숨기는 Spring의 DI 감각 그대로입니다.


4. 개발 순서

각 단계에 완료 기준(Definition of Done)을 정의합니다. 단계가 끝날 때마다 동작을 확인하고 다음으로 넘어갑니다.

단계 작업 완료 기준
Step 1 (D1) 프로젝트 뼈대 — pyproject, 설정, /health, DTO 전체 03 문서의 요청/응답 JSON 예시 3종이 스키마 검증을 통과
Step 2 (D2) 그래프 4노드 구현, LLM은 mock(정해진 답을 주는 가짜) 스타벅스 턴1 요청 → select_proposed 응답이 end-to-end로 반환
Step 3 (D3) 실제 LLM 연동, structured output, 프롬프트 v1 실제 모델이 5건 중 아침 스타벅스 3건을 선택 (여러 발화 변형으로 확인)
Step 4 (D4) Redis 체크포인터, 멀티턴 턴2 "2번째, 3번째만 결제해줘" → payment_requested + 올바른 2건
Step 5 (D5) 예외 흐름 — clarification/fallback/failed, 검증 재시도, 멱등성, 타임아웃 01 문서 3.3 표의 5개 상황이 전부 테스트로 통과
Step 6 (D6) WAS 연동 테스트, 사내 배포 시스템 배포 WAS → 에이전트 실호출로 턴1·턴2 시나리오 재현
Step 7 (D7) 버퍼 — 프롬프트 튜닝, 문구 정리, 미비점 보완 PoC 시연 가능 상태

미확정 사항이 개발을 막지 않는 이유: Q1(structured output)은 client.py에 격리, Q3(전문 스펙)은 Step 1~5 내내 mock 데이터로 진행하다가 확정 시 fixtures와 메타데이터만 교체, Q4(배포 상세)는 Step 6 전까지만 확인되면 됩니다. 단, Step 3부터는 개발환경에서 접속할 실제 LLM 엔드포인트가 필요합니다 — §5의 연결 선택지와 Q9를 참고하세요.


5. 테스트 전략

LLM이 들어간 시스템은 응답이 비결정적이므로, 테스트를 두 층으로 나눕니다.

  1. 결정적 테스트 (pytest, 자동화) — LLM을 mock으로 대체하고 나머지 전부를 검증: 계약(스키마), 그래프 분기, 환각 ID 차단, 멱등성, 멀티턴 상태 복원. 03 문서의 페이로드 예시가 그대로 테스트 픽스처가 됩니다 — 문서와 코드가 어긋나면 테스트가 깨지도록.
  2. 품질 테스트 (실제 LLM, 반자동) — 발화 변형 목록("아침에 스벅 간 거 다 내줘", "커피값 정리할게" 등)을 실제 모델에 넣고 선택 정확도를 확인. 프롬프트를 고칠 때마다 재실행하는 회귀 시트로 운영.

테스트 함수명은 test_ 접두사 뒤에 한글 문장으로 작성합니다(예: test_환각_ID는_재시도_후_성공한다). Python 식별자는 유니코드를 정식 지원하며, Testing 패널과 실패 리포트를 한국어로 읽기 위한 팀 컨벤션입니다.

Java 개발자 관점: 1번이 JUnit 단위/통합 테스트, 2번은 성격상 QA 시나리오 테스트에 가깝습니다.

테스트에 쓸 LLM — 어디에 연결하나

에이전트는 OpenAI 호환 API라면 어디든 붙습니다. .envLLM_BASE_URL·LLM_API_KEY 두 줄이 연결의 전부이며, 코드는 바뀌지 않습니다 (Spring 프로파일로 datasource를 교체하는 것과 같은 감각).

시기 필요한 LLM 방법
Step 1–2 없음 mock LLM — 정해진 JSON을 돌려주는 가짜 객체
Step 3 이후 gpt-oss-120b 아래 연결 선택지

연결 선택지 (권장 순):

  1. 사내 LLM 서빙의 개발용 엔드포인트 — 정답. 운영에서 쓸 바로 그 모델이므로 프롬프트 튜닝 결과가 그대로 유효합니다. 개발환경에서의 접근 가능 여부와 개발용 URL·API key 발급 절차를 서빙팀에 확인해야 합니다 (Q9).
  2. 외부 호스팅 API (1번이 늦어질 때의 임시 대안) — gpt-oss-120b는 오픈웨이트 모델이어서 Groq, OpenRouter, Fireworks 등 외부 서비스들이 호스팅하고 있습니다. 해당 서비스에 개별 가입해 API key를 발급받으면 붙일 수 있습니다. 단 외부 전송이므로 반드시 가짜(fixture) 데이터만 사용하고, 회사 보안 규정을 먼저 확인해야 합니다.
  3. 로컬/자체 서버 실행 (비권장) — 오픈웨이트라 직접 띄울 수는 있으나, 120b는 80GB급 GPU 메모리가 필요해 일반 서버·VPS에서는 비현실적입니다. 작은 형제 모델인 gpt-oss-20b(16GB급)로 흐름 확인은 가능하지만, 모델이 다르면 프롬프트 거동도 달라져 품질 튜닝은 결국 120b로 다시 해야 합니다.

결정 (2026-07-03): 개발 단계에서는 담당자 보유의 OpenRouter 계정(선택지 2)을 임시로 사용한다. 설정 차이는 .env가 전부다:

# .env — 개발 (OpenRouter)
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...             # 절대 저장소에 커밋하지 않음 (.env는 .gitignore 대상)
LLM_MODEL=openai/gpt-oss-120b     # OpenRouter에서의 모델 ID — 사내 서빙과 이름이 다름

# .env — 운영 (사내 서빙, Q9 확정 후)
LLM_BASE_URL=<사내 서빙 URL>/v1
LLM_MODEL=gpt-oss-120b

OpenRouter 사용 시 유의사항:

  1. 외부 전송이므로 fixture(가짜) 데이터만 사용 — 실거래·실고객 데이터 금지 (ADR-009의 마스킹과 별개로, 개발 단계엔 아예 가짜만)
  2. OpenRouter는 같은 모델을 여러 하위 제공자로 라우팅하므로, structured output(response_format)을 쓸 때는 해당 파라미터를 지원하는 제공자로 고정하는 옵션(provider routing)이 필요할 수 있음
  3. 최종 프롬프트 검증(Step 6~7)은 반드시 사내 서빙으로 수행 — 같은 모델이라도 서빙 환경(양자화 등)에 따라 미세한 거동 차이가 있을 수 있음

자주 하는 오해 — gpt-oss-120b는 ChatGPT가 아닙니다. OpenAI가 오픈웨이트(모델 파일 자체를 공개)로 배포한 별개의 모델로, 누구나 내려받아 자기 서버에서 서빙할 수 있습니다 — 사내 서빙팀이 제공하는 것이 바로 이 방식입니다. 따라서 ChatGPT 구독 계정(Enterprise 포함)은 이 프로젝트와 무관합니다: ChatGPT 구독으로는 API key가 발급되지 않고(OpenAI의 API는 별도 서비스), 그 OpenAI API에는 gpt-oss 모델이 올라와 있지도 않습니다.


6. 리스크와 대응

리스크 대응
gpt-oss-120b가 JSON 형식을 안 지키거나 엉뚱한 거래를 선택 validate 노드의 재시도(ADR-007) + 프롬프트에 정답 예시(few-shot) 포함 + 품질 테스트 시트로 조기 발견
response_format 미지원 (Q1) client.py에 격리된 폴백 구현으로 교체 — 하루 이내 작업
코어 전문 스펙(Q3) 지연 mock 데이터로 전 단계 진행 가능, 확정 시 fixtures·메타데이터만 교체
팀의 Python 경험 부족 본 문서의 Spring 대응표 + 코드 리뷰 시 대응 개념 주석. 구조를 Spring 레이어드와 동일하게 유지
60초 동기 응답의 UX 부담 MVP는 수용, 2단계 SSE 전환 준비 완료 (ADR-005)

7. 승인 후 첫 착수 작업

이 문서가 승인되면 Step 1부터 시작합니다. 첫 커밋에 포함될 것: pyproject.toml, app/main.py(부트스트랩), app/api/schemas.py(DTO 전체), app/config.py, GET /health, 그리고 03 문서 예시 기반의 계약 테스트. 이 시점부터 실행 가능한 서버가 존재하며, 이후 모든 단계는 "돌아가는 상태"를 유지하며 증분됩니다.