@reopt-ai/data-adapter-apps-in-toss
v0.2.2
Published
Apps-in-Toss WebView analytics adapter for reopt-data
Readme
@reopt-ai/data-adapter-apps-in-toss
앱인토스 WebView에서 명시적으로 기록하는 행동 이벤트를 토스 Analytics와
reopt-data에 함께 전달합니다. 기존 Analytics.log() envelope를 받는 log() hook과
새 호출부를 위한 track()·screen()·click()·impression()을 제공합니다.
설치와 초기화
pnpm add @reopt-ai/data-adapter-apps-in-toss @reopt-ai/data-sdk-clientimport { Analytics } from "@apps-in-toss/web-framework";
import { init } from "@reopt-ai/data-sdk-client";
import { createAppsInTossAnalytics } from "@reopt-ai/data-adapter-apps-in-toss";
// 브라우저 부트스트랩에서 한 번 생성합니다. 값은 호스트 앱이 제공합니다.
const reopt = init({
writeKey: publicWriteKey,
// 배포 origin 또는 BFF 프록시의 절대 URL. SDK는 /api/track을 뒤에 붙입니다.
baseUrl: ingestBaseUrl,
capture: { pageview: false, pageleave: false, scrollDepth: false, exceptions: false },
consent: { defaultConsent: analyticsAllowed },
});
const analytics = createAppsInTossAnalytics({
reopt,
toss: { enabled: isInToss, send: (event) => Analytics.log(event) },
tossConsent: tossAnalyticsAllowed,
observe: (notice) => {
// 전송 실패·SDK queue 거절을 운영 계측에 연결할 수 있습니다.
// 이 callback은 별도 로거/메트릭 큐에 연결합니다. 같은 analytics로 다시 기록하지 않습니다.
},
});
analytics.track("playback_complete", { media_kind: "generated" });
analytics.screen("result_open", { source: "library" });
analytics.click("purchase_button_click", { product_id: "ticket_3" });토스 SDK를 import하거나 초기화하지 않고 send를 주입받습니다. 따라서 어댑터가
네이티브 브리지를 중복 로드하지 않으며, 토스 SDK는 소비 앱이 설치·버전 관리합니다.
실제 검증 대상은 @apps-in-toss/[email protected]의 Analytics.log() envelope입니다.
React Native 네이티브 저장소·전송·생명주기 지원을 의미하지 않습니다.
공식 로그 가이드와의 관계
앱인토스 로그 가이드를 기준으로 명시적 행동 로그를 다룹니다. SDK 3.2.0의 helper 구현과 타입도 대조했습니다.
| 호출 | 토스 envelope | Reopt 기본 매핑 |
| ------------------------- | ----------------------------------------------- | ------------------------ |
| track(name, props) | log_type: "event", props 유지 | 같은 name과 props |
| screen(name, props) | log_type: "screen", props 유지 | 같은 name과 props |
| click(name, props) | log_type: "event", event_type: "click" | 같은 name과 정제된 props |
| impression(name, props) | log_type: "event", event_type: "impression" | 같은 name과 정제된 props |
| log(envelope) | 전달받은 screen/event envelope 유지 | log_name과 params 유지 |
click·impression은 props의 event_type보다 호출 종류가 우선합니다. log()는
기존 CM송 카탈로그처럼 이미 정제된 종류를 바꾸지 않습니다. native Analytics.log의
debug/info/warn/error/popup 등 모든 타입을 지원하는 범용 로거는 아닙니다.
토스는 페이지 이동을 자동 기록하지만, 이 어댑터는 SDK를 가로채지 않으므로 그 자동 로그를
Reopt에 복제하지 않습니다. Reopt의 자동 캡처를 끄는 것도 토스 자동 로그를 끄는 설정이 아닙니다.
Reopt 화면 퍼널은 호스트의 의미 있는 화면 전환에서 screen() 또는 기존 screen_view
카탈로그를 한 경로로 호출해 구성합니다. 같은 전환에서 둘을 함께 호출하지 마세요.
URL이 바뀌지 않는 상태 기반 화면 전환은 호스트가 직접 기록해야 합니다.
어댑터는 Analytics.log()만 호출하도록 연결합니다. native click·impression·screen
helper가 더하는 검색 파라미터·referrer·배포 정보·문서 제목 등을 재현하지 않습니다.
허용한 배포 버전 등의 속성이 필요하면 호스트에서 명시적으로 넣습니다. SDK/native가
자체적으로 추가·정규화하는 속성 때문에 양쪽 최종 저장 payload가 동일하다고 보장하지 않습니다.
native helper와 어댑터 helper를 같은 행동에서 동시에 호출하면 토스에 중복 전송됩니다.
노출 시점에 한 번 기록하기
impression()은 전송 함수입니다. 요소가 렌더됐다고 자동 호출하지 않습니다.
가이드 예시처럼 실제 화면 노출을 관측하고, 아래 예시는 요소의 10% 이상이 처음 보일 때만
기록합니다. 10%는 예시 정책이며 제품의 노출 정의에 맞춰 조정하세요.
function observeOffer(element: Element, itemId: string): () => void {
if (typeof IntersectionObserver === "undefined") return () => {};
let stopped = false;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.some(
(entry) => entry.target === element && entry.isIntersecting && entry.intersectionRatio >= 0.1
);
if (stopped || !visible) return;
stopped = true;
observer.disconnect();
analytics.impression("ticket_offer_impression", { item_id: itemId });
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => {
stopped = true;
observer.disconnect();
};
}DOM에 연결된 요소에 적용하고 화면 이탈/unmount 때 반환된 정리 함수를 호출하세요. 이 예시는 관측 인스턴스당 한 번이며, remount·새 방문까지 중복을 막지는 않습니다. 방문당 한 번이 필요하면 호스트 카탈로그의 once key와 결합합니다. IntersectionObserver는 기하학적 교차를 보므로 실제 주목 시간이나 다른 요소에 가려졌는지까지 보장하지 않습니다.
기존 이벤트 hook 연결
const existing = createAnalytics({
send: analytics.log,
enabled: () => typeof window !== "undefined",
storage: () => window.sessionStorage,
});log({ log_name, log_type, params })는 정제된 토스 envelope를 그대로 받습니다.
reopt-data에는 log_name을 이벤트 이름으로, params를 properties로 전달합니다.
screen()도 지정한 이름의 track 이벤트 하나를 기록합니다. 자동 $pageview나
$screen_view를 추가하지 않습니다. 종류별 분석이 필요하면 공통 계약의
event_type 속성을 전달하세요. 기존 화면 로깅과 SDK 자동 캡처의 중복은 소비 앱에서 끕니다.
이벤트 카탈로그·허용 속성·업무 중복 제거는 hook 앞의 소비 앱이 담당합니다. 어댑터는 비어 있는 이름·200자를 넘는 이름·잘못된 log type·비정상 숫자·중첩 속성을 양쪽 전송 전에 거절합니다. 문자열 내용이 개인정보인지는 자동 판정하지 않습니다. 서버 인증 토큰, 원본 userKey, 주문 ID, 자유 입력 문장을 공통 params에 넣지 마세요.
토스와 reopt-data에는 각각 별도 속성 사본을 전달합니다. 한쪽의 throw·Promise 거절이나 토스 지원 여부 판정 실패가 다른 수신처를 막지 않습니다. native Promise가 끝나지 않아도 호출부는 기다리지 않습니다. 관측 callback의 throw·거절도 격리합니다.
앱별 변환과 필터
토스의 기존 이벤트 이름을 유지하면서 reopt-data 이름을 표준화하거나, 프로젝트 공통 속성·screen/event 종류를 추가할 수 있습니다.
const analytics = createAppsInTossAnalytics({
reopt,
toss: { enabled: isInToss, send: (event) => Analytics.log(event) },
mapReoptEvent: (event) => {
if (event.params.local_only === true) return null;
return {
name: event.log_name.replace(/^cm_song_/, ""),
properties: {
...event.params,
app_id: "ai-cm-song",
environment: "production",
event_type: event.params.event_type ?? event.log_type,
},
};
},
});mapReoptEvent는 동기 순수 함수입니다. 입력은 복사·동결된 envelope이며 출력도 검증 후
복사합니다. null은 Reopt만 생략합니다. throw·비정상 반환값도 Reopt만 중단하며,
토스는 원래 envelope를 받습니다. mapper에서 네트워크 호출이나 analytics.track()을 하지 않습니다.
아직 비동기 enrichment·임의 수신처 plugin registry·중첩 properties는 지원하지 않습니다.
이는 실제 사용 사례와 전달 계약이 정해졌을 때 별도로 확장할 영역입니다.
식별·동의·생명주기
// 인증한 BFF가 발급한 분석용 프로필 ID. 토스 쪽에는 전달되지 않습니다.
analytics.identify(session.analyticsProfileId);
// BFF의 업무 요청에 분석 컨텍스트로만 전달합니다. 인증 수단이 아닙니다.
const deviceId = analytics.getDeviceId();
analytics.setConsent("reopt", false);
analytics.setConsent("toss", false);
// 로그아웃 또는 다른 계정으로 전환하기 전에 실행합니다.
analytics.reset();- SDK가 익명 device, profile 연결, 배치, 재시도와
eventId를 소유합니다. 어댑터는 별도 ID·큐를 만들지 않습니다. SDK의 네트워크 재시도는 같은 ID를 유지하지만,log()를 다시 호출하면 새 이벤트입니다. 수신처 사이에서 동일한 eventId를 보장하지 않습니다. getDeviceId()는 SDK 비활성화·analytics 동의 거부·조회 실패 시null입니다. 동의가 없으면identify()도 프로필을 변경하지 않습니다. 동의 허용 후 로그인 상태라면 호스트가identify()를 다시 호출해야 합니다.reset()은 Reopt SDK의 프로필과 기기를 초기화하고 대기 큐를 비웁니다. 전 계정의 미전송 이벤트를 새 계정으로 보내지 않기 위한 동작입니다. 토스의 자체 식별은 변경하지 않습니다.identify()는 익명→로그인에서 기존 device를 유지하고, 현재와 다른 로그인 프로필로 전환할 때는 자동reset()후 연결합니다. 이전 계정의 미전송 이벤트와 SDK 공통 속성도 지워집니다. 필요한 이전 이벤트를 보낼 계획이면 계정 전환 전에 호스트가 flush해야 합니다. 중요한 결제 결과의 보존은 브라우저 큐가 아닌 서버 Outbox로 보장합니다.- 프로필 ID는 SDK 계약에 맞춰 1~500자로 검사합니다. 큐 거절·예외로 identify가 실패하면 SDK가 먼저 변경한 내부 프로필을 복구합니다. 계정 전환의 reset 자체를 되돌리지는 않습니다.
- consent는 수신처별입니다. 토스 consent 기본값은
true이며 호스트의 실제 정책에 맞춰 초기값을 명시하세요. reopt-data 동의는 SDK 설정이 소유합니다. - 호스트가 SDK 인스턴스를 소유하고 필요할 때
reopt.flush()/reopt.close()를 호출합니다. React 컴포넌트 remount마다 인스턴스를 새로 만들거나 close하지 마세요. observe의 Reoptqueued는 SDK 큐 적재만, Tosscompleted는 native 호출 완료만 뜻합니다. 영구 저장 확인이 아닙니다. 서버 수집 결과는 SDKflush()와 SDK 관측 기능으로 확인합니다. 어댑터는 원본 예외나 이벤트 속성을 관측 callback에 전달하지 않습니다. 큐 결과도 ID·적재 여부·거절 사유만 복사하고 SDK 검증 메시지는 제외합니다.- 동기 재진입(전송/mapper/observer callback에서 같은 어댑터로 다시 log 호출)은 무시합니다. 비동기 observer가 완료되지 않은 동안 추가 관측 알림은 생략하지만 이벤트 전송은 계속합니다. 정확한 알림 계수가 필요하면 observer는 동기로 별도 큐에 기록하고 즉시 반환해야 합니다. 비동기 callback에서 같은 어댑터로 이벤트를 다시 기록하는 구성은 지원하지 않습니다.
- 앱/환경마다 별도 SDK client와 프로젝트 키를 사용합니다. 같은 SDK 인스턴스를 여러
어댑터가 공유하면 식별·동의·reset도 공유됩니다. SDK의
register()공통 속성 역시 최종 Reopt payload에 합쳐지므로 호스트에서 정제해야 합니다.
서버와의 경계
브라우저용 공개 writeKey만 사용합니다. clientSecret·조회 권한 키·mTLS 인증서는 서버에 둡니다. BFF 프록시를 사용할 때는 대상 origin과 수집 경로를 고정하고, 실제 토스 WebView origin에 대한 CORS, OPTIONS, 요청 크기·유입량 제한을 구현해야 합니다. 이 패키지는 프록시를 제공하지 않습니다.
구매 확정·환불·백그라운드 생성 완료는 서버 SDK와 업무 DB Outbox로 별도 수집합니다. 사용자 삭제, 이벤트 카탈로그, 관리자 조회 API도 호스트/서버의 책임입니다. 이 패키지 설치만으로 해당 서버 연동이나 운영 프로젝트가 설정되지는 않습니다.
개발·검증
pnpm exec turbo run build check-types lint test --filter=@reopt-ai/data-adapter-apps-in-toss
pnpm --filter @reopt-ai/data-adapter-apps-in-toss check:package실제 브라우저 SDK와 테스트 전송 함수를 이용해 익명→identify→전송, 동의 거부, reset 후 프로필 분리까지 검사합니다. 실제 토스 앱의 native 전송·운영 수집 서버 저장은 별도 실기기/통합 검증 대상입니다.
출시 후 검증 순서
- SDK 버전을 확인합니다. 가이드는 0.0.26 이상을 요구하며 현재 호환성 확인 대상은 3.2.0입니다.
- 로컬/샌드박스에서는 hook 호출·payload·종류·실패 격리를 검사합니다. SDK는 샌드박스에서 로그를 전송하지 않을 수 있으므로 native Promise 완료를 콘솔 집계 성공으로 해석하지 않습니다.
- 실제 출시 버전에서 클릭 1회, 기준 이상 노출 1회, 화면 전환 1회를 실행합니다. 같은 동작을 native helper와 어댑터가 각각 보내지 않는지 확인합니다. Reopt는 실제 ingest 저장까지 확인합니다.
- 가이드상 토스 콘솔 데이터는 샌드박스/출시 준비 단계에 제공되지 않고 출시 다음 날부터 확인 가능합니다. 콘솔의 분석 > 이벤트에서 log_name과 params, click/impression 구분을 확인합니다.
- 숫자는 각 시스템의 자동 수집·정규화·반영 지연 차이를 고려해 비교합니다. 두 시스템의 전체 페이지뷰 수가 일치해야 한다는 기준으로 검증하지 않습니다.
Reopt 재시도는 SDK 큐가 담당합니다. 어댑터가 토스 전송을 재시도하지는 않습니다. native 호출 결과만으로 저장 여부를 확정할 수 없어 무조건 재전송하면 중복될 수 있습니다. 토스까지 durable delivery 또는 exactly-once를 보장하는 모듈은 아닙니다.
로컬 소비 앱 검증은 pnpm pack --pack-destination <임시 디렉터리>로 만든 tarball을
격리된 fixture에 설치해 수행합니다. 운영 의존성은 발행된 버전으로 고정하고, 인접 저장소
소스 경로를 프로덕션 빌드에 연결하지 않습니다.
Installation-based runtime connection
The optional @reopt-ai/data-adapter-apps-in-toss/connection entry initializes the
browser SDK after fetching public configuration from your backend. The core entry
remains dependency-injected and has no connection timers.
import { connectAppsInTossAnalytics } from "@reopt-ai/data-adapter-apps-in-toss/connection";
const analytics = connectAppsInTossAnalytics({
appId: "my-miniapp",
environment: "production", // must match the backend configuration
consent: visitorAnalyticsConsent,
toss: { enabled: isInToss, send: (event) => Analytics.log(event) },
allowedIngestOrigins: ["https://data.example.com"],
loadConfig: async (signal) => {
const response = await fetch("/api/v1/my-miniapp/analytics/config", {
signal,
cache: "no-store",
credentials: "omit",
});
if (!response.ok) throw new Error("Configuration unavailable");
return response.json();
},
});
analytics.track("app_open"); // Toss is available immediately.
const connected = await analytics.ready; // false on disabled, invalid config or timeoutConfiguration v1 is { version: 1, appId, environment, enabled, bindingId, revision,
writeKey, baseUrl }. The last four fields are required when enabled. No installation
or server secret belongs in this response. The host pins allowed ingestion origins.
Default startup bounds: 5 seconds, 100 events in memory (configurable up to 30 seconds
and 1000 events). Only consented events are buffered. Reset, profile changes and consent
withdrawal discard pending startup events. Buffered events include
integration_buffered_at; the SDK's timestamp is assigned when it enqueues them.
Startup buffering does not claim a durable queue acknowledgement.
Each instance uses one binding snapshot. Reload the miniapp to adopt changed settings.
A new revision uses a separate queue namespace; old queues are never rerouted. The
provider immediately revokes the old write key on switch, rotation or disconnection,
so unsent old events can be rejected. dispose() releases the SDK and connection work.
The connection entry disables automatic pageview, pageleave, scroll and exception
capture. Anonymous device identity persists in localStorage under the binding's write
key, alongside its revision-scoped offline queue, so reloads retain the original device
when replaying events. Reset and consent withdrawal discard queued events and reset
identity; starting with consent denied also discards any restored queue. Storage falls
back to memory when localStorage is unavailable. Legacy queues from the memory-identity
connection use a different namespace and are not replayed because their original device
cannot be recovered. The browser SDK still supplies its
standard browser context (including path, referrer and UTM); disabling automatic events
does not remove that context. Supply no raw Toss user keys or tokens. Installation
approval is separate from visitor consent; update setConsent('reopt', allowed) when
the host's consent changes.
Since 0.2.0, the host must supply its expected environment. Runtime schemas are owned by @reopt-ai/data-contract/integration; app, environment and allowed origin must all match. See the shared contract.
