---
name:EPL Radar
org:개인 프로젝트
period:2026 ~ 진행 중
tags:진행 중사이드 프로젝트1인 개발
frontend:[Next.js 16 (App Router),React 19,TypeScript]
state:[React Query,Zustand]
ui:[SCSS Modules]
ai:[Claude API]
test:[Vitest,Playwright]
---
#
EPL Radar
>무료 API(분당 10회) 한도 안에서 여러 사용자가 동시에 쓰는 FotMob 스타일 축구 정보 서비스
9 → 2
홈 캐시 갱신 1회당 외부 API 요청 수
71%
리그 페이지 3회 조회 시 캐시 적중률 (실측)
0회
AI 기능 · 즐겨찾기로 늘어나는 외부 요청
## 프로젝트 배경
무료 플랜은 분당 10회 요청 제한이 있고, 스코어가 지연되며, 라인업 · 선수 개별 스탯을 주지 않습니다. 이 제약을 숨기는 대신 설계의 전제로 두었습니다. 그래서 이 프로젝트의 주제는 기능 개수가 아니라 분당 10회 한도 안에서 여러 사용자가 동시에 써도 서비스가 도는 캐시 · 요청 설계입니다. UI 어디에도 "실시간"이라고 쓰지 않고 "자동 갱신 · HH:mm 기준"으로 표기했습니다.
## 아키텍처
→/api/football/*
프록시 · 키 은닉 · 입력 검증
→footballServerFetch
ISR 60초 / 10분 / 24시간
→football-data.org
분당 10회 한도
클라이언트는 외부 API를 직접 부르지 않고 API 키는 서버 전용 계층에서만 읽습니다. 탭이 5개든 50개든 외부 호출은 캐시 주기당 1회입니다.
## 주요 성과 및 기여
→요청 예산 설계 → 홈 갱신당 외부 요청 9회 → 2회 — 처음에는 8개 리그를 리그마다 따로 불러와 홈 한 번 갱신에 9회를 썼고, 리그 · 팀 페이지를 여는 순간 429가 났습니다. /matches 단일 호출에 날짜 · 대회 필터를 걸어 한 번에 받고, 리그별 그룹핑은 순수 함수로 옮겼습니다. 기능마다 분당 몇 회를 쓰는지 먼저 계산하고 여유분 3회 이상을 남기는 기준으로 설계했습니다.
→설계가 실제로 도는지 보여주는 계측 화면 (/budget) — 서버 fetch 계층에 계측을 넣고 경로별 호출 · 외부 호출 · 캐시 적중률, 최근 1분 외부 호출 수, API가 헤더로 알려주는 잔여 한도를 화면으로 노출. 캐시 적중은 응답 Date 헤더로 판정(Next fetch 캐시는 헤더까지 그대로 재생)
·리그 페이지 3회 연속 조회 실측 — /competitions/PL/matches?status=FINISHED: 호출 6 · 외부 1 · 캐시 적중 83%
·/competitions/PL/standings · /competitions/PL · /scorers: 각 호출 3 · 외부 1 · 캐시 적중 67%
·전체 캐시 적중률 71% (최근 1분 외부 호출 6 / 10)
→Claude API 매치 프리뷰 · 승부 예측 — 비용 · 지연 · 실패 통제
·입력은 matchId 하나만 신뢰하고, 프롬프트 데이터는 이미 캐시된 URL을 재사용 → AI 때문에 축구 API 요청이 늘지 않음
·프리뷰: matchId + 경기 상태를 캐시 키로 써서 상태가 바뀌면 자동 무효화, 스트리밍 응답으로 첫 글자 대기 단축
·예측: JSON 스키마로 출력 고정, 킥오프 전 결과 고정, 같은 경기 동시 요청은 진행 중 요청을 공유해 생성 1회
·사용자 예측과 AI 예측을 경기 종료 후 채점(정확 스코어 3점 · 승부 1점), 채점은 화면에 있는 데이터로 처리해 추가 요청 없음
·키가 없거나 생성이 실패하면 해당 섹션만 사라지고 나머지 화면은 정상. 데이터에 없는 사실(부상 · 이적 · 라인업)을 만들지 않도록 프롬프트에 명시
→조건부 자동 갱신 폴링 — 진행 중 경기가 있을 때만 폴링하고 전 경기가 끝나면 멈춤. 간격은 서버 캐시 주기(60초)와 맞추고, Page Visibility API로 백그라운드 탭에서는 정지 · 복귀 시 데이터가 오래됐을 때만 1회 갱신. 첫 렌더는 서버 데이터를 initialData로 주입
→서버 컴포넌트 + localStorage 개인화의 하이드레이션 경계 — 서버는 전체 데이터 한 벌만 가져오고 클라이언트가 즐겨찾기 기준으로 정렬 · 필터만 하도록 분리. 사용자마다 서버 요청이 갈라지면 캐시 적중률이 무너지고 곧바로 429로 이어지기 때문. 하이드레이션 판별은 useSyncExternalStore 기반 훅으로 추가 렌더 없이 처리
→429를 예외가 아닌 정상 시나리오로 처리 — 전용 에러로 구분하고 재시도 루프를 두지 않음(한도 초과 상황의 재시도는 한도를 더 태울 뿐). 부가 데이터 실패는 빈 배열로 폴백해 핵심 화면은 유지
→선수 스탯이 없는 대신 경기 결과로 지표를 직접 계산 — 라운드별 순위 변동, 팀 폼 스트릭, 잔여 일정 난이도를 이미 받은 경기 결과만으로 계산해 추가 요청 없이 제공
## 테스트 · 개발 방식
→Vitest 103개 — 승/무/패 판정, 순위 계산, 예측 채점 등 순수 함수의 경계 케이스 고정
→Playwright 16개 — 목 서버로 API 응답을 대체해 실제 한도를 쓰지 않고 홈 → 순위 → 팀 상세 · 즐겨찾기 흐름 검증
→Claude Code 활용 — 아키텍처 규칙(데이터 계층, ISR 상수, 폴백 정책)을 CLAUDE.md에 명시하고, 로드맵을 Phase로 나눠 한 번에 한 단계씩 구현. 단계마다 완료 조건(예: "AI를 켜고 꺼도 축구 API 요청 수가 같다")을 직접 확인 · 리뷰한 뒤 커밋
## 배운 점
처음 구조로는 리그 페이지 하나만 열어도 429가 났습니다. 한도를 늘릴 수 없으니 기능을 먼저 만들고 요청을 맞추는 대신, 요청 수를 먼저 계산하고 그 안에서 기능을 설계하는 순서로 바꿨습니다. AI 기능도 같은 기준으로 붙였습니다. "요청이 줄었다"는 말을 숫자로 확인할 수 있게 /budget 화면을 만든 것도, 설명보다 보여주는 쪽이 설득력이 있다고 느꼈기 때문입니다.