Claude Code로 빠른 이미지 갤러리 만들기
Claude Code로 반응형 이미지 갤러리를 구현하는 방법. React 코드, srcset, 라이트박스, 검증까지 다룹니다.
이미지 갤러리는 단순한 장식 컴포넌트가 아니다
Claude Code로 이미지 갤러리를 만들 때 “예쁘게 masonry로 배치해 줘”라고만 요청하면 그럴듯한 데모는 빨리 나옵니다. 하지만 실제 공개 페이지에서는 이미지 용량, alt 텍스트, 레이아웃 흔들림, 모바일 폭, 키보드로 닫히는 라이트박스, 깨진 CMS 데이터, 이미지에서 다음 행동으로 이어지는 동선이 더 중요합니다. 갤러리는 포트폴리오, 상품 상세, 교육 자료, 기술 글의 스크린샷 묶음처럼 매출이나 신뢰와 직접 연결되는 화면입니다.
이 글은 Claude Code에게 이미지 갤러리를 맡길 때 필요한 실무 절차를 정리합니다. 먼저 제약이 있는 프롬프트를 만들고, 타입이 있는 React 컴포넌트를 작성하고, srcset, sizes, lazy loading, 라이트박스, 검증 기준까지 확인합니다. 관련 글로는 이미지 처리, 성능 최적화, 접근성 구현을 함께 보면 좋습니다. 공식 자료는 Claude Code docs, MDN의 responsive images, Lazy loading, WCAG 2.2를 기준으로 삼습니다.
실무에서는 UI보다 데이터 계약을 먼저 고정하는 편이 안전합니다. 각 이미지에 id, category, width, height, 의미 있는 alt가 있으면 Next.js Image, Astro 이미지, CDN 변환 서비스로 바꾸더라도 구조가 무너지지 않습니다.
Claude Code에 줄 프롬프트
다음 프롬프트는 그대로 붙여 넣어도 됩니다. 핵심은 “작동하는 코드”, “실패 상태”, “리뷰 기준”을 함께 요구하는 것입니다.
React 이미지 갤러리를 구현해 주세요.
목표는 글, 사례, 상품 스크린샷, 워크숍 사진을 빠르게 탐색하는 UI입니다.
조건:
- 기존 라우팅과 디자인 시스템을 깨지 않는다
- id, src, alt, width, height, category가 필수인 이미지 타입을 정의한다
- CSS Grid로 반응형 레이아웃을 만든다
- srcset, sizes, loading, fetchPriority를 의도적으로 사용한다
- 클릭하면 라이트박스를 열고 Escape로 닫을 수 있게 한다
- 빈 배열, 이미지 로드 실패, 긴 alt, 모바일 폭을 고려한다
- 구현 후 변경 파일, 테스트 관점, 남은 리스크를 설명한다
의사코드가 아니라 복사해서 실행할 수 있는 React/TypeScript와 CSS를 반환해 주세요.
이 프롬프트는 Claude Code가 화면을 크게 갈아엎지 않고 작은 diff를 만들도록 유도합니다. width와height는 레이아웃 이동을 줄이고, 필수 alt는 “image 1” 같은 빈 설명을 줄입니다. 마지막 리뷰 요청은 생성 모드에서 검토 모드로 한 번 더 전환시키는 장치입니다.
구현 구조
코드를 쓰기 전에 전체 흐름을 보여 주면 Claude Code가 책임을 나누기 쉽습니다.
flowchart LR
A["원본 이미지"] --> B["크기별 파일 생성"]
B --> C["GalleryImage 배열"]
C --> D["카테고리 필터"]
D --> E["CSS Grid 카드"]
E --> F["라이트박스"]
E --> G["Lighthouse와 수동 확인"]
| 결정 항목 | 안전한 기본값 | 다시 검토할 때 |
|---|---|---|
| 레이아웃 | CSS Grid | 이미지 높이 차이가 매우 클 때 |
| lazy loading | 화면 아래 이미지부터 | 첫 이미지가 늦게 뜰 때 |
| 이미지 크기 | 480/960/1440px 근처 | 대형 화면 사용자가 많을 때 |
| 라이트박스 | 최소 접근성 기능 | 구매 결정에 직접 영향을 줄 때 |
처음부터 masonry 라이브러리를 넣지 않는 이유는 단순합니다. 많은 페이지는 진짜 masonry보다 안정적인 카드, 빠른 썸네일, 명확한 미리보기가 필요합니다. 의존성이 적을수록 Claude Code가 만든 변경도 검토하기 쉽습니다.
복사해서 쓸 수 있는 React 구현
아래 구현은 특정 프레임워크에 강하게 묶이지 않습니다. Vite에 넣을 수 있고, Next.js client component로 옮길 수도 있습니다.
import { useEffect, useMemo, useState } from "react";
import "./image-gallery.css";
export type GalleryImage = {
id: string;
src: string;
alt: string;
width: number;
height: number;
category: string;
sources?: Array<{ width: number; src: string }>;
};
function buildSrcSet(image: GalleryImage) {
if (!image.sources?.length) return undefined;
return [...image.sources]
.sort((a, b) => a.width - b.width)
.map((source) => `${source.src} ${source.width}w`)
.join(", ");
}
export function ImageGallery({ images }: { images: GalleryImage[] }) {
const [category, setCategory] = useState("all");
const [activeId, setActiveId] = useState<string | null>(null);
const [brokenIds, setBrokenIds] = useState<Set<string>>(() => new Set());
const categories = useMemo(() => {
return ["all", ...Array.from(new Set(images.map((image) => image.category)))];
}, [images]);
const visibleImages = useMemo(() => {
if (category === "all") return images;
return images.filter((image) => image.category === category);
}, [category, images]);
const activeImage = visibleImages.find((image) => image.id === activeId);
useEffect(() => {
if (!activeImage) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setActiveId(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeImage]);
function markBroken(id: string) {
setBrokenIds((current) => new Set(current).add(id));
}
if (images.length === 0) {
return <p className="gallery-empty">No images are available yet.</p>;
}
return (
<section className="gallery" aria-label="Image gallery">
<div className="gallery-toolbar" aria-label="Filter images by category">
{categories.map((item) => (
<button
className={item === category ? "is-active" : ""}
key={item}
onClick={() => setCategory(item)}
type="button"
>
{item === "all" ? "All" : item}
</button>
))}
</div>
<div className="gallery-grid">
{visibleImages.map((image, index) => {
const isBroken = brokenIds.has(image.id);
return (
<button
className="gallery-card"
key={image.id}
onClick={() => setActiveId(image.id)}
type="button"
>
{isBroken ? (
<span className="gallery-fallback">Image unavailable</span>
) : (
<img
alt={image.alt}
width={image.width}
height={image.height}
src={image.src}
srcSet={buildSrcSet(image)}
sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
loading={index < 2 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
style={{ aspectRatio: `${image.width} / ${image.height}` }}
onError={() => markBroken(image.id)}
/>
)}
<span>{image.alt}</span>
</button>
);
})}
</div>
{activeImage && (
<div
className="gallery-lightbox"
role="dialog"
aria-modal="true"
aria-label={activeImage.alt}
tabIndex={-1}
onClick={() => setActiveId(null)}
>
<button className="gallery-close" onClick={() => setActiveId(null)} type="button">
Close
</button>
<img
alt={activeImage.alt}
width={activeImage.width}
height={activeImage.height}
src={activeImage.src}
onClick={(event) => event.stopPropagation()}
/>
</div>
)}
</section>
);
}
.gallery {
display: grid;
gap: 1rem;
}
.gallery-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.gallery-toolbar button,
.gallery-card,
.gallery-close {
border: 1px solid #d4d4d8;
background: #ffffff;
color: #18181b;
cursor: pointer;
}
.gallery-toolbar button {
border-radius: 999px;
padding: 0.45rem 0.8rem;
}
.gallery-toolbar .is-active {
background: #18181b;
color: #ffffff;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.gallery-card {
display: grid;
gap: 0.5rem;
padding: 0;
overflow: hidden;
border-radius: 8px;
text-align: left;
}
.gallery-card img {
width: 100%;
object-fit: cover;
background: #f4f4f5;
}
.gallery-fallback {
display: grid;
min-height: 180px;
place-items: center;
background: #f4f4f5;
color: #71717a;
}
.gallery-card span {
padding: 0 0.75rem 0.75rem;
font-size: 0.875rem;
}
.gallery-lightbox {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 2rem;
background: rgb(0 0 0 / 0.86);
}
.gallery-lightbox img {
max-width: min(100%, 1100px);
max-height: 82vh;
object-fit: contain;
}
.gallery-close {
position: absolute;
top: 1rem;
right: 1rem;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.gallery-empty {
color: #71717a;
}
실제 앱에서는 원시 img를 Next.js, Astro, CDN 컴포넌트로 바꿀 수 있습니다. 그래도 alt, width, height, sizes를 데이터 계약으로 유지하는 원칙은 그대로입니다.
데이터 모델과 사용 사례
데이터는 컴포넌트 밖에 둡니다. CMS에서 받아도 이 형태로 정규화하면 테스트와 장애 대응이 쉬워집니다.
import type { GalleryImage } from "./ImageGallery";
export const galleryImages: GalleryImage[] = [
{
id: "case-study-dashboard",
src: "/images/gallery/dashboard-960.webp",
alt: "Analytics dashboard after Claude Code refactoring",
width: 960,
height: 640,
category: "Case study",
sources: [
{ width: 480, src: "/images/gallery/dashboard-480.webp" },
{ width: 960, src: "/images/gallery/dashboard-960.webp" },
{ width: 1440, src: "/images/gallery/dashboard-1440.webp" },
],
},
{
id: "workshop-room",
src: "/images/gallery/workshop-960.webp",
alt: "Team workshop board with Claude Code review checklist",
width: 960,
height: 720,
category: "Training",
},
{
id: "product-shot",
src: "/images/gallery/template-pack-960.webp",
alt: "Claude Code template pack product preview",
width: 960,
height: 540,
category: "Product",
},
];
첫 번째 사용 사례는 포트폴리오와 사례 페이지입니다. 이미지는 결과를 비교하게 하고, 이후 사례 글이나 상담 페이지로 자연스럽게 이어져야 합니다.
두 번째는 전자상거래와 디지털 상품 페이지입니다. 사용 장면, 비교 이미지, 구매 후 화면은 불안을 줄입니다. 다만 모든 고해상도 이미지를 처음부터 로드하면 CTA를 보기 전에 이탈할 수 있습니다.
세 번째는 교육, 이벤트, 내부 지식 자료입니다. 화이트보드, 단계별 스크린샷, Before/After, 오류 화면은 재사용 가능한 학습 자료가 됩니다. 내부 자료라면 고객명, 이메일, 토큰 노출을 반드시 확인합니다.
네 번째는 기술 글입니다. 코드 예제가 많은 글은 개념도와 검증 스크린샷을 함께 제공할 때 독자가 흐름을 잃지 않습니다.
공개 전 잡아야 할 함정
가장 흔한 실수는 첫 화면 이미지를 무조건 lazy-load 하는 것입니다. 바로 보이는 이미지는 LCP에 영향을 줄 수 있으므로 첫 항목은 eager와 fetchPriority="high"를 검토합니다. 반대로 모든 이미지를 eager로 만들면 초기 로딩이 느려집니다.
두 번째는 width와 height를 빼는 것입니다. 이미지가 로드될 때마다 카드 높이가 바뀌면 페이지가 불안정해 보입니다. Claude Code에게 CLS 위험을 별도 항목으로 검토하게 하세요.
세 번째는 alt를 SEO 키워드 목록처럼 쓰는 것입니다. alt는 이미지를 볼 수 없는 사람에게 의미를 전달하는 설명입니다.
네 번째는 마우스 전용 라이트박스입니다. 이름 있는 닫기 버튼, Escape 처리, 보이는 포커스, 좁은 화면 동작이 필요합니다. 엄격한 focus trap이 필요하면 Radix UI나 React Aria 같은 검증된 도구를 검토합니다.
다섯 번째는 운영 규칙이 없는 것입니다. CMS에 6 MB PNG 하나가 올라오면 좋은 구현도 느려집니다. 최대 크기, 허용 형식, 파일명 규칙, 리뷰 항목을 CLAUDE.md에 넣어 두면 다음 작업에도 재사용됩니다.
검증 코드와 리뷰 흐름
공개 전에는 필터, 라이트박스, 키보드, 빈 데이터, 깨진 이미지, 375px 폭을 확인합니다. Playwright가 있다면 다음처럼 작게 시작합니다.
import { expect, test } from "@playwright/test";
test("image gallery filters and opens a lightbox", async ({ page }) => {
await page.goto("/gallery");
await expect(page.getByRole("region", { name: "Image gallery" })).toBeVisible();
await page.getByRole("button", { name: "Training" }).click();
await expect(page.getByRole("button", { name: /workshop/i })).toBeVisible();
await page.getByRole("button", { name: /workshop/i }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
});
리뷰 요청은 “괜찮아 보이나요?”보다 구체적이어야 합니다. 초기 이미지 요청 수, srcset과 실제 레이아웃 폭, alt 의미, 버튼과 링크의 역할, 모바일 overflow, 깨진 CMS 데이터, 개인정보 노출 여부를 고정 체크리스트로 확인합니다.
CTA와 실제 적용 결과
갤러리는 비즈니스 동선과 연결되어야 합니다. 사례 이미지는 사례 글로, 상품 이미지는 구매 설명으로, 교육 사진은 Claude Code 교육과 상담으로 이어지게 합니다. 더 깊이 구현하려면 이미지 lazy loading과 React 개발 가이드를 함께 보세요.
이 흐름으로 실제 작업을 나누면 한 번에 “멋진 갤러리”를 요청할 때보다 리뷰가 쉬웠습니다. 데이터 타입, 컴포넌트, CSS, 리뷰 기준을 분리하니 약한 alt와 빠진 width/height를 초기에 잡을 수 있었습니다. 마지막에는 DevTools Network, Lighthouse, 모바일 수동 조작으로 확인했습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.