서버 대 서버 - SKAdNetwork 4 구현 가이드

문서

SKAdNetwork 4.0 구현 가이드

서버 간 연동을 통해 개인 정보 보호 중심의 iOS 어트리뷰션을 위한 Apple의 SKAdNetwork 4.0 프레임워크를 구현하여, 사용자 개인 정보를 보호하면서 향상된 포스트백 윈도우와 대략적인 전환 값으로 앱 설치 캠페인을 측정할 수 있습니다.


개요

SKAdNetwork 4.0이란?

SKAdNetwork(SKAN)는 강력한 검증 및 추적을 위한 서버 간 구현을 통해 사용자의 개인 정보를 보호하면서 iOS 앱 인스톨 광고 캠페인을 측정할 수 있는 Apple의 개인 정보 보호 중심 어트리뷰션 프레임워크입니다.

이 프레임워크는 Apple의 규정된 방법을 통해 사용자 개인 정보를 유지하면서 어트리뷰션의 모든 중요한 측면을 처리하므로, iOS 14.5 이후 환경에서 활동하는 모바일 마케터에게 필수적입니다.


주요 특징

SKAdNetwork 4.0은 캠페인 최적화를 위한 향상된 측정 기능과 유연성을 도입했습니다.

  • 강력한 검증: 사기 방지 기능이 내장된 모든 네트워크의 포스트백을 집계합니다.
  • 동적 전환 관리: 전환 값 인코딩을 위한 대시보드 구성
  • 향상된 리포팅: 강화된 마케팅 파라미터 및 세분화된 데이터 인사이트 제공
  • 안전한 파트너 포스트백: 디코딩된 전환 가치 및 구매 추적
  • 포괄적인 검증: 구현 검증을 위한 이벤트 및 세션 추적 기능 강화
  • 다중 포스트백 윈도우: 0~2일, 3~7일, 8~35일 동안 자동화된 타임스탬프 관리
  • 구매 추적: 광고 구매화 및 통화 사양을 통한 정기 구매 지원

전제 조건

SKAN 4.0을 구현하기 전에 SKAdNetwork의 개념과 이전 버전을 숙지하세요.


Singular SKAdNetwork 솔루션

Singular의 SKAdNetwork 솔루션은 클라이언트 측 구현부터 포스트백 처리 및 캠페인 최적화에 이르기까지 엔드 투 엔드 어트리뷰션 관리를 제공합니다.

솔루션 구성 요소

플랫폼 특징

어트리뷰션 및 애널리틱스 워크플로우 전반에 걸친 포괄적인 SKAdNetwork 지원.

컴포넌트 기능
클라이언트 측 코드 SKAdNetwork 프레임워크 등록 및 전환 가치 관리를 위한 네이티브 iOS 코드 샘플. 전환 가치 API 엔드포인트를 사용하여 대체 서버 측 접근 방식 사용 가능
포스트백 처리 연동 리포팅을 통해 모든 애드 네트워크의 포스트백을 검증하고 집계합니다.
사기 방지 암호화 서명 검증, 트랜잭션 ID 중복 제거, 서명되지 않은 데이터에 대한 보안 파라미터 확인
전환 관리 설치 후 활동을 전환 값으로 인코딩하기 위한 동적 대시보드 구성
리포팅 세분화된 분석을 위한 마케팅 파라미터로 캠페인 ID 변환 및 보강
파트너 포스트백 파트너 최적화를 위해 이벤트 및 구매으로 디코딩된 전환 값 전송

구현 아키텍처

두 부분으로 구성된 구현

클라이언트 측 SKAdNetwork 구현은 크게 두 가지 구성 요소로 이루어집니다.

1. 클라이언트 측 구현(필수):

  • 앱 실행 시 SKAdNetwork 프레임워크 등록
  • 설치 후 활동에 기반한 지능형 전환 가치 관리
  • SKAdNetwork 어트리뷰션을 활용한 캠페인 최적화에 필수적입니다.
  • 인스톨 후 연관된 활동 추적 가능

2. 서버 측 연동 업데이트 (권장):

  • 클라이언트 측 구현 검증 및 문제 해결
  • 세션이벤트엔드포인트를 통해 전송된 이벤트와 세션을 보강합니다.
  • SKAdNetwork 메타데이터 유효성 검사 활성화
  • 적절한 앱 측 구현 확인

클라이언트 측 구현

SKAN 4.0 기능으로 최적의 캠페인 측정을 위해 Singular의 네이티브 iOS 코드 샘플을 사용하여 SKAdNetwork 프레임워크 등록 및 전환 가치 관리를 구현합니다.

구현 책임

핵심 기능

코드 샘플은 SKAdNetwork 등록 및 지능형 전환 가치 관리를 지원합니다.

  1. SKAdNetwork 등록: 앱 출시 직후 프레임워크에 앱을 등록하여 어트리뷰션을 활성화합니다.
  2. 전환 가치 관리:
    • Singular 엔드포인트와 동기식으로 통신하여 다음 전환 가치를 수신합니다.
    • 세션, 이벤트, 구매 등을 Singular에 보고합니다.
    • 인스톨 후 구성된 활동을 나타내는 인코딩된 전환 가치 수신
    • 검증 및 계산을 위해 측정 기간별로 SKAdNetwork 메타데이터를 수집합니다.

메타데이터 수집

유효성 검증과 전환 가치 산정을 위해 필수적인 SKAdNetwork 메타데이터를 수집합니다.

  • 기본 SKAdNetwork 프레임워크에 대한 첫 번째 호출 타임스탬프
  • 기본 SKAdNetwork 프레임워크에 대한 마지막 호출 타임스탬프
  • 마지막으로 업데이트된 포스트백 값(거칠게 및 세밀하게 모두)
  • 디바이스별 총 구매 및 총 광고 구매 창출 구매

연동 흐름

엔드 투 엔드 프로세스

클라이언트 측 전환 관리부터 포스트백 처리까지 S2S 고객을 위한 완벽한 SKAdNetwork 플로우를 제공합니다.

SKAdNetwork 4.0 S2S Integration Flow

  1. 전환 가치 요청: 앱 코드가 Singular 엔드포인트와 동기식으로 통신하여 세션, 이벤트, 구매에 기반한 최신 전환 가치를 가져옵니다.
  2. 프레임워크 업데이트: 앱이 수신된 전환 값으로 SKAdNetwork 프레임워크를 업데이트합니다.
  3. 메타데이터 보강: 앱이 검증을 위해 SKAdNetwork 메타데이터로 S2S 이벤트 및 세션을 보강합니다.
  4. 타이머 만료: 타이머 만료 후, SKAdNetwork가 포스트백을 애드 네트워크에 전송합니다.
  5. 포스트백 포워딩: 네트워크가 포스트백을 Singular(보안 설정 또는 일반)로 포워딩합니다.
  6. 포스트백 처리: Singular가 포스트백을 처리합니다:
    • 암호화 서명 검증
    • 구성된 모델을 사용하여 변환 값 디코딩
    • 파트너 연동의 네트워크 정보로 강화
    • 포스트백을 통해 디코딩된 데이터를 BI 및 파트너에게 보내기

데이터 분리: 테스트 및 검증 과정에서 기존 데이터 세트와의 혼용을 방지하기 위해 별도의 보고서, API, ETL 테이블, 포스트백을 통해 SKAdNetwork 데이터(인스톨 및 디코딩된 이벤트)에 액세스할 수 있습니다.


SKAdNetwork 인터페이스

어트리뷰션 추적, 전환 가치 업데이트, 구매 관리를 위한 메소드를 제공하는 SKAdNetwork 연동을 위한 완벽한 인터페이스 정의입니다.

메소드 정의

어트리뷰션 등록

앱이 처음 실행될 때 SKAN 어트리뷰션 추적을 초기화하여 초기 전환 값을 0으로 설정하고 기준 타임스탬프를 설정합니다.

Objective-C
+ (void)registerAppForAdNetworkAttribution;

전환 가치 관리

메소드는 앱에서 캡처한 설치 후 활동과 동적으로 구성된 전환 모델을 기반으로 전환 값을 업데이트합니다.

지원되는 활동:

  • 세션: 리텐션 측정에 중요
  • 전환 이벤트: 인스톨 후 이벤트 측정에 중요
  • 구매 이벤트: 구매 이벤트: 구매 측정에 필수

세션 추적

리텐션 및 코호트 분석을 위한 세션 기반 추적과 업데이트 후 작업을 위한 선택적 완료 핸들러를 관리합니다.

Objective-C
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

이벤트 추적

데이터를 Singular로 전송하기 전에 전환 이벤트 추적을 처리하여 이벤트 컨텍스트에 따라 전환 값을 업데이트합니다.

Objective-C
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

구매 관리

구매 이벤트를 추적하여 광고 구매 창출과 일반 구매에 대한 별도의 합계를 유지합니다. 전환 값 업데이트 전에 반드시 호출해야 합니다.

Objective-C
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

데이터 검색

전환 값, 타임스탬프, 구매 추적을 포함한 포괄적인 SKAN 데이터 사전을 반환합니다.

사전 포함:

  • 현재 및 이전의 세분화된 전환 값
  • 여러 포스트백 윈도우에 걸친 대략적인 값
  • 창 잠금 타임스탬프
  • 통화별 구매 추적
  • 광고 구매 창출과 일반 구매에 대한 별도 추적
Objective-C
+ (NSDictionary *)getSkanDetails;

구현 참고 사항

  • 메서드는 메인 스레드 차단을 방지하기 위해 비동기 패턴을 사용합니다.
  • 구매 추적은 전환 가치 업데이트보다 선행되어야 합니다.
  • 세분화된(0-63) 전환 값과 거친(낮음/중간/높음) 전환 값 모두 지원
  • 다양한 포스트백 기간에 대해 별도의 추적 유지
  • 완료 핸들러를 통해 포괄적인 오류 처리 구현

완전한 인터페이스 코드

SKANSnippet.h

Objective-C
//SKANSnippet.h

#import <Foundation/Foundation.h>

@interface SKANSnippet : NSObject

// Register for SKAdNetwork attribution.
// Call this method as soon as possible on first app launch.
// Sets conversion value to 0 and updates timestamp for additional processing.
+ (void)registerAppForAdNetworkAttribution;

// Track retention and cohorts by calling for each app open.
// Reports session details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track conversion events by calling after each event and before sending to Singular.
// Reports event details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track revenue by calling before every revenue event.
// Updates total revenue for next conversion value calculation.
// Note:
// 1. Call before 'updateConversionValuesAsync' to ensure revenue included
// 2. Avoid calling on event retries to prevent double-counting
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

// Gets current fine, coarse, window locked values saved in dictionary.
// Contains all relevant SKAN values including:
// - skan_current_conversion_value
// - prev_fine_value  
// - skan_first_call_to_skadnetwork_timestamp
// - skan_last_call_to_skadnetwork_timestamp
// - skan_total_revenue_by_currency
// - skan_total_admon_revenue_by_currency
// - p0_coarse, p1_coarse, p2_coarse
// - p0_window_lock, p1_window_lock, p2_window_lock
// - Previous coarse values and revenue per window
+ (NSDictionary *)getSkanDetails;

@end

SKAdNetwork 구현

여러 포스트백 윈도우에서 어트리뷰션 추적, 전환 값 및 구매 보고를 관리하는 Apple의 SKAdNetwork 4.0 인터페이스에 대한 완전한 구현 코드입니다.

구현 개요

상수 및 구성

구현은 사용자 활동과 전환을 추적하기 위한 세 가지 포스트백 윈도우를 정의합니다.

Objective-C
static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2;  // 48 hours
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7;  // 7 days
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35; // 35 days

주요 기능

  • 어트리뷰션 등록: 초기 앱 어트리뷰션 설정 및 최초 전환 가치 추적을 처리합니다.
  • 전환 관리: 여러 포스트백 윈도우에서 전환 값을 업데이트하고 추적합니다.
  • 구매 추적: 광고 구매화 및 정기적인 구매 이벤트에 대한 별도 추적 유지
  • 데이터 지속성: NSUserDefaults를 사용하여 앱 세션 전반에 걸쳐 SKAN 관련 데이터를 저장합니다.
  • 스레드 안전: 네트워크 호출 중 스레드 안전 작업을 위해 NSLock을 구현합니다.

데이터 저장 구조

  • 세분화된 변환 값(0-63)
  • 거친 값(0-2로 매핑된 낮음/중간/높음)
  • 각 포스트백 기간에 대한 통화별 구매 추적
  • 포스트백 기간 및 잠금 상태에 대한 타임스탬프 관리
  • 미세 및 거친 전환 모두에 대한 이전 값 추적

개인정보 보호 고려 사항

  • iOS 15.4+ 및 iOS 16.1+ 특정 기능 구현
  • Apple의 개인정보 보호 가이드라인에 따라 포스트백 전환 값 업데이트를 처리합니다.
  • 정확한 어트리뷰션을 보장하기 위해 다양한 구매 유형에 대해 별도의 트래킹을 유지합니다.

기술 노트

  • 네트워크 호출 및 값 업데이트에 비동기 작업 사용
  • 전환 값에 대한 오류 처리 및 유효성 검사 구현
  • 기존 및 거친 전환 가치 추적 모두 지원
  • 기간과 요구사항이 다른 여러 포스트백 윈도우 관리

완전한 구현 코드

SKANSnippet.m

중요: 프로덕션 환경에서 사용하기 전에 플레이스홀더 값(API 키, 앱 버전 등)을 애플리케이션의 실제 값으로 교체하세요.

Objective-C
//  SKANSnippet.m

#import "SKANSnippet.h"
#import <StoreKit/SKAdNetwork.h>
#import <UIKit/UIKit.h>

#define SESSION_EVENT_NAME @"__SESSION__"
#define SINGULAR_API_URL @"https://sdk-api-v1.singular.net/api/v2/conversion_value"

// SKAN Keys for NSUserDefaults persistency and requests
#define CONVERSION_VALUE_KEY @"skan_current_conversion_value"
#define FIRST_SKAN_CALL_TIMESTAMP @"skan_first_call_to_skadnetwork_timestamp"
#define LAST_SKAN_CALL_TIMESTAMP @"skan_last_call_to_skadnetwork_timestamp"
#define TOTAL_REVENUE_BY_CURRENCY @"skan_total_revenue_by_currency"
#define TOTAL_ADMON_REVENUE_BY_CURRENCY @"skan_total_admon_revenue_by_currency"
#define SKAN_UPDATED_CONVERSION_VALUE @"conversion_value"
#define SKAN_UPDATED_COARSE_VALUE @"skan_updated_coarse_value"
#define SKAN_UPDATED_LOCK_WINDOW_VALUE @"skan_updated_lock_window_value"

#define P0_COARSE @"p0_coarse"
#define P1_COARSE @"p1_coarse"
#define P2_COARSE @"p2_coarse"
#define P0_WINDOW_LOCK_TS @"p0_window_lock"
#define P1_WINDOW_LOCK_TS @"p1_window_lock"
#define P2_WINDOW_LOCK_TS @"p2_window_lock"

#define P0_PREV_FINE_VALUE @"prev_fine_value"
#define P0_PREV_COARSE_VALUE @"p0_prev_coarse_value"
#define P1_PREV_COARSE_VALUE @"p1_prev_coarse_value"
#define P2_PREV_COARSE_VALUE @"p2_prev_coarse_value"

#define TOTAL_REVENUE_P0 @"p0_total_iap_revenue"
#define TOTAL_REVENUE_P1 @"p1_total_iap_revenue"
#define TOTAL_REVENUE_P2 @"p2_total_iap_revenue"
#define TOTAL_ADMON_REVENUE_P0 @"p0_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P1 @"p1_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P2 @"p2_total_admon_revenue"

@implementation SKANSnippet

static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2; //48 hours in sec
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7;
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35;

static NSLock *lockObject;

+ (void)registerAppForAdNetworkAttribution {
    if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
        return;
    }
    
    if (@available(iOS 15.4, *)) {
        [SKAdNetwork updatePostbackConversionValue:0 completionHandler:nil];
        [SKANSnippet setFirstSkanCallTimestamp];
        [SKANSnippet setLastSkanCallTimestamp];
        [SKANSnippet valuesHasBeenUpdated:@(0) coarseValue:nil lockWindow:NO];
    } 
}

+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
    [SKANSnippet updateConversionValuesAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}

+ (void)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
        if ([SKANSnippet isSkanWindowOver]) {
            return;
        }
        
        [SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
}

+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon {
    // Update total revenues
    if (amount == 0 || !currency ) {
        return;
    }
    
    [SKANSnippet addToTotalRevenue:@(amount) withCurrency:currency isAdmon:admon];
}

+ (NSDictionary *)getSkanDetails {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSMutableDictionary *res = [NSMutableDictionary dictionary];
    //current fine
    [res setValue:[[userDefaults valueForKey:CONVERSION_VALUE_KEY] stringValue] forKey:CONVERSION_VALUE_KEY];
    //prev fine
    [res setValue:[[userDefaults valueForKey:P0_PREV_FINE_VALUE] stringValue] forKey:P0_PREV_FINE_VALUE];
    //current coarse
    [res setValue:[[userDefaults valueForKey:P0_COARSE] stringValue] forKey:P0_COARSE];
    [res setValue:[[userDefaults valueForKey:P1_COARSE] stringValue] forKey:P1_COARSE];
    [res setValue:[[userDefaults valueForKey:P2_COARSE] stringValue] forKey:P2_COARSE];
    //prev coarse
    [res setValue:[[userDefaults valueForKey:P0_PREV_COARSE_VALUE] stringValue] forKey:P0_PREV_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P1_PREV_COARSE_VALUE] stringValue] forKey:P1_PREV_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P2_PREV_COARSE_VALUE] stringValue] forKey:P2_PREV_COARSE_VALUE];
    //lock windows ts
    [res setValue:[[userDefaults valueForKey:P0_WINDOW_LOCK_TS] stringValue] forKey:P0_WINDOW_LOCK_TS];
    [res setValue:[[userDefaults valueForKey:P1_WINDOW_LOCK_TS] stringValue] forKey:P1_WINDOW_LOCK_TS];
    [res setValue:[[userDefaults valueForKey:P2_WINDOW_LOCK_TS] stringValue] forKey:P2_WINDOW_LOCK_TS];
    //total revenues
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_BY_CURRENCY] forKey:TOTAL_REVENUE_BY_CURRENCY];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_BY_CURRENCY] forKey:TOTAL_ADMON_REVENUE_BY_CURRENCY];
    //revenue per window
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P0] forKey:TOTAL_REVENUE_P0];
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P1] forKey:TOTAL_REVENUE_P1];
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P2] forKey:TOTAL_REVENUE_P2];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P0] forKey:TOTAL_ADMON_REVENUE_P0];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P1] forKey:TOTAL_ADMON_REVENUE_P1];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P2] forKey:TOTAL_ADMON_REVENUE_P2];
    //skan TS
    [res setValue:[[userDefaults valueForKey:LAST_SKAN_CALL_TIMESTAMP] stringValue] forKey:LAST_SKAN_CALL_TIMESTAMP];
    [res setValue:[[userDefaults valueForKey:FIRST_SKAN_CALL_TIMESTAMP] stringValue] forKey:FIRST_SKAN_CALL_TIMESTAMP];
    
    return res;
}


#pragma mark - internal
+ (BOOL)validateValues:(NSNumber *)conversionValue coarse:(NSNumber *)coarseValue{
    if ([conversionValue intValue] < 0 || 63 < [conversionValue intValue]) {
        return NO;
    }
    
    if (coarseValue) {
        if ([coarseValue intValue] > 2 || [coarseValue intValue] < 0) {
            return NO;
        }
    }
    
    return YES;
}

+ (NSURLComponents *)prepareQueryParams:(NSString *)bundleIdentifier eventName:(NSString *)eventName {
    NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
    
    NSString *API_KEY = @"YOUR API KEY";
    NSString *APP_VERSION = @"YOUR APP VERSION";
    NSString *IDFV = @"IDFV";
    NSString *IDFA = @"IDFA";
    
    NSMutableArray *queryItems = [@[
        [NSURLQueryItem queryItemWithName:@"a" value:API_KEY],
        [NSURLQueryItem queryItemWithName:@"v" value:[[UIDevice currentDevice] systemVersion]],
        [NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
        [NSURLQueryItem queryItemWithName:@"app_v" value:APP_VERSION],
        [NSURLQueryItem queryItemWithName:@"n" value:eventName],
        [NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
        [NSURLQueryItem queryItemWithName:@"idfv" value:IDFV],
        [NSURLQueryItem queryItemWithName:@"idfa" value:IDFA]
    ] mutableCopy];
    
    NSDictionary *skanValues = [SKANSnippet getSkanDetails];
    [skanValues enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        if ([obj isKindOfClass:[NSDictionary class]]) {
            [queryItems addObject:[NSURLQueryItem queryItemWithName:key value:[SKANSnippet dictionaryToJsonString:obj]]];
        } else {
            [queryItems addObject:[NSURLQueryItem queryItemWithName:key value:obj]];
        }
    }];
    
    components.queryItems = queryItems;
    
    return components;
}

+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
    if (!lockObject) {
        lockObject = [NSLock new];
    }
    
    @try {
        // Making the lock async so it will not freeze the calling thread
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
            
            [lockObject lock];
            NSString *bundleIdentifier = @"YOUR BUNDLE IDENTIFIER";
            NSURLComponents *components = [SKANSnippet prepareQueryParams:bundleIdentifier eventName:eventName];
            
            [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                         completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                if (error) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil, nil, NO, error);
                    }
                    
                    return;
                }
                
                NSDictionary *parsedResponse = [SKANSnippet jsonDataToDictionary:data];
                
                if (!parsedResponse) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]);
                    }
                    
                    return;
                }
                
                NSNumber *conversionValue = [parsedResponse objectForKey:SKAN_UPDATED_CONVERSION_VALUE];
                NSNumber *coarseValue = [parsedResponse objectForKey:SKAN_UPDATED_COARSE_VALUE];
                BOOL lockWindow = [[parsedResponse objectForKey:SKAN_UPDATED_LOCK_WINDOW_VALUE] boolValue];
                
                
                if (!conversionValue) {
                    [lockObject unlock];
                    NSString *status = [parsedResponse objectForKey:@"status"];
                    
                    if (!status || ![status isEqualToString:@"ok"]) {
                        if (handler) {
                            NSString *reason = [parsedResponse objectForKey:@"reason"];
                            if (!reason) {
                                reason = @"Got error from server";
                            }
                            
                            handler(nil, nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                      code:0
                                                                  userInfo:@{NSLocalizedDescriptionKey:reason}]);
                        }
                    }
                    
                    return;
                }
                
                
                if(![SKANSnippet validateValues:conversionValue coarse:coarseValue]) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Illegal values received"}]);
                    }
                    
                    return;
                }
                
                if (![SKANSnippet getFirstSkanCallTimestamp]) {
                    [SKANSnippet setFirstSkanCallTimestamp];
                }
                
                [SKANSnippet setConversionValues:conversionValue coarseValue:coarseValue lockWindow:lockWindow handler:handler];
                
                [lockObject unlock];
            }] resume];
        });
    } @catch (id exception) {
        NSLog(@"%@", exception);
    }
}

+ (void)setFirstSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] forKey:FIRST_SKAN_CALL_TIMESTAMP];
    
}

+ (void)setLastSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] forKey:LAST_SKAN_CALL_TIMESTAMP];
    
}

+ (NSString*)dictionaryToJsonString:(NSDictionary*)dictionary {
    if (!dictionary || [dictionary count] == 0){
        return @"{}";
    }
    
    NSError *error;
    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dictionary
                                                       options:0
                                                         error:&error];
    
    if (error || !jsonData) {
        return @"{}";
    }
    
    return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
}

+ (NSInteger)getFirstSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:FIRST_SKAN_CALL_TIMESTAMP];
}

+ (NSInteger)getLastSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:LAST_SKAN_CALL_TIMESTAMP];
}

+ (NSInteger)getCurrentUnixTimestamp {
    return [[NSDate date] timeIntervalSince1970];
}

+ (void)setConversionValues:(NSNumber *)conversionValue coarseValue:(NSNumber *)coarse lockWindow:(BOOL)lockWindow handler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
    @try {
        __block void(^skanResultHandler)(NSError * _Nullable error) = ^(NSError * _Nullable error) {
            if (handler) {
                if (error) {
                    handler(nil, nil, NO, error);
                } else {
                    handler(conversionValue, coarse, lockWindow, nil);
                }
            }
            
            [SKANSnippet valuesHasBeenUpdated:conversionValue coarseValue:coarse lockWindow:lockWindow];
        };
        
        if (@available(iOS 16.1, *)) {
            [SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] coarseValue:[SKANSnippet resolveCoarseValueFrom:coarse] lockWindow:lockWindow completionHandler:^(NSError * _Nullable error) {
                skanResultHandler(error);
            }];
        } else {
            if (@available(iOS 15.4, *)) {
                [SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] completionHandler:^(NSError * _Nullable error) {
                    skanResultHandler(error);
                }];
            }
        }
    } @catch (id exception) {
        NSLog(@"%@", exception);
    }
}

+ (NSNumber *)getConversionValue {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    
    if (![userDefaults objectForKey:CONVERSION_VALUE_KEY]) {
        return @(0);
    }
    
    return @([userDefaults integerForKey:CONVERSION_VALUE_KEY]);
}

+ (NSDictionary*)jsonDataToDictionary:(NSData*)jsonData {
    if (!jsonData) {
        return nil;
    }
    
    NSError *error;
    NSDictionary *parsedData = [NSJSONSerialization JSONObjectWithData:jsonData
                                                                options:kNilOptions error:&error];
    
    if (error || !parsedData) {
        return nil;
    }
    
    return parsedData;
}

+ (NSInteger)getCurrentSkanWindow {
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
    if (timeDiff < firstSkan4WindowInSec) { return 0; }
    if (timeDiff < secondSkan4WindowInSec) { return 1; }
    if (timeDiff < thirdSkan4WindowInSec) { return 2; }
    
    return -1;
}

// persist updated conversion values based on the active skan window.
+ (void)valuesHasBeenUpdated:(NSNumber *)fineValue coarseValue:(NSNumber *)coarseValue lockWindow:(BOOL)lockWindow {
    NSNumber *currentPersistedFineValue;
    NSNumber *currentPersistedCoarseValue;
    NSInteger window = [SKANSnippet getCurrentSkanWindow];
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
        
    switch (window) {
    case 0:
        currentPersistedFineValue = [userDefaults objectForKey:CONVERSION_VALUE_KEY];
        currentPersistedCoarseValue = [userDefaults objectForKey:P0_COARSE];
        [userDefaults setValue:fineValue forKey:CONVERSION_VALUE_KEY];
        [userDefaults setValue:currentPersistedFineValue forKey:P0_PREV_FINE_VALUE];
        [userDefaults setValue:coarseValue forKey:P0_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P0_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P0_WINDOW_LOCK_TS];
        }
            
        break;
    
    case 1:
        currentPersistedCoarseValue = [userDefaults objectForKey:P1_COARSE];
        [userDefaults setValue:coarseValue forKey:P1_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P1_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P1_WINDOW_LOCK_TS];
        }
            
        break;
    
    case 2:
        currentPersistedCoarseValue = [userDefaults objectForKey:P2_COARSE];
        [userDefaults setValue:coarseValue forKey:P2_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P2_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setValue:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P2_WINDOW_LOCK_TS];
        }
            
        break;
    }
    
    [SKANSnippet setLastSkanCallTimestamp];
}

+ (BOOL)isSkanWindowOver {
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
    return thirdSkan4WindowInSec <= timeDiff;
}

// Revenues are being accumulated and saved by Ad monetization and non ad monetization events, total sum and break down by skan windows.
+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency isAdmon:(BOOL)isAdmon  {
    
    NSString *key = isAdmon ? TOTAL_ADMON_REVENUE_BY_CURRENCY : TOTAL_REVENUE_BY_CURRENCY;
    [SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
        
    NSInteger window = [SKANSnippet getCurrentSkanWindow];
    switch (window) {
        case 0:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P0 : TOTAL_REVENUE_P0 ;
            break;
        case 1:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P1 : TOTAL_REVENUE_P1 ;
            break;
            
        case 2:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P2 : TOTAL_REVENUE_P2 ;
            break;
        case -1:
            key = nil;
            return;
    }
    
    [SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
}

// Coarse value is being sent on requests and responses as an Int and being translated into the system defined coarse value upon API execution.
+ (NSString *)resolveCoarseValueFrom:(NSNumber *)value {
    if(@available(iOS 16.1, *)) {
        if (!value) {
            return nil;
        }
        
        switch ([value integerValue]) {
            case 0:
                return SKAdNetworkCoarseConversionValueLow;
            case 1:
                return SKAdNetworkCoarseConversionValueMedium;
            case 2:
                return SKAdNetworkCoarseConversionValueHigh;
            default:
                return nil;
        }
    }
    
    return nil;
}

+ (NSDictionary*)getTotalRevenue:(NSString *)revenueKey {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    
    if (![userDefaults objectForKey:revenueKey]){
        [userDefaults setObject:[NSDictionary dictionary] forKey:revenueKey];
    }
    
    return [userDefaults objectForKey:revenueKey];
}

+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency forKey:(NSString *)revenueKey {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSMutableDictionary *revenues = [[SKANSnippet getTotalRevenue:revenueKey] mutableCopy];
    
    NSNumber *currentRevenue = @(0);

    if ([revenues objectForKey:currency]) {
        currentRevenue = [revenues objectForKey:currency];
    }

    currentRevenue = @([currentRevenue floatValue] + [newRevenue floatValue]);
    [revenues setObject:currentRevenue forKey:currency];
    [userDefaults setObject:revenues forKey:revenueKey];
    
}

@end

S2S 연동 업데이트

구현 유효성 검사 및 문제 해결을 위해 SKAdNetwork 메타데이터와 서버 간 연동을 강화합니다(모든 구현에 권장).

메타데이터 구조

데이터 검색

getSkanDetails 메서드를 사용하여 메타데이터 사전을 추출하고 세션 및 이벤트 엔드포인트 API 요청에 쿼리 파라미터로 추가하기 위해 서버로 전달합니다.

중요: 세션 알림 엔드포인트와 이벤트 알림 엔드포인트를 통해 Singular에 보고되는 모든 세션과 모든 이벤트에 대해 메타데이터를 전달해야 합니다.

Objective-C
NSDictionary *skanMetadata = [SKANSnippet getSkanDetails];

// Forward skanMetadata to your server for S2S API enrichment

앱 라이프사이클 구현

적절한 앱 라이프사이클 지점에서 SKAdNetwork 방식을 연동하여 SKAN 4.0 기능으로 완벽한 어트리뷰션 범위를 확보하세요.

구현 예시

구현 노트:

  • 메인 스레드 차단을 방지하기 위해 전환 값 업데이트에 비동기 메서드 사용
  • 서버 전송 전에 사전 형식으로 수집된 모든 SKAN 관련 데이터 수집
  • 필수적인 어트리뷰션 추적을 가능하게 하면서도 Apple의 개인정보 보호 우선 접근 방식을 따름
  • 정확한 보고를 위해 구매 추적에 화폐 가치 및 통화 사양 포함

앱 최초 런칭

어트리뷰션 추적을 위해 SKAdNetwork에 앱을 등록하고 초기 세션 데이터를 Singular의 엔드포인트로 전송합니다. 앱 최초 실행 시에만 실행되어 어트리뷰션 추적을 설정합니다.

Objective-C
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues]; // to Singular launch EP

세션 관리

각 세션 후 전환 값을 업데이트하고 업데이트된 SKAN 세부 정보를 전송하여 사용자 인게이지먼트를 추적합니다.

Objective-C
[SKANSnippet updateConversionValuesAsync:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Conversion value update failed: %@", error);
    } else {
        NSLog(@"Values updated - Fine: %@, Coarse: %@, Lock: %d", fine, coarse, lock);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues];

이벤트 추적

전환 값을 업데이트하고 이벤트 데이터를 Singular의 이벤트 엔드포인트로 전송하여 비구매 이벤트를 처리합니다.

Objective-C
[SKANSnippet updateConversionValuesAsync:@"event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Event conversion update failed: %@", error);
    } else {
        NSLog(@"Event values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"];

구매 추적

통화 및 관련 전환 값으로 구매 금액을 업데이트한 다음, 구매 관련 활동을 위해 Singular의 이벤트 엔드포인트로 전송하여 구매 이벤트를 관리합니다.

Objective-C
[SKANSnippet updateRevenue:15.53 andCurrency:@"USD" isAdMonetization:NO];
[SKANSnippet updateConversionValuesAsync:@"revenue_event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Revenue conversion update failed: %@", error);
    } else {
        NSLog(@"Revenue values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"];

전환 가치 API

클라이언트 측 인터페이스 구현 대신 REST API 엔드포인트를 사용하여 SKAdNetwork 전환 가치를 보고하는 대체 서버 측 접근 방식입니다.

API 개요

구현 방법

데이터 흐름과 보고 무결성이 동일한 두 가지 방법을 통해 SKAdNetwork 전환 가치를 보고할 수 있습니다.

  1. 직접 SKAdNetwork 인터페이스: 클라이언트 측 구현(위 참조)
  2. 서버 측 연동: 전환 가치 API 엔드포인트 사용

전환 가치 API 엔드포인트는 클라이언트 측 인터페이스와 동일한 파라미터를 허용하므로, 기술 아키텍처에 가장 적합한 구현을 선택할 수 있는 유연성과 함께 일관된 어트리뷰션 추적을 보장합니다.


API 엔드포인트

HTTP 메소드 및 URL

GET https://sdk-api-v1.singular.net/api/v2/conversion_value

필수 파라미터

API 키

파라미터 설명
a 개발자 도구의 Singular SDK 키. 보고 API 키는 사용하지 마세요.

예시: sdkKey_afdadsf7asf56

디바이스 식별자

파라미터 설명
idfa 광고 추적 및 어트리뷰션을 위한 광고주 식별자(IDFA)입니다. iOS 14.5부터는 ATT 프레임워크 옵트인이 필요합니다. 사용할 수 없는 경우 생략합니다(NULL 또는 빈 문자열을 전달하지 마세요).

예:DFC5A647-9043-4699-B2A5-76F03A97064B
idfv 벤더 식별자(IDFV) - ATT 상태와 관계없이 모든 요청에 필요합니다. 앱 에코시스템 전반에서 공급업체/개발자별로 고유합니다.

예:21DB6612-09B3-4ECC-84AC-B353B0AF1334

디바이스 매개변수

파라미터 설명
p 앱의 플랫폼(이 API의 경우 "iOS"여야 함).

예시: iOS
v 세션 시점의 디바이스 OS 버전.

예시: 16.1

애플리케이션 매개변수

매개변수 설명
i 앱 식별자(iOS 애플리케이션의 번들 ID, 대소문자 구분).

예시: com.singular.app
app_v 애플리케이션 버전.

예: 1.2.3

이벤트 매개변수

매개변수 설명
n 추적 중인 이벤트 이름(최대 32자). 세션의 경우 __SESSION__ 을 사용합니다. 이벤트의 경우, 이벤트 API를 통해 Singular로 전송된 이름과 대소문자를 동일하게 사용합니다.

예시: sng_add_to_cart

전환 값 매개변수

파라미터 설명
skan_current_conversion_value
iOS 15.4+
이전 세션/이벤트 시점의 최신 SKAdNetwork 전환 값(0-63).

예시: 7
p1_coarse
iOS 16.1+
포스트백_시퀀스 1에 대한 최신 SKAdNetwork 대략적인 전환 값(0-2).

예시: 0
p2_coarse
iOS 16.1+
postback_sequence 2(0-2)에 대한 최신 SKAdNetwork 거친 변환 값입니다.

예시: 1

구매 추적 파라미터

파라미터 설명
skan_total_revenue_by_currency
iOS 15.4+
IAP 또는 모든 구매 모델에 필요합니다. 현재 집계된 IAP 구매 합계(광고 구매화 제외), JSON URL 인코딩 문자열입니다.

예시: %7B%22USD%22%3A9.99%7D
skan_total_admon_revenue_by_currency
iOS 15.4+
애드몬 또는 모든 구매 모델에 필요합니다. 현재 집계된 광고 구매 창출 구매의 총합, JSON URL 인코딩 문자열입니다.

예시: %7B%22USD%22%3A1.2%7D

타임스탬프 매개변수

파라미터 설명
skan_first_call_to_skadnetwork_timestamp
iOS 15.4+
기본 SKAdNetwork API에 대한 첫 번째 호출의 유닉스 타임스탬프입니다.

예시: 1483228800
skan_last_call_to_skadnetwork_timestamp
iOS 15.4+
이 세션 알림 시점에 기본 SKAdNetwork API를 마지막으로 호출한 유닉스 타임스탬프입니다.

예시: 1483228800

요청 예시

샘플 구현

코드 샘플은 핵심 필수 파라미터를 보여줍니다. 구현할 때는 모든 필수 파라미터를 포함하고 프로덕션 사용 전에 올바른 값을 검증하세요.

PythoncURLHTTP
import requests

params = {
    'a': 'sdk_key_here',
    'p': 'iOS',
    'i': 'com.singular.app',
    'v': '16.1',
    'idfa': 'DFC5A647-9043-4699-B2A5-76F03A97064B',
    'idfv': '21DB6612-09B3-4ECC-84AC-B353B0AF1334',
    'n': '__SESSION__',
    'app_v': '1.2.3',
    'skan_current_conversion_value': 7,
    'p1_coarse': 0,
    'p2_coarse': 1,
    'skan_total_revenue_by_currency': {"USD":9.99},
    'skan_total_admon_revenue_by_currency': {"USD":1.2},
    'skan_first_call_to_skadnetwork_timestamp': 1510090877,
    'skan_last_call_to_skadnetwork_timestamp': 1510090877
}

response = requests.get('https://sdk-api-v1.singular.net/api/v2/conversion_value', params=params)
print(response.json())

응답 형식

성공 응답

HTTP 200 - 오류나 이유 없는 정상 응답은 요청이 처리를 위해 대기열로 전송되었음을 나타냅니다.

{
   "conversion_value":1,
   "skan_updated_coarse_value":0,
   "postback_sequence_index":0,
   "status":"ok"
}

응답 파라미터

Key 설명 예제
conversion_value 새 정밀 변환 값 0-63
skan_updated_coarse_value 새로운 거친 변환 값 0-2
postback_sequence_index SKAN 포스트백 측정 주기(0=포스트백 1, 1=포스트백 2, 2=포스트백 3). 업데이트할 거친 값 키를 나타냅니다. 0-2
status 처리 상태 ok

가능한 오류

  • 마지막 변환 업데이트 이후 24시간 이상 경과(28032시간), 업데이트 창이 닫힘
  • 알 수 없는 플랫폼 오류 - iOS 이외의 플랫폼
  • 전환 관리: 잘못된 매개변수 제공
  • 전환 관리: 앱에 대한 전환 모델을 찾을 수 없음
  • 잘못된 측정 기간
  • 전환 관리: 소유자의 통화를 찾을 수 없습니다.