[Swift 입문] 36편 — AI CLI 훅이란 무엇인가

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [35편] AVFoundation과 사운드 재생

훅의 실행 계약 — 프로세스로 실행되고, 종료 코드로 답한다

Claude Code, Codex CLI, Gemini CLI 같은 AI 코딩 도구는 도구를 호출하기 직전이나 직후 같은 특정 시점에 사용자가 등록해둔 스크립트를 서브프로세스로 실행합니다. 32편에서 배운 Process가 정확히 이 역할을 합니다 — 다만 이번엔 우리 앱이 아니라 AI CLI 도구 자신이 Process를 써서 우리가 등록한 스크립트를 실행하는 쪽입니다.

계약은 단순합니다.

  • AI 도구가 이벤트 정보를 JSON으로 인코딩해 표준 입력(stdin)에 흘려보낸다
  • 스크립트는 그 내용을 읽고 판단한 뒤, 종료 코드로 답한다
    • 0: 승인 — 그대로 진행
    • 2: 차단 — 표준 에러(stderr)에 쓴 메시지가 AI에게 그대로 전달되어 재고하게 만듦
    • 그 외: 훅 자체의 오류로 처리 — 진행은 막지 않되 로그를 남김
  • 필요하다면 표준 출력(stdout)에 JSON을 써서 더 세밀한 결정(허용/차단/사용자에게 물어보기)을 전달할 수도 있다
#!/usr/bin/env python3
import json
import sys

event = json.load(sys.stdin)  # AI 도구가 보낸 이벤트 JSON

if event.get("tool_name") == "Bash" and "rm -rf /" in event.get("tool_input", {}).get("command", ""):
    print("루트 삭제 명령은 차단됩니다.", file=sys.stderr)
    sys.exit(2)  # 차단

sys.exit(0)  # 승인

이 스크립트 하나만으로도 훅은 완성됩니다. AI 도구 입장에서는 “이 명령을 실행해도 되는지 물어봤더니 0(예)이 왔다” 이상도 이하도 아닙니다. 문제는 판단 로직이 복잡해질수록 이 스크립트 하나에 모든 규칙을 우겨넣기 어려워진다는 점입니다.


스크립트 뒤에 진짜 앱을 두고 싶다면

규칙이 몇 줄이라면 스크립트 안에 그대로 적어도 되지만, 정책이 계속 바뀌고 사용자에게 직접 승인을 물어봐야 하는 경우(뒤에서 다룰 ask 판정)까지 생기면 이야기가 달라집니다. 매번 스크립트를 고쳐 배포하는 대신, 스크립트는 얇은 클라이언트로만 두고 실제 판단은 상시 실행 중인 앱에 위임하는 구조가 훨씬 유지보수하기 쉽습니다.

[AI CLI 도구] --(서브프로세스 실행, stdin JSON)--> [훅 스크립트] --(소켓 연결)--> [내 앱의 소켓 서버]
                                                                                      |
                                                                            이벤트 파싱 + 판정
                                                                                      |
                                                    <-- allow/deny/ask 응답 --
                                                          |
                                                     종료 코드로 변환
                                                          |
                                                 [AI CLI 도구가 진행/중단]

스크립트가 AI 도구와 주고받는 계약(stdin JSON, 종료 코드)은 그대로 유지하면서, 스크립트 내부에서는 6부(29~31편)에서 만든 Unix Domain Socket + 길이-접두사 프레이밍으로 우리 앱과 통신합니다. 즉 AI 도구 쪽에서 보면 여전히 "짧게 실행되고 종료 코드로 답하는 프로세스"일 뿐이지만, 그 프로세스는 내부적으로 훨씬 정교한 판단을 상시 앱에 물어보고 있는 것입니다.


Unix Domain Socket 서버 만들기

이 절부터는 스크립트가 연결해올 서버 쪽, 즉 우리 macOS 앱이 구현할 코드입니다.

import Foundation

final class HookSocketServer {
    private let socketPath: String
    private var listenFD: Int32 = -1
    private let queue = DispatchQueue(label: "hook.server", attributes: .concurrent)

    init(socketPath: String) {
        self.socketPath = socketPath
    }

    func start() throws {
        unlink(socketPath)  // 이전 실행에서 남은 소켓 파일 제거

        listenFD = socket(AF_UNIX, SOCK_STREAM, 0)
        guard listenFD >= 0 else { throw POSIXError(.init(rawValue: errno)!) }

        var addr = sockaddr_un()
        addr.sun_family = sa_family_t(AF_UNIX)
        withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
            ptr.withMemoryRebound(to: CChar.self, capacity: 104) { cstr in
                socketPath.withCString { strcpy(cstr, $0) }
            }
        }

        let bindResult = withUnsafePointer(to: &addr) { ptr in
            ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockAddrPtr in
                bind(listenFD, sockAddrPtr, socklen_t(MemoryLayout.size))
            }
        }
        guard bindResult == 0 else { throw POSIXError(.init(rawValue: errno)!) }

        listen(listenFD, 32)  // 백로그 32 — 동시에 밀려드는 연결 요청 대기 한도
        acceptLoop()
    }

    private func acceptLoop() {
        queue.async { [weak self] in
            guard let self else { return }
            while true {
                let clientFD = accept(self.listenFD, nil, nil)
                guard clientFD >= 0 else { break }  // 서버 종료 시 루프 탈출

                // 연결마다 별도 작업으로 처리 — 한 클라이언트가 느려도 다른 연결을 막지 않음
                self.queue.async {
                    self.handleConnection(clientFD)
                }
            }
        }
    }

    private func handleConnection(_ fd: Int32) {
        defer { close(fd) }
        // 다음 절에서 이어집니다
    }
}

accept()는 새 연결이 들어올 때까지 블로킹됩니다. 그래서 별도 큐에서 무한 루프를 돌리고, 연결마다 다시 별도 작업으로 넘겨서 한 클라이언트의 느린 처리가 다른 클라이언트를 막지 않도록 합니다.


프레임 디코딩과 이벤트 파싱

31편에서 만든 FrameDecoder를 그대로 재사용합니다. 소켓에서 읽은 바이트를 흘려보내면, 완성된 메시지 단위로 잘라줍니다.

struct HookEvent: Decodable {
    let hookType: String       // "PreToolUse", "PostToolUse" 등 — 37편에서 구체적으로 다룸
    let toolName: String?
    let toolInput: [String: String]?
}

private func handleConnection(_ fd: Int32) {
    defer { close(fd) }

    let decoder = FrameDecoder()
    var readBuffer = [UInt8](repeating: 0, count: 4096)

    while true {
        let bytesRead = recv(fd, &readBuffer, readBuffer.count, 0)
        guard bytesRead > 0 else { break }  // 0이면 연결 종료, 음수면 에러

        let chunk = Data(bytes: readBuffer, count: bytesRead)
        for messageData in decoder.feed(chunk) {
            guard let event = try? JSONDecoder().decode(HookEvent.self, from: messageData) else {
                continue
            }
            handle(event: event, connectionFD: fd)
        }
    }
}

스크립트는 이벤트 하나를 보내고 응답 하나를 받으면 종료하는 짧은 수명이지만, 서버 코드 자체는 여러 메시지가 한 연결에서 올 수 있다고 가정해두는 편이 안전합니다.


동시성 다루기

여러 도구 호출이 짧은 시간에 겹쳐 일어나면, 여러 연결이 동시에 들어옵니다. DispatchQueue(attributes: .concurrent)로 넘긴 작업들은 병렬로 실행되므로, 이벤트를 처리하는 로직이 공유 상태를 건드린다면 그 부분만큼은 직렬 큐나 락으로 보호해야 합니다.


핵심 요약

  • 훅의 진짜 계약은 서브프로세스 실행 + stdin JSON + 종료 코드(0/2/기타) — AI 도구 입장에서 훅은 그 이상도 이하도 아님
  • 규칙이 복잡해지면 스크립트를 얇은 클라이언트로 두고, 실제 판단은 상시 실행 중인 앱에 위임하는 편이 유리
  • 스크립트 ↔ 앱 통신에는 6부의 Unix Domain Socket + 길이-접두사 프레이밍을 그대로 사용
  • 서버는 socket → bind → listen → accept 순으로 구성, 연결마다 별도 작업으로 처리
  • 31편의 FrameDecoder를 재사용해 바이트 스트림에서 완성된 JSON 이벤트를 추출

다음 편에서는 Claude Code가 실제로 어떤 훅 이벤트들을 정의하고 있는지, 그리고 그 판정이 어떻게 사용자에게 직접 물어보는 흐름(AskUserQuestion)까지 이어지는지 살펴봅니다.

🤖 Generated with Claude Code

One thought on “[Swift 입문] 36편 — AI CLI 훅이란 무엇인가

  1. 핑백: [Swift 입문] 37편 — Claude Code 훅 시스템: PermissionRequest부터 AskUserQuestion까지 | Naver Ending Study

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다