mason
mason-log.

이미지가 브라우저에서 렌더링 되는 과정.

mason

masonfe-hyunsu

🧵 1. 개요.

  • <img>src를 넣는 순간부터, 이미지가 화면에 보이기까지는 한 단계가 아니다.

  • 요청, 수신, 디코드, 페인트가 이어지고, 각 구간이 끝나는 타이밍도 다르다.

  • 이 포스팅에서는 그 단계를 정리하고, 타이밍을 알면 이미지 로딩 UX를 어떻게 챙길 수 있는지도 같이 본다.

한 줄로 요약하면, 이미지 렌더는 요청(fetch) → 수신(load) → 디코드(decode) → 페인트(paint)이고, 이 타이밍을 알면 더 나은 UX를 고민하기 쉬워진다.


📡 2. 호출부터 픽셀까지.

브라우저에 <img src="…">를 붙이거나 new Image()src를 넣는 순간, 대략 아래 순서로 흘러간다.

요청 수신 디코드 페인트 4단 흐름
  • 요청: 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 또는 CSS aspect-ratio로 박스 크기를 먼저 고정한다. 비율을 추측해 넣으면 로드 후 한 번 더 점프한다.

  • 스켈레톤 배경: 그라데이션이나 단색으로 빈 박스를 보여 준다. 이미지가 오기 전에도 "여기 미디어가 온다"는 신호가 된다.

  • 준비 완료 후 전환: load 다음 decode()까지 기다린 뒤 페이드인하고, 끝나면 스켈레톤 배경을 걷는다. object-fit: contain이면 배경을 안 치울 때 테두리가 비치기 쉽다.

핵심은 파이프라인 어디에 UX를 걸지에 가깝다. 수신만 보고 배경을 치우면 이르고, decode() 이후에 치우면 빈 틈과 잔상이 덜하다. 이 경계를 알아 두면 이미지 로딩 UX를 챙기기 수월하다.


🔗 7. 참고 자료.

이전 글
npm 스코프 배포.

댓글

불러오는 중...
목록으로