J.
BLOG
글 목록소개구독
기술

UMP가 ATT를 직접 부른다: 프레임워크 바이너리로 확인한 순서 계약

2026.08.18·읽기 12분

DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 무료로 내려면 배너 광고가 필요했고, 광고를 붙이는 순간 앱 안에 「동의」라는 세계가 하나 생겼습니다.

AdMob 콘솔에는 IDFA (Identifier for Advertisers - Apple의 광고 식별자) 설명 메시지라는 기능이 있습니다. ATT (App Tracking Transparency - Apple이 앱의 추적 권한을 사용자에게 묻게 하는 프레임워크) 시스템 팝업은 문구를 앱이 거의 못 바꾸는데, 그 앞에 왜 추적 권한이 필요한지 설명하는 화면을 붙일 수 있게 해줍니다. 8개 로케일로 초안을 만들어 두고 게시 버튼을 누르려던 참이었습니다.

그런데 누르지 못했습니다. 지금 게시하면 이 메시지가 한 번도 뜨지 않을 것이라는 걸 알게 됐기 때문입니다.

원인을 확인하려면 「앱과 UMP (User Messaging Platform - Google이 GDPR 동의 폼을 띄워주는 SDK) 중 어느 쪽이 requestTrackingAuthorization을 부르는가」에 답해야 했는데, 공식 개발자 문서에는 그 답이 없었습니다. 그래서 프레임워크 바이너리를 읽었습니다.

이 글은 그 과정과, 답을 알고 나서 고친 네 가지를 정리한 기록입니다. AdMob을 붙인 iOS 앱이라면 IDFA 설명 메시지를 게시하는 순간 같은 문제를 만납니다.

처음 구현은 이랬습니다

ATT 배선은 한 달 전에 이미 끝나 있었습니다. 그때 설계 감사에서 함정을 하나 잡아뒀습니다.

UMP 폼 완료 콜백 안에 ATT 요청을 넣으면 안 된다는 것입니다. UMP 폼은 지역 조건부입니다. GDPR (General Data Protection Regulation - EU 일반 개인정보 보호법)과 영국, 그리고 일부 미국 주 규정이 적용되는 지역에서만 뜹니다. EEA (European Economic Area - 유럽경제지역)와 영국을 벗어나면 폼이 아예 뜨지 않으니, 완료 콜백도 없고, 콜백에 얹은 ATT도 영영 뜨지 않습니다. 주 시장이 한국이라 이 함정에 걸리면 IDFA를 전혀 못 받게 됩니다.

그래서 호출 지점을 이렇게 못 박았습니다.

AppCoordinator 런치 task에서 await refreshConsent() 완료 직후 · maybeStartAdMob() 이전에, UMP 폼 노출 여부와 무관하게 비-adFree 전원 대상으로 호출한다.

폼 노출 여부와 무관하게」가 이 계약의 핵심이었습니다. 지역에 따라 갈리는 UMP에 ATT를 묶지 않겠다는 선언입니다.

NPA (Non-Personalized Ads - 비맞춤 광고) 폴백도 같이 정했습니다. ATT 거부 또는 UMP 비동의면 요청에 "npa": "1"을 실어 보냅니다. 보수적 union이라 과하게 제한하는 쪽만 허용합니다.

검증도 했습니다. ATT와 UMP 조합 4케이스 유닛 테스트, 그리고 시뮬레이터에서 첫 실행 ATT 프롬프트가 비EEA 로케일 포함해 뜨는 것까지 확인했습니다.

이 결정은 옳았습니다. 다만 한 방향으로만 옳았습니다.

어디서 어긋났을까요

3주 반 뒤, IDFA 설명 메시지를 게시하려다 막혔습니다.

AdMob 콘솔의 설명 문구가 단서였습니다.

설명 메시지를 사용하여 Apple의 iOS ATT 알림을 트리거하고

「트리거한다」는 표현이 걸렸습니다. 이 문장을 그대로 읽으면 UMP가 ATT를 띄운다는 뜻입니다. 그런데 앱은 이미 자기가 ATT를 부르고 있었습니다. 그것도 UMP 흐름과 병렬로, 별도 Task에서요.

requestTrackingAuthorization은 일회성입니다. 추적 권한 상태가 .notDetermined일 때만 실제로 팝업을 띄우고, 사용자가 한 번 응답하면 그 뒤 호출은 팝업 없이 현재 상태로 즉시 완료됩니다. 앱을 지우고 다시 깔기 전까지 다시 물을 수 없습니다.

먼저 부른 쪽이 소비합니다.

  • 앱이 먼저 부르면: 설명 메시지는 영영 안 뜹니다. 게시해도 no-op입니다.
  • UMP가 먼저 부르면: 설명 다음에 ATT. 의도한 동작입니다.

병렬이니 어느 쪽이 이길지는 그날의 네트워크 속도가 정합니다. 그리고 최악의 순서가 있습니다. ATT가 먼저 뜬 뒤에 설명이 나오는 것입니다. 설명 메시지가 막으려던 바로 그 혼란을, 설명 메시지가 만들어냅니다.

문제는 이 추론이 콘솔 문구 한 줄에만 기대고 있다는 점이었습니다. 이 PR 전체가 「어느 쪽이 부르는가」에 달려 있는데, 근거가 마케팅 문구 하나였습니다.

원인

문서가 답을 주지 않았습니다

Google 개발자 문서를 뒤졌습니다. IDFA 설명 메시지를 만드는 방법은 있는데, 코드 샘플도 순서 지침도 없었습니다. 앱이 ATT를 직접 불러야 하는지, 부르면 안 되는지, 부른다면 언제인지 — 어디에도 없었습니다.

Apple 문서에도 없습니다. Apple 입장에서 UMP는 서드파티 SDK일 뿐입니다.

추측으로 배선을 바꿀 수는 없었습니다. 틀리면 사용자마다 딱 한 번뿐인 프롬프트를 잘못 태우게 되고, 되돌릴 방법이 없습니다.

바이너리에는 답이 있었습니다

그래서 UMP 프레임워크 바이너리를 직접 읽었습니다.

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

otool -L: AppTrackingTransparency 하드 링크 없음

세 가지가 한 번에 드러났습니다.

  1. UMP는 requestTrackingAuthorizationWithCompletionHandler:호출합니다. 심볼이 바이너리에 있습니다.
  2. ATTrackingManager문자열로 동적 조회합니다. 클래스를 컴파일 타임에 참조하지 않습니다.
  3. otool -LAppTrackingTransparency 하드 링크가 없습니다.

2번과 3번이 함께 의미하는 것은, UMP가 이 프레임워크에 의존하되 자기가 링크하지는 않는다는 것입니다. 앱이 AppTrackingTransparency를 링크해야 UMP의 동적 조회가 성공합니다. 링크가 빠졌을 때 어떤 증상이 나오는지는 재현해보지 않았습니다 — 여기까지가 심볼이 말해주는 범위입니다.

이 확인이 이 글에서 가장 오래 걸린 부분이자, 나머지 결정을 전부 좌우한 부분입니다. 문서에 없다고 알 수 없는 건 아니었습니다.

계약이 반대 방향으로 뒤집혔습니다

답을 알고 나니 한 달 전 계약이 다시 보였습니다.

UMP 폼 노출 여부와 무관하게 호출한다」는 것은, UMP가 ATT를 안 부른다는 전제 위에서만 옳습니다. UMP가 부른다면 그 「무관하게」가 바로 경합의 원인입니다.

한 달 전에는 ATT를 UMP에 묶지 않는 것이 결함을 막는 방법이었고, 지금은 ATT를 UMP 뒤에 묶는 것이 결함을 막는 방법이 됐습니다. 같은 축의 반대편으로 넘어간 셈입니다.

이렇게 바꿨습니다

네 가지를 바꿨습니다.

변경
ATT 요청을 UMP 흐름 뒤로 경합 제거. UMP가 이미 물었으면 즉시 반환(.notDetermined가 아니므로) — 이중 프롬프트 없음
지우지는 않았다 UMP가 ATT를 띄우는 건 메시지가 게시돼 있을 때뿐. 지우면 미게시 상태에서 ATT를 영영 안 물어 IDFA가 사라지고 AdMob이 전부 비개인화로 떨어진다
폼이 «필요 없어도» UMP 흐름을 한 번 needsForm동의 폼만 가리킨다. IDFA 설명은 그 축이 아니라서, 이 분기가 없으면 비EEA는 흐름 자체가 안 돌아 게시해도 설명이 안 뜬다
maybeStartAdMob()은 막지 않음 주 시장은 광고 체인이 AdFit으로 고정이라 기다려 얻는 AdMob 첫 노출이 없다. 기다리면 모달 ATT가 배너 앞을 가로막기만 한다

②가 이 변경의 안전판입니다. 호출이 멱등이라 — .notDetermined일 때만 실제로 묻습니다 — 게시 전과 후 두 상태 모두에서 옳습니다. 콘솔 게시와 코드 머지의 순서를 맞출 필요가 없어집니다. 되돌릴 수 없는 자원을 다룰 때, 순서 의존을 하나 없애는 것은 그 자체로 값어치가 있습니다.

③은 한 달 전 감사가 남긴 교훈과 같은 축입니다. 그때는 「폼 콜백에 넣으면 비EEA는 ATT가 영영 안 뜬다」였고, 이번엔 「폼이 필요 없다고 흐름을 건너뛰면 비EEA는 설명이 영영 안 뜬다」입니다.

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

이 코드가 해결하는 것은 경합과 「설명 없는 ATT」입니다. 남는 한계는 메시지를 아직 게시하지 않았다면 여전히 앱이 ATT를 묻는다는 점인데, 그건 ②가 의도한 동작입니다.

적대적 리뷰가 잡은 두 가지

여기까지 하고 코드 리뷰에 넣었더니 두 개가 더 나왔습니다.

첫째, 동의 갱신 실패가 「폼 없음」에 접혀 있었습니다. 네트워크 오류로 UMP 상태를 못 읽은 경우입니다. 분기를 if / else if로 짜뒀더니 .refreshFailed가 조용히 .none 갈래로 흘렀고, UMP가 캐시된 「폼 없음」으로 성공 반환하면서 그 뒤 ATT가 태워졌습니다. 갱신이 실패했다는 건 설명 메시지가 게시됐는지도 모른다는 뜻인데 말입니다.

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

switch로 바꾼 것이 수정의 전부입니다. 케이스가 늘어날 때 「그 밖」이 기존 갈래에 조용히 접히지 않도록, 컴파일러가 결정을 강요하게 만든 것입니다.

둘째, 안내 시트를 닫은 경로가 남아 있었습니다. DailySudoku는 UMP 폼 앞에 커스텀 안내 시트를 하나 둡니다. 사용자가 그 시트를 scrim이나 그래버로 닫으면 presentConsentForm()이 불리지 않고, UMP는 설명을 띄울 기회를 못 갖습니다. 그 상태에서 앱이 ATT를 띄우면 설명 없는 ATT가 됩니다. 이번 변경이 없애려던 결함이 그대로 남습니다.

umpFlowRan 게이트가 여기서 일합니다. 흐름이 돌지 않았으면 묻지 않습니다. 안내 시트는 런치마다 다시 뜨므로 다음 기회가 있습니다.

그리고 이 게이트가 한 달 전의 「폼 노출 여부와 무관하게」를 최종적으로 뒤집었습니다.

게이트가 두 종류였습니다

부수적으로 하나 더 걸렸습니다. 바깥 가드는 「제3자 광고가 켜져 있는가」였는데, 광고 제공자가 여럿이라 이 가드는 그중 아무거나 하나만 켜져 있어도 통과합니다. 그래서 AdMob이 꺼진 구성에서도 ATT 경로에 도달했습니다.

그런데 그중 하나는 WKWebView 기반이라 IDFA를 쓰지 않습니다. 쓰지도 않는 추적 권한을 사용자에게 미리 요구할 이유가 없습니다. 그래서 ATT와 UMP 호출의 게이트만 IDFA를 실제로 쓰는 제공자 쪽으로 좁혔습니다.

ATTAuthorization이 지키는 두 가지

순서와 별개로, ATT 요청 자체에도 함정이 두 개 있습니다. AppTrackingTransparency import를 한 파일에 격리해두고 거기서 처리합니다.

첫째, 앱이 .active일 때 불러야 합니다. 그렇지 않으면 팝업 없이 .notDetermined로 즉시 반환합니다. didFinishLaunching이나 willConnectTo에서 직접 부르면 걸리는 함정입니다.

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() }
    }
}

호출 경로상 앞의 UMP 네트워크 왕복이 서스펜드하는 동안 메인 런루프가 sceneDidBecomeActive를 처리하므로 대체로 active에 도달합니다. 하지만 그건 호출부의 우연한 타이밍이지 이 함수가 보장하는 제약이 아닙니다. 그래서 여기서 직접 기다립니다.

둘째, .notDetermined일 때만 부릅니다. 이미 응답한 상태면 Apple이 팝업 없이 즉시 완료하므로 매 런치 호출해도 안전하지만, 그 왕복조차 생략합니다. 그리고 이 가드가 앞서 말한 ②의 안전판을 성립시킵니다.

확인한 방법

이 글은 2026년 8월 16일 DailySudoku develop 브랜치, 커밋 2ae29c42부터 657d87c7까지의 iOS 구현을 기준으로 합니다.

  • iOS xcodebuild test301 tests / 60 suites 통과. Android compileDebugKotlin 통과.
  • 시뮬레이터 실측: 첫 런치에서 ATT 프롬프트가 그대로 뜬다. 호출 위치를 옮겨도 깨지지 않는다는 것이 이 변경의 핵심 회귀 검증이다. 새로 추가한 「미게시 → no-op」 분기가 예상 밖 모달을 만들지 않는 것도 같은 실행에서 확인했다.
  • 두 번째 런치 로그도 확인: [ATTrackingManager] Returning from trackingAuthorizationStatus - 0(미결정) → 재요청.
  • 미국 주 지리 시뮬레이션 훅을 검증 수단으로 추가: iOS -AdMobDebugUSState, Android --ez admobDebugUsState true (UMPDebugGeographyRegulatedUSState = 3, SDK 헤더로 확인).

못 잰 것

simctl에는 ATT 응답을 주입하는 API가 없습니다. simctl privacy grant trackingOperation not permitted로 거부됩니다. 그래서 「응답 후 재프롬프트 없음」은 측정하지 못했습니다. 그 성질은 guard status == .notDetermined와 iOS의 일회성 계약에 기대고 있고, 실기기 QA 몫으로 남겼습니다.

같은 이유로 **「게시 후 두 번째 런치에서 설명 메시지가 다시 뜨지 않는가」**도 이 시점에는 잴 수 없었습니다. 게시가 선행 조건이기 때문입니다.

바이너리 심볼 조회는 특정 UMP 버전에서 확인한 것입니다. Google 공식 문서가 명시하는 동작이 아니므로, SDK를 올릴 때 다시 확인해야 합니다.

사이드 프로젝트라서 배운 것

문서에 없으면 바이너리를 봅니다. 혼자 만들면 물어볼 팀이 없습니다. 그런데 이번 경우 팀이 있었어도 답은 같은 곳에 있었을 겁니다. 「문서에 없다」와 「알 수 없다」는 다릅니다. otool과 심볼 목록으로 30분이면 확인되는 것을, 추측으로 배선했다면 되돌릴 수 없는 자원을 잘못 태웠을 겁니다.

배포 준비가 검증 수단이 됐습니다. 이 결함은 테스트가 잡은 게 아닙니다. AdMob 콘솔에 메시지 초안을 만들고 게시 버튼 앞까지 가본 것이 잡았습니다. 코드 안에서만 맴돌면 안 보이는 결함이 있고, 실제 배포 경로를 끝까지 밟아보는 것이 그걸 드러냅니다.

한 방향으로만 옳은 결정이 있습니다. 한 달 전 감사는 「ATT를 UMP에 묶지 마라」를 정확히 잡아냈고, 그 결정은 그 시점에 옳았습니다. 전제가 하나 바뀌자 같은 결정이 반대편 결함이 됐습니다. 결정을 기록할 때 결론만 적으면 이런 반전을 못 따라갑니다. 어떤 전제 위에서 옳은지를 함께 적어야 합니다.

그리고 계약을 바꾸면 주석도 바꿔야 합니다. 이 글을 쓰면서 ATTAuthorization.swift의 문서 주석이 아직 한 달 전 계약을 담고 있다는 걸 발견했습니다. 「폼 노출 여부와 무관하게 호출한다」, 「refreshConsent() 완료 직후」, 「ATT는 UMP와 독립된 Apple 플랫폼 요건」 — 세 줄 다 지금은 사실이 아닙니다. 같은 변경이 다른 파일의 낡은 주석 두 건은 정정했는데, 정작 계약을 바꾼 파일은 지나쳤습니다. 다음에 이 파일을 읽는 사람이 주석을 믿으면 이 글이 설명한 결함을 그대로 되살리게 됩니다. 별도 이슈로 올려뒀습니다.

출처

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

#iOS#Swift#Advertising#Privacy#ATT
이전 글경고 주석은 있는데 대조는 없었다: ITSAppUsesNonExemptEncryption 두 원천 검증다음 글READY_FOR_REVIEW는 «제출됨»이 아니다: App Store Connect 제출을 멱등하게 만들기
© 2026 진재명 · blog.jaemyeong.com
iOS 소프트웨어 엔지니어 · 부산