이미지 리스트를 비동기적으로 불러와 렌더링하는 방식은 많은 서비스에서 널리 사용되지만,
로딩 중 이미지 크기나 공간이 미리 확보되지 않으면, 콘텐츠가 갑자기 밀리거나 위치가 바뀌는 문제가 발생할 수 있습니다.
이는 “단순히 이미지가 늦게 뜨는 문제”를 넘어 사용자가 보는 UI 전체의 일관성과 신뢰도에 영향을 줄 수 있습니다.
이 글에서는 왜 이러한 문제가 발생하는지, 브라우저 렌더링 원리와 사용자 경험 관점에서 살펴보려고 합니다.
문제 상황
이미지 리스트 로딩 중 발생한 UI 불일치 문제
API를 통해 이미지 리스트를 불러왔을때 먼저 도착한 이미지부터 순차적으로 화면에 표시되어
전체 UI가 한 번에 렌더링되지 않고, 로딩 속도에 따라 이미지가 하나씩 들쭉날쭉 나타나는 현상이 발생했습니다.
이로 인해 사용자 입장에서는 콘텐츠의 위치가 불안정하게 흔들리거나
화면이 깜빡이는 것처럼 느껴지는 문제가 생기고 결과적으로 초반 UI의 일관성이 깨지게 되었습니다.
이러한 문제를 확인하고, 이 글에서는 해당 문제에 대해 어떤 기술적 접근과 구현 방식을 선택했는지 정리해보았습니다.
동적으로 이미지를 불러오는 과정
Note (동적 렌더링 핵심 흐름)
JS 실행 -> DOM 변경(필요시) → 브라우저는 해당 영역의 레이아웃을 재 계산(Reflow) → 다시 그림(Repaint) → 최종 합성(Composite)
동적으로 이미지를 불러올 때, 왜 화면이 흔들릴까?
- 각 이미지의 요청/응답 속도가 다르기 때문에 렌더링 순서가 일치하지 않을 수 있습니다.
- 이미지의
width,height가 지정되지 않았다면, 브라우저는 공간을 확보하지 못하고 로드 시점마다 레이아웃을 재계산(Reflow)을 합니다. - Reflow는 모든 영향을 받는 요소와 그 자손의 크기와 위치를 다시 계산해야 하므로 성능에 영향을 주며, 특히 반복적으로 발생하면 버벅거림을 유발할 수 있습니다
Warning (Reflow가 반복 발생하는 이유)
- API로 받아온 이미지는 각각 다른 시간에 도착하고 크기가 정해지지 않으면 도착할 때마다 Reflow가 발생합니다.
- 그로 인해 UI 전체가 순간적으로 이동하거나 깨진 듯 보일 수 있습니다.
Tip (Reflow란?)
Reflow란 브라우저 렌더링 과정 중 첫 번째 레이아웃이 일어난 이후에,
페이지의 일부분이나 전체 문서에 대한 크기나 위치(노드의 크기와 위치)를 다시 계산하는 것입니다.
이미지 로딩이 UI에 미치는 영향

API로 받아온 이미지는 JavaScript 실행 후 렌더링되기 때문에,이미지마다 요청 및 로딩 시점이 달라지고, 각 이미지의 크기를 사전에 알 수 없는 경우 순차적으로 Reflow가 발생합니다. 이미지 크기를 지정하지 않으면, 로딩 전에 브라우저는 요소의 공간을 확보하지 못합니다. 사용자 입장에서는 처음엔 아무것도 없었다가, 이미지들이 툭툭 튀어나오듯 순서 없이 등장하게 됩니다.
이 과정에서 다른 콘텐츠가 갑자기 밀리거나 위치가 바뀌는 듯 보이는데, 이는 브라우저가 이미지의 크기를 뒤늦게 파악하고 레이아웃을 다시 계산(Reflow)하기 때문입니다.
즉, 사용자에게는 “카드가 갑자기 중구난방으로 나오는 것처럼” 보이지만, 그 실제 원인은 초기 공간이 확보되지 않은 데서 시작된 Reflow의 반복입니다.
Danger (결과적으로 나타나는 UX 문제)
- 이미지 크기가 제각각인 것처럼 보이고 화면이 위아래로 밀리며 레이아웃 시프트 현상이 발생합니다.
- 콘텐츠 인지 흐름이 깨져 사용자가 빠르게 이탈할 가능성이 증가합니다.
Tip (레이아웃 시프트와 reflow의 상관관계)
레이아웃 시프트 (Layout Shift)**는 리플로우 과정의 시각적인 결과입니다.
리플로우가 발생하여 요소의 크기나 위치가 변경될 때, 페이지 상의 다른 요소들이 갑자기 밀리거나 움직이는 것처럼 보이는 현상이 레이아웃 시프트입니다.
즉, 리플로우는 브라우저가 요소의 크기와 위치를 재계산하는 내부적인 연산이고,
레이아웃 시프트는 그 재계산의 결과로 사용자가 화면에서 경험하는 시각적인 변화입니다.
해결 방법
방법 1: 모든 이미지 로드가 완료된 이후에 일괄 표시하는 방식
모든 이미지의 로딩이 완료된 후에 전체 UI를 한 번에 표시하는 방식입니다.
구현 방법: Promise.all() 및 Promise.allSettled(권장)을 사용해 이미지들이 모두 로드될 때까지 기다렸다가 한꺼번에 렌더링합니다.

11 collapsed lines
const [gallery, setGallery] = useState<GalleryItem[]>([]); const [loading, setLoading] = useState(false);
useEffect(() => { const fetchGallery = async () => { try { setLoading(true); const res = await fetch('https://picsum.photos/v2/list'); const data: GalleryItem[] = await res.json();
// 모든 이미지 로드 대기 await Promise.allSettled( data.map( (item) => new Promise<void>((resolve, reject) => { const img = new Image(); img.src = item.download_url; img.onload = () => resolve(); img.onerror = () => reject(); }), ), );
setGallery(data); } catch (err) { console.error('Error fetching gallery:', err); } finally { setLoading(false); } };
fetchGallery(); }, []);
21 collapsed lines
return ( <> <div aria-busy={loading} role="status" aria-label={loading ? '갤러리 이미지를 불러오는 중입니다' : '갤러리 이미지 목록'} className="grid w-full grid-cols-5 gap-4 p-4" > {loading ? new Array(30) .fill(0) .map((_, i) => ( <div key={i} className="aspect-square w-full animate-pulse bg-gray-300" aria-hidden="true"></div> )) : gallery.map((item) => ( <div key={item.id} className="animate-fade-in aspect-square overflow-hidden"> <img src={item.download_url} alt={item.author} className="h-full w-full object-cover" /> </div> ))} </div> </> );| 장점 | 레이아웃 유지, UX 안정성 |
| 단점 | 느린 이미지에 전체가 묶일 수 있음 |
방법 2: 각 이미지에 스켈레톤(Skeleton) UI 적용
이미지 로딩 전, 동일한 크기의 Skeleton UI를 먼저 표시하고, 이미지가 로드되면 교체하는 방식입니다.
구현 방법: <img> 태그의 onLoad 이벤트를 사용해 이미지가 로드되면 opacity 값과 transition 등을 고려한 부드러운 전환 처리를 하여 Skeleton 위에 자연스럽게 표시되도록 합니다.

22 collapsed lines
const [gallery, setGallery] = useState<GalleryItem[]>([]); const [loading, setLoading] = useState(false); const [loadedImages, setLoadedImages] = useState<string[]>([]);
useEffect(() => { const fetchGallery = async () => { try { setLoading(true); const res = await fetch('https://picsum.photos/v2/list'); const data: GalleryItem[] = await res.json();
setGallery(data); } catch (err) { console.error('Error fetching gallery:', err); } finally { setLoading(false); } };
fetchGallery(); }, []);
const handleonLoad = (id: string) => { setLoadedImages((prev) => [...prev, id]); };
9 collapsed lines
return ( <> <div aria-busy={loading} role="status" aria-label={loading ? '갤러리 이미지를 불러오는 중입니다' : '갤러리 이미지 목록'} className="grid w-full grid-cols-5 gap-4 p-4" > {loading ? new Array(30) .fill(0) .map((_, i) => ( <div key={i} className="aspect-square w-full animate-pulse bg-gray-300" aria-hidden="true"></div> )) : gallery.map((item) => ( <div key={item.id} className={ 'aspect-square overflow-hidden transition-all' + (loadedImages.includes(item.id) ? '' : 'animate-pulse bg-gray-200') } > <img src={item.download_url} alt={item.author} className={5 collapsed lines
'h-full w-full object-cover transition-opacity' + (loadedImages.includes(item.id) ? ' opacity-100' : ' opacity-0') } onLoad={() => handleonLoad(item.id)} /> </div> ))} </div> </> );| 장점 | 이미지 로딩 여부와 무관하게 레이아웃을 먼저 보여줄 수 있어 구조 인지에 유리. 콘텐츠를 순차적으로 노출시켜 이탈률을 줄일 수 있음. |
| 단점 | Skeleton 자체는 실제 콘텐츠가 아니기 때문에 시각적 몰입도가 떨어질 수 있음. Skeleton 크기가 정확하지 않으면 Reflow 발생. |
Warning (주의)
Skeleton이 실제 이미지와 다른 크기일 경우, 이미지가 교체되는 순간 Reflow가 발생해 레이아웃이 흔들릴 수 있습니다. → Skeleton과 이미지의 크기를 동일하게 유지하는 것이 중요합니다.
방법 3: Progressive Image Loading
사용자에게 저해상도 이미지를 먼저 보여주고, 이후 고해상도 이미지로 자연스럽게 전환하는 방식입니다. 로딩 중임을 느끼게 하기보다, “이미 이미지가 나왔다”는 심리적 안정을 주는 UX 전략입니다.
Skeleton이 이미지 공간만 확보하는 반면, 이 방식은 실제 이미지 자체를 흐리게 보여준다는 점에서 다릅니다.
구현 방법:
저해상도 썸네일 이미지에 Blur 필터를 적용해 먼저 렌더링한 뒤, 고해상도 이미지로 자연스럽게 교체합니다.
Next.js의 next/image 컴포넌트에서는 placeholder="blur"와 blurDataURL 속성을 사용해 구현할 수 있으며
blurDataURL은 base64로 인코딩된 저용량 이미지이며, plaiceholder 등의 도구를 사용해 생성할 수 있습니다.
React 환경에서는 직접 구현이 복잡할 수 있으나, Next.js의 next/image 컴포넌트를 사용하면 비교적 쉽게 적용 가능합니다.
| 장점 | 빠른 피드백 제공 (사용자는 이미지가 로딩 중임을 빠르게 인지) |
| 단점 | 직접 구현 시 난이도 높음 서버 또는 빌드 환경에서 이미지 BlurDataURL 처리가 필요할 수 있음 |
최종 선택 및 적용
최종적으로, 개별 이미지에 Skeleton UI를 적용하는 방식을 선택했습니다.
프로젝트가 React 기반이라 구현 복잡도가 높은 Progressive Image Loading보다는
구현 복잡도가 낮고 UI 흐름을 안정적으로 유지할 수 있으며, 콘텐츠를 순차적으로 노출시켜 이탈률을 줄일 확률이 높은 Skeleton 처리 방식이 더 적합하다고 판단했습니다:
Cumulative Layout Shift로 확인해본 적용 결과
적용 전


Cumulative Layout Shift (CLS) 점수
- 이미지가 순차적으로 들쭉날쭉 표시되며 콘텐츠의 흐름이 끊김,
- 레이아웃 시프트가 반복돼 “콘텐츠가 흔들린다”는
불안정한 인상을 줌.
적용 후


Cumulative Layout Shift (CLS) 점수
- 이미지 공간을 Skeleton으로 미리 확보하여,
이미지가 자연스럽게 전환되며 콘텐츠 흐름이 안정적으로 유지 - UI의 완성도와 신뢰도, CLS 점수가 향상됨.
Tip (실무에서 Skeleton UI를 적용할 때 유의할 점)
- 이미지 크기와 Skeleton 크기를 정확히 일치시켜야 Reflow가 발생하지 않음
- transition 효과를 적절히 넣으면 시각적 전환이 더 부드러움
- CLS 측정을 위해 Chrome DevTools의 Performance 탭을 통해 확인 가능.
Note (Reflow, Layout Shift, CLS 정리)
Reflow는 브라우저 내부의 연산이고, Layout Shift는 그 시각적 결과입니다. 그리고 CLS(Cumulative Layout Shift)는 이 Layout Shift가 얼마나 반복적으로, 심각하게 일어났는지를 측정한 웹 성능 지표입니다.
마무리
문제의 본질은 이미지 자체가 늦게 로딩되는 것이 아니라, 로딩 중 이미지 크기가 미리 확보되지 않아 발생한 레이아웃 시프트(Layout Shift)였습니다. 그로 인해 UI가 들쭉날쭉 흔들리는 듯한 인상을 주었고, 결과적으로 사용자 경험의 일관성과 몰입감이 무너졌습니다.
사용자는 웹사이트를 방문한 뒤 몇 초 안에 이탈 여부를 결정합니다. 콘텐츠를 보기 전 떠나버린다면, 그 이탈은 피드백을 줄 기회조차 사라졌다는 뜻이 됩니다. 물론 이탈 자체도 하나의 피드백일 수 있지만, 그것이 전부가 되어서는 안 됩니다.
Nielsen Norman Group의 연구에 따르면, 사용자는 웹 페이지에 평균 10~20초 정도만 머무르며, 이 짧은 시간 내에 명확한 가치 전달이 이뤄지지 않으면 이탈 가능성이 높다고 합니다.
그래서 UI가 처음 렌더링되는 순간부터 시각적 안정성과 정보의 인지 흐름을 설계하는 것이 중요합니다. Skeleton UI나 로딩 스피너와 같은 피드백 요소는 사용자가 콘텐츠를 기다리는 동안 불필요한 불안감을 줄이고, “지금 로딩 중이다”는 신호를 시각적으로 제공해 이탈을 줄이는 데 기여합니다.
또한, 환경에 따라 Next.js의 next/image와 blurDataURL을 활용한 Progressive Image Loading 기법을 적용하면 보다 부드러운 시각적 전환을 통해 UX를 한층 더 향상시킬 수 있습니다.
사용자의 이탈률은 웹사이트의 성공에 직접적인 영향을 미치므로, 다양한 방법으로 사용자의 이탈을 방지하고, 더 나은 피드백을 받을 수 있도록 해야합니다.
읽어보면 좋은 Tip
기타 방법: 로딩 스피너(Spinner) 보여주기
이미지가 로드되기 전, 각 위치 또는 화면 중앙에 원형으로 돌아가는 로딩 스피너(Spinner)를 표시합니다. 이미지가 로드되면 스피너를 숨기고 이미지를 보여줍니다.
| 장점 | 사용자가 로딩 중임을 명확하고 빠르게 인식할 수 있음(Skeleton보다 더 직관적인 피드백 제공) Skeleton보다 가볍고 빠르게 구현 가능 |
| 단점 | Skeleton처럼 콘텐츠의 자리 확보는 되지 않아 로딩 중 UI가 들쭉날쭉 흔들릴 수 있음 |
Tip (언제 이 방법이 특히 유용할까?)
- 사용자가 이탈하기 쉬운 로딩 구간에서 스피너는 즉각적으로 ‘기다림’을 인지시켜 이탈률을 줄이는 데 효과적
- 개별 이미지가 독립적으로 로드되고, Skeleton보다 가벼운 피드백이 필요할 때
- 이미지 수가 많지 않고, 전체 UI의 자리 확보가 중요하지 않을 때
- Skeleton을 사용하기 어려운 디자인이거나, 전체 레이아웃이 유동적인 경우
기타 로드 방법: 정적 이미지 로딩과 동적 이미지 로딩
Tip (정적 이미지 로딩과 동적 이미지 로딩의 차이)
브라우저는 HTML 문서를 정적으로 파싱할 때, 프리로드 스캐너를 통해 <img> 등의 리소스를 미리 탐색하고 요청하여 초기 렌더링 속도를 높일 수 있습니다.
하지만 API로 데이터를 받아 JavaScript로 이미지를 동적으로 렌더링하는 경우, 이 최적화는 작동하지 않습니다.
또한 프리로드 스캐너가 작동하더라도, 이미지 로딩 속도는 서버 응답 속도, 이미지 크기 등에 따라 달라질 수 있기 때문에,
결국 UI가 순차적으로 흔들리거나, Reflow가 발생하는 상황은 여전히 생길 수 있습니다.
Note (프리로드 스캐너란?)
프리로드 스캐너는 메인 HTML 파서와 별도로 동작하는 “미리보기 역할”을 하고 있습니다.
- 메인 파서가 DOM 트리를 차근차근 만드는 동안, 프리로드 스캐너는 HTML 문서를 빠르게 훑어보며 병렬로 동작합니다.
- 주요 역할은 리소스 선탐색(preloading)입니다.
- HTML 내
<link>,<img>,<script>,<video>,<audio>등 외부 리소스를 사용하는 태그들을 미리 발견합니다. - 메인 파서가 해당 태그에 도달하기도 전에, 브라우저가 해당 리소스를 미리 다운로드 요청할 수 있도록 합니다.
즉, 프리로드 스캐너는 렌더링 성능을 높이기 위한 브라우저의 최적화 전략 중 하나로, 스크립트나 이미지 등의 로딩을 앞당겨 병목을 줄이는 데 중요한 역할을 합니다.
ari Space
