
API 키 없이도 작동하는 축구 라이브 스코어 앱을 만들었습니다
요약
Next.js와 TypeScript를 사용하여 API 키 없이도 작동하는 축구 라이브 스코어 앱 'PitchPulse Live' 구축 과정을 소개합니다. 외부 API를 사용할 수 없는 상황을 대비해 자동 모의 데이터(mock data) 폴백 시스템을 구현한 것이 핵심입니다.
핵심 포인트
- API 키 부재 시 자동으로 모의 데이터로 전환되는 폴백 로직 구현
- Next.js App Router를 활용한 서버 사이드 렌더링 및 클라이언트 상호작용 최적화
- 확장 가능한 데이터 레이어 설계를 통한 프로덕션 스타일의 앱 구축
- TypeScript와 Tailwind CSS를 사용한 안정적이고 현대적인 UI 개발
축구 라이브 스코어 (livescore) 앱은 제대로 만들려고 시도하기 전까지는 단순해 보입니다.
처음에는 아이디어가 간단했습니다:
- 실시간 경기 표시
- 사용자가 날짜별로 경기 일정 (fixtures) 검색
- 리그별 경기 정리
- 경기 상세 페이지 추가
- 팀 및 대회 로고 표시
하지만 구축을 시작하자, 진짜 도전 과제는 점수를 렌더링하는 것이 아니었습니다.
더 어려운 부분은 다음과 같은 상황에서 데이터 레이어 (data layer)가 어떻게 동작해야 하는지를 결정하는 것이었습니다:
- API 키가 없는 경우
- 외부 API를 사용할 수 없는 경우
- 응답 형태 (response shape)가 변경되는 경우
- 팀 로고가 존재하지 않는 경우
- 라이브 연동이 준비되기 전에 UI가 작동해야 하는 경우
이로 인해 저는 Next.js, TypeScript, Tailwind CSS, 그리고 SportMicro 축구 데이터를 사용하여 프로덕션 스타일의 축구 라이브 스코어 애플리케이션인 PitchPulse Live를 구축하게 되었습니다.
제가 가장 마음에 드는 부분은 API 키가 설정되지 않았을 때도 애플리케이션을 사용할 수 없게 되지 않는다는 점입니다.
애플리케이션은 자동으로 모의 데이터 (mock data)로 전환됩니다.
이는 누군가가 저장소 (repository)를 클론하고, 앱을 실행하고, 인터페이스를 탐색하며, 외부 스포츠 API에 대한 액세스 설정을 먼저 하지 않고도 개발을 계속할 수 있음을 의미합니다.
저장소 (Repository):
github.com/mihailove123/pitchpulse-livescore-ap
내가 만들고자 했던 것
저는 이 프로젝트가 단순히 몇 개의 하드코딩된 경기 카드만 있는 페이지가 되기를 원하지 않았습니다.
목표는 나중에 더 고급 기능을 지원할 수 있는 구조를 갖춘, 작지만 현실적인 축구 제품을 만드는 것이었습니다.
첫 번째 버전에는 다음이 포함됩니다:
- 실시간 경기 스코어보드
- 일별 경기 일정 브라우징
- 리그 필터링
- 리그 디렉토리
- 개별 경기 페이지
- 팀 및 리그 로고
- 서버 사이드 렌더링 (server-rendered) 데이터 페칭 (fetching)
- 자동 모의 데이터 폴백 (automatic mock fallback)
주요 라우트 (routes) 는 다음과 같습니다:
/ 라이브 경기 및 일일 일정 (Live matches and daily fixtures)
/leagues 축구 리그 디렉토리 (Football league directory)
/matches/[id] 경기 상세 정보 (Match details)
이 앱은 Next.js App Router로 구축되었습니다.
이를 통해 초기 데이터 페칭 (data fetching)은 서버에서 수행하면서도, 날짜 선택 및 리그 필터링을 위해 상호작용이 가능한 클라이언트 컴포넌트 (client components)를 사용할 수 있었습니다.
기술 스택 (The Stack)
이 프로젝트는 다음을 사용합니다:
Next.js 16
React 19
TypeScript
...
프로젝트 구조는 다음과 같습니다:
src/
app/
page.tsx
...
중요한 설계 결정은 축구 데이터 로직을 UI와 분리하는 것이었습니다.
페이지는 인증 헤더 (authentication headers)가 어떻게 생성되는지, 쿼리 파라미터 (query parameters)가 어떻게 빌드되는지, 또는 가공되지 않은 API 응답 (raw API responses)이 어떻게 정규화 (normalized)되는지 알 필요가 없어야 합니다.
페이지는 앱에 최적화된 객체 (app-friendly objects)를 전달받아 렌더링하기만 하면 됩니다.
첫 번째 문제: 외부 데이터가 앱 전체를 제어해서는 안 된다
API 기반 인터페이스를 구축할 때, UI를 가공되지 않은 응답에 직접 연결하고 싶은 유혹에 빠지기 쉽습니다.
예를 들어:
<div>
<span>{match.home_team?.name}</span>
<span>{match.score?.home}</span>
...
이 방식은 처음에는 잘 작동하지만, 인터페이스와 외부 API 사이에 강한 의존성 (tight dependency)을 만듭니다.
만약 API에서 필드 이름을 변경하면, 해당 필드를 사용하는 모든 컴포넌트를 수정해야 할 수도 있습니다.
대신, PitchPulse는 데이터 레이어 (data layer) 내부에서 응답을 정규화 (normalize)합니다.
UI는 다음과 같은 내부 타입 (internal types)을 소비합니다:
export type FootballMatch = {
id: string
homeTeam: {
...
이를 통해 유용한 경계 (boundary)가 형성됩니다:
외부 API 응답 (External API response)
↓
정규화 레이어 (Normalization layer)
...
컴포넌트는 더 이상 데이터가 어디에서 왔는지 신경 쓰지 않습니다.
이는 동일한 컴포넌트가 라이브 API 데이터와 로컬 모의 데이터 (local mock data)를 모두 렌더링할 수 있기 때문에 특히 유용합니다.
쿼리 빌더 (Query Builder)로서 @sportmicro/endpoint 사용하기
이 프로젝트는 @sportmicro/endpoint 패키지를 사용하지만, 전체 API 클라이언트 (API client)로 사용하는 것은 아닙니다.
저는 이를 PostgREST 스타일의 엔드포인트 경로 (endpoint paths)를 구성하는 데 사용합니다.
예를 들어:
const path = endpoint("matches")
.property("status_type")
.equals("live")
...
이 패키지는 쿼리 구조 (query structure)를 처리합니다.
애플리케이션은 여전히 fetch를 사용하여 실제 HTTP 요청을 보냅니다.
const response = await fetch(
`${FOOTBALL_API_URL}/${path}?lang=en`,
{
...
저는 이 접근 방식이 엔드포인트 구성 (endpoint construction)을 읽기 쉽게 유지해 주기 때문에 좋아합니다.
긴 쿼리 문자열 (query string)을 수동으로 만드는 대신:
const url =
"/matches?status_type=eq.live&select=id,start_time,status_type..."
코드가 단계별로 쿼리를 설명합니다.
모든 축구 요청을 위한 하나의 래퍼 (Wrapper)
SportMicro 통합 로직은 다음 파일에 위치합니다:
src/lib/sportmicro.ts
이 파일은 다음 역할을 담당합니다:
- 엔드포인트 경로 (endpoint paths) 구축
- API 키 첨부
- 요청 전송
- 에러 처리
- 모의 데이터 (mock data)로 전환
- 응답 정규화 (normalizing responses)
애플리케이션의 나머지 부분은 다음과 같은 함수들을 호출합니다:
getLiveFootballMatches()
getFixturesByDate(date)
getMatchDetails(matchId)
...
페이지는 어떤 엔드포인트가 사용되는지 알 필요가 없습니다.
export default async function HomePage() {
const liveMatches =
await getLiveFootballMatches()
...
이를 통해 페이지는 프레젠테이션 (presentation)에만 집중할 수 있습니다.
전송 로직 (transport logic)은 한 곳에 머물러 있습니다.
모의 모드 (Mock Mode)를 추가한 이유
모의 폴백 (mock fallback)은 이 프로젝트에서 가장 유용한 부분 중 하나가 되었습니다.
일반적으로 API 기반의 저장소 (repository)는 이를 실행하려는 사람에게 마찰을 일으킵니다.
프로젝트를 클론(clone)하고, 의존성 (dependencies)을 설치하자마자 다음과 같은 에러를 보게 됩니다:
Missing API key
이는 훌륭한 첫 경험이 아닙니다.
PitchPulse Live에서 애플리케이션은 환경 변수 (environment variable)가 존재하는지 확인합니다.
const apiKey = process.env.SPORTMICRO_API_KEY
const isMockMode = !apiKey
키가 없으면 데이터 함수들은 로컬 샘플 데이터를 반환합니다.
export async function getLiveFootballMatches() {
if (isMockMode) {
return mockLiveMatches
...
즉, 설정 과정이 다음과 같이 간단해질 수 있다는 의미입니다:
git clone https://github.com/mihailove123/pitchpulse-livescore-ap.git
cd pitchpulse-livescore-ap
...
레이아웃을 확인하기 위해 외부 계정이 필요하지 않습니다.
컴포넌트가 작동하도록 구성해야 할 비밀 키 (secret)도 필요 없습니다.
라이브 연동은 나중에 .env.local 파일을 생성하여 활성화할 수 있습니다:
SPORTMICRO_API_KEY=your_sportmicro_api_key_here
이를 통해 두 가지 유용한 개발 모드 (development modes)가 생성됩니다.
Mock 모드
UI 개발
컴포넌트 테스트
...
두 데이터 소스 모두 동일한 타입 (types)으로 정규화 (normalized)되어 있기 때문에, 동일한 UI가 두 모드 모두에서 작동합니다.
누락된 로고를 의도적인 것처럼 보이게 만들기
축구 데이터는 완벽하게 완전한 경우가 드뭅니다.
어떤 팀은 로고가 있습니다.
어떤 팀은 없습니다.
어떤 이미지 URL은 작동하지 않습니다.
일부 하위 리그는 브랜딩 (branding)이 불완전할 수 있습니다.
깨진 이미지 아이콘을 렌더링하면 애플리케이션 전체가 미완성된 것처럼 보입니다.
그렇기 때문에 재사용 가능한 EntityLogo 컴포넌트를 추가했습니다.
type EntityLogoProps = {
name: string
imageUrl?: string | null
...
원격 이미지 (Remote images)는 다음에서 로드됩니다:
유효한 이미지를 사용할 수 없는 경우, 컴포넌트는 대신 이니셜 (initials)을 표시합니다.
이것은 작은 기능이지만, 불완전한 데이터가 인터페이스를 손상시키는 것을 방지합니다.
폴백 (fallback) 처리가 실수처럼 보이지 않고 의도적인 것처럼 보이게 합니다.
재사용 가능한 매치 카드 (Match Cards) 구축하기
주요 재사용 가능한 UI 단위는 MatchCard.tsx입니다.
매치 카드는 여러 상태 (states)를 지원해야 합니다:
예정됨 (Scheduled)
라이브 (Live)
종료됨 (Finished)
...
예정된 경기는 킥오프 시간을 강조해야 합니다.
라이브 경기는 점수와 현재 상태를 강조해야 합니다.
완료된 경기는 점수가 최종적임을 명확히 해야 합니다.
단순화된 버전은 다음과 같습니다:
export function MatchCard({
match,
}: {
...
카드가 정규화된 매치 데이터를 소비하기 때문에, 다음과 같은 곳에서 재사용할 수 있습니다:
- 홈 페이지
- 경기 일정 및 결과 (fixture results)
- 리그 페이지
- 관련 경기 섹션
- 검색 결과
날짜 및 리그 필터링
홈 페이지에는 경기 일정을 탐색하기 위한 컨트롤이 포함되어 있습니다.
사용자는 날짜를 선택하고 리그별로 목록을 좁힐 수 있습니다.
날짜 선택기 (Date selector)는 요청된 날짜를 업데이트합니다:
<DateSelector selectedDate={date} />
리그 필터 (League filter)는 반환된 경기 일정 (fixtures)에서 발견된 대회 (competitions) 정보를 전달받습니다:
<LeagueFilter
leagues={availableLeagues}
selectedLeague={leagueId}
...
필터링 로직은 경기 카드 (match card)와 분리되어 유지됩니다.
이러한 분리가 중요한 이유는 경기 컴포넌트 (match component)가 어떤 경기를 보여줄지 결정해서는 안 되기 때문입니다.
그 역할은 오직 하나의 경기를 정확하게 렌더링 (render)하는 것뿐입니다.
동적 라우트 (Dynamic Route)로서의 경기 상세 정보
각 경기는 다음으로 연결됩니다:
/matches/[id]
페이지는 동적 라우트 파라미터 (dynamic route parameter)를 읽어 상세 정보를 로드합니다.
type MatchPageProps = {
params: Promise<{
id: string
...
현재 페이지는 점수 스냅샷 (score snapshot)과 경기 요약 (match summary)을 표시합니다.
이 라우트는 향후 다음과 같은 항목들을 지원할 수 있도록 의도적으로 설계되었습니다:
- 사건 (incidents)
- 골 (goals)
- 카드 (cards)
- 교체 (substitutions)
- 라인업 (lineups)
- 통계 (statistics)
- 상대 전적 (head-to-head results)
모든 것을 한 페이지에 배치하는 대신, 명확한 라우트와 데이터 레이어 (data layer)로 시작하는 것이 가진 장점 중 하나입니다.
서버 렌더링 (Server Rendering)을 통한 페이지 단순화
애플리케이션은 서버 렌더링 데이터 페칭 (server-rendered data fetching)을 사용합니다.
이는 페이지가 렌더링되기 전에 데이터를 요청할 수 있음을 의미합니다.
export default async function LeaguesPage() {
const leagues = await getLeagues()
...
초기 클라이언트 사이드 (client-side) 로딩 효과가 없습니다.
자바스크립트 (JavaScript)가 첫 번째 결과를 가져올 때까지 기다리는 빈 페이지도 없습니다.
날짜 선택기와 같은 상호작용 요소들은 여전히 클라이언트 컴포넌트 (client components)일 수 있지만, 초기 축구 데이터는 서버에서 렌더링됩니다.
이를 통해 프로젝트는 깔끔하게 분리됩니다:
Server Components
data fetching
page composition
...
전체 데이터 흐름 (Data Flow)
전형적인 요청은 프로젝트를 통해 다음과 같이 이동합니다:
사용자가 홈 페이지를 엽니다
↓
Next.js 페이지가 getLiveFootballMatches()를 호출합니다
...
UI는 라이브 모드와 모의 (mock) 모드를 위해 별도의 코드를 가질 필요가 없습니다.
이것이 데이터가 컴포넌트에 도달하기 전에 정규화 (normalizing)하는 것의 주요 이점입니다.
프로젝트 실행하기
저장소를 클론(Clone)합니다:
git clone https://github.com/mihailove123/pitchpulse-livescore-ap.git
프로젝트 디렉토리로 이동합니다:
cd pitchpulse-livescore-ap
의존성(dependencies)을 설치합니다:
npm install
개발 서버(development server)를 실행합니다:
npm run dev
열기:
애플리케이션이 샘플 데이터와 함께 자동으로 실행됩니다.
실시간 축구 데이터를 활성화하려면, 다음 파일을 생성하세요:
.env.local
다음 내용을 추가합니다:
SPORTMICRO_API_KEY=your_sportmicro_api_key_here
그 다음 개발 서버를 재시작하세요.
사용 가능한 명령어 (Available Commands)
npm run dev
npm run build
npm run start
...
배포하기 전에 다음 명령어를 실행하는 것을 권장합니다:
npm run lint
npm run typecheck
npm run build
이를 통해 타입 오류(type errors)와 프로덕션 빌드(production build) 문제를 호스팅 플랫폼에 도달하기 전에 잡아낼 수 있습니다.
배포 (Deployment)
이 저장소는 Vercel 또는 다른 Next.js 호환 플랫폼에 배포할 수 있습니다.
기본적인 배포 흐름은 다음과 같습니다:
저장소를 GitHub에 푸시(Push)
↓
Vercel로 저장소 가져오기(Import)
...
실시간 인증 정보(credentials)를 사용할 수 없는 프리뷰 배포(preview deployments)의 경우, 모의(Mock) 모드도 유용할 수 있습니다.
다음에 구축할 것들
현재 저장소는 가능한 모든 축구 기능을 제공하기보다는 토대(foundation)를 제공합니다.
다음 추가 기능에는 다음과 같은 것들이 포함될 수 있습니다:
- 리그 순위 (league standings)
- 경기 사건 (match incidents)
- 팀 라인업 (team lineups)
- 선수 통계 (player statistics)
- 팀 페이지 (team pages)
- 리그별 경기 일정 페이지 (league-specific fixture pages)
- 즐겨찾는 팀 (favorite teams)
- 골 알림 (goal notifications)
- 실시간 투표 (live polling)
- 경기 검색 (match search)
- 사용자 시간대 (user time zones)
전송(transport) 계층과 UI 계층이 이미 분리되어 있기 때문에 기존 구조는 이러한 기능 추가를 더 쉽게 만들어 줍니다.
새로운 API 메서드는 다음 위치에 추가할 수 있습니다:
src/lib/sportmicro.ts
새로운 정규화된 타입(normalized types)은 다음 위치에 추가할 수 있습니다:
src/types/sportmicro.ts
컴포넌트들은 예측 가능한 애플리케이션 객체를 계속해서 전달받을 수 있습니다.
배운 점
이 프로젝트에서 얻은 가장 중요한 교훈은 축구 데이터에 관한 것이 아니었습니다.
그것은 바로 폴백 설계 (fallback design)에 관한 것이었습니다.
API 통합 (API integration)은 API 없이는 애플리케이션 전체를 실행할 수 없게 만들어서는 안 됩니다.
모의 데이터 (mock data)와 실시간 데이터 (live data)를 동일한 함수 뒤에 배치함으로써, 프로젝트는 다음과 같은 이점을 얻게 됩니다:
- 클론 (clone)하기 더 쉬움
- 테스트 (test)하기 더 쉬움
- 시연 (demonstrate)하기 더 쉬움
- 기여 (contribute)하기 더 쉬움
- 오프라인에서 개발 (develop offline)하기 더 쉬움
두 번째 교훈은 외부 데이터를 조기에 정규화 (normalize)하는 것이었습니다.
가공되지 않은 API 응답 (Raw API responses)은 네트워크 경계 (network boundary)에서는 유용합니다.
하지만 그것이 애플리케이션 전체에서 사용되는 언어가 되어서는 안 됩니다.
최종 아키텍처 (architecture)는 간단합니다:
Query builder
↓
Authenticated request
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기