THIP-405: Skeleton UI 공통 컴포넌트 구현#305
Conversation
- LoadingSpinner 대신 콘텐츠 구조를 보여주는 Skeleton UI 도입 - shared/ui/Skeleton에 재사용 가능한 컴포넌트 구현 (Box/Circle/Text) - 피드 페이지 로딩 상태에 스켈레톤 적용으로 UX 개선
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
개요새로운 스켈레톤 기반 로딩 UI 시스템을 도입하여 피드 페이지의 로딩 상태를 개선했습니다. LoadingSpinner를 FeedPostSkeleton 컴포넌트로 교체하고, 최소 1초의 인위적 로딩 지연을 추가했으며, 재사용 가능한 Skeleton 컴포넌트 라이브러리를 새로 구축했습니다. 변경 사항
시퀀스 다이어그램sequenceDiagram
participant User
participant Feed as Feed 컴포넌트
participant SkeletonUI as SkeletonWrapper<br/>(FeedPostSkeleton)
participant DataLoader as 데이터 로더
participant Promise as Promise.all
User->>Feed: 탭 전환/페이지 로드
Feed->>Feed: 로딩 상태 시작
Feed->>SkeletonUI: SkeletonWrapper와 3개의<br/>FeedPostSkeleton 렌더링
SkeletonUI-->>User: 스켈레톤 UI 표시
par 병렬 처리
Feed->>DataLoader: 피드 데이터 요청
Feed->>Promise: 1초 최소 지연 시작
end
DataLoader-->>Promise: 데이터 로드 완료
Promise-->>Feed: Promise.all 완료
Feed->>Feed: 로딩 상태 종료
Feed-->>User: 실제 데이터로 UI 업데이트
코드 리뷰 예상 소요 시간🎯 3 (Moderate) | ⏱️ ~25 minutes 관련 가능성 있는 PR
제안 라벨
제안 검토자
시
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsErrors were encountered while retrieving linked issues. Errors (1)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/pages/feed/Feed.tsx (1)
139-160:⚠️ Potential issue | 🟠 Major
useEffect클린업 부재로 인한 경쟁 조건 (Race Condition).탭을 빠르게 전환하면 이전 effect의 비동기 작업이 계속 실행되어
setTabLoading(false)가 새 effect의 로딩이 완료되기 전에 호출됩니다.minLoadingTime이 1초이므로 이 경쟁 조건의 발생 가능성이 더 높아졌습니다.시나리오:
- 탭 A 로드 시작 →
setTabLoading(true), Promise.all 시작- 500ms 후 탭 B로 전환 → 새 effect가
setTabLoading(true)재설정- 탭 A의 Promise.all(1000ms)이 완료 →
setTabLoading(false)호출 → 스켈레톤 조기 소멸- 탭 B의 데이터가 아직 준비되지 않은 상태에서 콘텐츠가 노출됨
🔒 수정 예시: `cancelled` 플래그로 클린업 처리
useEffect(() => { + let cancelled = false; + const loadFeedsWithToken = async () => { await waitForToken(); + if (cancelled) return; setTabLoading(true); try { const minLoadingTime = new Promise(resolve => setTimeout(resolve, 1000)); if (activeTab === '피드') { await Promise.all([loadTotalFeeds(), minLoadingTime]); } else if (activeTab === '내 피드') { await Promise.all([loadMyFeeds(), minLoadingTime]); } } finally { - setTabLoading(false); - setInitialLoading(false); + if (!cancelled) { + setTabLoading(false); + setInitialLoading(false); + } } }; loadFeedsWithToken(); + + return () => { + cancelled = true; + }; }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/feed/Feed.tsx` around lines 139 - 160, The effect in Feed.tsx (useEffect -> loadFeedsWithToken) lacks a cleanup and so previous async work can call setTabLoading/setInitialLoading after a new tab starts; fix by adding a local cancelled flag (or AbortController) inside the useEffect, check it after each await/Promise.all and before calling setTabLoading(false) or setInitialLoading(false), and return a cleanup function that flips cancelled = true so earlier promises do not mutate state for a newer activeTab (apply checks around minLoadingTime/Promise.all and after loadTotalFeeds/loadMyFeeds calls).
🧹 Nitpick comments (3)
src/pages/feed/Feed.styled.ts (1)
11-16:136px매직 넘버를 CSS 변수 또는 공유 상수로 추출하는 것을 권장합니다.
padding-top: 136px은MainHeader와TabBar의 합산 높이를 하드코딩한 값입니다. 둘 중 하나의 높이가 변경되면 스켈레톤 위치가 어긋나게 됩니다.♻️ 리팩토링 예시
+// src/shared/constants/layout.ts 또는 tokens 파일 +export const FEED_CONTENT_TOP_OFFSET = 136; // MainHeader(88px) + TabBar(48px) // Feed.styled.ts +import { FEED_CONTENT_TOP_OFFSET } from '@/shared/constants/layout'; export const SkeletonWrapper = styled.div` min-height: 100vh; - padding-top: 136px; + padding-top: ${FEED_CONTENT_TOP_OFFSET}px; padding-bottom: 125px; background-color: var(--color-black-main); `;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/feed/Feed.styled.ts` around lines 11 - 16, The hardcoded padding-top: 136px in SkeletonWrapper should be replaced with a shared value: extract the combined MainHeader and TabBar height into a CSS variable (or shared constant) and use that variable (or calc using --main-header-height and --tab-bar-height) for padding-top so the skeleton tracks header/tab height changes; update SkeletonWrapper to reference that variable and add/update the variable definitions where global CSS or theme constants are defined (also update any places using the 136px magic number).src/shared/ui/Skeleton/Skeleton.styled.ts (1)
22-23:display: inline-block→ 블록 레벨 스켈레톤에display: block검토.현재 모든 사용처가 flex 컨테이너 내부이므로 실제 레이아웃 문제는 없습니다. 다만 스켈레톤은 의미상 블록 수준 플레이스홀더이므로
display: block이 더 자연스러운 기본값입니다.inline-block은 flex/grid 외부 컨텍스트에서 예기치 않은 레이아웃을 유발할 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/Skeleton/Skeleton.styled.ts` around lines 22 - 23, The SkeletonBase styled component currently sets display: inline-block which can cause unexpected layout outside flex/grid contexts; update the SkeletonBase definition (styled.div<SkeletonBaseProps>) to use display: block as the default for true block-level skeleton placeholders, then run/adjust any layout-related tests or snapshots that assume inline behavior and verify components using SkeletonBase inside flex containers still render correctly.src/pages/feed/Feed.tsx (1)
139-160: 탭 전환 시마다 데이터 재요청 + 최소 1초 지연 발생.
activeTab이 바뀔 때마다 이미 로드된 탭으로 돌아와도 항상 재요청 후 스켈레톤을 1초 이상 노출합니다. 이는 이 PR에서 추가된minLoadingTime으로 인해 기존 대비 UX 체감이 더 떨어질 수 있습니다. 탭별 로드 완료 여부를 별도 상태(totalLoaded,myLoaded)로 관리하여, 이미 로드된 탭에는 스켈레톤을 건너뛰는 방안을 고려해 보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/feed/Feed.tsx` around lines 139 - 160, The effect always forces a reload and the 1s minLoadingTime on every activeTab change via minLoadingTime; add per-tab loaded flags (e.g., totalLoaded, myLoaded) and update them to true after successful load in loadTotalFeeds and loadMyFeeds (or immediately after those calls); then in the useEffect check these flags before triggering a reload—only call loadTotalFeeds when activeTab === '피드' && !totalLoaded (and similarly for '내 피드' with myLoaded), and only include minLoadingTime in the Promise.all when performing an actual network load; make sure you still call setTabLoading(true) for real loads and clear setTabLoading(false) and setInitialLoading(false) in finally as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/shared/ui/Skeleton/README.md`:
- Around line 25-50: Add a blank line before and after each Markdown table so
they are properly fenced by empty lines to satisfy MD058: specifically insert an
empty line above and below the table under the "### Skeleton (기본)" heading, the
table following "### Skeleton.Circle" (the Circle props table), and the table
under "### Skeleton.Text" so each table is separated from surrounding
paragraphs/headings by a blank line.
In `@src/shared/ui/Skeleton/Skeleton.styled.ts`:
- Around line 22-36: The styled div SkeletonBase is passing the custom prop
borderRadius through to the DOM causing React warnings; update the styled
component creation to use Emotion's shouldForwardProp to filter out borderRadius
(e.g., only forward native HTML attributes like width/height) so borderRadius is
not passed to the underlying div, and apply the same shouldForwardProp change to
the derived components SkeletonBox, SkeletonCircle, and SkeletonTextLine so none
of them forward the borderRadius prop to the DOM.
---
Outside diff comments:
In `@src/pages/feed/Feed.tsx`:
- Around line 139-160: The effect in Feed.tsx (useEffect -> loadFeedsWithToken)
lacks a cleanup and so previous async work can call
setTabLoading/setInitialLoading after a new tab starts; fix by adding a local
cancelled flag (or AbortController) inside the useEffect, check it after each
await/Promise.all and before calling setTabLoading(false) or
setInitialLoading(false), and return a cleanup function that flips cancelled =
true so earlier promises do not mutate state for a newer activeTab (apply checks
around minLoadingTime/Promise.all and after loadTotalFeeds/loadMyFeeds calls).
---
Nitpick comments:
In `@src/pages/feed/Feed.styled.ts`:
- Around line 11-16: The hardcoded padding-top: 136px in SkeletonWrapper should
be replaced with a shared value: extract the combined MainHeader and TabBar
height into a CSS variable (or shared constant) and use that variable (or calc
using --main-header-height and --tab-bar-height) for padding-top so the skeleton
tracks header/tab height changes; update SkeletonWrapper to reference that
variable and add/update the variable definitions where global CSS or theme
constants are defined (also update any places using the 136px magic number).
In `@src/pages/feed/Feed.tsx`:
- Around line 139-160: The effect always forces a reload and the 1s
minLoadingTime on every activeTab change via minLoadingTime; add per-tab loaded
flags (e.g., totalLoaded, myLoaded) and update them to true after successful
load in loadTotalFeeds and loadMyFeeds (or immediately after those calls); then
in the useEffect check these flags before triggering a reload—only call
loadTotalFeeds when activeTab === '피드' && !totalLoaded (and similarly for '내 피드'
with myLoaded), and only include minLoadingTime in the Promise.all when
performing an actual network load; make sure you still call setTabLoading(true)
for real loads and clear setTabLoading(false) and setInitialLoading(false) in
finally as before.
In `@src/shared/ui/Skeleton/Skeleton.styled.ts`:
- Around line 22-23: The SkeletonBase styled component currently sets display:
inline-block which can cause unexpected layout outside flex/grid contexts;
update the SkeletonBase definition (styled.div<SkeletonBaseProps>) to use
display: block as the default for true block-level skeleton placeholders, then
run/adjust any layout-related tests or snapshots that assume inline behavior and
verify components using SkeletonBase inside flex containers still render
correctly.
| ### Skeleton (기본) | ||
| | Props | Type | Default | Description | | ||
| |-------|------|---------|-------------| | ||
| | width | string \| number | '100%' | 너비 (px 또는 문자열) | | ||
| | height | string \| number | 20 | 높이 (px 또는 문자열) | | ||
| | borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) | | ||
| | className | string | - | 추가 스타일을 위한 클래스명 | | ||
|
|
||
| ### Skeleton.Box | ||
| 기본 Skeleton과 동일한 props 사용 | ||
|
|
||
| ### Skeleton.Circle | ||
| | Props | Type | Default | Description | | ||
| |-------|------|---------|-------------| | ||
| | width | string \| number | 40 | 원의 크기 (width, height 동일) | | ||
| | className | string | - | 추가 스타일을 위한 클래스명 | | ||
|
|
||
| ### Skeleton.Text | ||
| | Props | Type | Default | Description | | ||
| |-------|------|---------|-------------| | ||
| | lines | number | 1 | 텍스트 라인 개수 | | ||
| | gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) | | ||
| | width | string \| number | '100%' | 너비 | | ||
| | height | string \| number | 16 | 각 라인의 높이 | | ||
| | lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 | | ||
| | className | string | - | 추가 스타일을 위한 클래스명 | |
There was a problem hiding this comment.
테이블 앞뒤에 빈 줄을 추가해야 합니다 (MD058).
정적 분석 도구에서 26번, 37번, 43번 줄의 Markdown 테이블이 빈 줄로 둘러싸이지 않았다고 경고합니다.
📝 수정 예시
### Skeleton (기본)
+
| Props | Type | Default | Description |
|-------|------|---------|-------------|
...
+
### Skeleton.Box
...
### Skeleton.Circle
+
| Props | Type | Default | Description |
...
+
### Skeleton.Text
+
| Props | Type | Default | Description |
...
+📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ### Skeleton (기본) | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | width | string \| number | '100%' | 너비 (px 또는 문자열) | | |
| | height | string \| number | 20 | 높이 (px 또는 문자열) | | |
| | borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | | |
| ### Skeleton.Box | |
| 기본 Skeleton과 동일한 props 사용 | |
| ### Skeleton.Circle | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | width | string \| number | 40 | 원의 크기 (width, height 동일) | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | | |
| ### Skeleton.Text | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | lines | number | 1 | 텍스트 라인 개수 | | |
| | gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) | | |
| | width | string \| number | '100%' | 너비 | | |
| | height | string \| number | 16 | 각 라인의 높이 | | |
| | lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | | |
| ### Skeleton (기본) | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | width | string \| number | '100%' | 너비 (px 또는 문자열) | | |
| | height | string \| number | 20 | 높이 (px 또는 문자열) | | |
| | borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | | |
| ### Skeleton.Box | |
| 기본 Skeleton과 동일한 props 사용 | |
| ### Skeleton.Circle | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | width | string \| number | 40 | 원의 크기 (width, height 동일) | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | | |
| ### Skeleton.Text | |
| | Props | Type | Default | Description | | |
| |-------|------|---------|-------------| | |
| | lines | number | 1 | 텍스트 라인 개수 | | |
| | gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) | | |
| | width | string \| number | '100%' | 너비 | | |
| | height | string \| number | 16 | 각 라인의 높이 | | |
| | lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 | | |
| | className | string | - | 추가 스타일을 위한 클래스명 | |
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)
[warning] 26-26: Tables should be surrounded by blank lines
(MD058, blanks-around-tables)
[warning] 37-37: Tables should be surrounded by blank lines
(MD058, blanks-around-tables)
[warning] 43-43: Tables should be surrounded by blank lines
(MD058, blanks-around-tables)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/shared/ui/Skeleton/README.md` around lines 25 - 50, Add a blank line
before and after each Markdown table so they are properly fenced by empty lines
to satisfy MD058: specifically insert an empty line above and below the table
under the "### Skeleton (기본)" heading, the table following "### Skeleton.Circle"
(the Circle props table), and the table under "### Skeleton.Text" so each table
is separated from surrounding paragraphs/headings by a blank line.
| export const SkeletonBase = styled.div<SkeletonBaseProps>` | ||
| display: inline-block; | ||
| width: ${({ width }) => (typeof width === 'number' ? `${width}px` : width || '100%')}; | ||
| height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height || '20px')}; | ||
| border-radius: ${({ borderRadius }) => | ||
| typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius || '4px'}; | ||
| background: linear-gradient( | ||
| 90deg, | ||
| ${colors.darkgrey.main} 0%, | ||
| ${colors.grey[400]} 50%, | ||
| ${colors.darkgrey.main} 100% | ||
| ); | ||
| background-size: 468px 100%; | ||
| animation: ${shimmer} 1.5s ease-in-out infinite; | ||
| `; |
There was a problem hiding this comment.
borderRadius 커스텀 prop이 DOM으로 전달되어 React 개발 모드 경고 발생.
Emotion은 기본적으로 string tag(e.g. div)에 대해 유효한 HTML 어트리뷰트인 prop만 DOM으로 전달합니다. width·height는 HTML 표준 어트리뷰트이므로 전달돼도 경고가 없지만, borderRadius는 HTML 표준 어트리뷰트가 아니므로 개발 모드에서 Warning: React does not recognize the borderRadius prop on a DOM element 경고가 출력됩니다.
shouldForwardProp을 사용하여 커스텀 prop이 DOM으로 전달되지 않도록 수정하세요.
🛡️ 수정 예시
+import isPropValid from '@emotion/is-prop-valid';
-export const SkeletonBase = styled.div<SkeletonBaseProps>`
+export const SkeletonBase = styled('div', {
+ shouldForwardProp: (prop) => isPropValid(prop),
+})<SkeletonBaseProps>`
display: inline-block;
width: ${({ width }) => (typeof width === 'number' ? `${width}px` : width || '100%')};
height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height || '20px')};
border-radius: ${({ borderRadius }) =>
typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius || '4px'};
...
`;SkeletonBase를 상속하는 SkeletonBox, SkeletonCircle, SkeletonTextLine에도 동일하게 적용되어야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/shared/ui/Skeleton/Skeleton.styled.ts` around lines 22 - 36, The styled
div SkeletonBase is passing the custom prop borderRadius through to the DOM
causing React warnings; update the styled component creation to use Emotion's
shouldForwardProp to filter out borderRadius (e.g., only forward native HTML
attributes like width/height) so borderRadius is not passed to the underlying
div, and apply the same shouldForwardProp change to the derived components
SkeletonBox, SkeletonCircle, and SkeletonTextLine so none of them forward the
borderRadius prop to the DOM.
📝 작업 내용
피드 페이지의 로딩 상태 UX 개선을 위해 Skeleton UI 컴포넌트를 도입했습니다. 기존 LoadingSpinner는 단순 스피너만 표시하여 사용자가 어떤 콘텐츠가 로딩되는지 예측할 수 없었지만, Skeleton UI는 실제 콘텐츠의 레이아웃과 구조를 미리 보여주어 더 나은 사용자 경험을 제공합니다.
주요 변경사항
1. Skeleton UI 공통 컴포넌트 구현 (
src/shared/ui/Skeleton/)구현된 컴포넌트:
Skeleton- 기본 스켈레톤 (커스텀 크기/모양)Skeleton.Box- 박스 형태 스켈레톤 (카드, 이미지 등)Skeleton.Circle- 원형 스켈레톤 (프로필 이미지)Skeleton.Text- 텍스트 라인 스켈레톤 (다중 라인 지원)주요 기능:
2. 피드 페이지 스켈레톤 적용
변경 파일:
src/pages/feed/Feed.tsxsrc/pages/feed/Feed.styled.tsSkeletonWrapper추가 (136px 상단 여백으로 헤더/탭 아래 정확한 위치 배치)src/shared/ui/Skeleton/FeedPostSkeleton.tsx3. 기술적 개선사항
깜빡임 방지:
API 응답이 빠르더라도 스켈레톤을 최소 1초 표시하여 자연스러운 로딩 경험 제공
스크린샷 (선택)
2026-02-19.4.14.40.mov
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선 사항