[#55] React 쇼핑몰 - 스켈레톤 UI + 반응형 메인 리스트 페이지

2025. 4. 16. 17:13·LG 유플러스 유레카 SW/React
목차
  1. 이미지 용량 최적화
  2. ☠️ 스켈레톤 UI
  3. 배너가 로딩 중일 때 스켈레톤 UI 구현
  4. 반응형 메인 리스트 페이지

이미지 용량 최적화

✅ 이미지 최적화가 중요한 이유

  • 전체 페이지 로딩 시간의 대부분은 이미지가 차지
  • 이미지 최적화는 웹 성능 개선의 핵심 요소

 

📌 최적화 기준

  • 1MB 이상은 너무 큼 ➡️ 용량 줄이기 필수
  • 가능하면 200~300KB 이하로 유지

 

🥊 실제 용량 줄인 후 Lighthouse 비교

최적화 전 vs 최적화 후

 

 

☠️ 스켈레톤 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을 써서 넓은 화면에서 빈 공간이 없도록 함
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
      • 넘치는 자식 요소를 잘라냄
  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

[![Node.js CI](https://github.com/typicode/json-server/actions/workflows/node.js.yml/badge.svg)](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
  1. 이미지 용량 최적화
  2. ☠️ 스켈레톤 UI
  3. 배너가 로딩 중일 때 스켈레톤 UI 구현
  4. 반응형 메인 리스트 페이지
'LG 유플러스 유레카 SW/React' 카테고리의 다른 글
  • [#57] React 쇼핑몰 - 상품 상세 페이지(탭, 슬라이더) + 장바구니 조회/추가
  • [#56] React 쇼핑몰 - 상품 상세 페이지
  • [#54] React 쇼핑몰 - 성능 향상 + Hero 페이지 제작
  • [#53] React 쇼핑몰 - 헤더 반응형으로 만들어보기
nueos
nueos
  • nueos
    nueos 공부 기록
    nueos
  • 전체
    오늘
    어제
    • 분류 전체보기 (191)
      • 해커톤 (1)
      • 네이버 BoostCamp (6)
      • LG 유플러스 유레카 SW (3)
        • React (21)
        • TypeScript (2)
        • JavaScript (2)
        • HTML+CSS (5)
        • Spring (7)
        • Java (6)
        • SQL (2)
        • Algorithm (8)
        • CX (6)
        • Git (2)
        • 프로젝트 (2)
        • 스터디 (9)
        • 과제 (8)
        • 특강 (1)
      • React (3)
      • Next (0)
      • Javascript (2)
      • HTML (2)
      • CSS (9)
      • Algorithm (6)
      • Database (0)
      • OS (13)
      • C++ (24)
      • Python (1)
      • jQuery (1)
      • Django (1)
      • Git (1)
      • 개발 지식 (3)
      • 정보 보안 (22)
      • 포렌식 (1)
      • 암호 (2)
      • 기타 (4)
      • 패스트캠퍼스 FE 프로젝트십 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    제주해커톤
    디지랩챌린지
    힙
    Queue
    스택
    제주지역혁신플랫폼지능형서비스사업단
    기술로바꾸는세상
    완전 탐색
    exhaustive search
    heap
    큐
    디지털혁신
    Stack
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
nueos
[#55] React 쇼핑몰 - 스켈레톤 UI + 반응형 메인 리스트 페이지
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.