React Container & Presenter 패턴이란?

이수빈·2023년 6월 3일
1

펫모리플젝

목록 보기
3/9
  • 펫모리 프로젝트에서 와이어 프레임을 받았을 때 이 와이어프레임을 효율적으로 구현 할 수 있는 디자인패턴이 무엇일까 고민해보았다.

  • 2가지가 떠올랐다. 모든것을 원자단위로 쪼개서 UI 컴포넌트의 재사용성을 높히는 atomic 디자인 패턴과, UI로직과 비즈니스로직을 수행하는 컴포넌트를 분리해 Presenter 단위로 재사용성을 높히는 Container & Presenter 패턴이다.

  • 화면단위의 Presenter 재사용이 많았기 때문에,나는 Container & Presenter 패턴을 선정하고, 공통적으로 사용하는 UI Component 만 따로 분리하여 개발을 진행하였다.

Container & Presenter 패턴 선정이유

  1. View로직과 Business로직의 분리
  • 코드의 통일성 관점에서 UI로직과 Business로직을 분리하는 것은 중요하다. Container & Presenter 디자인패턴에서는 view로직을 Presenter가, Business로직을 Container가 담당하기 때문에 유지보수가 쉽다.

  • 다른사람이 코드를 보더라도 View로직을 수정할때는 Presenter, Business로직을 수정할때는 Container에서 코드를 수정하면 되기 때문이다.

  1. Presetener의 재사용
  • 공유앨범과 나의앨범 페이지는 MemoryPage에서 중첩 라우팅 구조를 통해 동일한 Album Presenter를 갖는다.

  • 비즈니스 로직을 분리함으로써, 동일한 Presenter를 재사용 할 수 있다는 장점이 있었다.

Container & Presenter 구현방법

  • 구현방법은 간단하다. Presenter는 Container부터 prop을 통해 View로직을 담당하고, Container는 data fetching이나 이벤트 핸들러와 같은 함수들을 prop을 통해 Presenter에 내려주면 된다.

  • 먼저 모든 페이지에서 사용하는 공통 Layout Container를 정의하였다. 이는 다른 Container를 Children으로 받아, Footer와 Navbar를 넣어주는 Container이다.

//LayoutContainer.tsx

import styled from 'styled-components';
import NavBar from './Navbar';
import MainPageNavbar from './MainPageNavbar';
import Footer from './Footer';

const PageContainer = styled.section``;

type LayoutProps = {
  children: React.ReactElement;
  isMain?: boolean;
};

const LayoutContainer = ({ children, isMain }: LayoutProps) => {
  return (
    <PageContainer>
      {isMain ? <MainPageNavbar /> : <NavBar />}
      {children}
      <Footer />
    </PageContainer>
  );
};

export default LayoutContainer;
  • 다음으로 Memory Page의 중첩라우팅 구조와 Container를 정의했다. MemoryContainer는 path에 따라 Outlet을 통해 subPage Container를 보여주는 역할을 한다.
//Router.tsx


const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Main />} />
        <Route path="/:username" element={<Main />} />
        <Route path="/login" element={<Login />} />
        <Route path="/memory" element={<MemoryPage />}>
          <Route path="myAlbum" element={<MyAlbumContainer />} />
          <Route path="question" element={<QuestionAlbumContainer />} />
          <Route path="sharedAlbum" element={<SharedAlbumContainer />} />
          <Route path="*" element={<NotFound />} />
        </Route>
        <Route path="/memory/myAlbum/:id" element={<MemoryDetail />} />
        <Route path="/memory/sharedAlbum/:id" element={<MemoryDetail />} />
        <Route path="/writeAlbum" element={<WriteAlbum />} />
        <Route path="/writeAlbum/:id" element={<WriteAlbum />} />
        <Route path="/mypage" element={<MyPage />} />
        <Route path="/ReviseInfo" element={<ReviseInfo />} />
        <Route path="/funeral" element={<FuneralPage />} />
        <Route path="/unauthorized" element={<UnAuthorized />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
};

export default Router;
// MemoryContainer.tsx

import { Outlet, useLocation } from 'react-router-dom';
..

const MemoryWrapper = styled.section`
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-bottom: 70px;
`;

const MemoryContainer = () => {
  const [currentPath, setCurrentPath] = useState<string>('');
  const location = useLocation();
  const { pathname } = location;

  useEffect(() => {
    setCurrentPath(pathname);
  }, [location]);

  return (
    <MemoryWrapper>
      {currentPath !== '/memory/question' ? (
        <Banner url={'/img/AlbumBanner.jpg'} />
      ) : (
        <Banner url={'/img/QuestionBanner.jpg'} />
      )}
      <MemoryNav />
      {currentPath !== '/memory/question' && (
        <EmotionTags width="80vw" isMargin={true} fontSize={16} temp={temp} />
      )}
      <Outlet />
    </MemoryWrapper>
  );
};

export default MemoryContainer;
  • Outlet을 통해 공유앨범페이지로 이동하면 SharedAlubmContainer를 mount한다.

  • SharedAlbum Container는 data Fetching과 관련된 작업들과 이벤트핸들러를 공용으로 사용하는 AlbumPresenter와 AlbumButton에 넘겨주는 역할을 한다.

  • 이런식으로 Container가 모든 비즈니스 로직을 담당하기 때문에 UI Component와 Presenter의 재사용이 쉬워지는 장점이 있었다.

//SharedAlbumContainer

const SharedAlbumContainer = () => {
  const [isLoading, setLoading] = useState(true);
  const [albumData, setAlbumData] = useState<AlbumContent[]>([]);
  const sortOption = useRecoilValue(sortOptionAtom);
  const activeTags = useRecoilValue(activeTagAtom);
  const [page, setPage] = useState<number>(0);
  const [hasNext, setHasNext] = useState<boolean>(false);

  const fetchSharedAlbum = async (page: number, sortOption: string, activeTags: string[]) => {
    const data = await getSharedAlbum({ page, sortOption, activeTags });
    setLoading(false);
    setAlbumData([...albumData, ...data.content]);
    setHasNext(data.hasNext);
    setPage(page + 1);
  };

  const refetchAlbum = async (page: number, sortOption: string, activeTags: string[]) => {
    const data = await getSharedAlbum({ page, sortOption, activeTags });
    setAlbumData(data.content);
    setHasNext(data.hasNext);
    setPage(data.page + 1);
  };

  const handleFetchAlbum = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    if (!hasNext) {
      alert('더 이상 앨범이 없습니다.');
      return;
    }
    fetchSharedAlbum(page, sortOption, activeTags);
  };

  useEffect(() => {
    fetchSharedAlbum(page, sortOption, activeTags);
  }, []);

  useEffect(() => {
    refetchAlbum(0, sortOption, activeTags);
  }, [sortOption, activeTags]);

  if (isLoading) {
    return <Spinner />;
  }

  return (
    <FlexContainer>
      <AlbumPresenter albumData={albumData} />
      <AlbumButton text="공유 앨범 더보기" handleClick={handleFetchAlbum} />
    </FlexContainer>
  );
};

export default SharedAlbumContainer;
  • api 호출을 관련하는 코드같은경우 공용 api에서 Intercepter를 이용해 refresh 토큰관련 재발급이나 토근이 있는지 확인하는 로직을 공통적으로 처리하였고, page에서 api 호출을 담당하는 로직만을 분리하였다.
// SharedAlbumApi.ts
import * as API from '../../../api/APINotLogin';

type getAlbumParmas = {
  page: number;
  size?: number;
  sortOption: string;
  activeTags: string[];
};

export const getSharedAlbum = async ({
  page = 0,
  size = 12,
  sortOption,
  activeTags,
}: getAlbumParmas) => {
  const activeTagsStr = activeTags.join(',');
  return await API.get(
    `/album?page=${page}&size=${size}&emotionTagList=${activeTagsStr}&sortType=${sortOption}`,
  );
};
  • 마지막으로 Container에서 처리된 비즈니스 로직을 Presenter와 공용 컴포넌트에서 Prop을 통해 받아 UI를 그린다.
//AlbumPresenter.tsx

import { Link } from 'react-router-dom';
import { AlbumContainer } from './style/AlbumPresenterStyle';
import type { AlbumContent } from '../../../type/AlbumType';

type AlbumPresenterProps = {
  albumData: AlbumContent[];
};

const AlbumPresenter = ({ albumData }: AlbumPresenterProps) => {
  return (
    <AlbumContainer>
      {albumData.map((item) => (
        <Link to={`${item.albumId}`} className="imgLink" key={item.albumId}>
          <figure>
            <img src={item.imageUrl} loading="lazy" alt={item.title} />
            <div className="figBox">
              <figcaption>
                <div className="figItemBox">
                  <img src="/img/HeartDog.svg" alt="HeartDog" className="figItemImg" />
                  <div>{item.empathyCount}</div>
                </div>
                <div className="figItemBox">
                  <img src="/img/Comment.svg" alt="Comment" className="figItemImg" />
                  <div>{item.commentCount}</div>
                </div>
              </figcaption>
            </div>
          </figure>
        </Link>
      ))}
    </AlbumContainer>
  );
};

export default AlbumPresenter;
// AlbumButton.ts
import { MouseEventHandler } from 'react';
import { IconButton } from '../../../components/common/CommonStyle';

type AlbumButtonProps = {
  text: string;
  handleClick?: MouseEventHandler<HTMLButtonElement>;
};

const AlbumButton = ({ text, handleClick }: AlbumButtonProps) => {
  return (
    <IconButton width="12vw" height="41px" minWidth="130px" maxWidth="171px" onClick={handleClick}>
      <div style={{ marginLeft: '15px' }}>{text}</div>
      <img src="/img/arrow.svg" alt="arrow" />
    </IconButton>
  );
};

export default AlbumButton;

Container & Presenter의 단점?

  • 개발을 진행하다 보니 Container & Presenter의 단점 또한 존재했다.

  • 비즈니스 로직의 처리를 하나의 Container에서 담당하다보니, Container가 담당하는 로직이 많은 경우 하나의 Container에서 코드양이 많아지는 경우도 발생하였다.

  • 이는 조금 더 관심사를 분리하는 과정을 통해 해결 할 수 있다고 생각한다.

  • 예를들어 modal을 띄우는 로직같은경우 modal을 보이게 하는 로직과 이벤트핸들러 같은 코드들을 custom hook을 통해 만드는 과정을 통해 코드를 분리 할 수 있을 것이다.

  • 이와 관련된 부분은 추후 프로젝트 코드 리팩토링 이후 작성하도록 하겠다.

profile
응애 나 애기 개발자

0개의 댓글