PROJECT/[ 장기게임 ]

react query를 도입해보자

Lim임 2025. 12. 3. 17:24

React Query 도입 가이드

목차

  1. React Query가 필요한 이유
  2. 현재 코드의 문제점
  3. React Query 도입 시 개선점
  4. 설치 및 설정
  5. 적용 예시
  6. 마이그레이션 가이드

React Query가 필요한 이유

현재 프로젝트의 API 호출 패턴

프로젝트에서 사용 중인 API:

  1. 조회(GET): getBoard, getMoves, getGamesByPlayer
  2. 생성(POST): createGame, addMove, validateMove, getPossibleMoves
  3. 수정(PUT): endGame

React Query가 해결하는 문제

React Query는 서버 상태(Server State) 관리 라이브러리입니다.

서버 상태 vs 클라이언트 상태:

  • 클라이언트 상태: UI 상태, 폼 입력값 등 (예: activeModal, player1Name)
    • 현재 useState로 잘 관리 중 ✅
  • 서버 상태: 백엔드에서 가져온 데이터 (예: 게임 목록, 이동 기록)
    • 현재 수동으로 관리 중 ⚠️

서버 상태의 특징:

  1. 비동기적: API 호출은 시간이 걸림
  2. 공유됨: 여러 컴포넌트에서 같은 데이터 필요
  3. 변경 가능: 다른 사용자나 시스템이 데이터를 바꿀 수 있음
  4. 캐싱 필요: 같은 데이터를 반복 요청하면 비효율적

현재 코드의 문제점

예시: HistorySelectContent.tsx

export const HistorySelectContent = () => {
  const [playerName, setPlayerName] = useState('');
  const [games, setGames] = useState<Game[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSearch = async () => {
    const name = playerName.trim();
    if (!name) {
      setError('닉네임을 입력해주세요.');
      return;
    }
    setError(null);
    setLoading(true);
    try {
      const data = await getGamesByPlayer(name);
      setGames(data);
    } catch (e) {
      setError('게임 목록을 불러오는 중 오류가 발생했습니다.');
    } finally {
      setLoading(false);
    }
  };
  // ...
};

문제점 분석

1. 보일러플레이트 코드 과다

  • loading, error, data 상태를 매번 수동으로 관리
  • try-catch-finally 블록 반복
  • 모든 API 호출마다 동일한 패턴 반복

2. 캐싱 없음

  • 같은 플레이어 이름으로 다시 검색하면 또 API 호출
  • 이미 받은 데이터를 재사용하지 못함
  • 불필요한 네트워크 요청 → 느린 UX

3. 재시도 로직 없음

  • 네트워크 오류 시 사용자가 수동으로 다시 클릭해야 함
  • 일시적인 네트워크 문제도 영구적인 에러로 표시

4. 백그라운드 업데이트 없음

  • 데이터가 오래되어도 자동으로 새로고침하지 않음
  • 사용자가 탭을 전환했다 돌아와도 오래된 데이터 표시

5. 낙관적 업데이트(Optimistic Update) 불가

  • 게임 종료 시 UI가 즉시 반영되지 않음
  • API 응답을 기다려야만 UI 업데이트

React Query 도입 시 개선점

1. 코드 간소화

Before (현재):

const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSearch = async () => {
  setError(null);
  setLoading(true);
  try {
    const data = await getGamesByPlayer(name);
    setGames(data);
  } catch (e) {
    setError('오류 발생');
  } finally {
    setLoading(false);
  }
};

After (React Query):

const { data: games, isLoading, error, refetch } = useQuery({
  queryKey: ['games', playerName],
  queryFn: () => getGamesByPlayer(playerName),
  enabled: !!playerName.trim(), // 이름이 있을 때만 실행
});

줄어든 코드:

  • 15줄 → 5줄 (67% 감소)
  • 상태 관리 로직 제거
  • 에러 처리 자동화

2. 자동 캐싱

// 첫 번째 검색: "홍길동"
useQuery(['games', '홍길동'], () => getGamesByPlayer('홍길동'));
// → API 호출 ✅

// 두 번째 검색: "홍길동" (5초 후)
useQuery(['games', '홍길동'], () => getGamesByPlayer('홍길동'));
// → 캐시에서 즉시 반환 ⚡ (API 호출 X)

// 세 번째 검색: "이순신"
useQuery(['games', '이순신'], () => getGamesByPlayer('이순신'));
// → 새로운 키이므로 API 호출 ✅

장점:

  • 네트워크 요청 감소 → 빠른 UX
  • 서버 부하 감소
  • 오프라인 환경에서도 캐시된 데이터 표시 가능

3. 자동 재시도

useQuery({
  queryKey: ['games', playerName],
  queryFn: () => getGamesByPlayer(playerName),
  retry: 3, // 실패 시 3번 재시도
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  // 1초 → 2초 → 4초 간격으로 재시도 (지수 백오프)
});

장점:

  • 일시적인 네트워크 오류 자동 복구
  • 사용자가 수동으로 재시도할 필요 없음

4. 백그라운드 자동 갱신

useQuery({
  queryKey: ['games', playerName],
  queryFn: () => getGamesByPlayer(playerName),
  staleTime: 5 * 60 * 1000, // 5분간 신선한 데이터로 간주
  refetchOnWindowFocus: true, // 탭 전환 시 자동 갱신
  refetchInterval: 30000, // 30초마다 자동 갱신 (선택적)
});

시나리오:

  1. 사용자가 게임 목록 조회
  2. 다른 탭으로 이동
  3. 5분 후 다시 돌아옴
  4. React Query가 자동으로 최신 데이터 요청 ✅

5. 낙관적 업데이트

const mutation = useMutation({
  mutationFn: (gameId) => endGame(gameId, 'cho'),
  onMutate: async (gameId) => {
    // API 호출 전에 UI 즉시 업데이트
    await queryClient.cancelQueries(['games']);
    const previousGames = queryClient.getQueryData(['games']);

    queryClient.setQueryData(['games'], (old) =>
      old.map((game) =>
        game.id === gameId ? { ...game, winner: 'cho' } : game
      )
    );

    return { previousGames }; // 롤백용
  },
  onError: (err, gameId, context) => {
    // 실패 시 이전 상태로 롤백
    queryClient.setQueryData(['games'], context.previousGames);
  },
});

장점:

  • 즉각적인 UI 반응 (체감 속도 향상)
  • 실패 시 자동 롤백

설치 및 설정

1. 패키지 설치

npm install @tanstack/react-query
npm install @tanstack/react-query-devtools --save-dev

2. QueryClient 설정

파일 생성: src/lib/queryClient.ts

import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5분
      cacheTime: 10 * 60 * 1000, // 10분
      retry: 1, // 1번 재시도
      refetchOnWindowFocus: true,
    },
    mutations: {
      retry: 0, // mutation은 재시도 안 함
    },
  },
});

3. Provider 설정

파일 수정: src/App.tsx 또는 src/main.tsx

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/queryClient';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <GameProvider>
        {/* 기존 앱 컴포넌트 */}
      </GameProvider>

      {/* 개발 환경에서만 표시되는 디버깅 도구 */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

적용 예시

예시 1: 게임 목록 조회 (Query)

Before:

// HistorySelectContent.tsx
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSearch = async () => {
  setError(null);
  setLoading(true);
  try {
    const data = await getGamesByPlayer(playerName);
    setGames(data);
  } catch (e) {
    setError('오류 발생');
  } finally {
    setLoading(false);
  }
};

After:

// HistorySelectContent.tsx
import { useQuery } from '@tanstack/react-query';

const {
  data: games = [],
  isLoading,
  error,
  refetch,
} = useQuery({
  queryKey: ['games', 'player', playerName],
  queryFn: () => getGamesByPlayer(playerName),
  enabled: !!playerName.trim(), // 이름이 있을 때만 실행
  staleTime: 2 * 60 * 1000, // 2분간 캐시 유지
});

// 버튼 클릭 시
<Button onClick={() => refetch()}>
  {isLoading ? '불러오는 중...' : '게임 목록 불러오기'}
</Button>

예시 2: 이동 기록 조회 (Query with Params)

새 파일: src/hooks/queries/useGameMoves.ts

import { useQuery } from '@tanstack/react-query';
import { getMoves } from '../../api/gameApi';

export const useGameMoves = (gameId: number | null) => {
  return useQuery({
    queryKey: ['moves', gameId],
    queryFn: () => getMoves(gameId!),
    enabled: !!gameId, // gameId가 있을 때만 실행
    staleTime: 1 * 60 * 1000, // 1분
  });
};

// 사용 예시
const { data: moves, isLoading } = useGameMoves(gameId);

예시 3: 게임 생성 (Mutation)

새 파일: src/hooks/mutations/useCreateGame.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createGame } from '../../api/gameApi';

export const useCreateGame = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ player1, player2 }: { player1: string; player2: string }) =>
      createGame(player1, player2),
    onSuccess: (newGame) => {
      // 게임 목록 캐시 무효화 (자동 재조회)
      queryClient.invalidateQueries({ queryKey: ['games'] });

      // 또는 직접 캐시 업데이트
      queryClient.setQueryData(['games', 'player', newGame.player1], (old: any) =>
        old ? [newGame, ...old] : [newGame]
      );
    },
  });
};

// 사용 예시
const createGameMutation = useCreateGame();

const handleStart = () => {
  createGameMutation.mutate(
    { player1: 'Alice', player2: 'Bob' },
    {
      onSuccess: (game) => {
        console.log('게임 생성 성공:', game.id);
      },
      onError: (error) => {
        alert('게임 생성 실패');
      },
    }
  );
};

예시 4: 게임 종료 (Mutation with Optimistic Update)

새 파일: src/hooks/mutations/useEndGame.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { endGame } from '../../api/gameApi';

export const useEndGame = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ gameId, winner }: { gameId: number; winner: string }) =>
      endGame(gameId, winner),

    // 낙관적 업데이트
    onMutate: async ({ gameId, winner }) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries({ queryKey: ['games'] });

      // 이전 데이터 백업
      const previousGames = queryClient.getQueryData(['games']);

      // 즉시 UI 업데이트
      queryClient.setQueryData(['games'], (old: any) =>
        old?.map((game: any) =>
          game.id === gameId ? { ...game, winner, endedAt: new Date() } : game
        )
      );

      return { previousGames };
    },

    // 에러 시 롤백
    onError: (err, variables, context) => {
      queryClient.setQueryData(['games'], context?.previousGames);
    },

    // 성공 시 서버 데이터로 동기화
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['games'] });
    },
  });
};

마이그레이션 가이드

단계별 적용 전략

Phase 1: 설정 및 간단한 Query부터 시작

  1. React Query 설치 및 Provider 설정
  2. HistorySelectContent 리팩토링 (가장 명확한 사례)
  3. DevTools로 동작 확인

Phase 2: 나머지 Query 적용

  1. useGameMoves 훅 생성 (리플레이 기능)
  2. useBoard 훅 생성 (보드 상태 조회)

Phase 3: Mutation 적용

  1. useCreateGame 훅 생성
  2. useAddMove 훅 생성
  3. useEndGame 훅 생성 (낙관적 업데이트 포함)

Phase 4: 고급 기능

  1. Prefetching (게임 목록 미리 불러오기)
  2. Infinite Query (무한 스크롤)
  3. Polling (실시간 업데이트)

우선순위

높음 (즉시 적용 권장):

  • HistorySelectContent (게임 목록 조회)
  • useGameMoves (이동 기록 조회)

중간 (점진적 적용):

  • useCreateGame (게임 생성)
  • useEndGame (게임 종료)

낮음 (선택적):

  • validateMove, getPossibleMoves (빠른 응답 필요, 캐싱 불필요)

요약

React Query를 사용해야 하는 이유

  1. 코드 간소화: 보일러플레이트 67% 감소
  2. 자동 캐싱: 불필요한 API 호출 제거
  3. 자동 재시도: 네트워크 오류 자동 복구
  4. 백그라운드 갱신: 항상 최신 데이터 유지
  5. 낙관적 업데이트: 즉각적인 UI 반응

현재 프로젝트에 적합한 이유

  • ✅ 여러 API 호출 존재 (7개)
  • ✅ 수동 상태 관리 중 (loading, error, data)
  • ✅ 캐싱이 유익한 데이터 (게임 목록, 이동 기록)
  • ✅ 실시간성이 중요한 기능 (리플레이, 게임 진행)

결론: React Query 도입 강력 권장! 🚀

'PROJECT > [ 장기게임 ]' 카테고리의 다른 글

react query란?????  (1) 2025.12.03
state와 context  (0) 2025.12.03
App.tsx 줄이기  (0) 2025.11.27
내 기록  (0) 2025.11.26