본문 바로가기

Capacitor 앱용 Apple Watch 앱 빌드 방법

Capacitor 앱용 Apple Watch 앱 빌드하기: watchOS 타겟 추가, SwiftUI 코드 작성, @capgo/capacitor-watch.로 iPhone과 데이터 동기화

Capacitor iOS 앱용 Apple Watch companion 앱 구축 가이드를 위한 Capgo 배너

Capacitor 앱용 Apple Watch 앱을 빌드하려면 iOS 프로젝트에 네이티브 watchOS 타깃을 추가하고, SwiftUI로 UI를 작성한 뒤, 웹 code에 연결합니다. 이때 사용하는 @capgo/capacitor-watch plugin은 Apple의 WatchConnectivity framework를 감싼 것입니다. 폰 앱은 Capacitor 웹 레이어를 그대로 유지하고, watch 앱은 딕셔너리를 주고받는 작은 네이티브 컴패니언이 됩니다.

이 가이드는 Capacitor 8과 Xcode 26 환경에서의 전체 구축 과정을 다룹니다: 아키텍처, Xcode 타깃, 양쪽의 Swift 및 TypeScript code, 사용할 전송 방식, 시뮬레이터 및 디바이스 테스트, 그리고 워치 앱 배포 방법까지.

워치 UI는 네이티브이고, 폰은 Capacitor인 이유

watchOS에는 WebView가 제공되지 않습니다. 워치에는 WKWebView 이 없으므로 Capacitor 앱은 워치에서 실행할 수 없으며, 어떤 플러그인으로도 이를 변경할 수 없습니다. 대신 역할을 분리할 수 있습니다:

레이어 지원 기기 언어 역할
Capacitor 앱 iPhone TypeScript, 선호하는 프레임워크 비즈니스 로직, 네트워크, 인증, 저장소
@capgo/capacitor-watch iPhone Swift (플러그인), JS API WatchConnectivity를 JavaScript로 브리징
CapgoWatchSDK Apple Watch Swift 워치 세션 핸들링
Watch 앱 Apple Watch SwiftUI 작은 화면, 한눈에 들어오는 UI, 빠른 액션

실제로 워치 앱은 “의존적”입니다. 데이터를 폰에서 가져오죠. 로직은 이미 TypeScript로 존재하는 폰에 두고, 워치에는 표시에 필요한 것만 보내세요.

사전 요구사항

  • iOS 플랫폼이 추가된 Capacitor 8 앱입니다. 이전 버전을 사용 중이라면 다음을 따르세요. Capacitor 8 업그레이드 가이드 먼저.
  • Mac용 Xcode 26. 2026년 4월부터 App Store 업로드에 Xcode 26이 필수입니다. 관련 내용은 Xcode 26 요구 사항.
  • 기기 테스트 및 배포를 위한 Apple Developer 계정.
  • iPhone에는 iOS 15 이상(플러그인 최소 요구 사항), Apple Watch에는 watchOS 9 이상(SDK 최소 요구 사항)이 필요합니다.
  • 실제 iPhone과 실제 Apple Watch를 페어링하는 것이 이상적입니다. 시뮬레이터도 도움이 되지만 모든 전송 유형을 지원하지는 않습니다.

1단계: 폰 쪽에 플러그인 설치

bun add @capgo/capacitor-watch
bunx cap sync ios

플러그인은 WCSession 로드되는 즉시 활성화되므로 init 호출이 필요 없습니다. 연결 상태부터 읽어 보세요:

import { CapgoWatch } from '@capgo/capacitor-watch';

const info = await CapgoWatch.getInfo();
console.log({
  supported: info.isSupported,          // false on iPad, Android without Wear OS, and web
  paired: info.isPaired,                // an Apple Watch is paired with this iPhone
  installed: info.isWatchAppInstalled,  // your watch app is on that watch
  reachable: info.isReachable,          // live messaging is possible right now
  state: info.activationState,          // 0 notActivated, 1 inactive, 2 activated
});

API 전체 레퍼런스는 다음에서 확인할 수 있습니다: 플러그인 문서 및 플러그인 페이지.

2단계: Xcode에서 watchOS 타겟 추가하기

다음 명령어로 iOS 프로젝트를 엽니다: bunx cap open ios. Swift Package Manager로 생성된 Capacitor 8 프로젝트를 열거나 App.xcodeproj; 기존 CocoaPods 프로젝트를 여세요 App.xcworkspace. 둘 다 됩니다.

  1. 선택 File > New > Target, 다음을 선택하세요: watchOS 탭, 다음 앱.
  2. 이름을 지정합니다. 예를 들어 MyWatch 대신 SwiftUI 인터페이스로 사용하세요.
  3. 옵션 시트에서 워치 앱이 기존 App 타깃의 동반 앱으로 내장되도록 설정하세요. 이 설정이 Xcode에서 워치 앱을 iPhone 빌드에 포함시키고 동반 번들 식별자를 설정하는 역할을 합니다.
  4. 지금 필요하지 않다면 알림 장면과 컴플리케이션은 꺼두세요. 위젯은 나중에 추가할 수 있습니다.
  5. 새 스킴 활성화 프롬프트를 수락하세요.

번들 식별자 규칙

Watch 앱의 bundle ID는 iPhone 앱의 bundle ID로 시작해야 합니다. 만약 앱이 com.example.shop라면, Xcode가 다음을 제안합니다. com.example.shop.watchkitapp. 이 접두사를 그대로 유지하세요. 나중에 iOS bundle ID를 변경하면 Watch 앱의 bundle ID도 함께 변경해야 합니다. 그렇지 않으면 빌드가 유효성 검사에 실패하고 WatchConnectivity가 두 앱을 페어링하지 못합니다.

아래 Signing & Capabilities라면, 두 타겟 모두 동일한 팀을 선택하세요. 자동 서명을 사용하면 Xcode가 두 번째 App ID와 provisioning profile을 자동으로 등록해 줍니다.

Watch 타겟에 CapgoWatchSDK 추가하기

  1. 파일 > 패키지 의존성 추가.
  2. 입력: https://github.com/Cap-go/capacitor-watch.git.
  3. “Up to Next Major Version” 옵션을 사용하세요. 시작 버전: 8.0.0.
  4. Xcode에서 추가할 제품을 선택하라는 메시지가 뜨면, 다음만 선택하세요: CapgoWatchSDK 그리고 이를 watch 타깃에 할당하세요. iOS 앱이 아닙니다. iOS 쪽은 이미 플러그인을 통해 cap sync.

3단계: SwiftUI로 watch 앱 작성하기

SDK는 WatchConnector.shared, ObservableObject 게시된 isReachable, isActivated, lastMessage, 그리고 applicationContext 프로퍼티를 노출합니다. 앱 시작 시 활성화하세요:

import SwiftUI
import CapgoWatchSDK

@main
struct MyWatchApp: App {
    init() {
        WatchConnector.shared.activate()
    }

    var body: some Scene {
        WindowGroup {
            OrdersView()
        }
    }
}

전화에서 전송된 데이터를 표시하고 작업을 반환하는 작은 화면:

import SwiftUI
import CapgoWatchSDK

struct OrdersView: View {
    @ObservedObject private var connector = WatchConnector.shared
    @State private var status = ""

    var openOrders: Int {
        connector.applicationContext["openOrders"] as? Int ?? 0
    }

    var body: some View {
        VStack(spacing: 8) {
            Text("\(openOrders)")
                .font(.system(size: 44, weight: .bold))
            Text("open orders")
                .font(.footnote)
                .foregroundStyle(.secondary)

            Button("Refresh") {
                refresh()
            }
            .disabled(!connector.isReachable)

            if !status.isEmpty {
                Text(status).font(.caption2)
            }
        }
    }

    private func refresh() {
        Task {
            do {
                let reply = try await connector.sendMessage(["action": "refresh"])
                status = "Updated \(reply["count"] as? Int ?? 0)"
            } catch {
                status = "iPhone not reachable"
            }
        }
    }
}

이 async 의 변형 sendMessage reply handler와 함께 전송되므로, 휴대폰은 이를 하나의 messageReceivedWithReply 이벤트로 수신합니다. 콜백 기반 방식은 sendMessage(_:replyHandler:errorHandler:) reply 과정을 생략할 수 있습니다.

published property 관찰보다 delegate callback을 선호한다면, 다음을 준수하세요: WatchConnectorDelegate 그리고 설정하세요. WatchConnector.shared.delegate. 여기에는 메시지, reply handler가 포함된 메시지, application context, user info, reachability, activation을 위한 메서드가 포함되어 있습니다.

4단계: TypeScript에서 watch와 통신하기

예를 들어 앱 부트스트랩에서처럼 이른 시점에 리스너를 등록해, 앱 실행 시 도착하는 이벤트를 놓치지 않도록 하세요.

import { CapgoWatch } from '@capgo/capacitor-watch';

export async function setupWatchBridge(getOpenOrders: () => Promise<number>) {
  // Watch asked for something and is waiting for an answer
  await CapgoWatch.addListener('messageReceivedWithReply', async (event) => {
    if (event.message.action === 'refresh') {
      const count = await getOpenOrders();
      await CapgoWatch.replyToMessage({
        callbackId: event.callbackId,
        data: { count },
      });
      await pushStateToWatch(count);
    }
  });

  // Fire-and-forget messages from the watch
  await CapgoWatch.addListener('messageReceived', (event) => {
    console.log('watch says', event.message);
  });

  await CapgoWatch.addListener('reachabilityChanged', (event) => {
    console.log('watch reachable:', event.isReachable);
  });

  await pushStateToWatch(await getOpenOrders());
}

export async function pushStateToWatch(openOrders: number) {
  const info = await CapgoWatch.getInfo();
  if (!info.isSupported || !info.isPaired || !info.isWatchAppInstalled) return;

  // Latest state only, delivered when the watch app next runs
  await CapgoWatch.updateApplicationContext({
    context: { openOrders, updatedAt: Date.now() },
  });
}

항상 다음 항목에 응답하세요: messageReceivedWithReply. watch가 응답을 기다리고 있으며, 응답하지 않은 요청은 watch 측에서 timeout 에러를 발생시킵니다.

어떤 전송 방식을 사용해야 할까요?

WatchConnectivity는 세 가지 채널을 제공하며, 플러그인은 각각을 다음과 같이 매핑합니다:

메서드 전송 방식 워치 연결 필요 적합한 용도
sendMessage 즉시 예, 미연결 시 거부 버튼 탭, 두 앱 모두 열려 있을 때 실시간 업데이트
updateApplicationContext 최신 값 우선, 가능한 시점에 전송 아니요 현재 상태: 카운트, 설정, 로그인 사용자
transferUserInfo 큐에 저장, 순서대로 전송 아니요 모두 도착해야 하는 이벤트: 기록된 운동, 생성된 기록

효과적인 패턴: 워치가 표시할 데이터의 단일 진실 공급원(source of truth)으로 애플리케이션 컨텍스트를 사용하고, sendMessage 워치 앱이 열려 있는 동안의 상호작용에만 transferUserInfo 누락할 수 없는 데이터에는

설계 시 고려할 제한 사항

  • 페이로드 크기. WatchConnectivity는 크기가 너무 큰 페이로드를 거부합니다 payloadTooLarge, 그리고 Apple은 모든 채널에 대해 고정된 바이트 수를 공개하지 않습니다. 페이로드는 작게 유지하세요: ID, 개수, 짧은 문자열. 큰 콘텐츠는 API에서 가져오세요.
  • 페이로드 유형. 데이터는 property-list와 호환되어야 합니다: 문자열, 숫자, 불리언, 날짜, 데이터, 배열, 딕셔너리. null JavaScript의 undefined 또는 null 값은 유효하지 않습니다. 전송 전에 제거하세요.
  • 이벤트를 처리하려면 휴대폰에서 앱이 실행 중이어야 합니다. 리스너는 Capacitor JavaScript 런타임에 존재합니다. iOS가 앱을 종료한 경우, 앱이 다시 실행될 때까지 이벤트가 TypeScript에 도달하지 않습니다. 컨텍스트와 사용자 정보는 시스템에 대기열로 유지되지만, 워치 UI는 캐시된 컨텍스트에서 작동하도록 설계하세요.
  • 페어링. Apple Watch는 iPhone과만 페어링됩니다. Android 사용자는 사용할 수 없으며, 플러그인의 Android 구현은 Wear OS를 대상으로 하며, 이는 우리의 Wear OS 가이드.
  • 독립성. WatchConnectivity 기반의 동반 앱은 스마트폰에 의존합니다. 시계가 스마트폰과 떨어져서 작동해야 한다면, 시계의 네트워크 연결을 통해 자체 API와 같은 두 번째 데이터 소스를 추가하세요.

시뮬레이터 및 기기 테스트

SwiftUI 레이아웃은 시뮬레이터에서 반복 개발할 수 있지만, 데이터 전송은 페어링된 환경이 필요합니다.

xcrun simctl list devices
xcrun simctl pair <watch-udid> <phone-udid>

페어링된 iPhone 시뮬레이터에서 iOS 앱을 실행한 후, 시계 시뮬레이터에서 watch 스킴을 실행하세요. 두 앱 모두 설치된 후에 isWatchAppInstalled true가 됩니다. Apple은 시뮬레이터가 transferUserInfo을(를) 지원하지 않는다고 명시하므로, 실제 하드웨어에서 해당 경로를 확인하세요.

실제 기기의 경우, iPhone을 연결하고 시계의 개발자 모드가 활성화되어 있는지 확인한 후, watch 스킴의 실행 대상으로 시계를 선택하세요. getInfo() 스모크 테스트로:

getInfo() 결과 의미
isPaired: false 이 폰에 페어링된 워치가 없음
isWatchAppInstalled: false 워치 앱이 없거나 bundle ID / companion 설정 오류
isReachable: false, 나머지는 true 워치 앱이 포그라운드에 없거나 범위를 벗어남
activationState: 2, 모두 true 준비 완료 sendMessage

문제 해결

isWatchAppInstalled false 상태 유지. 워치 bundle ID 접두사를 확인하고, watch target이 해당 항목에 포함되어 있는지 확인하세요: App target (Build Phases > Embed Watch Content)을 설정하고 두 앱을 모두 다시 설치하세요.

Watch에서 보낸 메시지가 JavaScript에 도달하지 않습니다. Watch가 보내기 전에 리스너를 등록하고, Watch가 호출하는지 확인하세요. WatchConnector.shared.activate() 앱 실행 시. iPhone 쪽은 Xcode 콘솔에서 플러그인 활성화 로그를 확인하세요.

sendMessage “Watch is not reachable” 오류와 함께 거부됩니다. Watch 앱이 닫혀 있을 때 예상되는 동작입니다. 대신 updateApplicationContext.

replyToMessage 무시되는 것으로 보입니다. 정확한 callbackId 값을 이벤트에서 전달하고 빠르게 응답하세요. Watch의 reply handler에는 타임아웃이 있습니다.

빌드가 “Embedded binary’s bundle identifier is not prefixed” 오류로 실패합니다. Watch bundle ID는 반드시 iOS bundle ID로 시작해야 합니다.

아이콘 누락으로 Archive 업로드가 거부되었습니다. Watch 타겟은 별도의 앱 아이콘이 필요합니다. Assets.xcassets.

Watch 앱 배포하기

Watch 앱은 별개의 제품이 아닙니다. iOS 아카이브에 포함되어 함께 업로드되고, 동일한 App Store Connect 레코드에 표시되며, 여기서 Watch 탭 아래에 Apple Watch 스크린샷을 추가합니다. 아카이브를 만드는 방식이 로컬 Xcode든, CI든, 또는 Capgo 빌드, 두 bundle ID 모두에 대한 provisioning profile이 필요하고 Xcode 26을 사용해야 합니다.

업데이트 시 주의할 점이 하나 있습니다. Capgo Live Update 사용하기 를 사용하면 스토어 심사 없이 폰의 web bundle 변경 사항을 배포할 수 있어, Watch bridge의 TypeScript 쪽에 버그가 있을 때 유용합니다. 다만 Watch의 Swift code와 네이티브 플러그인 변경 사항은 여전히 App Store 심사를 거쳐야 합니다. 메시지 포맷에는 버전을 두세요. 예를 들어 v 필드를 모든 dictionary에 넣으면, 최신 폰 bundle이 이전 Watch 빌드에서도 계속 동작합니다.

마무리

구조만 받아들이면 간단합니다. 손목에는 SwiftUI, 나머지는 Capacitor, 그 사이를 잇는 세 개의 WatchConnectivity 채널입니다. 먼저 @capgo/capacitor-watch를 설치하고, CapgoWatchSDK로 Watch 타겟을 추가한 뒤, 페이로드는 작게 유지하고 제출 전에 페어링된 기기에서 테스트하세요.

Capacitor 앱용 Live updates

웹 레이어 버그가 라이브 상태라면, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상 리뷰 절차를 그대로 따르는 반면, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 정말 프로페셔널한 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.