CLS 이미지 레이아웃 시프트 방지: aspect-ratio와 서버사이드 dimensions


PageSpeed Insights에서 CLS 점수를 처음 봤을 때, 점수가 왜 이렇게 나쁜지 감이 안 왔습니다. 화면이 깜빡이거나 느리다는 느낌은 없었거든요. 그런데 실제로 블로그 글 하나를 열어서 이미지가 로딩되는 과정을 천천히 지켜보니 문제가 보였습니다. 이미지가 뜨는 순간 아래쪽 텍스트 전체가 밀려 내려갔습니다.
CLS가 뭔가
CLS(Cumulative Layout Shift)는 구글의 Core Web Vitals 지표 중 하나입니다. 페이지가 로딩되는 동안 요소가 예상치 못하게 이동하는 정도를 측정합니다. 사용자가 읽고 있던 문단이 갑자기 아래로 밀리거나, 클릭하려던 버튼이 다른 위치로 옮겨가는 현상이 여기에 해당합니다.
구글은 이 지표를 검색 랭킹에 반영합니다. CLS가 나쁘면 검색 순위에 불이익을 받을 수 있습니다. 하지만 검색 순위보다 더 직접적인 문제는 사용자 경험입니다. 글을 읽다가 화면이 흔들리면 짜증이 납니다.
이미지가 가장 흔한 원인입니다. HTML에서 <img> 태그에 width와 height를 지정하지 않으면, 브라우저는 이미지를 다운로드하기 전까지 그 자리에 0px 높이의 공간을 잡습니다. 이미지가 로딩되면 실제 높이만큼 공간이 늘어나고, 그 아래에 있던 모든 요소가 밀려납니다.
이미지 로딩 전 이미지 로딩 후 ┌─────────────────────┐ ┌─────────────────────┐ │ 제목: 스프링 빈 생명주기│ │ 제목: 스프링 빈 생명주기│ ├─────────────────────┤ ├─────────────────────┤ │ [이미지: 높이 0px] │ │ │ ├─────────────────────┤ │ 이미지 (400px) │ │ 본문 텍스트가 여기 │ │ │ │ 있었는데... │ ├─────────────────────┤ │ │ │ 본문 텍스트가 여기 │ ← 400px 밀려남 │ │ │ 있었는데... │ └─────────────────────┘ └─────────────────────┘
FullStackFamily의 문제 상황
FullStackFamily에서는 블로그, 게시판, 도서 뷰어 등 여러 곳에서 마크다운을 렌더링합니다. react-markdown 라이브러리를 쓰고 있고, 커스텀 img 컴포넌트로 이미지를 처리하고 있었습니다.
문제는 이 커스텀 컴포넌트가 CLS를 전혀 고려하지 않았다는 점입니다. 마크다운의  문법에는 width/height 정보가 없습니다. react-markdown이 이걸 <img src="url" alt="alt"> 태그로 변환하면, 브라우저는 이미지 크기를 알 수 없으니 높이 0으로 시작합니다.
이미지가 한두 개면 큰 문제가 안 됩니다. 그런데 블로그 글에 이미지가 5개, 10개 들어가면 이야기가 달라집니다. 스크롤하다가 이미지가 로딩될 때마다 아래 콘텐츠가 툭툭 밀려나고, 읽던 위치를 자꾸 놓칩니다.
원인: 마크다운 이미지 렌더링의 구조적 한계
일반 HTML이라면 답이 간단합니다. <img width="800" height="600">처럼 크기를 명시하면 됩니다. 브라우저는 이미지를 받기 전에 이 값으로 공간을 미리 잡아둡니다.
마크다운에서는 이게 불가능합니다.
HTML: <img src="photo.jpg" width="800" height="600"> 마크다운: 
마크다운 문법에는 너비와 높이를 지정하는 방법이 없습니다. react-markdown이 변환한 <img> 태그에도 당연히 width/height가 빠져 있습니다. 서버에서 마크다운을 렌더링할 때 이미지의 실제 크기를 알 방법이 없으니, 이건 마크다운 기반 서비스라면 누구나 겪는 문제입니다.
처음부터 신경 썼다면
개발 초기에 이걸 알았다면, 이미지 업로드 시점에 서버에서 width/height를 추출하고, 마크다운 렌더링 시 이 정보를 전달하는 구조를 처음부터 잡았을 겁니다.
[이상적인 흐름] 이미지 업로드 → 서버에서 width/height 추출 → DB 저장 → 마크다운에  삽입 마크다운 렌더링 → img 태그의 src에서 URL 추출 → URL로 DB 조회 → width/height 획득 → <img src="url" width="800" height="600"> 렌더링 → 브라우저가 로딩 전에 공간 확보 → CLS 0
하지만 초기에는 CLS라는 개념 자체를 몰랐습니다. "이미지가 보이면 된 거 아닌가?"라고 생각했고, 한참 뒤에 PageSpeed Insights를 돌려보고 나서야 문제를 인식했습니다.
수정: 프론트엔드 (즉시 효과)
현실적으로 두 가지를 동시에 해야 했습니다. 이미 업로드된 기존 이미지는 DB에 width/height 정보가 없으니, 우선 프론트엔드에서 가능한 범위 안에서 CLS를 줄이는 것이 먼저였습니다.
핵심 아이디어는 CSS aspect-ratio 속성입니다. 이미지가 로딩되기 전에 16:9 비율로 공간을 미리 잡아두고, 로딩이 끝나면 실제 비율로 보정하는 방식입니다.
[수정된 흐름] 이미지 로딩 전 onLoad 후 ┌──────────────────┐ ┌──────────────────┐ │ │ │ │ │ aspect-ratio │ │ aspect-ratio │ │ 16 / 9 (기본) │ │ 800 / 600 (실제) │ │ opacity: 0 │ │ opacity: 1 │ │ │ │ fade-in 0.3s │ ├──────────────────┤ ├──────────────────┤ │ 텍스트 (안 밀림) │ │ 텍스트 (미세 조정) │ └──────────────────┘ └──────────────────┘
다음은 MarkdownImage 컴포넌트의 핵심 부분입니다.
function MarkdownImage({ src, alt, ...props }) { const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading') const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null) return ( <span className="block my-4 overflow-hidden" style={{ aspectRatio: dimensions ? `${dimensions.w} / ${dimensions.h}` : '16 / 9', maxWidth: dimensions ? `min(${dimensions.w}px, 100%)` : '100%', transition: 'aspect-ratio 0.3s ease', }}> <img src={src} alt={alt || ''} className={`max-w-full h-auto transition-opacity duration-300 ${ status === 'loaded' ? 'opacity-100' : 'opacity-0' }`} onLoad={(e) => { setDimensions({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight }) setStatus('loaded') }} onError={() => setStatus('error')} /> </span> ) }
- 래퍼
<span>에aspect-ratio: 16/9를 기본값으로 설정합니다. 이미지가 로딩되기 전에도 빈 공간이 확보됩니다. onLoad에서naturalWidth와naturalHeight를 읽어 실제 비율로 교체합니다.transition: aspect-ratio 0.3s ease로 비율이 바뀔 때 부드럽게 전환됩니다.maxWidth: min(원본px, 100%)는 작은 이미지가 컨테이너 폭에 맞춰 불필요하게 늘어나는 걸 막습니다. 300px짜리 아이콘이 800px로 늘어나면 이상하니까요.opacity: 0에서opacity: 1로의 페이드인은 이미지가 갑자기 나타나는 깜빡임을 방지합니다.
에러 상태에서는 고정 높이(60px)의 플레이스홀더를 보여줍니다. 깨진 이미지 아이콘과 "삭제된 이미지" 텍스트가 표시되고, 이 영역의 높이가 처음부터 고정이니 CLS는 0입니다.
이 컴포넌트를 MarkdownViewer와 ContentViewer 두 곳에 적용했습니다. MarkdownViewer는 블로그, 게시판, 댓글에서 쓰이고, ContentViewer는 도서 뷰어에서 쓰입니다. 이 두 컴포넌트만 수정하면 이미지가 렌더링되는 거의 모든 영역을 처리할 수 있습니다.
수정: 백엔드 (미래 업로드 대비)
프론트엔드 수정으로 기존 이미지의 CLS를 줄였지만, 근본적인 해결은 서버에서 이미지 크기를 알고 있는 것입니다. 앞으로 업로드되는 이미지에 대해서는 DB에 width/height를 저장하도록 백엔드를 수정했습니다.
FullStackFamily의 이미지 업로드 파이프라인은 이미 ImageOptimizer에서 BufferedImage를 읽고 있습니다. WebP 변환과 리사이즈를 위해서죠. 여기서 width/height를 추가로 뽑아내는 건 코드 몇 줄이면 됩니다.
[수정된 업로드 파이프라인] 사용자가 이미지 업로드 (PNG/JPEG) │ ▼ ImageOptimizer.optimize() ├── BufferedImage 읽기 (리사이즈/WebP 변환용, 기존 로직) ├── width/height 추출 ← 여기가 추가된 부분 └── OptimizeResult(bytes, contentType, width, height) 반환 │ ▼ R2StorageService.uploadImage() ├── R2에 파일 저장 └── ImageUploadResult(url, width, height) 반환 │ ▼ UnifiedFile 엔티티에 저장 ├── 기존: file_url, file_name, file_size, content_type └── 추가: image_width, image_height │ ▼ FileUploadResponse로 클라이언트에 전달 └── { url, imageWidth, imageHeight }
DB 마이그레이션은 단순합니다.
ALTER TABLE unified_file ADD COLUMN image_width INT NULL; ALTER TABLE unified_file ADD COLUMN image_height INT NULL;
기존 레코드는 NULL로 남아 있고, 새로 업로드되는 이미지부터 값이 채워집니다.
기대 효과
세 가지 시나리오로 나눠 봤습니다.
| 시나리오 | CLS 동작 | 기대 효과 |
|---|---|---|
| 새 이미지 | DB에 width/height 저장됨 | 향후 SSR에서 정확한 크기 전달 가능 |
| 기존 이미지 | aspect-ratio 16:9 기본 + onLoad 보정 | 큰 shift 제거, 미세 shift만 남음 |
| 에러 이미지 | 고정 높이 60px 플레이스홀더 | CLS 0 |
기존 이미지에 대해서는 완벽하지 않습니다. 16:9가 아닌 비율의 이미지는 onLoad 시점에 미세한 shift가 발생합니다. 하지만 높이 0에서 400px로 갑자기 늘어나는 것과, 16:9 높이에서 4:3 높이로 살짝 조정되는 것은 체감 차이가 큽니다. transition이 0.3초에 걸쳐 부드럽게 바뀌니까 사용자가 인지하기 어렵습니다.
아쉬운 부분
기존 이미지에 대한 dimensions 미보유
이미 업로드된 이미지 수백 장은 DB에 width/height가 없습니다. R2 스토리지에서 이미지를 하나씩 읽어와 dimensions를 추출하는 배치 작업을 돌리면 해결되지만, 아직 만들지 않았습니다. 당장의 CLS 개선은 프론트엔드 기본값(16:9)으로 충분해서 우선순위가 밀렸습니다.
SSR에서 dimensions 미활용
DB에 width/height를 저장하더라도, 마크다운 렌더링 시점에 이 정보를 이미지 컴포넌트까지 전달하는 경로가 아직 없습니다. react-markdown의 커스텀 img 컴포넌트가 받는 props는 마크다운 파서가 넘겨주는 src와 alt뿐입니다. 마크다운 본문 안의 이미지 URL을 보고 DB에서 dimensions를 조회한 뒤, 그 정보를 react-markdown에 주입하려면 마크다운 파싱 단계에서 URL-to-dimensions 매핑을 사전에 만들어야 합니다.
충분히 구현할 수 있습니다. API에서 게시글 본문과 함께 이미지 목록(URL, width, height)을 내려주고, 프론트에서 Map으로 만들어 react-markdown의 커스텀 컴포넌트에 Context나 props로 전달하면 됩니다. 다만 기존 API 응답 구조를 바꿔야 해서 작업 범위가 넓어집니다.
Next.js Image 미사용
next/image 컴포넌트의 fill 속성을 쓰면 CLS 방지와 lazy loading, 포맷 최적화까지 한 번에 해결됩니다. 하지만 react-markdown의 커스텀 img 컴포넌트 안에서 next/image를 쓰려면 부모 요소에 position: relative와 고정 높이를 설정해야 하고, 이미지마다 적절한 높이를 알아야 합니다. 결국 dimensions 문제로 돌아옵니다.
정리
| 구분 | 변경 내용 | 효과 |
|---|---|---|
| 프론트엔드 | MarkdownImage/ContentImage에 aspect-ratio 기본값 + onLoad 보정 | 기존 이미지 CLS 대폭 감소 |
| 백엔드 | ImageOptimizer에서 width/height 추출 후 DB 저장 | 신규 이미지 dimensions 확보 |
| DB | unified_file에 image_width/image_height 컬럼 추가 | 향후 SSR 활용 기반 마련 |
CLS는 초기에 신경 쓰면 비용이 거의 0입니다. 이미지 업로드 시점에 dimensions를 저장하고, 렌더링 시 width/height를 넣어주면 그만입니다. 나중에 고치려면 프론트엔드에서 aspect-ratio로 덮고, 백엔드에서 새 컬럼을 추가하고, 기존 데이터는 배치로 채우고, SSR 전달 경로까지 만들어야 합니다. 기술 부채가 쌓이는 모양이 딱 이렇습니다.






댓글
댓글을 작성하려면 이 필요합니다.