LLM에게 주문을 맡기지 않았다 — 안전 경계를 프롬프트가 아니라 코드에 두기

주문 실행을 모델의 지침 준수에 맡기고 싶지 않았다

토스증권 Open API를 이용한 개인 자동매매 시스템을 만들면서, 뉴스와 누적 정보를 읽고 매매 규칙을 제안하는 역할에 대규모 언어 모델(LLM)을 활용하려 했다. 이 글은 최초 작성 당시의 설계와 검증을 다룬다. 당시 실제 LLM 연결은 보류했고, 추론 결과를 주입하는 인터페이스와 주문 실행 경로부터 구현했다. 토스증권 Open API

모델의 출력은 달라질 수 있고 외부 문서의 잘못된 정보나 지시에 영향을 받을 수도 있다. 반면 체결된 주문은 취소 요청만으로 없던 일이 되지 않는다. 반대 거래를 하더라도 가격과 수수료 등 조건이 달라진다. 나는 모델이 제안한 내용이 곧바로 주문 요청이 되는 연결을 피하고 싶었다.

“하루 한도를 넘기지 마”라는 프롬프트를 주는 것만으로는 한도 검사를 대체할 수 없다. 그래서 규칙 제안과 실행을 분리하고, 실제 주문 전에 코드가 조건과 한도를 확인하도록 했다. 잘못된 입력을 받았을 때 어디서 거부하는지 테스트할 수 있는 구조가 목표였다.

제안은 저장하고, 승인된 규칙을 코드로 평가한다

역할은 크게 두 부분으로 나눴다. 조언자는 뉴스를 읽고 누적 정보와 함께 추론기에 전달한 뒤 규칙을 제안한다. 엔진은 승인된 규칙의 조건을 평가하고, 주문 의도를 케이지에 보낸다. 케이지는 한도와 실행 모드를 검사하는 주문 관문이다.

추론기는 교체 가능한 인터페이스로 두었다. 기본 구현인 NullInferencer는 빈 제안을 반환하고, 테스트에서는 가짜 추론기를 주입했다. 따라서 당시 확인한 것은 실제 모델의 투자 판단이나 프롬프트 인젝션 대응 성능이 아니라, 주어진 제안이 저장소와 실행 경로를 어떻게 통과하는지였다.

도표 크게 보기

조언자의 출력은 proposed 상태로 저장된다. 사용자가 CLI에서 승인하면 같은 저장소의 규칙이 active가 되고, 엔진은 활성 규칙을 읽는다. 여기서 사람의 승인은 규칙을 활성화하는 승인이다. 활성화된 규칙이 충족될 때마다 주문을 사람이 다시 승인하는 구조는 아니다.

호출자 검사는 정해진 호출 경로를 지키는 장치다

규칙 저장소의 propose_rule은 Caller.ADVISOR를 받을 때만 제안을 만들고 생성 상태를 proposed로 고정한다. 초안에는 활성 상태를 지정하는 필드가 없다. transition은 Caller.USER_CLI를 받을 때만 상태 전이를 허용하며, APPROVE는 제안 상태를 활성 상태로 바꾼다.

아래는 이 API를 사용하는 흐름이다. rules는 규칙 저장소, draft는 검토할 규칙 초안이다.

# 조언자 경로: 생성 상태는 proposed로 고정된다.
rule_id = rules.propose_rule(draft, caller=Caller.ADVISOR)

# 사용자 CLI 경로: 현재 상태를 검사한 뒤 active로 전이한다.
rules.transition(
    rule_id,
    RuleAction.APPROVE,
    caller=Caller.USER_CLI,
)

조언자가 caller=Caller.ADVISOR로 승인을 호출하면 예외가 발생한다. 조언자 루틴이 승인 메서드를 호출하지 않는지도 테스트했다. 이 검사로 의도한 모듈 사용법을 검증할 수 있었다.

다만 Caller는 호출 코드가 넘기는 열거형 값이다. 실제 사용자를 인증하거나 프로세스의 권한을 확인하는 값은 아니다. 같은 API에 접근하는 코드가 Caller.USER_CLI를 넘길 수 있다면 이 검사만으로 사람의 승인을 증명할 수 없다. 따라서 “LLM이 자기 제안을 승인할 통로가 절대로 없다”는 설명은 과도하다. 임의 코드를 실행할 수 있는 에이전트까지 격리하려면 승인 수단과 저장소 쓰기 권한을 별도로 제한해야 한다.

조언자 모듈의 import 검사도 같은 관점에서 봐야 한다. 기존 테스트는 해당 파일의 추상 구문 트리(AST)를 읽어 금지된 모듈명이 직접 import에 있는지 검사한다. 코어를 참조하는 코드가 추가되는 실수를 잡지만, 전체 간접 의존성을 추적하거나 동적 import·주입 객체·파일 접근을 차단하지는 않는다. 의존성 규칙을 검사하는 테스트와 실행 시점의 권한 격리는 서로 다른 장치다.

조건을 평가할 때는 LLM을 호출하지 않는다

엔진이 읽는 규칙의 조건은 JSON으로 저장된다. 다음은 실제 투자 전략과 관계없는 설명용 조건이다. 정해진 날짜까지 남은 기간과 가격 조건을 함께 확인한다.

{
  "all": [
    {"type": "schedule", "date": "2026-09-15", "within_days": 3},
    {"type": "price", "op": "<=", "value": "70000"}
  ]
}

엔진은 JSON을 파싱하고 지원하는 조건을 평가한다. 파싱에 실패하거나 최상위 값이 객체가 아니면 조건 미충족으로 처리한다. 지원하지 않는 신호, 필요한 시세의 누락, 평가 중 예외도 해당 규칙의 주문으로 이어지지 않게 했다. 빈 all 목록이 참으로 평가되지 않도록 별도 검사도 뒀다.

여기서 결정론이라는 말은 같은 규칙·시각·시세·상태를 주면 같은 코드 경로로 평가한다는 뜻이다. 시세와 시각이 바뀌면 결과도 바뀐다. JSON 형식이 올바르다고 전략이 타당하거나 시장 데이터가 정확해지는 것도 아니다. 코드가 감지한 평가 실패를 거부하는 것과 모든 불확실성을 알아서 감지하는 것은 구분해야 한다.

주문 전에는 케이지를 거친다

이 글에서 다루는 규칙 엔진은 주문 의도를 OrderIntent로 만들고 케이지의 submit에 전달한다. 케이지는 중지 스위치, 허용 종목, 금액·횟수 한도, 중복 주문 여부 등을 검사한다. 검사를 통과해도 실행 모드가 LIVE가 아니면 주문 전송 단계로 넘어가지 않는다. 기본 모드는 판단만 수행하는 SHADOW다.

LIVE 경로에서는 주문 식별자를 예약하고 감사 기록을 남긴 뒤 게이트웨이를 호출한다. 저장 실패나 중복 예약을 처리하는 분기도 이 경로에 있다. 이런 장치는 정해진 실행 경로에서 오발주 위험을 줄이지만, 케이지를 우회해 게이트웨이를 호출할 권한까지 없애 주는 것은 아니다. 구체적인 중복 방지와 타임아웃 처리는 다음 편의 케이지 설명에서 다룬다.

승인과 주문 검사를 분리한 방향은 도구의 기능·권한을 최소화하고 필요한 승인 절차를 두라는 OWASP의 과도한 자율성 대응 원칙과도 맞닿아 있다. 다만 그 원칙을 충족했는지는 함수 이름이나 도표만으로 판단할 수 없고, 실제로 부여한 권한과 우회 경로까지 확인해야 한다. OWASP Excessive Agency

테스트로 확인한 범위와 남은 범위

당시 테스트 기록에는 447개 통과가 남아 있다. 이 숫자는 규칙 저장소와 주문 경로뿐 아니라 위키 등 다른 모듈을 포함한 전체 테스트 수다. 실서버와 실제 LLM을 연결한 거래 검증 건수로 읽어서는 안 된다. 런타임 패키지는 표준 라이브러리 중심으로 구성했고, 테스트 도구로는 pytest를 사용했다.

관련 테스트에서는 미승인 규칙 제외, 잘못된 호출자 값 거부, 평가 실패 시 무발주, SHADOW·DRY 모드의 전송 차단을 확인했다. 같은 규칙을 같은 거래일에 반복 평가하는 사례에서는 가짜 게이트웨이 호출이 한 번만 발생하는지도 봤다. 이는 검사한 키와 상태·시나리오에서의 결과이며, 모든 장애와 동시 요청에서 중복 주문이 불가능하다는 증명은 아니다.

결함을 일부러 넣어 테스트가 실패하는지 확인한 기록도 있다. 모드 검사를 무력화한 사례와 여러 중복 방지 장치를 함께 무력화한 사례를 구분해야 한다. 중복 방지 장치 하나를 없앴는데 테스트가 통과한 경우에는 다른 장치가 같은 주문을 막았다. 변이 테스트는 특정 결함을 잡는지 보여 주는 근거이며 전체 테스트의 완전성을 보장하지 않는다.

최초 작성 당시에는 SHADOW 단계였고 실주문 성과를 주장하지 않았다. 이 글의 성과는 제안·승인·평가·주문 경로를 나누고, 각 경계의 동작을 테스트할 수 있게 만든 데 있다. 임의 코드 실행이나 자격 증명 접근까지 허용한 LLM 에이전트를 가둔 샌드박스를 만든 것은 아니다.

학습

← 전체 글 목록