프로젝트/Next+TypeScript

[Taskify] 1차 배포 테스트(2) - 사용자 편의성 개선

dev-hpk 2024. 12. 26. 17:49

오늘은 저번 이슈 해결에 이어서 사용자 편의성 개선 작업을 해보려고 합니다.

🌈 개선 사항 : 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
    1. 렌더링 유발(상태 변경 또는 상위 렌더링)
    2. React가 컴포넌트 렌더링
    3. 화면이 시각적으로 업데이트(paint)
    4. useEffect 실행
  • useLayoutEffect
    1. 렌더링 유발(상태 변경 또는 상위 렌더링)
    2. React가 컴포넌트 렌더링
    3. useLayoutEffect 실행, React는 완료될 때까지 기다림
    4. 화면이 시각적으로 업데이트(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)이 개발에서 얼마나 중요한지 다시금 깨달을 수 있었습니다😊

 

 

[Taskify] 1차 배포 테스트 - Trouble shooting

멘토님과 1차 배포 테스트를 진행했습니다.버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요😭테스트하면서 나온 이슈들을 정리해 보면 아래와 같습니다.버그를 하나씩 살펴

dev-hpk.tistory.com

 

 

[Taskify] Tag 이슈 수정

카드의 태그 관련 이슈가 발생했습니다.카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다. 🚫 문제 상황화면이 리렌더링 될 때마다 태그의 색상이 랜덤

dev-hpk.tistory.com