블로그 목록으로
React

React에서 컴포넌트를 어떻게 설계할 것인가?

SRP, Container/Presentational, Compound Component 패턴으로 재사용성과 유지보수성 높은 React 컴포넌트를 설계하는 방법을 정리했습니다.

  • #react
React에서 컴포넌트를 어떻게 설계할 것인가? 대표 이미지

안녕하세요?

이번 시간에는 React에서 컴포넌트를 어떻게 설계할 것인지에 대한 이야기를 보려고 합니다.

React 프로젝트를 진행하다 보면 다음과 같은 컴포넌트를 쉽게 만나게 됩니다.

  • API 호출
  • 상태 관리
  • UI 렌더링
  • 이벤트 처리

아마 컴포넌트를 만드시면서 모든 로직이 하나의 컴포넌트에 들어가 있는 경우를 종종 보셨을 겁니다.

이렇게 되면 컴포넌트가 점점 커지고, 재사용성과 유지보수성이 떨어지게 됩니다.

이 문제를 해결하기 위해 React에서는 몇 가지 설계 패턴이 자주 사용되는데요. 오늘은 이러한 문제를 해결하기 위한 설계 패턴들에 대해 알아봅시다!


SRP (Single Responsibility Principle)

SRP란 하나의 컴포넌트는 하나의 책임만 가져야 한다는 원칙이다.

function UserProfile() {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetchUser().then(setUser);
  }, []);
 
  return (
    <div>
      <h1>{user.name}</h1>
    </div>
  );
}

위 코드는 API 패칭, UI 표시, 데이터 관리의 총 세 가지 책임을 가지고 있습니다.

이로 인하여 테스트가 어려워지거나, 재사용이 떨어지거나, 유지보수에 어려움이 생길 수 있습니다.

따라서 React에서는 컴포넌트를 책임 기준으로 나누는 전략을 사용합니다.

1. 데이터 로직

  • API 호출
  • 상태 관리
  • 데이터 가공

2. UI 로직

  • JSX
  • 스타일
  • 화면 렌더링

위와 같이 하나의 컴포넌트당 하나의 책임만을 가지게 하여 효율적인 개발이 가능하게 합니다.

Container vs Presentational 패턴

SRP를 React에서 구현하는 대표적인 방법이 Container / Presentational 패턴입니다.

Container는 로직을 담당하는 컴포넌트로,

  • API 호출
  • 상태 관리
  • 데이터 가공
  • 이벤트 처리

등을 담당합니다.

function UserProfileContainer() {
  const { data } = useUser();
 
  return <UserProfileView user={data} />;
}

Presentational은 UI만 담당하는 컴포넌트로,

  • 화면 렌더링
  • 스타일
  • 레이아웃

들을 담당합니다.

function UserProfileView({ user }) {
  return <h1>{user.name}</h1>;
}

Container vs Presentational 패턴의 장점

1. 테스트가 쉬워진다

Presentational 컴포넌트는 UI 렌더링에만 집중하고, 데이터 로직이나 API 호출과 같은 복잡한 동작을 포함하지 않습니다. 따라서 컴포넌트를 테스트할 때 외부 환경에 의존하지 않고 단순히 props만 전달해서 테스트할 수 있습니다.

예를 들어 Container 컴포넌트에 API 요청과 상태 관리가 포함되어 있다면 테스트를 위해 다음과 같은 요소들을 준비해야 합니다.

  • API mocking
  • 상태 관리 mocking
  • 비동기 처리
  • 네트워크 요청 처리

하지만 Presentational 컴포넌트는 이런 요소들이 필요 없습니다. 단순히 props로 전달된 데이터를 화면에 잘 렌더링하는지만 확인하면 됩니다.

function UserListView({ users }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이 컴포넌트를 테스트할 때는 API나 상태 관리 없이 단순히 데이터를 전달하면 됩니다.

render(<UserListView users={[{ id: 1, name: "Alice" }]} />);

이처럼 입력(props) → 출력(UI) 구조가 명확하기 때문에 테스트 코드가 단순해지고, 테스트 안정성도 높아집니다.

2. 재사용성이 높아진다

Presentational 컴포넌트는 UI 표현만 담당하고 데이터 로직을 포함하지 않기 때문에 다양한 상황에서 재사용할 수 있습니다.

만약 UI 컴포넌트 내부에 API 요청이나 특정 상태 관리 로직이 포함되어 있다면, 다른 화면에서 동일한 UI를 사용하기 어렵습니다. 왜냐하면 컴포넌트가 특정 데이터 흐름에 강하게 결합되어 있기 때문입니다.

function UserList() {
  const { data } = useUsers();
 
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이 컴포넌트는 useUsers라는 특정 데이터 로직에 의존하고 있기 때문에 다른 상황에서 사용하기 어렵습니다.

하지만 UI와 로직을 분리하면 상황이 달라집니다.

Container 컴포넌트

function UserListContainer() {
  const { data } = useUsers();
 
  return <UserListView users={data} />;
}

Presentational 컴포넌트

function UserListView({ users }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이렇게 분리하면 UserListView는 다양한 상황에서 재사용할 수 있습니다.

  • 검색 결과 리스트
  • 관리자 페이지 유저 목록
  • 추천 사용자 목록

모두 같은 UI 컴포넌트를 사용할 수 있습니다. 즉, UI와 데이터 로직의 결합도가 낮아지기 때문에 재사용성이 높아집니다.

3. 유지보수가 쉬워진다

Container와 Presentational 컴포넌트를 분리하면 **관심사의 분리(Separation of Concerns)**가 이루어집니다. 즉, 각 컴포넌트가 담당하는 역할이 명확해집니다.

보통 React 컴포넌트는 다음 두 가지 역할을 수행합니다.

  1. 데이터를 가져오고 상태를 관리하는 역할
  2. 화면을 렌더링하는 역할

이 두 가지 역할이 하나의 컴포넌트에 섞여 있으면 코드가 점점 복잡해지고 수정하기 어려워집니다.

하지만 Container와 View로 분리하면 변경 범위가 명확해집니다.

변경 종류수정 위치
UI 디자인 변경Presentational 컴포넌트
데이터 로직 변경Container 컴포넌트

이처럼 변경의 영향을 최소화할 수 있기 때문에 유지보수가 훨씬 쉬워집니다.

실습 예시

이번 시간에 배워보는 내용들을 정리한 페이지를 제작하고 있습니다. 각 패턴에 대한 상세 페이지로 이동할 수 있는 카드 UI와 각 패턴의 데이터를 담은 Container를 분리해봤습니다.

  • PatternContainer: 패턴에 대한 데이터들만 처리합니다.
  • PatternCard: 패턴에 대한 데이터들을 보여줄 UI를 처리합니다.
import { PatternList } from "../../constants/home/PatternList";
import PatternCard from "./PatternCard";
 
const PatternContainer = () => {
  return (
    <div className="flex flex-col md:flex-row w-full justify-center items-center gap-6 max-w-6xl mx-auto">
      {PatternList.map((Pattern) => (
        <PatternCard
          key={Pattern.title}
          title={Pattern.title}
          description={Pattern.description}
          icon={Pattern.icon}
          color={Pattern.color}
          tags={Pattern.tags}
          link={Pattern.link}
        />
      ))}
    </div>
  );
};
 
export default PatternContainer;
import { Link } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
 
interface PatternCardProps {
  title: string;
  description: string;
  icon: LucideIcon;
  tags: string[];
  color: string;
  link: string;
}
 
const PatternCard = ({
  title,
  description,
  icon: Icon,
  color,
  tags,
  link,
}: PatternCardProps) => {
  return (
    <div className="flex flex-col w-full h-full gap-4 border border-gray-200 rounded-2xl p-6 hover:shadow-xl transition-all duration-300">
      <div
        className={`flex h-11 w-11 items-center justify-center rounded-lg ${color}`}
      >
        <Icon className="w-5 h-5" />
      </div>
      <div className="flex flex-col w-full gap-1 px-2">
        <h3 className="text-lg font-bold">{title}</h3>
        <p className="text-gray-500">{description}</p>
      </div>
      <div className="flex flex-col w-full gap-3 mt-auto">
        <div className="flex flex-wrap gap-2">
          {tags.map((tag) => (
            <span
              key={tag}
              className="text-sm text-gray-500 px-2 py-1 rounded-md bg-gray-100"
            >
              {tag}
            </span>
          ))}
        </div>
        <Link
          to={link}
          className="w-full text-center rounded-md bg-gray-500 text-white py-2"
        >
          자세히 보기
        </Link>
      </div>
    </div>
  );
};
 
export default PatternCard;

요즘 React에서는 Container 대신 custom Hook을 활용하여 Hook에서 Component에 데이터를 넘겨주는 형식으로 진행하기도 합니다.

Compound Component 패턴

유명 UI 라이브러리인 Radix UI, Headless UI, shadcn/ui에서도 차용하는 패턴으로, 여기서 Compound는 여러 개가 결합된이라는 뜻을 가지고 있습니다.

<Tabs>
  <Tabs.List>
    <Tabs.Trigger>Tab1</Tabs.Trigger>
    <Tabs.Trigger>Tab2</Tabs.Trigger>
  </Tabs.List>
 
  <Tabs.Content>내용</Tabs.Content>
</Tabs>

Compound Component 패턴은 하나의 부모 컴포넌트 아래에 관련된 여러 컴포넌트를 조합하여 사용하는 패턴입니다.

일반적인 컴포넌트의 경우, 구조 변경이 불가능하고 커스터마이징이 불가능하지만, Compound 방식을 적용한다면 일관된 디자인을 유지하며 커스터마이징 및 구조 변경이 가능합니다.

Parent

const TabsContext = createContext(null);
 
function Tabs({ children }) {
  const [active, setActive] = useState(0);
 
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      {children}
    </TabsContext.Provider>
  );
}

Trigger

function TabsTrigger({ index, children }) {
  const { active, setActive } = useContext(TabsContext);
 
  return <button onClick={() => setActive(index)}>{children}</button>;
}

Content

function TabsContent({ index, children }) {
  const { active } = useContext(TabsContext);
 
  if (active !== index) return null;
 
  return <div>{children}</div>;
}

Example

<Tabs>
  <TabsTrigger index={0}>Tab1</TabsTrigger>
  <TabsTrigger index={1}>Tab2</TabsTrigger>
 
  <TabsContent index={0}>내용1</TabsContent>
  <TabsContent index={1}>내용2</TabsContent>
</Tabs>

나의 사용 예시

toast.message(
  <ToastContent
    showCloseButton={true}
    autoDisappear={false}
    className="mt-0 flex min-w-85.5 flex-col gap-2 rounded-md p-3"
    style={{ marginTop: "var(--safe-area-inset-top)" }}
  >
    <ToastHeader>
      <ToastDescription>
        충분히 이완되셨나요? 변화된 상태를 측정해 보세요!
      </ToastDescription>
    </ToastHeader>
    <ToastFooter>
      <Button
        size="xs"
        onClick={() => {
          clearFlag();
          closeToast();
          nativeNavigate(ROUTE.DIAGNOSIS.HRV);
        }}
      >
        <p>HRV 측정하러 가기</p>
        <ArrowRightIcon className="size-3" />
      </Button>
    </ToastFooter>
  </ToastContent>,
  { id: MILESTONE_TOAST_ID },
);

기존의 토스트 메시지의 디자인에서 커스터마이징하여 UI를 작업한 모습입니다.


오늘은 다양한 패턴에 대해서 알아보았습니다.

위 패턴을 활용하여 재활용성 높은 컴포넌트를 구축하고, 효율적인 개발에 도움이 되었으면 좋습니다!

감사합니다.