프론트엔드 리팩토링 적용기

Natest·2024년 8월 10일

인프런에서 팀프로젝트를 구하는 커뮤니티를 통하여 진행중인 프로젝트에 투입되었다.

해당 프로젝트는 개발자 커뮤니티를 개발하는 프로젝트였는데 기존 프론트엔드 인원이 이탈을 하여 기존에 있던 프론트엔드 인원 1명과 내가 대신 프로젝트를 마무리 하게 되었다.

우선, 해당 프로젝트에 대한 코드를 확인해보는 것을 먼저 하였는데 기존 폴더구조를 리팩토링 하는 것을 목표로 수정하였다.

폴더구조는 우아한테크코스 프로젝트인 행록이라는 프로젝트의 깃허브 레포지토리를 참고해서 하였다.

폴더구조 리팩토링

우선, services 폴더의 구조를 개선하였다.

변경 전 구조

이전 구조에서는 모든 서비스 관련 파일들이 단일 레벨에 나열되어 있었다. 현재 프로젝트에서는 괜찮을 수 있지만, 애플리케이션이 커질수록 관리가 어려워질 수 있을 것 같다.

변경 후 구조

새로운 구조에서는 관련 기능들을 하위 폴더로 그룹화했다.

  1. admin/ : 관리자 관련 기능
  2. image/ : 이미지 처리 관련 기능
  3. post/ : 게시물 관련 모든 기능
  4. user/ : 사용자 관련 기능

root 레벨에는 일반적인 서비스나 여러 도메인에 걸쳐 사용되는 기능들을 남겼다.
이 변경으로 관련 기능들을 논리적으로 그룹화하고, 코드의 조직과 유지보수성을 크게 향상시켰다.

다음으로 hooks 폴더의 구조를 개선하였다.

변경 전 구조

이전 구조에서는 모든 hooks 파일들이 단일 레벨에 나열되어 있었다. 이 구조도 현재 프로젝트에서는 괜찮을 수 있지만, 애플리케이션이 커질수록 관리가 어려워질 수 있을 것 같다.

변경 후 구조

  • API 관련 훅 분리: api/ 하위 폴더를 생성하여 모든 API 관련 훅을 그룹화함. 이는 주로 React Query를 사용한 데이터 fetching hooks들임.
  • 비 API 훅 정리: API와 관련되지 않은 커스텀 훅들은 root 레벨에 남겨두어 쉽게 접근할 수 있게 함.
  • 명명 규칙 개선: API hooks의 이름을 더욱 명확하게 지정했습니다. 예를 들어, usePostQuery는 Post 데이터를 get해오는 훅, useDeleteArticleMutation는 특정 Article을 delete하는 훅 등으로 각 hook의 목적을 명확히 알 수 있게 함.

이 변경을 통해 API 관련 hooks를 별도로 분리하고 전반적인 구조를 더욱 체계화하였다.

성능과 유지보수성 개선

다음으로 App.tsx를 리팩토링을 하였다.

App.tsx (Before)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ModalProvider } from './context/ModalsContext.tsx';

const queryClient = new QueryClient();

import GlobalStyle from './styles/GlobalStyle.tsx';
import PostWritePage from './pages/PostWritePage.tsx';
// ... 다른 페이지 컴포넌트 임포트

const router = createBrowserRouter([
  {
    path: '/',
    element: <MainPage />,
  },
  {
    path: '/login',
    element: <LoginPage />,
  },
	// ... 다른 라우트 설정
]);

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <GlobalStyle />
    <RecoilRoot>
      <ModalProvider>
        <QueryClientProvider client={queryClient}>
          <RouterProvider router={router} />
        </QueryClientProvider>
      </ModalProvider>
    </RecoilRoot>
  </React.StrictMode>,
);

기존에는 모든 라우팅 로직과 임포트가 한 파일에 집중이 되어 있었다. 그래서 이를 개선하기 위해 다음과 같이 변경을 하였다.

1. 코드 분리와 모듈화

  • router 폴더 생성 후 AppRouter.tsx와 lazy.ts파일 생성
  • 라우터 로직을 AppRouter.tsx로 분리
    AppRouter.tsx
    // AppRouter.tsx
    import { Suspense } from 'react';
    import { RouterProvider, createBrowserRouter } from 'react-router-dom';
    import * as Lazy from './lazy';
    import ErrorPage from '../pages/ErrorPage';
    
    const AppRouter = () => {
      const router = createBrowserRouter([
        {
          path: '/',
          element: (
            <Suspense>
              <Lazy.MainPage />
            </Suspense>
          ),
        },
        {
          path: '/login',
          element: (
            <Suspense>
              <Lazy.LoginPage />
            </Suspense>
          ),
        },
        // ... 다른 라우트 설정
      ]);
    
      return <RouterProvider router={router} />;
    };
    
    export default AppRouter;
  • 레이지 로딩을 위한 컴포넌트들을 lazy.ts 파일로 분리

성능 개선을 위해 React의 lazy와 Suspense를 활용하여 코드 스플리팅을 구현함.

lazy.ts

import { lazy } from 'react';

export const MainPage = lazy(() => import(/* webpackChunkName: "MainPage" */ '../pages/MainPage'));

export const LoginPage = lazy(() => import(/* webpackChunkName: "LoginPage" */ '../pages/LoginPage'));

// ... 다른 페이지 컴포넌트 레이지 로딩 설정

이를 통해 초기 로딩 시간을 단축하고, 필요한 컴포넌트만 그때그때 로드할 수 있게 되었고, 각 파일의 책임을 명확히 하고, 코드의 가독성과 유지보수성을 높일 수 있었음.

2. 에러 처리 개선
전역적인 에러 처리를 위해 ErrorBoundary를 추가하고, 404 등의 에러 상황을 위한 ErrorPage 컴포넌트를 구현함.

<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
  {/* 애플리케이션 컴포넌트 */}
</ErrorBoundary>

이를 통해 예기치 못한 에러 상황에서도 애플리케이션이 완전히 중단되지 않고, 사용자에게 적절한 피드백을 제공할 수 있게 되었음.

3. React Query 개선

데이터 페칭과 상태 관리를 위해 사용 중인 React Query의 기능을 확장함.

  • QueryErrorResetBoundary를 사용하여 쿼리 에러 처리를 개선
  • ReactQueryDevtools를 추가하여 개발 시 디버깅을 용이하게 함

이러한 변경으로 데이터 관련 에러를 더 효과적으로 처리하고, 개발 과정에서의 디버깅 효율을 높임.

4. 성능 최적화

웹팩 청크 이름을 지정하여 빌드 최적화에 도움을 주었다.

import(/* webpackChunkName: "MainPage" */ '../pages/MainPage')

이를 통해 빌드 결과물을 더 효율적으로 관리하고, 필요한 청크만 로드할 수 있게 되었습니다.

이를 통합하여 최종적으로 코드를 리팩토링하였다.
App.tsx (After)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { RecoilRoot } from 'recoil';
import { QueryClientProvider, QueryErrorResetBoundary } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';

import { ModalProvider } from './context/ModalsContext.tsx';
import GlobalStyle from './styles/GlobalStyle.tsx';
import AppRouter from './router/AppRouter.tsx';
import LogIn from './components/common/Login/LogIn.tsx';
import { queryClient } from './hooks/api/queryClient.ts';
import ErrorFallback from './components/common/Error/ErrorFallback.tsx';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
          <GlobalStyle />
          <RecoilRoot>
            <ModalProvider>
              <QueryClientProvider client={queryClient}>
                <LogIn>
                  <AppRouter />
                  <ReactQueryDevtools initialIsOpen={false} />
                </LogIn>
              </QueryClientProvider>
            </ModalProvider>
          </RecoilRoot>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  </React.StrictMode>,
);

해당 리팩토링을 통해 코드의 구조를 개선하고, 성능을 최적화하며, 유지보수성을 크게 높일 수 있었다. 특히 레이지 로딩의 도입으로 초기 로딩속도가 개선되었고, 에러 처리 강화로 애플리케이션의 안전성이 향상되었다.

다만, 이러한 변경에는 트레이드오프도 존재하는데 예를 들어, 레이지 로딩으로 인해 일부 컴포넌트의 로딩이 지연될 수 있으며, 복잡한 에러 처리 로직으로 인해 코드의 복잡도가 약간 증가할 수 있다. 하지만 전반적인 사용자 경험과 개발 효율성 측면에서 볼 때, 이러한 변경은 분명 긍정적인 영향을 미쳤다고 생각한다.

기존 코드 개선

커스텀 훅을 활용한 코드 개선

기존에 작성된 질문 게시판을 형성하는 QuestionBoardPage 컴포넌트를 리팩토링 하였다.

1. 원본 코드 분석

먼저, 리팩토링 전의 QuestionBoardPage 컴포넌트를 살펴보았다.

import styled from 'styled-components';
import Navbar from '../components/NavBar';
import PickFlow from '../components/board/PickFlow';
import BoardInfo from '../components/board/BoardInfo';
import BoardController from '../components/board/BoardController';
import Divider from '../components/board/Divider';
import QuestionBoardList from '../components/board/QuestionBoardList';
import { useEffect, useState } from 'react';
import { Filters } from '../requestType/postType.ts';

function QuestionBoardPage() {
  const [filters, setFilters] = useState<Filters>({
    search: '',
    categories: 'question',
    sortBy: 'latest',
    status: ['wait', 'finish'],
    pageInfo: {
      page: 1,
      size: 15,
      offset: 0,
    },
  });

  useEffect(() => {
    console.log("filters 상태 변경:", filters);
  }, [filters]);

  return (
    <Container>
      <Navbar/>
      <PickFlow />
      <BoardInfo
        title="Q&A 게시판"
        description="질문은 정중하게~~ 어쩌고 저쩌고 이용 수칙을 따르지 않을 경우 제재할 수 있습니다."
      />
      <BoardController filters={filters} setFilters={setFilters}/>
      <Divider />
      <QuestionBoardList filters={filters}/>
    </Container>
  );
}

export default QuestionBoardPage;

이 컴포넌트는 질문 게시판 페이지를 렌더링하고 있으며, filters 상태를 관리하고 있다. 하지만 이 상태 관리 로직이 컴포넌트 내부에 직접 구현되어 있어, 재사용성이 떨어지고 컴포넌트가 불필요하게 복잡해지는 문제가 있다.

다음으로 BoardController 컴포넌트이다.

import styled from 'styled-components';
import CreateOutlinedIcon from '@mui/icons-material/CreateOutlined';
import RefreshOutlinedIcon from '@mui/icons-material/RefreshOutlined';
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Filters } from '../../../requestType/postType.ts';

interface Props {
  filters: Filters;
  setFilters: React.Dispatch<React.SetStateAction<Filters>>;
}

function BoardController({ filters, setFilters }: Props) {
  const navigate = useNavigate();
  const [activeButton, setActiveButton] = useState('전체');
  const [isAscending, setIsAscending] = useState<boolean | null>(null);
  const [isPending, setIsPending] = useState(false);
  const [isCompleted, setIsCompleted] = useState(false);
  const [selectedValue, setSelectedValue] = useState('10');
  const [inputValue, setInputValue] = useState('');

  // 데이터 재호출
  const queryClient = useQueryClient();
  const handleRefetch = () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  };

  // 필터 (오름차순, 내림차순)
  const handleFilterChange = (newSortby: string) => {
    // console.log('handleFilterChange');
    setFilters(prevFilters => ({
      ...prevFilters,
      sortBy: newSortby,
    }));
    if (newSortby === 'latest') {
      setIsAscending(true);
    } else if (newSortby === 'earliest') {
      setIsAscending(false);
    }
    setActiveButton(newSortby);
  };

  // 필터 (해결대기, 해결완료)
  const handleStatusChange = (status: string) => {
    // console.log(`isPendingBefore : ${isPending}`)
    if (status === 'wait') {
      setIsPending(!isPending);
    } else if (status === 'finish') {
      setIsCompleted(!isCompleted);
    }
    setActiveButton(status);
  };

  // 해결대기와 해결완료 데이터 변경시 setFilters 에 데이터 반영
  useEffect(() => {
    let newStaus;
    if (isPending && isCompleted) {
      newStaus = ['wait', 'finish']
    } else if (!isPending && isCompleted) {
      newStaus = ['finish']
    } else if (isPending && !isCompleted) {
      newStaus = ['wait']
    } else {
      newStaus = ['wait', 'finish']
    }
    // console.log(`newStaus : ${newStaus}`)
    setFilters(prevFilters => {
      return { ...prevFilters, status: newStaus };
    });
  }, [isPending, isCompleted]);

  // 전체(검색 조건 리셋)
  const handleResetFilters = () => {
    // console.log('handleResetFilters');
    const initialFilters = {
      search: '',
      categories: 'question',
      sortBy: 'latest',
      status: ['wait', 'finish'],
      pageInfo: {
        page: 1,
        size: 15,
      },
    };
    setFilters(initialFilters);
    setActiveButton('전체');
    setIsAscending(null);
    setIsPending(false);
    setIsCompleted(false);
    setInputValue('');
  };

  // 1페이지 몇개 볼 것인지 선택
  const handleSelectChange = (event: ChangeEvent<HTMLSelectElement>) => {
    setSelectedValue(event.target.value);
    setFilters(prevFilters => ({
      ...prevFilters,
      pageInfo: {
        ...prevFilters.pageInfo,
        size: Number(event.target.value)
      }
    }));
  };

  // 검색창에 입력되는 값
  const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
    setInputValue(event.target.value);
    setFilters(prevFilters => ({
      ...prevFilters,
      search: event.target.value
    }));
  };

  // 게시글 글쓰기 클릭 할 때
  const handlePostWriteClick = () => {
    navigate(`/write`);
  };

  return (
    <Container>
      <Wrapper>
        <WriteButton onClick={handlePostWriteClick}>
          <span>POST</span>
          <CreateOutlinedIcon className="icon" />
        </WriteButton>
        <ReloadButton>
          <RefreshOutlinedIcon onClick={handleRefetch}/>
        </ReloadButton>
      </Wrapper>

      <Wrapper>
        <CategoryList>
          <FilterItem className={activeButton === '전체' ? 'active' : ''} onClick={handleResetFilters}>
            <div className="dot" />
            <div>전체</div>
          </FilterItem>
          <FilterItem
            className={isAscending === false ? 'active' : ''}
            onClick={() => handleFilterChange('earliest')}
          >
            <div className="dot" />
            <div>오름차순</div>
          </FilterItem>
          <FilterItem
            className={isAscending === true ? 'active' : ''}
            onClick={() => handleFilterChange('latest')}
          >
            <div className="dot" />
            <div>내림차순</div>
          </FilterItem>
          <FilterItem
            className={isPending ? 'active' : ''}
            onClick={() => handleStatusChange( 'wait')}
          >
            <div className="dot" />
            <div>해결 대기</div>
          </FilterItem>
          <FilterItem
            className={isCompleted ? 'active' : ''}
            onClick={() => handleStatusChange('finish')}
          >
            <div className="dot" />
            <div>해결 완료</div>
          </FilterItem>
        </CategoryList>
        <SelectWrapper>
          <Select value={selectedValue} onChange={handleSelectChange}>
            <option value="15">15개씩 보기</option>
            <option value="30">30개씩 보기</option>
            <option value="50">50개씩 보기</option>
            <option value="100">100개씩 보기</option>
          </Select>
          <Icon  />
        </SelectWrapper>
        <SearchForm>
          <SearchInput value={inputValue} onChange={handleInputChange} type="text" placeholder="find in Q&A" />
          <SearchButton type="submit">
            <SearchOutlinedIcon />
          </SearchButton>
        </SearchForm>
      </Wrapper>
    </Container>
  );
}

export default BoardController;

이 코드에는 몇 가지 개선이 필요한 부분이 있어 보였다.

  1. 컴포넌트의 복잡성: 이 컴포넌트는 너무 많은 책임을 지고 있다. 상태 관리, 이벤트 핸들링, UI 렌더링 등 다양한 로직이 한 곳에 모여 있어 가독성과 유지보수성이 떨어진다.

  2. 상태 관리의 복잡성: 여러 개의 useState 훅을 사용하고 있어, 상태 관리가 복잡하다. 특히 isPending과 isCompleted 상태는 서로 의존적이어서 관리가 어려워 보인다.

    const [activeButton, setActiveButton] = useState('전체');
    const [isAscending, setIsAscending] = useState<boolean | null>(null);
    const [isPending, setIsPending] = useState(false);
    const [isCompleted, setIsCompleted] = useState(false);
    const [selectedValue, setSelectedValue] = useState('10');
    const [inputValue, setInputValue] = useState('');
  3. 사이드 이펙트 처리: useEffect를 사용하여 isPending과 isCompleted 상태 변경을 감지하고 있는데, 이는 코드의 복잡성을 증가시킨다.

    useEffect(() => {
      // isPending과 isCompleted 상태에 따라 newStatus 설정
      setFilters(prevFilters => {
        return { ...prevFilters, status: newStaus };
      });
    }, [isPending, isCompleted]);

2. 리팩토링 전략

이를 개선하기 위해 다음과 같은 전략을 세웠습니다.

  1. 필터 관리 로직을 별도의 커스텀 훅(useFilters)으로 분리
  2. BoardController의 로직을 별도의 커스텀 훅(useBoardControl)으로 분리
  3. 컴포넌트 코드를 간소화하고 가독성 향상

3. 리팩토링 과정

3.1 useFilters 훅 생성

먼저 필터 상태 관리를 위한 useFilters 훅을 만들었습니다.

import { useEffect, useState } from 'react';
import { INITIAL_FREE_FILTERS, INITIAL_QUESTION_FILTERS } from '../constants/filters';
import { Filters } from '../types/filter';

const useFilters = (categories: string) => {
  const [filters, setFilters] = useState<Filters>(
    categories === FILTERS_CATEGORIES_LIST.QUESTION ? INITIAL_QUESTION_FILTERS : INITIAL_FREE_FILTERS,
  );

  useEffect(() => {
    console.log('filters 상태 변경:', filters);
    // 여기에 상태 변화에 따른 추가 작업을 수행할 수 있습니다.
  }, [filters]);

  return { filters, setFilters };
};

export default useFilters;

filter.ts 라는 파일을 만들어 필터링과 정렬 기능을 위한 상수 리스트와 초기화 필터 설정을 정의하였습니다.

다음으로 as const 를 사용하여 객체나 배열의 값을 읽기 전용(readonly)으로 만들어 해당 값이 변경되지 않도록 타입 시스템이 강제하였다. 문자열 리터럴 타입이 자동으로 유추되어 할당된 값의 타입이 더욱 구체적으로 지정된다.

export const FILTERS_CATEGORIES_LIST = {
  QUESTION: 'question',
  FREE: 'free',
} as const;

export const FILTERS_SORT_LIST = {
  LATEST: 'latest',
  EARLIEST: 'earliest',
} as const;

export const FILTERS_POST_LIST = {
  WAIT: 'wait',
  FINISH: 'finish',
  ALL: '',
} as const;

export const INITIAL_QUESTION_FILTERS = {
  search: '',
  categories: FILTERS_CATEGORIES_LIST.QUESTION,
  sortBy: FILTERS_SORT_LIST.LATEST,
  status: FILTERS_POST_LIST.ALL,
  pageInfo: {
    page: 0,
    size: 15,
  },
} as const;

export const INITIAL_FREE_FILTERS = {
  search: '',
  categories: FILTERS_CATEGORIES_LIST.FREE,
  sortBy: FILTERS_SORT_LIST.LATEST,
  status: FILTERS_POST_LIST.ALL,
  pageInfo: {
    page: 0,
    size: 15,
  },
} as const;

3.2 useBoardControl 훅 구현

다음으로 BoardController의 로직을 분리한 useBoardControl 훅을 만들었습니다.

import { useState, ChangeEvent } from 'react';
import { useQueryClient } from '@tanstack/react-query';

import { FILTERS_POST_LIST } from '../constants/filters';
import { BoardControlType, PostStatus, SortByStatus } from '../types/filter';

const useBoardControl = ({ filters, setFilters }: BoardControlType) => {
  const [selectedValue, setSelectedValue] = useState<string>('10');

  // 데이터 재호출
  const queryClient = useQueryClient();
  const handleRefetch = () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  };

  // 필터 (오름차순, 내림차순)
  const handleFilterChange = (newSortby: SortByStatus) => {
    // console.log('handleFilterChange');
    setFilters((prevFilters) => ({
      ...prevFilters,
      sortBy: newSortby,
    }));
  };

  // 필터 (해결대기, 해결완료)
  const handleStatusChange = (newStatus: PostStatus) => {
    // console.log(`isPendingBefore : ${isPending}`)
    setFilters((prevFilters) => ({
      ...prevFilters,
      status: newStatus,
    }));
  };

  // 전체(검색 조건 리셋)
  const handleResetFilters = () => {
    // console.log('handleResetFilters');
    setFilters((prevFilters) => ({
      ...prevFilters,
      status: FILTERS_POST_LIST.ALL,
    }));
  };

  // 1페이지 몇개 볼 것인지 선택
  const handleSelectChange = (event: ChangeEvent<HTMLSelectElement>) => {
    setSelectedValue(event.target.value);
    setFilters((prevFilters) => ({
      ...prevFilters,
      pageInfo: {
        ...prevFilters.pageInfo,
        size: Number(event.target.value),
      },
    }));
  };

  return {
    selectedValue,
    handleRefetch,
    handleResetFilters,
    handleFilterChange,
    handleStatusChange,
    handleSelectChange,
  };
};

export default useBoardControl;

3.3 컴포넌트 수정

마지막으로 QuestionBoardPage 컴포넌트를 수정했습니다.

import styled from 'styled-components';

import Navbar from '../components/header/NavBar';
import PickFlow from '../components/board/PickFlow';
import BoardInfo from '../components/board/BoardInfo';
import BoardController from '../components/board/BoardController';
import Divider from '../components/board/Divider';
import QuestionBoardList from '../components/board/QuestionBoardList';
import useFilters from '../hooks/useFilters';
import { FILTERS_CATEGORIES_LIST } from '../constants/filters';

function QuestionBoardPage() {
  const { filters, setFilters } = useFilters(FILTERS_CATEGORIES_LIST.QUESTION);

  return (
    <Container>
      <Navbar />
      <PickFlow />
      <BoardInfo
        title="Q&A 게시판"
        description="질문은 정중하게~~ 어쩌고 저쩌고 이용 수칙을 따르지 않을 경우 제재할 수 있습니다."
      />
      <BoardController filters={filters} setFilters={setFilters} />
      <Divider />
      <QuestionBoardList filters={filters} setFilters={setFilters} />
    </Container>
  );
}

export default QuestionBoardPage;

const Container = styled.div`
  max-width: 1200px;
  margin: 0 auto;
`;

4. 리팩토링 결과

이번 리팩토링을 통해 다음과 같은 개선을 이루었습니다:

  1. 관심사의 분리: 필터 관리 로직과 보드 컨트롤 로직을 각각의 커스텀 훅으로 분리하여 컴포넌트의 책임을 줄임.
  2. 재사용성 향상: useFilters와 useBoardControl 훅은 다른 컴포넌트에서도 쉽게 재사용 가능.
  3. 가독성 개선: 메인 컴포넌트의 코드가 간결해져 전체적인 구조를 파악하기 쉬워짐.
  4. 유지보수성 향상: 로직이 분리되어 있어 수정이 필요할 때 해당 훅만 수정하면 되므로 유지보수가 용이해짐.

다음으로 게시글들의 목록들을 보여주는 컴포넌트인 QuestionBoardList 를 리팩토링하였다.

1. 원본 코드 분석

먼저, 리팩토링 전의 QuestionBoardList 컴포넌트를 살펴보았다.

import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Filters } from '../../../requestType/postType';
import BoardItem from './BoardItem';
import { Board, BoardListWrapper } from './styles';
import BookMarkIcon from '../BookMarkIcon';
import PaginationRounded from '../Pagination';
import usePosts from './usePosts.ts';

interface Props {
  filters: Filters;
}

function QuestionBoardList({ filters }: Props) {
  const navigate = useNavigate();
  const { posts, showLoadingMessage, showNoDataMessage, showPagination, toggleStatus } = usePosts(filters);
  const BookMarkIconMemo = React.memo(BookMarkIcon);

  // 게시글 북마크 하고자 할 때
  const handleBookMarkIconClick = useCallback((event: React.MouseEvent<HTMLDivElement>, id: number) => {
    event.stopPropagation();
    toggleStatus(id, 'bookmark', !posts.find(post => post.id === id)?.bookmark);
  }, [toggleStatus, posts]);

  // 블라인드 상태 토글하고자 할 때
  const handleMoreClick = useCallback((event: React.MouseEvent<HTMLButtonElement>, id: number) => {
    event.stopPropagation();
    toggleStatus(id, 'blind', !posts.find(post => post.id === id)?.blind);
  }, [toggleStatus, posts]);

  // 중요!!!
  // handleMoreClick 와 같은 기능이므로 삭제될 예정
  const handleBlindDataAddClick = useCallback((event: React.MouseEvent<HTMLButtonElement>, id: number) => {
    event.stopPropagation();
    toggleStatus(id, 'blind', !posts.find(post => post.id === id)?.blind);
  }, [toggleStatus, posts]);

  // 게시글 클릭 할 때
  const handlePostClick = useCallback((id: number, blind: boolean) => {
    if (!blind) {
      navigate(`/posts/${id}`);
    }
  }, [navigate]);

  // More 컴포넌트 클릭시 다른 곳 컴포넌트로 이벤트 퍼지는거 막기
  const eventStop = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
    event.stopPropagation();
  }, []);

  if (showLoadingMessage) {
    return (
      <BoardListWrapper>
        <Board>데이터 로딩중...</Board>
      </BoardListWrapper>
    );
  }

  if (showNoDataMessage) {
    return (
      <BoardListWrapper>
        <Board>데이터 0건</Board>
      </BoardListWrapper>
    );
  }

  return (
    <BoardListWrapper>
      {posts.map(post => (
        <BoardItem
          key={post.id}
          post={post}
          handlePostClick={handlePostClick}
          handleBookMarkIconClick={handleBookMarkIconClick}
          handleMoreClick={handleMoreClick}
          handleBlindDataAddClick={handleBlindDataAddClick}
          eventStop={eventStop}
          BookMarkIconMemo={BookMarkIconMemo}
        />
      ))}
      {showPagination && (<PaginationRounded />)}
    </BoardListWrapper>
  );
}

export default QuestionBoardList;

다음으로 BoardItem 컴포넌트를 살펴보았다.

import React from 'react';
import { Post } from '../../../responseType/postType';
import {
  BoardItemWrapper,
  StatusWrapper,
  BoardStatus,
  BoardWrapper,
  BoardContent,
  BookMarkWrapper,
  MoreWrapper
} from './styles';
import More from '../More';
import Profile from '../Profile';
import Status from '../Status';
import Block from '../Block';
import TagList from '../TagList';

interface BoardItemProps {
  post: Post;
  handlePostClick: (id: number, blind: boolean) => void;
  handleBookMarkIconClick: (event: React.MouseEvent<HTMLDivElement>, id: number) => void;
  handleMoreClick: (event: React.MouseEvent<HTMLButtonElement>, id: number) => void;
  handleBlindDataAddClick: (event: React.MouseEvent<HTMLButtonElement>, id: number) => void;
  eventStop: (event: React.MouseEvent<HTMLDivElement>) => void;
  BookMarkIconMemo: React.FC<{ isActive: boolean }>;
}

const BoardItem: React.FC<BoardItemProps> = ({
                                               post,
                                               handlePostClick,
                                               handleBookMarkIconClick,
                                               handleMoreClick,
                                               handleBlindDataAddClick,
                                               eventStop,
                                               BookMarkIconMemo,
                                             }) => {
  const { id, status, viewCount, commentCount, likeCount, nickname, timeline, title, tags, content, bookmark, blind } = post;

  return (
    <BoardItemWrapper $status={status} onClick={() => handlePostClick(id, blind)} key={status}>
      {blind && <Block onClick={(event) => handleBlindDataAddClick(event, id)} id={id}/>}
      <StatusWrapper>
        <BoardStatus $status={status}>
          {status === 'wait' ? '해결 대기' : '해결 완료'}
        </BoardStatus>
        <Status viewCount={viewCount} commentCount={commentCount} likeCount={likeCount} />
      </StatusWrapper>
      <BoardWrapper>
        <Profile avatar="" nickname={nickname} timeline={timeline} />
        <BoardContent>
          <div className="title">{title}</div>
          <TagList tags={tags} />
          <div className="content">{content}</div>
        </BoardContent>
      </BoardWrapper>
      {!blind && (
        <BookMarkWrapper onClick={(event) => handleBookMarkIconClick(event, id)}>
          <BookMarkIconMemo isActive={bookmark} />
        </BookMarkWrapper>
      )}
      {!blind && (
        <MoreWrapper onClick={eventStop}>
          <More onClick={(event) => handleMoreClick(event, id)} id={id}/>
        </MoreWrapper>
      )}
    </BoardItemWrapper>
  );
};

export default BoardItem;

2. 관심사의 분리

가장 큰 변화는 QuestionBoardList 컴포넌트에서 많은 로직을 분리한 것이다.
기존에는 이 컴포넌트가 여러 가지 책임을 지고 있었지만, 리팩토링 후에는 주로 렌더링에만 집중하게 되었다.

 import { BoardControlType } from '../../../types/filter';
 import BoardItem from './BoardItem';
 import { Board, BoardListWrapper } from './styles';
 import PaginationRounded from '../Pagination';
 import usePosts from '../../../hooks/usePosts.ts';
 
 function QuestionBoardList({ filters, setFilters }: BoardControlType) {
   const { posts, showLoadingMessage, showNoDataMessage, showPagination, toggleStatus, pagesCount } = usePosts(filters);
 
   if (showLoadingMessage) {
     return (
       <BoardListWrapper>
         <Board>데이터 로딩중...</Board>
       </BoardListWrapper>
     );
   }
 
   if (showNoDataMessage) {
     return (
       <BoardListWrapper>
         <Board>데이터 0건</Board>
       </BoardListWrapper>
     );
   }
 
   return (
     <BoardListWrapper>
       {posts.map((post) => (
         <BoardItem key={post.id} post={post} toggleStatus={toggleStatus} />
       ))}
       {showPagination && <PaginationRounded pagesCount={pagesCount} setFilters={setFilters} />}
     </BoardListWrapper>
   );
 }
 
 export default QuestionBoardList;

3. 커스텀 훅의 활용

usePost라는 커스텀 훅을 만들어 이벤트 핸들러 로직을 분리했다.
이를 통해 불필요한 리렌더링을 방지하고 성능을 개선하며 컴포넌트의 복잡도를 줄이고 로직의 재사용성을 높였다.

 import { useCallback } from 'react';
 import { Post, ToggleKey, ToggleValue } from '../types/post';
 import { useNavigate } from 'react-router-dom';
 
 interface Props {
   post: Post;
   toggleStatus: (id: number, type: ToggleKey, value: ToggleValue) => void;
 }
 
 const usePost = ({ post, toggleStatus }: Props) => {
   const navigate = useNavigate();
 
   // 게시글 북마크 하고자 할 때
   const handleBookMarkIconClick = useCallback(
     (event: React.MouseEvent<HTMLDivElement>, id: number) => {
       event.stopPropagation();
       toggleStatus(id, 'bookmark', post.bookmark);
     },
     [toggleStatus, post],
   );
 
   // 블라인드 상태 토글하고자 할 때
   const handleMoreClick = useCallback(
     (event: React.MouseEvent<HTMLButtonElement>, id: number) => {
       event.stopPropagation();
       toggleStatus(id, 'blind', post.blind);
     },
     [toggleStatus, post],
   );
 
   const handleBlindDataAddClick = useCallback(
     (event: React.MouseEvent<HTMLButtonElement>, id: number) => {
       event.stopPropagation();
       toggleStatus(id, 'blind', post.blind);
     },
     [toggleStatus, post],
   );
 
   // 게시글 클릭 할 때
   const handlePostClick = useCallback(
     (id: number, blind?: boolean) => {
       if (!blind) {
         navigate(`/posts/${id}`);
       }
     },
     [navigate],
   );
 
   // More 컴포넌트 클릭시 다른 곳 컴포넌트로 이벤트 퍼지는거 막기
   const eventStop = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
     event.stopPropagation();
   }, []);
 
   return { handleBookMarkIconClick, handleMoreClick, handleBlindDataAddClick, handlePostClick, eventStop };
 };
 
 export default usePost;

4. 컴포넌트 분리

기존에 BoardItem을 별도의 컴포넌트로 분리하여 각 게시글 아이템의 렌더링 로직을 캡슐화하여 QuestionBoardList 의 복잡도를 더욱 낮추는 효과를 가져왔었습니다.

하지만, 기존 코드에서는 모든 이벤트 핸들러가 부모 컴포넌트에서 전달이 되어 너무 많은 props때문에 가독성이 좋지 않았습니다.

그래서 앞서 설명했던 usePost훅을 통해 BoardItem내부에서 관리되도록 하여 props를 간소화 시켰습니다. 이를 통해 컴포넌트의 인터페이스를 단순화하고, 사용하기 쉽게 만들 수 있었습니다.

 import React from 'react';
 import {
   BoardItemWrapper,
   StatusWrapper,
   BoardStatus,
   BoardWrapper,
   BoardContent,
   BookMarkWrapper,
   MoreWrapper,
 } from './styles';
 import More from '../More';
 import Profile from '../Profile';
 import Status from '../Status';
 import Block from '../Block';
 import TagList from '../TagList';
 import { Post, ToggleKey, ToggleValue } from '../../../types/post';
 import usePost from '../../../hooks/usePost';
 import BookMarkIcon from '../BookMarkIcon';
 
 interface BoardItemProps {
   post: Post;
   toggleStatus: (id: number, type: ToggleKey, value: ToggleValue) => void;
 }
 
 const BoardItem: React.FC<BoardItemProps> = ({ post, toggleStatus }) => {
   const {
     id,
     status,
     viewCount,
     commentCount,
     likeCount,
     nickname,
     timeline,
     title,
     tags,
     content,
     bookmark,
     blind,
     profileImg,
   } = post;
   const { handleBookMarkIconClick, handleMoreClick, handleBlindDataAddClick, handlePostClick, eventStop } = usePost({
     post,
     toggleStatus,
   });
 
   return (
     <BoardItemWrapper $status={status} onClick={() => handlePostClick(id, blind)} key={status}>
       {blind && <Block onClick={(event) => handleBlindDataAddClick(event, id)} id={id} />}
       <StatusWrapper>
         <BoardStatus $status={status}>{status === 'wait' ? '해결 대기' : '해결 완료'}</BoardStatus>
         <Status viewCount={viewCount} commentCount={commentCount} likeCount={likeCount} />
       </StatusWrapper>
       <BoardWrapper>
         <Profile avatar={profileImg} nickname={nickname} timeline={timeline} />
         <BoardContent>
           <div className="title">{title}</div>
           <TagList tags={tags} />
           <div className="content">{content}</div>
         </BoardContent>
       </BoardWrapper>
       {!blind && (
         <>
           <BookMarkWrapper onClick={(event) => handleBookMarkIconClick(event, id)}>
             <BookMarkIcon isActive={bookmark} />
           </BookMarkWrapper>
           <MoreWrapper onClick={eventStop}>
             <More onClick={(event) => handleMoreClick(event, id)} id={id} />
           </MoreWrapper>
         </>
       )}
     </BoardItemWrapper>
   );
 };
 
 export default BoardItem;

해당 리팩토링을 통해 코드의 가독성, 유지보수성, 재사용성이 크게 향상되었다. 컴포넌트의 책임을 명확히 분리하고, 커스텀 훅을 활용하여 로직을 모듈화하여 장기적으로 프로젝트의 품질을 높이고 개발 생산성을 향상시키도록 기대 할 수 있을 것 같다.


다음으로 Profile과 ProfileEditForm 컴포넌트를 리팩토링 하였다.

기존 프로필 페이지는 하나의 큰 컴포넌트로 작성되어 있었다.

이 컴포넌트는 이미지 업로드, 사용자 정보 수정, 상태 관리 등 다양한 기능을 한 곳에서 처리하고 있었기 때문에 코드가 복잡하고 유지보수가 어려워 보였다.

1. 원본 코드 분석

먼저, 리팩토링 전의 Profile 컴포넌트를 살펴보았다.

import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useMutation } from '@tanstack/react-query';
import SettingsIcon from '@mui/icons-material/Settings';
import { useNavigate } from 'react-router-dom';
import LocalSeeIcon from '@mui/icons-material/LocalSee';
import { postFile } from '../../services/http';
import { useRecoilState } from 'recoil';
import { nickNameState } from '../../atoms/userInfoState';
import { ContentState } from '../../atoms/userInfoState';
import { usePutContent } from '../../hooks/mypageHooks';
import { useGetInfo } from '../../hooks/mypageHooks';
import { fetchPutImage } from '../../services/http';
import { useGetUserTags } from '../../hooks/mypageHooks';

function Profile() {
  const navigate = useNavigate();
  const [username, setUserName] = useRecoilState<string>(nickNameState);
  const [userTag, setUserTag] = useState<{ id: number; name: string }[]>([]);
  const [userContent, setUserContent] = useRecoilState<string>(ContentState);
  const [isEditing, setIsEditing] = useState(false);
  const [isContentChanged, setIsContentChanged] = useState<boolean>(false);

  //사진관련 상태
  const [image, setImage] = useState<string | null>(null);
  const [isImageChanged, setIsImageChanged] = useState<boolean>(false);
  const [isDefault, setIsDefault] = useState<boolean>(true);
  const fileInput = useRef<HTMLInputElement | null>(null);

  const { data: userData } = useGetInfo();
  const { mutateAsync: contentMutate } = usePutContent();
  const { data: userTags } = useGetUserTags();
  const { mutateAsync: postImg, error: mutationError } = useMutation({
    mutationFn: postFile,
    onSuccess: () => {
      console.log('이미지 s3에 보내기 성공!');
    },
    onError: (error) => {
      console.error('Mutation error:', error);
    },
  });

  useEffect(() => {
    if (userData && userData.data.content && userData.data.nickname && userTags) {
      setUserName(userData.data.nickname);
      setImage(userData.data.picture);
      setUserContent(userData.data.content);
      setUserTag(userTags.data);
    }
  }, [userData, setUserName, setUserContent, userTags]);

  const handleSave = async () => {
    if (!isImageChanged && !isContentChanged) {
      setIsEditing(false);
      console.log('No changes to save');
      return;
    }

    const contentToSend = { content: userContent };
    if (isContentChanged) {
      try {
        await contentMutate(contentToSend);
        setIsEditing(false);
      } catch (error) {
        console.log('내용 저장 안됨', error);
      }
    }

    if (image && isImageChanged) {
      const base64File = image;
      if (!base64File) {
        console.error('Invalid image data format');
        return;
      }
      try {
        // 파일 전송
        const data = await postImg({ base64File: base64File, targetId: 9 });
        console.log('이미지 업로드 성공');

        // S3로부터의 응답을 기다렸다가 서버에 다시 보내는 부분
        if (data && data.data && data.data.url) {
          console.log('data.url', data.data.url);

          try {
            await fetchPutImage({ picture: data.data.url });
            console.log('S3 이미지 서버에 전송 성공');
          } catch (error) {
            console.error('S3 이미지 서버에 전송 실패:', error);
          }
        }

        setIsEditing(false);
      } catch (error) {
        console.error('이미지 업로드 실패:', error);
      }
    }

    setIsImageChanged(false);
    setIsContentChanged(false);
  };

  if (mutationError) return <div>Error: {mutationError.message}</div>;

  const handleEdit = () => {
    setIsEditing(!isEditing);
  };

  const handleImgChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files && event.target.files[0]) {
      const file = event.target.files[0];
      const reader = new FileReader();

      reader.onload = async () => {
        const base64String = reader.result as string;
        setImage(base64String);
        setIsDefault(false);
        setIsImageChanged(true);
      };

      reader.readAsDataURL(file);
    } else {
      setImage(null);
      setIsDefault(true);
    }
  };

  return (
    <ProfileWrapper>
      {isEditing ? (
        <>
          <form>
            <ProfileImg>
              <input
                type="file"
                style={{ display: 'none' }}
                accept="image/*"
                name="profile_img"
                onChange={handleImgChange}
                ref={fileInput}
              />
              {isDefault ? (
                <div onClick={() => fileInput.current?.click()}>
                  <LocalSeeIcon style={{ fontSize: '50px' }} />
                </div>
              ) : (
                <img src={image} alt="Profile" onClick={() => fileInput.current?.click()} />
              )}
            </ProfileImg>
            <InfoWrapper>
              <UserInfoNickname>{username}</UserInfoNickname>
              <div>
                {userTag.map((tag) => (
                  <span key={tag.id}>{tag.name}</span>
                ))}
              </div>
              <input
                style={{ height: '57px' }}
                type="text"
                value={userContent}
                onChange={(event) => {
                  setUserContent(event.target.value);
                  setIsContentChanged(true);
                }}
              />
            </InfoWrapper>
          </form>
        </>
      ) : (
        <>
          <ProfileImg>{image && <img src={image} alt="Received Image" />}</ProfileImg>
          <InfoWrapper>
            <UserInfoNickname>{username}</UserInfoNickname>
            <div>
              {userTag.map((tag) => (
                <span key={tag.id}>{tag.name}</span>
              ))}
            </div>
            <p>{userContent}</p>
          </InfoWrapper>
        </>
      )}
      {isEditing ? (
        <>
          <button type="submit" style={{ margin: '10px' }} onClick={handleSave}>
            Complete
          </button>
          <button onClick={() => navigate('/setting')} type="button">
            <SettingsIcon />
          </button>
        </>
      ) : (
        <button onClick={handleEdit} type="button">
          Edit
        </button>
      )}
    </ProfileWrapper>
  );
}

export default Profile;

2. 리팩토링 코드

해당 리팩토링의 주요 목표는 코드의 복잡성을 줄이고 컴포넌트를 더 작고 독립적으로 나누는 것이었습니다. 따라서, 프로필 이미지, 프로필 정보, 프로필 수정 폼 등을 각각의 독립적인 컴포넌트로 분리했습니다.

1. 프로필 컴포넌트

리팩토링 후, Profile 컴포넌트는 전체 프로필 페이지를 관리하며, 보기 모드와 수정 모드를 전환하는 역할을 합니다. 또한, 사용자 데이터와 태그 데이터를 불러와 각각의 컴포넌트에 전달합니다.

 import { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useInfoQuery } from '../../hooks/api/useInfoQuery';
 import { useUserTagsQuery } from '../../hooks/api/useUserTagsQuery';
 import { useMyInfoQuery } from '../../hooks/api/useMyInfoQuery';
 import ProfileImage from './ProfileImage';
 import ProfileInfo from './ProfileInfo';
 import ProfileEditForm from './ProfileEditForm';
 import { ProfileWrapper } from './styles';
 
 function Profile() {
   const navigate = useNavigate();
   const [isEditing, setIsEditing] = useState(false);
 
   const { data: myInfo } = useMyInfoQuery();
   const { data: userData } = useInfoQuery({ id: myInfo.data.id });
   const { data: userTags } = useUserTagsQuery();
 
   const handleEdit = () => {
     setIsEditing(true);
   };
 
   return (
     <>
       {!isEditing && (
         <ProfileWrapper>
           <ProfileImage image={userData?.data?.picture} />
           <ProfileInfo
             nickname={userData?.data?.nickname}
             tags={userTags?.data || []}
             content={userData?.data?.content}
           />
           <button onClick={handleEdit} type="button">
             Edit
           </button>
         </ProfileWrapper>
       )}
 
       {isEditing && (
         <ProfileWrapper>
           <ProfileEditForm
             initialData={{
               id: userData?.data?.id,
               picture: userData?.data?.picture,
               nickname: userData?.data?.nickname,
               content: userData?.data?.content,
             }}
             tags={userTags?.data || []}
             onCancel={() => setIsEditing(false)}
             onNavigateToSettings={() => navigate('/setting')}
           />
         </ProfileWrapper>
       )}
     </>
   );
 }
 
 export default Profile;

2. 프로필 이미지 컴포넌트

ProfileImage 컴포넌트는 사용자의 프로필 사진을 렌더링하는 역할을 합니다.

 import styled from 'styled-components';
 
 interface Props {
   image: string;
 }
 
 const ProfileImage = ({ image }: Props) => {
   return <ProfileImg>{image && <img src={image} alt="Received Image" />}</ProfileImg>;
 };
 
 export default ProfileImage;
 
 const ProfileImg = styled.div`
   width: 6.25rem;
   height: 6.25rem;
   border-radius: 50px;
   overflow: hidden;
   img {
     width: 100%;
     height: 100%;
   }
   div {
     width: 100%;
     height: 100%;
     background-color: #6c6b76;
     display: flex;
     justify-content: center;
     align-items: center;
     color: #ffffff;
   }
 `;

3. 프로필 정보 컴포넌트

ProfileInfo 컴포넌트는 사용자의 닉네임, 태그, 내용을 보여줍니다. 이 컴포넌트는 단순히 데이터를 받아서 렌더링하는 역할만 하기 때문에, 쉽게 테스트하고 재사용할 수 있습니다.

 import { TagsType } from '../../types/tags';
 import { InfoWrapper, UserInfoNickname } from './styles';
 
 interface Props {
   nickname: string;
   tags: TagsType[];
   content: string;
 }
 
 const ProfileInfo = ({ nickname, tags, content }: Props) => {
   return (
     <InfoWrapper>
       <UserInfoNickname>{nickname}</UserInfoNickname>
       <div>
         {tags.map((tag: TagsType) => (
           <span key={tag.id}>{tag.name}</span>
         ))}
       </div>
       <p>{content}</p>
     </InfoWrapper>
   );
 };
 
 export default ProfileInfo;

4. 프로필 수정 폼 컴포넌트

ProfileEditForm 컴포넌트는 프로필 수정 폼과 폼 제출을 처리합니다. 이 컴포넌트는 입력 필드와 저장, 취소 버튼을 포함하고 있으며, 입력 데이터를 처리하고 저장하는 로직을 담고 있습니다.

 import { FormEvent, useRef, useState } from 'react';
 import { usePutUserMutation } from '../../hooks/api/usePutUserMutation';
 import { TagsType } from '../../types/tags';
 import { ProfileImg, InfoWrapper, UserInfoNickname, ButtonWrapper } from './styles';
 import SettingsIcon from '@mui/icons-material/Settings';
 import LocalSeeIcon from '@mui/icons-material/LocalSee';
 import { putUserProps } from '../../services/user/putUser';
 import usePostFileMutation from '../../hooks/api/usePostFileMutation';
 
 interface ProfileEditFormProps {
   initialData: {
     id: number;
     picture: string;
     nickname: string;
     content: string;
   };
   tags: TagsType[];
   onCancel: () => void;
   onNavigateToSettings: () => void;
 }
 
 const ProfileEditForm = ({ initialData, tags, onCancel, onNavigateToSettings }: ProfileEditFormProps) => {
   const [image, setImage] = useState<string | null>(null);
   const contentRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement | null>(null);
 
   const { userMutate } = usePutUserMutation();
   const { postImgMutate, mutationError } = usePostFileMutation();
 
   const handleImgChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
     if (event.target.files && event.target.files[0]) {
       const file = event.target.files[0];
       const reader = new FileReader();
 
       reader.onload = async () => {
         const base64String = reader.result as string;
         setImage(base64String);
       };
 
       reader.readAsDataURL(file);
     }
   };
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault();
     const newContent = contentRef.current?.value || initialData.content;
     const isContentChanged = newContent !== initialData.content;
     const isImageChanged = image !== initialData.picture;
 
     if (!isContentChanged && !isImageChanged) {
       onCancel();
       return;
     }
 
     let updatedData: putUserProps = { id: initialData.id };
 
     if (isContentChanged) {
       updatedData.content = newContent;
     }
 
     if (isImageChanged && image) {
       try {
         const data = await postImgMutate({ base64File: image, targetId: initialData.id });
         
         if (data?.data?.url) {
           updatedData.picture = data.data.url;
         }
       } catch (error) {
         console.error('Image upload failed:', error);
       }
     }
 
     userMutate(updatedData);
     onCancel();
   };
 
   if (mutationError) return <div>Error: {mutationError.message}</div>;
 
   return (
     <form onSubmit={handleSubmit}>
       <ProfileImg>
         <input
           type="file"
           style={{ display: 'none' }}
           accept="image/*"
           name="profile_img"
           onChange={handleImgChange}
           ref={fileInputRef}
         />
         {image ? (
           <img
             src={image}
             alt="Profile"
             onClick={() => {
               fileInputRef.current?.click();
             }}
           />
         ) : (
           <div
             onClick={() => {
               fileInputRef.current?.click();
             }}
           >
             <LocalSeeIcon style={{ fontSize: '50px' }} />
           </div>
         )}
       </ProfileImg>
       <InfoWrapper>
         <UserInfoNickname>{initialData.nickname}</UserInfoNickname>
         <div>
           {tags.map((tag) => (
             <span key={tag.id}>{tag.name}</span>
           ))}
         </div>
         <input type="text" defaultValue={initialData.content} ref={contentRef} />
       </InfoWrapper>
       <ButtonWrapper>
         <button type="submit">Save</button>
         <button type="button" onClick={onCancel}>
           Cancel
         </button>
         <button type="button" onClick={onNavigateToSettings}>
           <SettingsIcon />
         </button>
       </ButtonWrapper>
     </form>
   );
 };
 
 export default ProfileEditForm;

해당 리팩토링을 통해

  • 가독성: 각 컴포넌트의 역할이 명확해졌습니다. 이제 각 컴포넌트는 특정 기능만을 담당하므로 코드를 읽기 쉽습니다.
  • 유지보수성: 각 컴포넌트가 독립적이므로 수정할 때 다른 부분에 미치는 영향을 최소화할 수 있습니다.
  • 재사용성: 독립된 컴포넌트를 다른 곳에서도 재사용할 수 있습니다. 예를 들어, ProfileImage 컴포넌트는 프로필 이미지가 필요한 다른 페이지에서도 사용할 수 있습니다.
  • 테스트 용이성: 작은 단위의 컴포넌트는 테스트하기가 더 쉽습니다. 각 컴포넌트를 개별적으로 테스트할 수 있어 버그를 빨리 발견하고 수정할 수 있습니다.

다음과 같은 이점을 기대해 볼 수 있습니다.


다음으로 PostWritePage 컴포넌트를 리팩토링 하였다.

1. 원본 코드 분석

먼저, 리팩토링 전의 PostWritePage 컴포넌트를 살펴보았다.

import React, { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import styled from 'styled-components';
import TextField from '@mui/material/TextField';
import Editor from '../components/Editor';
import CategorySelect from '../components/write/CategorySelect';
import TagAutocomplete from '../components/write/TagAutocomplete';
import ActionButtons from '../components/write/ActionButtons';
import { createNewArticle, deleteArticle, getPost, updateArticle } from '../services/post';
import Navbar from '../components/NavBar.tsx';
import { useNavigate, useParams } from 'react-router-dom';
import { NotificationModal } from '../components/modal/NotificationModal.tsx';

interface PostData {
  title: string;
  tags: string[];
  content: string;
}

function PostWritePage() {
  const [category, setCategory] = useState<'question' | 'free'>('question');
  const [title, setTitle] = useState('');
  const [tagList, setTagList] = useState<string[]>([]);
  const [content, setContent] = useState('');

  const [isModalVisible, setModalVisible] = useState(false);
  const [modalMessage, setModalMessage] = useState('');

  const navigate = useNavigate();

  const { postId } = useParams();
  const [editData, setEditData ] = useState<PostData>();
  const [editVisible, setEditVisible] = useState(false);

  // postId 데이터 있으면 한 번 실행
  // edit 로 들어오는 경우
  useEffect(() => {
    if (postId) {
      // 게시글 데이터 호출
      (async () => {
        const response = await getPost(Number(postId));
        console.log(response);
        if (response && response.data) {
          setEditData(response.data);
        }
      })();
    }
  }, [postId]);

  // 글 수정 데이터 있으면 수정 에디터 폼으로 변경
  useEffect(() => {
    if (editData) {
      setCategory('question');
      setTitle(editData.title);
      setTagList(editData.tags);
      setContent(editData.content);
      setEditVisible(true);
    }
  }, [editData]);

  // 게시글 생성
  const { mutate: createMutate } = useMutation({
    mutationFn: createNewArticle,
    onSuccess: () => {
      console.log('성공!');
      navigate("/board/question");
    },
    onError: () => {
      setModalMessage('서버 통신 실패');
      setModalVisible(true);
    }
  });

  // 게시글 수정
  const { mutate : updateMutate } = useMutation({
    mutationFn: (
      data: {
        postId: number,
        updateArticle: {
          category: string | undefined;
          title: string;
          tagList: string[];
          contentText: string;
        }
      }) => updateArticle(data.postId, data.updateArticle),
    onSuccess: () => {
      console.log('성공!');
      navigate("/board/question");
    },
    onError: () => {
      setModalMessage('서버 통신 실패');
      setModalVisible(true);
    }
  });

  // 게시글 삭제
  const { mutate : deleteMutate } = useMutation({
    mutationFn: deleteArticle,
    onSuccess: () => {
      console.log('성공!');
      navigate("/board/question");
    },
    onError: () => {
      setModalMessage('서버 통신 실패');
      setModalVisible(true);
    }
  });

  const handleCategoryChange = (category: 'question' | 'free') => {
    setCategory(category);
  };

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value);
  };

  const handleTagListChange = (tagList: string[]) => {
    setTagList(tagList);
  };

  // 취소
  const handelCancel = () => {
    navigate("/board/question");
  }

  // 임시저장
  const handleSave = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  };

  // 저장
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    createMutate({ category: category, title: title, tagList:tagList, contentText: JSON.stringify(content)});
  };
  
  // 수정
  const handleUpdate = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const updateData = {
      category: category,
      title: title,
      tagList:tagList,
      contentText: JSON.stringify(content)
    }
    updateMutate({ postId: Number(postId), updateArticle: updateData });
  };

  // 삭제
  const handleDelete = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    deleteMutate(Number(postId))
  }

  // 모달창 Close 버튼 클릭시 동작 함수
  const handleCloseModal = () => {
    setModalVisible(false);
  };

  return (
    <Container>
      <Navbar/>
      <Form onSubmit={handleSubmit}>
        <Header>
          <CategorySelect category={category} onCategoryChange={handleCategoryChange} />
          <TextField fullWidth placeholder="제목" value={title} onChange={handleTitleChange} />
          <TagAutocomplete tagList={tagList} onTagListChange={handleTagListChange} />
        </Header>
        <Editor content={content} setContent={setContent} />
        <ActionButtons onCancel={handelCancel} onSave={handleSave} onSubmit={handleSubmit} onUpdate={handleUpdate} onDelete={handleDelete} editVisible={editVisible} />
      </Form>
      <NotificationModal
        message={modalMessage}
        isVisible={isModalVisible}
        onClose={handleCloseModal}
      />
    </Container>
  );
}

export default PostWritePage;

const Container = styled.div`
  padding-bottom: 100px;
`;

const Form = styled.form`
  max-width: 856px;
  margin: 0 auto;
  padding-top: 100px;
`;

const Header = styled.header`
  margin-bottom: 10px;
`;

2. 리팩토링 코드

해당 리팩토링을 통해 코드의 구조가 더욱 명확해지고, 각 컴포넌트의 책임이 분명해졌습니다. 특히 PostForm 컴포넌트의 분리로 인해 향후 다른 페이지에서도 이 폼을 재사용할 수 있게 되었습니다. 또한, 커스텀 훅의 사용으로 상태 관리 로직의 재사용성도 높아졌습니다.

1. 관심사의 분리

가장 큰 변화는 PostForm컴포넌트를 별도로 분리한 것입니다. 이전에는 PostWritePage 컴포넌트가 폼 로직과 상태 관리를 모두 담당했지만, 이제는 PostForm이 이 역할을 맡게 되었습니다. 이로써 PostWritePage는 레이아웃과 전체적인 구조에만 집중할 수 있게 되었습니다.

PostWritePage

 import { useParams } from 'react-router-dom';
 import styled from 'styled-components';
 
 import Navbar from '../components/header/NavBar.tsx';
 import { NotificationToast } from '../components/modal/NotificationToast.tsx';
 import { useToastControl } from '../hooks/useToastControl.ts';
 import PostForm from '../components/write/PostForm/index.tsx';
 
 function PostWritePage() {
   const { hideToast } = useToastControl();
   const { postId } = useParams();
 
   return (
     <Container>
       <Navbar />
       <PostForm postId={Number(postId)} />
       <NotificationToast onClose={hideToast} />
     </Container>
   );
 }
 
 export default PostWritePage;
 
 const Container = styled.div`
   padding-bottom: 100px;
 `;

PostForm

 import { useState, useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { TextField } from '@mui/material';
 import styled from 'styled-components';
 
 import Editor from '../../Editor';
 import ActionButtons from '../ActionButtons';
 import CategorySelect from '../CategorySelect';
 import TagAutocomplete from '../TagAutocomplete';
 import { useDeleteArticleMutation } from '../../../hooks/api/useDeleteArticleMutation';
 import { usePostArticleMutation } from '../../../hooks/api/usePostArticleMutation';
 import { usePutArticleMutation } from '../../../hooks/api/usePutArticleMutation';
 import { CategoriesStatus } from '../../../types/filter';
 import { FILTERS_CATEGORIES_LIST } from '../../../constants/filters';
 import { getPost } from '../../../services/post/getPost';
 
 interface PostFormProps {
   postId?: number;
 }
 
 const PostForm = ({ postId }: PostFormProps) => {
   const navigate = useNavigate();
   const [category, setCategory] = useState<CategoriesStatus>(FILTERS_CATEGORIES_LIST.QUESTION);
   const [title, setTitle] = useState<string>('');
   const [tagList, setTagList] = useState<string[]>([]);
   const [content, setContent] = useState<string>('');
   const [editVisible, setEditVisible] = useState(false);
 
   const { createMutate } = usePostArticleMutation();
   const { updateMutate } = usePutArticleMutation();
   const { deleteMutate } = useDeleteArticleMutation();
 
   const handleCategoryChange = (category: CategoriesStatus) => {
     setCategory(category);
   };
 
   const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     setTitle(e.target.value);
   };
 
   const handleTagListChange = (tagList: string[]) => {
     setTagList(tagList);
   };
 
   // 취소
   const handelCancel = () => {
     navigate(-1);
   };
 
   // 임시저장
   const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
   };
 
   // 저장
   const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
     const categoryId = category === FILTERS_CATEGORIES_LIST.FREE ? 1 : 2;
     createMutate({ categoryId: categoryId, title: title, tags: tagList, contentText: content });
   };
 
   // 수정
   const handleUpdate = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
     const categoryId = category === FILTERS_CATEGORIES_LIST.FREE ? 1 : 2;
     const updateData = {
       categoryId: categoryId,
       title: title,
       tags: tagList,
       contentText: JSON.stringify(content),
     };
     updateMutate({ postId: Number(postId), updateArticle: updateData });
     console.log('수정');
     navigate(-1);
   };
 
   // 삭제
   const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
     deleteMutate(Number(postId));
   };
 
   // postId 데이터 있으면 한 번 실행
   // edit 로 들어오는 경우
   useEffect(() => {
     if (postId) {
       // 게시글 데이터 호출
       (async () => {
         const response = await getPost(Number(postId));
         console.log(response);
         if (response && response.data) {
           setTitle(response.data.title);
           setTagList(response.data.tags);
           setContent(response.data.content);
           setEditVisible(true);
         }
       })();
     }
   }, [postId]);
 
   return (
     <Form onSubmit={handleSubmit}>
       <Header>
         <CategorySelect category={category} onCategoryChange={handleCategoryChange} />
         <TextField fullWidth placeholder="제목" value={title} onChange={handleTitleChange} />
         <TagAutocomplete tagList={tagList} onTagListChange={handleTagListChange} />
       </Header>
       <Editor content={content} setContent={setContent} />
       <ActionButtons
         onCancel={handelCancel}
         onSave={handleSave}
         onUpdate={handleUpdate}
         onDelete={handleDelete}
         editVisible={editVisible}
       />
     </Form>
   );
 };
 
 export default PostForm;
 
 const Form = styled.form`
   max-width: 856px;
   margin: 0 auto;
   padding-top: 100px;
 `;
 
 const Header = styled.header`
   margin-bottom: 10px;
 `;

2. 커스텀 훅 활용

useToastControl이라는 커스텀 훅을 도입하여 토스트 알림 로직을 분리했습니다. 이는 상태 관리 로직을 재사용 가능한 형태로 추상화한 좋은 예시입니다.

3. prop drilling 최소화

postId를 URL 파라미터에서 직접 추출하여 PostForm에 전달함으로써, 불필요한 prop drilling을 방지했습니다.

4. 성능 최적화

불필요한 리렌더링을 줄이기 위해 상태 관리 로직을 PostForm으로 이동시켰습니다. 이로 인해 PostWritePage는 거의 정적인 컴포넌트가 되어 리렌더링 빈도가 줄어들었습니다.


후기

리팩토링을 진행하면서 해당 프론트엔드 개발자가 어떤 생각을 가지고 컴포넌트를 설계하였는 지에 대해 다시 생각해 볼 수 있었던 시간이었습니다.

코드를 처음 확인하는 개발자도 이 컴포넌트가 어떤 동작을 하는 컴포넌트인지 쉽게 확인할 수 있도록 설계와 소통의 중요성에 대해서 더 많이 알게 된 경험이었습니다.

프로젝트 깃허브 주소

profile
누군가에게 도움이 될 정보이기를

0개의 댓글