Capacitor로 웹 앱을 iOS·Android에 올리기: 네이티브 브리지, 플러그인, WebView 수명 주기
이 글의 핵심
Capacitor는 빌드된 웹 자산을 네이티브 컨테이너에 싣고, JavaScript와 OS API 사이를 얇고 예측 가능한 브리지로 연결합니다. 이 글에서는 네이티브 브리지·플러그인 디스패치·WebView 수명 주기 같은 내부 동작, iOS/Android 차이, 프로덕션 배포 패턴까지 포함해 아키텍처, 프로젝트 구조, 공식·커스텀 플러그인, 푸시·백그라운드, 성능, Cordova 비교를 한 흐름으로 다룹니다.
이 글의 핵심
Capacitor는 Ionic 팀이 만든 크로스플랫폼 런타임으로, Vue·React·Angular·Svelte 등 어떤 웹 프론트엔드의 빌드 산출물이든 iOS·Android 앱과 웹(PWA)으로 배포할 수 있게 합니다. 하이브리드 앱에서 어려운 부분은 웹 기술로 UI를 그리는 일보다, 네이티브 수명 주기와 권한, 스토어 정책 안에서 브리지를 안정적으로 유지하는 일입니다. 이 글은 브리지와 플러그인이 내부에서 어떻게 동작하는지, WebView 수명 주기와 플랫폼 차이가 어떤 문제를 만드는지, 그리고 프로덕션 빌드와 배포를 어떻게 구성하는지를 순서대로 설명합니다.
Capacitor의 핵심 개념
네이티브 셸과 웹 자산
Capacitor 앱은 URL을 여는 브라우저가 아니라, Xcode와 Android Studio용 네이티브 프로젝트 안에 정적 웹 자산(webDir)을 복사해 넣고 WKWebView(iOS) 또는 Android System WebView가 이를 로드하는 구조입니다. 개발 중에는 server.url로 로컬 개발 서버에 붙여 라이브 리로드에 가까운 경험을 만들 수 있습니다.
Capacitor 런타임과 브리지
JavaScript 쪽에서는 @capacitor/core가 플러그인 프록시와 메시지 전달을 담당하고, 네이티브 쪽에서는 Capacitor 런타임이 같은 플러그인 이름과 메서드 이름에 대응하는 구현을 호출합니다. 이 계약이 단순하기 때문에 TypeScript 정의와 네이티브 구현을 맞추기 쉽고, 새 플러그인을 추가할 때도 같은 패턴이 반복됩니다.
npx cap sync의 의미
sync는 두 가지 일을 합니다. 웹 빌드 결과를 android/app/src/main/assets/public, ios/App/App/public 같은 네이티브 프로젝트 안 위치로 복사하고(cap copy), npm으로 설치한 플러그인을 Gradle과 CocoaPods(또는 Swift Package Manager) 설정에 반영합니다(cap update). 웹만 다시 빌드해서는 스토어 빌드에 반영되지 않는 이유가 여기에 있으므로, 릴리스 전에는 웹 빌드, cap sync, 네이티브 빌드 순서를 지켜야 합니다.
설정의 중심: capacitor.config
앱 ID(appId), 앱 이름(appName), 웹 자산 경로(webDir), 개발 서버 URL, 플랫폼별 옵션을 한곳에서 관리합니다. capacitor.config.ts는 Node에서 실행되는 TypeScript 파일이므로, 환경 변수로 스테이징·프로덕션 값을 분기할 수 있습니다.
// capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const isDevServer = process.env.CAP_DEV_SERVER === '1';
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'MyApp',
webDir: 'dist',
// 개발 서버 연결과 HTTP 허용은 로컬 개발에서만 켭니다
server: isDevServer
? { url: 'http://192.168.0.10:5173', cleartext: true }
: undefined,
ios: {
contentInset: 'automatic',
},
android: {
allowMixedContent: false,
},
};
export default config;
webDir는 Vite, Webpack, Angular CLI 등의 프로덕션 빌드 출력 폴더와 정확히 같아야 합니다. server.url이 남은 채로 스토어용 아카이브를 만들면 앱이 개발자 PC의 서버를 찾으며 흰 화면만 띄우게 되고, cleartext: true가 남으면 운영 빌드에서 평문 HTTP가 허용됩니다. 위처럼 환경 변수가 있을 때만 켜지게 하면 CI 빌드에 실수로 들어가는 일을 막을 수 있습니다.
보안 모델
패키징된 자산은 파일 스킴이 아니라 Capacitor가 제공하는 로컬 오리진에서 서빙됩니다. iOS는 기본적으로 capacitor://localhost, Android는 https://localhost(Capacitor 6부터 androidScheme 기본값이 https)입니다. 오리진이 플랫폼마다 다르기 때문에 API 서버의 CORS 허용 목록에 두 오리진을 모두 넣어야 하고, 쿠키 기반 인증은 서드파티 쿠키 정책에 걸리기 쉽습니다.
운영 빌드에서는 HTTPS만 허용하고, 앱 안에서 이동할 수 있는 외부 오리진은 server.allowNavigation에 꼭 필요한 것만 넣습니다. 장기 토큰처럼 민감한 값은 localStorage나 Preferences(평문 저장)에 두지 말고, Keychain/Keystore를 쓰는 보안 저장소 플러그인을 사용하는 편이 안전합니다.
네이티브 브리지 아키텍처
표면적으로는 import { Foo } from '@capacitor/foo' 한 줄이지만, 런타임에서는 JS 번들이 Capacitor 코어를 호출하고, 코어가 플랫폼별 브리지로 직렬화된 메시지를 넘기며, 네이티브 런타임이 플러그인 레지스트리에서 구현체를 찾아 실행하는 세 단계 구조입니다. 이 계층을 알면 sync가 왜 필요한지, 왜 특정 플러그인만 깨지는지 원인을 좁히기 쉬워집니다.
메시지 경로와 직렬화 경계
@capacitor/core는 플러그인 호출을 플러그인 이름, 메서드 이름, 인자 객체로 구성된 메시지로 표현합니다. 이 메시지는 JSON으로 직렬화되므로 인자와 반환값은 문자열, 숫자, 불리언, 객체, 배열로 제한되고, Blob, ArrayBuffer, 함수, 순환 참조는 그대로 넘길 수 없습니다. 바이너리 데이터는 보통 base64 문자열로 바꿔 넘기는데, 크기가 33% 정도 커지고 직렬화·역직렬화가 JS 메인 스레드에서 일어나므로, 수 MB 이상의 파일을 브리지로 주고받는 코드는 성능 문제의 단골 원인입니다.
iOS에서는 WKWebView의 스크립트 메시지 핸들러(WKScriptMessageHandler)로 네이티브가 메시지를 받고, Android에서는 WebView에 등록한 JavaScript 인터페이스로 받습니다. 클래스 이름과 등록 지점은 Capacitor 버전마다 다를 수 있지만, JS에서 네이티브의 단일 진입점을 거쳐 Capacitor 런타임으로 들어간다는 구조는 같습니다. 개발 중 server.url로 원격 번들을 로드할 때와 패키징된 자산을 로드할 때는 오리진과 CORS, 혼합 콘텐츠 정책이 달라지므로, 브리지가 같아도 네트워크와 보안 경계는 따로 봐야 합니다.
콜백 상관관계와 비동기 모델
플러그인 메서드는 대부분 Promise로 노출되지만, 내부적으로는 호출마다 콜백 ID를 붙여 네이티브 응답을 해당 Promise와 짝지어 주는 방식입니다. JS에서는 호출 한 번으로 보이지만 런타임은 요청 단위로 응답을 매칭합니다. 그래서 같은 메서드를 동시에 여러 번 호출해도 되는지(잠금이나 큐가 필요한지), 블루투스 스캔이나 위치 구독처럼 계속 결과를 보내는 작업을 어떻게 멈출지(리스너 핸들의 remove() 호출 시점), 플랫폼별 에러를 JS에서 분기할 수 있는 공통 코드로 어떻게 매핑할지를 앱 수준에서 정해 두어야 합니다.
스레드 모델: Promise와 메인 스레드
JS에서 비동기라고 해서 네이티브 쪽 작업이 메인 스레드를 피해 가는 것은 아닙니다. 네이티브 플러그인 구현이 메인 스레드에서 무거운 I/O를 하면 스크롤 버벅임, 터치 지연, Android의 ANR로 이어지고, 반대로 백그라운드 스레드에서 UI를 건드리면 iOS에서는 런타임 경고나 크래시가, Android에서는 CalledFromWrongThreadException이 납니다. 무거운 작업은 백그라운드로 보내고 결과만 메인 스레드에서 UI 갱신과 콜백 호출로 전달하는 것이 기본입니다.
플러그인 시스템 내부
플러그인은 npm 패키지이기 전에 네이티브 바이너리에 포함되어 등록된 모듈입니다. 그래서 npm 설치만으로는 쓸 수 없고, sync로 네이티브 프로젝트에 의존성을 추가한 뒤 앱을 다시 빌드해야 합니다.
등록, 탐색, 디스패치
Capacitor 3 이후로는 npx cap sync가 설치된 npm 플러그인을 찾아 플랫폼별 목록(Android의 capacitor.plugins.json, iOS의 capacitor.config.json 안 클래스 목록)을 생성하고, 런타임은 기동 시 이 목록으로 플러그인을 자동 등록합니다. 앱 프로젝트 안에 직접 만든 로컬 플러그인은 이 목록에 들어가지 않으므로, Android에서는 MainActivity.onCreate에서 super.onCreate() 전에 registerPlugin(MyPlugin.class)를 호출하고, iOS에서는 커스텀 CAPBridgeViewController의 capacitorDidLoad()에서 bridge?.registerPluginInstance(...)로 등록해야 합니다.
JS에서 Foo.bar()를 호출하면 코어가 플러그인 이름과 메서드 이름으로 메시지를 만들고, 네이티브 브리지가 이를 받아 등록된 플러그인 인스턴스의 메서드로 라우팅한 뒤, 결과나 오류를 직렬화해 JS로 돌려줍니다.
"Foo" plugin is not implemented on android 같은 오류는 대개 npm 설치 후 sync를 하지 않았거나, 로컬 플러그인을 수동 등록하지 않았거나, 네이티브 앱을 다시 빌드하지 않고 웹 자산만 갱신한 경우입니다.
어노테이션 기반 메서드 노출
Android에서는 Plugin을 상속한 클래스에 @CapacitorPlugin(name = "Foo")을 붙이고 노출할 메서드에 @PluginMethod를 붙이며, iOS에서는 CAPPlugin을 상속하고 CAPBridgedPlugin 프로토콜로 jsName과 pluginMethods 목록을 선언합니다(Capacitor 6 이후 방식이며, 이전에는 Objective-C 매크로 파일로 등록했습니다). Android는 리플렉션으로 메서드를 찾기 때문에 릴리스 빌드의 R8이 메서드를 제거하거나 이름을 바꾸지 않도록 하는 keep 규칙이 필요한데, 공식 플러그인은 이를 함께 배포합니다. 커스텀 플러그인은 공식 템플릿(npm init @capacitor/plugin)에서 시작하는 것이 이런 함정을 피하는 가장 쉬운 방법입니다.
Web 폴백과 registerPlugin
registerPlugin의 web 구현은 브라우저와 PWA에서 같은 인터페이스를 쓰기 위한 구현입니다. 모바일 전용 기능이라면 웹에서 this.unimplemented()처럼 명시적으로 실패시켜 잘못된 가정을 빨리 드러내는 편이 안전합니다. 반대로 Filesystem이나 Preferences처럼 웹에서도 의미 있는 대체 구현이 가능한 영역은 웹 구현이 개발 편의성을 크게 좌우합니다.
import { registerPlugin } from '@capacitor/core';
export interface SecureStoragePlugin {
get(options: { key: string }): Promise<{ value: string | null }>;
set(options: { key: string; value: string }): Promise<void>;
}
export const SecureStorage = registerPlugin<SecureStoragePlugin>('SecureStorage', {
web: () => import('./secure-storage-web').then((m) => new m.SecureStorageWeb()),
});
이 패턴에서 주의할 점은 웹 폴백이 어떤 저장소를 쓰느냐입니다. 모바일에서는 Keychain/Keystore에 저장하더라도 웹 폴백이 localStorage라면 XSS 한 번으로 값이 유출될 수 있어 위협 모델이 완전히 달라지므로, 이 차이를 문서에 남겨야 합니다.
WebView 수명 주기 관리
하이브리드 앱에서 생기는 이상 동작 상당수는 WebView가 데스크톱 브라우저 탭처럼 계속 살아 있을 것이라는 전제에서 비롯됩니다.
iOS: WKWebView와 웹 콘텐츠 프로세스 종료
WebKit은 웹 콘텐츠를 별도 프로세스에서 실행합니다. 앱이 백그라운드에 오래 있거나 메모리가 부족하면 OS가 이 프로세스를 종료할 수 있고, 그러면 WKWebView는 빈 화면이 되거나 페이지를 다시 로드합니다. 어느 쪽이든 JS 힙의 상태는 모두 사라지므로, 상태를 메모리에만 두는 SPA 설계는 하이브리드 앱에서 취약합니다.
대응은 세 가지입니다. 인증 세션, 작성 중인 폼, 현재 라우트처럼 잃으면 안 되는 상태는 Preferences, 파일, 서버 세션 같은 영속 저장소에 두고 시작 시 복원합니다. 네이티브의 webViewWebContentProcessDidTerminate 델리게이트에서 종료를 로깅하고 필요하면 다시 로드합니다(Capacitor의 기본 웹뷰 델리게이트도 이 상황에서 다시 로드를 시도합니다). 다시 로드한 직후 같은 이유로 또 종료되는 경우를 대비해 연속 재시도 횟수를 제한하고 사용자에게 안내합니다.
Android: Activity, 프로세스, WebView 일시정지
Android에서는 Activity 생명 주기에 맞춰 WebView의 타이머와 미디어를 멈추고 재개해야 합니다. Capacitor의 BridgeActivity가 기본적인 처리를 하지만, 앱이 백그라운드에서 setInterval 폴링이나 오디오 재생을 계속하면 배터리 소모와 백그라운드 실행 제한에 걸립니다. JS에서는 App 플러그인의 appStateChange 이벤트로 포그라운드 여부를 받아 타이머를 정리하는 것이 확실합니다.
또한 Android는 메모리가 부족하면 백그라운드 앱의 프로세스 자체를 종료하고, 사용자가 돌아오면 Activity를 새로 만듭니다. 이 경우도 iOS와 마찬가지로 JS 상태가 모두 사라지며, 카메라 앱을 띄웠다가 돌아오는 사이에 이런 일이 생기면 App 플러그인의 appRestoredResult 이벤트로 결과를 받아야 합니다.
Android System WebView는 Play 스토어로 따로 업데이트되므로, 같은 Android 버전이라도 기기마다 WebView 버전이 다를 수 있습니다. 특정 기기에서만 렌더링이 깨지면 WebView 버전부터 확인하고, 운영 정책으로 지원할 최소 WebView 버전을 정해 둡니다.
로드 소스: 로컬 패키지와 개발 서버
개발 중 server.url을 쓰면 네트워크, CORS, 캐시 동작이 프로덕션과 달라집니다. 릴리스 빌드는 로컬 정적 자산을 로드하는데, 이때 라우터의 base, 절대 경로로 쓴 자산 URL, 서비스 워커 캐시가 맞지 않으면 첫 화면만 뜨고 이후 이동이 깨지는 증상이 나옵니다. 히스토리 모드 라우터에서 하위 경로로 새로 고침하거나 딥링크로 진입해도 Capacitor는 없는 파일 요청에 index.html을 돌려주지만, 상대 경로 자산(./assets/...)을 쓰면 하위 경로 기준으로 해석되어 깨지므로 base: '/'를 유지하는 편이 안전합니다.
iOS와 Android 플랫폼 차이
같은 Capacitor API라도 권한 UX, 백그라운드 정책, 파일 접근, 브라우저 엔진 업데이트 방식이 달라 체감 동작이 크게 다릅니다.
브라우저 엔진과 기능 편차
iOS의 WKWebView는 OS에 포함된 WebKit을 쓰므로 웹 기능은 iOS 버전에 묶여 업데이트됩니다. Android는 Chromium 기반 System WebView가 Play 스토어로 갱신되어 기기마다 업데이트 상태가 다릅니다. 따라서 최신 Chrome에서만 되는 Web API를 전제로 하면 위험하고, 기능 탐지와 폴리필을 기본으로 삼아야 합니다.
권한과 프라이버시 UX
Android 13부터 생긴 알림 런타임 권한(POST_NOTIFICATIONS), iOS 14부터 생긴 사진 라이브러리 제한 접근과 정밀/대략 위치 선택 같은 변화는 플러그인 호출 한 줄로 끝나지 않습니다. 권한 거부, 재요청 불가 상태, 설정 화면으로 이동하는 흐름까지 UI 상태로 설계해야 합니다. 대부분의 공식 플러그인은 checkPermissions()와 requestPermissions()를 제공하므로, 기능을 호출하기 전에 상태를 확인하는 순서로 짭니다.
파일, URI, 스코프드 스토리지
Android는 스코프드 스토리지 이후 공용 저장소 접근이 제한되고, iOS는 앱 샌드박스 밖의 파일에 접근하려면 문서 선택기나 보안 범위 북마크를 거쳐야 합니다. Filesystem 플러그인이 앱 전용 디렉터리는 잘 추상화하지만, 다른 앱이 공유한 content:// URI나 미디어 스토어처럼 플랫폼 고유 개념이 필요해지면 커스텀 네이티브 코드가 필요합니다.
딥링크, 백 스택, 제스처
Android에는 시스템 뒤로 가기 버튼(제스처)이 있고, iOS에는 없으며 WKWebView의 가장자리 스와이프 뒤로 가기도 기본으로 꺼져 있습니다. Android에서 뒤로 가기를 따로 처리하지 않으면 Capacitor는 웹 히스토리가 있으면 뒤로 가고 없으면 앱을 종료하는데, 모달이 열린 상태에서 앱이 닫히는 식의 문제가 자주 생깁니다. App 플러그인의 backButton 리스너로 모달 닫기, 라우터 뒤로 가기, 앱 종료를 직접 결정하는 편이 예측 가능합니다.
프로덕션 배포 패턴
프로덕션은 웹 빌드 파이프라인과 모바일 스토어 파이프라인이 합쳐진 구조입니다. 핵심은 재현 가능한 빌드와 환경 분리입니다.
CI에서의 순서
팀마다 스크립트 이름은 달라도 순서는 같습니다. 환경 변수를 주입해 프론트엔드 프로덕션 빌드를 하고, npx cap sync로 자산과 네이티브 의존성을 맞추고, 서명된 네이티브 빌드(iOS는 xcodebuild 또는 Fastlane, Android는 ./gradlew bundleRelease)를 만든 뒤, AAB/IPA와 함께 R8 매핑 파일과 dSYM을 보관합니다.
# 단계 개요 (GitHub Actions, GitLab CI 등에 맞게 옮겨 씁니다)
# 1) npm ci && npm run build
# 2) npx cap sync
# 3) android: cd android && ./gradlew bundleRelease
# 4) ios: xcodebuild -workspace ios/App/App.xcworkspace -scheme App archive (또는 fastlane lane)
# 5) 아티팩트 업로드, 매핑 파일과 dSYM을 크래시 리포터에 업로드
sync를 빠뜨리면 코드는 바뀌었는데 앱에는 예전 dist가 들어가는 사고가 납니다. CI 로그에 웹 산출물의 해시를 남겨 두면 어떤 웹 빌드가 들어갔는지 추적할 수 있습니다.
환경별 설정과 비밀 관리
스테이징·프로덕션 API 주소나 기능 플래그는 capacitor.config.ts 분기나 빌드 시 환경 변수로 관리합니다. 다만 웹 빌드에 주입한 값은 모두 앱 패키지 안의 JS에 그대로 들어가 추출할 수 있으므로, API 시크릿 같은 값은 처음부터 클라이언트에 넣지 않는다는 전제로 설계합니다. 보호가 필요한 호출은 서버를 거치게 하고, 단기 토큰이나 App Attest/Play Integrity 같은 기기 증명으로 보완합니다.
Android: Play App Signing, AAB, 난독화
Google Play는 신규 앱에 AAB 제출과 Play App Signing을 요구합니다. 앱 서명 키는 Google이 보관하고, 개발자는 업로드 키로 서명해 올리므로 업로드 키를 안전하게 보관하는 것이 중요합니다(분실 시 재설정 요청 가능). R8 규칙은 리플렉션을 쓰는 플러그인에서 깨지기 쉬우므로, 릴리스 빌드에서만 재현되는 크래시가 나면 난독화부터 의심하고, 매핑 파일을 크래시 리포터에 올리는 절차를 자동화합니다.
iOS: 서명, 프로비저닝, dSYM
팀이 커지면 Xcode 자동 서명 대신 Fastlane match 같은 방식으로 인증서와 프로비저닝 프로파일을 중앙에서 관리하는 경우가 많습니다. 크래시 심볼리케이션을 위해 dSYM을 크래시 리포터에 올려야 합니다(Bitcode는 Xcode 14부터 폐기되어 더 이상 고려하지 않아도 됩니다). 스토어 제출 실패는 Capacitor 문제보다 서명, 빌드 번호 중복, Info.plist 권한 문구 누락에서 생기는 경우가 많습니다.
OTA 업데이트와 정책 리스크
웹 번들만 원격으로 바꾸는 라이브 업데이트 솔루션(Capgo, 자체 CDN 등)을 쓰면 스토어 심사 없이 수정을 내보낼 수 있습니다. App Store 가이드라인은 WebKit에서 실행되는 코드의 다운로드를 허용하지만, 앱의 주요 목적이나 기능을 바꾸는 업데이트는 허용하지 않으므로 범위를 버그 수정과 콘텐츠 변경 수준으로 제한해야 합니다. 또한 새 네이티브 플러그인이 필요한 변경은 OTA로 내보낼 수 없고, 이전 네이티브 바이너리에서 새 JS가 없는 플러그인을 호출하지 않도록 번들과 바이너리 버전의 호환 관계를 관리해야 합니다.
관측 가능성: 네이티브와 JS 스택
Sentry나 Firebase Crashlytics로 네이티브 크래시와 JS 오류를 함께 보려면 JS 소스맵과 네이티브 심볼을 모두 업로드해야 합니다. 오류가 브리지, 플러그인, 웹 라우터 중 어느 층에서 났는지 태그로 구분해 두면 장애 원인을 훨씬 빨리 찾을 수 있습니다.
iOS/Android 프로젝트 구성
초기 설치와 프로젝트 생성
- 웹 프로젝트가 프로덕션 빌드를 만들 수 있는 상태인지 확인합니다.
npm install @capacitor/core와npm install -D @capacitor/cli로 설치하고,npx cap init으로 앱 이름, 앱 ID,webDir를 지정합니다.npm install @capacitor/ios @capacitor/android후npx cap add ios,npx cap add android로 네이티브 폴더를 생성합니다.- 웹 빌드 후
npx cap sync로 자산을 복사하고 네이티브 의존성을 맞춥니다. npx cap open ios또는npx cap open android로 IDE를 엽니다.
팀원마다 Xcode, CocoaPods, Android SDK, JDK 버전이 다르면 빌드 결과가 달라지므로, 프로젝트가 요구하는 도구 버전을 README에 고정해 두는 것이 좋습니다. Capacitor 메이저 버전마다 최소 Xcode·JDK·Android SDK 요구 사항이 올라가므로 업그레이드 가이드를 함께 확인합니다.
iOS: Xcode 프로젝트에서 볼 것
번들 ID와 Signing & Capabilities 화면에서 푸시, 백그라운드 모드, Associated Domains 같은 기능을 켭니다. Info.plist의 카메라·마이크·사진 라이브러리 사용 목적 문자열(NSCameraUsageDescription 등)은 해당 API를 쓰면 반드시 있어야 하며, 없으면 권한 요청 시 앱이 종료되고 심사에서도 반려됩니다. 딥링크, 푸시 토큰, 앱 상태 전환은 AppDelegate에서 Capacitor로 전달되므로, 템플릿의 해당 코드를 지우지 않도록 주의합니다.
Android: Gradle과 매니페스트
applicationId는 capacitor.config의 appId와 일치시켜 둡니다. targetSdkVersion은 Google Play가 매년 요구 수준을 올리므로 주기적으로 올려야 하며, 올릴 때마다 동작 변경(권한, 백그라운드 제한)을 확인해야 합니다. 권한 선언, 평문 트래픽 허용 여부, 서비스 선언은 AndroidManifest.xml에 모입니다.
폴더 구조
웹 저장소 루트에 src/(프론트 소스), dist/(빌드 결과), ios/, android/가 함께 있습니다. Capacitor는 ios/와 android/를 소스 코드로 취급해 커밋하는 방식이므로, 이 폴더를 직접 수정하는 것은 자연스럽습니다. 다만 수정이 많을수록 Capacitor 메이저 업그레이드 때 템플릿 변경과 충돌하므로, 재사용할 네이티브 기능은 플러그인으로 분리해 두는 편이 업그레이드가 수월합니다.
flowchart LR
subgraph web [웹 빌드]
A[소스 TS/Vue/React] --> B[번들러 빌드]
B --> C[webDir 산출물]
end
subgraph cap [Capacitor]
C --> D[npx cap sync]
D --> E[iOS/Android 자산 복사]
end
subgraph native [네이티브]
E --> F[Xcode / Android Studio 빌드]
F --> G[IPA / AAB·APK]
end
공식 플러그인 활용
공식 @capacitor/* 플러그인은 네이티브 기능을 Promise 기반 API로 감쌉니다. 대부분 import { PluginName } from '@capacitor/...' 후 PluginName.method()를 호출하는 형태이고, 권한 거부와 사용자 취소를 UI에서 함께 처리해야 합니다.
Camera
@capacitor/camera는 사진 촬영과 갤러리 선택을 제공합니다. 권한 문구, 큰 이미지의 메모리 사용, EXIF 메타데이터(위치 정보 포함 여부)를 함께 고려해야 합니다.
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
export async function pickPhoto() {
const image = await Camera.getPhoto({
quality: 85,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Prompt, // 카메라/갤러리 선택 UI
width: 1600, // 다운샘플링해 메모리와 업로드 크기를 줄임
});
// image.webPath: <img src>나 fetch로 바로 쓸 수 있는 경로
return image;
}
resultType을 Base64로 받으면 원본 이미지 전체가 base64 문자열로 브리지를 통과해 메모리를 크게 씁니다. Uri로 받고 fetch(image.webPath)로 Blob을 만들어 업로드하면 브리지에는 경로 문자열만 오가므로 훨씬 가볍습니다. 사용자가 선택 화면을 닫으면 getPhoto가 reject되므로, 이를 오류가 아니라 취소로 처리해야 합니다.
Filesystem
@capacitor/filesystem은 Directory.Data, Directory.Cache, Directory.Documents 같은 저장소 구획을 추상화합니다. 오프라인 캐시, 로그 파일, 사용자 생성 콘텐츠 저장에 쓰며, 경로 문자열을 직접 조합하기보다 디렉터리 상수와 getUri()를 쓰면 OS 차이를 흡수하기 쉽습니다.
readFile과 writeFile은 기본적으로 데이터를 base64 문자열로 주고받기 때문에, 큰 파일을 통째로 읽고 쓰면 브리지 비용과 메모리 사용이 커집니다. 큰 파일 다운로드는 Filesystem.downloadFile(최신 버전에서는 @capacitor/file-transfer 플러그인으로 옮겨 가는 중)처럼 네이티브에서 바로 디스크에 쓰는 API를 쓰고, 표시에는 Capacitor.convertFileSrc(uri)로 바꾼 URL을 쓰는 편이 낫습니다.
기타 자주 쓰는 공식 플러그인
| 영역 | 플러그인 | 쓰임 |
|---|---|---|
| 앱·기기 | App, Device | 앱 상태·딥링크·뒤로 가기 이벤트, 모델명·OS 버전 |
| 네트워크 | Network | 오프라인 배너, 동기화 큐 재시도 트리거 |
| 저장소 | Preferences | 소량 키-값 저장(평문이므로 민감 정보는 피함) |
| UI | Toast, Dialog, Status Bar, Splash Screen | 네이티브 UI 요소 |
| 하드웨어 | Haptics, Geolocation | 진동 피드백, 위치(권한과 배터리 주의) |
| HTTP | CapacitorHttp(코어 포함) | 네이티브 HTTP로 요청해 CORS 제약 회피 |
플러그인마다 최소 OS 버전과 필요한 권한 선언이 다르므로, 스토어 제출 전에 지원 범위의 실기기에서 확인합니다.
커스텀 플러그인 개발
언제 만드는가
공식·커뮤니티 플러그인으로 해결되지 않는 사내 SDK 연동, 특수 주변기기, 네이티브에만 두어야 하는 보안 로직이 있을 때 커스텀 플러그인을 만듭니다. iOS는 Swift(또는 Objective-C), Android는 Kotlin 또는 Java로 메서드를 구현하고, TypeScript에서 같은 이름으로 호출합니다.
설계할 때는 플러그인과 메서드 이름을 처음에 정해 고정하고, 입출력을 JSON으로 직렬화 가능한 작은 값으로 유지하며, 무거운 작업은 백그라운드에서 처리한 뒤 결과만 돌려주고, 플랫폼별 오류를 공통 오류 코드로 매핑해 JS에서 분기할 수 있게 합니다.
TypeScript 측 정의
import { registerPlugin } from '@capacitor/core';
export interface EchoPlugin {
echo(options: { value: string }): Promise<{ value: string }>;
}
export const Echo = registerPlugin<EchoPlugin>('Echo', {
web: () => import('./echo-web').then((m) => new m.EchoWeb()),
});
웹 타깃까지 고려하면 web 구현을 제공해 같은 인터페이스로 개발할 수 있고, 모바일 전용이면 웹에서 명확한 오류를 반환합니다.
네이티브 측
iOS에서는 CAPPlugin을 상속하고 CAPBridgedPlugin을 채택한 클래스에 @objc func echo(_ call: CAPPluginCall) 형태로 메서드를 두고, Android에서는 @CapacitorPlugin을 붙인 Plugin 하위 클래스에 @PluginMethod fun echo(call: PluginCall)을 둡니다. 어느 쪽이든 call.getString("value")로 인자를 읽고, call.resolve(...)나 call.reject(...)를 반드시 정확히 한 번 호출해야 JS의 Promise가 끝납니다. 오류 경로에서 이 호출을 빠뜨리면 Promise가 영원히 대기하는데, 이것이 커스텀 플러그인에서 가장 흔한 버그입니다.
네이티브 경험이 적은 팀이라면 기능마다 플러그인을 쪼개기보다 관련 기능을 하나의 플러그인으로 묶어 유지보수 지점을 줄이는 편이 낫습니다.
푸시 알림과 백그라운드 작업
푸시 알림: FCM과 APNs
원격 푸시는 @capacitor/push-notifications가, 기기에서 예약하는 로컬 알림은 별도 플러그인인 @capacitor/local-notifications가 담당합니다. 푸시 플러그인은 권한 요청, 토큰 수신, 알림 수신·탭 이벤트를 제공할 뿐이고, 실제 발송 인프라(FCM HTTP v1 API, APNs 인증 키)와 토큰을 서버에 등록하는 API는 백엔드에서 따로 만들어야 합니다. Android는 google-services.json을 넣어 FCM을 쓰고, iOS는 플러그인이 APNs 토큰을 돌려주므로 FCM으로 통일하려면 추가 설정이 필요합니다.
운영에서는 토큰이 바뀔 때마다 서버와 동기화하는지, 앱이 포그라운드일 때 알림을 어떻게 보여 줄지(iOS는 presentationOptions 설정), 알림 데이터의 딥링크를 어느 화면으로 연결할지, Android 알림 채널을 어떻게 나눌지를 정해야 합니다.
백그라운드 작업의 현실
모바일 OS는 배터리와 프라이버시를 이유로 백그라운드 실행을 엄격히 제한합니다. 앱이 백그라운드로 가면 WebView의 JS도 곧 멈추므로, 데스크톱처럼 항상 도는 워커는 기대할 수 없습니다. iOS는 BGTaskScheduler의 앱 새로 고침·처리 작업, 위치 업데이트, 무음 푸시처럼 용도별로 정해진 실행 기회만 주며 실행 시점은 OS가 결정합니다. Android는 WorkManager와 알림이 필수인 포그라운드 서비스가 기본 수단이고, 포그라운드 서비스는 Android 14부터 서비스 유형 선언이 필요해지는 등 정책이 계속 바뀝니다.
@capacitor/background-runner는 WebView 없이 별도 JS 런타임에서 짧은 작업을 실행하는 공식 플러그인이지만, 사용할 수 있는 API가 제한되어 있고 실행 주기를 보장하지 않습니다. 주기적 동기화가 비즈니스의 핵심이라면 네이티브 설계부터 검토해야 합니다. 일반적으로는 푸시로 깨워 짧게 처리하거나, 오프라인 큐를 두고 앱이 다시 열릴 때 처리하는 방식이 현실적입니다.
성능 최적화
프로파일링 결과가 JS 실행, 레이아웃, 페인트에 몰려 있다면 네이티브 튜닝보다 프론트엔드 최적화가 먼저입니다. 긴 목록은 가상 스크롤로, 이미지는 지연 로딩과 표시 크기에 맞춘 해상도로, 긴 메인 스레드 작업은 분할하거나 Web Worker로 옮깁니다. 시작 시간은 초기 번들 크기에 크게 좌우되므로 라우트 단위로 코드를 분할하고 첫 화면에 필요 없는 모듈은 동적 import로 늦춥니다. Safari Web Inspector(iOS)와 chrome://inspect(Android)로 실기기의 WebView를 직접 프로파일링할 수 있으므로, 데스크톱 브라우저 측정만으로 판단하지 않는 것이 중요합니다.
메모리는 카메라, 파일, 지도처럼 큰 데이터를 다룰 때 급격히 늘어납니다. 앞서 설명한 base64 브리지 전송을 피하고, URL.createObjectURL로 만든 URL은 다 쓰면 revokeObjectURL로 해제하며, 이미지는 표시 크기에 맞게 다운샘플링합니다. iOS에서 메모리가 한계에 가까워지면 웹 콘텐츠 프로세스가 종료되므로, 메모리 최적화는 앞의 흰 화면 문제와도 직결됩니다.
Cordova와 Capacitor 비교
Cordova는 오랜 역사와 방대한 플러그인 생태계를 갖고 있고, 네이티브 프로젝트를 빌드할 때마다 생성하는 산출물로 취급합니다. Capacitor는 네이티브 프로젝트를 소스 코드로 커밋해 직접 수정할 수 있게 하고, npm 기반 도구에 맞춰 설계되었습니다. Capacitor는 Cordova 호환 레이어로 많은 Cordova 플러그인을 쓸 수 있지만, 빌드 훅이나 config.xml 설정에 의존하는 플러그인 등 일부는 동작하지 않습니다.
| 기준 | Cordova를 유지할 만한 경우 | Capacitor가 맞는 경우 |
|---|---|---|
| 기존 자산 | 호환 레이어에서 안 되는 Cordova 플러그인에 크게 의존 | 최신 웹 스택과 번들러 중심 |
| 네이티브 수정 | 네이티브 코드를 거의 건드리지 않음 | Xcode/Gradle 설정을 자주 수정 |
| 로드맵 | 유지보수만 하는 프로젝트 | 신규 기능과 스토어 정책 대응이 계속 필요 |
새 프로젝트는 대개 Capacitor가 유리하고, 레거시 프로젝트는 플러그인 호환성을 조사해 비용을 산정한 뒤 점진적으로 옮기는 것이 현실적입니다.
CLI 명령과 일상적인 작업 흐름
정확한 플래그는 npx cap --help와 설치된 @capacitor/cli 버전을 기준으로 확인합니다.
| 명령 | 용도 |
|---|---|
npx cap init | 앱 이름, 앱 ID, webDir로 설정 파일 생성 |
npx cap add ios / android | 네이티브 프로젝트 폴더 생성(이미 있으면 실패) |
npx cap sync | 웹 빌드 복사와 네이티브 의존성 갱신 |
npx cap copy | 웹 자산만 복사(플러그인을 추가·삭제했다면 sync 필요) |
npx cap open ios / android | Xcode 또는 Android Studio 열기 |
npx cap run ios / android | CLI에서 빌드 후 기기나 에뮬레이터에 실행 |
일상적인 개발 루프는 웹에서 기능을 개발하고, npm run build 후 npx cap sync를 거쳐 IDE나 cap run으로 실행하는 것입니다. 릴리스 브랜치에서는 server.url이 꺼져 있고 올바른 환경 변수가 주입되었는지 CI에서 검증합니다.
트러블슈팅
흰 화면, index.html을 찾지 못함
webDir가 실제 빌드 출력 폴더와 다르거나, 빌드 도구의 base 설정 때문에 자산이 404가 나는 경우가 많습니다. Safari Web Inspector나 chrome://inspect로 앱의 WebView에 붙어 네트워크 탭에서 첫 HTML과 JS가 로드되는지 확인합니다.
"Foo" plugin is not implemented on android
npm으로 플러그인을 설치한 뒤 npx cap sync를 하지 않았거나, 네이티브 앱을 다시 빌드하지 않은 경우가 대부분입니다. 앱 안에 직접 만든 로컬 플러그인이라면 앞에서 설명한 수동 등록(registerPlugin 또는 registerPluginInstance)이 빠졌는지 확인합니다.
iOS에서만 API가 실패
Info.plist 권한 문구 누락, Capabilities 미설정, 시뮬레이터와 실기기의 차이가 주된 원인입니다. 카메라, 푸시, 일부 센서는 시뮬레이터에서 동작하지 않거나 다르게 동작하므로 실기기에서 확인합니다.
CORS와 혼합 콘텐츠
WebView에서 원격 API를 부르면 브라우저와 똑같이 CORS 제약을 받고, 오리진은 앞서 설명한 capacitor://localhost나 https://localhost입니다. 해결 방법은 API 서버의 허용 오리진에 이 값들을 추가하거나, 앱 전용 게이트웨이를 두거나, CapacitorHttp를 켜서 요청을 네이티브 HTTP로 보내는 것입니다. 마지막 방법은 fetch를 패치하는 방식이라 스트리밍 응답 등 일부 동작이 브라우저와 다를 수 있으므로 검증이 필요합니다. https://localhost에서 http:// API를 부르면 혼합 콘텐츠로 차단되는데, 운영 빌드에서는 평문 HTTP를 허용하지 말고 API를 HTTPS로 제공해야 합니다.
딥링크와 유니버설 링크
특정 URL을 열었을 때 웹사이트 대신 앱 화면으로 연결하려면 iOS Universal Links와 Android App Links를 설정해야 합니다. 도메인에 인증 파일(apple-app-site-association, assetlinks.json)을 올리고, iOS는 Associated Domains 기능을, Android는 매니페스트의 autoVerify 인텐트 필터를 구성한 뒤, JS에서는 App 플러그인의 appUrlOpen 이벤트로 받은 URL을 라우터 경로로 바꿔 이동합니다. 링크 검증은 설치 시점이나 OS 캐시에 의존해 시뮬레이터와 실기기 결과가 다를 수 있으므로, 실기기에서 링크를 눌러 앱으로 전환되는 것과 뒤로 가기 동작까지 확인합니다.