개요
이전 글 SEO 구현에서 검색 엔진 최적화를 다루었습니다. 이번 글에서는 블로그 성능의 핵심인 이미지 최적화를 위해 개발한 커스텀 rehype 플러그인(rehype-picture.mjs)을 상세히 설명합니다.
이 플러그인은 마크다운에 포함된 <img> 요소를 자동으로 <picture> 요소로 변환하여 AVIF/WebP 형식의 이미지를 제공합니다. 이미지 최적화에 대한 보다 넓은 관점은 Lighthouse 성능 최적화 종합 가이드 포스트를 참고하세요.
왜 커스텀 플러그인인가
Astro에는 내장 <Image> 컴포넌트가 있지만, 마크다운 콘텐츠에서는 사용할 수 없습니다. 마크다운에서 이미지를 삽입하는 방법은 두 가지입니다.
<!-- 방법 1: 마크다운 문법 -->

<!-- 방법 2: HTML img 태그 -->
<img src="/assets/images/example.jpg" alt="Alt text" />
두 경우 모두 표준 <img> 태그로 렌더링되므로, Astro의 <Image> 컴포넌트의 최적화 혜택을 받을 수 없습니다. 따라서 마크다운 처리 파이프라인에서 자동으로 이미지를 최적화하는 rehype 플러그인을 직접 개발했습니다.
플러그인 동작 원리
rehype-picture.mjs의 전체 흐름은 다음과 같습니다.
마크다운 렌더링
↓
rehype AST(HAST) 생성
↓
rehype-picture 플러그인 실행
↓
1. <img> 요소 탐색 (HAST element + raw HTML)
2. 원본 이미지에서 AVIF/WebP 변환 파일 생성 (Sharp)
3. <img>를 <picture>로 교체
↓
최종 HTML 출력
astro.config.mjs에서의 설정
직접 제작한 rehype-picture 플러그인을 사용하기 위해 astro.config.mjs에서 markdown.rehypePlugins 옵션에 추가합니다.
markdown: {
rehypePlugins: [
[rehypePicture, { publicDir: new URL('./public', import.meta.url).pathname }],
],
},
publicDir 옵션으로 이미지 파일의 실제 경로를 알려줍니다.
이미지 변환 로직
rehype-picture.mjs에서 이미지 변환과 관련된 핵심 로직을 살펴보겠습니다.
지원 형식별 변환 전략
rehype-picture.mjs에 정의한 getVariants() 함수에서 원본 확장자에 따라 생성할 변환 파일을 결정합니다.
function getVariants(ext) {
const lower = ext.toLowerCase();
switch (lower) {
case '.jpg':
case '.jpeg':
case '.png':
return [
{ ext: 'avif', format: 'avif', mime: 'image/avif', animated: false },
{ ext: 'webp', format: 'webp', mime: 'image/webp', animated: false },
];
case '.gif':
return [
{ ext: 'webp', format: 'webp', mime: 'image/webp', animated: true },
];
default:
return [];
}
}
| 원본 형식 | 생성 형식 | 설명 |
|---|---|---|
| JPG/PNG | AVIF + WebP | 정적 이미지. AVIF가 더 작지만 호환성이 낮으므로 둘 다 제공 |
| GIF | WebP | 애니메이션 GIF는 animated WebP로 변환 |
| SVG | - | 벡터 이미지이므로 변환하지 않음 |
| 외부 URL | - | 로컬 파일만 처리 |
Sharp를 이용한 변환
실제 이미지 변환은 Sharp 라이브러리를 사용합니다. ensureVariant() 함수는 원본 이미지로부터 AVIF 또는 WebP 변환 파일을 생성하되, 이미 생성된 적이 있다면 건너뛰도록 캐싱 로직도 포함하고 있습니다.
async function ensureVariant(srcPath, destPath, format, isAnimated) {
const key = destPath;
if (generationCache.has(key)) return;
generationCache.add(key);
if (existsSync(destPath)) return;
try {
let pipeline = sharp(srcPath, { animated: isAnimated });
if (format === 'avif') {
pipeline = pipeline.avif({ quality: 50 });
} else if (format === 'webp') {
pipeline = pipeline.webp({ quality: 80 });
}
await pipeline.toFile(destPath);
} catch {
// 변환 실패 시 무시 (원본 이미지 사용)
}
}
- AVIF: quality 50으로 설정. AVIF는 낮은 품질에서도 시각적 차이가 적어 파일 크기를 크게 줄일 수 있습니다
- WebP: quality 80으로 설정. 더 넓은 브라우저 지원을 위한 폴백
- animated: GIF를 WebP로 변환할 때 애니메이션을 유지
건너뛰는 경우
모든 이미지를 변환할 필요는 없습니다. shouldSkip() 함수는 변환 대상에서 제외할 이미지를 판별합니다. 외부 URL은 로컬 파일이 아니므로 변환할 수 없고, data URI는 이미 인라인된 데이터이며, SVG는 벡터 이미지라 래스터 형식으로 변환하면 오히려 품질이 떨어집니다.
function shouldSkip(src) {
if (!src) return true;
if (src.startsWith('http://') || src.startsWith('https://')) return true;
if (src.startsWith('data:')) return true;
if (src.toLowerCase().endsWith('.svg')) return true;
return false;
}
마크다운 속 이미지를 찾아 변환하는 방법
rehype 플러그인은 마크다운을 HTML로 변환한 결과물을 트리 구조로 다룹니다. 이 트리를 HAST(Hypertext Abstract Syntax Tree)라고 부르며, HTML의 각 태그가 JavaScript 객체(노드)로 표현됩니다. 플러그인은 이 트리를 순회하면서 <img> 노드를 찾아 <picture>로 교체하는 방식으로 동작합니다.
이때 마크다운 내 이미지가 트리에 표현되는 방식이 두 가지라서, 각각 다르게 처리해야 합니다.
Case 1: 마크다운 문법으로 작성된 이미지
 문법으로 작성된 이미지는 HAST 트리에서 element 타입의 <img> 노드로 파싱됩니다. 이 경우 노드의 속성(properties.src)에 직접 접근하여 처리할 수 있습니다.
if (node.type === 'element' && node.tagName === 'img') {
// 이미 <picture> 안에 있으면 건너뛰기
if (parent?.type === 'element' && parent.tagName === 'picture') return;
const src = node.properties?.src;
// ... 변환 처리
parent.children[idx] = createPictureNode(node, result);
}
Case 2: HTML로 직접 작성된 이미지
마크다운 안에 <img src="..."> 태그를 직접 작성한 경우, HAST 트리에서는 파싱되지 않은 raw 문자열 노드로 남아 있습니다. 이 경우 노드 속성에 직접 접근할 수 없으므로, 정규식으로 <img> 태그를 추출하여 문자열 치환 방식으로 처리합니다.
if (node.type === 'raw' && typeof node.value === 'string') {
// 정규식으로 <img> 태그 추출
const imgMatches = [...node.value.matchAll(IMG_TAG_RE)];
// ... 문자열 치환으로 <picture> 변환
}
이처럼 두 가지 케이스를 모두 처리하기 때문에, 마크다운 문법이든 HTML 태그든 상관없이 모든 이미지가 자동으로 최적화됩니다.
변환 결과
변환 전:
<img src="/assets/images/example.jpg" alt="예시" />
변환 후:
<picture>
<source srcset="/assets/images/example.avif" type="image/avif" />
<source srcset="/assets/images/example.webp" type="image/webp" />
<img
src="/assets/images/example.jpg"
alt="예시"
width="800"
height="600"
loading="lazy"
decoding="async"
/>
</picture>
브라우저는 <source> 요소를 위에서 아래로 확인하며, 지원하는 첫 번째 형식을 사용합니다. AVIF를 지원하면 AVIF를, 아니면 WebP를, 둘 다 지원하지 않으면 원본 JPG를 로드합니다.
빌드 성능 최적화
이미지 변환은 CPU 집약적인 작업이므로, 빌드 성능을 위한 두 가지 캐싱 전략을 사용합니다.
Dimension 캐시
const dimensionCache = new Map();
async function getDimensions(filePath) {
if (dimensionCache.has(filePath)) return dimensionCache.get(filePath);
const meta = await sharp(filePath).metadata();
const dims = { width: meta.width, height: meta.height };
dimensionCache.set(filePath, dims);
return dims;
}
같은 이미지의 크기 정보를 반복 조회하지 않도록 메모리에 캐싱합니다. 다국어 블로그에서는 같은 이미지가 3개 언어 파일에서 사용되므로 효과적입니다.
Generation 캐시
const generationCache = new Set();
async function ensureVariant(srcPath, destPath, format, isAnimated) {
const key = destPath;
if (generationCache.has(key)) return; // 이미 생성 요청됨
generationCache.add(key);
if (existsSync(destPath)) return; // 이미 파일이 존재함
// ... 변환 실행
}
두 단계의 캐싱으로 불필요한 변환을 방지합니다.
generationCache: 같은 빌드에서 이미 요청된 변환인지 확인 (메모리)existsSync: 이전 빌드에서 이미 생성된 파일인지 확인 (파일시스템)
LCP 이미지 프리로드
Head.astro에서 포스트의 대표 이미지를 LCP(Largest Contentful Paint) 이미지로 프리로드합니다.
{image && <link rel="preload" as="image" href="{image}" fetchpriority="high" />}
대표 이미지는 페이지에서 가장 큰 콘텐츠 요소이므로, 프리로드하여 LCP 시간을 단축합니다. fetchpriority="high"로 브라우저에게 이 리소스의 우선순위가 높다고 알려줍니다.
나머지 이미지는 플러그인이 자동으로 loading="lazy"와 decoding="async"를 추가하여 초기 로딩에 영향을 주지 않도록 합니다.
완료
이번 글에서는 Astro 블로그를 위한 커스텀 rehype-picture 플러그인을 살펴보았습니다.
- 마크다운의
<img>요소를 자동으로<picture>요소로 변환 Sharp를 이용한 AVIF(quality 50)/WebP(quality 80) 이미지 생성- HAST element 노드와 raw HTML 문자열 노드 모두 처리
dimensionCache와generationCache로 빌드 성능 최적화- LCP 이미지 프리로드와 lazy loading으로 로딩 성능 개선
다음 글 댓글 시스템과 광고 연동에서는 Utterances 댓글 시스템과 Google AdSense 광고 연동 방법을 다룹니다.
시리즈 안내
이 포스트는 Jekyll에서 Astro로 마이그레이션 시리즈의 일부입니다.
- Jekyll에서 Astro로 마이그레이션한 이유
- Astro 설치 및 프로젝트 구성
- Content Collections와 마크다운 마이그레이션
- 다국어(i18n) 구현
- SEO 구현
- 이미지 최적화 — 커스텀 rehype 플러그인
- 댓글 시스템 (Utterances)
- 광고 연동 (Google AdSense)
- Pagefind를 이용한 검색 구현
- 레이아웃과 컴포넌트 아키텍처
- GitHub Pages 배포
- 소셜 공유 자동화 스크립트
- 트러블슈팅과 팁
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.