FCP와 LCP 같은 주요 로딩 지표를 개선하며 사용자 경험을 향상시켰습니다. 그러나 이 과정에서 TBT가 증가하고 전체 네트워크 페이로드 크기 또한 늘어나는 예상치 못한 문제에 직면하게 되었습니다.이러한 결과는 성능 최적화가 단순히 한두 가지 지표를 개선하는 데 그치지 않고, 전체적인 시스템의 균형을 고려해야 한다는 중요한 교훈을 주었습니다.
이미지 파일 크기를 줄였음에도 불구하고 TBT가 늘어난 것은 새로운 이미지 최적화 로직이나 컴포넌트 로직이 메인 스레드에 추가적인 부담을 주었을 가능성이 있고, 최적화 후 네트워크 페이로드 크기가 오히려 60KiB가량 증가한 것은 자바스크립트 번들 등 이미지 외의 다른 리소스가 더 커졌을 수 있음을 의미합니다. 앞으로는 아래에서 말씀드릴 목표를 실천하며 단기적인 지표 개선을 넘어, 지속적인 성능 모니터링과 분석을 통해 사용자에게 끊김 없고 빠른 경험을 제공하겠습니다.
프로젝트 문제점 파악하기
개발중인 서비스는 AI가 유저가 입력한 정보를 기반으로 취향이 맞는 사람끼리 매칭해주는 '조직 기반 소셜 매칭 서비스'입니다. 초기 개발 단계에서는 기능 구현에 집중하여 성능 최적화가 부족해 다음과 같은 문제들이 발생했습니다.
1) 모든 이미지를 PNG 포맷으로만 저장하여 파일 크기가 과도하게 컸습니다.
2) 초기 페이지 진입 시 모든 이미지가 동시에 로드되어 느린 로딩 속도가 부각되었습니다.
3) 불필요한 라이브러리와 코드로 인해 번들 크기가 증가하여 전반적인 성능을 저하시켰습니다.
최적화 전 성능 측정 결과, LCP는 3.2초, FCP는 1.8초에 달했습니다. 또한, 총 이미지 크기는 2.3MB, 번들 크기는 1.2MB에 육박했습니다.
이미지 최적화 전략 세우기
이러한 문제들을 해결하기 위해 두 가지 핵심 전략을 수립했습니다.
1. 이미지 포맷 최적화 전략
가장 먼저 PNG, JPG 포맷 대신 더 효율적인 AVIF와 WebP 포맷을 적극적으로 도입했습니다.
// 이미지 타입별 최적화 전략
const optimizationStrategy = {
// 배너 이미지: 고품질, 큰 크기
banner: {
formats: ['avif', 'webp'],
quality: { avif: 80, webp: 85 },
maxWidth: 1200,
},
// 프로필 이미지: 중간 품질, 작은 크기
profile: {
formats: ['avif', 'webp'],
quality: { avif: 75, webp: 80 },
maxWidth: 400,
},
// 아이콘: 낮은 품질, 최소 크기
icon: {
formats: ['avif', 'webp'],
quality: { avif: 70, webp: 75 },
maxWidth: 128,
},
};
- AVIF 선택 이유: AVIF는 WebP 대비 20-30% 더 나은 압축률을 제공하며, Next.js 13 이상에서 네이티브로 지원됩니다. 아직 일부 구형 브라우저에서 지원되지 않기 때문에 WebP를 fallback으로 함께 사용했습니다.
2. 자동화된 최적화 파이프라인 구축
수동 최적화의 비효율성을 해소하기 위해, 빌드 시점에 자동으로 이미지를 최적화하는 파이프라인을 구축했습니다. Sharp 라이브러리를 활용해 이미지 크기 조정, 포맷 변환, 압축률 계산을 자동화하는 스크립트를 만들었습니다.
// scripts/optimize-images.js
const sharp = require('sharp');
async function optimizeImage(inputPath, outputPath, format = 'webp', options = {}) {
const { width = 800, height = 600, quality = 80, effort = 6 } = options;
await sharp(inputPath)
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.toFormat(format, { quality, effort })
.toFile(outputPath);
// 압축률 계산 및 로깅
const originalSize = fs.statSync(inputPath).size;
const optimizedSize = fs.statSync(outputPath).size;
const savings = (((originalSize - optimizedSize) / originalSize) * 100).toFixed(1);
console.log(`✅ ${path.basename(inputPath)} → ${path.basename(outputPath)} (${savings}% 절약)`);
}
코드에 실제로 적용하기
성공적인 최적화를 위해 Next.js의 내장 기능을 적극 활용하고, 컴포넌트 레벨에서 다양한 최적화 기법을 적용했습니다.
1. Next.js 이미지 설정 최적화
next.config.mjs 파일에서 images 설정을 통해 다양한 디바이스 환경에 맞는 최적의 이미지를 자동으로 제공하도록 했습니다. 또한 minimumCacheTTL을 1년으로 설정하여 CDN 캐시를 효율적으로 활용해 반복 접속 시 빠른 로딩을 유도했습니다.
// next.config.mjs
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 31536000, // 1년 캐시
unoptimized: false,
},
// ... 기타 설정
};
2. 컴포넌트 레벨 최적화
사용자가 가장 먼저 보게 될 배너 컴포넌트를 next/image 컴포넌트를 활용해 로딩 우선순위를 조정했습니다. 위 코드에서 첫 번째 배너 이미지는 priority 속성을 적용해 즉시 로드되도록 하고, 나머지는 loading="lazy"를 사용해 지연 로딩을 구현했습니다. 이를 통해 초기 로딩 시간을 단축했습니다.
// src/components/home/BannerSection.tsx
const bannerImages = [
'/images/tuny1.avif', // AVIF 포맷 사용
'/images/tuny2.avif',
'/images/tuny3.avif',
'/images/tuny4.avif',
'/images/tuny5.avif',
];
export default function BannerSection() {
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set([0]));
// Intersection Observer로 지연 로딩
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setLoadedImages((prev) => {
const newSet = new Set(prev);
newSet.add(0);
newSet.add(1);
return newSet;
});
observer.disconnect();
}
});
},
{ threshold: 0.1 },
);
// ...
}, []);
return (
<div className="relative aspect-[3/2] overflow-hidden rounded-lg">
{bannerImages.map((src, index) => (
<div key={index}>
{loadedImages.has(index) ? (
<Image
src={src}
alt={`배너 ${index + 1}`}
width={400}
height={267}
priority={index === 0} // 첫 번째 이미지만 우선 로딩
loading={index === 0 ? 'eager' : 'lazy'}
sizes="(max-width: 768px) 100vw, 50vw"
/>
) : (
<div className="animate-pulse bg-gray-200" />
)}
</div>
))}
</div>
);
}
2-1. 프로필 이미지 최적화
다수의 프로필 이미지를 효율적으로 관리하기 위해 추가적인 최적화 요소를 적용했습니다. Blur Placeholder를 사용해 이미지 로딩 중 사용자 경험을 개선했으며, 에러 핸들링을 추가하여 로딩 실패 시에도 끊김 없는 경험을 제공했습니다.
// src/components/onboarding/information/ProfileImageSelector.tsx
const PROFILE_IMAGES = [
'/images/default-profile.webp',
'/images/cat-profile.webp',
'/images/dog-profile.webp',
// ... WebP 포맷 사용
];
<Image
src={finalUrl}
alt="프로필 이미지"
width={100}
height={100}
className="h-full w-full object-cover"
loading="lazy"
sizes="(max-width: 768px) 100px, 100px"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyiwjA"
/>;
3. 번들 최적화
Webpack 설정을 통해 번들을 효율적으로 분할하여 초기 로딩 시 필요한 코드만 다운로드 되도록 수정했습니다.
// next.config.mjs - Webpack 번들 분할 설정
webpack: (config, { isServer, dev }) => {
if (!isServer && !dev) {
config.optimization = {
...config.optimization,
splitChunks: {
chunks: 'all',
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
chunks: 'all',
priority: 20,
maxSize: 100000,
},
next: {
test: /[\\/]node_modules[\\/]next[\\/]/,
name: 'next',
chunks: 'all',
priority: 18,
maxSize: 150000,
},
// ... 기타 라이브러리별 분할
},
},
};
}
return config;
};
Lighthouse 분석하기
저희는 최적화 전후 Lighthouse 리포트를 비교하며 실제 개선 효과를 분석해보았습니다.
전체 성능 점수는 70점에서 67점으로 소폭 하락했지만, 세부 지표를 살펴보았을 때 깊은 의미를 발견할 수 있었습니다.
| 성능 지표 | 최적화 전 | 최적화 후 | 변화 |
| FCP (First Contentful Paint) | 1.7초 | 0.9초 | 약 47% 개선 |
| LCP (Largest Contentful Paint) | 22.5초 | 12.6초 | 약 44% 개선 |
| TBT (Total Blocking Time) | 220ms | 350ms | 약 59% 악화 |
| Speed Index (SI) | 3.3초 | 3.1초 | 약 6% 개선 |
| 네트워크 페이로드 총 크기 | 3,874KiB | 3,934KiB | +60KiB 증가 |
😉 긍정적인 변화
FCP는 1.7초에서 0.9초로, LCP는 22.5초에서 12.6초로 개선되었습니다. 이는 이미지 로딩 지연 현상을 해결하여 사용자가 페이지에 더 빠르게 진입하고, 가장 중요한 콘텐츠를 더 빨리 볼 수 있게 되었음을 의미합니다.
📚 새로운 숙제
하지만 TBT가 220ms에서 350ms로 악화되었고, 전체 네트워크 페이로드 크기는 오히려 증가했습니다. 이는 이미지 최적화 로직이나 새로운 컴포넌트 로직이 메인 스레드에 부담을 주었을 가능성이 있습니다.
학습한 점과 향후 계획
이번 프로젝트를 통해 단순히 이미지 크기를 줄이는 것을 넘어, 메인 스레드와 네트워크 등 전체적인 웹 성능 구조를 이해하고 개선하는 것이 중요함을 깨달았습니다.
이번 프로젝트를 통해 얻은 교훈은 다음과 같습니다.
- 이미지 포맷 선택의 중요성입니다. AVIF의 우수한 압축률을 확인했지만, 모든 브라우저에서 지원되지는 않으므로 WebP를 함께 사용하는 fallback 전략이 필수적이라는 것을 알게 되었습니다.
- 자동화의 가치입니다. 수동 최적화는 비효율적이고 실수를 유발하기 쉽기 때문에, 빌드 파이프라인에 최적화 스크립트를 통합하여 일관성을 확보하는 것이 중요합니다.
- 기술적 지표 개선뿐만 아니라 지연 로딩, 우선순위 로딩 등 사용자 경험을 고려한 사용자 중심 최적화 기법이 중요합니다.
회고
FCP(First Contentful Paint)와 LCP(Largest Contentful Paint) 같은 주요 로딩 지표를 획기적으로 개선하며 사용자 경험을 향상시킨 좋은 경험이였습니다. 하지만 이 과정에서 TBT(Total Blocking Time)가 증가하고 전체 네트워크 페이로드 크기 또한 늘어나는 예상치 못한 문제에 직면하게 되었습니다.
이러한 결과는 성능 최적화가 단순히 한두 가지 지표를 개선하는 데 그치지 않고 전체적인 시스템의 균형을 고려해야 한다는 중요한 교훈을 주었습니다. 이미지 파일 크기를 줄였음에도 불구하고 TBT가 늘어난 것은 새로운 이미지 최적화 로직이나 컴포넌트 로직이 메인 스레드에 추가적인 부담을 주었을 가능성이 있고, 최적화 후 네트워크 페이로드 크기가 오히려 60KiB가량 증가한 것은 자바스크립트 번들 등 이미지 외의 다른 리소스가 더 커졌을 수 있음을 의미합니다. 앞으로는 위에서 말씀드린 목표를 실천하며 단기적인 지표 개선을 넘어, 지속적인 성능 모니터링과 분석을 통해 사용자에게 끊김 없고 빠른 최고의 경험을 제공하겠습니다.
References
'👩🏻💻 Develop > Performance Optimization' 카테고리의 다른 글
| [Next.js] 성능 최적화를 위한 데이터 캐싱하기 (0) | 2025.10.13 |
|---|---|
| [Next.js] SSR 성능 최적화: Core Web Vitals 지표 개선하기 (1) | 2025.09.20 |
| [Next.js] Dynamic Import로 번들 사이즈 25% 최적화하기 (0) | 2025.09.18 |
| [Next.js] Zustand를 활용한 성능 개선하기 (1) | 2025.09.13 |
| [Next.js] 메모이제이션 및 리렌더링을 활용한 TBT 43% 개선기 (3) | 2025.09.01 |