대니
danny's blog@dannywon_dev
발행 81 · 대기 101

1인 개발자. 클로드 코드를 팀처럼 굴린다. 매일 겪은 것만 쓴다.

버그와 함정

SSR 에서 img onError 가 안 불린다

onError 를 걸었는데 폴백이 안 뜬다. naturalWidth 를 찍어보니 이미 끝난 실패였다.

깨진 사진 자리에 회색 네모를 대신 띄우려고 했다. 흔한 일이다.

<img src={src} onError={() => setBroken(true)} />

그런데 화면이 그대로였다. 여전히 깨진 아이콘과 alt 글자가 떠 있었다.

코드를 다시 봤다. 맞다. 저장했나 확인했다. 저장됐다. 서버를 재시작했다. 그대로였다.

complete: true, naturalWidth: 0

브라우저 콘솔에서 그 이미지 요소를 직접 찍어봤다.

[...document.querySelectorAll('img')].map(i => ({
  alt: i.alt, complete: i.complete, naturalWidth: i.naturalWidth
}))
// → { alt: "monologue", complete: true, naturalWidth: 0 }

complete: true — 로드가 끝났다. naturalWidth: 0 — 그런데 폭이 0이다.

로드가 끝났는데 폭이 0이면 실패한 것이다. 실패는 이미 확정돼 있었다. 그런데 onError 는 안 불렸다.

처음엔 리액트가 이벤트를 놓쳤나 싶어 리액트를 의심했다. 그다음엔 캐시를 의심했다. 서버를 두 번 더 재시작했다.

원인은 순서였다

서버에서 렌더한 페이지는 이런 순서로 화면에 뜬다.

  1. 서버가 완성된 HTML 을 보낸다. 그 안에 <img src="..."> 가 이미 들어 있다.
  2. 브라우저가 그 이미지를 즉시 받으러 간다. 자바스크립트가 아직 안 돌았어도 상관없다 — HTML 파서가 태그를 보는 순간 요청이 나간다.
  3. 404 가 온다. 실패 확정.
  4. 그제서야 자바스크립트가 실행되고, 리액트가 화면에 붙으면서 onError 핸들러를 건다.

실패는 3번에서 끝났고, 핸들러는 4번에서 붙었다. 이벤트는 한 번 지나가면 다시 안 온다. 그러니까 onError 는 앞으로 실패하면에만 유효하지, 이미 실패했으면에는 아무것도 안 한다.

클라이언트에서만 그리는 화면이었다면 이 문제가 안 생긴다. 리액트가 태그를 만들면서 핸들러도 같이 붙이니까 순서가 뒤집힐 일이 없다. 서버 렌더를 쓸 때만 생기는 함정이다.

그리고 고약한 점은 개발하면서는 잘 안 걸린다는 것이다. 로컬에선 이미지가 대부분 정상이라 실패 자체가 없고, 실패하는 사진 하나를 만나야만 드러난다.

두 겹으로 잡았다

붙는 순간에 이미 실패해 있나를 직접 확인하게 했다.

const ref = useRef<HTMLImageElement>(null);

useEffect(() => {
  const el = ref.current;
  // 붙기 전에 이미 실패했으면 onError는 안 온다 — 직접 본다
  if (el?.complete && el.naturalWidth === 0) setBroken(true);
}, [src]);

<img ref={ref} src={src} onError={() => setBroken(true)} />

둘 다 필요하다. useEffect 는 붙기 전에 실패한 것을, onError 는 붙은 뒤에 실패한 것을 잡는다. 느린 네트워크에서는 붙을 때까지도 로딩 중이라 후자로 넘어온다.

window error 리스너는 접었다

처음엔 창 전체에 에러 리스너를 달면 한 번에 해결되지 않나 싶었다.

window.addEventListener('error', handler)

이건 실제로 이미지 로드 실패도 잡는다. 그런데 너무 많이 잡는다. 스크립트 로드 실패, 광고 차단기가 막은 요청, 사용자 네트워크가 끊긴 것까지 전부 온다. 에러 수집을 붙여둔 상태라면 깨진 사진 하나가 로그를 채운다.

구분법은 있다 — 리소스 실패는 event.error 가 비어 있고 event.target 이 창이 아니라 요소다. 그래서 그 리스너에서는 리소스 실패를 걸러내고, 이미지 자체는 위의 두 겹으로 처리하는 쪽으로 갔다.

← 목록으로