모든 테스트는 통과했다. 하지만 사용자는 여전히 게임을 할 수 없었다.
요약
테스트 통과 여부와 실제 사용자 경험 사이의 간극을 해결하기 위해, 두 개의 LLM 에이전트를 활용한 '2-에이전트 정적 워크스루' 기법을 소개합니다. 한 에이전트는 고집스러운 사용자가 되고, 다른 에이전트는 실제 소스 코드를 추적하며 UX의 결함을 찾아냅니다.
핵심 포인트
- 코드의 작동(200 OK)이 반드시 사용자 경험의 성공을 의미하지는 않음
- 사용자 페르소나 에이전트와 코드 추적 에이전트 간의 상호작용 활용
- 앱을 실행하지 않고도 소스 코드 분석을 통해 UX 막다른 길을 탐색
- E2E 테스트가 놓칠 수 있는 로직과 UI 간의 연결성 결함 발견
"봐! 모든 테스트가 초록색(green)이야! API가 200 OK를 반환하고 있다고!"
"진정해. 시스템은 완벽하게 작동하고 있어. 사용자가 그냥 화면을 멍하니 바라보고 서 있는 거라면, 그건 사용자의 문제야."
나는 첫 엔지니어링 직장에 들어간 지 2년째 되던 해였고, 속으로 나의 선배(senpais)들이 가망 없다고 조용히 결론을 내린 상태였다. 그들은 대시보드 안에서만 살았고, 실제 제품에는 거의 손을 대지 않았으며, 테스트 커버리지(coverage) 수치에 즐겁게 취해 있었다. 그들의 유일한 장점은 "코드가 작동한다"는 사실에 취해 취할수록 더 유쾌해진다는 점이었다.
하지만 코드가 "작동하는 것"과 실제 인간이 "자신이 원하는 것을 얻는 것"은 완전히 다른 문제다. 버튼이 200 OK를 반환하더라도, 사용자는 변하지 않는 화면을 멍하니 바라보다가 결국 포기하고 떠나버릴 수 있다.
그래서 어느 오후, 나는 논쟁하는 대신 터미널을 열고 앱을 단 한 번도 실행하지 않고도 모든 UX 막다른 길(dead-end)을 찾아내는 약 30줄짜리 셸 주문(shell spell)을 만들었다. 나는 이것을 **2-에이전트 정적 워크스루 (two-agent static walkthrough)**라고 부른다.
주문 (The spell)
두 개의 LLM 에이전트가 루프 안에서 서로 대화한다:
- 에이전트 A — 사용자. 구체적인 목표를 가진 구체적인 페르소나: "나는 프로그래머가 아니에요. 그냥 열어서 클릭할 수 있는 플레이 가능한 틱택토(tic-tac-toe) 게임을 원할 뿐이에요." 이 에이전트의 결정적인 특징은 고집스럽다는 것이다. 첫 번째 실망에 포기하지 않고, 계속해서 다른 시도를 한다.
- 에이전트 B — 앱. 하지만 B는 아무것도 _상상하는 것_이 금지되어 있다. B에게는 **실제 소스 코드에 대한 읽기 권한(read access)**이 주어지며, 사용자의 동작이 닿는 실제 코드 경로를 추적하도록 지시받는다. 그 후
file:line을 인용하며 _사용자가 실제로 경험하게 될 것_을 서술하고, 모든 간극, 막다른 길, 정체된 상태(stale state), 그리고 거짓말을 고백해야 한다. 코드에 없는 동작을 지어내서는 안 된다.
아무도 앱을 실행하지 않는다. 두 AI는 단순히 실제 사용자가 겪게 될 절망의 경로를 차례대로 따라가며 터미널에 출력할 뿐이다.
비극: AI 미니게임 생성기
나는 팀원 중 누구도 건드리고 싶어 하지 않았던 기능, 즉 **AI 미니게임 생성기 (AI mini-game generator)**를 대상으로 삼았다. 에이전트 A의 목표는 간단했다. 버튼을 누르고 플레이 가능한 틱택토 (tic-tac-toe) 게임을 얻는 것. 무자비한 로그가 시작되었다.
턴 1 — 버튼의 배신
A (사용자): "좋아! '틱택토 만들어줘'라고 입력하고 생성 (Generate) 버튼을 눌렀어! 정말 기대된다!"
B (앱): "나쁜 소식입니다. app.js:45의 라우팅 (routing)에 따라, 당신의 요청은 새로운 게임 생성 API가 아닌 프로토타입 시대의 v1/chat 엔드포인트 (endpoint)로 삼켜졌습니다. 새로운 파이프라인 (pipeline)에는 전혀 도달하지 못했습니다."
테스트는 새로운 API를 대상으로 작성되어 있었다. 하지만 버튼은 여전히 예전 API를 가리키고 있었다. E2E 테스트 (End-to-End test)가 없었기에, 아무도 이를 알아차리지 못했다.
턴 2 — 클릭할 수 없는 공허
A: "어라, 코드처럼 보이는 텍스트 덩어리가 나타났네. 알았어 — 플레이 가능한 미리보기를 열기 위해 클릭할게! 클릭"
B: "무용지물입니다. 이전 API가 반환한 마크다운 (markdown)은 미리보기 iframe이 아닌 일반 div에 부어졌습니다. 링크도 없고 핸들러 (handler)도 없습니다 (render.js:88). 아무리 클릭해도 아무것도 변하지 않을 것입니다."
턴 3 — 구조적 죽음과 거짓된 축복
A: "으윽. 코드는 잘 모르겠지만, 고장 난 건 확실해 — 그러니까 **게임 수정 (Fix Game)**을 눌러서 AI가 고치게 해야지!"
B: "당신의 시도는 api.py:112에서 끝납니다. 수리를 위해서는 새로운 API만이 발급하는 session_id가 필요하지만, 당신은 이전 API를 거쳤기 때문에 이 값은 null입니다. 백엔드 (backend)는 500 에러를 반환했습니다: 수정할 것이 없음."
A: "뭐라고?! 그럼 화면에 에러가 뜨는 거 아냐?"
B: "아니요. 알림 로직 (notification logic)이 try/catch (app.js:88) 구문 _외부_에 위치해 있습니다. 따라서 백엔드가 죽어가는 동안, 당신의 화면에는 **'게임이 업데이트되었습니다!'**라는 초록색 토스트 (toast) 메시지가 자랑스럽게 표시됩니다."
밑바닥에는 에러가, 상단에는 성공 메시지가 떠 있다. 이것은 그 어떤 것보다도 사람을 미치게 만드는 UX (User Experience) 패턴이다.
4턴 — 잘려 나간 희망
A: "아아악! '업데이트됨'이라고 뜨는데 아무것도 안 바뀌었잖아! 좋아 — 내가 직접 코드 텍스트를 복사해서 HTML 파일에 붙여넣고 강제로 실행할 거야!"
B: "위로를 전합니다. 기존 API에는 여전히 500자 출력 제한이 있습니다. 당신이 복사하고 있는 코드는 </html> 직전에서 잘려 있습니다. 절대로 실행되지 않을 겁니다. ...게임 오버."
A: "......" (떠난다)
그녀는 계속 시도했다 — 세 개의 문이 더 있었지만, 전체 대화 내용을 다 적지는 않겠다. 그녀는 요청 범위를 단일 모듈로 좁혔고, 작동하지 않는 "경로 수정(course-correct)" 버튼이 어딘가에 반드시 존재해야 함을 암시하는 별도의 목표(goal) 진입구를 찾아 헤맸으며; 마지막으로 앱에 아무것도 전달하지 말고 그냥 게임 그 자체가 되어 달라고 요청했다 — 채팅창에 보드를 그리고, 그녀의 움직임을 받아들이라고 말이다. 모든 시도는 동일한 파이프라인(pipeline)으로 흘러 들어갔고, 똑같이 쾌활한 "작동 중..." 메시지 뒤에 숨겨져 있었다.
일곱 가지 전술. 그 모든 전술은 200 OK 또는 가짜 성공 토스트(success toast) 뒤에서 **구조적 죽음(structural death)**을 맞이했다 — 정확히 일반적인 유닛 테스트(unit test)가 초록색으로 표시하는 바로 그 지점들이다. 이것이 바로 "코드는 작동하지만 사용자는 절망하는" 상태다.
작동 원리
- 고집스러운 페르소나가 실제 경로를 소진시킨다. 나의 첫 번째 실행에서는 사용자가 한 번의 실망 후에 포기하게 두었더니 거의 아무것도 찾아내지 못했다. 하지만 A에게 _"공정하고 철저하게 시도해 보세요. 정말로 막다른 길에 다다랐을 때만 포기하세요"_라고 말한 실행에서는 모든 것을 찾아냈다. 진짜 절망은 첫 번째 막다른 길 너머에 존재한다.
- B는 실제 코드에 기반하므로, 해피 패스(happy path)를 환각(hallucinate)할 수 없다. "결과를 클릭하세요"라는 말은 "
textContent로 렌더링되었으며, 핸들러(handler)가 연결되지 않음 — 클릭해도 아무 일도 일어나지 않음"이라는 줄 번호가 포함된 보고로 바뀐다. - 대조가 곧 신호다. A는 결과(outcome)를 원하고, B는 메커니즘(mechanism)을 보고한다. 이 둘이 만나지 못하는 지점이 바로 당신의 UX 실패 지점이다.
설정 (약 30줄의 쉘 스크립트)
각 턴은 에이전트당 하나의 비대화형 CLI 호출이며, 공유된 트랜스크립트(transcript) 파일을 스레딩(threading)한다:
# B: 자신의 코드를 읽는 앱 (읽기 전용 샌드박스, 리포지토리 마운트됨)
B=$(codex exec --sandbox read-only -C "$REPO" "$(cat prompt_B.txt)")
...
prompt_B.txt ≈ "당신은 앱 그 자체입니다. 소스 코드를 읽으세요. 사용자의 최신 동작 이후에 사용자가 보는 것을 정확하게 추적하세요. 파일:줄(file:line)을 인용하세요. 막다른 길(dead-ends)에 대해서는 매우 솔직해야 합니다. 코드에 없는 동작을 절대 지어내지 마세요. 대화 기록(TRANSCRIPT): …"
prompt_A.txt ≈ "당신은 <목표(goal)>를 가진 <페르소나(persona)>입니다. 앱의 마지막 응답에 반응한 다음, 구체적인 동작을 계속 시도하세요. 끈기 있게 행동하세요. 정말로 막다른 길에 다다랐을 때만 멈추세요. 대화 기록(TRANSCRIPT): …"
두 턴(turn)을 모두 대화 기록에 추가하고, 5~6라운드를 반복하며, 사용자가 포기하면 중단합니다.
도구 관련 참고 사항: 코드 읽기(code-reading) 에이전트의 경우, 호출당 하나의 제한된 답변을 안정적으로 반환하는 CLI를 사용하세요. 페르소나(persona) 에이전트의 경우, 역할극(role-play) 프롬프트는 어느 쪽에서든 작동합니다. 다만, 스스로 백그라운드로 돌아가 버려 깔끔한 턴을 반환하지 못할 수 있는 무거운 "연구(research)" 모드를 유발하는 프롬프트는 피하십시오.
언제 활용해야 하는가
- UX 검토 전, 의도(intent)가 현실과 만나는 지점을 파악하기 위해.
- 엔드 투 엔드(end-to-end)로 작동한다고 생각하는 흐름에서 — 두 서브시스템(예: 오래된 버튼과 새로운 파이프라인) 사이의 불일치를 정확히 찾아냅니다.
- 실제 테스트를 대체하는 것이 아니라 보완하는 용도로. 이 방식은 코드를 _추론(reasons about)_하는 것이지, 실행하는 것이 아닙니다. 이 방식의 결과물은 **확인해야 할 단서(leads to verify)**로 취급하고, 실제 실행을 통해 진짜인지 확인하십시오.
핵심 요약 (Takeaways)
- 엔드포인트가 200을 반환하는지뿐만 아니라, 사용자가 목표에 도달하는지를 테스트하세요.
- "사용자" 에이전트를 고집스럽게(stubborn) 만드세요 — 심층적인 발견은 첫 번째 막다른 길을 넘어선 곳에 존재합니다.
- "앱" 에이전트를 실제 코드에 근거(ground)시키세요 — 그것이 역할극을 팬픽션이 아닌 버그 리포트로 바꾸는 핵심입니다.
- 이 방식은 정적(static)이고 저렴하며, 단 하나의 테스트 픽스처(test fixture)를 작성하기 전에도 실행할 수 있습니다.
버그 리포트는 스스로 작성되었습니다. 이제 저는 이것을 선배들(senpais)에게 던져주고 정시에 퇴근하기만 하면 됩니다. 저는 겸손하게 살아왔고, 앞으로도 겸손하게 살 생각입니다. 그래로 이 작은 마법이 제가 빈둥거릴 수 있는 시간을 더 벌어다 줄 수 있도록 말이죠.
전체 시스템은 두 개의 CLI 코딩 에이전트를 제어하는 약 30줄짜리 쉘 루프(shell loop)였습니다. 관심 있는 분들이 있다면 후속 글로 스크립트를 공개하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기