이미지가 브라우저에서 렌더링 되는 과정.
masonfe-hyunsu
🧵 1. 개요.
<img>에src를 넣는 순간부터, 이미지가 화면에 보이기까지는 한 단계가 아니다.요청, 수신, 디코드, 페인트가 이어지고, 각 구간이 끝나는 타이밍도 다르다.
이 포스팅에서는 그 단계를 정리하고, 타이밍을 알면 이미지 로딩 UX를 어떻게 챙길 수 있는지도 같이 본다.
한 줄로 요약하면, 이미지 렌더는 요청(fetch) → 수신(load) → 디코드(decode) → 페인트(paint)이고, 이 타이밍을 알면 더 나은 UX를 고민하기 쉬워진다.
📡 2. 호출부터 픽셀까지.
브라우저에 <img src="…">를 붙이거나 new Image()로 src를 넣는 순간, 대략 아래 순서로 흘러간다.

요청: URL을 보고 캐시를 먼저 본다. 히트면 네트워크를 거의 건너뛰고, 아니면 HTTP(S)로 가져온다.
수신: 응답 바이트가 모이면
load가 뜬다. 이 시점complete는 true여도, 아직 픽셀로 풀렸다는 뜻은 아니다.디코드(decode, 압축된 이미지 바이트를 픽셀 버퍼로 푸는 작업): JPEG, PNG, WebP 등을 실제 그릴 수 있는 형태로 푼다. CPU/GPU 비용이 여기서 나온다.
페인트, 합성: 레이아웃에 자리를 잡고 레이어에 올린다.
object-fit, 라운드 같은 스타일도 이 구간에 겹친다.
포맷이나 해상도에 따라 디코드 비용이 달라지고, 같은 "로드됨"이어도 화면에 안정적으로 붙는 시점은 조금 더 뒤일 수 있다.
🔎 3. 구간이 끝나는 신호.
각 단계가 끝났는지 알려 주는 API는 겹쳐 보이지만, 가리키는 구간이 다르다.
complete +
naturalWidth: 이미 캐시돼 있으면 동기적으로 true다. 메타(가로, 세로)를 읽을 수 있다는 쪽에 가깝다.load/error: 바이트 수신이 끝나거나 실패한 지점이다.img.onload/img.onerror에 붙인 핸들러가 여기서 호출된다.decode(): Promise가 resolve되면 디코드까지 끝나 그리기 안전한 상태에 가깝다. (MDN decode())
onLoadingComplete: React/
next/image가 감싼 콜백이다. 보통load계열에 가깝고,decode()대기까지 보장한다고 보진 않는다.
수신이 끝난 뒤 디코드까지 확인하려면 load(또는 complete) 다음에 decode()를 이어서 보면 된다.
async function whenImageDecoded(img: HTMLImageElement) {
if (!img.complete || img.naturalWidth === 0) {
await new Promise<void>((resolve) => {
const done = () => {
img.removeEventListener('load', done);
img.removeEventListener('error', done);
resolve();
};
img.addEventListener('load', done);
img.addEventListener('error', done);
});
}
if (typeof img.decode === 'function') {
await img.decode();
}
}
load는 "바이트가 왔다"이고,decode()는 "그릴 픽셀이 준비됐다"에 가깝다.
decoding="async"는 디코드를 메인 스레드 밖으로 미루는 힌트다. 파이프라인 자체를 바꾸기보다, 언제 디코드 비용을 치를지에 가깝다.
🖼️ 4. 레이아웃에서 화면까지.
디코드가 끝나도 바로 "사용자가 본 것"은 아니다. 브라우저는 크기와 위치를 잡고, 픽셀을 레이어에 올려 합성한다.

레이아웃:
width/height속성, CSS 크기,aspect-ratio로 박스 크기를 정한다. 크기를 나중에야 알면 주변 텍스트가 밀린다.래스터, 페인트: 디코드된 버퍼를 실제 그릴 영역에 맞춘다. 축소, 크롭(
object-fit)이 여기서 적용된다.합성(compositing): 다른 레이어와 겹쳐 최종 프레임을 만든다. opacity, transform이 있으면 합성 경로가 달라질 수 있다.
그래서 같은 이미지라도 CSS로 크게 키우거나, 여러 장을 한꺼번에 붙이면 디코드, 페인트 비용이 눈에 띄게 늘어난다.
⚙️ 5. 경로를 바꾸는 조건.
기본 파이프라인은 같지만, 아래 조건이면 체감 구간이 달라진다.
캐시 히트: 네트워크 수신이 거의 사라지고,
complete가 바로 true인 경우가 많다. 그래도 디코드는 다시 필요할 수 있다.lazy: 뷰포트 근처까지
src요청을 미룬다. 호출 시점이 늦어질 뿐, 이후 수신→디코드→페인트 순서는 같다.포맷, 해상도: WebP/AVIF와 큰 PNG는 디코드 비용이 다르다. intrinsic 크기와 표시 크기가 많이 어긋나면 스케일 비용도 붙는다.
에러: 404, 깨진 바이트면
error로 끝나고 페인트 단계로 못 간다.
여기까지가 src부터 화면까지 기본 줄기다. 다음 절에서는 이 빈 구간에 UX를 어떻게 걸 수 있는지 본다.
🎨 6. 스켈레톤으로 빈 구간 메우기.
파이프라인을 알아도, 수신, 디코드가 끝나기 전 화면은 비어 있거나 레이아웃이 한 번 밀릴 수 있다. 그 틈을 스켈레톤 UI로 메우는 패턴을 정리해 본다.
자리 확보: HTML
width/height또는 CSSaspect-ratio로 박스 크기를 먼저 고정한다. 비율을 추측해 넣으면 로드 후 한 번 더 점프한다.스켈레톤 배경: 그라데이션이나 단색으로 빈 박스를 보여 준다. 이미지가 오기 전에도 "여기 미디어가 온다"는 신호가 된다.
준비 완료 후 전환:
load다음decode()까지 기다린 뒤 페이드인하고, 끝나면 스켈레톤 배경을 걷는다.object-fit: contain이면 배경을 안 치울 때 테두리가 비치기 쉽다.
핵심은 파이프라인 어디에 UX를 걸지에 가깝다. 수신만 보고 배경을 치우면 이르고, decode() 이후에 치우면 빈 틈과 잔상이 덜하다. 이 경계를 알아 두면 이미지 로딩 UX를 챙기기 수월하다.
🔗 7. 참고 자료.
HTMLImageElement: decode() - MDN — decode Promise와 DOM 삽입 타이밍
HTMLImageElement: complete - MDN — 캐시, 동기 complete 판별
HTMLImageElement: decoding - MDN — async/sync/auto 디코드 힌트
댓글