컴포넌트 확장성에 대하여

contability·2025년 9월 25일

지금까지 컴포넌트 확장성을 서비스 내에서 자주 반복적으로 발생하는 상태 변경이나 동작들을 props로 정의할 수 있는, 재사용이 가능한 컴포넌트 정도로 단순하게 생각하고 있었다.

더 자세하게 찾아보니 진짜 확장성은 기존에 알던 단순한 개념을 넘어서는 것이었다.

진정한 확장성은 컴포넌트가 다양한 상황과 요구사항에 유연하게 적응할 수 있는 능력으로, 단순한 재사용성을 넘어서 미래의 변화에 대응할 수 있는 유연성과 적응력을 의미한다. 이를 위해서는 설계 단계부터 다양한 시나리오를 고려하고 확장 가능한 아키텍처를 구축하는 것이 중요하다는 개념이었다.

개념들을 살펴보니 다형성의 경우 styled-components로만 가능한 줄 알았고, 나머지는 이미 알고 있던 것들이었다. 하지만 이것들을 확장성의 개념으로 바라보는 관점이 새로웠고, 내가 개념 이해가 부족했다는 생각이 든다.

이번 기회에 한 번에 정리해본다.

1. 다형성 (Polymorphism)

설명

컴포넌트가 다양한 HTML 요소로 렌더링될 수 있도록 하는 패턴이다. as prop을 통해 동일한 스타일과 로직을 유지하면서 다른 요소로 렌더링할 수 있다.

예제

interface PolymorphicButtonProps<T extends React.ElementType = 'button'> {
  as?: T;
  variant?: 'primary' | 'secondary' | 'danger';
  children: ReactNode;
}

type ButtonProps<T extends React.ElementType> = PolymorphicButtonProps<T> &
  Omit<ComponentProps<T>, keyof PolymorphicButtonProps>;

const PolymorphicButton = <T extends React.ElementType = 'button'>({
  as,
  variant = 'primary',
  children,
  ...props
}: ButtonProps<T>) => {
  const Component = as || 'button';
  const baseStyles = 'px-4 py-2 rounded transition-colors';
  const variantStyles = {
    primary: 'bg-blue-500 hover:bg-blue-600 text-white',
    secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
    danger: 'bg-red-500 hover:bg-red-600 text-white'
  };
  
  return (
    <Component 
      className={`${baseStyles} ${variantStyles[variant]}`}
      {...props}
    >
      {children}
    </Component>
  );
};

// 사용 예시
<PolymorphicButton>기본 버튼</PolymorphicButton>
<PolymorphicButton as="a" href="#" variant="secondary">
  링크로 렌더링
</PolymorphicButton>
<PolymorphicButton as="div" variant="danger">
  div로 렌더링
</PolymorphicButton>

2. Compound Component 패턴

설명

여러 하위 컴포넌트가 조합되어 하나의 완전한 기능을 만드는 패턴이다. Context를 통해 상태와 설정을 공유하며, 의미있는 구조를 만들 수 있다. 대부분의 경우 DX(Developer Experience) 향상을 위해 사용된다.

예제

const CardContext = React.createContext<{ variant?: string }>({});

const Card = ({ 
  children, 
  variant = 'default',
  className = '' 
}: { 
  children: ReactNode; 
  variant?: string;
  className?: string;
}) => (
  <CardContext.Provider value={{ variant }}>
    <div className={`border rounded-lg overflow-hidden ${className}`}>
      {children}
    </div>
  </CardContext.Provider>
);

const CardHeader = ({ children }: { children: ReactNode }) => {
  const { variant } = React.useContext(CardContext);
  const bgColor = variant === 'primary' ? 'bg-blue-100' : 'bg-gray-100';
  
  return (
    <header className={`${bgColor} px-4 py-3 border-b`}>
      {children}
    </header>
  );
};

const CardBody = ({ children }: { children: ReactNode }) => (
  <main className="px-4 py-3">
    {children}
  </main>
);

const CardFooter = ({ children }: { children: ReactNode }) => (
  <footer className="px-4 py-3 bg-gray-50 border-t">
    {children}
  </footer>
);

// 네임스페이스 바인딩 (주로 DX를 위함)
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;

// 사용 예시
<Card variant="primary">
  <Card.Header>
    <h3>제목</h3>
  </Card.Header>
  <Card.Body>
    <p>내용이다. variant 정보가 Context를 통해 전달된다.</p>
  </Card.Body>
  <Card.Footer>
    <button>액션</button>
  </Card.Footer>
</Card>

3. Render Props 패턴

설명

컴포넌트가 렌더링 로직을 외부에 위임하는 패턴이다. 데이터나 상태만 제공하고 UI는 사용하는 곳에서 결정할 수 있어 매우 유연하다.

예제

interface DataFetcherProps<T> {
  url: string;
  children: (data: T | null, loading: boolean, error: string | null) => ReactNode;
}

const DataFetcher = <T,>({ url, children }: DataFetcherProps<T>) => {
  const [data, setData] = React.useState<T | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(null);

  React.useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [url]);

  return <>{children(data, loading, error)}</>;
};

// 사용 예시
<DataFetcher<{ message: string }> url="/api/data">
  {(data, loading, error) => (
    <div>
      {loading && <p>로딩 중...</p>}
      {error && <p>에러: {error}</p>}
      {data && <p>데이터: {data.message}</p>}
    </div>
  )}
</DataFetcher>

4. HOC (Higher-Order Component)

설명

컴포넌트를 받아서 새로운 컴포넌트를 반환하는 함수다. 횡단 관심사(cross-cutting concerns)를 처리할 때 유용하다. 로딩, 인증, 에러 처리 같은 공통 로직을 여러 컴포넌트에 일괄 적용할 수 있다.

예제

function withLoading<P extends object>(
  WrappedComponent: React.ComponentType<P>
) {
  return function WithLoadingComponent(props: P & { isLoading?: boolean }) {
    const { isLoading, ...restProps } = props;
    
    if (isLoading) {
      return (
        <div className="loading-spinner" aria-live="polite">
          로딩 중...
        </div>
      );
    }
    
    return <WrappedComponent {...(restProps as P)} />;
  };
}

const UserProfile = ({ name, email }: { name: string; email: string }) => (
  <div className="user-profile">
    <h3>{name}</h3>
    <p>{email}</p>
  </div>
);

// HOC 적용
const LoadingUserProfile = withLoading(UserProfile);

// 사용 예시
<LoadingUserProfile 
  name="김철수" 
  email="kim@example.com" 
  isLoading={false}
/>
<LoadingUserProfile 
  name="이영희" 
  email="lee@example.com" 
  isLoading={true}
/>

5. forwardRef를 통한 DOM 접근

설명

ref를 전달받아 실제 DOM 요소에 접근할 수 있게 하는 패턴이다. 외부에서 포커스, 스크롤, 값 조작 등을 할 수 있어 확장성이 높아진다.

예제

const Input = forwardRef<
  HTMLInputElement,
  ComponentProps<'input'> & { 
    label?: string;
    error?: string;
  }
>(({ label, error, className = '', ...props }, ref) => (
  <div className="input-group">
    {label && (
      <label htmlFor={props.id} className="input-label">
        {label}
      </label>
    )}
    <input
      ref={ref}
      className={`input ${error ? 'input-error' : ''} ${className}`}
      aria-invalid={!!error}
      aria-describedby={error ? `${props.id}-error` : undefined}
      {...props}
    />
    {error && (
      <span 
        id={`${props.id}-error`}
        className="error-message"
        role="alert"
      >
        {error}
      </span>
    )}
  </div>
));

// 사용 예시
const App = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input
        ref={inputRef}
        id="username"
        label="사용자 이름"
        placeholder="이름을 입력하세요"
        error="이 필드는 필수다"
      />
      <button onClick={focusInput}>입력창 포커스</button>
    </div>
  );
};

6. 제네릭을 활용한 타입 안전성

설명

TypeScript의 제네릭을 활용해서 타입 안전성을 유지하면서 다양한 데이터 타입을 처리할 수 있다. 런타임 에러를 줄이고 개발 시점에 타입 검증이 가능하다.

예제

interface SelectOption<T = string> {
  value: T;
  label: string;
}

interface GenericSelectProps<T> {
  options: SelectOption<T>[];
  value?: T;
  onChange: (value: T) => void;
  placeholder?: string;
}

const GenericSelect = <T extends string | number>({
  options,
  value,
  onChange,
  placeholder = '선택하세요'
}: GenericSelectProps<T>) => (
  <select 
    value={value || ''} 
    onChange={(e) => {
      const selectedOption = options.find(opt => String(opt.value) === e.target.value);
      if (selectedOption) onChange(selectedOption.value);
    }}
  >
    <option value="">{placeholder}</option>
    {options.map((option) => (
      <option key={String(option.value)} value={String(option.value)}>
        {option.label}
      </option>
    ))}
  </select>
);

// 사용 예시
const App = () => {
  const [selectedColor, setSelectedColor] = useState<string>('');
  const [selectedNumber, setSelectedNumber] = useState<number>(0);

  const colorOptions: SelectOption<string>[] = [
    { value: 'red', label: '빨강' },
    { value: 'blue', label: '파랑' }
  ];

  const numberOptions: SelectOption<number>[] = [
    { value: 1, label: '하나' },
    { value: 2, label: '둘' }
  ];

  return (
    <div>
      {/* 문자열 타입 */}
      <GenericSelect
        options={colorOptions}
        value={selectedColor}
        onChange={setSelectedColor}
        placeholder="색상을 선택하세요"
      />
      
      {/* 숫자 타입 */}
      <GenericSelect
        options={numberOptions}
        value={selectedNumber}
        onChange={setSelectedNumber}
        placeholder="숫자를 선택하세요"
      />
    </div>
  );
};

확장성 체크리스트

  • 다양한 use case에서 재사용 가능한가?
  • 새로운 요구사항 추가 시 기존 코드 수정 없이 확장 가능한가?
  • 접근성 가이드라인을 준수하는가?
  • 타입 안정성이 보장되는가?
  • 성능상 문제가 없는가?

정리

컴포넌트 확장성의 핵심은 단순히 props를 받는 것을 넘어서, 구조적으로 재사용 가능하고 조합 가능하며, 타입 안전하게 설계하는 것이다.

  • 다형성: 동일한 로직으로 다양한 요소 렌더링
  • Compound Component: 의미있는 구조와 상태 공유
  • Render Props: 렌더링 로직의 완전한 위임
  • HOC: 공통 로직의 일괄 적용
  • forwardRef: DOM 직접 접근을 통한 확장성
  • 제네릭: 타입 안전성과 재사용성

이런 패턴들을 적절히 조합하면 확장 가능하고 유지보수하기 좋은 컴포넌트를 만들 수 있다.

0개의 댓글