[트러블슈팅] router.prefetch의 함정

contability·2025년 12월 19일
post-thumbnail

1. 문제 상황

인계받아 유지보수를 시작한 레거시 서비스에서 "최근 본 레시피" 기능이 이상하게 동작하는 문제가 발견되었다.

증상

  • 사용자가 레시피 하나만 조회했는데, 여러 개의 레시피가 "최근 본 레시피"에 한 번에 쌓임
  • 특히 레시피 상세 페이지 하단의 "비슷한 레시피" 섹션에 표시되는 레시피들이 자동으로 저장됨
  • 실제로 클릭하지 않은 레시피까지 조회 기록에 포함됨

재현 방법

  1. 레시피 A 상세 페이지 접속
  2. 하단에 "비슷한 레시피" 섹션에 레시피 B, C, D가 표시됨
  3. 아무것도 클릭하지 않고 "최근 본 레시피" 확인
  4. 결과: 레시피 A뿐만 아니라 B, C, D까지 모두 기록됨

환경

  • Next.js 14 (App Router)
  • React 18
  • 서버 액션을 통한 DB 저장 로직

2. 원인 분석 및 해결 과정

2.1. 초기 가설: 중복 호출 문제?

처음에는 saveRecentRecipeView 함수가 여러 번 호출되는 것이 아닐까 의심했다.

// src/app/recipes/[id]/page.tsx
export default async function RecipePage({ params }) {
  const { id } = await params;
  
  saveRecentRecipeView(id).catch((error) => {
    console.error('최근 조회 기록 저장 실패', error);
  });

  return (/* ... */);
}

하지만 서버 컴포넌트는 한 번만 실행되므로 이 가설은 맞지 않았다.

2.2. 핵심 발견: router.prefetch의 숨겨진 동작

문제를 추적하던 중, RecipeCard 컴포넌트에서 수상한 코드를 발견했다.

// src/entities/recipe/ui/RecipeCard.tsx (기존 코드)
export default function RecipeCard({ recipe, size, className, from }) {
  const router = useRouter();

  useEffect(() => {
    router.prefetch(`/recipes/${recipe.id}`);  // 🚨 문제의 코드
  }, [router, recipe.id]);

  const handleCardClick = (e) => {
    track('click_recipe_card', { /* ... */ });
    router.push(baseItem.url);
  };

  return (
    <div onClick={handleCardClick}>
      {/* 레시ピ 카드 UI */}
    </div>
  );
}

이 코드는 레거시 코드로, 아마도 성능 최적화를 위해 추가되었을 것으로 추정된다. 하지만 router.prefetch()는 실무에서 거의 사용하지 않는 API라서, 서비스를 인수인계 받은 입장에서는 이런 코드가 있는지조차 파악하기 어려웠다.

2.3. router.prefetch의 동작 방식

핵심: prefetch는 서버 컴포넌트까지 실행한다.

Next.js App Router에서 router.prefetch()는 단순히 클라이언트 번들만 로드하는 것이 아니라, 해당 페이지의 서버 컴포넌트를 미리 실행한다. 즉:

// 이 서버 컴포넌트가 prefetch 시에도 실행됨!
export default async function RecipePage({ params }) {
  const { id } = await params;
  
  // 🚨 prefetch만 해도 이 코드가 실행됨
  saveRecentRecipeView(id).catch(console.error);
  
  return (/* ... */);
}

2.4. 문제 발생 메커니즘

  1. 사용자가 레시피 A를 조회
  2. 페이지 하단에 "비슷한 레시피" 섹션 렌더링 (레시피 B, C, D 표시)
  3. RecipeCard 컴포넌트가 마운트되면서 useEffect 실행
  4. 레시피 B, C, D에 대한 router.prefetch() 호출
  5. 각 prefetch가 서버 컴포넌트를 실행하면서:
    • saveRecentRecipeView(B) 실행
    • saveRecentRecipeView(C) 실행
    • saveRecentRecipeView(D) 실행
  6. 결과: 사용자가 보지도 않은 레시피들이 DB에 저장됨
사용자 액션: 레시피 A 조회
    ↓
RecipePage(A) 렌더링
    ↓
saveRecentRecipeView(A) ✅ 정상
    ↓
SimilarRecipes 컴포넌트 렌더링
    ↓
RecipeCard(B), RecipeCard(C), RecipeCard(D) 마운트
    ↓
useEffect 실행 → router.prefetch(B), prefetch(C), prefetch(D)
    ↓
RecipePage(B), RecipePage(C), RecipePage(D) 서버 컴포넌트 실행
    ↓
saveRecentRecipeView(B) ❌ 문제!
saveRecentRecipeView(C) ❌ 문제!
saveRecentRecipeView(D) ❌ 문제!

2.5. 왜 이런 코드가 있었을까?

router.prefetch()는 Next.js 공식 API지만 실무에서는 거의 사용하지 않는다:

  • Next.js의 <Link> 컴포넌트가 이미 자동으로 prefetch를 처리
  • viewport에 들어온 링크는 자동으로 prefetch됨
  • 수동 prefetch는 복잡도만 증가시킴

이 코드는 아마도:

  • 초기 개발 시 성능 최적화 목적으로 추가되었거나
  • Next.js의 자동 prefetch를 신뢰하지 못해 명시적으로 추가했을 가능성

하지만 서버 액션의 부작용(side effect)까지 고려하지 못한 채 운영되고 있었고, 이를 인수인계 받은 입장에서는 파악하기 어려웠다.

3. 해결 방안

3.1. 최종 해결책: router.prefetch 제거

가장 근본적인 해결책은 불필요한 router.prefetch를 제거하는 것이다.

// src/entities/recipe/ui/RecipeCard.tsx (수정 전)
export default function RecipeCard({ recipe, size, className, from }) {
  const router = useRouter();

  useEffect(() => {
    router.prefetch(`/recipes/${recipe.id}`);  // ❌ 제거
  }, [router, recipe.id]);

  const handleCardClick = (e) => {
    e.stopPropagation();
    track('click_recipe_card', { /* ... */ });
    router.push(baseItem.url);
  };

  return (
    <div onClick={handleCardClick}>
      {/* UI */}
    </div>
  );
}
// src/entities/recipe/ui/RecipeCard.tsx (수정 후)
export default function RecipeCard({ recipe, size, className, from }) {
  // ✅ router, useEffect 제거
  
  const baseItem = useMemo(() => ({
    id: recipe.id,
    url: `/recipes/${recipe.id}`,
    // ...
  }), [recipe]);

  return (
    <div>
      {/* UI */}
    </div>
  );
}

효과:

  • ✅ 문제의 근본 원인 제거
  • ✅ 불필요한 서버 부하 감소
  • ✅ Next.js의 기본 동작에 의존 (더 안정적)
  • ✅ 코드 복잡도 감소

문제를 해결하는 과정에서 추가적인 개선점도 발견했다.

기존 코드의 문제점:

// ❌ 안티패턴
<div onClick={handleCardClick}>
  <img src={thumbnail} />
  <h3>{title}</h3>
</div>
  • 시맨틱하지 않은 HTML (SEO 불리)
  • 키보드 네비게이션 불가
  • 스크린 리더 접근성 저하
  • 우클릭 메뉴 사용 불가

개선된 코드:

// ✅ 시맨틱 HTML
import CustomLink from '@/shared/ui/Link/custom-link';

export default function RecipeCard({ recipe, size, className, from }) {
  const baseItem = useMemo(() => ({
    id: recipe.id,
    url: `/recipes/${recipe.id}`,
    // ...
  }), [recipe]);

  return (
    <motion.div>
      <CustomLink
        href={baseItem.url}
        event={{
          name: 'click_recipe_card',
          params: {
            page: from,
            recipe_id: baseItem.id,
            platform: baseItem.type,
            recipe_title: baseItem.title,
            url: recipe.normalized_url || '',
          },
        }}
      >
        <img src={thumbnail} />
        <h3>{title}</h3>
      </CustomLink>
    </motion.div>
  );
}

개선 효과:

  • ✅ 시맨틱 HTML (<a> 태그)
  • ✅ SEO 개선
  • ✅ 키보드 네비게이션 지원
  • ✅ 스크린 리더 접근성 향상
  • ✅ 브라우저 기본 기능 지원 (Ctrl+Click, 우클릭 등)
  • ✅ Next.js 자동 prefetch 활용
  • ✅ 이벤트 트래킹 자동화

3.3. 고려했으나 채택하지 않은 방법

방법: 클라이언트 컴포넌트로 분리

// 고려했던 방법
'use client';

export function SaveRecentRecipeViewClient({ recipeId }) {
  useEffect(() => {
    saveRecentRecipeView(recipeId).catch(console.error);
  }, [recipeId]);

  return null;
}

채택하지 않은 이유:

  • 어거지스러운 해결책
  • 불필요한 클라이언트 컴포넌트 추가
  • 근본 원인(불필요한 prefetch)을 해결하지 못함
  • 코드 복잡도만 증가

4. 학습 및 인사이트

4.1. router.prefetch는 신중하게 사용하라

핵심 교훈:

  • router.prefetch()는 서버 컴포넌트까지 실행한다
  • Next.js의 <Link> 컴포넌트가 이미 충분히 최적화되어 있다
  • 수동 prefetch는 대부분의 경우 불필요하다

권장 사항:

// ❌ 나쁜 예: 수동 prefetch
useEffect(() => {
  router.prefetch('/some-page');
}, []);

// ✅ 좋은 예: Link 컴포넌트 사용
<Link href="/some-page">Go to page</Link>

4.2. 서버 컴포넌트에서 부작용을 조심하라

문제가 되는 패턴:

// ❌ 나쁜 예: 서버 컴포넌트에서 부작용 실행
export default async function Page({ params }) {
  await saveLog(params.id);  // prefetch 시에도 실행됨!
  await incrementViewCount(params.id);  // 의도하지 않은 증가!
  
  return <div>...</div>;
}

권장 패턴:

// ✅ 좋은 예: 클라이언트에서 실제 방문 시에만 실행
'use client';

export function PageLogger({ id }) {
  useEffect(() => {
    saveLog(id);
  }, [id]);
  
  return null;
}

// 서버 컴포넌트
export default async function Page({ params }) {
  return (
    <>
      <PageLogger id={params.id} />
      <div>...</div>
    </>
  );
}

4.3. 레거시 코드 인수인계 시 주의사항

이번 경험을 통해 배운 점:

  1. 겉으로 보이지 않는 최적화 코드가 숨어있을 수 있다

    • useEffect 내부의 router/navigation 관련 코드는 특히 주의
    • 잘 사용되지 않는 API (router.prefetch 등)는 더욱 주의
  2. 이상 동작 발견 시 전체 플로우를 추적하라

    • 단순히 해당 함수만 보지 말고
    • 어디서, 언제, 어떻게 호출되는지 전체 흐름 파악
  3. "왜 이렇게 구현했을까?" 질문하기

    • 불필요해 보이는 코드도 이유가 있을 수 있음
    • 하지만 그 이유가 현재는 유효하지 않을 수도 있음

4.4. 시맨틱 HTML의 중요성

교훈:

  • <div onClick>보다 <a href>가 항상 낫다
  • 접근성과 SEO는 나중에 고치기 어렵다
  • 처음부터 올바른 HTML 요소를 사용하자

실용적 가이드:

// ❌ 피해야 할 패턴
<div onClick={() => router.push('/page')}>Click me</div>
<span onClick={handleClick}>Link</span>

// ✅ 권장 패턴
<Link href="/page">Click me</Link>
<a href="/page">Link</a>

4.5. Next.js App Router 사용 시 주의사항

prefetch 관련:

  • <Link> 컴포넌트는 기본적으로 prefetch={true}
  • viewport에 들어오면 자동으로 prefetch됨
  • 수동 prefetch는 특별한 경우가 아니면 불필요

서버 컴포넌트 관련:

  • 서버 컴포넌트는 prefetch 시에도 실행됨
  • 부작용(DB 저장, 로깅 등)은 클라이언트에서 처리
  • 데이터 fetching은 서버 컴포넌트에서 처리

결론

운영 중인 서비스의 이상 동작을 추적한 결과, 잘 사용되지 않는 router.prefetch API의 숨겨진 동작이 원인이었다.

핵심 포인트:

  • router.prefetch()는 서버 컴포넌트까지 실행한다
  • 대부분의 경우 수동 prefetch는 불필요하다
  • Next.js의 기본 동작(<Link>)을 신뢰하고 활용하자
  • 레거시 코드에는 예상치 못한 최적화 코드가 숨어있을 수 있다

이번 트러블슈팅을 통해:

  • Next.js App Router의 동작 방식을 더 깊이 이해
  • 불필요한 최적화 코드를 제거하여 더 안정적인 서비스 구현
  • 시맨틱 HTML 적용으로 접근성과 SEO 개선

최종 개선 사항:

  • ✅ "최근 본 레시피" 기능 정상 동작
  • ✅ 불필요한 서버 부하 제거
  • ✅ 코드 복잡도 감소
  • ✅ 접근성 및 SEO 개선

0개의 댓글