돌멩이 하나/셀프 크리틱

불필요한 useEffect 없애기

미래에서 온 개발자 2024. 6. 2. 15:38

지난 5월 원티드 프리온보딩 FE 챌린지에서 데이터 / 계산 / 액션을 구분하는 것이 복잡도와 의존도를 낮추는 방법 중 하나라는 걸 배웠다. 

 

1. 데이터: 이벤트에 대한 사실. 문자열, 객체 등 단순한 값 그 자체 

    예) 사용자가 입력한 이메일 주소, 은행 API로 가져온 달러 수량

 

2. 계산: 입력으로 얻은 출력. 순수 함수라고 부르기도 함. 

    예) 최댓값 찾기, 이메일 주소가 올바른지 확인하기 

 

3. 액션: 외부 세계와 소통하므로 실행 시점과 횟수에 의존. 부수효과를 일으킴.

    예) 이메일 보내기, 데이터베이스 읽기 

 

 

이번주 티켓 중에 antd Table을 사용해 Weekly 캘린더를 구현해야 하는 작업이 있었다. (antd Calendar는 yearly, monthly만 제공한다.) 이 중 antd Table 컴포넌트의 columns prop으로 넘길 데이터를 만드는 과정을 간단하게 정리해 보려고 한다. 

 

내가 만들고자 했던 columns 배열은 다음과 같은 형태였다. 

[
  { key: 'sun', title: '2일 (일)', width: '14%', render: () => {} },
  { key: 'mon', title: '3일 (월)', width: '14%', render: () => {} },
  { key: 'tue', title: '4일 (화)', width: '14%', render: () => {} },
  { key: 'wed', title: '5일 (수)', width: '14%', render: () => {} },
  { key: 'thu', title: '6일 (목)', width: '14%', render: () => {} },
  { key: 'fri', title: '7일 (금)', width: '14%', render: () => {} },
  { key: 'sat', title: '8일 (토)', width: '14%', render: () => {} },
]

 

title은 부모 컴포넌트에서 prop으로 전달되는 날짜(초기값은 오늘 날짜, Monthly 캘린더와 공유하는 기준 날짜)를 기준으로 동적으로 변해야 했고, render 함수는 서버에서 받아온 데이터를 핸들링해서 해당 날짜에 맞는 작업들을 표시해 주어야 했다. 

 

처음에는 단순하게 부모 컴포넌트에서 받는 날짜 `date`가 바뀌면 columns가 바뀌어야겠구나 하고, 다음과 같이 코드를 작성했다.

 

 

 

import dayjs from 'dayjs';
import type { ColumnsType } from 'antd/es/table';
/** @returns {string[]} 'YYYY-MM-DD' 형식의 문자열을 요소로 갖는 배열 */
function getWeekDates(startDate: Date): string[] {
const dates = [];
const startDayOfWeek = startDate.getDay(); // 월요일이라면 1을 반환
for (let i = 0; i < 7; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() - startDayOfWeek + i);
dates.push(dayjs(date).format('YYYY-MM-DD'));
}
return dates;
}
interface WeekData {
sun: JobSubset[];
mon: JobSubset[];
tue: JobSubset[];
wed: JobSubset[];
thu: JobSubset[];
fri: JobSubset[];
sat: JobSubset[];
[key: string]: JobSubset[];
}
const columnsTemplate: ColumnsType<WeekData> = [
{ key: 'sun', width: '14%' },
{ key: 'mon', width: '14%' },
{ key: 'tue', width: '14%' },
{ key: 'wed', width: '14%' },
{ key: 'thu', width: '14%' },
{ key: 'fri', width: '14%' },
{ key: 'sat', width: '14%' },
];
type Props = {
date: Date;
setDate: React.Dispatch<React.SetStateAction<Date>>;
};
export default function Weekly({ date, setDate }: Props) {
const [columns, setColumns] = useState(columnsTemplate);
useEffect(() => {
const weekDates = getWeekDates(date);
const newColumns: ColumnsType<WeekData> = columnsTemplate.map((col, index) => ({
...col,
title: () => {
const today = dayjs().format('YYYY-MM-DD');
return (
<>
{weekDates[index] === today ? (
<span className="p-1 mr-px rounded-full bg-red-400 text-white">{weekDates[index].slice(8)}</span>
) : (
parseInt(weekDates[index].slice(8)) // YYYY-MM-DD에서 DD에 해당하는 02, 03 을 2, 3과 같은 숫자로 변환
)}
{`일 (${['일', '월', '화', '수', '목', '금', '토'][index]})`}
</>
);
},
render: (_, record) => {
const jobs = record[col.key as keyof WeekData];
return (
<ul>
{jobs.map((job) => {
const content = job.translator?.name ? `[${job.translator.name}] ${job.name}` : job.name;
return (
<li key={job.id} className="mb-1 p-2 rounded shadow-md">
<Badge color="blue" text={content} />
</li>
);
})}
</ul>
);
},
}));
setColumns(newColumns);
}, [date]);
return (
<Table columns={columns} pagination={false} className="weekly" />
);
}

 

 

 

prop으로 받는 `date`가 바뀔 때마다 `columns`도 바뀌어야 하니까 useEffect의 의존성 배열에 `date`를 넣고, 로컬 상태 `columns`를 useEffect 안에서 만들어 setColumns를 호출하는 방식으로 작성한 코드다. 

 

title과 render 함수를 동적으로 만드는 과정은 사실상 '계산'에 해당하는 부분이다. 외부 세계와 소통하는 것도 없고, 부수 효과를 일으킬 필요도 없다. 그렇다면 useEffect를 사용하지 않고도 `columns`를 만들 수 있지 않을까? 라는 생각에 착안해 useEffect 안의 코드들을 컴포넌트 밖으로 하나씩 분리해 나가는 방식으로 리팩토링을 했다. 

 

 

 

const DAYS_OF_WEEK = ['일', '월', '화', '수', '목', '금', '토'];
const today = dayjs().format('YYYY-MM-DD');
const getColumnTitle = (dateStr: string, index: number) => {
const dayOfMonth = dateStr.slice(8); // YYYY-MM-DD 에서 DD에 해당하는 부분
const isToday = dateStr === today;
return (
<>
<span className={`mr-px ${isToday ? 'p-[2px] bg-red-400 text-white' : 'p-px'} rounded-full`}>{parseInt(dayOfMonth)}</span>
{`일 (${DAYS_OF_WEEK[index]})`}
</>
);
};
const renderJobs = (jobs: WeekData[keyof WeekData]) => (
<ul>
{jobs.map((job) => {
const content = job.translator?.name ? `[${job.translator.name}] ${job.name}` : job.name;
return (
<li key={job.id} className="mb-1 p-2 rounded shadow-md">
<Badge color="blue" text={content} />
</li>
);
})}
</ul>
);
const generateColumns = (currentDate: Date): ColumnsType<WeekData> => {
const weekDates = getWeekDates(currentDate);
return columnsTemplate.map((col, index) => ({
...col,
title: () => getColumnTitle(weekDates[index], index),
render: (_, record: WeekData) => renderJobs(record[col.key as keyof WeekData]),
}));
};
export default function Weekly({ date, setDate }: Props) {
const columns = generateColumns(date);
return (
<Table columns={columns} pagination={false} className="weekly" />
);
}

 

 

 

부모 컴포넌트에 `date`라는 부모 컴포넌트의 로컬 상태가 있고, `date`가 바뀔 때마다 부모 컴포넌트를 비롯하여 모든 자식 컴포넌트들이 리렌더링된다. 컴포넌트가 리렌더링된다는 것은 컴포넌트 안의 모든 코드가 재실행된다는 것이다. 따라서 부모 컴포넌트의 상태 `date`가 바뀔 때마다 Weekly 컴포넌트가 리렌더링되고, 그때마다 `generateColumns(date)` 함수가 실행된다. 

 

useEffect에서 의존성 배열로 관리하지 않아도 `date` 가 바뀔 때마다 실행되는 함수를 만들었고, 해당 함수는 Date 객체를 입력으로 받으면 columns 배열을 출력으로 내놓는 순수 함수이기 때문에 리액트의 렌더링 사이클과 무관하다. columns 배열은 부모 컴포넌트의 `date` 상태에 따른 일종의 파생 상태(title과 render가 date에 따라 바뀌기 때문)라고 볼 수 있다. 파생 상태를 Weekly 컴포넌트에서 별도의 로컬 상태로 가질 필요가 없다. 

 

상태와 파생 상태, 리액트의 렌더링 사이클과 연관이 없는 순수 자바스크립트 함수 등에 대해 다시 한 번 생각해 볼 수 있는 시간이었다. 

트위터에서 아래 스레드를 보기도 했는데, 아래 두 가지 사항을 개선한 리팩토링이 아니었나 생각해 본다. 
1. useEffect로 로직 공유

4. 파생상태로 표현 가능한 녀석을 useState

https://x.com/beingbook/status/1796758366603383230

 

 

 

📚 참고 자료

 TkDodo의 Don't over useState 포스트 번역문 

 

useState를 남용하지 마세요

state 변경 함수가 effect 내부에서 오직 동기화를 위해서만 사용된다면 그 state를 제거하세요!

www.philly.im