이미지 용량 최적화
✅ 이미지 최적화가 중요한 이유
- 전체 페이지 로딩 시간의 대부분은 이미지가 차지
- 이미지 최적화는 웹 성능 개선의 핵심 요소
📌 최적화 기준
- 1MB 이상은 너무 큼 ➡️ 용량 줄이기 필수
- 가능하면 200~300KB 이하로 유지
🥊 실제 용량 줄인 후 Lighthouse 비교


☠️ 스켈레톤 UI
https://somyclass.notion.site/UI-1d6973f5e0fe8018b219d86627ba1bb8
스켈레톤 UI | Notion
스켈레톤 UI(Skeleton UI)는 콘텐츠가 로딩되는 동안 표시되는 저해상도 미리보기 또는 자리 표시자 화면으로, 실제 콘텐츠의 구조와 레이아웃을 시각적으로 나타냅니다. 이는 사용자에게 콘텐츠가
somyclass.notion.site
배너가 로딩 중일 때 스켈레톤 UI 구현
1️⃣ ::after 가상요소와 shimmer 애니메이션으로 빛나는 줄이 지나가는 듯한 효과 만들기
- ::after로 <div className="skeleton"> 내부에 애니메이션용 가상 요소 삽입
- linear-gradient로 중앙이 흰색으로 빛나고 양옆은 투명한 효과를 만들기
- animation: shimmer 1.5s infinite; 로 무한 애니메이션
/* 스켈레톤 UI */
/* .skeleton { -> imgWrap 과 겹치는 부분 제외하니 모두 주석 처리
position: relative;
width: 300px;
height: 300px;
overflow: hidden;
} */
.skeleton::after {
content: '';
position: absolute;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 50%,
rgba(255, 255, 255, 0) 100%
);
width: 100%;
height: 100%;
top: 0;
left: 0;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
/* %는 시간을 나타냄 (ex. 2초 -> 0% = 0초, 50% = 1초, 100% = 2초) */
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
2️⃣ 로딩 상태 정의
const [loading, setLoading] = useState(false)
useEffect(() => {
const fetchBanner = async () => {
try {
setLoading(true) // 로딩 O
const data = await getBannerData()
setBanner(data)
setLoading(false) // 로딩 X
} catch (err) {
console.log('error', err)
setLoading(false) // 로딩 X
}
}
fetchBanner()
}, [])
return (
<section>
<h2 hidden>Banner Event</h2>
<Swiper pagination={{ clickable: true }} modules={[Pagination]} className={css.mainSlider}>
{loading ? (
<SwiperSlide>
<div className={`${css.skeleton} ${css.imgWrap}`}></div>
</SwiperSlide>
) : (
banner.map(item => (
<SwiperSlide key={item.id}>
<div className={css.imgWrap}>
<img src={item.img} alt={item.title} />
</div>
<div className={css.textWrap}>
<p className={css.title}>{item.title}</p>
<p className={css.desc}>{item.description}</p>
<Link to={item.link} className={css.more}>
View Product
</Link>
</div>
</SwiperSlide>
))
)}
</Swiper>
</section>
)
3️⃣ 딜레이 임시 적용
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
useEffect(() => {
const fetchBanner = async () => {
try {
setLoading(true) // 로딩 O
const data = await getBannerData()
await delay(1000) // 1초 지연
setBanner(data)
setLoading(false) // 로딩 X
} catch (err) {
console.log('error', err)
setLoading(false) // 로딩 X
}
}
fetchBanner()
}, [])
➡️ 결과

➡️ 스켈레톤 UI 적용 후 성능

반응형 메인 리스트 페이지
1️⃣ 레이아웃 및 스타일 잡기
<section className={css.listCon}>
<h2>Shop The Latest</h2>
<Link to={'/shop'} className={css.more}>
View All
</Link>
<ul className={css.list}>
{products.map(item => (
<li key={item.id}>
<ProductCard item={item} />
</li>
))
)}
</ul>
</section>
.listCon {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--fs33);
}
.more {
font-size: var(--fs12);
transition: color 0.3s;
}
.more:hover {
color: var(--dark-colors-accent-dark);
font-weight: bold;
}
.more::after {
content: '\F138'; /* 부트스트랩 아이콘 CSS Code point */
font-family: 'bootstrap-icons'; /* 필수 작성 */
display: inline-block;
transition: transform 0.3s;
}
.more:hover::after {
transform: translateX(5px);
}
.list {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: var(--fs20);
}
@media (max-width: 600px) {
.list {
grid-template-columns: repeat(auto-fill, 1fr);
}
}
- Grid 스타일 적용 - 부모의 너비에 따라 최소 200px 이상인 박스를 가능한 만큼 자동으로 배치 + 남는 공간도 자동으로 나눠서 균등하게 채움
- auto-fill
- repeat() 함수와 함께 사용
- 열 또는 행의 개수를 미리 지정하지 않고, 설정된 너비가 허용하는 한 최대한 셀을 채움
- 컨테이너 내부에 공간이 남을 경우, auto-fit의 경우 셀의 길이를 늘려서 꽉 채우는데 비해 auto-fill은 셀의 길이를 늘리지 않음. 단, 보이지 않는 셀들이 존재
- minmax(<min-size>,<max-size>)
- 웹페이지가 resize될 때 최소값을 줌으로 레이아웃 유지
- 최대값은 fr을 써서 넓은 화면에서 빈 공간이 없도록 함
- auto-fill
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
2️⃣ 상품 카드 컴포넌트 생성
import React from 'react'
import { Link } from 'react-router-dom'
import css from './ProductCard.module.css'
const ProductCard = ({ item }) => {
return (
<div className={css.card}>
<div className={css.imgWrap}>
<img src={`/public/img/${item.img}`} alt={item.title} />
<span className={css.cate}>{item.category}</span>
<span className={css.discount}>{item.discount}%</span>
</div>
<div className={css.textWrap}>
<strong className={css.title}>{item.title}</strong>
<span className={css.price}>{item.price.toLocaleString()}원</span>
</div>
<Link to={`/detail/${item.id}`} className={css.btnGoDetail}></Link>
</div>
)
}
export default ProductCard
.card {
position: relative;
}
.card:hover img {
transform: scale(1.5);
}
.imgWrap {
border-radius: var(--fs16);
overflow: hidden;
padding-top: 100%; /* 부모의 너비 100% -> 정사각형 */
position: relative;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
}
.imgWrap > * {
position: absolute;
}
.imgWrap img {
width: 100%;
height: 100%;
object-fit: cover;
top: 0;
transition: transform 0.3s;
}
.cate {
bottom: var(--fs16);
right: var(--fs16);
color: var(--dark-colors-black-dark);
font-weight: bold;
}
.discount {
top: var(--fs16);
left: var(--fs16);
background-color: var(--dark-colors-accent-dark);
color: var(--dark-colors-white-dark);
padding: var(--fs8) var(--fs16);
border-radius: var(--fs33);
}
.textWrap {
display: flex;
flex-direction: column;
gap: var(--fs8);
font-size: var(--fs20);
padding: var(--fs16);
}
.title {
color: var(--dark-colors-black-dark);
}
.price {
color: var(--dark-colors-accent-dark);
font-weight: bold;
}
.btnGoDetail {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
- 정사각형 비율의 박스를 만들기 위한 패턴
- padding-top: 100%
- padding-top은 부모 요소의 width의 비율로 계산됨
- padding-top: 100%는 부모의 너비 = 본인의 높이, 즉 정사각형
- position: relative
- 자식 요소에서 absolute 포지션을 쓸 수 있게 함
- overflow: hidden
- 넘치는 자식 요소를 잘라냄
- padding-top: 100%
overflow: hidden;
padding-top: 100%; /* 부모의 너비 100% -> 정사각형 */
position: relative;
3️⃣ json-server로 데이터 통신
- productApi.js
import axios from 'axios'
const BASE_URL = 'http://localhost:3000/products'
export const getProductsData = async (query = '') => {
try {
const res = await axios.get(`${BASE_URL}/?${query}`)
return res.data
} catch (err) {
console.log('[error]', err)
}
}
- LatestList.jsx
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(false)
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
useEffect(() => {
const fetchProducts = async () => {
try {
setLoading(true)
const data = await getProductsData(`category=new&_limit=${count}`)
await delay(1000) // 1초 지연
setProducts(data)
setLoading(false)
} catch (err) {
console.log('[error]', err)
setLoading(false)
}
}
fetchProducts()
}, [count])
📍 json-server 조건 옵션
- 쿼리 파라미터
- category=new 👉🏻 category가 'new'인 상품만 가져오기
- _limit=8 👉🏻 최대 8개까지만 가져오기
const data = await getProductsData('category=new&_limit=8')
https://www.npmjs.com/package/json-server
json-server
[](https://github.com/typicode/json-server/actions/workflows/node.js.yml). Latest version: 1.0.0-beta.3, last published: 7 months ago. Start using json-server in
www.npmjs.com
➡️ 반응형 완성



728x90
반응형
'LG 유플러스 유레카 SW > React' 카테고리의 다른 글
[#57] React 쇼핑몰 - 상품 상세 페이지(탭, 슬라이더) + 장바구니 조회/추가 (0) | 2025.04.18 |
---|---|
[#56] React 쇼핑몰 - 상품 상세 페이지 (1) | 2025.04.17 |
[#54] React 쇼핑몰 - 성능 향상 + Hero 페이지 제작 (2) | 2025.04.15 |
[#53] React 쇼핑몰 - 헤더 반응형으로 만들어보기 (1) | 2025.04.14 |
[#52] React Router (2) | 2025.04.11 |