[Swift 입문] 38편 — Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [37편] Claude Code 훅 시스템: PermissionRequest부터 AskUserQuestion까지

같은 문제, 다른 해법

Claude Code만 훅을 갖고 있는 게 아닙니다. Codex CLI, Gemini CLI 같은 다른 AI 코딩 에이전트들도 “위험할 수 있는 행동 전에 외부에 물어본다”는 같은 요구를 갖고 있고, 저마다의 방식으로 확장 지점을 제공합니다. 문제는 셋 다 철학은 같은데 설정 형식과 필드 이름이 다르다는 점입니다.

Claude Code Codex CLI Gemini CLI
설정 형식 JSON (settings.json) TOML (config.toml) JSON/설정 파일
확장 방식 이벤트별 커맨드 훅 배열 알림용 커맨드(notify) 지정 도구별 승인 설정
실행 방식 서브프로세스 + stdin JSON 서브프로세스 + 인자/환경변수 서브프로세스 기반 확장
판정 전달 종료 코드 또는 stdout JSON 종료 코드 위주 도구별 정책 응답

구체적인 필드 이름과 세부 스펙은 각 도구가 계속 발전하고 있어 이 글보다는 각 프로젝트의 공식 문서를 항상 최신 기준으로 확인하는 게 맞습니다. 여기서는 세 도구를 관통하는 공통 구조에 집중합니다.


공통점 — 결국 셋 다 같은 질문을 한다

  • “이 도구 호출을 실행해도 되는가?” — 실행 전에 반드시 묻는다
  • 답을 알 수 없으면 사용자에게 넘긴다 — 자동 판정과 사람 판단 사이의 다리 역할
  • 확장 로직은 별도 프로세스(스크립트)로 위임한다 — 에이전트 코어 자체를 수정하지 않고도 정책을 바꿀 수 있게

이 공통 구조 덕분에, 36~37편에서 만든 “훅 스크립트 → 소켓 → 우리 앱” 아키텍처는 Claude Code 전용이 아니라 여러 CLI 도구에 재사용할 수 있습니다. 각 도구의 훅 스크립트가 받는 입력 형식만 우리 내부 모델로 변환해주면 됩니다.


어댑터 패턴으로 통합하기

도구마다 다른 입력 형식을 하나의 공통 타입으로 변환하는 계층을 두면, 뒤쪽의 정책 엔진·승인 큐는 어떤 CLI에서 온 요청인지 신경 쓸 필요가 없어집니다.

// 세 도구가 공유할 내부 표준 모델
struct NormalizedHookEvent {
    let provider: String       // "claude-code", "codex", "gemini"
    let command: String
    let sessionID: String
}

protocol ProviderAdapter {
    // 도구별 원본 JSON을 표준 모델로 변환
    func normalize(rawEvent: [String: Any]) -> NormalizedHookEvent?

    // 표준 판정을 도구가 이해하는 응답 형식으로 되돌림
    func encodeResponse(decision: PolicyDecision) -> Data
}

final class ClaudeCodeAdapter: ProviderAdapter {
    func normalize(rawEvent: [String: Any]) -> NormalizedHookEvent? {
        guard let command = (rawEvent["tool_input"] as? [String: Any])?["command"] as? String,
              let sessionID = rawEvent["session_id"] as? String else { return nil }
        return NormalizedHookEvent(provider: "claude-code", command: command, sessionID: sessionID)
    }

    func encodeResponse(decision: PolicyDecision) -> Data {
        let permissionDecision: String
        switch decision {
        case .allow: permissionDecision = "allow"
        case .deny: permissionDecision = "deny"
        case .ask: permissionDecision = "ask"
        }
        let payload: [String: Any] = [
            "hookSpecificOutput": [
                "hookEventName": "PreToolUse",
                "permissionDecision": permissionDecision
            ]
        ]
        return (try? JSONSerialization.data(withJSONObject: payload)) ?? Data()
    }
}

Codex CLI나 Gemini CLI를 지원하고 싶다면 같은 프로토콜을 채택하는 CodexAdapter, GeminiAdapter를 추가하기만 하면 됩니다. 소켓 서버(36편)는 어떤 어댑터를 거쳐 왔는지와 무관하게 NormalizedHookEvent만 다루고, 정책 엔진(39편)도 마찬가지입니다. 이것이 프로토콜 지향 설계(10편)와 프로토콜/제네릭이 실전에서 갖는 힘입니다 — 서로 다른 세 가지 구체 타입을 하나의 추상화 뒤에 숨겨서, 나머지 코드는 그 차이를 몰라도 되게 만듭니다.


어느 도구를 쓰는지 어떻게 구분할까

소켓 서버 입장에서는 들어온 원본 JSON의 필드 구성을 보고 어떤 어댑터로 넘길지 판단해야 합니다. 가장 간단한 방법은 연결 시점에 클라이언트(훅 스크립트)가 자신이 어떤 도구용인지 명시하도록 하는 것입니다.

func selectAdapter(for providerName: String) -> ProviderAdapter? {
    switch providerName {
    case "claude-code": return ClaudeCodeAdapter()
    case "codex": return CodexAdapter()
    case "gemini": return GeminiAdapter()
    default: return nil
    }
}

훅 스크립트를 설치할 때 어떤 도구용 스크립트인지가 이미 정해지므로(설치 스크립트 자체를 도구별로 따로 배포), 스크립트가 소켓 연결 초반에 자신의 provider 이름을 한 번 보내주는 정도로 충분합니다.


핵심 요약

  • Claude Code·Codex CLI·Gemini CLI 모두 “실행 전에 외부에 물어본다”는 같은 철학을 공유하지만, 설정 형식과 필드 이름은 각자 다름
  • 세부 스펙은 계속 바뀌므로 정확한 필드명은 항상 각 프로젝트의 공식 문서 기준으로 확인
  • 어댑터 패턴으로 도구별 원본 형식을 공통 내부 모델(NormalizedHookEvent)로 변환하면, 뒤쪽 로직(정책 엔진·승인 큐)이 도구 차이를 몰라도 되게 만들 수 있음
  • 프로토콜 하나로 여러 구체 타입을 추상화하는 것은 10편에서 배운 프로토콜 지향 설계의 실전 사례

다음 편은 8부의 마지막으로, 지금까지의 판정 로직을 정책 엔진과 감사 로그를 갖춘 완성된 승인 프록시로 조립합니다.

🤖 Generated with Claude Code

One thought on “[Swift 입문] 38편 — Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식

  1. 핑백: [Swift 입문] 39편 — 전체 조합: Hook 시스템 완성하기 | Naver Ending Study

답글 남기기

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