프로젝트/Next+TypeScript

[Taskify] 대시보드 상세 페이지

dev-hpk 2024. 12. 17. 19:37

대시보드 상세 페이지 UI

오늘은 대시보드 페이지를 개발해 봤습니다.

우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!

 

GET Columns

import axios from '@/lib/instance';
import { GetColumnParams, GetColumnsResponse } from '@/type/column';

const getColumns = async ({
  teamId,
  dashboardId,
}: GetColumnParams): Promise<GetColumnsResponse> => {
  try {
    const response = await axios.get(`/${teamId}/columns/`, {
      params: {
        dashboardId,
      },
    });

    if (response.status === 200) return response.data;
    throw new Error('컬럼을 불러오는 데 실패했습니다.');
  } catch (error) {
    console.error('컬럼 조회 실패 : ', error);
    throw error;
  }
};

export default getColumns;

 

GET Cards

import axios from '@/lib/instance';
import { GetCardParams, GetCardsResponse } from '@/type/card';

const getCards = async ({
  teamId,
  size = 4,
  cursorId = null,
  columnId,
}: GetCardParams): Promise<GetCardsResponse> => {
  try {
    const response = await axios.get(`/${teamId}/cards/`, {
      params: {
        size,
        cursorId,
        columnId,
      },
    });

    if (response.status === 200) return response.data;
    throw new Error('카드 목록을 불러오는 데 실패했습니다.');
  } catch (error) {
    console.error('카드 목록 조회 실패 : ', error);
    throw error;
  }
};

export default getCards;

 

/dashboards/[id].tsx

import Sidebar from '@/components/common/sidebar/Sidebar';
import CDSButton from '@/components/common/button/CDSButton';
import getColumns from '@/lib/dashboard/getColumns';
import { useEffect, useState, useCallback } from 'react';
import styles from '@/pages/dashboards/Dashboard.module.css';
import Column from '@/components/dashboard/column/Column';
import { Column as ColumnType } from '@/type/column';
import { useRouter } from 'next/router';

function DashBoard() {
  const { query } = useRouter();
  const [columns, setColumns] = useState<ColumnType[]>([]);

  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);
      }
    } catch (error) {
      console.error('컬럼 조회 실패 : ', error);
    }
  }, [query.id]);

  useEffect(() => {
    fetchColumns();
  }, [fetchColumns]);

  return (
    <div>
      <Sidebar />
      <div className={styles.container}>
        {columns.map(({ id, title }) => (
          <Column key={`column_${id}`} targetId={id} columnTitle={title} />
        ))}
        <div className={styles['add-column']}>
          <CDSButton btnType="column">새로운 컬럼 추가하기</CDSButton>
        </div>
      </div>
    </div>
  );
}

export default DashBoard;

 

 

Column.tsx

import styles from '@/components/dashboard/column/Column.module.css';
import CDSButton from '@/components/common/button/CDSButton';
import Card from '@/components/dashboard/card/Card';
import { useCallback, useEffect, useRef } from 'react';
import SettingIcon from 'public/ic/ic_setting.svg';
import Link from 'next/link';
import useIntersectionObserver from '@/hooks/useIntersectionObserver';
import useColumnData from '@/hooks/useColumnData';

interface ColumnProp {
  targetId: number;
  columnTitle: string;
}

function Column({ targetId, columnTitle }: ColumnProp) {
  const { columnData, fetchCards } = useColumnData(targetId);

  const handleObserver = useCallback(
    ([entry]) => {
      if (entry.isIntersecting && columnData.cursorId)
        fetchCards(columnData.cursorId);
    },
    [fetchCards, columnData.cursorId],
  );

  const endPoint = useIntersectionObserver(handleObserver);

  useEffect(() => {
    fetchCards();
  }, [fetchCards]);

  return (
    <div className={styles.column}>
      <div className={styles['column-title-section']}>
        <div className={styles['column-title']}>
          {columnTitle}
          <span className={styles['column-size']}>{columnData.totalCount}</span>
        </div>
        <Link
          href={`/dashboard/${targetId}/edit`}
          className={styles['btn-edit-column']}
        >
          <SettingIcon className={styles['icon-setting']} />
        </Link>
      </div>
      <CDSButton btnType="todo" />
      <div className={styles['card-section']}>
        {columnData.cards.map(
          ({ imageUrl, id, title, tags, dueDate, assignee: { nickname } }) => (
            <Card
              key={`card_${id}`}
              imageUrl={imageUrl}
              id={id}
              title={title}
              tags={tags}
              dueDate={dueDate}
              nickname={nickname}
            />
          ),
        )}
        {columnData.cursorId && (
          <div ref={endPoint} className={styles['end-point']} />
        )}
      </div>
    </div>
  );
}

export default Column;

 

Card.tsx

import styles from '@/components/dashboard/card/Card.module.css';
import Chip from '@/components/common/chip/Chip';
import CardImage from '@/components/dashboard/card/CardImage';
import CalendarIcon from 'public/ic/ic_calendar.svg';
import formatDate from '@/utils/formatDate';

interface CardProps {
  imageUrl: string;
  id: number;
  title: string;
  tags: string[];
  dueDate: string;
  nickname: string;
}

function Card({ imageUrl, id, title, tags, dueDate, nickname }: CardProps) {
  const fomattedDueDate = formatDate(dueDate);
  const nameInitial = nickname[0].toUpperCase();

  return (
    <button type="button" className={styles.card}>
      <CardImage image={imageUrl} name={title} />
      <div className={styles['content-section']}>
        <div className={styles['card-title']}>{title}</div>
        <div className={styles['description-section']}>
          <div className={styles['card-tags']}>
            {tags.map((tag) => (
              <Chip key={`${id}_tag_${tag}`} chipType="tag">
                {tag}
              </Chip>
            ))}
          </div>
          <div className={styles['card-date']}>
            <CalendarIcon className={styles['icon-calendar']} />
            <span className={styles.date}>{fomattedDueDate}</span>
          </div>
          <div className={styles.badge}>{nameInitial}</div>
        </div>
      </div>
    </button>
  );
}

export default Card;

 

실행 결과

실행 결과

 

실행 결과를 보시면 데이터가 잘 호출되고 화면도 잘 나오는 것 같습니다.

하지만 네트워크 탭을 자세히 보면 같은 네트워크 요청이 2번씩 반복되는 것을 볼 수 있습니다,

네트워크 요청

 

분명 데이터를 한 번만 요청했는데, 왜 두 번이나 요청을 보낸 거지...?

Card 데이터를 요청하는 코드로 돌아가 간단한 테스트를 해봤습니다.

// Column.tsx
useEffect(() => {
    console.log('render');
    fetchCards();
}, [fetchCards]);

console 디버깅 결과

console.log도 2번 실행되네요. 이제 문제는 useEffect에 있다는 것이 확실해졌습니다.

 

문제 발생 원인

Next.js에서 useEffect의 콜백이 두 번 실행되는 이유는 React Strict Mode 때문이었습니다.

React의 Strict Mode는 개발 모드에서 useEffectcomponentDidMount와 같은 라이프사이클 훅이 두 번 실행되도록 만들어 상태 업데이트나 렌더링 시 발생할 수 있는 사이드 이펙트를 미리 검출한다고 합니다.

문제 해결 방법

next.config.ts 파일의  reactStrictMode를 fasle로 변경합니다.

const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: false,
};

 

이제 서버를 다시 시작하면 ✨해결 완료✨

문제 해결

그런데 Strict Mode의 역할을 찾아보고 나서 뭔가 계속 찝찝합니다.

개발 모드에서만 더블 렌더링이 발생하고 배포 후에는 더블 렌더링이 발생하지 않는다고 하니 reactStrictMode를 true로 설정한 상태로 작업을 해보겠습니다. 

Flag를 사용해 더블 렌더링 체크하기

const isFirstRender = useRef(true); // StrictMode 때문에 api 2번 요청해서 임시로 추가

useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }

    fetchCards();
}, [fetchCards]);

라이프사이클 훅이 2번 실행되기 때문에 첫 번째 렌더링 때는 아무 동작도 하지 않도록 하기 위해 isFirstRender이라는 Ref 객체를 선언했습니다. useEffect를 보시면 isFirstRender.current라는 조건문으로 첫 번째 렌더링인 경우 아무 동작도 하지 않고 return 해버리도록 만들었습니다.

 

결과는...

더블 렌더링 체크 결과

Strict Mode를 false로 설정했을 때와 동일하게 1번만 요청합니다!!

 

최종 배포를 할 때 코드를 삭제해줘야 하는 번거로움이 있지만, 그래도 Strict Mode의 여러 장점을 활용할 수 있으니 이 방법이 좋을 것 같습니다.

 

 

[Taskify] Chip(공통 컴포넌트) 추가

오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.// Chip.tsimport { ReactNode } from 'react';type ChipType = 'tag' | 'status' | 'status-option';export interface ChipProps { children: ReactNode; chipType: ChipType;}export const bgTag = ['oran

dev-hpk.tistory.com

 

 

[Taskify] 버튼(공통 컴포넌트) 추가

프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!// Button.tsximport styles from "./Button.module

dev-hpk.tistory.com