UMP의 사전 안내와 ATT 요청 순서를 바로잡은 기록

개인 프로젝트로 DailySudoku를 만들고 있어요. 스도쿠 퍼즐을 날마다 하나씩 내는 앱이에요. 이 앱은 App Store, Google Play, 웹에 배포하고, 무료로 유지하려고 배너 광고와 동의 처리를 넣었어요.

글에 나오는 약어부터 풀어 둘게요. IDFA(Identifier for Advertisers)는 Apple의 광고 식별자예요. ATT(App Tracking Transparency)는 추적 허가를 묻는 Apple의 프레임워크이고, UMP(User Messaging Platform)는 Google이 제공하는 동의 화면 SDK예요. GDPR(General Data Protection Regulation)은 EU의 개인정보 보호 규정, EEA(European Economic Area)는 유럽경제지역, NPA(Non-Personalized Ads)는 비맞춤 광고를 뜻해요.

ATT 시스템 알림은 문구를 고칠 수 있는 범위가 좁아요. 그래서 요청 전에 이유를 설명하는 화면을 따로 보여 주려고, AdMob 콘솔에서 IDFA용 사전 안내 메시지 초안을 8개 로케일로 작성했어요. 그런데 콘솔에 이 안내 화면이 ATT 알림을 띄운다는 설명이 있었어요. SDK가 직접 ATT를 요청한다면, 이미 앱에서 ATT를 요청하고 있는 지금 구조에서는 안내가 아예 표시되지 않을 수 있어요. 게시를 멈추고 조사를 시작했어요. 앞선 작업에서 3주 반쯤 지난 시점이었어요.

한 달 전의 설계

ATT는 한 달쯤 전에 연결하고 감사까지 마친 상태였어요. 그때의 판단은 이랬어요. 지역에 따라 UMP 화면이 생략되는데, ATT를 UMP 완료 콜백 안에 넣으면 그 지역에서는 ATT도 같이 빠져요. UMP 화면이 나오는 곳으로는 GDPR 적용 지역, 영국, 미국 일부 주를 염두에 뒀어요. 주요 시장은 한국이어서 EEA 밖에서도 IDFA를 받을 수 있어야 했어요.

그래서 호출 규약을 UMP 화면과 무관하게 잡았어요. AppCoordinator의 시작 task에서 refreshConsent() 다음, maybeStartAdMob() 앞에 두고 adFree가 아닌 모든 사용자에게 적용했어요. 실제 ATT 요청은 별도 Task로 돌아서 UMP와 동시에 진행됐어요.

광고 요청에는 두 응답 가운데 더 제한적인 쪽을 따르는 정책을 썼어요. ATT 거부와 UMP 미동의 가운데 하나만 있어도 npa=1을 붙여요. 당시에는 ATT와 UMP 상태 조합 네 가지를 유닛 테스트로 확인했고, 시뮬레이터 첫 실행에서 ATT 알림이 뜨는 것도 EEA가 아닌 로케일을 포함해 확인했어요.

먼저 뜬 쪽이 이기는 구조

ATT 요청 화면은 상태가 .notDetermined일 때만 나와요. 이미 응답한 뒤의 호출은 기존 상태를 돌려줄 뿐이에요. 그래서 앱의 요청이 먼저 뜨면 SDK의 사전 안내가 나올 자리가 없어져요. SDK가 먼저 움직이면 안내가 나오고 그 뒤에 ATT가 떠요. 어느 쪽이 먼저일지는 네트워크에 걸리는 시간에 따라 달라졌어요. ATT가 먼저 뜬 다음에 안내가 나오는 뒤집힌 순서도 생길 수 있었어요.

이 문제는 기존 테스트에서 드러나지 않았고, AdMob 메시지를 게시하려고 준비하다가 발견했어요.

문서에 없어서 바이너리를 열었어요

Google의 iOS UMP 통합 문서에서는 안내 메시지를 만드는 절차를 찾을 수 있었어요. 하지만 ATT를 누가 호출하는지, 샘플은 어떻게 쓰는지, 실행 순서가 어떻게 되는지에 대한 근거는 당시 찾지 못했어요. Apple의 AppTrackingTransparency 문서에도 UMP에 관한 답은 없었어요. 콘솔의 안내 문구 하나만 믿고 코드를 바꾸기에는 근거가 부족했어요.

그래서 UserMessagingPlatform 바이너리를 직접 봤어요.

UserMessagingPlatform 바이너리:
  _objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
  _objc_msgSend$trackingAuthorizationStatus
  "ATTrackingManager"   ← 문자열(동적 조회)

otool -L: AppTrackingTransparency 하드 링크 없음

요청 selector와 상태 조회 selector가 있고, ATTrackingManager는 문자열로 들어 있어요. otool -L 결과에는 AppTrackingTransparency를 직접 링크한 흔적이 없었어요. SDK가 클래스를 동적으로 찾아 쓴다고 해석했어요. 그렇다면 프레임워크는 앱 쪽에서 링크해 둬야 한다고 추론했는데, 링크를 뺀 상황을 실제로 재현해 보지는 않았어요.

순서를 바꾼 네 가지 결정

첫째, 앱의 ATT 요청을 UMP 처리가 끝난 뒤로 옮겼어요. 권한이 이미 결정돼 있으면 가드에서 바로 돌아가니 중복 요청은 생기지 않아요.

둘째, 앱의 ATT 호출을 없애지 않았어요. 안내 메시지를 게시하지 않으면 SDK가 ATT를 요청하지 않는다고 보고, 그 경우에는 앱이 대신 요청하게 했어요. 메시지 게시와 코드 머지 가운데 무엇이 먼저냐에 덜 의존하게 돼요. 앱 호출을 없애면 게시 전까지 IDFA를 받을 길이 없고 AdMob 광고가 비맞춤으로 나간다고 판단했어요.

셋째, 동의 화면이 필요 없는 경로에서도 UMP를 한 번 실행해요. needsForm은 동의 화면이 필요한지만 알려 주는 값이라고 봤어요. EEA 밖에서도 SDK가 안내를 처리할 기회를 주려는 것이고, 메시지가 게시돼 있지 않으면 바로 돌아올 것으로 예상했어요.

동의 화면이 필요 없는 경우의 분기예요.

case .none, .formOnly:
    guard AdsConfig.adMobEnabled else { break }
    // 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
    // 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
    umpFlowRan = await consentProvider.presentConsentForm()

넷째, maybeStartAdMob()은 ATT 응답을 기다리지 않아요. 주요 시장에서는 AdFit을 쓰는 광고 체인이라 AdMob의 첫 노출을 기다려서 얻는 것이 없어요. ATT 알림이 떠 있는 동안 배너가 가려질 수 있다는 점도 고려했어요.

UMP 흐름이 실제로 돌았을 때만 ATT를 예약하는 부분이에요.

if AdsConfig.adMobEnabled, umpFlowRan {
    Task { await ATTAuthorization.request() }
}
maybeStartAdMob()

코드 리뷰에서 나온 세 가지 수정

첫 번째는 갱신 실패였어요. 네트워크 오류로 생기는 .refreshFailed가 if/else if 구조에서 .none 처리로 흘러들어 갔어요. 캐시에 «화면 불필요»가 남아 있으면 UMP가 성공을 돌려주고, 안내가 게시됐는지 모르는 채로 ATT를 요청할 수 있었어요. switch로 바꿔서 실패를 따로 처리했어요. 나중에 enum에 값이 늘어나면 컴파일러가 빠진 분기를 알려 주는 효과도 있어요. 아래 발췌에서 .none·.formOnly 분기는 앞에서 본 코드와 같아서 줄였어요.

switch prompt {
case .refreshFailed:
    // 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
    // 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
    break
case .introThenForm:
    umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
    // …
}

두 번째는 자체 안내 시트였어요. SDK 화면 앞에 보여 주는 안내 시트를 scrim이나 그래버로 닫으면 presentConsentForm()이 호출되지 않아요. umpFlowRan으로 UMP가 실제로 실행됐는지 확인해서, 이 경로에서는 ATT를 건너뛰어요. 안내 시트는 앱을 시작할 때마다 다시 보여 줘요.

세 번째는 광고 제공자 조건이었어요. 제3자 광고 전체를 보는 가드는 다른 제공자만 켜져 있어도 통과해요. AdMob을 끈 구성에서도 ATT가 실행될 수 있었어요. WKWebView 기반 제공자는 IDFA를 쓰지 않아요. ATT와 UMP의 조건을 AdsConfig.adMobEnabled로 좁혔어요.

ATTAuthorization.request()가 하는 일

AppTrackingTransparency를 import 하는 코드는 파일 하나에 모았어요.

static func request() async {
    guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
    if UIApplication.shared.applicationState != .active {
        await withCheckedContinuation { continuation in
            var observer: NSObjectProtocol?
            observer = NotificationCenter.default.addObserver(
                forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
            ) { _ in
                if let observer { NotificationCenter.default.removeObserver(observer) }
                continuation.resume()
            }
        }
    }
    await withCheckedContinuation { continuation in
        ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
    }
}

맨 위의 가드는 상태가 .notDetermined가 아니면 바로 끝내요. 매번 시작할 때 호출해도 이미 응답한 사용자에게는 요청 API를 부르지 않아요.

그다음은 앱이 .active인지 확인하는 부분이에요. didFinishLaunching이나 willConnectTo 같은 시작 콜백에서 바로 요청하면 화면 없이 .notDetermined가 돌아올 수 있어요. 보통은 UMP가 네트워크를 기다리는 동안 sceneDidBecomeActive가 지나가지만, 그 타이밍에 기대지 않으려고 didBecomeActiveNotification을 직접 기다려요.

확인한 것과 확인하지 못한 것

2026-08-16의 develop 브랜치(커밋 2ae29c42부터 657d87c7까지) 기준으로 iOS는 xcodebuild test에서 301 tests, 60 suites가 통과했고 Android는 compileDebugKotlin이 성공했어요. 시뮬레이터 첫 시작에서는 ATT 알림이 예전처럼 떴고, 메시지를 게시하지 않은 상태의 UMP 실행에서 모달이 추가로 뜨지 않았어요. 그다음 시작의 로그에는 [ATTrackingManager] Returning from trackingAuthorizationStatus - 0이 찍혔어요. 0은 미결정이라서 다시 요청하는 경로예요.

미국 규제 주를 흉내 낼 수 있게 iOS에는 -AdMobDebugUSState, Android에는 --ez admobDebugUsState true 인자를 넣었어요. SDK 헤더에서 UMPDebugGeographyRegulatedUSState의 값이 3인 것을 확인하고 연결했어요.

확인하지 못한 것은 두 가지예요. simctl privacy grant tracking은 Operation not permitted로 실패해서, ATT 응답을 자동으로 넣을 방법이 없었어요. 응답을 마친 뒤에 다시 묻지 않는지는 실기기 QA로 남겼어요. 메시지를 게시한 뒤 두 번째 실행에서 안내가 다시 뜨는지도 보지 못했어요. 당시에는 게시 조건을 채우지 못했기 때문이에요.

남은 한계도 있어요. 안내 메시지를 게시하기 전에는 앱의 ATT가 안내 없이 뜰 수 있어요. 폴백으로 의도한 동작이에요. 바이너리에서 본 내용은 그때 쓰던 UMP 배포본에 한정된 관찰이고, Google 공식 문서가 보장하는 동작이 아니에요. SDK를 바꾸면 다시 조사해야 해요.

문서 빚도 하나 남겼어요. ATTAuthorization.swift의 주석에 예전 호출 규약이 그대로 있어요. 화면 표시와 무관하게 실행한다는 설명, refreshConsent() 다음에 바로 실행한다는 설명, ATT와 UMP가 서로 독립이라는 설명이 지금 구현과 맞지 않아요. 다른 파일의 주석 두 건은 고쳤는데 이 파일은 놓쳐서 이슈로 등록해 뒀어요. 전제가 바뀌면 예전 결정도 다시 봐야 해요. 그래서 결정을 적을 때는 결론뿐 아니라 그 결론이 성립하는 전제를 같이 적어야 해요.