

인계받아 유지보수를 시작한 레거시 서비스에서 "최근 본 레시피" 기능이 이상하게 동작하는 문제가 발견되었다.
처음에는 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 (/* ... */);
}
하지만 서버 컴포넌트는 한 번만 실행되므로 이 가설은 맞지 않았다.
문제를 추적하던 중, 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라서, 서비스를 인수인계 받은 입장에서는 이런 코드가 있는지조차 파악하기 어려웠다.
핵심: 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 (/* ... */);
}
RecipeCard 컴포넌트가 마운트되면서 useEffect 실행router.prefetch() 호출saveRecentRecipeView(B) 실행saveRecentRecipeView(C) 실행saveRecentRecipeView(D) 실행사용자 액션: 레시피 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) ❌ 문제!
router.prefetch()는 Next.js 공식 API지만 실무에서는 거의 사용하지 않는다:
<Link> 컴포넌트가 이미 자동으로 prefetch를 처리이 코드는 아마도:
하지만 서버 액션의 부작용(side effect)까지 고려하지 못한 채 운영되고 있었고, 이를 인수인계 받은 입장에서는 파악하기 어려웠다.
가장 근본적인 해결책은 불필요한 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>
);
}
효과:
문제를 해결하는 과정에서 추가적인 개선점도 발견했다.
기존 코드의 문제점:
// ❌ 안티패턴
<div onClick={handleCardClick}>
<img src={thumbnail} />
<h3>{title}</h3>
</div>
개선된 코드:
// ✅ 시맨틱 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>
);
}
개선 효과:
<a> 태그)방법: 클라이언트 컴포넌트로 분리
// 고려했던 방법
'use client';
export function SaveRecentRecipeViewClient({ recipeId }) {
useEffect(() => {
saveRecentRecipeView(recipeId).catch(console.error);
}, [recipeId]);
return null;
}
채택하지 않은 이유:
핵심 교훈:
router.prefetch()는 서버 컴포넌트까지 실행한다<Link> 컴포넌트가 이미 충분히 최적화되어 있다권장 사항:
// ❌ 나쁜 예: 수동 prefetch
useEffect(() => {
router.prefetch('/some-page');
}, []);
// ✅ 좋은 예: Link 컴포넌트 사용
<Link href="/some-page">Go to page</Link>
문제가 되는 패턴:
// ❌ 나쁜 예: 서버 컴포넌트에서 부작용 실행
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>
</>
);
}
이번 경험을 통해 배운 점:
겉으로 보이지 않는 최적화 코드가 숨어있을 수 있다
useEffect 내부의 router/navigation 관련 코드는 특히 주의router.prefetch 등)는 더욱 주의이상 동작 발견 시 전체 플로우를 추적하라
"왜 이렇게 구현했을까?" 질문하기
교훈:
<div onClick>보다 <a href>가 항상 낫다실용적 가이드:
// ❌ 피해야 할 패턴
<div onClick={() => router.push('/page')}>Click me</div>
<span onClick={handleClick}>Link</span>
// ✅ 권장 패턴
<Link href="/page">Click me</Link>
<a href="/page">Link</a>
prefetch 관련:
<Link> 컴포넌트는 기본적으로 prefetch={true}서버 컴포넌트 관련:
운영 중인 서비스의 이상 동작을 추적한 결과, 잘 사용되지 않는 router.prefetch API의 숨겨진 동작이 원인이었다.
핵심 포인트:
router.prefetch()는 서버 컴포넌트까지 실행한다<Link>)을 신뢰하고 활용하자이번 트러블슈팅을 통해:
최종 개선 사항: