모두의주차장에서 주차권을 구매하고 결제 완료 화면을 보는 순간, 사용자 입장에선 모든 게 끝난 것처럼 보입니다. 하지만 저희 백엔드에선 그때부터 진짜 일이 시작됩니다.
사용자는 주차권을 샀으니, 현장에서 출차할 때 차단기가 곧바로 열리기를 기대합니다. 그 한 번의 동작을 위해 저희는 현장 정산 시스템과 연결된 관리자 웹에 접속해 로그인하고, 사용자의 차량번호에 할인권을 입력해 주어야 합니다.
처음 주차권을 팔기 시작한 약 10년 전에는 이 작업을 전부 사람이 직접 했다고 합니다. 이후 판매량이 늘면서 스크립트로, 다시 할인 적용 배치(batch)로 진화했고, 지금도 저희는 이 배치를 부지런히 개선하고 있습니다.
문제는 이 배치가 50여 개의 서로 다른 외부 웹을 자동화하고 있다는 점입니다. 주차장의 정산 장비와 관리자 웹을 만드는 업체(이하 장비사)가 50여 곳이고, 웹은 장비사마다 다르게 생겼거든요. 저희가 직접 유지보수하는 사이트가 아니다 보니 작은 변경에도 취약하고, 언제든 대형 장애로 번질 위험을 안고 있습니다.
실제로 2025년 한 해에만 할인권 적용이 약 30만 건 실패했고, 그중 16.5만 건이 환불로 이어졌습니다. 장애를 인지하고, 바뀐 화면을 들여다보고, 배치 코드를 고쳐 배포하기까지는 평균 4시간이 걸렸습니다. 그동안 고객 영향은 계속 이어졌고요.
저희는 그동안 이 문제를 "어쩔 수 없는 것"으로 여겨왔습니다. AI가 지금처럼 발전하기 전까지는요. 이 글에서는 에이전틱 엔지니어링(Agentic Engineering)으로 이 오래된 문제를 풀어낸 기술적 경험을 공유드리려 합니다.
💡 문제의 비즈니스 임팩트와 AI 도입을 결정하기까지의 이야기는 LLM은 생각보다 똑똑하고, 생각보다 멍청하다에 더 자세히 정리돼 있습니다.
에이전틱 엔지니어링
AI가 필요했던 이유
앞서 이야기한 할인 적용 배치는, 결국 "여기를 누르고 이 값을 입력하라"는 규칙을 코드로 박아둔 룰 베이스(rule-based) 자동화입니다. 외부 웹이 늘 같은 모습으로 동작해야 한다는 전제 위에서만 성립하는 방식이죠. 그런데 그 50여 개의 웹은, 언제든 예고 없이 바뀝니다.
반면 사람은 화면이 바뀌어도 막힘없이 처리합니다. 화면 스크린샷, DOM 트리, 접근성 트리(accessibility tree) 같은 정보를 건네주면, AI도 사람이 그러듯 "할인권을 적용하려면 어디를 눌러야 하는지"를 그 자리에서 유추해 행동할 수 있습니다. 정해진 절차를 그대로 밟는 게 아니라, 화면을 보고 다음 행동을 스스로 정하는 것이죠. 저희는 이렇게 런타임에 판단이 필요한 작업이야말로 AI에 맡기기 가장 적절한 작업이라고 판단했습니다.
도구 — 상태와 라우팅은 코드가 쥔다
프롬프트 하나로는 풀 수 없는 문제
이 문제는 "프롬프트를 한 번 던지고 답을 받는" 단순한 문제가 아니었습니다. 사이트 접근 → 로그인 → 차량 조회 → 할인권 적용 → 적용 검증 → 변경점 비교 → PR 생성이 순서대로 이어집니다. 게다가 중간에 실패하면 그 원인을 분석해야 하고, 그 분석이 무한히 돌아서도 안 됩니다.
그래서 프레임워크는 LangGraph를 골라, StateGraph로 전체 흐름을 고정했습니다. 이 선택의 이유가 곧 이 글의 주제이기도 합니다. 상태와 라우팅을 코드가 저수준(low-level)에서 직접 쥐고, LLM은 전체 문제를 자유롭게 푸는 게 아니라 각 노드 안에서만 판단하게 만들 수 있었거든요. OpenAI Agents SDK와 Claude Agent SDK도 후보였지만, 특정 벤더에 묶이거나 상태·라우팅을 이만큼 저수준에서 쥐기 어려워 접었습니다.
브라우저 조작과 모델
브라우저 조작은 Playwright MCP로 붙였습니다. 공식 MCP(Model Context Protocol) 서버가 있어 에이전트에 곧바로 연결되기도 했지만, 결정적인 이유는 화면을 접근성 트리 기반의 구조화된 스냅샷으로 준다는 점이었습니다. 픽셀 스크린샷 한 장보다, 각 요소의 역할과 참조(ref)가 정리된 스냅샷을 근거로 삼는 편이 훨씬 정확했습니다. 이 스냅샷의 ref는 뒤에서 계속 등장합니다.
모델은 Gemini 3.1 Pro를 씁니다. 트레이스로 실측해 보면 복구 한 건에 드는 LLM 비용은 대략 몇백 원 수준이고, 복구 시간의 대부분도 브라우저 조작 대기가 아니라 LLM 호출이 차지합니다. 화면을 읽고, 도구를 고르고, 다음 행동을 추론하는 반복이 곧 이 에이전트의 실행 시간이자 비용입니다.
설계 — 원인이 아니라 단계로
실패한 첫 설계: 장애 원인별 분리
에이전트를 여러 개 두는 것 자체는 처음부터 정해져 있었습니다. 문제는 "무엇을 기준으로 나눌 것인가"였죠. 첫 가설은 장애 원인 카테고리를 축으로 삼는 것이었습니다.
- DOM Agent: UI/selector 변경 분석
- Network Agent: API/request 변경 분석
- Policy Agent: 정책/데이터 문제 분석
논리적으로는 깔끔했습니다. 실패 원인을 나누고 각 분야 전문가 에이전트에게 맡기는 방식이니까요. 하지만 실제로는 잘 맞지 않았습니다. 지금 장애가 DOM 문제인지 API 문제인지 알려면 이미 상당한 분석이 끝나 있어야 합니다. 즉 어느 에이전트에게 보낼지 라우팅하려면 또 다른 분류기가 필요했고, 그 분류기가 틀리면 — 예컨대 로그인이 막힌 상황을 API 문제로 오판하면 — Network Agent가 엉뚱하게 payload만 파헤치고 있게 됩니다.
더 근본적인 문제는, 이 축이 실제 개발자의 장애 대응 방식과 달랐다는 점입니다. 운영자는 장애를 마주하면 원인부터 분류하지 않습니다. 일단 순서대로 밟아 봅니다. 로그인이 되나? 차량이 조회되나? 할인권이 들어가나? 그러다 막히는 지점에서 화면이나 API가 예전과 달라졌는지 확인합니다.
최종 설계: 로그인부터 검증까지 단계별 분리
첫 가설은 버리고, 분할 축을 바꿨습니다. 원인이 아니라 개발자가 실제로 밟는 대응 순서를 그대로 에이전트로 옮긴 거죠. 그래프의 각 노드가 곧 하나의 에이전트입니다.
- login 에이전트 — 로그인을 수행
- search 에이전트 — 차량을 조회
- discount 에이전트 — 할인권을 적용
- check 에이전트 — 적용 여부를 검증
- compare 에이전트 — 화면·API가 예전과 달라졌는지 비교
사이트 접근은 이 앞단의 공통 노드가 맡고, 변경이 확정되면 마지막에 PR을 만듭니다. 장비사 유형에 따라 이 단계들을 브라우저 UI로 조작하는 경로와 네트워크 API로 처리하는 경로, 두 갈래로 나뉘는데요. 밟는 순서는 같고 조작 방식만 다릅니다.
여기서 compare가 비교하는 기준선이 스키마입니다. 장비사별로 "정상일 때의 화면 구조와 API 명세"를 JSON 파일로 저장해 둔 것으로, 이 글에서는 기준선 스키마라고 부르겠습니다(뒤에 나올 LLM 응답의 출력 스키마와는 다른 것입니다). 배치 코드에서 LLM으로 초안을 추출한 뒤 사람이 검토해서 커밋합니다. 실행할 때마다 코드베이스를 분석하는 것은 토큰 낭비이기도 하고, 기준선은 신뢰할 수 있어야 하기 때문에 개발자의 검증 프로세스를 넣었습니다. 에이전트가 매번 관찰한 "현재 모습"과 이 기준선의 차이가 곧 변경점이 됩니다. 두 경로 중 어느 쪽으로 갈지도 이 기준선 스키마의 systemType 필드가 정적으로 정합니다. 관리자 웹이 API 호출 위주로 동작하는 장비사는 api, 나머지는 headless(브라우저) — 경로 선택조차 LLM의 런타임 판단이 아니라 데이터가 정하는 값입니다.
이 설계가 더 나았던 이유는 세 가지였습니다.
- 프롬프트가 짧아집니다. 각 에이전트는 자기 단계의 목표만 알면 됩니다. login 에이전트는 로그인만, search 에이전트는 조회만 신경 쓰면 되죠.
- 실패 지점이 명확해집니다. discount는 성공했는데 check가 실패한 것과, discount 자체가 실패한 것은 완전히 다른 신호거든요.
- 재현성이 좋아집니다. LLM이 다음 큰 방향을 정하는 게 아니라, 코드가 정한 단계 순서를 따라가니까요.
분석 루프에는 브레이크를
물론 단계마다 실패할 수 있습니다. 실패하면 해당 경로의 analyze 에이전트가 장애 원인을 분석하고, 가능하면 고쳐서 다시 시도합니다. 다만 이 분석 루프에는 반드시 종료 조건이 필요했습니다. 비결정적인 LLM에게 "될 때까지 해봐"라고 맡길 수는 없으니까요. 아래 중 하나라도 만족하면 루프를 멈추도록 했습니다.
- 분석 시도가 3회에 도달
- 누적 3분 경과
- 원인 분류가 확정됨 — 분류가 정해지고 confidence가 0.5를 넘으면, 한도가 남아 있어도 즉시 멈춥니다
앞의 둘이 폭주를 막는 상한이라면, 셋째는 답을 찾았으면 더 돌지 않는 조기 종료입니다. 그리고 루프가 어느 조건으로 끝났든, 복구에 실패한 케이스가 그냥 버려지지는 않습니다. 실행 결과는 원인 분류와 분석 코멘트, 관측 트레이스 링크와 함께 어드민의 복구 케이스로 기록되고, 운영·개발 담당자가 거기서 이어받습니다.
마지막 단계: 코드를 고치는 PR
변경이 확정되면 에이전트는 마지막 일을 합니다. 배치 코드를 직접 고쳐서 PR을 올리는 거죠. PR 에이전트는 GitHub 도구 네 개(파일 읽기·브랜치 생성·커밋·PR 생성)로 기존 코드 읽기 → 수정 → 브랜치 생성 → 커밋 → PR 생성 다섯 단계를 순서대로 수행합니다. 수정 자체는 도구 없이 메모리에서 하고요. 이 PR을 어디까지 자동으로 밀어붙일지는 뒤에서 다시 다루겠습니다.
평가 — 프롬프트 한 줄 변경에 검증 30분
방어 장치 이야기로 넘어가기 전에, 이 장치들이 효과가 있는지 어떻게 확인했는지부터 밝혀두겠습니다. PoC 기간의 평가는 세련되지 않았습니다. 검증용 테스트셋을 정해 두고, 프롬프트나 그래프를 고칠 때마다 스크립트로 전체를 다시 돌려 결과를 눈으로 대조했습니다. 한 번 돌리는 데 2~3분, 수정 하나에 열 번쯤 돌리게 되니 프롬프트 한 줄 고치는 데 검증 20~30분이 따라붙는 식이었습니다. 이 수동 리그레션을 반복하다가 다른 PoC 팀이 LangWatch로 evaluation을 돌리는 걸 보고 저런 도구가 필요하다는 걸 절감했습니다. 아직 도입하지 못한, 다음 숙제로 남아 있는 부분입니다. 뒤에 나올 "오탐이 줄었다", "정확도가 올라갔다" 같은 판단은 모두 이 대조 과정에서 나온 것입니다.
LLM을 믿지 않아도 되는 설계
에이전트를 만들며 가장 크게 배운 것 하나를 꼽자면, LLM의 실수를 "더 좋은 프롬프트"로 막으려 하면 끝이 없다는 점입니다. 할루시네이션은 어쩌다 나오는 버그가 아니라 LLM의 기본 성질이거든요. 그래서 저희는 LLM의 실수를 세 가지 축으로 유형화하고, 축마다 코드 레벨의 방어 장치를 두었습니다.
- Action — 잘못된 도구를 호출하거나, 다뤄선 안 되는 값을 다룬다 → 애초에 못 하게 만든다
- Behavior — 그럴듯한 잘못된 결론을 보고한다 → 보고를 믿지 않고 검증한다
- Context — 정보가 너무 적으면 못 풀고, 많으면 엉뚱한 정보에 끌린다 → 필요한 만큼만 보여준다
그리고 이 장치들이 다 지나간 뒤의 마지막 방어선은 사람입니다.
Action — 애초에 못 하게 만들기
가장 확실한 방어는 프롬프트로 "하지 마세요"라고 부탁하는 게 아니라, 할 수 없게 만드는 것입니다.
대표적인 게 credential입니다. 로그인하려면 아이디와 비밀번호를 입력해야 하는데, 이 값을 LLM에게 알려주는 순간 프롬프트, 로그, 트레이스 어디로 새어 나갈지 알 수 없습니다. 그래서 저희는 전용 login 도구를 만들었습니다. LLM은 화면 스냅샷에서 아이디 입력창·비밀번호 입력창·로그인 버튼의 참조(ref) 세 개만 찾아서 넘기고, 실제 값은 도구 내부에서 주입됩니다. LLM은 비밀번호를 끝까지 모릅니다.
// LLM에게는 도구만 노출되고, credential은 클로저에 바인딩됩니다
const loginTool = createLoginTool({ id, pwd }, playwrightTools)
// LLM의 호출: login({ usernameSelector: "e34", passwordSelector: "e36", submitSelector: "e38" })
// 값 주입은 도구 안에서 일어납니다
도구 자체를 덜 주는 방법도 씁니다. 적용 여부를 검증하는 check 에이전트에게는 화면을 읽는 browser_snapshot 하나만 지급합니다. 클릭도 입력도 도구가 없으니 구조적으로 불가능하죠. 검증하라고 보냈더니 화면을 조작해 버리는 사고를 도구 목록이 막아줍니다.
화면 이동은 어떻게 하느냐면, 안 합니다. 그래프 전체가 브라우저 하나를 공유하므로 check는 discount가 끝난 그 화면을 그대로 물려받습니다. 대신 discount 프롬프트가 "적용 후 반드시 스냅샷을 다시 확인하라"고 강제해서, 할인 내역이 보이는 상태로 단계가 끝나도록 보장합니다.
외부 웹의 네이티브 팝업(alert/confirm)도 Action 사고의 단골이었습니다. 팝업이 뜨면 LLM이 처리하길 기다리는 대신, 모든 도구를 wrapper로 감싸 팝업을 자동으로 확인 처리하게 했는데요. 다만 팝업 처리 후 원래 도구를 재시도하는 건 browser_snapshot 같은 읽기 전용 도구뿐입니다. 클릭이나 입력을 재시도했다가는 할인권이 두 번 적용되는 더블 액션이 나기 때문입니다.
Behavior — 자기 보고를 믿지 않기
LLM은 자기가 한 일을 낙관적으로 보고합니다. "할인권을 적용했습니다"라는 답변과 실제로 할인권이 적용된 것은 다른 문제입니다. 이 축의 방어는 세 겹입니다. 검증은 남이 하고, 응답의 형태는 스키마로 강제하고, 내용의 기준은 예시로 잡습니다.
검증은 남이 합니다
그래서 discount 에이전트가 성공을 보고해도 그걸로 끝내지 않습니다. 별도의 check 에이전트가 할인 내역 화면을 읽어 실제로 적용됐는지 다시 확인합니다. 검증하는 쪽과 실행한 쪽은 컨텍스트를 공유하지 않습니다.
LLM이 보고한 값 자체도 코드가 다시 확인합니다. 차량 조회 단계에서 에이전트가 "이 차량을 선택했다"고 보고하면, 그 차량번호가 요청된 번호와 정확히 일치하는지 코드가 비교하고, 다르면 강제로 실패 처리합니다. 비슷한 차량번호(111가1234 vs 112가1234)를 골라놓고 성공이라 우기는 걸 막는 장치입니다.
분석 단계에서는 근거 없는 결론이 문제였습니다. analyze 에이전트가 "기준선 스키마와 화면이 다르니 UI가 변경됐다"고 결론 내리기 쉬운데, 화면이 다른 이유는 일시적 점검 페이지일 수도, 세션 만료일 수도 있거든요. 그래서 분류에는 근거 — 배치 에러 메시지, 네트워크 응답, 명확한 구조 변경 — 를 요구하고, 근거가 부족하면 '일시적 오류(temporary)'로 분류하도록 했습니다. 그리고 믿지 않는 대상에는 에이전트 자신도 포함됩니다. MCP 연결이 끊기거나, 도구 입력 검증이 깨지거나, 그래프가 재귀 한도에 걸리는 건 외부 사이트의 장애가 아니라 에이전트의 고장입니다. 이런 에러 패턴이 섞인 실행은 결론을 "사이트가 바뀌었다"로 보고하지 못하도록 코드가 걸러냅니다. 운영자에게 가는 원인 분석 코멘트를 생략하고, 미분류로 남긴 채 내부 알람만 올립니다. 에이전트가 자기 버그를 외부 장애로 둔갑시키는 것만큼 시스템의 신뢰를 빨리 깎아 먹는 건 없으니까요.
여기에 앞서 소개한 분석 루프의 종료 조건(3회·3분)이 이 검증들의 안전망이 되어 줍니다.
응답은 문장이 아니라 스키마로 (Structured Output)
에이전트의 응답을 자유 텍스트로 받으면 이런 문장이 돌아옵니다. "로그인에 성공한 것으로 보입니다. 다만 화면이 일부 다를 수 있습니다." — 사람이 읽기엔 자연스럽지만, 이걸 받아서 다음 노드로 라우팅해야 하는 건 코드입니다. 코드는 문장의 행간을 읽지 못합니다. 그래서 모든 에이전트의 최종 응답을 Zod 출력 스키마로 강제했습니다.
const AnalysisOutputSchema = z.object({
resolved: z.boolean().describe('문제 해결 여부'),
category: AnalysisCategory.describe('원인 분류'), // 6개 값의 enum
description: z.string().describe('분석 결과 상세 설명'),
confidence: z.number().min(0).max(1).describe('확신도 (0~1)')
})
const agent = createAgent({
model,
tools,
responseFormat: toolStrategy(AnalysisOutputSchema)
})
핵심 이득은 두 가지입니다.
라우팅이 타입 위에서 돕니다. "분석이 끝난 것 같다"는 문장을 해석하는 대신, resolved 같은 불리언과 confidence > 0.5 같은 숫자 비교로 분기합니다. 원인 분류(category)도 enum이라서 LLM이 "아마도 UI 변경 같은 것"이라는 애매한 값을 만들어낼 수 없습니다. 여섯 가지 정해진 값 중 하나를 고르거나, 스키마 검증에 실패하거나 둘 중 하나죠.
테스트의 경계가 생깁니다. 노드가 "LLM 호출"과 "결과 처리"로 나뉘고 그 경계가 스키마라서, 테스트에서는 LLM 전체를 스키마 형태의 값을 돌려주는 mock으로 갈아끼우면 됩니다. LLM을 실제로 호출하는 테스트는 느리고, 비싸고, 결과가 매번 다르지만, 이 방식이면 "로그인이 실패하면 analyze로 라우팅되는가" 같은 그래프의 동작을 빠르고 결정적으로 검증할 수 있습니다. 저희 유닛 테스트 대부분이 LLM 없이 이렇게 돌아갑니다.
덤으로 스키마의 .describe()가 곧 출력 명세 역할을 해서, "응답은 이런 JSON 형태로 주세요" 같은 지시문이 프롬프트에서 사라집니다.
그럼 LLM이 이 스키마조차 못 지키면 어떻게 될까요? 일단은 프레임워크가 스키마 오류를 피드백하며 그 자리에서 재시도합니다. 그래도 끝내 못 지키면 해당 노드의 실패로 기록되어 분석 루프(최대 3회)로 흘러가고, 오탐이 곧 사고인 compare는 실패 시 "변경 없음"이라는 안전한 기본값으로 떨어져 PR을 만들지 않습니다. 형식 위반은 앞서 말한 '에이전트 자신의 고장'으로 분류되어, 외부 장애 보고에서 걸러집니다.
정답의 기준을 보여주기 (Few-shot)
structured output이 응답의 형태를 강제한다면, 그 안에 담기는 내용의 기준은 여전히 LLM의 몫입니다. 어디까지가 "변경"이고 어디부터가 호들갑인지 — 스키마는 이걸 알려주지 못합니다. 저희는 이 기준을 Few-shot 예시로 잡았습니다. 판단이 필요한 에이전트(compare, 일시적 오류 추론, PR 생성)의 시스템 프롬프트는 역할 → 핵심 원칙 → 절차 → Few-shot 예시 구조의 공용 빌더로 만들고, 예시는 케이스 이름을 붙인 완성된 응답 JSON으로 넣습니다.
const API_COMPARE_PROMPT = buildSystemPrompt({
role: '당신은 API 변경점 분석 전문가입니다. ...',
principles: ['**보수적 판단**: 확실한 변경만 보고합니다.' /* ... */],
procedure: [/* ... */],
examples: {
'변경 없음': {
hasDiff: false,
diffs: [],
summary: '모든 API가 기준선 스키마와 일치'
},
'API endpoint 변경': {
hasDiff: true,
diffs: [{
step: 'search',
changeType: 'api_endpoint',
field: 'url',
original: '/api/store/{{siteId}}/search',
current: '/api/v2/search/a1b2c3d4',
confidence: 0.95
}],
summary: 'search API endpoint 변경'
}
}
})
예시를 고를 때 신경 쓴 건 두 가지였습니다.
"아무것도 없음" 예시를 반드시 넣습니다. LLM은 뭐라도 찾아내려는 경향이 있어서, 변경점을 찾으라고 시키면 없는 변경점이라도 만들어서 보고하고 싶어 합니다. hasDiff: false, diffs: []라는 예시는 "없으면 없다고 답해도 된다"는 허가증 역할을 합니다. 실제로 이 예시가 있고 없고에 따라 오탐(false positive)이 눈에 띄게 달라졌습니다. 없는 diff는 곧 불필요한 PR로 이어지기 때문에, 어쩌면 정답 예시보다 이 무죄 예시가 더 중요합니다.
confidence에 눈금을 새깁니다. "확신도 0~1로 답하세요"라고만 하면 LLM의 0.9는 매번 다른 의미가 됩니다. 그래서 예시마다 값을 달리 박아뒀습니다. endpoint가 명백히 바뀐 케이스는 0.95, 네트워크 타임아웃 추정은 0.85, 서버 오류 추정은 0.75 — 이런 앵커가 있으면 모델이 자기 확신을 상대적인 눈금 위에 놓게 됩니다. 이 confidence는 앞서 본 분석 루프의 조기 종료(0.5 초과 시 분류 확정)에 직접 쓰이는 값이라, 눈금이 흔들리면 루프가 너무 일찍 멈추거나 헛돌게 됩니다.
그리고 예시도 결국 코드로 관리할 대상입니다. 출력 스키마는 진화하는데 예시만 조용히 낡으면 모델이 낡은 기준을 배우게 되거든요. 예시 객체를 각 노드의 출력 스키마 타입(z.infer)으로 묶어, 스키마가 바뀌면 예시가 컴파일 에러로 함께 잡히도록 했습니다. 응답뿐 아니라 도구 호출에도 예시를 씁니다. login 도구처럼 인자 형식이 특수한 경우(CSS selector가 아니라 스냅샷의 ref), 호출 예시 한 줄이 장황한 설명보다 잘 통했습니다.
Context — 필요한 만큼만 보여주기
컨텍스트는 많을수록 좋은 게 아니었습니다. 관련 없는 정보가 섞이면 LLM은 거기에 끌려갑니다.
격리·태깅·상한
기본 구조부터 격리입니다. 각 단계 에이전트는 매번 새로 생성되고, 이전 단계의 대화 히스토리를 물려받지 않습니다. login 에이전트가 뭘 했는지 search 에이전트는 모릅니다. 알 필요가 없으니까요. 각 에이전트에는 자기 단계의 기준선 스키마와 힌트만 주입됩니다.
수집하는 데이터도 단계별로 태깅합니다. 자동 처리된 팝업 메시지는 실패한 단계와 관련된 것만 골라 분석 프롬프트에 넣고, 네트워크 로그는 단계별로 나눠 수집해서 비교할 때 해당 단계의 것만 사용합니다. API 응답을 기준선 스키마와 비교할 때는 응답에서 실제 값을 지우고 구조(키·타입)만 추출해서 넘기는데요. 비교에 값은 필요 없고, 값이 있으면 오히려 LLM이 값의 차이에 끌려 엉뚱한 diff를 만들어내기 때문입니다.
마지막으로 state에 쌓이는 로그에는 상한(네트워크 1,000건, 팝업 500건)을 둬서, 오래 도는 세션에서 컨텍스트가 무한히 불어나는 것도 막았습니다.
프롬프트에서 지식을 분리하기 (장비사별 힌트)
덜어내는 것만이 Context 설계는 아닙니다. 필요한 지식을 정확히 넣는 것도 같은 축입니다. 처음엔 우아한 프롬프트 하나로 50여 개 웹을 전부 커버하는 그림을 그렸습니다. 그건 이상이었죠. 장비사마다 검색이 입력창이 아니라 번호판 클릭인 곳이 있고, 할인권 적용 후 확인 모달을 두 번 눌러야 하는 곳이 있고, iframe 이름이 매번 바뀌는 곳도 있습니다. 이걸 프롬프트 하나에 다 욱여넣는 대신, 프롬프트를 둘로 갈랐습니다. 모든 장비사에 통하는 범용 지침은 노드 프롬프트(코드)에, 장비사 고유의 지식은 기준선 스키마의 힌트(데이터)에 둡니다. 힌트는 장비사별 JSON 파일에 스텝 단위로 저장되고, 각 에이전트의 프롬프트에는 자기 장비사·자기 스텝의 힌트만 주입됩니다.
장비사 기준선 스키마 파일(definitions/{systemCode}.json)의 힌트는 이런 식입니다.
{
"hints": {
"search": "화면 상단에 '30초 후 검색 초기화' 체크박스가 있습니다. 이 기능이 켜져 있으면 검색 결과가 30초 후 사라지므로, 검색 전에 꺼진 상태여야 합니다."
}
}
이 힌트들은 결국 사람이 그 장비사 웹을 다루며 몸으로 배운 지식입니다. "이 사이트는 차량번호를 번호판 모양 버튼으로 고른다" 같은 건 어떤 범용 프롬프트도 스스로 알아낼 수 없거든요. 이 방식으로 정확도가 크게 올라갔습니다.
물론 공짜는 아닙니다. 힌트는 장비사 웹이 바뀌면 함께 낡습니다. 지금은 장비사 UI가 바뀌어 배치 코드를 수정할 때 개발자가 스크립트로 기준선 스키마·힌트를 함께 갱신하고 있고, 배치 코드가 수정되면 자동으로 따라 갱신되는 파이프라인을 준비하고 있습니다.
마지막 방어선은 사람
PR 생성까지 자동화하고 나면 마지막 질문이 남습니다. 어디까지 맡길 것인가. 저희가 그은 선은 하나입니다. 에이전트는 절대 코드를 배포하지 않습니다. PR 생성 도구는 Draft를 기본값으로 두고 프롬프트의 첫 번째 원칙으로도 못 박아서, PR은 Draft로만 생성됩니다. 리뷰와 머지는 사람의 몫입니다. 이른바 HITL(Human-in-the-Loop)인데요. 흔한 안전 장치처럼 들리지만, 사실 이 선이 시스템 전체의 속도 설계이기도 합니다.
- 복구는 빠르게 — 할인권을 다시 적용하는 건 에이전트가 즉시, 자동으로 합니다. 고객 피해가 진행 중이니까요.
- 수정은 신중하게 — 코드 변경은 Draft PR로 멈춰 섭니다. 잘못된 코드가 머지되면 50여 개 장비사 배치가 한꺼번에 영향을 받을 수 있으니까요.
그리고 이 PR의 근거 데이터에도 같은 원칙 — LLM의 자기 보고를 믿지 않는다 — 이 적용됩니다. PR에 담기는 "현재 화면의 selector"는 LLM에게 "아까 뭘 클릭했었지?"라고 물어본 값이 아니라, 복구 과정에서 에이전트가 실제로 클릭하고 입력할 때 도구 wrapper가 그 순간 기록해 둔 값입니다. compare 에이전트는 이 실행 기록을 기준선 스키마와 비교해 diff를 만들고, 그 diff가 PR이 됩니다.
이 PR이 고치는 범위는 배치 코드까지입니다. 기준선 스키마와 힌트의 갱신은 앞서 말한 대로 개발자의 스크립트 몫이라, 머지 후 갱신 전까지는 compare가 같은 diff를 또 볼 수 있습니다. 다만 복구 케이스가 사이트당 하나만 활성화되는 구조여서, 같은 보고가 케이스를 도배하는 게 아니라 열려 있는 케이스에 합류합니다.
PoC 결과, 그리고 실서비스로
2주 PoC은 판매량이 가장 큰 장비사 한 곳을 대상으로 진행했습니다. 검증은 Datadog에서 추출한 실제 장애 186건으로 했는데, 사이트에 들어가 장애를 재현하는 방식이 아니라 장애 당시의 로그를 정답지 삼아 저자 두 명이 직접 라벨링하고 에이전트의 판정과 대조하는 오프라인 채점이었습니다.
이때 본 지표는 복구 성공률이 아니라 실패 원인 분류였습니다. 원인은 여섯 갈래로 나눴는데요. 로그에서 식별한 세 가지 — 입주사 할인과 중복 적용 불가, 할인권 체크 타이밍 이슈, 월정기권 등록 차량 — 와 경험적으로 잡은 세 가지 — 장비사 서버 장애, 장비사 서버 변경, 휴먼 에러(데이터 오류) — 입니다. 이 분류는 이후 에이전트가 조치를 결정할 수 있는 기술적 축으로 재편되어, 앞서 출력 스키마에 나온 여섯 값짜리 category enum이 됐습니다. 입주사 할인 중복 같은 도메인 케이스는 데이터 오류로, 서버 장애·변경은 일시적 오류나 UI/API 변경으로 흡수되는 식입니다. 186건 전부가 저자 라벨과 일치하게 분류됐습니다. 문제의 규모와 PoC의 성적표를 나란히 놓으면 이렇습니다.
PoC 기간에 변경점 감지로 자동 생성된 Draft PR은 1건이었는데, 실제 코드 변경이 필요했던 사례가 그 한 건뿐이었기 때문입니다. '5분 이내'는 할인권 재적용(고객 영향 차단) 기준이고, 코드 수정까지는 Draft PR 리뷰를 거칩니다. 물론 장비사 한 곳, 186건 표본 안에서의 수치입니다.
이 결과를 근거로 50여 개 장비사 전체로 확대하기로 결정됐습니다. 다만 앞서 소개한 장치가 모두 이 2주 안에 나온 것은 아닙니다. 상당수는 지금부터 이야기할 운영 기간에 다듬어졌고, PoC 시점의 시스템은 그 원형이었습니다. PoC과 실서비스는 완전히 다른 문제였습니다. 여기서부터는 그 이야기입니다.
운영하며 드러난 것들
4월 말 확대 배포 후 두 달여간 에이전트가 실제로 복구한 — 실패한 할인권을 다시 적용한 — 건수는 7,267건입니다. 그런데 두 달의 운영이 남긴 건 이 숫자만이 아니었습니다.
배치의 민낯
에이전트를 실서비스에 올리고 나서 예상 못 한 것이 하나 있었습니다. 실패 이벤트를 전수로 뜯어보기 시작하니, 장비사 웹이 바뀐 것보다 저희 배치 코드 문제가 먼저 튀어나왔습니다.
몇 가지만 예로 들면 이렇습니다. 제일 흔했던 건 "실패가 아닌 실패"였습니다. 할인권이 이미 적용돼 있는 차량에 배치가 또 적용을 시도하면, 장비사 웹이 "이미 적용된 할인권입니다" 팝업을 띄웁니다. 배치는 이걸 실패로 기록합니다. 그런데 사용자 입장에선 아무 문제가 없는 상태죠. 에이전트가 들어가 보니 멀쩡히 적용돼 있어서, discount 에이전트에 "이미 적용됨 팝업은 성공으로 처리"라는 지침을 넣고 이런 건은 복구 성공으로 집계했습니다. 실패 통계의 일부는 처음부터 실패가 아니었던 셈입니다.
에러 메시지도 문제였습니다. 에이전트가 장애 원인을 분류할 때 1순위 근거로 삼는 게 배치가 남긴 에러 메시지인데, 막상 열어보면 "할인 적용 실패" 딱 이 정도인 경우가 많았습니다. 어느 단계에서 뭘 하다가 사이트가 뭐라고 답했는지가 없으니, LLM이 아니라 사람이 봐도 원인을 알 수 없는 로그였습니다. 분류 정확도를 올리는 제일 빠른 길이 프롬프트 튜닝이 아니라 배치 에러 메시지를 고치는 일이었던 적이 여러 번입니다.
생각해보면 당연한 일입니다. 그동안 배치의 실패는 "환불하면 되는 것"으로 처리됐지, 한 건 한 건 왜 실패했는지 들여다본 사람은 없었으니까요. 에이전트는 복구 시스템이기 전에, 그 실패를 처음으로 전부 들여다보게 만든 시스템이기도 했습니다. 이게 어디로 이어졌는지는 마지막에 다시 얘기하겠습니다.
장애 없는 장애를 찾아내다
운영하면서 에이전트가 제일 값어치를 한 순간은, 정작 아무 장애도 없던 날이었습니다.
어느 날 에이전트가 "API가 바뀐 것 같다"는 보고를 올렸습니다. 대상은 전체 주차권의 4분의 1쯤을 맡고 있는 대형 장비사 A사. 그런데 이상했습니다. 배치 대시보드는 멀쩡히 초록불이었고, 터진 것도 없었습니다. 에이전트를 부른 건 A사에서 난 일시적인 실패 한 건이었는데, 복구는 금방 끝났고, 그 뒤에 늘 돌리는 변경점 비교(compare)에서 이 보고가 나온 것이었습니다.
열어보니 이랬습니다.
- A사의 실제 관리자 웹은 이미 v2 엔드포인트를 호출하고 있었습니다
- 저희 배치는 여전히 v1 API를 쓰고 있었습니다 — v1이 아직 호환용으로 살아 있었을 뿐입니다
- v1이 언제 닫힐지는 장비사만 알고 있었습니다. 공지는 없었고요
에러는 없지만 언제 터질지 모르는 폭탄이 박혀 있던 셈입니다. 어느 날 예고 없이 v1이 닫혔으면 그 해 제일 큰 장애가 됐을 겁니다. 에러가 나야 알림이 오는 기존 모니터링은 이걸 잡을 수 없습니다. 에이전트가 잡을 수 있었던 건 보는 방식이 달랐기 때문입니다. 실행할 때마다 실제 웹의 지금 모습(화면 구조, 네트워크 호출)을 기준선 스키마와 비교하니까요.
솔직히 말하면 운도 따랐습니다. compare는 실패 이벤트가 에이전트를 깨울 때만 함께 돌기 때문에, 실패가 한 건도 없는 장비사에서 조용히 진행되는 변경은 아직 잡지 못합니다. 그래도 이 일로 에이전트에게 역할이 하나 더 생겼습니다. 복구하려고 만들었는데, 돌려보니 감시도 겸하고 있던 것입니다. 이 건도 결국 에이전트가 diff까지 붙여 올린 Draft PR을 사람이 확인하고 그대로 배포하는 것으로 끝났습니다. 감지도 에이전트가 했고, 고칠 코드도 에이전트가 썼습니다.
에이전트가 변경점을 감지해 자동으로 올린 실제 PR입니다. 변경 원인과 diff 근거, 확신도까지 에이전트가 썼고, 사람이 확인한 뒤 그대로 머지됐습니다. author가 사람 계정인 건 당시 에이전트가 개발자의 토큰을 빌려 쓰던 시절이라 그렇습니다 — 지금은 전용 GitHub App으로 PR을 올립니다.
스케일아웃 대신 동시 실행 제한
처음 배포한 구조는 단순했습니다. 파드 하나에 브라우저 하나. 실패 이벤트가 오면 브라우저를 띄워서 복구하고, 끝나면 다음 걸 처리하는 직렬 구조였습니다. 평소엔 아무 문제 없었습니다.
문제는 장애가 몰릴 때였습니다. 장비사 웹 하나가 바뀌면 그 장비사 주차장 수십 곳에서 실패가 한꺼번에 쏟아집니다. 평소엔 놀다가 장애 순간에만 확 몰리는 워크로드죠. 파드를 늘리는 스케일아웃으로 버틸 수도 있지만, 브라우저 하나 더 띄우자고 파드를 통째로 늘리는 건 낭비고 반응도 느립니다.
그래서 파드 하나 안에서 여러 작업이 같이 돌게 바꿨습니다. 브라우저 인스턴스(Playwright MCP 프로세스)를 필요할 때마다 만들어 각 LangGraph 실행에 넘겨주는 식으로요. 그랬더니 바로 다음 문제가 터졌습니다. OOM이요. 그것도 한가한 주말 낮에. 실패가 동시다발로 들어오니 브라우저가 그 수만큼 떠서 메모리가 차올랐고, 파드가 죽기 시작했습니다.
해결은 세마포어였습니다. 동시에 떠 있을 수 있는 브라우저 수에 상한을 걸고, 넘치는 요청은 대기 큐에서 자리가 날 때까지 기다리게 했습니다. 너무 오래 기다리지 않도록 타임아웃도 뒀고요. 실제 코드에서 안전 처리를 걷어내고 뼈대만 남기면 이런 모양입니다.
// 개념적으로 줄인 코드
async acquire(): Promise<BrowserInstance> {
if (this.activeCount < this.maxSize) {
this.activeCount++ // 생성을 기다리는 사이 다른 요청이 상한을 뚫지 못하도록 자리부터 선점
try {
return await this.createInstance()
} catch (error) {
this.activeCount--
throw error
}
}
// 상한 도달 → 대기 큐 (타임아웃 포함)
return new Promise((resolve, reject) => { /* waitQueue */ })
}
async release(instance: BrowserInstance): Promise<void> {
await instance.close()
this.activeCount--
// 대기 중인 요청이 있으면 새 인스턴스를 만들어 넘긴다
}
한 가지, 이건 브라우저를 만들어두고 재사용하는 풀이 아닙니다. 작업마다 새로 띄우고 끝나면 바로 닫습니다. 오래 살려두면 메모리 누수와 페이지 핸들 손상이 쌓이거든요. 그래서 여기서 관리하는 건 브라우저 인스턴스가 아니라 "동시에 몇 개까지 띄우느냐" 하나이고, 매번 새 브라우저라 이전 작업의 쿠키나 상태가 남지 않는 건 덤입니다.
세마포어가 리소스의 상한이라면, 그 앞단에는 유입을 거르는 필터가 두 겹 더 있습니다. 배치는 같은 실패 이벤트를 10분 주기로 재발행하기 때문에, 그대로 두면 에이전트가 같은 티켓을 한 시간에 여섯 번 복구하러 들어갑니다. 그래서 티켓당 시도를 1시간에 1회로 묶는 마커를 둡니다. 또 장비사 웹 하나가 죽으면 그 현장의 티켓 수십 장이 같은 이유로 실패하므로, 현장(주차장) 단위 cooldown 1시간을 함께 겁니다. 같은 원인으로 실패할 게 뻔한 실행에 브라우저와 LLM을 태우지 않는 거죠. 현장 cooldown에 막혀 이번 회차를 건너뛴 티켓은 마커를 반납해서, 다음 재발행 때 기회를 잃지 않게 합니다.
재미있는 건 cooldown의 해제 조건입니다. 복구에 성공했거나, 분석 결과의 category가 현장 문제가 아니라 티켓 단위 문제(데이터 오류·일시적 오류)로 나오면 cooldown을 즉시 풀어 같은 현장의 다음 티켓을 바로 받습니다. structured output으로 받은 분류값이 라우팅에만 쓰이는 게 아니라, 비용 방어 정책의 스위치로도 쓰이는 셈입니다.
마치며
AI 프로젝트의 절반은 AI가 아니었습니다
시작할 때 저희가 그린 그림은 "장비사 웹이 바뀌면 알아서 고치는 AI"였습니다. 그건 어느 정도 됐습니다. 복구는 자동으로 돌고, 원인도 분류돼서 올라오고, 코드 고치는 PR까지 열립니다.
그런데 프로젝트 막바지에 커밋 로그를 보다가 웃음이 났습니다. 에이전트 코드보다 기존 할인권 배치를 고친 커밋이 더 많았습니다. 실패를 전수로 들여다보니 배치 문제가 줄줄이 나왔고, 그걸 고치는 게 복구율 올리는 제일 빠른 길인 경우가 많았거든요. AI 하러 갔다가 정작 저희 코드부터 손보고 온 셈입니다.
그래서 이 글에서 딱 하나만 가져가신다면, 이것이었으면 합니다.
코드로 풀 수 있는 건 코드로 풀고, AI에게는 AI만 할 수 있는 일을 맡기세요.
글머리에서 저희가 찾은 답은 AI를 "작게 가두는 것"이라고 했는데, 가두고 남은 자리를 채운 것이 바로 코드였습니다. 워크플로우는 코드가 고정하고, 분석이 무한히 도는 건 종료 조건이 끊고, 비밀번호는 클로저가 가리고, 폭주는 세마포어가 잡습니다. 그 안에서 LLM이 실제로 한 일은 화면을 보고 사람처럼 판단하는 것 하나인데, 그건 LLM 말고는 못 하는 일이었습니다. 반대로 재시도나 검증, 격리, 제한을 LLM에게 부탁하고 있다면, 그건 십중팔구 코드가 할 일입니다.
데모와 프로덕션의 차이도 여기 있었습니다. 데모에선 LLM이 주인공이지만, 프로덕션에선 잘 짜인 시스템의 부품 하나입니다. 그리고 부품이 된 LLM이, 주인공이던 LLM보다 훨씬 믿을 만했습니다.
50여 개의 외부 웹은 여전히 예고 없이 바뀝니다. 다만 이제는 바뀌는 순간 에이전트가 먼저 알아차리고, 먼저 고쳐두고, 사람은 마지막 판단만 합니다. 저희처럼 "이건 어쩔 수 없지" 하고 덮어둔 문제가 있다면, 이 기록이 조금이나마 참고가 되면 좋겠습니다.


