[Swift 입문] 43편 — XPC와 프로세스 권한 분리

🤖 이 글은 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)
    }
}

interruptionHandlerinvalidationHandler를 구분하는 것이 중요합니다. 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

답글 남기기

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