LLM 에이전트에 훅(Hook) 시스템 붙이기 — 코드를 고치지 않고 동작을 바꾸는 법
"모든 응답 끝에 면책 문구를 붙여줘." "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 필드만, 거부는 경고 로그로 남긴다
- 감사 로그 필수 — 입력은 해시로, 에러 상세는 암호화해서
- 반드시 지켜야 하는 규칙은 프롬프트가 아니라 훅으로
훅 시스템을 붙이면 에이전트 코어가 다시 단순해집니다. 특수 케이스가 코어 밖으로 나가고, 코어는 "이벤트를 정확한 시점에 쏜다"는 한 가지 책임만 지죠. 확장 요구가 계속 들어오는 시스템일수록, 이 경계 하나가 코드베이스의 수명을 좌우합니다.
댓글
댓글 쓰기