리액트 컴포넌트 설계에 대하여

Mar 21, 2025

건설

TLDR

  • 확장성있고 재사용성 있는 코드를 만들어야 한다
  • 관심사에 따라서 코드를 분리하고 단일 책임을 가지는 컴포넌트를 만들어야 한다
  • 외부에 제어를 위임시키는 것을 고려 해야한다
  • 책임은 분리하고, 분기는 상단으로 올려라
  • 하위 컴포넌트는 제어를 위임하라
  • 전역 상태 사용을 지양하라

왜 이 글을 쓰는가: 리액트를 처음 배웠던 때와 실무의 괴리

리액트를 처음 배울 때는 빠르게 익히기 위해 서적과 강의에서 제시하는 구조를 그대로 따라 설계했다. 자바스크립트에 대한 깊은 이해가 부족했던 터라, 리액트는 마치 마법처럼 느껴졌고, 주어진 예제대로 구현하는 데 집중했다. 하지만 실무에서는 훨씬 더 복잡한 요구사항을 다뤄야 하므로, 초보 수준에서 배운 대로만 개발하다 보면 코드가 점점 비대해지고, 복잡도가 증가하며, 수정할 때마다 사이드 이펙트가 발생하는 관리하기 어려운 코드가 되어버린다. 결국, 흔히 말하는 "썩은 레거시 코드"가 되어 유지보수가 부담스러워진다.

리액트는 DOM 라이프사이클과 같은 복잡하고 사이드 이펙트가 발생하기 쉬운 로직을 내부적으로 처리해, 개발자가 사이드 이펙트에 대한 걱정 없이 선언형 방식으로 뷰를 설계할 수 있도록 돕는다. 이를 통해 우리는 리액트 안에서 구조적인 자유를 가지면서도 다양한 시도를 할 수 있다. 하지만 설계는 개발자의 취향과 기준에 따라 달라질 수 있으며, 절대적인 정답은 없다. 그렇다면 리액트를 어떤 원칙에 따라 설계할 수 있을까? 그리고 구체적으로 어떤 방식으로 설계되고 있는지 살펴보자

리액트 컴포넌트를 잘 설계해야 하는 이유

많은 개발자가 깊이 고민하지 않고 리액트를 사용하기 시작한다. 리액트는 복잡한 DOM 업데이트 로직을 추상화하여 쉽고 빠르게 재사용 가능한 컴포넌트를 만들 수 있도록 도와준다. 하지만 애플리케이션의 변화를 어떻게 설계하고 관리할지는 결국 개발자의 몫이다.

애플리케이션의 변화는 항상 염두에 두어야 하지만, 종종 간과되곤 한다. 개발이 진행되는 동안 수많은 코드 수정과 여러 개발자의 손을 거치면서 애플리케이션은 점점 비대해진다. 처음부터 명확한 설계 원칙을 세우지 않으면, 프로젝트가 커질수록 의존성이 강하고 목적이 흐려진 코드가 쌓이게 된다. 결국, 누구도 손대기 어려운 코드가 되고, 프로젝트는 예상보다 빠르게 유지보수 불가능한 상태에 이르게 된다.

개발자의 주된 업무는 정의된 도메인 정책들을 코드로 옮기는 일이다. 코드화된 정책들은 프로젝트에 차곡 차곡 쌓여가며 부지런한 개발자에 의해서 잘 정리되거나 혹은 게으른 개발자에 의해서 지저분하게 얽히고 섥혀서 처리가 어려운 코드들이 된다. 얽히고 섥힌 코드들은 더이상 수정을 어렵게 하고 새로운 정책들이 코드로 변해서 들어올때마다 프로젝트의 유지보수 난이도를 급상승 시킨다.

비즈니스 요구 사항은 끊임없이 변화하며, 이러한 변화에 대응하는 것은 개발자의 몫이다. 빠르게 발전하는 비즈니스의 일부인 코드 중 영원히 수정되지 않는 코드는 없다. 그리고 코드 수정에는 필연적으로 사이드 이펙트가 뒤따른다. 이러한 사이드 이펙트를 최소화하는 것 역시 개발자의 책임이다. 리액트 컴포넌트를 잘 설계한다면 버그 혹은 성능 저하를 일으키는 사이드 이펙트를 최소화 시킬 수 있고 비즈니스의 안정적인 발전을 이룰 수 있다.

견고한 컴포넌트를 위한 핵심 원칙들

컴포넌트란 무엇인가

이제 우리는 리액트가 제공하는 자유로움을 바탕으로, 리액트 프로젝트를 잘 설계할 수 있는 원칙을 세워야 한다. 이를 위해 가장 먼저 컴포넌트에 대한 깊은 이해가 필요하다.

"컴포넌트는 컴퓨터 소프트웨어에서 재사용 가능한 범용성을 위해 개발된 소프트웨어 구성 요소를 의미한다." - Wikipedia

리액트 애플리케이션은 수많은 컴포넌트로 이루어져 있으며, 컴포넌트를 어떻게 설계하느냐가 리액트 프로젝트의 전체적인 구조와 유지보수성에 큰 영향을 미친다. 사전적 정의에 따르면, 컴포넌트는 ‘재사용성’‘범용성’ 을 염두에 두고 만들어져야 한다. 앞서 언급한 의존성이 짙고 목적성이 흐려진 코드의 문제는 컴포넌트의 핵심적인 특성을 무시한 채 개발되었기 때문이라고 볼 수 있다.

실무에서는 일정 압박이나 귀찮음, 혹은 경험 부족으로 인해 컴포넌트의 역할을 깊이 고민하지 않고 단순히 "이 코드가 여기 들어가면 동작하겠지" 하는 식으로 개발하는 경우가 많다. 이렇게 만들어진 컴포넌트는 여러 기능이 뒤섞인 재사용성과 범용성이 떨어지는 코드가 되며, 결국 유지보수가 어려운 ‘처치 곤란 코드’가 되어버린다.

그렇다면 이러한 코드를 어떻게 개선할 수 있을까? 그리고 이런 문제를 사전에 방지하려면 어떤 접근 방식을 가져야 할까? 함께 고민해보자.

관심사를 분리하고 단일책임으로 설계하기

컴포넌트가 재사용성과 범용성을 갖추기 위해서는, 관심사에 따라 단 하나의 역할만 수행하도록 설계하는 것이 중요하다. 이는 객체지향 프로그래밍에서 말하는 단일 책임 원칙(Single Responsibility Principle, SRP) 과 같은 개념이다.

리액트 컴포넌트는 기본적으로 props를 받아서 DOM을 렌더링하는 JSX를 반환하는 함수다. 여기서 프로그래밍 개념 중 하나인 순수 함수(pure function) 를 참고할 수 있다.

순수 함수란? 컴퓨터 프로그래밍에서 순수 함수는 다음 조건을 만족한다.

  1. 동일한 인자에 대해 항상 같은 값을 반환한다.
  2. 사이드 이펙트를 발생시키지 않는다.

리액트 컴포넌트를 하나의 역할만 수행하는 구조로 설계하려면, 순수 함수처럼 동일한 props를 받으면 일관된 JSX를 반환하는 방식으로 작성하는 것이 바람직하다. 혹 조건에 따라 다르게 그려지는 컴포넌트가 있다면 이 컴포넌트는 단일 책임 원칙과 순수 함수성을 위반한 컴포넌트다.

예를 들어, 유저 프로필 이미지를 표시하는 컴포넌트를 생각해보자. 이 컴포넌트가 단순히 이미지를 받아서 렌더링하는 것이 아니라, 이미지가 없을 경우 랜덤 이미지를 생성하는 기능까지 포함하고 있다면, 이는 단일 책임 원칙을 위반한 것이다. 또 랜덤 이미지에 따라서 JSX가 달리 반환되기 때문에 순수 함수성도 무시하게 된다. 랜덤 이미지 생성은 프로필 이미지 렌더링과는 별개의 비즈니스 로직이므로, 해당 기능은 컴포넌트 내부가 아니라 별도로 분리되고 프로필 이미지 컴포넌트는 받은 이미지를 일관되게 그대로 보여줘야한다.

컴포넌트를 잘 격리하고 역할을 명확히 정의하면, 코드가 불필요하게 광범위한 기능을 수행하는 것을 방지할 수 있다. 단일 책임을 갖는 순수한 컴포넌트는 테스트가 용이하고, 가독성이 높아 유지보수하기 쉬운 코드로 이어진다.

제어 위임

제어를 외부에 위임할수록 컴포넌트의 유연성과 재사용성이 높아진다. 대표적인 예로 Material UI, shadcn/ui 같은 UI 라이브러리를 생각해보자. 이들 컴포넌트는 필요한 props만 받아서 설정에 따라 동작하며, setState 같은 제어 props를 활용해 외부에서 상태를 관리할 수 있도록 설계되어 있다. 즉, 핵심 로직의 제어권은 해당 컴포넌트를 사용하는 부모 컴포넌트에 있으며, 이를 통해 동일한 컴포넌트를 다양한 상황에서 유연하게 활용할 수 있다.

우리가 만드는 컴포넌트도 마찬가지다. 제어를 위임할수록, 비즈니스 로직을 담당하는 컴포넌트에서 필요에 맞게 import하여 재사용할 수 있는 구조가 된다. 재사용이 가능한 부분들은 별도로 분리하여 개별 컴포넌트로 만들거나, 공통 로직을 hooks로 감싸는 방식을 활용하면 더욱 효율적인 컴포넌트를 만들 수 있다.

하지만 제어를 위임하면 할수록 props가 비대화 되고 해당 컴포넌트를 사용하는 코드의 이해 난이도가 높아지고 가독성이 떨어질 수 있다는 문제도 있다. 따라서, 위임의 장점과 사용 용이성 사이에서 균형을 잡는 것이 중요하다.

다음으로는 위의 원칙들을 리액트에서 어떻게 지키면서 유연하게 개발이 가능할지 찾아보자

견고한 컴포넌트를 구현하는 방법들

도메인 정책에 의한 분기는 되도록 최상단 코드에서 처리하자.

서비스를 운영하다 보면 수많은 도메인 정책이 생기고, 이 정책들이 코드에 반영될 때 대부분 분기(if/else) 형태로 등장한다.

예를 들어보자 👻 게으른 개발자 집단이 Profile 컴포넌트를 구현하라는 요구를 받았다

// 프로필 이미지가 없는 경우 랜덤 이미지를 생성해서 프로필 이미지로 사용한다
// 추가로 타이틀이나 라벨이 없으면 그리지 않는 컴포넌트들
 
const ProfileImage = ({ profile }) => {
  const profileImage = profile.imageUrl && createRandomImage();
  return (
    <div>
      <img src={profileImage} />
    </div>
  );
};
 
const ProfileTitle = ({ profile }) => {
  return profile.profileTitle ? (
    <div>
      <span>{profile.profileTitle}</span>
    </div>
  ) : null;
};
 
const ProfileLabel = ({ profile }) => {
  return profile.label ? <div>{profile.label}</div> : null;
};

하지만 시간이 지나면서 채팅방 방장(HOST) 이라는 역할이 생기고, 이 경우엔 랜덤 이미지 대신 방장 전용 이미지를 사용하라는 정책이 추가된다. 그러면 코드는 아래처럼 변한다.

// 프로필 이미지가 없는 경우 랜덤 이미지를 생성해서 프로필 이미지로 사용한다
// 채팅방 방장의 경우 방장용 기본 이미지, 타이틀, 라벨을 띄운다
 
const ProfileImage = ({ profile }) => {
  const profileImage =
    profile.imageUrl &&
    (profile.type === "HOST" ? "방장용 기본 프로필" : createRandomImage());
  return (
    <div>
      <img src={profileImage} />
    </div>
  );
};
 
const ProfileTitle = ({ profile }) => {
  const profileTitle =
    profile.title && (profile.type === "HOST" ? "방장용 기본 타이틀" : "");
  return profileTitle ? (
    <div>
      <span>{profileTitle}</span>
    </div>
  ) : null;
};
 
const ProfileLabel = ({ profile }) => {
  const profileLabel =
    profile.label && (profile.type === "HOST" ? "방장용 기본 라벨" : "");
  return profileLabel ? <div>{profileLabel}</div> : null;
};

여기서 끝이 아니다. 부방장(SUB_HOST) 도 생기고, 그에 맞는 기본 이미지, 타이틀, 라벨도 추가된다.

const ProfileImage = ({ profile }) => {
  const profileImage =
    profile.imageUrl &&
    (profile.type === "HOST"
      ? "방장용 기본 프로필"
      : profile.type === "SUB_HOST"
      ? "부방장용 기본 프로필"
      : createRandomImage());
  return (
    <div>
      <img src={profileImage} />
    </div>
  );
};
 
const ProfileTitle = (profile) => {
  const profileTitle =
    profile.title &&
    (profile.type === "HOST"
      ? "방장용 기본 타이틀"
      : profile.type === "SUB_HOST"
      ? "부방장용 기본 타이틀"
      : "");
  return profileTitle ? (
    <div>
      <span>{profileTitle}</span>
    </div>
  ) : null;
};
 
const ProfileLabel = (profile) => {
  const profileLabel =
    profile.label &&
    (profile.type === "HOST"
      ? "방장용 기본 라벨"
      : profile.type === "SUB_HOST"
      ? "부방장용 기본 라벨"
      : "");
  return profileLabel ? <div>{profileLabel}</div> : null;
};

처음에는 게으르게 시작했지만, 이제는 ProfileImage, ProfileTitle, ProfileLabel 모두가 도메인 정책을 인지하고 분기 처리를 해야 한다. 결과적으로 더 부지런히 수정해야 하는 구조가 되어버렸다.

그리고 더 무서운 상황이 기다린다.

🙄 "방장 / 부방장 역할을 없애기로 했습니다."

모든 분기를 지우기 위해 여러 컴포넌트를 돌아다니며 수정을 해야 하고, 실수의 가능성은 올라간다.

현업의 컴포넌트는 이보다 훨씬 많고, 더 깊고, 더 복잡하다. 이 작은 분기조차도 복잡도를 기하급수적으로 증가시킨다.

만약 처음부터 도메인 분기를 상단에서 했다면 어떤 결과를 만났을지도 확인해보자 도메인 정책은 상위 컴포넌트에서 처리하고, 하위 컴포넌트는 받은 props 그대로 그리는 역할만 하도록 구성한다.

// 프로필 이미지가 없는 경우 랜덤 이미지를 생성해서 프로필 이미지로 사용한다
// 추가로 타이틀이나 라벨이 없으면 그리지 않는 컴포넌트들
 
const ProfileImage = ({ imageUrl }) => {
  return (
    <div>
      <img src={imageUrl} />
    </div>
  );
};
 
const ProfileTitle = ({ title }) => {
  return title ? (
    <div>
      <span>{title}</span>
    </div>
  ) : null;
};
 
const ProfileLabel = ({ label }) => {
  return label ? <div>{label}</div> : null;
};
 
const Profile = ({ imageUrl, title, label }) => {
  return (
    <Wrapper>
      <ProfileImage imageUrl={imageUrl} />
      <ProfileTitle title={title} />
      <ProfileLabel label={label} />
    </Wrapper>
  );
};
 
const Page = () => {
  const { data: profile } = useQuery("/profile");
  const { imageUrl, title, label } = profile;
  const profileImageUrl = imageUrl && createRandomImage();
  return (
    <Layout>
      <Profile imageUrl={profileImageUrl} title={title} label={label} />
    </Layout>
  );
};
// 프로필 이미지가 없는 경우 랜덤 이미지를 생성해서 프로필 이미지로 사용한다
// 채팅방 방장, 부방장의 경우 방장, 부방장용 기본 이미지, 타이틀, 라벨를 띄운다
 
const ProfileImage = ({ imageUrl }) => {
  return (
    <div>
      <img src={imageUrl} />
    </div>
  );
};
 
const ProfileTitle = ({ title }) => {
  return title ? (
    <div>
      <span>{title}</span>
    </div>
  ) : null;
};
 
const ProfileLabel = ({ label }) => {
  return label ? <div>{label}</div> : null;
};
 
const Profile = ({ imageUrl, title, label }) => {
  return (
    <Wrapper>
      <ProfileImage imageUrl={imageUrl} />
      <ProfileTitle title={title} />
      <ProfileLabel label={label} />
    </Wrapper>
  );
};
 
const getProfileProps = (profile) => {
  if (profile.type === "HOST") {
    return {
      imageUrl: imageUrl && "방장용 기본 프로필",
      title: title && "방장용 기본 타이틀",
      label: label && "방장용 기본 타이틀",
    };
  } else if (profile.type === "SUB_HOST") {
    return {
      imageUrl: imageUrl && "부방장용 기본 프로필",
      title: title && "부방장용 기본 타이틀",
      label: label && "부방장용 기본 타이틀",
    };
  } else {
    return {
      imageUrl: imageUrl && createRandomImage(),
      title,
      label,
    };
  }
};
 
const Page = () => {
  const { data: profile } = useQuery("/profile");
  const { imageUrl, title, label } = getProfileProps(profile);
 
  return (
    <Layout>
      <Profile imageUrl={imageUrl} title={title} label={label} />
    </Layout>
  );
};

이제 정책이 바뀌더라도 Page 컴포넌트 상단의 getProfileProps() 함수만 수정하면 된다.

ProfileImage, ProfileTitle, ProfileLabel은 손댈 필요조차 없다.

😋 "방장 / 부방장 역할을 없애기로 했습니다."

이제 전혀 두렵지 않다. 몇 개의 분기만 지우면 끝이다. 하위 컴포넌트는 받은 데이터를 그대로 렌더링하는 단일 책임만 수행하고, 모든 분기 처리는 최상단 컴포넌트 하나에서만 관리된다. Page는 도메인 로직을 처리하는 역할을 수행하고 있고 다른 컴포넌트 들은 UI를 그리는 역할을 수행하고 있다. 관심사를 분리하고 역할에 맞게 동작하고 있다.

이런 구조는 변경에 유연하고, 추적이 쉬우며, 재사용성과 확장성 모두를 갖춘 설계다.

코드가 커질수록 이런 유연함은 진가를 발휘하게 된다. 다음으로 제어를 위임하는 사례를 소개하겠다.

하위 컴포넌트의 재사용성은 얼마나 제어를 위임하느냐에 따라서 달려있다.

‘제어를 위임한다’는 말은 조금 모호할 수도 있지만, 이전에 언급한 “도메인 정책에 의한 분기는 되도록 최상단 코드에서 처리하자”와 같은 맥락이다.

컴포넌트가 스스로 도메인 로직을 포함해 제어하려고 들면, 점점 재사용이 어려운 컴포넌트가 된다.

예를 들어보자 👻 게으른 개발자 집단이 Profile 컴포넌트에 롱프레스 컨텍스트 메뉴를 붙이라는 요구를 받았다

const LONG_PRESS_DURATION = 700; // ms
 
const Profile = ({ imageUrl, title, label }) => {
  const [showMenu, setShowMenu] = useState(false);
  const timeoutRef = useRef(null);
 
  const handleTouchStart = () => {
    timeoutRef.current = setTimeout(() => {
      setShowMenu(true); // 길게 누르면 메뉴 보이기
    }, LONG_PRESS_DURATION);
  };
 
  const handleTouchEnd = () => {
    clearTimeout(timeoutRef.current); // 짧게 누르면 취소
  };
 
  const handleCloseMenu = () => {
    setShowMenu(false);
  };
 
  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchEnd} // 취소도 대비
    >
      <ProfileImage imageUrl={imageUrl} />
      <ProfileTitle title={title} />
      <ProfileLabel label={label} />
      {showMenu && (
        <div>
          <button onClick={handleCloseMenu}>닫기</button>
          <div>📄 프로필 복사</div>
          <div>📌 즐겨찾기 추가</div>
          <div>🚫 차단하기</div>
        </div>
      )}
    </div>
  );
};

처음엔 잘 동작한다. 하지만, 예상대로 정책은 곧 바뀐다.

😵‍💫 “방장/부방장이 길게 눌렀을 때는 유저 강퇴가 가능해야 해요”

게다가 다음 조건도 따라붙는다:

  • 방장은 자기 자신을 강퇴할 수 없다.
  • 부방장은 자기 자신과 방장은 강퇴할 수 없다.

자, 이제 누가 누군지를 모두 알아야 한다. 그리고 코드는 아래처럼 더러워진다.

const LONG_PRESS_DURATION = 700; // ms
 
const Profile = ({
  imageUrl,
  title,
  label,
  profileId,
  userId,
  userType,
  profileType,
}) => {
  const [showMenu, setShowMenu] = useState(false);
  const timeoutRef = useRef(null);
 
  const handleTouchStart = () => {
    timeoutRef.current = setTimeout(() => {
      setShowMenu(true); // 길게 누르면 메뉴 보이기
    }, LONG_PRESS_DURATION);
  };
 
  const handleTouchEnd = () => {
    clearTimeout(timeoutRef.current); // 짧게 누르면 취소
  };
 
  const handleCloseMenu = () => {
    setShowMenu(false);
  };
 
  // 강퇴 권한 판단 함수
  const canBanUser = () => {
    if (userType === "HOST") return profileId !== userId;
    // 방장은 스스로는 강퇴할 수 없다
    if (userType === "SUB_HOST")
      return profileId !== userId && profileType !== "HOST";
    // 부방장은 HOST를 강퇴 할 수 없다
    return false;
  };
 
  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchEnd} // 취소도 대비
    >
      <ProfileImage imageUrl={imageUrl} />
      <ProfileTitle title={title} />
      <ProfileLabel label={label} />
      {showMenu && (
        <div>
          <button onClick={handleCloseMenu}>닫기</button>
          <div>📄 프로필 복사</div>
          <div>📌 즐겨찾기 추가</div>
          <div>🚫 차단하기</div>
          {canBanUser() && <div className="text-red-500">🦵 강퇴하기</div>}
        </div>
      )}
    </div>
  );
};

😨 Props는 비대해지고, 컴포넌트는 복잡해졌다. 정책이 추가될수록 Profile은 점점 비대한 컴포넌트가 된다. 처음 우리가 잘 설계해두었던 "재사용 가능한 단순 컴포넌트"는 사라지고, 도메인 정책을 품은 거대한 UI 덩어리가 된다.

더 나아가 위의 사례처럼 방장/부방장 기능을 삭제합니다라는 요구가 들어온다면?

각종 분기와 프로퍼티를 지우기 위해 이 컴포넌트를 또 다시 손봐야 하고 수정에 의한 사이드 이펙트로 기존 동작들도 동작하지 않을 위험도 커진다.

그래서 우리에게 필요한 건 제어 역전(Inversion of Control) 이다

이름은 거창하지만 단순하다.

컴포넌트가 직접 제어하지 않고, 부모 컴포넌트에게 제어를 위임하는 것

const getProfileProps = (profile, userType) => {
  const contextMenu = ["📄 프로필 복사", "📌 즐겨찾기 추가", "🚫 차단하기"];
 
  if (profile.type === "HOST") {
    // 방장은 누구도 강퇴 할 수 없다
    return {
      imageUrl: imageUrl && "방장용 기본 프로필",
      title: title && "방장용 기본 타이틀",
      label: label && "방장용 기본 타이틀",
      menus: [...contextMenu],
    };
  } else if (profile.type === "SUB_HOST") {
    // 부방장은 방장이 강퇴 할 수 있다
    if (userType === "HOST") {
      contextMenu.push("🦵 강퇴하기");
    }
    return {
      imageUrl: imageUrl && "부방장용 기본 프로필",
      title: title && "부방장용 기본 타이틀",
      label: label && "부방장용 기본 타이틀",
      menus: [...contextMenu],
    };
  } else {
    if (userType === "HOST" || userType === "SUB_HOST") {
      contextMenu.push("🦵 강퇴하기");
    }
    return {
      imageUrl: imageUrl && createRandomImage(),
      title,
      label,
      menus: [...contextMenu],
    };
  }
};
const LONG_PRESS_DURATION = 700; // ms
 
// Profile: 롱프레스 발생만 감지하고, UI는 부모에게 위임
const Profile = ({ imageUrl, title, label, onLongPress, children }) => {
  const timeoutRef = useRef(null);
 
  const handleTouchStart = () => {
    timeoutRef.current = setTimeout(() => {
      onLongPress();
    }, LONG_PRESS_DURATION);
  };
 
  const handleTouchEnd = () => {
    clearTimeout(timeoutRef.current); // 짧게 누르면 취소
  };
 
  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchEnd={handleTouchEnd}
      onTouchCancel={handleTouchEnd} // 취소도 대비
    >
      <ProfileImage imageUrl={imageUrl} />
      <ProfileTitle title={title} />
      <ProfileLabel label={label} />
      {children}
    </div>
  );
};
 
// ContextMenu: 닫기만 감지하고, 동작은 부모에게 위임
const ContextMenu = ({ menus, onCloseClick }) => (
  <div>
    <button onClick={onCloseClick}>닫기</button>
    {menus.map((menu) => (
      <div key={menu}>{menu}</div>
    ))}
  </div>
);
 
// Page: 모든 도메인 정책과 상태 관리 담당
const Page = () => {
  const { data: profile } = useQuery("/profile");
  const { imageUrl, title, label, menus } = getProfileProps(profile);
  const [showMenu, setShowMenu] = useState(false);
 
  return (
    <Layout>
      <Profile
        imageUrl={imageUrl}
        title={title}
        label={label}
        onLongPress={() => setShowMenu(true)}
      >
        {showMenu && (
          <ContextMenu menus={menus} onCloseClick={() => setShowMenu(false)} />
        )}
      </Profile>
    </Layout>
  );
};

이렇게 되면

  • ProfileContextMenu는 제어받은 상태로만 동작 → 재사용 가능
  • 도메인 정책은 모두 Page 컴포넌트에서 처리 → 유지보수 용이
  • 방장/부방장 정책이 사라졌습니다 → 분기만 수정하면 끝

제어를 위임하면 컴포넌트는 단순해지고, 제어를 역전하면 시스템은 유연해진다. 정책은 계속 바뀌지만, 컴포넌트의 재사용성은 그대로 유지된다.

전역 상태는 독이다

전역 상태를 사용하게 되면, 컴포넌트는 해당 상태에 의도하지 않게 강하게 결합되기 쉽다. 이는 결국 수정 시 예기치 못한 사이드 이펙트로 이어질 가능성을 높인다.

대부분 전역 상태는 외부 라이브러리(zustand, jotai 등)를 통해 관리되는데, 그 로직은 컴포넌트 내부가 아닌 외부에 존재하게 된다. 이로 인해 상태가 어디서 바뀌는지 추적하기 어려운 구조가 되며, 이 상태가 왜 이런 값이지? 를 따라가다 보면 디버깅 지옥을 경험하게 된다.

이처럼 전역 상태에 의존하는 코드는 재사용도 어렵고, 컨텍스트에 강하게 묶인 비유연한 구조로 변해버린다.

const Profile = () => {
  const profile = useProfileStore(); // 이렇게 전역 상태에서 가져다 썼다면?
  return (
    <Wrapper>
      <ProfileImage imageUrl={profile.imageUrl} />
      <ProfileTitle title={profile.title} />
      <ProfileLabel label={profile.label} />
    </Wrapper>
  );
};
 
const Page = () => {
  const { data: profile } = useQuery("/profile");
  const newProfile = getProfileProps(profile);
 
  const setProfile = useSetProfileStore();
 
  useEffect(() => {
    setProfile(newProfile);
  }, [newProfile]);
 
  return (
    <Layout>
      <Profile />
    </Layout>
  );
};

위 예시처럼 Profile 컴포넌트가 제대로 동작하려면 반드시 Page에서 setProfile()을 호출해줘야 하는 전제가 생긴다.

이는 다음과 같은 문제를 일으킨다:

  • Profile을 사용하는 곳마다 setProfile을 호출해야 한다는 보이지 않는 의존 조건이 생김
  • setProfile이 도메인 정책에 따라 제대로 가공되었는지도 일일이 검증해야 함
  • 다른 컴포넌트에서 setProfile을 실수로 다시 호출해 상태가 덮어쓰이는 버그 발생 가능성

이처럼 전역 상태는 사용이 쉬운 만큼, 의존성과 복잡도, 사이드 이펙트의 위험도 함께 증가한다.

원칙에 맞게 만들어진 컴포넌트인지 진단하는 방법

마지막으로, 지금까지 이야기한 원칙들을 바탕으로

확장성, 재사용성, 단일 책임, 관심사 분리가 잘 지켜진 견고한 컴포넌트인지 어떻게 판단할 수 있을지 생각해보자.

예측 가능한가?

견고한 컴포넌트는 예측이 가능하다. 즉, props를 어떻게 넘기느냐에 따라 그에 맞는 UI가 정확하게 렌더링된다. 이는 곧 주변 컴포넌트와의 의존성이 낮다는 뜻이며, 외부 상태나 전역 상태에 크게 의존하지 않는다는 의미이기도 하다.

이런 특성은 테스트하기 좋은 컴포넌트인지로도 판단해볼 수 있다. 의존성이 낮고, 주어진 props에 따라 정해진 결과를 출력하는 컴포넌트는 테스트 코드도 간결하고 직관적으로 작성된다. 전역 상태를 mocking하지 않아도 되고, 외부 로직을 신경 쓰지 않아도 되는 컴포넌트는 순수 함수에 가까운 구조를 가진 경우가 많다.

이런 테스트 용이성과 구조적인 분리는 자연스럽게 TDD(Test-Driven Development) 와도 연결된다.

TDD의 핵심은 단순히 테스트 코드를 열심히 짜자가 아니라, 테스트를 작성하는 과정에서 자연스럽게 의존성이 낮고, 확장성과 재사용성, 단일 책임, 관심사 분리가 잘 된 구조가 만들어지도록 유도한다는 점에 있다.

수정 범위가 제한적인가?

또 하나의 기준은 코드를 수정할 때 영향을 받는 범위를 보는 것이다. 잘 설계되지 않은 컴포넌트는 작은 변경에도 여러 곳을 수정해야 하며, 그만큼 사이드 이펙트의 위험이 커진다. 반면, 잘 설계된 컴포넌트는 도메인 정책이 바뀌더라도 수정 범위가 제한적이며, UI만 변경된다면 순수한 UI 컴포넌트만 수정하면 된다. 도메인 정책이 변경되면 도메인 로직이 있는 상위 컴포넌트만 손보면 된다.

UI와 도메인이 분리되어 설계되어 있기 때문에, 변경이 일어났을 때 어디를 수정해야 하는지 명확하고, 수정에 따른 사이드 이펙트도 예측하고 관리할 수 있다.

마치며

2021년 봄에 이 글을 처음 쓰고, 2025년 봄에 다시 꺼내 보며 새로 작성했다. 그 사이 리액트 생태계는 많이 달라졌지만, 컴포넌트를 잘 설계하기 위한 핵심 원칙은 여전히 유효하다. 예제 코드나 나 스스로의 사고 방식은 많은 변화가 있었기에, 이번엔 그에 맞춰 많은 부분을 수정했다. 당시엔 여러 참고 자료를 기반으로 글을 썼지만, 이제는 스스로 고민하고 만든 코드와 설계 원칙을 담았다.

그때나 지금이나 리액트는 자유롭고 유연한 도구다. 하지만 자유롭다는 건 곧 그만큼의 책임이 따른다는 의미이기도 하다. 잘 설계된 컴포넌트는 비즈니스 변화에도 흔들리지 않으며, 유지보수와 확장에 강하고, 협업과 테스트도 수월하게 만들어준다.

이 글이 단순히 작동하는 코드가 아닌, 변화에 강하고 원칙이 살아 있는 구조를 고민하는 데 도움이 되었기를 바란다.