Logo
Published on
·14 min read

애드몹(AdMob) GDPR 적용 - 안드로이드

개요

애드몹(AdMob)에서 계속 GDPR 메시지를 게시하라고 경고를 보여줍니다.

애드센스에서의 GDPR 메시지 작성은 간단했기에 애드몹도 비슷하게 적용할 수 있을 것이라고 생각했습니다.

하지만, 애드몹에서는 GDPR 메시지 작성 외에, 직접 어플리케이션에 동의 여부를 확인하는 코드 작성이 필요했습니다. 😱

스마트 탁상 시계 앱에 적용한 내용을 정리해봅니다.

React Native로 구현한 내용은 애드몹(AdMob) GDPR 적용 - React Native(안드로이드) 에서 확인할 수 있습니다.

🛑 주의

❗ 법적인 책임을 지지 않습니다. ❗

구글 문서와 샘플 코드 및 검색등을 이용하여 구현하였습니다. 하지만 정책이나 구현에 대해 잘못된 해석이 있을 수 있습니다. 구현에 대해 참고만 하시길 바랍니다.

테스트 기기에서, 테스트 모드로 진행시에는 정상 동작하는 것을 확인했지만 EEA 지역에서 실제 기기의 동작은 확인하지 못했습니다.

GDPR이란?

2018년 5월 25일부터 시행되고있는 EU(유럽연합)의 개인정보보호 법령으로 위반시 과징금 등 행정처분이 부과될 수 있으며, EU내 사업장이 없더라도 EU를 대상으로 사업을 하는 경우 적용대상이 될 수 있어 우리 기업의 주의가 필요함

KISA, GDPR 소개

GDPR 메시지 설정

Google AdMob > GDPR > [메시지 만들기] 버튼을 클릭합니다.

설정은 본인 앱에 맞게 설정하시길 바랍니다. 제 설정은 아래와 같습니다.

메시지 만들기
  • 내 앱: 메시지를 적용할 앱을 선택했습니다.
  • 언어: 메시지를 표시할 언어를 '영어'로 지정했습니다.
  • 동의하지 않음: 사용
  • 닫기(동의하지 않음): 사용 안함

메시지를 만들고 게시하고, 코드 구현을 해야 설정한 GDPR 메시지가 표시됩니다.

결과 화면

테스트 모드로 진행하면서 확인한 화면입니다.

왼쪽은 앱 실행시 표시된 메시지입니다.
오른쪽은 설정에서 GDPR 설정을 변경할 수 있도록 버튼을 추가했습니다. 해당 버튼 클릭시 왼쪽 메시지 화면이 표시됩니다.

결과 화면

구현

1. Install with Gradle

Google AdMob, UMP에서 안내대로 app/build.gradle 파일의 dependencies에 아래 내용을 추가합니다.

dependencies {
  // ... 기존 내용들 ...
  implementation 'com.google.android.ump:user-messaging-platform:2.1.0'
}

그리고 프로젝트와 동기화(Sync Now)합니다.

2. 앱 측정 지연

Google AdMob, UMP > GDPR IAB 지원 > 앱 측정 지연 항목의 안내대로 AndroidManifest.xml 파일에 아래 내용을 추가합니다.

<manifest>
     <application>
        <!-- 기존 내용들 -->
        <!-- Delay app measurement until MobileAds.initialize() is called. -->
        <meta-data
            android:name="com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT"
            android:value="true"/>
    </application>
</manifest>

3. 코드

package your_package_name

import android.app.Activity
import android.content.Context
import com.google.android.ump.ConsentDebugSettings
import com.google.android.ump.ConsentForm.OnConsentFormDismissedListener
import com.google.android.ump.ConsentInformation
import com.google.android.ump.ConsentRequestParameters
import com.google.android.ump.FormError
import com.google.android.ump.UserMessagingPlatform

/**
 * The Google Mobile Ads SDK provides the User Messaging Platform (Google's IAB Certified consent
 * management platform) as one solution to capture consent for users in GDPR impacted countries.
 * This is an example and you can choose another consent management platform to capture consent.
 */
class GoogleMobileAdsConsentManager private constructor(context: Context) {
    private val consentInformation: ConsentInformation =
        UserMessagingPlatform.getConsentInformation(context)

    /** Interface definition for a callback to be invoked when consent gathering is complete. */
    fun interface OnConsentGatheringCompleteListener {
        fun consentGatheringComplete(error: FormError?)
    }

    /** Helper variable to determine if the app can request ads. */
    val canRequestAds: Boolean
        get() = consentInformation.canRequestAds()

    /** Helper variable to determine if the privacy options form is required. */
    val isPrivacyOptionsRequired: Boolean
        get() =
            consentInformation.privacyOptionsRequirementStatus ==
                    ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED

    // 동의, 비동의 체크를 위해 추가한 코드 [[
    // https://itnext.io/android-admob-consent-with-ump-personalized-or-non-personalized-ads-in-eea-3592e192ec90
    // https://stackoverflow.com/questions/65351543/how-to-implement-ump-sdk-correctly-for-eu-consent/68310602#68310602
    fun isGDPR(context: Context): Boolean {
        val prefs = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
        val gdpr = prefs.getInt("IABTCF_gdprApplies", 0)
        return gdpr == 1
    }

    fun canShowAds(context: Context): Boolean {
        //val prefs = PreferenceManager.getDefaultSharedPreferences(context)
        // PreferenceManager 가 Deprecated 되어서 아래와 같이 사용함
        val prefs = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
        val purposeConsent = prefs.getString("IABTCF_PurposeConsents", "") ?: ""
        val vendorConsent = prefs.getString("IABTCF_VendorConsents", "") ?: ""
        val vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests", "") ?: ""
        val purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests", "") ?: ""

        val googleId = 755
        val hasGoogleVendorConsent = hasAttribute(vendorConsent, googleId)
        val hasGoogleVendorLI = hasAttribute(vendorLI, googleId)

        // Personalized Ads: listOf(1, 3, 4)
        return hasConsentFor(listOf(1), purposeConsent, hasGoogleVendorConsent)
                && hasConsentOrLegitimateInterestFor(
                    listOf(2,7,9,10),
                    purposeConsent,
                    purposeLI,
                    hasGoogleVendorConsent,
                    hasGoogleVendorLI
                )
    }

    // Check if a binary string has a "1" at position "index" (1-based)
    private fun hasAttribute(input: String, index: Int): Boolean {
        return input.length >= index && input[index-1] == '1'
    }

    // Check if consent is given for a list of purposes
    private fun hasConsentFor(purposes: List<Int>, purposeConsent: String, hasVendorConsent: Boolean): Boolean {
        return purposes.all { p -> hasAttribute(purposeConsent, p)} && hasVendorConsent
    }

    // Check if a vendor either has consent or legitimate interest for a list of purposes
    private fun hasConsentOrLegitimateInterestFor(purposes: List<Int>, purposeConsent: String, purposeLI: String, hasVendorConsent: Boolean, hasVendorLI: Boolean): Boolean {
        return purposes.all { p ->
            (hasAttribute(purposeLI, p) && hasVendorLI) ||
                    (hasAttribute(purposeConsent, p) && hasVendorConsent)
        }
    }
    // 동의, 비동의 체크를 위해 추가한 코드 ]]

    /**
     * Helper method to call the UMP SDK methods to request consent information and load/show a
     * consent form if necessary.
     */
    fun gatherConsent(
        activity: Activity,
        onConsentGatheringCompleteListener: OnConsentGatheringCompleteListener
    ) {
        // For testing purposes, you can force a DebugGeography of EEA or NOT_EEA.
        val debugSettings =
            ConsentDebugSettings.Builder(activity)
                .setDebugGeography(ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_EEA) // EEA 지역이라고 강제 설정
                // Check your logcat output for the hashed device ID e.g.
                // "Use new ConsentDebugSettings.Builder().addTestDeviceHashedId("ABCDEF012345")" to use
                // the debug functionality.
                .addTestDeviceHashedId("본인의-테스트-디바이스-해시-아이디")
                .build()

        val params = ConsentRequestParameters
            .Builder()
            //.setConsentDebugSettings(debugSettings) // 테스트 시에만 사용
            .build()

        // consentInformation.reset() // 동의 상태 재설정, 테스트 시에만 사용
        // Requesting an update to consent information should be called on every app launch.
        consentInformation.requestConsentInfoUpdate(
            activity,
            params,
            {
                UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { formError ->
                    // Consent has been gathered.
                    onConsentGatheringCompleteListener.consentGatheringComplete(formError)
                }
            },
            { requestConsentError ->
                onConsentGatheringCompleteListener.consentGatheringComplete(requestConsentError)
            }
        )
    }

    /** Helper method to call the UMP SDK method to show the privacy options form. */
    fun showPrivacyOptionsForm(
        activity: Activity,
        onConsentFormDismissedListener: OnConsentFormDismissedListener
    ) {
        UserMessagingPlatform.showPrivacyOptionsForm(activity, onConsentFormDismissedListener)
    }

    companion object {
        @Volatile private var instance: GoogleMobileAdsConsentManager? = null

        fun getInstance(context: Context) =
            instance
                ?: synchronized(this) {
                    instance ?: GoogleMobileAdsConsentManager(context).also { instance = it }
                }
    }
}

4. 테스트 설정

'본인의-테스트-디바이스-해시-아이디' 변경

  1. GoogleMobileAdsConsentManager.kt > 본인의-테스트-디바이스-해시-아이디 부분을 본인의 디바이스 값으로 변경합니다.

    • 본인의 디바이스 해시 아이디: Log에 찍힌 Use new ConsentDebugSettings.Builder().addTestDeviceHashedId("본인의 디바이스 해시 아이디")를 찾아서 복사하여 붙여넣습니다.
  2. 테스트 설정이 적용되도록 setConsentDebugSettings 부분 주석 풀기

    • ❗ 테스트 시에만 사용하고, 배포시에는 꼭 주석으로 막아주세요. ❗
val params = ConsentRequestParameters
            .Builder()
            .setConsentDebugSettings(debugSettings) // 주석 풀기, 테스트 시에만 사용
            .build()

5. 적용

  • 해당 앱은 GDPR / 광고 로드를 ClockFragment에서 처리하고 있습니다.

  • GDPR 적용 국가인 경우 GDPR 최초 로드 시 동의 / 동의 하지 않음 팝업을 로드합니다. 이후 설정 버튼 클릭 시 동의 여부를 체크하여 동의하지 않은 경우 동의 팝업을 다시 로드합니다.

  • 권고에 따라 설정에서 동의 여부를 변경할 수 있도록 했습니다.

ClockFragment.kt

  • onCreate > checkAdMobGDPRConsent: GDPR 로드
  • onViewCreated > binding.btnSetting.setOnClickListener: 설정 아이콘 클릭 시, GDPR 동의 여부 확인
class ClockFragment : Fragment() {
    private var interstitialAd: InterstitialAd? = null // Admob 전면 광고 관련

    private val isMobileAdsInitializeCalled = AtomicBoolean(false)
    private lateinit var googleMobileAdsConsentManager: GoogleMobileAdsConsentManager
    private var TAG = "ClockFragment"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // GDPR 로드
        checkAdMobGDPRConsent()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 설정 아이콘 클릭 시, 설정 화면으로
        binding.btnSetting.setOnClickListener {
            val isGDPR = googleMobileAdsConsentManager.isGDPR(requireContext())
            val canShowAds = googleMobileAdsConsentManager.canShowAds(requireContext())

            // gdpr 확인 필요 시, 동의 여부 확인
            if(isGDPR && !canShowAds){
                googleMobileAdsConsentManager.showPrivacyOptionsForm(requireActivity()) { formError ->
                    if (formError != null) {
                        // 오류시에는 설정 화면으로 이동
                        Toast.makeText(requireContext(), formError.message, Toast.LENGTH_SHORT).show()
                        showSetting()
                    } else {
                        // 동의 여부 다시 확인
                        val result = googleMobileAdsConsentManager.canShowAds(requireContext())

                        // 동의했을 경우 설정 화면으로 이동
                        if(result){
                            showSetting()
                        }
                    }
                }
            } else{
                showSetting()
            }
        }

        // 생략
    }

    private fun showSetting() {
        // Admob 전면 광고 관련, 아래 조건에 부합할 때 광고 보여주기
        if (SETTING_CLICKED_COUNT % ADMOB_CHECK_COUNT == 0) {
            showInterstitial()
        }
        else{
            findNavController().navigate(R.id.action_clockFragment_to_previewAndSettingFragment)
            SETTING_CLICKED_COUNT++
        }
    }

    private fun checkAdMobGDPRConsent(){
        googleMobileAdsConsentManager = GoogleMobileAdsConsentManager.getInstance(requireContext())
        googleMobileAdsConsentManager.gatherConsent(
            requireActivity(),
        ) { error ->
            Log.i(
                TAG,
                "Consent gathering result: ${error?.message}, googleMobileAdsConsentManager.canRequestAds: ${googleMobileAdsConsentManager.canRequestAds}"
            )

            if (error != null) {
                // Consent not obtained in current session.
                Log.d(TAG, "${error.errorCode}: ${error.message}")
                initializeMobileAdsSdk()
            } else {
                if (googleMobileAdsConsentManager.canRequestAds) {
                    initializeMobileAdsSdk()
                }
            }
        }

        // This sample attempts to load ads using consent obtained in the previous session.
        if (googleMobileAdsConsentManager.canRequestAds) {
            initializeMobileAdsSdk()
        }
    }


    /**
     * Admob 전면 광고 관련
     */
    private fun initializeMobileAdsSdk() {
        if (isMobileAdsInitializeCalled.getAndSet(true)) {
            return
        }

        val appContext = activity?.applicationContext ?: return

        MobileAds.initialize(appContext) {}
        loadInterstitialAd(appContext)
    }

    // 광고 로드
    private fun loadInterstitialAd(context: Context){
        val adRequest = AdRequest.Builder().build()
        InterstitialAd.load(context, getString(R.string.interstitial_ad_unit_id), adRequest,
            object : InterstitialAdLoadCallback() {
                // 생략, interstitialAd = ad 또는 interstitialAd = null
            })
    }

    private fun showInterstitial() {
        // 생략, 광고 표시
    }

    // 생략
}
  • GDPR 메시지 로드가 안된다면?

    • Google AdMob 사이트에서, 해당 앱에 대해 GDPR을 게시했는 지 확인하세요.
    • '본인의-테스트-디바이스-해시-아이디'가 맞는 지 확인하세요.
  • 테스트 모드 ↔ 테스트 모드가 아닐 때를 전환하며 테스트 할 때 이전 설정이 남아있어서 테스트가 안될 경우

    • 테스트 모드: .setConsentDebugSettings(debugSettings) 주석 풀기, consentInformation.reset() 주석 풀어 설정을 초기화 후, 다시 주석 막기
    • 비 테스트 모드: //.setConsentDebugSettings(null) 주석으로 막기, consentInformation.reset() 주석 풀어 설정을 초기화 후, 다시 주석 막기
  • 로드 되지 않아야 할때 로드 되거나, 로드 되야하는데 로드되지 않는 경우

    • 선택한 정보들은 Local Storage에 저장됩니다. 따라서, 해당 앱의 Storage를 삭제 후 다시 실행하면 정상 동작했습니다.
    • 그것으로도 안될 경우 최후의 수단, 해당 앱을 Uninstall 후 다시 실행하면 정상적으로 동작했습니다.

SettingFragment.kt

설정에서 GDPR 설정을 변경할 수 있도록 버튼을 추가했습니다.

버튼 클릭 시 동의/비동의 선택할 수 있는 GDPR 메시지가 표시됩니다.

class SettingFragment : Fragment() {

    private lateinit var googleMobileAdsConsentManager: GoogleMobileAdsConsentManager

    // 생략

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 생략

        googleMobileAdsConsentManager = GoogleMobileAdsConsentManager.getInstance(requireContext())
        binding.btnGdprSetting.isVisible = googleMobileAdsConsentManager.isPrivacyOptionsRequired
        binding.btnGdprSetting.setOnClickListener {
            googleMobileAdsConsentManager.showPrivacyOptionsForm(requireActivity()) { formError ->
                if (formError != null) {
                    Toast.makeText(requireContext(), formError.message, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    // 생략
}

참고