# 앱에 측정 넣기

React Native·Expo 앱이나 웹뷰 앱(Capacitor 등)에 allob 앱 SDK 를 넣는 방법이에요. 기기 ID, 동의, 세션, 전송은 SDK 가 처리해요. 앱은 값을 저장할 곳, 앱이 화면에 떠 있는지 알려 주는 신호, 네트워크 전송 방법 세 가지만 SDK 에 넘겨주면 돼요. 앱 버전도 넘기면 릴리스를 버전별로 비교할 수 있어요.

## 에이전트에게 맡기기

:::prompt
https://allob.app/docs/app.md 를 읽고 이 앱에 allob 앱 SDK 를 넣어 줘.
1. allob schema pull 로 스키마를 받고, 앱 이벤트에 "surface": "app" 이 있는지 확인해. 없으면 추가해서 allob schema push 해.
2. allob.json 의 "sdk" 에 app 경로가 앱 코드 폴더 안(예: "app/src/allob")을 가리키는지 확인하고, 없거나 다르면 고쳐.
3. allob sdk pull 로 app.ts 를 받아.
4. 앱이 켜질 때 buildApp() 으로 만들고 start() 와 askCountry() 를 불러. buildApp 에 앱 버전(version)을 넘겨 — Expo 면 expo-application(OTA 를 쓰면 expo-updates 도)을 설치하고 version: expoVersion(Application, Updates) 로.
5. 앱 열기·화면 이동·핵심 행동에서 이벤트를 보내.
6. 결제 직전에 buyerSid() 값을 결제 서비스에 넘겨서 서버가 결제를 이 기기에 연결할 수 있게 해. storePurchase 를 부르면 시험 결제에는 sandbox: true 를 넣어.
7. 개발 빌드는 이벤트를 보내지 않아. 개발 빌드에서 확인하려면 sendInDev: true 로 잠깐 켜고, 끝나면 그 기기를 빼(https://allob.app/docs/app.md 의 "내 기기 빼기").
끝나면 바꾼 파일과 각 이벤트를 보내는 위치를 알려 줘.
:::

## 앱 SDK 받기

앱 SDK 는 `allob.json` 의 `"sdk"` 에 적은 `app` 폴더에 들어가요. `allob folder init` 은 폴더를 보고 대상을 골라요. `package.json` 에 Expo·React Native 가 있으면 `"sdk"` 에 `"app": "allob"` 이 들어가요. 폴더에서 알 수 있는 게 없을 때만 제품 플랫폼으로 정해요(iOS·Android·macOS 가 있으면 `app`). 스키마에 앱 이벤트가 아직 없으면 앱 SDK 는 이번엔 받지 않고, 이벤트를 추가한 뒤 `allob sdk pull` 로 받아요. 웹과 앱이 한 저장소에 있으면 폴더마다 적어요.

```json
"sdk": { "web": "web/src/allob", "app": "app/src/allob" }
```

```sh
allob sdk pull
```

| 파일 | 내용 |
|---|---|
| `allob/app.ts` | `buildApp()` 함수와 수집 주소(`ALLOB_APP`) |
| `allob/analytics.ts` | 이벤트별 함수와 타입 |
| `allob/sdk/` | 앱 SDK 본체(기기 ID, 동의, 세션, 전송) |

아래 예시의 `./allob/` 은 이 폴더를 말해요. 앱은 내 서버를 거치지 않고 allob 으로 바로 보내요. 이미 내 서버로 보내던 옛 앱 버전이 있다면 [이미 설치된 옛 앱 버전](#이미-설치된-옛-앱-버전)을 봐요.

## 앱 이벤트 정의하기

스키마에 앱 이벤트가 있어야 앱 SDK 를 받을 수 있어요. 앱 이벤트가 없으면 `allob sdk pull` 이 앱 이벤트를 먼저 추가하라고 알려 줘요. 이벤트마다 `"surface": "app"` 을 적거나, 제품 전체가 앱이면 `tracking.surface` 를 `"app"` 으로 둬요. 제품 플랫폼에 앱(iOS·Android·macOS)을 고르고 `allob folder init` 으로 시작했다면 첫 스키마에 앱 열기 이벤트 `app_open` 이 들어 있어요. 앱만 골랐다면 `tracking.surface` 도 `"app"` 이고, 웹과 앱을 함께 골랐다면 `tracking.surface` 는 `"web"` 이고 `app_open` 에만 `"surface": "app"` 이 붙어요.

```json
"events": {
  "app_open": { "label": "앱 열기", "surface": "app", "props": { "how": { "enum": ["cold", "resume"] } }, "order": ["how"] },
  "screen_view": { "label": "화면 보기", "surface": "app", "props": { "screen": { "enum": ["home", "settings"] } }, "order": ["screen"] },
  "entry_write": { "label": "일기 쓰기", "surface": "app" }
}
```

## React Native·Expo

```ts
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Application from "expo-application";
import * as Updates from "expo-updates";
import { buildApp, ALLOB_APP, appStateLifecycle, expoVersion, fetchNetwork } from "./allob/app.ts";

const storage = {
  get: (k: string) => AsyncStorage.getItem(k),
  set: (k: string, v: string) => AsyncStorage.setItem(k, v),
  delete: (k: string) => AsyncStorage.removeItem(k),
};

export const allob = buildApp({
  network: fetchNetwork(ALLOB_APP.endpoint),
  lifecycle: appStateLifecycle(AppState),
  storage,
  prompt: true,          // 앱에 동의 창이 있으면 true
  version: expoVersion(Application, Updates),   // 앱 버전·빌드 번호·OTA 업데이트 ID
});

await allob.start();
await allob.askCountry(ALLOB_APP.countryEndpoint);   // 실패하면 false — 앱이 다시 열릴 때 재시도
allob.track.entryWrite();
```

`ALLOB_APP.endpoint` 에는 수집 키가 들어 있어요. 수집 키는 공개 값이라 앱에 들어가도 괜찮아요.

## 앱 버전

`version` 을 넘기면 SDK 가 모든 이벤트에 앱 버전(`1.2.0`), 빌드 번호(`34`), OTA 업데이트 ID 를 실어 보내요. allob 은 이 값으로 릴리스를 그 버전을 쓰는 기기와 이어서, 같은 기간에 새 버전 기기와 옛 버전 기기를 나눠 비교하고 업데이트가 얼마나 퍼졌는지 보여 줘요([전후 비교](/docs/metrics#앱-릴리스는-버전별로-비교해요)). 넘기지 않으면 버전별 비교를 할 수 없어요(개발 빌드에서 경고가 떠요).

| 앱 | 넘기는 값 |
|---|---|
| Expo | `npx expo install expo-application`(OTA 를 쓰면 `expo-updates` 도) 후 `version: expoVersion(Application, Updates)`. OTA 를 안 쓰면 `expoVersion(Application)` |
| React Native(Expo 없이) | `version: { version: DeviceInfo.getVersion(), build: DeviceInfo.getBuildNumber() }`(react-native-device-info) |
| Capacitor | `version: () => App.getInfo()`(`@capacitor/app` — 버전과 빌드 번호를 줘요) |

- OTA 업데이트 ID 는 OTA 로 받은 코드로 돌 때만 실어요. 앱에 들어 있던 코드로 돌면 비워요.
- 버전은 `start()` 에서 한 번 읽어요. 함수를 넘기면 비동기여도 돼요.
- 웹 이벤트와 서버 이벤트(결제 웹훅 등)에는 버전이 없어요. 서버 이벤트는 같은 기기의 앱 이벤트로 그때 쓰던 버전을 찾아요.

개발 빌드(`__DEV__` — 시뮬레이터, Expo 개발 빌드)에서는 이벤트를 보내지 않아요. 설치를 확인하려고 개발 빌드에서 보내 보고 싶으면 `buildApp({ ..., sendInDev: true })` 로 켜요. 이때 보낸 이벤트는 지표에 쌓이니, 확인이 끝나면 그 기기를 빼 주세요([내 기기 빼기](#내-기기-빼기)).

## 웹뷰 앱

Capacitor 나 네이티브 앱 안의 웹 화면에서는 웹뷰용 연결을 써요. 웹 화면을 그대로 담아 웹 SDK 로 측정하는 앱이라면, 광고·분석 도구만 앱으로 보내는 [앱 안 웹 화면에서 도구까지](/docs/web#앱-안-웹-화면capacitor에서-도구까지)도 있어요. 화면이 가려지면 백그라운드로, 페이지가 닫히면 남은 이벤트를 `sendBeacon` 으로 보내요.

```ts
import { App } from "@capacitor/app";
import { buildApp, ALLOB_APP, webLifecycle, webNetwork, webStorage } from "./allob/app.ts";

export const allob = buildApp({
  network: webNetwork(ALLOB_APP.endpoint),
  lifecycle: webLifecycle(),
  storage: webStorage(),
  prompt: false,
  version: () => App.getInfo(),   // 앱 버전·빌드 번호
});
```

## 동의

앱 SDK 는 동의 여부가 정해지기 전에는 아무것도 보내지 않아요. 그동안 생긴 이벤트는 메모리에 최대 20개까지 보관했다가 정해지면 보내요.

1. `askCountry()` 가 allob 에 이 기기의 국가를 물어요. 스키마 `tracking.consent` 에서 `opt-in` 으로 정한 지역이면 동의가 필요해요.
2. 동의 창이 있는 앱(`prompt: true`)은 `allob.consent.decision().ask` 가 `true` 일 때 창을 띄우고, 사용자의 선택을 `allob.consent.choose("1")`(허용) 또는 `allob.consent.choose("0")`(거부)으로 넣어요.
3. 동의가 필요 없는 지역이면 바로 측정을 시작해요.

`allob.consent.subscribe()` 로 상태가 바뀔 때마다 화면을 다시 그릴 수 있어요.

## 세션과 화면

```ts
const session = allob.session({
  onOpen: ({ start, first }) => allob.track.appOpen({ how: start }),   // start: cold(새로 실행) · resume(백그라운드에서 복귀), first: 이 기기 첫 실행
  screenOf: (route) => (route.startsWith("/settings") ? "settings" : "home"),
  onScreen: (screen) => allob.track.screenView({ screen }),            // 세션마다 화면 종류별로 한 번
});
session.install();
// 화면이 바뀔 때마다
session.route(currentPath);
```

앱이 백그라운드에 오래 있다가 돌아오면 새 세션으로 쳐요. 기준 시간은 스키마 `tracking.session` 에서 정해요.

## 결제

스토어 결제는 결제 확인을 받는 서버(RevenueCat 웹훅 등)가 기록해요. 앱은 결제 직전에 기기 ID 를 결제 서비스에 넘겨 두기만 해요.

```ts
const sid = await allob.buyerSid();   // 측정하지 않는 기기면 null
if (sid) await Purchases.setAttributes({ allob_sid: sid });
```

서버에서 이 값을 서버 이벤트의 `sid` 로 넣으면 결제가 이 기기에 이어져요. [결제 기록하기](/docs/payments)를 참고하세요. 광고 도구에도 결제를 알리려면 `allob.storePurchase({ productId, transactionId, price, currency })` 를 불러요.

시험 결제(TestFlight·샌드박스·Play 테스트 트랙)는 `sandbox: true` 를 넣어 주세요. 광고 도구로 보내지 않아요. RevenueCat 이면 결제 정보의 환경이 `PRODUCTION` 이 아닐 때, StoreKit 2 면 `transaction.environment` 가 `.production` 이 아닐 때예요.

```ts
allob.storePurchase({ productId, transactionId, price, currency, sandbox: environment !== "PRODUCTION" });
```

## 광고·분석 도구(Google 애널리틱스·Meta) 붙이기

앱 설치 광고의 성과를 재려면 앱 안에 Google 애널리틱스(Firebase)와 Meta 앱 이벤트가 있어야 해요. allob 이 이 둘과 앱 추적 허용(ATT) 창, 스토어 결제 넘기기까지 하는 앱 측정 모듈(`allob-measurement`)을 줘요. 동의를 받기 전에는 아무것도 켜지 않고, 앱 SDK 가 켜고 끄는 때를 정해요.

1. 스키마에 `apps.measurement` 를 적어요([스키마 레퍼런스](/docs/schema#apps-필드)).

   ```json
   "apps": {
     "measurement": {
       "google": true,
       "meta": { "appId": "1234567890", "clientTokenEnv": "EXPO_PUBLIC_META_CLIENT_TOKEN" },
       "att": { "text": { "en": "Used to measure which ads brought you here.", "ko": "어떤 광고를 보고 왔는지 측정하는 데 쓰여요." } }
     }
   }
   ```

2. `allob.json` 의 `"sdk"` 에 모듈 폴더를 적어요. 패키지 폴더라서 다른 대상과 같은 폴더에 둘 수 없고, 앱 폴더(`allob.json` 이 있는 폴더) 자체도 안 돼요. 앱의 `package.json`·`ios/`·`android/` 와 겹치기 때문이에요. 새 폴더를 적어요. 앱의 이 파일들이 이미 있는 폴더를 적으면 `--force` 를 붙여도 받지 않아요.

   ```json
   "sdk": { "app": "src/allob", "native": "modules/allob-measurement" }
   ```

3. `allob schema push` 로 올리고 `allob sdk pull` 로 받아요.
4. 앱에 붙여요.
   - Expo: `app.config` 의 `plugins` 에 `"./modules/allob-measurement/plugin"` 을 넣고(옵션 없이) `npx expo prebuild` 를 실행해요. Firebase 를 켜면 `expo-build-properties` 에 `ios.extraPods: [{ "name": "GoogleUtilities", "modular_headers": true }]` 도 필요해요.
   - Capacitor: `pnpm add ./modules/allob-measurement` 뒤 `npx cap sync` 를 실행해요.
   - Google 은 앱 폴더에 `GoogleService-Info.plist`·`google-services.json` 이 있어야 켜져요. Meta 클라이언트 토큰은 스키마가 아니라 빌드 환경 변수(`clientTokenEnv` 에 적은 이름)에 넣어요.
5. 앱 SDK 에 도구로 넘겨요.

   ```ts
   import { expoTools } from "./modules/allob-measurement";

   const tools = expoTools({ metaClientToken: process.env.EXPO_PUBLIC_META_CLIENT_TOKEN });
   export const allob = buildApp({ ..., tools, excluded: tools.isTestLabDevice() });
   ```

   Capacitor 면 `import { capacitorTools } from "allob-measurement/capacitor"` 를 쓰고 `capacitorTools(registerPlugin, { metaClientToken })` 로 만들어요(`isTestLabDevice()` 는 `await` 해요).

- 도구를 켜면 iOS 는 앱이 앞에 있을 때 앱 추적 허용 창을 한 번 띄워요. "허용"일 때만 광고 식별자를 Google·Meta 로 보내요.
- 사용자가 거부하면 도구가 기기에 모아 둔 것도 지워요.
- 이벤트마다 어떤 도구 이벤트로 보낼지는 스키마의 `appTools` 줄로 정해요.
- 네이티브 코드가 바뀌므로 OTA 업데이트로는 나가지 않아요. 새로 빌드해서 스토어에 올려요.
- 도구 없이 AdMob 광고만 싣는 앱은 `"apps": { "measurement": { "admob": {} } }` 만 적어요. 그러면 config plugin 만 받아서 SKAdNetwork 목록과 미디에이션 어댑터만 붙고, Firebase·Meta 는 앱에 들어가지 않아요.

## 이미 설치된 옛 앱 버전

새 앱 빌드는 allob 으로 바로 보내요. 그런데 예전 버전이 내 서버의 `/api/e`·`/api/country` 로 보내도록 만들어져 이미 설치돼 있다면, 그 버전이 사라질 때까지 내 서버가 allob 으로 넘겨 줘야 해요.

1. `allob.json` 에 `"proxy": "/api/e"` 를 넣고, `"sdk"` 에 `server` 폴더가 있게 해요.
2. `allob sdk pull` 로 받으면 `server.ts` 에 `relayEvents`·`relayCountry` 가 들어 있어요.
3. `allob server-key create --label relay` 로 서버 키를 만들어 서버의 비밀 값 `ALLOB_SERVER_KEY` 에 넣어요. 없으면 allob 이 방문자 대신 내 서버의 IP·국가로 세요.
4. 서버에 두 줄을 넣어요.

Cloudflare Workers 라면 이런 코드예요.

```js
// Cloudflare Worker — only for app builds already installed that still send to your server. New app builds and web pages send straight to allob.
// ALLOB_SERVER_KEY: a Worker secret from `allob server-key create` — without it allob counts these events with your server's IP and country
import { relayCountry, relayEvents } from "./allob/server.ts";

const url = new URL(req.url);
if (url.pathname === "/api/e") return (await relayEvents(req, env, ctx)).response;
if (url.pathname === "/api/country") return relayCountry(req, env);
```

Next.js 라면 이렇게 넣어요.

```ts
// Next.js — the same for app builds already installed: app/api/e/route.ts and app/api/country/route.ts
// ALLOB_SERVER_KEY: an environment secret from `allob server-key create`
import { relayCountry, relayEvents } from "@/allob/server";

export async function POST(req: Request) {
  return (await relayEvents(req, { ALLOB_SERVER_KEY: process.env.ALLOB_SERVER_KEY })).response; // app/api/e/route.ts
}
export async function GET(req: Request) {
  return relayCountry(req, { ALLOB_SERVER_KEY: process.env.ALLOB_SERVER_KEY }); // app/api/country/route.ts
}
```

- 옛 앱의 다시 보내기가 그대로 동작하도록, allob 의 응답 코드(429·413·400)와 기다릴 시간을 앱에 그대로 돌려줘요.
- 웹 페이지는 넘기지 않아요. 웹 SDK 는 늘 allob 으로 바로 보내요.

## 그 밖의 함수

| 함수 | 설명 |
|---|---|
| `setExcluded(true)` | 내 기기나 테스트 기기를 측정에서 빼요. 이벤트를 보내지 않고 도구도 멈춰요. 기기에 기억해서 앱을 다시 켜도 빠져 있어요. `setExcluded(false)` 로 풀어요 |
| `deviceId()` | 이 기기의 allob 기기 ID(없으면 `null`). [내 기기 빼기](#내-기기-빼기)에서 써요 |
| `forgetDevice()` | 로그아웃이나 계정 삭제 때 이 기기의 기록(대기 중인 이벤트, 기기 ID, 도구가 모은 정보)을 지워요 |
| `adoptSid(next)` | 로그인으로 다른 기기 ID 와 같은 사람인 걸 알았을 때 기기 ID 를 바꿔요 |
| `setUserId(id)` | 로그인한 사용자 ID 를 알려 줘요. 제품 안 A/B 그룹을 사용자 ID 로 정해서 다른 기기에서도 같은 그룹이에요. 이벤트에는 ID 를 싣지 않고, [피드백](/docs/feedback)을 보낼 때만 보낸 사람으로 같이 가요. 로그아웃이면 `null`([제품 안 A/B](/docs/schema#제품-안-ab)) |
| `measured()` | 지금 측정 중인지 알려 줘요 |

사용자 의견을 앱 안에서 받으려면 `feedback.send()` 를 써요([앱 안 피드백 받기](/docs/feedback)).

## 내 기기 빼기

내 폰이나 테스트 기기의 사용 기록이 지표에 섞이지 않게 하려면 두 가지를 해요.

1. 앱에서 그 기기를 빼요. 예를 들어 설정 화면의 숨은 메뉴에서 `allob.setExcluded(true)` 를 불러요. 기기에 기억돼서 다음 실행부터도 보내지 않아요.
2. 이미 쌓인 기록까지 빼려면 `await allob.deviceId()` 로 기기 ID 를 확인해서(빼기 전에), allob 화면의 제품 설정 › 데이터 거르기 › **앱 기기 ID** 에 넣어요. allob 이 그 기기의 지난 이벤트를 지표에서 빼고, 그 기기에서 오는 이벤트도 받지 않아요.

자동 테스트 기기(Google Play 출시 전 보고서, Firebase Test Lab)도 같은 방법으로 빼요. 이 기기들은 시스템 설정 `firebase.test.lab` 이 `"true"` 예요. [앱 측정 모듈](#광고분석-도구google-애널리틱스meta-붙이기)의 `isTestLabDevice()` 가 이 값을 읽어 줘요. iOS 는 믿을 만한 신호가 없어서 늘 `false` 예요.

```ts
export const allob = buildApp({ ..., tools, excluded: tools.isTestLabDevice() });   // 이번 실행만 빼요(기억하지 않아요)
```

## 전송 방식

앱은 allob 의 응답 코드(400·413·429)에 맞춰 동작해요. 보내지 못한 이벤트는 기기에 보관했다가 다시 보내고, 서버가 기다리라고 하면 기다려요. 앱이 백그라운드로 가거나 닫힐 때 남은 이벤트를 보내요. allob 이 원격으로 전송을 끄라고 응답하면 allob 으로 보내는 것만 멈춰요.

전송은 allob 화면의 제품 › 설정 › 측정에서 「이벤트 수집」을 끄거나 `allob ingest pause` 로 원격으로 끌 수 있어요. 끄면 1분 안에 allob 이 이 제품의 이벤트를 받지 않고, 앱은 다음 실행부터 allob 으로 보내는 것을 멈추고 모아 둔 이벤트를 지워요. Meta·Google 같은 다른 측정 도구로 보내는 것과 서버 이벤트는 그대로예요. 다시 켜려면 `allob ingest resume` 을 실행해요.
