Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 실전형 무한 스크롤 구현하기

Claude Code, React, Intersection Observer, cursor API로 무한 스크롤을 구현하고 접근성, SEO, 실패 처리를 점검합니다.

Claude Code로 실전형 무한 스크롤 구현하기

무한 스크롤은 사용자가 목록 하단에 가까워질 때 다음 데이터를 자동으로 불러오는 UI 패턴입니다. 소셜 피드, 글 목록, 상품 검색, 알림 센터, 관리자 로그 화면에서는 자연스럽게 느껴지지만, 실제 서비스에서는 단순히 “마지막 카드가 보이면 fetch”로 끝나지 않습니다.

실패 지점은 대개 비슷합니다. 같은 페이지를 두 번 불러오거나, 오래된 응답이 최신 상태를 덮어쓰거나, 상세 페이지에서 돌아왔을 때 위치가 사라지거나, 검색엔진과 스크린 리더가 상태를 이해하지 못합니다. API가 offset 기반이면 새 데이터가 추가되는 순간 중복과 누락도 생깁니다.

그래서 Claude Code에 “무한 스크롤 만들어줘”라고만 요청하면 데모는 빠르게 나오지만 운영 품질은 부족할 수 있습니다. 이 글에서는 React hook, 목록 컴포넌트, Next.js cursor API, 세 가지 이상의 사용 사례, 구체적인 함정, 공식 링크, 검증 메모까지 한 번에 정리합니다. 많은 DOM을 줄이는 문제는 가상 스크롤 구현을, 명시적인 페이지 번호가 필요한 경우는 페이지네이션 구현을 함께 보세요.

설계부터 고정하기

Intersection Observer는 특정 요소가 viewport 또는 지정한 컨테이너와 교차하는지 브라우저가 알려주는 API입니다. 쉽게 말하면, 목록 끝에 둔 “감시 요소”가 화면 근처에 왔는지 알려줍니다. 매 스크롤마다 계산하는 방식보다 가볍고, 동작 기준은 MDN Intersection Observer API를 기준으로 확인할 수 있습니다.

목록 끝의 작은 요소는 sentinel이라고 부르기도 합니다. 여기서는 “감시 요소”라고 생각하면 충분합니다. 이 요소가 보이면 다음 페이지를 불러옵니다. rootMargin을 주면 사용자가 바닥을 보기 전에 미리 요청할 수 있습니다.

두 번째 결정은 페이지 방식입니다. offset pagination은 “40개를 건너뛰고 다음 20개”처럼 숫자로 접근합니다. 데이터가 계속 추가되는 피드에서는 순서가 밀려 중복과 누락이 생깁니다. cursor pagination은 “이 id 이후부터”처럼 마지막으로 읽은 위치를 전달하므로 글 목록, 알림, 로그에 더 안정적입니다.

Claude Code에는 이렇게 요청하는 편이 좋습니다.

React와 Next.js로 글 목록 무한 스크롤을 구현하세요.
Intersection Observer를 사용하고 API는 cursor 기반으로 만드세요.
중복 요청 방지, AbortController 정리, 오류 표시, 수동 Load more 버튼,
aria-live, role="feed", SEO에 안전한 일반 링크를 포함하세요.
기존 frontmatter, heroImage, 내부 링크, 로컬라이즈된 라우트는 삭제하지 마세요.

Claude Code 공식 common workflows는 명확한 작업, 예시, 제약을 전달하는 흐름을 강조합니다. 무한 스크롤은 UI, API, 접근성, 제품 흐름이 함께 얽히므로 이 원칙이 특히 중요합니다.

잘 맞는 사용 사례

첫 번째는 글 아카이브입니다. 튜토리얼이 많은 사이트는 첫 화면을 가볍게 유지하면서도 관심 있는 독자에게 다음 글을 계속 보여줄 수 있습니다. 다만 글 상세로 들어갔다가 돌아왔을 때 위치가 복원되지 않으면 이 장점이 바로 사라집니다.

두 번째는 이커머스나 SaaS 검색 결과입니다. 상품, 템플릿, 통합 기능을 둘러보는 화면에서는 무한 스크롤이 자연스럽습니다. 대신 필터, 정렬, 검색어가 URL에 남아야 공유와 재방문이 가능합니다.

세 번째는 관리자 알림과 감사 로그입니다. 운영자는 최신 항목부터 빠르게 훑어보려 합니다. 이 경우 cursor, timestamp, 읽음 상태를 서로 다른 개념으로 두어야 합니다. “마지막으로 본 위치”를 DB cursor와 업무 상태로 동시에 쓰면 나중에 꼬입니다.

네 번째는 채팅, 댓글, 활동 피드입니다. 여기서는 아래로 내려가는 방식뿐 아니라 위로 과거 메시지를 불러오는 역방향 무한 스크롤도 많습니다. Claude Code에 요청할 때 로딩 방향을 꼭 적어야 합니다.

다섯 번째는 학습 대시보드입니다. 강의, 예제, 체크리스트를 연속으로 보여줄 수 있지만 각 섹션의 고유 URL, 진행 상태, Claude Code 교육 같은 CTA는 유지해야 합니다.

React Hook 구현

다음 hook은 cursor API를 기준으로 한 작은 실전형 구현입니다. AbortController로 오래된 요청을 정리하고, loadingRef로 중복 fetch를 막으며, rootMargin으로 사용자가 바닥에 닿기 전에 요청을 시작합니다.

import { useCallback, useEffect, useRef, useState } from "react";

export type CursorPage<T> = {
  items: T[];
  nextCursor: string | null;
};

type FetchPage<T> = (args: {
  cursor: string | null;
  signal: AbortSignal;
}) => Promise<CursorPage<T>>;

type InfiniteStatus = "idle" | "loading" | "error" | "done";

type UseInfiniteCursorOptions<T> = {
  fetchPage: FetchPage<T>;
  mergeItems?: (previous: T[], next: T[]) => T[];
  initialCursor?: string | null;
};

export function useInfiniteCursor<T>({
  fetchPage,
  mergeItems,
  initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(initialCursor);
  const [status, setStatus] = useState<InfiniteStatus>("idle");
  const [error, setError] = useState<Error | null>(null);

  const abortRef = useRef<AbortController | null>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);
  const loadingRef = useRef(false);
  const hasMore = cursor !== null || items.length === 0;

  const loadMore = useCallback(async () => {
    if (loadingRef.current || !hasMore) return;

    loadingRef.current = true;
    abortRef.current?.abort();

    const controller = new AbortController();
    abortRef.current = controller;
    setStatus("loading");
    setError(null);

    try {
      const page = await fetchPage({ cursor, signal: controller.signal });
      setItems((previous) =>
        mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
      );
      setCursor(page.nextCursor);
      setStatus(page.nextCursor ? "idle" : "done");
    } catch (unknownError) {
      if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
        return;
      }
      setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
      setStatus("error");
    } finally {
      loadingRef.current = false;
    }
  }, [cursor, fetchPage, hasMore, mergeItems]);

  const sentinelRef = useCallback(
    (node: HTMLElement | null) => {
      observerRef.current?.disconnect();
      if (!node || !hasMore) return;

      observerRef.current = new IntersectionObserver(
        ([entry]) => {
          if (entry?.isIntersecting) void loadMore();
        },
        { rootMargin: "600px 0px", threshold: 0 },
      );
      observerRef.current.observe(node);
    },
    [hasMore, loadMore],
  );

  useEffect(() => {
    void loadMore();
    return () => {
      abortRef.current?.abort();
      observerRef.current?.disconnect();
    };
  }, [loadMore]);

  return { items, status, error, hasMore, loadMore, sentinelRef };
}

Effect에서 외부 시스템과 동기화하고 정리하는 방식은 React useEffect 공식 문서를 기준으로 삼으세요. Claude Code에 리뷰를 맡길 때도 observer와 fetch cleanup을 명시해야 합니다.

목록 컴포넌트

자동 로딩만 있으면 실패했을 때 사용자가 할 수 있는 일이 없습니다. 아래 컴포넌트는 자동 감지와 수동 버튼을 같은 loadMore 경로로 연결합니다.

import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";

type Article = {
  id: string;
  title: string;
  summary: string;
  href: string;
  publishedAt: string;
};

function mergeUniqueById(previous: Article[], next: Article[]) {
  const seen = new Set(previous.map((item) => item.id));
  return [...previous, ...next.filter((item) => !seen.has(item.id))];
}

async function fetchArticlePage({
  cursor,
  signal,
}: {
  cursor: string | null;
  signal: AbortSignal;
}): Promise<CursorPage<Article>> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const response = await fetch(`/api/articles?${params}`, { signal });
  if (!response.ok) throw new Error(`Failed to load articles: ${response.status}`);
  return response.json();
}

export function ArticleFeed() {
  const fetchPage = useCallback(fetchArticlePage, []);
  const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
    fetchPage,
    mergeItems: mergeUniqueById,
  });

  return (
    <section aria-labelledby="article-feed-title">
      <h2 id="article-feed-title">최신 글</h2>

      <div role="feed" aria-busy={status === "loading"}>
        {items.map((article, index) => (
          <article
            key={article.id}
            role="article"
            aria-posinset={index + 1}
            aria-setsize={hasMore ? -1 : items.length}
          >
            <a href={article.href}>
              <h3>{article.title}</h3>
            </a>
            <p>{article.summary}</p>
            <time dateTime={article.publishedAt}>
              {new Intl.DateTimeFormat("ko-KR").format(new Date(article.publishedAt))}
            </time>
          </article>
        ))}
      </div>

      {error && <p role="alert">불러오기에 실패했습니다. 연결을 확인한 뒤 다시 시도하세요.</p>}

      <div ref={sentinelRef} aria-hidden="true" />

      <p aria-live="polite">
        {status === "loading" && "글을 더 불러오는 중입니다."}
        {status === "done" && "모든 글을 표시했습니다."}
      </p>

      {hasMore && (
        <button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
          더 보기
        </button>
      )}
    </section>
  );
}

role="feed"를 쓴다면 WAI-ARIA feed pattern도 확인하세요. 단순한 목록에 항상 필요한 것은 아니지만 위치, 로딩 상태, 실패 상태를 보조 기술에 설명하는 관점을 줍니다.

Next.js Cursor API

프론트엔드만으로는 불안정한 페이지 순서를 해결할 수 없습니다. 아래 route는 limit + 1개를 조회하고, 실제로는 limit개만 반환한 뒤 여분의 1개로 다음 cursor 존재 여부를 판단합니다.

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? "20"), 1), 50);
  const cursor = searchParams.get("cursor");

  const rows = await prisma.article.findMany({
    take: limit + 1,
    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
    orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
    select: {
      id: true,
      title: true,
      summary: true,
      href: true,
      publishedAt: true,
    },
  });

  const items = rows.slice(0, limit);
  const nextCursor = rows.length > limit ? items.at(-1)?.id ?? null : null;

  return NextResponse.json({ items, nextCursor });
}

운영 환경에서는 정렬 필드에 맞는 인덱스도 확인해야 합니다. 쿼리가 느려지면 observer가 정확해도 UI는 버벅입니다. 성능 최적화의 관점처럼 API 지연과 DB 실행 계획을 함께 봐야 합니다.

실패 사례와 함정

첫 번째 함정은 observer가 반복해서 발화하는 것입니다. sentinel이 화면에 계속 보이면 렌더링 중에도 다음 요청이 들어갈 수 있습니다. 그래서 state뿐 아니라 즉시 잠기는 ref가 필요합니다.

두 번째는 변하는 피드에서 offset pagination을 쓰는 것입니다. 새 글이 맨 위에 들어오면 2페이지가 1페이지와 겹칠 수 있습니다. cursor API와 client dedupe를 같이 두세요.

세 번째는 footer와 CTA가 영원히 멀어지는 문제입니다. 회사 정보, 문의, Claude Code 교육 같은 전환 경로에 도달하지 못하면 사업적으로도 손해입니다. 일정 페이지 이후에는 자동 로딩을 멈추고 수동 버튼으로 전환하는 방식을 고려하세요.

네 번째는 SEO입니다. 검색엔진과 소셜 미리보기는 사용자가 스크롤한 상태를 알 수 없습니다. 일반 링크, 카테고리 URL, sitemap, 페이지 메타데이터를 유지해야 합니다.

다섯 번째는 브라우저 뒤로가기입니다. 상세 페이지에서 돌아왔을 때 목록이 맨 위로 가면 탐색 흐름이 끊깁니다. scroll restoration, 캐시, 필터와 cursor의 URL 상태를 테스트하세요.

Claude Code 리뷰 프롬프트

구현 후에는 막연한 코드 리뷰보다 실패 모드 리뷰를 요청하세요.

이 무한 스크롤 구현을 운영 리스크 관점에서 리뷰하세요.
중복 fetch, 오래된 응답 혼입, IntersectionObserver cleanup,
AbortError 처리, cursor pagination, 접근성, SEO, 브라우저 뒤로가기,
DB 인덱스, 실패 후 수동 복구를 확인하세요.
파일별 문제와 구체적인 수정안을 반환하세요.

도구 자체는 Anthropic Claude Code overview에서 확인할 수 있습니다. 에이전트에게 맡기는 범위가 넓을수록 제약과 리뷰 기준이 더 중요해집니다.

정리와 CTA

무한 스크롤은 작은 UI처럼 보이지만 브라우저, API, 데이터베이스, 접근성, SEO, 전환 흐름을 모두 건드립니다. Claude Code를 쓸 때는 Intersection Observer, cursor API, 수동 복구, cleanup, 위치 복원, 검증 기준을 하나의 작업으로 요청하세요.

팀이 이 품질을 반복 가능한 방식으로 만들고 싶다면 Claude Code 교육에서 프롬프트 설계, 차이 리뷰, 테스트, 공개 전 점검을 함께 정리하는 것이 좋습니다.

실제로 확인한 결과

이번 업데이트에서는 MDN, React, WAI-ARIA, Anthropic 공식 문서를 확인하고 깨진 본문을 실전 구현 가이드로 교체했습니다. 코드는 TypeScript/TSX 구조로 정리했으며 중복 요청 방지, AbortController, cursor API, 수동 복구, aria-live를 포함합니다. 실제 프로젝트라면 npm run build, API 부하 확인, 모바일 브라우저 테스트, 뒤로가기 위치 복원을 추가로 검증합니다.

#Claude Code #무한 스크롤 #React #성능 #UX
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.