프로젝트

React 강좌 15강 — 프로젝트②: GitHub 유저 탐색기 (REST API 연동)

🎯 학습 목표

  • 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"을 추가하면 스크린 리더 사용자 경험이 개선됩니다.