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

> AdMob IDFA 설명 메시지를 게시하려다 막혔습니다. 어느 쪽이 requestTrackingAuthorization을 부르는지 공식 문서에 없어서 UMP 프레임워크 바이너리를 읽었고, 거기서 앱과 SDK가 일회성 프롬프트를 두고 경합한다는 걸 확인했습니다.

- Canonical: https://blog.jaemyeong.com/posts/att-ump-ordering-ios-consent/
- Published: 2026.08.18
- Category: 기술
- Tags: #iOS, #Swift, #Advertising, #Privacy, #ATT

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 프레임워크 바이너리를 직접 읽었습니다.

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

otool -L: AppTrackingTransparency 하드 링크 없음
```

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

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

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는 설명이 영영 안 뜬다」입니다.

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

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

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

### 적대적 리뷰가 잡은 두 가지

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

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

```swift
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`에서 직접 부르면 걸리는 함정입니다.

```swift
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 test` — **301 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 tracking`은 *Operation not permitted*로 거부됩니다. 그래서 **「응답 후 재프롬프트 없음」은 측정하지 못했습니다.** 그 성질은 `guard status == .notDetermined`와 iOS의 일회성 계약에 기대고 있고, 실기기 QA 몫으로 남겼습니다.

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

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

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

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

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

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

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

## 출처

- [App Tracking Transparency - Apple Developer](https://developer.apple.com/documentation/apptrackingtransparency)
- [requestTrackingAuthorization(completionHandler:) - Apple Developer](https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/requesttrackingauthorization(completionhandler:))
- [User Messaging Platform for iOS - Google AdMob](https://developers.google.com/admob/ios/privacy)
- [DailySudoku - App Store](https://apps.apple.com/app/id1149229748)
- [DailySudoku - Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku)
- [DailySudoku 웹](https://dailysudoku.app/ko/)
