AdMob 광고 매출, Reporting API로 직접 가져오기
Google AdMob 광고 매출을 Reporting API 로 직접 받는 법 — OAuth(admob.readonly), networkReport POST, NDJSON·micros 파싱, 통화 함정. App Store Connect 와 비교.
horog 는 앱 광고 매출도 시급 계산에 넣어야 했다. 그래서 AdMob 을 붙였는데, App Store Connect 와는 결이 꽤 달랐다. App Store 는 JWT(ES256) 서명 + gzip TSV 였는데, AdMob 은 Google OAuth 2.0 + NDJSON + micros 다. 이 글은 그 차이와 부딪힌 함정 정리다.
Google 에서 받을 것
- Google Cloud Console → APIs & Services 에서 AdMob API 활성화
- OAuth 2.0 Client (Web) 생성 →
Client ID/Client Secret - 스코프
admob.readonly— 리포트 *읽기* 만. 광고 단위 생성·결제 정보 접근은 안 됨 - OAuth 동의 화면에 본인 Google 계정을 테스트 사용자로 등록
OAuth — JWT 가 아니다
App Store 는 키 1개로 매 요청 JWT 를 굽지만, AdMob 은 표준 Google OAuth authorization code 플로우다. 핵심은 access_type=offline + prompt=consent — 이게 있어야 refresh_token 을 준다. 없으면 access token 만 받고 1시간 뒤 끊긴다.
// AdMob 은 App Store 처럼 JWT 가 아니라 Google OAuth 2.0.
// admob.readonly 스코프 + access_type=offline 로 refresh_token 확보.
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", process.env.ADMOB_OAUTH_CLIENT_ID!);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set(
"scope",
"https://www.googleapis.com/auth/admob.readonly openid email profile",
);
url.searchParams.set("access_type", "offline"); // refresh_token 받으려면 필수
url.searchParams.set("prompt", "consent");
// 콜백에서 code → token 교환 (POST oauth2.googleapis.com/token,
// grant_type=authorization_code). 만료되면 grant_type=refresh_token 으로 갱신.리포트 호출 — POST + JSON
먼저 GET /v1/accounts 로 publisher 를 찾는다 (보통 사용자당 1개, pub-XXXXXXXXXXXX 형식). 그다음 networkReport:generate 를 부르는데 — 이게 GET 이 아니라 POST 에 JSON body 다.
// 함정 1: 리포트는 GET 이 아니라 POST + JSON body.
// publisherId 는 GET /v1/accounts 의 첫 row (보통 사용자당 1개).
const res = await fetch(
`https://admob.googleapis.com/v1/accounts/${publisherId}/networkReport:generate`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
reportSpec: {
dateRange: {
startDate: { year: 2026, month: 6, day: 1 },
endDate: { year: 2026, month: 6, day: 7 },
},
dimensions: ["DATE", "APP"],
metrics: ["ESTIMATED_EARNINGS", "IMPRESSIONS", "CLICKS"],
// 함정 2: 통화 안 맞추면 계정 통화(KRW 등)로 옴 → USD 고정
localizationSettings: { currencyCode: "USD", languageCode: "en-US" },
},
}),
},
);파싱 — NDJSON + micros
응답이 단일 JSON 객체가 아니라 NDJSON 이다. 줄마다 객체 하나 — 첫 줄은 header, 가운데가 데이터 row, 마지막이 footer. 줄 단위로 파싱하고 row 키가 있는 것만 쓴다. 그리고 매출은 micros 라서 100만으로 나눠야 달러가 된다.
// 함정 3: 응답이 NDJSON — 줄마다 JSON 1개 (header → row들 → footer).
const text = await res.text();
for (const line of text.trim().split("\n")) {
const obj = JSON.parse(line);
if (!("row" in obj)) continue; // header / footer line 은 건너뜀
const date = obj.row.dimensionValues.DATE.value; // "20260607" (YYYYMMDD)
const app = obj.row.dimensionValues.APP.displayLabel; // 앱 이름
// 함정 4: ESTIMATED_EARNINGS 는 micros 정수 (1 USD = 1,000,000)
const usd =
Number(obj.row.metricValues.ESTIMATED_EARNINGS.microsValue) / 1_000_000;
}날짜는 20260607 같은 YYYYMMDD 문자열로 온다. 앱별로 보려면 APP dimension 의 displayLabel 을 쓴다.
실제로 부딪힌 함정
- JWT 아님 — Google OAuth 2.0.
access_type=offline없으면 refresh_token 이 안 온다 - 리포트는 POST —
networkReport:generate, JSON body 에reportSpec - NDJSON — 줄마다 JSON. header·footer row 를 데이터와 섞지 말 것
- micros —
ESTIMATED_EARNINGS는 1 USD = 1,000,000. 그냥 더하면 100만 배 매출이 된다 - 통화 — 계정 통화로 옴.
localizationSettings.currencyCode로 USD 고정하거나, 환산을 직접 - 추정치 — ESTIMATED_EARNINGS 는 *추정*. 월말 확정 정산과 몇 % 차이날 수 있다. 시급 추세엔 충분하지만 회계용은 아님
- 401 → refresh — access token 만료 시 한 번 갱신 후 재시도
App Store 와 비교
- 인증: App Store = JWT(ES256, 20분) / AdMob = OAuth refresh_token
- 리포트: App Store = GET → gzip TSV / AdMob = POST → NDJSON
- 금액: App Store = 소수 문자열 / AdMob = micros 정수
- 지연: App Store = D+1 / AdMob = 당일도 부분 집계(추정)