LLM 에이전트에 훅(Hook) 시스템 붙이기 — 코드를 고치지 않고 동작을 바꾸는 법

🌐 English

"모든 응답 끝에 면책 문구를 붙여줘." "exec 도구는 특정 팀만 쓰게 해줘." "주문이 나갈 때마다 슬랙에 알려줘."

요구 하나하나는 사소합니다. 문제는 이런 게 계속 들어온다는 것이고, 매번 파이프라인 코드에 if를 하나씩 심다 보면 몇 달 뒤 에이전트 코어가 특수 케이스 더미가 됩니다. 훅(hook)은 이걸 "코어는 이벤트만 쏘고, 확장은 밖에서 붙는다"로 뒤집습니다.

1. 어디에 이벤트를 뚫을 것인가

아무 데나 뚫으면 안 됩니다. 의미 있는 경계에만 둡니다. 에이전트 한 턴의 흐름을 따라가면 자연스럽게 후보가 나옵니다.

사용자 입력

   │

   ├─ user_prompt_submit   ← 입력이 파이프라인에 들어가기 전

   │

   LLM 호출 → 도구 호출 결정

   │

   ├─ pre_tool_use         ← 도구 실행 직전

   │  (도구 실행)

   ├─ post_tool_use        ← 도구 실행 직후

   │

   응답 생성 완료

   │

   ├─ pre_response         ← 채널로 전달되기 직전

   │

   세션 종료

   └─ stop

여기에 서브에이전트를 쓴다면 subagent_start / subagent_stop을 더합니다.

2. 차단형과 관찰형을 반드시 나눠라

이 구분이 훅 시스템 설계의 핵심입니다. 어떤 훅은 진행 여부를 결정해야 하고(그래서 파이프라인이 기다려야 하고), 어떤 훅은 그냥 구경만 합니다.

func (e HookEvent) IsBlocking() bool {

    switch e {

    case EventUserPromptSubmit, EventPreToolUse, EventSubagentStart:

        return true     // 동기 실행, allow/block 판정 필요

    default:

        return false    // 비동기 발사, 결과를 기다리지 않음

    }

}

왜 이렇게 갈라야 하냐면 지연 때문입니다. 모든 이벤트를 동기로 기다리면 훅 하나가 300ms만 걸려도 턴마다 그게 쌓입니다. 반대로 pre_tool_use를 비동기로 두면 차단이 아무 의미가 없습니다 — 막기로 결정했을 땐 이미 도구가 실행된 뒤죠.

그리고 차단형은 반드시 fail-closed여야 합니다.

const (

    DecisionAllow   Decision = "allow"

    DecisionBlock   Decision = "block"

    DecisionError   Decision = "error"

    DecisionTimeout Decision = "timeout"

)

func (d *Dispatcher) fire(ctx context.Context, ev Event, cfg HookConfig) Decision {

    if !ev.HookEvent.IsBlocking() {

        go d.runAsync(ev, cfg)      // 관찰형: 던지고 잊는다

        return DecisionAllow

    }

    ctx, cancel := context.WithTimeout(ctx,

        time.Duration(cfg.TimeoutMS)*time.Millisecond)

    defer cancel()

    dec, err := d.runSync(ctx, ev, cfg)

    if err != nil || dec == DecisionTimeout {

        return cfg.OnTimeout        // 기본값은 block

    }

    return dec

}

보안 훅이 응답을 못 했는데 "일단 통과"시키면, 훅 서버를 느리게 만드는 것만으로 보안이 무력화됩니다. 판단이 안 될 때는 막는 쪽이 맞습니다. 대신 on_timeout을 훅마다 설정할 수 있게 해서, 중요하지 않은 훅은 allow로 열어두면 됩니다.

체인이 여러 개일 때 규칙도 단순하게. 먼저 block한 훅이 이깁니다. 하나라도 막으면 끝이고, 나머지는 실행하지 않습니다.

3. 핸들러 네 종류

훅이 "무엇을 실행하는가"는 용도에 따라 다릅니다.

  • script — 샌드박스 안에서 도는 짧은 JS 조각. 가장 가볍고 빠릅니다. 파일·네트워크 접근을 막아둔 런타임(예: goja 같은 임베디드 엔진)에서 실행하고, 실행 시간 상한을 겁니다
  • command — 로컬 셸 명령. 이벤트 데이터를 stdin으로 넣고 종료 코드·stdout으로 결과를 받습니다. 기존 스크립트 자산을 그대로 재활용할 때 좋습니다
  • http — 외부 엔드포인트로 POST. 승인 시스템이나 사내 정책 서버에 물릴 때 씁니다. 대신 네트워크 지연이 그대로 턴 지연이 되니 차단형에 붙일 땐 타임아웃을 짧게
  • prompt — 이벤트를 LLM에게 물어봅니다. "이 요청이 정책 위반인가?" 같은 규칙으로 못 적는 판단에 씁니다. 느리고 비싸니 정말 필요한 곳에만

스크립트 핸들러의 계약은 이 정도면 충분합니다.

// 입력: event 객체, 출력: { decision, reason, updatedInput? }

function handle(event) {

  if (event.toolName === "exec") {

    var cmd = (event.toolInput.command || "");

    if (/rm\s+-rf|mkfs|dd\s+if=/.test(cmd)) {

      return { decision: "block", reason: "파괴적 명령 차단" };

    }

  }

  return { decision: "allow" };

}

4. 언제 실행할지 걸러내기 — matcher와 조건식

도구 훅이 등록되어 있다고 매번 다 돌리면 낭비입니다. 두 단계로 거릅니다.

{

  "event": "pre_tool_use",

  "matcher": "exec|write_file",      // 도구 이름 패턴 — 싸게 1차 필터

  "if_expr": "toolInput.path.startsWith('/etc')",  // 조건식 — 2차

  "timeout_ms": 2000,

  "on_timeout": "block",

  "priority": 10,

  "scope": "tenant"

}

matcher런타임 진입 전에 평가되므로 매우 쌉니다. 무거운 스크립트 엔진을 띄우기 전에 여기서 대부분을 걸러내세요. priority는 실행 순서인데, 차단 가능성이 높고 빠른 훅을 앞에 두면 체인 전체가 일찍 끝납니다.

scope는 global / tenant / agent 세 단계로 두는 게 실용적입니다. 플랫폼 차원의 안전장치는 global, 고객사 정책은 tenant, 특정 에이전트의 개성은 agent에.

5. 입력을 고치게 할 것인가 — 위험한 기능

훅이 allow/block만 하는 게 아니라 입력을 수정할 수 있으면 강력해집니다. 사용자 메시지에 컨텍스트를 덧붙이거나, 도구 인자의 위험한 옵션을 떼어내거나.

동시에 가장 위험한 기능이기도 합니다. 테넌트가 등록한 스크립트가 도구 인자를 마음대로 바꿀 수 있다면, 그건 사실상 코드 실행 권한입니다. 두 겹으로 막습니다.

// ① 출처가 신뢰 계층인 훅만 변조 허용

if res.UpdatedInput != nil {

    if cfg.Source != SourceBuiltin {

        log.Warn("hook.mutation_stripped", "hook_id", cfg.ID)

        res.UpdatedInput = nil       // 사용자 작성 훅은 변조 무시

    } else {

        // ② 허용된 필드만 반영 (allow-list)

        applyMutation(&ev, res.UpdatedInput, allowlistFor(cfg.ID))

    }

}

정리하면 차단은 누구나, 변조는 신뢰된 훅만, 그것도 화이트리스트 필드만입니다. 그리고 거부했을 때 조용히 넘어가지 말고 경고 로그를 남기세요 — 누가 무엇을 시도했는지가 곧 보안 신호입니다.

6. 응답 후처리 — 붙일 것인가 따로 보낼 것인가

pre_response 훅은 실무에서 가장 많이 쓰이는 축입니다. 이때 결과를 어떻게 반영할지 모드로 구분해두면 편합니다.

  • append — 원래 응답 뒤에 이어 붙입니다. 면책 문구, 출처 표기 같은 것
  • send — 별도 메시지로 보냅니다. 후속 알림이나 요약처럼 본문과 분리하는 게 나은 것
type FireResult struct {

    Decision       Decision

    AppendResponse []string   // 응답 뒤에 이어붙일 조각들

    ExtraMessages  []string   // 독립 메시지로 보낼 것들

}

7. 감사 로그 — 훅은 반드시 기록으로 남는다

훅은 보이지 않게 동작을 바꾸는 장치입니다. 기록이 없으면 "왜 이 요청이 막혔지"를 아무도 설명하지 못합니다.

CREATE TABLE hook_executions (

    id          TEXT PRIMARY KEY,

    hook_id     TEXT,          -- 훅 삭제 시 NULL (기록은 보존)

    session_id  TEXT NOT NULL,

    event       TEXT NOT NULL,

    input_hash  TEXT NOT NULL, -- 정규화 JSON의 sha256 (원문 대신)

    decision    TEXT NOT NULL,

    duration_ms INTEGER,

    dedup_key   TEXT,          -- (hook_id, event_id) 중복 방지

    error       TEXT,          -- 256자로 절단

    error_detail BLOB,         -- 상세는 암호화해서 보관

    created_at  TIMESTAMP

);

설계 포인트가 몇 개 있습니다. 입력 원문 대신 해시를 저장하면 개인정보를 남기지 않고도 "같은 입력이 반복됐는가"를 알 수 있습니다. 에러 상세에는 종종 민감한 값이 섞이므로 암호화해서 넣습니다. dedup_key는 재시도 때 같은 실행이 두 번 기록되는 걸 막습니다.

8. 실전 사례 셋

  • 위험 명령 차단pre_tool_use + script. exec 인자를 패턴 검사해 파괴적 명령을 block. 응답 시간 1ms 수준이라 항상 켜둘 만합니다
  • 주문 알림post_tool_use + http. 주문 도구가 실행될 때마다 슬랙 웹훅으로 밀어냅니다. 관찰형이라 지연이 안 붙습니다
  • 정책 문구 자동 첨부pre_response + command(append). 응답 끝에 고지 문구를 붙입니다. 프롬프트로 지시하면 LLM이 가끔 빼먹지만, 훅은 100% 붙습니다

마지막 사례가 훅의 본질을 잘 보여줍니다. 반드시 지켜야 하는 규칙은 프롬프트가 아니라 코드에 두세요. 프롬프트는 확률이고 훅은 보장입니다.

정리

  • 이벤트는 의미 있는 경계에만 — 입력 진입, 도구 전후, 응답 전달 직전, 종료
  • 차단형과 관찰형을 분리 — 전부 동기면 느려지고, 차단형이 비동기면 무의미하다
  • 차단형 타임아웃은 fail-closed(block) — 아니면 느리게 만드는 것만으로 뚫린다
  • 핸들러는 script / command / http / prompt — 무게와 용도가 다르다
  • matcher런타임 진입 전에 싸게 거르고, priority로 순서를 잡는다
  • 입력 변조는 신뢰된 훅 + allow-list 필드만, 거부는 경고 로그로 남긴다
  • 감사 로그 필수 — 입력은 해시로, 에러 상세는 암호화해서
  • 반드시 지켜야 하는 규칙은 프롬프트가 아니라 훅으로

훅 시스템을 붙이면 에이전트 코어가 다시 단순해집니다. 특수 케이스가 코어 밖으로 나가고, 코어는 "이벤트를 정확한 시점에 쏜다"는 한 가지 책임만 지죠. 확장 요구가 계속 들어오는 시스템일수록, 이 경계 하나가 코드베이스의 수명을 좌우합니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기