🎯 학습 목표
- GitHub REST API 인증 헤더로 Rate Limit을 높이는 방법을 안다
- useDebounce 커스텀 훅을 직접 구현하고 검색에 적용한다
- 페이지네이션(이전/다음 버튼)을 state로 구현한다
- API Rate Limit 응답을 처리하는 방법을 이해한다
- Error Boundary 컴포넌트로 렌더링 에러를 처리한다
📖 핵심 개념 1 — GitHub API와 인증
GitHub REST API는 인증 없이 시간당 60회, Personal Access Token으로 인증 시 시간당 5,000회 요청이 가능합니다.
// .env 파일 (프로젝트 루트, .gitignore에 추가 필수!)
// VITE_GITHUB_TOKEN=ghp_your_token_here
// GitHub Personal Access Token 발급:
// GitHub → Settings → Developer settings → Personal access tokens → Generate new token
// 필요 권한: public_repo (공개 저장소 검색만 필요 시)
// src/api/github.js
const BASE_URL = 'https://api.github.com';
// 인증 헤더 설정
const headers = {
Accept: 'application/vnd.github.v3+json',
...(import.meta.env.VITE_GITHUB_TOKEN
? { Authorization: `Bearer ${import.meta.env.VITE_GITHUB_TOKEN}` }
: {}),
};
export async function searchRepositories({ query, page = 1, perPage = 10 }) {
if (!query.trim()) return { items: [], total_count: 0 };
const params = new URLSearchParams({
q: query,
sort: 'stars',
order: 'desc',
page: String(page),
per_page: String(perPage),
});
const response = await fetch(`${BASE_URL}/search/repositories?${params}`, { headers });
// Rate Limit 응답 처리
const remaining = response.headers.get('X-RateLimit-Remaining');
const resetTime = response.headers.get('X-RateLimit-Reset');
if (response.status === 403 && remaining === '0') {
const resetDate = new Date(Number(resetTime) * 1000);
throw new Error(`API Rate Limit 초과. ${resetDate.toLocaleTimeString()}에 초기화됩니다.`);
}
if (!response.ok) {
throw new Error(`GitHub API 오류: ${response.status} ${response.statusText}`);
}
return response.json();
}
export async function getRepository(owner, repo) {
const response = await fetch(`${BASE_URL}/repos/${owner}/${repo}`, { headers });
if (!response.ok) throw new Error(`저장소를 찾을 수 없습니다: ${owner}/${repo}`);
return response.json();
}
📖 핵심 개념 2 — useDebounce 커스텀 훅
검색어 입력마다 API를 호출하면 불필요한 요청이 많아집니다. Debounce는 연속 입력 시 마지막 입력 후 일정 시간이 지나야 실행되도록 하는 기법입니다.
// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// delay 후에 값을 업데이트하는 타이머 설정
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// value나 delay가 변경되면 이전 타이머 취소
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 동작 방식:
// 사용자 입력: "r" → "re" → "rea" → "reac" → "react"
// 각 키 입력마다 500ms 타이머 리셋
// 마지막 "react" 입력 후 500ms가 지난 뒤에만 API 호출
// 빠른 타이핑 시 "react" 하나의 요청만 전송됨
📖 핵심 개념 3 — 페이지네이션 구현
// src/hooks/useGithubSearch.js
import { useState, useEffect } from 'react';
import { searchRepositories } from '../api/github';
import { useDebounce } from './useDebounce';
const PER_PAGE = 10;
export function useGithubSearch() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [results, setResults] = useState({ items: [], total_count: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 검색어 변경 시 페이지 초기화
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
setPage(1); // 새 검색어 입력 시 1페이지로
}, [debouncedQuery]);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults({ items: [], total_count: 0 });
return;
}
const controller = new AbortController();
async function doSearch() {
setLoading(true);
setError(null);
try {
const data = await searchRepositories({
query: debouncedQuery,
page,
perPage: PER_PAGE,
});
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
doSearch();
return () => controller.abort();
}, [debouncedQuery, page]);
const totalPages = Math.ceil(results.total_count / PER_PAGE);
return {
query,
setQuery,
page,
setPage,
results,
loading,
error,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
📖 핵심 개념 4 — Error Boundary
Error Boundary는 자식 컴포넌트의 렌더링 에러를 잡아서 전체 앱이 하얀 화면으로 죽는 것을 방지합니다. 현재 클래스 컴포넌트로만 구현 가능합니다.
// src/components/ErrorBoundary.jsx
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
// 에러 발생 시 state 업데이트 (렌더링 전 호출됨)
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// 에러 로깅 (Sentry 등 모니터링 서비스에 보낼 수 있음)
componentDidCatch(error, errorInfo) {
console.error('렌더링 에러:', error, errorInfo);
// Sentry.captureException(error, { extra: errorInfo });
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
// fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h2>⚠️ 오류가 발생했습니다</h2>
<p style={{ color: '#666' }}>{this.state.error?.message}</p>
<button onClick={this.handleReset}>다시 시도</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
💻 코드 예제 — GitHub 탐색기 전체 구현
// src/App.jsx
import { useGithubSearch } from './hooks/useGithubSearch';
import ErrorBoundary from './components/ErrorBoundary';
function RepoCard({ repo }) {
return (
<div style={{ border: '1px solid #e1e4e8', borderRadius: '6px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<a href={repo.html_url} target="_blank" rel="noopener noreferrer"
style={{ fontSize: '18px', fontWeight: 'bold', color: '#0366d6' }}>
{repo.full_name}
</a>
<span style={{ color: '#f1c40f' }}>⭐ {repo.stargazers_count.toLocaleString()}</span>
</div>
{repo.description && (
<p style={{ color: '#586069', margin: '8px 0' }}>{repo.description}</p>
)}
<div style={{ display: 'flex', gap: '16px', color: '#586069', fontSize: '14px' }}>
{repo.language && <span>🔵 {repo.language}</span>}
<span>🍴 {repo.forks_count.toLocaleString()}</span>
<span>👀 {repo.watchers_count.toLocaleString()}</span>
</div>
</div>
);
}
function SearchResults({ results, loading, error, page, setPage, totalPages, hasNextPage, hasPrevPage }) {
if (loading) return <p style={{ textAlign: 'center', padding: '20px' }}>검색 중...</p>;
if (error) return <p style={{ color: 'red', padding: '20px' }}>⚠️ {error}</p>;
if (!results.items.length) return null;
return (
<div>
<p style={{ color: '#586069' }}>
총 {results.total_count.toLocaleString()}개 결과
</p>
{results.items.map(repo => (
<RepoCard key={repo.id} repo={repo} />
))}
{/* 페이지네이션 */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
<button
onClick={() => setPage(p => p - 1)}
disabled={!hasPrevPage}
>
← 이전
</button>
<span style={{ padding: '8px 16px' }}>{page} / {totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!hasNextPage}
>
다음 →
</button>
</div>
</div>
);
}
function GitHubExplorer() {
const search = useGithubSearch();
return (
<div style={{ maxWidth: '800px', margin: '40px auto', padding: '0 20px' }}>
<h1>🔍 GitHub 저장소 탐색기</h1>
<input
value={search.query}
onChange={(e) => search.setQuery(e.target.value)}
placeholder="저장소 이름이나 키워드 검색..."
style={{ width: '100%', padding: '12px', fontSize: '16px', boxSizing: 'border-box' }}
/>
{search.query && !search.loading && !search.results.items.length && !search.error && (
<p style={{ textAlign: 'center', color: '#999', padding: '40px' }}>
"{search.query}" 검색 결과가 없습니다.
</p>
)}
<ErrorBoundary>
<SearchResults {...search} />
</ErrorBoundary>
</div>
);
}
export default GitHubExplorer;
📖 핵심 개념 5 — Vercel 배포
// 1. GitHub에 프로젝트 push
// git init
// git add .
// git commit -m "initial commit"
// git remote add origin https://github.com/username/github-explorer.git
// git push -u origin main
// 2. vercel.com 접속 → GitHub 연결 → 저장소 선택 → Import
// 3. 환경 변수 설정 (Vercel 대시보드 → Settings → Environment Variables)
// VITE_GITHUB_TOKEN = ghp_your_token
// 4. 자동 배포
// main 브랜치에 push할 때마다 자동으로 배포됨
// vercel.json (SPA 라우팅을 위한 설정)
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
⚠️ 흔한 실수 (よくあるミス)
- API 토큰을 코드에 직접 하드코딩: 절대 금지입니다.
.env파일을 사용하고.gitignore에 반드시 추가하세요. GitHub는 토큰이 포함된 커밋을 감지하면 자동으로 토큰을 무효화합니다. - Debounce 없이 onChange에서 바로 API 호출: 타이핑마다 API를 호출하면 Rate Limit이 금방 소진되고 불필요한 요청이 대량 발생합니다.
- 페이지 변경 시 스크롤 처리 누락: 페이지 이동 시 자동으로 페이지 상단으로 스크롤되도록
window.scrollTo(0, 0)을 추가하면 UX가 개선됩니다. - Rate Limit 에러를 일반 에러와 동일 처리:
X-RateLimit-Remaining헤더를 확인해 Rate Limit 에러는 남은 시간을 보여주는 별도 메시지로 안내하세요.
💡 실무 팁
- React Query로 전환: 현재 구현을 TanStack Query로 전환하면 캐싱(같은 쿼리 재요청 방지), 백그라운드 갱신, 에러 재시도가 자동으로 처리됩니다. 검색 결과를 queryKey로 캐싱하면 뒤로가기 시 이전 결과가 즉시 표시됩니다.
- URL에 검색 상태 동기화:
useSearchParams를 활용해 검색어와 페이지를 URL 쿼리 파라미터(?q=react&page=2)로 관리하면 링크 공유와 뒤로가기가 자연스럽게 동작합니다. - 무한 스크롤 구현: 페이지네이션 대신 Intersection Observer API와 TanStack Query의
useInfiniteQuery를 조합하면 인스타그램 같은 무한 스크롤을 구현할 수 있습니다. - 접근성(a11y) 체크: 검색 input에
aria-label, 로딩 중에aria-busy="true", 결과 영역에role="region"을 추가하면 스크린 리더 사용자 경험이 개선됩니다.