펫모리 프로젝트에서 와이어 프레임을 받았을 때 이 와이어프레임을 효율적으로 구현 할 수 있는 디자인패턴이 무엇일까 고민해보았다.
2가지가 떠올랐다. 모든것을 원자단위로 쪼개서 UI 컴포넌트의 재사용성을 높히는 atomic 디자인 패턴과, UI로직과 비즈니스로직을 수행하는 컴포넌트를 분리해 Presenter 단위로 재사용성을 높히는 Container & Presenter 패턴이다.
화면단위의 Presenter 재사용이 많았기 때문에,나는 Container & Presenter 패턴을 선정하고, 공통적으로 사용하는 UI Component 만 따로 분리하여 개발을 진행하였다.
코드의 통일성 관점에서 UI로직과 Business로직을 분리하는 것은 중요하다. Container & Presenter 디자인패턴에서는 view로직을 Presenter가, Business로직을 Container가 담당하기 때문에 유지보수가 쉽다.
다른사람이 코드를 보더라도 View로직을 수정할때는 Presenter, Business로직을 수정할때는 Container에서 코드를 수정하면 되기 때문이다.
공유앨범과 나의앨범 페이지는 MemoryPage에서 중첩 라우팅 구조를 통해 동일한 Album Presenter를 갖는다.
비즈니스 로직을 분리함으로써, 동일한 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;
//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;
// 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}`,
);
};
//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에서 담당하다보니, Container가 담당하는 로직이 많은 경우 하나의 Container에서 코드양이 많아지는 경우도 발생하였다.
이는 조금 더 관심사를 분리하는 과정을 통해 해결 할 수 있다고 생각한다.
예를들어 modal을 띄우는 로직같은경우 modal을 보이게 하는 로직과 이벤트핸들러 같은 코드들을 custom hook을 통해 만드는 과정을 통해 코드를 분리 할 수 있을 것이다.
이와 관련된 부분은 추후 프로젝트 코드 리팩토링 이후 작성하도록 하겠다.