SKAdNetwork 4.0 S2S 구현

SKAdNetwork는 iOS에서 앱 인스톨 광고 캠페인을 개인정보 보호 친화적으로 측정할 수 있도록 Apple에서 제공하는 프레임워크입니다.SKAdNetwork 4.0은 프레임워크의 최신 버전으로, 몇 가지 중요한 세부 사항에서 SKAdNetwork 3.0과 다릅니다.

이 가이드를 통해 Singular S2S 연동의 일부로 SKAdNetwork 4.0을 구현할수 있습니다.

SKAdNetwork 4.0 S2S 구현하기

Singular의 SKAdNetwork 솔루션은 다음과 같은 구성 요소로 이루어져 있습니다:

  • SKAdNetwork를 구현하기 위한 클라이언트 측 코드
  • 모든 네트워크에서 포스트백 유효성 검사 및 집계
  • 사기 방지:
    • 서명 검증 및 트랜잭션 ID 중복 제거.
    • 서명되지 않은 파라미터(전환 값 및 지오데이터)를 검증하기 위한 네트워크 보안 설정.
  • 전환 가치 관리: Singular의 대시보드에서 동적으로 구성할 수 있는 기능을 제공하여 설치 후 활동을 SKAdNetwork 전환 가치로 인코딩할 수 있습니다.
  • 리포팅: 제한된 SKAdNetwork 캠페인 ID를 변환하고 더 많은 마케팅 파라미터와 세분화로 데이터를 보강합니다.
  • 파트너 포스트백: 이벤트 및 구매으로 디코딩된 전환 가치를 이벤트 및 구매으로 변환하여 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 연동 업데이트

필수

활성화된 전환 모델이 있으면 S2S 엔드포인트는 클라이언트 측 코드에서 업데이트할 다음 값을 포함하는 "conversion_value"라는 새 int 필드를 반환하기 시작합니다.

권장 사항

연동을 검증하고 추가 인사이트를 지원하려면 코드 샘플을 사용하여 현재 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. 첫째, 앱의 코드가 SKAdNetwork 전용 엔드포인트를 통해 Singular 서버와 통신하여 앱에서 발생하는 이벤트/세션/매출 이벤트에 따라 최신 전환 값을 동기식으로 가져오고, 이 값으로 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" 아래 사전에 저장됩니다. 또한 SKAN 목적과 관련된 다른 모든 값을 포함합니다.*/ // 예: // { // "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/v2/conversion_value"
  
  // NSUserDefaults 지속성 및 요청을 위한 SKAN 키
  #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 @"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 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] 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_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;
  }
  
  // 활성 스캔 창을 기반으로 업데이트된 전환 값을 유지합니다.
  + (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;
  }
  
  // 구매은 광고 구매화 및 비광고 구매화 이벤트에 의해 누적되고 저장되며, 합계 및 스칸 창별로 세분화됩니다.
  + (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];
  }
  
  // 대략적인 값은 요청 및 응답에 대해 Int로 전송되고 API 실행 시 시스템에서 정의된 대략적인 값으로 변환됩니다.
  + (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 연동 업데이트(권장)

다음 SKAdNetwork 메타데이터로 S2S 연동을 업데이트하세요(이 메타데이터는 SKAdNetwork 구현 유효성 검사를 위해 Singular에 보고되는 모든 세션 및 이벤트에서 전달되어야 합니다):

  • SKAN_CURRENT_CONVERSION_VALUE: 최신 미세 전환 값
  • PREV_FINE_VALUE: 이전 미세 전환 값
  • SKAN_FIRST_CALL_TO_SKADNETWORK_TIMESTAMP: 기본 SkAdNetwork API에 대한 첫 번째 호출의 유닉스 타임스탬프입니다.
  • SKAN_LAST_CALL_TO_SKADNETWORK_TIMESTAMP: 기본 SkAdNetwork API에 대한 마지막 호출의 유닉스 타임스탬프입니다.
  • p0/1/2_coarse: 창별 최신 거친 값
  • p0/1/2_prev_coarse_value: 창별 이전 거친 값
  • p0/1/2_window_lock: 창별 창 잠금이 있는 마지막 업데이트의 유닉스 타임스탬프
  • p0/1/2_total_iap_revenue: 창당 비광고 구매화 구매의 합계
  • P0/1/2_총_애드몬_구매: 창당 광고 구매화 구매의 합계
  • SKAN_총_구매_통화별: 비광고 구매화 구매의 합계
  • SKAN_총_애드몬_구매_통화별: 광고 구매화 구매의 합계

다음 함수는 이러한 값을 추출하는 방법을 보여줍니다:

NSDictionary *values = [SKANSnippet getSkanDetails];

이제 이러한 매개변수를 서버 측으로 전송한 후에는 서버 간 API 엔드포인트를 통해 전달할 수 있습니다. 자세한 내용은 서버 간 API 참조에서 이러한 파라미터를 검색하세요.

앱 라이프사이클 흐름 예시

  // 앱 최초 실행 시에만
  [SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //Singular "LAUNCH" 엔드포인트로 // 각 세션이 처리된 후 [SKANSnippet updateConversionValueAsync:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //Singular "LAUNCH" 엔드포인트로
// 비구매 이벤트 처리 후 [SKANSnippet updateConversionValueAsync:@"event_name"
withCompletionHandler:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"] //Singular "EVT" 엔드포인트로
// 구매 이벤트 처리 후 [SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"]; [SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"] //Singular "EVT" 엔드포인트로