우아한 이미지 오류 처리법을 찾아

우아한 이미지 오류 처리법을 찾아

상품 섬네일처럼 레이아웃에서 중요한 이미지를 불러오지 못했을 때, 그 자리를 비워놓거나 엑박으로 방치하지 않고 오류 또는 임시 이미지를 대신 노출해달라는 건 기본적인 요구사항이다. 그러나 그 구현은 쉽지 않다.

바닐라맛 JavaScript (onerror)

가장 쉬운 대체 방법은 이미지마다 onerror를 쓰는 것. onerror 내의 식은 인라인 대신 전역 스코프에 미리 선언해놓고 쓸 수도 있겠다.

<img
  alt="공허의 속삭임"
  src="https://example.org/void.png"
  onerror="this.src = 'https://placehold.co/140'">

불러오는 중...

React맛 JSX

onerror는 React에서 쓰기 힘드니 onError를 써야 하겠다.

<div id="app"></div>
/** @jsxRuntime classic */
import React, { useCallback, useEffect, useRef } from 'https://esm.sh/react@18'
import { createRoot } from 'https://esm.sh/react-dom@18/client'
const root = createRoot(document.getElementById('app'))
function ImageFallback({ alt, fallback, src }) {
  const doFallback = useCallback((error) => {
    error.target.src = fallback
  }, [fallback])

  return <img alt={alt} src={src} onError={doFallback}  />
}

root.render(
  <ImageFallback alt="공허의 속삭임" fallback="https://placehold.co/140" src="https://example.org/void.png" />
)

불러오는 중...

위의 코드는 대개 올바르게 작동한다. 그런데 SSR에서는 제대로 동작하지 않을 수도 있다. 어째서 그럴까?

이미지는 빠르고 스크립트는 느렸다

SSR 환경의 <ImageFallback fallback="fallback" src="src" />는 서버에서 먼저 렌더링되어 HTML 본문 내의 <img src="src">로 응답된다. 클라이언트가 제일 먼저 받는 건 이 HTML이고, 스크립트 로딩은 그 다음이고, 하이드레이션은 그 다음이다.

클라이언트는 HTML에서 이미지를 발견한 직후 요청을 시작할텐데, 만약 이 요청은 충분히 일찍 실패했고 스크립트를 불러오는 것은 충분히 느렸다면 error 이벤트가 하이드레이션보다 먼저 발생하므로 이미지 교체가 진행되지 않는다.

<div id="app"><img alt="공허의 속삭임" src="https://example.org/void.png"></div>
<output id="out"></output>
/** @jsxRuntime classic */
import React, { useCallback, useEffect, useRef } from 'https://esm.sh/react@18'
import { hydrateRoot } from 'https://esm.sh/react-dom@18/client'
function ImageFallback({ alt, fallback, src }) {
  useEffect(() => {
    document.getElementById('out').textContent = '하이드레이션 후 마운트 완료!'
  }, [])

  const doFallback = useCallback((error) => {
    error.target.src = fallback
  }, [fallback])

  return <img alt={alt} src={src} onError={doFallback}  />
}

await new Promise(resolve => setTimeout(resolve, 200))

hydrateRoot(
  document.getElementById('app'),
  <ImageFallback alt="공허의 속삭임" fallback="https://placehold.co/140" src="https://example.org/void.png" />
)

불러오는 중...

200ms는 문제를 시연하기 위해 과장한 수치이나, 하이드레이션 후 마운트 완료!가 출력됐음에도 이미지는 교체되지 않는 걸 볼 수 있다(이미지가 교체되는 경우 타임아웃을 더 늘려볼 것).

제2의 이미지 (new Image())

다음은 HTML <img>와는 별도의 new Image()를 생성하고, 이것에 onerror 이벤트를 거는 방법이다.

<div id="app"><img alt="공허의 속삭임" src="https://example.org/void.png"></div>
<output id="out"></output>
/** @jsxRuntime classic */
import React, { useEffect, useRef } from 'https://esm.sh/react@18'
import { hydrateRoot } from 'https://esm.sh/react-dom@18/client'
function ImageFallback({ alt, fallback, src }) {
  const $ref = useRef()

  useEffect(() => {
    const image = new Image()
    image.onerror = () => ($ref.current.src = fallback)
    image.src = src

    document.getElementById('out').textContent = '하이드레이션 후 마운트 완료!'

    return () => $ref.current.removeEventListener('error', handler)
  }, [])

  return <img ref={$ref} alt={alt} src={src} />
}

await new Promise(resolve => setTimeout(resolve, 1000))

hydrateRoot(
  document.getElementById('app'),
  <ImageFallback alt="공허의 속삭임" fallback="https://placehold.co/140" src="https://example.org/void.png" />
)

불러오는 중...

하이드레이션 지연에도 불구하고 잘 동작한다. 게다가 저 image 인스턴스의 onload 콜백을 활용하면 오류 대체 뿐만 아니라 로딩 대체도 가능하다. 완벽하다! 정말?

첫 번째, image.src = src에서 네트워크 요청이 한 번 더 발생한다는 점. 이미지를 정상적으로 가져온 경우 브라우저가 캐시를 대신 사용하겠지만, 오류가 발생한 경우 다시 요청해보므로 비효율적일 수 있다.

두 번째, image.src = src<ImageFallback>이 뷰포트 밖 저 멀리 있어도 컴포넌트 마운트와 함께 실행되고, 따라서 네트워크 요청이 무조건 발생한다는 점. 따라서 <img loading="lazy">를 무의미하게 만든다. image.src = src만으로는 네트워크 요청만 발생하고 디코딩은 하지 않으니 그 자원 정도는 아낄 수 있겠지만.

loading="lazy"일 땐 스크립트보다 이미지 로딩이 느릴 테니 onError를 쓰고, 아닐 땐 지금처럼 new Image()를 하면 어떨까? 하지만 고작 엑박을 대체하고 싶을 뿐인데… loading="lazy"인 이미지가 처음부터 뷰포트 내에 존재할 때에도 스크립트보다 무조건 느리게 로딩될지 또한 의문이다.

바닐라맛 HTML (<object>)

<object>를 쓰는 방법도 있다고 한다. <object>가 가리키는 외부 리소스를 사용할 수 없으면 자식 요소를 대신 렌더링하는 점을 이용하는 것이다.

<p><code>image/xml+svg</code></p>
<object
  aria-label="공허의 속삭임"
  role="image"
  type="image/xml+svg"
  src="https://exa2mple.org/void.svg">
  <img src="https://placehold.co/140">
</object>

<p><code>image/png</code></p>
<object
  aria-label="공허의 속삭임"
  type="image/png"
  src="https://exa2mple.org/void.png">
  <img src="https://placehold.co/140">
</object>
object {
  display: block;
  height: 140px;
  object-fit: cover;
  width: 140px;
}

img {
  display: block;
  height: 100%;
  object-fit: cover;
  width: 100%;
}

불러오는 중...

런타임 코드 전혀 없이 사용할 수 있는 점이 좋다. 그러나 그게 간단함을 의미하지는 않는다.

우선 대체 텍스트를 alt가 아니라 aria-label로 지정해야 함을 기억해야 한다. 그리고 Firefox를 제외한 브라우저에서는 반드시 type 특성을 올바른 MIME 유형으로 설정해야 한다. 벌써 곤란하지만, 심지어는 <object>가 가리키는 이미지와, 안쪽의 대체용 <img>가 가리키는 이미지의 MIME 유형이 같아야 한다.

Chromium 브라우저 또는 Safari로 접속 후, 위 예제의 image/png를 참고하라. 제대로 보이지 않는다. 원래 요청하려던 이미지가 PNG면 오류 이미지도 반드시 PNG여야 하고, 원래 SVG를 요청하려고 했다면 오류 이미지도 SVG여야 한다. 교차해서 사용할 수 없다. 상당한 제약이다.

바닐라맛 JavaScript 2 (error)

<img src="https://example.org/void.png" data-fallback="https://placehold.co/140">

onerror를 각 이미지마다 하지 말고, 위와 같이 선언적으로 할 수 있으면 어떨까? 제일 간단하게는…

document.querySelectorAll('img').forEach((img) => {
  img.addEventListener('error', () => (img.src = img.dataset.fallback), { once: true })
})

위 코드를 한 번 실행하는 것이겠지만, 동적으로 추가되는 이미지에 대해서는 대응할 수 없다. 이미지가 추가될 때마다 매번 실행해주는 것도 현실적이지 않고.

그래도 방법은 있다. 이벤트 위임(delegation)으로 error 모든 이벤트들을 맨 위, document에서 처리하는 것.

document.addEventListener('error', (event) => {
  const target = event.target
  if (target.tagName === 'IMG' && target.dataset.fallback) {
    target.src = target.dataset.fallback
  }
}, { capture: true })
<img
  alt="공허의 속삭임"
  src="https://example.org/void.png"
  data-fallback="https://placehold.co/140">

<img
  alt="공허의 속삭임2"
  src="https://example.org/void2.png"
  data-fallback="https://placehold.co/140">

불러오는 중...

선언적이고 스크립트도 몇 줄 안된다.

하지만 document에 이벤트 수신기를 부착하는 사이드 이펙트가 필요하고, 스크립트가 모든 <img>보다 먼저 실행돼야 하므로 <head><body> 위쪽에 위치해야 하며, asyncdefer여선 안된다. 번들러들은 모든 스크립트를 <body> 아래쪽에 논 블로킹 <script>로 추가하므로, 위 스크립트를 따로 배포한다 해도 번들러에 넣어선 안된다. 사용자가 직접 적절한 위치에 <script src="...">를 해줘야 한다. data-fallback이라는 이름 또한 컨벤션에 불과하므로 전파 비용이 존재한다.

따라서 단독 라이브러리로 배포하기도 애매하고, UI 라이브러리의 일부는 더더욱 되기 힘들다. <script src="(CDN 주소)"><head>에 추가해 주세요~ 라고 부탁할 수야 있겠다.


결론

결국 모든 상황에서 오류 처리를 쉽게 할 수 있는 방법은 없다. 모든 방법에 하자가 하나씩 있다. 제일 적절한 걸 선택하는 것도 능력이겠지만, 어째서 WHATWG는 <img placeholder="fallback">처럼 별도 특성을 추가하거나, srcset에 오류 폴백 정의를 지원하지 않는가…

보너스: 의사 요소 (::before/after, Chromium만)

잠깐! 하지만 <img>가 의사 요소를 받을 수 없다는 건 널리 알려진 사실이다. 정확히 왜 받지 못할까? 이건 CSS 명세를 봐야 한다.

… 다른 일반적인 자식 요소와 마찬가지로, ::before::after 의사 요소는 그 부모인 유래 요소(originating element)가 대체되면 무시된다.

CSS Pseudo-Elements Module Level 4

그리고 <img>는 대체되는 요소, 즉 대체 요소로 알려져있다. 대체 요소 <img>에 의사 요소를 넣고도 무시되지 않겠다는 것의 근거는 HTML 명세에서 찾을 수 있다.

사용자 에이전트는 <img> 요소를 렌더링할 때 다음 목록에서 제일 처음 만족하는 규칙을 따르는 것이 예상된다.

해당 요소가 이미지를 표현한다면

사용자 에이전트는 해당 요소를 대체 요소로 취급하고 이미지를 CSS에서 정의된 바에 따라 렌더링 하는 것이 예상된다.

해당 요소가 이미지를 표현하지 않고, 다음 중 하나에 해당한다면
  • 사용자 에이전트가 이미지를 곧 사용 가능하고 따라서 렌더링할 수 있을 거라고 믿는 이유가 있거나
  • 요소에 alt 특성이 없음

사용자 에이전트는 해당 요소를 대체 요소로 취급하고, 요소가 텍스트를 표현한다면 그 텍스트를 요소의 내용으로 취급하는 것이 예상된다. 가능하다면, 이미지를 불러오고 있음을 나타내는 아이콘을 선택적으로 표시할 수 있다.

해당 요소가 어떤 텍스트를 표현하고, 사용자 에이전트는 이것이 바뀔 것이라 판단하지 않는다면

사용자 에이전트는 해당 요소를 대체되지 않는 요소로, 그 내용은 요소가 표현하는 텍스트로 취급하는 것이 예상된다. 사용자가 이미지 표시를 요청하거나 왜 렌더링 되지 않는지 조사할 수 있도록, 이미지 누락을 나타내는 아이콘을 선택적으로 표시할 수 있다. (후략)

해당 요소가 아무것도 표현하지 않고, 사용자 에이전트는 이것이 바뀔 것이라 판단하지 않는다면

사용자 에이전트는 해당 요소를 내재적 크기가 0인 대체 요소로 취급하는 것이 예상된다. (따라서 추가 스타일이 없다면 해당 요소는 렌더링되지 않을 것이다.)

HTML Standard: 15.4.2 Images

이미지를 불러오지 못했지만 alt 특성이 존재하는 요소라면 대체 요소가 아니라는 것이다. 대체 요소가 아니라면 의사 요소도 적용할 수 있다.

<div>
  <img
    alt="공허의 속삭임"
    src="https://example.org/void.png">
  <img
    alt
    src="https://example.org/void.png">
  <img
    src="https://example.org/void.png">
</div>
div {
  display: flex;
}

img {
  display: block;
  height: 140px;
  position: relative;
  width: 140px;
}
img::after {
  background: url('https://placehold.co/140');
  content: '';
  inset: 0;
  position: absolute;
}

불러오는 중...

Chromium 계열 브라우저에서는 위 이미지 세 개 모두 임시 이미지가 잘 노출되는 걸 확인할 수 있다. 다른 브라우저에서도 동일했으면 좋았겠지만 불행하게도 전혀 그렇지 않다.

Chrome 등에서는 따라서 alt가 비어있어도, 비어있지 않아도 의사 요소를 표시한다. 빈 문자열의 alt라고 해도 이 요소가 적어도 (접근성 트리와는 별개로) 텍스트를 표현하는 걸로 취급하는 듯 하다. 그런데 세 번째 이미지, <img src="https://example.org/void.png">에서도 의사 요소가 나타난다. 두 번째 경우에서 “요소에 alt 특성이 없음”에 해당하므로 대체되어야 하는데 그렇지 않은 것이다.

반면 Firefox에서는 alt가 없는 경우 뿐만 아니라 비어있는 경우에도 <img>가 대체된다. alt가 빈 값이라면 아무것도 표현하지 못하는 장식 요소로 볼 수 있으므로, 네 번째 경우에 해당하는 “내재적 크기가 0인 대체 요소”로 그리는 걸로 보인다. 또한 alt가 아예 없는 경우에도 대체하므로 의사 요소가 나타나지 않는다. 이건 대체하는 게 명세에 맞긴 하다.

Firefox에서 보이는 화면

제일 심각한 건 Safari다. 적어도 첫 번째 이미지의 의사 요소는 표현돼야 하는데, 세 개 이미지 모두 엑박으로 나타난다. 모든 경우에 대체해버리는 것. Webkit 트래커에 이미 등록된 문제기도 하다.