오늘은 저번 이슈 해결에 이어서 사용자 편의성 개선 작업을 해보려고 합니다.
🌈 개선 사항 : select 메뉴 버튼이나 옵션 바깥 부분을 클릭했을 때 select 메뉴가 닫히면 좋을 것 같다.
멘토님께 피드백을 받고 구글, 네이버 같은 사이트들을 확인해 봤는데 모두 select 메뉴 바깥 부분을 클릭하면 닫히도록 동작하네요👀
💡 수정 코드 및 결과
/* import, type 등 생략... */
function Dropdown({
children,
menus,
onMenuClick,
}: PropsWithChildren<DropdownProps>) {
const { isOpen, toggleDropdown, closeDropdown } = useDropdown();
const ref = useRef<HTMLDivElement | null>();
const handleMenuClick = (value: string) => {
onMenuClick(value);
closeDropdown();
};
useEffect(() => {
const handleClickOutside = (e: Event) => {
const target = e.target as HTMLDivElement;
// ref(dropdown 컴포넌트) 외부 클릭 시 closeDropdown 호출해 닫기
if (ref.current && !ref.current.contains(target)) closeDropdown();
};
document.addEventListener('click', handleClickOutside); // 클릭 이벤트 리스너 등록
// 컴포넌트 언마운트 시 이벤트 리스너 제거하도록 cleanup 함수 등록
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [ref]);
return (
<div
ref={ref} // 드롭다운 컴포넌트를 ref로 참조
className={styles.dropdown}
onClick={(e) => e.stopPropagation()} // 드롭다운 내부 클릭 시 이벤트 전파 방지
>
<button
type="button"
className={styles['btn-dropdown']}
onClick={toggleDropdown}
>
{children}
</button>
{isOpen && (
<ul className={styles['dropdown-menus']}>
{menus.map(({ label, value }) => (
<li key={`btn_${value}`}>
<button
type="button"
className={styles['dropdown-menu']}
onClick={() => handleMenuClick(value)}
>
{label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
export default Dropdown;
🌈 개선 사항 : 새로고침하거나 화면에 진입할 때 깜빡거리는 이슈
검색해 보니 useEffect는 컴포넌트들이 render와 paint 된 후 실행되고 비동기적(asynchronous)으로 실행된다고 합니다. 즉 paint 된 후 실행되기 때문에 useEffect 내부에 dom에 영향을 주는 코드가 있을 경우 사용자 입장에서는 화면의 깜빡임을 보게된다고 하네요.
❓ 해결 방법
1. useEffect 대신 useLayoutEffect 사용
useEffect와 useLayoutEffect는 effect가 호출되는 타이밍이 다르고 하네요.
- useEffect
- 렌더링 유발(상태 변경 또는 상위 렌더링)
- React가 컴포넌트 렌더링
- 화면이 시각적으로 업데이트(paint)
- useEffect 실행
- useLayoutEffect
- 렌더링 유발(상태 변경 또는 상위 렌더링)
- React가 컴포넌트 렌더링
- useLayoutEffect 실행, React는 완료될 때까지 기다림
- 화면이 시각적으로 업데이트(paint)
2. Skeleton UI 적용
서버에서 데이터를 가져오는 동안 빈 화면이 노출되면 사용자는 콘텐츠를 기다리다가 쉽게 지치고 지루함을 느껴 사이트를 떠나게 되겠죠🤔
Skeleton UI는 실제 콘텐츠가 들어가게 될 자리를 잠시 대신할 빈 껍데기입니다. 일반적인 패턴은 흰색 배경과 반짝이는 CSS 애니메이션을 적용한다고 하네요.
저희 팀은 회의 끝에 airbnb를 참고해 Skeleton UI를 적용하기로 했습니다.
💡 코드 및 결과
UI 구조와 css는 기존 컬럼과 카드의 코드를 사용했기 때문에 생략했습니다.
const [columns, setColumns] = useState<ColumnType[] | null>(null);
const fetchColumns = useCallback(async () => {
const dashboardId = Number(query.id);
if (!dashboardId) return;
try {
const { data, result } = await getColumns({
teamId: '11-6',
dashboardId,
});
if (result === 'SUCCESS') {
setColumns(data);
} else {
setColumns([]);
}
} catch (error) {
console.error('컬럼 조회 실패 : ', error);
setColumns([]);
}
}, [query.id]);
{
columns === null
? Array.from({ length: 4 }).map((_, index) => (
<SkeletonColumn key={`skeleton_${index}`} />
))
: columns.map(({ id, title }) => (
<Column
key={`column_${id}`}
columnId={id}
columnTitle={title}
setColumns={setColumns}
/>
))
}
const fetchCards = useCallback(
async ({
cursor,
size = 4,
reset = false,
}: {
cursor?: number;
size?: number;
reset?: boolean;
} = {}) => {
if (isLoading) return;
setIsLoading(true);
try {
const response = await getCards({
teamId: '11-6',
size,
columnId: targetId,
cursorId: cursor,
});
const { cards, totalCount, cursorId } = response;
setColumnData((prev) => ({
cards: reset ? cards : [...prev.cards, ...cards],
totalCount,
cursorId,
}));
} catch (error) {
console.error('컬럼 조회 실패 : ', error);
} finally {
setIsLoading(false);
}
},
[targetId],
);
{
isLoading
? Array.from({ length: 4 }).map((_, index) => (
<SkeletonCard key={`skeleton_${index}`} />
))
: columnData.cards.map(({
imageUrl,
id,
title,
tags,
dueDate,
assignee: { nickname, profileImageUrl },
}) => (
<Card
key={`card_${id}`}
imageUrl={imageUrl}
id={id}
title={title}
tags={tags}
dueDate={dueDate}
nickname={nickname}
profileImage={profileImageUrl}
columnTitle={columnTitle}
columnId={columnId}
setColumnData={setColumnData}
onUpdate={handleUpdate}
/>
),
)}
Skeleton UI 관련 CSS
// SkeletonColumn.module.css
.title-text,
.column-size,
.btn-edit-column,
.btn-add {
background-color: var(--gray-medium);
animation: loading 2s infinite linear;
}
@keyframes loading {
0% {
opacity: 0.99;
}
50% {
opacity: 0.5;
}
100% {
opacity: 0.99;
}
}
// SkeletonCard.module.css
.card-image,
.card-title,
.card-tags,
.icon-calendar,
.date,
.badge {
background-color: var(--gray-medium);
animation: loading 2s infinite linear;
}
@keyframes loading {
0% {
opacity: 0.99;
}
50% {
opacity: 0.5;
}
100% {
opacity: 0.99;
}
}
돌이켜보면 저도 사이트에 접속했을 때 데이터가 늦게 로드되어 빈 화면이 보이면 바로 닫아버린 경험이 많았습니다.
이번 작업을 통해 사용자 입장에서 "어떻게 하면 더 자연스럽고 편리하게 느껴질까?"를 깊이 고민하게 되었고, 이를 통해 사용자 경험(UX)이 개발에서 얼마나 중요한지 다시금 깨달을 수 있었습니다😊
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[Taskify] Trouble Shooting - 동적 페이지 라우팅 (5) | 2024.12.28 |
---|---|
[Taskify] 1차 배포 테스트 - Trouble shooting (4) | 2024.12.24 |
[Taskify] Tag 이슈 수정 (4) | 2024.12.23 |
[Taskify] 이미지 확장자 제한 추가 (5) | 2024.12.21 |
[Taskify] 할 일 카드 모달 컴포넌트 (feat. optimistic update) (6) | 2024.12.20 |