프론트엔드 개발자가 리액트를 활용해서 컴포넌트를 만들 때 고려하는 것들이 있다. 재사용성이 좋은가, 쉽게 유지보수 가능한가, 충분히 빠르게 동작하는가 등이 있다. 이번에는 재사용성, 유지보수, 코드 가독성과 같은 개발자 생산성 관점에서 어떻게 리액트 컴포넌트를 만드는 게 좋을지 배워보도록 한다.
리액트르 막 배운 경우에는 쉽게 하나의 페이지 컴포넌트로 만들 수 있다.
# 폴더 구조
pages
FolderPage.jsx
styles
FolderPage.css
// pages/FolderPage.jsx
import 'styles/FolderPage.css'
...
import axios from 'axios'
const FolderPage = () => {
const [
linkValue,
setLinkValue,
] = useState("");
const [
searchValue,
setSearchValue,
] = useState("");
const [
user,
setUser,
] = useState({ profileImage: "", email: "" });
const [
folders,
setFolders,
] = useState([]);
const [
links,
setLinks,
] = useState([]);
const handleLinkValueChange = (event) => setLinkValue(event.target.value);
const handleSearchValueChange = (event) => setSearchValue(event.target.value);
const handleAddLinkClick = () => axios.post("/links", { link: linkValue });
const handleAddFolderClick = () => openFolderModal();
const handleShareClick = () => openShareModal();
const handleRenameClick = () => openRenameModal();
const handleDeleteClick = () => openDeleteModal();
useEffect(() => {
const fetchUser = async () => {
const response = await axios.get("/user");
const { data } = await response.json();
setUser(data);
};
const fetchFolders = async () => {
const response = await axios.get("/folders");
const { data } = await response.json();
setFolders(data);
};
const fetchLinks = async () => {
const response = await axios.get(`/links`);
const { data } = await response.json();
setLinks(data);
};
fetchUser();
fetchFolders();
fetchLinks();
}, []);
return (
<div className="page">
<header>
<nav>
<img src="logo.png" />
<div className="profile">
<img src={user.profileImage} />
<span>{user.email}</span>
</div>
</nav>
<div className="addLinkBox">
<div className="addLink">
<img src="link.png" />
<input
className="addLinkInput"
placeholder="링크를 추가해 보세요"
value={linkValue}
onChange={handleLinkValueChange}
/>
<button
className="addLinkButton"
onClick={handleAddLinkClick}
>
<span>추가하기</span>
</button>
</div>
</div>
</header>
<div className="search">
<img src="search.png" />
<input
className="searchInput"
placeholder="제목을 검색해 보세요"
value={searchValue}
onChange={handleSearchValueChange}
/>
</div>
<div className="contentBox">
<div className="folderList">
{folders.map((folder) => (
<Link
key={folder.id}
className={`folderItem ${folder.id === id ? "selected" : ""}`}
href={`/folder/${folder.id}`}
>
<span>{folder.name}</span>
</Link>
))}
</div>
<button className="addFolderButton" onClick={handleAddFolderClick}>
폴더 추가
<img src="plus.png" />
</button>
<h2>{folders.find((folder) => folder.id === id)?.name}</h2>
<button className="shareButton" onClick={handleShareClick}>
<img src="share.png" />
공유
</button>
<button className="renameButton" onClick={handleRenameClick}>
<img src="pencil.png" />
이름 변경
</button>
<button className="deleteButton" onClick={handleDeleteClick}>
<img src="trashcan.png" />
삭제
</button>
{links.map((link) => (
<Link key={link.id} href={link.url}>
<div className="linkItem">
<img src={link.imageSource} />
{/* 카드에 들어갈 내용 ... */}
</div>
</Link>
))}
</div>
<footer>
{/* 푸터에 들어갈 내용 ... */}
</footer>
</div>
);
};
export default FolderPage;
위 코드의 경우 코드 양이 매우 많이 들어간다. 이로 인해 해당 코드가 어떤 기능을 하는지 빠르게 파악하기 어렵다.
앞의 페이지 작업이 끝난 뒤 /shared 페이지도 만들어 달라는 요청이 있다고 가정해보자.
/folder 페이지와 동일하게 사용하는 컴포넌트가 많이 보인다. 이미 만들었던 코드를 다시 활용하기 위해 크게 두 가지 선택지에 놓이게 된다.
/folder 페이지에서 반복되는 부분을 복사해서 /shared 페이지로 붙여 넣기/folder 페이지에서 컴포넌트를 잘 분리해서 새로운 컴포넌트를 만들기1번의 선택은 두 페이지에서 공통적으로 사용하는 영역에 변경이 있는 경우, 변경해야 하는 부분을 찾고 수정하는 작업을 두 번 반복해야 한다. 또한 둘 중 하나의 페이지에는 수정을 누락할 위험도 높다.
2번 선택지로 결정하고 분리를 위해 /folder 페이지에서 재사용 가능한 컴포넌트를 분리해서 새로운 컴포넌트(nav 컴포넌트, 링크 검색 컴포넌트, 링크 카드 리스트 등)를 만든다. /folder 페이지에서 분리한 새로운 컴포넌트를 사용하도록 수정하고 /shared 페이지에는 분리한 새로운 컴포넌트를 사용한다.
2번 선택지의 수정 및 추가 작업을 하면서 다음과 같은 문제를 느껴야 한다. /shared 페이지 만드는 작업을 해야 하는데, 왜 /folder 페이지를 수정하고 있는가?
살펴본 예시와 같이 페이지 전체를 하나의 컴포넌트로 만들어서 발생하는 문제가 아니라도, 개발자가 모든 경우의 수를 미리 예측하고 컴포넌트를 분리할 수는 없기 때문에 유사한 상황을 종종 마주하게 된다.
관심사의 분리란 컴퓨터 프로그램을 관심사 별로 구별해서 분리하는 설계 원칙이다.
분리를 하려면 경계를 세워야 하는데 경계란 주어진 책임을 설명하는 논리적이거나 물리적인 제한을 의미한다. 소스 구성에 대한 프로젝트, 폴더 구조를 포함하기도 한다.
관심사 분리의 목표는 시스템을 분리되지 않는 파트들로 조각내는 것이 아니라 시스템을 반복하지 않고 각각의 책임을 가진 요소들로 구성하는 것에 있다. 앞으로 책임이란 단어를 자주보게 되는데 책임은 특정 코드가 수행해야 하는 동작이라고 생각하면 된다. 관심사의 분리를 잘하면 아래와 같은 효과를 얻을 수 있다.
수평적인 관심사는 애플리케이션 내에 동일한 역항르 수행하는 기능의 논리적 단위로 분리한다. 마치 건물의 층이 나누어져 있는 모습과 유사하고, 크게 아래와 같이 세 개의 레이어로 나누어 볼 수 있다.
이렇게 레이어를 분리하면 UI의 스타일에 문제가 있을 때 Presentation Layer만 보면 되고, 요청한 데이터를 받지 못하는 문제가 있으면 Resource Access Layer만 보면 문제를 확인할 수 있다. 또한 비즈니스의 정책 변경이 있다면 해당하는 Business Layer만 찾아서 수정하면 된다. 이처럼 수평적인 분리를 통해 문제를 빠르게 발견해 고칠 수 있고, 변경이 필요한 경우 빠르고 정확하게 반영할 수 있어 유지보수가 쉬워진다. 그리고 반복해서 필요한 UI, 비즈니스 규칙, 데이터 요청 등을 재사용하기 쉬워진다.
수직적인 관심사는 애플리케이션의 동일한 도메인(비즈니스 관심사)을 모듈로 묶어서 분리한다.
이러한 분리는 각 도메인의 책임을 분명하게 해 준다. 또한 서로 다른 개발조직이 각각의 모듈에 집중할 수 있어 관리하기 편하게 해 준다.
예시로 보았던 /folder 페이지 수직적 관심사를 살펴보면 아래와 같이 나누어 볼 수 있다.
user
folder
link
nav
footer
지금까지 수평적 분리와 수직적 분리를 살펴보았는데, 둘 중 하나를 선택해야 할 필요는 없고, 필요에 따라 이 둘을 함께 적용할 수도 있다.
Presentational and Container(또는 Smart and Dumb) 패턴을 활용해 Presentational Layer를 분리할 수 있다. 해당 패턴에 대해 간략히 알아보면, Presentational 컴포넌트는 사용자가 보고, 조작하는 UI 컴포넌트이다. UI만을 위한 상태를 제외하고는 상태를 가지지 않고 Container 컴포넌트가 내려준 props를 통해 조작된다. Container 컴포넌트는 데이터를 받거나 비즈니스 로직을 설정할 수 있고, UI 컴포넌트를 포함할 수 있고, UI 컴포넌트에 props를 전달해 UI를 조작한다.
Container 컴포넌트에 데이터를 받아오는 부분이 있는 경우가 많은데, 이는 분리할 수 있다. 이때 리액트의 커스텀 훅을 활용하면, 데이터 접근 로직만 분리해서 재사용도 가능하다.
유틸리티 함수란 계산과 처리를 대신하는 일반 함수를 말한다. UI 컴포넌트, 데이터 접근을 위한 훅, 비즈니스 로직 등을 만들다보면 유틸리티 성격의 함수들을 만들어 사용하게 되는ㄷ 이런 함수들도 분리할 수 있다. 분리하게 되면 재사용할 수도 있고, 분리된 환경에서 유틸리티 함수만 테스트하기도 쉬워진다. 지금까지 크게 네 가지로 분리해 봤고, 분리한 관심사가 잘 드러나도록 이름을 붙인다.
위 관심사들을 분리했지만, 의존성을 아무렇게나 가져가면 분리한 의미가 없어지게 된다.
ui 컴포넌트에서 특정 data-access 훅을 import해서 사용한다면 ui 컴포넌트는 data-access 훅에 의존하는 컴포넌트가 된다. 이렇게 되면 ui 컴포넌트에 다른 data-access 사용이 필요한 경우 사용할 수 없게 된다. ui 컴포펀트가 다른 ui 컴포넌트를 의존하는 경우에는, 그 자체로 새로운 ui 컴포넌트가 되고 다른 도메인에서 사용하는데 문제될 것이 없다. data-access를 의존하면 안 되는 같은 이유로 ui 컴포넌트는 feature 컴포넌트를 의존해서 안된다.
feature 컴포넌트에서는 data-access를 활용해서 데이터를 가져오고 이를 활용해 ui 컴포넌트의 props로 전달해야 한다. 또한, feature 컴포넌트는 다른 feature 컴포넌트를 사용할 수도 있다.
이러한 관계들을 반영해 아래와 같이 의존성을 정리해 볼 수 있다.
수직적 분리는 서비스에 따라 다른데, 비즈니스 도메인에 따라 나누면 좋다. 앞서 살펴본 예시의 경우 user, folder, link로 나눠볼 수 있고, nav, footer의 경우 여러 페이지에서 공유하는 사용이 필요해 sharing이라는 관심사로 묶어볼 수 있다.
# 폴더 구조
page-layout
FolderLayout
FolderLayout.jsx
FolderLayout.module.css
SharedLayout
SharedLayout.jsx
SharedLayout.module.css
pages
FolderPage.jsx
SharedPage.jsx
src
user
data-access-user
useCurrentUser.js
useGetUser.js
ui-profile-badge
ProfileBadge.jsx
ProfileBadge.module.css
folder
data-access-folder
useAddFolder.js
useDeleteFolder.js
useGetFolders.js
useRenameFolder.js
ui-folder-info
FolderInfo.jsx
feature-folder-tool-bar
FolderToolBar.jsx
FolderToolBar.module.css
ui-folder-button
FolderButton.jsx
FolderButton.module.css
ui-folder-list
FolderList.jsx
FolderList.module.css
ui-icon-and-text-button
IconAndTextButton.jsx
IconAndTextButton.module.css
link
data-access-link
useAddLink.js
useGetLinks.js
feature-link-form
LinkForm.jsx
feature-link-search
LinkSearch.jsx
useLinkSearch.js
ui-link-form
LinkForm.jsx
LinkForm.module.css
ui-card-list
CardList.jsx
CardList.module.css
ui-card
Card.jsx
Card.module.css
ui-search-bar
SearchBar.jsx
SearchBar.module.css
util-map
mapLinks.js
util-filter
filterLinkSearch.js
sharing
footer
ui-footer
Footer.jsx
nav
feature-nav
Nav.jsx
ui-top-nav
TopNav.jsx
TopNav.module.css
ui-side-nav
SideNav.jsx
SideNav.module.css
modal
feature-modal
Modal.jsx
useModal.js
ui-modal
Modal.jsx
Modal.module.css
ui
ui-cta-button
CtaButton.jsx
CtaButton.module.css
...
util
...
...
// pages/FolderPage.jsx
import { FolderLayout } from 'layouts/FolderLayout'
import { Nav } from 'src/sharing/feature-nav'
...
import { Footer } from 'src/sharing/ui-footer'
const FolderPage = () => {
const { links } = useGetLinks(folderId);
const { searchInput, setSearchInput, filteredLinks } = useLinkSearch(links);
const handleSearchInputChange = (event) => setSearchInput(event.target.searchInput);
return (
<FolderLayout
nav={<Nav />}
addLink={<AddLink />}
linkSearch={
<LinkSearch
value={searchInput}
onChange={handleSearchInputChange}
/>
}
folderToolBar={<FolderToolBar />}
cardList={<CardList links={filteredLinks} />}
footer={<Footer />}
/>
)
};
export default FolderPage;
폴더 레이아웃 컴포넌트를 사용해서 props로 사용할 컴포넌트를 설정한다.
// page-layout/FolderLayout.jsx
export const FolderLayout = ({
nav,
addLink,
linkSearch,
folderToolBar,
cardList,
footer
}) => {
return (
<div className="folderPage">
<div className="nav">{nav}</div>
<div className="addLink">{addLink}</div>
<div className="linkSearch">{linkSearch}</div>
<div className="folderToolBar">{folderToolBar}</div>
<div className="cardList">{cardList}</div>
<div className="footer">{footer}</div>
</div>
);
};
폴더 레이아웃 컴포넌트는 폴더 페이지에 여러 컴포넌트들의 배치 스타일링을 담당한다.
// src/link/ui-card-list/CardList.jsx
export const CardList = ({ links }) => {
return (
<div className="cardList">
{links.map((link) => <LinkItem key={link.id} {...link} />)
</div>
);
};
links 데이터를 props로 받아 LinkItem들을 보여준다.
// pages/SharedPage.jsx
import { SharingLayout } from 'layouts/sharing/SharingLayout'
import { Nav } from 'src/shared/nav/feature-nav'
...
import { Footer } from 'src/shared/footer/feature-footer'
const FolderPage = () => {
const { links } = useGetLinks(id);
const { searchInput, setSearchInput, filteredLinks } = useLinkSearch(links);
const handleSearchInputChange = (event) => setSearchInput(event.target.searchInput);
return (
<FolderLayout
nav={<Nav />}
folderInfo={<FolderInfo />}
linkSearch={
<LinkSearch
value={searchInput}
onChange={handleSearchInputChange}
/>
}
cardList={<cardList links={filteredLinks} />}
footer={<Footer />}
/>
)
};
export default FolderPage;
Nav, LinkSearch, CardList, Footer 컴포넌트를 재사용하고, useLinkSearch도 재사용해서 만든다. LinkItem으로 만든 링크 내용이 담긴 카드 형태의 컴포넌트가 다른 도메인에서 사용할 수 있다면 컴포넌트 이름을 Card로 바꾸고 상위 폴더인 shared/ui로 옮겨 줄 수 있다.
제일 처음에 살펴본 페이지 컴포넌트와 관심사 분리를 적용한 페이지 컴포넌트를 비교할 때 관심사 분리를 적용한 컴포넌트가 가진 장점들이 있다. 하지만 이러한 장점을 위해서 어떤 구조로 만들어야 할지 고민해야 하고, 각각을 분리하는 과정에서 더 많은 코드를 작성해야 하고, 컴포넌트 이름을 정해줘야 할 것도 더 많아진다. 즉, 개발자의 리소스가 더 많이 들어간다.
언제 어느 정도의 관심사 분리를 할 지는 프로젝트의 규모, 프로젝트의 성격, 피쳐의 생명주기 등을 고려해서 정하면 된다.