이미지 로딩 실패해도 화면이 안 깨지게 만드는 법 — React Skeleton + 재시도 전략
![]()
이미지를 S3에 올려놓고 Lambda로 썸네일을 생성하는 구조, 많이들 쓰잖아요. 근데 이 구조에는 생각보다 짜증나는 문제가 하나 있거든요. 썸네일이 아직 안 만들어졌는데 프론트에서 이미 이미지를 요청한다는 거예요. 그 사이 2~3초, 사용자는 빈 화면을 멍하니 쳐다보고 있어야 하는데 — 솔직히 그거 UX 아니잖아요.
스켈레톤이 해결하는 건 "체감 속도"다
스켈레톤 UI라는 게 별거 아닌 것 같아도, 실제로 세 가지 문제를 동시에 잡아줘요.
첫 번째, 사용자 경험. 빈 화면 vs 뼈대가 보이는 화면. 둘 다 기다리는 건 똑같은데, 후자는 "뭔가 오고 있다"는 맥락이 있어서 체감 로딩 시간이 확 줄어들더라고요. 두 번째는 CLS(Cumulative Layout Shift) 방지예요. 이미지가 갑자기 툭 나타나면서 레이아웃이 밀리는 현상, Core Web Vitals 점수 깎이는 주범이잖아요. 구글이 CLS 수치 0.1 이하를 요구하는데, 스켈레톤으로 미리 공간을 잡아두면 이 수치를 확보할 수 있어요. 세 번째가 바로 SEO 점수 향상. CLS 개선이 Core Web Vitals 지표에 직접 영향을 주니까, 결국 검색 순위까지 연결되는 거고요.
근데 핵심은 따로 있어요. 이 모든 게 "이미지가 결국 로딩된다"는 전제 위에 서 있다는 점이에요. Lambda가 썸네일을 만들어주길 기다리면서 한 번만 시도하고 포기하면? 스켈레톤이고 뭐고 의미가 없죠.
`new Image()`가 `<img>` 태그보다 나은 이유
재시도 로직을 만들려면 이미지 로딩을 코드로 제어할 수 있어야 해요. `<img>` 태그의 `onLoad`, `onError` 이벤트 핸들러를 쓰는 방법도 있긴 한데, 코드가 금방 복잡해지더라고요.
그래서 등장하는 게 브라우저 기본 제공 DOM 클래스인 `Image` 객체예요. `new Image()`로 생성하고, `src` 속성에 URL을 넣으면 바로 로딩이 시작돼요.
```javascript const image = new Image() image.onload = () => { ... } image.onerror = () => { ... } image.src = "https://example.com/images/temp.png" ```
`<img>` 태그랑 `Image` 객체는 동일한 네트워크 스택을 사용해요. HTTP 요청도 같고, CORS 정책도 같고요. 약간 다른 점이라면 `<img>`는 HTML 파싱 중에 로딩을 시작하고, `Image` 객체는 자바스크립트 실행 시점에 시작한다는 것 정도. (사실 이 차이가 재시도 로직에서는 오히려 장점이에요.)
좀 웃긴 건, URL이 동일하면 캐시가 공유된다는 거예요. `Image` 객체로 preload하고, 나중에 `<img>` 태그로 보여줘도 네트워크 요청은 딱 1회만 발생해요. 공짜 preloading인 셈이죠.
재귀로 만드는 1초 간격 5회 재시도
자, 이제 진짜 핵심. 이미지 로딩이 실패하면 1초 간격으로 최대 5회 재시도하는 함수를 만들어볼게요.
```typescript export const loadImageWithRety = async ( url: string, timeout: number = 1000, retry: number = 4 ): Promise<HTMLImageElement> => { return new Promise((resolve, reject) => { const image = new Image() image.onload = () => { resolve(image) } image.onerror = () => { setTimeout(() => { if(retry > 0) { loadImageWithRety(url, timeout, retry - 1) .then(resolve).catch(reject) } else { reject(new Error("over maximum retry")) } }, timeout) } image.src = url }) } ```
재귀적 Promise 체이닝이에요. `onerror`가 발생하면 `setTimeout`으로 1초 뒤에 `retry - 1`로 다시 호출하고, 성공하면 `resolve`, 횟수 초과하면 `reject`. 깔끔하죠?
이걸 테스트하려면 `Image` 클래스를 모킹해야 하는데, vitest의 `stubGlobal`로 처리할 수 있어요.
```typescript const mockImage = { onload: null, onerror: null, src: "", } as unknown as HTMLImageElement;
beforeEach(() => { vi.stubGlobal("Image", vi.fn(function(this: HTMLImageElement) { return mockImage; })); }); ```
테스트 케이스는 세 가지예요. URL이 제대로 설정되는지, 중간에 에러 나도 재시도 후 성공하면 정상 반환하는지, 그리고 최대 횟수를 초과하면 예외를 던지는지. 에러 2번 발생 후 성공하는 테스트를 보면:
```typescript test("retries on error and eventually resolves", async () => { const resultPromise = loadImageWithRety("https://example.com/temp.png", 100, 2); mockImage?.onerror?.(new Event("error")); vi.advanceTimersByTime(100); mockImage?.onerror?.(new Event("error")); vi.advanceTimersByTime(100); mockImage?.onload?.(new Event("load")); const result = await resultPromise; expect(result.src).toEqual("https://example.com/temp.png"); }); ```
`vi.advanceTimersByTime`으로 타이머를 수동 제어하는 게 포인트예요. 실제로 1초씩 기다리면 테스트가 영원히 걸릴 테니까요.
SkeletonImage 컴포넌트 — 세 가지 상태를 깔끔하게
재시도 로직이 준비됐으니 이제 React 컴포넌트를 만들 차례예요. 상태는 딱 세 가지밖에 없어요. 로딩 중(스켈레톤), 성공(이미지), 실패(대체 이미지).
```tsx export const SkeletonImage = ({ src }: Props) => { const [isLoaded, setIsLoaded] = useState(false); const [isError, setError] = useState(false);
useEffect(() => { loadImageWithRety(src) .catch(() => setError(true)) .finally(() => setIsLoaded(true)); }, [src]);
if (!isLoaded) { return <div role="status" aria-label="skeleton" className="skeleton" style={imageStyle} />; } return isError ? <div role="img" aria-label="alternate-image" style={{ ...imageStyle, backgroundColor: "darkblue" }} /> : <img src={src} alt="thumbnail-image" style={imageStyle} />; }; ```
`useEffect`에서 `loadImageWithRety`를 호출하고, `.catch`로 에러 플래그, `.finally`로 로딩 완료 플래그를 세팅해요. `isLoaded`가 `false`인 동안 스켈레톤이 보이고, `true`가 되면 성공/실패에 따라 분기하는 거예요. 단순한 것 같아도, 이 패턴이 실무에서 꽤 잘 먹혀요.
테스트도 세 상태를 각각 검증하면 돼요. 로딩 중일 때 `skeleton`이 보이는지, 성공 시 `thumbnail-image`가 뜨는지, 실패 시 `alternate-image`가 나오는지. (접근성 라벨을 셀렉터로 쓰는 것도 좋은 패턴이에요.)
실제로 돌려보면
백엔드에서 50% 확률로 404를 던지는 이미지 엔드포인트를 만들어놓고 테스트하면, 스켈레톤이 반짝이다가 이미지가 나타나는 걸 눈으로 확인할 수 있어요.
```python @app.get("/images") def get_image(): if random.random() >= 0.5: return JSONResponse(status_code=404, content={"detail": "Not Found"}) return FileResponse(DUMMY_IMAGE_PATH, media_type="image/png") ```
50%면 꽤 가혹한 조건이잖아요. 근데 5회 재시도면 전부 실패할 확률이 3.125%. 실제 Lambda 썸네일 생성은 이것보다 훨씬 안정적이니까, 대부분의 경우 2~3초 안에 이미지가 뜰 거예요.
결국 이 컴포넌트가 하는 일은 간단해요. "아직 안 됐어? 잠깐만. 다시 해볼게." 그게 전부인데, 사용자 입장에서는 화면이 안 깨지고, 기다리면 결과가 나온다는 신뢰를 주는 거예요. 비동기 처리에서 프론트가 할 수 있는 최선이 뭔지 생각해보면 — 결국 이런 작은 배려거든요.