PromleeBlog
sitemapaboutMe

posting thumbnail
React Query로 무한스크롤 - FlatList 무한 스크롤 (2)
Infinite Scroll with React Query - FlatList Infinite Scroll (2)

📅

🚀

들어가기 전에🔗

안녕하세요. Expo FlatList 무한 스크롤 구현 시리즈의 두 번째 파트입니다.
지난 파트 1에서는 axios와 React의 기본 훅(useState, useEffect, useCallback)을 사용하여 무한 스크롤을 구현하고, 초기 데이터 로딩 시 발생할 수 있는 연쇄 호출 문제를 useEffect 의존성 배열 수정으로 해결하는 과정을 살펴보았습니다.


이번 파트에서는 강력한 서버 상태 관리 라이브러리인
React Query
(현재는
TanStack Query
)를 사용하여 무한 스크롤을 구현하는 방법을 알아봅니다.
React Query의 useInfiniteQuery 훅을 사용하면 데이터 로딩, 캐싱, 페이지 관리, 로딩 및 에러 상태 처리 등 많은 부분을 간결하고 효율적으로 처리할 수 있습니다.
파트 1에서 직접 구현해야 했던 복잡한 로직들이 어떻게 단순화되는지 비교하며 살펴보는 것이 이번 파트의 핵심입니다.
👨‍💻
React Query (TanStack Query)에 대한 자세한 설명과 설치법은 아래 링크를 참고해주세요
React(TanStack) Query 사용법 기초 (React Native, Expo)

🚀

React Query로 무한 스크롤 구현하기 - useInfiniteQuery🔗

전체 코드는 여기 에 있습니다!
무한스크롤 구현 결과
무한스크롤 구현 결과
React Query는 무한 스크롤 구현을 위해 특별히 설계된 useInfiniteQuery 훅을 제공합니다. 이 훅 하나로 많은 것을 처리할 수 있습니다.

1. 데이터 가져오기 함수 작성🔗

먼저 API에서 데이터를 가져오는 비동기 함수를 정의합니다.
이 함수는 useInfiniteQuery에 의해 호출되며, 인자로 받는 객체 안의 pageParam 속성을 통해 현재 가져와야 할 페이지 번호를 전달받습니다.
특별히 시각적으로 로딩 상태를 확인하기 위해 setTimeout을 사용하여 1초 지연을 추가했습니다. 이 부분은 실제 API 호출에서는 필요하지 않습니다.
import axios from 'axios';
 
// 게시글 데이터 타입
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
 
// API 응답 예상 타입 (데이터 배열과 다음 페이지 정보 포함)
interface PostsResponse {
  data: Post[];
  nextPage?: number; // 다음 페이지 번호 (API가 제공하지 않으면 직접 계산)
}
 
// useInfiniteQuery가 사용할 데이터 로딩 함수
// pageParam의 타입은 useInfiniteQuery의 initialPageParam 타입과 일치해야 합니다.
const fetchPostsReactQuery = async ({ pageParam }: { pageParam: number }): Promise<PostsResponse> => {
  console.log(`Fetching page ${pageParam} with React Query (v5)...`);
 
  // --- 로딩 확인용 1초 지연 ---
  await new Promise(resolve => setTimeout(resolve, 1000));
  // --------------------------
 
  const response = await axios.get<Post[]>(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
  );
 
  // API 응답에 다음 페이지 정보가 없으므로, 받아온 데이터 유무로 판단
  const hasMoreData = response.data.length > 0;
 
  // useInfiniteQuery가 이해할 수 있는 형태로 데이터를 반환해야 함
  return {
    data: response.data, // 현재 페이지 데이터
    nextPage: hasMoreData ? pageParam + 1 : undefined, // 다음 페이지 번호, 없으면 undefined
  };
};
이 함수는 Promise를 반환하며, 반환 객체에는 실제 데이터와 다음 페이지를 위한 nextPage 정보가 포함됩니다.
nextPageundefined면 더 이상 로드할 페이지가 없음을 의미합니다.

2. useInfiniteQuery 훅 사용하기🔗

React Query v5부터 useInfiniteQuery는 단일 객체 형태의 인자만 받습니다. 이 객체 안에 쿼리 키, 데이터 로딩 함수, 초기 페이지 파라미터, 다음 페이지 파라미터 계산 함수 등을 설정합니다.
주석이 조금 많아 지저분할 수 있는데... 이해를 돕기 위해서입니다. 하나씩 이해하면서 지워보세요!
import React from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
import { useInfiniteQuery, QueryClient, QueryClientProvider, InfiniteData } from '@tanstack/react-query';
 
// ...
 
// 컴포넌트 내에서 useInfiniteQuery 사용
const PostsScreenWithReactQueryComponent: React.FC = () => {
  const {
    data,             // 타입: InfiniteData<PostsResponse, number> | undefined
    error,            // 에러 상태 객체
    fetchNextPage,    // 다음 페이지 데이터를 가져오는 함수
    hasNextPage,      // 다음 페이지 존재 여부 (boolean)
    isFetching,       // 모든 종류의 데이터 가져오기 진행 여부
    isFetchingNextPage, // '다음 페이지' 데이터만 가져오는 중인지 여부 (boolean)
    isLoading,        // '초기' 데이터 로딩 중인지 여부 (boolean)
  } = useInfiniteQuery({
    queryKey: ['posts'],                // 쿼리 키: 이 쿼리 데이터를 식별하는 고유 키
    queryFn: fetchPostsReactQuery,      // 위에서 정의한 데이터 가져오기 함수
    initialPageParam: 1,                // 초기 페이지 파라미터 (필수!)
    // 다음 페이지 파라미터(pageParam)를 결정하는 함수
    // lastPage는 가장 최근에 가져온 페이지의 데이터(PostsResponse 객체)입니다.
    getNextPageParam: (lastPage: PostsResponse) => lastPage.nextPage,
    // (선택적) 제네릭 타입 명시 (타입 추론이 잘 되므로 대부분 생략 가능)
    // <PostsResponse, Error, InfiniteData<PostsResponse, number>, string[], number>
  });
 
  // ... (FlatList 렌더링 로직) ...
};
 
// ... (스타일 정의 및 QueryClientProvider 래퍼) ...
useInfiniteQuery
훅은 다양한 상태와 함수를 반환합니다.
useInfiniteQuery는 initialPageParam을 사용하여 첫 페이지 데이터를 자동으로 가져옵니다. useEffect로 직접 첫 페이지 로딩을 트리거할 필요가 없습니다.

3. 자동 호출 방지🔗

파트 1에서 useEffect 의존성 문제로 발생했던 연쇄 호출 문제는 React Query에서는 거의 발생하지 않습니다.
fetchNextPage 함수는 명시적으로 호출해야 다음 데이터를 가져오며, React Query 내부의 정확한 상태 관리(
isFetchingNextPage
) 덕분에 로딩 중 중복 호출이 방지됩니다.
따라서 간단한 조건 확인만으로 안정적인 무한 스크롤이 가능합니다.

4. FlatList 컴포넌트 설정🔗

useInfiniteQuery에서 받은 값들을 사용하여 FlatList를 설정합니다.
  // 초기 로딩 상태 처리
  if (isLoading) {
    return (
      <SafeAreaView style={styles.container}>
        <View style={styles.centered}>
          <ActivityIndicator size="large" color="#007AFF" />
        </View>
      </SafeAreaView>
    );
  }
 
  // 에러 상태 처리
  if (error) {
    return (
      <SafeAreaView style={styles.container}>
        <View style={styles.centered}>
          <Text>오류가 발생했습니다: {error.message}</Text>
        </View>
      </SafeAreaView>
    );
  }
 
  // FlatList에 필요한 데이터 형태로 변환 (모든 페이지 데이터 합치기)
  // data 객체는 InfiniteData<PostsResponse, number> 타입이며, data.pages는 PostsResponse[] 타입입니다.
  const allPosts = data?.pages.flatMap(page => page.data) ?? [];
 
  // 아이템 렌더링 함수 (동일)
  const renderItem = ({ item }: { item: Post }) => (
    <View style={styles.itemContainer}>
      <Text style={styles.itemTitle}>{item.id}. {item.title}</Text>
      <Text style={styles.itemBody}>{item.body}</Text>
    </View>
  );
 
  // 리스트 하단 로딩 인디케이터
  const renderFooter = () => {
    return isFetchingNextPage ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
  };
 
  // 스크롤 끝 도달 시 호출될 함수
  const handleLoadMore = () => {
    console.log(`onEndReached triggered (RQ). hasNextPage=${hasNextPage}, isFetchingNextPage=${isFetchingNextPage}`);
    if (hasNextPage && !isFetchingNextPage) {
      console.log('handleLoadMore (RQ) is calling fetchNextPage...');
      fetchNextPage();
    } else {
       console.log('handleLoadMore (RQ) determined not to call fetchNextPage.');
    }
  };
 
  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={allPosts}
        renderItem={renderItem}
        keyExtractor={(item) => item.id.toString()}
        onEndReached={handleLoadMore}
        onEndReachedThreshold={0.5}
        ListFooterComponent={renderFooter}
        contentContainerStyle={styles.listContainer}
      />
    </SafeAreaView>
  );
 
// --- 스타일 정의 ---
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  listContainer: { paddingHorizontal: 15, paddingTop: 10, paddingBottom: 30 },
  itemContainer: { backgroundColor: '#f9f9f9', padding: 15, marginBottom: 12, borderRadius: 8, borderWidth: 1, borderColor: '#eee', shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
  itemTitle: { fontSize: 17, fontWeight: '600', marginBottom: 6, color: '#333' },
  itemBody: { fontSize: 14, color: '#666', lineHeight: 20 },
  loader: { marginVertical: 25, alignItems: 'center' },
});
 
// QueryClientProvider 래퍼 컴포넌트 (실제 앱 구조에 맞게 조정 필요)
const queryClient = new QueryClient(); // 실제로는 App.tsx에서 생성된 것을 사용
const PostsScreenWithReactQuery: React.FC = () => (
  <QueryClientProvider client={queryClient}>
    <PostsScreenWithReactQueryComponent />
  </QueryClientProvider>
);
 
export default PostsScreenWithReactQuery;

🚀

결론🔗

React Query 무한스크롤 구현 전체 예제 코드 보기
// PostsScreenWithReactQuery.tsx
 
import React from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
// InfiniteData 타입을 import합니다.
import { useInfiniteQuery, QueryClient, QueryClientProvider, InfiniteData } from '@tanstack/react-query';
import axios from 'axios';
 
// --- QueryClient 설정 (실제 앱에서는 최상위에서 한 번만 생성) ---
const queryClient = new QueryClient();
 
// --- 타입 정의 ---
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
 
interface PostsResponse {
  data: Post[];
  nextPage?: number;
}
 
// --- 데이터 로딩 함수 ---
const fetchPostsReactQuery = async ({ pageParam }: { pageParam: number }): Promise<PostsResponse> => {
  console.log(`Fetching page ${pageParam} with React Query (v5)...`);
 
  // --- 로딩 확인용 1초 지연 ---
  await new Promise(resolve => setTimeout(resolve, 1000));
  // --------------------------
 
  const response = await axios.get<Post[]>(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
  );
 
  const hasMoreData = response.data.length > 0;
 
  return {
    data: response.data,
    nextPage: hasMoreData ? pageParam + 1 : undefined,
  };
};
 
// --- 메인 컴포넌트 로직 ---
const PostsScreenWithReactQueryComponent: React.FC = () => {
  const {
    data, // 타입: InfiniteData<PostsResponse, number> | undefined
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading, // 초기 로딩 상태
  } = useInfiniteQuery({ // v5 스타일: 단일 객체 인자
    queryKey: ['posts'], // 쿼리 키
    queryFn: fetchPostsReactQuery, // 데이터 로딩 함수 (pageParam 타입을 number로 가정)
    initialPageParam: 1, // 초기 페이지 파라미터 (필수!)
    // 다음 페이지 파라미터 결정 함수
    getNextPageParam: (lastPage: PostsResponse) => lastPage.nextPage,
  });
 
  // 초기 로딩 상태 처리
  if (isLoading) {
    return (
      <SafeAreaView style={styles.container}>
        <View style={styles.centered}>
          <ActivityIndicator size="large" color="#007AFF" />
        </View>
      </SafeAreaView>
    );
  }
 
  // 에러 상태 처리
  if (error) {
    return (
      <SafeAreaView style={styles.container}>
        <View style={styles.centered}>
          <Text>오류가 발생했습니다: {error.message}</Text>
        </View>
      </SafeAreaView>
    );
  }
 
  // FlatList 데이터 준비 (모든 페이지 데이터 합치기)
  const allPosts = data?.pages.flatMap(page => page.data) ?? [];
 
  // 아이템 렌더링 함수
  const renderItem = ({ item }: { item: Post }) => (
    <View style={styles.itemContainer}>
      <Text style={styles.itemTitle}>{item.id}. {item.title}</Text>
      <Text style={styles.itemBody}>{item.body}</Text>
    </View>
  );
 
  // 리스트 하단 로딩 인디케이터
  const renderFooter = () => {
    return isFetchingNextPage ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
  };
 
  // 스크롤 끝 도달 시 호출될 함수
  const handleLoadMore = () => {
    console.log(`onEndReached triggered (RQ). hasNextPage=${hasNextPage}, isFetchingNextPage=${isFetchingNextPage}`);
    // 다음 페이지가 있고, 현재 로딩 중이 아닐 때만 다음 페이지 요청
    if (hasNextPage && !isFetchingNextPage) {
      console.log('handleLoadMore (RQ) is calling fetchNextPage...');
      fetchNextPage();
    } else {
       console.log('handleLoadMore (RQ) determined not to call fetchNextPage.');
    }
  };
 
  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={allPosts}
        renderItem={renderItem}
        keyExtractor={(item) => item.id.toString()}
        onEndReached={handleLoadMore}
        onEndReachedThreshold={0.5} // 리스트 끝에서 50% 지점 도달 시
        ListFooterComponent={renderFooter}
        contentContainerStyle={styles.listContainer}
      />
    </SafeAreaView>
  );
};
 
// --- 스타일 정의 ---
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  listContainer: { paddingHorizontal: 15, paddingTop: 10, paddingBottom: 30 },
  itemContainer: { backgroundColor: '#f9f9f9', padding: 15, marginBottom: 12, borderRadius: 8, borderWidth: 1, borderColor: '#eee', shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
  itemTitle: { fontSize: 17, fontWeight: '600', marginBottom: 6, color: '#333' },
  itemBody: { fontSize: 14, color: '#666', lineHeight: 20 },
  loader: { marginVertical: 25, alignItems: 'center' },
});
 
 
// --- 최종 Export 컴포넌트 (QueryClientProvider 포함) ---
const PostsScreenWithReactQuery: React.FC = () => (
  <QueryClientProvider client={queryClient}>
    <PostsScreenWithReactQueryComponent />
  </QueryClientProvider>
);
 
export default PostsScreenWithReactQuery;
이번 파트에서는 React Query(TanStack Query) v5의 useInfiniteQuery 훅을 사용하여 React Native, Expo 환경에서 무한 스크롤을 구현하는 방법을 알아보았습니다.
axios만을 사용한 직접 구현 방식과 비교했을 때, React Query는 서버 상태 관리 로직을 단순화하고, 코드의 간결성과 안정성을 크게 향상시키며, 캐싱과 같은 강력한 부가 기능을 통해 성능 및 사용자 경험을 개선하는 데 효과적입니다.
React Query의 초기 설정과 훅 사용법에 익숙해지면 복잡한 데이터 의존성을 가진 애플리케이션 개발이 훨씬 수월해질 것입니다.

참고 (파트 2)🔗