어느날 chat gpt에게 합성 컴포넌트 패턴에 대해 물어보았다

Sally·2023년 4월 3일
0

What-I-Learn

목록 보기
6/6

재사용 가능한 컴포넌트를 만드는데 효과적인 패턴이 있다면서요?

최근, 디자인 시스템을 제작하는 사이드 프로젝트에 참가하게 되었다. 🕵️

이 때문에 재사용 가능한 컴포넌트를 어떻게 구현해야할지 고민에 고민을 하고 있는 중이다.🤔

고민을 거듭 하던 중 머리 속 번뜩이는 용어 하나
합성 컴포넌트 패턴 !

합성 컴포넌트 패턴을 사용하면 재사용 가능한 컴포넌트를 만들기에 편리하다는 이야기가 뇌의 어딘가에 저장되어 있었다. (어디선가 들은 기억이 ... 🧐)

그래서 이번 기회에 합성 컴포넌트 패턴 에 대해서 자세히 알아보고 글로 작성하고자 해본다.

(그런데 이제 chat gpt를 추가한)

chat gpt! 합성 컴포넌트 패턴을 알려줘!

합성 컴포넌트에 대한 힌트를 얻기 위해서 chatgpt에 질문을 해보았다.

합성 컴포넌트 패턴이 뭐야?

우리의 chat gpt는 꽤나 길고 상세한 답변을 주었다.

한국어로 번역해서 내용을 요약하자면 아래와 같다.

  • 정의
    복잡한 구성 요소를 만들기 위해 결합할 수 있는 작고 재사용 가능한 구성 요소 집합을 만드는 디자인 패턴

  • 특징

    • 개발자가 이해, 수정 및 유지 관리가 더 쉬운 인터페이스를 만들 수 있게 도와준다
    • 여러 개의 기능이나 측면이 있는 컴포넌트를 작고 재사용가능한 컴포넌트로 분리한다
    • 사용자가 유연성을 가지고 요구사항에 맞게 구성 요소들을 구성하고 결합할 수 있다

🧩 🤔 chat gpt의 설명을 읽었을 때의 이해점

chat gpt의 설명과 예전에 들은 합성 컴포넌트에 대한 지식을 조합하여 합성 컴포넌트의 특징에 대해서 고민해보았다.

insight # 1

가장 먼저 떠오른 생각은

"상황이 optional인 상황에서 유용하겠다" 라는 생각이였다.

chat gpt에 따르면 합성 컴포넌트는여러 개의 기능이나 측면이 있는 컴포넌트를 하나 하나의 함수로 분리하고 이를 다시 조합하여 사용하는 컴포넌트이다.

즉, 아래와 같이 특정 기능을 담당하는 A,B 각각의 컴포넌트를 가지고
A, B로 이루어지거나 or A로만 이루어지거나 or B로만 이루어지도록 구성하여 새로운 컴포넌트를 만들 수 있을 것이다.

<Container>
	<AItem/>
    <BItem/> 
</Container>

or 

<Container>
	<AItem/>
    <AItem/> 
</Container>

or 

<Container>
	<BItem/>
    <BItem/> 
</Container>

insight # 2

두 번 째로는

나의 기억 속에 있는 여러 개의 컴포넌트들 중
어떤 컴포넌트가 합성 컴포넌트 패턴에 어울릴 것인지 고민해보았다. 🤔👀

여러 개의 컴포넌트들 중 2가지 컴포넌트가 떠올랐다.

첫 번째는 NavBar이다.

공식이라는 프로젝트에서

로그인 여부에 따라
로그인 되어 있을 경우 => 유저의 프로필 이미지
로그인 안 되어 있을 경우 => 기본 icon

를 보여주는 NavBar컴포넌트를 구현한 경험이 있다.

그 때에
유저의 프로필 이미지를 보여주는 UserProfile컴포넌트가 있었는데 그곳 내부에서 로그인 여부에 따라 icon을 보여주거나 프로필 이미지를 보여주도록 구현하였다.

이때의 구현 방법은 로직이 컴포넌트 내부에 숨겨져 있어서 다시 유지 보수 하기에는 어려움이 있을 것 같다고 생각 하고 있었다.

그래서 합성 컴포넌트를 적용하여
다른 곳에서도 사용되는 UserProfile 컴포넌트는 로그인 되었을 때에만 사용되는 컴포넌트로 두고 NavBar에서 종속된 UserIcon컴포넌트는 아래와 같이 구현하면 더 효과적일 것 같다는 생각이 들었다.

<NavBar>
	<NavBar.Write />
    {
    	isLogin ? <UserProfile /> : <NavBar.UserIcon /> 
    }
    <NavBar.Menu /> 
</NavBar>

두 번째는 Select이다.

select가 역할을 하는 컴포넌트를 구현할 때에는 꽤나 많은 폼이 들어간다.

만약, 재사용성을 고려하여 경우에 따라서 select에 들어갈 option들이 달라진다면 그 복잡성은 더 올라간다.

그렇기 때문에 페이지에 따라서

<Select>
	<BItem />
    <AItem />
</Select>
or
<Select>
	<AItem />
    <BItem /> 
</Select>

이렇게 option의 순서나 종류를 바꿔야 할 때에 합성 컴포넌트 패턴을 사용하면 효과적일 것 같다.

나의 이런 생각들이 맞는지 확인해보기 위해 이번에는 chat gpt에게 자세한 예시를 보여달라 요청했다.

show me example 👀

예시를 묻자
chat gpt는 DropDown의 코드를 알려주었다.

chat gpt가 말하는 DropDown에 들어갈 기능은 3가지 이다.

  1. menu를 open그리고 close로 toggle이 될 것
  2. menu item을 고르고,고른 item의 value를 볼 수 있다
  3. 각각의 menu item을 위한 설명이나 아이콘과 같은 추가적인 설명을 넣을 수 있다

chat gpt가 선보인 코드

꽤나 긴 코드이지만 chat gpt가 설명해준 그대로 복사해본다

function Dropdown({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState(null);

  function toggleMenu() {
    setIsOpen(!isOpen);
  }

  function handleItemClick(itemValue) {
    setSelectedItem(itemValue);
    setIsOpen(false);
  }

  return (
    <div className="dropdown">
      <button onClick={toggleMenu}>Toggle Menu</button>
      {isOpen && (
        <ul className="dropdown-menu">
          {React.Children.map(children, (child) => {
            // check if child is a DropdownItem component
            if (child.type.name === 'DropdownItem') {
              // clone the DropdownItem component and pass additional props
              return React.cloneElement(child, {
                onItemClick: handleItemClick,
                isSelected: selectedItem === child.props.value,
              });
            }
          })}
        </ul>
      )}
      {selectedItem && (
        <div className="selected-item">
          Selected Item: {selectedItem}
        </div>
      )}
    </div>
  );
}

function DropdownItem({ value, children, onItemClick, isSelected }) {
  function handleClick() {
    onItemClick(value);
  }

  return (
    <li onClick={handleClick} className={isSelected ? 'selected' : ''}>
      {children}
    </li>
  );
}

// usage example
<Dropdown>
  <DropdownItem value="option1">Option 1</DropdownItem>
  <DropdownItem value="option2">Option 2</DropdownItem>
  <DropdownItem value="option3">Option 3</DropdownItem>
</Dropdown>

그리고 이것을 그대로 코드를 실행시켜보았다

.
.
.
???

조금은 엉성하다😅
스타일링이 제대로 되어 있지 않아 그런거라는 희망과 함께
사용된 함수들에 대해서 살펴보고 코드를 수정해보자.

낯선 함수들에 대해서 살펴보자 🤔

코드를 본격적으로 수정하기 전, chat gpt가 답해준 코드에서 사용된 함수들에 대해서 알아보고 가자.

Children.map

코드를 살펴보다 보면 React.Children.map이라는 다소 익숙하지 않은 함수가 사용되고 있는 것을 볼 수 있다.

{React.Children.map(children, (child) => 

이에 대해서 React beta의 설명은 아래와 같다.

Children.map(children, fn, thisArg?)

  • children
    : Component가 받은 children props 값
  • fn
    : 자바스크립트의 map 콜백 함수와 유사한 함수이다. child를 첫 번째 인수로, index 값을 두 번째 인수로 받아 호출한다. React 노드를 반환해야한다
  • thisArg
    : this 값을 지정할 수 있다.

chat gpt는 해당 api를
children의 형태로 받은 각각의 component들에 접근하여 함수를 제공해주는 데에 사용하고 있다.

React.cloneElement

Children.map 내부의 child에 부모 컴포넌트에서 구현한 함수를 props로 넘겨주기 위해 React.cloneElemt가 사용되고 있다.

return React.cloneElement(child, {
                onItemClick: handleItemClick,
                isSelected: selectedItem === child.props.value,
              });

위와 마찬 가지로 React beta의 설명을 찾아보았을 때는 아래와 같은 답을 주는 것을 알 수 있다.

cloneElement(element, props, ...children)

  • element
    : 유효한 React element를 넣기
  • props
    : 객체 이거나 null 이여야 한다. null을 넘길 경우 original props를 그대로 넘겨준다. 객체를 넘겨줄 경우 기존의 props와 합쳐져 넘겨지게 된다. 만약 key와 ref값을 넘기지 않더라도 기존의 값을 그대로 넘겨준다
  • children
    : React의 자식 노드들을 설정하여 넘겨줄 수 있다

즉, Element를 복사하여 복사한 element에 새로운 props들을 추가하여 넘기는데 사용할 수 있는 api이다.

그렇지만, React docs beta에서는 해당 api 사용을 아래의 3가지 이유 때문에 renderItem, Context API, Custom Hook 으로 대체하여 사용하기를 권장하고 있다.

  1. original element를 수정할 수 없다
  2. children이 동적인 경우 동적인 children에 대해서 key가 누락 될 수 있다.
  3. 데이터 흐름을 파악하기 힘들다

리액트에서 React.cloneElement사용을 권장하지 않고 있으니 chat gpt가 제시해준 코드에서

Context API로 교체 +
typescript을 적용하여 코드를 수정해보자.

합성 컴포넌트 최최종본 🩹

Context API를 통해서 값을 공유하자

React.cloneElement를 통해서 컴포넌트를 복사하여 props로

DropDown 컴포넌트 내에서 선언된
함수 handleItemClick + 상태값 selectedItem 변수 값을
props로 넘겼던 것에서

Context api를 통해서 공유하기 위해 createContext를 해주자

const DropDownContext = createContext<{
  handleItemClick: (valueItem: string) => void;
  selectedItem: string;
} | null>(null);
function Container({
  children,
}: {
  children:
    | ReactElement<PropsWithChildren<DropDownItemProps>>[]
    | ReactElement<PropsWithChildren<DropDownItemProps>>;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<string>('');

  function toggleMenu() {
    setIsOpen(!isOpen);
  }

  function handleItemClick(itemValue: string) {
    setSelectedItem(itemValue);
    setIsOpen(false);
  }

  return (
    <Box>
      <ToggleButton onClick={toggleMenu}>메뉴 열기</ToggleButton>
      {isOpen && (
        <ul>
          <DropDownContext.Provider value={{ handleItemClick, selectedItem }}>
            {children}
          </DropDownContext.Provider>
        </ul>
      )}
      {selectedItem && <div>Selected Item: {selectedItem}</div>}
    </Box>
  );
}

DropDown의 children으로 하나의 원소만 올 수 도 있으니
children의 타입을

ReactElement<PropsWithChildren<DropDownItemProps>>[] | ReactElement<PropsWithChildren<DropDownItemProps>>

으로 정하게 되었다.

또한 기존에 React.cloneElement를 통해서 넘기고 있던 함수들을
Context Provider의 value로 넘겨주어 children에서 사용할 수 있도록 하였다.

interface DropDownItemProps {
  value: string;
}

function Item({ value, children }: PropsWithChildren<DropDownItemProps>) {
  const context = useContext(DropDownContext);
  if (!context) {
    throw new Error('context api 사용 불가');
  }
  const { handleItemClick, selectedItem } = context;
  function handleClick() {
    handleItemClick(value);
  }

  return (
    <LiItem onClick={handleClick} isSelected={selectedItem === value}>
      {children}
    </LiItem>
  );
}

이제, DropDownItem은 props로 item에 보여줄 value만을 받지 않는다

그대신 ContextAPI 를 통해서 함수 값을 받아오고 있기 때문에 context 여부에 따른 예외 처리를 해주어야 한다.

const context = useContext(DropDownContext);
  if (!context) {
    throw new Error('context api 사용 불가');
  }

export

내보낼 때에는 Object.assign을 활용하여 묶어서 사용할 수 있도록 한다.

const DropDown = Object.assign(Container, {
  item: Item,
});

export default DropDown;

사용하는 곳에서

<DropDown>
        <DropDown.item value="option1">option 1</DropDown.item>
        <DropDown.item value="option2">option 2</DropDown.item>
        <DropDown.item value="option3">option 3</DropDown.item>
</DropDown>

사용하는 곳에서는 각각의 컴포넌트에 value값을 다르게 주어 구현이 가능해진다

이제 이 코드에 아주 조금의 스타일을 추가하여 결과물을 살펴보면 아래와 같다.

결론

이번 기회에 처음으로 합성 컴포넌트 패턴을 사용하여 컴포넌트를 구현해보았다.
구현 과정이 Context API를 사용하거나 컴포넌트를 여러개로 쪼개놓기 때문에 구현하는 데에 꽤나 복잡하다고 생각이 들었다.

하지만 선언적이라는 리액트의 철학을 다시 한번 생각해보았을 때
해당 철학이 잘 녹여져 있는 패턴이라고 생각이 들었다.

사용 되는 곳에서 선언적으로 (한 단계 더 들어갈 필요 없이) 해당 컴포넌트에 어떤 구성 요소들이 있는지 확인할 수 있으니 말이다.

나의 가정인 NavBar에서 합성 컴포넌트 패턴 사용이 적절한지는 사실 아직 애매하긴 하다.
isLogin여부를 NavBar에서 분기처리해도 되지 않는가? 라고 말한다면 딱히 반박할 수는 없지만 그래도 isLogin을 Context api와 같은 전역 상태 관리 도구에서 관리하고 있다면 효과적인 패턴이 될 수 있을 것 같다는 생각이 들었다.

디자인 시스템을 해나가면서 오늘 이렇게 배운 합성 컴포넌트에 대해 좀 더 탐구하여 블로그 글을 추후 포스팅하겠다

0개의 댓글