Web

TanStack Query 실습

sungjae0309 2026. 5. 9. 03:38

1. 개요

    • 내용: 이번 프로젝트의 목적과 주요 기능(인증, 데이터 조회, 반응형 UI)을 간단히 소개한다 


2. 기술 스택 및 초기 설정

  • 내용: Vite, React, TypeScript, TanStack Query, Axios를 사용
  • 코드: 8000번 포트의 백엔드 서버와 통신하기 위한 베이스 URL 설정
import axios from 'axios';

const axiosInstance = axios.create({
  baseURL: 'http://localhost:8000/v1', 
  headers: {
    'Content-Type': 'application/json',
  },
});


axiosInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default axiosInstance;

3. TanStack Query를 활용한 효율적인 데이터 관리

  • 내용: useQuery를 사용하여 서버 상태를 관리하고, 특히 정렬(최신순/오래된순) 기능을 처리
  • queryKey에 sortOrder를 넣어 정렬이 바뀔 때마다 자동으로 패칭되는 로직
import { useQuery } from '@tanstack/react-query';
import client from '../api/client'; // 

const fetchLPs = async (order: 'asc' | 'desc') => {
  const { data } = await client.get(`/lps?order=${order}`);
  return data.data.data; 
};


export const useLPs = (sortOrder: 'asc' | 'desc') => {
  return useQuery({
    queryKey: ['lps', sortOrder], 
    queryFn: () => fetchLPs(sortOrder),
    staleTime: 1000 * 60 * 5, 
  });
};


export const useLPDetail = (lpId: string) => {
  return useQuery({
    queryKey: ['lp', lpId],
    queryFn: async () => {
      const { data } = await client.get(`/lps/${lpId}`);
      return data.data;
    },
    enabled: !!lpId, 
  });
};

4. 사용자 인터페이스(UI) 및 인터랙션 구현

    • 내용: 목록 페이지의 격자 레이아웃과 카드의 호버 효과 구현
    • 마우스를 올렸을 때 사진이 확대되고 검은 오버레이 위에 정보(제목, 업로드일, 좋아요)가 뜬다 

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

const fetchLPs = async (order: 'asc' | 'desc') => {
  const { data } = await client.get(`/lps?order=${order}`);
  return data.data.data; 
};


export const useLPs = (sortOrder: 'asc' | 'desc') => {
  return useQuery({
    queryKey: ['lps', sortOrder], 
    queryFn: () => fetchLPs(sortOrder),
    staleTime: 1000 * 60 * 5, 
  });
};


export const useLPDetail = (lpId: string) => {
  return useQuery({
    queryKey: ['lp', lpId],
    queryFn: async () => {
      const { data } = await client.get(`/lps/${lpId}`);
      return data.data;
    },
    enabled: !!lpId, 
  });
};

5. 반응형 레이아웃 및 내비게이션

  • 내용: 창 크기에 따라 변하는 사이드바와 플로팅 버튼 라우팅을 다뤘다 

 

6. 상세 페이지 및 보안(보호 라우트)

  • 내용: 특정 LP의 상세 정보를 보여주고, 비로그인 사용자의 접근을 차단하는 로직
  • useParams로 ID를 받고 useEffect로 토큰 존재 여부를 확인해 비로그인 사용자를 튕겨내는 '보호 라우트' 사용

 

7. 무한 스크롤(Infinite Scroll) 구현

  • 내용: 대량의 데이터를 한 번에 가져오지 않고, 사용자의 스크롤에 맞춰 적절히 끊어 가져오는 기술
export const useInfiniteLPs = (sortOrder: 'asc' | 'desc') => {
  return useInfiniteQuery({
    queryKey: ['lps', 'infinite', sortOrder],
    queryFn: async ({ pageParam = 0 }) => {
      const { data } = await client.get(`/lps`, {
        params: { order: sortOrder, cursor: pageParam },
      });
      return data.data;
    },
    getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined,
    initialPageParam: 0,
  });
};
  • useInfiniteQuery를 사용해 getNextPageParam으로 서버의 cursor 값을 처리하는 로직은 무한 스크롤의 핵심 
const ListPage = () => {
  const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc');
  const { ref, inView } = useInView();
  const { 
    data, 
    isLoading, 
    isError, 
    error, 
    refetch, 
    fetchNextPage, 
    hasNextPage, 
    isFetchingNextPage 
  } = useInfiniteLPs(sortOrder);
  • react-intersection-observer를 사용하여 스크롤 하단 감지 시 fetchNextPage를 호출하는 트리거 로직

8. 지루함 없는 로딩: Shimmer 애니메이션 스켈레톤 UI

  • 내용: 단순한 로딩 스피너 대신, 실제 콘텐츠와 유사한 형태의 스켈레톤을 노출하여 심리적 대기 시간을 줄인 과정

  • opacity가 부드럽게 변하는 애니메이션 코드를 통해 디테일한 UI 구현 

9. 상세 페이지 고도화: 댓글 시스템

  • 내용: 단순 정보 조회를 넘어, 사용자와 소통하는 댓글창을 무한 스크롤로 구현하고 가독성을 높인 레이아웃 
  • 고양이 사진과 메타 정보가 상단에 배치되고, 그 아래로 댓글창과 댓글 목록이 자연스럽게 이어지는 개선된 가독성

const DetailPage = () => {
  const { lpid } = useParams();
  const navigate = useNavigate();
  const [commentOrder, setCommentOrder] = useState<'desc' | 'asc'>('desc');
  const [commentText, setCommentText] = useState('');
  
  const { data: lp, isLoading: isLPLoading } = useLPDetail(lpid as string);
  const { 
    data: commentData, 
    isLoading: isCommentLoading, 
    fetchNextPage, 
    hasNextPage, 
    isFetchingNextPage 
  } = useInfiniteComments(lpid as string, commentOrder);
  
  const { ref, inView } = useInView();
  const accessToken = localStorage.getItem('accessToken');

  useEffect(() => {
    if (!accessToken) {
      alert('로그인이 필요한 서비스입니다.');
      navigate('/login');
    }
  }, [accessToken, navigate]);

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLPLoading) return <div style={{ padding: '50px', textAlign: 'center' }}>데이터 로딩 중...</div>;