iOS 에이전트 적용
WhaTap 모바일 에이전트를 iOS 앱에 적용하면 앱 시작 성능, 화면 로딩, 네트워크 호출, 크래시, 리소스 사용량, WebView 페이지 성능을 수 집할 수 있습니다.
이 문서는 앱 개발자를 대상으로 라이브러리 추가부터 데이터 수집 확인까지 전체 절차를 안내합니다. WebView 안의 웹 페이지까지 수집하려면 웹 담당자의 작업도 필요합니다. 자세한 내용은 WebView 페이지 수집 설정에서 다룹니다.
지원 환경
빌드 환경
| Item | Value |
|---|---|
| iOS | 15.0 이상 |
| Xcode | 16.0 이상 |
| 언어 | Swift 5.9 이상 또는 Objective-C |
배포 타깃
앱의 배포 타깃(IPHONEOS_DEPLOYMENT_TARGET)을 iOS 15.0 이상으로 설정하세요. 배포 패키지의 platforms 선언도 .iOS(.v15)이므로, Swift Package Manager로 추가하면 15.0 미만인 앱은 패키지 해석 단계에서 막힙니다.
XCFramework을 직접 추가한 경우에는 해석 단계의 검사가 없어 링크할 때 경고로만 나타납니다.
building for iOS x.y, but linking with dylib … which was built for newer version 15.0
빌드는 통과하지만 15.0 미만 기기에서의 동작은 보증하지 않습니다.
Xcode 15 이하는 지원하지 않습니다.
모듈 인터페이스가 SwiftUICore를 참조하는데, 이 모듈은 iOS 18 SDK(Xcode 16)에서 분리 신설된 것이라 그 이전 SDK에는 존재하지 않습니다.
WebView 연동 요구 버전
앱 안의 WebView 페이지까지 함께 수집하려면 iOS 에이전트와 브라우저 에이전트가 모두 요구 버전을 충족해야 합니다. 한쪽이라도 버전이 낮으면 일부 항목이 수집되지 않습니다. 이때 앱과 웹 어느 로그에도 오류가 남지 않습니다.
| Component | Minimum version | Owner |
|---|---|---|
| iOS 에이전트 | 2.7.11 | 앱 개발자 |
| 브라우저 에이전트 | 3.2.0 | 웹 개발자 |
2.7.11 미만에서는 웹뷰 데이터가 대시보드에 표시되지 않습니다. 2.8.0에서 웹뷰 크래시와 메모리 누수를 고쳤습니다. 최신 버전을 권장합니다.
적용을 마친 뒤 대시보드의 agent_version 값에서 실제로 동작 중인 버전을 확인하세요. 파일을 교체하는 것만으로는 새 버전이 반영되었는지 알 수 없습니다.
.zip으로 받은 프레임워크는 체크섬이 안내받은 값과 일치하는지 확인합니다.
swift package compute-checksum WhatapAgent.xcframework.zip
브라우저 에이전트 파일의 버전은 첫 4줄의 배너로 확인합니다.
head -4 whatap-browser-agent.js
에이전트 설치
다음 순서로 진행합니다.
1. 라이브러리 추가
세 가지 방식 중 하나를 선택합니다. Swift Package Manager 방식을 권장합니다.
Swift Package Manager(권장)
-
Xcode에서 프로젝트를 열고 File > Add Package Dependencies 메뉴를 선택하세요.
-
패키지 URL 필드에 다음 주소를 입력하세요.
https://github.com/whatap/WhatapIOSAgent-Release -
Releases에서 최신 버전을 확인해 그 버전을 선택하고 Add Package 버튼을 클릭하세요.
branch를 지정하거나 상한 없는from:을 쓰지 마세요. -
WhatapAgent 라이브러리를 앱 타겟에 추가하세요.
Package.swift로 관리하는 프로젝트라면 다음과 같이 지정합니다.
dependencies: [
.package(url: "https://github.com/whatap/WhatapIOSAgent-Release.git", exact: "<최신 버전>")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "WhatapAgent", package: "WhatapIOSAgent-Release")
]
)
]
Swift Package Manager 방식도 CDN 접근이 필요합니다.
GitHub 저장소에는 바이너리가 없고 binaryTarget(url:checksum:)이 CDN을 가리킵니다. 빌드 머신에서 github.com과 repo.whatap-mobile-agent.io가 모두 열려 있어야 합니다. 방화벽으로 CDN이 막혀 있으면 swift package resolve 단계에서 실패합니다. 이 경우 로컬 파일 설치를 사용하세요.
수동 XCFramework 설치
-
XCFramework을 내려받습니다.
<최신 버전>자리에는 Releases에서 확인한 버전을 넣으세요.curl -L -o WhatapAgent.xcframework.zip \
https://repo.whatap-mobile-agent.io/uploads/<최신 버전>/WhatapAgent.xcframework.zip
unzip WhatapAgent.xcframework.zip -
Xcode 프로젝트에 추가합니다.
- 프로젝트 타겟을 선택하고 General 탭을 선택하세요.
- Frameworks, Libraries, and Embedded Content 섹션에서 + 버튼을 클릭하세요.
- Add Other > Add Files를 선택하고
WhatapAgent.xcframework를 선택하세요. - Embed & Sign 설정을 확인하세요.
로컬 파일 설치(폐쇄망, 사내망)
외부 저장소에 접근할 수 없는 환경에서는 와탭이 제공한 zip 파일을 받아 압축을 푼 뒤 프로젝트에 추가합니다. 이후 수동 XCFramework 설치와 같은 방법으로 Xcode에서 Embed & Sign으로 추가하세요.
unzip WhatapAgent.xcframework.zip
mv WhatapAgent.xcframework /path/to/YourApp/
2. Agent 초기화
SDK는 앱이 시작될 때 초기화해야 합니다. 초기화가 늦어지면 pre-main time, 초기 메모리 사용량 등 앱 시작 초기의 성능 데이터를 수집하지 못합니다.
| Framework | 초기화 위치 |
|---|---|
| SwiftUI | @main struct의 init(). 앱 구조체가 만들어지는 시점 |
| UIKit | application:willFinishLaunchingWithOptions:. didFinishLaunching보다 먼저 호출됨 |
build() 뒤에 initialize()를 반드시 호출하세요.
build()는 설정만 구성합니다. initialize()를 호출하지 않아도 setupWebViewBridge()는 그대로 동작해 window.whatapBridge가 주입되므로, 브라우저 에이전트는 브릿지가 정상이라고 판단해 HTTP 폴백도 하지 않습니다. 그 결과 웹과 네이티브 데이터가 모두 유실되고 앱 로그에만 다음 오류가 남습니다.
[ERROR] [WhatapWebviewBridge] WhatapAgent가 초기화되지 않았습니다.
initialize()는 두 번째 호출부터 무시됩니다. 이미 운영 중인 앱에 WebView 연동을 추가한다면 초기화 코드를 새로 만들지 말고 기존 코드를 수정하세요. 초기화를 두 곳에 나누어 넣어도 뒤쪽 호출은 적용되지 않습니다.
Swift
SwiftUI 앱은 @main struct의 init()에서 초기화합니다.
import SwiftUI
import WhatapAgent
@main
struct MyApp: App {
init() {
let agent = WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<수집 서버 도메인>/m") // 끝의 /m 필수
.build()
agent.initialize()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
UIKit 앱은 AppDelegate의 application:willFinishLaunchingWithOptions:에서 초기화하는 것을 권장합니다.
import UIKit
import WhatapAgent
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let agent = WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<수집 서버 도메인>/m")
.build()
agent.initialize()
return true
}
}
Objective-C
willFinishLaunchingWithOptions와 didFinishLaunchingWithOptions 중 어느 위치에서 초기화해도 동작합니다. 다음은 didFinishLaunchingWithOptions에서 초기화하는 예제입니다.
#import <WebKit/WebKit.h> // WhatapAgent 헤더보다 먼저
#import <WhatapAgent/WhatapAgent-Swift.h>
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
WhatapAgentBuilder *b = [[WhatapAgentBuilder alloc] init];
(void)[b setProjectKey:@"발급받은 액세스 키"];
(void)[b setPCode:12345];
(void)[b setServerUrl:@"https://수집서버-주소/m"]; // /m 포함, 끝에 / 없이
(void)[b setSamplingRate:1.0]; // 0.0 ~ 1.0 (웹의 0 ~ 100 과 다릅니다)
WhatapIOSAgent *agent = [b build];
[agent initialize]; // 이 호출이 없으면 데이터가 전송되지 않습니다
return YES;
}
헤더를 들여오는 순서. #import <WhatapAgent/WhatapAgent-Swift.h>보다 먼저 #import <WebKit/WebKit.h>를 넣으세요. 생성 헤더는 WKWebView를 전방 선언만 하고 정의를 가져오지 않습니다. 모듈을 끈 프로젝트(CLANG_ENABLE_MODULES = NO)에서는 이 헤더를 import하는 모든 파일이 먼저 WebKit을 import해야 합니다. 그렇지 않으면 cannot find protocol declaration for 'WKNavigationDelegate' 오류가 발생하고 빌드가 실패합니다. WebView를 다루지 않는 AppDelegate도 마찬가지입니다.
@import WhatapAgent; 대신 #import 형태를 사용하세요. @import도 동작하지만, 모듈을 끈 프로젝트에서는 use of '@import' when modules are disabled 오류가 발생해 빌드가 실패합니다. 또한 umbrella 헤더가 공개 대상이 아닌 선언까지 자동완성에 노출합니다.
빌더 setter는 모두 warn_unused_result입니다. 위 예제처럼 체인을 끊어 여러 줄로 작성하면서 (void) 캐스팅을 생략하면 각 줄에서 -Wunused-result 경고가 발생합니다. -Werror를 사용하는 프로젝트에서는 이 경고 때문에 빌드가 실패합니다. 한 줄 체인으로 작성하면 캐스팅이 필요 없습니다.
3. 설치 확인
앱을 실행한 뒤 Xcode Console에서 초기화 로그를 확인하세요. 디버그 빌드에서 로그를 자세히 보려면 다음 설정을 추가합니다.
#if DEBUG
WhatapLogger.isDebug = true
#endif
데이터가 수집되지 않으면 프로젝트 키, PCode, 서버 URL, initialize() 호출, 샘플링 비율과 디스크 버퍼를 확인하세요.
수집 항목
build()와 initialize()를 호출하면 다음 항목을 자동으로 수집합니다.
- 크래시, 예외, 시그널
- CPU, 메모리, 배터리, 열 상태
다음 네 항목은 build()와 initialize()를 호출하는 것만으로는 자동으로 켜지지 않습니다. 별도의 작업이 필요합니다.
| Item | 필요한 작업 |
|---|---|
| 앱 시작 성능 | 앱 시작 성능 수집의 StartupTimer 호출 |
| 화면별 로딩 시간 | 화면 추적의 네 가지 방법 중 하나 적용 |
| 네트워크 요청, 응답 정보 | 수집과 HTTP 옵션의 setCollectNetwork(true) 지정 |
| WebView 페이지 로드와 성능 정보 | WebView 페이지 수집 설정의 브릿지 등록 |
화면별 로딩 시간은 자동으로 켜지지 않습니다.
UIViewController 생명주기를 기반으로 하는 화면 추적은 기본적으로 꺼져 있습니다. setCollectScreenLoading의 기본값은 true지만, 에이전트의 disableAutoScreenTracking 기본값도 true입니다. Builder가 이 값을 되돌리지 않기 때문에 화면 추적은 자동으로 켜지지 않습니다. 켜는 방법은 화면 추적을 참조하세요.
자동 화면 추적을 켜지 않아도 WebView 데이터는 정상적으로 수집됩니다. initialize()를 호출하고 0.3초가 지나면 앱 이름(CFBundleName)으로 기본 ScreenGroup이 하나 시작됩니다. 웹뷰 페이지 로드는 이 그룹에 연결됩니다. 자동 추적을 사용하지 않으면 네이티브 화면 단위의 구분만 생기지 않습니다.
Builder 옵션
모든 Builder 옵션은 선택 사항이며, 지정하지 않으면 기본값이 적용됩니다.
전송과 버퍼 옵션
| Method | Default | Description |
|---|---|---|
setFlushInterval(_:) | 10.0 | 배치 전송 주기(초) |
setQueueSize(_:) | 1000 | 메모리 큐 크기 |
setMaxDiskBytes(_:) | 512000 | 오프라인 디스크 버퍼 상한(bytes) |
setMaxDiskFiles(_:) | 5 | 디스크 버퍼 파일 개수 |
수집과 HTTP 옵션
| Method | Default | Description |
|---|---|---|
setCollectScreenLoading(_:) | true | 화면 로딩 수집. 화면 추적 설정이 함께 필요 |
setCollectNetwork(_:) | false | 자동 네트워크 수집 |
setKeepAlive(_:) | true | HTTP keep-alive |
setMaxConnectionsPerHost(_:) | 4 | 호스트당 최대 연결 수 |
네트워크 수집은 opt-in입니다.
iOS SDK 2.7.3부터 자동 네트워크 수집의 기본값은 false입니다. 이 기능을 활성화하면 URLProtocol을 통해 요청을 계측합니다. 따라서 인증서 pinning, 사용자 정의 URLSessionDelegate, 자체 쿠키나 인증 헤더, HTTP/2 협상에 의존하는 앱은 영향을 먼저 확인하세요. 보안에 민감한 요청은 별도의 custom URLSession으로 분리하는 것을 권장합니다.
네트워크 헤더 캡처
| Method | Default | Description |
|---|---|---|
setCaptureRequestHeaders(_:) | false | request 헤더를 http.request.header.* attribute로 캡처 |
setCaptureResponseHeaders(_:) | false | response 헤더를 http.response.header.* attribute로 캡처 |
setHeaderValueMaxLength(_:) | 512 | 헤더 값 최대 길이(bytes). 초과 시 절삭. 0은 무제한 |
옵션을 켜면 헤더를 다음 규칙에 따라 attribute로 붙입니다.
| Rule | Example |
|---|---|
헤더 이름을 소문자로 바꾸고 -를 _로 변환 | X-Request-ID → http.request.header.x_request_id |
| 다중값 헤더는 쉼표로 연결 | a, b, c |
| 길이 초과 시 앞부분만 남기고 표시 | ...[TRUNCATED to <N>B] |
민감 헤더 자동 마스킹이 없습니다.
Authorization, Cookie, Set-Cookie, X-API-Key, Proxy-Authorization 같은 민감 헤더도 옵션을 켜면 값을 그대로 캡처해 수집 서버로 보냅니다. 켜기 전에 캡처 범위와 민감 정보 노출 여부를 반드시 검토하고, 필요 없다면 기본값 false로 두세요.
그 밖의 옵션
| Method | Default | Description |
|---|---|---|
setKeyNormalization(_:) | false | true이면 로그 attribute 키를 snake_case로 강제 변환(2.6.x 이전 동작) |
커스텀 endpoint
| Method | Default | Description |
|---|---|---|
setLogServerUrl(_:) | serverUrl + "/log" | log 엔드포인트를 직접 지정할 때만 사용 |
setSpanServerUrl(_:) | serverUrl + "/trace" | trace 엔드포인트를 직접 지정할 때만 사용 |
setLogServerUrl(_:)과 setSpanServerUrl(_:)을 직접 지정했더라도 setServerUrl은 반드시 설정해야 합니다. 자세한 내용은 수집 서버 주소 확인을 참조하세요.
ScreenGroup과 추적 옵션
| Method | Default | Description |
|---|---|---|
setGroupWaitingInterval(_:) | 3.0 | 화면 그룹 닫힘 지연(초) |
setExcludeLifecycleEventsFromScreenGroup(_:) | false | 화면 그룹에서 생명주기 이벤트 제외 |
enableMethodTracing(_:) | false | Method Tracing 사용 여부 |
setSamplingRate(_:) | 1.0 | 수집 비율. 0.0 ~ 1.0 스케일. 세션 단위로 추첨 |
setSingleSessionPerLaunch(_:) | false | true이면 앱 실행 시 발급한 세션을 프로세스가 종료될 때까지 유지(2.7.15 이상) |
setSamplingRate(_:)는 0.0 ~ 1.0 범위의 값을 사용합니다. 반면 브라우저 에이전트의 sampleRate는 0 ~ 100 범위의 값을 사용합니다. 따라서 모바일 에이전트의 값을 브라우저 에이전트에 그대로 입력하면 안 됩니다. 모바일의 1.0을 웹에 그대로 넣으면 수집 비율이 1%가 됩니다.
범위를 벗어난 값은 무시되어 전수 수집으로 남습니다.
0.0 ~ 1.0을 벗어난 값을 넣으면 상한이나 하한으로 조정하지 않고 직전에 설정한 유효한 값을 그대로 유지합니다. 한 번도 설정하지 않았다면 기본값 1.0입니다. 즉 잘못된 값을 넣으면 수집이 꺼지는 것이 아니라 전수 수집으로 남고, 증상은 「샘플링이 걸리지 않는다」로 나타납니다.
이때 다음 경고가 남지만 WhatapLogger.isDebug를 켜야 보입니다. 수집량이 설정과 어긋나면 디버그 로그를 켜고 이 문구를 확인하세요.
[WARN] [SESSION] configure: invalid samplingRate=50.0 — keeping 1.0
세션이 나뉘는 기준
기본값에서 세션은 15분 동안 사용자 활동이 없거나 시작 후 4시간이 지나면 새로 발급됩니다. 4시간 최대 지속은 iOS 에이전트 2.7.15부터 적용되며, 그 이전 릴리스는 세션 최대 지속 시간이 30분입니다. 앱을 다시 실행해도 새 세션입니다. 샘플링 추첨은 세션 단위여서 세션이 새로 발급될 때마다 다시 추첨하며, 한 세션 안의 데이터는 전부 수집되거나 전부 수집되지 않습니다.
setSingleSessionPerLaunch(true)는 위 두 만료 조건을 적용하지 않아 세션이 앱 실행당 하나만 생기고 샘플링 추첨도 앱 실행 시 한 번만 일어납니다. 세션 수로 과금되는 SaaS 프로젝트에서는 기본값 false를 유지하세요. 세션 수가 과금과 무관한 설치형 환경에서만 켜는 옵션입니다. setSingleSessionPerLaunch는 2.7.15 이상에서만 사용할 수 있으며, 그 이전 릴리스에는 이 API가 없습니다.
WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<수집 서버 도메인>/m")
.setFlushInterval(120)
.setMaxDiskBytes(2 * 1024 * 1024)
.setMaxDiskFiles(5)
.setQueueSize(1000)
.setKeepAlive(true)
.setMaxConnectionsPerHost(4)
.setGroupWaitingInterval(3.0)
.build()
화면 추적
네이티브 화면별 로딩 시간을 수집하려면 다음 네 가지 중 하나를 사용하세요.
방법 1. 자동 추적 플래그 내리기. 이 방법을 사용하면 모든 UIViewController를 자동으로 추적합니다. build()를 호출하기 전에 설정하세요.
// 방법 1) build() 전에 플래그를 내린다. 모든 UIViewController 를 자동 추적
WhatapIOSAgent.disableAutoScreenTracking = false
let agent = WhatapAgentBuilder()
// ...
.build()
agent.initialize()
// 방법 2) 특정 화면만 등록한다 (플래그를 내리지 않아도 동작)
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
WhatapIOSAgent.trackViewController(self)
}
WhatapIOSAgent.disableAutoScreenTracking = NO; // 방법 1, build 호출 전
[WhatapIOSAgent trackViewController:self]; // 방법 2
방법 3. SwiftUI modifier. SwiftUI 화면에서는 전용 modifier를 사용하세요. trackViewController(_:)는 전달받은 인스턴스의 클래스에 swizzling을 적용하는 API입니다. 따라서 UIHostingController를 즉석에서 만들어 전달하면 의도한 화면이 잡히지 않을 수 있습니다.
struct CheckoutView: View {
var body: some View {
ContentView()
.whatapScreenTracking("결제 화면") // onAppear / onDisappear 자동 연결
}
}
직접 제어하려면 WhatapIOSAgent.trackScreen("결제 화면")과 WhatapIOSAgent.endScreenTracking("결제 화면")을 짝으로 호출합니다.
방법 4. 화면 Task 직접 제어. swizzling을 전혀 사용하지 않는 경우입니다. 모두 인스턴스 메서드입니다.
guard let taskId = WhatapIOSAgent.shared?.startScreenTaskFor(self) else { return }
// 또는 startScreenTaskWithName("결제 화면")
WhatapIOSAgent.shared?.endScreenTaskWithTaskId(taskId)
자동 화면 추적에서 클래스명 대신 다른 이름을 사용하려면 ViewController에서 WhatapScreenNaming 프로토콜을 채택하고 whatapScreenName을 반환하세요. 이 프로토콜은 웹뷰 화면 이름에는 적용되지 않습니다. 웹뷰 화면 이름을 지정하는 방법은 화면 이름 지정을 참조하세요.
세션 유지 시간 갱신
iOS 에이전트 2.7.15 이상에서 사용할 수 있습니다.
세션은 사용자 활동이 있을 때만 유지됩니다. 활동으로 세는 시점은 다음 두 가지입니다.
- 앱이 백그라운드에서 포그라운드로 돌아올 때
- 자동 추적이나
startScreenTaskFor로 새 화면 추적이 시작될 때
데이터 전송은 활동으로 세지 않습니다. 백그라운드 전송만으로 세션이 연장되면 세션 경계가 실제 사용과 어긋나기 때문입니다. 자동 화면 추적을 쓰지 않고 화면 전환을 직접 관리하는 앱은 화면이 바뀌는 시점에 다음 API를 호출하세요.
WhatapIOSAgent.shared?.notifyUserActivity()
호출하지 않으면 앱을 백그라운드로 보내지 않은 채 15분 넘게 연속으로 사용할 때, 다음 활동 시점에 세션이 새로 발급될 수 있습니다. setSingleSessionPerLaunch(true)를 쓰면 세션 만료가 없으므로 호출하지 않아도 됩니다.
수동 연동
앱 시작 성능 수집
Cold start, Warm start, pre-main 같은 앱 시작 성능은 자동으로 수집되지 않습니다. initialize()를 호출한 뒤 StartupTimer를 직접 호출하세요.
WhatapIOSAgent.initialize()
StartupTimer.appDidLaunch()
StartupTimer.appDidFinishLaunching()
appDidLaunch()는 앱 프로세스가 뜬 시점을, appDidFinishLaunching()은 첫 화면을 그릴 준비가 끝난 시점을 기록합니다. 두 호출 사이의 구간이 시작 성능으로 집계됩니다.
Swift 전용입니다. Objective-C에서는 StartupTimer를 호출할 수 없습니다. Objective-C로 작성한 앱은 앱 시작 성능을 수집하지 못합니다.
수동 Task API
결제나 이미지 로딩처럼 한 화면 안의 비동기 작업을 별도 span으로 기록할 수 있습니다.
WhatapIOSAgent.startTask("checkout", taskId: "task-001")
WhatapIOSAgent.endTask("task-001")
Method Tracing
Method Tracing은 opt-in 기능입니다. Builder에 enableMethodTracing(true)를 추가한 뒤 필요한 메서드의 성능을 기록하세요.
methodStart와 methodEnd
func validateBiometric(
agent: WhatapIOSAgent,
validate: () throws -> Void
) rethrows {
agent.methodStart(className: "AuthService", methodName: "validateBiometric")
defer {
agent.methodEnd(className: "AuthService", methodName: "validateBiometric")
}
try validate()
}
StackSpan
func charge(
agent: WhatapIOSAgent,
operation: () async throws -> Void
) async rethrows {
let stackSpan = agent.start(className: "PaymentService", methodName: "charge")
do {
try await operation()
stackSpan.end()
} catch {
stackSpan.endWithError(error)
throw error
}
}
전역 컨텍스트
ExtrasStore의 값은 모든 로그와 span에 자동으로 붙습니다.
import WhatapAgent
ExtrasStore.shared.setExtra(key: "user_id", value: userId)
ExtrasStore.shared.removeExtra(key: "user_id")
ExtrasStore.shared.clearExtras()
[[ExtrasStore shared] setStringExtra:userId forKey:@"user_id"];
[[ExtrasStore shared] setNumberExtra:@(3) forKey:@"retry_count"];
ExtrasStore에 저장한 키는 Android 호환을 위해 전송할 때 자동으로 .c suffix가 붙습니다. user_id는 user_id.c로 전송됩니다.