React.lazy로 모달 떼어내고 사전 로딩으로 지연 메우기
이미지 목록 화면에서 항목을 클릭하면 크게 볼 수 있는 상세 모달을 새로 붙이는 일이 있었다. 큰 이미지를 띄우고 좌우로 넘기는 뷰어가 들어가는, 평소엔 안 열려 있는 화면이다. 처음부터 이걸 초기 번들에 같이 넣고 싶지 않아서 분리해서 붙였고, 분리한 다음 생긴 클릭 지연을 메우는 순서로 작업했다.
평소엔 안 열리는 모달을 초기 번들에 같이 넣고 싶지 않았다
상세 모달은 사용자가 목록의 항목을 직접 누를 때만 열린다. 첫 화면에서 곧장 보여줄 요소도 아니고, 많은 사용자는 한 번도 열지 않을 수도 있다. 이런 화면이 첫 진입 시점에 같이 다운로드되는 건 비용에 비해 효용이 낮다. 새 컴포넌트를 만드는 김에 처음부터 분리해서 붙이기로 했다.
React.lazy로 모달만 별도 청크에 묶었다
React.lazy(컴포넌트를 동적으로 import 해 번들에서 분리해주는 React API)에 동적 import()를 넘기면 해당 모듈은 별도 청크로 분리되고, 실제로 렌더링될 때 비로소 로드된다. lazy가 받는 함수는 default export를 반환해야 한다.
// App.js
// 기존 방식 - 모든 컴포넌트 즉시 로드
import ImageModal from './components/ImageModal';
// 지연 로딩 방식
const LazyImageModal = lazy(() => import('./components/ImageModal'));
function App() {
const [showModal, setShowModal] = useState(false);
return (
<Suspense fallback={null}>
{showModal && <LazyImageModal />}
</Suspense>
);
}Suspense로 감싸지 않으면 비동기 로딩 중 트리가 멈출 때 에러가 난다. 로딩 중에 보여줄 게 없으니 fallback은 null로 뒀다.
이렇게 붙이고 나면 모달 컴포넌트와 그 안에서 쓰는 의존성들이 같이 별도 청크로 묶인다. 상세 모달 안에서 큰 이미지 뷰어를 띄우는 데 쓴 라이브러리도 이 청크에 함께 들어갔다. 빌드 산출물은 1.chunk 하나에서 0.chunk + 2.chunk + 3.chunk로 쪼개졌고, 초기 스크립트의 크기와 실행 시간이 줄었다. 이번 케이스는 모달 한 개에만 적용한 결과라 체감 폭이 작았지만, 트리거로 조건부 렌더링되는 컴포넌트들을 같은 방식으로 정리하면 누적 효과는 커진다.
지연 로딩의 부작용 — 모달 클릭 후 렌더가 한 박자 늦다
분리된 청크는 사용자가 모달을 열어야 비로소 다운로드가 시작된다. 그래서 트리거를 누른 순간부터 청크가 도착해 렌더가 끝날 때까지 빈 화면 시간이 생긴다. 처음부터 지연 로딩으로 붙였기 때문에 생긴 비용이다.
번들은 가볍게 시작했는데 사용자가 느끼는 반응 속도는 손해를 본 셈. 다음 단계로 이걸 메울 방법이 필요했다.
마우스오버 시점에 미리 받아오기
사용자가 버튼 위에 마우스를 올리는 순간은 클릭 직전이다. 이 타이밍에 동적 import()를 한 번 호출해두면 브라우저가 청크를 미리 받아둔다. 동적 import는 한 번 평가되면 같은 모듈을 두 번 받지 않으므로, 이후 실제 클릭에서 lazy가 다시 import 해도 캐시된 모듈을 즉시 가져온다.
// App.js
const handleMouseOver = () => {
// 마우스오버 시 미리 로드
(() => import('./components/ImageModal'))();
};
return (
<button
onMouseOver={handleMouseOver}
onClick={() => setShowModal(true)}
>
올림픽 사진 보기
</button>
);마우스를 올렸다 떼는 시간 동안 다운로드가 끝나면, 클릭 시점엔 이미 청크가 준비돼 있다.
마운트 직후 백그라운드 사전 로딩
마우스오버가 일어나지 않는 경로(모바일 탭, 키보드 포커스 등)도 있어서 다른 보강책을 같이 뒀다. 컴포넌트가 마운트된 직후 useEffect에서 한 번 동적 import를 돌려둔다. 주요 콘텐츠가 다 그려진 다음, 후순위로 필요한 모듈을 백그라운드에서 받아두는 전략이다.
// App.js
function App() {
const [showModal, setShowModal] = useState(false);
useEffect(() => {
// 페이지 로딩 완료 후 백그라운드에서 로드
(() => import('./components/ImageModal'))();
}, []);
return (
// ... JSX
);
}적용 후엔 모달 트리거 클릭과 거의 동시에 렌더가 붙었다. 청크가 이미 메모리에 들어와 있기 때문이다.
이미지도 같이 — new Image()로 캐시에 박아두기
상세 모달의 무게는 자바스크립트만이 아니었다. 모달이 열리면 큰 원본 이미지를 띄우는데, 이 이미지도 첫 표시까지 시간을 잡아먹는다. new Image()로 인스턴스를 만들고 src를 지정하면 브라우저가 이미지를 요청해 캐시에 넣어둔다. 화면에 붙이지 않아도 네트워크 요청은 일어난다.
// App.js
useEffect(() => {
// 컴포넌트와 이미지 동시 사전 로딩
(() => import('./components/ImageModal'))();
const preloadImage = new Image();
preloadImage.src = "https://example.com/large-image.jpg";
}, []);여러 장이라면 배열을 돌리면 된다.
// App.js
useEffect(() => {
const imageUrls = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
'https://example.com/image3.jpg'
];
imageUrls.forEach(url => {
const img = new Image();
img.src = url; // 브라우저 캐시에 저장
});
}, []);캐시가 실제로 먹었는지는 개발자 도구 Network 탭에서 확인한다. 첫 요청은 실제 파일 크기(예: 2.1MB)가 찍히지만, 두 번째 요청부터는 (from memory cache) 또는 (from disk cache)로 표시된다. 한 번 받은 이미지가 다시 네트워크를 타지 않는 흐름이 그대로 보인다.