PromleeBlog
sitemapaboutMe

posting thumbnail
Axios로 무한스크롤 - FlatList 무한 스크롤 (1)
Infinite Scroll with Axios - FlatList Infinite Scroll (1)

📅

🚀

들어가기 전에🔗

이 글은 기본적인 Expo 프로젝트가 생성되어 있다고 가정합니다. Expo 프로젝트를 생성하는 방법은 Expo 프로젝트 생성:윈도우, Mac을 참고해주세요.
이번 글에서는 모바일 앱에서 사용자 경험을 높이는 중요한 기능인
무한 스크롤
을 Expo 환경의 FlatList 컴포넌트를 사용해 구현하는 방법을 알아보겠습니다.
SNS 피드나 상품 목록처럼 아주 많은 데이터를 보여줘야 할 때, 한 번에 모든 데이터를 불러오면 앱이 느려지거나 멈출 수 있습니다.
무한 스크롤은 사용자가 화면을 아래로 스크롤할 때마다 필요한 만큼의 데이터만 조금씩 불러와 보여주는 똑똑한 방법입니다.


이번 파트 1에서는 무한 스크롤의 기본 원리를 살펴보고,
jsonplaceholder
라는 가짜 API 서버와
axios
라이브러리를 사용하여 직접 무한 스크롤을 구현하는 과정을 단계별로 알아보겠습니다.
다음 파트 2에서는 React Query 라이브러리를 활용하여 더 간결하고 효율적으로 무한 스크롤을 구현하는 방법을 알아볼 예정입니다.
무한 스크롤 구현 결과
무한 스크롤 구현 결과

🚀

axios로 무한 스크롤 구현하기🔗

전체 코드는 여기 에서 확인하실 수 있습니다.

1. 상태 변수 설정하기🔗

무한 스크롤을 구현하기 위해 필요한 상태 변수를 설정합니다. 여기서는 posts, page, loading, hasMore 네 가지 상태 변수를 사용합니다.
import React, { useState, useEffect, useCallback } from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
import axios from 'axios';
 
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
 
const PostsScreenWithAxios: React.FC = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [page, setPage] = useState<number>(1); // 현재 로드 '해야 할' 페이지
  const [loading, setLoading] = useState<boolean>(false);
  const [hasMore, setHasMore] = useState<boolean>(true);
 
  // ... (fetchPosts, useEffect 수정, renderItem, renderFooter, handleLoadMore 구현) ...
};
 
// ... (스타일 정의) ...

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

데이터를 page, loading, hasMore 상태의 변화에 따라 fetching 하는 함수를 생성합니다. 시각적으로 변화를 확인하기 위해 1초의 지연을 추가했습니다. 이 부분은 실제 API 호출 시에는 필요하지 않습니다.
  const fetchPosts = useCallback(async () => {
    // 로딩 중이거나 더 가져올 데이터가 없으면 실행하지 않음 (중복 방지)
    if (loading || !hasMore) {
        console.log(`fetchPosts skipped: loading=${loading}, hasMore=${hasMore}`);
        return;
    }
 
    setLoading(true);
    console.log(`Fetching page ${page}...`);
 
    try {
      // --- 로딩 확인용 1초 지연 ---
      await new Promise(resolve => setTimeout(resolve, 1000));
      // --------------------------
 
      const response = await axios.get<Post[]>(
        `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=5`
      );
 
      if (response.data.length > 0) {
        setPosts((prevPosts) => [...prevPosts, ...response.data]); // 기존 게시글에 새 게시글 추가
        setPage((prevPage) => prevPage + 1); // 다음 페이지 번호로 업데이트
      } else {
        setHasMore(false);
        console.log('No more posts to load.');
      }
    } catch (error) {
      console.error("데이터 로딩 중 오류 발생:", error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]); // page, loading, hasMore가 변경될 때 함수 재생성
➡️

useCallback을 사용하는 이유🔗

useCallback을 제외하고, useEffect에 의존성 배열로 page, loading, hasMore를 넣으면, 이 상태들이 변경될 때마다 fetchPosts 함수가 재생성됩니다.
이 경우, 부모 컴포넌트 상태들이 변경될 때마다 fetchPosts 함수를 재생성하게 되어, 무한 스크롤이 연쇄적으로 발생할 수 있습니다.
fetchPosts 작동 -> posts 상태 변경 -> PostsScreenWithAxios 컴포넌트 리렌더링 -> fetchPosts 호출 -> posts 상태 변경... 이런 식으로 무한 루프에 빠질 수 있습니다.

3. 초기 데이터 로딩 설정🔗

useEffect의 의존성 배열을 빈 배열 []로 설정하여 마운트 시 1회만 실행되도록 합니다.
  useEffect(() => {
    // 컴포넌트 마운트 시 첫 페이지 데이터 로딩 시작 (page가 1일 때)
    console.log('useEffect for initial fetch triggered.');
    fetchPosts();
  }, []);

4. FlatList 설정 및 onEndReached 핸들러🔗

FlatList를 만들어 기본적인 구성을 설정합니다.
onEndReached 속성에 fetchPosts 함수를 연결하여 스크롤이 리스트 끝에 도달했을 때 데이터를 추가로 로드하도록 합니다.
  // 아이템 렌더링 함수
  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 loading ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
  };
 
  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={posts}
        renderItem={renderItem}
        keyExtractor={(item) => item.id.toString()}
        onEndReached={fetchPosts} // 스크롤 기반 로딩 트리거
        onEndReachedThreshold={1} // 스크롤이 리스트 끝에 도달했을 때 호출되는 임계값 (0~1 사이) - 1이면 리스트 끝에 도달했을 때 호출됨
        ListFooterComponent={renderFooter}
        contentContainerStyle={styles.listContainer}
      />
    </SafeAreaView>
  );
 
// --- 스타일 정의 ---
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  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 default PostsScreenWithAxios;

🚀

결론🔗

Axios 구현 전체 예제 코드 보기
// PostsScreenWithAxios.tsx
 
import React, { useState, useEffect, useCallback } from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
import axios from 'axios';
 
// 게시글 데이터 타입 정의
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
 
const PostsScreenWithAxios: React.FC = () => {
  const [posts, setPosts] = useState<Post[]>([]); // 불러온 게시글 데이터 배열
  const [page, setPage] = useState<number>(1); // 현재 로드 '해야 할' 페이지 번호
  const [loading, setLoading] = useState<boolean>(false); // 데이터 로딩 중 여부
  const [hasMore, setHasMore] = useState<boolean>(true); // 더 불러올 데이터가 있는지 여부
 
  // 데이터 가져오기 함수 (useCallback 사용)
  const fetchPosts = useCallback(async () => {
    // 로딩 중이거나 더 가져올 데이터가 없으면 실행하지 않음
    if (loading || !hasMore) {
      console.log(`fetchPosts skipped: loading=${loading}, hasMore=${hasMore}`);
      return;
    }
 
    setLoading(true); // 로딩 시작
    console.log(`Fetching page ${page}...`);
 
    try {
      // --- 로딩 상태 확인을 위한 인위적 지연 추가 (1초) ---
      await new Promise(resolve => setTimeout(resolve, 1000));
      // -------------------------------------------------
 
      const response = await axios.get<Post[]>(
        `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
      );
 
      if (response.data.length > 0) {
        setPosts((prevPosts) => [...prevPosts, ...response.data]);
        // 다음 페이지 번호로 업데이트
        setPage((prevPage) => prevPage + 1);
      } else {
        setHasMore(false); // 더 이상 데이터 없음
        console.log('No more posts to load.');
      }
    } catch (error) {
      console.error("데이터 로딩 중 오류 발생:", error);
      // 에러 처리 로직
    } finally {
      setLoading(false); // 로딩 종료
    }
    // useCallback의 의존성 배열: 함수 내에서 직접 사용하는 상태/프롭스
  }, [page, loading, hasMore]);
 
  // *** 핵심 수정: 초기 데이터 로딩 ***
  useEffect(() => {
    // 컴포넌트 마운트 시 첫 페이지 데이터 로딩 시작 (page가 1일 때)
    console.log('useEffect for initial fetch triggered.');
    fetchPosts();
    // 의존성 배열을 빈 배열([])로 설정하여 마운트 시 오직 한 번만 실행
  }, []); // 빈 배열로 변경!
 
  // 아이템 렌더링 함수
  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 loading ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
  };
 
  // 스크롤이 리스트 끝에 도달하면 호출될 함수
  const handleLoadMore = () => {
    console.log(`onEndReached triggered. Current state: loading=${loading}, hasMore=${hasMore}, page=${page}`);
    // 로딩 중이 아니고, 더 가져올 페이지가 있을 때만 fetchPosts 호출
    if (!loading && hasMore) {
       console.log('handleLoadMore is calling fetchPosts...');
       fetchPosts();
    } else {
       console.log('handleLoadMore determined not to call fetchPosts.');
    }
  };
 
  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={posts}
        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' },
  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 default PostsScreenWithAxios;
이번 파트에서 우리는 Expo 환경에서 FlatList와 axios를 사용하여 무한 스크롤을 구현하는 방법을 알아보았습니다.
다음 파트에서는
React Query
가 이러한 상태 관리, 비동기 로직 처리, 그리고 무한 스크롤 구현을 얼마나 더 효율적으로 다룰 수 있는지 자세히 살펴보겠습니다.

참고🔗