2026.04.25
new Image()가 아니라 <link rel="preload">인가?IMAGE_SIZES를 컨테이너별로 분리한 게 왜 중요한가?img.decode()는 왜 결국 안 쓰기로 했나?next/image가 화면에 들어오면 그때서야 이미지를 받기 시작한다. 모달이나 캐러셀처럼 유저 클릭 직후 즉시 큰 이미지가 떠야 하는 케이스는 클릭 시점에 fetch가 시작되어 200~800ms 빈 화면이 보인다.
해결: 유저가 클릭하기 전에 이미지를 미리 받아 놓기. 두 가지 시점에서 프리로드한다.
next/image가 응답을 캐시에 넣어 두므로, 실제 <img>가 마운트될 때 새 fetch 없이 캐시 hit으로 즉시 paint된다.
new Image() → <link rel="preload">로 바꾼 이유new Image())const img = new Image();
img.src = `https://yourdomain.com/_next/image?url=...&w=1200&q=75`;
브라우저는 img.src로 지정된 단일 URL을 fetch해서 메모리/디스크 캐시에 적재한다. 단순하고 동작은 한다.
하지만 결정적 문제가 있다.
next/image는 이렇게 렌더된다:
<img
srcset="
/_next/image?url=...&w=640&q=75 640w,
/_next/image?url=...&w=750&q=75 750w,
/_next/image?url=...&w=828&q=75 828w,
/_next/image?url=...&w=1080&q=75 1080w,
/_next/image?url=...&w=1200&q=75 1200w,
..."
sizes="(max-width: 768px) 100vw, 768px"
/>
브라우저는 현재 뷰포트 폭, DPR, sizes 힌트를 종합해서 srcset 후보 중 하나만 골라 fetch한다. 어떤 후보가 뽑힐지는 디바이스마다 다르다.
1200w 후보 선택1920w 또는 2048w 후보 선택750w 또는 828w 후보 선택new Image()로는 개발자가 미리 한 후보를 추측해서 강제로 받을 수밖에 없다. 추측이 빗나가면:
프리로드: 1200w 후보 받음 (캐시에 적재)
실제 <img>: 1920w 후보 선택 → 캐시 미스 → 새 fetch → 프리로드는 헛수고
<link rel="preload" imagesrcset imagesizes>)const link = document.createElement("link");
link.rel = "preload";
link.as = "image";
link.setAttribute("imagesrcset", "/_next/image?url=...&w=640&q=75 640w, ... 3840w");
link.setAttribute("imagesizes", "(max-width: 768px) 100vw, 768px");
document.head.appendChild(link);
브라우저는 <img>와 동일한 srcset 알고리즘으로 후보를 고른다.
뷰포트/DPR/sizes를 보고 자기 기준으로 1개를 선택해 fetch한다. 그래서 나중에 <img srcset=... sizes=...>가 마운트되면 자기가 골랐던 그 URL이 이미 캐시에 있다. 100% hit.
프리로드의 정답은 "어떤 URL을 받을지 정하는 것"이 아니라 **"브라우저에게 후보 목록과 결정 규칙을 통째로 넘기는 것"**이다. 그래야 실제
<img>와 같은 결정을 내린다.
이게 이번 작업의 가장 중요한 부분이었다!
imagesrcset / imagesizes라는 속성HTML 표준에 정식으로 있는 link preload 전용 속성.
<img>의 srcset/sizes와 동일한 문자열을 받는다.
브라우저가 link와 img의 매칭 규칙을 동일하게 적용하라고 만든 속성이다.
같은 문자열을 두 곳에 넣어주는 게 정합성의 핵심.
IMAGE_SIZES를 컨테이너별로 분리한 이유sizes 속성은 브라우저에게 "이 이미지가 표시될 슬롯의 폭이 얼마인지" 알려주는 힌트다.
부정확하면 브라우저가 잘못된 후보를 고른다.
IMAGE_SIZESconst IMAGE_SIZES = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw";
이걸 그리드 썸네일/모달 본문/콘텐츠 상세 모두에 똑같이 사용했다. 문제:
33vw로 알려줌 → 브라우저가 너무 큰 후보 fetch → 데이터 낭비 + 느림max-w-3xl = 768px 고정): 데스크톱 1440px에서 실제는 ~700px인데 33vw = 475px로 힌트 → 작아 보이게 만들고 있음. 브라우저가 작은 후보를 고르거나, DPR 2면 큰 거 고르거나... 일관성 없음33vw로 힌트 → 매우 부정확// 그리드 카드: sm 3열, md 4열, lg 5열, xl 6열
export const IMAGE_SIZES_GRID =
"(max-width: 640px) 33vw, (max-width: 768px) 25vw, (max-width: 1024px) 20vw, 16vw";
// 모달 본문: 모바일 풀폭, 데스크톱은 max-w-3xl=768px 고정
export const IMAGE_SIZES_MODAL =
"(max-width: 768px) 100vw, 768px";
// 콘텐츠 상세: 모바일은 95vw, 데스크톱은 ~1109px이라 1100px로 힌트해서 1200w 후보 노림
export const IMAGE_SIZES_POOLSOOP =
"(max-width: 768px) 95vw, 1100px";
각 컨테이너가 자기에 맞는 sizes를 가지면:
<img> 양쪽에 넘김 → 캐시 hit 정확성 보장그리드 카드에서 hover 시 프리로드할 때, sizes를 IMAGE_SIZES_GRID가 아니라 IMAGE_SIZES_MODAL로 줘야 한다.
그리드 카드 자체의 썸네일은 이미 priority로 처리되어 별도 프리로드가 필요 없다.
hover 프리로드의 진짜 의도는 "다음에 열릴 모달의 본문 이미지를 미리 받는 것" 이다.
모달 sizes로 받아야 모달 마운트 시점에 캐시 hit이 일어난다. 코드 한 줄 차이지만,
의도와 sizes를 일치시키지 않으면 모든 노력이 무의미해진다.
img.decode()를 결국 안 쓴 이유decode()가 뭔지이미지가 표시되려면 두 단계가 필요하다.
<img>가 페인트될 때 디코드가 발생하는데, 큰 이미지면 메인 스레드 블로킹이 생겨 화면이 튄다.
img.decode()는 디코드를 사전에 백그라운드에서 끝내 두는 메서드다.
const img = new Image();
img.src = url;
await img.decode(); // 디코드까지 끝나고 resolve
이론적으로는 프리로드 시 decode까지 같이 해 두면 paint 시 디코드 비용도 0이 되어 더 빠르다.
문제는 <link rel=preload imagesrcset>와 Image() + decode()의 매칭이 보장되지 않는다는 것.
// 시나리오:
const link = document.createElement("link");
link.rel = "preload";
link.as = "image";
link.imageSrcset = "...640w, ...828w, ...1200w";
link.imageSizes = "(max-width: 768px) 100vw, 768px";
// → 브라우저가 srcset 알고리즘으로 1200w 후보 선택해 캐시에 적재
const img = new Image();
img.srcset = "...640w, ...828w, ...1200w";
img.sizes = "(max-width: 768px) 100vw, 768px";
await img.decode();
// → 이 Image() 인스턴스도 srcset 알고리즘 적용.
// 그러나 link과 다른 currentSrc를 고를 수 있다 (스펙 차이, 타이밍 차이)
// → 만약 1080w를 골랐다면 → 새로 fetch → 캐시 미스 → 프리로드 두 배
link와 Image()의 후보 선택이 어긋나면:
link로 받은 1200w는 캐시에 들어갔지만 미사용 (낭비)Image()가 새로 1080w를 fetch (추가 egress)<img>가 마운트될 때 또 다른 후보를 골라 또 캐시 미스 가능→ decode 한 번 끼워넣으려다가 fetch가 두세 번 일어날 위험.
decode가 가져오는 이득(메인 스레드 디코드 비용 100~200ms)보다, 매칭 미스로 인한 추가 fetch 손실이 훨씬 크다. 게다가:
onload만으로 충분히 빠름 (브라우저가 이미 디스크 캐시에 넣음)결정: link.onload를 "성공" 신호로만 사용. decode는 안 쓴다.
향후 진짜로 첫 1장의 LCP가 더 중요해지면,
첫 1장에 한해서만 Image+decode를 옵션으로 켤 여지를 남겨 뒀다
(PreloadImagesOptions에 decodeFirst?: boolean 추가 가능). 지금은 끔.
"더 좋아 보이는 최적화"가 시스템 다른 부분과 매칭되는지 검증하는 것이 더 중요하다. 두 메커니즘이 같은 기준으로 결정을 내리지 않으면, 각각 잘 동작해도 합쳤을 때 깨진다. 통합 정합성 > 개별 최적화.
프리로드는 무료가 아니다. 6장을 동시에 받으면:
특히 모달이 열린 직후처럼 인터랙션 직후에 6개 fetch가 동시에 시작되면 모달 자체의 첫 paint가 늦어진다.
preloadImages(srcs, options)
├─ srcs[0]: 즉시 시작 + fetchpriority="high"
└─ srcs[1..N-1]: requestIdleCallback로 미루고 fetchpriority="low"
requestIdleCallback의 의미브라우저가 "지금 한가하다(다음 paint 전 여유 시간이 있다)"고 판단할 때만 콜백을 실행한다.
인터랙션이 진행 중이면 미뤄지므로 유저가 체감하는 반응성을 해치지 않는다.
Safari/iOS는 미지원이라 setTimeout(cb, 1)로 폴백 — 약하지만 micro-task 큐에 양보 정도는 됨.
fetchpriority의 의미브라우저에게 네트워크 큐 우선순위 힌트. high는 다른 low/auto 자원보다 먼저 받음. low는 늦게. 우리는 "첫 1장만 진짜 중요" 사실을 브라우저에 정확히 알려준다.
프리로드는 도움이 되지만 너무 많이 하면 페널티가 된다. "필요한 만큼만, 적절한 우선순위로." 메인 스레드와 네트워크 큐를 자원으로 인식하고 budgeting하는 게 프론트 퍼포먼스의 본질.
linkRegistry (모듈 스코프 Map)const linkRegistry = new Map<string, { element: HTMLLinkElement; refCount: number; ... }>();
같은 이미지를 여러 곳에서 프리로드 시도해도 link element는 한 번만 생성하고 refCount로 관리. cleanup 시 refCount=0이면 link 제거. React StrictMode(dev에서 effect를 두 번 실행)에서도 link 중복 없이 동작하게 하려는 안전장치.
PreloadHandle { done, cleanup } APIconst handle = preloadImages(srcs, options);
useEffect(() => handle.cleanup, [handle]);
// 컴포넌트 unmount 시 진행 중인 idle 콜백 취소 + link 제거(refCount 감소)
unmount 후에도 link가 head에 남아 있거나 idle 콜백이 살아 있으면 누수. cleanup으로 명시적 회수.
if (typeof document === "undefined") {
return { done: Promise.resolve(0), cleanup: () => {} };
}
서버 렌더에서 호출되어도 안전하게.
| 결정 | 이유 |
|---|---|
new Image() → <link rel=preload imagesrcset> | next/image의 srcset과 동일 매칭 알고리즘 → 캐시 hit 100% 보장 |
IMAGE_SIZES → 컨테이너별 분리 | 브라우저에게 정확한 슬롯 폭을 알려서 딱 맞는 후보 선택 |
| 그리드 hover preload는 모달 sizes 사용 | hover의 의도는 모달 본문 캐시 적재이므로 |
img.decode() 비활성 | link와 Image의 매칭 미스 위험이 decode 이득보다 큼 |
| 첫 1장 high 즉시 + 나머지 idle low | 메인 스레드/네트워크 budgeting. 인터랙션 우선 |
linkRegistry refCount + cleanup | 중복 link 방지, 메모리/누수 방지, StrictMode 안전 |
특히 srcset+sizes의 알고리즘은 한 번 정독하면.. 이 아니라, 두 번 세 번 보고있다 ㅠㅠ