회고 · 대시보드 · App Store Connect · JWT

App Store Connect API로 경찰과 도둑 앱 다운로드와 노출 수 받기

박찬빈

어드민 지표 대시보드를 만든 과정을 일곱 편으로 나눠 적었다. 이 글은 4편이다.

  1. 경찰과 도둑 앱 지표를 골라 매일 모으기로 한 이유
  2. 인스타그램 API로 경찰과 도둑 앱 하루치 지표를 구해 디스코드로 보내기
  3. 서비스 계정으로 경찰과 도둑 앱의 Google Play 통계 CSV 받기
  4. App Store Connect API로 경찰과 도둑 앱 다운로드와 노출 수 받기
  5. GA4 Data API로 경찰과 도둑 앱과 웹사이트 지표 모으기
  6. AdMob API 갱신 토큰으로 경찰과 도둑 앱 광고 지표 매일 받기
  7. Supabase로 경찰과 도둑 앱 지표 대시보드 만들기

App Store 숫자를 모으려고 보니 받아야 할 리포트가 두 가지였다. 다운로드는 판매 리포트(Sales and Trends)에 있고, 노출과 제품 페이지 조회는 분석 리포트(Analytics Reports)에 있다. 둘 다 같은 API 키로 받지만, 받는 방식은 꽤 다르다.

따라 하기 0. API 접근 확인

App Store Connect API를 처음 쓰는 계정은 프로그램으로 숫자를 받기 전에 접근 요청부터 해야 한다.

  1. App Store Connect → 위쪽 "Users and Access"
  2. "Integrations" 탭 → 왼쪽 "App Store Connect API"
  3. "Request Access"를 누르고 약관에 동의해 제출한다

이 요청은 개발자 계정을 처음 만든 계정인 Account Holder만 할 수 있다. Admin 권한으로도 할 수 없다. 그래서 아직 승인받지 않았는데 버튼이 안 보인다면, 아마 Account Holder가 아닌 계정으로 들어왔을 것이다.

"Team Keys" 탭이 보이고 목록에 키가 있으면 예전에 승인받은 것이다. 우리는 이미 팀 키가 하나 있어서 이 단계가 필요 없었다.

키를 새로 만든 이유

목록에는 이미 Admin 권한 팀 키(App-Store-Connect-API-Key)가 하나 있었다. 처음에는 '이거 그냥 쓰면 되는 거 아냐?' 싶었다.

그런데 그 키는 다른 곳에서 쓰는 중이었다. 매일 숫자를 모으는 수집기(7편)에 문제가 생겨 키를 지워야 한다면, 그 키를 쓰는 다른 곳까지 같이 멈춘다. 그래서 쓰임마다 키를 새로 만들었다.

키 이름권한언제 쓰나
metrics-collectorSales and Reports매일 수집
analytics-setupAdmin분석 리포트를 처음 신청할 때 딱 한 번. 쓰고 바로 지운다

분석 리포트 신청은 Admin만 할 수 있지만, 그 뒤로 매일 받아 가는 건 Sales and Reports면 충분하다. Admin 키를 매일 실행하는 프로그램에 넣어 두면 필요 이상의 권한을 계속 주게 된다. 그래서 신청용과 수집용을 나눴다.

따라 하기 1. 팀 키 만들기

  1. Users and Access → Integrations → App Store Connect API → Team Keys

  2. "Active" 옆 파란 + 버튼

    팀 키 목록. 위쪽에 Issuer ID, 아래에 키마다 Key ID와 권한이 보인다
    팀 키 목록. 위쪽에 Issuer ID, 아래에 키마다 Key ID와 권한이 보인다
  3. Name에 키 이름을 넣는다(30자까지)

  4. Access를 누르면 역할 목록이 뜬다. 수집용은 Sales and Reports

    Access를 누르면 역할 목록이 뜬다
    Access를 누르면 역할 목록이 뜬다
    수집용 키는 Sales and Reports 하나
    수집용 키는 Sales and Reports 하나
  5. "Generate"

목록에는 이 권한이 "Access to Reports, Sales"로 보인다. 이름은 다르게 나오지만 같은 권한이다.

키가 생기면 그 줄에 Download API Key가 나온다. 누르면 AuthKey_XXXXXXXXXX.p8 파일이 내려오는데, 한 번만 받을 수 있으니 받자마자 비공개 폴더로 옮긴다.

화면 설명에 적힌 대로 키는 만료가 없고, 만든 뒤에는 권한을 바꿀 수 없다. 잘못 만들었으면 지우고(Revoke) 다시 만든다. "Individual Keys" 탭에서 만든 개인 키로는 판매와 보고서를 받을 수 없으니, Team Keys에서 만들어야 한다.

키와 함께 적어 둘 값

값어디서
Issuer IDTeam Keys 화면 위쪽. 계정마다 하나
Key ID키 목록의 KEY ID 칸
.p8 파일방금 받은 것
앱 Apple IDApps → 우리 앱 → App Information의 Apple ID(숫자)
Vendor 번호위쪽 Reports(Payments and Financial Reports) 왼쪽 위
Reports 화면 왼쪽 위의 Vendor 번호. 판매 리포트를 받을 때 쓴다
Reports 화면 왼쪽 위의 Vendor 번호. 판매 리포트를 받을 때 쓴다

Vendor 번호는 Account Holder, Admin, Finance 권한이 있어야 보인다.

ES256 JWT로 부르는 원리

키를 받았으니 이제 API를 부를 때 우리가 보낸 요청이라는 걸 증명해야 했다. App Store Connect API는 OAuth 서버가 따로 없어서, 우리가 직접 JWT를 만들어 매 요청에 넣는다. 3편의 구글 서비스 계정은 JWT를 구글에 보내 액세스 토큰으로 바꿨는데, 애플은 JWT 자체를 토큰으로 쓴다.

헤더    { "alg": "ES256", "kid": "<Key ID>", "typ": "JWT" }
페이로드 { "iss": "<Issuer ID>", "iat": 지금(초), "exp": 지금+1200, "aud": "appstoreconnect-v1" }
서명    .p8 개인 키(타원곡선 P-256)로 ES256 서명

토큰이 언제까지 유효한지 적는 exp는 만든 시각부터 20분 안이어야 한다. 그래서 실행할 때마다 새로 만든다. aud는 늘 appstoreconnect-v1이다.

그런데 Node 기본 crypto로 직접 만들 때는 서명 형식을 맞춰야 했다. Node의 ECDSA 서명은 기본이 DER 형식인데, JWT의 ES256은 r과 s를 이어 붙인 64바이트 형식을 쓴다. 그래서 dsaEncoding: "ieee-p1363"을 줘야 맞는 서명이 나온다.

TypeScript
const sig = sign("sha256", Buffer.from(unsigned), { key, dsaEncoding: "ieee-p1363" }).toString("base64url");

키를 코드에서 읽을 때는 줄바꿈도 맞춰야 했다. .p8 내용을 Vercel 환경변수(서버 코드가 읽을 값을 넣어 두는 설정)에 넣으면 줄바꿈이 \n 글자로 바뀌어 들어가는 경우가 있다. 그대로 키로 읽으면 실패해서, 코드에서 \n을 진짜 줄바꿈으로 바꿔 읽도록 했다.

판매 리포트로 다운로드 받기

따라 하기 2. 화면에서 먼저 보기

API로 받기 전에 화면에서 같은 파일을 먼저 받아 볼 수 있다. 경로는 Trends → 왼쪽 "Sales and Trends Reports" → Summary Sales Report다.

Summary Sales Report. API의 판매 리포트와 같은 파일이다
Summary Sales Report. API의 판매 리포트와 같은 파일이다

날짜를 고르고 Download를 누르면 탭으로 칸을 나눈 표 파일이 gzip으로 압축되어 내려온다. API로 받는 것도 같은 파일이다.

같은 파일을 API로 받기

GET https://api.appstoreconnect.apple.com/v1/salesReports
    ?filter[frequency]=DAILY
    &filter[reportType]=SALES
    &filter[reportSubType]=SUMMARY
    &filter[vendorNumber]=<Vendor 번호>
    &filter[reportDate]=2026-10-03
Authorization: Bearer <JWT>
Accept: application/a-gzip

응답으로 받은 gzip 파일을 풀면 상품, 나라, 기기, 상품 유형별 수량이 한 줄씩 들어 있다. 계정에 앱이 여럿이면 다른 앱 줄도 섞여 오니 Apple Identifier로 우리 앱만 거른다. 그중 다운로드를 세려면 Product Type Identifier를 봐야 한다.

상품 유형뜻
1, 1F, 1T첫 다운로드
3, 3F다시 다운로드
7, 7F, 7T업데이트

10월 5일 키를 만들고 처음 받아 봤다. 10월 3일 첫 다운로드 13건, 10월 2일 14건이 나왔다.

판매 없음과 아직 안 나옴이 같은 404

파일은 잘 받았는데, 판매가 없는 날이 까다로웠다. 판매 리포트는 그날 판매(다운로드)가 없으면 404(찾는 것이 없다는 응답)를 준다.

404 There were no sales for the date specified

처음에는 404를 전부 0건으로 저장했다. 그런데 리포트가 아직 안 나온 날도 404였다. 그대로 두면 아직 안 나온 어제 숫자를 0건으로 저장하고 다음 날에도 다시 받지 않게 된다. 그래서 오류 문구로 둘을 구분했다.

TypeScript
if (res.status === 404) {
  const detail = body.errors?.[0]?.detail ?? "";
  if (/no sales/i.test(detail)) return [];            // 진짜 0건
  throw new AscError(`판매 리포트가 아직 없어요 (${day})`, 404); // 실패로 남겨 다음 날 다시
}

둘을 구분하고 나서도, 판매가 없는 날은 저장할 줄이 하나도 없었다. 그래서 숫자를 쌓아 두는 DB만 봐서는 받았는데 0건인 날과 아직 안 받은 날이 똑같이 빈칸이었다. 결국 판매가 없는 날도 수량 0인 표시 줄을 하나 남겼다.

짐작했다 틀린 날짜 기준

처음에는 애플 판매 리포트도 미국 서부 날짜일 줄 알았다. 그래서 Play처럼(3편) 하루 뒤로 옮겨 한국 날짜에 맞췄다.

그런데 차트를 보니, 9월 19일 어린이대공원에서 행사를 했는데 App Store 다운로드는 다음 날인 9월 20일에 몰려 있었다. 그제야 '아, 이거 확인도 안 하고 넣은 거였네.' 싶었다.

확인하려고 리포트 날짜별 다운로드를 GA4 iOS 첫 실행과 날짜별로 맞춰 봤다. 첫 실행은 앱을 처음 연 기록이고, 한국 시간으로 남는다.

리포트 날짜를GA4 iOS 첫 실행과 상관
그대로 두면0.92
하루 뒤로 옮기면0.14

두 숫자가 날짜별로 함께 오르내리는 정도를 보니, 날짜를 그대로 뒀을 때 상관이 더 높았다. 행사 날도 그대로 맞았다. 7월 4일 서울게임타운은 리포트 다운로드 15건, GA4 첫 실행 15명으로 하나도 어긋나지 않았고, 9월 19일은 43건과 42명이었다.

리포트가 미국 서부 날짜였다면, 한국 낮에 한 행사는 미국으로는 전날 밤이니 전날 리포트에 잡혔어야 했다. 그래서 App Store만 날짜를 옮기지 않게 바꿨다. Play(3편)와 인스타(2편)는 따로 맞춰 보고 미국 서부 날짜라는 걸 확인했기 때문에 그대로 뒀다.

다만 날짜를 그대로 쓰면 월요일 아침 디스코드로 보내는 주간 리포트(7편)에 일요일 App Store 숫자가 아직 없을 수 있다. 그래서 보내기 직전에 한 번 더 받고, 그래도 없으면 두 주 모두 월~토로 비교했다고 적는다.

그 뒤로는 시간대를 정할 때, 날짜를 이미 알고 있는 행사와 먼저 맞춰 봤다.

따로 신청하는 노출과 제품 페이지 조회

판매 리포트로는 내려받은 수량(첫 다운로드, 다시 다운로드, 업데이트)만 받는다. 우리 앱이 스토어에서 몇 번, 몇 대의 기기에 보였나(노출), 상세 페이지가 몇 번 열렸나(제품 페이지 조회)는 분석 리포트에 있다.

노출은 횟수와 고유 기기 수가 따로 온다. 고유 기기 수는 같은 기기가 여러 번 봐도 한 번으로 세니 사람 수에 가깝지만, 같지는 않다.

App Store Connect 애널리틱스 개요. 이 노출과 제품 페이지 조회를 API로 받는다
App Store Connect 애널리틱스 개요. 이 노출과 제품 페이지 조회를 API로 받는다

그런데 분석 리포트는 판매 리포트처럼 바로 파일을 받는 방식이 아니었다. 이 앱의 리포트를 계속 만들어 달라고 먼저 신청해야 하고, 애플이 그 뒤로 파일을 쌓아 준다.

따라 하기 3. 리포트 신청 (Admin 키로 딱 한 번)

신청은 화면에 버튼이 없어서 API로만 한다. 여기서 아까 만든 analytics-setup(Admin) 키를 쓴다.

POST https://api.appstoreconnect.apple.com/v1/analyticsReportRequests
Authorization: Bearer <Admin 키로 만든 JWT>
Content-Type: application/json

{
  "data": {
    "type": "analyticsReportRequests",
    "attributes": { "accessType": "ONGOING" },
    "relationships": { "app": { "data": { "type": "apps", "id": "<앱 Apple ID>" } } }
  }
}

어떤 방식으로 리포트를 받을지 정하는 accessType은 두 가지다.

값뜻우리
ONGOING앞으로 매일 쌓아 달라10월 5일 신청
ONE_TIME_SNAPSHOT지금까지 기록을 한 번에 달라10월 6일 신청, 10월 8일 도착. 4월 6일부터 채워졌다

첫 파일은 신청하고 1~2일 뒤에 나온다. 그래서 1편에서 권한은 개발보다 먼저 신청해 두라고 했다. 두 신청을 다 받고 나서, 처음 신청할 때만 쓰고 지우기로 했던 Admin 키를 지웠다.

10월 9일에 지운 analytics-setup(Admin) 키. 지운 키는 30일 동안 목록에 남는다
10월 9일에 지운 analytics-setup(Admin) 키. 지운 키는 30일 동안 목록에 남는다

신청한 리포트에서 실제 파일까지 찾아가기

신청이 끝났다고 바로 파일 주소가 나오는 건 아니었다. 매일 Sales and Reports 키로 신청 목록부터 조회하고, 리포트 종류와 처리일별 파일 묶음을 거쳐 실제 파일 주소를 찾는다.

GET /v1/apps/{앱ID}/analyticsReportRequests            우리 앱의 신청들(ONGOING, ONE_TIME_SNAPSHOT)
GET /v1/analyticsReportRequests/{신청ID}/reports       신청마다 리포트 종류 수십 개
GET /v1/analyticsReports/{리포트ID}/instances
    ?filter[granularity]=DAILY                          리포트마다 처리일별 파일 묶음
GET /v1/analyticsReportInstances/{인스턴스ID}/segments  묶음 안의 실제 파일 주소
GET <세그먼트 url>                                      gzip 탭 구분 파일

마지막 세그먼트 주소에는 정해진 시간까지 누구든 파일을 받을 수 있게 하는 서명이 들어 있다. 이 서명된 임시 주소로 파일을 받을 때는 JWT를 넣는 Authorization 헤더를 보내지 않는다.

리포트 종류는 수십 개였는데, 우리는 네 개만 받기로 했다. 노출과 제품 페이지 조회 외에 다운로드 경로와 삭제 비율도 여기서 받았다.

리포트쓰는 곳
Discovery and Engagement Standard노출, 제품 페이지 조회, 나라별
App Downloads Standard어떻게 받았나(스토어 검색, 웹 링크, 다른 앱 링크)
App Downloads Detailed모으기만. 칸이 잘게 나뉘어 5명 미만 칸이 빠지다 보니 남는 줄이 거의 없다
Installation and Deletion Standard받고 7일 안에 지운 비율(iOS)

파일을 받은 뒤 맞출 것

파일은 35일 뒤 지워진다. 1편에서 매일 쌓기로 한 이유 중 하나였다. 오래 안 받아 가면 신청 자체가 멈추기도 한다. 그래서 새 리포트 종류를 받기로 했으면 35일 안에 수집에 넣어야 예전 기록이 남는다.

API 값은 블로그 글을 그대로 믿으면 안 됐다. 여러 글이 카테고리 값을 APP_STORE_COMMERCE라고 쓰는데, 문서의 스키마에 적힌 실제 값은 COMMERCE였다. 그래서 이런 값은 문서의 스키마에서 확인해야 했다.

애플은 개인정보 때문에 사용자가 5명 미만인 칸을 빼고, 작은 숫자에 ±2~6 정도 잡음을 섞는다. 우리처럼 작은 앱은 그만큼 숫자가 흔들려 보일 수 있어서, 팀에 미리 말해 둬야 했다.

같은 날짜가 여러 파일에 나오기도 한다. 처리일별 파일 묶음(인스턴스)은 처리일마다 하나씩 생기고, 같은 날짜 숫자가 나중 처리일 파일에서 수정되어 다시 나온다. 그래서 가장 최근 처리일의 숫자만 남겼다. 한 번 처리한 인스턴스는 기록해 두고 다시 받지 않도록 했다.

다만 한국 날짜와 맞는 판매 리포트와 달리 분석 리포트는 UTC 날짜를 쓴다. 그래서 하루가 바뀌는 기준이 한국 오전 9시다.

"노출(고유 기기)"은 하루마다 센 기기 수라, 4주를 더하면 같은 사람을 여러 번 세게 된다. 그러면 App Store Connect 화면의 4주 값보다 조금 크게 나오기 때문에, 이 숫자는 비율의 분모로만 쓰고 그렇게 적었다.

나누는 숫자가 다른 전환율

숫자를 받고 나서는 전환율을 무엇으로 나누는지도 확인했다. App Store Connect 화면의 전환율 옆 i를 누르면 정의가 나온다.

전환율은 다운로드와 사전 예약을 고유 기기 노출로 나눈다
전환율은 다운로드와 사전 예약을 고유 기기 노출로 나눈다

확인해 보니 분모는 고유 기기 노출이었다. 그런데 인터넷에 도는 App Store 평균 전환율은 대부분 페이지 조회를 분모로 쓴다. 같은 이름이어도 나누는 숫자가 달라 나란히 놓고 비교하기는 어려웠다. 그래서 어드민에서는 두 단계를 다 보여 주고 어느 것으로 나눴는지 적었다.

어드민 스토어 탭. 노출된 기기, 제품 페이지를 연 기기, 최초 다운로드를 나란히 두고 무엇으로 나눴는지 적었다
어드민 스토어 탭. 노출된 기기, 제품 페이지를 연 기기, 최초 다운로드를 나란히 두고 무엇으로 나눴는지 적었다

차트에 찍을 iOS 출시일

차트에 이날 iOS 업데이트가 나갔다는 표시를 넣고 싶었다. 그런데 App Store Connect API의 버전 목록에는 출시한 날이 없고 만든 날만 있었다.

처음에는 판매 리포트의 버전 칸을 보고, 새 버전을 처음 내려받은 날을 출시일로 썼다(3.1.20은 10월 1일 업데이트 38건). 그런데 콘솔의 "배포 준비됨" 날짜와 맞춰 보니 30개 중 6개가 하루 어긋났다.

결국 10월 8일에 방식을 바꿨다. 매일 지금 배포 중인 버전을 보고, 처음 보는 버전이면 그 전날을 출시일로 넣는다. 그 전 출시는 콘솔 출시 기록을 보고 옮겨 넣었고, 이 출시일도 1편의 일정 달력에 자동으로 들어간다.


이제 App Store의 다운로드와 노출 수는 매일 받아 쌓인다. 그런데 웹 숫자를 받으려고 코드에서 gtag를 찾았을 때는 아무것도 나오지 않아, 웹에는 GA가 없다고 잘못 판단했다. 사실 웹 숫자는 gtag 없이도 GA4에 쌓이고 있었다. 다음 편에서는 그 이유부터 적었다.

다음 편: 5편 GA4 Data API로 경찰과 도둑 앱과 웹사이트 지표 모으기

keep reading

comments

자유롭게 생각을 남겨주세요.