「업데이트 노트」는 연재를 마친 프로젝트에 그 뒤로 더해진 기능과 고친 점을 한 편씩 정리하는 연재다. 애드소프트 기술블로그와 「오늘 누가 쏘냐?」처럼 개발기는 끝났지만 계속 손보고 있는 프로젝트를 다룬다.

첫 편은 블로그 구축기의 후속이다. 구축기 마지막 글에서 방문 통계를 직접 만들었는데, 이번에는 그 옆에 애드센스 수익을 보는 화면을 붙였다.

왜 관리자 화면에 붙였나

수익은 애드센스 사이트에 들어가면 볼 수 있다. 그런데 방문 통계는 블로그 관리자 화면에 있고 수익은 다른 사이트에 있으니, "조회수가 늘었는데 수익은 어땠지?"를 보려면 두 화면을 오가야 했다. 그래서 관리자 > 애드센스 > 수익(/admin/revenue)을 만들고, 기존 광고 설정 화면과 함께 '애드센스' 메뉴로 묶었다.

구글은 이런 용도로 AdSense Management API v2를 제공한다. 계정의 보고서, 광고 단위, 지급 내역을 읽을 수 있다.

구성

AdminRevenueController   연결 시작 · 콜백 · 연동 해제 · 새로고침 · 대시보드
        ↓
AdSenseRevenueService    토큰 관리 · 보고서 조회 · 집계 · 캐시
        ↓
AdSenseApiClient         구글 OAuth / AdSense API REST 호출 (HttpClient)

구글이 .NET용 클라이언트 라이브러리를 제공하지만 쓰지 않았다. 필요한 호출은 토큰 발급·갱신·폐기와 API 세 가지(계정, 보고서, 지급) 정도라서, IHttpClientFactory로 REST를 직접 부르는 편이 의존성도 적고 응답 형식도 필요한 필드만 정의하면 됐다. 구글 서버가 응답하지 않을 때 관리자 화면이 오래 멈추지 않도록 시간 제한은 20초로 두었다.

필요한 권한(scope)

요청하는 범위는 세 가지다.

scope 용도
adsense.readonly 보고서·광고 단위·지급 내역 읽기 (쓰기 권한 없음)
openid, email 연결한 구글 계정 이메일을 화면에 표시

수익을 보는 화면이라 쓰기 권한은 필요 없다. 읽기 전용 범위만 받으면 토큰이 새어 나가더라도 애드센스 설정을 바꿀 수는 없다.

OAuth 연결 흐름

관리자가 "애드센스 연결하기"를 누르면 구글 동의 화면으로 보내고, 허용하면 콜백으로 돌아온다. 흔한 인가 코드 흐름이다.

[연결하기] POST /admin/revenue/connect
   → state 생성, 쿠키에 보관 → 구글 동의 화면으로 리디렉션
   → 사용자 허용
   → GET /admin/revenue/callback?code=…&state=…
   → state 비교 → 코드로 토큰 교환 → 계정 조회 → 저장

인가 주소를 만들 때 두 가지를 넣었다.

+ "&access_type=offline"
+ "&prompt=consent" // 다시 연결할 때도 refresh token 을 받는다

access_type=offline이 있어야 refresh token을 받는다. 그런데 구글은 이미 허용한 앱이 다시 요청하면 refresh token을 주지 않는다. prompt=consent로 매번 동의 화면을 띄워야 다시 연결할 때도 새 refresh token이 온다. 그래도 refresh token이 비어 있으면 저장하지 않고, 구글 계정의 서드파티 액세스에서 앱을 지운 뒤 다시 연결하라고 안내한다.

state 검증

state는 연결을 시작할 때 무작위 16바이트로 만들어 쿠키에 넣는다. 쿠키는 HttpOnly, 경로는 /admin/revenue로 좁히고 10분 뒤 사라진다. 구글에서 돌아오는 요청은 다른 사이트에서 시작된 GET이라 SameSite=Lax여야 쿠키가 실린다.

var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
Response.Cookies.Append(StateCookie, state, new CookieOptions
{
    HttpOnly = true,
    Secure = Request.IsHttps,
    SameSite = SameSiteMode.Lax, // 구글에서 돌아오는 GET 에 실려야 한다
    Path = "/admin/revenue",
    MaxAge = TimeSpan.FromMinutes(10)
});

콜백에서는 쿠키를 먼저 지우고 나서 값을 비교한다. 비교는 CryptographicOperations.FixedTimeEquals로 한다. 값이 없거나 다르면 "연결 요청이 만료되었거나 올바르지 않습니다"를 보여 주고 끝낸다. 사용자가 동의 화면에서 취소하면 구글이 error=access_denied로 돌려보내는데, 이때는 오류가 아니라 "연결을 취소했습니다"로 안내한다.

계정 이메일

연결한 계정이 어느 구글 계정인지 화면에 보여 주려고 id_token의 email을 읽는다. 토큰 엔드포인트에서 TLS로 직접 받은 값이라 서명 검증은 하지 않고 JWT의 payload만 디코딩했다. 화면에는 앞 세 글자만 남기고 가려서 abc****@example.com처럼 표시한다.

토큰 저장 방식

토큰은 두 종류다. 둘을 다르게 다뤘다.

토큰 수명 보관
refresh token 길다 (폐기 전까지) ASP.NET Core DataProtection으로 암호화해 DB SiteSetting에
access token 약 1시간 메모리에만

refresh token은 앱이 재시작돼도 남아 있어야 하니 DB에 둔다. 다만 평문으로 두지 않고 DataProtection으로 암호화한다. 연결 정보는 AdSenseApi: 접두어가 붙은 키 몇 개(암호화된 토큰, 계정 이름, 이메일, 계정 시간대, 연결 시각)로 저장한다. 이 작업을 하면서 SiteSetting 읽기·쓰기를 SiteSettingStore로 분리해 광고 설정 화면과 같이 쓰게 했다.

_protector = dataProtection.CreateProtector("AddsoftBlog.AdSenseApi.RefreshToken");
...
[RefreshTokenKey] = _protector.Protect(token.RefreshToken),

암호화 키가 바뀌어 복호화에 실패하면 예외를 그대로 던지지 않고 "다시 연결"로 안내한다.

access token은 refresh token으로 언제든 다시 받을 수 있으니 메모리에만 둔다. 만료 1분 전에 미리 갱신하고, 여러 요청이 동시에 갱신하지 않도록 SemaphoreSlim으로 감쌌다.

await _tokenLock.WaitAsync();
try
{
    if (_accessToken != null && _accessTokenFor == refreshToken && DateTime.UtcNow < _accessTokenExpires)
    {
        return _accessToken;
    }
    var token = await _api.RefreshAsync(clientId, clientSecret, refreshToken);
    SetAccessToken(refreshToken, token.AccessToken, token.ExpiresIn); // 만료 60초 전까지 유효로 계산
    return token.AccessToken;
}
finally
{
    _tokenLock.Release();
}

연동 해제를 누르면 구글의 revoke 엔드포인트로 refresh token을 폐기하고 DB의 연결 정보도 지운다. 구글 쪽 요청이 실패해도 우리 쪽 연결 정보는 지운다.

OAuth 클라이언트 ID와 보안 비밀은 DB가 아니라 서버 설정 파일(개발 환경은 user-secrets)에 둔다. 값이 비어 있으면 연결 버튼 대신 설정 방법과 구글 콘솔에 등록할 리디렉션 URI를 화면에 보여 준다.

연결이 7일마다 끊기는 문제

구글 클라우드 콘솔의 OAuth 동의 화면이 '테스트' 상태면 refresh token이 7일 뒤 만료된다. 매주 다시 연결해야 하니 게시 상태를 '프로덕션'으로 바꿨다. 구글 검증을 받지 않은 앱이라 연결할 때 "확인되지 않은 앱" 경고가 나오지만, 자기 계정을 연결하는 용도라 고급 메뉴로 진행하면 된다.

refresh token이 만료되거나 폐기되면 토큰 갱신이 invalid_grant로 실패한다. 이 경우를 따로 구분해 "연결이 만료되었거나 해제되었습니다. 다시 연결하세요"와 함께 다시 연결 버튼을 보여 준다.

동의 화면에는 앱 로고가 보인다. 마침 블로그 파비콘을 회사 </> 아이콘으로 바꾸던 참이라, 같은 원본에서 동의 화면용 120×120 로고(흰 바탕, 투명 바탕 두 가지)를 만들어 등록했다. 바뀐 파비콘이 브라우저 캐시에 막히지 않도록 링크에 asp-append-version도 붙였다.

권한을 빼고 허용하면

연결을 시험하다가 이상한 경우를 만났다. 구글 동의 화면은 요청한 범위마다 체크박스를 보여 주는데, '애드센스 데이터 보기' 체크를 풀고 허용해도 연결 자체는 성공한다. 토큰은 받았지만 그 토큰에는 adsense.readonly가 없다. 그 상태로 API를 부르면 403과 함께 insufficient authentication scopes가 돌아오고, 화면에는 이 영어 원문만 보였다.

두 군데에서 잡도록 고쳤다.

1. 연결할 때. 토큰 응답의 scope 필드에는 실제로 허용된 범위가 공백으로 구분돼 온다. 여기에 adsense.readonly가 없으면 저장하지 않고, 받은 토큰을 바로 폐기한 뒤 안내한다.

public bool HasAdSenseScope => Scope == null || Scope.Split(' ').Contains(AdSenseScope);
if (!token.HasAdSenseScope)
{
    await _api.RevokeAsync(token.RefreshToken);
    throw new AdSenseApiException(MissingScopeMessage, HttpStatusCode.Forbidden, insufficientScope: true);
}

scope가 아예 없는 응답은 판단하지 않고 넘긴다. 대신 바로 다음 단계인 계정 조회에서 범위 부족 403이 나면 같은 방식으로 폐기하고 안내한다. 확인되지 않은 토큰을 DB에 남기지 않기 위해서다.

2. 조회할 때. 이미 저장된 연결로 보고서를 부르다가 범위 부족 403이 나도 원문 대신 같은 안내와 다시 연결 버튼을 보여 준다. API 클라이언트가 403 응답의 메시지를 보고 예외에 InsufficientScope 표시를 달고, 서비스는 InvalidGrant(만료)와 InsufficientScope(권한 부족)를 모두 "다시 연결이 필요한 상태"로 묶는다.

model.NeedsReconnect = ex.InvalidGrant || ex.InsufficientScope;
model.Error = ex.InvalidGrant ? ExpiredMessage : ex.InsufficientScope ? MissingScopeMessage : ex.Message;

안내 문구는 무엇을 다시 해야 하는지 바로 알 수 있게 썼다. "다시 연결하면서 구글 동의 화면의 '애드센스 데이터 보기' 항목을 체크하세요."

보고서 API 호출

수익은 accounts/{계정}/reports:generate로 받는다. 기간, 차원(dimension), 지표(metric)를 쿼리 문자열로 넘긴다.

GET https://adsense.googleapis.com/v2/accounts/pub-0000000000000000/reports:generate
    ?dateRange=CUSTOM
    &startDate.year=2026&startDate.month=9&startDate.day=1
    &endDate.year=2026&endDate.month=9&endDate.day=30
    &dimensions=DATE
    &metrics=ESTIMATED_EARNINGS&metrics=PAGE_VIEWS&metrics=IMPRESSIONS&metrics=CLICKS

날짜는 startDate.year 같은 식으로 연·월·일을 따로 넘긴다. 지표는 네 가지로 고정했다.

지표 의미
ESTIMATED_EARNINGS 예상 수익
PAGE_VIEWS 페이지 조회수
IMPRESSIONS 광고 노출수
CLICKS 광고 클릭수

응답은 headers와 rows로 온다. 각 행의 cells는 headers 순서대로 값이 들어 있고, 차원 값이 앞쪽에 온다. 순서를 가정하지 않고 header 이름으로 열 위치를 찾아 읽게 했다. 수익의 통화 코드도 ESTIMATED_EARNINGS header에 함께 온다.

요청 횟수를 줄이는 방법

화면에는 요약 카드(오늘·어제·최근 7일·30일)와 차트(21개 막대), 선택 기간의 표가 나온다. 이걸 각각 보고서로 부르면 요청이 많아진다. 그래서 DATE 차원의 일별 보고서 한 번으로 요약과 차트를 모두 채운다.

// 일별 보고서 한 번으로 요약(최근 60일)과 차트 창(+ 증감용 앞 기간 1개)을 모두 채운다
var oldest = today.AddYears(-MaxYearsBack).AddDays(1);
var queryStart = StatsService.AddPeriods(unit, window.Start, -1);
var fetchStart = Max(Min(queryStart, today.AddDays(-59)), oldest);

조회 시작일은 "차트 창보다 한 기간 앞"과 "60일 전" 중 이른 날로 잡는다. 최근 30일과 그 앞 30일을 비교해야 하니 최소 60일이 필요하고, 차트의 첫 막대도 앞 기간 대비 증감을 보여 주려면 한 기간이 더 필요하다. 받아 온 일별 값을 날짜 구간으로 더해서 카드와 막대를 만든다. 애드센스 보고서는 약 3년 전까지만 조회되므로 그보다 앞은 자른다.

날짜는 서버 시간이 아니라 애드센스 계정의 시간대 기준이다. 연결할 때 계정 정보에서 받은 IANA 시간대(Asia/Seoul 등)를 저장해 두고, 그 시간대의 "오늘"을 계산해 애드센스 사이트에 보이는 날짜 구분과 맞췄다.

차트의 창 계산(일간·주간·월간, 이전·다음 이동)은 방문 통계와 똑같은 모양이라, 방문 통계 쪽 코드를 StatsService.GetWindow로 분리해 두 화면이 같이 쓰게 했다.

광고 단위별 표

선택한 기간(막대 하나)은 AD_UNIT_ID, AD_UNIT_NAME 차원으로 따로 조회해 광고 단위별 노출수·클릭수·노출 RPM·수익을 표로 보여 준다. 노출 RPM은 API에서 받지 않고 받은 값으로 계산한다.

public decimal ImpressionRpm => Impressions == 0 ? 0 : Earnings * 1000 / Impressions;

AD_UNIT_ID는 ca-pub-…:슬롯ID 형식이라 콜론 뒤를 잘라 slot을 얻는다. 이 값을 광고 설정 화면에서 위치별로 넣어 둔 slot과 맞춰 보고, 같으면 "본문 상단", "사이드바" 같은 위치 이름을 붙인다. 어느 위치의 광고가 수익을 내는지 바로 보인다. 위치 이름은 설정을 바꾸면 바로 반영되도록 캐시된 결과의 복사본에 붙인다.

잔고

accounts/{계정}/payments 응답 중 이름이 /unpaid로 끝나는 항목이 미지급 잔액이다. 금액은 통화 기호가 붙은 문자열로 오므로 그대로 표시한다.

도메인별 수익

애드센스 계정 하나에 사이트가 여러 개 붙어 있으면 보고서 수익은 계정 전체 합계다. 이 블로그 수익만 보려면 나눠야 했다.

보고서 API는 DOMAIN_NAME 차원과 필터를 지원한다. 화면 위에 사이트 선택을 두고, 선택한 도메인으로 요약·차트·광고 단위 표를 모두 걸렀다.

var filter = model.Site == null ? null : "DOMAIN_NAME==" + AdSenseApiClient.FilterValue(model.Site);

필터 문법에서 쉼표와 세미콜론은 조건을 잇는 기호라, 값에 들어가면 앞에 역슬래시를 붙여 이스케이프한다. 전체 필터 문자열은 다시 URL 인코딩해서 넘긴다.

public static string FilterValue(string value)
{
    return value.Replace("\\", "\\\\").Replace(",", "\\,").Replace(";", "\\;");
}

사이트 목록은 고정하지 않았다. 조회 기간의 DOMAIN_NAME 보고서에 나온 도메인으로 만든다. 예전에 광고를 달았던 다른 블로그 서비스를 정리하는 식으로 사이트 구성이 바뀌어도 코드를 고칠 필요가 없다.

  • 기본 선택은 이 블로그다. 설정의 사이트 주소(Site:BaseUrl)에서 호스트를 꺼내 쓴다.
  • "전체"를 고르면 필터 없이 계정 전체를 본다.
  • 주소의 ?site= 값이 목록에 없으면 이 블로그로 처리한다. 기간·지표 링크와 새로고침에도 선택이 유지된다.
  • 선택 기간의 사이트별 표(계정 전체)를 따로 두고, 이름을 누르면 그 사이트로 전환한다.
  • 잔고는 도메인으로 나눌 수 없는 값이라 "계정 전체"라고 표시한다.

DOMAIN_NAME은 하위 도메인이 따로 나온다. blog.example.com과 www.example.com은 다른 행이다.

같은 코드를 쓰는 다른 블로그에서는 다른 도메인 수익을 보이지 않고 그 사이트 수익만 표시하는 설정(ThisSiteOnly)을 나중에 더했다. 이 이야기는 3편에서 다룬다.

캐시

보고서는 화면을 열 때마다 새로 받을 필요가 없다. 그래서 API 결과는 30분 동안 메모리(IMemoryCache)에 둔다. 캐시 키에는 계정, 사이트, 조회 기간이 들어간다.

바로 다시 보고 싶을 때를 위해 화면에 새로고침 버튼을 두었다. 캐시 항목을 하나씩 찾아 지우는 대신, 키 앞에 붙는 세대 번호를 올리는 방식으로 비운다.

private string CacheKey(string key) => "adsense-revenue:" + Volatile.Read(ref _cacheVersion) + ":" + key;

public void ClearCache()
{
    Interlocked.Increment(ref _cacheVersion);
}

세대가 바뀌면 이전 키는 더 이상 조회되지 않고 30분 뒤 만료로 사라진다. 연결·해제할 때도 같은 방법으로 캐시를 비운다. 화면에는 언제 받아 온 값인지 조회 시각을 함께 보여 준다.

화면 구성

위에서부터 이렇게 배치했다.

  1. 연결 카드 — 연동 계정(가린 이메일), 게시자 ID, 연동 해제 버튼. 연결 전이면 연결 버튼, OAuth 클라이언트가 없으면 설정 안내.
  2. 사이트 선택 — 이 블로그 / 보고서에 나온 다른 도메인 / 전체.
  3. 요약 카드 — 오늘(집계 중), 어제(그저께 대비), 최근 7일(앞 7일 대비), 최근 30일(앞 30일 대비) 수익과 증감, 잔고.
  4. 차트 — 수익 또는 노출수 × 일간·주간·월간, 21개 막대, 이전·다음 이동. 막대를 누르면 그 기간이 선택된다.
  5. 선택 기간 표 — 광고 단위별, 사이트별 노출수·클릭수·노출 RPM·수익.

차트는 방문 통계 화면과 같은 형태로 맞췄다. 두 화면을 오가도 읽는 방법이 같다.

개발 중에는 실제 구글 API를 부르지 않고, 구글 응답을 흉내 내는 임시 핸들러를 끼워서 확인했다. state 검증, 연결 저장, 요약·증감 값, 일·주·월 차트, 3년보다 이전 기간, 광고 단위 위치 표시, 사이트별로 나뉜 금액이 합계와 맞는지, 잘못된 site 값 처리, 모바일 화면까지 이 방식으로 확인했다. 이 핸들러는 커밋에 넣지 않았다.

정리하며

  • 필요한 만큼만 권한을 받는다. 수익을 보는 데는 adsense.readonly면 충분하다.
  • 토큰은 수명에 맞게 둔다. 오래 가는 refresh token은 암호화해 DB에, 금방 만료되는 access token은 메모리에 둔다.
  • 사용자가 동의 화면에서 무엇을 뺄 수 있는지 생각한다. 연결은 성공했는데 권한이 없는 상태가 생긴다. 토큰 응답의 scope를 확인하고, 403의 원인을 사람이 읽을 수 있는 안내로 바꿨다.
  • 요청은 묶는다. 일별 보고서 한 번으로 요약과 차트를 채우고, 결과는 30분 캐시에 둔다.

이제 방문 통계와 수익을 같은 관리자 화면에서 본다. 조회수와 수익을 나란히 놓고 볼 수 있게 된 것이 이번 작업에서 가장 쓸모 있는 부분이다.