logo

DowanKim

8. 개발자란 호들갑 떠는 존재(무한 스크롤 구현)

2025년 11월 10일

포트폴리오 사이트

트러블슈팅 : 블로그 글 무한 스크롤 구현

1. 문제 상황

블로그 포스트가 많아질수록 초기 로딩 시간이 길어지고 사용자 경험이 저하될 수 있습니다.

기존 방식의 문제:

  • 모든 포스트를 한 번에 가져옴
  • 초기 로딩 시간 증가
  • 불필요한 데이터 전송
  • 모바일에서 성능 저하

2. 무한 스크롤을 선택한 이유

  1. 성능 개선: 초기 로딩 시 필요한 데이터만 가져와 첫 화면 렌더링 속도 향상
  2. 사용자 경험 향상: 스크롤만으로 추가 콘텐츠 탐색 가능
  3. 네트워크 효율성: 필요한 만큼만 전송
  4. 모바일 친화적: 페이지네이션 버튼보다 스크롤이 더 직관적

3. 구현 방식: 하이브리드 (SSR + 클라이언트 사이드)

SEO를 유지하면서 사용자 경험을 개선하기 위해 하이브리드 방식을 선택했습니다.

구조:

  • 초기 데이터: SSR로 가져와서 SEO 최적화 및 빠른 첫 화면 렌더링
  • 추가 데이터: 클라이언트에서 사용자 스크롤에 따라 동적 로드

이점:

  • SEO 유지: 초기 12개 포스트는 서버에서 렌더링
  • 빠른 첫 화면: 초기 데이터만 로드
  • 동적 로딩: 사용자가 스크롤할 때만 추가 데이터 로드

4. 기술

4.1 Firestore 페이지네이션

Firestore의 커서 기반 페이지네이션을 사용합니다:

  • limit(): 가져올 문서 개수 제한
  • startAfter(): 특정 문서 이후부터 가져오기
  • QueryDocumentSnapshot: 다음 페이지의 시작점으로 사용

4.2 Intersection Observer API

스크롤 감지를 위해 Intersection Observer를 사용합니다:

  • 성능: scroll 이벤트보다 효율적 (브라우저 최적화)
  • 정확성: 요소가 뷰포트에 들어오는 시점을 정확히 감지
  • 간단함: 스크롤 위치 계산 불필요
  • 모바일 친화적: 모바일 브라우저에서도 잘 작동

5. 구현 과정

5.1 Firestore 페이지네이션 함수 구현

lib/blog.ts에 페이지네이션 함수를 추가했습니다:

export const getPostsByTagPaginated = async ( tag: string, pageSize: number = 12, lastDoc?: QueryDocumentSnapshot ): Promise<{ posts: BlogPost[], lastDoc: QueryDocumentSnapshot | null, hasMore: boolean }> => { let q = query( collection(db, POSTS_COLLECTION), where('tags', 'array-contains', tag), where('published', '==', true), orderBy('createdAt', 'desc'), limit(pageSize + 1) // 한 개 더 가져와서 hasMore 확인 ) if (lastDoc) { q = query( collection(db, POSTS_COLLECTION), where('tags', 'array-contains', tag), where('published', '==', true), orderBy('createdAt', 'desc'), startAfter(lastDoc), limit(pageSize + 1) ) } const snapshot = await getDocs(q) const docs = snapshot.docs const hasMore = docs.length > pageSize const postsToReturn = hasMore ? docs.slice(0, pageSize) : docs const posts = postsToReturn.map(doc => ({ id: doc.id, ...doc.data(), createdAt: doc.data().createdAt.toDate(), updatedAt: doc.data().updatedAt.toDate(), })) as BlogPost[] const lastDocument = postsToReturn.length > 0 ? docs[postsToReturn.length - 1] : null return { posts, lastDoc: lastDocument, hasMore } }

핵심 로직:

  1. limit(pageSize + 1)

    • 요청한 개수보다 1개 더 가져와서 다음 페이지 존재 여부 확인
    • 예: 12개 요청 → 13개 가져옴 → 13개면 다음 페이지 있음
  2. startAfter(lastDoc)

    • 마지막 문서 이후부터 가져오기
    • 커서 기반 페이지네이션 핵심 기술?
  3. hasMore 계산

    • 가져온 문서가 pageSize + 1개면 다음 페이지 있음
    • 실제 반환은 pageSize개만 (마지막 1개는 제외)

5.2 서버 컴포넌트에서 초기 데이터 가져오기

app/blog/[tag]/page.tsx에서 초기 데이터만 서버에서 가져옵니다:

export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) const project = await getProjectByTag(decodedTag) // 초기 12개만 서버에서 가져오기 const initialResult = await getPostsByTagPaginated(decodedTag, 12) const initialPosts = initialResult.posts if (initialPosts.length === 0) { notFound() } return ( <> <Header /> <section className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto"> <h1 className="text-5xl text-white mb-8 text-center">{decodedTag}</h1> {/* 프로젝트 설명 */} {project && project.description && ( <div className="bg-[#1b1e26] p-4 md:p-8 rounded-2xl border border-[rgba(3,232,249,0.2)] mb-12"> {/* ... */} </div> )} {/* 클라이언트 컴포넌트로 초기 데이터 전달 */} <PostList initialPosts={initialPosts} tag={decodedTag} /> </div> </section> </> ) }

장점:

  • 초기 12개는 서버에서 렌더링되어 SEO에 유리
  • 빠른 첫 화면 렌더링
  • 나머지는 클라이언트에서 동적으로 로드

5.3 클라이언트 컴포넌트에서 무한 스크롤 구현

PostList.tsx에서 무한 스크롤을 구현했습니다.

상태 관리

const [posts, setPosts] = useState<BlogPost[]>(initialPosts) // 초기 데이터로 시작 const [loading, setLoading] = useState(false) // 로딩 상태 const [hasMore, setHasMore] = useState(initialPosts.length === 12) // 초기 데이터가 12개면 다음 페이지 가능 const [lastDoc, setLastDoc] = useState<QueryDocumentSnapshot | null>(null) // 마지막 문서 스냅샷 const [isFirstLoad, setIsFirstLoad] = useState(true) // 첫 번째 로드인지 확인 const observerRef = useRef<HTMLDivElement>(null) // Intersection Observer 타겟

상태 설명:

  • posts: 현재 표시할 포스트 목록
  • loading: 로딩 중 여부
  • hasMore: 다음 페이지 존재 여부
  • lastDoc: 다음 페이지의 시작점
  • isFirstLoad: 첫 번째 로드 여부 (중복 방지용)
  • observerRef: Intersection Observer가 관찰할 요소

useCallback을 사용한 loadMore 함수

const loadMore = useCallback(async () => { if (loading || !hasMore) return setLoading(true) try { let result // 첫 번째 로드이고 초기 데이터가 12개인 경우 if (isFirstLoad && initialPosts.length === 12) { // 초기 데이터를 다시 가져와서 lastDoc을 얻음 (중복 방지) const firstPageResult = await getPostsByTagPaginated(tag, 12) if (firstPageResult.lastDoc) { // 두 번째 페이지 가져오기 result = await getPostsByTagPaginated(tag, 12, firstPageResult.lastDoc) } else { result = { posts: [], lastDoc: null, hasMore: false } } setIsFirstLoad(false) } else { // 일반적인 경우 result = await getPostsByTagPaginated(tag, 12, lastDoc || undefined) } if (result.posts.length > 0) { setPosts(prev => [...prev, ...result.posts]) setLastDoc(result.lastDoc) setHasMore(result.hasMore) } else { setHasMore(false) } } catch (error) { console.error('포스트 로드 실패:', error) setHasMore(false) } finally { setLoading(false) } }, [tag, lastDoc, loading, hasMore, isFirstLoad, initialPosts.length])

useCallback 사용 이유:

  1. 의존성 배열 관리: useEffect의 의존성 배열에 함수를 넣을 때, 함수가 매 렌더링마다 새로 생성되면 useEffect가 불필요하게 재실행됨
  2. 메모이제이션: useCallback으로 함수를 메모이제이션하여 의존성이 변경되지 않으면 같은 함수 참조 유지
  3. 무한 루프 방지: useEffect의 의존성 배열에 loadMore를 넣어야 하는데, useCallback 없이는 매번 새로운 함수가 생성되어 useEffect가 계속 재실행됨

첫 번째 로드 분기 처리:

문제:

  • 서버에서 가져온 lastDoc을 클라이언트로 전달할 수 없음 (QueryDocumentSnapshot은 직렬화 불가)
  • 초기 데이터만 전달되고 lastDocnull

해결:

  • 첫 번째 loadMore 호출 시 초기 데이터를 다시 가져와서 lastDoc 획득
  • lastDoc을 사용해서 두 번째 페이지 가져오기

동작 흐름:

1. 서버에서 초기 데이터 가져오기
   → posts: [1~12번], lastDoc: 12번 (하지만 전달 불가)

2. 클라이언트로 전달
   → initialPosts: [1~12번]만 전달
   → lastDoc: null (전달 불가)

3. 첫 번째 loadMore 호출
   → lastDoc이 null이므로 초기 데이터를 다시 가져와서 lastDoc 획득
   → 그 lastDoc을 사용해서 두 번째 페이지(13~24번) 가져오기
   → lastDoc = 24번 저장

4. 두 번째 loadMore 호출
   → lastDoc = 24번이 있으므로 바로 사용
   → 세 번째 페이지(25~36번) 가져오기
   → lastDoc = 36번 저장

5. 세 번째 loadMore 호출
   → lastDoc = 36번이 있으므로 바로 사용
   → 네 번째 페이지(37~48번) 가져오기
   ...

Intersection Observer로 스크롤 감지

useEffect(() => { const currentObserver = observerRef.current if (!currentObserver || !hasMore || loading) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { loadMore() // 하단 요소가 보이면 다음 페이지 로드 } }, { threshold: 0.1 } // 10% 보이면 트리거 ) observer.observe(currentObserver) return () => { observer.disconnect() // cleanup } }, [hasMore, loading, loadMore])

동작 원리:

  1. IntersectionObserver 생성: 관찰할 요소와 콜백 함수 설정
  2. threshold: 0.1: 요소가 10% 보이면 트리거
  3. observe(): 관찰 시작
  4. disconnect(): cleanup 시 관찰 중지

Cleanup 함수 주의사항:

  • observerRef.current는 cleanup 시점에 변경될 수 있음
  • 따라서 useEffect 내부에서 currentObserver 변수에 저장하여 사용
  • cleanup 함수에서는 저장된 변수를 사용

UI 요소

{/* 스크롤 감지용 요소 */} {hasMore && ( <div ref={observerRef} className="h-25 flex items-center justify-center mt-8"> {loading && <p className="text-[#03e8f9] text-base text-center">로딩 중...</p>} </div> )} {!hasMore && posts.length > 0 && ( <p className="text-white/60 text-base text-center mt-12 p-8">더 이상 포스트가 없습니다.</p> )}

별도 요소를 사용하는 이유:

  • 포스트 리스트의 마지막 요소를 직접 관찰하면 스크롤이 끝나기 전에 트리거될 수 있음
  • 별도의 감지 요소를 사용하면 정확한 시점에 다음 페이지를 로드할 수 있음

6. Firebase 인덱스 설정

복합 쿼리를 사용하므로 Firestore 인덱스가 필요합니다.

필요한 인덱스:

  • Collection: posts
  • Fields:
    • tags (Array)
    • published (Ascending)
    • createdAt (Descending)

인덱스 생성 방법:

  1. 코드 실행 시 Firebase에서 인덱스 필요 에러 발생
  2. 에러 메시지에 인덱스 생성 링크 포함
  3. 링크 클릭하여 자동으로 인덱스 생성
  4. 인덱스 생성 완료 후 정상 작동

인덱스가 필요한 이유:

  • whereorderBy를 함께 사용하는 복합 쿼리는 인덱스가 필요
  • Firestore는 쿼리 성능을 위해 인덱스를 사용

7. 전체 동작 흐름

1. 사용자가 페이지 접근
   ↓
2. 서버에서 초기 12개 포스트 가져오기 (SSR)
   ↓
3. 초기 데이터를 HTML에 포함하여 렌더링
   ↓
4. 클라이언트에서 하이드레이션 완료
   ↓
5. 사용자가 스크롤
   ↓
6. Intersection Observer가 감지 요소를 감지
   ↓
7. loadMore 함수 호출
   ↓
8. Firestore에서 다음 12개 포스트 가져오기
   ↓
9. 기존 포스트 목록에 추가
   ↓
10. 사용자가 계속 스크롤
    ↓
11. 6~9번 반복
    ↓
12. 더 이상 포스트가 없으면 "더 이상 포스트가 없습니다" 메시지 표시

8. 마무리

  1. 성능 개선: 초기 로딩 시간 단축
  2. 사용자 경험 향상: 자연스러운 스크롤 탐색
  3. SEO 유지: 초기 데이터는 SSR로 렌더링
  4. 네트워크 효율성: 필요한 만큼만 데이터 전송

사실 각 프로젝트별로 그렇게 많은 데이터가 있는것도 아닌데 굳이 왜 무한스크롤을 구현하냐, 그냥 요즘 다 쓴다니까 억지로 쓰는거 아니냐 라고 하면 할말이 없습니다.

근데 자고로 개발자란, 호들갑 떠는 존재가 아니겠습니까. 지금은 게시글이 적지만 나중에는 100개 넘게 게시글이 존재할 수도 있는거고.. 누군가는 모바일에서 제 사이트를 볼수도 있는 거고..

이러한 상황을 혼자 상상하며 seo도 유지하며 성능도 개선하고자 노력했습니다.