
AI 에이전트를 위한 가드레일 — LLM과 실제 자금 사이의 5가지 결정론적 고리
요약
LLM 에이전트의 예기치 않은 행동을 방지하기 위해 프롬프트가 아닌 아키텍처 중심의 가드레일 설계 방식을 제안합니다. 결정론적 흐름, MCP 계약, 상태 머신 등을 활용하여 LLM이 우회할 수 없는 5단계의 안전 계층을 구축하는 방법을 다룹니다.
핵심 포인트
- 프롬프트는 제안일 뿐이며, 진정한 가드레일은 LLM이 우회할 수 없는 아키텍처여야 함
- 대화, 신원, 소유권, 상태 머신, 멱등성 쓰기를 통한 계층적 방어 체계 구축
- LangGraph와 MCP를 활용한 프로덕션급 에이전트 설계 패턴 제시
- LLM의 도구 호출(tool call) 오류를 방지하기 위한 결정론적 고리 설계
프롬프트(Prompts)는 제안입니다. 가드레일(Guardrails)은 아키텍처(Architecture)입니다. 대출 실행 에이전트가 결정론적 흐름(deterministic flow), MCP 계약, 소유권 게이트(ownership gates), 순수 상태 머신(pure state machine), 그리고 멱등성 쓰기(idempotent writes)를 어떻게 계층화하여 LLM이 틀리더라도 안전하게 작동할 수 있게 하는지에 대하여.
모든 "폭주하는 에이전트"에 대한 사후 분석은 동일한 형태를 띱니다. LLM이 예상치 못한 행동을 했고, 그 예상치 못한 행동과 결과 사이에는 아무런 장치가 없었다는 것입니다. 프롬프트에는 _"고객이 수락한 후에만 지급하십시오"_라고 적혀 있었지만, 프롬프트가 패배한 것입니다.
해결책은 더 나은 프롬프트를 만드는 것이 아닙니다. 다음과 같은 설계 규칙을 받아들이는 것입니다:
가드레일은 LLM이 이를 우회할 수 없을 때에만 진정한 가드레일입니다. 모델이 도구 호출(tool call)의 문구를 다르게 표현함으로써 건너뛸 수 있는 모든 것은 통제(control)가 아니라 관례(convention)일 뿐입니다.
이 글에서는 프로덕션급 대화형 대출 에이전트 — LangGraph 에이전트, MCP 도구 계층, Node 오케스트레이터, 뱅킹 코어(banking core) — 의 가드레일 스택을 외부에서 내부로 살펴보겠습니다. 전체 시스템은 docker compose up으로 오프라인에서 실행되며, 가드레일은 이를 공격하는 엔드 투 엔드(end-to-end) 테스트를 통해 검증됩니다.

순서대로 다이어그램을 읽어보겠습니다:
- 제안 (The proposal) — LLM이 명령을 제안합니다 — «대출금을 지급하십시오». 여기서부터 LLM은 더 이상 권한이 없습니다. 이 제안은 각각 변경 이유가 다른 서로 다른 모듈이 소유한 5개의 게이트(gate)를 통과해야 합니다.
- 게이트 1 — 대화 (conversation) — 슬롯(slots)이 유효하고 조건이 수락되었습니까? 그렇지 않다면, 답변은 "계속 대화하기"입니다. 즉, 흐름은 정보 수집(intake) 단계에 머물며, 라우터(router)가 잘못 읽은 짧은 후속 질문 때문에 결코 앞으로 나아가지 않습니다.
- 게이트 2 — 신원 (identity) — 이 호출자가 본인의 당사자로서 행동하는 것이 증명되었습니까? 검증 가능한 토큰(token)이 없다면 여정은 시작되지 않습니다. 게이트는 401을 반환하며 아무것도 기록되지 않습니다.
- 게이트 3 — 소유권 및 규칙 (ownership & rules) — 본인의 대출입니까, 당사자가 여전히 유효합니까, 정책을 통과합니까? 여기서 "아니오"가 나오면 403, 404 또는 422가 발생합니다. 이는 예외(exception)가 아니라 기계가 읽을 수 있는 거절(refusal)입니다.
- 게이트 4 — 상태 머신 (state machine) — 이것이 현재 법적으로 허용된 단계입니까? 순수한 XState 머신은
canFire(state, DISBURSE)로 응답합니다. 순서가 맞지 않으면 409를 받게 됩니다. 모델은 대화만으로 라이프사이클(lifecycle)을 무시하고 넘어갈 수 없습니다. - 게이트 5 — 멱등성 (idempotency) — 이 정확한 명령이 실행되는 첫 번째 시점입니까? 중복된 명령은 돈을 두 번 보내는 대신 첫 번째 답변을 재현합니다. 두 번 물어도, 지급은 한 번만 이루어집니다.
- 자금 이동 (Money moves) — 다섯 번의 "예"가 모두 충족되어야만, 정확히 단 한 번 자금이 이동합니다. 자금으로 향하는 하나의 직선 도로이며, 다섯 곳에서 멈출 수 있고 오직 한 곳에서만 완료될 수 있습니다.
- 모든 "아니오"는 데이터입니다 — 그리고 모든 거절은 코드와 함께 채팅에서 설명할 수 있는 이유가 되어 에이전트에게 돌아갑니다. 결코 예외(exception)가 아니며, 결코 기록(write)되지 않습니다. LLM은 틀릴 수 있지만, 안전합니다.
원문 기사에는 읽음에 따라 각 단계를 밝혀주는 대화형 스크롤 기반 다이어그램 버전이 있습니다.
하나의 제안, 다섯 개의 예/아니오 게이트, 하나의 되돌릴 수 없는 행동. "예"는 명령을 자금에 한 단계 더 가깝게 이동시키고, 모든 "아니오"는 데이터로 돌아옵니다. 즉, 코드와 함께 에이전트가 채팅에서 설명할 수 있는 이유가 되어 돌아오며, 결코 예외(exception)가 아니며 결코 기록(write)되지 않습니다. 각 게이트는 변경 이유가 서로 다른 별개의 모듈이 소유합니다. 이제 외부에서 내부로 하나씩 살펴보겠습니다.
Ring 1 — 중요한 지점에서 에이전트 자체가 결정론적(deterministic)임
LangGraph 에이전트는 대출 도구(loan tools)에 대해 자유로운 ReAct 루프를 실행하지 않습니다. 대출 라이프사이클은 결정론적이며, 턴 재개(turn-resumed) 방식의 인테이크 노드(intake node)입니다. LLM은 의도 분류(intent classification) 제안과 도움말 텍스트 문구 작성이라는 두 가지 좁은 작업으로 격리됩니다. 두 작업 모두 흐름을 진행시킬 수는 없습니다.
가장 교육적인 부분은 스티키 인테이크(sticky intake) 라우팅 가드레일입니다:
"""
상태(state)와 라우터 노드에서 분류된 의도(intent)를 기반으로 경로를 지정합니다.
...
이것이 없다면, _"몇 개월인가요?"_라는 질문에 "24"라고 답하는 고객은 잡담(small talk)으로 오분류되어 흐름이 탈선하게 됩니다. 이 가드레일이 있으면, 여정이 시작된 이후에 라우터의 의견은 권고 사항(advisory)이 됩니다.
인테이크 노드는 또한 도구 호출(tool call) 전에 실제 정책(policy)에 따라 슬롯(slots)을 사전 검증합니다. 여기서 가져올 만한 가치가 있는 분해 상세 내용은 다음과 같습니다. 에이전트와 오케스트레이터(orchestrator)가 _동일한 공유 정책 함수(shared policy function)_를 호출하기 때문에, 대화형 확인과 권위 있는 확인이 결코 서로 어긋날 수 없습니다:
// 순수 정책 확인(Pure policy check). 에이전트와 오케스트레이터가 동일한(IDENTICAL) 규칙을 적용하도록 공유됩니다.
// 사람이 읽을 수 있는 위반 사항 목록을 반환하며(빈 목록 = 허용됨), 이를 통해 오케스트레이터는
// BUSINESS_RULE_VIOLATION 상세 정보에 넣을 수 있고 에이전트는 이를 대화로 전달할 수 있습니다.
...
마지막으로, 에이전트는 승인 전 개인화된 고지 사항(disclosures)과 명시적인 "수락 / 취소" 게이트를 보여줍니다. 이는 고객의 정확한 수치를 결정론적 템플릿으로 렌더링한 것이며, 의도적으로 검색(retrieval) 방식을 사용하지 않았습니다. LLM이 의역(paraphrase)한 고지 사항은 변호사가 승인할 수 없는 고지 사항입니다.
Ring 2 — MCP 레이어: 신뢰할 수 없는 호출자를 위해 설계된 계약
FastMCP 서버(loan-mcp)는 에이전트 지향적 설계가 구현되는 곳입니다. 독스트링(docstrings)은 순서 힌트("이것을 먼저 호출하십시오", "accept_terms 이후에만 법적 절차 진행")를 포함하며, 결정적으로 에러는 예외(exception)가 아니라 데이터(data)입니다:
"""
성공 시 도구(Tools)는 오케스트레이터(orchestrator)의 가치 객체(value object)를 통과합니다. 실패 시에는 예외(exception)를 발생시키는 대신 {"error": <RFC 9457 problem>}을 반환하여 에이전트가 ...
모델에 구조화된 데이터(structured data)로 전달되는 거절은 하나의 _대화적(conversational)_ 이벤트가 됩니다: "아직 자금을 집행할 수 없습니다 — 약관에 동의하지 않으셨습니다." 반면, 예외(exception)로 소멸해버리는 거절은 중단된 채팅이 됩니다.
이 계층은 또한 **신원 접합부(identity seam)**이기도 합니다: `LOAN_MCP_AUTH_ENFORCED=true` 설정 시, 검증 가능한 토큰이 없으면 여정(journey) 자체가 시작되지 않으며, 유효한 토큰은 검증된 이메일로부터 확인된 _호출자 본인(caller's own)_의 당사자(party)와 해당 여정을 결합합니다. 실패 시 차단(Fail closed) 방식입니다:
"""
- 토큰 없음 + 강제 적용(enforcement) ON -> 거절됨 (401): 구성된 스택(composed stack)이 실패 시 차단되므로, 증명되지 않은 신원에 대해서는 여정이 절대 열릴 수 없습니다.
...
## Ring 3 — 오케스트레이터의 게이트: 소유권, 당사자, 규칙
오케스트레이터의 REST 에지(edge)에 도달하는 모든 명령은 상태 머신(state machine)에 대한 질문이 던져지기도 전에 _순서대로_ 세 가지 게이트를 통과합니다:
1. **소유권 (Ownership)** — 전달된 토큰은 _독립적으로 재검증(independently re-verified)_되며(loan-mcp와는 의도적으로 분리된 두 번째 구현체임), 토큰에서 유도된 당사자(party)는 조작 대상인 `customerRef`와 일치해야 합니다. 에러는 의도적으로 포괄적(coarse)으로 처리됩니다. 상세 정보는 로그로 전송되며, 공격자는 아무것도 알 수 없습니다.
2. **당사자 게이트 (Party gate)** — 당사자는 반드시 존재해야 하며 활성 상태여야 합니다. 삭제된(암호학적으로 파기된, crypto-shredded) 당사자는 422를, 알 수 없는 당사자는 404를 반환합니다. 만약 party-api에 접속할 수 없는 경우, 게이트는 **503 — 실패 시 차단(fail closed)**을 반환하며, 결코 "문제가 없다고 가정"하지 않습니다.
3. **규칙 게이트 (Rules gate)** — 권위 있는 정책 확인(Ring 1과 동일한 공유 함수)을 수행하며, 사람이 읽을 수 있는 위반 사항과 함께 422를 반환합니다.
트레이드오프(trade-off)는 명시적이며 이름이 지정되어 있습니다: `ORCH_AUTH_MODE`는 `off` / `verify-when-present` / `require` 모드로 실행됩니다. 구성 스택(compose stack)은 토큰이 없는 E2E(end-to-end) 및 내부 도구들이 계속 작동할 수 있도록 `verify-when-present`를 사용하지만, 실제 배포 환경에서는 `require`를 실행합니다. 핵심은 보안 태세(posture)가 미들웨어의 우연한 결과가 아니라, 설정에 따른 _결정(decision)_이라는 점입니다.
Ring 1과의 비대칭성에 주목하십시오: **신원(identity) 및 정책 의존성(policy dependencies)은 실패 시 차단(fail closed)되지만, 대화형 도우미(conversational helpers)는 실패 시 허용(fail open)됩니다.** 에이전트의 초안 확인(draft check)은 "오케스트레이터(orchestrator)가 생성 시 여전히 권위 있게 강제한다"는 점 덕분에 우아하게 성능이 저하(degrade gracefully)되지만, 오케스트레이터의 게이트는 결코 저하되지 않습니다. 그 뒤에는 아무것도 없기 때문입니다.
## Ring 4 — 순수 상태 머신(pure state machine): "이것이 발생해도 되는가"에 대한 유일한 권위
시스템의 핵심은 금욕적일 정도로 순수한 XState 머신입니다:
// 대출 취득 라이프사이클을 순수하고(PURE) 결정론적인(deterministic) XState 머신으로 구현합니다.
// 이것이 가드레일(guardrail) 역할을 합니다: 현재의 지속 가능한 상태(durable state)와
// 후보 이벤트(비결정론적인 LLM에 의해 제안되거나, loans-api 도메인 이벤트로부터 투영된 이벤트)가 주어지면,
// 이 머신은 "이것이 ..."
`disburse` 핸들러는 "계층적 거부(layered refusal)"가 실제로 어떻게 작동하는지 보여줍니다. 각 확인 단계는 고유한 상태 코드(status code)를 가지므로, 호출자는 정확히 _어느_ 링(ring)이 거부했는지 알 수 있습니다:
if (!canFire(current.currentState, { type: 'DISBURSE' })) {
return fail('STATE_TRANSITION', cannot disburse from state ${current.currentState}, 409);
}
이 머신은 순수하며 아무것에도 의존하지 않기 때문에, 동일한 적법성 함수(legality function)가 **세 가지** 호출자에게 서비스를 제공합니다: 동기식 명령 핸들러(synchronous command handlers), SQS 백스톱(backstop)의 투영 적용기(projection applier, 이를 통해 순서가 잘못된 재생 이벤트가 적용되지 않고 _건너뛰어짐_), 그리고 상태를 건드리지 않고 분류기(classifier)에서 가드레일(guardrail)로 이어지는 정밀도(precision)를 측정할 수 있게 해주는 비변이(non-mutating) `/signal` 확인 엔드포인트입니다.
## Ring 5 — loans-api: 적법한 명령이라도 반복 실행 시 안전해야 함
가장 안쪽의 링은 그 위의 모든 단계가 실패했다고 가정합니다. 기록 시스템(system of record)은 모든 쓰기 작업을 멱등성 엔벨로프(idempotency envelope, 도메인 변경과 동일한 트랜잭션)로 감싸며, 관계형 벨트 앤 서스펜더스(relational belt-and-suspenders, 이중 안전장치) 확인을 추가합니다. 즉, 지급(disburse)되는 수락 건이 실제로 활성 시뮬레이션에 속해 있어야 합니다. 재시도되거나 중복된 명령은 기록된 응답을 재현할 뿐이며, 절대 두 번 쓰지 않습니다.
다섯 가지 고리 아래에는 내구성 (durability)이 자리 잡고 있습니다. 제어 기록 (Control Record)은 Postgres 상의 DBOS를 통해 영속화되며, **버전 비교 및 스왑 (version compare-and-swap, CAS)과 이벤트별 중복 제거 (per-event dedup)**를 통해 고도화됩니다. 버전-CAS의 상세 내용은 그 자체로 하나의 전쟁 이야기입니다. 과거의 CAS는 `state`를 키로 사용했는데, 재시뮬레이션은 자기 루프 (self-loop) — `simulated → simulated` — 이기 때문에 CAS가 오래된 행과 조용히 일치해 버리는 문제가 있었습니다. 단조로운 (Monotonic) `version`이 이를 해결했습니다. 만약 당신의 상태 머신 (state machine)에 자기 루프가 있다면, 상태 기반 CAS는 아직 만나지 못한 버그가 될 것입니다.
## 공격의 엔드 투 엔드 (end to end) 과정
다섯 가지 고리에 대한 전체 상세 내용 — 누가 무엇을, 어떤 순서로 확인하는지, 그리고 정상 경로 (happy path)와 거부 경로 (rejected path)가 어떻게 구성되는지 설명합니다:
[](https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbttcgwc964zwcbzq1x5s.png)
창의적인 LLM, 호기심 많은 테스터, 혹은 직접적인 REST 호출과 같은 무언가가 여정이 여전히 `simulated` 상태인 동안 `disburse`를 시도하면 어떤 일이 발생할까요?
1. `canFire('simulated', DISBURSE)` → `false`. **아무것도 변경되지 않으며, loans-api는 호출되지 않습니다.**
2. 오케스트레이터 (orchestrator)는 RFC 9457 `application/problem+json` 형식으로 상태 코드 409, `STATE_TRANSITION`을 반환합니다.
3. loan-mcp는 이를 값으로서 그대로 전달합니다: `{"error": {code, status, title, …}}`.
4. 에이전트 (agent)는 코드를 읽고 대화하듯 설명합니다 — "먼저 약관을 검토해 봅시다."
5. 내구성 있는 상태 (Durable state)는 `simulated`로 유지되며, `acceptanceId`와 `disbursementId`는 null 상태로 남습니다.
이 경로의 모든 단계는 REST와 MCP 모두에 대해 E2E 테스트로 검증됩니다. 여기에는 MCP 도구 호출이 (에러를 던지는 대신) *해결 (resolves)* 된다는 점과 내구성 있는 기록이 건드려지지 않는다는 점이 포함됩니다. 공격을 시도하는 테스트가 없는 가드레일은 그저 희망 사항일 뿐입니다.
## 부드러운 외부 필터, 그리고 그것이 왜 부드러워도 되는가
5개의 고리 외부에는 모델 수준의 콘텐츠 필터링을 위한 Bedrock Guardrails 설정이라는 한 단계가 더 존재합니다. 이 설정의 거부 주제(deny-topics)는 유해 콘텐츠 필터 및 개인정보(PII) 마스킹과 더불어, _무허가 금융 자문_ ("이 대출금을 암호화폐에 넣어야 할까요?") 및 _대출 사기 및 회피_ ("자격을 갖추기 위해 소득을 어떻게 부풀릴 수 있을까요?")와 같이 특정 도메인에 특화되어 있습니다. 고객에게 보여지는 거절 문구는 단일 모듈 수준의 상수(constant)에 저장되므로, Bedrock으로 전송되는 가드레일 정의와 로컬 폴백(fallback) 메시지가 서로 어긋나는 일이 발생하지 않습니다.
내부의 다른 모든 요소와 달리, 이 필터는 오류 발생 시 _오픈 방식(fails open)_으로 작동합니다. 이는 이 필터가 권위 있는 제어 수단이 아니며, 결코 되어서도 안 되기 때문에 허용되는 방식입니다. 운영상의 참고 사항 하나는, 가드레일은 _이름(name)_을 통해 생성되거나 가져오기 때문에, 리포지토리(repo)에서의 정책 변경 사항은 가드레일이 재생성되거나 새 버전이 게시될 때만 기존 환경에 반영됩니다.
이것이 바로 고리 모델(ring model)의 진정한 교훈입니다. 내부의 고리들이 견고하기 때문에, 외부의 필터는 부드럽고 불완전해도 괜찮습니다. **되돌릴 수 없는 지점에 당신의 엄격함을 쏟으십시오.**
## 결론
5개의 고리는 단순히 중복을 위한 중복이 아닙니다. 각 고리는 서로 다른 비밀을 숨기고 있으며, 서로 다른 유형의 실수를 방지합니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기