🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [42편] UserNotifications와 시스템 알림 센터
왜 프로세스를 나누는가
앱의 모든 코드가 같은 프로세스, 같은 권한으로 실행되면 한 곳의 취약점이 앱 전체의 권한을 위협합니다. 예를 들어 네트워크에서 받은 데이터를 파싱하는 코드에 버그가 있다면, 그 코드가 파일 시스템 전체에 접근할 수 있는 권한까지 가지고 있어서는 안 됩니다.
최소 권한 원칙(principle of least privilege)에 따라, 위험하거나 민감한 작업을 별도의 프로세스로 분리하고 그 프로세스에는 꼭 필요한 권한만 부여하는 설계가 있습니다. macOS에서 이런 프로세스 간 통신을 표준화한 것이 XPC입니다.
29~31편에서 Unix Domain Socket으로 직접 프레이밍 프로토콜을 만들었던 것을 떠올려보세요. XPC는 그 과정(연결, 직렬화, 프레이밍, 오류 복구)을 Apple이 대신 처리해주는, 앱 내부 컴포넌트 간 통신에 특화된 상위 계층입니다.
XPC vs 직접 만든 소켓 프로토콜
| Unix Domain Socket (29~31편) | XPC | |
|---|---|---|
| 용도 | 임의의 두 프로세스(다른 언어 포함) 간 통신 | 같은 앱 생태계 내 컴포넌트 간 통신 |
| 직렬화 | 직접 설계(JSON + 길이-접두사) | Codable 기반 자동 처리 |
| 연결 관리 | 직접 구현 | launchd가 필요 시 자동으로 서비스 실행 |
| 보안 경계 | 직접 검증 필요 | 코드 서명 검증을 시스템이 자동 수행 |
8부에서 만든 hook 시스템처럼 Python 스크립트 등 다른 언어와도 통신해야 한다면 소켓 프로토콜이 맞는 선택입니다. 반면 “내 macOS 앱 안에서 권한이 다른 두 부분을 나누고 싶다”는 상황이라면 XPC가 훨씬 적은 코드로 안전하게 해결해줍니다.
프로토콜 정의하기
메인 앱과 XPC 서비스가 공유하는 인터페이스를 프로토콜로 정의합니다.
import Foundation
@objc protocol PrivilegedTaskProtocol {
func writeSystemFile(atPath path: String, contents: Data, reply: @escaping (Bool, String?) -> Void)
}
XPC는 Objective-C 런타임 기반이라 @objc가 필요하고, 반환값은 콜백(reply 클로저)으로 전달합니다. 값을 그냥 return할 수 없는 이유는 실제 호출이 프로세스 경계를 넘어 비동기로 일어나기 때문입니다.
XPC 서비스 쪽 구현
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()
메인 앱에서 연결하기
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)
}
}
interruptionHandler와 invalidationHandler를 구분하는 것이 중요합니다. interruption은 서비스 프로세스가 죽었지만 연결 객체 자체는 아직 살아있어서 재시도할 수 있는 상태이고, invalidation은 연결이 완전히 무효화되어 새로 만들어야 하는 상태입니다. 8부의 승인 큐에서 다뤘던 “타임아웃과 재시도” 개념이 여기서도 똑같이 적용됩니다 — 원격 호출은 언제든 실패할 수 있다고 가정하고 설계해야 합니다.
보안 경계가 실제로 의미하는 것
XPC 서비스는 메인 앱과 별도의 샌드박스 엔타이틀먼트를 가질 수 있습니다. 예를 들어 메인 앱은 네트워크 접근이 필요 없고, 파일을 쓰는 헬퍼만 디스크 접근 권한이 필요하다면, 두 프로세스에 서로 다른 .entitlements 파일을 부여합니다. 이렇게 하면 메인 앱 쪽 코드에 취약점이 있어도 공격자가 파일 시스템에 직접 쓸 수 있는 권한까지 얻지는 못합니다 — 얻을 수 있는 최대치는 “XPC 서비스가 노출한 제한된 인터페이스를 호출하는 것”뿐입니다.
핵심 요약
- 최소 권한 원칙에 따라 민감한 작업은 별도 프로세스(XPC 서비스)로 분리
- XPC는 29~31편에서 직접 만든 소켓 프로토콜의 상위 계층 — 직렬화·연결 관리·코드 서명 검증을 시스템이 대신 처리
- 프로토콜은
@objc로 정의하고, 결과는reply콜백으로 비동기 전달 interruptionHandler(재시도 가능)와invalidationHandler(재연결 필요)를 구분해서 처리- 서로 다른 엔타이틀먼트를 가진 프로세스로 나누면, 한쪽이 뚫려도 피해 범위가 그 프로세스의 권한으로 제한됨
43강 시리즈를 마치며
Swift 문법조차 몰랐던 지점에서 시작해, 언어 기초 → SwiftUI → macOS 앱 아키텍처 → 로컬 데이터베이스 → 프로세스 간 통신 → 시스템 프로그래밍 → AI CLI 훅 시스템 → macOS 시스템 API까지 아홉 개 부(部)를 거쳐 여기까지 왔습니다.
- 1부 Swift 언어 기초 (1~10편)
- 2부 Swift 고급 주제 (11~14편)
- 3부 SwiftUI (15~19편)
- 4부 macOS 앱 개발 (20~24편)
- 5부 Foundation과 데이터 저장 (25~28편)
- 6부 네트워킹과 IPC (29~31편)
- 7부 시스템 프로그래밍 (32~35편)
- 8부 AI CLI 훅 시스템 (36~39편)
- 9부 macOS 시스템 API (40~43편)
이 시리즈의 목표는 각 개념을 따로따로 암기시키는 것이 아니라, 뒤로 갈수록 앞에서 배운 조각들을 계속 다시 꺼내 쓰도록 구성하는 것이었습니다. 소켓 프레이밍(31편)이 hook 시스템(36편)의 기반이 되고, SQLite(28편)가 승인 큐(39편)의 영속성을 담당하고, LaunchAgent(34편)가 그 전체를 백그라운드 서비스로 띄우는 식으로요. 실제 macOS 앱 하나가 만들어지는 과정을 그대로 따라온 셈입니다.
여기서 배운 조각들을 실제 프로젝트에 적용해보면서 계속 익혀나가시길 바랍니다.
🤖 Generated with Claude Code