챕터 25: 이미지 최적화와 처리
서론 웹 페이지의 로딩 속도에서 이미지가 차지하는 비중은 매우 큽니다. 최적화되지 않은 이미지는 사용자 경험을 크게 해치고, SEO에도 부정적인 영향을 미칩니다. 다행히 Next.js는 강력한 Image 컴포넌트를 제공하여 이미지 최적화를 자동으로 처리해줍니다.
이번 챕터에서는 Next.js Image 컴포넌트의 모든 기능을 살펴보고, 반응형 이미지를 구현하며, 레이지 로딩과 플레이스홀더를 활용하는 방법까지 자세히 알아보겠습니다. 이미지 최적화의 모든 것을 마스터해보겠습니다.
본론 Next.js Image 컴포넌트 기초 Next.js의 Image 컴포넌트는 자동으로 이미지를 최적화합니다:
jsx import Image from 'next/image'
export default function ImageBasics() { return (
Next.js Image 컴포넌트
{/* 기본 사용법 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">기본 이미지</h2>
<Image
src="/images/hero-image.jpg"
alt="Hero 이미지"
width={800}
height={400}
className="rounded-lg"
/>
<p className="text-sm text-gray-600 mt-2">
자동으로 WebP 포맷으로 변환되고 최적화됩니다
</p>
</div>
{/* 우선순위 높은 이미지 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">우선순위 로딩</h2>
<Image
src="/images/above-fold.jpg"
alt="중요한 이미지"
width={800}
height={400}
priority // LCP (Largest Contentful Paint) 개선
className="rounded-lg"
/>
<p className="text-sm text-gray-600 mt-2">
priority 속성으로 중요한 이미지를 먼저 로드합니다
</p>
</div>
{/* 외부 이미지 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">외부 이미지</h2>
<Image
src="https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead"
alt="외부 이미지"
width={800}
height={400}
className="rounded-lg"
unoptimized // 외부 이미지는 최적화 건너뛰기 가능
/>
<p className="text-sm text-gray-600 mt-2">
next.config.js에 도메인을 등록해야 합니다
</p>
</div>
</div>
</div>
) }
// next.config.js module.exports = { images: { domains: ['images.unsplash.com'], // 또는 더 안전한 방법: remotePatterns: [ { protocol: 'https', hostname: 'images.unsplash.com', port: '', pathname: '/**', }, ], }, } 반응형 이미지 구현 다양한 화면 크기에 대응하는 반응형 이미지를 만들어봅시다:
jsx import Image from 'next/image'
export default function ResponsiveImages() { return (
반응형 이미지
{/* Fill 속성 사용 */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Fill 속성 (부모 요소 크기에 맞춤)</h2>
<div className="relative h-96 bg-gray-200 rounded-lg overflow-hidden">
<Image
src="/images/responsive-image.jpg"
alt="반응형 이미지"
fill
style={{ objectFit: 'cover' }}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</div>
{/* 다양한 Object Fit */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div>
<h3 className="font-medium mb-2">Cover</h3>
<div className="relative h-48 bg-gray-200 rounded overflow-hidden">
<Image
src="/images/sample.jpg"
alt="Cover"
fill
style={{ objectFit: 'cover' }}
/>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Contain</h3>
<div className="relative h-48 bg-gray-200 rounded overflow-hidden">
<Image
src="/images/sample.jpg"
alt="Contain"
fill
style={{ objectFit: 'contain' }}
/>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Fill</h3>
<div className="relative h-48 bg-gray-200 rounded overflow-hidden">
<Image
src="/images/sample.jpg"
alt="Fill"
fill
style={{ objectFit: 'fill' }}
/>
</div>
</div>
</div>
{/* 아트 디렉션 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">아트 디렉션 (다른 비율)</h2>
<div className="space-y-4">
{/* 모바일: 정사각형 */}
<div className="block md:hidden">
<div className="relative aspect-square">
<Image
src="/images/mobile-square.jpg"
alt="모바일 이미지"
fill
style={{ objectFit: 'cover' }}
/>
</div>
</div>
{/* 태블릿: 4:3 */}
<div className="hidden md:block lg:hidden">
<div className="relative aspect-4/3">
<Image
src="/images/tablet-landscape.jpg"
alt="태블릿 이미지"
fill
style={{ objectFit: 'cover' }}
/>
</div>
</div>
{/* 데스크톱: 16:9 */}
<div className="hidden lg:block">
<div className="relative aspect-video">
<Image
src="/images/desktop-wide.jpg"
alt="데스크톱 이미지"
fill
style={{ objectFit: 'cover' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
) } 레이지 로딩과 플레이스홀더 사용자 경험을 개선하는 로딩 전략을 구현해봅시다:
jsx 'use client'
import Image from 'next/image' import { useState } from 'react'
export default function LazyLoadingAndPlaceholders() { const [imageLoaded, setImageLoaded] = useState({})
const handleImageLoad = (id) => { setImageLoaded(prev => ({ ...prev, [id]: true })) }
return (
레이지 로딩과 플레이스홀더
{/* Blur 플레이스홀더 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Blur 플레이스홀더</h2>
<Image
src="/images/high-quality.jpg"
alt="Blur 플레이스홀더"
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..." // 실제 base64 데이터
className="rounded-lg"
/>
</div>
{/* 스켈레톤 로딩 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">스켈레톤 로딩</h2>
<div className="relative">
{!imageLoaded['skeleton'] && (
<div className="absolute inset-0 bg-gray-200 animate-pulse rounded-lg" />
)}
<Image
src="/images/skeleton-example.jpg"
alt="스켈레톤 예제"
width={800}
height={400}
onLoad={() => handleImageLoad('skeleton')}
className="rounded-lg"
/>
</div>
</div>
{/* 프로그레시브 로딩 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">프로그레시브 이미지 갤러리</h2>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((index) => (
<div key={index} className="relative group">
<div className="relative aspect-square overflow-hidden rounded-lg">
{!imageLoaded[`gallery-${index}`] && (
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 animate-pulse" />
)}
<Image
src={`/images/gallery-${index}.jpg`}
alt={`갤러리 이미지 ${index}`}
fill
style={{ objectFit: 'cover' }}
onLoad={() => handleImageLoad(`gallery-${index}`)}
className="group-hover:scale-110 transition-transform duration-300"
loading="lazy"
/>
</div>
</div>
))}
</div>
</div>
{/* 무한 스크롤 이미지 */}
<InfiniteScrollImages />
</div>
</div>
) }
// 무한 스크롤 컴포넌트 function InfiniteScrollImages() { const [images, setImages] = useState(Array.from({ length: 6 }, (_, i) => i + 1)) const [loading, setLoading] = useState(false)
const loadMore = () => { if (loading) return
setLoading(true)
setTimeout(() => {
const newImages = Array.from(
{ length: 6 },
(_, i) => images.length + i + 1
)
setImages([...images, ...newImages])
setLoading(false)
}, 1000)
}
return (
무한 스크롤 갤러리
https://picsum.photos/400/400?random=${id}}
alt={무한 스크롤 이미지 ${id}}
fill
style={{ objectFit: 'cover' }}
className="rounded-lg"
loading="lazy"
/>
<button
onClick={loadMore}
disabled={loading}
className="mt-6 w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '로딩 중...' : '더 보기'}
</button>
</div>
) } 이미지 갤러리 컴포넌트 실용적인 이미지 갤러리를 만들어봅시다:
jsx 'use client'
import Image from 'next/image' import { useState } from 'react'
export default function ImageGallery() { const [selectedImage, setSelectedImage] = useState(null)
const images = [ { id: 1, src: '/images/gallery-1.jpg', alt: '풍경 사진 1', category: 'nature' }, { id: 2, src: '/images/gallery-2.jpg', alt: '도시 사진 1', category: 'city' }, { id: 3, src: '/images/gallery-3.jpg', alt: '인물 사진 1', category: 'people' }, { id: 4, src: '/images/gallery-4.jpg', alt: '풍경 사진 2', category: 'nature' }, { id: 5, src: '/images/gallery-5.jpg', alt: '도시 사진 2', category: 'city' }, { id: 6, src: '/images/gallery-6.jpg', alt: '인물 사진 2', category: 'people' } ]
const [filter, setFilter] = useState('all')
const filteredImages = filter === 'all' ? images : images.filter(img => img.category === filter)
return (
이미지 갤러리
{/* 필터 버튼 */}
<div className="flex space-x-4 mb-8">
{['all', 'nature', 'city', 'people'].map((category) => (
<button
key={category}
onClick={() => setFilter(category)}
className={`px-4 py-2 rounded-lg transition ${
filter === category
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
{category === 'all' ? '전체' :
category === 'nature' ? '자연' :
category === 'city' ? '도시' : '인물'}
</button>
))}
</div>
{/* 갤러리 그리드 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{filteredImages.map((image) => (
<div
key={image.id}
className="relative aspect-square cursor-pointer group"
onClick={() => setSelectedImage(image)}
>
<Image
src={image.src}
alt={image.alt}
fill
style={{ objectFit: 'cover' }}
className="rounded-lg group-hover:brightness-90 transition"
sizes="(max-width: 768px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition rounded-lg" />
</div>
))}
</div>
{/* 라이트박스 */}
{selectedImage && (
<div
className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh]">
<Image
src={selectedImage.src}
alt={selectedImage.alt}
width={1200}
height={800}
style={{ objectFit: 'contain' }}
className="rounded-lg"
/>
<button
onClick={() => setSelectedImage(null)}
className="absolute top-4 right-4 text-white bg-black bg-opacity-50 rounded-full p-2 hover:bg-opacity-70"
>
✕
</button>
</div>
</div>
)}
</div>
</div>
) } 이미지 업로드와 미리보기 사용자가 이미지를 업로드하고 미리볼 수 있는 기능을 구현해봅시다:
jsx 'use client'
import { useState } from 'react' import Image from 'next/image'
export default function ImageUpload() { const [selectedFiles, setSelectedFiles] = useState([]) const [uploadProgress, setUploadProgress] = useState({})
const handleFileSelect = (e) => { const files = Array.from(e.target.files)
const newFiles = files.map(file => ({
id: Date.now() + Math.random(),
file,
preview: URL.createObjectURL(file),
name: file.name,
size: file.size
}))
setSelectedFiles([...selectedFiles, ...newFiles])
}
const removeFile = (id) => { setSelectedFiles(selectedFiles.filter(f => f.id !== id)) }
const formatFileSize = (bytes) => { if (bytes === 0) return '0 Bytes' const k = 1024 const sizes = ['Bytes', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] }
const uploadFiles = async () => { for (const fileData of selectedFiles) { // 실제 업로드 시뮬레이션 setUploadProgress(prev => ({ ...prev, [fileData.id]: 0 }))
for (let i = 0; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 100))
setUploadProgress(prev => ({ ...prev, [fileData.id]: i }))
}
}
}
return (
이미지 업로드
{/* 업로드 영역 */}
<div className="bg-white rounded-lg shadow-md p-8 mb-8">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
id="file-input"
/>
<label
htmlFor="file-input"
className="cursor-pointer"
>
<div className="text-6xl mb-4">📸</div>
<p className="text-xl font-medium mb-2">
클릭하여 이미지 선택
</p>
<p className="text-gray-500">
또는 파일을 여기로 드래그하세요
</p>
<p className="text-sm text-gray-400 mt-2">
PNG, JPG, GIF (최대 10MB)
</p>
</label>
</div>
</div>
{/* 미리보기 */}
{selectedFiles.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">
선택된 파일 ({selectedFiles.length})
</h2>
<div className="space-y-4">
{selectedFiles.map((fileData) => (
<div key={fileData.id} className="flex items-center space-x-4 p-4 border rounded-lg">
<div className="relative w-20 h-20">
<Image
src={fileData.preview}
alt={fileData.name}
fill
style={{ objectFit: 'cover' }}
className="rounded"
/>
</div>
<div className="flex-1">
<p className="font-medium">{fileData.name}</p>
<p className="text-sm text-gray-500">
{formatFileSize(fileData.size)}
</p>
{uploadProgress[fileData.id] !== undefined && (
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[fileData.id]}%` }}
/>
</div>
</div>
)}
</div>
<button
onClick={() => removeFile(fileData.id)}
className="text-red-500 hover:text-red-700"
>
삭제
</button>
</div>
))}
</div>
<button
onClick={uploadFiles}
className="mt-6 w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700"
>
업로드 시작
</button>
</div>
)}
</div>
</div>
) } 결론 이미지 최적화는 웹 성능의 핵심입니다. 이번 챕터에서 우리는 Next.js Image 컴포넌트의 다양한 기능, 반응형 이미지 구현, 레이지 로딩과 플레이스홀더, 그리고 실용적인 갤러리와 업로드 기능까지 구현해보았습니다.
Next.js의 Image 컴포넌트를 사용하면 자동으로 이미지를 최적화하고, WebP 같은 현대적인 포맷으로 변환하며, 레이지 로딩을 적용하여 성능을 크게 향상시킬 수 있습니다. 또한 반응형 이미지와 아트 디렉션을 통해 모든 기기에서 최적의 사용자 경험을 제공할 수 있습니다.
다음 챕터에서는 폼과 사용자 입력 처리에 대해 알아보겠습니다. 유효성 검사, 에러 처리, 그리고 사용자 친화적인 폼을 만드는 방법을 배워보겠습니다!