무한 스크롤 구현기

이영민·2025년 2월 27일
post-thumbnail

1. 문제점과 개선 동기

기존에는 사용자와 팔로우한 사람들의 모든 게시물을 한 번에 가져오도록 설계하였기 때문에, 데이터 양이 많아질수록 API 응답 시간이 길어지고 초기 로딩 속도가 크게 저하되었다. 이로 인해 사용자 경험이 크게 떨어지는 문제가 발생하였다. 이에 따라, 필요한 데이터만 점진적으로 로드하는 방식으로 전환할 필요가 있었다.


2. React QueryIntersection Observer 초기 세팅

무한 스크롤 기능을 구현하기 위해 React Query와 Intersection Observer를 사용하였다. 먼저, React Query를 프로젝트에 적용하기 위한 초기 세팅과 Intersection Observer 관련 의존성을 설치해야 한다.

2.1 React Query 초기 세팅

React Query를 사용하기 위해서는 먼저 관련 패키지를 설치한 후, QueryClientQueryClientProvider를 앱의 최상위 컴포넌트에 추가하여 전역으로 설정한다. 아래는 설치 명령어와 초기 설정 코드이다.

패키지 설치

npm install @tanstack/react-query

QueryClient 초기화

// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'

const queryClient = new QueryClient()

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

위와 같이 QueryClient를 생성하고, QueryClientProvider로 감싸면 React Query의 기능을 앱 전역에서 사용할 수 있게 된다. 나는 next.js의 앱라우터 방식을 사용해서 app디렉토리 아래에 provider.tsx를 생성했다.

2.2 Intersection Observer 관련 의존성 설치

Intersection Observer를 사용하기 위해 react-intersection-observer 패키지를 설치하였다. 이 패키지는 스크롤 감지를 간편하게 구현할 수 있도록 도와준다.

패키지 설치

npm install react-intersection-observer

위 명령어를 통해 필요한 의존성을 설치한 후, 아래와 같이 사용할 수 있다.

import { useInView } from 'react-intersection-observer'

function ExampleComponent() {
  const { ref, inView } = useInView({
    threshold: 0.1,
  })

  return (
    <div ref={ref}>
      {inView ? '화면에 노출됨' : '화면에서 벗어남'}
    </div>
  )
}

3. API 설계 (Next.js API Routes)

무한 스크롤을 지원하기 위해 서버에서는 페이지네이션을 적용한 API를 설계하였다. API는 다음과 같이 동작한다.

3.1 API 동작 방식

  1. 사용자의 팔로우 목록을 조회하여 피드에 표시할 사용자 리스트를 가져온다.
  2. 게시물 데이터를 페이지 단위로 가져온다. (예: limit = 5)
  3. 페이지네이션 적용: page 값을 기준으로 데이터를 나누고, hasNextPage를 설정하여 다음 페이지 존재 여부를 반환한다.

3.2 API 코드 (/api/posts/feed.ts)

export async function GET(request: Request) {
  const supabase = await createClient()
  console.log('API 호출 시작')

  const { searchParams } = new URL(request.url)
  const pageParam = searchParams.get('page')
  const page = pageParam ? parseInt(pageParam) : 1
  const limit = 5
  const offset = (page - 1) * limit

  try {
    const {
      data: { user },
      error: sessionError,
    } = await supabase.auth.getUser()

    if (sessionError) throw new Error(sessionError.message)
    if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

    const userId = user.id

    // ✅ 1. 사용자가 팔로우하는 유저들의 ID 가져오기
    const { data: followingData } = await supabase
      .from('follows')
      .select('following_id')
      .eq('follower_id', userId)

    const followingIds = [...followingData.map((follow) => follow.following_id), userId]

    // ✅ 2. 게시물 데이터 조회 (페이지네이션 적용)
    const { data: posts } = await supabase
      .from('posts')
      .select(`
        id, user_id, image_url, caption, created_at, updated_at
      `)
      .in('user_id', followingIds)
      .order('created_at', { ascending: false })
      .range(offset, offset + limit - 1)

    // ✅ 3. 페이지네이션 계산
    const hasNextPage = posts.length === limit
    const nextPage = hasNextPage ? page + 1 : undefined

    return NextResponse.json(
      { data: posts, currentPage: page, nextPage, hasNextPage },
      { status: 200 }
    )
  } catch (err) {
    console.error('Error:', err)
    return NextResponse.json({ error: String(err) }, { status: 500 })
  }
}

3.3 API 응답 예시

{
  "data": [
    {
      "id": 1,
      "user_id": "abc123",
      "image_url": "https://example.com/image.jpg",
      "caption": "첫 번째 게시물",
      "created_at": "2025-02-19"
    },
    {
      "id": 2,
      "user_id": "def456",
      "image_url": "https://example.com/image2.jpg",
      "caption": "두 번째 게시물",
      "created_at": "2025-02-18"
    }
  ],
  "currentPage": 1,
  "nextPage": 2,
  "hasNextPage": true
}

위 응답 구조는 nextPagehasNextPage를 통해 다음 페이지 여부를 클라이언트에 전달하여, 무한 스크롤 구현에 사용된다.


4. 클라이언트 구현 (React Query 기반 무한 스크롤)

클라이언트 측에서는 React Query의 useInfiniteQuery와 Intersection Observer를 활용하여 무한 스크롤 기능을 구현하였다.

4.1 API 요청 함수

페이지 단위로 데이터를 요청하는 함수를 구현하였다. 이 함수는 pageParam을 받아 해당 페이지의 데이터를 가져오며, 피드용 API와 특정 사용자 게시물 API를 조건에 따라 호출한다.

const fetchPost = async ({ pageParam = 1, type, id }: { pageParam?: number; type: string; id: string }): Promise<PostsResponse> => {
  const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
  const apiUrl = type === 'feed'
    ? `${baseUrl}/api/posts/feed?page=${pageParam}`
    : `${baseUrl}/api/profile/${id}/user-posts?page=${pageParam}`

  const response = await fetch(apiUrl, { method: 'GET' })
  if (!response.ok) throw new Error('게시글을 가져오는데 실패했다.')

  return response.json()
}

4.2 useInfiniteQuery 적용

React Query의 useInfiniteQuery를 활용하여 무한 스크롤 기능을 구현하였다. 이 훅은 초기 페이지 로딩 후, getNextPageParam을 통해 다음 페이지를 자동으로 요청할 수 있도록 구성되었다.

const useInfinitePosts = (type: string, id: string) => {
  return useInfiniteQuery({
    queryKey: ['posts', type, id],
    queryFn: ({ pageParam = 1 }) => fetchPost({ pageParam, type, id }),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  })
}

getNextPageParam 옵션은 lastPage.nextPage 값이 존재할 경우에만 다음 페이지를 요청하도록 하여, 불필요한 API 호출을 방지한다.

4.3 무한 스크롤 컴포넌트

마지막으로, Intersection Observer를 사용하여 스크롤 이벤트에 따라 다음 페이지 데이터를 불러오는 컴포넌트를 구현하였다.

import { useEffect } from 'react'
import { useInView } from 'react-intersection-observer'

function InfiniteSurveyList({ type, id }: InfiniteSurveyListProps) {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, error, isLoading } = useInfinitePosts(type, id || '')
  const { ref, inView } = useInView({ threshold: 0.1 })

  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage() // 스크롤 트리거 시 다음 페이지 불러오기
    }
  }, [inView, hasNextPage, fetchNextPage])

  if (isLoading) return <SurveyCardSkeleton />
  if (error) return <p>Error: {(error as Error).message}</p>

  return (
    <div>
      {data?.pages.flatMap((page) => page.data).map((post) => (
        <SurveyCard key={post.post_id} {...post} />
      ))}
      <div ref={ref} style={{ height: '1px', margin: '10px 0' }} />
      {isFetchingNextPage && <p>Loading more...</p>}
    </div>
  )
}

이 컴포넌트는 useInView를 통해 스크롤이 특정 영역에 도달하면 자동으로 fetchNextPage()를 호출하여 데이터를 추가로 불러오도록 구성되어 있다.


5. 결론

React Query의 useInfiniteQuery와 Next.js API Routes, 그리고 Intersection Observer를 활용하여 효율적인 무한 스크롤 기능을 구현하였다. 이번 구현을 통해 SNS 피드의 성능을 최적화하고, 사용자 경험을 개선하였다. 당연하게도 속도가 향상되었는데 로컬에서 테스트를 했을 때 기존의 구현 방식보다 훨씬 더 부드러웠고 빨랐다.

0개의 댓글