<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Naver Ending Study</title>
	<atom:link href="https://nangchang.nes.or.kr/feed/" rel="self" type="application/rss+xml" />
	<link>https://nangchang.nes.or.kr</link>
	<description></description>
	<lastBuildDate>Sun, 06 Sep 2026 10:28:07 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.8.6</generator>
	<item>
		<title>[Swift 입문] 43편 — XPC와 프로세스 권한 분리</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-43%ed%8e%b8-xpc%ec%99%80-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b6%8c%ed%95%9c-%eb%b6%84%eb%a6%ac/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-43%ed%8e%b8-xpc%ec%99%80-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b6%8c%ed%95%9c-%eb%b6%84%eb%a6%ac/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:54:26 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1365</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [42편] UserNotifications와 시스템 알림 센터 왜 프로세스를 나누는가 앱의 모든 코드가 같은 프로세스, 같은 권한으로 실행되면 한 곳의 취약점이 앱 전체의 권한을 위협합니다. 예를 들어 네트워크에서 받은 데이터를 파싱하는 코드에 버그가 있다면, 그 코드가 파일 시스템 전체에 접근할 수 있는 권한까지 가지고 있어서는 안 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1363">[42편] UserNotifications와 시스템 알림 센터</a></p>
</blockquote>
<h2>왜 프로세스를 나누는가</h2>
<p>앱의 모든 코드가 같은 프로세스, 같은 권한으로 실행되면 한 곳의 취약점이 앱 전체의 권한을 위협합니다. 예를 들어 네트워크에서 받은 데이터를 파싱하는 코드에 버그가 있다면, 그 코드가 파일 시스템 전체에 접근할 수 있는 권한까지 가지고 있어서는 안 됩니다.</p>
<p><strong>최소 권한 원칙(principle of least privilege)</strong>에 따라, 위험하거나 민감한 작업을 별도의 프로세스로 분리하고 그 프로세스에는 꼭 필요한 권한만 부여하는 설계가 있습니다. macOS에서 이런 프로세스 간 통신을 표준화한 것이 <strong>XPC</strong>입니다.</p>
<p>29~31편에서 Unix Domain Socket으로 직접 프레이밍 프로토콜을 만들었던 것을 떠올려보세요. XPC는 그 과정(연결, 직렬화, 프레이밍, 오류 복구)을 Apple이 대신 처리해주는, 앱 내부 컴포넌트 간 통신에 특화된 상위 계층입니다.</p>
<hr/>
<h2>XPC vs 직접 만든 소켓 프로토콜</h2>
<table>
<thead>
<tr>
<th></th>
<th>Unix Domain Socket (29~31편)</th>
<th>XPC</th>
</tr>
</thead>
<tbody>
<tr>
<td>용도</td>
<td>임의의 두 프로세스(다른 언어 포함) 간 통신</td>
<td>같은 앱 생태계 내 컴포넌트 간 통신</td>
</tr>
<tr>
<td>직렬화</td>
<td>직접 설계(JSON + 길이-접두사)</td>
<td>Codable 기반 자동 처리</td>
</tr>
<tr>
<td>연결 관리</td>
<td>직접 구현</td>
<td>launchd가 필요 시 자동으로 서비스 실행</td>
</tr>
<tr>
<td>보안 경계</td>
<td>직접 검증 필요</td>
<td>코드 서명 검증을 시스템이 자동 수행</td>
</tr>
</tbody>
</table>
<p>8부에서 만든 hook 시스템처럼 Python 스크립트 등 다른 언어와도 통신해야 한다면 소켓 프로토콜이 맞는 선택입니다. 반면 &#8220;내 macOS 앱 안에서 권한이 다른 두 부분을 나누고 싶다&#8221;는 상황이라면 XPC가 훨씬 적은 코드로 안전하게 해결해줍니다.</p>
<hr/>
<h2>프로토콜 정의하기</h2>
<p>메인 앱과 XPC 서비스가 공유하는 인터페이스를 프로토콜로 정의합니다.</p>
<pre><code class="language-swift">import Foundation

@objc protocol PrivilegedTaskProtocol {
    func writeSystemFile(atPath path: String, contents: Data, reply: @escaping (Bool, String?) -> Void)
}
</code></pre>
<p>XPC는 Objective-C 런타임 기반이라 <code>@objc</code>가 필요하고, 반환값은 콜백(<code>reply</code> 클로저)으로 전달합니다. 값을 그냥 <code>return</code>할 수 없는 이유는 실제 호출이 프로세스 경계를 넘어 비동기로 일어나기 때문입니다.</p>
<hr/>
<h2>XPC 서비스 쪽 구현</h2>
<pre><code class="language-swift">final class PrivilegedTaskService: NSObject, PrivilegedTaskProtocol {
    func writeSystemFile(atPath path: String, contents: Data, reply: @escaping (Bool, String?) -> Void) {
        // 이 프로세스에만 부여된 권한으로 민감한 작업을 수행
        do {
            try contents.write(to: URL(fileURLWithPath: path))
            reply(true, nil)
        } catch {
            reply(false, error.localizedDescription)
        }
    }
}

final class ServiceDelegate: NSObject, NSXPCListenerDelegate {
    func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
        connection.exportedInterface = NSXPCInterface(with: PrivilegedTaskProtocol.self)
        connection.exportedObject = PrivilegedTaskService()
        connection.resume()
        return true
    }
}

let delegate = ServiceDelegate()
let listener = NSXPCListener.service()
listener.delegate = delegate
listener.resume()
</code></pre>
<hr/>
<h2>메인 앱에서 연결하기</h2>
<pre><code class="language-swift">final class PrivilegedTaskClient {
    private var connection: NSXPCConnection?

    func connect() {
        let connection = NSXPCConnection(serviceName: "com.example.myapp.PrivilegedTaskService")
        connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedTaskProtocol.self)

        connection.interruptionHandler = {
            print("XPC 연결이 끊겼습니다 — 서비스가 크래시했을 수 있음")
        }
        connection.invalidationHandler = {
            print("XPC 연결이 무효화됨")
        }

        connection.resume()
        self.connection = connection
    }

    func writeFile(path: String, data: Data, completion: @escaping (Bool, String?) -> Void) {
        guard let proxy = connection?.remoteObjectProxyWithErrorHandler({ error in
            completion(false, error.localizedDescription)
        }) as? PrivilegedTaskProtocol else {
            completion(false, "서비스에 연결할 수 없음")
            return
        }

        proxy.writeSystemFile(atPath: path, contents: data, reply: completion)
    }
}
</code></pre>
<p><code>interruptionHandler</code>와 <code>invalidationHandler</code>를 구분하는 것이 중요합니다. <strong>interruption</strong>은 서비스 프로세스가 죽었지만 연결 객체 자체는 아직 살아있어서 재시도할 수 있는 상태이고, <strong>invalidation</strong>은 연결이 완전히 무효화되어 새로 만들어야 하는 상태입니다. 8부의 승인 큐에서 다뤘던 &#8220;타임아웃과 재시도&#8221; 개념이 여기서도 똑같이 적용됩니다 — 원격 호출은 언제든 실패할 수 있다고 가정하고 설계해야 합니다.</p>
<hr/>
<h2>보안 경계가 실제로 의미하는 것</h2>
<p>XPC 서비스는 메인 앱과 별도의 샌드박스 엔타이틀먼트를 가질 수 있습니다. 예를 들어 메인 앱은 네트워크 접근이 필요 없고, 파일을 쓰는 헬퍼만 디스크 접근 권한이 필요하다면, 두 프로세스에 서로 다른 <code>.entitlements</code> 파일을 부여합니다. 이렇게 하면 메인 앱 쪽 코드에 취약점이 있어도 공격자가 파일 시스템에 직접 쓸 수 있는 권한까지 얻지는 못합니다 — 얻을 수 있는 최대치는 &#8220;XPC 서비스가 노출한 제한된 인터페이스를 호출하는 것&#8221;뿐입니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>최소 권한 원칙</strong>에 따라 민감한 작업은 별도 프로세스(XPC 서비스)로 분리</li>
<li>XPC는 29~31편에서 직접 만든 소켓 프로토콜의 상위 계층 — 직렬화·연결 관리·코드 서명 검증을 시스템이 대신 처리</li>
<li>프로토콜은 <code>@objc</code>로 정의하고, 결과는 <code>reply</code> 콜백으로 비동기 전달</li>
<li><code>interruptionHandler</code>(재시도 가능)와 <code>invalidationHandler</code>(재연결 필요)를 구분해서 처리</li>
<li>서로 다른 엔타이틀먼트를 가진 프로세스로 나누면, 한쪽이 뚫려도 피해 범위가 그 프로세스의 권한으로 제한됨</li>
</ul>
<hr/>
<h2>43강 시리즈를 마치며</h2>
<p>Swift 문법조차 몰랐던 지점에서 시작해, 언어 기초 → SwiftUI → macOS 앱 아키텍처 → 로컬 데이터베이스 → 프로세스 간 통신 → 시스템 프로그래밍 → AI CLI 훅 시스템 → macOS 시스템 API까지 아홉 개 부(部)를 거쳐 여기까지 왔습니다.</p>
<ul>
<li>1부 Swift 언어 기초 (1~10편)</li>
<li>2부 Swift 고급 주제 (11~14편)</li>
<li>3부 SwiftUI (15~19편)</li>
<li>4부 macOS 앱 개발 (20~24편)</li>
<li>5부 Foundation과 데이터 저장 (25~28편)</li>
<li>6부 네트워킹과 IPC (29~31편)</li>
<li>7부 시스템 프로그래밍 (32~35편)</li>
<li>8부 AI CLI 훅 시스템 (36~39편)</li>
<li>9부 macOS 시스템 API (40~43편)</li>
</ul>
<p>이 시리즈의 목표는 각 개념을 따로따로 암기시키는 것이 아니라, 뒤로 갈수록 앞에서 배운 조각들을 계속 다시 꺼내 쓰도록 구성하는 것이었습니다. 소켓 프레이밍(31편)이 hook 시스템(36편)의 기반이 되고, SQLite(28편)가 승인 큐(39편)의 영속성을 담당하고, LaunchAgent(34편)가 그 전체를 백그라운드 서비스로 띄우는 식으로요. 실제 macOS 앱 하나가 만들어지는 과정을 그대로 따라온 셈입니다.</p>
<p>여기서 배운 조각들을 실제 프로젝트에 적용해보면서 계속 익혀나가시길 바랍니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-43%ed%8e%b8-xpc%ec%99%80-%ed%94%84%eb%a1%9c%ec%84%b8%ec%8a%a4-%ea%b6%8c%ed%95%9c-%eb%b6%84%eb%a6%ac/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 42편 — UserNotifications와 시스템 알림 센터</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-42%ed%8e%b8-usernotifications%ec%99%80-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%95%8c%eb%a6%bc-%ec%84%bc%ed%84%b0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-42%ed%8e%b8-usernotifications%ec%99%80-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%95%8c%eb%a6%bc-%ec%84%bc%ed%84%b0/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:54:24 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1363</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [41편] FSEvents와 파일 시스템 감시 왜 앱 안에서 배너를 직접 그리지 않을까 사용자에게 뭔가 알려야 할 때 SwiftUI 뷰나 커스텀 윈도우로 직접 배너를 그릴 수도 있습니다. 하지만 시스템 알림 센터를 쓰면 몇 가지를 공짜로 얻습니다. 앱이 백그라운드에 있거나 최소화되어 있어도 사용자에게 도달함 알림 센터(화면 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1362">[41편] FSEvents와 파일 시스템 감시</a></p>
</blockquote>
<h2>왜 앱 안에서 배너를 직접 그리지 않을까</h2>
<p>사용자에게 뭔가 알려야 할 때 SwiftUI 뷰나 커스텀 윈도우로 직접 배너를 그릴 수도 있습니다. 하지만 시스템 알림 센터를 쓰면 몇 가지를 공짜로 얻습니다.</p>
<ul>
<li>앱이 백그라운드에 있거나 최소화되어 있어도 사용자에게 도달함</li>
<li>알림 센터(화면 오른쪽 위 스와이프)에 이력이 쌓여 나중에 다시 확인 가능</li>
<li>사용자가 시스템 설정에서 알림 스타일(배너/알림창/무음)을 직접 제어 가능</li>
<li>액션 버튼, 사운드, 배지 숫자 등 OS가 표준화한 UI를 그대로 사용</li>
</ul>
<p>8부에서 만든 승인 큐도 실제로는 &#8220;사용자에게 물어봐야 할 때&#8221; 알림을 띄우는 방식으로 자연스럽게 이어집니다.</p>
<hr/>
<h2>권한 요청</h2>
<p>알림도 Accessibility API(24편)나 Apple Events(40편)처럼 사용자 승인이 필요합니다. 앱 시작 시 한 번 요청합니다.</p>
<pre><code class="language-swift">import UserNotifications

func requestNotificationPermission() {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if let error {
            print("알림 권한 요청 실패: \(error)")
        }
        print(granted ? "알림 허용됨" : "알림 거부됨")
    }
}
</code></pre>
<p>사용자가 한 번 거부하면 앱에서 다시 팝업을 띄울 수 없습니다. 시스템 설정으로 안내하는 버튼을 따로 마련해두는 것이 좋습니다.</p>
<hr/>
<h2>알림 보내기</h2>
<pre><code class="language-swift">func sendNotification(title: String, body: String, identifier: String = UUID().uuidString) {
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default

    // nil 트리거 = 즉시 발송
    let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)

    UNUserNotificationCenter.current().add(request) { error in
        if let error {
            print("알림 발송 실패: \(error)")
        }
    }
}

// 사용
sendNotification(title: "승인 필요", body: "git push origin main 실행을 승인하시겠습니까?")
</code></pre>
<p><code>trigger</code>에 <code>UNTimeIntervalNotificationTrigger</code>를 넘기면 일정 시간 뒤에 발송하도록 예약할 수도 있습니다. <code>identifier</code>를 명시적으로 관리해두면, 같은 알림을 나중에 <code>removePendingNotificationRequests(withIdentifiers:)</code>로 취소하거나 갱신할 수 있습니다.</p>
<hr/>
<h2>액션 버튼 추가하기</h2>
<p>단순히 알리기만 하는 게 아니라, 알림 자체에서 &#8220;승인&#8221;/&#8221;거부&#8221; 버튼을 누르게 하고 싶다면 <code>UNNotificationCategory</code>를 등록합니다.</p>
<pre><code class="language-swift">func registerApprovalCategory() {
    let approveAction = UNNotificationAction(
        identifier: "APPROVE_ACTION",
        title: "승인",
        options: [.authenticationRequired]  // 잠금 화면에서는 인증 요구
    )
    let denyAction = UNNotificationAction(
        identifier: "DENY_ACTION",
        title: "거부",
        options: [.destructive]
    )

    let category = UNNotificationCategory(
        identifier: "APPROVAL_REQUEST",
        actions: [approveAction, denyAction],
        intentIdentifiers: [],
        options: []
    )

    UNUserNotificationCenter.current().setNotificationCategories([category])
}

func sendApprovalNotification(command: String, approvalID: UUID) {
    let content = UNMutableNotificationContent()
    content.title = "승인 필요"
    content.body = command
    content.categoryIdentifier = "APPROVAL_REQUEST"
    content.userInfo = ["approvalID": approvalID.uuidString]

    let request = UNNotificationRequest(identifier: approvalID.uuidString, content: content, trigger: nil)
    UNUserNotificationCenter.current().add(request)
}
</code></pre>
<hr/>
<h2>버튼 클릭에 반응하기</h2>
<p>사용자가 알림의 액션 버튼을 누르면 델리게이트 메서드로 통보됩니다. 8부(39편)에서 만든 <code>ApprovalQueue</code>와 바로 연결할 수 있습니다.</p>
<pre><code class="language-swift">final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
    let approvalQueue: ApprovalQueue

    init(approvalQueue: ApprovalQueue) {
        self.approvalQueue = approvalQueue
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        guard let idString = response.notification.request.content.userInfo["approvalID"] as? String,
              let approvalID = UUID(uuidString: idString) else {
            completionHandler()
            return
        }

        switch response.actionIdentifier {
        case "APPROVE_ACTION":
            onUserDecision(id: approvalID, approved: true, queue: approvalQueue)
        case "DENY_ACTION":
            onUserDecision(id: approvalID, approved: false, queue: approvalQueue)
        default:
            break  // 알림 본문을 그냥 탭한 경우 등
        }

        completionHandler()
    }
}

// 앱 시작 시 델리게이트 등록
UNUserNotificationCenter.current().delegate = NotificationDelegate(approvalQueue: myApprovalQueue)
</code></pre>
<p><code>completionHandler()</code>는 반드시 호출해야 합니다. 호출하지 않으면 시스템이 알림 처리가 끝나지 않았다고 판단해 다음 알림 전달이 지연될 수 있습니다.</p>
<hr/>
<h2>포그라운드에서도 알림 보이기</h2>
<p>기본적으로 앱이 최전면에 떠 있을 때는 배너가 뜨지 않습니다. 앱을 보고 있는 중에도 알림을 표시하려면 델리게이트에 다음 메서드를 추가합니다.</p>
<pre><code class="language-swift">func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
    completionHandler([.banner, .sound])
}
</code></pre>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>시스템 알림 센터는 백그라운드 도달, 이력 보관, 표준 UI를 공짜로 제공</li>
<li><code>UNUserNotificationCenter.requestAuthorization</code>으로 최초 1회 권한 요청</li>
<li><code>UNNotificationRequest(trigger: nil)</code>로 즉시 발송, <code>identifier</code>로 갱신/취소 관리</li>
<li><code>UNNotificationCategory</code> + <code>UNNotificationAction</code>으로 알림에 승인/거부 버튼 추가</li>
<li>버튼 클릭은 델리게이트의 <code>didReceive</code>로 전달 — 승인 큐(39편)와 직접 연결 가능</li>
<li>포그라운드 배너를 보이려면 <code>willPresent</code>에서 <code>completionHandler</code>로 옵션을 지정</li>
</ul>
<p>다음 편은 시리즈의 마지막으로, <strong>XPC와 프로세스 권한 분리</strong>를 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-42%ed%8e%b8-usernotifications%ec%99%80-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%95%8c%eb%a6%bc-%ec%84%bc%ed%84%b0/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 41편 — FSEvents와 파일 시스템 감시</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-41%ed%8e%b8-fsevents%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ea%b0%90%ec%8b%9c/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-41%ed%8e%b8-fsevents%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ea%b0%90%ec%8b%9c/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:54:21 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1362</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [40편] Apple Events와 다른 앱 제어 파일이 바뀌었는지 어떻게 아는가 27편에서 FileManager로 파일을 읽고 쓰는 법을 배웠습니다. 그런데 &#8220;누군가 이 파일을 수정하면 즉시 알려달라&#8221;는 요구는 또 다른 문제입니다. 매초 파일을 다시 읽어서 내용이 바뀌었는지 비교하는 폴링(polling) 방식도 가능하지만, 감시할 파일이 많아질수록 비효율적이고 반응이 느립니다. [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1370">[40편] Apple Events와 다른 앱 제어</a></p>
</blockquote>
<h2>파일이 바뀌었는지 어떻게 아는가</h2>
<p>27편에서 <code>FileManager</code>로 파일을 읽고 쓰는 법을 배웠습니다. 그런데 &#8220;누군가 이 파일을 수정하면 즉시 알려달라&#8221;는 요구는 또 다른 문제입니다. 매초 파일을 다시 읽어서 내용이 바뀌었는지 비교하는 폴링(polling) 방식도 가능하지만, 감시할 파일이 많아질수록 비효율적이고 반응이 느립니다.</p>
<p>macOS는 커널 수준에서 파일 시스템 변경을 감지해 알려주는 두 가지 API를 제공합니다.</p>
<ul>
<li><strong>FSEvents</strong>: 디렉터리 단위로, 넓은 범위(폴더 전체, 심지어 디스크 전체)를 적은 리소스로 감시</li>
<li><strong>DispatchSource(kqueue 기반)</strong>: 특정 파일 디스크립터 하나를 정밀하게 감시</li>
</ul>
<hr/>
<h2>DispatchSource로 파일 하나 감시하기</h2>
<p>감시 대상이 파일 몇 개뿐이라면 GCD의 <code>DispatchSourceFileSystemObject</code>가 더 간단합니다.</p>
<pre><code class="language-swift">import Foundation

final class FileWatcher {
    private var source: DispatchSourceFileSystemObject?
    private var fileDescriptor: Int32 = -1

    func watch(path: String, onChange: @escaping () -> Void) {
        fileDescriptor = open(path, O_EVTONLY)  // 이벤트 감시 전용 — 읽기/쓰기 목적 아님
        guard fileDescriptor >= 0 else { return }

        source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fileDescriptor,
            eventMask: [.write, .rename, .delete],
            queue: .main
        )

        source?.setEventHandler {
            onChange()
        }

        source?.setCancelHandler { [weak self] in
            guard let self else { return }
            close(self.fileDescriptor)
        }

        source?.resume()
    }

    func stop() {
        source?.cancel()
    }
}

// 사용
let watcher = FileWatcher()
watcher.watch(path: "/Users/me/config.json") {
    print("설정 파일이 변경됨")
}
</code></pre>
<p><code>O_EVTONLY</code> 플래그가 핵심입니다. 이 파일을 실제로 읽거나 쓰려는 게 아니라 &#8220;이벤트만 지켜보겠다&#8221;는 의도를 커널에 알립니다. 일반 <code>O_RDONLY</code>로 열면 그 파일을 다른 프로세스가 마운트 해제하려 할 때 방해가 될 수 있는데, <code>O_EVTONLY</code>는 그런 부작용이 없습니다.</p>
<p><code>eventMask</code>에는 감시할 이벤트 종류를 지정합니다. <code>.write</code>(내용 변경), <code>.rename</code>(이름 변경/이동), <code>.delete</code>(삭제) 외에도 <code>.link</code>, <code>.extend</code>, <code>.attrib</code> 등이 있습니다.</p>
<hr/>
<h2>DispatchSource의 한계 — 파일이 삭제되면?</h2>
<p>파일이 삭제되거나 이름이 바뀌면, 열어두었던 파일 디스크립터는 더 이상 그 경로를 가리키지 않습니다. 에디터가 저장할 때 &#8220;임시 파일 쓰기 → 원본 삭제 → 임시 파일을 원래 이름으로 변경&#8221;하는 방식(atomic save)을 쓰는 경우가 흔한데, 이때 <code>.delete</code> 이벤트가 발생하고 나면 기존 fd로는 더 이상 새 내용을 감시할 수 없습니다. 이런 경우 <code>.delete</code>나 <code>.rename</code> 이벤트를 받으면 감시를 취소하고, 같은 경로로 다시 <code>watch()</code>를 호출해 새로 열어주는 재시작 로직이 필요합니다.</p>
<pre><code class="language-swift">source?.setEventHandler { [weak self] in
    guard let source = self?.source else { return }
    let flags = source.data  // 어떤 이벤트가 발생했는지

    if flags.contains(.delete) || flags.contains(.rename) {
        self?.stop()
        self?.watch(path: path, onChange: onChange)  // 재구독
    }
    onChange()
}
</code></pre>
<hr/>
<h2>FSEvents로 디렉터리 전체 감시하기</h2>
<p>파일 하나가 아니라 폴더 안에서 무슨 일이 일어나는지(파일이 몇 개든, 하위 폴더가 몇 겹이든) 감시하려면 <code>FSEvents</code>가 적합합니다. 파일마다 fd를 여는 <code>DispatchSource</code>와 달리, 커널의 변경 로그를 구독하는 방식이라 대상이 아무리 많아도 리소스 사용량이 일정합니다.</p>
<pre><code class="language-swift">import CoreServices

final class DirectoryWatcher {
    private var streamRef: FSEventStreamRef?

    func watch(directory: String, onChange: @escaping ([String]) -> Void) {
        var context = FSEventStreamContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil, release: nil, copyDescription: nil
        )

        let callback: FSEventStreamCallback = { (streamRef, clientInfo, numEvents, eventPaths, eventFlags, eventIds) in
            let watcher = Unmanaged&lt;DirectoryWatcher&gt;.fromOpaque(clientInfo!).takeUnretainedValue()
            let paths = unsafeBitCast(eventPaths, to: NSArray.self) as! [String]
            watcher.onChange?(paths)
        }

        self.onChange = onChange

        streamRef = FSEventStreamCreate(
            kCFAllocatorDefault,
            callback,
            &context,
            [directory] as CFArray,
            FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
            0.5,  // 지연 시간(초) — 짧은 시간 내 여러 변경을 묶어서 전달
            FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents)
        )

        FSEventStreamSetDispatchQueue(streamRef!, .main)
        FSEventStreamStart(streamRef!)
    }

    private var onChange: (([String]) -> Void)?

    func stop() {
        guard let streamRef else { return }
        FSEventStreamStop(streamRef)
        FSEventStreamInvalidate(streamRef)
        FSEventStreamRelease(streamRef)
    }
}
</code></pre>
<p>세 번째 인자인 지연 시간(위 예시에서 0.5초)은 성능과 즉시성 사이의 트레이드오프입니다. 짧은 시간에 수백 개 파일이 바뀌는 상황(예: git checkout)에서 매 파일마다 콜백을 호출하면 과도한 부하가 생기므로, FSEvents는 지연 시간 동안의 변경을 모아 한 번에 알려줍니다. 즉시성이 중요하면 값을 줄이고, 부하가 걱정되면 늘립니다.</p>
<hr/>
<h2>어떤 것을 골라야 할까</h2>
<table>
<thead>
<tr>
<th></th>
<th>DispatchSource</th>
<th>FSEvents</th>
</tr>
</thead>
<tbody>
<tr>
<td>감시 대상</td>
<td>파일/디렉터리 하나</td>
<td>디렉터리 트리 전체</td>
</tr>
<tr>
<td>리소스</td>
<td>대상마다 fd 하나씩 필요</td>
<td>대상 수와 무관하게 일정</td>
</tr>
<tr>
<td>세밀함</td>
<td>이벤트 종류가 세밀함</td>
<td>변경된 경로 목록 위주</td>
</tr>
<tr>
<td>적합한 경우</td>
<td>설정 파일 하나 감시</td>
<td>프로젝트 폴더 전체 감시</td>
</tr>
</tbody>
</table>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>파일 변경 감지는 폴링 대신 커널이 알려주는 <strong>이벤트 기반</strong> API를 사용</li>
<li><strong>DispatchSource</strong>: 파일 하나를 <code>O_EVTONLY</code>로 열어 정밀하게 감시, 삭제/이름변경 시 재구독 필요</li>
<li><strong>FSEvents</strong>: 디렉터리 트리 전체를 낮은 리소스로 감시, 지연 시간으로 이벤트를 배칭</li>
<li>대상이 하나면 DispatchSource, 폴더 전체면 FSEvents가 자연스러운 선택</li>
</ul>
<p>다음 편에서는 <strong>UserNotifications</strong> 프레임워크로 시스템 알림 센터에 알림을 띄우는 방법을 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-41%ed%8e%b8-fsevents%ec%99%80-%ed%8c%8c%ec%9d%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ea%b0%90%ec%8b%9c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 40편 — Apple Events와 다른 앱 제어</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-40%ed%8e%b8-apple-events%ec%99%80-%eb%8b%a4%eb%a5%b8-%ec%95%b1-%ec%a0%9c%ec%96%b4/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-40%ed%8e%b8-apple-events%ec%99%80-%eb%8b%a4%eb%a5%b8-%ec%95%b1-%ec%a0%9c%ec%96%b4/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:54:19 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1370</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [39편] 훅 기반 승인 프록시 설계: 정책 엔진과 감사 로그 Apple Events란 32편에서 osascript를 서브프로세스로 실행해 AppleScript를 돌리는 법을 간단히 봤습니다. 이번 편은 그 뒤에 있는 메커니즘, Apple Events를 조금 더 깊이 다룹니다. Apple Events는 macOS 프로세스끼리 명령과 데이터를 주고받는 오래된(그리고 여전히 널리 쓰이는) [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1357">[39편] 훅 기반 승인 프록시 설계: 정책 엔진과 감사 로그</a></p>
</blockquote>
<h2>Apple Events란</h2>
<p>32편에서 <code>osascript</code>를 서브프로세스로 실행해 AppleScript를 돌리는 법을 간단히 봤습니다. 이번 편은 그 뒤에 있는 메커니즘, <strong>Apple Events</strong>를 조금 더 깊이 다룹니다.</p>
<p>Apple Events는 macOS 프로세스끼리 명령과 데이터를 주고받는 오래된(그리고 여전히 널리 쓰이는) IPC 방식입니다. &#8220;Finder야, 이 폴더를 열어줘&#8221;, &#8220;Terminal아, 최전면으로 와줘&#8221; 같은 요청은 전부 Apple Event 메시지로 전달됩니다. AppleScript는 이 메시지를 사람이 읽기 쉬운 문법으로 감싼 것이고, <code>osascript</code>는 그 문법을 해석해서 실제 Apple Event를 보내는 커맨드라인 도구입니다.</p>
<hr/>
<h2>osascript vs NSAppleScript</h2>
<p>32편처럼 <code>Process</code>로 <code>osascript</code>를 실행하는 방법 외에, 앱 안에서 직접 AppleScript를 실행하는 <code>NSAppleScript</code> API도 있습니다.</p>
<pre><code class="language-swift">import Foundation

func runAppleScriptInProcess(_ source: String) -> String? {
    guard let script = NSAppleScript(source: source) else { return nil }

    var errorDict: NSDictionary?
    let result = script.executeAndReturnError(&errorDict)

    if let errorDict {
        print("AppleScript 오류: \(errorDict)")
        return nil
    }
    return result.stringValue
}

// 사용
runAppleScriptInProcess("""
tell application "Terminal"
    activate
end tell
""")
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>osascript (Process)</th>
<th>NSAppleScript</th>
</tr>
</thead>
<tbody>
<tr>
<td>실행 위치</td>
<td>별도 프로세스</td>
<td>내 앱 프로세스 안</td>
</tr>
<tr>
<td>오버헤드</td>
<td>프로세스 생성 비용</td>
<td>더 가벼움</td>
</tr>
<tr>
<td>디버깅</td>
<td>커맨드라인에서 독립적으로 테스트 가능</td>
<td>앱 코드와 얽혀 있음</td>
</tr>
<tr>
<td>결과 타입</td>
<td>문자열(stdout)</td>
<td><code>NSAppleEventDescriptor</code> — 더 풍부한 타입 정보</td>
</tr>
</tbody>
</table>
<p>스크립트를 자주, 빠르게 실행해야 한다면 <code>NSAppleScript</code>가 유리하고, 스크립트를 독립적으로 테스트하고 로그로 실행 여부를 남기고 싶다면 <code>osascript</code> 서브프로세스 방식이 편합니다.</p>
<hr/>
<h2>실전 예시 — 터미널 포커스 이동</h2>
<p>승인 알림(41편에서 다룰 시스템 알림)에서 &#8220;터미널 보기&#8221; 버튼을 눌렀을 때, 실제로 그 명령을 실행 중인 터미널 앱을 최전면으로 가져오는 기능을 만들어봅니다.</p>
<pre><code class="language-swift">enum TerminalApp: String {
    case appleTerminal = "com.apple.Terminal"
    case iTerm2 = "com.googlecode.iterm2"
    case warp = "dev.warp.Warp-Stable"
}

func focusTerminal(_ app: TerminalApp) {
    let script: String
    switch app {
    case .appleTerminal:
        script = """
        tell application "Terminal"
            activate
        end tell
        """
    case .iTerm2:
        script = """
        tell application "iTerm2"
            activate
        end tell
        """
    case .warp:
        // 일부 앱은 AppleScript 사전(dictionary)을 지원하지 않아 System Events로 우회
        script = """
        tell application "System Events"
            tell process "Warp"
                set frontmost to true
            end tell
        end tell
        """
    }
    _ = runAppleScriptInProcess(script)
}
</code></pre>
<p>모든 앱이 <code>tell application "AppName" \n activate \n end tell</code> 형태를 지원하는 것은 아닙니다. AppleScript 사전을 제공하지 않는 앱은 <strong>System Events</strong>를 경유해서 프로세스 단위로 조작해야 합니다. 어떤 방식이 필요한지는 대상 앱마다 다르므로, 스크립트 편집기(Script Editor) 앱에서 대상 앱의 사전을 열어 확인하는 것이 정석입니다.</p>
<hr/>
<h2>권한과 샌드박스</h2>
<p>Apple Events로 다른 앱을 제어하려면 macOS의 <strong>자동화 권한</strong>이 필요합니다. 처음 시도하면 시스템이 &#8220;○○이(가) Terminal을 제어하려고 합니다&#8221; 같은 승인 대화상자를 띄웁니다. 사용자가 거부하면 이후 호출은 계속 에러를 반환하므로, 실패를 감지해서 시스템 설정(개인정보 보호 및 보안 › 자동화)으로 안내하는 처리가 필요합니다.</p>
<p>샌드박스 앱이라면 <code>.entitlements</code> 파일에 대상 앱별 <code>com.apple.security.temporary-exception.apple-events</code> 항목을 추가해야 할 수도 있습니다. 24편에서 다룬 Accessibility 권한과 마찬가지로, Apple Events 제어도 사용자 승인 없이는 조용히 실패한다는 점을 기억하세요.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>Apple Events</strong>는 macOS 프로세스 간 명령·데이터 교환의 오래된 표준 — AppleScript는 그 위에 얹힌 문법, <code>osascript</code>는 그 문법을 해석하는 커맨드라인 도구</li>
<li><code>osascript</code>(별도 프로세스, 32편) vs <code>NSAppleScript</code>(앱 내부 실행) — 빈도와 디버깅 편의에 따라 선택</li>
<li>모든 앱이 <code>activate</code> 같은 표준 명령을 지원하지 않으므로, 필요하면 <strong>System Events</strong>로 프로세스 단위 제어를 우회</li>
<li>Apple Events 제어는 사용자의 <strong>자동화 권한</strong> 승인이 필요하고, 거부되면 조용히 실패하므로 에러 처리가 중요</li>
</ul>
<p>다음 편에서는 <strong>FSEvents</strong>로 파일 시스템 변경을 감시하는 방법을 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-40%ed%8e%b8-apple-events%ec%99%80-%eb%8b%a4%eb%a5%b8-%ec%95%b1-%ec%a0%9c%ec%96%b4/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 39편 — 훅 기반 승인 프록시 설계: 정책 엔진과 감사 로그</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-39%ed%8e%b8-%ec%a0%84%ec%b2%b4-%ec%a1%b0%ed%95%a9-hook-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%99%84%ec%84%b1%ed%95%98%ea%b8%b0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-39%ed%8e%b8-%ec%a0%84%ec%b2%b4-%ec%a1%b0%ed%95%a9-hook-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%99%84%ec%84%b1%ed%95%98%ea%b8%b0/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:32:43 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1357</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [38편] Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식 지금까지 만든 조각들 8부에서 세 편에 걸쳐 만든 부품을 정리하면 이렇습니다. 36편: 훅의 실행 계약(stdin JSON + 종료 코드)과 그 뒤에 숨은 Unix Domain Socket 서버 37편: Claude Code의 실제 훅 이벤트(PreToolUse 등)와 permissionDecision [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1356">[38편] Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식</a></p>
</blockquote>
<h2>지금까지 만든 조각들</h2>
<p>8부에서 세 편에 걸쳐 만든 부품을 정리하면 이렇습니다.</p>
<ul>
<li><strong>36편</strong>: 훅의 실행 계약(stdin JSON + 종료 코드)과 그 뒤에 숨은 Unix Domain Socket 서버</li>
<li><strong>37편</strong>: Claude Code의 실제 훅 이벤트(PreToolUse 등)와 <code>permissionDecision</code></li>
<li><strong>38편</strong>: 여러 CLI 도구를 하나의 표준 모델로 통합하는 어댑터 패턴</li>
</ul>
<p>이제 남은 것은 <code>NormalizedHookEvent</code>를 받아 실제로 <strong>allow/deny/ask</strong>를 판정하는 정책 엔진, 판정을 사용자에게 물어봐야 할 때 대기시키는 승인 큐, 그리고 나중에 &#8220;그때 왜 그렇게 판단했는지&#8221; 되짚어볼 수 있는 감사 로그입니다.</p>
<hr/>
<h2>정책 엔진 — allow/deny/ask 판정</h2>
<pre><code class="language-swift">enum PolicyDecision {
    case allow
    case deny(reason: String)
    case ask
}

struct PolicyRule {
    let pattern: String       // 매칭할 명령어 패턴 (정규식)
    let decision: PolicyDecision
    let compiledRegex: NSRegularExpression?

    init(pattern: String, decision: PolicyDecision) {
        self.pattern = pattern
        self.decision = decision
        self.compiledRegex = try? NSRegularExpression(pattern: pattern)
    }
}

final class PolicyEngine {
    private var rules: [PolicyRule] = []
    private let fallback: PolicyDecision = .ask  // 정의되지 않은 경우 사용자에게 물어봄

    func addRule(_ rule: PolicyRule) {
        rules.append(rule)
    }

    func evaluate(command: String) -> PolicyDecision {
        for rule in rules {
            guard let regex = rule.compiledRegex else { continue }
            let range = NSRange(command.startIndex..., in: command)
            if regex.firstMatch(in: command, range: range) != nil {
                return rule.decision
            }
        }
        return fallback
    }
}
</code></pre>
<p>규칙은 순서가 있는 리스트로, <strong>먼저 매칭되는 규칙이 우선</strong>합니다. 알 수 없는 명령의 기본값(fallback)은 보수적으로 — <code>allow</code>가 아니라 <code>ask</code>나 <code>deny</code>로 두는 것이 안전합니다. 정규식은 규칙을 불러올 때 한 번만 컴파일해서 <code>compiledRegex</code>에 저장해두면, 이벤트가 많이 들어와도 매번 새로 컴파일하는 비용을 피할 수 있습니다.</p>
<p>규칙 자체는 코드에 하드코딩하지 않고 JSON 같은 데이터 파일로 분리해두면, 코드를 재빌드하지 않고도 정책을 바꿀 수 있습니다.</p>
<pre><code class="language-json">[
  { "pattern": "^rm -rf /$", "decision": "deny", "reason": "루트 삭제 차단" },
  { "pattern": "^git (status|diff|log)", "decision": "allow" },
  { "pattern": "^git push", "decision": "ask" }
]
</code></pre>
<hr/>
<h2>승인 큐 — ask 판정을 대기시키기</h2>
<p><code>ask</code> 판정은 즉시 응답할 수 없습니다. 사용자가 결정할 때까지 요청을 어딘가에 보관해야 하고, 이 요청은 hook 스크립트의 연결을 열어둔 채로 대기시켜야 합니다.</p>
<pre><code class="language-swift">struct PendingApproval {
    let id: UUID
    let command: String
    let createdAt: Date
    let connectionFD: Int32   // 응답을 보낼 때 사용할 연결
}

final class ApprovalQueue {
    private var pending: [UUID: PendingApproval] = [:]
    private let lock = NSLock()

    func enqueue(_ approval: PendingApproval) {
        lock.lock(); defer { lock.unlock() }
        pending[approval.id] = approval
    }

    func resolve(id: UUID) -> PendingApproval? {
        lock.lock(); defer { lock.unlock() }
        return pending.removeValue(forKey: id)
    }
}
</code></pre>
<p>여러 연결이 동시에 <code>enqueue</code>를 호출하고, 앱 UI 스레드가 동시에 <code>resolve</code>를 호출할 수 있으므로 <code>NSLock</code>으로 접근을 직렬화합니다. 메모리에만 두면 앱이 재시작될 때 대기 중이던 요청이 사라지므로, 28편에서 배운 SQLite로 함께 영속화해둡니다.</p>
<pre><code class="language-swift">func enqueueForApproval(event: NormalizedHookEvent, connectionFD: Int32) {
    let approval = PendingApproval(id: UUID(), command: event.command, createdAt: Date(), connectionFD: connectionFD)
    approvalQueue.enqueue(approval)
    approvalStore.insertPending(approval)  // SQLite에 저장

    // 30초 안에 응답이 없으면 안전하게 deny 처리
    DispatchQueue.global().asyncAfter(deadline: .now() + 30) { [weak self] in
        guard let self, let stillPending = self.approvalQueue.resolve(id: approval.id) else { return }
        self.respond(decision: .deny(reason: "응답 시간 초과"), fd: stillPending.connectionFD)
    }
}
</code></pre>
<p><code>resolve(id:)</code>가 이미 <code>nil</code>을 반환한다면 사용자가 그 사이 직접 응답했다는 뜻이므로 타임아웃 처리를 건너뜁니다. 큐가 &#8220;누가 먼저 이 항목을 처리하느냐&#8221;의 경쟁 상태를 자연스럽게 중재해줍니다.</p>
<hr/>
<h2>감사 로그 — 모든 판정을 기록으로 남기기</h2>
<p>승인 큐는 <strong>아직 결정되지 않은</strong> 요청만 담습니다. 하지만 &#8220;지난주에 이 명령이 왜 차단됐는지&#8221; 되짚어보려면, allow/deny/ask 여부와 상관없이 <strong>모든 판정을 영구 기록</strong>해두는 별도의 로그 테이블이 필요합니다.</p>
<pre><code class="language-swift">private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

final class AuditLog {
    private let db: OpaquePointer?

    init(path: String) {
        var handle: OpaquePointer?
        sqlite3_open(path, &handle)
        sqlite3_exec(handle, """
            CREATE TABLE IF NOT EXISTS audit_log (
                id TEXT PRIMARY KEY,
                provider TEXT NOT NULL,
                command TEXT NOT NULL,
                decision TEXT NOT NULL,
                reason TEXT,
                decided_at REAL NOT NULL
            )
            """, nil, nil, nil)
        self.db = handle
    }

    func record(provider: String, command: String, decision: String, reason: String?) {
        let sql = "INSERT INTO audit_log (id, provider, command, decision, reason, decided_at) VALUES (?, ?, ?, ?, ?, ?)"
        var statement: OpaquePointer?
        sqlite3_prepare_v2(db, sql, -1, &statement, nil)
        defer { sqlite3_finalize(statement) }

        sqlite3_bind_text(statement, 1, UUID().uuidString, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(statement, 2, provider, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(statement, 3, command, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(statement, 4, decision, -1, SQLITE_TRANSIENT)
        if let reason {
            sqlite3_bind_text(statement, 5, reason, -1, SQLITE_TRANSIENT)
        } else {
            sqlite3_bind_null(statement, 5)  // reason은 옵셔널 — nil이면 NULL 컬럼으로 저장
        }
        sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
        sqlite3_step(statement)
    }
}
</code></pre>
<p>승인 큐의 항목은 결정이 나는 순간 큐에서 사라지지만(<code>resolve</code>가 지워버림), 감사 로그의 항목은 지워지지 않고 계속 쌓입니다. 이 둘을 혼동하지 않는 것이 설계의 핵심입니다 — 큐는 &#8220;지금 처리해야 할 것&#8221;, 로그는 &#8220;지금까지 있었던 모든 것&#8221;입니다.</p>
<hr/>
<h2>전체 조립하기</h2>
<pre><code class="language-swift">final class HookApprovalProxy {
    private let policyEngine = PolicyEngine()
    private let approvalQueue = ApprovalQueue()
    private let approvalStore: ApprovalStore
    private let auditLog: AuditLog

    init(dbPath: String) {
        self.approvalStore = ApprovalStore(path: dbPath)
        self.auditLog = AuditLog(path: dbPath)
    }

    func handle(event: NormalizedHookEvent, connectionFD: Int32) {
        let decision = policyEngine.evaluate(command: event.command)

        switch decision {
        case .allow:
            auditLog.record(provider: event.provider, command: event.command, decision: "allow", reason: nil)
            respond(decision: decision, fd: connectionFD)
        case .deny(let reason):
            auditLog.record(provider: event.provider, command: event.command, decision: "deny", reason: reason)
            respond(decision: decision, fd: connectionFD)
        case .ask:
            // 사용자가 결정한 시점에 별도로 auditLog.record가 호출됨
            enqueueForApproval(event: event, connectionFD: connectionFD)
        }
    }

    private func respond(decision: PolicyDecision, fd: Int32) {
        // 38편의 ProviderAdapter.encodeResponse로 도구별 형식에 맞게 인코딩 후 전송
    }
}
</code></pre>
<p>각 부품(정책 엔진 / 승인 큐 / 감사 로그)이 단일 책임을 가지며, <code>HookApprovalProxy</code>는 이를 조합하는 얇은 층으로 남습니다. 정책 엔진만 따로 테스트하거나, 감사 로그 저장 방식만 바꾸는 일이 쉬워집니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>정책 엔진은 규칙 리스트로 <strong>allow/deny/ask</strong>를 판정하며, 정규식은 로드 시점에 한 번만 컴파일</li>
<li>규칙을 데이터(JSON)로 분리하면 재빌드 없이 정책을 바꿀 수 있음</li>
<li><strong>승인 큐</strong>는 &#8220;아직 결정되지 않은&#8221; 요청만 담고, 처리되는 즉시 사라짐 — 타임아웃으로 무한 대기 방지</li>
<li><strong>감사 로그</strong>는 allow/deny/ask 상관없이 모든 판정을 영구 기록 — &#8220;지금까지 있었던 모든 것&#8221;</li>
<li>34편의 LaunchAgent로 이 전체를 백그라운드 서비스로 상시 실행</li>
</ul>
<p>8부가 여기서 마무리됩니다. 다음 편부터는 마지막 9부 <strong>macOS 시스템 API</strong>로, Apple Events를 이용한 다른 앱 제어부터 시작합니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-39%ed%8e%b8-%ec%a0%84%ec%b2%b4-%ec%a1%b0%ed%95%a9-hook-%ec%8b%9c%ec%8a%a4%ed%85%9c-%ec%99%84%ec%84%b1%ed%95%98%ea%b8%b0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 38편 — Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-38%ed%8e%b8-%ec%8a%b9%ec%9d%b8-%ed%81%90%ec%99%80-%ec%98%81%ec%86%8d%ec%84%b1/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-38%ed%8e%b8-%ec%8a%b9%ec%9d%b8-%ed%81%90%ec%99%80-%ec%98%81%ec%86%8d%ec%84%b1/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:32:40 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1356</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [37편] Claude Code 훅 시스템: PermissionRequest부터 AskUserQuestion까지 같은 문제, 다른 해법 Claude Code만 훅을 갖고 있는 게 아닙니다. Codex CLI, Gemini CLI 같은 다른 AI 코딩 에이전트들도 &#8220;위험할 수 있는 행동 전에 외부에 물어본다&#8221;는 같은 요구를 갖고 있고, 저마다의 방식으로 확장 지점을 제공합니다. 문제는 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1355">[37편] Claude Code 훅 시스템: PermissionRequest부터 AskUserQuestion까지</a></p>
</blockquote>
<h2>같은 문제, 다른 해법</h2>
<p>Claude Code만 훅을 갖고 있는 게 아닙니다. Codex CLI, Gemini CLI 같은 다른 AI 코딩 에이전트들도 &#8220;위험할 수 있는 행동 전에 외부에 물어본다&#8221;는 같은 요구를 갖고 있고, 저마다의 방식으로 확장 지점을 제공합니다. 문제는 셋 다 <strong>철학은 같은데 설정 형식과 필드 이름이 다르다</strong>는 점입니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Claude Code</th>
<th>Codex CLI</th>
<th>Gemini CLI</th>
</tr>
</thead>
<tbody>
<tr>
<td>설정 형식</td>
<td>JSON (<code>settings.json</code>)</td>
<td>TOML (<code>config.toml</code>)</td>
<td>JSON/설정 파일</td>
</tr>
<tr>
<td>확장 방식</td>
<td>이벤트별 커맨드 훅 배열</td>
<td>알림용 커맨드(notify) 지정</td>
<td>도구별 승인 설정</td>
</tr>
<tr>
<td>실행 방식</td>
<td>서브프로세스 + stdin JSON</td>
<td>서브프로세스 + 인자/환경변수</td>
<td>서브프로세스 기반 확장</td>
</tr>
<tr>
<td>판정 전달</td>
<td>종료 코드 또는 stdout JSON</td>
<td>종료 코드 위주</td>
<td>도구별 정책 응답</td>
</tr>
</tbody>
</table>
<p>구체적인 필드 이름과 세부 스펙은 각 도구가 계속 발전하고 있어 이 글보다는 각 프로젝트의 공식 문서를 항상 최신 기준으로 확인하는 게 맞습니다. 여기서는 세 도구를 관통하는 <strong>공통 구조</strong>에 집중합니다.</p>
<hr/>
<h2>공통점 — 결국 셋 다 같은 질문을 한다</h2>
<ul>
<li>&#8220;이 도구 호출을 실행해도 되는가?&#8221; — 실행 전에 반드시 묻는다</li>
<li>답을 알 수 없으면 사용자에게 넘긴다 — 자동 판정과 사람 판단 사이의 다리 역할</li>
<li>확장 로직은 별도 프로세스(스크립트)로 위임한다 — 에이전트 코어 자체를 수정하지 않고도 정책을 바꿀 수 있게</li>
</ul>
<p>이 공통 구조 덕분에, 36~37편에서 만든 &#8220;훅 스크립트 → 소켓 → 우리 앱&#8221; 아키텍처는 <strong>Claude Code 전용이 아니라 여러 CLI 도구에 재사용</strong>할 수 있습니다. 각 도구의 훅 스크립트가 받는 입력 형식만 우리 내부 모델로 변환해주면 됩니다.</p>
<hr/>
<h2>어댑터 패턴으로 통합하기</h2>
<p>도구마다 다른 입력 형식을 하나의 공통 타입으로 변환하는 계층을 두면, 뒤쪽의 정책 엔진·승인 큐는 어떤 CLI에서 온 요청인지 신경 쓸 필요가 없어집니다.</p>
<pre><code class="language-swift">// 세 도구가 공유할 내부 표준 모델
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()
    }
}
</code></pre>
<p>Codex CLI나 Gemini CLI를 지원하고 싶다면 같은 프로토콜을 채택하는 <code>CodexAdapter</code>, <code>GeminiAdapter</code>를 추가하기만 하면 됩니다. 소켓 서버(36편)는 어떤 어댑터를 거쳐 왔는지와 무관하게 <code>NormalizedHookEvent</code>만 다루고, 정책 엔진(39편)도 마찬가지입니다. 이것이 프로토콜 지향 설계(10편)와 프로토콜/제네릭이 실전에서 갖는 힘입니다 — 서로 다른 세 가지 구체 타입을 하나의 추상화 뒤에 숨겨서, 나머지 코드는 그 차이를 몰라도 되게 만듭니다.</p>
<hr/>
<h2>어느 도구를 쓰는지 어떻게 구분할까</h2>
<p>소켓 서버 입장에서는 들어온 원본 JSON의 필드 구성을 보고 어떤 어댑터로 넘길지 판단해야 합니다. 가장 간단한 방법은 연결 시점에 클라이언트(훅 스크립트)가 자신이 어떤 도구용인지 명시하도록 하는 것입니다.</p>
<pre><code class="language-swift">func selectAdapter(for providerName: String) -> ProviderAdapter? {
    switch providerName {
    case "claude-code": return ClaudeCodeAdapter()
    case "codex": return CodexAdapter()
    case "gemini": return GeminiAdapter()
    default: return nil
    }
}
</code></pre>
<p>훅 스크립트를 설치할 때 어떤 도구용 스크립트인지가 이미 정해지므로(설치 스크립트 자체를 도구별로 따로 배포), 스크립트가 소켓 연결 초반에 자신의 provider 이름을 한 번 보내주는 정도로 충분합니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>Claude Code·Codex CLI·Gemini CLI 모두 &#8220;실행 전에 외부에 물어본다&#8221;는 같은 철학을 공유하지만, 설정 형식과 필드 이름은 각자 다름</li>
<li>세부 스펙은 계속 바뀌므로 정확한 필드명은 항상 각 프로젝트의 공식 문서 기준으로 확인</li>
<li><strong>어댑터 패턴</strong>으로 도구별 원본 형식을 공통 내부 모델(<code>NormalizedHookEvent</code>)로 변환하면, 뒤쪽 로직(정책 엔진·승인 큐)이 도구 차이를 몰라도 되게 만들 수 있음</li>
<li>프로토콜 하나로 여러 구체 타입을 추상화하는 것은 10편에서 배운 프로토콜 지향 설계의 실전 사례</li>
</ul>
<p>다음 편은 8부의 마지막으로, 지금까지의 판정 로직을 <strong>정책 엔진과 감사 로그</strong>를 갖춘 완성된 승인 프록시로 조립합니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-38%ed%8e%b8-%ec%8a%b9%ec%9d%b8-%ed%81%90%ec%99%80-%ec%98%81%ec%86%8d%ec%84%b1/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 37편 — Claude Code 훅 시스템: PermissionRequest부터 AskUserQuestion까지</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-37%ed%8e%b8-%ec%a0%95%ec%b1%85-%ed%8f%89%ea%b0%80-%ec%97%94%ec%a7%84-%eb%a7%8c%eb%93%a4%ea%b8%b0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-37%ed%8e%b8-%ec%a0%95%ec%b1%85-%ed%8f%89%ea%b0%80-%ec%97%94%ec%a7%84-%eb%a7%8c%eb%93%a4%ea%b8%b0/#respond</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:32:38 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1355</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [36편] AI CLI 훅이란 무엇인가 Claude Code가 정의하는 훅 이벤트들 36편에서 훅의 일반적인 실행 계약(서브프로세스 + stdin JSON + 종료 코드)을 배웠습니다. Claude Code는 이 계약 위에 구체적인 이벤트 종류를 정의합니다. 대표적인 것들만 추려보면 이렇습니다. PreToolUse: 도구를 실행하기 직전 — 유일하게 실행 자체를 막을 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1353">[36편] AI CLI 훅이란 무엇인가</a></p>
</blockquote>
<h2>Claude Code가 정의하는 훅 이벤트들</h2>
<p>36편에서 훅의 일반적인 실행 계약(서브프로세스 + stdin JSON + 종료 코드)을 배웠습니다. Claude Code는 이 계약 위에 구체적인 이벤트 종류를 정의합니다. 대표적인 것들만 추려보면 이렇습니다.</p>
<ul>
<li><strong>PreToolUse</strong>: 도구를 실행하기 직전 — 유일하게 실행 자체를 막을 수 있는 시점</li>
<li><strong>PostToolUse</strong>: 도구 실행이 끝난 직후 — 결과를 검사하거나 후처리하는 용도</li>
<li><strong>UserPromptSubmit</strong>: 사용자가 프롬프트를 보내기 직전 — 내용을 검사하거나 컨텍스트를 덧붙일 수 있음</li>
<li><strong>Notification</strong>: 권한 요청 대기, 입력 대기 등 사용자에게 알릴 만한 상황</li>
<li><strong>Stop / SubagentStop</strong>: 에이전트(또는 서브에이전트)가 응답을 마쳤을 때</li>
</ul>
<p>이 중 가장 자주 쓰이는 것은 단연 <strong>PreToolUse</strong>입니다. 이 시리즈 8부에서 계속 다뤄온 &#8220;허용/차단/사용자 확인&#8221;이 바로 이 훅의 역할입니다.</p>
<hr/>
<h2>settings.json에 훅 등록하기</h2>
<pre><code class="language-json">{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "/usr/local/bin/my-hook-bridge" }
        ]
      }
    ]
  }
}
</code></pre>
<p><code>matcher</code>는 어떤 도구 이름에 대해서만 이 훅을 실행할지 정하는 필터입니다. <code>Bash</code>만 감시하고 싶다면 위처럼, 모든 도구를 감시하고 싶다면 <code>matcher</code>를 생략하거나 <code>"*"</code>로 둡니다. <code>hooks</code> 배열에는 실행할 커맨드를 하나 이상 나열할 수 있습니다.</p>
<hr/>
<h2>PreToolUse의 입력과 출력</h2>
<p>스크립트가 stdin으로 받는 JSON에는 대략 다음 정보가 담깁니다.</p>
<pre><code class="language-json">{
  "session_id": "abc123",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf build/" }
}
</code></pre>
<p>단순히 종료 코드 0/2만으로 답하는 대신, 표준 출력에 JSON을 써서 더 세밀하게 답할 수도 있습니다.</p>
<pre><code class="language-json">{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "ask",
    "permissionDecisionReason": "빌드 산출물 전체 삭제는 확인이 필요합니다"
  }
}
</code></pre>
<p><code>permissionDecision</code>은 <code>allow</code> / <code>deny</code> / <code>ask</code> 세 가지 값을 가질 수 있습니다 — 37편 이전(8부 전체)에서 계속 다뤄온 세 가지 판정과 정확히 대응됩니다. <code>ask</code>를 반환하면 훅 스크립트의 역할은 끝나고, 이제 Claude Code 자신이 사용자에게 직접 확인을 구하는 단계로 넘어갑니다.</p>
<hr/>
<h2>ask 판정 이후 — 사용자에게 직접 묻기</h2>
<p>PreToolUse 훅이 즉시 답을 내지 못하고 <code>ask</code>를 반환하면, Claude Code는 진행을 멈추고 사용자의 승인을 기다립니다. 이 지점이 8부 앞부분(38편에서 다룰 승인 큐)에서 만든 구조가 실제로 맞물리는 자리입니다 — 우리 앱이 훅을 통해 &#8220;ask&#8221;를 받으면, 그 요청을 큐에 넣고 사용자가 결정할 때까지 대기시킵니다.</p>
<p>한편 <strong>도구 호출 자체가 아니라, Claude Code가 작업 중 사용자에게 명확한 선택지를 주고 싶을 때</strong> 호출하는 별도의 내장 도구가 <code>AskUserQuestion</code>입니다. &#8220;이 두 라이브러리 중 어느 걸 쓸까요?&#8221; 같은 질문을 구조화된 선택지로 사용자에게 던지는 용도로, PreToolUse가 막은 위험한 명령에 대한 승인/거부와는 성격이 다릅니다 — 전자는 안전을 위해 실행을 멈추는 것이고, 후자는 애초에 여러 선택지 중 사용자의 의사를 묻는 것입니다. 하지만 둘 다 &#8220;AI가 확신이 없을 때 진행을 멈추고 사람에게 묻는다&#8221;는 같은 철학을 공유합니다.</p>
<hr/>
<h2>PostToolUse로 결과 검사하기</h2>
<p>PreToolUse가 &#8220;실행해도 되는가&#8221;를 묻는다면, PostToolUse는 &#8220;실행한 결과가 괜찮은가&#8221;를 검사하는 자리입니다. 예를 들어 파일 쓰기 도구 실행 직후 린터를 돌려서 문제가 있으면 Claude에게 다시 피드백을 주는 용도로 쓸 수 있습니다.</p>
<pre><code class="language-python">#!/usr/bin/env python3
import json
import sys

event = json.load(sys.stdin)

if event.get("tool_name") == "Write":
    file_path = event.get("tool_input", {}).get("file_path", "")
    if file_path.endswith(".py"):
        # 예: 방금 쓴 파일에 대해 린터 실행 후 문제가 있으면 stderr로 피드백
        pass

sys.exit(0)
</code></pre>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>Claude Code는 <strong>PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop</strong> 등 구체적인 훅 이벤트를 정의</li>
<li><code>settings.json</code>의 <code>matcher</code>로 특정 도구에만 훅을 걸 수 있음</li>
<li>PreToolUse 출력의 <code>permissionDecision</code>(<code>allow</code>/<code>deny</code>/<code>ask</code>)이 8부 전체에서 다뤄온 세 가지 판정과 정확히 대응</li>
<li><code>ask</code> 판정은 Claude Code가 직접 사용자 확인을 구하는 단계로 넘어감 — 승인 큐(38편)와 맞물리는 지점</li>
<li><code>AskUserQuestion</code>은 위험 차단용 승인과는 별개로, 여러 선택지 중 사용자 의사를 구조화해서 묻는 내장 도구</li>
<li>PostToolUse로 실행 &#8220;이후&#8221; 결과를 검사해 Claude에게 피드백을 줄 수 있음</li>
</ul>
<p>다음 편에서는 같은 철학을 공유하지만 형식이 다른 <strong>Codex CLI와 Gemini CLI의 훅</strong>을 비교해봅니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-37%ed%8e%b8-%ec%a0%95%ec%b1%85-%ed%8f%89%ea%b0%80-%ec%97%94%ec%a7%84-%eb%a7%8c%eb%93%a4%ea%b8%b0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 36편 — AI CLI 훅이란 무엇인가</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-36%ed%8e%b8-hook-%ec%9d%b4%eb%b2%a4%ed%8a%b8-%ec%88%98%ec%8b%a0%ed%95%98%ea%b8%b0/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-36%ed%8e%b8-hook-%ec%9d%b4%eb%b2%a4%ed%8a%b8-%ec%88%98%ec%8b%a0%ed%95%98%ea%b8%b0/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:32:35 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1353</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [35편] AVFoundation과 사운드 재생 훅의 실행 계약 — 프로세스로 실행되고, 종료 코드로 답한다 Claude Code, Codex CLI, Gemini CLI 같은 AI 코딩 도구는 도구를 호출하기 직전이나 직후 같은 특정 시점에 사용자가 등록해둔 스크립트를 서브프로세스로 실행합니다. 32편에서 배운 Process가 정확히 이 역할을 합니다 — 다만 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1348">[35편] AVFoundation과 사운드 재생</a></p>
</blockquote>
<h2>훅의 실행 계약 — 프로세스로 실행되고, 종료 코드로 답한다</h2>
<p>Claude Code, Codex CLI, Gemini CLI 같은 AI 코딩 도구는 도구를 호출하기 직전이나 직후 같은 특정 시점에 사용자가 등록해둔 스크립트를 <strong>서브프로세스로 실행</strong>합니다. 32편에서 배운 <code>Process</code>가 정확히 이 역할을 합니다 — 다만 이번엔 우리 앱이 아니라 AI CLI 도구 자신이 <code>Process</code>를 써서 우리가 등록한 스크립트를 실행하는 쪽입니다.</p>
<p>계약은 단순합니다.</p>
<ul>
<li>AI 도구가 이벤트 정보를 <strong>JSON으로 인코딩해 표준 입력(stdin)</strong>에 흘려보낸다</li>
<li>스크립트는 그 내용을 읽고 판단한 뒤, <strong>종료 코드</strong>로 답한다
<ul>
<li><code>0</code>: 승인 — 그대로 진행</li>
<li><code>2</code>: 차단 — 표준 에러(stderr)에 쓴 메시지가 AI에게 그대로 전달되어 재고하게 만듦</li>
<li>그 외: 훅 자체의 오류로 처리 — 진행은 막지 않되 로그를 남김</li>
</ul>
</li>
<li>필요하다면 표준 출력(stdout)에 JSON을 써서 더 세밀한 결정(허용/차단/사용자에게 물어보기)을 전달할 수도 있다</li>
</ul>
<pre><code class="language-python">#!/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)  # 승인
</code></pre>
<p>이 스크립트 하나만으로도 훅은 완성됩니다. AI 도구 입장에서는 &#8220;이 명령을 실행해도 되는지 물어봤더니 0(예)이 왔다&#8221; 이상도 이하도 아닙니다. 문제는 판단 로직이 복잡해질수록 이 스크립트 하나에 모든 규칙을 우겨넣기 어려워진다는 점입니다.</p>
<hr/>
<h2>스크립트 뒤에 진짜 앱을 두고 싶다면</h2>
<p>규칙이 몇 줄이라면 스크립트 안에 그대로 적어도 되지만, 정책이 계속 바뀌고 사용자에게 직접 승인을 물어봐야 하는 경우(뒤에서 다룰 <code>ask</code> 판정)까지 생기면 이야기가 달라집니다. 매번 스크립트를 고쳐 배포하는 대신, <strong>스크립트는 얇은 클라이언트로만 두고 실제 판단은 상시 실행 중인 앱에 위임</strong>하는 구조가 훨씬 유지보수하기 쉽습니다.</p>
<pre><code>[AI CLI 도구] --(서브프로세스 실행, stdin JSON)--> [훅 스크립트] --(소켓 연결)--> [내 앱의 소켓 서버]
                                                                                      |
                                                                            이벤트 파싱 + 판정
                                                                                      |
                                                    <-- allow/deny/ask 응답 --
                                                          |
                                                     종료 코드로 변환
                                                          |
                                                 [AI CLI 도구가 진행/중단]
</code></pre>
<p>스크립트가 AI 도구와 주고받는 계약(stdin JSON, 종료 코드)은 그대로 유지하면서, 스크립트 내부에서는 6부(29~31편)에서 만든 <strong>Unix Domain Socket + 길이-접두사 프레이밍</strong>으로 우리 앱과 통신합니다. 즉 AI 도구 쪽에서 보면 여전히 "짧게 실행되고 종료 코드로 답하는 프로세스"일 뿐이지만, 그 프로세스는 내부적으로 훨씬 정교한 판단을 상시 앱에 물어보고 있는 것입니다.</p>
<hr/>
<h2>Unix Domain Socket 서버 만들기</h2>
<p>이 절부터는 스크립트가 연결해올 <strong>서버</strong> 쪽, 즉 우리 macOS 앱이 구현할 코드입니다.</p>
<pre><code class="language-swift">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&lt;sockaddr_un&gt;.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) }
        // 다음 절에서 이어집니다
    }
}
</code></pre>
<p><code>accept()</code>는 새 연결이 들어올 때까지 블로킹됩니다. 그래서 별도 큐에서 무한 루프를 돌리고, 연결마다 다시 별도 작업으로 넘겨서 <strong>한 클라이언트의 느린 처리가 다른 클라이언트를 막지 않도록</strong> 합니다.</p>
<hr/>
<h2>프레임 디코딩과 이벤트 파싱</h2>
<p>31편에서 만든 <code>FrameDecoder</code>를 그대로 재사용합니다. 소켓에서 읽은 바이트를 흘려보내면, 완성된 메시지 단위로 잘라줍니다.</p>
<pre><code class="language-swift">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)
        }
    }
}
</code></pre>
<p>스크립트는 이벤트 하나를 보내고 응답 하나를 받으면 종료하는 짧은 수명이지만, 서버 코드 자체는 여러 메시지가 한 연결에서 올 수 있다고 가정해두는 편이 안전합니다.</p>
<hr/>
<h2>동시성 다루기</h2>
<p>여러 도구 호출이 짧은 시간에 겹쳐 일어나면, 여러 연결이 동시에 들어옵니다. <code>DispatchQueue(attributes: .concurrent)</code>로 넘긴 작업들은 병렬로 실행되므로, 이벤트를 처리하는 로직이 공유 상태를 건드린다면 그 부분만큼은 직렬 큐나 락으로 보호해야 합니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li>훅의 진짜 계약은 <strong>서브프로세스 실행 + stdin JSON + 종료 코드(0/2/기타)</strong> — AI 도구 입장에서 훅은 그 이상도 이하도 아님</li>
<li>규칙이 복잡해지면 스크립트를 <strong>얇은 클라이언트</strong>로 두고, 실제 판단은 상시 실행 중인 앱에 위임하는 편이 유리</li>
<li>스크립트 <img src="https://s.w.org/images/core/emoji/16.0.1/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 앱 통신에는 6부의 <strong>Unix Domain Socket + 길이-접두사 프레이밍</strong>을 그대로 사용</li>
<li>서버는 <code>socket → bind → listen → accept</code> 순으로 구성, 연결마다 별도 작업으로 처리</li>
<li>31편의 <code>FrameDecoder</code>를 재사용해 바이트 스트림에서 완성된 JSON 이벤트를 추출</li>
</ul>
<p>다음 편에서는 Claude Code가 실제로 어떤 훅 이벤트들을 정의하고 있는지, 그리고 그 판정이 어떻게 사용자에게 직접 물어보는 흐름(AskUserQuestion)까지 이어지는지 살펴봅니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-36%ed%8e%b8-hook-%ec%9d%b4%eb%b2%a4%ed%8a%b8-%ec%88%98%ec%8b%a0%ed%95%98%ea%b8%b0/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 35편 — AVFoundation과 사운드 재생</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-35%ed%8e%b8-avfoundation%ea%b3%bc-%ec%82%ac%ec%9a%b4%eb%93%9c-%ec%9e%ac%ec%83%9d/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-35%ed%8e%b8-avfoundation%ea%b3%bc-%ec%82%ac%ec%9a%b4%eb%93%9c-%ec%9e%ac%ec%83%9d/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:27:53 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1348</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [34편] LaunchAgent와 백그라운드 데몬 AVAudioPlayer로 소리 재생하기 macOS에서 짧은 효과음이나 알림음을 재생하는 가장 간단한 방법은 AVFoundation의 AVAudioPlayer입니다. 스트리밍이 필요 없는 로컬 파일 재생에 적합합니다. import AVFoundation final class SoundPlayer { private var player: AVAudioPlayer? func play(fileURL: URL, volume: Float = 1.0) { do { [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1347">[34편] LaunchAgent와 백그라운드 데몬</a></p>
</blockquote>
<h2>AVAudioPlayer로 소리 재생하기</h2>
<p>macOS에서 짧은 효과음이나 알림음을 재생하는 가장 간단한 방법은 AVFoundation의 <code>AVAudioPlayer</code>입니다. 스트리밍이 필요 없는 로컬 파일 재생에 적합합니다.</p>
<pre><code class="language-swift">import AVFoundation

final class SoundPlayer {
    private var player: AVAudioPlayer?

    func play(fileURL: URL, volume: Float = 1.0) {
        do {
            let player = try AVAudioPlayer(contentsOf: fileURL)
            player.volume = volume
            player.prepareToPlay()
            player.play()
            self.player = player  // 재생이 끝나기 전에 해제되지 않도록 참조 유지
        } catch {
            print("사운드 재생 실패: \(error)")
        }
    }
}
</code></pre>
<p>여기서 흔히 놓치는 부분이 <code>self.player = player</code>입니다. <code>AVAudioPlayer</code> 인스턴스가 로컬 변수로만 존재하면, 재생이 끝나기도 전에 ARC가 이를 해제해버려 소리가 갑자기 끊깁니다. 재생이 끝날 때까지 어딘가 강한 참조를 들고 있어야 합니다.</p>
<hr/>
<h2>여러 사운드를 동시에 재생하기</h2>
<p>플레이어 하나로는 겹치는 소리를 재생할 수 없습니다(새 재생이 이전 것을 끊어버립니다). 이벤트가 빠르게 연달아 발생하는 앱이라면 플레이어를 여러 개 풀(pool)로 관리하는 편이 낫습니다.</p>
<pre><code class="language-swift">private var delegateKey: UInt8 = 0  // objc_setAssociatedObject용 고유 키

final class SoundPool {
    private var activePlayers: Set&lt;AVAudioPlayer&gt; = []
    private let queue = DispatchQueue(label: "soundpool.sync")

    func play(fileURL: URL, volume: Float = 1.0) {
        guard let player = try? AVAudioPlayer(contentsOf: fileURL) else { return }
        player.volume = volume
        player.prepareToPlay()

        let delegate = CompletionDelegate { [weak self] finishedPlayer in
            self?.queue.sync {
                self?.activePlayers.remove(finishedPlayer)
            }
        }
        player.delegate = delegate
        objc_setAssociatedObject(player, &delegateKey, delegate, .OBJC_ASSOCIATION_RETAIN)

        queue.sync { activePlayers.insert(player) }
        player.play()
    }
}
</code></pre>
<p><code>AVAudioPlayer</code>는 <code>Hashable</code>이 아니므로 실제 코드에서는 배열이나 딕셔너리로 관리하는 경우가 더 흔하지만, 핵심 아이디어는 같습니다. <strong>재생이 끝난 플레이어를 델리게이트 콜백에서 정리</strong>해서 무한정 쌓이지 않게 합니다.</p>
<hr/>
<h2>사운드팩 구조 설계하기</h2>
<p>이벤트별로 다른 소리를 매핑하는 &#8220;사운드팩&#8221; 기능을 만든다고 생각해봅시다. 성공, 실패, 알림 같은 카테고리에 각각 오디오 파일을 매핑합니다.</p>
<pre><code class="language-swift">enum SoundEvent: String {
    case success
    case failure
    case notification
}

struct SoundPack {
    let name: String
    let mapping: [SoundEvent: URL]

    func url(for event: SoundEvent) -> URL? {
        mapping[event]
    }
}

final class SoundEngine {
    private let pool = SoundPool()
    private var currentPack: SoundPack?
    private var isMuted = false

    func loadPack(_ pack: SoundPack) {
        currentPack = pack
    }

    func trigger(_ event: SoundEvent, volume: Float = 1.0) {
        guard !isMuted, let pack = currentPack, let url = pack.url(for: event) else { return }
        pool.play(fileURL: url, volume: volume)
    }
}
</code></pre>
<p>이렇게 이벤트와 파일을 분리해두면, 사용자가 사운드팩을 통째로 교체하거나 특정 이벤트만 음소거하는 기능을 붙이기 쉬워집니다.</p>
<hr/>
<h2>디바운스 — 짧은 시간에 같은 소리가 몰릴 때</h2>
<p>같은 이벤트가 짧은 시간 안에 여러 번 발생하면(예: 빠르게 연속된 알림), 매번 소리를 재생하는 대신 일정 시간 안의 중복은 걸러주는 것이 사용자 경험에 낫습니다.</p>
<pre><code class="language-swift">final class DebouncedSoundEngine {
    private let engine = SoundEngine()
    private var lastTriggered: [SoundEvent: Date] = [:]
    private let minInterval: TimeInterval = 0.3

    func trigger(_ event: SoundEvent) {
        let now = Date()
        if let last = lastTriggered[event], now.timeIntervalSince(last) < minInterval {
            return  // 너무 최근에 울렸으므로 무시
        }
        lastTriggered[event] = now
        engine.trigger(event)
    }
}
</code></pre>
<p>디바운스 간격은 너무 짧으면 효과가 없고, 너무 길면 정말 연속으로 알려야 할 이벤트까지 묻어버립니다. 실제 이벤트 발생 패턴을 관찰하며 값을 조정하세요.</p>
<hr/>
<h2>비동기 로딩과 캐싱</h2>
<p>사운드팩의 오디오 파일이 많아지면, 재생 시점마다 <code>AVAudioPlayer(contentsOf:)</code>로 매번 디스크에서 읽는 대신 미리 로드해두는 편이 지연을 줄입니다.</p>
<pre><code class="language-swift">final class PreloadedSoundCache {
    private var cache: [URL: Data] = [:]

    func preload(_ urls: [URL]) {
        for url in urls {
            guard cache[url] == nil, let data = try? Data(contentsOf: url) else { continue }
            cache[url] = data
        }
    }

    func player(for url: URL) -> AVAudioPlayer? {
        guard let data = cache[url] else {
            return try? AVAudioPlayer(contentsOf: url)  // 캐시 미스 시 폴백
        }
        return try? AVAudioPlayer(data: data)
    }
}
</code></pre>
<p><code>AVAudioPlayer(data:)</code>는 이미 메모리에 올라온 <code>Data</code>로 플레이어를 만듭니다. 사운드팩을 앱 시작 시 한 번 <code>preload</code>해두면, 실제 이벤트가 발생했을 때는 디스크 접근 없이 바로 재생할 수 있습니다.</p>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><code>AVAudioPlayer</code>로 로컬 오디오 파일 재생 — 재생 중 참조를 반드시 유지해야 함</li>
<li>여러 소리를 동시에 재생하려면 플레이어를 <strong>풀</strong>로 관리하고, 델리게이트에서 종료된 플레이어를 정리</li>
<li>이벤트 → 파일 매핑을 분리한 <strong>사운드팩</strong> 구조로 확장성 확보</li>
<li>짧은 시간 내 중복 재생은 <strong>디바운스</strong>로 걸러 사용자 경험 개선</li>
<li>자주 쓰는 사운드는 <strong>미리 로드(preload)</strong>해서 재생 지연 최소화</li>
</ul>
<p>이것으로 7부 시스템 프로그래밍이 완결됩니다(Process, PTY, LaunchAgent, AVFoundation). 다음 편부터는 8부 <strong>AI CLI 훅 시스템</strong>으로, 훅의 실행 계약부터 Claude Code·Codex CLI·Gemini CLI의 실제 구조, 그리고 정책 엔진과 감사 로그를 갖춘 승인 프록시까지 다룹니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-35%ed%8e%b8-avfoundation%ea%b3%bc-%ec%82%ac%ec%9a%b4%eb%93%9c-%ec%9e%ac%ec%83%9d/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>[Swift 입문] 34편 — LaunchAgent와 백그라운드 데몬</title>
		<link>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-34%ed%8e%b8-launchagent%ec%99%80-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%eb%8d%b0%eb%aa%ac/</link>
					<comments>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-34%ed%8e%b8-launchagent%ec%99%80-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%eb%8d%b0%eb%aa%ac/#comments</comments>
		
		<dc:creator><![CDATA[낭창]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 09:27:50 +0000</pubDate>
				<category><![CDATA[프로그래밍 이야기]]></category>
		<guid isPermaLink="false">https://nangchang.nes.or.kr/?p=1347</guid>

					<description><![CDATA[🤖 이 글은 Claude Code(AI)가 작성합니다. &#124; 시리즈 목차 &#124; 이전: [33편] PTY와 가상 터미널 launchd — macOS의 프로세스 관리자 macOS는 cron이나 init.d 같은 전통적인 유닉스 도구 대신 launchd 하나로 부팅 프로세스부터 사용자 로그인, 백그라운드 서비스, 주기적 작업까지 전부 관리합니다. launchd에게 &#8220;이 프로그램을 이런 조건에서 실행해줘&#8221;라고 알려주는 방법이 plist(property list) 파일입니다. 이 plist를 어디에 두느냐에 [&#8230;]]]></description>
										<content:encoded><![CDATA[<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 이 글은 <a href="https://claude.ai/claude-code">Claude Code</a>(AI)가 작성합니다. | <a href="https://nangchang.nes.or.kr/?p=1085">시리즈 목차</a> | 이전: <a href="https://nangchang.nes.or.kr/?p=1346">[33편] PTY와 가상 터미널</a></p>
</blockquote>
<h2>launchd — macOS의 프로세스 관리자</h2>
<p>macOS는 cron이나 init.d 같은 전통적인 유닉스 도구 대신 <strong>launchd</strong> 하나로 부팅 프로세스부터 사용자 로그인, 백그라운드 서비스, 주기적 작업까지 전부 관리합니다. launchd에게 &#8220;이 프로그램을 이런 조건에서 실행해줘&#8221;라고 알려주는 방법이 <strong>plist(property list) 파일</strong>입니다.</p>
<p>이 plist를 어디에 두느냐에 따라 역할이 갈립니다.</p>
<ul>
<li><strong>LaunchAgent</strong>: <code>~/Library/LaunchAgents/</code> — 특정 사용자가 로그인했을 때 그 사용자 권한으로 실행. GUI 접근 가능</li>
<li><strong>LaunchDaemon</strong>: <code>/Library/LaunchDaemons/</code> — 로그인 여부와 무관하게 시스템 부팅 시 root 권한으로 실행. GUI 접근 불가</li>
</ul>
<p>메뉴바 앱이나 백그라운드 브리지처럼 &#8220;사용자가 로그인해 있는 동안 계속 떠 있어야 하는&#8221; 도구는 대부분 LaunchAgent를 씁니다.</p>
<hr/>
<h2>plist 기본 구조</h2>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
    "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&gt;
&lt;plist version="1.0"&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.example.mybridge&lt;/string&gt;

    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/Users/me/bin/mybridge&lt;/string&gt;
        &lt;string&gt;--daemon&lt;/string&gt;
    &lt;/array&gt;

    &lt;key&gt;RunAtLoad&lt;/key&gt;
    &lt;true/&gt;

    &lt;key&gt;KeepAlive&lt;/key&gt;
    &lt;true/&gt;

    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/tmp/mybridge.log&lt;/string&gt;

    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/tmp/mybridge.error.log&lt;/string&gt;
&lt;/dict&gt;
&lt;/plist&gt;
</code></pre>
<p>핵심 키들을 짚어보면:</p>
<ul>
<li><strong>Label</strong>: 이 작업의 고유 식별자. 역도메인 표기(<code>com.example.xxx</code>)가 관례이며, <code>launchctl</code> 명령에서 이 이름으로 작업을 지칭합니다.</li>
<li><strong>ProgramArguments</strong>: 실행할 프로그램과 인자. 배열의 첫 항목이 실행 파일 경로입니다.</li>
<li><strong>RunAtLoad</strong>: launchd가 이 plist를 로드하는 즉시(보통 로그인 시점) 한 번 실행할지 여부.</li>
<li><strong>KeepAlive</strong>: 프로세스가 죽으면 launchd가 자동으로 재시작할지 여부. <code>true</code>로 두면 크래시나 강제 종료 시에도 계속 되살아납니다.</li>
<li><strong>StandardOutPath / StandardErrorPath</strong>: 표준 출력/에러를 리다이렉트할 파일. 백그라운드로 뜨는 프로세스는 터미널이 없으므로, 로그를 보려면 반드시 지정해야 합니다.</li>
</ul>
<hr/>
<h2>launchctl로 제어하기</h2>
<pre><code class="language-bash"># 등록 (최신 문법, macOS 10.11+)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.mybridge.plist

# 상태 확인
launchctl print gui/$(id -u)/com.example.mybridge

# 해제
launchctl bootout gui/$(id -u)/com.example.mybridge

# 예전 문법 (여전히 널리 쓰임)
launchctl load ~/Library/LaunchAgents/com.example.mybridge.plist
launchctl unload ~/Library/LaunchAgents/com.example.mybridge.plist
</code></pre>
<p><code>load</code>/<code>unload</code>는 오래된 문법이지만 여전히 동작하고 스크립트에서 흔히 볼 수 있습니다. <code>bootstrap</code>/<code>bootout</code>은 launchd가 관리하는 여러 도메인(시스템 전체, 특정 사용자 GUI 세션 등)을 명시적으로 지정할 수 있어 더 정확합니다. <code>gui/$(id -u)</code>는 &#8220;현재 로그인한 사용자의 GUI 세션&#8221;을 가리킵니다.</p>
<hr/>
<h2>설치 스크립트 자동화하기</h2>
<p>사용자가 매번 plist를 손으로 복사하게 만들 필요는 없습니다. 셸 스크립트 한 번으로 plist를 생성하고 등록까지 끝내는 편이 좋습니다.</p>
<pre><code class="language-bash">#!/bin/bash
set -euo pipefail

LABEL="com.example.mybridge"
PLIST_PATH="$HOME/Library/LaunchAgents/${LABEL}.plist"
BIN_PATH="$HOME/bin/mybridge"

mkdir -p "$HOME/Library/LaunchAgents"

cat > "$PLIST_PATH" << EOF
&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
    "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&gt;
&lt;plist version="1.0"&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;${LABEL}&lt;/string&gt;
    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;${BIN_PATH}&lt;/string&gt;
    &lt;/array&gt;
    &lt;key&gt;RunAtLoad&lt;/key&gt;
    &lt;true/&gt;
    &lt;key&gt;KeepAlive&lt;/key&gt;
    &lt;true/&gt;
&lt;/dict&gt;
&lt;/plist&gt;
EOF

# 이미 등록되어 있으면 먼저 내리고 다시 올린다 (idempotent하게)
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"

echo "설치 완료: ${PLIST_PATH}"
</code></pre>
<p><code>bootout</code>을 실패해도 무시하는 <code>|| true</code> 처리가 중요합니다. 처음 설치하는 경우에는 아직 등록된 게 없어서 <code>bootout</code>이 실패하는 게 정상이기 때문입니다. 스크립트를 몇 번 실행해도 같은 결과가 나오는 <strong>idempotent</strong>한 설치 스크립트를 만드는 습관을 들이세요.</p>
<hr/>
<h2>흔한 문제들</h2>
<ul>
<li><strong>경로 문제</strong>: LaunchAgent로 실행되는 프로세스는 셸 로그인 과정을 거치지 않으므로 <code>.zshrc</code>의 <code>PATH</code> 설정이 적용되지 않습니다. <code>ProgramArguments</code>에는 항상 절대 경로를 쓰세요.</li>
<li><strong>plist 문법 오류</strong>: <code>plutil -lint 파일.plist</code>로 미리 검증하면 launchd가 조용히 무시해버리는 상황을 피할 수 있습니다.</li>
<li><strong>재시작 폭주</strong>: <code>KeepAlive</code>가 켜진 상태에서 프로그램이 시작하자마자 계속 크래시하면, launchd가 짧은 간격으로 재시작을 반복하다가 결국 &#8220;너무 빨리 재시작한다&#8221;며 포기합니다. <code>ThrottleInterval</code> 키로 최소 재시작 간격을 지정할 수 있습니다.</li>
<li><strong>변경 사항 미반영</strong>: plist를 수정한 뒤에는 <code>bootout</code> 후 <code>bootstrap</code>으로 다시 로드해야 합니다. launchd는 실행 중인 작업의 plist가 바뀌었다고 자동으로 알아채지 않습니다.</li>
</ul>
<hr/>
<h2>핵심 요약</h2>
<ul>
<li><strong>launchd</strong>가 macOS의 모든 백그라운드 프로세스를 관리 — cron/init.d를 대체</li>
<li><strong>LaunchAgent</strong>(사용자 세션, GUI 가능) vs <strong>LaunchDaemon</strong>(시스템 전체, root)</li>
<li>plist의 <code>Label</code>·<code>ProgramArguments</code>·<code>RunAtLoad</code>·<code>KeepAlive</code>가 핵심 설정</li>
<li><code>StandardOutPath</code>/<code>StandardErrorPath</code>로 로그를 리다이렉트해야 백그라운드 프로세스 상태를 확인할 수 있다</li>
<li>설치 스크립트는 <code>bootout || true</code> 패턴으로 idempotent하게 작성</li>
<li>PATH에 의존하지 말고 절대 경로 사용, <code>plutil -lint</code>로 plist 검증</li>
</ul>
<p>다음 편에서는 7부의 마지막 주제인 <strong>AVFoundation과 사운드 재생</strong>을 다룹니다. 이벤트별로 다른 소리를 매핑하는 사운드팩 구조를 함께 설계해봅니다.</p>
<blockquote>
<p><img src="https://s.w.org/images/core/emoji/16.0.1/72x72/1f916.png" alt="🤖" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Generated with <a href="https://claude.ai/claude-code">Claude Code</a></p>
</blockquote>
]]></content:encoded>
					
					<wfw:commentRss>https://nangchang.nes.or.kr/swift-%ec%9e%85%eb%ac%b8-34%ed%8e%b8-launchagent%ec%99%80-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%eb%8d%b0%eb%aa%ac/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
	</channel>
</rss>
