React 디자인 패턴을 Vue로 옮겨보자 -1 (Compund Component Pattern)

쭌로그·2025년 10월 27일

오래 미뤄왔던 블로그를 다시 끄적끄적 하려고합니다..! Vue를 개발하면서 React의 디자인 패턴을 Vue에서도 사용하면 좋겠다는 생각이 들어 React에서 자주 사용하는 패턴을 Vue로 옮겨보려고합니다. 오늘은 그 중에서도 가장 많이 사용되는 compound component pattern을 옮겨보겠습니다!!

1. Compound Component Pattern이란?

Compound Component Pattern은 React, Vue와 같은 라이브러리에서 하나의 컴포넌트를 여러 개의 하위 컴포넌트로 나누되, 상호 의존성을 자연스럽게 유지하기 위한 디자인 패턴입니다.

<RadioGroup name="fruit">
  <Radio value="apple"  onChange={...} checked={true} />
  <Radio value="banana"  onChange={...} checked={false} />
</RadioGroup>

위는 Compound Component 패턴을 적용하지 않았을 때의 모습입니다. 위 방식으로 구현한다면 Radio 컴포넌트에 onChange, checked를 일일이 넘겨줘야 해서 props drilling 문제가 발생할 수 있습니다. 이러한 반복적인 props를 전달하는 상황이 생길 때 Compound Component 패턴이 해결책이 될 수 있습니다.

Compound Component의 핵심이 부모를 통해 자식에게 상태를 '보이지 않게 전달한다'이기 때문입니다. 부모에게 필요한 상태, 메서드를 전달하고 하위 요소에 전달하는 방식으로 구현합니다. 이를 위해 React는 Context APIVue에서는 provide/inject 기능을 사용합니다.

2. React로 Compound Component Pattern 구현하기

import { createContext, useContext, type ReactNode } from "react";

/**
 * RadioGroup 내부에서 Radio들이 공유하는 상태(Context)
 * - name: input name 속성 (같은 그룹임을 표시)
 * - value: 현재 선택된 라디오의 값
 * - onChange: 선택이 변경될 때 실행되는 함수
 */
interface RadioGroupContextProps {
  onChange:(value: string) => void;
  name: string; 
  value: string; 
}

interface RadioGroupProps extends RadioGroupContextProps {
  children: ReactNode // 하위 Radio Component
  className?: string;
  defaultValue?: string;
}


const RadioGroupContext = createContext<RadioGroupContextProps | null>(null);

export function RadioGroup({name, value, onChange, children, className}: RadioGroupProps) {



  return (
    <RadioGroupContext.Provider value={{name, value, onChange}}>
      <fieldset className={className}>
          {children}
      </fieldset>
    </RadioGroupContext.Provider>
  )
}

export const useRadioGroup = () => {
  const radioGroupContext =  useContext(RadioGroupContext);
  if(!radioGroupContext) throw Error('Radio는 Group안에서만 사용할 수 있습니다.');
  return radioGroupContext;
}

Radio Group에서는 Context API를 통해 부모 요소에서 받은 Props를 하위 요소에게 전달하는 역할을 수행하며 공통 로직에 대한 처리를 담당합니다.

import {  type ReactNode } from "react";
import { useRadioGroup } from "./group/RadioGroup";

interface RadioProps {
  disabled?: boolean;
  className?: string;
  defaultChecked?: boolean;
  children: ReactNode;
  value: string;
}

function Radio({ children, value, disabled, defaultChecked=false }:RadioProps) {
  const group = useRadioGroup();

  return (
    <label>
      <input
        type="radio"
        value={value}
        name={group.name}
        defaultChecked={defaultChecked}
        disabled={disabled}
        checked={group.value !== undefined ? value === group.value : undefined}
        onChange={(e) => group.onChange && group.onChange(e.target.value)}
      />
      {children}
    </label>
  );
}

export default Radio;

Radio 컴포넌트에서는 Context API에서 받은 값과 Props로 받은 요소들을 조합하여 컴포넌트 값을 바인딩합니다. 이런 방식으로 구현하면 Radio 컴포넌트에 많은 컴포넌트가 생기는 현상을 방지할 수 있어 가독성이 높아지고 확장성이 향상됩니다.


import { useState } from 'react'
import { RadioGroup } from './components/group/RadioGroup'
import Radio from './components/Radio'
function App() {
  const [fruit, setFruit] = useState('');
  return (
    <>
    <RadioGroup name="tools" value={fruit} onChange={(value:string) => setFruit(value)}>
        <Radio value="email">이메일</Radio>
        <Radio value="phone">전화</Radio>
    </RadioGroup> 
    </>
  )
}

export default App

3. Vue에서 Compound Component Pattern 구현하기

<script setup lang="ts">
import { provide } from 'vue';
// Context 타입 정의
export interface RadioGroupProvideProps {
  name: string;
  value: string
 onChnage:(value: string) => void
}

// Props 정의
interface RadioGroupProps {
  name: string;
  value: string
  className?: string
}

interface RadioGroupEmits {
  (e: 'change',value:string): void;
}


const props = defineProps<RadioGroupProps>()

const emits = defineEmits<RadioGroupEmits>()

// inject의 키와 동일해야함.
provide<RadioGroupProvideProps>('RadioGroup', {
  name: props.name,
  value: props.value,
  onChnage:(value: string) => emits('change', value)
})

</script>

<template>
  <fieldset :class="className">
    <slot />
  </fieldset>
</template>

리액트와 비슷하게 Vue 또한 부모 컴포넌트에서 props 외에 데이터를 전달하는 방법이 있습니다. Provide/inject 문법입니다. 부모 요소에서 provide를 설정하고 inject로 가져오는 방식입니다. 이 때, 각 매개변수의 첫번째는 식별자입니다. provide/inject의 키 값을 동일하게 맞춰야 사용할 수 있습니다. 때문에 보통 변수로 Symbol 변수로 선언하고 다른 파일에서 import 하는 방식으로 자주 사용합니다.

<script setup lang="ts">
import { inject } from 'vue';
import type { RadioGroupProvideProps } from './group/RadioGroup.vue';

interface RadioProps {
  value: string;
  disabled?: boolean;
  className?: string;
}

const group = inject<RadioGroupProvideProps>('RadioGroup');

const props = withDefaults(defineProps<RadioProps>(), {
  disabled: false,
  className: ''
})

// Context 가져오기 (없으면 에러)
if (!group) {
  throw new Error('Radio는 RadioGroup 안에서만 사용할 수 있습니다.');
}
</script>

<template>
  <label :class="props.className">
    <input
      type="radio"
      :value="value"
      :name="group.name"
      :checked="props.value === group.value"
      :disabled="disabled"
      @change="() => group.onChnage(props.value)"
    />
    <slot />
  </label>
</template>

자식 컴포넌트는 React와 매우 흡사합니다. inject로 부모 요소의 값을 받는다 외에는 동일하게 보여집니다.


<script setup lang="ts">
import { ref } from 'vue';
import RadioGroup from './components/group/RadioGroup.vue';
import Radio from './components/Radio.vue';

// React의 useState와 동일
const fruit = ref<string>('');
</script>

<template>
  <RadioGroup
    name="tools"
    :value="fruit"
    :onChange="(value: string) => (fruit = value)"
    v-slot="{Radio}"
  >
    <Radio value="email">이메일</Radio>
    <Radio value="phone">전화</Radio>
  </RadioGroup>
</template>

선언 방식 또한 문법의 차이일 뿐 방식은 동일합니다.

정리

오늘은 React와 Vue의 Compound Component Pattern을 직접 구현하였습니다. 부모에서 자식 요소에게 데이터를 전달하는 방식 자체는 Vue가 더 편한것처럼 보이지만 타입의 props, emits 등 다양한 선언으로 인해 타입과 코드의 길이가 좀 더 늘어나는 것 같습니다. 글 읽어주셔서 감사합니다!

profile
매일 발전하는 프론트엔드 개발자

0개의 댓글