Chapter 4

이미지 갤러리 최적화

  • 4.1 레이아웃 이동 피하기 (CLS)
  • 4.2 이미지 지연 로딩
  • 4.3 리덕스 렌더링 최적화
  • 4.4 병목 코드 최적화

이미지 갤러리를 열면 이미지들이 로드되면서 레이아웃이 들썩들썩 움직이는 경험 해본 적 있지? 이게 바로 **CLS(Cumulative Layout Shift)**야.

이미지가 로드되기 전에는 높이가 0이었다가, 로드되면서 갑자기 공간을 차지하니까 아래 콘텐츠가 밀려내려가는 거거든. Core Web Vitals 세 가지 지표 중 하나이고, Google 검색 랭킹에도 영향을 줘. 읽고 있던 텍스트가 갑자기 내려가거나, 클릭하려던 버튼이 밀려서 엉뚱한 걸 누르게 되는 사용자 경험 문제가 직접적으로 생기지.

해결 방법은 의외로 간단해 — 이미지 영역의 크기를 미리 잡아두면 돼. <img> 태그에 widthheight 속성을 지정하면 브라우저가 이미지 로드 전에 가로세로 비율을 계산해서 공간을 미리 확보해. CSS의 aspect-ratio 속성으로 고정하는 방법도 있고, placeholder로 블러 처리된 저해상도 이미지를 먼저 보여주는 Medium 스타일 기법도 있어. CLS를 유발하는 다른 원인들로는 동적으로 삽입되는 광고 배너, 웹 폰트 로드 후 텍스트 크기 변화, API 응답 후 리스트 추가 같은 것들도 있어.

이미지 갤러리는 이미지가 수십~수백 장이니까 지연 로딩의 효과가 더 크지. rootMargin을 설정하면 뷰포트 아래 200px까지 미리 감지해서, 사용자가 스크롤했을 때 이미 로드가 완료되어 있을 확률이 높아져. 네이티브 지연 로딩(<img loading="lazy" />)도 있지만, 세밀한 제어가 필요하면 Intersection Observer를 직접 쓰는 게 나아.

리덕스 쪽에서도 성능 문제가 숨어 있어. 이미지 하나를 클릭하면 모달이 열리는데 갤러리 전체가 리렌더링되는 거야. useSelector가 매번 새 객체를 반환하면 리덕스가 "값이 바뀌었네" 하고 리렌더링을 트리거하거든. 해결법은 세 가지야. useSelector를 분리해서 원시값을 반환하게 하거나, shallowEqual을 두 번째 인자로 넘겨서 얕은 비교를 하거나, ReselectcreateSelector로 메모이제이션하는 거지.

// 나쁜 예 - 매번 새 객체 생성
const { modalOpen, selectedImage } = useSelector(state => ({
  modalOpen: state.modal.open,
  selectedImage: state.modal.image
}));

// 좋은 예 - 원시값 반환
const modalOpen = useSelector(state => state.modal.open);
const selectedImage = useSelector(state => state.modal.image);

병목 코드 최적화도 한 단계 올라가. 1장에서는 비효율적인 코드를 효율적으로 바꾸는 수준이었는데, 여기서는 아키텍처 레벨까지 가거든. useMemo로 같은 이미지에 같은 필터를 다시 적용할 때 이전 결과를 캐싱하거나, Web Worker로 CPU 집약적 작업을 메인 스레드에서 빼서 UI가 안 얼게 만드는 거야. 이미지 처리 같은 무거운 작업은 메인 스레드에서 수백 ms 동안 점유하면서 UI를 얼려버리거든.


정리

4장 읽고 기억할 거 세 가지:

  1. CLS는 사전에 공간을 확보하면 해결된다. width/height 명시, aspect-ratio, placeholder — 핵심은 이미지 로드 전에 레이아웃이 확정되어 있어야 한다는 것
  2. 리덕스 리렌더링은 셀렉터 설계가 전부. 새 객체를 반환하는 셀렉터가 불필요한 리렌더링의 주범. useSelector 분리, shallowEqual, Reselect로 해결
  3. CPU 집약적 작업은 메인 스레드에서 빼라. 메모이제이션으로 반복 계산을 줄이고, 그래도 무거우면 Web Worker로 넘겨서 UI 응답성을 지켜야 한다