App Store Connect API로 수익 데이터 가져오기 — JWT ES256부터 Vercel Cron까지
App Store Connect API로 iOS 앱 수익 데이터를 자동 수집하는 실전 튜토리얼. JWT ES256 인증, Sales Reports API 호출, gzip 해제, Vercel Cron 배포, after() 패턴까지 Next.js 기준으로 정리했다.
On this page (11)
2026년 4월 · 개발 튜토리얼
iOS 앱 수익은 App Store Connect 웹에 매일 들어가서 확인한다. 앱이 2~3개면 괜찮은데 10개가 넘으면 비현실적이다. 화면을 매일 10번 갈아타면서 숫자를 옮겨 적는 건 아무도 안 한다. 그래서 App Store Connect API로 자동화한다.
이 글은 Next.js에서 App Store Connect API로 수익 데이터를 가져오는 실전 튜토리얼이다. JWT ES256 인증, Sales Reports API 호출, gzip 응답 해제, 데이터베이스 적재, Vercel Cron으로 매일 자동 실행, 10초 타임아웃 해결까지 순서대로 다룬다. 막히는 지점마다 해결책과 실제 에러 로그를 같이 넣었다.
결론부터 말하면 구현 자체는 반나절이면 끝난다. 막히는 건 대부분 세 곳이다. Key 파일 포맷, JWT 서명 방식, Cron 타임아웃. 이 세 개 짚고 가면 나머지는 HTTP 호출에 불과하다. 이 세 개가 귀찮으면 Apsity 같은 도구가 같은 일을 대신 해준다는 점도 마지막에 짚는다.
- App Store Connect API 키 발급 (.p8 파일, Key ID, Issuer ID)
- JWT ES256으로 20분 만료 토큰 생성 (RS256·HS256 거절됨)
- Sales Reports v1/salesReports 엔드포인트 호출 — gzip 응답
- 일간 리포트는 PT 오전 9시경 생성, 24시간 이상 지연 감안
- Vercel Cron Hobby 기본 10초·maxDuration으로 60초까지 → 부족하면 after() 백그라운드
- Supabase에 적재하고 대시보드에서 조회
- 기성 도구로 해결 가능 — Apsity·Appfigures·Sensor Tower
1. 왜 API로 가져오는가
App Store Connect는 웹에서 매출·설치·유지율을 다 보여준다. 그럼 왜 API를 쓸까. 세 가지 이유다. 첫째, 자동화다. 매일 같은 화면 10번 여는 건 시간이 아깝다. 둘째, 통합이다. 여러 앱의 데이터를 한 화면에 모으려면 API가 필수다. 셋째, 가공이다. 커스텀 지표(예: 앱별 ARPU, 지역별 전환율)는 웹 UI로 계산할 수 없다.
앱이 1~2개면 웹 화면으로 충분하다. 3개부터 피곤해지고 10개 넘으면 관리가 불가능하다. 인디 개발자가 앱을 늘릴 때 자동화하지 않으면 운영 시간이 개발 시간을 넘는다. API 자동화는 "앱을 더 만들 시간"을 확보하는 투자다.
App Store Connect API는 2018년부터 Apple이 공식 지원한다. Sales Reports, Finance Reports, Analytics Reports, TestFlight까지 10개 이상 리소스를 제공한다. 이 글은 가장 많이 쓰는 Sales Reports에 집중한다.
2. API 키 발급 — .p8 · Key ID · Issuer ID
API 호출 전에 세 가지 정보가 필요하다.
- .p8 파일 — ECDSA 프라이빗 키 (한 번만 다운로드 가능)
- Key ID — 10자리 영문·숫자 조합
- Issuer ID — UUID 형식, 계정 고유 ID
발급 경로는 App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys다. "Generate API Key" 버튼으로 새 키를 만든다. 권한은 최소한으로 — Sales Reports만 필요하면 "Finance" 역할만 준다.
Apple은 보안상 .p8 파일을 재다운로드 시키지 않는다. 분실하면 키를 폐기하고 다시 만들어야 한다. 1Password 같은 시크릿 매니저에 파일째 올려두는 게 안전하다. Vercel 환경 변수에는 파일 내용을 그대로 올리지 말고 Base64 인코딩해서 올린다.
.env.local에 세 값을 넣는다.
ASC_KEY_ID=ABC1234567
ASC_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a
ASC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# 줄바꿈은 \n으로 이스케이프
프라이빗 키를 한 줄로 환경 변수에 넣는 게 핵심이다. 실제 줄바꿈을 \n으로 치환해서 넣고, 코드에서 읽을 때 역치환한다.
3. JWT ES256 토큰 생성
App Store Connect API는 모든 요청에 JWT Bearer 토큰을 요구한다. Apple이 ES256 알고리즘(ECDSA using P-256 and SHA-256)만 허용한다. RS256이나 HS256은 거절된다. Node.js에서는 jose 라이브러리를 쓰는 게 가장 깔끔하다.
import { SignJWT, importPKCS8 } from 'jose';
export async function generateToken() {
const keyText = process.env.ASC_PRIVATE_KEY!.replace(/\\n/g, '\n');
const privateKey = await importPKCS8(keyText, 'ES256');
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: process.env.ASC_KEY_ID!, typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('20m')
.setIssuer(process.env.ASC_ISSUER_ID!)
.setAudience('appstoreconnect-v1')
.sign(privateKey);
return jwt;
}
중요한 포인트는 세 가지다. 만료 시간은 최대 20분까지만 허용된다(Apple 규정). aud는 반드시 appstoreconnect-v1 문자열. kid(header)와 iss(payload)는 각각 Key ID와 Issuer ID다. 혼동하기 쉽다.
토큰을 매 요청마다 생성할 필요는 없다. 20분 동안 메모리에 캐시하고 재사용하는 게 효율적이다. Next.js의 unstable_cache 또는 Redis에 저장한다.
4. Sales Reports API 호출
JWT가 준비되면 Sales Reports 엔드포인트를 호출한다. 일간 리포트는 하루치 데이터를 담은 gzip TSV 파일이다.
export async function fetchDailySales(date: string) {
const token = await generateToken();
const url = 'https://api.appstoreconnect.apple.com/v1/salesReports';
const params = new URLSearchParams({
'filter[frequency]': 'DAILY',
'filter[reportSubType]': 'SUMMARY',
'filter[reportType]': 'SALES',
'filter[vendorNumber]': process.env.ASC_VENDOR_NUMBER!,
'filter[reportDate]': date,
});
const res = await fetch(`${url}?${params}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error(`ASC API ${res.status}`);
return await res.arrayBuffer(); // gzip 버퍼
}
반환은 gzip 압축된 TSV다. 응답을 그대로 파일에 쓰면 .tsv.gz가 되고 gunzip으로 풀어 확인할 수 있다. vendorNumber는 App Store Connect → Payments and Financial Reports에서 확인한다. 계정당 하나뿐이다.
일간 리포트는 PT(태평양 시간) 오전 9시경 생성된다. 어제 데이터를 오늘 가져오려면 최소 24시간 여유를 둔다. 아직 생성되지 않은 날짜를 요청하면 404가 나온다.
5. gzip 응답 해제와 TSV 파싱
응답은 gzip TSV다. Node.js 내장 zlib으로 풀고 탭 구분자로 파싱한다.
import { gunzipSync } from 'node:zlib';
export function parseSalesTsv(buffer: ArrayBuffer) {
const tsv = gunzipSync(Buffer.from(buffer)).toString('utf-8');
const [header, ...rows] = tsv.trim().split('\n').map(r => r.split('\t'));
return rows.map(row => Object.fromEntries(header.map((h, i) => [h, row[i]])));
}
TSV 헤더는 30개 이상 컬럼이다. Apple 이름이라 직관적이진 않다. 주요 컬럼만 뽑으면 이렇다.
| 컬럼명 | 의미 |
|---|---|
| SKU | 앱 SKU (개발자 지정) |
| Apple Identifier | 앱 ID (숫자) |
| Units | 다운로드 또는 구매 수 |
| Customer Price | 사용자 지불 가격 (현지 통화) |
| Developer Proceeds | 개발자 몫 (수수료 제외) |
| Country Code | ISO 2자리 국가 코드 |
| Product Type Identifier | 상품 유형 (1F=앱, 1I=인앱) |
Developer Proceeds가 실제 개발자에게 들어오는 수익이다. Apple 수수료(15~30%)를 제외한 금액이다. Customer Price와 혼동하면 수익이 실제보다 과대 표시된다.
6. Supabase에 적재하기
파싱한 데이터는 Supabase에 저장한다. 일별로 쌓이므로 정규화된 스키마가 낫다.
CREATE TABLE daily_sales (
id bigserial primary key,
report_date date not null,
apple_id text not null,
country_code text not null,
product_type text not null,
units integer not null default 0,
proceeds_usd numeric(10,2),
created_at timestamptz default now(),
unique(report_date, apple_id, country_code, product_type)
);
CREATE INDEX idx_daily_sales_app ON daily_sales(apple_id, report_date);
unique 제약으로 멱등성을 보장한다. 같은 리포트를 여러 번 가져와도 중복되지 않는다. 재시도 로직이 편해진다.
7. Vercel Cron으로 매일 자동 실행
Next.js App Router에서 Cron은 vercel.json에 등록한다. 매일 오전 3시(KST)에 어제 데이터를 수집하는 설정이다.
{
"crons": [{
"path": "/api/cron/sales",
"schedule": "0 18 * * *" // UTC 18시 = KST 03시
}]
}
// app/api/cron/sales/route.ts
export async function GET(req: Request) {
const auth = req.headers.get('authorization');
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('unauthorized', { status: 401 });
}
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10);
const buffer = await fetchDailySales(yesterday);
const rows = parseSalesTsv(buffer);
await supabase.from('daily_sales').upsert(rows);
return Response.json({ ok: true, rows: rows.length });
}
CRON_SECRET은 Vercel이 자동 생성한다. 외부에서 /api/cron/sales를 직접 호출하지 못하게 막는 역할이다.
8. 10초 타임아웃 해결 — after() 패턴
Vercel Hobby 플랜 Function/Cron은 기본 10초 타임아웃이다. 라우트 파일에 export const maxDuration = 60을 넣으면 60초까지 확장할 수 있다(Hobby 최대값). 60초로도 모자라면 after() 패턴을 쓰거나 Pro 플랜(기본 15초·최대 300초)으로 올린다.
after()는 응답을 먼저 보내고 무거운 작업을 백그라운드에서 마무리한다. Next.js 15.1부터 안정 버전으로 쓸 수 있다. 동일 라우트의 maxDuration 범위 안에서 실행된다 — 즉 Hobby에서 maxDuration 60으로 잡았다면 after() 안의 작업도 60초 안에 끝나야 한다.
export async function GET(req: Request) {
// 응답 먼저 종료 (10초 안에)
after(async () => {
const buffer = await fetchDailySales(yesterday);
const rows = parseSalesTsv(buffer);
await supabase.from('daily_sales').upsert(rows);
});
return Response.json({ ok: true, scheduled: true });
}
핵심은 "사용자에게 보이는 응답 시간"과 "백그라운드 실행 시간"을 분리한다는 점이다. 편의점 계산 후 영수증 출력 — 손님은 먼저 보내고 뒤에서 마무리하는 구조다. 사용자 체감 응답은 수 밀리초로 끝내고, 실제 데이터 적재는 남은 maxDuration 안에서 돌린다.
maxDuration 자체를 늘리고 싶다면 Pro 플랜이 답이다. Hobby는 최대 60초, Pro는 최대 300초(5분)까지 가능하다. 앱 수가 많고 국가별 분석까지 돌리는 규모라면 Pro로 올리는 게 안정적이다.
after() 안의 작업은 해당 라우트의 maxDuration을 넘길 수 없다. Hobby에서 최대 60초, Pro 최대 300초다. 앱이 많고 국가별·제품별 수십 로우를 처리한다면 60초가 빠듯할 수 있다. 이 경우 Inngest, Vercel Queues 같은 작업 큐로 분할 실행하거나 Pro 플랜으로 올려 300초를 확보한다.
9. 직접 만들까, 기성 도구를 쓸까
여기까지 따라 하면 대시보드가 돌기는 한다. 문제는 유지보수다. Apple API 스펙 변경, vendor number 추가, 국가별 환율 적용, 인앱 구독 롤오버 처리 — 요구사항이 계속 는다. 처음 반나절로 시작한 프로젝트가 나중엔 별도 마이크로서비스가 된다.
| 상황 | 추천 | 이유 |
|---|---|---|
| 학습 · 포트폴리오 | 직접 구축 | JWT·ASC API 학습 가치 |
| 커스텀 지표 많음 | 직접 구축 | SQL로 자유 가공 |
| 인디 · 시간 부족 | Apsity Free | 세팅 0분, 즉시 사용 |
| 팀 프로젝트 · 세무 리포트 | Appfigures | CSV·브랜드 필터링 |
| AI 자동 추천 필요 | Apsity Pro | Claude 기반 키워드 제안 |
직접 구축의 장점은 데이터 소유권과 커스텀 자유도다. 단점은 시간 비용이다. 반나절 개발 + 매월 유지보수 평균 1~2시간이 현실적이다. 앱이 5개 미만이면 직접 구축, 10개 넘고 시간이 모자라면 기성 도구 쪽이 ROI가 낫다. 세 도구 비교와 인디 도구 3종 비교 글을 같이 보면 선택이 명확해진다.
자주 묻는 질문
Q. App Store Connect API 키는 어디서 발급받나?
App Store Connect 웹 → Users and Access → Integrations → Team Keys에서 발급한다. .p8 파일, Key ID, Issuer ID 세 개가 필요하다. .p8은 한 번만 다운로드 가능하니 안전한 곳에 보관한다. 권한은 최소한으로 — Sales Reports만 필요하면 "Finance" 역할만 준다.
Q. JWT는 왜 ES256을 쓰나?
Apple이 ES256(ECDSA using P-256 and SHA-256)만 허용한다. RS256이나 HS256은 거절된다. Node.js에서는 jose 또는 jsonwebtoken 라이브러리로 처리한다. jose 쪽이 최신 Node.js API와 잘 맞는다.
Q. Sales Reports는 얼마나 자주 업데이트되나?
일간 리포트는 다음 날 PT 오전 9시경 생성된다. 주간/월간 리포트는 기간 종료 후 수 일이 걸린다. 실시간이 아니므로 24시간 이상 지연을 감안한 설계가 필요하다. 아직 생성되지 않은 날짜를 요청하면 404가 나온다.
Q. Vercel Cron 타임아웃 문제는 어떻게 푸나?
Hobby 플랜 Function은 기본 10초 타임아웃이지만 export const maxDuration = 60으로 60초까지 확장할 수 있다. Cron도 동일하게 적용된다. 60초로도 부족하면 after() 패턴으로 응답 먼저 보내고 maxDuration 범위 안에서 백그라운드 처리하거나, Pro 플랜으로 올려 최대 300초를 확보한다.
Q. 직접 만드는 대신 기성 도구를 쓰면 안 되나?
Apsity, Appfigures 같은 도구가 같은 작업을 대신 해준다. 인디라면 기성 도구가 합리적이다. 세팅 0분에 즉시 사용이 가능하다. 직접 구축은 커스텀 지표가 많거나 데이터 소유권이 중요하거나 학습 목적일 때 의미 있다.
마무리
App Store Connect API 자동화의 핵심은 세 가지다. JWT ES256 토큰 발급, Sales Reports gzip 파싱, Vercel 타임아웃 관리(maxDuration + after()). 이 세 개만 통과하면 나머지는 일반 HTTP API 호출 + DB 적재에 불과하다. 반나절이면 돌아가는 대시보드가 나온다.
다만 반나절은 시작이다. 환율 처리, 인앱 구독 롤오버, 다국가 세금 계산, API 스펙 변경 대응 같은 추가 요구가 따라붙는다. 앱 규모가 커지면 자체 유지보수 비용이 기성 도구 구독료를 넘을 수 있다. 그 시점엔 Apsity나 Appfigures로 옮기는 게 합리적이다. 직접 만든 경험이 있으면 기성 도구 선택도 더 잘 한다 — 뭘 해주는지, 뭘 해주지 않는지가 보인다.
· App Store Connect API Reference
· Downloading Sales and Trends Reports
· jose — JWT library for Node.js
· Next.js after() API Reference
이 튜토리얼은 2026년 4월 기준 App Store Connect API v1, Next.js 16, Vercel 공식 문서를 바탕으로 작성됐다. API 스펙은 변경될 수 있으니 Apple 공식 문서에서 최신 상태를 확인한다.
코드 예시는 간결성을 위해 에러 처리·로깅을 축약했다. 프로덕션에서는 재시도·모니터링을 추가한다.