서버 없이 브라우저에서 GitHub API를 부를 때 지킨 것들
이 사이트의 기여자 목록은 서버 없이 방문자의 브라우저가 GitHub에 직접 물어봐요. 시간당 60번이라는 제한 안에서 버티기 위해 캐시와 요청을 어떻게 다뤘는지 정리했어요.
첫 화면의 프로젝트 박스에는 기여자 프로필이 동그랗게 붙어 있고, 맨 아래 Our Crew에는 팀원별 커밋 수와 최근 28일 활동이 나와요. 이 숫자들은 미리 저장해 둔 것이 아니에요. 페이지를 연 브라우저가 GitHub 공개 API에 직접 물어봐서 그려요.
서버를 두지 않아서 간단했지만, 대신 지켜야 할 것이 생겼어요.
로그인 없이 부르면 시간당 60번이에요
GitHub API는 인증 없이도 공개 저장소 정보를 돌려줘요. 대신 IP 하나당 한 시간에 60번까지만 받아 줘요. 넘으면 403이 돌아오고 한 시간 동안 아무것도 받을 수 없어요.
토큰을 넣으면 제한이 훨씬 넉넉해지지만, 브라우저에서 도는 코드에 토큰을 넣으면 누구나 볼 수 있어요. 그래서 토큰 없이 버티는 쪽을 골랐어요.
저장소 하나를 그리는 데 드는 요청을 세어 보면 이래요.
| 요청 | 횟수 |
|---|---|
| 기여자 목록 | 1번 |
| 최근 28일 커밋 | 1번에서 최대 3번 |
| 최근 28일에 커밋이 없는 사람의 마지막 커밋 | 사람마다 1번 |
저장소가 둘이고 기여자가 여럿이면 한 번 방문에 열 번 가까이 쓰기도 해요. 새로고침 몇 번이면 한도가 바닥나요.
한 시간 동안은 다시 묻지 않아요
받은 결과는 브라우저의 localStorage에 받은 시각과 함께 저장해요.
const CACHE_PREFIX = 'hidly:repo:v1:'
const CACHE_TTL = 60 * 60 * 1000
function writeCache(repo: string, data: RepoStats) {
try {
const entry: CacheEntry = { at: Date.now(), data }
localStorage.setItem(CACHE_PREFIX + repo, JSON.stringify(entry))
} catch {
return
}
}
다음에 페이지를 열었을 때 저장한 지 한 시간이 안 됐으면 GitHub에 묻지 않고 저장한 것을 그대로 써요. 커밋 수가 몇 분 늦게 바뀌는 것은 아무도 신경 쓰지 않지만, 숫자가 아예 안 나오는 것은 눈에 띄어요.
키에 v1을 붙인 이유도 있어요. 저장하는 데이터의 모양을 바꾸면 예전 모양으로 저장된 것을 읽다가 화면이 깨질 수 있어요. 그때는 v2로 올리면 예전 것은 자연스럽게 무시돼요.
try로 감싼 것은 localStorage가 언제든 실패할 수 있어서예요. 사파리의 개인 정보 보호 모드나 저장 공간이 꽉 찬 경우에는 저장이 막혀요. 저장에 실패해도 화면은 그려져야 하니 조용히 넘어가요.
같은 요청이 동시에 두 번 나가지 않게
첫 화면에서는 같은 저장소 정보를 프로젝트 박스와 Our Crew가 함께 써요. 리액트 개발 모드에서는 화면을 두 번 그려 보기도 해요. 그대로 두면 캐시가 채워지기 전에 같은 요청이 두세 번 나가요.
그래서 진행 중인 요청을 저장소 이름으로 묶어 두고, 같은 저장소를 또 물으면 새로 보내지 않고 진행 중인 것을 돌려줘요.
const pending = new Map<string, Promise<RepoStats | null>>()
export function loadRepo(repo: string): Promise<RepoStats | null> {
const cached = readCache(repo)
if (cached && Date.now() - cached.at < CACHE_TTL) {
return Promise.resolve(cached.data)
}
const running = pending.get(repo)
if (running) return running
const request = fetchRepo(repo)
.then((data) => {
writeCache(repo, data)
return data
})
.catch(() => cached?.data ?? null)
.finally(() => pending.delete(repo))
pending.set(repo, request)
return request
}
실패하면 오래된 캐시라도 보여줘요
위 코드의 catch가 중요해요. 한도를 넘었거나 네트워크가 끊겨서 요청이 실패하면, 한 시간이 지난 캐시라도 있으면 그것을 돌려줘요. 하루 전 숫자라도 아무것도 없는 것보다 나아요.
캐시조차 없으면 null을 돌려주고, 화면은 기여자 대신 by HIDDENLIGHTYOUTH처럼 만든 팀 이름만 보여줘요. 오류 메시지를 띄우지는 않아요. 방문자가 할 수 있는 일이 없는 오류는 보여줘도 도움이 되지 않아요.
화면은 캐시로 먼저 그려요
요청이 끝날 때까지 빈 화면을 보여줄 필요는 없어요. 리액트 상태의 처음 값을 캐시에서 바로 읽어서, 첫 화면부터 지난번 숫자가 나오게 했어요.
export function useRepoStats(repos: string[]) {
const [stats, setStats] = useState<RepoStatsMap>(() =>
toMap(repos.map(readCachedRepo)),
)
useEffect(() => {
let active = true
void Promise.all(repos.map(loadRepo)).then((list) => {
if (active) setStats(toMap(list))
})
return () => {
active = false
}
}, [repos])
return stats
}
readCachedRepo는 시간이 지났는지 따지지 않고 있는 것을 돌려줘요. 그다음 loadRepo가 필요하면 새로 받아 와서 바꿔 끼워요. 숫자가 살짝 바뀌는 일은 있어도 박스가 비었다가 채워지면서 출렁이는 일은 없어요.
active 변수는 요청이 끝나기 전에 화면이 사라진 경우를 위한 것이에요. 이미 사라진 화면의 상태를 바꾸지 않게 막아요.
요청 수 자체도 줄였어요
캐시만큼 중요한 것이 처음부터 덜 묻는 것이에요.
- 최근 커밋은
since로 28일 전부터만 받아요. 저장소 전체 기록을 받을 필요가 없어요. - 한 번에 100개씩 받고, 100개보다 적게 오면 마지막 페이지로 보고 멈춰요. 아무리 많아도 3페이지까지만 봐요.
- 기여자 목록과 최근 커밋은 서로 기다릴 이유가 없어서 동시에 보내요.
- 최근 28일에 커밋이 없는 사람만 마지막 커밋을 따로 물어봐요.
for (let page = 1; page <= MAX_COMMIT_PAGES; page += 1) {
const batch = await getJson<ApiCommit[]>(
`/repos/${repo}/commits?since=${since}&per_page=100&page=${page}`,
)
commits.push(...batch)
if (batch.length < 100) break
}
봇은 빼요
기여자 목록에는 사람만 있는 게 아니에요. 의존성을 올려 주는 봇도 커밋을 해요. API가 알려 주는 type이 User인 것만 남겨서, 팀원 목록에 봇이 끼지 않게 했어요.
한계
- 방문자가 처음 오는 순간에는 캐시가 없어서 숫자가 조금 늦게 떠요.
- 같은 공유기를 쓰는 사람들은 한도를 같이 써요. 학교나 회사 네트워크에서는 금방 바닥날 수 있어요.
- 커밋이 300개를 넘는 달에는 28일 활동 그래프의 오래된 쪽이 덜 채워져요.
방문자가 많아지면 서버에서 한 번만 받아서 모두에게 나눠 주는 쪽이 맞아요. 지금은 저장소가 두 개라 브라우저에서 직접 부르는 것으로 충분했어요.
정리
- 인증 없는 GitHub API는 IP당 시간당 60번이라, 브라우저에서 부를 때는 캐시가 필수예요.
- 캐시 키에 버전을 붙이면 데이터 모양을 바꿀 때 편해요.
- 진행 중인 요청을 묶어 두면 같은 요청이 겹쳐 나가지 않아요.
- 실패했을 때는 오래된 캐시라도 보여주고, 화면은 캐시로 먼저 그려요.