React 컴포넌트 설계

깨진알·2024년 1월 13일

React

목록 보기
10/12

React 컴포넌트를 만들 때 마주하는 어려움

1. React 컴포넌트를 만드는 예시

(1) 리액트 컴포넌트를 만들 때 고려하는 것

프론트엔드 개발자가 리액트를 활용해서 컴포넌트를 만들 때 고려하는 것들이 있다. 재사용성이 좋은가, 쉽게 유지보수 가능한가, 충분히 빠르게 동작하는가 등이 있다. 이번에는 재사용성, 유지보수, 코드 가독성과 같은 개발자 생산성 관점에서 어떻게 리액트 컴포넌트를 만드는 게 좋을지 배워보도록 한다.

리액트르 막 배운 경우에는 쉽게 하나의 페이지 컴포넌트로 만들 수 있다.

# 폴더 구조

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;

위 코드의 경우 코드 양이 매우 많이 들어간다. 이로 인해 해당 코드가 어떤 기능을 하는지 빠르게 파악하기 어렵다.

(2) 추가적인 작업 요청이 발생한 경우

앞의 페이지 작업이 끝난 뒤 /shared 페이지도 만들어 달라는 요청이 있다고 가정해보자.

/folder 페이지와 동일하게 사용하는 컴포넌트가 많이 보인다. 이미 만들었던 코드를 다시 활용하기 위해 크게 두 가지 선택지에 놓이게 된다.

  1. /folder 페이지에서 반복되는 부분을 복사해서 /shared 페이지로 붙여 넣기
  2. /folder 페이지에서 컴포넌트를 잘 분리해서 새로운 컴포넌트를 만들기

1번의 선택은 두 페이지에서 공통적으로 사용하는 영역에 변경이 있는 경우, 변경해야 하는 부분을 찾고 수정하는 작업을 두 번 반복해야 한다. 또한 둘 중 하나의 페이지에는 수정을 누락할 위험도 높다.

2번 선택지로 결정하고 분리를 위해 /folder 페이지에서 재사용 가능한 컴포넌트를 분리해서 새로운 컴포넌트(nav 컴포넌트, 링크 검색 컴포넌트, 링크 카드 리스트 등)를 만든다. /folder 페이지에서 분리한 새로운 컴포넌트를 사용하도록 수정하고 /shared 페이지에는 분리한 새로운 컴포넌트를 사용한다.

2번 선택지의 수정 및 추가 작업을 하면서 다음과 같은 문제를 느껴야 한다. /shared 페이지 만드는 작업을 해야 하는데, 왜 /folder 페이지를 수정하고 있는가?

살펴본 예시와 같이 페이지 전체를 하나의 컴포넌트로 만들어서 발생하는 문제가 아니라도, 개발자가 모든 경우의 수를 미리 예측하고 컴포넌트를 분리할 수는 없기 때문에 유사한 상황을 종종 마주하게 된다.


관심사의 분리 (Separation of Concerns)

1. 관심사의 분리

관심사의 분리란 컴퓨터 프로그램을 관심사 별로 구별해서 분리하는 설계 원칙이다.

분리를 하려면 경계를 세워야 하는데 경계란 주어진 책임을 설명하는 논리적이거나 물리적인 제한을 의미한다. 소스 구성에 대한 프로젝트, 폴더 구조를 포함하기도 한다.

관심사 분리의 목표는 시스템을 분리되지 않는 파트들로 조각내는 것이 아니라 시스템을 반복하지 않고 각각의 책임을 가진 요소들로 구성하는 것에 있다. 앞으로 책임이란 단어를 자주보게 되는데 책임은 특정 코드가 수행해야 하는 동작이라고 생각하면 된다. 관심사의 분리를 잘하면 아래와 같은 효과를 얻을 수 있다.

  • 컴포넌트의 불필요한 반복이 없어지고, 책임이 단일화되어 전체 시스템을 유지보수하기 쉽게 만든다.
  • 시스템 전체가 유지보수성이 올라가 더 안정적이게 된다.
  • 각각의 컴포넌트가 단일 책이으로 자신의 관심사만 집중해서 확장 가능성을 만든다.
  • 고유한 책임들이 잘 나누어지면 팀 간의 필요한 협력을 최소화시키고, 각 팀이 그들의 책임과 핵심 경쟁력에 집중할 수 있게 해 준다. 이를 통해 전체 비즈니스 목표 달성에 도움을 준다.

2. 관심사의 분리 - 수평적인 분리

수평적인 관심사는 애플리케이션 내에 동일한 역항르 수행하는 기능의 논리적 단위로 분리한다. 마치 건물의 층이 나누어져 있는 모습과 유사하고, 크게 아래와 같이 세 개의 레이어로 나누어 볼 수 있다.

  • Presentation Layer: 보이는 요소인 HTML, CSS, UI에 필요한 상태관리로 이루어진 UI 컴포넌트
  • Business Layer: 비즈니스 로직과 정책 등에 관련된 컴포넌트
  • Resource Access Layer: 유저에게 제공할 데이터를 받기 위해 백엔드 서버에 요청, 로컬 스토리지 접근, 외부 api 서버 요청 등 외부 정보 접근과 관련된 훅 또는 함수

이렇게 레이어를 분리하면 UI의 스타일에 문제가 있을 때 Presentation Layer만 보면 되고, 요청한 데이터를 받지 못하는 문제가 있으면 Resource Access Layer만 보면 문제를 확인할 수 있다. 또한 비즈니스의 정책 변경이 있다면 해당하는 Business Layer만 찾아서 수정하면 된다. 이처럼 수평적인 분리를 통해 문제를 빠르게 발견해 고칠 수 있고, 변경이 필요한 경우 빠르고 정확하게 반영할 수 있어 유지보수가 쉬워진다. 그리고 반복해서 필요한 UI, 비즈니스 규칙, 데이터 요청 등을 재사용하기 쉬워진다.

3. 관심사의 분리 - 수직적인 분리

수직적인 관심사는 애플리케이션의 동일한 도메인(비즈니스 관심사)을 모듈로 묶어서 분리한다.

이러한 분리는 각 도메인의 책임을 분명하게 해 준다. 또한 서로 다른 개발조직이 각각의 모듈에 집중할 수 있어 관리하기 편하게 해 준다.

예시로 보았던 /folder 페이지 수직적 관심사를 살펴보면 아래와 같이 나누어 볼 수 있다.

user
folder
link
nav
footer

지금까지 수평적 분리와 수직적 분리를 살펴보았는데, 둘 중 하나를 선택해야 할 필요는 없고, 필요에 따라 이 둘을 함께 적용할 수도 있다.


React 컴포넌트에 관심사의 분리 적용하기

1. React 컴포넌트에 관심사의 분리 적용

(1) 수평적 분리

1. Presentational & Container 패턴

Presentational and Container(또는 Smart and Dumb) 패턴을 활용해 Presentational Layer를 분리할 수 있다. 해당 패턴에 대해 간략히 알아보면, Presentational 컴포넌트는 사용자가 보고, 조작하는 UI 컴포넌트이다. UI만을 위한 상태를 제외하고는 상태를 가지지 않고 Container 컴포넌트가 내려준 props를 통해 조작된다. Container 컴포넌트는 데이터를 받거나 비즈니스 로직을 설정할 수 있고, UI 컴포넌트를 포함할 수 있고, UI 컴포넌트에 props를 전달해 UI를 조작한다.

2. 데이터 접근 분리

Container 컴포넌트에 데이터를 받아오는 부분이 있는 경우가 많은데, 이는 분리할 수 있다. 이때 리액트의 커스텀 훅을 활용하면, 데이터 접근 로직만 분리해서 재사용도 가능하다.

3. 유틸리티 함수 분리

유틸리티 함수란 계산과 처리를 대신하는 일반 함수를 말한다. UI 컴포넌트, 데이터 접근을 위한 훅, 비즈니스 로직 등을 만들다보면 유틸리티 성격의 함수들을 만들어 사용하게 되는ㄷ 이런 함수들도 분리할 수 있다. 분리하게 되면 재사용할 수도 있고, 분리된 환경에서 유틸리티 함수만 테스트하기도 쉬워진다. 지금까지 크게 네 가지로 분리해 봤고, 분리한 관심사가 잘 드러나도록 이름을 붙인다.

  • UI 컴포넌트 -> ui
  • 데이터 접근 -> data-access
  • 데이터 접근을 제외한 Container 컴포넌트 -> feature
  • 유틸리티 함수 -> util

위 관심사들을 분리했지만, 의존성을 아무렇게나 가져가면 분리한 의미가 없어지게 된다.

ui 컴포넌트에서 특정 data-access 훅을 import해서 사용한다면 ui 컴포넌트는 data-access 훅에 의존하는 컴포넌트가 된다. 이렇게 되면 ui 컴포넌트에 다른 data-access 사용이 필요한 경우 사용할 수 없게 된다. ui 컴포펀트가 다른 ui 컴포넌트를 의존하는 경우에는, 그 자체로 새로운 ui 컴포넌트가 되고 다른 도메인에서 사용하는데 문제될 것이 없다. data-access를 의존하면 안 되는 같은 이유로 ui 컴포넌트는 feature 컴포넌트를 의존해서 안된다.

feature 컴포넌트에서는 data-access를 활용해서 데이터를 가져오고 이를 활용해 ui 컴포넌트의 props로 전달해야 한다. 또한, feature 컴포넌트는 다른 feature 컴포넌트를 사용할 수도 있다.

이러한 관계들을 반영해 아래와 같이 의존성을 정리해 볼 수 있다.

  • ui: ui, uitl에 의존할 수 있다.
  • data-acess: data-access, util에 의존할 수 있다.
  • feature: feature, ui, data-access, util 모두에 의존할 수 있다.
  • util: util끼리만 의존할 수 있다.

(2) 수직적 분리

수직적 분리는 서비스에 따라 다른데, 비즈니스 도메인에 따라 나누면 좋다. 앞서 살펴본 예시의 경우 user, folder, link로 나눠볼 수 있고, nav, footer의 경우 여러 페이지에서 공유하는 사용이 필요해 sharing이라는 관심사로 묶어볼 수 있다.

2. 예시에 관심사 분리 적용

# 폴더 구조

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로 옮겨 줄 수 있다.

3. 관심사의 분리 적용 전 생각해 볼 것

(1) Trade-off

제일 처음에 살펴본 페이지 컴포넌트와 관심사 분리를 적용한 페이지 컴포넌트를 비교할 때 관심사 분리를 적용한 컴포넌트가 가진 장점들이 있다. 하지만 이러한 장점을 위해서 어떤 구조로 만들어야 할지 고민해야 하고, 각각을 분리하는 과정에서 더 많은 코드를 작성해야 하고, 컴포넌트 이름을 정해줘야 할 것도 더 많아진다. 즉, 개발자의 리소스가 더 많이 들어간다.

언제 어느 정도의 관심사 분리를 할 지는 프로젝트의 규모, 프로젝트의 성격, 피쳐의 생명주기 등을 고려해서 정하면 된다.

  • 규모가 크고 현재 서비스 중인 프로젝트라면 비즈니스 규칙 변경, UI 추가/수정, 오류 해결 등 유지보수가 중요하다. 또한 이미 만들어진 컴포넌트, 함수, 비즈니스 로직 등을 재사용해야 하는 경우도 많다. 이런 경우 당장은 조금 더 많은 시간이 들 수 있지만, 관심사 분리를 잘하면 장기적으로 개발 생산성이 크게 높아진다.
  • 디자인 요소는 크게 중요하지 않고 기능 자체가 빠르게 필요한 관리자용 프로젝트라면 UI 라이브러리를 활용해 UI 컴포넌트 개발의 관심사는 외부에 의존하고 데이터 접근, 비즈니스 로직 등에 집중하는 것도 고려할 수 있다.
  • 잠깐 사용하고 사라질 이벤트 페이지를 만들거나 재사용 가능성이 없는 컴포넌트를 만들어야 할 경우 많은 리소스를 사용하기보다 최소한의 리소스를 사용해 빠르게 만드는 게 좋을 수 있다.
profile
프론트엔드 지식으로 가득찰 때까지

0개의 댓글