본문으로 건너뛰기
fe.run

이미지 로딩을 React에 맡기기

글 복사 완료!

img가 Promise를 하나 갖고 있어요. 거기에 경계 두 개만 세우면 돼요.

·12분·

1편에서 onLoad 로 만든 상태가 새는 자리를 셋 봤어요. 원인은 하나였죠. 로딩이라는 시간의 문제를 상태라는 값의 문제로 바꿔 풀고 있었다는 것. img 에는 이미 시간을 시간 그대로 다루는 메서드가 있고, 그게 돌려주는 Promise 를 캐시해서 use 에 넘기면 나머지는 경계 두 개가 맡아요.

decode가 Promise를 돌려줘요

HTMLImageElement.decode() 예요. 이름만 보면 디코딩 관련 저수준 API 같은데, 하는 일이 우리가 원하던 바로 그거예요.

"The decode() method of the HTMLImageElement interface returns a Promise that resolves once the image is decoded and is safe to be appended to the DOM." - MDN

디코딩까지 끝나서 DOM 에 붙여도 안전한 시점에 resolve 되는 Promise 를 준다는 뜻이에요. load 이벤트보다 한 걸음 더 간 지점이라, 붙이자마자 다음 프레임에서 깜빡이는 일도 줄어요.

재밌는 건 MDN 이 이 메서드를 설명하면서 1편의 코드를 직접 언급한다는 거예요.

"Without a Promise-returning method, you would add the image to the DOM in a load event handler, and handle the error in the error event's handler." - MDN

Promise 를 돌려주는 메서드가 없었다면 load 핸들러에서 DOM 에 넣고 error 핸들러에서 에러를 처리했을 거라는 서술이에요. 우리가 1편에서 짠 게 정확히 그 코드죠. 문서가 대안으로 제시하는 쪽이 decode() 고요.

실패도 Promise 규약을 따라요. 요청이 실패했거나, decode() 를 부른 뒤에 src 가 바뀌었거나, 데이터가 손상됐을 때 EncodingError 로 reject 돼요. 성공은 resolve 로 실패는 reject 로 오니까, 이제 이벤트 두 개를 각각 붙잡던 걸 하나의 흐름으로 다룰 수 있어요.

캐시가 없으면 fallback이 깜빡여요

Promise 를 React 에서 읽는 공식 도구가 use 예요. 상태 훅이 아니라 리소스를 읽는 도구라 조건문 안에서도 부를 수 있고요.

다만 아무 Promise 나 넘기면 안 돼요. 여기에 이 패턴의 유일한 함정이 있어요.

"Promises created during render are recreated on every render, which causes React to show the Suspense fallback repeatedly and prevents content from appearing." - React 공식 문서

렌더 중에 만든 Promise 는 렌더마다 새로 만들어져서, 폴백만 반복해서 뜨고 내용은 영영 안 나온다는 뜻이에요. React 가 suspend 된 렌더의 상태를 보존하지 않고 처음부터 다시 그리기 때문이에요.

그래서 src 를 키로 하는 캐시가 필요해요. 같은 주소면 같은 Promise 인스턴스가 나와야 하거든요. 모듈 스코프에 Map 하나면 충분해요.

const imageCache = new Map();
 
function loadImage(src) {
  let entry = imageCache.get(src);
  if (!entry) {
    const img = new Image();
    img.src = src;
    entry = img.decode().then(() => src);
    imageCache.set(src, entry);
  }
  return entry;
}

이게 전부예요. 이제 컴포넌트는 로딩 상태를 하나도 들고 있지 않아요.

경계 두 개를 세워요

읽는 쪽은 세 줄이면 끝나요. use 가 Promise 를 읽는 동안 컴포넌트는 suspend 되고, 가장 가까운 Suspense 가 폴백을 그려요. reject 되면 가장 가까운 에러 경계로 흘러가고요.

여기서 에러 경계는 선택이 아니라 필수예요. 문서가 못을 박거든요.

"use cannot be called inside a try-catch block. Instead, wrap your component in an Error Boundary to catch the error and display a fallback." - React 공식 문서

use 는 try-catch 안에서 못 불러요. 대신 컴포넌트를 에러 경계로 감싸라고 안내해요. 굳이 감싸면 "Suspense Exception" 이라는 낯선 에러를 보게 돼요.

에러 경계는 아직 클래스로만 쓸 수 있어요. 2026년에 클래스를 쓰는 게 이상해 보이지만 Suspense 안쪽 동작과 마찬가지로 이건 현재 상태예요. 공식 문서도 함수형 대안이 없다고 밝히면서 react-error-boundary 같은 라이브러리를 권합니다.

아래 데모가 1편의 명령형 버전과 정확히 같은 일을 해요. 다만 컴포넌트 안에 status 같은 상태가 하나도 없어요.

재시도는 캐시에서 해당 항목을 지우고 경계를 다시 마운트시키는 게 전부예요. 실패한 Promise 가 캐시에 남아 있으면 몇 번을 눌러도 같은 실패를 다시 읽거든요. key 를 바꿔서 경계를 새로 세우는 이유고요.

어디까지가 공식인가

이 패턴을 쓰기 전에 경계선을 알아둘 값어치가 있어요. Suspense 를 트리거하는 방법으로 흔히 "Promise 를 throw 한다" 고 설명하는 글이 많은데, 공식 문서는 그렇게 쓰지 않아요.

"Suspense does not detect when data is fetched inside an Effect or event handler. It only activates in the cases listed below." - React 공식 문서

활성화되는 경우를 아래 목록에 열거한 것으로 한정한다는 뜻이에요. 그 목록에 lazyuse 는 있고, Promise 를 직접 throw 하는 방식은 없어요.

오해하지 않는 게 중요해요. React 가 throw 방식을 금지한다고 적어둔 건 아니에요. 다만 인정 목록에도 없어서, 문서화되지 않은 내부 동작에 기대는 셈이 돼요. 실제로 use 자체가 내부적으로 throw 해서 Suspense 와 맞물리거든요. throw 는 use 의 구현 방식이지 우리가 쓸 API 가 아닌 거죠. 그래서 이 글은 use 만 씁니다.

한계도 하나 있어요. use 문서는 Promise 를 렌더 전에, 그러니까 이벤트 핸들러나 라우트 로더에서 만들어두길 권해요. 위 패턴은 렌더 중에 캐시를 채우니까 그 권고와 완전히 겹치지는 않아요. 캐시가 있어서 폴백이 반복되진 않지만, 이미지 요청이 렌더 시점까지 밀리는 건 사실이에요.

그 간격은 요청을 앞당겨서 메울 수 있어요. react-dompreloadas: "image" 를 지원하거든요.

import { preload } from "react-dom";
 
// 목록을 그리기 전에, 또는 링크에 마우스를 올릴 때
preload("/photos/hero.jpg", { as: "image" });

같은 계열의 preinitscriptstyle 만 받아서 이미지에는 못 써요. 이미지는 preload 쪽이에요. 요청을 미리 띄워두면 decode() 가 붙을 때 이미 받아둔 상태라 폴백이 스쳐 지나가듯 짧아져요. 이미지가 지표를 흔드는 방식을 생각하면 이 조합이 특히 값어치가 있고요.

두 편을 정리하면 이래요. img 는 로딩을 값으로 알려주지 않아서 우리가 상태를 만들었고, 그 상태가 캐시와 에러 경계 두 곳에서 샜어요. decode() 가 그 시간을 Promise 로 돌려주니까, 캐시 하나 두고 use 로 읽으면 로딩과 실패가 각자의 경계로 흘러가요. 컴포넌트에는 이미지를 그리는 코드만 남고요. 선언적이라는 말이 추상적으로 느껴졌다면, 상태 세 줄이 사라지는 이 차이가 그 말의 실물에 가까워요.

참고 자료

관련 글