[BETA] SKAdNetwork 4.0 S2S 구현하기

SKAdNetwork는 Apple이 iOS 광고 캠페인의 앱 인스톨을 개인정보보호 친화적인 방법으로 측정하도록 제공한 프레임워크입니다. SKAdNetwork 4.0는 해당 프레임워크의 최신 버전이며, SKAdNetwork 3.0와는 몇 가지 차이점이 있습니다.

Singular S2S 연동 중 한 부분으로써 SKAdNetwork 4.0를 구현할 때 이 가이드를 활용하세요. 

SKAdNetwork 4.0 S2S 구현

Singular의 SKAdNetwork 솔루션은 다음 요소들로 구성되어 있습니다:

  • 광고주 측 SKAdNetwork 코드 구현
  • 포스트 백 확인과 모든 매체로부터의 집계
  • 프러드 보호:
    • 서명 유효성 검사와 결제 ID의 중복 제거.
    • 서명되지 않은 파라미터를 확인하기 위한 매체 보안 설정 (컨버전 값과 지역 데이터).
  • 컨버전 값 관리: 인스톨 후 활동이 SKAdNetwork 컨버전 값으로 인코딩되어있는 Singular 대시보드와의 유동적인 설정을 제공 
  • 보고: 제한된 SKAdNetwork 캠페인 ID를 변환하여 마케팅 파라미터와 세분성(granularity)으로 데이터를 풍부화
  • 파트너 포스트 백: 컨버전 값이 이벤트와 매출로 디코딩된 SKAdNetwork 포스트 백 전송을 제공하며, 이는 최적화에 있어 매우 중요함

서버-투-서버 (S2S)를 통한 Singular 연동을 활용하는 고객에게는, SKAdNetwork 구현은 두 주요 작업으로 구성됩니다:

  1. 고객 측 SKAdNetwork 구현: 이 작업은 앱을 SKAdNetwork에 등록하고 SKAdNetwork 컨버전을 현명하게 관리하는데 있어 중요한 부분입니다. 현명하게 관리한다는 의미는 곧 구현을 통해 SKAdNetwork 어트리뷰션과 인스톨 후 활동들에 기반해 캠페인을 최적화할 수 있게 관리하는 것을 의미합니다. 
  2. S2S 연동 업데이트: 이 작업은 고객 측에서 구현을 확인하고 트러블슈팅하는데 있어 중요합니다. Singular의 S2S 세션과 이벤트 엔드포인트를 통해 오늘 Singular로 전송된 이벤트와 세션을 SKAdNetwork 데이터로 (컨버전 값과 타임스템프 업데이트) 풍부화하여, Singular가 구현이 앱에서 올바르게 되었는지 확인할 수 있습니다. 

SKAdNetwork 고객 측 구현 

Singular는 SKAdNetwork에 등록을 지원하고 컨버전 값을 관리하는 코드 샘플을 제공합니다. 이 코드 샘플들은 다음 사항들을 담당합니다.

  1. SKAdnetwork 지원과 등록
  2. 컨버전 값 관리:
    • 코드는 구성된 컨버전 모델에 기반하여 다음 컨버전 값을 받기 위해 Singular의 엔드포인트와 동시에 소통합니다. 코드는 이벤트/세션/매출에 대해 보고하고 다음 컨버전 값을 받으며, 이 컨버전 값은 인코딩된 숫자로써 Singular 대시보드에 측정을 위해 구성한 인스톨 후 활동을 의미합니다.   
    • 해당 코드는 또한 측정 기간에 따라 SKAdnetwork 메타 데이터를 수집합니다. 메타 데이터는 다음 컨버전 값의 계산과 확인을 위해 사용됩니다.  
      • SKAdNetwork 프레임워크의 첫 번째 호출 타임스템프 
      • SKAdNetwork 프레임워크의 마지막 호출 타임스템프 
      • 마지막으로 업데이트된 포스트 백 값 (단순 전환 값과 상세 값) 
      • 유저에 의해 생성된 총매출과 광고 수익화 매출 

S2S 연동 업데이트

필수 사항

컨버전 모델을 한번 활성화시키면, Singular S2S 엔드포인트는 "conversion_value"라는 새로운 int 필드를 반환하기 시작하며, 이는 고객 측 코드에 업데이트하기 위한 다음 값을 포함합니다. 

권장 사항 

연동을 확인하고 추가적인 통찰을 위해, Singular 코드 샘플을 사용하여 현 S2S 연동을 확장하는 것을 권장합니다. Singular의 S2S 이벤트와 세션 엔드포인트는 이미 다음과 같은 SKAdNetwork 파라미터 획득을 지원합니다. 

  • P0,P1,P2 컨버전 값의 단순 전환 값과 상세 값
  • P0,P1,P2 이전 컨버전 값의 단순 전환 값과 상세 값
  • SKAdNetwork 프레임워크에 호출한 마지막 타임스템프 
  • SKAdNetwork 프레임워크에 호출한 첫번째 타임스템프
  • 윈도우 측정 기간당 집계된 매출 (P0, P1, P2) 

연동 플로우

Screen_Shot_2020-09-16_at_18.59.13.png

상단의 다이어그램은 S2S 고객에 대한 SKAdNetwork 플로우를 묘사합니다. 

  1. 첫째로, 앱의 코드는 Singular 서버와 (SKAdNetwork 전용 엔드포인트를 통해) 소통하여 앱에서 발생한 이벤트/세션/매출 이벤트에 기반한 가장 최신의 컨버전 값을 동시에 가져오고, 해당 값으로 SKAdNetwork를 업데이트  합니다. 
  2. 둘째로, 앱이 현존하는 이벤트와 세션들을 SKAdNetwork 데이터와 함께 풍부화하고, 후에 이를 확인 작업에 사용합니다. 
  3. 앱이 새로운 컨버전 값으로 SKAdNetwork 프레임워크를 업데이트한 후 SKAdNetwork 타이머가 만료되면 SKAdNetwork 포스트 백이 매체로 전송됩니다.  
  4. 매체는 이를 (보안 설정 또는 일반 설정을 통해) Singular에 다시 보내옵니다.
  5. Singular는 다음에 따라 포스트 백을 처리합니다.
    • 서명의 유효성 검사
    • 구성된 컨버전 모델에 기반한 컨버전 값 디코딩  
    • 매체 정보와 함께 포스트 백을 풍부화. 데이터는 SKAdNetwork와 매체 캠페인 ID를 결합한 매체와의 연동을 통해 얻는 데이터를 의미
    • 디코딩된 포스트 백을 BI와 파트너측에 전송  

중요한 점은 인스톨과 디코딩된 이벤트를 포함하여 SKAdNetwork 정보는 여러 다른 리포트/API/ETL 테이블 세트와 포스트 백을 통해 확인할 수 있으며, 이를 통해 기존 데이터 세트와 혼합되는 것을 방지할 수 있습니다. 이는 다음 몇 주 동안 기존 캠페인 활동과 함께 SKAdNetwork를 측정하고 테스트할 수 있게 하므로 특히 중요합니다. 

SKAdNetwork 네이티브 iOS 코드 샘플

SKAdNetwork 인터페이스

이 인터페이스는 다음 SKAdNetwork 구성 요소들을 포함합니다: 

SKAdNetwork에 등록

  • 이 메서드는 처음 SKAdNetwork를 호출하는데 사용됩니다. 
  • P0의 컨버전 값은 0로 설정됩니다.
  • 디바이스가 앱의 어트리뷰션 데이터를 보유하는 경우 Apple API 메서드는 알림을 생성합니다. 

컨버전 값 관리

  • 컨버전 값은 디바이스의 인스톨 후 활동에 기반하여 계산되며, 이는 하단의 메서드와 유저가 유동적으로 구성할 수 있는 컨버전 모델에 의해 포획됩니다. 
  • 이 구간의 메서드는 선택된 컨버전 모델과 보고된 인스톨 후 활동들에 따라 Singular의 엔드포인트로부터 다음 컨버전 값을 가져오는 데 사용됩니다. (문서 상단 참조)  
  • 하단 메서드는 다음 인스톨 후 활동들에 기반한 컨버전 값을 업데이트합니다. 
    • 세션: 유저와 SKAdNetwork의 리텐션 측정에 반드시 필요합니다.
    • 컨버전 이벤트: 인스톨 후 컨버전 이벤트와 SKAdNetwork를 측정하는 데 반드시 필요합니다.  
    • 매출 이벤트: 매출과 SKAdNetwork를 측정하는 데 반드시 필요합니다. 
//SKANSnippet.h

#import <Foundation/Foundation.h>

@interface SKANSnippet : NSObject

// SkAdNetwork 어트리뷰션을 등록하세요.
// 앱을 처음 출시하는 경우 이 메서드를 빠른 시일 내 호출해야 합니다.
// 이 기능은 컨버전 값을 0으로 설정하고 추가적인 처리에 관한 타임 스템프를 업데이트합니다.
+ (void)registerAppForAdNetworkAttribution;

// 리텐션과 코호트를 추적하려면 앱이 오픈될 때마다 이 메서드를 호출해야 합니다. 
// 이는 세션의 상세 사항을 보고하고, 필요한 경우 이 세션으로 인해 컨버전 값을 업데이트합니다. 
// 이 메서드로 콜백을 전달하는 것은 선택사항이나, 이를 컨버전 값이 업데이트된 후 코드를 실행하는 데 사용할 수 있습니다. 
//
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// SKAdNetwork으로 컨버전 이벤트를 추적하려면, 이 이벤트가 Singular에 전송되기 전에 각각의 이벤트 이후 이 메서드를 호출해야 합니다.
// 이는 이벤트 상세 사항을 보고하고, 필요한 경우 이 세션으로 인해 컨버전 값을 업데이트합니다.
// 이 메서드로 콜백을 전달하는 것은 선택사항이나, 컨버전 값이 업데이트된 후 코드를 실행하는 데 사용할 수 있습니다.
+ (void)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// SKAdNetwork으로 매출을 추적하려면, 모든 매출 이벤트 전에 이 메서드를 호출해야 합니다.
// 이는 매출의 총합을 업데이트하기 때문에, 'updateConversionValuesAsync'를 호출할 때 신규 컨버전 값은 매출의 총합에 따라 결정됩니다.
// 참고하실 점은:
// 1. 'updateConversionValuesAsync'를 호출하기 전 이 메서드로 호출하여 매출이 업데이트되도록 해야 합니다.
// 2. 이벤트를 다시 실행할 때 이 메서드를 호출하면 동일한 매출이 두번 카운트되므로, 이 경우 해당 메서드를 호출하지 마세요.
+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon;

// 이는  "fineValue", "coarseValue", "windowLock" 하위의 dictionary에 저장된 현재의 상세 값, 단순 전환 값, 윈도우 잠김 값을 포함합니다. 
// 또, SKAN에 관한 모든 값을 포함합니다. 
// e.g. 
// {
//    "skan_current_conversion_value": 3,
//    "prev_fine_value": 2,
//    "skan_first_call_to_skadnetwork_timestamp": 167890942,
//    "skan_last_call_to_skadnetwork_timestamp": 167831134,
//    "skan_total_revenue_by_currency": { "USD": 1.2 },
//    "skan_total_admon_revenue_by_currency": { "USD": 0.8 },
//    "p0_coarse": 0,
//    "p1_coarse": 1,
//    "p2_coarse": nil,
//    "p0_window_lock": 167890942,
//    "p1_window_lock": nil,
//    "p2_window_lock": nil,
//    "p0_prev_coarse_value": 0,
//    "p1_prev_coarse_value": 0,
//    "p2_prev_coarse_value": nil,
//    "p0_total_iap_revenue": nil,
//    "p1_total_iap_revenue": nil,
//    "p2_total_iap_revenue": nil,
//    "p0_total_admon_revenue": nil,
//    "p1_total_admon_revenue": nil,
//    "p2_total_admon_revenue": nil
// }
+ (NSDictionary *)getSkanDetails;

 @end

SKAdNetwork 인터페이스 구현

//  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/v1/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_CURRNECY @"skan_total_admon_revenue_by_currency"
#define SKAN_UPDATED_CONVERSION_VALUE @"skan_updated_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_VALUE @"p0_coarse"
#define P1_COARSE_VALUE @"p1_coarse"
#define P2_COARSE_VALUE @"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 16.1, *)) {
        [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_VALUE] stringValue] forKey:P0_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P1_COARSE_VALUE] stringValue] forKey:P1_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P2_COARSE_VALUE] stringValue] forKey:P2_COARSE_VALUE];
    //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_CURRNECY] forKey:TOTAL_ADMON_REVENUE_BY_CURRNECY];
    //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]) {
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Illegal values recieved"}]);
                    }
                    
                    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];
    [userDefaults synchronize];
}

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

+ (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_VALUE];
        [userDefaults setValue:fineValue forKey:CONVERSION_VALUE_KEY];
        [userDefaults setValue:currentPersistedFineValue forKey:P0_PREV_FINE_VALUE];
        [userDefaults setValue:coarseValue forKey:P0_COARSE_VALUE];
        [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_VALUE];
        [userDefaults setValue:coarseValue forKey:P1_COARSE_VALUE];
        [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_VALUE];
        [userDefaults setValue:coarseValue forKey:P2_COARSE_VALUE];
        [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_CURRNECY : 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];
    [userDefaults synchronize];
}

@end

S2S 구현 업데이트 (권장)

S2S 연동을 다음 SKAdNetwork 메타 데이터 (이 메타 데이터는 SKAdNetwork 구현 확인을 위해 모든 세션과 이벤트마다 전달되어야 합니다)와 함께 업데이트하세요.

  • skan_current_conversion_value: 가장 최근 상세 컨버전 값
  • prev_fine_value: 이전 상세 컨버전 값
  • skan_first_call_to_skadnetwork_timestamp: SkAdNetwork API에 처음에 호출한 Unix 타임스템프 
  • skan_last_call_to_skadnetwork_timestamp: SkAdNetwork API에 마지막(최근)에 호출한 Unix 타임스템프
  • p0/1/2_coarse: 윈도우 당 최신 단순 전환 값 
  • p0/1/2_window_lock: 마지막 업데이트와 윈도우 당 윈도우 잠금에 대한 Unix 타임스템프
  • p0/1/2_total_iap_revenue: 윈도우 당 비 광고 수익화의 총매출 
  • p0/1/2_total_admon_revenue: 윈도우 당 광고 수익화의 총매출 
  • skan_total_revenue_by_currency: 비 광고 수익화 총매출
  • skan_total_admon_revenue_by_currency: 광고 수익화 매출의 총합 

다음 기능은 어떻게 값을 추출해오는지 설명합니다: 

NSDictionary *values = [SKANSnippet getSkanDetails];

이 파라미터들을 귀하의 서버에 전송하면, 해당 파라미터들을 싱귤러 서버-투-서버 API 엔드포인트를 통해 전달할 수 있습니다. 자세한 내용은 서버-투-서버 API 레퍼런스에서 해당 파라미터들을 검색하여 확인하세요.

앱의 주기 플로우 예시

// 첫 앱 출시인 경우만
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP // 각 세션이 처리된 후 [SKANSnippet updateConversionValueAsync:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP
// 비 매출 이벤트 처리 후 [SKANSnippet updateConversionValueAsync:@"event_name"
withCompletionHandler:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"] //to Singular evt EP
// 매출 이벤트 처리 후 [SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"]; [SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"] //to Singular evt EP