국립대구과학관 홈페이지 개편
🔎 프로젝트 개요
📅 프로젝트 기간: 2025.02 ~ 2025.05
💡프로젝트 소개
국립대구과학관의 전시/행사 예약 통합 관리 및 공모전 운영·시상 홍보 플랫폼의 노후화된 디자인 및 시스템 구조를 개선하는 프로젝트입니다.
사용자 홈페이지와 관리자 시스템을 Vue.js 3 기반 프론트엔드와 Spring Boot 기반 백엔드로 통합하여 클라우드 환경에 맞춰 고도화를 진행했습니다.
⚙️ Skills
- Frontend: Vue.js 3, Pinia, Vite
- Backend: Java, Spring Boot, MyBatis, MySQL
🧩 담당 역할
- 관리자 신청 관리 기능 UI 및 연동 개발
- 사용자 공모전 수상작/신청 기능 UI 및 연동 개발
- Intersection Observer 기반 이미지 Lazy Loading 최적화
- v-lazy 커스텀 디렉티브 설계 및 구현
- Lighthouse 기반 웹 성능 분석 및 개선
🧨 문제 인식
이미지 중심 페이지의 성능 저하
공모전 수상작 리스트 및 메인 페이지는 고해상도 이미지가 다량(70+개) 포함되어 있어, 초기 전체 이미지 로딩 시 다음과 같은 문제가 발생했습니다:
- 초기 렌더링 시 모든 이미지 동시 요청으로 인한 네트워크 병목
- 페이지 프리징 현상 발생
- Layout Shift로 인한 사용자 경험 저하
- 느린 초기 로딩 속도
동적 컴포넌트의 이미지 로딩 제어 어려움
- 기본
loading="lazy"속성으로는 Swiper 등 동적 컴포넌트의 이미지 렌더링 제어가 불가능
- Active 슬라이드가 아닌 이미지도 모두 로드되어 불필요한 리소스 낭비
외부 콘텐츠 최적화의 제약
- 유튜브 썸네일 등 외부 이미지 리소스는 직접 최적화 적용이 어려움
- 다양한 디바이스 해상도에 대한 이미지 최적화 필요
🛠️ 해결 방안
🧩 Intersection Observer 기반 이미지 Lazy Loading 구현
Lighthouse 성능 분석
Lighthouse를 활용하여 페이지 로딩 성능을 분석하고, 이미지 로딩이 주요 병목 지점임을 확인했습니다.
- FCP(First Contentful Paint): 이미지 로딩으로 인한 지연
- LCP(Largest Contentful Paint): 메인 이미지 로딩 시간 영향
- CLS(Cumulative Layout Shift): 이미지 로드 전후 레이아웃 변경으로 인한 높은 CLS 값 (0.25)
Intersection Observer 기반 Composable 개발
뷰포트 진입 감지를 위한 재사용 가능한 composable을 개발했습니다.
// composables/useIntersectionObserver.js import { ref, onMounted, onUnmounted } from 'vue'; export function useIntersectionObserver(options = {}) { const isIntersecting = ref(false); const target = ref(null); let observer = null; const createObserver = () => { observer = new IntersectionObserver( ([entry]) => { isIntersecting.value = entry.isIntersecting; // 한 번 로드되면 observer 해제 (메모리 최적화) if (entry.isIntersecting && options.once) { observer.disconnect(); } }, { rootMargin: options.rootMargin || '50px', threshold: options.threshold || 0.1, } ); if (target.value) { observer.observe(target.value); } }; onMounted(() => { createObserver(); }); onUnmounted(() => { if (observer) { observer.disconnect(); } }); return { target, isIntersecting }; }
v-lazy 커스텀 디렉티브 구현
Intersection Observer를 활용한 v-lazy 디렉티브를 구현하여 뷰포트 진입 시에만 이미지를 로드하도록 최적화했습니다.
// directives/lazy.js export const vLazy = { mounted(el, binding) { const options = { rootMargin: '50px', threshold: 0.1, }; const loadImage = () => { const img = el.tagName === 'IMG' ? el : el.querySelector('img'); if (img) { // data-src 속성의 이미지 URL을 실제 src로 설정 const src = img.dataset.src; if (src) { img.src = src; img.classList.add('loaded'); img.removeAttribute('data-src'); } } }; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadImage(); observer.disconnect(); } }); }, options); observer.observe(el); // cleanup을 위해 element에 observer 저장 el._lazyObserver = observer; }, unmounted(el) { if (el._lazyObserver) { el._lazyObserver.disconnect(); delete el._lazyObserver; } }, };
사용 예시
<template> <div class="image-container"> <!-- Skeleton UI --> <div v-if="!imageLoaded" class="skeleton"></div> <!-- Lazy Loading 이미지 --> <img v-lazy :data-src="imageUrl" alt="공모전 작품" @load="imageLoaded = true" /> </div> </template> <script setup> import { ref } from 'vue'; const imageLoaded = ref(false); const imageUrl = 'https://example.com/image.jpg'; </script> <style scoped> .skeleton { width: 100%; height: 200px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: loading 1.5s infinite; } @keyframes loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } </style>
효과
- 뷰포트에 진입한 이미지만 로드하여 초기 네트워크 요청 감소
- Skeleton UI로 Layout Shift 방지
- 재사용 가능한 디렉티브로 코드 중복 최소화
🧩 Swiper 슬라이더 내 이미지 최적화
문제 상황
Swiper 컴포넌트 내의 모든 슬라이드 이미지가 초기에 로드되어 불필요한 리소스 낭비가 발생했습니다.
해결 방안
Active 슬라이드만 이미지를 로드하도록 v-lazy 디렉티브를 확장했습니다.
// directives/lazy.js (Swiper 지원 추가) export const vLazy = { mounted(el, binding) { const options = { rootMargin: '50px', threshold: 0.1, }; const loadImage = () => { const img = el.tagName === 'IMG' ? el : el.querySelector('img'); if (img) { const src = img.dataset.src; if (src) { img.src = src; img.classList.add('loaded'); img.removeAttribute('data-src'); } } }; // Swiper Active Slide 체크 const checkSwiperActive = () => { const swiperSlide = el.closest('.swiper-slide'); if (swiperSlide) { return swiperSlide.classList.contains('swiper-slide-active'); } return true; // Swiper가 아니면 일반 처리 }; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && checkSwiperActive()) { loadImage(); observer.disconnect(); } }); }, options); observer.observe(el); el._lazyObserver = observer; }, unmounted(el) { if (el._lazyObserver) { el._lazyObserver.disconnect(); delete el._lazyObserver; } }, };
효과
- Active 슬라이드 이미지만 로드하여 네트워크 리소스 절약
- 슬라이드 전환 시 자연스러운 이미지 로딩
🧩 유튜브 썸네일 srcset 적용
문제 상황
유튜브 썸네일은 외부 리소스로 직접 최적화가 어려웠고, 다양한 디바이스 해상도에 대한 대응이 필요했습니다.
해결 방안
srcset 속성을 활용하여 디바이스 viewport에 따라 최적 해상도를 자동 선택하도록 구현했습니다.<template> <img v-lazy :data-src="youtubeThumbnail.default" :data-srcset="` ${youtubeThumbnail.default} 120w, ${youtubeThumbnail.medium} 320w, ${youtubeThumbnail.high} 480w, ${youtubeThumbnail.maxres} 1280w `" sizes="(max-width: 768px) 320px, (max-width: 1024px) 480px, 1280px" alt="유튜브 썸네일" /> </template> <script setup> const youtubeThumbnail = { default: 'https://img.youtube.com/vi/VIDEO_ID/default.jpg', medium: 'https://img.youtube.com/vi/VIDEO_ID/mqdefault.jpg', high: 'https://img.youtube.com/vi/VIDEO_ID/hqdefault.jpg', maxres: 'https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg', }; </script>
효과
- 디바이스에 최적화된 이미지 해상도 자동 선택
- 모바일 환경에서 불필요한 고해상도 이미지 로드 방지
- 네트워크 대역폭 절약
🚀 주요 성과
이미지 로딩 성능 대폭 개선
- 초기 이미지 요청 수 60-70% 감소: Fullscreen 기준 60-70개 → 20-25개
- 초기 렌더링 속도 40-60% 개선: Lazy Loading으로 필수 리소스만 우선 로드
- 페이지 프리징 현상 완전 해소: 점진적 이미지 로딩으로 메인 스레드 부하 분산
CLS(Cumulative Layout Shift) 개선
- CLS 약 70% 개선: 0.25 → 0.07
- Skeleton UI 도입으로 Layout Shift 최소화
- 사용자 경험 품질 향상
재사용 가능한 최적화 인프라 구축
- v-lazy 커스텀 디렉티브를 공통 유틸리티로 분리하여 프로젝트 전반에 재사용
- Intersection Observer 기반 composable로 다양한 최적화 시나리오 대응 가능
- 유지보수성 및 확장성 확보
🤔 아쉬운 점 및 개선 방향
CDN 및 이미지 캐싱 인프라 부재
현재 상황
이미지 최적화는 클라이언트 레벨에서 이루어졌으나, 서버 및 인프라 레벨의 최적화는 적용되지 않았습니다.
개선 방향
CDN(Content Delivery Network) 도입
- CloudFront, Cloudflare 등 CDN을 통한 이미지 캐싱 및 전송 최적화
- 지리적으로 가까운 엣지 서버에서 이미지 제공
- 원본 서버 부하 감소 및 응답 속도 향상
이미지 변환 및 최적화 서비스 활용
- Imgix, Cloudinary 등 이미지 최적화 서비스 도입
- WebP, AVIF 등 차세대 이미지 포맷 자동 변환
- 동적 리사이징 및 품질 조정
브라우저 캐싱 전략 강화
- Cache-Control 헤더 설정으로 브라우저 캐싱 최적화
- Service Worker를 활용한 오프라인 캐싱 전략 도입
기대 효과
- 반복 방문 시 이미지 로딩 시간 대폭 단축
- 서버 대역폭 비용 절감
- 글로벌 사용자 대응 가능
이미지 포맷 최적화 부족
현재 상황
JPEG, PNG 등 전통적인 이미지 포맷만 사용되어 파일 크기 최적화에 한계가 있었습니다.
개선 방향
차세대 이미지 포맷 도입
- WebP: 손실/무손실 압축 모두 지원, JPEG 대비 25-35% 파일 크기 감소
- AVIF: 최신 포맷으로 WebP 대비 추가 20-30% 압축률 향상
- 브라우저 호환성에 따라 fallback 제공
<picture> <source srcset="image.avif" type="image/avif"> <source srcset="image.webp" type="image/webp"> <img src="image.jpg" alt="fallback"> </picture>
기대 효과
- 파일 크기 감소로 전송 시간 단축
- 네트워크 대역폭 절약
- 모바일 사용자 경험 개선
📚 기술적 성장
웹 성능 최적화 경험
- Lighthouse를 활용한 체계적인 성능 분석 및 개선 경험
- Core Web Vitals (FCP, LCP, CLS) 개선 노하우 습득
- 웹 성능 지표에 대한 깊이 있는 이해
브라우저 API 활용
- Intersection Observer API의 실무 활용 경험
- 커스텀 디렉티브 설계 및 구현 역량 향상
- 재사용 가능한 유틸리티 설계 경험
성능과 사용자 경험의 균형
- 성능 최적화와 사용자 경험을 동시에 고려한 설계 경험
- Skeleton UI 등 로딩 상태를 자연스럽게 처리하는 방법 습득
- 점진적 개선(Progressive Enhancement) 전략 이해