매번 수동으로 테스트하는 건 생각보다 부담이 크다. 기능이 바뀔 때마다 같은 흐름을 다시 눌러 보고, 회귀가 없는지 확인하고, 제안이나 데모를 앞두면 그 테스트가 반드시 통과해야 하는 경우도 많다.
문제는 테스트 코드가 보통 개발자만 아는 언어로 작성된다는 것이다. Playwright, Robot Framework, Selenium — QA나 기획자 입장에서는 “무엇을 검증해야 하는지"는 알지만, 코드로 옮기기는 어렵다. 개발자에게 매번 시나리오 작성을 맡기면 일정이 밀리고, QA/기획이 직접 화면을 보며 시나리오를 다듬기도 어렵다.
이 프로젝트는 그 간극을 줄이기 위해 시작했다. 기획서만 업로드하면 AI 에이전트가 테스트 시나리오를 만들어 주는 흐름을 목표로 했다. QA·기획자가 자연어로 요구사항을 말하고, 실제 화면을 보면서 시나리오를 정리할 수 있어야 했다.
사내에서는 QA, 기획자, BDX(비즈니스 지원 전략) 팀과도 이야기를 나눴다. BDX는 영업·제안서 작성을 담당하는 팀인데, 제안이나 데모를 앞두고 “이 흐름이 실제로 통과하는지"를 빠르게 확인해야 하는 경우가 많았다. 그런 상황에서 테스트 코드를 개발자에게만 맡기는 구조는 맞지 않았다.
이걸 내부 프로세스로 풀기로 했고, 에이전트 워크플로우 설계·구현은 내가 맡아 진행했다. NL-E2E에서는 chat agent session을 기준으로 잡았다. 기획서·문서를 첨부하고, 채팅으로 요구사항을 보완하며, AI가 브라우저를 탐색하고, 애매한 지점은 사용자에게 물어보고, 최종적으로 실행 가능한 E2E 시나리오 초안까지 이어지는 흐름이다.
솔직히 처음엔 “LLM에게 프롬프트 하나 던지면 시나리오 코드가 나오겠지"라고 생각했다. 구현해 보니 그렇게 단순하지 않았다. 대화 중간에 사람 손이 필요하고, 브라우저 탐색과 시나리오 제출이 섞이고, 기획서 첨부와 실시간 스트리밍까지 한 세션 안에서 처리해야 했다.
이 글에서는 그 과정에서 왜 LangGraph를 도입했는지, Function Calling tool loop와 어떻게 결합했는지 정리해 본다.
왜 이걸 만들었는가
당시 필요했던 건 단순한 “테스트 자동화 도구"가 아니었다. 기획서를 업로드하면 시나리오 작성의 출발점이 잡혀야 했고, QA·기획자가 코딩 없이 실제 화면을 보면서 시나리오를 만들 수 있어야 했다. BDX처럼 영업·제안 일정이 있는 팀도, 데모 전에 통과하는 시나리오가 필요했다.
기존 E2E 도구는 대부분 “개발자가 코드를 작성한다"는 전제에 가깝다. LLM만 붙여 “자연어 → 코드 한 방에 생성"해도 한계는 비슷했다.
| 접근 | 한계 |
|---|---|
| 자연어 → 코드 한 방에 생성 | 실제 DOM·흐름을 모름 → 실행 시 깨짐 |
| 폼 기반 시나리오 빌더 | QA/PM에게 진입 장벽이 높음 |
| 기획서 + 채팅 + 브라우저 탐색 + 질의 | 문서에서 시작해 화면을 보며 비개발자도 참여 가능 |
“로그인 버튼을 클릭한다"고 기획서에 적혀 있어도, 실제 화면에는 버튼이 여러 개일 수 있고, 모달이 뜨는지, 어떤 셀렉터가 안정적인지는 코드를 쓰기 전까지 알 수 없다. 그래서 “기획서만 넣으면 끝"이 아니라, 화면을 직접 보며 시나리오를 다듬는 흐름이 필요했다.
설계 방향은 이렇게 잡았다.
기획서·문서 intake → 브라우저 그라운딩 → 사용자 확인 → 실행 가능한 초안
사용자는 기획서를 첨부하고 자연어로 보완한다. 에이전트는 필요하면 Playwright로 화면을 탐색하고, 확신이 없으면 propose_options로 선택지를 제시한다. 확정되면 submit_scenario로 초안을 제출한다.
왜 LangGraph를 도입했는가
처음에는 OpenAI Function Calling tool loop 하나로 끝낼 수 있을 거라고 생각했다. “메시지 받고 → tool 호출하고 → 결과 넣고 → 반복"이면 되지 않나.
구현해 보니 세션 시작 구간과 대화 중 구간의 요구가 달랐다.
기획서와 첨부 문서를 처음 ingest한 뒤에는, 요구사항·용어집·초기 브라우저 탐색 결과를 한꺼번에 해석해야 했다. 그리고 “어느 화면부터 검증할까요?”, “이 기획서의 어떤 흐름을 우선 테스트할까요?” 같은 pre-flight 질문이 나오면, 그 시점에서 실행을 멈추고 사용자 답변을 기다려야 했다. 답변이 오면 같은 맥락으로 다시 추출(extract)해야 했다.
이걸 tool loop 안에 if문으로 우겨 넣기 시작하니 상태가 금방 스파게티가 됐다. 지금 질문을 기다리는 중인지, 대화 중 tool을 실행하는 중인지, 시나리오 제출이 끝난 상태인지 — 세션마다 분기가 달라지고, 중간에 끊겼다가 재개할 때 어디까지 진행했는지 복원하기 어려웠다.
LangGraph를 쓰기로 한 건 크게 세 가지 이유에서였다.
1. 사용자 확인 흐름을 그래프에 그대로 표현할 수 있다
LangGraph의 interrupt()는 “여기서 멈추고 사용자 입력을 기다린다"를 실행 흐름의 일부로 표현할 수 있다. 기획서만으로는 애매한 지점을 ask_user 노드에서 interrupt하고, QA/기획자 답변이 오면 Command(resume=...) 형태로 extract로 돌아간다. 코드 한곳에 pending_question 플래그를 두는 것보다 의도가 분명했다.
2. 세션 상태를 체크포인트로 남길 수 있다
SqliteSaver 기반 checkpointer로 세션별 thread id를 유지했다. extract → ask_user → extract 반복이 재개 가능한 단위로 남는다. “오늘 기획서 올리고 질문에 답하고, 내일 이어서 화면 보며 수정"하는 흐름에 맞았다.
3. 세션 시작과 대화 중 실행을 분리할 수 있다
LangGraph는 기획서 ingest 이후 첫 해석·pre-flight 질문을 담당한다. 이후 QA/기획자가 채팅으로 계속 말하는 구간은 chat_runtime의 Function Calling tool loop가 담당한다. 역할이 겹치지 않게 나눌 수 있었다.
| 구간 | 담당 | 하는 일 |
|---|---|---|
| 세션 시작 | LangGraph | extract, pre-flight 질문, 초기 draft/flow |
| 대화 이후 | tool loop | browser tool, propose_options, submit_scenario |
LangGraph 없이도 만들 수는 있었다. 다만 질문 대기·재개·초기 추출 루프를 직접 상태 머신으로 짜는 비용이 더 컸고, 사용자 확인 지점이 늘어날수록 유지보수가 어려워질 거라고 판단했다.
전체 아키텍처 한눈에 보기
에이전트 서비스(nle2e-agent)는 위에서 말한 두 경로를 가진다.
- LangGraph — 세션 시작 시 요구사항 추출(extract)과 pre-flight 질문(ask_user)
- chat_runtime tool loop — 이후 대화 메시지·답변·환경 확정을 Function Calling으로 처리
둘 다 “에이전트"지만 역할이 다르다. LangGraph는 기획서를 해석하고 첫 질문을 만드는 쪽, tool loop는 대화가 이어지는 동안 브라우저를 조작하고 시나리오를 제출하는 쪽이다.
flowchart LR
subgraph Users["사용자"]
QA["QA / 기획자"]
BDX["BDX 팀"]
end
subgraph Frontend["Frontend"]
UI["Chat Session UI"]
end
subgraph Backend["Backend - 권위 상태"]
API["REST WebSocket"]
DB[("PostgreSQL")]
Store[("MinIO")]
end
subgraph Agent["nle2e-agent"]
LG["LangGraph extract ask_user"]
Loop["chat_runtime tool loop"]
end
PW["Playwright"]
QA --> UI
BDX --> UI
UI <-->|REST WS| API
API <-->|internal| LG
API <-->|internal| Loop
API --> DB
API --> Store
Loop --> PW
LG --> PW
Loop -.->|events| API
LG -.->|events| API에이전트는 비즈니스 상태의 source of truth가 아니다. 프로젝트·시나리오·권한·승인은 backend가, 추론과 탐색은 agent가 담당한다. agent 메모리와 DB 상태가 어긋나면 “어제 만든 시나리오가 어디 갔지?” 같은 문제가 생긴다. 이 경계는 처음부터 지키는 편이 낫다고 봤다.
LangGraph: 세션 시작과 pre-flight 질문
세션이 시작되면 LangGraph 그래프가 먼저 실행된다.
# langgraph_runtime.py (요약)
builder = StateGraph(AgentGraphState)
builder.add_node("extract", self._extract_node)
builder.add_node("ask_user", self._ask_user_node)
builder.add_edge(START, "extract")
builder.add_conditional_edges(
"extract",
self._route_after_extract,
{"ask_user": "ask_user", "done": END},
)
builder.add_edge("ask_user", "extract")
흐름은 단순하다.
- extract — 기획서·첨부 문서, 용어집, 브라우저 탐색 결과를 바탕으로 시나리오 초안과 follow-up 질문 생성
- ask_user —
interrupt()로 QA/기획자 답변 대기 - 답변이 오면 다시 extract로 돌아가 반복
flowchart TD
Start[세션 시작] --> Extract[extract]
Extract -->|follow-up 질문 있음| AskUser[ask_user]
Extract -->|초안 완료| Done[완료]
AskUser -->|사용자 답변 resume| ExtractLangGraph의 interrupt는 여기서 중요하다. 에이전트가 “어느 진입 경로를 검증할까요?” 같은 pre-flight 질문을 만들면, 그래프 실행을 일시 중단하고 backend/frontend를 통해 사용자 입력을 기다린다. 답변이 들어오면 Command(resume=...) 형태로 재개한다.
기획서만으로는 “어떤 사용자 역할로”, “어떤 화면부터” 검증할지가 빠지는 경우가 많다. pre-flight 단계에서 먼저 물어두면 이후 브라우저 탐색이 헛돌 가능성이 줄어든다.
세션 시작 시점에는 chat tool loop가 아직 돌지 않는다. 대신 초기 draft, flow 노드, robot placeholder, mirror 이벤트가 만들어진다. QA/기획자는 이 시점부터 “AI가 기획서를 어떻게 읽었는지"를 볼 수 있다.
Function Calling tool loop: 대화 중 자율 실행
세션이 열린 뒤 QA/기획자가 채팅으로 메시지를 내면 chat_runtime의 tool loop가 동작한다.
# chat_runtime.py (요약)
for iteration in range(MAX_TOOL_ITERATIONS):
response_stream = client.chat.completions.create(
model=config.model,
messages=_build_messages(session),
tools=tools,
tool_choice="auto",
stream=True,
)
# chunk 수신 → tool_calls 파싱
if not tool_calls:
return _text_outcome(...)
for call in tool_calls:
result = execute_tool(tool_name, arguments, ...)
LLM이 tool을 호출하지 않으면 일반 채팅 응답으로 끝난다. tool을 호출하면 execute_tool이 실행되고, 결과가 conversation에 다시 append된다. 최대 50회까지 반복할 수 있다.
이 구간은 “기획서에서 읽은 내용"을 실제 화면에서 확인·수정하는 단계다. 브라우저를 열고, 클릭하고, 스냅샷을 찍고, trial run을 돌리는 연속 동작이 대화 턴 안에서 자율적으로 이어져야 했다. LangGraph보다 유연한 tool loop가 이 구간에 맞았다.
등록된 tool은 대략 이렇게 나뉜다.
| 분류 | tool 예시 | 역할 |
|---|---|---|
| 브라우저 | browser_open, browser_click, browser_type, … | Playwright 그라운딩 |
| 시나리오 | submit_scenario, run_robot_test | 초안 제출·trial run |
| 협업 | propose_options | 사용자에게 선택지 질의 |
| 프로젝트 | list_glossary, save_environment, … | 용어집·환경 연동 |
브라우저 tool이 실행될 때마다 스크린샷 mirror 이벤트도 backend로 callback된다. QA/기획자가 Playwright 코드를 읽지 않고도 “지금 AI가 어떤 화면을 보고 판단하는지” 확인할 수 있다.
run_robot_test로 trial run을 돌려 통과한 코드는 submit_scenario에 반영할 수 있다. 제안·데모 전에 “이 시나리오가 실제로 통과하는지"를 chat agent session 안에서 먼저 검증하는 흐름이다.
propose_options: 대화 중 사용자에게 물어보기
tool loop에서 가장 신경 쓴 부분 중 하나가, 대화 중에 사용자에게 물어보는 흐름이었다.
아래가 실제 chat agent session 화면이다. 왼쪽은 채팅과 Open Browser, Move Page 같은 tool 실행 로그, 오른쪽은 browser mirror(에이전트가 보고 있는 화면), 하단 선택지가 propose_options다. 기획서에 “로그인한다"만 적혀 있을 때, 비밀번호를 에이전트가 추측하지 않고 여기서 멈추고 물어본다.

기획서나 자연어 요구사항만으로는 분기가 애매한 경우가 많다. “이 버튼인가요, 저 버튼인가요?” — LLM이 plain text로만 물어보면 frontend가 질문 상태를 안정적으로 잡기 어렵다. 게다가 답변 전까지 LLM이 계속 tool을 호출하면 안 된다.
그래서 propose_options tool을 만들었다. 처음엔 예외로 루프를 끊는 방식이 좀 어색해 보였는데, 구현해 보니 frontend와의 계약을 맞추기엔 이게 제일 깔끔했다.
# tools.py
class ProposeInterrupt(Exception):
"""propose_options 툴 호출 시 루프를 종료하고 결과를 반환하기 위한 예외."""
에이전트가 propose_options를 호출하면 execute_tool 내부에서 ProposeInterrupt가 발생하고, tool loop는 즉시 종료된다. 동시에 question.asked 이벤트가 backend로 publish된다.
# chat_runtime.py (요약)
except ProposeInterrupt as interrupt:
return _propose_outcome(session, agent_session_id, interrupt.payload, ...)
이 패턴을 쓴 이유는 세 가지다.
- 루프를 멈춰야 한다 — 사용자 답변 전까지 LLM이 계속 tool을 호출하면 안 됨
- 이벤트 계약이 명확해야 한다 — frontend가 “지금 질문 중” 상태를 판단할 수 있어야 함
- plain text 질문과 tool 질문을 구분 — system prompt에서도 branching decision은 반드시
propose_options를 쓰도록 강제
LangGraph의 interrupt가 세션 시작·기획서 해석 구간의 pre-flight 질문을 담당한다면, propose_options는 대화 중 화면을 보며 나오는 분기 질문을 담당한다. 둘 다 “사용자에게 물어보고 기다리는” 흐름이지만, 타이밍과 계약이 다르다.
비슷하게 submit_scenario는 SubmitInterrupt로 루프를 끝낸다. 시나리오 초안이 확정되면 브라우저 executor를 정리하고 session.completed 이벤트를 보낸다.
문서 첨부 RAG
이 프로젝트의 intake는 채팅만이 아니다. 기획서·요구사항 문서가 핵심 입력이다. QA/PM은 PRD, 테스트 케이스 Excel, API 명세 PDF를 첨부하는 경우가 많았다.
첨부 파일은 ingest API로 들어오면 markdown/text로 파싱하고, RecursiveCharacterTextSplitter로 chunk(1000자, overlap 150)한 뒤, OpenAI embedding으로 SessionRagStore에 세션별 저장한다. 이후 메시지마다 관련 chunk를 retrieval한다.
# rag.py (요약)
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
separators=["\n\n", "\n", "??", ". ", " "],
)
기획서 전체를 프롬프트에 넣으면 토큰 비용이 커지고 노이즈도 늘어난다. retrieval 결과만 세션 컨텍스트 preamble에 붙여, “기획서의 어느 부분을 지금 시나리오로 옮기는지"에 집중하게 했다.
OPENAI_API_KEY가 없는 로컬 환경에서는 fallback embedding으로 degrade한다. 사내 개발 환경에서 agent 서비스 전체가 죽지 않게 하려는 선택이었다.
실시간 message/tool 스트리밍
에이전트 처리는 BackgroundTasks로 비동기 dispatch된다. HTTP 응답만으로는 “지금 브라우저 탐색 중인지, 기획서를 해석 중인지” 알 수 없다.
그래서 ChatCallbackEmitter가 이벤트를 backend callback으로 보내고, backend가 WebSocket으로 frontend에 relay한다.
| eventType | 의미 |
|---|---|
message.chunk | LLM 응답 토큰 스트리밍 |
message.final | 최종 assistant 메시지 |
tool.call / tool.result | tool 실행 시작·결과 |
browser.mirror.updated | 스크린샷 mirror |
question.asked | propose_options / pre-flight 질문 |
# chat_runtime.py (요약)
if isinstance(delta_content, str) and delta_content:
content_parts.append(delta_content)
if event_emitter is not None:
event_emitter.publish_message_chunk(delta_content)
브라우저 탐색은 수십 초 걸릴 수 있다. 스트리밍 없이 기다리면 QA/기획자 입장에서 “멈춘 건지 동작 중인지” 구분이 안 된다. 실제로 QA 분과 같이 테스트해 보니, tool call/result와 mirror 이벤트가 같이 와야 “아, 지금 화면 보고 있구나"가 바로 느껴졌다.
전체 흐름 정리
한 chat agent session의 lifecycle을 시간순으로 정리하면 이렇다.
sequenceDiagram
participant User as "QA / 기획자"
participant FE as Frontend
participant BE as Backend
participant LG as LangGraph
participant ChatRT as "chat runtime"
participant PW as Playwright
User->>FE: 기획서 업로드
FE->>BE: ingest, RAG 저장
BE->>LG: 세션 시작
LG->>LG: extract
alt pre-flight 질문
LG->>BE: question.asked
BE->>FE: WebSocket
User->>FE: 답변
FE->>BE: resume, extract
end
LG->>BE: 초기 draft
loop 대화 중
User->>FE: 자연어 메시지
FE->>ChatRT: tool loop
ChatRT->>PW: browser tool
ChatRT->>BE: mirror / tool events
BE->>FE: 실시간 반영
opt propose_options
ChatRT->>BE: question.asked
User->>FE: 선택지 응답
end
end
ChatRT->>BE: submit_scenario
BE->>User: scenario / robot 초안
BE->>FE: session.completed- 기획서·문서 첨부 — ingest → RAG chunk 저장
- 세션 시작 — LangGraph extract → 필요 시 ask_user interrupt
- 채팅 보완 — QA/기획자가 자연어로 요구사항 정리
- 화면 탐색 — tool loop → browser tool / mirror
- 사용자 확인 — propose_options → 선택 → loop 재개
- 완료 — submit_scenario → scenario / flow / robot 초안 → backend 영속화
설계하면서 지킨 원칙은 세 가지다.
- 모호한 UI → 브라우저 그라운딩
- 모호한 의도 → propose_options
- 기획서 내용과 실제 화면 → 같이 보며 맞춘다
마치며
이 프로젝트는 “LLM API 하나 붙이기"가 아니었다.
기획서만 업로드해도 시나리오 작성이 시작되고, QA·기획자가 자연어로 말하면서 실제 화면을 보며 시나리오를 만들 수 있게 하는 게 목표였다. LangGraph는 기획서 해석과 pre-flight 질문에서 멈추고 재개하는 흐름을, Function Calling tool loop는 대화 중 브라우저 탐색과 시나리오 제출을 담당했다. 그 사이 propose_options로 사용자 확인을 끼워 넣었고, RAG와 실시간 스트리밍으로 비개발자도 쓸 수 있는 chat agent session을 만들었다.
다음 글에서는 이 에이전트 이벤트를 frontend가 어떻게 받아 Decision Palette·통합 타임라인·미러 패널로 바꿨는지 정리할 예정이다.