Overview
리라이팅 진행 중 대학교 선택 드롭다운을 구현하면서 예상치 못한 성능 문제에 직면했다. 호버할 때마다 이미지들이 매번 새로 로드되는 현상이 발생했는데, 처음엔 단순한 캐싱 문제인 줄 알았지만 파고들어보니 컴포넌트 라이프사이클과 브라우저 렌더링 최적화까지 연관된 복잡한 문제였다.
문제 발견
대학교 선택 드롭다운을 구현하던 중 성능 문제를 발견했다. 드롭다운에 호버할 때마다 내부의 대학교 로고 이미지들이 계속 로드되는 현상이 발생했다.
빠른 네트워크 환경에서도 이미지 로딩이 눈에 띄었고, 네트워크 탭에서도 재요청이 발생하는 것을 확인할 수 있었다.
실제 사용자들의 네트워크 환경을 고려해 Throttling을 살짝 조정한 후 다시 테스트해보았다.
역시나 네트워크가 느린 환경에서는 문제가 더욱 심각했다. 사용자 경험에 부정적인 영향을 미칠 것이 분명했다.
원인 분석
먼저 근본적인 문제의 핵심은 컴포넌트 마운트/언마운트 사이클에 있었다.
핵심 원인: Next.js Image 컴포넌트 인스턴스가 매번 새로 생성되면서 내부 최적화 로직이 재실행되어 네트워크 재요청이 발생한다.
브라우저 HTTP 캐시는 유지되지만, Next.js Image 컴포넌트의 내부 상태와 최적화 메커니즘이 초기화되면서 동일한 이미지에 대해서도 재요청이 발생하는 것이다. (실제로는 304 Not Modified로 빠르게 응답받지만 요청 오버헤드는 존재한다)
// ❌ 문제가 있던 기존 코드
{isDropdownOpen && (
<DropdownMenu>
<UniversityList /> {/* 매번 새로 마운트되면서 이미지 재로딩 */}
</DropdownMenu>
)}직관적이고 불필요한 DOM 노드를 생성하지 않아 메모리를 절약한다는 생각으로 조건부 렌더링을 사용하였다.
하지만 드롭다운이 자주 토글되는 상황에서는 다음과 같은 문제가 발생한다:
- Next.js Image 컴포넌트 인스턴스가 매번 새로 생성됨
- 컴포넌트 내부의 이미지 로딩 상태와 최적화 로직이 초기화됨
- 결과적으로 동일한 이미지에 대해 불필요한 네트워크 요청 발생
해결 과정
1단계: 컴포넌트 인스턴스 유지
핵심 문제인 Next.js Image 컴포넌트 인스턴스가 매번 새로 생성되는 문제를 먼저 해결했다.
조건부 렌더링 방식은 호버할 때마다 UniversityList 컴포넌트가 완전히 새로 생성되면서 내부의 모든 Image 컴포넌트 인스턴스도 새로 만들어진다.
해결책은 컴포넌트를 DOM에 유지하고 CSS로 가시성만 제어하는 것이었다:
// ✅ 개선된 방식 - CSS로 가시성 제어
<DropdownMenu isOpen={isDropdownOpen}>
<UniversityList /> {/* DOM에 유지되어 인스턴스 보존, 단 메모리는 지속 점유 */}
</DropdownMenu>결과: 이 변경만으로도 이미지 재요청 문제가 상당 부분 개선되었다. Next.js Image 컴포넌트가 동일한 인스턴스를 유지하면서 내부 최적화가 정상 작동했다.
그런데 추가로 최적화가 필요할 것 같았다
기본적인 문제는 해결했지만, 이 컴포넌트는 이후 랜딩 페이지나 푸터 등 다른 영역에서도 재사용될 가능성이 있었다. 그럴 경우, 외부 상태 변경으로 인해 불필요하게 리렌더링될 수 있다는 점이 우려됐다.
2단계: 컴포넌트 리렌더링 최적화
드롭다운 로직을 관리하는 useDropDown.ts 훅에서 함수들을 메모이제이션했다:
// useDropDown.ts - 함수 메모이제이션
const handleDropdownOpen = useCallback(() => {
setIsDropdownOpen(true);
}, []);
const handleDropdownMouseLeave = useCallback(() => {
// 타이머로 지연 닫기
}, []);목적: 부모 컴포넌트 리렌더링 시 불필요한 자식 컴포넌트 리렌더링 방지
3단계: 이미지 로딩 최적화
마지막으로 UniversityList 컴포넌트 자체를 메모이제이션하고 이미지 로딩을 최적화했다:
// UniversityList.tsx - 메모이제이션과 이미지 최적화
const UniversityList = memo(function UniversityList() {
return (
<UniversityGrid>
{universities.map((university) => (
<Image src={university.logo} loading="eager" priority />
))}
</UniversityGrid>
);
});1차 최적화 결과
이미지 재요청 문제가 상당 부분 해결되었다.
중간 학습 포인트
핵심 문제: 조건부 렌더링으로 인한 Next.js Image 컴포넌트 인스턴스의 완전한 제거와 재생성
해결의 핵심:
- 브라우저 HTTP 캐시는 유지되지만, Next.js Image 컴포넌트의 내부 최적화 로직이 매번 초기화됨
- 컴포넌트 인스턴스를 유지하면 내부 상태와 최적화가 보존됨
단순한 캐싱 문제로 생각했지만, 컴포넌트 라이프사이클과 브라우저 캐싱 메커니즘, React의 렌더링 최적화가 어떻게 상호작용하는지까지 연관된 복잡한 문제였다. 하지만 이 과정에서 단순히 증상을 고치는 것이 아니라 근본 원인을 파악하고 전체적인 개선 가능성을 보는 경험을 할 수 있었다.
적용 가능한 패턴: 드롭다운, 모달, 툴팁 등 자주 토글되는 UI 컴포넌트에서 무거운 리소스를 다룰 때는 CSS 가시성 제어를 고려해볼 수 있다.
추가 문제 발견: 호버 시 재요청
1차 최적화 후에도 완전하지 않았다. 며칠 후 테스트 중 개별 로고에 호버할 때마다 또 다시 네트워크 재요청이 발생하는 것을 발견했다.
새로운 원인 분석
이번에는 다른 원인이었다. CSS 호버 상태 변경으로 인한 컴포넌트 리렌더링이 문제였다.
문제의 핵심:
- 호버로 인한 리렌더링 → 이미지 src 객체 재생성 → Next.js Image가 새로운 이미지로 인식
- JavaScript 객체는 매번 새로운 참조를 가지므로
prevProps.src !== currentProps.src
4단계: 객체 참조 안정화
문제가 되던 코드:
// ❌ 매 렌더링마다 새로운 객체 참조 생성
<Image
src={university.logo} // { src: "/logo.svg", width: 100 } - 새 객체
alt={university.name}
/>해결책: 이미지 데이터를 사전에 안정화하여 참조 불안정성 제거
// ✅ 컴포넌트 외부 또는 useMemo로 안정적인 데이터 생성
const universityData = useMemo(() => {
return universities.map((university) => ({
...university,
imageSrc: university.logo.src, // 문자열로 추출하여 안정적인 참조
}));
}, []);
<Image src={university.imageSrc} /> // ✅ 리렌더링 시에도 동일한 문자열 참조핵심:
- 객체 타입 props는 매 렌더링마다 새로운 참조 생성
- 문자열, 숫자 등 원시 타입은 값이 같으면 참조도 동일
최종 결과
모든 문제가 해결되어 더 이상 이미지 재요청이 발생하지 않는다.
최종 학습 포인트
이 문제 해결 과정에서 두 가지 서로 다른 원인이 연쇄적으로 작용했음을 확인했다:
1. 컴포넌트 인스턴스 생성/소멸 문제
- 원인: 조건부 렌더링으로 인한 Next.js Image 컴포넌트 인스턴스 재생성
- 해결: CSS 가시성 제어로 컴포넌트 인스턴스 유지
2. 객체 참조 불안정성 문제
- 원인: 매 렌더링마다 새로운 객체 참조 생성으로 Next.js Image가 다른 이미지로 인식
- 해결:
useMemo로 데이터 안정화, 원시 타입 props 사용