왜 공통 컴포넌트는 항상 레거시가 될까? 확장성을 위한 실전 설계 전략

2025-11-25

공통컴포넌트, 개발속도를 가장 많이 향상시켜주기도, 가장 많이 사용되기도 하는 핵심 컴포넌트인데요. 만들때 만큼은 모든 팀원에게 내가 만든 코드를 자랑하고 싶어지지만 몇 달, 아니 몇 주만 지나더라도 요상한 props가 덕지덕지 붙은 꼴보기 싫은 컴포넌트가 되기도 하는 애증의 컴포넌트입니다.

처음에는 3가지 테마와 사이즈로 시작했던 버튼이, 어느 순간엔가 특정 페이지를 위한 예외 케이스들로 뒤섞이고, 요구사항을 공통 컴포넌트로는 도저히 구현할 수 없어 점점 공통컴포넌트는 레거시 속으로 쳐박히기도 합니다.

현재 진행중인 프로젝트 역시 다양한 고객사의 요구사항을 맞춰주고 커스텀하다보니, 파일의 라인수가 400줄이 넘어가기도 하고, props가 10개가 넘어가는 상황도 심심치 않게 등장하고 있었는데요. 이번 포스팅에서는 이처럼 다양한 요구사항에 커스텀해야 할 때 유용한 확장성 높은 컴포넌트를 구현하는 방법에 대해 공유해드리려 합니다.

Props 통째로 전달하기

import cx from 'clsx';
import { type ComponentProps } from 'react';
import styles from './Button.module.scss';

interface Props extends ComponentProps<'button'> {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
}

const Button = ({
  variant = 'primary',
  size = 'md',
  className,
  children,
  ...props
}: Props) => {
  return (
    <button
      className={cx(styles.button, styles[variant], styles[size], className)}
      {...props}
    >
      {children}
    </button>
  );
};

export default Button;

시작부터 SOLID 원칙을 철저히 위배하는 Props 통째로 전달하기 방식은, Input과 같이 세세한 props를 많이 받아야할 때 빛을 발합니다. 처음에는 value, onChange, placeholder 같은 단순한 동작만을 생각하기 쉽지만, 막상 요구사항을 구현할 때면 disabled, autoComplete, min/max, tabIndex ... 별의별 속성이 필요하게 됩니다. 그리고 그때마다 props를 추가하면 결국 props만 20개를 받아야 하는 레거시 of 레거시 코드로 전락하게 됩니다.

...props 혹은 ...rest 로 흔히 쓰이는 이 방식은 button 속성을 모두 전달하는 방식으로, 사용하는 곳에서 버튼의 어떤 속성이라도 사용할 수 있게 해줍니다. 사용하지 않는 메서드를 전달하지 말라는 Interface Segregation Principle와 정면으로 부딪히는 방식이지만, 다양한 속성을 사용해야 한다면 props를 통째로 넘기는 것이 오히려 좋은 코드가 될 수 있습니다.

스타일 합치기

전달하는 Props 중 className을 별도로 떼어놓은 것을 볼 수 있는데요. 이는 SCSS를 사용할 때 className을 대체하는 것이 아닌, 병합시키기 위함인데요. (여기서 사용된 cx는 ${styles.button} ${className}와 같은 역할을 해줍니다) 이런 방식을 사용하면 variant, size로 정해진 스타일 외에도 사용자가 원하는 스타일을 자유롭게 적용할 수 있게 됩니다.

물론 무조건 좋은 방식은 아닌데요. 사용자가 임의로 스타일을 수정하는 방식이다보니, 나중에 한번에 스타일을 적용해야 한다거나 공통컴포넌트에 수정이 필요할 때 유지보수비용이 증가하기도 합니다. 때문에 props를 많이 받는 상황이 아닐 때에도 습관적으로 사용하는 것은 지양해야합니다.

스타일 우선순위 선택자 문제

스타일을 합쳤는데, 막상 스타일이 바뀌지 않는 경우가 있을 수 있습니다. 그렇다면 우선순위 선택자를 의심해보아야 하는데요. className을 같이 쓸 때, 동일한 스타일이 무조건 덮어씌워지는 것은 아닙니다. 엄밀히 말하면 덮어씌우기보다 합친다는 것에 가깝기 때문에 공통컴포넌트의 스타일이 태그 선택자와 같이 높은 우선순위를 가지는 스타일이라면 덮어씌우는 스타일 역시 동일한 우선순위를 가져야만 합니다.

.primary:disabled {
  border-color: $line_main;
  background: color-mix(in srgb, $primary 50%, transparent);
}

예를 들어, 위와 같은 스타일이 있을 때<Button variant="primary" disabled className={styles.disabled} />, .disabled { ... } 와 같이 적용한다면 disabled 스타일은 적용되지 않을 것입니다.

이러한 상황을 간단히 해결하고 싶다면 CSS의 최신문법인 @layer 를 사용해보는 것도 도움이 됩니다. @layer reset, base, components, overrides;와 같이 레이어를 나누고, @layer base { ... } 으로 스타일을 감싸게 되면 이후 overrides 등의 레이어 스타일이 무조건 앞서기 때문에 이후 합치는 모든 스타일이 덮어 씌워지게 됩니다.

하위 컴포넌트가 있을 때의 스타일 전달

공통 컴포넌트를 개발하다보면 Checkbox와 같이 input과 label이 동시에 필요하거나 컴포넌트를 감싸야 하는 경우가 쉽게 생기곤 합니다. 하지만 요구사항은 어떤 태그를 수정해야 할지 전혀 예측할 수 없게 합니다. 때문에 가장 바깥쪽의 태그에 클래스를 전달하고 하위 선택자를 선택하는 방식을 사용하게 되는데요. 이런 상황에서 만든 우버는 각각의 태그에 스타일을 포함한 커스텀 속성을 모두 전달해주고 있습니다.

// https://github.com/uber/baseweb/blob/main/src/card/card.tsx
{...getOverrideProps(HeaderImageOverride)} />
      )}
      <Contents {...getOverrideProps(ContentsOverride)}>
        {thumbnailSrc && <Thumbnail src={thumbnailSrc} {...getOverrideProps(ThumbnailOverride)} />}
        {title && (
          <Title $hasThumbnail={$hasThumbnail} {...getOverrideProps(TitleOverride)}>
            {title}

우버에서 만든 리액트 컴포넌트 라이브러리 Base Web React Components 는 위와 같이 getOverrideProps을 통해 각각의 태그들을 커스텀할 수 있도록 확장성을 열어놓은 것을 볼 수 있습니다.

헤드리스 & 컴파운드 컴포넌트 패턴

헤드리스 패턴

// https://headlessui.com/react/input
import { Description, Field, Input, Label } from '@headlessui/react';
import clsx from 'clsx';

export default function Example() {
  return (
    <div className="w-full max-w-md px-4">
      <Field>
        <Label className="text-sm/6 font-medium text-white">Name</Label>
        <Description className="text-sm/6 text-white/50">
          Use your real name so people will recognize you.
        </Description>
        <Input
          className={clsx(
            'mt-3 block w-full rounded-lg border-none bg-white/5 px-3 py-1.5 text-sm/6 text-white',
            'focus:not-data-focus:outline-none data-focus:outline-2 data-focus:-outline-offset-2 data-focus:outline-white/25'
          )}
        />
      </Field>
    </div>
  );
}

확장성을 고려할 때 빼놓을 수 없는 패턴이 있는데요. 바로 헤드리스 패턴과 컴파운드 컴포넌트 패턴입니다. 헤드리스는 컴포넌트의 로직만 구현하고 UI는 전적으로 사용자에게 위임하여 극강의 확장성을 가질 수 있도록 합니다. 위의 코드는 HeadlessUI의 input 예시인데요. 보시다시피 빼곡한 테일윈드 스타일처럼 UI를 직접 작성해야 하다보니, 공통컴포넌트로 쓰이기보다는 공통컴포넌트를 이루는 재료로 주로 쓰이곤 합니다. 때문에 저는 컴파운드 컴포넌트 패턴을 사용해서 사용성과 확장성을 동시에 잡으려 하고 있습니다.

컴파운드 컴포넌트 패턴

// https://ui.shadcn.com/docs/components/alert-dialog
export function AlertDialogDemo() {
  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>
        <Button variant="outline">Show Dialog</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            This action cannot be undone. This will permanently delete your
            account and remove your data from our servers.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction>Continue</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

컴파운드 컴포넌트 패턴은 상위 컴포넌트가 context로 상태를 제공하고 하위 컴포넌트들이 children 기반의 조합을 통해 UI와 기능을 구성하는 패턴입니다. 어느정도 작성된 구조와 UI 속에서 children을 직접 작성하면서 Headless의 자유도와 props rendering의 장점을 조금씩 가지게 되는 것인데요. 꼭 상위 컴포넌트가 context를 제공하는 것은 아니지만, 상위 컴포넌트가 context로 상태를 주입함으로써 하위컴포넌트들은 props 전달과 메모리 낭비 걱정없이 상태를 사용할 수 있게 됩니다. 이전에 컴파운드 컴포넌트를 사용한 모달에 대한 포스팅을 작성하기도 했는데요. 궁금하신 분은 기존 문제를 해결하기 위한 새로운 리액트 모달 구현포스팅을 참고하시면 좋을 것 같습니다.

마무리

저는 공통 컴포넌트를 설계할 때 가장 중요한 것은 확장성과 사용성의 트레이드오프를 어떻게 최적화 할 것 인가 라고 생각합니다. 그리고 그 트레이드오프를 결정짓는 가장 중요한 요소는 어떤 것을 개발하느냐 라고 생각해요. 안정적이고 체계적인 기획과 진행방향을 가진 프로젝트라면 폐쇄성을 높이는 대신 안정성을 챙겨야 하고, 빠르게 변화하는 요구사항과 촉박한 시간이라면 안정성을 조금 잃는 대신 사용성을 얻는 방식이죠.

결국 중요한 것은 은총알을 찾기 보다는 내게 필요한 패턴을 사용한 것이라 생각합니다. 이번 글에서 소개한 방법들이 여러분의 공통 컴포넌트를 조금 더 단단하고, 조금 더 오래가는 구조로 만드는데 도움이 되었으면 합니다.

이런 포스팅은 어떤가요?