Post

[Day55] 스켈레톤, 쇼핑몰 실습(신상품)

[Day55] 스켈레톤, 쇼핑몰 실습(신상품)

🔸 스켈레톤 UI

  • 콘텐츠가 로딩 중일 때 표시되는 저해상도 미리보기 또는 자리 표시자 화면
  • 실제 콘텐츠의 구조와 레이아웃을 시각적으로 표현해 체감 로딩 시간을 줄이고 사용자 경험을 향상시킴

스켈레톤 UI 목적

  • 인지된 성능 향상
    • 사용자에게 “무언가 로딩 중이다”라는 신호를 줘서 기다림의 지루함 완화
  • 레이아웃 시프트 방지
    • 콘텐츠 로드 시 발생하는 요소의 갑작스러운 이동 방지
  • 사용자 경험 개선
    • 빈 화면이나 단순 스피너보다 더 친숙하고 안정적인 인상 제공

주요 특징

  • 실제 레이아웃에 맞춰 구성
  • 중립적인 색상: 회색 계열로 시각적 방해 최소화
  • 애니메이션 효과: shimmer(반짝임) 등으로 로딩 상태 강조
  • 세부 정보 생략: 텍스트·이미지 대신 단순 도형 사용

실습 적용

🔄 로딩 중 UI

day53

✅ 로딩 완료 UI

day53

HeroSlider 컴포넌트 코드 요약

HeroSlider.jsx 핵심 구현

  • loading 상태를 기준으로 스켈레톤 UI vs 실제 UI 분기 렌더링
  • 임시로 3초 delay 적용하여 로딩 상태 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{loading ? (
  <SwiperSlide>
    <div className={`${css.skletion} ${css.imgWrap}`}></div>
  </SwiperSlide>
) : (
  banner.map(item => (
    <SwiperSlide key={item.id}>
      <div className={css.imgWrap}>
        <img src={item.img} alt={item.title} />
      </div>
      ...
    </SwiperSlide>
  ))
)}

CSS - 스켈레톤 효과 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.skletion::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 1) 50%,
    rgba(255, 255, 255, 0) 100%
  );
  animation: shimmer 1s ease-in infinite;
}

@keyframes shimmer {
  0% {
    transform: translateX(-100%);
  }
  100% {
    transform: translateX(100%);
  }
}

🔸 CSS 애니메이션 사용하기

  • CSS 스타일 속성 값을 시간에 따라 부드럽게 변화시키는 기능

animation 속성의 하위 속성

속성명설명
@keyframes애니메이션의 단계별 스타일 변화 정의 (0%, 100% 등)
animation-name사용할 @keyframes의 이름 지정
animation-duration애니메이션 전체 실행 시간 (ex: 2s, 500ms)
animation-timing-function애니메이션의 속도 곡선 설정 (ex: linear, ease-in)
animation-iteration-count애니메이션 반복 횟수 (ex: 1, infinite)
animation-delay엘리먼트가 로드되고 나서 언제 애니메이션이 시작될지 (ex: 1s)
animation-direction반복 시 진행 방향 설정 (normal, reverse, alternate, alternate-reverse)
animation-fill-mode애니메이션 전/후 상태 유지 여부 (none, forwards, backwards, both)
animation-play-state애니메이션 재생/일시정지 상태 제어 (running, paused)

실습 코드 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.skeleton::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 1) 50%,
    rgba(255, 255, 255, 0) 100%
  );
  animation-name: shimmer;                 /* keyframes 이름 */
  animation-timing-function: ease-in;      /* 느리게 시작 */
  animation-duration: 2s;                  /* 2초 동안 실행 */
  animation-iteration-count: infinite;     /* 무한 반복 */
  transform: translateX(0%);
}
1
2
3
4
5
6
7
8
9
10
11
@keyframes shimmer {
  0% {
    transform: translateX(-100%);  /* 왼쪽 바깥에서 시작 */
  }
  50% {
    /* 중간 단계에서 스타일을 지정할 수 있음 (생략 가능) */
  }
  100% {
    transform: translateX(100%);  /* 오른쪽 바깥으로 이동 */
  }
}

🔸 스켈레톤 UI 지연 처리 (with Promise)

❓ 질문 요약

1
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

이걸 왜 쓸까? 왜 그냥 아래처럼 안 쓰는가,,,
new Promise는 처음보는 형태의 코드라서 좀 더 찾아보았다.

1
2
3
setTimeout(() => {
  // 뭐 실행
}, 3000)

💡 핵심 개념 요약

1. setTimeout“비동기 함수 내부에서 기다려주는 기능”이 없음

1
2
3
4
setTimeout(() => {
  console.log('3초 뒤 실행!')
}, 3000)
console.log('바로 실행!')

👉 이 경우 console.log('바로 실행!')이 먼저 실행됨
→ 예약만 해놓고 기다려주지 않고 다음 코드로 바로 넘어간다.

2. Promise + await을 쓰면 “기다렸다가 다음 코드 실행” 가능

1
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
  • setTimeout()Promise로 래핑
  • 이제 await delay(3000)처럼 사용하면 실제로 3초 기다린다!

resolve는 무슨 역할?
Promise가 “작업을 성공적으로 끝냈다”는 걸 알려주는 함수

Promise
“나, 나중에 어떤 일이 끝나면 알려줄게!” 라고 약속하는 객체

그리고 그 약속이 잘 끝났을 때resolve()를 호출해서
“끝났어!”라고 알려줌

예:
setTimeout(resolve, ms)
→ ms 후에 resolve()가 호출됨
→ 그러면 delay(ms)라는 Promise가 성공적으로 끝났다고 인식됨


실제 코드 사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

const fetchBanner = async () => {
    try {
        setLoading(true)
        const data = await getBannerData()
        await delay(3000) // 3초 동안 스켈레톤 UI 유지
        setBanner(data)
        setLoading(false)
    } catch (err) {
        console.log(err)    
    }
}

Promise + await 조합은
비동기 코드를 동기처럼 짜기 위한 도구 !
즉, “순서대로 처리되도록 보이게 하고, 실제로도 그렇게 작동하게” 해주는 것.


new Promise란?

1
2
3
const promise = new Promise((resolve, reject) => {
  // 비동기 작업 실행
})
  • new Promise는 “나, 나중에 어떤 작업이 끝나면 결과를 알려줄게!”라고 약속(promise) 을 만드는 객체
  • resolve → 작업이 성공했을 때 호출
  • reject → 작업이 실패했을 때 호출

기본 사용 예시

1
2
3
4
5
6
7
8
9
const promise = new Promise((resolve, reject) => {
  const success = true

  if (success) {
    resolve('성공했어요!')
  } else {
    reject('실패했어요!')
  }
})

2초 뒤에 작업 완료되는 Promise

1
2
3
4
5
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

delay(2000).then(() => {
  console.log('2초 지남!')
})
  • new Promise로 만든 함수 delay
  • setTimeout이 끝난 뒤 → resolve()를 호출함
  • 그러면 .then()으로 “2초 뒤에 실행해줘!”가 가능해짐

🔸 json-server

⚠️ 문제가 생길 수 있는 예시

조건부 정상 작동 (ID처럼 정확한 경로 요청일 때만)

1
2
3
4
5
6
7
8
9
10
11
12
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 (error) {
    throw error
  }
}
// query가 '3'이라면 → /products/3 → 정상 작동
// query가 'category=new&_limit=8'이라면 → ❌ 문제 생김
  • ✔️ query가 ID처럼 숫자일 땐 json-server가 해당 id의 리소스를 반환
  • ❌ 하지만 query string(category=new&_limit=8)이면 경로로 인식돼서 오류 발생

슬래시 중복

1
2
3
4
5
6
7
8
9
10
11
12
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 (error) {
    throw error
  }
}
// 예: query가 "category=new&_limit=8"이면
// → 요청 URL: http://localhost:3000/products/category=new&_limit=8 ❌
  • /products/category=new&_limit=8 이라는 경로로 요청한 것
  • 쿼리 파라미터로 인식이 안 되고, 그냥 경로 이름으로 인식함
    • 그래서 404 Not Found 에러 뜰 수 있음

✅ 올바른 요청

1
2
3
4
5
6
7
8
9
10
11
12
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 (error) {
    console.log(error)
    throw error
  }
}
// 예: category=new&_limit=8 → /products?category=new&_limit=8
  • ✅ 올바른 요청 형식: /products?category=new&_limit=8
  • json-server/products 뒤에 ?query=... 붙이는 걸 잘 인식함
  • / 이후에 바로 ? 붙이는 건 문제 안 됨 → 이건 쿼리스트링
    • const res = await axios.get(${BASE_URL}/?${query}) 이 코드도 정상 작동

END

This post is licensed under CC BY 4.0 by the author.