웹에서 앱 설치로 추적 매개변수를 전달하는 방법

opoinstall
2026-10-10
5 min read

모바일 웹사이트에서 앱 스토어 설치로 추적 매개변수를 어떻게 전달할까요? 모바일 웹사이트에서 신규 설치된 앱으로 추적 매개변수를 전달하는 과정은 OpoInstall 웹 JS SDK를 로드하고, 허용된(allowlisted) URL 매개변수를 캡처한 뒤, 앱이 처음 실행될 때 네이티브 SDK를 통해 지원되는 어트리뷰션 데이터를 복원하는 과정을 포함합니다.

앱 스토어 다운로드 전반에 걸친 매개변수 스티칭은 사용자가 모바일 랜딩 페이지에서 앱 스토어로 이동한 후, 새로 설치된 애플리케이션을 실행할 때 적격한 웹 URL 매개변수를 보존하는 프로세스입니다. 웹 SDK는 캠페인 또는 추천(referral) 컨텍스트를 캡처하며, 플랫폼 지원 메커니즘이나 지연된 복원 서비스가 관련 매개변수를 네이티브 앱에서 사용할 수 있도록 합니다. 복원 여부는 플랫폼, SDK 구성, 가용 매칭 신호 및 해당 개인정보 보호 요구사항에 따라 달라집니다.

용어 정의 관련 엔티티 검색 의도 역할
추적 매개변수 (Tracking Parameters) 트래픽 컨텍스트, 캠페인 소스 또는 추천 정보를 식별하기 위해 URL에 추가되는 키-값 쌍. URL 쿼리 문자열 정보 제공형 / 기술적
앱 매개변수 설치 (App Parameter Installation) 설치 후 적격한 웹 컨텍스트를 사용할 수 있게 만드는 지연된 매개변수 복원 워크플로우. 네이티브 모바일 SDK 기술적 / 정보 제공형
Google Play 설치 리퍼러 (Google Play Install Referrer) Google Play 설치와 관련된 리퍼러 메타데이터를 검색하기 위한 공식 Android API. Android SDK 기술적

웹-스토어 어트리뷰션 격차: 앱 스토어 전반의 플랫폼 장벽

웹 추적 매개변수는 앱 스토어 설치 경계를 자동으로 넘지 않습니다.

단절 장벽: 운영 체제 샌드박스가 브라우저 컨텍스트를 격리하는 방식

웹 분석에서는 UTM 태그(utm_source, utm_medium, utm_campaign), gclid 및 fbclid와 같은 광고 클릭 식별자, 그리고 사용자 지정 추천 매개변수를 포함한 URL 쿼리 문자열을 일반적으로 사용합니다.

참여 웹사이트 내에서 브라우저 API와 자사 분석 시스템은 허용된 분석 및 캠페인 워크플로우를 위해 이러한 값을 캡처할 수 있습니다.

문제는 방문자가 브라우저를 떠나 앱 스토어 설치 과정에 진입할 때 발생합니다. 브라우저와 새로 설치된 네이티브 애플리케이션은 서로 격리된 환경에서 작동합니다. 브라우저 쿠키, 세션 스토리지 및 페이지 URL 매개변수는 설치 후 애플리케이션 내부에서 자동으로 사용할 수 없게 됩니다.

따라서 개발자는 설치 전의 적격한 컨텍스트와 설치 후의 앱 세션을 연결하기 위해 스토어가 지원하는 리퍼러 메커니즘이나 지연된 매개변수 복원 워크플로우가 필요합니다.

플랫폼 세부 정보: Apple App Store vs. Google Play Store 처리

Android와 iOS는 매개변수가 하나의 앱 계약으로 정규화되기 전에 서로 다른 메커니즘을 사용합니다.

모바일 앱 스토어는 리퍼러 컨텍스트를 전달하기 위해 서로 다른 방식을 지원합니다.

  • Apple App Store (iOS): 표준 앱 스토어 설치는 외부 URL 쿼리 매개변수를 새로 설치된 애플리케이션의 첫 실행 환경으로 자동으로 전달하지 않습니다. 지연된 복원 서비스나 사용자가 매개하는 워크플로우를 통해 Apple의 개인정보 보호 요구사항에 따라 적격한 컨텍스트를 복구할 수 있습니다.
  • Google Play Store (Android): Google은 공식 Google Play Install Referrer API를 제공합니다. 적절히 구성된 Google Play 리퍼러 흐름을 사용할 경우, 설치된 애플리케이션은 리퍼러 문자열과 관련 타임스탬프를 포함한 리퍼러 메타데이터를 검색할 수 있습니다.

이러한 메커니즘을 상호 교환 가능한 것으로 간주해서는 안 됩니다. Google Play는 공식 스토어 수준의 리퍼러 인터페이스를 제공하지만, iOS에서의 임의적인 지연 매개변수 복원은 별도로 설계된 정책 준수 메커니즘이 필요합니다.

비귀속 설치 트래픽이 채널 ROI 계산에 미치는 비즈니스 영향

웹 방문과 앱 설치 사이에서 캠페인 매개변수가 손실되면, 분석 시스템은 설치를 원본 캠페인과 올바르게 연결하지 못할 수 있습니다.

이는 미귀속 설치, 불완전한 획득 리포팅, 그리고 ROI(투자 대비 수익) 또는 CAC(고객 획득 비용) 계산의 부정확성으로 이어질 수 있습니다.

매개변수 손실은 온보딩 경험에도 영향을 줄 수 있습니다. 예를 들어, 웹 추천 링크에 초대자 식별자나 프로모션 코드가 포함되어 있을 수 있습니다:

https://example.com/download?inviter_id=USR_1024
https://example.com/download?promo_code=WELCOME50

애플리케이션이 이러한 값을 복구하지 못하면 사용자가 수동으로 다시 입력해야 할 수도 있습니다.

단, 추천이나 프로모션 값을 복원한다고 해서 보상이 자동으로 승인되는 것은 아닙니다. 애플리케이션 백엔드는 자격, 상환 한도 및 해당 비즈니스 규칙을 독립적으로 검증해야 합니다.

스토어 지원 vs. 지연된 매개변수 메커니즘: Apple App Store vs. Google Play

Google Play Store 메커니즘: 네이티브 Install Referrer API 활용

Android에서 Google Play Install Referrer API는 지원되는 설치 리퍼러 메타데이터를 검색하기 위한 공식 메커니즘을 제공합니다.

Google Play 다운로드 URL에는 인코딩된 referrer 매개변수가 포함될 수 있습니다:

https://play.google.com/store/apps/details?id=com.example.app&referrer=utm_source%3Dfacebook%26inviter_id%3DUSR_1024

이 예시 URL에서 인코딩된 리퍼러 값은 다음을 나타냅니다:

utm_source=facebook&inviter_id=USR_1024

설치 후, 네이티브 Android 애플리케이션은 Install Referrer Client를 사용하여 Google Play에서 제공하는 리퍼러 정보를 검색할 수 있습니다.

특정 버전의 OpoInstall 웹 SDK가 이러한 URL을 자동으로 생성, 수정 또는 관리하는지 여부는 배포된 문서 및 동작을 통해 확인해야 합니다. Google 공식 API의 존재가 타사 SDK의 내부 구현을 보장하는 것은 아닙니다.

Apple App Store 경계: 네이티브 쿼리 매개변수 없이 표준 스토어 리다이렉션 처리

Apple App Store는 임의의 웹 쿼리 매개변수를 새로 설치된 애플리케이션의 첫 실행 콜백으로 직접 전달하지 않습니다.

따라서 지연된 복원 워크플로우에는 스토어 리다이렉트 전에 허용된 캠페인 정보를 캡처하고, 네이티브 애플리케이션이 실행될 때 관련 컨텍스트를 요청하는 과정이 포함될 수 있습니다.

SDK 기반 워크플로우의 경우, 개발자는 벤더가 문서화한 기능과 배포된 SDK 버전에서 사용하는 매칭 방법을 구분해야 합니다.

모든 매칭 또는 데이터 수집 방법은 Apple의 개인정보 보호 정책을 준수해야 합니다. 특히, 어트리뷰션 구현은 금지된 기기 핑거프린팅(device fingerprinting)에 의존해서는 안 되며, ATT 권한 허용이 평소에 금지된 매칭 방법을 허용한다고 가정해서는 안 됩니다.

유니버설 링크(Universal Links)는 이미 설치된 애플리케이션으로 링크를 라우팅하는 데 유용하지만, 그 자체로 임의의 웹 쿼리 매개변수가 신규 앱 스토어 설치를 통해 유지됨을 보장하지는 않습니다.

교차 플랫폼 아키텍처 발산: 통합 매개변수 계약 구축

Android와 iOS는 서로 다른 플랫폼 메커니즘을 사용하지만, 애플리케이션은 복구된 값을 공유 애플리케이션 수준 스키마로 정규화할 수 있습니다.

예를 들면 다음과 같습니다:

{
  "utm_source": "facebook",
  "utm_medium": "paid_social",
  "utm_campaign": "summer_sale",
  "inviter_id": "USR_1024",
  "promo_code": "WELCOME50"
}

이 JSON 객체는 애플리케이션이 정의한 정규화된 매개변수 계약을 나타내며, 모든 OpoInstall SDK 릴리스에서 보장되는 리터럴 응답 형식이 아닙니다.

애플리케이션은 이러한 필드를 두 가지 범주로 분리해야 합니다:

  • 마케팅 어트리뷰션: utm_source, utm_medium, utm_campaign.
  • 추천 및 프로모션 활동: inviter_id, promo_code.

마케팅 어트리뷰션은 승인된 분석 파이프라인에 제출할 수 있습니다. 추천 및 프로모션 활동은 애플리케이션이 혜택을 부여하기 전에 독립적인 서버 측 승인이 필요합니다.

스토어 리다이렉션 브릿지에서 컨텍스트 페이로드 캐싱의 역할

지연된 복원 서비스는 일반적으로 설치 컨텍스트에 대해 제한된 보존 기간을 요구합니다.

적용 가능한 보존 창, 복원 동작 및 매칭 가용성은 배포된 서비스, SDK 구성, 네트워크 상태, 플랫폼 제한 및 개인정보 보호 요구사항에 따라 달라집니다.

엔지니어링 팀은 확인되지 않은 정확도 주장을 수용하기보다 자체 설치 코호트를 사용하여 복원 결과를 측정해야 합니다.

개인정보 보호를 고려한 지연된 컨텍스트 아키텍처와 플랫폼 경계

iOS의 시스템 페이스트보드 경계: 투명성 배너와 UIPasteControl 의도 메커니즘

과거의 일부 지연된 딥링크 구현은 애플리케이션 간에 일시적인 컨텍스트를 전달하기 위해 시스템 페이스트보드(pasteboard)를 사용했습니다.

그러나 iOS는 애플리케이션 간 페이스트보드 접근을 제한하고 사용자에게 노출합니다.

  • iOS 14: 다른 애플리케이션에서 복사된 콘텐츠를 읽으면 페이스트보드 접근 알림이 트리거될 수 있습니다.
  • iOS 16 이상: 특정 프로그래밍 방식의 붙여넣기 작업은 권한 프롬프트를 트리거할 수 있습니다. UIPasteControl과 같은 컨트롤은 사용자가 시작한 붙여넣기 상호작용을 지원합니다.

따라서 일반적인 페이스트보드 접근을 보이지 않거나 제한 없는 설치 매개변수 전송 채널로 취급해서는 안 됩니다.

페이스트보드 관련 기능을 사용하는 애플리케이션은 사용자 기대치에 맞게 상호작용을 설계하고, 권한 거부나 콘텐츠 누락을 적절히 처리해야 합니다.

Android 포그라운드 클립보드 제한: 내비게이션 제약 및 자동 삭제

Android 역시 클립보드 접근에 제한을 적용합니다.

  • Android 10 이상: 플랫폼 정의 예외를 제외하고, 적격한 포그라운드 컨텍스트에 있지 않은 애플리케이션의 클립보드 접근은 제한됩니다.
  • Android 13 이상: Android는 클립보드 관련 사용자 인터페이스 피드백과 클립보드 콘텐츠의 자동 삭제 동작을 제공합니다.

애플리케이션은 클립보드 데이터를 조용히 검색할 수 있거나 무기한으로 유지된다고 가정해서는 안 됩니다.

클립보드 접근은 기본 어트리뷰션 채널로 간주되기보다, 적절한 사용자 대면 워크플로우에 한해 선택적으로 제한되어야 합니다.

Apple 사용자 개인정보 보호 및 데이터 사용 정책 엄수: 기기 핑거프린팅 금지

Apple은 기기 및 환경적 특성의 조합을 통해 사용자나 기기를 식별하는 데 사용되는 기기 핑거프린팅을 금지합니다.

구현 시 IP 주소, 사용자 에이전트 정보, 디스플레이 특성 또는 기타 기기 신호를 Apple의 정책을 위반하는 방식으로 결합하여 지속적 또는 확률적 식별자를 생성해서는 안 됩니다.

App Tracking Transparency(앱 추적 투명성) 권한을 부여한다고 해서 금지된 핑거프린팅 방법이 허용되는 것은 아닙니다.

타사 지연 매개변수 복원 서비스의 경우, 개발자는 배포된 SDK의 실제 데이터 수집, 매칭 동작, 개인정보 보호 고지 및 플랫폼 정책 요구사항을 검토해야 합니다.

개인정보 보호를 고려한 통합은 데이터 최소화, 투명한 사용자 상호작용 및 지원되는 플랫폼 메커니즘을 우선시해야 합니다.

보안 및 신뢰 경계: 매개변수 수명 주기 및 서버 승인 관리

복구된 설치 매개변수는 별도의 분석 및 백엔드 승인 처리가 필요합니다.

복원된 모든 매개변수는 신뢰할 수 없는 입력으로 취급되어야 합니다.

마케팅 태그와 추천 토큰은 수정 가능한 URL이나 신뢰할 수 없는 클라이언트 요청에서 시작될 수 있습니다. 이러한 정보가 존재한다고 해서 캠페인이 정당하다거나 사용자가 보상 대상임을 의미하지는 않습니다.

애플리케이션은 다음 원칙을 준수해야 합니다:

  • 허용된 값(Allowlisted): 명시적으로 지원되는 키와 범위 내의 값만 수용합니다.
  • 어트리뷰션과 승인 분리: UTM 값은 트래픽 출처를 설명할 수 있지만, 프로모션 혜택을 독립적으로 승인해서는 안 됩니다.
  • 시간 제한이 있는 보류 레코드: 애플리케이션 정의 만료 기간에 따라 처리에 필요한 정보만 보존합니다.
  • 보호된 스토리지: Android Keystore 기반 설계나 iOS Keychain Services와 같이 보류 중인 토큰의 민감도에 적합한 스토리지를 사용합니다.
  • 독립적인 전달 결과: 마케팅 및 보상 매개변수를 모두 포함하는 레코드의 경우, 애플리케이션은 각 결과를 독립적으로 추적해야 합니다.
  • 서버 측 보상 검증: 추천 크레딧, 프로모션 할인 및 유사한 자격은 인증된 애플리케이션 백엔드에서 승인되어야 합니다.
  • 멱등성 있는 보상 적용: 백엔드는 반복된 클라이언트 요청으로 인해 일회성 보상이 중복 지급되지 않도록 트랜잭션 수준의 멱등성과 비즈니스 수준의 고유성 제약을 강제해야 합니다.

아래 코드는 매개변수 경계와 호스트 애플리케이션의 통합 책임을 보여줍니다. 이는 완전한 프로덕션 수준의 스토리지, 분석 또는 보상 트랜잭션 시스템을 제공하려는 것이 아닙니다.

엔드투엔드 구현: 웹 매개변수 캡처부터 네이티브 복원까지

웹 쿼리 매개변수 정화: HTML 부트스트랩, 허용 목록 파싱 및 웹 JS SDK 설정

랜딩 페이지는 명시적으로 지원되는 쿼리 매개변수를 캡처하여 제한된 페이로드로 웹 SDK에 전달할 수 있습니다.

다음 예제는 다음을 보여줍니다:

  • 구성된 소스로부터 SDK 로드.
  • 정상적인 HTTPS 다운로드 폴백(fallback) 링크 유지.
  • 지원되는 매개변수 키만 허용.
  • 엄격한 문자 및 길이 정책을 벗어난 값 거부.
  • SDK 다운로드 액션을 페이지의 다운로드 버튼과 연결.

SDK 생성자, 구성 키 및 메서드는 대표적인 예시입니다. 게시 또는 배포 전에 정확한 OpoInstall 웹 SDK 릴리스의 시그니처를 확인하십시오.

<!-- CODE_BLOCK_01: 대표적인 HTML 부트스트랩 및 웹 SDK 통합 -->
<!-- 파일: landing_page.html -->

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>애플리케이션 다운로드</title>
</head>
<body>
    <!-- 실제 HTTPS 다운로드 또는 스토어 라우팅 URL로 대체하십시오. -->
    <a
        id="download-app-btn"
        href="https://app.example.com/store-download"
    >
        앱 다운로드
    </a>

    <!-- 공식 CDN URL을 배포된 SDK 버전과 대조하여 확인하십시오. -->
    <script src="https://web.cdn.opoinstallcloud.com/openinstall.js"></script>

    <script>
    (function () {
        "use strict";

        function getSanitizedQueryParams() {
            var allowedKeys = new Set([
                "utm_source",
                "utm_medium",
                "utm_campaign",
                "inviter_id",
                "promo_code"
            ]);

            var validPattern = /^[A-Za-z0-9_.-]{1,64}$/;
            var result = Object.create(null);

            try {
                var query = new URLSearchParams(window.location.search);

                query.forEach(function (value, key) {
                    if (
                        allowedKeys.has(key) &&
                        validPattern.test(value) &&
                        !Object.prototype.hasOwnProperty.call(result, key)
                    ) {
                        result[key] = value;
                    }
                });
            } catch (error) {
                console.warn("URL 매개변수를 파싱할 수 없습니다.");
                return Object.create(null);
            }

            return result;
        }

        var downloadButton = document.getElementById(
            "download-app-btn"
        );

        // 앵커 href를 일반 브라우저 폴백으로 유지합니다.
        if (!downloadButton) {
            return;
        }

        if (typeof OpenInstall === "undefined") {
            console.warn(
                "웹 SDK를 사용할 수 없습니다; 다운로드 링크를 사용합니다."
            );
            return;
        }

        var customParams = getSanitizedQueryParams();

        try {
            new OpenInstall({
                appKey: "OPOINSTALL_APPKEY_PLACEHOLDER",
                data: customParams,

                onready: function () {
                    var sdkClient = this;

                    downloadButton.addEventListener(
                        "click",
                        function (event) {
                            try {
                                // 대표적인 SDK 메서드.
                                // 배포된 릴리스에 대한 동작 및 비동기 실패
                                // 처리 방식을 확인하십시오.
                                sdkClient.wakeupOrInstall();
                                event.preventDefault();
                            } catch (error) {
                                console.warn(
                                    "SDK 작업 실패; 브라우저가 폴백을 사용합니다."
                                );

                                // 기본 앵커 작업을 방지하지 마십시오.
                            }
                        }
                    );
                }
            });
        } catch (error) {
            console.warn(
                "웹 SDK 초기화 실패; 폴백 링크를 사용합니다."
            );
        }
    })();
    </script>
</body>
</html>

값 정책은 의도적으로 문자, 숫자, 밑줄, 하이픈, 마침표만 허용하며 최대 64자로 제한합니다.

공백이나 다른 문자가 포함된 값은 이 참조 구현에서 제외됩니다. 더 넓은 UTM 명명 요구사항이 있는 팀은 모든 URL 값이 지원된다고 단순히 가정하기보다, 필드별로 적절한 스키마를 정의하고 테스트해야 합니다.

마찬가지로 gclid 및 fbclid와 같은 광고 클릭 식별자는 일반적인 웹 어트리뷰션 개념으로 논의되지만, 이 샘플 허용 목록에는 포함되지 않습니다. 이를 지원하려면 별도의 데이터 수집, 보존 및 개인정보 보호 검토가 필요합니다.

SDK가 로드되지 않거나 초기화할 수 없을 때 폴백 앵커를 사용할 수 있습니다. SDK 작업은 비동기적으로 실패할 수 있으므로, 프로덕션 통합은 SDK의 문서화된 오류 처리 및 리다이렉트 동작을 추가로 따라야 합니다. 동기식 try/catch만으로는 완전한 비동기식 폴백 전략이 될 수 없습니다.

Android 네이티브 구현: SDK 설정, 매개변수 검증 및 호스트 애플리케이션 계약

설치 후 Android 애플리케이션은 네이티브 SDK에 지원되는 매개변수를 요청하고 이를 애플리케이션 정의 레코드로 정규화할 수 있습니다.

아래 샘플은 책임을 다음과 같이 명확히 분리합니다:

  1. OpoInstall SDK가 애플리케이션의 메인 프로세스에서 초기화됨.
  2. 네이티브 콜백이 후보 페이로드를 제공함.
  3. 애플리케이션이 페이로드를 검증하고 정규화함.
  4. 애플리케이션이 호스트 제공 스토리지 인터페이스를 통해 레코드를 저장함.
  5. 호스트 제공 처리 구성요소가 마케팅 어트리뷰션과 보상 승인을 조정함.
  6. 애플리케이션은 해당 구성요소가 검증된 처리 결과를 보고한 후에만 레코드를 완료로 표시함.

중요: 호스트 인터페이스는 OpoInstall API가 아닌 애플리케이션 정의 계약입니다. 이 예제는 호스트 애플리케이션이 구현을 제공할 것을 요구합니다. 여기에는 안전하지 않은 폴백 스토리지, 시뮬레이션된 분석 전달 또는 모의 보상 승인 응답은 포함되지 않습니다.

// CODE_BLOCK_02: 대표적인 Android 통합 (Kotlin)
// 파일: CustomApplication.kt 및 MainActivity.kt
//
// OpoInstall 임포트와 콜백을 배포된 AAR과 대조하여 확인하십시오.
// 매개변수 처리 전에 호스트 구현이 제공되어야 합니다.

package com.example.app

import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.opoinstall.api.OpoInstall
import com.opoinstall.api.OpoData
import com.opoinstall.api.OpoError
import com.opoinstall.api.ResultCallBack
import org.json.JSONObject
import java.util.UUID

enum class ParamFetchState {
    NOT_STARTED,
    FETCHING,
    RECEIVED,
    APPLIED,
    REJECTED,
    NO_MATCH,
    RETRYABLE_FAILURE
}

data class PendingAttributionRecord(
    val utmSource: String?,
    val utmMedium: String?,
    val utmCampaign: String?,
    val inviterId: String?,
    val promoCode: String?,
    val idempotencyKey: String,
    val createdAt: Long,
    val expiresAt: Long
) {
    val hasAttributionData: Boolean
        get() = listOf(
            utmSource, utmMedium, utmCampaign
        ).any { !it.isNullOrEmpty() }

    val hasRewardData: Boolean
        get() = !inviterId.isNullOrEmpty() ||
                !promoCode.isNullOrEmpty()

    val hasAnyData: Boolean
        get() = hasAttributionData || hasRewardData

    fun isValid(): Boolean {
        val now = System.currentTimeMillis()
        val pattern = Regex("^[A-Za-z0-9_.-]{1,64}$")

        val values = listOf(
            utmSource, utmMedium, utmCampaign,
            inviterId, promoCode
        )

        val validFields = values.all {
            it == null || pattern.matches(it)
        }

        val validWindow =
            expiresAt > createdAt &&
            expiresAt - createdAt <= 48L * 60 * 60 * 1000 &&
            now >= createdAt &&
            now <= expiresAt

        val validId = try {
            UUID.fromString(idempotencyKey)
            true
        } catch (_: IllegalArgumentException) {
            false
        }

        return hasAnyData &&
               validFields &&
               validWindow &&
               validId
    }
}

// 호스트 정의 보안 지속성 계약.
// 구현은 누락된 데이터와 스토리지 오류를 구분해야 하며 필요 시 계정 바인딩을 처리해야 합니다.
interface PendingTransactionStore {
    fun save(record: PendingAttributionRecord): Boolean
    fun load(): PendingAttributionRecord?
    fun clear(): Boolean
}

// 호스트 정의 코디네이터. 구현 책임은 독립적인 원격 측정/보상 확인,
// 멱등성, 재시도 처리 및 트랜잭션 ID 확인에 있습니다.
interface InstallParameterProcessor {
    fun process(
        record: PendingAttributionRecord,
        onComplete: (ProcessingOutcome) -> Unit
    )
}

enum class ProcessingOutcome {
    COMPLETED,
    REJECTED,
    RETRYABLE_FAILURE
}

// 애플리케이션 종속성이며 OpoInstall SDK API가 아닙니다.
object HostAppDependencies {
    var transactionStore: PendingTransactionStore? = null
    var parameterProcessor: InstallParameterProcessor? = null

    val isConfigured: Boolean
        get() = transactionStore != null &&
                parameterProcessor != null
}

class CustomApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        if (isMainProcess()) {
            OpoInstall.initialize(this)

            // 호스트 앱은 실제 DI/부트스트랩 메커니즘을 사용하여 여기서 종속성을 구성해야 합니다.
            //
            // HostAppDependencies.transactionStore =
            //     hostProvidedProtectedStore
            //
            // HostAppDependencies.parameterProcessor =
            //     hostProvidedInstallParameterProcessor
        }
    }

    private fun isMainProcess(): Boolean {
        val manager = getSystemService(
            Context.ACTIVITY_SERVICE
        ) as ActivityManager

        val currentPid = Process.myPid()

        return manager.runningAppProcesses?.any {
            it.pid == currentPid &&
            it.processName == packageName
        } == true
    }
}

class MainActivity : AppCompatActivity() {

    private val mainHandler = Handler(Looper.getMainLooper())

    private var state = ParamFetchState.NOT_STARTED
    private var activeRecord: PendingAttributionRecord? = null
    private var processingInFlight = false

    private val transactionTtlMs =
        24L * 60 * 60 * 1000

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        startParameterFlow()
    }

    private fun startParameterFlow() {
        val store = HostAppDependencies.transactionStore
        val processor = HostAppDependencies.parameterProcessor

        if (store == null || processor == null) {
            Log.w(
                "OpoInstall",
                "호스트 종속성을 사용할 수 없습니다; 처리가 일시 중단됨"
            )
            state = ParamFetchState.RETRYABLE_FAILURE
            return
        }

        // 대표적인 레코드 복구.
        // 프로덕션 스토리지는 읽기 오류를 보류 중인 레코드의 부재와 구분하여 보고해야 합니다.
        val existingRecord = try {
            store.load()
        } catch (error: Exception) {
            state = ParamFetchState.RETRYABLE_FAILURE
            return
        }

        if (existingRecord != null) {
            if (existingRecord.isValid()) {
                activeRecord = existingRecord
                state = ParamFetchState.RECEIVED
                processRecord(existingRecord)
            } else {
                // 유효하지 않거나 만료된 레코드는 처리되지 않습니다.
                if (!store.clear()) {
                    state = ParamFetchState.RETRYABLE_FAILURE
                    return
                }

                requestInstallParameters()
            }
        } else {
            requestInstallParameters()
        }
    }

    private fun requestInstallParameters() {
        if (!HostAppDependencies.isConfigured) {
            state = ParamFetchState.RETRYABLE_FAILURE
            return
        }

        if (state == ParamFetchState.FETCHING ||
            state == ParamFetchState.APPLIED ||
            state == ParamFetchState.NO_MATCH ||
            processingInFlight
        ) {
            return
        }

        state = ParamFetchState.FETCHING

        // 대표적인 SDK 콜백.
        // AAR에 대한 시그니처를 확인하십시오.
        OpoInstall.getInstance().getInstallParam(
            object : ResultCallBack<OpoData> {

                override fun onResult(opoData: OpoData?) {
                    mainHandler.post {
                        if (state != ParamFetchState.FETCHING) {
                            return@post
                        }

                        val rawData: Any? = opoData?.data

                        val payload = when (rawData) {
                            is String -> rawData
                            is JSONObject -> rawData.toString()
                            is Map<*, *> -> try {
                                JSONObject(rawData).toString()
                            } catch (_: Exception) {
                                null
                            }
                            else -> null
                        }

                        val record = payload?.let {
                            parseAndSanitizeRawPayload(it)
                        }

                        if (record == null) {
                            // 적격 데이터가 복구되지 않았습니다.
                            // 이것이 유기적(organic) 설치임을 확립하지 않습니다.
                            state = ParamFetchState.NO_MATCH
                            return@post
                        }

                        val store =
                            HostAppDependencies.transactionStore

                        if (store == null ||
                            !HostAppDependencies.isConfigured
                        ) {
                            state = ParamFetchState.RETRYABLE_FAILURE
                            return@post
                        }

                        activeRecord = record

                        // 저장에 실패할 경우 내구성 있는 복구를 주장하지 마십시오.
                        val saved = try {
                            store.save(record)
                        } catch (_: Exception) {
                            false
                        }

                        if (!saved) {
                            Log.w(
                                "OpoInstall",
                                "보류 레코드를 지속할 수 없습니다"
                            )
                            state = ParamFetchState.RETRYABLE_FAILURE
                            return@post
                        }

                        state = ParamFetchState.RECEIVED
                        processRecord(record)
                    }
                }

                override fun onError(error: OpoError?) {
                    mainHandler.post {
                        if (state == ParamFetchState.FETCHING) {
                            Log.w(
                                "OpoInstall",
                                "설치 매개변수 검색 실패"
                            )
                            state = ParamFetchState.RETRYABLE_FAILURE
                        }
                    }
                }
            }
        )
    }

    private fun parseAndSanitizeRawPayload(
        rawJson: String
    ): PendingAttributionRecord? {
        return try {
            if (rawJson.length > 4096) return null

            val json = JSONObject(rawJson)
            val pattern = Regex("^[A-Za-z0-9_.-]{1,64}$")

            fun field(key: String): String? =
                (json.opt(key) as? String)?.takeIf {
                    pattern.matches(it)
                }

            val now = System.currentTimeMillis()

            val record = PendingAttributionRecord(
                utmSource = field("utm_source"),
                utmMedium = field("utm_medium"),
                utmCampaign = field("utm_campaign"),
                inviterId = field("inviter_id"),
                promoCode = field("promo_code"),
                idempotencyKey = UUID.randomUUID().toString(),
                createdAt = now,
                expiresAt = now + transactionTtlMs
            )

            record.takeIf { it.isValid() }
        } catch (_: Exception) {
            null
        }
    }

    private fun processRecord(
        record: PendingAttributionRecord
    ) {
        val processor = HostAppDependencies.parameterProcessor
        val store = HostAppDependencies.transactionStore

        if (processor == null || store == null ||
            !record.isValid()
        ) {
            state = ParamFetchState.RETRYABLE_FAILURE
            return
        }

        if (processingInFlight) return

        processingInFlight = true

        // 호스트 프로세서는 모든 필수 채널별 결과가 확인되기 전까지 COMPLETED를 보고해서는 안 됩니다.
        processor.process(record) { outcome ->
            mainHandler.post {
                // 대체되었거나 오래된 트랜잭션은 무시합니다.
                if (activeRecord?.idempotencyKey !=
                    record.idempotencyKey
                ) {
                    return@post
                }

                processingInFlight = false

                when (outcome) {
                    ProcessingOutcome.COMPLETED -> {
                        finalizeRecord(
                            record,
                            ParamFetchState.APPLIED
                        )
                    }

                    ProcessingOutcome.REJECTED -> {
                        finalizeRecord(
                            record,
                            ParamFetchState.REJECTED
                        )
                    }

                    ProcessingOutcome.RETRYABLE_FAILURE -> {
                        state =
                            ParamFetchState.RETRYABLE_FAILURE
                    }
                }
            }
        }
    }

    private fun finalizeRecord(
        record: PendingAttributionRecord,
        terminalState: ParamFetchState
    ) {
        val store = HostAppDependencies.transactionStore
            ?: run {
                state = ParamFetchState.RETRYABLE_FAILURE
                return
            }

        if (activeRecord?.idempotencyKey !=
            record.idempotencyKey
        ) {
            return
        }

        // 이 예제는 의도적으로 내구성 있는 터미널 저널링 및 원자적 정리를 호스트 스토어에 위임합니다.
        // clear()는 정리 성공 여부를 보고해야 합니다.
        val cleared = try {
            store.clear()
        } catch (_: Exception) {
            false
        }

        if (!cleared) {
            state = ParamFetchState.RETRYABLE_FAILURE
            return
        }

        activeRecord = null
        state = terminalState

        Log.i(
            "OpoInstall",
            "설치 매개변수 처리가 해결됨"
        )
    }
}

Android 샘플에는 PendingTransactionStore 또는 InstallParameterProcessor의 기본 구현이 포함되어 있지 않습니다.

호스트 애플리케이션은 워크플로우를 호출하기 전에 이를 제공해야 합니다.

특히, 프로세서는 단순히 로깅 문이 실행되거나 비동기 요청이 시작되었다는 이유만으로 COMPLETED를 반환해서는 안 됩니다. 완료된 결과는 실제 페이로드에 대한 처리 요구사항과 일치해야 합니다:

  • UTM 전용 페이로드: 필수 어트리뷰션 이벤트가 구성된 분석 시스템이나 내구성 있는 이벤트 큐에 의해 안정적으로 수락됨.
  • 보상 전용 페이로드: 인증된 백엔드가 승인된 터미널 승인 결과를 반환함.
  • 혼합된 UTM 및 보상 페이로드: 두 필수 채널이 독립적으로 정의된 결과에 도달해야 하며, 한 채널이 다른 채널에 필요한 작업을 조기에 제거할 수 없음.

호스트 애플리케이션이 프로세스 간 복구를 요구하는 경우, 프로세서와 보호된 스토리지는 충분한 채널별 진행 상황을 유지하고 안정적인 트랜잭션 식별자를 보존해야 합니다.

위의 액티비티는 통합 경계를 보여주며, 애플리케이션별 구현을 대체하지 않습니다.

iOS 네이티브 구현: SDK 복원 및 애플리케이션 통합 계약

iOS 통합도 동일한 경계를 따릅니다.

네이티브 SDK는 후보 설치 매개변수를 제공합니다. 애플리케이션은 페이로드를 검증하고, 호스트 제공 스토리지 구성요소를 통해 보류 레코드를 유지하며, 비즈니스 처리를 애플리케이션 소유 코디네이터에 위임합니다.

이 코드는 대표적인 예시이며 설치된 OpoInstall iOS SDK 릴리스의 모듈 이름, 델리게이트 프로토콜 및 콜백 시그니처에 맞게 조정되어야 합니다.

// CODE_BLOCK_03: 대표적인 iOS 통합 (Swift)
// 파일: AppDelegate.swift
//
// 배포된 OpoInstall 프레임워크 버전과 모듈 및 SDK 메서드를 대조하십시오.

import UIKit
import libOpoInstallSDK

enum ParamFetchState: String {
    case notStarted = "NOT_STARTED"
    case fetching = "FETCHING"
    case received = "RECEIVED"
    case applied = "APPLIED"
    case rejected = "REJECTED"
    case noMatch = "NO_MATCH"
    case retryableFailure = "RETRYABLE_FAILURE"
}

struct PendingAttributionRecord: Codable {
    let utmSource: String?
    let utmMedium: String?
    let utmCampaign: String?
    let inviterId: String?
    let promoCode: String?
    let idempotencyKey: String
    let createdAt: TimeInterval
    let expiresAt: TimeInterval

    var hasAttributionData: Bool {
        return [
            utmSource, utmMedium, utmCampaign
        ].contains { !$0.isNilOrEmpty }
    }

    var hasRewardData: Bool {
        return !inviterId.isNilOrEmpty ||
               !promoCode.isNilOrEmpty
    }

    var hasAnyData: Bool {
        return hasAttributionData || hasRewardData
    }

    var isValid: Bool {
        let now = Date().timeIntervalSince1970
        let window = expiresAt - createdAt

        let pattern = "^[A-Za-z0-9_.-]{1,64}$"

        let fields = [
            utmSource, utmMedium, utmCampaign,
            inviterId, promoCode
        ]

        let validFields = fields.allSatisfy { field in
            guard let field = field else { return true }
            return field.range(
                of: pattern,
                options: .regularExpression
            ) != nil
        }

        return hasAnyData &&
               validFields &&
               UUID(uuidString: idempotencyKey) != nil &&
               window > 0 &&
               window <= 48 * 60 * 60 &&
               now >= createdAt &&
               now <= expiresAt
    }
}

private extension Optional where Wrapped == String {
    var isNilOrEmpty: Bool {
        return self?.isEmpty ?? true
    }
}

// 호스트 제공 보호된 지속성 계약.
protocol PendingTransactionStore {
    func save(_ record: PendingAttributionRecord) -> Bool
    func load() throws -> PendingAttributionRecord?
    func clear() -> Bool
}

enum ProcessingOutcome {
    case completed
    case rejected
    case retryableFailure
}

// 분석 및 보상 처리를 위한 호스트 제공 코디네이터.
protocol InstallParameterProcessor {
    func process(
        record: PendingAttributionRecord,
        completion: @escaping (ProcessingOutcome) -> Void
    )
}

// 애플리케이션 계약이며 OpoInstall API가 아닙니다.
struct HostAppDependencies {
    static var transactionStore: PendingTransactionStore?
    static var parameterProcessor: InstallParameterProcessor?

    static var isConfigured: Bool {
        return transactionStore != nil &&
               parameterProcessor != nil
    }
}

@UIApplicationMain
class AppDelegate:
    UIResponder,
    UIApplicationDelegate,
    OpoInstallDelegate {

    var window: UIWindow?

    private var state: ParamFetchState = .notStarted
    private var activeRecord: PendingAttributionRecord?
    private var processingInFlight = false

    private let transactionTTL: TimeInterval =
        24 * 60 * 60

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions:
            [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // 대표적인 SDK 초기화.
        OpoInstallSDK.initWith(self)

        // 호스트 앱은 매개변수 처리를 시작하기 전에 실제 종속성을 등록해야 합니다.
        //
        // HostAppDependencies.transactionStore =
        //     hostProvidedProtectedStore
        //
        // HostAppDependencies.parameterProcessor =
        //     hostProvidedInstallParameterProcessor

        startParameterFlow()
        return true
    }

    private func startParameterFlow() {
        guard HostAppDependencies.isConfigured,
              let store = HostAppDependencies.transactionStore
        else {
            print(
                "OpoInstall: 필요한 호스트 종속성을 사용할 수 없습니다"
            )
            state = .retryableFailure
            return
        }

        do {
            if let saved = try store.load() {
                if saved.isValid {
                    activeRecord = saved
                    state = .received
                    processRecord(saved)
                    return
                }

                guard store.clear() else {
                    state = .retryableFailure
                    return
                }
            }
        } catch {
            // 읽기 오류는 빈 스토어와 동일하지 않습니다.
            state = .retryableFailure
            return
        }

        requestInstallParameters()
    }

    private func requestInstallParameters() {
        guard HostAppDependencies.isConfigured,
              state != .fetching,
              state != .applied,
              state != .noMatch,
              !processingInFlight
        else {
            return
        }

        guard let manager = OpoInstallSDK.defaultManager()
        else {
            state = .retryableFailure
            return
        }

        state = .fetching

        // 대표적인 콜백 시그니처.
        manager.getInstallParmsCompleted({
            [weak self] (appData: OpoinstallData?) in

            DispatchQueue.main.async {
                guard let self = self,
                      self.state == .fetching
                else {
                    return
                }

                let rawData: Any? = appData?.data

                guard let record =
                    self.parseAndSanitizeRawPayload(rawData)
                else {
                    // 적격 커스텀 매개변수가 복구되지 않았습니다.
                    // 이것이 유기적 트래픽이라는 결정적인 증거는 아닙니다.
                    self.state = .noMatch
                    return
                }

                guard HostAppDependencies.isConfigured,
                      let store =
                          HostAppDependencies.transactionStore
                else {
                    self.state = .retryableFailure
                    return
                }

                self.activeRecord = record

                guard store.save(record) else {
                    // 지속성이 실패했을 때 내구성 있는 보류 레코드를 주장하지 마십시오.
                    self.state = .retryableFailure
                    return
                }

                self.state = .received
                self.processRecord(record)
            }
        })
    }

    private func parseAndSanitizeRawPayload(
        _ rawData: Any?
    ) -> PendingAttributionRecord? {

        guard let rawData = rawData else {
            return nil
        }

        var dictionary: [String: Any]?

        if let map = rawData as? [String: Any] {
            dictionary = map
        } else if let jsonString = rawData as? String,
                  jsonString.count <= 4096,
                  let data = jsonString.data(using: .utf8),
                  let parsed = try? JSONSerialization.jsonObject(
                      with: data
                  ) as? [String: Any] {
            dictionary = parsed
        }

        guard let payload = dictionary else {
            return nil
        }

        let pattern = "^[A-Za-z0-9_.-]{1,64}$"

        func field(_ key: String) -> String? {
            guard let value = payload[key] as? String,
                  value.range(
                    of: pattern,
                    options: .regularExpression
                  ) != nil
            else {
                return nil
            }

            return value
        }

        let now = Date().timeIntervalSince1970

        let record = PendingAttributionRecord(
            utmSource: field("utm_source"),
            utmMedium: field("utm_medium"),
            utmCampaign: field("utm_campaign"),
            inviterId: field("inviter_id"),
            promoCode: field("promo_code"),
            idempotencyKey: UUID().uuidString,
            createdAt: now,
            expiresAt: now + transactionTTL
        )

        return record.isValid ? record : nil
    }

    private func processRecord(
        _ record: PendingAttributionRecord
    ) {
        guard record.isValid,
              let processor =
                  HostAppDependencies.parameterProcessor
        else {
            state = .retryableFailure
            return
        }

        guard !processingInFlight else {
            return
        }

        processingInFlight = true

        // 호스트 프로세서는 필수 어트리뷰션과 보상 결과를 독립적으로 확인해야 합니다.
        processor.process(record: record) {
            [weak self] outcome in

            DispatchQueue.main.async {
                guard let self = self,
                      self.activeRecord?.idempotencyKey ==
                          record.idempotencyKey
                else {
                    return
                }

                self.processingInFlight = false

                switch outcome {
                case .completed:
                    self.finalizeRecord(
                        record,
                        terminalState: .applied
                    )

                case .rejected:
                    self.finalizeRecord(
                        record,
                        terminalState: .rejected
                    )

                case .retryableFailure:
                    self.state = .retryableFailure
                }
            }
        }
    }

    private func finalizeRecord(
        _ record: PendingAttributionRecord,
        terminalState: ParamFetchState
    ) {
        guard activeRecord?.idempotencyKey ==
                  record.idempotencyKey,
              let store =
                  HostAppDependencies.transactionStore
        else {
            state = .retryableFailure
            return
        }

        // 호스트 스토어는 애플리케이션의 신뢰성 요구에 맞춰 지속적인 완료 및 정리를 조정해야 합니다.
        guard store.clear() else {
            state = .retryableFailure
            return
        }

        activeRecord = nil
        state = terminalState

        print(
            "OpoInstall: 설치 매개변수 처리가 해결됨"
        )
    }
}

iOS 예제는 기본 UserDefaults 기반 보류 트랜잭션 스토어나 자동으로 성공을 보고하는 원격 측정 클라이언트를 제공하지 않습니다.

호스트 애플리케이션은 보안, 분석 및 계정 관리 요구사항에 적합한 스토리지 구현과 매개변수 프로세서를 제공해야 합니다.

보상 관련 값을 처리하는 경우, 프로세서는 서버 측 승인이 완료되기 전까지 .completed를 반환해서는 안 됩니다.

페이로드에 마케팅 데이터만 포함된 경우, 완료는 애플리케이션의 정의된 분석 수락 또는 안정적인 큐 요구사항을 반영해야 합니다.

혼합된 레코드의 경우, 프로세서는 각 채널에 대한 미해결 작업을 보존하고 오래된 콜백이 다른 트랜잭션을 최종 처리하지 못하도록 해야 합니다.

대표 코드에는 완전한 재시도 스케줄러와 내구성 있는 다중 채널 트랜잭션 저널이 의도적으로 생략되었습니다. 이러한 고려 사항은 호스트 애플리케이션 구현에 속하며 실제 수명 주기와 백엔드를 테스트해야 합니다.

프로덕션 통합 경계 및 검증 체크리스트

두 모바일 예제를 배포하기 전에 엔지니어링 팀은 다음 조건을 확인해야 합니다.

통합 경계 필수 프로덕션 검증
웹 SDK 배포된 릴리스에 대한 CDN 소스, 생성자 구성, 다운로드 메서드 및 비동기 실패 동작 확인.
네이티브 SDK 정확한 Android AAR 또는 iOS 프레임워크와 대조하여 패키지/모듈 이름, 초기화 요구사항, 콜백 시그니처 및 페이로드 유형 확인.
입력 스키마 지원되는 매개변수 이름, 값 길이, 문자 규칙, 비정상적이거나 중복된 키 처리 방식 확인.
보호된 스토리지 적절한 보호, 만료 및 오류 보고 기능을 갖춘 애플리케이션 제공 스토리지 구현 사용.
마케팅 원격 측정 성공적인 완료가 단순히 로깅이 아닌, 분석 이벤트가 안정적으로 수락되거나 내구성 있게 큐에 저장되었음을 의미하는지 확인.
보상 승인 인증된 애플리케이션 백엔드 검증 사용; 클라이언트 제공 매개변수만 기반으로 보상을 부여하지 마십시오.
혼합된 페이로드 각 필수 결과가 해결될 때까지 독립적으로 보류 중인 어트리뷰션 및 보상 작업을 보존.
멱등성 재시도 전반에 걸쳐 안정적인 트랜잭션 ID를 유지하고 서버에서 보상 고유성을 강제.
릴리스 빌드 실제 SDK 아티팩트로 Android 축소 빌드 및 iOS 빌드 검증.
개인정보 보호 검토 플랫폼 및 규제 요구사항과 대조하여 SDK의 실제 데이터 수집 및 매칭 동작 확인.

이들은 통합 수락 기준이며, 참조 코드나 특정 SDK 릴리스가 이미 프로덕션 테스트를 통과했다는 주장이 아닙니다.

매개변수 전달 전략 비교 매트릭스

매개변수 복원 전략의 방법론적 분석

모바일 획득 시스템은 웹 방문과 네이티브 애플리케이션 간에 리퍼러 및 어트리뷰션 컨텍스트를 보존하기 위해 다양한 접근 방식을 사용합니다.

적절한 접근 방식은 대상 플랫폼, 필수 매개변수 유형, 허용 가능한 사용자 마찰, 데이터 민감도 및 가용 플랫폼 메커니즘에 따라 달라집니다.

매개변수 전략 복원 범위 사용자 마찰 개인정보 보호 / 정책 고려사항 백엔드 승인 및 멱등성 필요
수동 프로모션 코드 입력 사용자 참여 및 코드 가용성에 따라 다름. 높음; 사용자가 코드를 수동으로 입력함. 코드 입력을 위한 기기 매칭은 필요 없으나, 일반적인 데이터 보호 규칙은 적용됨. 예, 보상 상환 시.
Google Play Install Referrer 지원되는 Android 흐름의 Google Play 설치. 성공적으로 구현 시 최소화됨. 공식 스토어 지원 전송; 다운스트림 처리 및 데이터 사용은 여전히 검토 필요. 예, 리퍼러 값이 보상을 트리거할 수 있는 경우.
OpoInstall 매개변수 복원 배포된 SDK 동작, 진입 경로, 플랫폼 및 허용된 매칭 메커니즘에 따라 다름. 복원 성공 시 보통 최소화됨. SDK 데이터 관행, 권한 및 플랫폼 규칙 평가 필요. 예, 보상 관련 매개변수의 경우.

정확도, 개인정보 준수, 사용자 마찰 및 보안 경계 평가

수동 프로모션 코드 입력은 기술적으로 합리적이지만 사용자 마찰을 유발할 수 있습니다.

Google Play Install Referrer는 기본 스토어 지원 Android 리퍼러 채널을 제공합니다.

타사 매개변수 복원 SDK는 적격한 지연 컨텍스트를 위해 애플리케이션 수준의 통합 계층을 제공할 수 있습니다. 그러나 실제 전달 범위, 기술 메커니즘 및 개인정보 보호 특성은 배포된 릴리스를 사용하여 평가되어야 합니다.

복원 방법과 관계없이 애플리케이션은 매개변수를 수신하는 것과 비즈니스 액션을 승인하는 것을 구분해야 합니다.

매개변수 복원, 상태 관리 및 SDK 통합 오류 문제 해결

클라이언트 측 상태 전환 관리: No-Match, 재시도 및 터미널 결과 처리

혼합된 어트리뷰션 및 보상 페이로드는 정리 전에 독립적인 완료 상태가 필요합니다.

매개변수 복원 워크플로우는 7가지 논리적 상태로 설명할 수 있습니다:

상태 의미
NOT_STARTED 검색 시도가 시작되지 않음.
FETCHING 애플리케이션이 설치 매개변수를 요청 중.
RECEIVED 후보 페이로드가 수신되어 처리를 위해 수락됨.
APPLIED 호스트 프로세서가 이 레코드에 필요한 모든 성공적인 결과를 완료했다고 보고함.
REJECTED 호스트 프로세서가 정의된 터미널 거부를 보고함.
NO_MATCH 검색 시도 결과 적격한 커스텀 매개변수가 생성되지 않음.
RETRYABLE_FAILURE 워크플로우를 현재 계속할 수 없으며 통제된 정책 하에 재시도될 수 있음.

이러한 상태는 애플리케이션 수준의 결과를 설명합니다. 호스트 애플리케이션의 분석, 보상, 스토리지 및 재시도를 위한 내구성 있는 조정을 대체하지 않습니다.

특히, 마케팅 전용 레코드는 보상 승인 없이 완료될 수 있지만, 보상 관련 레코드는 적절한 서버 응답을 받아야 합니다.

NO_MATCH 결과를 설치가 유기적이었다는 확정적인 증거로 해석해서는 안 됩니다.

매개변수 요청 타임아웃 및 네트워크 지연을 원활하게 해결하기

매개변수 복원은 연결성, 콜백 타이밍, 앱 시작 순서 또는 사용할 수 없는 백엔드 서비스의 영향을 받을 수 있습니다.

애플리케이션은 다음을 수행해야 합니다:

  • 네트워크 작업을 비동기적으로 유지.
  • 설치 매개변수 검색 중 메인 사용자 인터페이스가 차단되지 않도록 함.
  • 사용할 수 없는 SDK 관리자와 빈 매개변수 결과를 구분.
  • 로컬 지속성 오류를 레코드가 없다는 확인이 아닌 처리 실패로 취급.
  • 포그라운드 전환 시마다 반복적으로 요청을 발행하는 대신 제한된 애플리케이션 정책에 따라 재시도.
  • 작업을 재시도해야 할 때 기존 보류 레코드를 보존.

자동으로 재시도하는 모든 구현은 중복 요청을 방지하고 대체된 트랜잭션에 속하는 콜백을 거부해야 합니다.

애플리케이션 덮어쓰기 설치 및 오래된 매개변수 버퍼 관리

매개변수 복원은 일반적으로 적격한 설치 후 첫 번째 실행을 대상으로 하지만, 애플리케이션 업데이트와 반복적인 실행은 이전에 저장된 로컬 상태를 노출할 수 있습니다.

엔지니어링 팀은 복원 시도가 언제 적격한지, 이미 소비된 레코드가 어떻게 식별되는지, 보류 데이터가 어떻게 만료되는지 정의해야 합니다.

보상 관련 매개변수의 경우 서버 측 멱등성이 필수적입니다. 클라이언트 측 플래그만으로는 중복 보상에 대한 충분한 보호가 되지 않습니다.

애플리케이션이 보류 트랜잭션을 로컬에서 유지하는 경우, 필수 처리 결과가 내구성 있게 해결된 후에만 레코드를 제거해야 합니다.

Android 다중 프로세스 재초기화 문제 방지

다중 프로세스를 사용하는 Android 애플리케이션은 Application.onCreate()를 두 번 이상 실행할 수 있습니다.

네이티브 SDK가 기본 애플리케이션 프로세스에서만 초기화되어야 하는 경우, 초기화 전 현재 프로세스를 확인해야 합니다.

Android 샘플은 하나의 프로세스 이름 비교 전략을 보여줍니다. 프로덕션 애플리케이션은 지원되는 Android 버전과 프로세스 아키텍처를 대조하여 접근 방식을 확인해야 합니다.

클라이언트 콜백 시그니처 불일치 및 코드 슈링커(Code Shrinker) 난독화 규칙 디버깅

타사 SDK의 패키지 이름, 콜백 인터페이스 및 클래스 선언은 릴리스마다 다를 수 있습니다.

통합 실패 시 개발자는 예제 인터페이스가 변경되지 않았다고 가정하지 말고 배포된 SDK를 검증해야 합니다.

Android 릴리스 빌드의 경우:

  • 정확한 SDK 아티팩트와 함께 제공되는 소비자 R8 / ProGuard 규칙을 선호.
  • 증거 없이 광범위한 패키지 단위 -keep 또는 -dontwarn 규칙 사용 지양.
  • 좁은 범위의 보조 규칙을 추가하기 전에 실제 리플렉션 요구사항을 조사.
  • 축소된 릴리스 구성에서 설치 매개변수 콜백 테스트.

이 가이드의 코드는 대표적인 통합 예시이며, 해당 빌드 및 런타임 증거 없이 릴리스 검증이 완료되었다고 설명해서는 안 됩니다.

자주 묻는 질문 (FAQ)

사용자가 앱 스토어를 통해 앱을 설치할 때 추적 매개변수는 어떻게 유지되나요?
추적 매개변수는 지연된 매개변수 복원 파이프라인을 통해 유지됩니다. 사용자가 웹 랜딩 페이지와 상호작용하면 OpoInstall 웹 JS SDK는 쿼리 매개변수를 패키징하여 어트리뷰션 백엔드로 전송합니다. 사용자가 새로 설치된 애플리케이션을 처음 실행하면, 네이티브 OpoInstall SDK가 백엔드 또는 Play Store API를 쿼리하여 세션을 매칭하고 커스텀 쿼리 매개변수를 앱에 전달합니다.
iOS 및 Android의 클립보드 페이로드에는 어떤 개인정보 보호 제한이 적용되나요?
iOS 14 이상에서는 애플리케이션 간에 일반 페이스트보드에 접근할 경우 시스템 알림 배너가 트리거될 수 있으며, iOS 16에서는 `UIPasteControl`과 같은 컨트롤을 통해 시작되지 않는 한 프로그래밍 방식의 읽기에 대해 붙여넣기 권한 프롬프트를 도입했습니다. Android 10 이상에서는 클립보드 접근이 포그라운드 애플리케이션으로 제한되며, Android 13은 콘텐츠가 복사될 때 시스템 UI 확인을 제공합니다. 따라서 클립보드 페이로드는 제한 없는 기본 채널이 아닌, 선택적이고 개인정보를 민감하게 다루는 전송 신호로 취급해야 합니다.
추적 매개변수가 동적 인앱 라우팅을 위해 중첩된 JSON 페이로드를 전달할 수 있나요?
기본 네트워크 프로토콜은 문자열화된 JSON 페이로드를 전송할 수 있지만, 이 가이드의 참조 구현을 포함한 프로덕션 보안 아키텍처는 스크립트 인젝션 및 메모리 경계 위험을 완화하기 위해 엄격하게 허용된 스칼라 스키마(예: 최대 64자의 영숫자 문자열 및 캠페인 토큰)를 강제합니다. 복잡한 인앱 라우팅이 필요한 경우, 애플리케이션은 불투명한 라우트 식별자를 전달하고 클라이언트에서 유효성 검증되지 않은 중첩 JSON 구조를 직접 파싱하는 대신 신뢰할 수 있는 백엔드 구성에서 풍부한 내비게이션 경로를 안전하게 해결해야 합니다.

요약 및 의사결정 프레임워크

웹 클릭을 모바일 앱 스토어 설치와 연결하려면 브라우저, 앱 스토어, 네이티브 애플리케이션 간의 기술적 경계를 존중하는 매개변수 복원 접근 방식이 필요합니다.

Android의 경우, Google Play Install Referrer API가 지원되는 설치 흐름에 대한 공식 리퍼러 메커니즘을 제공합니다. iOS의 경우 임의의 웹 매개변수가 앱 스토어를 통해 자동으로 전달되지 않으므로, 지연된 복원을 위해서는 독립적으로 검증된 개인정보 보호를 고려한 접근 방식이 필요합니다.

실제 OpoInstall 통합은 허용된(allowlisted) 랜딩 페이지 매개변수를 캡처하고 네이티브 SDK를 통해 적격한 컨텍스트를 요청할 수 있습니다. 애플리케이션은 마케팅 어트리뷰션, 내비게이션 또는 프로모션 워크플로우에 데이터를 사용하기 전에 복구된 데이터를 정규화하고 검증해야 합니다.

주요 엔지니어링 구분점은 매개변수 복원과 애플리케이션 측 처리 사이에 있습니다. 매개변수를 복구했다고 해서 분석 전달, 보상 자격 또는 성공적인 승인이 보장되는 것은 아닙니다.

참조 코드는 SDK 통합 경계와 애플리케이션 정의 계약을 보여줍니다. 프로덕션 팀은 자체 보호된 스토리지, 신뢰할 수 있는 분석 처리, 서버 측 자격 검증 및 수명 주기 조정을 제공해야 합니다.

벤더 통합 요구사항을 살펴보려면 OpoInstall SDK 문서 또는 OpoInstall 개발자 등록 페이지를 참조하십시오.

관련 자료

개념: 앱 매개변수 설치, Google Play 설치 리퍼러, 지연된 매개변수 복원, 어트리뷰션 원격 측정, 서버 승인 경계.

기술: 웹 JS SDK, 네이티브 모바일 SDK, URL 쿼리 매개변수, Android Keystore, iOS Keychain Services.

표준: IETF RFC 3986 URI 구문, OWASP 모바일 애플리케이션 보안 검증 표준, W3C 클립보드 API 및 이벤트.

API: OpoInstall 네이티브 설치 매개변수 API, Google Play 설치 리퍼러 API, Apple UIPasteboard API, Android 클립보드 API.

공식 문서 및 참조:

Share this article