Expo 프로젝트에서 iOS 위젯 구현하기 대표 이미지

Expo 프로젝트에서 iOS 위젯 구현하기

2025. 10. 31.
AI 요약
Expo 프로젝트에서 iOS 위젯을 구현하려면 @bacons/apple-targets로 Widget Target을 추가하고, ExtensionStorage로 JS ↔ Swift 간 데이터를 동기화한다. Prebuild 템플릿을 활용하면 Xcode Preview에서 빠르게 위젯 UI를 미리볼 수 있다. UserDefaults와 App Groups로 앱과 위젯이 데이터를 공유하며, SwiftUI로 위젯 화면을 구현한다. Expo Modules API는 네이티브 기능을 TypeScript로 래핑하여 웹 개발자도 위젯을 효율적으로 개발할 수 있게 한다.

이번에는 이전 글에서 언급했던 Evan Bacon의 expo-apple-targets 라이브러리를 사용하여 Expo 프로젝트에 iOS 위젯을 추가해보자. Expo 프로젝트에 위젯을 추가하는 것만이 목적이라면 사실 그리 어렵지 않다.

중요한 것은 Expo 프로젝트에 iOS 네이티브 코드를 연결하는 일련의 과정을 잘 이해하는 것이다. 실무에서 이를 활용하려면 특정 라이브러리에 의존하는 것이 아니라, 향후 유지보수 및 신규 개발이 가능한 수준으로 파악하고 있어야 한다.

이 글에서는 위젯을 적용하는 전체 과정을 러프하게 살펴본다. 아쉽게도 코드 예제는 바로 적용 가능한 예제는 아니다. 그러한 예제를 제공하더라도 지속적인 개발이 불가능하기에 큰 의미가 없다고 판단했다. 또한 위젯의 모든 기능을 다루기보다는, Expo 프로젝트에서 위젯을 구현하고 유지보수하기 위해 이해해야 할 개념과 전체적인 그림에 초점을 맞춘다.

Xcode에 Target 추가하기

빠르게 Expo 앱에 위젯을 띄워보자. 우선 Expo CLI로 앱을 먼저 생성하자. 앱 이름은 이 글에서는 중요하지 않으니 적당히 지어주면 된다.

Bash
npx create-expo-app@latest

그다음 Prebuild를 통해 네이티브 파일들을 노출시킨다.

Bash
npx expo prebuild -p ios

이제 Target을 추가할 환경을 만들어야 한다. Target에 대해 잠깐 설명하자면, Target은 Xcode 프로젝트에서 독립적으로 빌드되는 하나의 제품 단위를 의미한다. 하나의 Xcode 프로젝트 안에 메인 앱, 위젯, 앱 클립 등 여러 Target이 존재할 수 있다. 그리고 각 Target은 고유의 번들 식별자와 빌드 설정을 가진다.

아래 명령어로 expo-apple-targets의 CLI를 실행할 수 있다.

Bash
npx create-target

이 과정에서 @bacons/apple-targets 의존성이 설치되고, "Choose a Target" 질문이 나올 텐데, Widget을 선택한다. 명령어 실행 후 Expo 프로젝트 루트 경로에는 targets/widget 폴더가 새로 생성되어 있다. 생성된 파일은 아래와 같다.

  • expo-target.config.js
    • Target에서 사용될 일부 메타데이터를 JavaScript로 작성할 수 있다.
  • Info.plist
    • WidgetKit을 추가하기 위한 메타데이터가 담겨 있다. 운영체제는 Info.plist를 통해 앱의 권한과 기능 등을 인식하고 실행한다. 위젯이 아니어도 접할 일이 많으니 알아두면 좋은 파일이다.
  • index.swift
    • 위젯의 진입점으로, 코드에는 위젯과 제어센터 위젯, 라이브 액티비티가 호출되어 있다. 사용하지 않을 종류의 위젯은 제거하면 된다.
  • widgets.swift
    • 위젯 관련 코드를 작성하는 메인 파일이다. 내부에는 placeholdersnapshot, timeline 같은 위젯을 작성하는 데 필수로 알아야 할 함수가 선언되어 있다.
  • AppIntent.swift
    • 위젯의 상호작용을 위한 일종의 환경설정 파일이다.
  • WidgetControl.swift
    • 제어센터 위젯에 대한 파일이다. 이 글에서는 사용하지 않는다.
  • WidgetLiveActivity.swift
    • 라이브 액티비티에 대한 파일이다. 이 글에서는 사용하지 않는다.

기존 app.json 파일에는 @bacons/apple-targets 가 플러그인에 추가되어 있을 것이다. 만약 기존 파일이 app.config.js 혹은 app.config.ts라면, 직접 추가해 주면 된다.

JSON
// app.json
{
  "expo": {
    "plugins": [
      "@bacons/apple-targets"
    ]
  }
}

새로 만들어진 targets/widget 폴더를 Expo 프로젝트의 iOS 네이티브에 반영하기 위해 Prebuild를 다시 실행한다. 이 과정에서 Config Plugin이 targets/widget의 파일들을 읽어 Xcode 프로젝트에 Widget Target을 추가한다.

만약 Xcode 프로젝트 파일의 변경사항이 구체적으로 궁금하다면, 이 단계에서 루트 경로의 .gitignore 파일에서 /ios 를 임시로 주석 처리하고 변경 사항을 스테이징하자. 다음 단계에서 플러그인을 이용하여 Widget Target을 설정할 예정인데, Prebuild 이후 발생할 /ios 폴더 내부의 project.pbxproj 변경 사항을 Git Diff로 보기 위함이다. 이를 보면 Xcode 프로젝트의 구조를 조금 더 이해할 수 있다.

Bash
npx expo prebuild -p ios

명령어 실행 후 Expo Config에 앱 그룹에 대한 권한 설정을 하라는 안내가 나온다. 안내 지시사항에 따라 app.json에 com.apple.security.application-groups 키 값을 추가하면 된다.

JSON
{
  "ios": {
    "entitlements": {
      "com.apple.security.application-groups": [
	      // 식별자는 각자의 프로젝트에 따라 다르다.
        "group.com.식별자.CounterTargets"
      ]
    }
  }
}

최종적으로 @bacons/apple-targets 플러그인을 통해 targets/widget 폴더가 Expo의 iOS 네이티브(Xcode 프로젝트)에 반영되었다. 이제 시뮬레이터로 iOS 앱을 실행하면 보일러플레이트 위젯을 만날 수 있다.

Bash
npx expo run:ios

iOS Widget

iOS Widget

위젯을 추가하는 데 성공했지만, 아직 프로젝트 설정이 끝난 것은 아니다. 위젯을 실제로 개발할 때는 Xcode를 통해서 개발하는데, Xcode의 미리보기 기능인 Preview를 활용하면 매번 시뮬레이터를 확인하지 않아도 되어서 위젯 개발 환경이 더 쾌적해진다. Preview는 기능만 보면 웹에서의 HMR(Hot Module Replacement)과 얼핏 유사하지만, HMR과는 다르게 전체 컴파일이 필요하다.

여기서 문제가 생긴다. Expo 프로젝트는 React Native 기반이며, React Native를 비롯한 여러 의존성은 상당한 양의 네이티브 코드(Objective-C/C++)를 포함하고 있다. 이로 인해 전체 프로젝트를 컴파일할 때마다 수 분의 시간이 소요된다. 위젯 UI를 조금만 수정해도 매번 이 긴 컴파일 과정을 거쳐야 한다면 개발 생산성이 크게 떨어진다.

그래서 이를 우회하기 위해 expo-apple-targets의 제작자인 Evan Bacon은 최소한의 React Native 의존성이 포함된 위젯 개발용 Prebuild 템플릿을 제공한다. 다만 템플릿을 사용하면 앱과 위젯의 전체적인 동작 테스트는 불가능하므로 이 경우에는 불가피하게 템플릿 없이 Prebuild를 다시 진행한 후 전체 컴파일을 해야 할 것이다.

기존 iOS 네이티브 폴더를 제거하고 아래 명령어를 통해 Prebuild를 진행하자.

Bash
npx expo prebuild --template ./node_modules/@bacons/apple-targets/prebuild-blank.tgz

Prebuild Template에 관한 링크

Xcode로 iOS 네이티브 폴더를 실행해서 widgets.swift 파일을 열고 Preview 기능을 사용해 보자. 기존에 템플릿 없이 사용해 봤다면, 그때와는 컴파일 시간이 확연히 다름을 느낄 수 있다.

iOS Widget

이번에는 Xcode의 좌측 패널을 살펴보자.

iOS Widget

좌측 패널에 expo:targets 폴더가 있는 것을 볼 수 있는데, 이는 우리가 이전에 루트 경로에서 보았던 targets 폴더이다. 그 아래 CounterTargets 폴더에는 Prebuild로 생성된 iOS 네이티브 파일들이 있다.

흥미로운 점은 Xcode에서 보이는 폴더 구조가 실제 파일 시스템의 폴더 구조와 일치하지 않는다는 것이다. Xcode는 자체적인 가상 폴더 시스템을 사용하며, 이 구조는 project.pbxproj 파일에 메타데이터로 저장된다. 이 파일의 구조를 이해하면 Expo의 Config Plugins가 어떻게 Xcode 프로젝트를 조작하는지 알 수 있다.

좌측 패널의 expo:targets는 일반적인 폴더가 아닌 Xcode의 'Group'이라는 가상 폴더이다. Group은 파일들을 논리적으로 구조화하기 위한 것으로, 실제 파일 시스템의 디렉토리 구조와 무관하게 구성할 수 있다.

이전에 Git Diff로 project.pbxproj의 변경사항을 확인해볼 수 있음을 부가적으로 언급했었는데, 이를 통해 보면 Widget Target 추가 후 아래 코드가 추가되어 있다.

83CBB9F61A601CBA00E9B192 = {
	isa = PBXGroup;
	children = (
		XX3E41C1850246162C0061XX /* expo:targets */,
		13B07FAE1A68108700A75B9A /* CounterTargets */,
		832341AE1AAA6A7D00B99B32 /* Libraries */,
		83CBBA001A601CBA00E9B192 /* Products */,
		2D16E6871FA4F8E400B85C8A /* Frameworks */,
		BAC59216C235031ACB0FAC07 /* Pods */,
		7744105D5E1B81A64406116A /* ExpoModulesProviders */,
	);
	indentWidth = 2;
	sourceTree = "<group>";
	tabWidth = 2;
	usesTabs = 0;
};

PBXGroup 은 Xcode 프로젝트의 폴더 및 그룹 구조를 나타내는 객체 타입이다. children 은 이 그룹 안에 포함될 하위 항목들의 ID 값이다. 그중 하나로 expo:targets의 ID 값이 존재하는데, 이 ID 값이 사용된 곳을 더 찾아보면 아래와 같다.

/* Begin PBXGroup section */
		XX3E41C1850246162C0061XX /* expo:targets */ = {
			isa = PBXGroup;
			children = (
				XXC40C00DE3692AAE38F25XX /* widget */,
			);
			name = "expo:targets";
			path = ../targets;
			sourceTree = "<group>";
		};
/* End PBXGroup section */

PBXGroup section 은 각 Group의 상세 메타데이터를 정의하는 섹션이며, BeginEnd 로 주제별 메타데이터를 정의하는 시작과 끝을 나타낸다. 만약 여기서 name 을 직접 수정하면 Xcode의 좌측 패널에 바로 반영된다.

project.pbxproj 파일의 내용을 Prebuild 과정에서 바꾸기 위해서는 Config Plugins의 힘이 필요하다. 다만 Config Plugins과 pbxproj 파일의 텍스트를 수정하기 위한 정규식으로도 가능은 하지만 상당히 비효율적이고 정확도가 불안정한 구현이 될 것이다.

Evan Bacon은 이를 위해 project.pbxproj 파일을 구조적으로 파싱하기 위한 Lexer와 Parser를 만들었다. @bacons/xcode 라는 이름의 오픈소스이며, expo-apple-targets 라이브러리 내부에서는 이를 적극 활용하고 있다.

expo-apple-targets은 @bacons/xcode를 활용하여 Target 추가와 번들 식별자, 컴파일러 버전 같은 각종 빌드 설정, 가상 폴더 그룹 등 project.pbxproj 파일의 전반적인 설정을 변경한다.

향후 기회가 되면 @bacons/xcode에 대해서도 후속 글을 작성해보겠다.

위젯 데이터 공유

지금까지 진행했던 일련의 프로젝트 세팅으로 위젯을 적용할 수 있는 환경은 모두 갖췄다. 남은 것은 Expo와 iOS 위젯 간에 데이터 공유를 어떻게 하는지 알아보는 것이다.

다행히 expo-apple-targets 라이브러리는 이 문제를 해결하는 ExtensionStorage API를 제공한다. ExtensionStorage를 활용하면 JavaScript에서 익숙한 방식으로 iOS 네이티브 저장소에 접근할 수 있게 해준다. 사용 방법은 간단하다.

TypeScript
import { ExtensionStorage } from "@bacons/apple-targets";
 
const storage = new ExtensionStorage(
  "group.com.식별자.data"
);
 
// 새 데이터를 저장소에 설정한다.
storage.set("key", "value");
 
// 위젯 데이터를 갱신하고 싶을때 호출한다.
// ExtensionStorage에서 직접 호출하는 static 메서드다.
ExtensionStorage.reloadWidget();

평소 웹 프론트엔드 개발자가 사용하는 Storage API들과 크게 다르지 않아서 익숙할 것이다. ExtensionStorage의 생성자에는 app.json에 작성했던 com.apple.security.application-groups의 값을 전달한다. 또한 위젯을 갱신하고 싶을 때는 reloadWidget 메서드를 원하는 타이밍에 호출하면 된다.

예를 들어 React의 Context API와 연동한다면 아래와 같은 코드가 될 것이다.

TypeScript
// 실제 동작하는 예제는 아니다. 의사 코드로 참고해달라.
import { 
  createContext,
  useCallback,
  useContext,
  useEffect,
  type ReactNode
} from "react";
import { ExtensionStorage } from "@bacons/apple-targets";
 
const storage = new ExtensionStorage(
  "group.com.식별자.data"
);
 
const WidgetContext = createContext<{
  refreshWidget: () => void;
} | null>(null);
 
export function WidgetProvider({ children }: { children: ReactNode }) {
  const todos = useTodos();
 
  useEffect(() => {
	// 새 데이터를 저장소에 설정한다.
    storage.set("widget_todos", todos);
    // 위젯 데이터를 동기적으로 갱신한다.
    ExtensionStorage.reloadWidget();
  }, [list]);
 
	// 필요한 곳에서 명시적으로 위젯 데이터를 갱신한다. (예시: 핸들러에 바인딩 등)
  const refreshWidget = useCallback(() => {
    ExtensionStorage.reloadWidget();
  }, []);
 
  return (
    <WidgetContext.Provider value={{ refreshWidget }}>
      {children}
    </WidgetContext.Provider>
  );
}
 
export const useWidget = () => {
  const context = useContext(WidgetContext);
 
  if (!context) {
    throw new Error("useWidget must be used within a WidgetProvider");
  }
 
  return context;
};

React를 사용해본 웹 프론트엔드 개발자라면 코드를 이해하는 게 어렵지 않을 것이다. 작성한 WidgetProvider 함수는 필요한 위치에서 사용하면 된다.

다음 단계인 Swift 위젯 코드를 작성하기 전에 먼저 살펴볼 것이 있다. 우리가 사용한 ExtensionStorage API는 사실 UserDefaults라고 하는 iOS 네이티브의 Key-Value 기반 저장소를 사용하며, 이를 Expo Modules API를 활용해서 래핑한 것이다. 자세한 이해를 위해 ExtensionStorage의 내부 구현의 코드 일부를 살펴보자.

Swift
// expo-apple-targets
// ExtensionStorageModule.swift
// 일부 코드가 생략되었음
import ExpoModulesCore
import WidgetKit
 
public class ExtensionStorageModule: Module {
    public func definition() -> ModuleDefinition {
        Name("ExtensionStorage")
        
        Function("get") { (key: String, group: String?) -> String? in
            let userDefaults = UserDefaults(suiteName: group)
            if let data = userDefaults?.data(forKey: key) {
                do {
                    let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
                    let jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted])
                    return String(data: jsonData, encoding: .utf8)
                } catch {                    
                    return nil
                }
            }
            if let value = userDefaults?.object(forKey: key) {
                return String(describing: value)
            }
            return nil
        }
        
        Function("reloadWidget") { (timeline: String?) in
            if let timeline = timeline {
                WidgetCenter.shared.reloadTimelines(ofKind: timeline)
            } else {
                WidgetCenter.shared.reloadAllTimelines()
            }
        }
       
        Function("setArray") { (forKey: String, data: [[String: Any]], suiteName: String?) -> Bool in
            do {
                let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
                UserDefaults(suiteName: suiteName)?.set(jsonData, forKey: forKey)
                return true
            } catch {
                return false
            }
        }
        
        Function("setObject") { ... }
        Function("setInt") { ... }
        Function("setString") { ... }      
        Function("remove") { ... }
    }
}

ExtensionStorageModule.swift

Expo Modules API를 사용해본 경험이 있다면 코드 구조가 익숙할 것이다. ExpoModulesCore는 Expo 런타임과 통신하기 위한 Module 프로토콜을 제공하며, definition 함수 내부에서 NameFunction을 선언하여 네이티브 기능을 JavaScript로 노출할 수 있다. Swift에서 선언한 Name은 TypeScript의 객체명이 되고, Function은 해당 객체의 메서드가 된다. 아래 TypeScript 구현체를 보면 이 매핑 관계를 명확히 확인할 수 있다.

TypeScript
// expo-apple-targets
// ExtensionStorage.ts
// 일부 코드가 생략되었음
const ExtensionStorageModule = expo?.modules?.ExtensionStorage;
const nativeModule: NativeModule = ExtensionStorageModule;
 
export class ExtensionStorage {
  static reloadWidget(name?: string) {
    nativeModule.reloadWidget(name);
  }
 
  constructor(private readonly appGroup: string) {}
 
  set(
    key: string,
    value?:
      | string
      | number
      | Record<string, string | number>
      | Array<Record<string, string | number>>
  ) {
    if (typeof value === "number") {
      nativeModule.setInt(key, value, this.appGroup);
    } else if (Array.isArray(value)) {
      nativeModule.setArray(key, value, this.appGroup);
    } else if (typeof value === "string") {
      nativeModule.setString(key, value, this.appGroup);
    } else if (value == null) {
      nativeModule.remove(key, this.appGroup);
    } else {
      nativeModule.setObject(key, value, this.appGroup);
    }
  }
 
  get(key: string): string | null {
    return nativeModule.get(key, this.appGroup);
  }
 
  remove(key: string) {
    nativeModule.remove(key, this.appGroup);
  }
}

Swift에서 정의한 Module은 TypeScript에서 expo.modules 네임스페이스를 통해 접근할 수 있다. NameExtensionStorage로 선언했으므로 expo.modules.ExtensionStorage로 사용하는 구조다.

Expo는 React Native와 별개로 자체 런타임을 가지고 있으며, 이 런타임이 Modules API로 작성된 네이티브 코드를 읽어 expo.modules 네임스페이스에 자동으로 매핑한다. 예를 들어 ExtensionStorage 클래스를 보면 이전 Context API와 함께 사용했던 예제에서의 set 메서드는 expo.modules.ExtensionStorage의 메서드들을 타입 가드와 함께 조건에 맞춰 호출하고 있는 것을 알 수 있다.

다시 위젯으로 돌아오자. 이전에 Expo에서의 코드 작성은 끝났다. 다음으로는 위젯의 UI와 데이터 관련 로직인데 이는 Swift로 작성해야 한다. 팀에 iOS 개발자가 없다면 피할 수 없는 과정이다.

직접 작성한다면 SwiftUI의 기본 문법과 WidgetKit의 핵심 개념만 이해하면 충분하다. 또한 최근의 AI 도구들은 Swift 코드 작성에 큰 도움이 되므로, 기본 개념을 익힌 후 AI의 도움을 받아 개발하는 것을 권장한다.

target/widget 폴더의 widgets.swift 파일을 Xcode로 열어보자. 이 중 widgetEntryView 구조체는 위젯 화면에 해당하는 컴포넌트라고 이해하면 되는데, 우리는 이 구조체에서만 작업할 것이다. 구조체에서 UserDefaults를 활용하여 이전 ExtensionStorage에 저장했던 데이터를 아래 코드처럼 불러와서 사용하면 된다.

Swift
// 실제 동작하는 예제는 아니다. 의사 코드로 참고해달라.
struct widgetEntryView : View {
  @Environment(\.widgetFamily) var widgetFamily
  var entry: Provider.Entry
 
  struct Todo: Codable {
    let todoId: String
    let title: String
    let description: String
  }
 
  var todos: [Todo]? {
    let defaults = UserDefaults(suiteName: "group.com.식별자.data")
      guard let data = defaults?.data(forKey: "widget_todos"),
            let todos = try? JSONDecoder().decode([Todo].self, from: data) else {
        return nil
      }
    return todos.isEmpty ? nil : todos
  }
  
  var body: some View {
    ZStack {
      VStack(alignment: .leading, spacing: 6) {
        if let todos = todos {
          VStack(alignment: .leading, spacing: 4) {
            ForEach(todos, id: \.todoId) { todo in
              HStack {
                Text(todo.title)
                  .font(.system(size: 16, weight: .bold))
                Text(todo.description)
                  .font(.system(size: 14))
              }
            }
          }
        } else {
          SkeletonView
        }
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
  }
}

Swift로 작성되어 있지만 코드 로직은 간단하다. UserDefaults에서 데이터를 가져와 JSON으로 역직렬화한 후 ForEach로 화면에 렌더링한다. ForEach는 React의 map과 유사하게 배열을 순회하며 UI를 생성하는 방식이라고 이해하면 된다.

이제 네이티브 코드 수정이 완료되었으므로, 템플릿 없이 Prebuild를 실행한 후 시뮬레이터에서 위젯의 최종 동작을 확인하면 된다.

추가적으로 widgets.swift 파일을 보면 Provider 구조체 내부에 placeholder, snapshot, timeline 등의 함수가 정의되어 있다. 이들은 위젯의 생명주기를 관리하는 핵심 함수들이다.

placeholder는 위젯이 로딩 중일 때 보여줄 화면을, snapshot은 위젯 갤러리에서 보여줄 미리보기를, timeline은 위젯이 언제 어떤 데이터로 업데이트될지를 정의한다. 특히 timeline은 위젯의 새로고침 정책을 결정하므로 중요한 함수이다.

이번 글에서 Provider 를 포함한 위젯의 구조체 전부를 다루기는 이것만으로도 글 하나를 작성할 수 있는 정도라서, 본격적으로 위젯을 적용하고자 할 때 별도로 학습해보기를 권한다.

그리고 이 글의 예제에서는 UserDefaults를 통해 앱과 위젯 간에 직접 데이터를 공유하는 방식을 사용했다. 이는 구현이 간단하지만, 앱이 실행되어야만 위젯이 업데이트된다는 한계가 있다. 만약 위젯이 앱 실행 없이도 독립적으로 최신 데이터를 표시해야 한다면, 위젯에서 직접 서버 API를 호출하는 아키텍처를 고려해볼 수 있다. 이 경우 앱과 위젯이 같은 API 엔드포인트를 공유하게 되며, 각각 독립적으로 데이터를 가져온다.

마치며

위젯을 적용하기 위한 전체 과정을 살펴봤다. 다룬 내용이 많으므로 Expo 프로젝트에 위젯을 구현하기까지 알아야 할 핵심을 정리하면 다음과 같다.

  • Xcode 프로젝트에 Target을 추가 시 발생하는 변경사항 (project.pbxproj, 빌드 설정 등)
  • Expo의 Prebuild, Config Plugins, Modules API의 동작 원리와 사용 방법
  • SwiftUI와 WidgetKit 사용 방법
  • (선택) expo-apple-targets, @bacons/xcode의 동작 원리 및 사용 방법

선택 사항을 제외한 세 가지는 웹 프론트엔드 개발자가 iOS 개발자와 협업할 때 반드시 이해해야 할 핵심 개념이다. expo-apple-targets와 @bacons/xcode는 소규모 팀이나 빠른 프로토타이핑이 필요한 경우 유용한 도구이지만, 규모가 있는 조직에서는 이러한 개념을 바탕으로 자체 솔루션을 구축하는 경우가 많다.

마지막으로, 이 글에서는 위젯에 초점을 맞췄지만 동일한 접근 방식으로 제어 센터 위젯, 라이브 액티비티, 앱 클립 등 다른 Target 기반 기능들도 구현할 수 있다. expo-apple-targets는 특정 기능을 위한 라이브러리가 아니라 Expo와 Xcode Target을 연결하는 범용 도구이므로, 이 글에서 다룬 개념들을 다양한 iOS 네이티브 기능에 응용할 수 있다.