위치 권한 없이 GeoIP 권역으로 광고 공급자 순서 정하기

지역마다 쓸 수 있는 광고 공급자가 다르면 앱은 사용자가 어느 지역에 있는지를 알아야 해요. 이때 필요한 정보는 좌표가 아니에요. 국가나 그보다 넓은 권역이면 충분해요. 광고를 고르려고 정밀한 위치 접근을 요구하는 것은 사용자에게도 앱에도 부담이 커요.

아래는 OS의 위치 권한을 쓰지 않고, 서버가 IP로 추정한 권역만으로 공급자 순서를 정하는 설계예요. 신호마다 무엇을 뜻하는지와 어디서 틀릴 수 있는지를 설명한 것이고, 실제 장애를 관측하거나 측정한 기록은 이 글에 없어요. 각 SDK와 서비스의 버전도 싣지 않았어요. 순서를 정하는 예제 함수만 따로 실행해 봤고, 그 결과는 «예제 함수를 실행해 본 결과» 절에 있어요.

공식 문서가 직접 설명하는 것은 위치 권한, CDN의 위치 헤더, UMP의 게이트가 각각 어떻게 동작하는지까지예요. 지역을 동의 뒤에 조회하는 순서, 프로세스 단위의 캐시, 해석기와 로더의 분리, 공급자 체인, 테스트의 기댓값은 문서의 요구가 아니고 그 위에서 고른 설계이며 제 해석이에요.

지역을 알려 주는 신호 네 가지

지역처럼 보이는 신호는 뜻이 서로 달라요.

  • 앱 언어는 읽기 편한 언어의 선호예요.
  • Locale의 국가는 사용자가 고른 설정값이에요.
  • OS의 위치 서비스는 기기가 측정한 값이고, 권한과 민감정보 처리가 따라와요.
  • GeoIP는 외부에서 관측한 IP로 지역을 추정한 값이에요.

한국어를 쓴다는 이유만으로 국내 공급자를 고를 수는 없어요. 여행 중이거나 설정을 바꾸지 않았다면 Locale과 지금 있는 곳이 다를 수 있어요. GeoIP도 VPN, 중계 서비스, 통신사 게이트웨이의 영향으로 틀리거나 값이 없을 수 있어요. 그리고 조회하는 서버에는 IP가 보여요.

그래서 정밀도가 낮은 신호부터 검토해요. Locale은 언어와 정책의 기본값으로만 쓰고, 공급자를 쓸 수 있는 지역인지의 근거로 삼지 않아요. 지역별 분기가 필요할 때에만 GeoIP를 따로 요청하고, 판단할 근거가 모자라면 unknown으로 둬요. 실제로 위치가 필요한 기능이라면 Apple의 위치 승인 안내와 Android의 권한 최소화 지침에 따라 그 기능의 맥락과 최소 권한 수준을 따져요.

동의가 먼저, 지역은 그다음

앱을 시작할 때의 순서는 이래요.

  1. 저장된 동의 상태를 갱신해요.
  2. 필요하면 동의 UI를 처리해요.
  3. 광고를 요청해도 되는지 게이트를 확인해요.
  4. 지역을 프로세스당 한 번 해석하고 메모리에 둬요.
  5. 권역 버킷에 맞는 공급자 배열을 만들어요.
  6. 배열 순서대로 요청해요.
  7. 모두 실패하면 인하우스 콘텐츠로 끝내요.

게이트의 예는 Google UMP의 canRequestAds예요. 광고를 요청해도 된다는 것과 지역 추정을 믿을 수 있다는 것은 별개의 문제예요.

동의를 확인하지 못했거나 외부 광고가 차단된 상태에서는 지역 조회를 아예 하지 않아요. 그때 무엇을 보여 줄지는 제품과 정책에 따라 정하지만, 우회해서 외부 광고를 호출하면 안 돼요.

초기화가 두 번 들어오는 경우도 막아야 해요. 동의 갱신 직후와 동의 UI의 완료 콜백 양쪽에서 초기화에 들어올 수 있어요. 진행 중인 지역 작업이나 이미 만든 계획을 다시 쓰면 요청의 중복과 경쟁을 줄일 수 있어요.

서버는 버킷만 돌려줘요

CDN이 붙여 주는 위치 헤더는 서버가 관측한 IP를 바탕으로 해요. CloudFront의 요청 헤더가 그런 예예요. 도시 값은 빠질 수 있고, ASCII가 아닌 이름은 인코딩돼서 올 수 있어요. 앱마다 원시 도시 값을 파싱하고 정책을 따로 두면 중복이 생기므로, 서버에서 권역 버킷을 만들어서 내려 줘요.

버킷은 셋이에요. regionalMarket은 지역 공급자를 먼저 시도할 후보이고, otherMarket은 전역 공급자의 대상이고, unknown은 분류할 수 없는 경우예요. 응답에는 좌표, IP, 도시 이름을 넣지 않아요. 도시 단위의 분기가 꼭 필요하다면 사업 규칙에 쓰는 식별자만 전달해요. 헤더가 없거나, 디코딩에 실패하거나, 검증에 실패하면 unknown이에요.

CDN 캐시도 봐야 해요. 지역에 따라 달라지는 응답인데 그 헤더가 캐시 키에 없으면, 한 권역의 결과가 다른 권역에 재사용될 수 있어요. 반대로 키를 지나치게 잘게 나누면 캐시 적중률이 떨어져요. 응답을 캐시하지 않거나, 서버가 만든 거친 버킷을 기준으로 캐시하도록 정책에 적어 둬요.

지역 해석과 광고 로딩을 나눠요

지역 해석기에는 광고 SDK 의존성을 넣지 않고, 광고 로더에는 IP나 Locale 의존성을 넣지 않아요. 둘은 열거형으로만 이어져요.

동의 상태를 먼저 보고, 권역 버킷에 따라 공급자 후보 배열을 돌려주는 예시예요. 이 함수는 광고를 로드하지 않고 배열만 만들어요.

enum ConsentState {
    case pending
    case adsAllowed
    case externalAdsBlocked
}

enum AdRegion {
    case regionalMarket
    case otherMarket
    case unknown
}

enum AdProvider {
    case regional
    case global
    case inHouse
}

func makeAdPlan(
    consent: ConsentState,
    resolveRegion: () async -> AdRegion
) async -> [AdProvider] {
    switch consent {
    case .pending, .externalAdsBlocked:
        return [.inHouse]
    case .adsAllowed:
        break
    }

    switch await resolveRegion() {
    case .regionalMarket:
        return [.regional, .global, .inHouse]
    case .otherMarket, .unknown:
        return [.global, .inHouse]
    }
}

이 예시는 정책 하나를 코드로 옮긴 것이에요. 동의가 끝나지 않았거나 차단됐을 때 [.inHouse]를 돌려주는 것도, unknown에서 전역 공급자를 시도하는 것도 일반 규칙이 아니에요. unknown에서 전역 공급자를 시도할지는 서비스가 지원하는 지역과 동의 조건에 달려 있어요. 이 선택은 해석기 안에 숨기지 않고 체인 정책에서 드러내요.

함수 자체에는 중복 실행을 막는 장치도, 네트워크 timeout도, 로더도 없어요. 한 번만 실행하고 결과를 캐시하는 일은 호출하는 쪽이나 해석기가 작업을 재사용해서 구현해요. 로더는 배열 순서대로 시도하고 처음 성공한 곳에서 멈춰요. 실패한 공급자를 곧바로 다시 부르거나 체인 전체를 처음부터 다시 돌리지 않아요. inHouse는 외부 요청이 모두 실패했을 때 슬롯이 비지 않게 하는 마지막 선택지예요. 공급자를 추가할 때에는 해석기를 건드리지 않고 체인만 바꾸면 돼요.

iOS, Android, Web은 버킷과 우선순위의 계약을 공유할 수 있어요. 구현 모듈까지 합칠 필요는 없어요. 플랫폼마다 자기 SDK를 쓰되 ConsentState → AdRegion → [AdProvider]라는 계약만 지켜요.

예제 함수를 실행해 본 결과

위의 makeAdPlan을 그대로 컴파일해서 동의 상태 셋과 지역 버킷 셋의 조합을 모두 실행해 봤어요. 실행한 곳은 macOS 27.0.1의 Swift 6.4이고, 날짜는 2026년 10월 4일이에요. 지역을 돌려주는 클로저가 몇 번 불렸는지도 함께 찍었어요.

pending + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
adsAllowed + regionalMarket -> [geodemo.AdProvider.regional, geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + otherMarket -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + unknown -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
externalAdsBlocked + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0

geodemo는 실행 파일의 모듈 이름이에요. 동의가 pending이거나 외부 광고가 막혀 있으면 지역 조회가 한 번도 일어나지 않았고 인하우스만 나왔어요. 광고가 허용된 경우에만 조회가 한 번 일어났고, 지역 버킷일 때만 지역 공급자가 앞에 왔어요.

이 실행이 확인한 것은 순서를 정하는 함수까지예요. 실제 GeoIP 조회, 광고 SDK, 순서대로 시도하는 로더, timeout, 캐시는 실행하지 않았어요.

조회가 실패해도 멈추지 않게

지역 조회의 네트워크 요청에는 제한을 걸어요.

  • 지정한 HTTPS 대상만 허용하고, 예상하지 못한 redirect는 막아요.
  • timeout은 몇 초 이하로 두고, 화면을 그리는 중에 다시 요청하지 않아요.
  • 응답 크기를 제한하고, 버킷 값은 허용 목록에 있는 것만 받아요.
  • 맞지 않는 응답은 unknown으로 처리해요.
  • 결과와 진행 중인 요청은 프로세스 안에서 재사용해요.
  • IP와 도시의 원본 값은 디스크에 저장하지 않고 분석 로그에도 남기지 않아요.

HTTP 오류, timeout, 오프라인, 파싱 오류도 모두 평범한 실패값으로 다뤄요. timeout과 응답 크기의 구체적인 수치는 이 글에서 정하지 않았어요.

메모리 캐시에는 한계가 있어요. 망이 바뀐 뒤에도 이전 버킷을 계속 쓸 수 있어요. 거친 우선순위 힌트로는 받아들일 만하다고 판단했고, 다음에 프로세스가 시작될 때 다시 평가해요. 이 판단을 뒷받침하는 측정 결과는 이 글에 싣지 않았어요. 장기 캐시가 필요하다는 측정이 나오기 전에는 영구 저장과 만료 관리를 더하지 않아요.

권한이 없다고 익명인 것은 아니에요

OS의 위치 API를 쓰지 않으면 위치 권한 창은 뜨지 않아요. 하지만 GeoIP 서버와 CDN은 여전히 IP 주소를 처리해요. «권한이 필요 없다»를 «익명이다» 또는 «위치 데이터를 다루지 않는다»로 설명하면 정확하지 않아요.

실제 데이터 흐름을 놓고 목적, 보관, 제3자 전달, 동의와의 관계를 검토해야 해요. 앱에는 버킷만 내려 주고, 서버는 IP와 위치 헤더를 필요 이상으로 보존하지 않고, 라우팅에 쓴 값을 장기 프로필에 쓰지 않아요. 수집을 줄였다는 것과 법률이나 스토어 정책을 지켰다는 것은 별개예요. Apple의 개인정보 보호 지침도 이 관점에서 참고해요.

테스트로 고정할 것

실제 GeoIP와 광고 SDK를 호출하면 VPN, 망 상태, 광고 재고 때문에 결과가 실행마다 달라질 수 있어요. 단위 테스트에서는 지역 함수와 로더를 주입해서 아래 기댓값을 고정해요.

  • 동의가 pending이면 지역 조회는 0회이고 인하우스를 골라요.
  • 광고가 허용되고 지역 버킷이면 지역 공급자가 먼저예요.
  • 지역 공급자가 실패하면 전역으로 한 번 넘어가요.
  • timeout이나 응답 오류면 unknown 경로로 전역을 골라요.
  • 외부 요청이 모두 실패하면 인하우스로 끝나요.
  • 동의 완료 콜백이 두 번 와도 조회와 계획 생성은 각각 한 번이에요.

통합 환경에서는 오프라인, 응답 지연, 헤더 누락, 인코딩된 도시 값, VPN, Private Relay 조건을 확인해요. 통과 기준은 허용된 공급자 조합만 나오는지, 시간 제한 안에 끝나는지, 원시 민감값이 로그에 보이지 않는지예요. 실제 위치를 얼마나 맞혔는지는 통과 조건으로 삼지 않아요.

이 설계의 목표는 GeoIP가 틀려도 앱이 멈추지 않는 것이에요. 위의 기댓값과 통합 조건은 그 목표를 확인하는 데 쓸 기준이에요. 이 가운데 실행으로 확인한 것은 계획을 만드는 함수가 허용된 조합만 내놓는다는 것까지예요. 실제 조회가 시간 제한 안에 끝나는지와 로더의 동작을 확인한 결과는 이 글에 없어요.