React에서 탭이나 모드를 전환할 때 컴포넌트가 느리게 뜨는 경험을 해본 적이 있을 것이다. 특히 iframe이나 Canvas처럼 초기화 비용이 큰 컴포넌트가 매번 리마운트되면 사용자 경험이 확연히 나빠진다.
문제: 모드 전환마다 컴포넌트가 리셋된다
pixelDiff에는 세 가지 비교 모드가 있다:
- iframe 모드: 웹페이지를 iframe으로 띄우고 Figma 이미지와 오버레이 비교
- opacity 모드: 투명도 조절로 차이점 확인
- devices 모드: 여러 기기 해상도에서 동시에 비교
초기 구현은 단순한 조건부 렌더링이었다:
{comparisonMode === 'devices' && <DevicesMode />}
{comparisonMode !== 'devices' && <CompareCanvas />}
문제는 모드를 전환할 때마다 발생했다:
- DevicesMode 리마운트: 내부의 iframe들이 전부 다시 로드됨 (각 1-2초)
- CompareCanvas 리마운트: PixiJS 캔버스 재초기화 (200-400ms)
- 상태 초기화: 줌 레벨, 스크롤 위치 등 사용자 조작 내용이 리셋됨
iframe 모드에서 작업하다가 devices 모드로 잠깐 확인하고 돌아오면, 모든 상태가 초기화되어 있었다.
선택지: 숨기는 방법에 따른 트레이드오프
조건부 렌더링 대신 "한번 마운트된 컴포넌트를 숨기기만 하는" 방식을 검토했다.
| 방식 | 장점 | 한계 |
|---|---|---|
display: none |
CSS 한 줄로 간단 | 레이아웃에서 완전히 제거됨 → ResizeObserver가 크기를 0으로 인식 |
visibility: hidden |
레이아웃 유지, 크기 계산 정상 | 메모리에 계속 존재 |
| 조건부 렌더링 유지 | 메모리 효율적 | 전환 시마다 리마운트 비용 |
결정적 차이는 PixiJS와의 호환성이었다.
CompareCanvas 내부의 PixiJS 캔버스는 ResizeObserver로 컨테이너 크기 변화를 감지한다. ResizeObserver는 요소의 크기가 변할 때 콜백을 실행하는 브라우저 API다.
display: none을 적용하면 요소가 레이아웃에서 빠지면서 크기가 0이 되고, 캔버스가 깨진다.
visibility: hidden은 요소가 보이지 않지만 레이아웃에서 공간을 차지한다. ResizeObserver는 정상적인 크기를 감지하고, 캔버스도 문제없이 동작한다.
구현: renderedModes Set + visibility 패턴
핵심 아이디어는 두 가지다:
- 지연 렌더링: 처음 방문한 모드만 렌더링 (사용하지 않은 모드는 마운트하지 않음)
- 캐싱: 한번 렌더링된 모드는 언마운트하지 않고 visibility로 숨김
// 1. 렌더링된 모드 추적
const initialMode = project.state?.active?.mode ?? 'iframe';
const [renderedModes, setRenderedModes] = useState<Set<ComparisonMode>>(
() => new Set([initialMode])
);
// 2. 모드 전환 시 Set에 추가
useEffect(() => {
if (!renderedModes.has(comparisonMode)) {
setRenderedModes(prev => new Set([...prev, comparisonMode]));
}
}, [comparisonMode, renderedModes]);
// 3. 렌더링 조건
const hasCanvasMode = renderedModes.has('iframe') || renderedModes.has('opacity');
const hasDevicesMode = renderedModes.has('devices');
JSX에서는 "렌더링된 적 있는지"와 "현재 활성화된 모드인지"를 분리한다:
{/* 한번이라도 devices 모드를 사용했으면 DOM에 유지 */}
{hasDevicesMode && (
<div
style={{
visibility: isDevicesMode ? 'visible' : 'hidden',
pointerEvents: isDevicesMode ? 'auto' : 'none',
}}
>
<DevicesMode />
</div>
)}
{/* iframe/opacity 모드도 동일한 패턴 */}
{hasCanvasMode && (
<div
style={{
visibility: isDevicesMode ? 'hidden' : 'visible',
pointerEvents: isDevicesMode ? 'none' : 'auto',
}}
>
<CompareCanvas />
</div>
)}
초기값 설정이 중요하다. 서버에서 마지막으로 사용한 모드를 복원하면, 사용자가 처음 진입할 때 불필요한 컴포넌트를 렌더링하지 않는다.
pointerEvents를 함께 제어해야 하는 이유
visibility만 숨기면 숨겨진 요소가 여전히 클릭 이벤트를 받는다. 두 모드가 absolute로 겹쳐 있으면, 보이는 요소를 클릭해도 숨겨진 요소가 이벤트를 가로챌 수 있다.
{/* 잘못된 예: 숨겨진 DevicesMode가 클릭을 가로챔 */}
<div style={{ visibility: 'hidden' }}>
<DevicesMode />
</div>
{/* 올바른 예: pointerEvents: none으로 이벤트 차단 */}
<div style={{ visibility: 'hidden', pointerEvents: 'none' }}>
<DevicesMode />
</div>
트레이드오프: 메모리 vs 전환 속도
이 패턴의 단점은 메모리 사용량 증가다. 모든 모드가 DOM에 유지되므로, 컴포넌트가 점유하는 메모리가 누적된다.
| 항목 | 기존 (조건부 렌더링) | 변경 후 (visibility 캐싱) |
|---|---|---|
| 초기 로드 | 현재 모드만 마운트 | 현재 모드만 마운트 (동일) |
| 모드 전환 | 1-2초 (리마운트) | 즉시 (~50ms) |
| 메모리 | 현재 모드만 | 방문한 모든 모드 |
| 상태 유지 | 리셋됨 | 유지됨 |
pixelDiff의 경우 모드가 3개뿐이고, 각 모드의 메모리 사용량도 크지 않아 트레이드오프가 합리적이다. 하지만 탭이 수십 개이거나 각 탭이 무거운 데이터를 들고 있다면, LRU 캐시(최근 사용한 N개만 유지하는 방식)처럼 제한을 두는 것을 고려해야 한다.
이 패턴이 적합한 상황
모든 조건부 렌더링에 이 패턴을 적용할 필요는 없다.
적합한 경우:
- 마운트 비용이 큰 컴포넌트 (iframe, Canvas, 무거운 초기화 로직)
- 사용자가 자주 전환하는 UI (탭, 모드)
- 전환 시 상태를 유지해야 하는 경우
부적합한 경우:
- 가벼운 컴포넌트 (마운트 비용이 무시할 수준)
- 전환 빈도가 낮은 UI
- 메모리가 제한된 환경
핵심은 "마운트 비용"과 "전환 빈도"의 곱이다. 둘 다 높으면 이 패턴이 효과적이고, 둘 중 하나라도 낮으면 기존 조건부 렌더링이 나을 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > React' 카테고리의 다른 글
| React Query 낙관적 업데이트, 두 가지 패턴 (0) | 2026.02.17 |
|---|---|
| Next.js에서 Prisma 연결 풀 최적화하기 (0) | 2026.02.12 |
| NextAuth.js 세션 전략: DB에서 JWT로 전환하여 400ms → 5ms 달성하기 (0) | 2026.02.03 |
| Next.js로만 백엔드, 프론트엔드 구축하기 (0) | 2026.01.24 |
| Zustand로 Undo/Redo 구현하기 (0) | 2026.01.13 |
