← iOS 개발자 CS 로드맵 24 / 35

24 · 프로세스 경계와 App Extension

익스텐션이 앱이 아닌 이유, extension point가 API까지 결정하는 구조, App Group 공유 컨테이너, NSItemProvider의 프로세스 간 전달, UTI 준수 트리, 그리고 익스텐션의 자원 예산.
진행률
0 / 0 완료

P0 03장이 "iOS 앱은 프로세스로 어떻게 표현되고 샌드박스로 어떻게 격리되는가"를 다뤘다. 이 챕터는 그 격리를 실제로 마주치는 자리를 다룬다 — App Extension이다. 같은 번들 안에 들어 있고 같은 개발자가 만들었는데도 메모리를 공유할 수 없고, 파일 컨테이너가 다르고, 쓸 수 있는 API가 다르고, 자원 예산이 훨씬 빡빡하다. 여기서 겪는 문제들은 전부 "프로세스 경계란 무엇인가"의 구체적 귀결이다.

Q1. App Extension은 왜 앱이 아닌가?

🔑 30초 답변

번들 안에 .appex중첩되어 함께 배포될 뿐, 별도 바이너리이고 별도 프로세스로 독립 실행되기 때문이다. 관계는 3자다 — host app(사용자가 공유 버튼을 눌러 익스텐션을 고른 앱) → app extensioncontaining app(익스텐션을 담아 배포한 우리 앱). 익스텐션은 host하고만 직접 통신하고, containing app과 host는 서로 전혀 통신하지 않는다. 결정적으로 익스텐션이 도는 동안 컨테이닝 앱은 보통 실행조차 되어 있지 않다. 그래서 "우리 앱의 싱글턴을 쓰면 되지"가 성립하지 않는다 — 그 싱글턴이 사는 프로세스가 없다.

CS 원리

P0 03장에서 본 프로세스 격리가 그대로 적용된다. 두 프로세스는 서로 다른 가상 주소 공간을 갖고(P0 01장), 서로 다른 샌드박스 컨테이너를 가지며, 커널이 그 경계를 강제한다. 같은 팀이 만들었다는 사실은 커널에게 아무 의미가 없다.

그래서 프로세스 간에 무언가를 전달하려면 명시적 IPC 메커니즘이 필요하다. iOS 익스텐션 모델이 제공하는 통로는 정확히 두 개뿐이다.

여기서 중요한 예외가 하나 있다 — (레거시) Today 위젯만 NSExtensionContext.open(_:completionHandler:)로 컨테이닝 앱을 직접 열 수 있다. 나머지 익스텐션은 이 경로가 없다. WidgetKit 위젯은 이 API가 아니라 widgetURL·Link 딥링크로 앱을 연다.

생애주기도 앱과 근본적으로 다르다. 앱은 사용자가 종료할 때까지 살지만, 익스텐션은 요청/응답 트랜잭션이다.

  1. host가 요청을 발행한다.
  2. 시스템이 익스텐션을 인스턴스화하고 통신 채널을 연다.
  3. 익스텐션이 host의 컨텍스트 안에 뷰를 띄우고 작업한다.
  4. 사용자가 완료하거나 취소한다.
  5. 작업 직후 시스템이 익스텐션을 종료한다.

5번이 특히 중요하다. completeRequest…를 호출한 직후부터 시스템은 언제든 익스텐션을 종료할 수 있다. "완료 처리하고 나서 정리 작업을 하자"는 발상이 통하지 않는다.

host app 사진 앱 · Safari 등 사용자가 공유를 누른 앱 요청 + 아이템 완료 / 취소 app extension .appex — 별도 프로세스 별도 바이너리 · 별도 샌드박스 완료 직후 즉시 종료됨 App Group 공유 컨테이너 유일한 정식 통로 (Q3) containing app 우리 앱 본체 보통 실행돼 있지도 않다 host와 containing app은 서로 전혀 통신하지 않는다 (점선은 "연결 없음"을 나타낸다)
3자 관계에서 익스텐션만이 양쪽과 닿는다. 컨테이닝 앱은 파일을 통해서만, host는 요청 채널로만 연결된다.

iOS에서는

이 구조가 만드는 실무 결과를 정리하면 이렇다.

앱에서 되던 것익스텐션에서는이유
싱글턴에 담아둔 로그인 세션없음다른 프로세스 — 메모리 공유 불가
UserDefaults.standard다른 저장소샌드박스 컨테이너가 다름 (Q3)
UIApplication.shared접근 금지app-extension-safe 위반 (Q6)
오래 걸리는 백그라운드 작업금지URLSession 업로드/다운로드만 예외
카메라·마이크금지iMessage 앱만 예외
충분한 메모리훨씬 적음Q7

배포·심사 쪽 제약도 있다.

그리고 디버깅 방법이 다르다. 익스텐션 scheme의 Run 단계에서 host app을 executable로 지정하고 그 host UI를 통해 익스텐션을 띄워야 디버거가 붙는다. 컨테이닝 앱 scheme으로 실행하면 익스텐션에 디버거가 붙지 않는다 — "브레이크포인트가 안 걸린다"의 대부분이 이것이다.

실험 · 도구

익스텐션이 정말 별도 프로세스인지는 직접 확인할 수 있다.

import Foundation
import os

// 익스텐션과 컨테이닝 앱 양쪽에 같은 코드를 넣고 로그를 비교한다
func dumpProcessIdentity(_ where_: String) {
    let info = ProcessInfo.processInfo
    Logger(subsystem: "com.example.shop", category: "process").notice("""
        [\(where_, privacy: .public)] \
        pid=\(info.processIdentifier, privacy: .public) \
        name=\(info.processName, privacy: .public) \
        bundle=\(Bundle.main.bundleIdentifier ?? "?", privacy: .public) \
        path=\(Bundle.main.bundlePath, privacy: .public)
        """)

    // 전역 변수는 프로세스마다 별개다 — 이것이 "싱글턴이 안 통하는" 이유
    Counter.shared.increment()
    Logger(subsystem: "com.example.shop", category: "process")
        .notice("[\(where_, privacy: .public)] 싱글턴 카운터 = \(Counter.shared.value, privacy: .public)")
}

final class Counter {
    static let shared = Counter()
    private(set) var value = 0
    func increment() { value += 1 }
}
# 두 프로세스를 동시에 관찰한다
log stream --predicate 'subsystem == "com.example.shop" AND category == "process"' \
           --style compact

# 결과 예시:
# [containing app] pid=1234 name=Shop bundle=com.example.shop path=/…/Shop.app
# [share extension] pid=5678 name=ShareExt bundle=com.example.shop.share
#                              path=/…/Shop.app/PlugIns/ShareExt.appex
#   → pid가 다르다. bundle identifier도 다르다. 번들 경로는 중첩돼 있다.
#   → 싱글턴 카운터는 양쪽 다 1이다. 서로의 값을 절대 못 본다.

# 실행 중인 익스텐션 프로세스 확인 (기기 연결 상태)
log stream --predicate 'process ENDSWITH "Ext"' --style compact

생애주기의 짧음도 확인해볼 만하다.

// completeRequest 이후 코드가 실행되지 않을 수 있음을 확인한다
extension ShareViewController {
    override func didSelectPost() {
        saveToSharedContainer()          // ✅ 완료 전에 끝낸다

        extensionContext?.completeRequest(returningItems: nil) { expired in
            // ⚠️ 이 블록이 불린 뒤 시스템은 언제든 프로세스를 죽일 수 있다
            Logger(subsystem: "com.example.shop", category: "process")
                .notice("완료 콜백 expired=\(expired, privacy: .public)")
        }

        // ❌ 여기에 중요한 작업을 두면 실행되지 않을 수 있다
        uploadAnalytics()                // 실행 보장 없음
    }
}

프로젝트 적용

익스텐션을 추가할 때 설계 원칙 넷.

⚠️ 흔한 오해

"익스텐션은 앱의 일부라 앱 상태를 쓸 수 있다"가 가장 근본적인 오해다. 별도 프로세스라 메모리를 공유하지 않고, 컨테이닝 앱은 대개 실행조차 되어 있지 않다. "컨테이닝 앱을 깨워서 처리를 넘기면 된다"도 안 된다 — (레거시) Today 위젯만 NSExtensionContext.open으로 컨테이닝 앱을 직접 열 수 있고, 나머지는 그 경로가 없다(WidgetKit 위젯은 딥링크를 쓴다). "host 앱과 우리 앱이 통신한다"도 틀렸다 — 둘은 서로 전혀 통신하지 않는다. 또 "브레이크포인트가 안 걸리는 건 Xcode 버그"도 아니다 — host를 executable로 지정해야 붙는다. 마지막으로 "completeRequest 다음 줄은 당연히 실행된다"도 틀렸다 — 그 직후부터 언제든 종료될 수 있다.

🧒 쉽게 이해하기

출장 수리 기사를 생각해보자. 우리 회사(컨테이닝 앱) 소속이지만, 일할 때는 손님 집(host app)에 간다.

여기서 중요한 사실들이 있다.

  • 기사는 회사 사무실 책상을 쓸 수 없다. 손님 집에 있으니까. 사무실에 놓고 온 자료는 못 본다. (메모리 공유 불가)
  • 회사는 지금 문을 닫았을 수도 있다. 기사가 일하는 동안 사무실에 아무도 없는 게 보통이다.
  • 손님과 회사는 서로 연락하지 않는다. 손님은 기사에게만 말하고, 회사도 기사를 통해서만 안다.
  • 일이 끝나면 기사는 바로 철수한다. "끝났습니다" 하고 나서 짐 정리를 하려 해도, 이미 문이 닫혔을 수 있다.

그럼 회사와 자료를 어떻게 주고받을까? 공용 창고(App Group)에 넣어두는 방법뿐이다. 기사가 창고에 서류를 넣어두면, 나중에 사무실 직원이 창고에서 꺼내 본다. 직접 만나서 주고받는 게 아니다.

그래서 "회사 시스템에 로그인돼 있으니까 기사도 당연히 로그인 상태겠지"는 틀렸다. 기사는 매번 창고에서 출입증을 꺼내 봐야 한다.

꼬리 질문

익스텐션에서 발생한 크래시는 왜 늦게 보고되며, 아예 유실되기도 하는가?

19장 Q2에서 본 "크래시 리포터는 다음 실행 때 전송한다"는 구조가 익스텐션에서 훨씬 불리하게 작동한다.

(1) 다음 실행이 언제일지 모른다. 앱은 사용자가 자주 열지만, 공유 익스텐션은 사용자가 다시 공유를 시도할 때만 실행된다. 몇 주가 걸릴 수 있고, 그 익스텐션이 계속 크래시한다면 사용자가 다시는 안 쓸 수도 있다.

(2) 실행 시간이 너무 짧다. 익스텐션은 몇 초 만에 종료되므로, 이전 크래시 리포트를 읽어 전송할 시간이 부족할 수 있다. 특히 네트워크가 느리면 전송이 끝나기 전에 프로세스가 죽는다.

(3) 컨테이닝 앱이 대신 보내야 한다. 그래서 실무 구성은 익스텐션은 공유 컨테이너에 리포트를 쓰고, 컨테이닝 앱이 실행될 때 그것까지 전송하는 방식이다. 대부분의 SDK가 이 패턴을 지원하며, 설정에서 공유 컨테이너 식별자를 지정해야 동작한다. 이 설정을 빠뜨리면 익스텐션 크래시가 통째로 안 보인다.

결과적으로 익스텐션 크래시율은 구조적으로 과소 집계된다. 19장 Q2 꼬리질문에서 본 "심각한 크래시일수록 과소 집계"가 여기서 증폭된다.

쉽게 말하면 출장 기사가 사고 보고서를 썼는데, 다음번 출장 나갈 때 회사에 제출하는 규칙이다. 그런데 다음 출장이 언제일지 모르고, 사고 때문에 아예 출장을 안 나가게 될 수도 있다. 그래서 보고서를 공용 창고에 넣어두고 사무실 직원이 대신 제출하게 만들어야 한다.
익스텐션이 백그라운드 URLSession만 예외로 허용되는 이유는?

백그라운드 세션은 앱이나 익스텐션 프로세스가 아니라 시스템 데몬이 전송을 수행하기 때문이다. 프로세스가 종료돼도 전송은 계속되고, 완료되면 시스템이 앱을 깨워 알린다.

즉 "익스텐션이 오래 사는 것"이 아니라 일을 시스템에 넘기고 익스텐션은 죽는 것이다. 그래서 익스텐션의 짧은 생애주기 원칙과 충돌하지 않는다.

다만 조건이 붙는다.

  • 공유 컨테이너 설정이 필수다. URLSessionConfiguration.sharedContainerIdentifier에 App Group 식별자를 넣어야 양쪽이 전송된 데이터에 접근할 수 있다.
  • 세션 식별자를 프로세스마다 다르게 해야 한다. 백그라운드 세션은 한 번에 한 프로세스만 쓸 수 있으므로, 컨테이닝 앱과 각 익스텐션이 같은 식별자를 쓰면 충돌한다.
  • 세션 생성 코드가 효율적이어야 한다. completeRequest 직후 종료될 수 있으므로, 세션을 만드는 데 시간이 오래 걸리면 시작도 못 하고 죽는다.

반대로 VoIP·백그라운드 오디오 같은 다른 백그라운드 모드는 금지이고, UIBackgroundModes 키가 익스텐션 Info.plist에 있으면 앱스토어가 리젝한다.

쉽게 말하면 기사가 무거운 짐을 옮겨야 하는데, 직접 나르는 대신 택배를 부르고 퇴근한다. 택배 회사가 알아서 배달하고, 도착하면 회사에 연락이 온다. 기사가 오래 남아 있을 필요가 없다. 대신 택배 접수증에 어느 창고로 보낼지를 정확히 적어야 하고, 기사마다 접수 번호를 다르게 써야 섞이지 않는다.
공유 코드를 embedded framework로 만들 때 컨테이닝 앱 전용 코드는 어떻게 분리하는가?

프레임워크 전체가 app-extension-safe여야 하므로(Q6), 금지 API를 쓰는 코드는 프레임워크에 넣을 수 없다. 분리 전략이 셋 있다.

(1) 프레임워크를 둘로 나눈다. SharedCore(안전, 양쪽에서 링크)와 AppOnly(금지 API 사용, 컨테이닝 앱만 링크). 가장 명확하지만 프레임워크 개수가 는다.

(2) 프로토콜로 경계를 만든다. 프레임워크에는 protocol AppServices { func openSettings() }만 두고, 구현은 앱 타깃에 둔다. 프레임워크 코드는 프로토콜만 알므로 금지 API를 링크하지 않는다. P0 17장의 의존성 역전이 빌드 제약을 푸는 데 쓰이는 사례다.

(3) 조건부 컴파일. #if !EXTENSION으로 감싸고 익스텐션 타깃에 EXTENSION 플래그를 준다. 간단하지만 코드가 지저분해지고, 같은 프레임워크 바이너리를 공유할 수 없다(타깃마다 다시 컴파일해야 한다).

실무에서는 (2)를 기본으로, 규모가 커지면 (1)로 간다. (3)은 소규모 예외에만 쓴다.

그리고 빌드 설정에서 놓치기 쉬운 함정이 하나 있다 — Copy Files 빌드 페이즈의 목적지를 반드시 "Frameworks"로 골라야 한다. "SharedFramework"를 고르면 앱스토어가 제출을 리젝한다.

쉽게 말하면 공용 공구함을 만드는데, 출장 기사가 쓸 수 없는 공구가 하나라도 들어 있으면 공구함 전체를 못 가져간다. 그래서 공용 공구함과 사무실 전용 공구함을 나누거나, 공구함에는 "이런 공구가 필요하다"는 요청서만 넣어두고 실제 공구는 사무실에 두는 방식을 쓴다.

Q2. Extension point 선택이 왜 API 집합까지 결정하는가?

🔑 30초 답변

extension point는 "시스템이 확장을 허용하는 영역 하나"이고, 각 point가 사용 정책과 전용 API를 함께 정의하기 때문이다. 즉 "어디에 꽂을 것인가"를 고르는 순간 무엇을 쓸 수 있는지, 그 API가 어떻게 동작하는지까지 결정된다. Info.plistNSExtensionPointIdentifier에 역-DNS 문자열로 표기하며(com.apple.share-services, com.apple.ui-services 등), 모든 익스텐션에 필수 키다. Apple 문서가 반복하는 메시지는 "용도가 다르면 point를 바꿔라"다 — 콘텐츠를 외부로 보내면 Share, 조작·열람하면 Action, 관심 정보 업데이트를 보여주면 Today. 억지로 Share를 골라 다른 일을 시키는 것은 명시적으로 잘못된 선택으로 규정돼 있다.

CS 원리

이건 플러그인 아키텍처의 표준 설계다. 호스트 시스템이 확장 지점을 정의할 때 필요한 것은 셋이다.

extension point는 이 셋을 묶음으로 제공한다. 그래서 point를 고르는 것은 인터페이스만 고르는 게 아니라 정책 패키지 전체를 고르는 것이다.

extension point를 고르는 것 = 정책 패키지 전체를 고르는 것 Share com.apple.share-services 콘텐츠가 밖으로 Action com.apple.ui-services host에 돌려준다 Custom Keyboard com.apple.keyboard-service 전역 입력 대체 Photo Editing · File Provider … point마다 다른 묶음 각 point가 아래 셋을 한 덩어리로 정의한다 계약 — 구현할 인터페이스 NSExtensionPrincipalClass Xcode 템플릿이 곧 이 선택 정책 — 쓸 수 있는 API 전용 API · 금지 API · 자원 예산 최소 권한이 구조적으로 강제 발견 — 언제 나타나나 NSExtensionActivationRule UTI 체계 위에서 판정 (Q5) 그래서 "인터페이스만 바꿔 다른 point로 옮기기"가 안 된다 묶여 있는 API 집합과 활성화 규칙이 통째로 달라지기 때문
point 선택은 계약 · 정책 · 발견 셋을 동시에 고르는 일이다. 그래서 되돌리기가 비싸다.

왜 이렇게 묶었는지는 보안 모델로 설명된다. 만약 "모든 익스텐션은 같은 API를 쓸 수 있다"고 하면, 커스텀 키보드가 카메라를 쓰거나 위젯이 사용자 입력을 가로챌 수 있게 된다. 용도별로 권한을 좁히면 최소 권한 원칙이 구조적으로 강제된다.

발견 메커니즘도 point마다 다르다. Share 익스텐션은 공유 시트에 나타나야 하고, 그러려면 시스템이 "이 익스텐션이 이 데이터를 처리할 수 있는가"를 판정해야 한다 — 그게 NSExtensionActivationRule이고 Q5의 UTI 체계 위에서 동작한다. Today 위젯은 그런 판정이 필요 없고 대신 업데이트 주기 정책이 붙는다.

iOS에서는

주요 extension point 식별자를 정리하면 이렇다.

Extension point식별자용도
Sharecom.apple.share-services콘텐츠를 내 서비스/웹사이트로 보내기
Action (UI)com.apple.ui-services콘텐츠를 조작·열람 (UI 있음)
Action (non-UI)com.apple.services콘텐츠 변환 (UI 없음)
Today (위젯) com.apple.widget-extension관심 정보의 업데이트 표시
Custom Keyboardcom.apple.keyboard-service시스템 전역 키보드 대체
Document Pickercom.apple.fileprovider-ui문서 선택 UI
File Providercom.apple.fileprovider-nonui원격 문서 저장소 제공
Photo Editingcom.apple.photo-editing사진 앱 안에서 편집
Message Filtercom.apple.identitylookup.message-filterSMS 필터링

Today 익스텐션은 iOS 14에서 WidgetKit으로 대체되어 deprecated다. 최신 iOS에는 Today 뷰 자체가 없으므로 새로 만들 위젯은 WidgetKit이고, 컨테이닝 앱을 여는 것도 NSExtensionContext.open이 아니라 widgetURL·Link 딥링크를 쓴다. 아래 Today 관련 서술은 레거시 익스텐션 모델을 설명하기 위한 것이다.

이 키는 NSExtension 딕셔너리의 직계 자식이어야 하고, Xcode 템플릿이 각각 올바른 값을 미리 설정해 준다. 그래서 템플릿을 고르는 것이 곧 point를 고르는 것이다.

point마다 기본 capability도 다르다. 예를 들어 iOS Document Provider 템플릿은 com.apple.security.application-groups 엔타이틀먼트를 기본 포함하고, macOS 익스텐션 템플릿은 App Sandbox와 com.apple.security.files.user-selected.read-only를 기본 포함한다.

Share와 Action의 구분이 실무에서 가장 자주 헷갈린다. 판단 기준은 데이터가 어디로 가는가다.

Action이 결과를 host에 돌려줄 수 있다는 점이 결정적 차이다. Share는 보내고 끝이다.

그리고 Share 익스텐션에는 별도 제약이 있다 — 공유 UI는 폭을 늘릴 수 없고, iOS 13부터 컨테이닝 앱 표시명으로 노출되므로 앱당 하나만 두어야 한다. 여러 개를 만들면 공유 시트에 같은 이름이 여러 번 나타난다.

실험 · 도구

Info.plist 구조를 직접 확인하고 검증하는 것이 첫걸음이다.

# 익스텐션의 extension point 확인
plutil -p "YourApp.app/PlugIns/ShareExt.appex/Info.plist" \
  | grep -A6 NSExtension

# 출력 예시:
#   "NSExtension" => {
#     "NSExtensionPointIdentifier" => "com.apple.share-services"
#     "NSExtensionPrincipalClass" => "ShareViewController"
#     "NSExtensionAttributes" => { "NSExtensionActivationRule" => ... }
#   }

# 앱 안 모든 익스텐션의 point를 한 번에 나열한다
for ext in YourApp.app/PlugIns/*.appex; do
  id=$(plutil -extract NSExtension.NSExtensionPointIdentifier raw "$ext/Info.plist" 2>/dev/null)
  echo "$(basename "$ext"): ${id:-없음}"
done
# CI 검증: point 식별자가 없거나 TRUEPREDICATE가 남아 있으면 실패시킨다
for ext in "$ARCHIVE"/Products/Applications/*.app/PlugIns/*.appex; do
  plist="$ext/Info.plist"
  point=$(plutil -extract NSExtension.NSExtensionPointIdentifier raw "$plist" 2>/dev/null)
  [ -n "$point" ] || { echo "❌ $(basename "$ext"): NSExtensionPointIdentifier 없음"; exit 1; }

  # Q5에서 다룰 리젝 사유 — 여기서 함께 잡는다
  if plutil -p "$plist" | grep -q TRUEPREDICATE; then
    echo "❌ $(basename "$ext"): TRUEPREDICATE 가 남아 있다 — 앱스토어 리젝 사유"
    exit 1
  fi
  echo "✅ $(basename "$ext"): $point"
done

Share와 Action의 동작 차이는 결과 반환으로 확인할 수 있다.

// Action 익스텐션 — 수정한 결과를 host에 돌려준다
final class WatermarkActionViewController: UIViewController {
    func finish(with image: UIImage) {
        let provider = NSItemProvider(item: image.pngData() as NSData?,
                                      typeIdentifier: UTType.png.identifier)
        let item = NSExtensionItem()
        item.attachments = [provider]

        // returningItems 로 host에 결과를 전달한다 — Share에는 없는 흐름
        extensionContext?.completeRequest(returningItems: [item], completionHandler: nil)
    }
}

// Share 익스텐션 — 보내고 끝. 돌려줄 것이 없다
final class ShareViewController: SLComposeServiceViewController {
    override func didSelectPost() {
        uploadToOurService()
        extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
    }
}

프로젝트 적용

extension point 선택에서 판단할 것 넷.

⚠️ 흔한 오해

"extension point는 그냥 분류 라벨"이 가장 큰 오해다. 사용 정책과 전용 API를 함께 정의하므로 선택이 곧 쓸 수 있는 것의 범위다. "Share로 만들어놓고 편집 기능을 넣으면 된다"도 잘못이다 — Apple 문서가 억지로 Share를 고르는 것을 명시적으로 잘못된 선택으로 규정한다. "식별자를 바꾸면 다른 종류가 된다"도 틀렸다 — 템플릿이 함께 설정하는 엔타이틀먼트와 기본 구조가 따라오지 않는다. 또 "Share 익스텐션을 여러 개 만들어 기능별로 나누자"도 iOS 13 이후로는 나쁜 선택이다 — 전부 컨테이닝 앱 이름으로 표시되어 사용자가 구분할 수 없다.

🧒 쉽게 이해하기

건물에 여러 종류의 출입구가 있다고 하자. 정문, 배송구, 비상구, 주차장 입구.

여기서 중요한 건 어느 문으로 들어가느냐에 따라 할 수 있는 일이 정해진다는 것이다.

  • 배송구로 들어오면 물건을 놓고 갈 수 있다. 대신 사무실에는 못 들어간다.
  • 정문으로 들어오면 안내데스크를 거쳐 여러 곳에 갈 수 있다. 대신 큰 짐은 못 들고 온다.
  • 주차장 입구는 차만 들어온다.

문을 고르는 건 "어디로 들어갈까"가 아니라 "무엇을 할 수 있을까"를 고르는 것이다.

그래서 "배송구로 들어가서 회의에 참석하겠다"는 안 된다. 문 앞에 "배송 전용"이라고 써 있고, 실제로 그 문에서는 회의실로 가는 길이 없다. 억지로 하려 들면 중간에 막힌다.

그리고 간판을 바꿔 단다고 문이 바뀌지 않는다. 배송구에 "정문"이라고 써 붙여도 여전히 회의실로는 못 간다.

꼬리 질문

같은 앱에 여러 종류의 익스텐션을 넣을 때 고려할 것은?

기술적으로는 얼마든지 넣을 수 있고 흔하다 — 공유 익스텐션 + 알림 서비스 익스텐션 + 위젯 조합이 전형적이다. 고려할 것이 넷 있다.

(1) 각각 별도 프로세스·별도 dSYM·별도 privacy manifest. 21장 Q6의 개수 검증, 23장 Q2의 manifest 검증에서 익스텐션 개수만큼 확인해야 한다. 하나 추가할 때마다 이 목록이 늘어난다.

(2) 공유 컨테이너 동시 접근. 여러 익스텐션이 동시에 실행될 수 있고(알림 서비스와 위젯이 겹칠 수 있다), 전부 같은 공유 컨테이너를 쓴다. 동기화가 필수가 된다(Q3).

(3) 백그라운드 세션 식별자 충돌. 각 프로세스가 다른 세션 식별자를 써야 한다. 공유 코드에서 식별자를 상수로 두면 전부 같은 값을 쓰게 되어 충돌한다.

(4) 앱 크기. 익스텐션마다 바이너리가 늘고, 공유 프레임워크를 링크하면 그만큼 더 커진다(26장).

설계 원칙은 익스텐션마다 자기 책임을 좁게 두는 것이다. 하나가 여러 일을 하려 들면 필요한 API가 늘어 point 제약에 부딪히고, 자원 예산도 빠듯해진다(Q7).

쉽게 말하면 출장 기사를 여러 명 두는 것과 같다. 각자 따로 나가고 따로 보고서를 쓴다. 그리고 같은 창고를 여럿이 쓰니 서로 물건을 뒤섞지 않게 규칙이 필요하다.
Action 익스텐션이 결과를 돌려줄 때 host가 그것을 안 받으면 어떻게 되는가?

host app이 결과를 받을 준비가 되어 있어야 의미가 있다. completeRequest(returningItems:)로 아이템을 넘겨도, host가 그 결과를 처리하는 코드를 갖고 있지 않으면 조용히 버려진다.

그래서 Action 익스텐션의 유용성은 host에 달려 있다. 사진 앱은 편집 결과를 받아 저장하지만, 임의의 서드파티 앱은 그렇지 않을 수 있다. 우리가 통제할 수 없는 부분이다.

실무적 대응은 둘이다.

(1) 결과 반환에 의존하지 않는 설계. 변환 결과를 host에 돌려주는 동시에 우리 공유 컨테이너에도 저장해두면, host가 안 받아도 사용자가 우리 앱에서 결과를 볼 수 있다.

(2) 어느 host에서 실행됐는지 기록한다. 관측 이벤트에 host 정보를 태그로 남기면(18장 Q5), "어떤 앱에서 주로 쓰이는가"와 "어떤 host에서 문제가 생기는가"를 알 수 있다. host마다 아이템 전달 방식이 미묘하게 다른 경우가 있어 이 정보가 유용하다.

반대로 Photo Editing 익스텐션처럼 host가 명확히 정해진 point는 이 문제가 없다 — 사진 앱이 결과를 받도록 보장된다. point 선택이 여기서도 영향을 준다.

쉽게 말하면 기사가 수리한 물건을 손님에게 돌려주는데, 손님이 받을 준비가 안 돼 있으면 그냥 두고 오는 셈이 된다. 그래서 회사 창고에도 사본을 남겨두면 나중에 손님이 다시 찾을 수 있다.
Xcode 템플릿이 설정해주는 것들 중 직접 확인해야 할 항목은?

템플릿이 대부분을 맞춰주지만, 사람이 확인해야 하는 것이 몇 가지 있다.

(1) NSExtensionActivationRule. Share 템플릿의 기본값이 TRUEPREDICATE인데, 이건 개발 중 스텁이고 그대로 제출하면 리젝된다. 실제 처리 가능한 타입으로 반드시 교체해야 한다(Q5). Xcode가 기본값으로 넣어주기 때문에 오히려 사고가 잦다.

(2) "Require Only App-Extension-Safe API". 익스텐션 타깃에는 켜져 있지만, 공유 프레임워크 타깃에도 켜야 한다. 안 켜면 링크 시점에 경고만 나고 넘어갔다가 심사에서 걸린다(Q6).

(3) App Group 엔타이틀먼트. point에 따라 기본 포함되기도 하지만, 컨테이닝 앱과 각 익스텐션 모두에 같은 그룹을 추가했는지 확인해야 한다. 하나라도 빠지면 공유 컨테이너 접근이 실패한다(Q3).

(4) 타깃 디바이스 패밀리. 익스텐션은 반드시 universal이어야 한다. 컨테이닝 앱이 iPad 전용이어도 그렇다.

(5) Copy Files 목적지. embedded framework를 넣을 때 "Frameworks"를 골라야 한다. "SharedFramework"를 고르면 리젝된다.

이 다섯을 CI 검증에 넣어두면 심사 반려를 크게 줄일 수 있다. 전부 빌드는 성공하는데 심사에서 걸리는 종류라, 21장 Q6에서 본 "조용한 실패"의 전형이다.

쉽게 말하면 서류 양식이 미리 채워져 나오는데, 몇 칸은 "예시"라고 적혀 있다. 그걸 안 고치고 제출하면 반려된다. 양식이 편한 만큼 어느 칸이 예시인지를 알아두는 게 중요하다.

Q3. 익스텐션과 컨테이닝 앱은 데이터를 어떻게 주고받는가?

🔑 30초 답변

App Group 공유 컨테이너가 유일한 정식 통로다. App Group은 App Sandbox의 프로세스 격리에 대한 명시적 예외로, 같은 팀 ID의 여러 타깃이 group.com.company.app 식별자로 컨테이너 하나에 접근하게 해준다. 번들이 중첩돼 있어도 서로의 컨테이너에는 직접 접근할 수 없다. 그리고 가장 중요한 함정 — "공유 컨테이너를 만들면 알아서 안전해진다"는 틀렸다. Apple 문서가 경고하는 그대로, 컨테이닝 앱과 각 익스텐션이 모두 읽기·쓰기 권한을 가지므로 데이터 손상을 피하려면 접근을 직접 동기화해야 한다. 문서가 지목하는 수단은 Core Data · SQLite · POSIX 락 셋이다.

CS 원리

App Group은 격리에 구멍을 뚫는 장치이고, 구멍을 뚫는 순간 격리가 막아주던 문제가 돌아온다. 그 문제가 동시성이다.

P0 02장에서 본 데이터 경쟁이 여기서 프로세스 간으로 확장된다. 그런데 프로세스 간 경쟁은 스레드 간 경쟁보다 다루기 어렵다.

그래서 공유 컨테이너 설계는 "어떻게 공유할까"가 아니라 "어떻게 안전하게 공유할까"가 본질이다.

컨테이닝 앱 자기 컨테이너 UserDefaults.standard Share 익스텐션 자기 컨테이너 UserDefaults.standard 서로의 컨테이너에 직접 접근 불가 번들이 중첩돼 있어도 마찬가지 — 샌드박스가 강제 App Group 공유 컨테이너 group.com.example.shop — 격리의 명시적 예외 UserDefaults(suiteName:) 공유 파일 · SQLite ⚠️ 양쪽 다 읽기·쓰기 권한 — 동기화는 우리 몫 Apple이 지목한 수단: Core Data · SQLite · POSIX 락 그리고 락을 쥔 채 서스펜드되면 0xdead10cc 로 종료된다 (19장 Q4)
App Group은 격리에 뚫은 구멍이다. 구멍을 뚫는 순간 격리가 막아주던 동시성 문제가 돌아온다.

iOS에서는

설정 순서는 이렇다 — Xcode/Developer 포털에서 컨테이닝 앱과 각 익스텐션에 App Groups를 켜고 → 포털에 그룹을 등록하고 → 쓸 그룹을 지정한다. 하나라도 빠지면 접근이 실패한다.

실제 접근 방법은 둘이다.

import Foundation

enum SharedStorage {
    static let groupID = "group.com.example.shop"

    /// (1) UserDefaults — 작은 설정값용
    /// ⚠️ .standard 와 완전히 별도의 저장소다. 자동 마이그레이션은 없다.
    static let defaults = UserDefaults(suiteName: groupID)!

    /// (2) 공유 컨테이너 디렉터리 — 파일용
    static var containerURL: URL {
        FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: groupID)!
    }

    static var databaseURL: URL { containerURL.appendingPathComponent("shared.sqlite") }
    static var inboxURL: URL    { containerURL.appendingPathComponent("Inbox", isDirectory: true) }
}

UserDefaults(suiteName:).standard와 완전히 별개라는 점이 실무에서 가장 자주 부딪히는 사실이다. 같은 키에 각각 다른 값을 넣으면 서로 다른 plist에 저장되어 절대 안 섞인다. 그래서 "App Group을 켰더니 기존 .standard 값이 익스텐션에 보인다"는 틀렸다 — 마이그레이션 코드를 직접 짜야 한다.

/// .standard → App Group 마이그레이션. 앱 시작 시 1회 실행한다.
enum DefaultsMigration {
    private static let doneKey = "migratedToAppGroup.v1"
    private static let keysToMove = ["lastSyncDate", "selectedTheme", "apiEnvironment"]

    static func runIfNeeded() {
        let shared = SharedStorage.defaults
        guard !shared.bool(forKey: doneKey) else { return }

        for key in keysToMove {
            guard let value = UserDefaults.standard.object(forKey: key) else { continue }
            shared.set(value, forKey: key)
            // 원본을 지울지는 정책 — 롤백 가능성을 원하면 남긴다
        }
        shared.set(true, forKey: doneKey)
    }
}

동시 접근 동기화는 문서가 지목한 셋 중에서 고른다.

수단적합한 경우주의
Core Data구조화된 데이터, 이미 쓰고 있는 경우스토어 옵션과 컨텍스트 동시성 설정 필요 (P0 09장)
SQLite세밀한 제어가 필요할 때WAL 모드와 busy timeout 설정
POSIX 락단순 파일 하나를 보호락 보유 중 종료 시 0xdead10cc 위험 (19장 Q4)
UIDocument (iOS 8.2+)문서 단위 접근
NSFileCoordinator (iOS 9+)파일 조율이 필요할 때익스텐션이 백그라운드로 갈 때 NSFilePresenter를 반드시 제거해야 한다

마지막 행의 경고가 중요하다. NSFileCoordinator를 쓰면서 presenter를 제거하지 않으면 다른 프로세스의 파일 접근이 우리 응답을 기다리며 멈춘다 — 이미 죽은 익스텐션을 기다리는 셈이라 교착이 된다.

그리고 변경 통지가 별도 문제다. UserDefaults의 KVO는 프로세스를 넘지 않으므로, 값이 바뀐 것을 알리려면 Darwin notification을 쓴다.

import Foundation

/// 프로세스 간 변경 통지 — KVO나 NotificationCenter는 프로세스를 넘지 않는다
enum CrossProcessNotifier {
    private static let name = "com.example.shop.sharedDataChanged" as CFString

    static func post() {
        CFNotificationCenterPostNotification(
            CFNotificationCenterGetDarwinNotifyCenter(),
            CFNotificationName(name), nil, nil, true)
    }

    static func observe(_ handler: @escaping () -> Void) {
        // 콜백이 C 함수 포인터라 컨텍스트를 못 넘긴다 — 전역 저장이 필요하다
        Self.handler = handler
        CFNotificationCenterAddObserver(
            CFNotificationCenterGetDarwinNotifyCenter(),
            nil,
            { _, _, _, _, _ in Self.handler?() },
            name, nil, .deliverImmediately)
    }

    private static var handler: (() -> Void)?
}

위젯의 경우는 또 다르다 — 위젯은 공유 값을 실시간이 아니라 스냅샷 시점에만 읽으므로, 값을 바꾼 뒤 WidgetCenter.shared.reloadTimelines(ofKind:)를 직접 호출해야 갱신된다.

실험 · 도구

공유 저장소가 정말 분리돼 있는지 직접 확인해보자.

// 같은 키에 서로 다른 값을 넣고 양쪽을 읽어본다
UserDefaults.standard.set("standard-value", forKey: "probe")
SharedStorage.defaults.set("group-value", forKey: "probe")

print(UserDefaults.standard.string(forKey: "probe"))    // "standard-value"
print(SharedStorage.defaults.string(forKey: "probe"))   // "group-value"
// → 절대 안 섞인다. 다른 plist 파일이다.

// 실제 파일 위치를 확인한다
print("app 컨테이너:   ", FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0].path)
print("공유 컨테이너: ", SharedStorage.containerURL.path)
// → 완전히 다른 경로다.

동시 쓰기 경쟁도 재현할 수 있다.

// ⚠️ 동기화 없이 공유 파일에 쓰면 손상된다 — 재현 실험
func unsafeAppend(_ line: String) {
    let url = SharedStorage.containerURL.appendingPathComponent("log.txt")
    // 여러 프로세스가 동시에 이러면 내용이 섞이거나 잘린다
    if let h = try? FileHandle(forWritingTo: url) {
        h.seekToEndOfFile()
        h.write(Data(line.utf8))
        try? h.close()
    }
}

// ✅ POSIX 락으로 보호 — 단, 락 보유 중 종료되면 0xdead10cc 위험 (19장 Q4)
func safeAppend(_ line: String) {
    let url = SharedStorage.containerURL.appendingPathComponent("log.txt")
    guard let h = try? FileHandle(forWritingTo: url) else { return }
    defer { flock(h.fileDescriptor, LOCK_UN); try? h.close() }   // 반드시 해제

    guard flock(h.fileDescriptor, LOCK_EX) == 0 else { return }
    h.seekToEndOfFile()
    h.write(Data(line.utf8))
}
# 공유 컨테이너 내용을 기기에서 직접 확인 (Xcode)
#   Window › Devices and Simulators › (기기) › Installed Apps
#   › 앱 선택 › ⚙︎ › Download Container
#   ⚠️ 이 방법은 앱 컨테이너만 받는다. 공유 컨테이너는 별도로 확인해야 한다.

# 시뮬레이터에서는 경로로 직접 접근할 수 있다
xcrun simctl get_app_container booted com.example.shop groups
# → group.com.example.shop 의 실제 경로가 출력된다

프로젝트 적용

공유 컨테이너 설계 규칙 다섯.

⚠️ 흔한 오해

"App Group을 켜면 .standard 값이 자동으로 공유된다"가 가장 흔한 오해다. 완전히 별도의 저장소이고 마이그레이션은 직접 짜야 한다. "공유 컨테이너를 만들면 안전하게 공유된다"도 틀렸다 — Apple 문서가 모두 읽기·쓰기 권한을 가지므로 접근을 직접 동기화해야 한다고 경고한다. "번들이 중첩돼 있으니 서로의 파일을 볼 수 있다"도 틀렸다 — 서로의 컨테이너에는 직접 접근할 수 없고 App Group만이 통로다. "KVO로 변경을 감지하면 된다"도 안 된다 — 프로세스를 넘지 않는다. 또 NSFileCoordinator를 쓰면서 백그라운드 전환 시 NSFilePresenter를 제거하지 않으면 다른 프로세스를 멈추게 만든다.

🧒 쉽게 이해하기

앞에서 말한 공용 창고 이야기다. 회사 사무실과 출장 기사가 이 창고로만 물건을 주고받는다.

여기서 사람들이 자주 착각하는 게 있다.

착각 1 — "사무실 서랍에 있던 물건이 창고에도 있겠지." 아니다. 창고는 완전히 다른 공간이다. 사무실 서랍 물건을 창고에서 쓰려면 누군가 직접 옮겨야 한다.

착각 2 — "창고에 넣어두면 안전하겠지." 아니다. 사무실 직원도, 기사 여러 명도 전부 열쇠를 갖고 있다. 둘이 동시에 같은 상자를 정리하면 물건이 뒤섞이거나 없어진다. "들어갈 때 문에 표지판을 걸어두는" 규칙을 직접 만들어야 한다.

착각 3 — "누가 물건을 바꾸면 알림이 오겠지." 안 온다. 사무실에서 쓰던 알림 시스템은 창고까지 안 닿는다. 별도로 신호를 보내는 장치를 달아야 한다.

그리고 가장 위험한 상황 — 기사가 창고 문을 잠근 채 퇴근해버리는 것이다. 그러면 아무도 못 들어간다. 그래서 시스템이 "문 잠그고 나간 사람"을 발견하면 강제로 쫓아낸다. 이게 0xdead10cc다.

꼬리 질문

공유 컨테이너에 큰 파일을 넘길 때 익스텐션 메모리 한도를 어떻게 피하는가?

핵심은 파일을 메모리에 올리지 않는 것이다. 익스텐션 메모리 예산이 앱보다 훨씬 낮으므로(Q7), 큰 이미지나 동영상을 Data로 읽는 순간 죽는다.

수단이 셋 있다.

(1) NSItemProvider의 파일 표현을 쓴다. loadDataRepresentation은 메모리에 올리지만 loadFileRepresentation임시 파일로 기록한다(Q4). 큰 파일은 후자를 써서 경로만 받고, 그 파일을 공유 컨테이너로 이동시킨다.

(2) 복사 대신 이동. FileManager.moveItem은 같은 볼륨이면 메타데이터만 바꾸므로 파일 크기와 무관하게 빠르고 메모리를 안 쓴다. copyItem은 실제로 바이트를 복사한다. 다만 임시 파일이 다른 볼륨에 있으면 이동도 복사가 된다.

(3) 스트리밍 처리. 변환이 필요하면 전체를 읽지 말고 InputStream/OutputStream이나 FileHandle로 청크 단위 처리한다. 이미지라면 다운샘플링으로 원본 디코딩을 피한다(P0 08장 Q5).

그리고 익스텐션은 저장만 하고 무거운 처리는 컨테이닝 앱에 넘기는 패턴이 유용하다. 익스텐션은 파일을 공유 컨테이너의 Inbox에 넣고 "처리 대기" 표시만 남긴 뒤 즉시 종료하고, 나중에 앱이 실행될 때 그것을 처리한다. 제약이 적은 쪽에서 무거운 일을 한다는 원칙이다.

쉽게 말하면 기사가 무거운 짐을 들고 옮기려 하면 허리가 나간다. 대신 창고로 밀어 넣기만 하고(이동), 포장을 뜯어 정리하는 일은 사무실 직원이 나중에 하게 하는 것이다.
Core Data를 공유 컨테이너에서 쓸 때 추가로 필요한 설정은?

세 가지가 필요하다.

(1) 스토어 위치를 공유 컨테이너로. NSPersistentContainer의 기본 위치는 앱 컨테이너이므로, persistentStoreDescriptions의 URL을 공유 컨테이너 경로로 바꿔야 한다.

let container = NSPersistentContainer(name: "Model")
let url = SharedStorage.containerURL.appendingPathComponent("Model.sqlite")
container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: url)]

(2) 프로세스 간 변경 감지. Core Data의 NSManagedObjectContextDidSave 알림은 프로세스를 넘지 않는다. 다른 프로세스가 저장한 변경을 반영하려면 persistent history tracking을 켜고 변경 이력을 읽어야 한다. 이걸 안 하면 익스텐션이 저장한 데이터가 앱에서 안 보이거나, 각자 오래된 스냅샷을 보게 된다.

let desc = NSPersistentStoreDescription(url: url)
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

(3) 동시 접근 대비. Core Data가 SQLite 수준의 락을 관리해주지만, 익스텐션이 락을 쥔 채 종료되는 상황은 여전히 가능하다. 익스텐션에서는 쓰기를 짧게 하고, 완료 직후 컨텍스트를 정리한다.

P0 09장 Q6에서 본 "Core Data 컨텍스트는 스레드 세이프하지 않다"가 여기서 프로세스 차원으로 확장된 셈이다. 스레드 안전성은 컨텍스트 큐로 해결되지만, 프로세스 간 일관성은 history tracking이라는 별도 메커니즘이 필요하다.

쉽게 말하면 창고에 공용 장부를 두고 여럿이 쓴다. 장부 자체는 잘 만들어져 있어서 동시에 써도 찢어지지는 않는다. 그런데 내가 아까 본 페이지가 지금도 최신인지는 별도로 확인해야 한다. 그래서 "누가 언제 뭘 고쳤는지" 기록하는 페이지를 따로 두고 그걸 보는 것이다.
공유 컨테이너의 데이터가 손상됐을 때 어떻게 진단하는가?

손상의 원인이 동시 접근이라 재현이 어렵다. 진단 전략이 셋 있다.

(1) 쓰기 주체를 기록한다. 공유 저장소에 쓸 때마다 어느 프로세스가 썼는지를 함께 남긴다. 프로세스 이름과 pid, 타임스탬프를 메타데이터로 붙이면, 손상된 데이터를 발견했을 때 "누가 마지막에 썼나"를 알 수 있다.

struct WriteAudit: Codable {
    let process: String     // "Shop" / "ShareExt" / "NotificationExt"
    let at: Date
    let version: String
}

(2) 무결성 검사를 넣는다. 저장 시 체크섬을 함께 기록하고 읽을 때 검증한다. 불일치하면 손상 사실 자체를 관측 이벤트로 보낸다(18장). 손상이 조용히 지나가면 나중에 엉뚱한 증상으로 나타난다.

(3) 복구 경로를 만든다. 손상을 감지하면 안전한 기본 상태로 되돌리고 사용자에게 최소한의 영향만 주도록 한다. 앱이 시작 자체를 못 하게 되는 것이 최악이다 — 19장 Q2 꼬리질문에서 본 "앱 시작 크래시는 과소 집계된다"가 여기서 현실이 된다.

예방이 훨씬 싸다는 점을 강조할 만하다. 중간 크기의 JSON 파일을 여러 프로세스가 직접 읽고 쓰는 구조가 손상의 주범이고, 이건 DB로 바꾸면 대부분 사라진다. Apple이 Core Data·SQLite를 먼저 지목한 이유다.

쉽게 말하면 창고 장부가 엉망이 됐다. 누가 그랬는지 알려면 기록할 때마다 이름을 적게 해야 한다. 그리고 페이지마다 합계를 적어두면 숫자가 안 맞을 때 바로 알 수 있다. 무엇보다 — 낱장 종이 대신 제본된 장부를 쓰면 애초에 이런 일이 훨씬 덜 생긴다.

Q4. NSItemProvider는 프로세스 경계를 어떻게 넘는가?

🔑 30초 답변

NSItemProvider프로세스 사이로 데이터나 파일을 실어 나르는 운반체다 — 드래그 앤 드롭, 복사/붙여넣기, host → 익스텐션 전달에 모두 쓰인다. 핵심 설계는 지연 로딩이다. 프로바이더는 실제 데이터가 아니라 "어떤 타입으로 제공할 수 있는지"의 목록(UTI)과 포맷별 로드 핸들러를 들고 있고, 소비자가 요청한 포맷의 핸들러만 실행된다. 그래서 3단계 프로토콜이 된다 — (1) attachments를 훑고, (2) hasItemConformingToTypeIdentifier로 내가 아는 타입이 있는지 확인하고, (3) 있으면 로드한다. 그리고 완료 블록은 내부 큐에서 호출되므로 UI 갱신은 반드시 메인 큐로 넘겨야 한다 — Apple 문서가 명시적으로 경고하는 항목이다.

CS 원리

프로세스 간에 데이터를 넘기는 순진한 방법은 전부 복사해서 보내는 것이다. 그런데 이건 두 가지로 나쁘다.

NSItemProvider는 이를 능력 선언 + 지연 실행으로 푼다. 이건 흔한 패턴이다 — 값이 아니라 값을 만드는 방법을 전달하는 것. Swift의 lazyAsyncSequence와 같은 발상이고, 프로세스 경계에 적용한 것뿐이다.

구조를 정리하면 이렇다.

  1. 제공자가 포맷별 로드 핸들러를 등록한다. 이 시점에는 아무것도 계산되지 않는다.
  2. 프로바이더는 등록된 타입 식별자 목록만 노출한다. 소비자는 이 목록으로 협상한다.
  3. 소비자가 특정 포맷을 요청하면 그 핸들러만 실행되어 데이터가 만들어진다.

여기서 타입 식별자로 협상한다는 점이 중요하다. 그 식별자 체계가 UTI이고, 준수 트리 덕분에 "정확히 이 타입"이 아니라 "이 타입에 준수하는 무엇이든"으로 물어볼 수 있다(Q5).

값이 아니라 "값을 만드는 방법"을 프로세스 경계 너머로 보낸다 host app (사진 앱) 원본 HEIC 12 MB 핸들러: public.heic 핸들러: public.jpeg 핸들러: public.png ① 목록만 registeredTypeIdentifiers (데이터 아님) 익스텐션 ② hasItemConforming(public.image) 준수 트리 덕에 heic·jpeg·png 셋 다 한 번에 걸린다 ③ loadFileRepresentation(.heic) 요청한 핸들러만 실행 jpeg·png 는 만들지도 않음 임시 파일 경로만 전달 이 구조가 익스텐션의 좁은 메모리 예산과 맞물린다 loadDataRepresentation 12 MB 를 메모리에 올린다 loadFileRepresentation 경로만 받고 moveItem 그리고 완료 블록은 내부 큐에서 불린다 — UI 갱신은 반드시 메인 큐로.
제공자는 데이터가 아니라 로드 핸들러를 들고 있다. 그래서 안 쓸 포맷은 계산조차 되지 않는다.

iOS에서는

익스텐션에서의 표준 흐름은 3단계다.

import UIKit
import UniformTypeIdentifiers

final class ShareViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        handleInputItems()
    }

    private func handleInputItems() {
        // (1) NSExtensionItem의 attachments를 훑는다
        guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return }

        for item in items {
            for provider in item.attachments ?? [] {
                // (2) 내가 아는 타입이 있는지 확인한다 — 준수 트리로 판정된다
                if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
                    loadImage(from: provider)
                } else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
                    loadURL(from: provider)
                }
            }
        }
    }

    private func loadImage(from provider: NSItemProvider) {
        // (3) 로드한다 — 큰 파일은 Data가 아니라 파일 표현으로 받는다
        _ = provider.loadFileRepresentation(for: .image) { [weak self] url, _, error in
            // ⚠️ 이 블록은 내부 큐에서 호출된다. 문서가 명시적으로 경고하는 지점.
            guard let url, error == nil else { return }

            // 파일은 이 블록이 끝나면 사라질 수 있으므로 즉시 옮긴다
            let dest = SharedStorage.inboxURL.appendingPathComponent(url.lastPathComponent)
            try? FileManager.default.moveItem(at: url, to: dest)   // 복사가 아니라 이동

            DispatchQueue.main.async {
                // UI 갱신은 반드시 메인 큐로
                self?.showPreview(at: dest)
            }
        }
    }

    private func loadURL(from provider: NSItemProvider) { /* … */ }
    private func showPreview(at url: URL) { /* … */ }
}

로드 계열 메서드가 여럿인데, 메모리 관점에서 선택이 갈린다. (UTType을 받는 …(for:) 계열은 iOS 16+이고, 그 이전은 문자열 식별자를 받는 …(forTypeIdentifier:) 원형을 쓴다.)

메서드결과메모리
loadDataRepresentation(for:)Data로 복사전부 메모리에 올린다
loadFileRepresentation(for:)임시 파일로 기록경로만 받는다
loadInPlaceFileRepresentation(forTypeIdentifier:)
또는 loadFileRepresentation(for:openInPlace: true)
가능하면 제자리에서 열기복사조차 피한다
loadObject(ofClass:) / loadTransferable(type:)객체로타입에 따라
loadItem(forTypeIdentifier:options:)(deprecated) 타입 강제 변환

익스텐션에서는 파일 표현을 기본으로 삼아야 한다. 사진·동영상은 수십~수백 MB가 될 수 있고, Data로 받으면 그 순간 메모리 한도를 넘는다(Q7).

조회 계열도 알아둘 만하다 — registeredTypeIdentifiers등록 순서대로 반환하므로 제공자가 선호하는 순서를 담고 있다. hasRepresentationConforming(toTypeIdentifier:fileOptions:)는 open-in-place 동작까지 고려해 판정한다.

반대 방향 — 익스텐션이 결과를 host에 돌려줄 때는 우리가 프로바이더를 만든다. 이때 여러 포맷을 등록해두면 소비자가 요청한 것만 계산되므로 효율적이다.

// 하나의 프로바이더가 여러 포맷을 지연 제공한다
let provider = NSItemProvider()

provider.registerDataRepresentation(for: .png, visibility: .all) { completion in
    // PNG를 요청받았을 때만 실행된다
    completion(self.renderedImage.pngData(), nil)
    return nil        // Progress를 반환할 수 있다
}
provider.registerDataRepresentation(for: .jpeg, visibility: .all) { completion in
    // JPEG를 요청받았을 때만 실행된다 — 둘 다 미리 만들지 않는다
    completion(self.renderedImage.jpegData(compressionQuality: 0.9), nil)
    return nil
}
provider.registerFileRepresentation(for: .movie, visibility: .all) { completion in
    completion(self.exportedMovieURL, true, nil)   // true = 제자리 열기 허용
    return nil
}

실험 · 도구

host가 실제로 무엇을 보내는지 확인하는 것이 개발의 출발점이다. host마다 제공하는 타입이 다르다.

/// 들어온 아이템의 실체를 전부 덤프한다 — 개발 초기에 필수
private func dumpInputItems() {
    guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return }
    let log = Logger(subsystem: "com.example.shop", category: "share")

    for (i, item) in items.enumerated() {
        log.notice("item[\(i, privacy: .public)] attachments=\(item.attachments?.count ?? 0, privacy: .public)")
        log.notice("  attributedTitle=\(item.attributedTitle?.string ?? "nil", privacy: .public)")

        for (j, p) in (item.attachments ?? []).enumerated() {
            // 등록 순서 = 제공자가 선호하는 순서
            log.notice("  provider[\(j, privacy: .public)] types=\(p.registeredTypeIdentifiers.joined(separator: ", "), privacy: .public)")
            log.notice("    suggestedName=\(p.suggestedName ?? "nil", privacy: .public)")
        }
    }
}
# 여러 host에서 공유를 시도하며 타입 목록을 비교한다
log stream --predicate 'subsystem == "com.example.shop" AND category == "share"' --style compact

# 관찰 포인트:
#   사진 앱 → public.jpeg, public.heic 등 이미지 타입
#   Safari  → public.url, public.plain-text, 그리고 웹 페이지 타입
#   파일 앱 → public.file-url 과 실제 파일 타입
#   → host마다 다르므로 "이미지겠지" 하고 하드코딩하면 특정 앱에서만 동작한다

메모리 차이도 직접 재볼 수 있다.

// Data 표현과 파일 표현의 메모리 차이를 비교한다
func compareLoadStrategies(_ provider: NSItemProvider) {
    let before = currentFootprintBytes() ?? 0        // 19장 Q5의 phys_footprint

    // (A) Data — 파일 크기만큼 메모리가 는다
    _ = provider.loadDataRepresentation(for: .movie) { data, _ in
        let after = currentFootprintBytes() ?? 0
        print("Data 로드: +\((after - before) / 1_048_576) MB, 크기 \(data?.count ?? 0)")
    }

    // (B) 파일 — 메모리는 거의 그대로다
    _ = provider.loadFileRepresentation(for: .movie) { url, _, _ in
        let after = currentFootprintBytes() ?? 0
        print("파일 로드: +\((after - before) / 1_048_576) MB, 경로 \(url?.lastPathComponent ?? "-")")
    }
}

프로젝트 적용

실무 규칙 다섯.

⚠️ 흔한 오해

"완료 블록은 호출한 큐에서 불린다"가 가장 위험한 오해다. 내부 큐에서 호출되므로 UI를 직접 건드리면 안 된다 — 그리고 가끔은 우연히 동작하기 때문에 발견이 늦다. "프로바이더가 데이터를 들고 있다"도 틀렸다 — 데이터를 만드는 방법을 들고 있고 요청받을 때 실행한다. "loadFileRepresentation이 준 파일은 계속 쓸 수 있다"도 틀렸다 — 임시 파일이라 사라진다. "정확한 타입 식별자로 물어봐야 한다"도 비효율적이다 — 상위 타입으로 물으면 준수 트리가 알아서 잡는다. 또 "host가 항상 같은 타입을 준다"도 틀렸다 — host마다 다르다.

🧒 쉽게 이해하기

음식 배달을 생각해보자. 손님이 "사진 좀 보내줘"라고 했다.

순진한 방법은 이렇다. 가진 사진을 JPG로도 만들고, PNG로도 만들고, PDF로도 만들어서 전부 보낸다. 손님은 그중 하나만 쓰고 나머지는 버린다. 만드느라 시간과 자리를 다 썼는데 대부분 낭비다.

실제 방식은 이렇다. 먼저 "저는 JPG, PNG, PDF로 드릴 수 있어요"라는 메뉴판만 보낸다. 손님이 "PNG로 주세요"라고 하면 그때 PNG만 만들어서 준다. 나머지는 만들지도 않는다.

그래서 대화가 3단계가 된다. 메뉴판 확인 → 내가 원하는 게 있나 확인 → 그것만 주문.

여기에 요령이 하나 더 있다. 아주 큰 물건은 손에 들고 오면 힘들다. 그래서 "창고 몇 번 칸에 뒀어요"라고 위치만 알려주는 방식을 쓴다(파일 표현). 손에 들고 오는 방식(Data)은 작은 물건일 때만 쓴다.

마지막으로 조심할 것 — 물건을 받는 사람이 "지금 어디에 서 있는지"가 정해져 있지 않다. 그래서 받자마자 "안내데스크에 갖다 놓기" 같은 일을 하려면, 반드시 안내데스크로 걸어가서 해야 한다. 그 자리에서 하면 엉뚱한 곳에 놓게 된다.

꼬리 질문

같은 아이템에 여러 타입이 등록돼 있을 때 무엇을 골라야 하는가?

판단 기준이 셋 있다.

(1) 등록 순서를 존중한다. registeredTypeIdentifiers등록된 순서대로 반환하고, 이 순서는 대개 제공자가 선호하는 순서(원본에 가까운 것이 앞)다. 특별한 이유가 없으면 앞쪽을 우선한다.

(2) 품질 손실을 피한다. 이미지라면 원본 포맷(HEIC/PNG)이 JPEG보다 손실이 적다. 다만 원본 포맷이 우리 처리 파이프라인에서 지원되지 않을 수 있으므로, 지원 가능한 것 중 가장 원본에 가까운 것을 고른다.

(3) 크기와 메모리를 고려한다. RAW나 무압축 포맷은 훨씬 크다. 익스텐션의 예산을 생각하면 적당히 압축된 포맷이 안전할 수 있다.

실무 구현은 선호 순서 목록을 만들어 순회하는 방식이 깔끔하다.

let preferred: [UTType] = [.heic, .png, .jpeg, .image]   // 앞에서부터 시도
for type in preferred where provider.hasItemConformingToTypeIdentifier(type.identifier) {
    load(provider, as: type)
    break
}

마지막에 .image 같은 상위 타입을 폴백으로 두면, 예상 못 한 하위 포맷도 잡힌다. Q5의 준수 트리가 이걸 가능하게 한다.

쉽게 말하면 메뉴판에 여러 형식이 있으면 맨 위에 적힌 것이 대개 "가게가 제일 자신 있는 것"이다. 그리고 "아무거나 사진 형식"이라는 항목을 마지막에 하나 넣어두면, 처음 보는 형식이 와도 받을 수 있다.
임시 파일을 옮기지 않고 그대로 쓰면 어떤 문제가 생기는가?

loadFileRepresentation이 주는 URL은 시스템이 만든 임시 파일이고 수명이 보장되지 않는다. 구체적으로 세 가지 문제가 생긴다.

(1) 완료 블록이 끝나면 삭제될 수 있다. 시스템이 정리하므로, 블록 밖에서 그 경로를 다시 쓰려 하면 파일이 없다. 비동기로 업로드를 시작해두고 블록을 빠져나오는 코드가 특히 위험하다.

(2) 익스텐션이 종료되면 확실히 사라진다. 익스텐션은 완료 직후 종료되므로(Q1), 그 임시 파일에 의존하는 후속 작업은 실행될 기회가 없다.

(3) 샌드박스 경계. 임시 파일은 익스텐션의 컨텍스트에 있어서 컨테이닝 앱이 접근할 수 없다. 앱에서 처리하려면 공유 컨테이너로 옮겨야 한다(Q3).

올바른 패턴은 완료 블록 안에서 즉시 이동이다. moveItem은 같은 볼륨이면 메타데이터만 바꾸므로 크기와 무관하게 빠르다. 다른 볼륨이면 복사가 되지만, 그래도 블록 안에서 끝내야 한다.

그리고 제자리 열기(loadInPlaceFileRepresentation(forTypeIdentifier:) 또는 loadFileRepresentation(for:openInPlace: true))는 또 다르다 — 원본을 제자리에서 열게 해주므로 복사조차 피할 수 있지만, 접근 권한이 일시적이라 startAccessingSecurityScopedResource() 같은 처리가 필요하고 범위를 벗어나면 접근이 끊긴다.

쉽게 말하면 손님이 "저기 탁자 위에 뒀어요"라고 알려줬다. 그런데 그 탁자는 곧 치워질 임시 탁자다. 그 자리에 두고 나중에 가지러 오면 없다. 받자마자 우리 창고로 옮겨야 한다.
host에 결과를 돌려줄 때 여러 포맷을 등록하는 것이 왜 유리한가?

계산이 실제로 요청될 때만 일어나기 때문이다. 세 포맷을 등록해도, host가 하나만 요청하면 나머지 두 핸들러는 실행되지 않는다.

이게 익스텐션에서 특히 중요한 이유는 시간과 메모리가 둘 다 빠듯하기 때문이다(Q7). 세 포맷을 미리 만들면 세 배의 시간과 메모리를 쓰는데, 그중 둘은 버려진다. 익스텐션은 "well under one second"를 목표로 해야 하므로 이 낭비가 치명적일 수 있다.

또 하나의 이점은 호환성이다. host마다 원하는 포맷이 다른데, 여러 개를 등록해두면 각 host가 자기가 다룰 수 있는 것을 고른다. 하나만 제공하면 그 포맷을 못 다루는 host에서는 아무것도 안 된다.

주의할 점은 등록 순서가 선호도를 나타낸다는 것이다. 가장 좋은 품질이나 가장 원본에 가까운 포맷을 먼저 등록한다.

그리고 previewImageHandler도 등록할 수 있다 — host가 미리보기를 보여줄 때 쓰는 작은 이미지다. 이것도 지연 생성되므로 미리 만들 필요가 없다.

쉽게 말하면 사진관에서 "인화도 되고, USB도 되고, 이메일도 됩니다"라고 안내만 해두는 것이다. 손님이 고른 것만 실제로 작업한다. 세 가지를 다 미리 만들어두면 시간도 재료도 세 배가 드는데 손님은 하나만 가져간다.

Q5. UTI는 무엇이며 시스템은 파일 타입을 어떻게 판단하는가?

🔑 30초 답변

UTI(Uniform Type Identifier)는 파일 포맷 하나를 정규적으로 가리키는 단일 문자열이다. 같은 JPEG를 두고 확장자는 .jpeg/.jpg/.jpe, MIME은 image/jpeg와 비표준 image/jpg — 다섯 개의 메타데이터가 있지만 Apple 플랫폼은 전부 public.jpeg 하나로 정규화한다. 판정 방법이 결정적으로 중요하다 — 시스템은 파일을 열어보지 않는다. 바이트를 읽어 타입을 판별하는 건 매우 비싸고 대부분의 프로세스에 읽기 권한도 없어서, OS는 거의 모든 결정을 경로 확장자에 기반해 내린다. 그리고 UTI는 다중 상속이 가능한 준수 트리라, public.jpegpublic.image에, 그것은 public.datapublic.content에 준수한다.

CS 원리

타입 식별 문제의 본질은 같은 것을 여러 이름으로 부르는 상황이다. 확장자·MIME 타입·매직 넘버가 각각 다른 체계이고 서로 1:1 대응도 아니다. 이걸 방치하면 모든 코드가 모든 별칭을 알아야 한다.

UTI는 정규 표현(canonical representation)을 도입해 이를 푼다. 별칭에서 정규형으로 가는 매핑을 시스템이 관리하고, 앱은 정규형만 다룬다.

두 번째 설계 결정이 준수 트리다. 단순 열거형이라면 "이미지면 다 받겠다"를 표현할 수 없어 모든 이미지 포맷을 나열해야 한다. 준수 관계를 도입하면 상위 타입으로 물어보는 것이 가능해진다.

public.jpeg ─┬─→ public.image ─┬─→ public.data ──→ public.item
             │                 └─→ public.content
public.png ──┘   (형제: public.tiff, public.heic)
public.audio ────→ public.data
public.text  ────→ public.data
다중 상속이 가능한 준수 트리 — 프로토콜 채택과 같은 모양 public.jpeg public.png public.heic public.image public.text public.audio (다른 계열) public.content "사용자가 신경 쓰는 것" public.data 바이트 시퀀스 일반 public.item 모든 파일시스템 객체 hasItemConforming(public.jpeg) → PNG·HEIC 는 형제라 안 걸린다 hasItemConforming(public.image) → 셋 다 걸린다 ✅ 넓게 묻고 좁게 처리
준수 관계가 있어서 상위 타입 하나로 물으면 하위가 전부 잡힌다. 포맷을 나열할 필요가 없는 이유다.

이 구조가 Swift/ObjC의 프로토콜 준수와 같은 모양이라는 점이 이해에 도움이 된다. public.image를 요구하는 것은 some Image를 요구하는 것과 같고, 다중 상속이 가능하다는 것은 여러 프로토콜을 동시에 채택하는 것과 같다.

확장자 기반 판정이라는 선택은 성능과 권한의 타협이다. 내용을 읽어 판별하는 것(매직 넘버 검사)이 정확하지만, 파일 목록을 표시할 때마다 수백 개 파일을 열어야 한다면 비용이 감당되지 않는다. 게다가 샌드박스 환경에서는 읽기 권한 자체가 없는 경우가 많다. 그래서 이름만 보고 판단한다.

이 선택의 귀결이 실무에서 자주 문제를 만든다 — 확장자가 틀리면 타입도 틀리고, 확장자가 없으면 시스템이 dyn 네임스페이스의 임시 식별자를 만들어낸다.

iOS에서는

UTI를 다루는 두 축이 있다 — 선언지원이다. Apple 문서가 명확히 구분한다.

선언은 다시 둘로 갈린다.

ExportedImported
의미내가 만든 타입 — "나는 권위자다"남이 만든 타입을 빌려 씀
handler rankOwnerAlternate
Swift 선언let 상수로 가능반드시 static var

imported 타입을 static var로 선언해야 하는 이유가 중요하다 — 원 소유 앱이 설치되면 시스템이 더 권위 있는 선언으로 런타임에 갈아끼우는데, 상수로 잡아두면 그 갱신을 못 받는다.

import UniformTypeIdentifiers

extension UTType {
    /// exported — 우리가 정의한 타입. 우리가 권위자이므로 상수로 둬도 된다.
    static let shopReceipt = UTType(exportedAs: "com.example.shop.receipt")

    /// imported — 남의 타입. 반드시 계산 프로퍼티여야 런타임 갱신을 받는다.
    static var thirdPartyDoc: UTType {
        UTType(importedAs: "com.othervendor.document")
    }
}

예약 네임스페이스도 알아둬야 한다 — 직접 만들면 안 되는 것들이다.

식별자는 대소문자 무시 ASCII의 역-DNS다. 그리고 확장자는 3글자에 얽매일 필요가 없다 — 문서는 오히려 충돌 회피를 위해 긴 확장자를 권장한다.

익스텐션에서 UTI가 쓰이는 자리는 셋이다.

  1. NSExtensionActivationRule — 시스템이 "이 익스텐션을 공유 시트에 띄울까"를 판정할 때. UTI-CONFORMS-TO 연산자가 준수 트리를 타고 판정한다.
  2. NSItemProviderregisteredTypeIdentifiershasItemConformingToTypeIdentifier(Q4).
  3. 문서 타입 선언 — 우리 앱이 어떤 파일을 열 수 있는지.

NSExtensionActivationRule미리 정의된 키 방식과 술어 방식 둘 다 지원한다. 미리 정의된 키가 훨씬 간단하다.

<key>NSExtensionAttributes</key>
<dict>
  <key>NSExtensionActivationRule</key>
  <dict>
    <key>NSExtensionActivationSupportsImageWithMaxCount</key><integer>10</integer>
    <key>NSExtensionActivationSupportsMovieWithMaxCount</key><integer>1</integer>
    <key>NSExtensionActivationSupportsWebURLWithMaxCount</key><integer>1</integer>
  </dict>
</dict>

주요 키는 이렇다 — SupportsText, SupportsWebURLWithMaxCount, SupportsWebPageWithMaxCount(JS 접근 시 필수이고 0이 아니어야 함), SupportsImageWithMaxCount, SupportsMovieWithMaxCount, SupportsFileWithMaxCount, SupportsAttachmentsWithMaxCount/MinCount. 값은 처리 가능한 최대 개수이고, 지원하지 않으면 0이거나 키를 뺀다.

⚠️ 주의

TRUEPREDICATE는 개발 중에만 쓰는 스텁이다. Xcode Share 템플릿의 기본값이 이것이라 그냥 두고 제출하는 사고가 잦은데, 문자열 TRUEPREDICATE가 남아 있으면 앱이 리젝된다고 문서가 명시한다. 제출 전에 실제 술어나 위 키들로 반드시 교체해야 한다.

실험 · 도구

준수 트리를 직접 탐색해보면 구조가 손에 잡힌다.

import UniformTypeIdentifiers

// 준수 관계 확인 — 상위 타입으로 물어보면 하위가 전부 잡힌다
print(UTType.jpeg.conforms(to: .image))     // true
print(UTType.jpeg.conforms(to: .data))      // true — 트리를 타고 올라간다
print(UTType.jpeg.conforms(to: .content))   // true — 다중 상속
print(UTType.png.conforms(to: .jpeg))       // false — 형제 관계

// 상위 타입 목록을 재귀로 펼쳐본다
func printTree(_ t: UTType, depth: Int = 0) {
    print(String(repeating: "  ", count: depth) + t.identifier)
    for parent in t.supertypes.filter({ $0 != t }) where depth < 3 {
        printTree(parent, depth: depth + 1)
    }
}
printTree(.heic)

// 확장자 ↔ UTI ↔ MIME 상호 변환
print(UTType(filenameExtension: "jpg")?.identifier)          // "public.jpeg"
print(UTType(filenameExtension: "jpeg")?.identifier)         // "public.jpeg" — 같은 값으로 정규화
print(UTType(mimeType: "image/jpeg")?.identifier)            // "public.jpeg"
print(UTType.jpeg.preferredFilenameExtension)                // "jpeg"
print(UTType.jpeg.preferredMIMEType)                         // "image/jpeg"

// 확장자가 없거나 알 수 없으면 dyn 네임스페이스가 나온다
print(UTType(filenameExtension: "zzzunknown")?.identifier)   // "dyn.ah62d4..." ← 하드코딩 금지

"시스템은 파일을 열어보지 않는다"는 것도 실험으로 확인된다.

// PNG 데이터를 .txt 확장자로 저장하면 시스템은 텍스트로 판단한다
let pngData = UIImage(named: "logo")!.pngData()!
let url = FileManager.default.temporaryDirectory.appendingPathComponent("logo.txt")
try pngData.write(to: url)

let values = try url.resourceValues(forKeys: [.contentTypeKey])
print(values.contentType?.identifier)   // "public.plain-text" ← 내용은 PNG인데도
//   → 시스템은 바이트를 읽지 않고 확장자만 봤다.
//   → 그래서 사용자가 준 파일의 확장자를 믿으면 안 되는 경우가 생긴다.
# 커맨드라인으로 UTI 확인
mdls -name kMDItemContentType -name kMDItemContentTypeTree ~/Desktop/photo.jpg

# 시스템에 등록된 타입 선언 확인 (macOS)
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -dump | grep -A5 "com.example.shop.receipt"

프로젝트 적용

UTI 관련 실무 규칙 다섯.

⚠️ 흔한 오해

"시스템이 파일 내용을 보고 타입을 정한다"가 가장 흔한 오해다. 거의 모든 결정이 경로 확장자 기반이다 — 내용 읽기는 비싸고 권한도 없기 때문이다. "public.jpeg만 확인하면 이미지를 다 잡는다"도 틀렸다 — HEIC·PNG는 형제라 안 잡힌다. public.image로 물어야 준수 트리가 전부 잡는다. "imported든 exported든 상수로 선언하면 된다"도 틀렸다 — imported는 static var여야 런타임 갱신을 받는다. "확장자는 3글자여야 한다"도 낡은 관념이고, 문서는 충돌 회피를 위해 긴 확장자를 권한다. 그리고 TRUEPREDICATE를 그대로 두면 리젝인데, Xcode 템플릿 기본값이라 사고가 잦다.

🧒 쉽게 이해하기

도서관에서 책을 분류하는 방법을 생각해보자.

문제 상황 — 같은 책을 두고 어떤 사람은 "소설", 어떤 사람은 "장편소설", 어떤 사람은 "픽션"이라고 부른다. 이름이 제각각이면 찾을 수가 없다.

해결 1 — 정식 이름 하나로 통일. 뭐라고 부르든 도서관 안에서는 하나의 분류 번호로 정한다. 이게 UTI다.

해결 2 — 계단식 분류. "장편소설"은 "소설"에 속하고, "소설"은 "문학"에 속한다. 그래서 "문학 코너 책 아무거나"라고 하면 장편소설도 자동으로 포함된다. 하나하나 나열할 필요가 없다.

그런데 여기 놀라운 규칙이 하나 있다. 도서관 직원은 책을 펼쳐보지 않는다. 책이 수만 권인데 다 펼쳐볼 수 없고, 어떤 책은 열어볼 권한도 없다. 그래서 표지에 적힌 제목만 보고 분류한다.

이 규칙의 결과가 재밌다. 소설책 표지에 "요리책"이라고 붙여두면 도서관은 요리책으로 분류한다. 내용은 상관없다. 그래서 "표지를 믿고 분류하는" 시스템에서는 표지를 잘못 붙이는 것이 곧 오분류가 된다.

꼬리 질문

확장자 기반 판정이라 생기는 보안 문제는 무엇이며 어떻게 대응하는가?

핵심 위험은 타입 혼동(type confusion)이다. 공격자가 실행 가능한 내용이나 악의적 페이로드를 담은 파일에 무해한 확장자를 붙여 보내면, 시스템은 무해한 타입으로 분류하고 우리 앱은 그것을 신뢰해 처리할 수 있다.

구체적 시나리오를 보면 이렇다.

  • 파서 혼동. .txt라고 받아서 텍스트 파서에 넣었는데 실제로는 거대한 압축 폭탄이라 메모리가 터진다.
  • 이미지 디코더 공격. .png인 줄 알고 디코더에 넘겼는데 조작된 파일이라 디코더 취약점을 건드린다.
  • 확장자 위조로 필터 우회. "우리는 이미지만 받는다"는 검사를 확장자로만 하면 우회된다.

대응은 셋이다.

(1) 내용을 검증한다. 신뢰할 수 없는 출처의 파일은 매직 넘버를 직접 확인한다. 시스템이 안 해준다고 우리도 안 할 이유는 없다 — 우리는 파일 하나만 보면 되므로 비용이 감당된다.

(2) 크기·구조 한도를 먼저 건다. P0 05장에서 본 "신뢰할 수 없는 소스의 중첩 깊이는 파싱 전에 크기·깊이로 먼저 거른다"가 그대로 적용된다.

(3) 실패를 안전하게 처리한다. 파싱 실패를 크래시가 아니라 에러로 만든다. 익스텐션에서 크래시하면 진단도 어렵다(Q1 꼬리질문).

/// 확장자를 믿지 않고 매직 넘버로 확인한다
func looksLikePNG(_ url: URL) -> Bool {
    guard let h = try? FileHandle(forReadingFrom: url) else { return false }
    defer { try? h.close() }
    let magic = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
    return (try? h.read(upToCount: 8)) == magic
}
쉽게 말하면 도서관이 표지만 보고 분류한다는 걸 알면, 누군가 위험한 책에 "동화책" 표지를 씌워 넣을 수 있다. 도서관 전체를 다 펼쳐볼 수는 없지만, 내가 실제로 읽을 책 한 권은 열어서 확인할 수 있다. 그 정도는 해야 한다.
활성화 규칙을 좁게 잡는 것과 넓게 잡는 것의 트레이드오프는?

좁게 잡으면 — 우리가 처리할 수 있는 것만 정확히 받는다. 사용자가 우리 익스텐션을 골랐는데 "지원하지 않는 형식"이라는 메시지를 보는 일이 없다. 대신 공유 시트에 아예 안 나타나는 경우가 생기고, 사용자는 우리 앱이 그 기능을 지원하는지조차 모른다.

넓게 잡으면 — 항상 나타나므로 발견성이 좋다. 대신 처리 못 하는 것도 들어오고, 그때 사용자 경험이 나빠진다.

실무 권장은 "넓게 받고 안에서 안내"다. 이유가 둘 있다.

(1) 발견성이 더 중요하다. 공유 시트에 없으면 사용자는 다시 시도하지 않는다. 있는데 안내를 받으면 최소한 "이 앱이 이런 걸 한다"는 것은 안다.

(2) host마다 타입 구성이 다르다(Q4). 좁게 잡으면 특정 host에서만 안 나타나는 예측 불가능한 동작이 생긴다.

다만 완전히 넓게(모든 파일) 잡는 것은 다르다. 우리와 전혀 무관한 콘텐츠에도 나타나면 사용자에게 방해가 되고, 공유 시트가 지저분해진다. 대분류 수준(이미지 전체, URL 전체)이 적절한 지점이다.

그리고 개수 제한도 신중히 정한다. MaxCount를 크게 잡았는데 실제로는 하나씩만 처리한다면, 사용자가 20장을 선택했을 때 나쁜 경험이 된다. 처리 가능한 실제 개수를 적는다.

쉽게 말하면 가게 간판에 "수선 전문"이라고만 쓰면 옷 수선인지 신발 수선인지 몰라 안 들어온다. 반대로 "면 소재 셔츠 3번째 단추 전문"이라고 쓰면 정확하지만 아무도 못 찾는다. "옷 수선" 정도가 맞고, 못 하는 게 오면 들어온 손님에게 친절히 안내하면 된다.
우리만의 파일 포맷을 만들 때 준수 관계를 어떻게 설계해야 하는가?

원칙은 "시스템이 알아야 할 것만 선언하고, 구현 세부는 감춘다"이다. P0 17장의 정보 은닉이 파일 포맷에 적용된 사례다.

반드시 선언할 것은 물리적 성질이다.

  • 일반 파일이면 public.data에 준수시킨다.
  • OS가 파일처럼 다뤄야 할 디렉터리(번들 형태)면 com.apple.package에 준수시킨다. 이걸 빠뜨리면 사용자에게 폴더로 보여 내부가 노출된다.
  • 사용자가 신경 쓰는 문서라면 public.content도 고려한다.

선택적인 것이 구현 세부다. 우리 포맷이 내부적으로 JSON이라고 해서 public.json에 준수시킬 필요는 없다. Apple 문서도 이를 선택으로 규정하며, 포맷이 바뀔 여지가 있거나 남이 의존하길 원치 않으면 빼는 편이 낫다고 안내한다.

이유가 명확하다 — public.json에 준수시키면 모든 JSON 편집기가 우리 파일을 열 수 있다고 주장하게 되고, 사용자가 텍스트 에디터로 열어 망가뜨릴 수 있다. 그리고 나중에 바이너리 포맷으로 바꾸면 그 준수 선언이 거짓이 된다.

추가로 정할 것 셋 — exported로 선언(우리가 권위자), 확장자를 충분히 길게(충돌 회피), 역-DNS 식별자를 우리 도메인으로(com.example은 예제 전용이라 쓰면 안 된다).

쉽게 말하면 우리 회사만의 서류 양식을 만들 때, "이건 종이 문서입니다"는 반드시 알려야 한다(안 그러면 시스템이 어떻게 다룰지 모른다). 그런데 "안에 엑셀 표가 들어 있어요"까지 알릴 필요는 없다. 알리면 아무나 엑셀로 열어서 고치려 들고, 나중에 표 대신 그림으로 바꾸면 그 말이 거짓이 된다.

Q6. App-extension-safe API를 어기면 언제 터지는가?

🔑 30초 답변

컴파일도 되고 실행도 되는데 심사에서 걸린다는 것이 이 문제의 성질이다. 익스텐션 타깃은 빌드 설정 "Require Only App-Extension-Safe API = Yes"를 켜야 하고, 안 켜면 Xcode가 linking against dylib not safe for use in application extensions 경고로만 알려준다 — 경고라서 무시하기 쉽다. 금지 목록은 sharedApplication 접근, NS_EXTENSION_UNAVAILABLE 표시 API, 카메라·마이크(iMessage 앱만 예외), 장시간 백그라운드 작업(URLSession 업로드/다운로드만 예외), AirDrop 수신(송신은 가능)이다. 그리고 결정적으로 — 금지 API를 쓰는 프레임워크를 익스텐션이 링크하기만 해도 앱스토어가 리젝한다. 우리가 그 함수를 호출하지 않아도 그렇다.

CS 원리

여기서 작동하는 원리는 정적 링크 시점의 전이적 의존이다. 익스텐션이 프레임워크 F를 링크하면, F가 참조하는 심볼이 전부 익스텐션의 의존성이 된다. 우리가 그 코드 경로를 실행하지 않아도, 바이너리에는 그 심볼 참조가 남아 있다.

Apple의 검사는 바이너리 수준에서 이뤄지므로 "호출 안 하니까 괜찮다"가 통하지 않는다. 실행 여부가 아니라 링크 여부가 기준이다.

왜 이렇게 엄격한지는 보안 모델의 일관성으로 설명된다. 익스텐션은 host app의 컨텍스트 안에서 실행되므로, 익스텐션이 할 수 있는 일은 host의 신뢰 경계에 영향을 준다. 커스텀 키보드가 카메라를 열 수 있다면 사용자가 어떤 앱에서 타이핑하든 위험해진다. 그래서 "실행 시점에 막는다"가 아니라 "애초에 링크할 수 없게 한다"는 접근을 택했다.

이건 P0 03장의 샌드박스 개념과 층이 다르다는 점도 짚을 만하다.

두 층이 함께 작동해서, 런타임 우회도 어렵고 빌드 우회도 어렵게 만든다.

iOS에서는

금지 목록을 구체적으로 정리하면 이렇다.

금지예외대안
sharedApplication 객체와 그 모든 메서드없음NSExtensionContext의 해당 기능 (레거시 Today 위젯만 open(_:), WidgetKit은 딥링크)
NS_EXTENSION_UNAVAILABLE 표시 API없음API별로 다름. 컴파일러가 알려준다
카메라 · 마이크iMessage 앱 (usage description 설정 시)컨테이닝 앱으로 유도
장시간 백그라운드 작업URLSession 업로드/다운로드공유 컨테이너에 넣고 앱이 처리 (Q3)
AirDrop 수신송신은 UIActivityViewController로 가능

UIBackgroundModes 키가 익스텐션 Info.plist에 있으면 그 자체로 리젝이다. VoIP·백그라운드 오디오 같은 모드를 쓰려고 키를 넣으면 안 된다.

기준은 실행 여부가 아니라 링크 여부 ShareExtension 우리는 이 경로를 안 탄다 링크 SharedCore.framework 공유 코드 참조 UIApplication.shared 바이너리에 심볼 참조가 남는다 "호출 안 하니까 괜찮다"가 통하지 않는 이유 — 검사는 바이너리 수준이다 그래서 가장 늦게 터진다 컴파일 통과 ✓ 설정 안 켜면 경고뿐 기기 실행 통과 ✓ 그 경로를 안 타니 멀쩡 QA · TestFlight 통과 ✓ 앱 심사 리젝 ✕ 릴리스 일정이 밀린다 대책은 하나 — 경고를 빌드 실패로 승격한다 익스텐션 타깃과 공유 프레임워크 타깃 모두 APPLICATION_EXTENSION_API_ONLY = YES
익스텐션이 프레임워크를 링크하면 그 프레임워크가 참조하는 심볼 전부가 익스텐션의 의존성이 된다.

공유 코드 구성에서 부딪히는 지점이 embedded framework다. Apple이 정한 정식 코드 공유 수단인데, 그 프레임워크 전체가 app-extension-safe여야 익스텐션이 링크할 수 있다.

금지 API를 쓰는 프레임워크는 컨테이닝 앱에서만 안전하게 링크할 수 있고, 익스텐션과는 그 코드를 공유할 수 없다. Q1 꼬리질문에서 본 분리 전략(프레임워크 분할, 프로토콜 경계, 조건부 컴파일)이 여기서 필요해진다.

빌드 설정에서 놓치기 쉬운 함정도 있다.

실험 · 도구

가장 중요한 것은 경고를 에러로 승격하는 것이다. 경고로 두면 반드시 새어 나간다 — 21장 Q6에서 본 "조용한 실패"와 같은 구조다.

# 1) 빌드 설정 확인 — 익스텐션 타깃과 공유 프레임워크 타깃 모두 켜야 한다
xcodebuild -showBuildSettings -target ShareExtension \
  | grep APPLICATION_EXTENSION_API_ONLY
#   APPLICATION_EXTENSION_API_ONLY = YES  ← 이래야 정상

# 공유 프레임워크도 확인 — 여기를 빼먹는 경우가 많다
xcodebuild -showBuildSettings -target SharedCore \
  | grep APPLICATION_EXTENSION_API_ONLY

# 2) CI 검증 — 하나라도 NO 면 실패시킨다
for t in ShareExtension NotificationExtension SharedCore; do
  v=$(xcodebuild -showBuildSettings -target "$t" 2>/dev/null \
      | awk -F' = ' '/APPLICATION_EXTENSION_API_ONLY/{print $2}' | tr -d ' ')
  [ "$v" = "YES" ] || { echo "❌ $t: APPLICATION_EXTENSION_API_ONLY=$v"; exit 1; }
done

# 3) 빌드 로그에서 관련 경고를 잡아낸다
xcodebuild build 2>&1 | grep -i "not safe for use in application extensions" \
  && { echo "❌ app-extension-safe 위반"; exit 1; }

바이너리 수준 검사도 가능하다. 링크된 심볼을 직접 들여다본다.

# 익스텐션 바이너리가 금지 심볼을 참조하는지 확인
EXT="YourApp.app/PlugIns/ShareExt.appex/ShareExt"

nm -u "$EXT" | grep -E 'UIApplication|sharedApplication' \
  && echo "⚠️ UIApplication 참조 발견 — 확인 필요"

# 링크된 라이브러리 목록 — 예상 밖의 프레임워크가 있는지 본다
otool -L "$EXT"
#   /System/Library/Frameworks/UIKit.framework/UIKit  ← 정상
#   @rpath/SharedCore.framework/SharedCore            ← 우리 프레임워크
#   @rpath/SomeAnalyticsSDK.framework/...             ← ⚠️ 이게 안전한가?

컴파일 타임에 잡아내는 방법도 있다. Swift에서는 가용성 검사로 일부가 걸린다.

// APPLICATION_EXTENSION_API_ONLY=YES 인 타깃에서는 컴파일 에러가 난다
func openSettings() {
    // ❌ 'shared' is unavailable in application extensions for iOS
    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
}

// ✅ 대안 — 익스텐션에서는 컨텍스트를 통하거나, 아예 다른 흐름을 설계한다
func requestOpenSettings() {
    // ⚠️ 레거시 Today 위젯만 이 경로를 쓸 수 있다. WidgetKit 위젯은 widgetURL/Link 딥링크.
    extensionContext?.open(URL(string: "shop://settings")!, completionHandler: nil)
}

// 공유 코드에서 앱 전용 기능이 필요하면 프로토콜로 경계를 만든다 (Q1 꼬리질문)
protocol AppCapabilities {
    func openExternalURL(_ url: URL)
}
// 구현은 앱 타깃에만 둔다 — 프레임워크는 프로토콜만 안다

프로젝트 적용

실무 규칙 다섯.

⚠️ 흔한 오해

"호출하지 않으면 괜찮다"가 가장 비싼 오해다. 금지 API를 쓰는 프레임워크를 링크하기만 해도 리젝된다 — 검사는 실행이 아니라 링크 기준이다. "빌드가 되니 문제없다"도 틀렸다 — 경고로만 나오고 빌드는 성공한다. "익스텐션 타깃만 설정하면 된다"도 부족하다 — 링크하는 프레임워크 전부에 켜야 한다. "카메라는 권한만 받으면 된다"도 아니다 — iMessage 앱을 제외하면 권한과 무관하게 금지다. 또 "Copy Files 목적지는 아무거나"도 위험하다 — "SharedFramework"를 고르면 리젝이다.

🧒 쉽게 이해하기

출장 기사가 손님 집에서 일한다는 걸 다시 떠올려보자. 회사에서는 마음대로 쓰던 장비가 손님 집에서는 금지된다.

왜? 손님 집이니까. 기사가 손님 집에서 카메라를 돌리거나, 밤새 눌러앉아 있거나, 손님 집 전화로 아무 데나 연락하면 곤란하다. 그래서 가져갈 수 있는 장비 목록이 정해져 있다.

여기서 무서운 규칙이 하나 있다. 공구함에 금지 장비가 하나라도 들어 있으면, 쓰지 않아도 반입 금지다. "안 쓸 건데요"는 안 통한다. 문 앞에서 공구함을 열어보고 있으면 돌려보낸다.

더 얄궂은 건 언제 걸리느냐다. 회사에서 공구함을 챙길 때는 아무도 뭐라 안 한다. 짐을 싣고 출발할 때도 괜찮다. 손님 집 문 앞에 도착해서야 검사를 받는다. 그때 돌려보내지면 하루가 날아간다.

그래서 회사에서 미리 검사하는 절차를 만들어야 한다. "출발 전 공구함 점검"을 경고가 아니라 통과 조건으로 두는 것이다.

꼬리 질문

서드파티 SDK가 app-extension-safe인지 어떻게 확인하는가?

세 단계로 확인한다.

(1) 문서 확인. 성숙한 SDK는 익스텐션 지원 여부를 명시하고, 대개 익스텐션 전용 배포 타깃을 따로 제공한다(예: SomeSDKSomeSDK-Extension). 이런 구분이 있다는 것 자체가 문제를 인지하고 대응했다는 신호다.

(2) 바이너리 검사. 문서가 불명확하면 직접 본다.

FW="Carthage/Build/iOS/SomeSDK.framework/SomeSDK"

# 빌드 시 app-extension-safe로 컴파일됐는지 확인
otool -l "$FW" | grep -A3 LC_BUILD_VERSION

# 금지 심볼 참조 확인
nm -u "$FW" | grep -E 'UIApplication|sharedApplication|AVCaptureDevice'

(3) 실제로 링크해본다. 가장 확실하다. 익스텐션 타깃에 APPLICATION_EXTENSION_API_ONLY=YES를 켜고 링크하면, 안전하지 않을 때 경고가 나온다. 그 경고를 CI에서 에러로 잡으면 자동 검증이 된다.

안전하지 않다고 판명되면 대응은 셋이다 — 익스텐션 전용 변형을 요청하거나, 익스텐션에서는 그 SDK를 안 쓰거나(관측 SDK라면 익스텐션 데이터를 포기하는 셈이라 아쉽다), 대체재를 찾는다.

21장 Q3(dSYM 미제공)과 23장 Q2(manifest 미제공)에 이어, 이것도 SDK 도입 기준 체크리스트에 넣을 항목이다. 세 가지가 모두 "벤더가 플랫폼 요구사항을 따라오고 있는가"를 묻는 같은 종류의 질문이다.

쉽게 말하면 협력업체 공구함을 받아 쓰는데, 손님 집에 가져가도 되는 물건인지를 확인해야 한다. 업체가 "출장용 세트"를 따로 만들어 준다면 신뢰할 만하고, 그런 구분이 없으면 직접 열어봐야 한다.
공유 프레임워크에서 UIKit 의존을 줄이면 어떤 부수 효과가 있는가?

app-extension-safe 문제가 거의 자동으로 해결되는 것이 직접 효과이고, 부수 효과가 셋 더 있다.

(1) 테스트가 쉬워진다. UIKit 의존이 없으면 시뮬레이터 없이도 유닛 테스트가 돌고, 실행이 빨라진다. P0 15장의 테스트 층위 논의와 이어진다.

(2) 플랫폼 이식이 가능해진다. 순수 로직은 watchOS·macOS·서버 사이드에서도 쓸 수 있다. 익스텐션을 만들다 보면 자연스럽게 이 방향으로 밀린다.

(3) 성능 격리가 생긴다. 20장 Q6에서 본 격리 전염 문제 — UI 타입이 @MainActor라 그 안의 로직도 메인에서 돌게 되는 것 — 이 구조적으로 사라진다. UIKit에 의존하지 않는 타입은 @MainActor가 아니므로 백그라운드 실행이 자연스럽다.

익스텐션 제약이 좋은 설계를 강제하는 사례다. 처음에는 불편해 보이지만, 따르다 보면 P0 17장에서 말한 관심사 분리가 실제로 이뤄진다.

실무 지침은 "공유 프레임워크에는 import UIKit을 넣지 않는 것을 기본 규칙으로" 두는 것이다. 정말 필요하면 별도 UI 프레임워크로 분리한다.

쉽게 말하면 "손님 집에 가져갈 수 있는 공구만 넣자"고 공구함을 정리하다 보면, 덩치 크고 무거운 장비가 자연스럽게 빠진다. 그런데 정리하고 보니 공구함이 가벼워져서 어디든 들고 다니기 좋아졌다. 원래 목적은 규정 준수였는데 다른 이득이 따라온 것이다.
익스텐션에서 카메라를 쓸 수 없다면 사진을 다루는 기능은 어떻게 설계하는가?

세 가지 접근이 있다.

(1) 입력을 host에게 맡긴다. 익스텐션은 이미 존재하는 사진을 받아 처리한다. 사용자가 사진 앱에서 사진을 고르고 공유를 누르면, 그 사진이 NSItemProvider로 들어온다(Q4). 대부분의 시나리오가 이걸로 해결된다 — 익스텐션은 "새로 찍는" 곳이 아니라 "이미 있는 것을 처리하는" 곳이다.

(2) 사진 라이브러리 접근. 카메라와 달리 사진 라이브러리 읽기는 익스텐션에서도 가능한 경우가 있다(권한과 point에 따라 다르다). 다만 권한 요청 UI를 익스텐션에서 띄우는 것은 사용자 경험이 나쁘므로, 컨테이닝 앱에서 미리 권한을 받아두는 것이 낫다.

(3) 컨테이닝 앱으로 유도. 정말 촬영이 필요하면 익스텐션에서는 안내만 하고 앱을 열게 한다. 다만 레거시 Today 위젯만 extensionContext.open으로 앱을 열 수 있고(Q1), 다른 익스텐션은 그 경로가 없다(WidgetKit은 딥링크). 그래서 이 방법은 제한적이다.

설계 관점의 결론은 "익스텐션의 책임을 좁게"다. 익스텐션은 host가 준 것을 받아 공유 컨테이너에 넣고 끝내는 얇은 층으로 두고, 무거운 일과 권한이 필요한 일은 앱이 한다. 이건 메모리 예산(Q7)과 실행 시간 제약에도 맞는 구조다.

예외는 iMessage 앱이다 — NSCameraUsageDescription·NSMicrophoneUsageDescription을 설정하면 카메라·마이크를 쓸 수 있다. 스티커나 사진 메시지 같은 용도를 위해 열어둔 것이다.

쉽게 말하면 출장 기사는 사진을 찍을 수 없지만, 손님이 이미 찍어둔 사진을 건네주는 것은 받을 수 있다. 그래서 "기사가 직접 찍는" 설계 대신 "손님이 고른 사진을 받는" 설계로 바꾸면 대부분 해결된다.

Q7. 익스텐션의 자원 예산은 왜 앱보다 빡빡한가?

🔑 30초 답변

익스텐션은 host app의 사용자 경험 안에서 실행되기 때문이다. 익스텐션이 느리거나 무거우면 host 앱이 느려 보이고, 심하면 시스템 전체가 영향을 받는다. 그래서 Apple의 성능 계약은 명확하다 — 실행은 "well under one second"를 목표로 하고 느리면 시스템이 죽인다. 메모리 한도는 포그라운드 앱보다 "significantly lower"이며 시스템이 공격적으로 종료한다. 또한 메인 런루프를 익스텐션이 소유하지 않으므로 블로킹하면 다른 익스텐션과 앱까지 망친다. GPU도 공유 자원이고 익스텐션은 우선순위가 낮다. 주의 — 널리 인용되는 "120MB" 같은 수치는 Apple 공식 문서에 없다. 크래시 로그와 포럼에서 관측된 값이므로 정확한 숫자에 의존한 설계는 위험하다.

CS 원리

여기서 작동하는 것은 자원 공정성과 격리의 트레이드오프다. P0 01장에서 본 per-process 메모리 한도가 익스텐션에서 훨씬 낮게 설정되는 이유는 셋이다.

여기에 런루프 소유권 문제가 더해진다. 익스텐션은 host의 UI 컨텍스트 안에 뷰를 띄우는데, 메인 런루프를 소유하지 않는다. 20장에서 본 "메인스레드를 막으면 앱이 멈춘다"가 여기서는 남의 앱을 멈추게 하는 문제가 된다. 그래서 익스텐션에서의 메인스레드 블로킹은 앱에서보다 죄질이 무겁다.

같은 코드인데 예산이 다르다 — 그리고 실수의 파급 범위도 다르다 포그라운드 앱 메모리 예산 — 기기 RAM 에 비례해 커진다 넉넉 익스텐션 "significantly lower" 쓸 수 없는 영역 — 넘으면 공격적으로 종료 ⚠️ 정확한 수치는 Apple 미문서화. 종류·버전·기기마다 다르다 → os_proc_available_memory() 로 런타임 확인 메인스레드를 막았을 때 — 파급 범위가 다르다 앱에서 막으면 내 앱만 멈춘다 사용자가 내 앱을 탓한다 익스텐션에서 막으면 런루프를 소유하지 않으므로 host 앱·다른 익스텐션까지 멈춘다 실행은 "well under one second" 목표 — 느리면 시스템이 죽인다
예산이 좁을 뿐 아니라 남의 앱까지 영향을 준다. 익스텐션의 블로킹 기준이 앱보다 엄격해야 하는 이유다.

19장에서 본 종료 메커니즘도 그대로 적용된다 — 메모리 한도 초과는 EXC_RESOURCE (MEMORY) 경고 후 Jetsam 종료로, 시간 초과는 워치독 계열로 나타난다. 다만 임계값이 훨씬 낮다.

iOS에서는

제약을 항목별로 정리하면 이렇다.

자원제약근거 등급
실행 시간"well under one second" 목표, 초과 시 시스템이 종료Apple 문서 명시
메모리포그라운드 앱보다 "significantly lower", 공격적 종료Apple 문서 명시 (수치는 없음)
메모리 (구체 수치)Share 약 120MB, Widget 약 30MB 등비공식 — 크래시 로그·포럼 관측값
메인 런루프소유하지 않음 — 블로킹하면 다른 익스텐션·앱에 영향Apple 문서 명시
GPU공유 자원이며 익스텐션은 우선순위가 낮음Apple 문서 명시
백그라운드장시간 작업 금지 (URLSession 전송만 예외)Apple 문서 명시
⚠️ 수치의 출처 등급

"공유 익스텐션은 120MB"라는 수치는 Apple 공식 문서 어디에도 없다. 크래시 로그의 EXC_RESOURCE … exceeded mem limit 메시지와 개발자 포럼에서 반복 관측된 값이다. 익스텐션 종류·iOS 버전·기기에 따라 다를 수 있고 예고 없이 바뀔 수 있다. 18장 노트에서 말한 대로, 이런 값을 공식 스펙처럼 인용하는 것은 면접에서도 실무에서도 위험하다. 수치에 맞추지 말고 "최대한 적게 쓰도록" 설계하고, 실제 한도는 os_proc_available_memory()런타임에 확인한다.

실험 · 도구

익스텐션의 실제 여유 메모리는 런타임에 확인할 수 있다. 이것이 고정 수치에 의존하지 않는 유일한 방법이다.

import Foundation
import os

enum MemoryBudget {
    /// 지금 이 프로세스가 더 쓸 수 있는 메모리 (바이트)
    static var available: Int { os_proc_available_memory() }

    /// 현재 footprint — Jetsam이 판정에 쓰는 값 (19장 Q5)
    static var footprint: UInt64 {
        var info = task_vm_info_data_t()
        var count = mach_msg_type_number_t(
            MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
        let kr = withUnsafeMutablePointer(to: &info) {
            $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
                task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
            }
        }
        return kr == KERN_SUCCESS ? info.phys_footprint : 0
    }

    /// 작업 전 여유를 확인하고 규모를 조절한다
    static func canAfford(_ bytes: Int, safetyMargin: Double = 0.5) -> Bool {
        Double(available) * safetyMargin > Double(bytes)
    }

    static func log(_ stage: String) {
        Logger(subsystem: "com.example.shop", category: "memory").notice("""
            [\(stage, privacy: .public)] \
            footprint=\(footprint / 1_048_576, privacy: .public)MB \
            available=\(available / 1_048_576, privacy: .public)MB
            """)
    }
}

// 익스텐션 시작 시 로그를 남기면 실제 예산이 보인다
MemoryBudget.log("extension start")
// [extension start] footprint=12MB available=108MB
//   → 이 기기·이 익스텐션의 실제 한도가 약 120MB임을 런타임에 알 수 있다.
//     하드코딩하지 말고 이 값으로 판단한다.

이미지 처리처럼 메모리를 많이 쓰는 작업은 여유에 맞춰 규모를 조절한다.

import ImageIO
import UniformTypeIdentifiers

/// 원본 디코딩 없이 다운샘플링 — 익스텐션의 필수 기법 (P0 08장 Q5)
func downsample(at url: URL, maxPixel: Int) -> CGImage? {
    let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else { return nil }

    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixel,
    ] as CFDictionary

    // 원본 전체를 메모리에 올리지 않고 축소본만 만든다
    return CGImageSourceCreateThumbnailAtIndex(src, 0, options)
}

// 여유에 따라 목표 크기를 정한다
func safeMaxPixel() -> Int {
    let available = MemoryBudget.available
    switch available {
    case ..<(30 * 1_048_576):   return 512     // 여유가 적으면 작게
    case ..<(80 * 1_048_576):   return 1024
    default:                    return 2048
    }
}
# 익스텐션의 메모리 한도 초과를 실제로 관찰한다
# 1) 실기기에서 익스텐션을 실행하고 큰 이미지를 계속 로드
# 2) 종료된 뒤 기기의 분석 데이터에서 리포트를 확인한다
#    설정 › 개인정보 보호 및 보안 › 분석 및 향상 › 분석 데이터
#    → ShareExt-...-JetsamEvent 또는 EXC_RESOURCE 리포트

# 리포트에서 실제 한도를 확인한다 (19장 Q5)
grep -E "Exception (Type|Subtype|Message)" ShareExt-*.ips
#   Exception Type:    EXC_RESOURCE
#   Exception Subtype: MEMORY
#   Exception Message: Memory limit exceeded (Limit 120 MB)
#     ↑ 여기서 실제 값을 확인할 수 있다. 다만 이 값에 의존하지는 말 것.

# Instruments로 프로파일링 — 익스텐션 scheme에 host를 지정해야 붙는다 (Q1)

프로젝트 적용

익스텐션 성능 설계 원칙 여섯.

⚠️ 흔한 오해

"익스텐션 메모리 한도는 120MB"라고 단정하는 것이 대표적 오해다. Apple 공식 문서에 수치가 없고 종류·버전·기기에 따라 다르며 바뀔 수 있다. "본 앱에서 되던 코드니 익스텐션에서도 된다"도 틀렸다 — 예산이 훨씬 작아 같은 이미지 처리가 죽는다. "익스텐션이 죽어도 앱은 멀쩡하니 괜찮다"도 위험하다 — 사용자는 host 앱이나 우리 앱이 이상하다고 느끼고, 그 기능을 다시 안 쓴다. "메인스레드 블로킹은 우리 익스텐션만 느려지게 한다"도 틀렸다 — 런루프를 소유하지 않으므로 다른 익스텐션과 앱까지 영향을 받는다. 또 GPU도 공유 자원이고 익스텐션은 우선순위가 낮다.

🧒 쉽게 이해하기

출장 기사가 손님 집 식탁 한 귀퉁이에서 작업한다고 하자.

회사 작업실에서는 큰 책상을 다 써도 된다. 그런데 손님 집에서는 귀퉁이만 쓸 수 있다. 짐을 많이 펼치면 손님이 밥을 못 먹는다. 그래서 규칙이 이렇다.

  • 1초 안에 끝내라. 오래 걸리면 손님이 불편하니 관리인이 내보낸다.
  • 짐을 적게 펼쳐라. 회사에서 되던 만큼 펼치면 식탁이 넘친다.
  • 통로를 막지 마라. 식탁은 손님 것이라 내가 막으면 손님도, 다른 기사도 못 지나간다.

여기서 자주 하는 실수 — "저번에 120cm까지는 괜찮았으니 이번에도 120cm". 그런데 그 120cm는 아무도 정식으로 알려준 적이 없다. 그때 그 집 식탁이 그랬을 뿐이고, 다음 집은 다를 수 있다.

맞는 방법은 "지금 이 식탁에 얼마나 자리가 남았나"를 그때그때 재보는 것이다. 그리고 애초에 짐을 최대한 적게 가져가는 것이 가장 확실하다.

그리고 제일 좋은 전략 — 큰 작업은 아예 회사에서 한다. 손님 집에서는 물건만 받아서 창고에 넣고, 뜯어서 고치는 일은 사무실에서 한다.

꼬리 질문

익스텐션이 메모리 한도로 죽었는지 시간 초과로 죽었는지 어떻게 구분하는가?

19장의 판독 절차가 그대로 적용된다 — Exception TypeTermination Reason을 본다.

  • 메모리EXC_RESOURCE / Exception Subtype: MEMORY, 또는 Jetsam 이벤트 리포트. Exception MessageMemory limit exceeded (Limit N MB)가 있어 실제 한도까지 알 수 있다.
  • CPU 시간EXC_RESOURCE / CPU 또는 CPU_FATAL. 익스텐션은 CPU 예산이 엄격해서 CPU_FATAL이 실제 종료를 동반하는 경우가 앱보다 흔하다.
  • 시간 초과(응답성)EXC_CRASH (SIGKILL) + Termination Reason. 워치독 계열 코드가 붙는다.
  • 정상 종료 — 완료 후 시스템이 정리한 것이라 리포트가 없다.

구분이 중요한 이유는 대응이 완전히 다르기 때문이다. 메모리면 다운샘플링과 파일 표현으로, CPU면 작업 축소로, 시간 초과면 무거운 일을 앱으로 넘기는 구조 변경으로 간다.

다만 익스텐션 크래시는 수집 자체가 어렵다(Q1 꼬리질문). 공유 컨테이너에 리포트를 쓰고 앱이 전송하는 구성이 되어 있어야 이 판독을 할 수 있다. 그 설정이 없으면 "익스텐션이 가끔 안 된다"는 제보만 받고 원인을 영영 모른다.

쉽게 말하면 기사가 쫓겨났는데 사유서를 봐야 한다. "짐이 너무 많음"인지 "너무 오래 걸림"인지에 따라 다음에 뭘 고쳐야 할지가 완전히 다르다. 그런데 사유서가 손님 집에 남아 있으면 못 본다 — 창고를 통해 회사로 가져오는 경로를 미리 만들어둬야 한다.
"무거운 일은 앱이 한다"는 원칙에서 앱을 언제 어떻게 깨우는가?

익스텐션은 앱을 직접 깨울 수 없다(Today 위젯 제외, Q1). 그래서 앱이 실행될 때 처리하는 지연 방식이 기본이다. 구체적 패턴 넷이 있다.

(1) 작업 큐를 공유 컨테이너에 둔다. 익스텐션이 파일과 "처리 대기" 레코드를 남기고 종료한다. 앱이 실행될 때(didBecomeActive 등) 큐를 확인해 처리한다. 가장 단순하고 확실하다.

(2) 백그라운드 URLSession으로 전송만 위임한다. 업로드가 목적이라면 익스텐션이 백그라운드 세션에 태스크를 등록하고 종료한다. 시스템이 전송하고, 완료 시 앱을 깨워 알린다. 이 경우 앱이 자동으로 깨어나므로 사용자가 앱을 열 필요가 없다(Q1 꼬리질문).

(3) BGTaskScheduler로 예약한다. 앱이 백그라운드 처리 태스크를 미리 등록해두면 시스템이 적절한 시점에 실행한다. 다만 실행 시점을 우리가 통제할 수 없고, 익스텐션에서 직접 예약할 수 있는지는 확인이 필요하다.

(4) 사용자에게 알린다. 처리가 필요하다는 로컬 알림을 띄워 사용자가 앱을 열도록 유도한다. 남용하면 성가시므로 정말 필요할 때만.

실무 조합은 (1)을 기본으로, 전송이 목적이면 (2)다. 그리고 중요한 것은 큐가 무한히 쌓이지 않게 하는 것 — 사용자가 앱을 오래 안 열면 공유 컨테이너에 파일이 쌓여 저장 공간을 먹는다. 보관 기한과 개수 상한을 두고 초과분은 정리한다.

쉽게 말하면 기사가 물건을 창고에 넣고 "처리 요망" 쪽지를 붙여둔다. 사무실 직원이 출근하면 창고를 확인해 처리한다. 급한 배송이면 택배사에 바로 접수해두면 알아서 가고 도착하면 연락이 온다. 다만 창고가 무한하지 않으니 오래된 물건은 정리하는 규칙이 필요하다.
익스텐션의 성능을 프로덕션에서 어떻게 측정하는가?

18~22장의 관측 파이프라인이 익스텐션에서는 제약을 받는다. 세 가지 조정이 필요하다.

(1) 초기화 비용이 치명적이다. 18장 Q7에서 본 SDK 초기화 비용이 앱에서는 콜드 스타트의 일부지만, 익스텐션에서는 전체 실행 시간이 1초 미만이어야 하므로 같은 비용이 훨씬 큰 비중을 차지한다. 익스텐션에서는 최소 구성으로 초기화하거나, 자동 계측을 대부분 끄는 것이 맞다.

(2) 전송이 안 끝난다. 익스텐션은 완료 직후 종료되므로 이벤트 전송이 중간에 잘린다. 공유 컨테이너에 쌓고 앱이 전송하는 구성이 필수다.

(3) 측정 항목을 좁힌다. 익스텐션에서 정말 필요한 것은 소수다 — 실행 시간, 피크 메모리, 성공/실패, 종료 사유. 상세 트레이스는 오버헤드 대비 이득이 적다.

// 익스텐션용 최소 계측 — 완료 전에 반드시 기록한다
final class ExtensionMetrics {
    private let start = ContinuousClock.now
    private var peakFootprint: UInt64 = 0

    func sample() { peakFootprint = max(peakFootprint, MemoryBudget.footprint) }

    /// completeRequest 호출 "전에" 부른다 — 이후는 실행 보장이 없다 (Q1)
    func finish(outcome: String) {
        let elapsed = ContinuousClock.now - start
        SharedStorage.appendPendingEvent([
            "kind": "extension.run",
            "process": Bundle.main.bundleIdentifier ?? "?",
            "duration_ms": elapsed.milliseconds,
            "peak_mb": peakFootprint / 1_048_576,
            "available_mb": MemoryBudget.available / 1_048_576,
            "outcome": outcome,
        ])
    }
}

그리고 MetricKit이 익스텐션에도 유용하다(19장 Q7). OS가 집계하므로 우리 전송이 실패해도 남고, MXAppExitMetric으로 종료 유형을 알 수 있다. 다만 익스텐션별 분리가 되는지는 확인이 필요하다.

쉽게 말하면 기사에게 작업 일지를 아주 자세히 쓰게 하면, 일지 쓰다가 시간이 다 간다. 1분 안에 끝내야 하는 일인데. 그래서 "몇 분 걸렸나, 짐이 얼마나 됐나, 성공했나" 정도만 창고에 쪽지로 남기고 가게 한다. 정리는 사무실에서 한다.

출처 · 참고자료