이미지·첨부파일과 화면 성능을 구성하기
원본과 화면용 파일을 구분하고 표시 크기에 맞는 이미지를 전달한다. 전송량·응답 시간·화면 표시·상호작용을 각각 측정해 느린 구간을 찾는다.
이미지 저장소·압축·브라우저 요청을 구성한다
운영 시간을 포스터 이미지에만 넣으면 텍스트를 읽는 도구는 본문에서 요일과 예외를 얻기 어렵다. 포스터 주소까지 만료되면 이미지 자체도 가져올 수 없다. 운영 시간과 휴관일을 HTML로 쓰고, 사진은 접근 가능한 주소와 설명으로 제공하면 텍스트와 이미지 각각을 읽을 경로가 생긴다. Google 이미지 안내
오브젝트 저장소(object storage)는 이미지·첨부 파일을 키와 파일 내용으로 저장하는 서비스다. 버킷(bucket)은 파일을 묶는 저장 단위다. DB에는 파일 위치와 설명·공개 상태를 두고, 저장소에는 원본과 화면용 파생본을 둘 수 있다. 서명 URL(signed URL)은 서명과 만료 조건으로 파일 접근을 허용하는 주소다. 공개 페이지에서 만료가 짧은 주소를 고정 저장하면 이후 수집 시 이미지가 사라질 수 있다.
WebP·AVIF는 이미지 파일 형식이다. 인코딩(encoding)은 픽셀을 파일 형식으로 변환하고, 디코딩(decoding)은 표시할 픽셀로 되돌리는 작업이다. EXIF는 사진의 방향·촬영 정보 같은 부가 자료다. 해시(hash)는 내용에서 계산한 식별 값이며 원본 변경과 파생본 재사용을 구분하는 데 쓸 수 있다.
src는 기본 이미지 주소, srcset은 크기별 후보 주소, sizes는 화면 배치에서 예상하는 표시 폭이다. DPR(Device Pixel Ratio)은 CSS 픽셀 하나에 대응하는 장치 픽셀의 비율이다. 같은 표시 폭이라도 DPR에 따라 더 큰 후보가 선택될 수 있다. width·height나 aspect-ratio로 표시 자리를 먼저 확보하면 이미지 로드 뒤 주변 글이 밀리는 현상을 줄일 수 있다.
Brotli·gzip은 HTML·CSS·JavaScript 같은 전송 내용을 압축하는 방식이다. 서버나 CDN에서 협상한 Content-Encoding으로 전달한다. 이미 압축한 이미지의 파일 크기는 이미지 변환 과정에서 줄인다. 첫 화면의 주요 사진을 늦게 요청하는 lazy loading으로 처리하면 표시 시간이 늘 수 있으므로 첫 화면과 아래쪽 이미지를 나누어 요청 시점을 확인한다.
CORS(Cross-Origin Resource Sharing)는 브라우저가 다른 오리진의 응답을 JavaScript로 읽을 수 있는지 제어하는 규칙이다. 서버 간 조회나 일반 문서 접속의 공개 여부와는 다르다. 공개 API가 브라우저의 다른 도메인에서 호출돼야 한다면 허용할 오리진을 설정하고, 인증이 필요한 API에는 별도로 권한 검사를 적용한다.
응답 시간·화면 성능·오류율의 뜻을 알고 측정한다
TTFB(Time To First Byte)는 요청을 시작한 뒤 첫 응답 바이트가 도착할 때까지의 시간이다. DNS 조회·연결·네트워크 왕복·서버 처리 등이 영향을 준다. 서버 처리 시간은 애플리케이션 로그에서 따로 읽어야 느린 위치를 찾을 수 있다.
LCP(Largest Contentful Paint)는 화면에서 큰 주요 콘텐츠가 표시되는 시점, CLS(Cumulative Layout Shift)는 예상하지 못한 배치 이동을 나타낸다. INP(Interaction to Next Paint)는 클릭·탭·키보드 입력에서 다음 화면이 그려질 때까지의 반응 지연을 페이지 방문 전체에서 관측한 값이다. 보통 가장 긴 지연을 사용하되 상호작용 50회마다 가장 높은 값 하나를 제외하는 방식으로 극단값을 다룬다. 개별 클릭 한 번의 지연과 방문 전체의 INP를 구분한다. 여러 방문의 INP를 모은 p75는 또 다른 집계 단계다. INP 정의 · Web Vitals
랩 데이터(lab data)는 정한 기기·네트워크 조건으로 실행한 검사 결과다. 필드 데이터(field data)는 실제 이용자 환경에서 모은 측정이다. p75·p95는 관측값을 작은 순서로 놓았을 때 각각 75%·95% 위치의 값이다. 평균과 함께 분포를 보면 일부 요청만 크게 느린 문제를 찾을 수 있다. 기기·기간·표본 수를 함께 저장한다.
오류율은 대상 요청 중 실패한 요청의 비율이다. 5xx, 타임아웃, WAF 차단을 나누어 집계한다. 캐시 적중률은 캐시를 조회한 요청 중 저장된 응답으로 처리한 비율이다. 적중률이 높아도 오래된 글을 반환할 수 있으므로 응답의 revision을 함께 읽는다.
검색에서 들어온 사람이 큰 이미지가 뜰 때까지 기다리거나, 신청 버튼이 로딩 중 밀리면 정보를 읽고 행동하는 과정이 방해받는다. LCP·INP·CLS는 이 경험을 서로 다른 측면에서 측정한다. Google은 Core Web Vitals를 순위 시스템에서 사용한다고 설명한다. 생성형 답변의 인용 여부를 판단할 때는 실제 인용 결과를 따로 읽고, 이미지 접근·텍스트 설명·사용자 경험 중 무엇을 바꿨는지 구분한다. 페이지 경험과 검색
CMS 이미지 전송과 캐시를 공개 정책에 맞춘다
화면에서 작게 표시한 사진도 원본 전체를 내려받을 수 있다. 개발자 도구의 Network에서 실제 요청 URL·응답 바이트·MIME·픽셀 크기를 보고 화면의 표시 폭과 비교한다. 정적 사진은 최적화됐는데 CMS 사진만 원본을 내려주는지, srcset과 sizes가 실제로 출력되는지, 브라우저가 어느 후보를 선택했는지 확인한다.
원본은 근거 자료로 보존하고 화면용 파생 이미지를 별도로 만든다. 변환기에는 입력 용량·픽셀 수·허용 형식·처리 시간 제한을 둔다. 회전 정보를 적용한 뒤 크기를 줄이고 WebP나 AVIF 등 지원 형식으로 인코딩한다. 원본보다 확대하지 않고 투명도·애니메이션 처리도 확인한다. EXIF 제거가 필요하면 공개 파생본에 적용하고 원본을 덮지 않는다.
파생본 키에는 원본 해시·출력 폭·인코더 설정 버전을 포함한다. 같은 사진의 변환 결과를 재사용하면서 원본 교체나 품질 설정 변경 시 이전 파일을 잘못 재사용하지 않게 한다. 지원할 폭과 품질은 실제 표시 크기·DPR·화질을 비교해 정한다. 카드·본문·확대 보기의 표시 크기가 다르면 각각 필요한 파생본 폭도 달라진다.
화면에는 img의 srcset·sizes 또는 설정한 이미지 컴포넌트를 사용한다. sizes는 실제 그리드에서 이미지가 차지하는 폭에 맞춰야 한다. 모든 카드에 작은 폭을 선언하면 큰 화면에서 흐려지고, 항상 전체 화면 폭을 선언하면 불필요하게 큰 이미지를 받는다. width·height나 aspect-ratio로 자리도 확보한다. Google 이미지 안내에 따라 src 대체 주소와 대체 텍스트, 본문 맥락을 제공한다.
next/image의 unoptimized는 내장 이미지 변환을 건너뛰는 옵션이다. loader는 주어진 폭과 품질에 맞는 이미지 주소를 만드는 함수다. 사용자 정의 loader가 파생 이미지를 제공하는지, 인증이 필요한 원본이나 원본을 직접 지정한 다른 컴포넌트가 있는지 조사한다. Network에서 선택된 파생본의 폭·용량·형식을 비교한다.
캐시는 변환 비용과 접근 제어를 나누어 설계한다. 철회 후 즉시 차단해야 하는 자료라면 비공개 저장소의 파생본을 재사용하더라도 매 요청 현재 공개 상태와 권한을 확인할 수 있다. 공개 CDN에 오래 남는 immutable 응답을 사용하면 철회 뒤에도 파일이 전달될 수 있다. 원본·파생본·일반 이미지 최적화 주소까지 같은 철회 조건을 적용하고, 접근 검사를 우회하는 주소가 없는지 확인한다.
Next.js 기본 이미지 최적화기는 원본 요청에 인증 헤더를 전달하지 않는다. 인증이 필요한 원본은 요청 구조를 확인해야 하며, 공개 이미지를 제공할 별도 경로를 만들거나 기존 이미지 서비스가 만든 파생본을 사용할 수 있다. unoptimized를 설정했다면 src가 작은 파생본인지 큰 원본인지에 따라 전송량이 달라진다. loader가 /media/photo?w=640이라는 문자열을 만들더라도 그 서버가 실제 640px 파일을 만들어야 용량이 줄어든다. Next.js Image 설정
이미지 전송량과 실제 로딩 시간을 따로 측정한다
이미지 수정 전후는 같은 페이지·화면 폭·DPR·스크롤 범위·캐시 조건에서 비교한다. 요청한 사진 수, 선택된 srcset 후보, 응답 바이트, 이미지 디코딩, 원본과의 시각 차이를 기록한다. 첫 방문과 재방문을 섞으면 압축 효과와 캐시 효과를 구분하기 어렵다.
전송량 감소는 내려받은 파일 크기의 결과다. 주요 콘텐츠 표시 시간인 LCP, 반응성인 INP, 화면 이동인 CLS는 별도로 측정한다. 큰 첫 화면 이미지를 줄여도 서버 응답이나 다른 자원이 병목이면 LCP가 기대만큼 줄지 않을 수 있다. Lighthouse는 지정한 검사 환경에서 측정하고, 실제 이용자 데이터는 다양한 기기·네트워크에서 모인다. 두 결과를 따로 저장해 느린 구간을 찾는다.
필드 데이터는 제공 기간과 페이지·사이트 집계 범위를 적는다. 자료가 부족해 값이 없으면 미확인으로 기록한다. 배포 직후 전송량을 확인한 결과와 이후 집계되는 사용자 경험, 검색 노출·클릭을 분리해 비교한다. 검색 노출·클릭과 AI 인용은 각각의 관측표에서 전후 값을 비교한다.
브라우저가 선택한 이미지 주소를 직접 확인한다
make-image-demo.mjs는 도형 이미지 두 개와 HTML 한 장을 만든다. 이미지 폭은 640px·1280px이며 형식은 WebP다. Next.js 폴더에서 npm install --save-dev sharp@0.35.5를 실행한 뒤 node make-image-demo.mjs를 실행한다. 파일 생성 후 서버를 시작한다. 이미 실행 중이면 재시작해 public 파일을 인식하게 한다.
// 설치: npm install --save-dev sharp@0.35.5
// 실행: node make-image-demo.mjs (Next.js 실습 프로젝트 루트)
import fs from 'node:fs/promises';
import sharp from 'sharp';
await fs.mkdir('public/photos', { recursive: true });
const source = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720">
<rect width="1280" height="720" fill="#e8eff5"/>
<path d="M160 540V180H1120V540Z" fill="#31516b"/>
<path d="M240 260H1040M240 360H1040M240 460H1040" stroke="#e8eff5" stroke-width="32"/>
</svg>`);
for (const width of [640, 1280]) {
await sharp(source).resize({ width }).webp({ quality: 80 }).toFile(`public/photos/shelves-${width}.webp`);
}
await fs.writeFile('public/image-demo.html', `<!doctype html>
<html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>이미지 크기 선택 실습</title>
<style>body{margin:16px}main{max-width:960px;margin:auto}img{display:block;width:100%;height:auto}</style>
<main><h1>이미지 크기 선택 실습</h1>
<img src="/photos/shelves-640.webp"
srcset="/photos/shelves-640.webp 640w, /photos/shelves-1280.webp 1280w"
sizes="(max-width: 992px) calc(100vw - 32px), 960px"
width="1280" height="720" alt="세 줄의 선반을 그린 실습용 도형"
fetchpriority="high">
<p>크기별 파일 선택을 확인하는 실습용 도형입니다.</p>
</main></html>`);
console.log('Created public/image-demo.html and two WebP fixtures');
Buffer는 SVG 문자열을 파일 처리에 넘길 바이트로 만들고 sharp의 resize·webp·toFile은 각 폭의 파생 파일을 저장한다. HTML의 src는 기본 주소, srcset은 후보와 실제 픽셀 폭, sizes는 CSS 배치에서의 표시 폭이다. body의 양옆 16px과 main의 최대 960px을 sizes와 맞췄다. width·height는 16:9 공간을 먼저 확보한다.
실습 서버의 /image-demo.html을 390px와 1440px 화면에서 연다. 개발자 도구 Console에서 document.querySelector("img").currentSrc와 naturalWidth, getBoundingClientRect().width를 확인한다. Network에서 실제 요청 파일과 전송량을 읽는다. DPR과 캐시 때문에 후보 선택이 달라질 수 있으므로 좁은 화면에서도 큰 파일을 재사용할 수 있다. 새 문맥과 같은 DPR·캐시 조건으로 비교한다.
첫 화면의 주요 이미지에는 loading="lazy"를 붙이지 않았다. 아래쪽 사진에는 lazy를 적용한 뒤 스크롤 전후 요청 시점을 확인할 수 있다. alt는 이 이미지의 의미를 설명하고 장식만 하는 이미지는 빈 alt를 사용한다. 후보 파일이 404이면 public 경로와 srcset을, 표시 폭보다 큰 파일을 계속 받으면 sizes와 실제 배치를 대조한다.
srcset과 sizes는 화면에 필요한 후보를 브라우저가 고를 수 있게 한다. 390px 화면에서 큰 원본 대신 알맞은 파생본을 받으면 전송량을 줄일 수 있고, width·height는 사진이 오기 전 자리를 확보한다. 이 코드가 바꾼 결과는 currentSrc·응답 바이트·화면 이동에서 확인한다. 시간표 내용이 이미지에만 있던 문제는 별도의 본문 설명으로 고친다.
같은 CMS 이미지의 원본 요청과 파생본 요청을 비교한다
앞의 정적 파일 실습은 미리 만들어 둔 파일을 선택했다. 아래 실습은 CMS 이미지 ID photo를 요청할 때 공개 상태를 확인하고 파생본을 생성하거나 재사용한다. HTML은 같은 배치를 유지하고 원본 보기에서는 원본 URL을, 파생본 보기에서는 srcset 후보를 출력한다. 이 차이를 비교하면 CSS로 작게 보이는 사진이 실제로도 작은 파일인지 알 수 있다.
빈 폴더에 공개 데이터 편의 media-store.mjs, 문서와 시간 편의 identity-time.mjs, 구현 편의 article-jsonld.mjs, 주소와 수집 편의 feed.mjs를 저장한다. 아래를 media-demo.mjs로 저장한다. Node.js 22와 sharp 0.35.5를 사용하며 기존 Next.js 실습과 별도 폴더에서 실행한다.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
import { createMediaStore } from './media-store.mjs';
import { article, renderArticle, escapeHtml } from './article-jsonld.mjs';
import { renderFeed } from './feed.mjs';
export async function sampleImage() {
// 실행 결과를 재현할 수 있는 도형이다. 실제 사진은 실행 인자로 전달한다.
const rectangles = Array.from({ length: 1600 }, (_, i) =>
`<rect x="${(i * 67) % 1600}" y="${(i * 43) % 1000}" width="55" height="55"
fill="hsl(${i % 360},70%,50%)"/>`).join('');
return sharp(Buffer.from(`<svg width="1600" height="1000">${rectangles}</svg>`))
.jpeg({ quality: 95 }).toBuffer();
}
export function createDemoServer(store, origin = 'http://127.0.0.1:3031') {
return http.createServer(async (req, res) => {
const send = (status, type, body, extra = {}) => {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'private, no-store, max-age=0', ...extra });
res.end(body);
};
try {
const url = new URL(req.url, origin);
if (req.method !== 'GET') return send(405, 'text/plain', 'Method not allowed');
if (url.pathname === '/media/photo') {
const width = url.searchParams.has('w') ? Number(url.searchParams.get('w')) : null;
const r = await store.read(width);
return send(r.status, 'text/plain', r.body, r.headers);
}
if (url.pathname === '/rss.xml') {
// 이 실습은 기사와 사진 한 장을 함께 공개·철회한다.
const visible = (await store.read()).status === 200;
return send(200, 'application/rss+xml; charset=utf-8', renderFeed(visible ? [article] : [], origin,
new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')));
}
if (url.pathname === '/video.html' || url.pathname === '/talk.mp4') {
const body = await readFile(new URL('.' + url.pathname, import.meta.url));
if (url.pathname.endsWith('.html')) return send(200, 'text/html; charset=utf-8', body);
const headers = { 'Accept-Ranges': 'bytes', 'Content-Length': body.length };
if (!req.headers.range) return send(200, 'video/mp4', body, headers);
// 작은 로컬 영상의 단일 byte range. 운영에서는 파일 스트림·미디어 서버를 사용한다.
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
let start = match?.[1] ? Number(match[1]) : Math.max(0, body.length - Number(match?.[2]));
let end = match?.[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1;
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start)
|| !Number.isSafeInteger(end) || start > end || start >= body.length)
return send(416, 'text/plain', '', { 'Content-Range': `bytes */${body.length}` });
return send(206, 'video/mp4', body.subarray(start, end + 1), {
...headers, 'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
});
}
if (url.pathname !== '/' && url.pathname !== article.path) return send(404, 'text/plain', 'Not found');
const candidates = await Promise.all([320, 640, 1280].map(async width => {
const r = await store.read(width);
return { ...r, path: `/media/photo?w=${width}` };
}));
if (candidates.some(c => c.status !== 200)) return send(404, 'text/plain', 'Not found');
const largest = candidates.at(-1);
if (url.pathname === article.path)
return send(200, 'text/html; charset=utf-8', renderArticle(origin, largest).html);
// 작은 원본은 확대하지 않는다. 같은 폭이 된 후보는 하나만 남긴다.
const unique = [...new Map(candidates.map(c => [c.width, c])).values()];
const srcset = unique.map(c => `${c.path} ${c.width}w`).join(', ');
const original = url.searchParams.get('mode') === 'original';
return send(200, 'text/html; charset=utf-8', `<!doctype html><html lang="ko"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CMS 이미지 전송 실습</title><link rel="alternate" type="application/rss+xml" href="/rss.xml">
<style>body{margin:16px;font-family:sans-serif}img{display:block;width:min(100%,640px);height:auto}</style>
</head><body><h1>${original ? '원본' : '파생본'} 전송</h1>
<p><a href="/?mode=original">원본 보기</a> · <a href="/">파생본 보기</a>
· <a href="${article.path}">기사</a> · <a href="/video.html?t=4">영상 4초</a></p>
<img src="${original ? '/media/photo' : largest.path}"
${original ? '' : `srcset="${escapeHtml(srcset)}" sizes="(max-width:672px) calc(100vw - 32px), 640px"`}
width="${largest.width}" height="${largest.height}" alt="이미지 전송 비교용 도형">
</body></html>`);
} catch (error) {
send(error.code === 'ENOENT' ? 404 : 500, 'text/plain', 'Resource unavailable');
}
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const source = process.argv[2] ? await readFile(process.argv[2]) : await sampleImage();
const store = await createMediaStore(source);
createDemoServer(store).listen(3031, '127.0.0.1', () => console.log('http://127.0.0.1:3031'));
}
npm init -y
npm install sharp@0.35.5
node media-demo.mjs
# 다른 터미널에서 응답 비교
curl -sS -D original-headers.txt http://127.0.0.1:3031/media/photo -o original.jpg
curl -sS -D variant-headers.txt "http://127.0.0.1:3031/media/photo?w=640" -o variant.webp
# 서버 종료 후 자신의 사진으로 실행할 수도 있다.
# node media-demo.mjs ./photo.jpg
sampleImage는 1600×1000 도형을 JPEG로 만든다. 직접 만든 사진 경로를 실행 인자로 주면 같은 경로로 사진을 처리한다. 서버는 127.0.0.1에만 연결하며 외부 공개 업로드 기능은 없다. /media/photo는 원본, ?w=640은 파생본, /news/new-hours는 기사, /rss.xml은 피드다. 영상 링크는 다음 콘텐츠 편의 두 파일과 MP4를 준비하면 열린다.
후보 폭은 320·640·1280이며, sizes는 화면 양옆 여백 32px과 최대 표시 폭 640px을 반영한다. 390px 화면의 표시 폭은 358px이고 DPR 2에서는 약 716픽셀이 필요하다. 따라서 좁은 화면에서도 1280 후보를 고를 수 있다. 선택은 브라우저의 판단과 캐시·연결 조건에도 영향을 받으므로 화면 폭만 보고 320 파일을 기대하면 안 된다.
작은 원본을 넣으면 여러 후보가 같은 실제 폭이 될 수 있다. unique는 실제 폭이 같은 후보를 하나로 합친다. srcset에는 그 결과의 폭을 쓰고 img의 width·height에는 실제 비율을 쓴다. 화면 크기를 바꾼 뒤 currentSrc만 보는 데서 그치지 말고 응답 바이트와 파일 메타데이터의 폭도 비교한다.
이 서버는 실제 출력 폭을 얻기 위해 HTML을 만들기 전에 세 파생본을 준비한다. 운영 CMS에서는 업로드 때 파생본과 크기를 저장해 두면 첫 페이지 요청에서 세 번의 변환을 기다리지 않아도 된다. 업로드 완료와 공개 가능 시점을 연결하고 변환 실패 시 사용할 이미지도 정한다. 사진을 바꿨을 때는 그 사진의 대체 텍스트와 설명도 함께 고친다.
이 실습은 기사와 사진 한 장을 함께 공개하는 자료로 취급한다. 사진을 철회하면 기사·홈페이지는 404이고 RSS에서도 해당 기사를 제외한다. 실제 CMS에서 사진만 바꿀 수 있는 기사라면 본문 공개 상태와 이미지 공개 상태를 각각 저장하고, 비공개 사진을 제외한 본문을 제공할지 정한다.
파일 크기와 다운로드·표시 시점을 같은 실행에서 기록한다
아래 inspect-image.mjs는 새 브라우저 문맥에서 390·1440px, DPR 1·2, 원본·파생본의 여덟 조합을 실행한다. 응답 body의 바이트를 세고 sharp로 실제 파일 폭을 읽는다. 브라우저에서는 표시 폭·currentSrc와 Resource Timing, LCP 후보를 읽는다. img.decode() 완료를 기다린 뒤 프레임을 넘겨 표시 결과를 확인한다.
측정 전에 홈페이지를 한 번 요청해 서버 파생본을 준비한다. 브라우저 문맥은 매번 새로 만들어 이전 이미지 선택과 브라우저 캐시의 영향을 줄인다. 처음 변환하는 비용을 조사하려면 서버를 재시작한 뒤 첫 요청의 서버 처리 시간도 별도로 기록한다.
import { chromium } from 'playwright';
import sharp from 'sharp';
const browser = await chromium.launch();
const rows = [];
try {
// 서버 파생본 생성은 마친 상태로 맞춘다. 브라우저 문맥은 매번 새로 만든다.
const warm = await fetch('http://127.0.0.1:3031/');
if (!warm.ok) throw new Error('Image server unavailable');
await warm.text();
for (const width of [390, 1440]) for (const dpr of [1, 2]) for (const mode of ['original', 'variants']) {
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: dpr });
try {
const page = await context.newPage();
await page.addInitScript(() => {
window.imageLcp = [];
new PerformanceObserver(list => {
window.imageLcp.push(...list.getEntries().map(e => ({ time: e.startTime, url: e.url, tag: e.element?.tagName })));
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
const imageResponse = page.waitForResponse(r => new URL(r.url()).pathname === '/media/photo');
await page.goto('http://127.0.0.1:3031/' + (mode === 'original' ? '?mode=original' : ''));
const response = await imageResponse;
const bytes = await response.body();
const metadata = await sharp(bytes).metadata();
await page.locator('img').evaluate(img => img.decode());
// 두 번의 프레임을 기다려 decode 이후 표시와 LCP 항목을 읽는다.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const observed = await page.locator('img').evaluate(img => {
const resource = performance.getEntriesByName(img.currentSrc).at(-1);
return { currentSrc: img.currentSrc, cssWidth: img.getBoundingClientRect().width,
naturalWidth: img.naturalWidth, dpr: devicePixelRatio,
resource: resource && { start: resource.startTime, responseStart: resource.responseStart,
responseEnd: resource.responseEnd, encodedBodySize: resource.encodedBodySize, transferSize: resource.transferSize },
lcp: window.imageLcp.at(-1) ?? null };
});
rows.push({ width, mode, ...observed, bodyBytes: bytes.length,
fileWidth: metadata.width, fileHeight: metadata.height, format: metadata.format,
cacheControl: response.headers()['cache-control'] });
} finally { await context.close(); }
}
} finally { await browser.close(); }
console.log(JSON.stringify(rows, null, 2));
# 이미지 서버를 켠 상태에서 같은 실습 폴더의 다른 터미널에서 실행
npm install --save-dev playwright@1.59.1
npx playwright install chromium
node inspect-image.mjs > image-observations.json
bodyBytes는 받은 이미지 본문 크기, fileWidth는 파일의 픽셀 폭, cssWidth는 화면의 표시 폭이다. responseStart부터 responseEnd까지는 응답 수신 구간이며 start부터 responseStart까지는 요청 준비와 서버 대기 등이 포함된다. transferSize는 브라우저가 보고한 헤더 포함 전송 크기다. 원격 주소에서는 Timing-Allow-Origin 같은 조건에 따라 일부 값이 0일 수 있다.
LCP의 tag와 url을 함께 읽어 가장 큰 요소가 사진인지 제목인지 확인한다. 사진의 다운로드가 줄어도 HTML 응답이 늦거나 사진 요청을 늦게 발견하면 전체 표시 지연이 남는다. 이 코드는 로컬 장치의 여덟 실행 결과를 저장한다. 운영 속도를 비교하려면 같은 브라우저·기기·네트워크·캐시 조건에서 여러 번 실행하고 분포를 읽는다. 디코딩에 걸린 시간과 메인 스레드 차단의 상세 원인은 브라우저 Performance 기록에서 확인한다.
TBT는 FCP(첫 콘텐츠 표시) 이후의 관측 구간에서, 메인 스레드 작업이 50ms를 넘긴 부분을 합한 값이다. 120ms 작업 한 개는 70ms의 차단 시간을 만든다. INP는 사용자의 클릭·입력 같은 상호작용 이후 화면이 반응할 때까지의 지연을 평가한다. TBT가 낮은 페이지도 나중에 여는 메뉴의 처리 함수가 무거우면 상호작용이 느릴 수 있다. 로딩 기록과 실제 상호작용을 각각 조사한다. TBT와 INP의 관계
폰트가 늦게 오면 글자 표시가 기다리거나 대체 글꼴에서 바뀌며 줄바꿈이 이동할 수 있다. font-display: swap은 짧은 대기 후 대체 글꼴을 사용하게 하고, 사용할 폰트의 preload는 요청을 일찍 시작하게 한다. 대체 글꼴과 본문 글꼴의 폭이 다르면 CLS가 남는다. 첫 화면에 쓰는 파일만 미리 받고 모바일에서 실제 폰트 요청·글자 표시·줄바꿈 이동을 확인한다. font-display의 동작
이 도형을 로컬에서 실행한 한 표본에서는 원본이 238,236바이트, 640px WebP가 28,676바이트, 1280px WebP가 49,546바이트였다. 390px·DPR 1에서는 640px를, 같은 화면·DPR 2에서는 1280px를 받았다. 같은 원본에서도 화면의 물리 픽셀 요구에 따라 전송량이 달라지는 예다. 사진·품질 설정·인코더가 바뀌면 파일 크기도 다시 측정한다.