🔸 스켈레톤 UI
- 콘텐츠가 로딩 중일 때 표시되는 저해상도 미리보기 또는 자리 표시자 화면
- 실제 콘텐츠의 구조와 레이아웃을 시각적으로 표현해 체감 로딩 시간을 줄이고 사용자 경험을 향상시킴
스켈레톤 UI 목적
- 인지된 성능 향상
- 사용자에게 “무언가 로딩 중이다”라는 신호를 줘서 기다림의 지루함 완화
- 레이아웃 시프트 방지
- 콘텐츠 로드 시 발생하는 요소의 갑작스러운 이동 방지
- 사용자 경험 개선
- 빈 화면이나 단순 스피너보다 더 친숙하고 안정적인 인상 제공
주요 특징
- 실제 레이아웃에 맞춰 구성
- 중립적인 색상: 회색 계열로 시각적 방해 최소화
- 애니메이션 효과: shimmer(반짝임) 등으로 로딩 상태 강조
- 세부 정보 생략: 텍스트·이미지 대신 단순 도형 사용
실습 적용
🔄 로딩 중 UI
✅ 로딩 완료 UI
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