티스토리 뷰

 
 

 
리액트에는 DRY 원칙이라는 개념이 있다. 
 

"Do no Repeat Yourself"

 
 
개발을 할 때 동일한 소스코드가 반복되는 것을 막으라는 뜻이다.
 
동일한 소스코드가 반복되는 경우 추후 수정사항이 생기면 반복되는 모든 코드를 찾아서 수정 해야 하기 때문에 잠재적인 버그의 위협을 증가시킨다. 
 
이것은 프로젝트의 규모가 커질수록 반복되는 코드로 인한 유지 보수 오버헤드가 커지게 되므로 작은 프로젝트부터 코드를 반복해서 사용하지 않는 습관이 중요하다.  
 

- 오버헤드란?

오버헤드란 프로그램의 실행흐름에서 나타나는 현상중 하나로, 예를 들어 프로그램의 실행흐름 도중에 동떨어진 위치의 코드를 실행시켜야 할 때 추가적으로 시간,메모리,자원이 사용되는 현상이다.
한마디로 정의하자면,  오버 헤드는 특정 기능을 수행하는데 드는 간접적인 시간, 메모리 등 자원을 말한다. 
ex. 10초 걸리는 기능이 간접적인 원인으로 20초걸린다면 오버헤드는 10초가 되는것이다. 

 
그렇다면 동일한 소스코드를 반복하지 않으려면 어떻게 해야할까?
답은 컴포넌트의 재사용성을 증대시키면 된다.
 
따라서 오늘은 잘못된 추상화의 예시를 통해 컴파운드 패턴, render props 기법을 살펴보면서 어떻게 컴포넌트의 재사용성과 유연함을 증대시킬 수 있는지 알아보겠다.
 
 
 

1. 잘못된 추상화


추상화가 뭘까?
처음 이 말을 들었을 때 잘 와닿지 않았다. 
 
결론부터 말하자면 추상화 복잡한 문제를 더 단순하고 재사용 가능한 형태로 구조화하여 관리하는 프로그래밍 개념이다. 리액트에서 추상화는 주로 다음과 같은 방식으로 사용된다.
 
1) 컴포넌트 추상화

  • 리액트에서는 UI를 구성하기 위해 컴포넌트라는 기본 단위를 사용한다.
  • 컴포넌트는 특정 기능을 가진 UI 요소를 캡슐화한 것이다. 예를 들어, 버튼, 입력 필드, 리스트 등과 같은 UI 요소를 각각의 컴포넌트로 추상화하여 사용할 수 있다.
  • 컴포넌트를 활용하면 UI를 더 작고 재사용 가능한 단위로 나눌 수 있음과 동시에, 재사용성을 높이고 코드의 복잡도를 줄일 수 있다.

2) hooks 추상화

  • 리액트 16.8 버전부터 도입된 Hooks는 함수형 컴포넌트에서 상태와 부수 효과(side effects)를 관리하는 추상화 방법이다.
  • useState, useEffect와 같은 기본 Hooks 외에도, 사용자 정의 Hooks를 만들어서 로직을 추상화하고 재사용할 수 있다.
  • 사용자 정의 Hooks를 통해 컴포넌트의 로직을 분리하여 코드의 가독성과 유지 보수성을 향상시킬 수 있다.

3) 상속과 합성

  • 리액트는 주로 합성(composition)을 통해 추상화를 구현한다.
  • 합성은 여러 컴포넌트를 조합하여 더 큰 컴포넌트를 만드는 방식을 의미한다.
  • 상속을 사용하기보다는 합성을 통해 추상화를 수행함으로써 더 유연하고 확장 가능한 코드를 작성할 수 있다.

4) 컨텍스트(Context) 추상화

  • 리액트의 컨텍스트 API를 사용하여 상태나 데이터를 전역적으로 관리하고, 여러 컴포넌트 사이에서 공유할 수 있다.
  • 컨텍스트를 통해 데이터나 상태 관리를 추상화함으로써 전체 애플리케이션의 구조를 단순화할 수 있다.

5. 고차 컴포넌트(HOC)

  • 고차 컴포넌트는 컴포넌트를 매개변수로 받아서 새로운 컴포넌트를 반환하는 함수이다.
  • 이를 통해 공통적인 로직이나 기능을 추상화하여 여러 컴포넌트에 재사용할 수 있다.
  • HOC를 사용하게되면 발생하는 대표적인 문제가 컴포넌트 뎁스가 깊어지는, 즉 wrapper hells가 발생하게된다. 이렇게 되면 디버깅의 관점에서 데이터의 흐름을 파악하기가 힘들어지게된다. 지금은 잘 채택되지 않는다.

만약 추상화를 잘못한다면, 불필요하게 복잡해지거나, 코드의 재사용성이 저하되는 등 다양한 문제가 발생한다.
 
다음의 예시를 살펴보자.

/* 컴포넌트 탄생! 깔끔하다 ✨ */
<Dialog
  title="안내"
  description="이것은 멋진 내용을 담고 있는 안내입니다."
  button={{
    label: '확인',
    onClick: doSomething,
  }}
/>

/**************************/
/********* 1주일 뒤 *********/
/**************************/

/**
 * "다이얼로그 버튼이 하단에 있던데, 상단에 있는 경우도 필요합니다!"
 *
 * -> props 추가 (buttonPosition)
 */
<Dialog
  title="안내"
  description="이것은 멋진 내용을 담고 있는 안내입니다."
  button={{
    label: '확인',
    onClick: doSomething
  }}
  buttonPosition="top"
/>


/**************************/
/********* 2주일 뒤 *********/
/**************************/

/**
 * "두 개의 버튼이 있는 다이얼로그가 필요해요! 둘 중 하나는 Primary, 하나는 Secondary 타입으로요"
 *
 * -> props 변경 (button -> buttons, variant 추가)
 */
<Dialog
  title="안내"
  description="이것은 멋진 내용을 담고 있는 안내입니다."
  buttonPosition="top"
  buttons={[
    {
      label: '확인',
      onClick: doSomething,
      variant: 'primary',
    }, {
      label: '취소',
      onClick: doSomethingElse,
      variant: 'secondary',
    },
  ]}
/>


/**************************/
/********* 1개월 뒤 *********/
/**************************/

/**
 * "버튼이 세로로 나열되어 있는 다이얼로그도 추가해주세요!"
 * "title 위에 아이콘도 하나 넣어주세요!"
 *
 * -> props 추가 (buttonAlign, iconAboveTitle)
 */
<Dialog
  title="안내"
  description="이것은 멋진 내용을 담고 있는 안내입니다."
  buttonPosition="top"
  buttons={[
    {
      label: '확인',
      onClick: doSomething,
      variant: 'primary',
    }, {
      label: '취소',
      onClick: doSomethingElse,
      variant: 'secondary',
    },
  ]}
  buttonAlign="vertical"
  iconAboveTitle="fancy-icon"
/>


/**************************/
/********* 6개월 뒤 *********/
/**************************/

<Dialog
  {...프많쓰않} // 프롭스가 많지만 쓰지 않는다
/>

 

위 예시를 보면 시간이 지날수록 props의 개수가 계속해서 증가하는 것을 볼 수 있다.
(이것을 apropcalypse라고 하는데, 요약하자면 새 기능을 추가할 때 props를 마구잡이로 늘리게 되면 해당 컴포넌트를 유지보수 하기 힘들어지는 현상을 말한다.)
 
props가 많아지면 다음과 같은 문제들이 발생한다.

  1. 개발자가 각 props가 어떤 역할을 하는지 파악하기 어려워짐
  2. 파악하기 어려운 props를 설명해주기위한 주석이나 문서 작성 및 관리 필요
  3. 요구사항이 복잡해질수록 기괴한 props명이 나올 확률이 커짐
  4. 이런 종합적인 이유들로, 컴포넌트를 변경하기 어렵고 두려워짐

즉 이런 다양한 문제를 안고 있는 컴포넌트는 추상화가 잘 되었다고 말하기 어렵고, 재사용성은 갖추었지만 유연성은 갖추지 못했을 경우가 많다.
 
이런 컴포넌트들이 가지고 있는 또 다른 특징은 바로 비즈니스 로직이 컴포넌트 안에 들어있다는 것이다.
버튼 개수나 위치, 배열 등 모든 것들은 비즈니스 로직의 한 부분인데, 이러한 규칙은 시간이 지나며 변경될 여지가 많다.
 
즉, 우리는 비즈니스 로직을 밖으로 꺼내야한다.
 
그렇다면 어떻게 비즈니스 로직을 밖으로 꺼낼 수 있을까?
 
 
 

2. 리액트는 상속보다는 조합


조합 이라는 말은 여러 개의 조각을 끼워 맞추는 의미이다.
 
리액트는 조합에 특화된 설계를 가지고 있다. 그러나 위에서 본 <Dialog> 컴포넌트는 여러개의 조각을 쓰기 보다는(조합) 하나의 큰 덩어리를(상속) 사용하는 모습니다.
 
그렇다면 저 자이언트 컴포넌트를 쪼개어 조합을 사용하도록 바꿔보겠다.

// 자이언트 컴포넌트
<Dialog
  iconAboveTitle="fancy-icon"
  title="안내"
  description="이것은 멋진 내용을 담고 있는 안내입니다."
  buttonPosition="bottom"
  buttonAlign="vertical"
  buttons={[{
    label: '확인',
    onClick: doSomething,
    type: 'cta',
  }, {
    label: '취소',
    onClick: doSomethingElse,
    type: 'secondary',
  },]}
/>


// 조합기반 컴포넌트
<Dialog>
  <Dialog.Icon type="fancy" /> 
  <Dialog.Content
    title="안내"
    description="이것은 멋진 내용을 담고 있는 안내입니다."
  />
  <Dialog.ButtonContainer align="vertical">
    <Dialog.Button type="secondary" onClick={doSomethingElse}>
      취소
    </Dialog.Button>
    <Dialog.Button type="primary" onClick={doSomething}>
      확인
    </Dialog.Button>
  </Dialog.ButtonContainer>
</Dialog>

 
 
조합 기반 컴포넌트의 장점은 명확한 props만 남게 되기 때문에 아래와 같은 장점이 있다.

  1. 개발자가 각 props가 어떤 역할을 하는지 파악하기 수월함
  2. props가 명확해서 별도의 문서화를 할 필요가 없어짐
  3. 모호한 props가 없기때문에 작명 고민을 오래 할 필요가 없음
  4. 컴포넌트 명세를 변경해야할때 어디를 고쳐야할지도 명확함

 

 

3. 제어역전 (Inversion of Control)


전과 후의 차이를 살펴보자
 

- Before: 자이언트 컴포넌트일때 담당하던 역할

  1. 전달받은 props값에 따라 내부 UI 컴포넌트 배치 (Title, Description, Button이 어떠한 순서와 조합으로 그려질지 결정)
  2. Title, Description, Button의 style 결정 (글자 크기, 색상, 간격 등)

- After: 조합 버전에서 담당하는 역할

  1. Title, Description, Button의 style 결정

조합 버전에서 기존처럼 어떻게 배치할까에 대한 역할을 <Dialog>가 더이상 담당하지 않는다.
해당 역할은 <Dialog>를 사용하는 개발자에게 넘어갔다.
 
프로그래밍에서 API를 사용하는 쪽으로 특정 역할을 넘기는 패턴을 제어역전 (Inversion of Control, IoC)이라고 부르는데 위와 같이 페이지를 개발 시 컴포넌트를 조합하여 만드는 것도 제어역전의 한 형태라고 볼 수 있다.
 
사실 제어역전 패턴은 이미 흔하 게 사용되고 있다. JS Array 의 map, filter, forEach, reduce가 대표적인 예이다.

// 일반 filter
const dogs = filterDogs(animals)

// 제어역전 filter
const dogs = animals.filter(animal => animal.species === 'dog')

 
1. 일반 filter는 강아지를 필터링한다는 것을 명시함으로 선언적인 코드가 갖는 장점을 갖게 되지만, 필터링 로직이 조금이라도 바뀔 경우 두번째 파라미터로 option 객체를 받는 등 기존 로직을 변경해야 한다.
 
2. 제어 역전 filter는 필터링 기능만 제공하고 어떻게 필터링 할 지는 사용자에 맡기고 있다. 따라서 필터링 로직에 어떠한 변화가 생기든 기존 filter 함수는 그대로 남아있을 수 있다.
 
 
 

4. Compound Components


위에서 들었던 예시도 Compound Components 인데, 이런 합성 컴포넌트는 hooks나 다른 패턴과 같이 사용하면 state를 숨김으로써 더 깔끔하고 명확한 추상화를 제공 할 수 있는 장점이 있다.
 
 
먼저 Tab을 자이언트 컴포넌트로 만든다면 다음과 같이 사용될 것이다.

function Page() {
  const [tab, setTab] = React.useState(initialTab)

  return (
    <Tabs
      items={tabItems}
      onSelectTab={setTab}
      selectedTab={tab}
    />
  )
}

 
 
이후 조합으로 쪼갰다.

function Page() {
  const [selectedTab, setTab] = React.useState(initialTab)

  return (
    <Tabs>
      {tabItems.map(tabItem => (
        <Tabs.Item
          value={tabItem}
          isSelected={selectedTab === tabItem}
          onSelect={setTab}
        >
          {tabItem}
        </Tabs.Item>
      ))}
    </Tabs>
  )
}

 
 
state를 숨긴 모습은 다음과 같다.

function Page() {
  return (
    <Tabs>
      {tabItems.map(tabItem => (
        <Tabs.Item value={tabItem}>
          {tabItem}
        </Tabs.Item>
      ))}
    </Tabs>
  )
}

 
 
state는 state 컴포넌트 트리의 가장 상위에 있는 컴포넌트인 <Tabs>가 가지고 있고, React.Children과 React.cloneElement를 활용해 state를 숨긴 tabs를 만들 수 있다.

// Tabs.js
import React from 'react'

function Tabs({ children }) {
  const [selectedTab, setTab] = React.useState(initialTab)

  return (
    <ul className="tab-container">
      {React.Children.map(children, child => (
        React.cloneElement(child, {
          isSelected: child.props.value === selectedTab,
          onSelect: () => setTab(child.props.value),
        })
      ))}
    </ul>
  )
}

Tabs.Item = ({ isSelected, onSelect, children }) => (
  <li
    onClick={onSelect}
    className={`tab-item ${isSelected ? 'selected' : ''}`}
  >
    {children}
  </li>
)

 
그러나 이 패턴의 경우, Tabs.Item이 Tabs의 바로 아래 자식으로 와야한다는 제약이 생기는데, 이 제약을 없애고 유연하게 변경하고 싶다면 Context를 통해 개선을 해줄 수 있다.


1. Context 를 이용한 개선

// Tabs.jsx
import React from 'react'

const TabContext = React.createContext()

function Tabs({ children }) {
  const [selectedTab, setTab] = React.useState(initialTab)

  return (
    <TabContext.Provider value={{ selectedTab, setTab }}>
      <ul className="tab-container">
        {children}
      </ul>
    </TabContext.Provider>
  )
}

Tabs.Item = ({ value, children }) => {
  const ctx = React.useContext(TabContext)
  if (ctx === undefined) {
    throw new Error('<Tabs.Item> 컴포넌트는 <Tabs> 컴포넌트 아래에서만 사용될 수 있습니다.')
  }
  const { selectedTab, setTab } = ctx
  
  return (
    <li
      onClick={() => setTab(value)}
      className={`tab-item ${selectedTab === value ? 'selected' : ''}`}
    >
      {children}
    </li>
  )
}
// Page.jsx
function Page() {

  return (
    <Tabs>
      {/* 원하는 대로 children을 구성할 수 있습니다. */}
      <BetweenComponent>
        {tabItems.map(tabItem => (
          <Tabs.Item value={tabItem}>
            {tabItem}
          </Tabs.Item>
        ))}
      </BetweenComponent>
    </Tabs>
  )
}

 
이렇게 context를 활용할 경우 컴포넌트 트리에서 Tabs보다 아래쪽에 있기만 한다면, useContext를 통해 해당 값에 접근할 수 있다. 따라서 Tabs의 바로 아래 자식이어야한다는 제약이 사라지게됩니다.
 
즉, Tabs에서 context에 상태를 전달해주고, Tabs.item이 context로부터 상태를 전달받게되는 구조이다.
 
 
 

2. Render Props를 이용한 개선

render props 방식은 한마디로 props혹은 children으로 함수를 전달해주는 방식을 일컫는다.

function Page() {
  return (
    <Layout>
      {/* Tab의 상태는 WithTab 내부로 추상화 */}
      <WithTab items={tabItems}>
        {selectedTab => {
          switch (selectedTab) {
            case 'LOGIN':
              return <Login />
            case 'JOIN':
              return <Join />
            case 'HOME':
            default:
              return <Home />
          }
        }}
      </WithTab>
    </Layout>
  )
}


// 꼭 children으로만 함수를 전달할 필요는 없습니다 :)
function Page() {
  return (
    <Layout>
      <WithTab
        items={tabItems}
        renderContent={selectedTab => {
          switch (selectedTab) {
            case 'LOGIN':
              return <Login />
            case 'JOIN':
              return <Join />
            case 'HOME':
            default:
              return <Home />
          }
        }}
      />
    </Layout>
  )
}

 
 

 
children으로 전달하는 예시를 더 자세히 보면

// Tabs.jsx
import React from 'react'

function Tabs({ children }) {
  const [selectedTab, setTab] = React.useState(initialTab)

  return (
    <ul className="tab-container">
      {children(selectedTab, setTab)}
    </ul>
  )
}

Tabs.Item = ({ isSelected, onSelect, children }) => (
  <li
    onClick={onSelect}
    className={`tab-item ${isSelected ? 'selected' : ''}`}
  >
    {children}
  </li>
)
// Page.jsx
function Page() {

  return (
    <Tabs>
      {(selectedTab, setTab) => (
        tabItems.map(tabItem => (
          <Tabs.Item
            isSelected={tabItem === selectedTab}
            onSelect={() => setTab(tabItem)}
          >
            {tabItem}
          </Tabs.Item>
        ))
      )}
    </Tabs>
  )
}

 
1. Tabs 컴포넌트가 children을 함수로써 호출하고있다.
2. Tabs를 사용하는 Page컴포넌트에서 Tabs의 children으로 함수를 전달한다
 
즉 Tabs 내부에 있는 state를 바깥에서 쓸 수 있도록 만들어주어 유연함을 증대시켜주는데 목적이 있다.
isSelected, onSelect까지 컴포넌트를 사용하는 사용자가 결정하게 바꾸었다.
 
하지만 더 유연하다고 더 좋은 추상화는 아니기때문에 각 컴포넌트에 맞는 적절한 추상화를 고민해 볼 필요가 있다. 또 주의할 점은, render props는 함수를 전달하기때문에 rendering이 일어날때마다 새로운 props로 인식되기 때문에 최적화가 필요하면 useCallback을 사용하거나 render props 사용을 다시 고민해봐야한다.

 
 
 

결론


다양한 사례 (use-case)를 포용할수 있는 컴포넌트는 재사용 가능한, 유연한 컴포넌트라고 할 수 있다.
 
조합과 제어역전을 통해 비즈니스 로직을 컴포넌트 바깥으로 끄집어내어 다양한 사례를 포용할 수 있는 컴포넌트를 만들 수 있지만, 제어역전이 다 좋은건 아니다.
 
코드양이 많아지고 한눈에 컴포넌트가 하는 역할이 보이지 않는다는 단점 또한 존재한다. 만약 변경될 여지가 있어보이는 코드에 제어역전을 사용하는것은 좋은 안전장치가 될 수 있지만 역할이 명확한 컴포넌트에 가깝다면 제어 역전이 오히려 개발 경험을 해칠수도 있다는 점을 유의해야 한다.
 
 
 

⁕ 위 글은 아래의 글을 바탕으로 재구성 되었습니다.

https://brunch.co.kr/@finda/556

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함