Supabase로 경찰과 도둑 앱 지표 대시보드 만들기
어드민 지표 대시보드를 만든 과정을 일곱 편으로 나눠 적었다. 이 글은 7편이다.
- 경찰과 도둑 앱 지표를 골라 매일 모으기로 한 이유
- 인스타그램 API로 경찰과 도둑 앱 하루치 지표를 구해 디스코드로 보내기
- 서비스 계정으로 경찰과 도둑 앱의 Google Play 통계 CSV 받기
- App Store Connect API로 경찰과 도둑 앱 다운로드와 노출 수 받기
- GA4 Data API로 경찰과 도둑 앱과 웹사이트 지표 모으기
- AdMob API 갱신 토큰으로 경찰과 도둑 앱 광고 지표 매일 받기
- Supabase로 경찰과 도둑 앱 지표 대시보드 만들기
2~6편에서는 숫자를 가져올 곳마다 권한을 설정하고, 실제로 숫자를 받아 봤다. 이제 이 일을 매일 자동으로 돌리고, 모인 숫자를 팀이 보는 어드민 화면에 올릴 차례였다.
이미 돌고 있던 방식
노션 글을 사이트로 옮기는 블로그 동기화는 이미 GitHub Actions 예약 실행으로 Vercel 주소를 부르고 Supabase에 저장하고 있었다. 팀이 아는 구조라 새로 배울 게 없었고, 게임 서버와 게임 DB도 건드리지 않아도 됐다. 그래서 지표 수집도 같은 방식으로 만들었다.
정해진 시각이 되면 GitHub의 자동 실행 기능(Actions)이 Vercel에 올린 우리 사이트의 수집 주소를 부른다. 그러면 Vercel 서버가 각 API에 숫자를 요청해 데이터베이스(Supabase)에 저장한다.
GitHub Actions (예약 실행, 공개 저장소)
metrics-collect.yml 매일 UTC 20:43 (한국 05:43)
├─ POST /api/metrics/appstore ┐
├─ POST /api/metrics/ga4 │ 헤더에 METRICS_CRON_SECRET
├─ POST /api/metrics/admob │
└─ POST /api/metrics/play ┘
instagram-report.yml 매일 UTC 0:17 (한국 09:17) → /api/metrics/instagram (+ 디스코드)
weekly-report.yml 월요일 UTC 0:37 (한국 09:37) → /api/metrics/weekly (디스코드)
Vercel (Next.js 서버 함수)
수집 라우트: 환경변수의 키로 각 API를 부르고 Supabase에 저장
Supabase (Postgres)
appstore_sales_daily, ga4_daily, admob_daily, play_daily, instagram_* ...
어드민 화면 (/admin/metrics)
→ /api/admin/metrics 가 어드민인지 확인하고 Supabase에서 읽어 내려준다
주간 리포트는 월요일 아침에 지난주 숫자를 그 전 주와 비교해 디스코드로 보낸다. 다만 실제 판 수만은 저장해 둔 지표를 쓰지 않고, 어드민 화면을 열 때 게임 서버(백엔드)에 바로 묻는다(1편).
주소만 부르는 Actions
Actions 안에서 바로 API를 부르고 Supabase에 넣어도 된다. 그런데 어드민 화면, 주간 리포트, 손으로 돌리는 스크립트도 같은 코드를 써야 하고, 그 코드는 Next.js 저장소 안에 있다. Actions에서도 API를 부르면 키를 GitHub 시크릿과 Vercel 환경변수, 두 군데에 둬야 한다.
그래서 Actions는 정해진 시각에 Vercel 주소를 한 번 부르는 일만 하도록 했다. API 키는 전부 Vercel 환경변수에만 뒀다.
GitHub 시크릿 두 개

GitHub에서 비밀 값을 보관하는 시크릿에는 수집용 비밀 문자열 하나만 넣었다. 이 값으로 수집 요청이 우리 예약 실행에서 왔는지 확인한다. 같은 값을 Vercel에도 넣고, 요청 헤더에 담긴 값과 같을 때만 수집 코드가 실행되게 했다. GitHub 시크릿에 함께 있는 BLOG_SYNC_SECRET은 블로그 동기화용이다.
Vercel 환경변수에 모은 API 키

각 API를 부를 때 필요한 키는 코드에 넣지 않고, 서버가 실행될 때 읽는 Vercel 환경변수에 아래처럼 모았다.
| 환경변수 | 편 |
|---|---|
INSTAGRAM_ACCESS_TOKEN, DISCORD_INSTAGRAM_WEBHOOK_URL | 2편 |
GOOGLE_SERVICE_ACCOUNT_JSON, PLAY_GCS_URI | 3편, 5편 |
ASC_ISSUER_ID, ASC_KEY_ID, ASC_PRIVATE_KEY | 4편 |
ADMOB_CLIENT_ID, ADMOB_CLIENT_SECRET, ADMOB_REFRESH_TOKEN | 6편 |
METRICS_CRON_SECRET | 이 편 |
환경변수가 많아질수록 어디에 뭘 넣었는지 찾는 것도 일이 됐다. 그래서 비밀이 아닌 식별자(앱 Apple ID, Vendor 번호, GA4 속성 ID, AdMob 퍼블리셔 ID)는 코드 설정 파일에 두고, 비밀 값만 환경변수에 넣었다.
ASC_PRIVATE_KEY처럼 여러 줄짜리 값은 넣는 방법에 따라 줄바꿈이 \n 글자로 바뀐다. 그래서 코드에서는 진짜 줄바꿈과 바뀐 글자를 둘 다 받아 주게 했다(4편). 키는 실제 서비스에 쓰는 Production 환경에만 넣었다.
따라 하기: 예약 실행 워크플로
name: metrics-collect
on:
schedule:
- cron: "43 20 * * *" # UTC 20:43 = 한국 05:43
workflow_dispatch: # 손으로 돌리기 버튼
inputs:
from:
description: "이 날부터 다시 채우기 (YYYY-MM-DD, 비우면 최근 7일)"
type: string
default: ""
jobs:
collect:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false # 한 소스가 실패해도 나머지는 돈다
matrix:
source: [appstore, ga4, admob, play]
steps:
- name: ${{ matrix.source }} 수집
env:
SOURCE: ${{ matrix.source }}
FROM: ${{ inputs.from }}
SECRET: ${{ secrets.METRICS_CRON_SECRET }}
run: |
query=""
if [ -n "$FROM" ]; then query="?from=$FROM"; fi
code=$(curl -sS -o response.json -w "%{http_code}" -X POST \
"https://<우리 사이트>/api/metrics/$SOURCE$query" \
-H "Authorization: Bearer $SECRET")
# 공개 저장소 로그라 수익과 판매 개수는 빼고, 됐는지와 몇 줄인지만 남긴다
jq 'del(.earningsUsd, .sales.units)' response.json || head -c 300 response.json
echo "HTTP $code"
test "$code" = "200"
한 소스가 실패했다고 나머지 수집까지 멈추면 안 됐다. 그래서 matrix로 네 소스를 나눠 각각 다른 작업으로 돌리고, fail-fast: false를 넣었다. 이 설정이 없으면 하나가 실패하는 순간 나머지도 취소되지만, 지금은 Play 403이 나도 나머지는 저장된다.
Vercel 서버 함수의 실행 시간 한도도 나눠 부른 이유였다. 우리 설정에서는 한도가 300초라, 네 소스를 한 번에 부르면 300초를 넘을 수 있었다. 처음부터 몇 달치를 채우는 일은 수집 주소로 요청하는 대신 로컬 스크립트로 한다.
node --env-file=.env.local scripts/metrics-collect.mjs ga4 --from all
예약 시각을 적는 cron도 기준을 맞춰야 했다. GitHub Actions의 schedule은 늘 UTC 기준이라 한국 시간에서 9시간을 빼서 적는다. GitHub 문서에는 예약 실행이 몰리는 정각에는 늦어지거나 건너뛸 수 있다고 나와 있어서, 43분처럼 어중간한 분을 골랐다.
정각을 피해도 다섯 시간 늦은 실행

예약은 한국 오전 5시 43분이었다. 그런데 실행 기록을 열어 보니 실제로 돈 시각은 이랬다.
| 날짜 | 실제 실행 |
|---|---|
| 10월 13일 | 오전 11:13 |
| 10월 14일 | 오전 8:50 |
| 10월 15일 | 오전 8:25 |
| 10월 16일 | 오전 8:15 |
| 10월 17일 | 오전 7:59 |
정각을 피해 일부러 어중간한 분을 골랐는데도, 두 시간에서 다섯 시간 반까지 늦었다. 그래서 우리 저장소에서는 예약 시각을 대략적인 기준으로만 보기로 했다.
다행히 어제까지의 숫자를 보는 화면이라 몇 시간 늦어도 문제가 없었다. 다만 월요일 오전 9시 37분에 주간 리포트를 보낼 때도, 그 전에 수집이 끝났다고 믿을 수는 없었다. 일요일 App Store 판매가 아직 안 나왔을 수 있어서, 주간 리포트를 보내기 직전에 판매 리포트를 한 번 더 받는다(4편). 수집이 늦은 날에도 이 덕을 봤다.
정확한 시각이 꼭 필요하면 Vercel Cron이나 외부 스케줄러를 쓰는 게 낫다. 우리는 매일 한 번, 늦어도 오전 중이면 충분해서 그대로 뒀다.
공개 로그에서 뺀 수익과 판매 개수
우리 웹 저장소는 공개라 Actions 로그를 누구나 볼 수 있다. 그런데 수집 주소가 돌려주는 응답에는 광고 수익과 판매 개수도 들어 있다. 그래서 위 워크플로처럼 JSON 편집 도구 jq로 두 값을 지우고 로그에 남기도록 했다.
수집 라우트 응답에는 원래 몇 줄 받았는지 같은 요약만 담았다. 로그에서도 수집이 됐는지 확인하는 데 필요한 내용만 남겼다.
Supabase에 쌓기
소스별로 나눈 테이블
숫자를 받아 보니 소스마다 담긴 항목과 모양이 꽤 달랐다. 그래서 Supabase의 테이블은 소스별로 나누되, 칸은 모두 날짜, 몇 가지 기준, 숫자 순서로 맞췄다.
| 테이블 | 한 줄 |
|---|---|
appstore_sales_daily | 날짜, 나라, 상품 유형, 기기별 수량 |
appstore_analytics_daily | 분석 리포트 한 줄(숫자 외 칸은 jsonb) |
ga4_daily | 날짜, 속성, 쪼갠 기준, 키, 지표, 값 (세로로 길게) |
admob_daily | 날짜, 플랫폼, 형식, 나라별 요청, 매치, 노출, 클릭, 수익(마이크로) |
play_daily | 날짜, 파일, 축, 키, 지표, 값 |
instagram_media_daily, instagram_account_daily | 게시물 누적값, 계정 하루 값 |
metrics_events | 행사, 출시 같은 일정 |
metrics_tokens | 연장한 인스타 토큰 (2편) |
metrics_notifications | 보낸 리포트 기록 (2편) |
다시 받으면 덮어쓰기
1편에서 정한 대로 최근 7일을 매일 지우고 새로 넣는다. 몇 번을 다시 돌려도 결과가 같고, 하루 실패해도 다음 날 수집할 때 그날 숫자까지 다시 채운다.
서버를 거쳐서만 읽는 숫자
Supabase는 브라우저에서 바로 DB를 읽게 해 주는 게 장점인데, 지표 테이블은 일부러 막았다. 테이블마다 어떤 행을 읽도록 허용할지 정하는 행 단위 보안(RLS)을 켜고 읽기 정책을 만들지 않았다. 그러면 브라우저에 들어가는 공개 키로는 아무것도 읽을 수 없고, 서버만 service role 키로 읽는다.
어드민 화면은 /api/admin/metrics를 부른다. 이 주소의 서버 코드가 먼저 어드민 토큰이 맞는지 백엔드에 물어본다. 토큰이 만료됐으면 401, 어드민이 아니면 403 오류를 돌려준다. 어드민으로 확인되면 Supabase에서 숫자를 읽어 정리해 내려준다.
어드민 화면 자체도 어드민 전용 주소(서브도메인)에서만 열린다. 메인 도메인의 /admin은 404를 준다. Next.js 미들웨어(proxy.ts)가 요청이 들어온 호스트를 보고 나누도록 했다.
1,000줄에서 조용히 잘린 조회
어드민 화면에서 앱 첫 실행 경로를 보니, 114명이어야 할 숫자가 7명이었다. 그런데 오류는 하나도 없었다. 이번 작업에서 가장 무서웠던 버그였다.
Supabase(정확히는 그 아래 PostgREST)는 한 번에 1,000줄까지만 준다. .limit(50000)을 써도 1,000줄에서 오류 없이 끊는다. GA4 테이블은 2주만 읽어도 1,000줄 가까이 돼서, 뒤쪽 날짜가 통째로 빠진 채로 합계가 나오고 있었다.
그래서 쪽을 넘겨 가며 끝까지 읽도록 바꿨다. 지표를 읽는 곳은 모두 아래 함수 하나를 쓰게 했다.
// 1,000줄씩 쪽을 넘겨 끝까지 읽는다
async function selectAll(query) {
const out = [];
for (let from = 0; ; from += 1000) {
const { data, error } = await query.range(from, from + 999);
if (error) throw error;
out.push(...data);
if (data.length < 1000) return out;
}
}
오류가 나면 고칠 곳을 알 수 있었는데, 숫자만 그럴듯하게 틀리면 알아채기 어려웠다. 이번에는 114가 7로 나와서 확인할 수 있었지만, 114가 98로 나왔으면 모르고 넘어갔을 것 같았다.
읽는 코드를 손보면서 Supabase가 가끔 내는 "JWT issued at future" 오류도 처리했다. 서버 사이 시계 차이로 생기는 오류인데, 바로 다시 물으면 된다. 그래서 selectAll은 실패하면 0.8초 뒤 한 번 더 묻게 했다.
다섯 탭으로 나눈 화면
질문 순서대로 나눈 탭

처음에는 한 페이지에 다 넣었는데, 노트북에서 대여섯 화면을 내려야 끝이 나왔다. 그래서 요약, 인스타그램, 유입 경로, 앱과 광고 네 탭으로 나눴다. Play 수집을 추가하면서 스토어 탭이 생겨 지금은 다섯이다.
| 탭 | 담은 것 |
|---|---|
| 요약 | 앱 첫 실행 큰 숫자, 모든 경로를 합친 흐름, 신규 사용자 퍼널, 핵심 비율, 일정 |
| 인스타그램 | 계정 숫자, 게시물 표와 상세 (2편) |
| 유입 경로 | 사이트 방문, 스토어로 이동, 채널별 표 (5편) |
| 스토어 | App Store와 Google Play를 골라 보기 (3편, 4편) |
| 앱과 광고 | 사용, 남는 사람, 광고 (5편, 6편) |
요약 맨 위 큰 숫자는 앱 첫 실행이다. 1편에서 제안한 주간 플레이 인원은 아직 팀과 확정하지 못했다.
매일 여는 사람은 요약만 보고, 맡은 일이 있는 사람은 필요한 탭으로 가도록 나눴다. 보고 있는 탭은 주소 ?tab=에 남아서 링크를 보내면 같은 탭이 열린다.

7일, 4주, 13주 기간
주말에 몰리는 숫자를 이전 기간과 비교하려면 기간마다 요일 수가 같아야 했다. 그래서 30일, 90일 대신 28일, 91일로 잡았다. 특정 기간이 궁금할 때는 "직접 선택"으로 고를 수 있게 했다.

행사 주간의 비교 대상
마케터가 가장 자주 하는 질문은 "행사 주간이 평소 한 주보다 어땠나"인데, 화면은 늘 바로 앞 같은 길이의 기간과만 비교했다. 그래서 비교 기간을 고르는 메뉴를 만들었다. 행사 하루만 볼 때는 1편의 행사 성적표로 평소 같은 요일과 비교하고, 이 메뉴는 행사 주간처럼 기간을 볼 때 쓴다.

직접 고를 때도 길이가 다르면 합계를 같은 기준으로 비교하기 어렵고, 차트를 날짜별로 겹칠 수 없다. 그래서 시작일만 누르고 길이는 지금 기간과 같게 고정했다. 바로 앞이 아닌 기간과 비교할 때는 "이전 기간" 대신 "비교 기간"이라고 부르게 했다.
다만 데이터가 4월부터 있어서, "작년 같은 기간"과 비교하면 내년까지는 0만 나온다. 그래서 이 항목은 뺐다.
실제로 9월 19일 행사 주간(9월 19일부터 7일)을 9월 1~7일과 비교해 보니, 행사를 연 주에 첫 실행이 오히려 6회 적었다(95회 대 101회). 9월 1일 주에는 모집 글이 크게 퍼진 9월 4일(공유 218회, 도달 5,310명, 2편)이 들어 있었다. 행사를 했다는 이유만으로 늘었다고 볼 수는 없었고, 어느 기간과 비교하는지도 같이 봐야 했다.
숫자 옆에 적은 기준
소스마다 하루 기준도, 늦게 들어오는 정도도 다르다. 그래서 화면 맨 아래에 그 기준을 늘 적어 두었다.


설치 수는 스토어별로 나란히 뒀다(1편). 더하는 건 App Store 첫 다운로드와 Play 최초 설치뿐이다. 둘 다 처음 받은 것만 세기 때문이다. 더한 칸 아래에는 스토어별 숫자를 같이 적었다.
차트 손보기
다크 모드에서 강조색으로 쓰던 네온 초록을 차트 선에 넣으니 눈이 부셨다. 노란 둘째 선과 같이 두면 적록색약 시뮬레이션에서 색 차이가 4.1(기준 8 이상)이라 거의 같은 색이었다. 그래서 색약 검사를 통과하는 차트 전용 색을 따로 뒀다.
처음에는 비교 기간 선을 점선으로 그렸다. 그런데 Stripe, Plausible, GOV.UK 모두 점선을 예측이나 덜 들어온 숫자를 나타낼 때 쓴다. 그래서 비교 기간은 실선 회색으로 바꿨다.
세로축 눈금을 최댓값의 50%, 100%에 그으니 147 같은 어중간한 값이 나와서, 0, 50, 100처럼 반듯한 값으로 바꿨다. 숫자가 없는 날은 0으로 그리면 실제로 0이었던 날과 구분이 안 돼서 선을 끊었다.
인스타 게시물은 동그라미, 행사와 출시 같은 일정은 마름모로 모든 차트 위에 겹쳤다(일정 달력은 1편). 숫자가 튄 날에 어떤 게시물이나 일정이 있었는지 차트에서 바로 보이게 했다.
직접 만든 툴팁
브라우저 기본 툴팁(title)은 늦게 뜨고 크기도 작은 데다 다크 모드를 따르지 않는다. 키보드와 터치로는 열리지 않아서, 짧은 설명을 띄우는 툴팁을 따로 만들었다. 마우스는 올리면, 키보드는 초점이 오면, 터치는 누르면 열린다.
원본과 다시 맞추기
화면을 다 만든 뒤에는 숫자가 맞는지 확인해야 했다. 화면에 보이는 숫자를 GA4, AdMob, App Store, 인스타 API에 따로 다시 물어 하나씩 맞춰 보고, 계산식도 다시 살펴봤다. 그 과정에서 아래 문제를 찾았다.
- 다운로드 버튼 클릭 99번 중 79번이 버튼이 아니었다 (5편)
- 시작한 게임 311판이 사실 사람 수였다 (5편)
- Supabase 1,000줄 잘림 (위)
- 우리 팀 개발 방문, 인스타
ig꼬리표 누락 (5편) - 사이트와 앱 GA4 속성의 시작일이 달라 13주 비교가 부풀었다 (5편)
다시 살펴보면서 11개를 더 찾았다. 첫 실행 후 7일 안에 게임에 참가한 비율인 활성화율(5편)이 100%를 넘을 수 있는 경우, 팔로워 증감이 하루 밀리는 것, 게임 기록을 두 번 셀 수 있는 것 등이었다. 이렇게 맞춰 보고도 아래 세 숫자는 끝까지 차이가 남았다.
| 숫자 | 차이 | 이유 |
|---|---|---|
| 사이트 방문 | 1 | 자정을 넘긴 방문이 이틀에 걸려 두 번 세진다 |
| 인스타 조회수 | 9 | 인스타가 며칠 지난 숫자를 고친다 |
| 광고 수익 | 0.00003달러 | 추정치가 고쳐진다 |
이 차이는 원인을 알고 남긴 것이라 문서에 적어 두고 넘어갔다.
다시 원본 앞에서
사업계획서에 넣을 숫자를 모으려고 콘솔을 돌며 파일을 받고 날짜를 맞추던 일에서 시작했다. 이제 흩어진 숫자는 어드민의 다섯 탭에 모인다. 다만 처음 제안했던 주간 플레이 인원은 아직 팀과 확정하지 못해, 요약 맨 위에는 앱 첫 실행을 두었다.
실제로 손을 댄 날은 사흘이었고, 마지막 하루는 거의 숫자를 고치는 데 썼다. 처음에는 어드민에서 API를 부르면 끝이라고 생각했다. 그런데 마지막에 내가 하고 있던 일은 화면에 나온 숫자를 GA4, AdMob, App Store, 인스타 API에 다시 묻는 일이었다.
keep reading
comments
자유롭게 생각을 남겨주세요.
