이전에 구현했던 Chart.js를 활용한 데이터 시각화 1에 대한 성능 최적화 및 기능 확장 작업을 완료했다. 주요 개선 사항으로는 렌더링 속도와 상태 관리 최적화가 있으며, 새롭게 파일 내보내기/가져오기, Bar 차트, 월 단위 데이터 등을 추가했다.

개선

렌더링 속도 개선

이전에는 새로운 폼을 추가할 때 렌더링 속도가 느렸다. 폼을 추가할 때마다 182.7ms가 걸렸다. 시간 복잡도가 높은 로직과 무거운 컴포넌트는 useMemo(), memo()를 사용하여 최적화를 하였는데도 여전히 렌더링 속도가 느렸다.

가장 큰 원인은 지출표, 입금표에 대한 입력값 처리였다. 입금표, 지출표는 MUI의 <DataGrid />와 각 셀에 react hook form의 <Controller />를 사용하여 입력값을 처리하였는데 해당 부분이 문제였다.

각 셀 컴포넌트에 memo()를 사용하여 불필요한 리렌더링을 줄일 수 있겠지만, <DataGrid />의 column에 editable 속성을 활성화하면 입력이 가능하고 processRowUpdate 프로퍼티를 통해서 수정된 값을 가져올 수 있어 내장된 기능을 사용하였다. (원래 기능을 알고 있었는데 생각을 못했다…)

const columns: GridColDef[] = useMemo(() => {
  const fields = []
 
  return fields.map((field) => ({
    //...properties
    editable: true,
  }))
}, [])
 
const handleExpenseChange = (
  newRow: Expense & {
    id: string
    product: string
  },
) => {
  const { id, product, ...rest } = newRow
 
  for (const key in rest) {
    const expenseKey = key as ExpenseKey
    rest[expenseKey] = rest[expenseKey] ?? 0
  }
 
  onUpdate({
    expense: {
      ...form.expense,
      [id]: {
        ...rest,
      },
    },
  })
  return newRow
}
 
;<DataGrid
  // ...properties
  columns={columns}
  processRowUpdate={handleRevenueChange}
/>

이밖에도 memo()로 감싼 컴포넌트의 프로퍼티에 직접적으로 flat()과 같은 새로운 참조를 반환하는 함수를 사용하는 부분과 불필요한 dependencies를 제거하니 81ms(대략 2배) 까지 줄일 수 있었다.

코드를 개선하다 보니 전체적으로 react hook form이 불필요하다고 생각해서 react hook form을 제거하고 reducer와 hook을 사용하여 모든 입력값 처리를 하였다.

import { useEffect, useReducer, useState } from "react"
 
import _ from "lodash"
import { nanoid } from "nanoid"
 
import { FilterForm } from "../types"
 
export interface FilterFormState {
  forms: FilterForm[]
}
 
export type FilterFormAction =
  | {
      type: "ADD_FORM"
    }
  | { type: "REMOVE_FORM"; payload: { formIdx: number } }
  | { type: "UPDATE_FORM"; payload: { formIdx: number; formData: Partial<FilterForm> } }
  | { type: "IMPORT_FORMS"; payload: { forms: FilterForm[] } }
 
const reducer = (state: FilterFormState, action: FilterFormAction) => {
  switch (action.type) {
    case "ADD_FORM": {
      if (state.forms.length >= 4) return state
      const prevForm = state.forms[state.forms.length - 1]
      return {
        forms: [
          ...state.forms,
          {
            ...prevForm,
            id: nanoid(),
            name: `필터 ${state.forms.length + 1}`,
          },
        ],
      }
    }
    case "REMOVE_FORM": {
      if (state.forms.length <= 1) return state
      const { formIdx } = action.payload
      return {
        forms: [...state.forms.slice(0, formIdx), ...state.forms.slice(formIdx + 1)],
      }
    }
    case "UPDATE_FORM": {
      const { formIdx, formData } = action.payload
      return {
        forms: [
          ...state.forms.slice(0, formIdx),
          {
            ...state.forms[formIdx],
            ...formData,
          },
          ...state.forms.slice(formIdx + 1),
        ],
      }
    }
    case "IMPORT_FORMS": {
      const { forms } = action.payload
      const limitedForms = forms.slice(0, 4)
      return {
        forms: limitedForms.map((form) => ({
          ...form,
          id: form.id || nanoid(),
        })),
      }
    }
    default: {
      return state
    }
  }
}
 
export const useFilterFormReducer = (initialState: FilterForm) => {
  const [draft, dispatch] = useReducer(reducer, {
    forms: [initialState],
  })
 
  const [final, setFinal] = useState({
    forms: [initialState],
  })
 
  const [isDirty, setIsDirty] = useState(false)
 
  const addForm = () => {
    dispatch({ type: "ADD_FORM" })
  }
 
  const removeForm = (formIdx: number) => {
    dispatch({ type: "REMOVE_FORM", payload: { formIdx } })
  }
 
  const updateForm = (formIdx: number, formData: Partial<FilterForm>) => {
    dispatch({ type: "UPDATE_FORM", payload: { formIdx, formData } })
  }
 
  const submitForm = () => {
    setFinal({
      forms: draft.forms.map((form) => ({ ...form })),
    })
  }
 
  const importForms = (forms: FilterForm[]) => {
    dispatch({ type: "IMPORT_FORMS", payload: { forms } })
  }
 
  useEffect(() => {
    setIsDirty(!_.isEqual(draft.forms, final.forms))
  }, [draft.forms, final.forms])
 
  return {
    draftForms: draft.forms,
    finalForms: final.forms,
    isDirty,
    addForm,
    removeForm,
    updateForm,
    submitForm,
    importForms,
  }
}

서버 상태 관리 최적화

서버 상태 관리를 React Query를 사용했다. 데이터를 가져올 때 폼에 입력된 기간에 해당하는 데이터를 가져오기 때문에 query key도 시작, 끝 날짜로 구성하였다.

문제가 되었던 것은 아니지만 25.01.01 ~ 25.12.31와 같이 기간이 긴 데이터가 캐시된 상태에서 25.01.15 ~ 25.01.31 데이터와 같이 캐시된 데이터에서 해당 기간의 데이터가 포함되어 있어, 캐시된 데이터에서 데이터를 가져오면 불필요한 데이터 패치를 줄일 수 있다고 판단했다.

이를 개선하기 위해 기존의 query key 기반 캐싱 대신 캐시된 데이터를 확인하여 불필요한 네트워크 요청을 줄이는 방식으로 변경하였다:

const makeQueryConfigs = (dateRange: DateRange, queryClient: QueryClient) => {
    const key = ["데이터", ...dateRange.map((d) => d?.format("YYYY-MM-DD"))]
    return {
        queryKey: key,
        queryFn: () => {
            const cachedQueries = queryClient.getQueryCache().findAll({
                predicate: (queryCache) => {
                    const { queryKey } = queryCache
                    if (key === queryKey) return false
                    const isQuery = queryKey[0] === "데이터"
 
                    return (
                        isQuery &&
                        dateRange[0]!.isSameOrAfter(queryKey[1], "day") &&
                        dateRange[1]!.isSameOrBefore(queryKey[2], "day")
                    )
                },
            })
 
            if (cachedQueries.length > 0) {
                return (cachedQueries[0].state.data as Reservation[]).filter((data) => {
                    return (
                        dateRange[0]?.isSameOrBefore(data.date, "day") &&
                        dateRange[1]?.isSameOrAfter(data.date, "day")
                    )
                })
            }
 
   // 서버 데이터 패치 코드
        },
        staleTime: 1000 * 60 * 10,
        cacheTime: 1000 * 60 * 10,
    }
}
 

queryClient.getQueryCache()를 통해 캐시된 데이터를 가져와 패치할 데이터 기간에 해당하는 데이터를 필터링하여 반환하였다.

추가 기능

폼 가져오기 및 내보내기

전체 폼 데이터를 JSON 형태로 저장하고 가져올 수 있는 기능을 추가하였다. 현재는 파일로 저장하지만 향후에는 JSON 형태로 데이터베이스에 저장하고 가져오는 방식으로 변경할 계획이다.

폼 데이터 뿐만 아니라 입금표와 지출표를 개별적으로 xlsx 파일로 업로드할 수 있는 기능을 구현하여 금액 데이터를 더 편리하게 구성할 수 있도록 하였다.

차트 결과 테이블

차트 결과를 각 폼 기준으로 데이터를 나타내는 테이블을 추가하여 시각적 데이터와 함께 수치 데이터도 확인할 수 있도록 하였다.

Bar 차트

Bar 차트 추가 요청이 있어 구현하였다. Line 차트와 옵션 차이가 크지 않아 비교적 쉽게 구성할 수 있었다.

스택 기능도 추가하였는데, 폼 단위로 구분되어야 했기 때문에 dataset 구성 시 stack 프로퍼티에 폼 id를 지정하여 각 폼별로 데이터가 스택되도록 구현하였다.

{
 data,
 borderColor: form.borderColor,
 borderWidth: 4,
 backgroundColor: colorMap[productId],
 pointBackgroundColor: colorMap[productId],
 pointRadius: 7,
 pointBorderWidth: 4,
 pointHoverRadius: 7,
 pointHoverBorderWidth: 4,
 stack: barType === "stacked" ? form.id : undefined, // stack
 spanGaps: true,
}

그 외

이 밖에도 월 단위 데이터 비교 기능을 추가하였고, 주요 로직에 대한 테스트 코드도 작성하였다. 테스트 코드 작성 시 faker 라이브러리를 활용하여 목업 데이터를 생성했는데 쉽게 데이터를 만들 수 있어 매우 유용했다.