스마트 앱 배너를 이용한 리타겟팅 캠페인 운영 방법은 무엇인가요? 스마트 앱 배너를 활용한 리타겟팅 캠페인은 자사 웹 브라우징 맥락을 포착하고, 맥락에 맞는 딥링크 CTA가 포함된 동적 HTML 배너를 렌더링하며, 복귀 사용자를 앱 내 일치하는 화면으로 직접 연결함과 동시에 어트리뷰션 토큰을 전달해야 합니다.
스마트 앱 배너를 활용한 웹 방문자 리타겟팅은 모바일 웹사이트에서 자사 브라우징 맥락을 포착하여 개인화된 프로모션 배너를 노출하고, 방문자를 네이티브 앱의 관련 화면으로 바로 연결하는 성장 엔지니어링 전략입니다. 정적인 앱 스토어 링크를 맥락 중심의 딥링크로 대체함으로써, 리타겟팅 배너는 사용자 의도를 보존하고 기존 사용자의 재참여를 유도하며 장기적인 앱 활용도를 높이는 데 기여합니다.
| 용어 | 정의 | 관련 주체 | 검색 의도 |
|---|---|---|---|
| 앱 참여(App Engagement) | 모바일 앱 내에서의 사용자 상호작용 깊이, 빈도 및 지속 시간. | 사용자 유지 | 정보성 / 상업성 |
| 스마트 앱 배너 | 동적 앱 실행 또는 다운로드 CTA를 제공하는 웹 기반 프로모션 요소. | 웹-앱 리다이렉션 | 정보성 |
| 웹-투-앱(Web to App) | 웹 브라우저 방문자를 네이티브 모바일 앱으로 유도하는 아키텍처 프로세스. | 모바일 딥링크 | 정보성 |

웹 방문자 리타겟팅이 앱 참여를 지원하는 방법
모바일 웹 의도의 역설: 높은 브라우징 볼륨 대 낮은 전환율
모바일 웹사이트는 유기적 검색, 유료 캠페인, 소셜 발견 및 콘텐츠 신디케이션을 통해 유입되는 퍼널 상단의 트래픽을 확보하는 방대한 획득 채널입니다. 그러나 모바일 웹 브라우저에서의 소비자 행동은 의도의 역설을 보입니다. 즉, 사용자는 모바일 웹에서 제품을 탐색, 조사, 평가하는 경우가 많지만, 네이티브 모바일 앱은 저장된 로컬 상태와 최적화된 탐색 경로를 통해 더 원활한 거래 환경을 제공합니다.
모바일 브라우저는 재인증 요구사항이나 다단계 웹 폼과 같은 운영상의 마찰 지점을 내포하고 있습니다. 구매 의도가 높은 방문자가 특정 제품 카탈로그를 탐색하거나 모바일 웹 장바구니에 상품을 추가했을 때, 네이티브 앱 환경으로의 직접적인 전환을 제공하지 못하면 장바구니 이탈로 이어져 고객 생애 가치(LTV)가 낮아질 수 있습니다.
“로비 이탈” 극복: 홈페이지로의 재참여 유도가 전환율을 떨어뜨리는 이유
모바일 웹 리타겟팅의 일반적인 문제는 복귀 사용자를 네이티브 앱의 기본 홈 화면으로 유도하는 정적 배너를 사용하는 것입니다. 활동 중이거나 휴면 상태인 앱 사용자가 모바일 웹사이트에서 특정 제품을 탐색할 때, 일반적인 배너를 탭하면 메인 로비로 연결되는 앱 실행이 트리거됩니다.
이러한 단절은 즉각적인 인지적 마찰을 발생시킵니다. 사용자는 직접 카테고리 메뉴를 탐색하거나 검색 쿼리를 실행하거나, 장바구니를 다시 찾아야 합니다. 수동 탐색 단계마다 이탈 위험이 증가합니다. 동적 스마트 앱 배너는 웹 브라우징 맥락을 딥링크 경로와 직접 결합하여 해결하며, 사용자를 관련 제품 보기, 미리 채워진 장바구니 또는 네이티브 앱 내의 프로모션 랜딩 화면으로 안내합니다.
웹 방문자에 대한 운영 마찰 지표로서의 실행 시간(Time-to-Action) 평가
라이프사이클 마케팅에서 사용자의 주의력은 탐색 지연과 함께 빠르게 감소합니다. 운영 지표인 실행 시간(Time-to-Action) (
비맥락적 퍼널에서
맥락 스티칭(Context Stitching)이 정적 웹 배너를 리타겟팅 도구로 바꾸는 방법
자사 웹 맥락 포착 및 저장소 라이프사이클 관리
하드코딩된 복사본을 표시하는 정적 배너와 달리, 동적 리타겟팅 배너는 자사 웹 세션 데이터를 검사하여 메시지를 맞춤화합니다. 사용자가 모바일 웹사이트를 탐색할 때, 클라이언트 측 스크립트는 DOM, URL 쿼리 매개변수 또는 자사 웹 스토리지(sessionStorage 또는 localStorage)에서 세션 상태를 읽습니다:
- 조회한 제품 SKU: 현재 조회 중인 특정 제품 식별자(예:
item_id=SKU_5501)를 포착합니다. - 장바구니 이탈 토큰: 보류 중인 장바구니 식별자 및 할인 자격 플래그를 읽습니다.
- 카테고리 관심사: 상위 수준의 탐색 카테고리(예:
electronics,apparel)를 추적하여 대체 프로모션을 개인화합니다.
설계자는 모바일 브라우저 스토리지 라이프사이클을 고려해야 합니다. 최신 WebKit 추적 방지 정책에 따라, 클라이언트 스크립트로 쓰기 가능한 스토리지(localStorage, sessionStorage, IndexedDB)는 WebKit 추적 방지 상태 및 최근 사용자 참여에 따라 웹사이트와 7일간 상호작용이 없으면 삭제될 수 있습니다. 브라우저 스토리지는 권위 있는 고객 프로필이나 데이터베이스가 아닌, 최선의 노력으로 유지되는 임시 클라이언트 측 세션 캐시로 취급되어야 합니다. 권위 있는 장바구니 상태, 품목 가용성 및 사용자 권한은 항상 백엔드에서 해결하고 검증해야 합니다.

리타겟팅 데이터를 위한 개인정보 보호 및 동의 경계
웹과 네이티브 경계 전반에서 브라우징 맥락을 수집하고 전달하려면 개인정보 보호 거버넌스를 엄격히 준수해야 합니다:
- 데이터 최소화: 웹사이트 및 애플리케이션의 해당 동의, 고지, 보존 및 데이터 최소화 정책에 따라서만 리타겟팅 맥락을 수집하고 사용하십시오.
- URL에 PII 포함 금지: 배너 URL이나 클라이언트 측 스토리지에 개인을 식별할 수 있는 데이터(PII)나 민감한 개인 속성을 직접 인코딩하는 것을 피하십시오.
- 일시적 상태: 포착된 브라우징 맥락을 사용자 동의 설정 및 플랫폼 추적 방지 규칙의 적용을 받는 일시적인 자사 상태로 취급하십시오.
동적 콘텐츠 렌더링: 배너 복사본, 아트워크 및 CTA 실시간 업데이트
세션 맥락이 추출되면 배너는 시각적 레이아웃을 동적으로 업데이트합니다:
- 배너 제목이 일반적인 텍스트에서 맥락에 맞는 프롬프트(예: “주문 계속하기” 또는 “앱에서 제품 보기”)로 업데이트됩니다.
- CTA 버튼이 표준적인 “앱 받기”에서 실행 가능한 프롬프트(예: “장바구니 열기”)로 변경됩니다.
- 동적 아트워크가 현재 재고 또는 가격 표시와 함께 특정 제품 썸네일을 표시합니다.
이러한 맥락적 관련성은 배너를 수동적인 광고 요소에서 대화형 유틸리티로 변환합니다.
교차 도메인 제약 관리: Safari 유니버설 링크를 위한 전용 서브도메인 권장
iOS에서 유니버설 링크를 배포할 때, 웹 설계자는 콘텐츠에 연결할 수 있도록 앱 및 웹사이트 허용에 관한 Apple 개발자 문서에 기록된 Apple Safari의 동일 도메인 탐색 제약을 고려해야 합니다. 사용자가 https://example.com에서 웹 페이지를 탐색하다가 동일한 도메인을 가리키는 유니버설 링크를 탭하면, Safari는 보통 네이티브 앱을 실행하는 대신 브라우저에 머무릅니다.
별도로 연결된 라우팅 호스트를 사용하면 Safari의 문서화된 동일 도메인 브라우징 동작을 피할 수 있지만, 네이티브 앱 실행은 여전히 유효한 유니버설 링크 연결, 설치된 애플리케이션 자격 및 플랫폼 상태에 따라 다릅니다:
- 기본 모바일 웹사이트를
https://www.example.com에 호스팅하십시오. - 유니버설 링크 배너 대상을
https://app.example.com/product/5501과 같은 검증된 연결 서브도메인을 통해 라우팅하십시오.
웹 방문자가 앱을 설치하지 않았을 때 지연 딥링크의 역할
동적 배너로 리타겟팅된 모든 웹 방문자가 앱을 설치한 것은 아닙니다. 사용자 지정 URI 스킴(myapp://)은 페이지가 명시적인 대체 경로(fallback)를 제공하지 않으면 설치되지 않은 기기에서 해결되지 않을 수 있습니다.
지연 딥링크(Deferred Deep Linking)는 이 시나리오를 해결합니다. 설치하지 않은 사용자가 리타겟팅 배너를 탭하면, 라우팅 레이어는 브라우저를 Google Play 또는 App Store로 리다이렉트하기 전에 어트리뷰션 서버에서 의도된 대상 맥락(예: 조회한 SKU 및 활성 프로모션 토큰)을 포착합니다. 사용자가 처음으로 앱을 다운로드하고 실행하면, 어트리뷰션 SDK가 캐시된 매개변수를 검색하여 플랫폼 개인정보 보호 정책에서 허용하는 경우 첫 실행 시 대상 화면을 복원할 수 있게 합니다.
동적 매개변수 바인딩 및 딥링크 라우팅의 기술적 메커니즘
리타겟팅을 위한 URL 매개변수 구조화
강력한 리타겟팅 쿼리 문자열은 대상 라우팅, 프로모션 토큰 및 캠페인 어트리뷰션을 명확하게 구조화합니다:
https://app.example.com/promo/cart?scene=cart&item_id=SKU_5501&promo_code=RESTART10&token=TK_1234567890abcdef&utm_source=web_retargeting
이 페이로드는 라우팅 지침(scene=cart), 비즈니스 식별자(item_id) 및 추적 맥락(utm_source)을 깔끔하게 분리합니다.
클라이언트 측 데이터 위생 처리 및 길이 제한 강제
불안전한 딥링크에 대한 OWASP 모바일 애플리케이션 보안 테스트 가이드 지침에 따라, 웹 URL이나 클라이언트 측 스토리지에서 추출된 모든 매개변수는 신뢰할 수 없는 입력으로 취급해야 합니다.
딥링크 핸드오프 페이로드를 구성하기 전에 다음을 수행하십시오:
- 승인된 보기 대상(
cart,product_detail,promo_hub)의 허용 목록에 대해scene식별자를 검증하십시오. - ID 및 프로모션 코드에 대해 영숫자 정규 표현식 필터(예:
^[A-Za-z0-9_-]{1,64}$)를 적용하십시오. - 경로 토큰에 대해 엄격한 길이 경계(예: 16~128자)를 적용하고 애플리케이션 정의 문자 및 길이 정책에 따라 캠페인 문자열을 검증하여, 유효하지 않은 값을 거부하거나 안전한 기본값으로 대체하십시오.
- 경로 토큰을 신뢰할 수 없는 불투명한 참조로 취급하십시오. 경로 토큰을 보유했다고 해서 인증된 백엔드 검증 없이 장바구니 접근, 할인 또는 계정 작업을 승인해서는 안 됩니다.
웹 SDK 핸드오프 핸들러를 통한 직접 핸드오프 트리거
대표적인 OpoInstall Web SDK 통합은 실행 또는 설치 핸드오프 메서드를 노출할 수 있습니다. 사용 중인 환경에 배포된 프로덕션 SDK 버전과 비교하여 정확한 메서드 이름, 생성자, CDN 경로 및 매개변수 스키마를 확인하십시오.
OpoInstall은 교차 플랫폼 웹-투-앱 핸드오프 및 지연 매개변수 복구를 지원하며, 정확한 라우팅 메커니즘과 SDK 계약은 배포된 SDK 버전에 따라 다릅니다. 전체 인터페이스 매개변수 및 API 사양은 SDK 통합 문서를 검토하십시오.
[사용자가 모바일 웹 페이지 탐색 (예: SKU_1024 조회)]
│
▼
[스크립트가 자사 세션에서 맥락 포착]
│
▼
[동적 스마트 배너가 맥락 중심 오퍼 렌더링]
│
▼
[사용자가 "앱에서 계속하기" 탭]
│
┌───────────────────┴───────────────────┐
▼ ▼
[앱 설치됨] [앱 미설치]
│ │
▼ ▼
[유니버설 링크 / 앱 링크] [웹 라우팅 레이어]
│ │
▼ ▼
[네이티브 앱 직접 실행] [스토어 다운로드 / 지연 링크]
│ │
└───────────────────┬───────────────────┘
▼
[네이티브 SDK 매개변수 검색]
│
▼
[서버 상태 및 인증 검증]
│
▼
[대상 앱 내 화면 렌더링]
웹 리타겟팅을 위한 frictionless 앱 내 화면 복원 아키텍처 설계
Android 및 iOS 라이프사이클 전반의 콜드 스타트 대 백그라운드 재개 처리
네이티브 모바일 애플리케이션은 서로 다른 실행 상태 전반에서 들어오는 리타겟팅 페이로드를 처리해야 합니다:
- 웜 재개(Warm Resume): 앱이 이미 백그라운드 메모리에서 실행 중인 경우입니다. Android에서는 Activity 작업 구성이 기존 인스턴스를 재사용할 때 인텐트가
onNewIntent로 전달됩니다. iOS에서는 링크가scene(_:continue:)로 전달됩니다. 앱 라우터는 전역 상태를 다시 초기화하지 않고 활성 보기 계층을 탐색합니다. - 콜드 스타트(Cold Start): 앱 프로세스가 종료된 경우입니다. 운영체제가 프로세스를 부팅하고 시작 중에 인텐트를 전달합니다. 네이티브 아키텍처는 페이로드를 포착하고, 초기화를 검증하며, 기본 UI 계층이 로드되면 대상 화면으로 라우팅해야 합니다.
사용자 인증 자격 증명으로부터 라우팅 식별자 격리
딥링크 URL 및 웹-투-앱 배너는 라우팅 의도(어떤 제품이나 장바구니를 표시할지)와 불투명한 단기 참조 토큰만 전달해야 합니다. 딥링크 쿼리 문자열은 절대 원시 데이터베이스 사용자 ID, 계정 비밀번호 또는 해시되지 않은 세션 토큰을 전달해서는 안 됩니다.
네이티브 앱은 개인 사용자 정보를 렌더링하거나 계정 상태를 수정하기 전에 보안 로컬 자격 증명 저장소(예: iOS Keychain 또는 Android Keystore)에서 사용자 인증을 독립적으로 해결해야 합니다.
독점 할인 및 장바구니 상태를 위한 서버 측 인증 게이트 구현
유효한 딥링크 쿼리 문자열이 프로모션이 활성 상태이거나 사용자가 그것을 청구할 권한이 있음을 보장하지는 않습니다. 클라이언트 애플리케이션은 서버 측 검증을 위해 경로 토큰을 백엔드에 제출해야 합니다:
- 프로모션 쿠폰 코드(
promo_code)가 만료되지 않았으며 인증된 사용자에게 유효한지 확인하십시오. - 장바구니 토큰이 활성 상태이며 인증된 계정에 속하는지 검증하십시오.
- 쿠폰 남용을 방지하기 위해 일회성 멱등성 및 재시도 확인을 적용하십시오.
만료된 대상 관리: 만료된 거래 및 품절 재고에 대한 대체 라우팅
웹 방문자는 프로모션 거래가 종료되거나 재고 항목이 품절된 후 며칠 뒤에 리타겟팅 배너를 클릭할 수 있습니다. 앱이 상태 검증 없이 삭제된 제품을 로드하려고 시도하면 사용자는 손상된 인터페이스를 경험하게 됩니다.
프로덕션 아키텍처는 2계층 대체 경로 게이트를 적용합니다:
- 클라이언트 측 경로 검증: 대상 화면을 인식할 수 없거나 페이로드 구문이 잘못된 경우, 즉시 기본 홈 화면으로 라우팅하십시오.
- 서버 측 상태 검증: 경로는 유효하지만 상품이 품절되었거나 프로모션 코드가 만료된 경우, 유익한 모달 알림(예: “이 항목은 현재 품절되었으나 관련 추천 상품을 살펴보세요”)을 표시하고 관련 카테고리 허브로 매끄럽게 전환하십시오.
맥락적 리타겟팅 배너를 위한 프론트엔드 및 모바일 구현

사전 연결된 대체 경로를 포함한 맥락적 프론트엔드 스크립트 구조화
구현은 모듈식 배너 구성 요소 컨테이너가 웹페이지 마크업 내에 이미 마운트되어 있다고 가정합니다. 스크립트는 클라이언트 측 SDK 핸들러에 바인딩하기 전에 방어적 null 검사, 플랫폼 분류, 로컬 스토리지 쿨다운 확인 및 엄격한 매개변수 위생 처리를 적용합니다. 배너 텍스트의 개인화는 표시된 복사본과 기본 핸드오프 페이로드 간의 엄격한 정렬을 보장하기 위해 정규화된 데이터 모델에서 직접 파생됩니다.
Kotlin의 Android 인텐트 가로채기 및 매개변수 추출
Android에서 기본 MainActivity는 onCreate 및 onNewIntent 전반에서 들어오는 딥링크 인텐트를 포착하고, 데이터 유형을 정규화하며, 백엔드 인증에 위임하기 전에 허용 목록에 대해 페이로드 필드를 검증합니다.
Swift의 iOS SceneDelegate 유니버설 링크 처리
iOS에서 SceneDelegate.swift는 scene(_:continue:)를 통해 전달된 유니버설 링크를 처리하고, 매개변수를 구문 분석하며, 입력을 위생 처리하고, 메인 액터의 네이티브 뷰 컨트롤러로 라우팅합니다.
아래 구현은 프론트엔드 배너 구성과 네이티브 Android(Kotlin) 및 iOS(Swift) 매개변수 추출을 보여줍니다. 대표적인 OpoInstall 통합 패턴이 아래에 표시되어 있으며, 현재 배포된 OpoInstall SDK 릴리스와 패키지 이름, SDK 클래스, CDN 경로, 콜백 이름 및 메서드 서명을 비교 확인하십시오.
// JavaScript: 맥락 추출, 플랫폼 게이팅 및 대표 SDK 통합
// 대표적인 통합 패턴입니다. 스크립트 URL, 생성자 이름 및 API 서명을
// 환경에 배포된 프로덕션 OpoInstall SDK 릴리스와 비교하여 확인하십시오.
// 참고: 대상 ID가 있는 재사용 가능한 배너 구성 요소 마크업이 이미 DOM에 마운트되어 있다고 가정합니다.
(function() {
var DISMISS_KEY = "retarget_smart_banner_dismissed_at";
var COOL_DOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7일 쿨다운 윈도우
// 1. 플랫폼 평가: 데스크톱 환경에서 배너 표시 안 함
function getMobilePlatform() {
var ua = navigator.userAgent || navigator.vendor || window.opera;
if (/Android/i.test(ua)) return "android";
var isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
var isIPadOS = (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (isIOS || isIPadOS) return "ios";
return "unsupported_desktop";
}
var platform = getMobilePlatform();
if (platform === "unsupported_desktop") {
return; // 데스크톱 브라우저에서 표시 안 함
}
// 2. 로컬 스토리지를 통한 해제 확인
function shouldShowBanner() {
try {
var dismissedAt = localStorage.getItem(DISMISS_KEY);
if (!dismissedAt) return true;
var now = new Date().getTime();
return (now - parseInt(dismissedAt, 10)) > COOL_DOWN_MS;
} catch (e) {
return true; // 로컬 스토리지가 제한된 경우 표시로 대체
}
}
if (!shouldShowBanner()) {
return;
}
// DOM Null-가드: 조작 전 요소가 존재하는지 확인
var bannerContainer = document.getElementById("dynamicRetargetBanner");
var closeBtn = document.getElementById("bannerCloseBtn");
var actionBtn = document.getElementById("bannerActionBtn");
var bannerTitle = document.getElementById("bannerTitle");
if (!bannerContainer || !actionBtn || !bannerTitle) {
return;
}
// 3. 자사 브라우징 맥락 추출 및 위생 처리 (일관된 'item_id' 스키마)
var urlParams = new URLSearchParams(window.location.search);
var rawScene = urlParams.get("scene") || "cart";
var rawId = urlParams.get("item_id") || "";
var rawPromo = urlParams.get("promo_code") || "";
var rawToken = urlParams.get("token") || "";
var rawChannel = urlParams.get("utm_source") || "web_retargeting";
function sanitizePayload() {
var allowedScenes = ["cart", "product_detail", "promo_hub"];
var targetScene = allowedScenes.indexOf(rawScene) !== -1 ? rawScene : "cart";
var idRegex = /^[A-Za-z0-9_-]{1,64}$/;
var targetId = idRegex.test(rawId) ? rawId : "";
// 독립적인 프로모션 코드 검증 (네이티브 계약과 일치하는 엄격한 32자 제한)
var promoRegex = /^[A-Za-z0-9_-]{1,32}$/;
var promoCode = promoRegex.test(rawPromo) ? rawPromo : "";
var tokenRegex = /^[A-Za-z0-9_-]{16,128}$/;
var routeToken = tokenRegex.test(rawToken) ? rawToken : "";
var channelRegex = /^[A-Za-z0-9_-]{1,32}$/;
var channelCode = channelRegex.test(rawChannel) ? rawChannel : "web_retargeting";
var payload = {
scene: targetScene,
token: routeToken
};
if (targetId.length > 0) {
payload.item_id = targetId;
}
if (promoCode.length > 0) {
payload.promo_code = promoCode;
}
return {
payload: payload,
channelCode: channelCode
};
}
var normalizedData = sanitizePayload();
// 정규화된 모델을 기반으로 배너 메시지 개인화
if (normalizedData.payload.scene === "cart") {
bannerTitle.textContent = "주문 계속하기";
actionBtn.textContent = "장바구니 열기";
} else if (normalizedData.payload.scene === "product_detail") {
bannerTitle.textContent = "앱에서 제품 보기";
actionBtn.textContent = "아이템 보기";
}
bannerContainer.style.display = "block";
// 사용자 해제 처리
if (closeBtn) {
closeBtn.addEventListener("click", function() {
try {
localStorage.setItem(DISMISS_KEY, new Date().getTime().toString());
} catch (e) {}
bannerContainer.style.display = "none";
});
}
// 4. 초기 정적 대체 라우팅
function executeStaticFallback() {
if (platform === "android") {
window.location.href = "https://play.google.com/store/apps/details?id=com.example.app";
} else if (platform === "ios") {
window.location.href = "https://apps.apple.com/app/id123456789";
}
}
var activeClickHandler = function() {
executeStaticFallback();
};
actionBtn.addEventListener("click", function(e) {
activeClickHandler(e);
});
// 5. SDK 통합을 위한 동적 스크립트 주입
var script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://web.cdn.opoinstallcloud.com/openinstall.js";
script.onload = function() {
try {
if (typeof OpenInstall === "function") {
var openInstall = new OpenInstall({
appKey: "YOUR_OPOINSTALL_APPKEY",
onready: function() {
var m = this;
// SDK가 준비되면 동적 딥링크 핸드오프로 업그레이드
activeClickHandler = function() {
var sanitized = sanitizePayload();
m.wakeupOrInstall({
data: sanitized.payload,
channelCode: sanitized.channelCode
});
};
}
}, actionBtn);
}
} catch (err) {
// 초기화 오류 시 정적 대체 경로 유지
}
};
script.onerror = function() {
// 네트워크 요청 실패 시 정적 대체 경로 유지
};
document.head.appendChild(script);
})();
// Android: MainActivity.kt - 재참여 인텐트 처리 및 경로 검증 게이트
// 대표적인 통합 패턴입니다. 패키지 이름, 콜백 클래스 및 메서드 서명을
// 환경에 배포된 프로덕션 OpoInstall SDK 릴리스와 비교하여 확인하십시오.
package com.example.app.ui
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.opoinstall.api.OpoInstall
import com.opoinstall.api.listener.AppWakeUpAdapter
import com.opoinstall.api.model.AppData
import org.json.JSONObject
data class ValidatedRetargetingRoute(
val scene: String,
val targetId: String,
val promoCode: String,
val routeToken: String,
val rawKeys: Set<String>
)
object OpoInstallPayloadAdapter {
/**
* 이기종 SDK 데이터 표현(JSON 문자열, 맵 또는 JSONObject)을
* 엄격한 Fail-Closed 유형 검사가 포함된 정식 애플리케이션 소유 리타겟팅 모델로 정규화합니다.
*/
fun normalize(rawPayload: Any?): ValidatedRetargetingRoute? {
if (rawPayload == null) return null
val stringMap = when (rawPayload) {
is String -> parseJsonStringStrict(rawPayload)
is Map<*, *> -> parseMapStrict(rawPayload)
is JSONObject -> parseJsonObjectStrict(rawPayload)
else -> {
Log.w("PayloadAdapter", "지원되지 않는 SDK 페이로드 유형: ${rawPayload.javaClass.name}")
null
}
} ?: return null
val scene = stringMap["scene"] ?: ""
if (scene.isEmpty()) return null
return ValidatedRetargetingRoute(
scene = scene,
targetId = stringMap["item_id"] ?: "",
promoCode = stringMap["promo_code"] ?: "",
routeToken = stringMap["token"] ?: "",
rawKeys = stringMap.keys
)
}
private fun parseJsonStringStrict(rawJson: String): Map<String, String>? {
return try {
val json = JSONObject(rawJson)
parseJsonObjectStrict(json)
} catch (e: Exception) {
Log.e("PayloadAdapter", "JSON 문자열 파싱 실패", e)
null
}
}
private fun parseJsonObjectStrict(json: JSONObject): Map<String, String>? {
val map = mutableMapOf<String, String>()
for (key in json.keys()) {
val value = json.opt(key)
// Fail-closed: 유형 강제 공격을 방지하기 위해 비문자열 유형 거부
if (value !is String) {
Log.w("PayloadAdapter", "키에 대한 비문자열 페이로드 값 거부: $key")
null
}
map[key] = value as String
}
return map
}
private fun parseMapStrict(rawMap: Map<*, *>): Map<String, String>? {
val map = mutableMapOf<String, String>()
for ((key, value) in rawMap) {
if (key !is String || value !is String) {
Log.w("PayloadAdapter", "원시 맵에서 비문자열 키 또는 값 거부: $key")
null
}
map[key as String] = value as String
}
return map
}
}
object RetargetingRouteValidator {
private val allowedKeys = setOf("scene", "item_id", "promo_code", "token")
private val allowedScenes = setOf("cart", "product_detail", "promo_hub")
fun validate(payload: ValidatedRetargetingRoute): ValidatedRetargetingRoute? {
// Step 1: 엄격한 Fail-closed 키 검증 (알 수 없는 페이로드 키 거부)
if (!allowedKeys.containsAll(payload.rawKeys)) {
return null
}
// Step 2: 엄격한 허용 목록에 대해 화면 검증
if (!allowedScenes.contains(payload.scene)) {
return null
}
// Step 3: 대상 식별자에 영숫자 및 길이 제한 강제
val alphanumericRegex = Regex("^[A-Za-z0-9_-]+$")
if (payload.targetId.isNotEmpty() && (payload.targetId.length > 64 || !payload.targetId.matches(alphanumericRegex))) {
return null
}
if (payload.promoCode.isNotEmpty() && (payload.promoCode.length > 32 || !payload.promoCode.matches(alphanumericRegex))) {
return null
}
// Step 4: 경로 토큰 형식 검증
if (payload.routeToken.isNotEmpty() && (payload.routeToken.length !in 16..128 || !payload.routeToken.matches(alphanumericRegex))) {
return null
}
return payload
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 콜드 스타트 리타겟팅 인텐트 처리
intent?.let { handleRetargetingIntent(it) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Activity가 재사용될 때 웜 재개 리타겟팅 인텐트 처리
handleRetargetingIntent(intent)
}
private fun handleRetargetingIntent(intent: Intent) {
OpoInstall.getInstance().getWakeUp(intent, object : AppWakeUpAdapter() {
override fun onWakeUp(appData: AppData?) {
if (appData == null) return
val rawPayload = appData.data
if (rawPayload == null) return
// Step 1: 어댑터를 통해 공급업체 SDK 페이로드 직접 정규화
val canonicalPayload = OpoInstallPayloadAdapter.normalize(rawPayload)
val validatedRoute = canonicalPayload?.let { RetargetingRouteValidator.validate(it) }
if (validatedRoute != null) {
// Step 2: 인증된 앱 세션을 사용하여 서버 인증 및 리소스 상태 검증
BackendRouteAuthorizer.verifyRouteAuthorization(validatedRoute.scene, validatedRoute.targetId, validatedRoute.routeToken) { isAuthorized ->
runOnUiThread {
if (isAuthorized) {
executeTargetNavigation(validatedRoute)
} else {
executeLobbyFallback("요청된 리타겟팅 거래나 항목이 만료되었습니다.")
}
}
}
} else {
runOnUiThread {
executeLobbyFallback("잘못되었거나 승인되지 않은 리타겟팅 페이로드입니다.")
}
}
}
})
}
private fun executeTargetNavigation(route: ValidatedRetargetingRoute) {
Log.i("AppNavigator", "리타겟팅 화면으로 이동 중: ${route.scene}")
// 내부 탐색 컨트롤러로 전달
}
private fun executeLobbyFallback(reason: String) {
Log.w("AppNavigator", "홈 로비로 안전한 대체 경로: $reason")
// 공지사항 표시 및 기본 홈 보기로 라우팅
}
}
// 앱별 백엔드 인증 플레이스홀더 (OpoInstall SDK API 아님)
object BackendRouteAuthorizer {
fun verifyRouteAuthorization(scene: String, targetId: String, token: String, callback: (Boolean) -> Unit) {
// Fail-closed 플레이스홀더: 라이브 백엔드 인증 API에 연결해야 함.
// 프로덕션 백엔드는 활성 사용자 세션, 토큰 만료, 장바구니 소유권 및 일회성 멱등성을 검증합니다.
val isAuthorized = false
callback(isAuthorized)
}
}
// iOS: SceneDelegate.swift - 유니버설 링크 처리 및 경로 검증 게이트
// 대표적인 통합 패턴입니다. 패키지 이름, 콜백 클래스 및 메서드 서명을
// 환경에 배포된 프로덕션 OpoInstall SDK 릴리스와 비교하여 확인하십시오.
import UIKit
import libOpoInstallSDK
struct ValidatedRetargetingRoute {
let scene: String
let targetId: String
let promoCode: String
let routeToken: String
let rawKeys: Set<String>
}
class OpoInstallPayloadAdapter {
/**
* 이기종 SDK 데이터 표현(사전, JSON 문자열 또는 사용자 지정 객체)을
* 엄격한 Fail-Closed 유형 검사가 포함된 정식 애플리케이션 소유 정규 모델로 정규화합니다.
*/
static func normalize(rawPayload: Any?) -> ValidatedRetargetingRoute? {
guard let payload = rawPayload else { return nil }
if let dict = payload as? [String: Any] {
return normalizeDictionaryStrict(dict)
} else if let jsonString = payload as? String, let data = jsonString.data(using: .utf8) {
do {
if let dict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
return normalizeDictionaryStrict(dict)
}
} catch {
NSLog("[PayloadAdapter] JSON 역직렬화 실패: %@", error.localizedDescription)
return nil
}
}
return nil
}
private static func normalizeDictionaryStrict(_ dict: [String: Any]) -> ValidatedRetargetingRoute? {
for (key, value) in dict {
guard value is String else {
NSLog("[PayloadAdapter] 키에 대한 비문자열 값 거부: %@", key)
return nil
}
}
guard let scene = dict["scene"] as? String, !scene.isEmpty else {
return nil
}
let targetId = dict["item_id"] as? String ?? ""
let promoCode = dict["promo_code"] as? String ?? ""
let routeToken = dict["token"] as? String ?? ""
let keys = Set(dict.keys)
return ValidatedRetargetingRoute(
scene: scene,
targetId: targetId,
promoCode: promoCode,
routeToken: routeToken,
rawKeys: keys
)
}
}
class RetargetingRouteValidator {
private static let allowedKeys: Set<String> = ["scene", "item_id", "promo_code", "token"]
private static let allowedScenes: Set<String> = ["cart", "product_detail", "promo_hub"]
static func validate(payload: ValidatedRetargetingRoute) -> ValidatedRetargetingRoute? {
// Step 1: Fail-closed 키 검증 (알 수 없는 페이로드 키 거부)
guard payload.rawKeys.isSubset(of: allowedKeys) else {
return nil
}
// Step 2: 엄격한 허용 목록에 대해 화면 검증
guard allowedScenes.contains(payload.scene) else {
return nil
}
// Step 3: 대상 식별자 및 프로모션 코드에 영숫자 및 길이 제한 강제
let validChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
if !payload.targetId.isEmpty {
guard payload.targetId.count <= 64, payload.targetId.rangeOfCharacter(from: validChars.inverted) == nil else {
return nil
}
}
if !payload.promoCode.isEmpty {
guard payload.promoCode.count <= 32, payload.promoCode.rangeOfCharacter(from: validChars.inverted) == nil else {
return nil
}
}
// Step 4: 경로 토큰 형식 검증
if !payload.routeToken.isEmpty {
guard payload.routeToken.count >= 16 && payload.routeToken.count <= 128,
payload.routeToken.rangeOfCharacter(from: validChars.inverted) == nil else {
return nil
}
}
return payload
}
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate, OpoInstallDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let _ = (scene as? UIWindowScene) else { return }
// OpoInstall SDK 초기화
OpoInstallSDK.initWith(self)
// 유니버설 링크를 통한 콜드 런치 처리
if let userActivity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }) {
OpoInstallSDK.continue(userActivity)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
// 유니버설 링크를 통한 웜 재개 처리
if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
OpoInstallSDK.continue(userActivity)
}
}
// OpoInstallDelegate 깨우기 콜백
func getWakeUpParams(_ appData: OpoInstallData?) {
guard let data = appData, let rawPayload = data.data else {
return
}
// Step 1: 엄격한 유형 검사가 포함된 공급업체 SDK 페이로드 표현 정규화
guard let canonicalPayload = OpoInstallPayloadAdapter.normalize(rawPayload: rawPayload),
let validatedRoute = RetargetingRouteValidator.validate(payload: canonicalPayload) else {
DispatchQueue.main.async {
self.executeLobbyFallback(reason: "잘못되었거나 승인되지 않은 리타겟팅 페이로드")
}
return
}
// Step 2: 인증된 앱 세션을 사용하여 서버 인증 및 리소스 상태 검증
BackendRouteAuthorizer.shared.verifyRouteAuthorization(scene: validatedRoute.scene, targetId: validatedRoute.targetId, token: validatedRoute.routeToken) { isAuthorized in
DispatchQueue.main.async {
if isAuthorized {
self.executeTargetNavigation(route: validatedRoute)
} else {
self.executeLobbyFallback(reason: "리소스 만료 또는 승인되지 않음")
}
}
}
}
private func executeTargetNavigation(route: ValidatedRetargetingRoute) {
NSLog("[AppNavigator] 리타겟팅 화면으로 이동 중: %@", route.scene)
// 내부 뷰 컨트롤러 전환 실행
}
private func executeLobbyFallback(reason: String) {
NSLog("[AppNavigator] 홈 로비로 안전한 대체 경로: %@", reason)
// 공지사항 표시 및 루트 뷰 컨트롤러로 라우팅
}
}
// 앱별 백엔드 인증 플레이스홀더 (OpoInstall SDK API 아님)
class BackendRouteAuthorizer {
static let shared = BackendRouteAuthorizer()
func verifyRouteAuthorization(scene: String, targetId: String, token: String, completion: @escaping (Bool) -> Void) {
// Fail-closed 플레이스홀더: 라이브 백엔드 인증 API에 연결해야 함.
// 프로덕션 백엔드는 사용자 세션, 토큰 만료, 장바구니 소유권 및 일회성 멱등성을 검증합니다.
let isAuthorized = false
completion(isAuthorized)
}
}
스레드 안전한 탐색 실행: UI 스레드 안전성 보존
씬 콜백은 UIKit 라이프사이클에서 도착합니다. 비동기 인증 콜백은 시각적 렌더링 결함을 방지하고 스레드 안전성을 유지하기 위해 메인 액터나 UI 스레드(Swift의 DispatchQueue.main.async, Kotlin의 runOnUiThread)로 UI 변경을 마샬링해야 합니다.
웹-투-앱 리타겟팅 퍼널 및 성능 측정 매트릭스
웹-투-앱 리타겟팅을 위한 원격 측정 게이트
리타겟팅 캠페인 성능을 경험적으로 평가하기 위해 성장 팀은 네 가지 주요 퍼널 지표를 추적합니다:
- 배너 클릭률(CTR): 동적 스마트 앱 배너를 탭한 모바일 웹페이지 방문자의 비율.
- 앱 오픈 클릭률(CAOR): 네이티브 앱 오픈이 확인된 배너 클릭의 비율.
- 화면 복원 성공률: 홈 로비로 대체되지 않고 성공적으로 대상 화면을 검증하고 렌더링한 딥링크된 앱 세션의 비율.
- 다운스트림 전환율(CVR): 정의된 어트리뷰션 윈도우(예: 24시간) 내에 핵심 작업(예: 결제 또는 등록)을 완료한 리타겟팅 사용자의 비율.
유지 곡선 감사: 리타겟팅 방문자에 대한 통제된 코호트 실험

재참여 효과는 Intent-to-Treat(ITT) 비즈니스 리프트를 조건부 오픈 후 유지율과 명확하게 구분하는 통제된 실험을 통해 평가되어야 합니다:
-
Intent-to-Treat(ITT) 앱 활성율 (
): 치료 후 조건화 없이 모든 적격 웹 방문자 전반의 인과적 리프트를 평가하기 위해, 데이터 팀은 맥락적 배너에 노출된 무작위 방문자 코호트를 일반 배너나 표준 웹 탐색에 노출된 무작위 대조군과 비교합니다: -
조건부 오픈 후 유지율 (
): 웹-투-앱 핸드오프를 성공적으로 완료한 사용자 간의 앱 고착성을 분석하기 위해, 팀은 초기 앱 실행을 조건으로 유지율을 모니터링합니다:
조건부 유지율(
웹-투-앱 리타겟팅 채널 평가 매트릭스
아래 표는 기술적 기능과 운영 특성에 따른 주요 웹-투-앱 배너 접근 방식을 대비합니다:
| 퍼널 차원 | 정적 웹 배너 | 네이티브 Safari 배너 | 동적 리타겟팅 배너 (OpoInstall) |
|---|---|---|---|
| 타겟팅 정밀도 | 일반적 (모든 사용자에게 동일한 복사본 표시) | 고정된 앱 스토어 메타데이터 | 동적 (SKU, 장바구니 또는 카테고리 특정) |
| 교차 플랫폼 도달 | 광범위한 웹 브라우저 렌더링 | 지원되는 Apple 플랫폼의 Safari만 | 광범위한 교차 브라우저 (Android, iOS, Chrome, Safari) |
| 앱 내 핸드오프 대상 | 기본 홈 로비 | 기본 홈 또는 정적 인수 | 딥링크 대상 화면 (장바구니, 제품, 프로모션) |
| 해제 관리 | 관리되지 않음 / 기본 쿠키 | Safari 제어 억제 | 개발자 구성 가능한 쿨다운 윈도우 |
| 다운스트림 참여 | 경험적 (코호트/채널별 측정) | 경험적 (코호트/채널별 측정) | 경험적 (코호트/채널별 측정) |
자주 묻는 질문(FAQ)
동적 스마트 앱 배너는 정적 스마트 배너와 어떻게 다른가요?
사용자가 앱을 삭제한 경우 웹-투-앱 리타겟팅은 어떻게 맥락을 유지하나요?
웹-투-앱 리타겟팅 배너로 인한 누적 레이아웃 이동(CLS)을 어떻게 방지하나요?
요약 및 의사결정 프레임워크
동적 스마트 앱 배너를 통한 모바일 웹 방문자 리타겟팅은 퍼널 상단의 브라우징 트래픽과 보존된 맥락의 네이티브 앱 경험 사이의 간극을 메웁니다. 일반적인 홈 화면 프롬프트나 정적 스토어 링크에 의존하는 것은 가치 있는 구매 의도를 저버리는 것이며, 획득 효율성과 비교하여 측정해야 하는 추가적인 전환 마찰을 유발할 수 있습니다.
자사 웹 브라우징 신호를 포착하고, 개인화된 HTML 배너를 렌더링하며, 네이티브 뷰 컨트롤러로 검증된 딥링크 핸드오프를 실행함으로써 엔지니어링 및 성장 팀은 전환 마찰을 줄이고 지속적인 앱 참여를 지원합니다. 다운스트림 유지율 이득과 캠페인 ROI는 통제된 코호트 실험을 통해 경험적으로 검증되어야 합니다.
교차 플랫폼 웹 배너 통합 및 동적 딥링크 아키텍처를 탐색하려면 SDK 통합 문서를 참조하십시오.
관련 자료
-
개념: 앱 참여, 웹-앱 리다이렉션, 동적 스마트 앱 배너, 화면 복원, 리타겟팅 퍼널
-
기술: 유니버설 링크, Android 앱 링크, 지연 딥링크, W3C 웹 스토리지
-
표준: IETF RFC 3986 URI(Uniform Resource Identifier), Apple Associated Domains 사양, Android 디지털 자산 링크 프로토콜, OWASP 모바일 애플리케이션 보안 테스트 가이드(MASTG)
-
API / 통합 패턴: 웹-투-앱 딥링크 통합 패턴, Android
getIntent, iOSUIWindowSceneDelegate -
공식 문서 및 참조:
Share this article



