8. 개발자란 호들갑 떠는 존재(무한 스크롤 구현)
2025년 11월 10일
포트폴리오 사이트
트러블슈팅 : 블로그 글 무한 스크롤 구현
1. 문제 상황
블로그 포스트가 많아질수록 초기 로딩 시간이 길어지고 사용자 경험이 저하될 수 있습니다.
기존 방식의 문제:
- 모든 포스트를 한 번에 가져옴
- 초기 로딩 시간 증가
- 불필요한 데이터 전송
- 모바일에서 성능 저하
2. 무한 스크롤을 선택한 이유
- 성능 개선: 초기 로딩 시 필요한 데이터만 가져와 첫 화면 렌더링 속도 향상
- 사용자 경험 향상: 스크롤만으로 추가 콘텐츠 탐색 가능
- 네트워크 효율성: 필요한 만큼만 전송
- 모바일 친화적: 페이지네이션 버튼보다 스크롤이 더 직관적
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 } }
핵심 로직:
-
limit(pageSize + 1)- 요청한 개수보다 1개 더 가져와서 다음 페이지 존재 여부 확인
- 예: 12개 요청 → 13개 가져옴 → 13개면 다음 페이지 있음
-
startAfter(lastDoc)- 마지막 문서 이후부터 가져오기
- 커서 기반 페이지네이션 핵심 기술?
-
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 사용 이유:
- 의존성 배열 관리:
useEffect의 의존성 배열에 함수를 넣을 때, 함수가 매 렌더링마다 새로 생성되면useEffect가 불필요하게 재실행됨 - 메모이제이션:
useCallback으로 함수를 메모이제이션하여 의존성이 변경되지 않으면 같은 함수 참조 유지 - 무한 루프 방지:
useEffect의 의존성 배열에loadMore를 넣어야 하는데,useCallback없이는 매번 새로운 함수가 생성되어useEffect가 계속 재실행됨
첫 번째 로드 분기 처리:
문제:
- 서버에서 가져온
lastDoc을 클라이언트로 전달할 수 없음 (QueryDocumentSnapshot은 직렬화 불가) - 초기 데이터만 전달되고
lastDoc은null
해결:
- 첫 번째
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])
동작 원리:
IntersectionObserver생성: 관찰할 요소와 콜백 함수 설정threshold: 0.1: 요소가 10% 보이면 트리거observe(): 관찰 시작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)
인덱스 생성 방법:
- 코드 실행 시 Firebase에서 인덱스 필요 에러 발생
- 에러 메시지에 인덱스 생성 링크 포함
- 링크 클릭하여 자동으로 인덱스 생성
- 인덱스 생성 완료 후 정상 작동
인덱스가 필요한 이유:
where와orderBy를 함께 사용하는 복합 쿼리는 인덱스가 필요- 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. 마무리
- 성능 개선: 초기 로딩 시간 단축
- 사용자 경험 향상: 자연스러운 스크롤 탐색
- SEO 유지: 초기 데이터는 SSR로 렌더링
- 네트워크 효율성: 필요한 만큼만 데이터 전송
사실 각 프로젝트별로 그렇게 많은 데이터가 있는것도 아닌데 굳이 왜 무한스크롤을 구현하냐, 그냥 요즘 다 쓴다니까 억지로 쓰는거 아니냐 라고 하면 할말이 없습니다.
근데 자고로 개발자란, 호들갑 떠는 존재가 아니겠습니까. 지금은 게시글이 적지만 나중에는 100개 넘게 게시글이 존재할 수도 있는거고.. 누군가는 모바일에서 제 사이트를 볼수도 있는 거고..
이러한 상황을 혼자 상상하며 seo도 유지하며 성능도 개선하고자 노력했습니다.