컴포넌트는 화면을 나누는 조각이 아니다

vx_developer·2026년 9월 12일

개발하다가

목록 보기
22/30
post-thumbnail

프론트엔드 개발을 처음 배울 때 컴포넌트는 보통 “화면을 여러 조각으로 나누어 만드는 방법”이라고 설명한다.

function CouponCard() {
  return (
    <div>
      <h2>커피 데이트</h2>
      <button>사용하기</button>
    </div>
  );
}

CouponCard는 쿠폰 하나를 보여주는 화면 조각이다.

컴포넌트의 기본 개념을 이해하기에는 충분한 설명이다.

하지만 실제 서비스를 개발하기 시작하면 화면을 어디에서 자를 것인지만으로는 좋은 컴포넌트를 만들기 어렵다.

쿠폰 카드 하나에도 여러 판단이 필요하다.

  • 쿠폰 데이터는 누가 가져오는가?
  • 사용 가능 여부는 누가 계산하는가?
  • 버튼 클릭 후 상태는 어디에서 변경하는가?
  • 로딩과 실패 상태는 누가 보여주는가?
  • 카드가 다른 화면에서도 사용되면 어떤 부분을 바꿀 수 있어야 하는가?
  • 부모 컴포넌트는 자식의 내부 구현을 얼마나 알아야 하는가?
  • 비슷하게 생긴 카드는 반드시 하나로 합쳐야 하는가?
  • 접근성과 오류 처리는 어느 컴포넌트가 책임져야 하는가?

실제 서비스에서 컴포넌트는 화면을 나누는 조각이 아니다. 하나의 UI가 맡을 책임과 상태의 소유권, 외부와의 계약, 재사용 가능한 변경 범위를 결정하는 경계다.


화면에서 보이는 사각형이 항상 컴포넌트의 경계는 아니다

쿠폰 상세 화면을 눈에 보이는 영역에 따라 나눌 수 있다.

function CouponPage() {
  return (
    <>
      <CouponHeader />
      <CouponInformation />
      <CouponActions />
      <CouponFooter />
    </>
  );
}

이 구조가 잘못된 것은 아니다.

각 영역이 독립적인 책임을 가진다면 이해하기 좋은 구성일 수 있다.

하지만 화면에 구분선이 있다는 이유만으로 컴포넌트를 나누면 이름만 다른 작은 파일이 늘어날 수 있다.

function CouponTitle({
  title,
}: {
  title: string;
}) {
  return <h2>{title}</h2>;
}

CouponTitle이 단 한 곳에서 제목 한 줄만 출력하고 별도의 규칙도 없다면, 이 분리가 제공하는 이점은 크지 않을 수 있다.

반대로 화면에서는 작아 보여도 독립된 책임이 있다면 컴포넌트로 분리할 이유가 생긴다.

function RedeemCouponButton({
  couponId,
  disabled,
  onRedeemed,
}: RedeemCouponButtonProps) {
  const [isSubmitting, setIsSubmitting] =
    useState(false);

  // 쿠폰 사용 요청과 결과 처리
}

이 버튼은 단순한 모양 이상의 책임을 가진다.

중복 클릭을 막고, 요청 중인 상태를 보여주며, 쿠폰 사용 결과를 부모에게 전달할 수 있다.

컴포넌트 경계는 픽셀이나 HTML 태그의 개수보다 다음 질문으로 판단하는 편이 유용하다.

이 UI는 다른 부분과 구분되는 하나의 책임과 변경 이유를 가지는가?


하나의 컴포넌트가 데이터 조회부터 화면 전체까지 책임지면 변경 이유가 섞인다

쿠폰 목록 컴포넌트가 모든 작업을 처리할 수 있다.

function CouponList() {
  const [coupons, setCoupons] =
    useState<Coupon[]>([]);
  const [query, setQuery] =
    useState("");
  const [selectedId, setSelectedId] =
    useState<string | null>(null);

  useEffect(() => {
    fetch("/api/coupons")
      .then((response) =>
        response.json()
      )
      .then(setCoupons);
  }, []);

  // 검색, 정렬, 선택, 사용 요청,
  // 오류 표시와 전체 화면 렌더링
}

처음에는 한 파일에서 전체 흐름을 볼 수 있어 편리하다.

기능이 늘어나면 이 컴포넌트는 서로 다른 이유로 계속 변경된다.

  • API 응답 형식이 바뀐다.
  • 검색 정책이 바뀐다.
  • 카드 디자인이 바뀐다.
  • 쿠폰 선택 방식이 바뀐다.
  • 쿠폰 사용 요청의 오류 처리가 바뀐다.
  • 빈 목록 화면이 바뀐다.

책임에 따라 경계를 나눌 수 있다.

function CouponPage() {
  const couponQuery = useCoupons();

  return (
    <CouponListSection
      query={couponQuery}
    />
  );
}
function CouponListSection({
  query,
}: CouponListSectionProps) {
  if (query.isLoading) {
    return <CouponListSkeleton />;
  }

  if (query.isError) {
    return <CouponListError />;
  }

  return (
    <CouponList
      coupons={query.data}
    />
  );
}
function CouponList({
  coupons,
}: {
  coupons: Coupon[];
}) {
  return coupons.map((coupon) => (
    <CouponCard
      key={coupon.id}
      coupon={coupon}
    />
  ));
}

각 컴포넌트는 다른 질문에 답한다.

  • CouponPage는 화면에 필요한 서버 데이터를 준비한다.
  • CouponListSection은 로딩·실패·성공 상태를 표현한다.
  • CouponList는 목록을 렌더링한다.
  • CouponCard는 쿠폰 하나의 정보와 행동을 보여준다.

파일 수를 늘리는 것이 목적은 아니다.

서로 다른 변경 이유가 한 컴포넌트에 계속 쌓일 때 책임의 경계를 만드는 것이 목적이다.


Props는 값을 전달하는 문법이 아니라 컴포넌트의 계약이다

컴포넌트는 props를 통해 값을 받을 수 있다.

<CouponCard
  title="커피 데이트"
  remainingUses={3}
/>

문법적으로는 부모가 자식에게 문자열과 숫자를 전달한 것이다.

서비스 설계 관점에서 props는 컴포넌트를 사용하기 위해 외부가 알아야 하는 계약이다.

계약이 지나치게 넓으면 사용하는 쪽이 내부 사정을 많이 알아야 한다.

<CouponCard
  title={coupon.title}
  recipientName={
    coupon.recipient.name
  }
  remainingUses={
    coupon.remainingUses
  }
  expiresAt={coupon.expiresAt}
  status={coupon.status}
  isExpired={
    coupon.expiresAt <= now
  }
  canRedeem={
    coupon.status === "active" &&
    coupon.remainingUses > 0
  }
  buttonLabel="사용하기"
  buttonColor="green"
/>

부모가 만료와 사용 가능 여부를 계산하고 버튼의 세부 표현까지 결정한다.

쿠폰 카드의 규칙이 여러 화면으로 퍼질 수 있다.

컴포넌트가 맡을 판단을 계약 안으로 옮길 수 있다.

<CouponCard
  coupon={coupon}
  currentUserId={currentUser.id}
  onRedeem={handleRedeem}
/>

이제 부모는 쿠폰 카드가 수행하는 구체적인 렌더링 판단을 모두 알 필요가 없다.

그렇다고 모든 데이터를 하나의 거대한 객체로 전달하는 것이 항상 좋은 것은 아니다.

<UserProfileCard
  applicationState={applicationState}
/>

이 컴포넌트는 필요한 값이 무엇인지 드러내지 않으며 애플리케이션 전체 구조에 의존할 수 있다.

좋은 컴포넌트 계약은 다음 두 가지 사이에서 균형을 찾는다.

  • 부모가 자식의 내부 규칙을 대신 계산하지 않게 한다.
  • 자식이 필요하지 않은 애플리케이션 전체 상태에 의존하지 않게 한다.

상태는 사용하는 컴포넌트가 아니라 책임지는 컴포넌트에 둬야 한다

쿠폰 카드 안에서 선택 상태를 관리할 수 있다.

function CouponCard({
  coupon,
}: CouponCardProps) {
  const [isSelected, setIsSelected] =
    useState(false);

  // ...
}

카드 하나만 자신의 선택 여부를 사용한다면 자연스러운 위치다.

하지만 한 번에 하나의 쿠폰만 선택할 수 있다면 각 카드가 독립적으로 상태를 가지면 안 된다.

쿠폰 A: 선택됨
쿠폰 B: 선택됨
쿠폰 C: 선택됨

여러 카드가 동시에 선택되는 상태가 만들어질 수 있다.

목록이 선택 규칙을 책임지도록 상태를 올릴 수 있다.

function CouponList({
  coupons,
}: CouponListProps) {
  const [
    selectedCouponId,
    setSelectedCouponId,
  ] = useState<string | null>(null);

  return coupons.map((coupon) => (
    <CouponCard
      key={coupon.id}
      coupon={coupon}
      selected={
        coupon.id === selectedCouponId
      }
      onSelect={() =>
        setSelectedCouponId(coupon.id)
      }
    />
  ));
}

이제 목록이 “하나의 쿠폰만 선택할 수 있다”는 규칙을 관리한다.

상태를 어디에 둘지는 값을 표시하는 위치만으로 결정하지 않는다.

그 상태의 일관성을 누가 책임져야 하는가?

이 질문에 따라 가장 가까운 적절한 소유자를 선택해야 한다.

상태를 너무 아래에 두면 여러 컴포넌트가 동기화되지 않는다.

반대로 모든 상태를 전역 저장소에 두면 작은 화면 변경도 애플리케이션 전체의 관심사가 된다.


서버 데이터와 화면 상호작용 상태는 같은 방식으로 관리할 필요가 없다

쿠폰 목록은 서버에서 가져온 데이터다.

const couponQuery = useQuery({
  queryKey: ["coupons"],
  queryFn: fetchCoupons,
});

검색 입력창의 열림 여부는 현재 화면에서만 필요한 상태일 수 있다.

const [
  isSearchOpen,
  setIsSearchOpen,
] = useState(false);

두 값 모두 화면에 영향을 주지만 성격은 다르다.

상태원본주요 책임
쿠폰 목록서버·데이터베이스조회, 캐시, 갱신, 오래된 데이터 처리
검색창 열림 여부현재 UI사용자 상호작용
선택된 쿠폰 ID화면 또는 URL선택 흐름과 복원
쿠폰 사용 횟수서버·데이터베이스영구 상태와 동시성
편집 중인 제목화면의 초안저장 전 임시 변경

서버에서 받은 쿠폰을 컴포넌트 상태에 그대로 복사하면 두 개의 원본이 생길 수 있다.

const { data: coupon } =
  useCoupon(couponId);

const [localCoupon, setLocalCoupon] =
  useState(coupon);

서버 데이터가 갱신되어도 localCoupon이 자동으로 같아진다는 보장은 없다.

단순히 표시하려는 목적이라면 서버 상태를 직접 사용하는 편이 자연스럽다.

const { data: coupon } =
  useCoupon(couponId);

return coupon
  ? <CouponCard coupon={coupon} />
  : null;

별도의 상태가 필요하다면 다른 의미를 분명하게 표현해야 한다.

const [couponDraft, setCouponDraft] =
  useState(() =>
    createCouponDraft(coupon)
  );

couponDraft는 서버 상태의 우연한 복사본이 아니라 사용자가 수정 중인 초안이다.

컴포넌트의 상태를 설계할 때는 값의 위치뿐 아니라 원본과 사본의 관계도 결정해야 한다.


이벤트 콜백은 내부 구현을 노출하지 않고 사용자 의도를 전달해야 한다

자식 컴포넌트가 부모의 상태 변경 함수를 직접 알 수 있다.

<CouponCard
  coupon={coupon}
  setSelectedCouponId={
    setSelectedCouponId
  }
/>

이 계약은 자식에게 부모 상태의 저장 방식과 변경 방법을 노출한다.

자식은 사용자의 행동만 전달할 수 있다.

<CouponCard
  coupon={coupon}
  onSelect={handleCouponSelect}
/>

onSelect는 “부모의 특정 상태를 변경하라”는 구현 지시가 아니다.

“사용자가 이 쿠폰을 선택했다”는 UI 이벤트를 전달한다.

부모는 그 의미에 맞게 반응한다.

function handleCouponSelect(
  couponId: string
) {
  setSelectedCouponId(couponId);
  closeCouponPreview();
  trackCouponSelection(couponId);
}

자식은 선택 이후 부모가 무엇을 하는지 알 필요가 없다.

이벤트 콜백의 이름도 기술적 동작보다 의미를 표현하는 편이 좋다.

onClick={setTrue}

이 이름은 값이 변경된다는 사실만 보여준다.

onOpenRedeemDialog={handleOpen}

이 이름은 사용자가 어떤 상호작용을 요청했는지 보여준다.

컴포넌트 이벤트는 부모와 자식을 느슨하게 만드는 동시에 두 영역 사이의 의미 있는 계약이 된다.


Boolean 옵션이 늘어나면 컴포넌트가 존재할 수 없는 모습을 허용한다

재사용 가능한 쿠폰 카드를 만들기 위해 여러 옵션을 추가할 수 있다.

<CouponCard
  compact
  editable
  selectable
  showRecipient
  hideActions
  adminMode
/>

옵션이 여섯 개라면 이론적으로 64개의 조합이 만들어질 수 있다.

그중에는 의미가 모순되는 조합도 있을 수 있다.

<CouponCard
  editable
  hideActions
/>

수정 가능하지만 행동 버튼은 숨겨진 카드가 서비스에서 실제로 필요한 형태인지 불분명하다.

역할이 분명한 변형으로 제한할 수 있다.

<CouponCard
  variant="selectable"
  coupon={coupon}
/>

또는 공통 시각 요소를 조합하되 행동은 별도 컴포넌트가 맡게 할 수 있다.

<CouponCardLayout
  header={<CouponTitle coupon={coupon} />}
  footer={
    <RedeemCouponAction
      coupon={coupon}
    />
  }
/>

이 구조에서 레이아웃은 배치를 책임지고, 행동 컴포넌트는 쿠폰 사용 흐름을 책임진다.

재사용은 옵션을 계속 추가해 하나의 컴포넌트가 모든 경우를 처리하게 만드는 일이 아니다.

재사용 가능한 부분과 상황에 따라 달라지는 부분의 경계를 찾는 일이다.


비슷하게 생겼다는 이유만으로 하나의 컴포넌트가 되어야 하는 것은 아니다

관리자 쿠폰 카드와 사용자 쿠폰 카드가 비슷하게 보일 수 있다.

<AdminCouponCard coupon={coupon} />
<UserCouponCard coupon={coupon} />

둘을 하나로 합치면 코드 중복을 줄일 수 있을 것처럼 보인다.

<CouponCard
  coupon={coupon}
  isAdmin={isAdmin}
  canDelete={canDelete}
  canRedeem={canRedeem}
  showOwner={showOwner}
/>

하지만 두 화면의 책임은 다를 수 있다.

  • 관리자는 쿠폰을 중지하거나 삭제한다.
  • 사용자는 쿠폰을 확인하고 사용한다.
  • 서로 다른 권한 규칙을 가진다.
  • 서로 다른 오류와 상태를 보여준다.
  • 변경되는 이유도 다르다.

시각적으로 공통인 작은 요소만 재사용할 수 있다.

function CouponSummary({
  coupon,
}: CouponSummaryProps) {
  return (
    <>
      <CouponTitle
        title={coupon.title}
      />
      <CouponExpiry
        expiresAt={coupon.expiresAt}
      />
    </>
  );
}

관리자와 사용자 화면은 CouponSummary를 함께 사용하면서 각자의 행동을 별도로 관리할 수 있다.

재사용 여부는 현재 모양이 같은지가 아니라 같은 책임과 같은 이유로 함께 변경되는지에 따라 결정해야 한다.

성급한 공통 컴포넌트는 중복 코드를 줄이는 대신 서로 다른 기능을 하나의 변경 경로로 묶을 수 있다.


로딩·실패·빈 결과도 컴포넌트가 표현해야 하는 정상 상태다

성공한 데이터만 가정한 컴포넌트는 간단하다.

function CouponList({
  coupons,
}: CouponListProps) {
  return coupons.map((coupon) => (
    <CouponCard
      key={coupon.id}
      coupon={coupon}
    />
  ));
}

하지만 실제 화면에는 성공 목록 외의 상태도 존재한다.

  • 아직 데이터를 불러오는 중이다.
  • 요청이 실패했다.
  • 조회에는 성공했지만 쿠폰이 없다.
  • 일부 데이터만 사용할 수 없다.
  • 사용자의 권한이 없다.

이 상태를 명시적으로 표현할 수 있다.

function CouponListSection({
  state,
}: CouponListSectionProps) {
  switch (state.status) {
    case "loading":
      return <CouponListSkeleton />;

    case "error":
      return (
        <CouponListError
          onRetry={state.retry}
        />
      );

    case "success":
      return state.coupons.length > 0
        ? (
          <CouponList
            coupons={state.coupons}
          />
        )
        : <EmptyCouponList />;
  }
}

이 컴포넌트는 목록의 요청 상태에 따라 어떤 UI가 존재할 수 있는지 정의한다.

실패 화면과 빈 화면은 성공 화면에 임시로 붙이는 예외가 아니다.

사용자가 실제로 마주치는 서비스 상태다.


접근성은 재사용 컴포넌트의 내부 책임이 될 수 있다

클릭 가능한 카드를 div로 만들 수 있다.

<div
  onClick={onSelect}
>
  {coupon.title}
</div>

마우스로는 동작하지만 키보드 사용자는 선택하기 어려울 수 있다.

상호작용의 의미에 맞는 요소를 사용할 수 있다.

<button
  type="button"
  onClick={onSelect}
  aria-pressed={selected}
>
  {coupon.title}
</button>

이 컴포넌트는 클릭뿐 아니라 키보드 조작과 선택 상태 전달에도 적합한 기본 동작을 가진다.

버튼의 포커스 처리, 레이블, 비활성 상태 같은 규칙을 재사용 컴포넌트 안에서 일관되게 관리하면 사용하는 화면마다 같은 문제를 다시 해결할 필요가 줄어든다.

컴포넌트의 책임은 화면을 그리는 데서 끝나지 않는다.

사용자가 그 UI를 어떤 방식으로 이해하고 조작할 수 있는지도 포함한다.


목록에서는 컴포넌트의 정체성을 안정적으로 유지해야 한다

쿠폰 목록을 배열 인덱스로 렌더링할 수 있다.

coupons.map((coupon, index) => (
  <CouponCard
    key={index}
    coupon={coupon}
  />
));

목록의 순서가 바뀌거나 항목이 삭제되면 같은 인덱스가 다른 쿠폰을 가리킬 수 있다.

컴포넌트 내부 상태가 잘못된 쿠폰에 연결될 수 있다.

coupons.map((coupon) => (
  <CouponCard
    key={coupon.id}
    coupon={coupon}
  />
));

쿠폰 ID를 사용하면 화면 순서가 달라져도 컴포넌트의 정체성이 실제 쿠폰과 연결된다.

key는 렌더링 경고를 없애기 위한 값에 그치지 않는다.

목록이 변할 때 어떤 이전 컴포넌트가 어떤 다음 데이터에 대응하는지 판단하는 기준이다.


컴포넌트 사이의 데이터 흐름은 소유권을 보여줘야 한다

쿠폰 선택과 사용 흐름을 다음과 같이 구성할 수 있다.

flowchart TD
    A[CouponPage: 서버 데이터와 사용 요청]
    -->|coupons| B[CouponList: 목록과 선택 상태]
    B -->|coupon, selected| C[CouponCard: 쿠폰 표시]
    C -->|onSelect| B
    C -->|onRedeem| A
    A -->|갱신된 서버 상태| B

데이터는 책임을 가진 소유자에서 표시 컴포넌트로 내려간다.

사용자 행동은 의미 있는 이벤트로 위쪽에 전달된다.

이 흐름은 모든 상태를 반드시 최상위 컴포넌트에 두라는 뜻이 아니다.

각 상태와 행동을 책임질 수 있는 가장 가까운 경계를 선택해야 한다는 뜻이다.

컴포넌트를 설계하는 일은 결국 UI 안에서 소유권과 데이터 흐름을 설계하는 일이다.


컴포넌트를 나누기 전에 물어봐야 할 질문

책임과 경계

  1. 이 컴포넌트는 어떤 사용자 목적을 책임지는가?
  2. 다른 부분과 구분되는 변경 이유가 있는가?
  3. 화면 모양 때문에 나누는가, 책임 때문에 나누는가?
  4. 컴포넌트 이름만으로 맡은 역할을 이해할 수 있는가?
  5. 하나의 컴포넌트가 데이터 조회, 정책 판단, 렌더링과 여러 부수 효과를 모두 처리하고 있지는 않은가?

상태와 데이터

  1. 이 상태의 원본은 화면인가, URL인가, 서버인가?
  2. 누가 상태의 일관성을 책임져야 하는가?
  3. 여러 자식이 공유한다면 가장 가까운 공통 소유자는 어디인가?
  4. 계산 가능한 값을 별도 상태로 저장하고 있지는 않은가?
  5. 서버 상태를 의미 없는 로컬 사본으로 만들고 있지는 않은가?

외부 계약

  1. 사용하는 쪽이 반드시 알아야 하는 값은 무엇인가?
  2. 부모가 자식의 내부 규칙을 대신 계산하고 있지는 않은가?
  3. 자식이 애플리케이션 전체 상태에 의존하고 있지는 않은가?
  4. 콜백 이름이 기술적 변경보다 사용자 행동을 표현하는가?
  5. 잘못된 속성 조합을 계약 단계에서 막을 수 있는가?

재사용과 변경

  1. 두 UI는 모양만 같은가, 책임과 변경 이유도 같은가?
  2. 공통화하려고 Boolean 옵션을 계속 추가하고 있지는 않은가?
  3. 전체 컴포넌트보다 레이아웃이나 작은 시각 요소만 공유하는 편이 자연스럽지 않은가?
  4. 실제로 두 번째 사용 사례가 존재하는가?
  5. 재사용으로 줄어드는 중복보다 추가되는 조건과 간접성이 더 크지는 않은가?

사용자 경험과 운영

  1. 로딩·실패·빈 결과·권한 없음 상태를 표현하는가?
  2. 중복 클릭과 요청 중 행동을 제어하는가?
  3. 키보드와 보조 기술로도 사용할 수 있는가?
  4. 오류가 발생했을 때 사용자가 다시 시도할 수 있는가?
  5. 컴포넌트에서 시작된 요청과 실패를 운영 중 추적할 수 있는가?

흔히 하는 실수

모든 HTML 영역을 별도 컴포넌트로 만든다

<CouponTitle />
<CouponDescription />
<CouponDate />

독립된 책임이나 재사용 가능성이 없다면 파일과 이동 경로만 늘어날 수 있다.

변경 이유와 의미 있는 계약이 생길 때 경계를 만든다.


큰 컴포넌트 하나가 모든 책임을 가진다

function CouponPage() {
  // 데이터 조회
  // 검색
  // 선택
  // 사용 요청
  // 알림
  // 전체 화면 렌더링
}

기능마다 다른 변경 이유가 한곳에 쌓인다.

서버 데이터 준비, 요청 상태 표현, 목록 렌더링과 개별 행동을 책임에 따라 나눈다.


자식에게 부모의 상태 변경 방법을 전달한다

<CouponCard
  setSelectedId={setSelectedId}
/>

자식이 부모의 상태 구조를 알아야 한다.

<CouponCard
  onSelect={handleSelect}
/>

자식은 사용자 행동을 전달하고 부모가 상태 변경 방식을 결정한다.


재사용을 위해 Boolean 속성을 계속 추가한다

<CouponCard
  admin
  compact
  editable
  selectable
  hideActions
/>

조합이 늘어나면서 존재해서는 안 되는 형태까지 허용될 수 있다.

역할이 명확한 변형, 조합 또는 별도 컴포넌트를 선택한다.


서버 데이터를 로컬 상태에 무조건 복사한다

const [coupon, setCoupon] =
  useState(query.data);

서버 원본과 로컬 사본이 서로 달라질 수 있다.

편집 초안처럼 별도의 의미가 있을 때만 복사하고 이름과 동기화 규칙을 명확히 한다.


성공한 화면만 컴포넌트로 설계한다

return (
  <CouponList
    coupons={query.data}
  />
);

로딩, 오류와 빈 목록이 우연한 예외 처리로 남는다.

요청이 가질 수 있는 상태를 명시하고 각 상태의 UI를 설계한다.


배열 인덱스를 항상 key로 사용한다

<CouponCard
  key={index}
  coupon={coupon}
/>

정렬과 삭제 후 컴포넌트 정체성이 다른 데이터에 연결될 수 있다.

목록 항목의 안정적인 ID를 사용한다.


핵심 정리

컴포넌트는 화면을 여러 조각으로 나누는 방법이라고 배울 수 있다.

function CouponCard() {
  return <article>쿠폰</article>;
}

입문 단계에서는 독립된 UI 조각을 만드는 개념으로 충분하다.

하지만 실제 서비스의 컴포넌트는 더 많은 설계 판단을 포함한다.

  • 어떤 사용자 목적과 UI 책임을 맡는가
  • 어떤 데이터가 외부에서 들어오는가
  • 어떤 상태를 직접 소유하는가
  • 어떤 행동을 이벤트로 외부에 전달하는가
  • 로딩과 실패를 어디에서 표현하는가
  • 서버 상태와 화면 상태를 어떻게 구분하는가
  • 부모와 자식 사이에 어떤 계약을 공개하는가
  • 어떤 부분이 함께 변경되어야 하는가
  • 어느 범위까지 재사용해야 하는가
  • 접근성과 상호작용 규칙을 누가 보장하는가
  • 목록이 바뀔 때 정체성을 어떻게 유지하는가

컴포넌트의 기본 흐름은 다음과 같이 볼 수 있다.

flowchart LR
    A[외부 데이터와 상태]
    --> B[컴포넌트 계약]
    --> C[UI 표현]
    --> D[사용자 행동]
    --> E[의미 있는 이벤트]
    --> F[상태 또는 서비스 변경]
    --> A

컴포넌트는 데이터를 받아 화면을 만들고, 사용자 행동을 의미 있는 이벤트로 전달한다.

이 과정에서 상태와 규칙의 책임이 어느 경계에 있는지를 분명하게 해야 한다.

컴포넌트를 설계한다는 것은 결국 다음 질문에 답하는 일이다.

이 UI가 책임져야 하는 상태와 행동은 무엇이며, 외부에는 어떤 계약만 공개해야 이후의 변경을 안전하게 수용할 수 있는가?

컴포넌트는 화면을 나누는 조각이 아니다.

UI의 책임과 상태의 소유권을 정하고, 사용자 행동과 데이터 흐름을 연결하며, 변경과 재사용의 범위를 설계하는 경계다.

profile
Vision eXperience Developer

0개의 댓글