webedit button

변경에 유연한 컴포넌트

48 min read|21. 9. 27.

이번 포스트에서는 변경에 유연하게 대응할 수 있는 컴포넌트에 대해 이야기해보려고 한다

TL;DR

  • 컴포넌트는 데이터를 중심으로 추상화한다.
  • 일반적인 인터페이스로 컴포넌트를 디자인한다.

변경에 유연하다는 것

우리가 작성하는 소프트웨어는 지속가능해야 한다. 클린 코드에 입각하여 코드가 잘 읽히도록 작성하는 것 그 자체가 목적이 되어서는 안 된다. 우리가 작성하는 코드는 예상할 수 없는 변경에 그나마 유연하게 대응할 수 있어야 한다.

Why?

소프트웨어는 끊임없이 변하기 때문이다. (우리는 어떤 상황을 마주하게 되는가?)

진행하고 있는 프로젝트가 그 어떠한 변경 없이 처음에 기획한 대로 출시되었던 적이 있는지 생각해보자. 출시를 하고 나서 그 어떠한 수정이 없던 적이 있는지 생각해보자. 이런 경우보다 출시하기 전에도 수십 번의 변경이 일어나지 않던가? 수정을 반복하는 개발자 입장에서 변경 사항을 빠르게 반영할 수 있다면 좋지 않을까?

코드가 변경에 유연해야 하는 이유는 사업적인 면에서도 옳다. 제품에 어떤 변경사항을 반영하는데 오래 걸린다면 그 변경사항이 미치는 영향을 늦게 파악하게 된다. 좋은 영향이라면 그만큼 손해를 본 것이고 좋지 않은 영향이라면 빠르게 새로운 가설을 세워야 하기 때문에 또 그만큼 손해를 본 것이다.

결국 제품을 만들 때, 비즈니스의 확장과 변경을 대비할 수 있어야 하는데 미래를 누구도 예측할 수는 없다. 그렇기에 변경에 그나마 유연하게 대응할 수 있도록 코드를 작성해야 하는 것이다.

오늘도 컴포넌트를 수정하고 있는데, 변경에 유연하게 대응할 수 있는 컴포넌트는 어떻게 만들 수 있을까?

컴포넌트의 역할과 책임

소프트웨어 설계에 법칙이란 존재하지 않는다. 원칙만 있을 뿐이고 원칙에는 예외가 넘쳐난다.
오브젝트: 메시지와 인터페이스 p199

어떤 컴포넌트를 설계하는 방법은 한 가지 이상일 수 있다. (대부분 그렇다.) 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물이다. 법칙을 만드려다가 그 법칙에 매몰되는 경우도 생긴다.

우리가 작성한 애플리케이션은 컴포넌트의 집합으로 구성된다. 그렇다면 애플리케이션을 구성하고 있는 컴포넌트는 무엇인가? 어떤 역할을 하고 있는가?

컴포넌트?

컴포넌트라는 개념은 프런트엔드에만 국한된 개념은 아니다. 엔지니어링 측면에선 어떤 시스템을 구성하는 여러 요소 중 하나를 컴포넌트라고 할 수 있다. 프런트엔드 애플리케이션의 경우에는 시스템을 **UI(User Interface)**로 보고 UI를 구성하고 있는 요소를 컴포넌트라고 할 수 있다.

왜 컴포넌트로 나누어 개발을 할까?

그렇다면 왜 컴포넌트로 나누어 개발을 할까? 어느 제품이든 UI에는 일정한 패턴을 두고 반복되는 요소들이 존재한다. 그렇기 때문에 반복되는 요소들을 매번 개발하기보다는 미리 만들어둔 요소를 재사용하여 여러 가지 이득을 취할 수 있기 때문에 컴포넌트로 개발하곤 한다.

컴포넌트 기반으로 개발하여 반복되는 요소들 간의 일관성을 유지할 수 있고, 만드는데 필요한 비용을 절감할 수 있다는 것을 경험적으로 체득했다.

단 UI의 일관성을 유지하고 비용을 절감한다는 것은 컴포넌트가 '제대로' 나눠졌을 때의 이야기이다.

컴포넌트가 제대로 나눠지지 않는 이유는 UI를 결정짓는 요소들은 정말 많기 때문이다. 수많은 요소 중 하나라도 달라지면 재사용이 어려워지기 때문에 컴포넌트 기반으로 개발할 때 얻을 수 있는 이점이 사라지게 되는 것이다.

그렇다면 컴포넌트는 어떻게 '잘' 나눌 수 있을까? 기본으로 돌아가서 다시 생각해보자. 소프트웨어 기본 원칙 중 **'역할과 책임'**에 따라서 분리한다라는 개념을 UI 컴포넌트에 가져와보자.

역할과 책임

프런트엔드 애플리케이션이 복잡해지면서 수많은 데이터들을 관리하게 되었다. 데이터를 가져오고 이를 보여주는 부분이 복잡해지자 이 부분을 **'추상화'**하려는 시도들이 생겨났다. SPA(Single Page Application)로 프런트엔드 애플리케이션을 만들면서 이를 컴포넌트 기반으로 보다 선언적인 코드로 작성하게 된 것이다. 그러면서 컴포넌트는 크게 3가지 역할을 담당하게 되었다.

1) 외부로부터 주입된 데이터를 관리한다

컴포넌트 안에서 태어나는 데이터(상태)도 있지만 api, local storage 등 외부 리소스로부터 전달받는다. 외부 리소스의 데이터가 아니더라도 컴포넌트의 부모 컴포넌트에서 전달되는 데이터를 외부 데이터로 볼 수 있다.

2) 데이터를 UI로 표현한다

React와 같은 라이브러리들은 화면에 보이는 페이지 중심이 아니라 데이터 중심으로 돌아간다. 그렇기 때문에 컴포넌트에서 관리하고 있는 데이터를 화면에 어떻게 렌더링 할지 선언하면 된다.

3) 사용자로부터 인터랙션을 받는다

사용자로부터 어떤 인터랙션을 받을지 이벤트 핸들러를 정의해준다. 정의해준 이벤트 핸들러에서 어떤 일이 일어나는지를 작성하곤 한다. 이때 이벤트 핸들러는 컴포넌트에서 관리하는 데이터를 조작하기도 한다.

컴포넌트의 역할을 규정짓는 중 데이터가 빠지지 않는다. 컴포넌트를 데이터 기준으로 나눠야 역할을 기반으로 컴포넌트를 분리할 수 있겠다. 컴포넌트를 데이터 중심으로 나누어보고 이로 인해 발생하는 문제점들도 하나씩 해결해가면서 변경에 유연한 컴포넌트를 만들어보자.

컴포넌트를 역할과 책임을 기준으로 분리해보자.

1. 데이터 기반 설계

외부로부터 주소 목록을 가져와 렌더링하는 페이지를 컴포넌트로 분리해보자. (간결한 예제 코드를 위해 스타일 코드는 제외했다.)

function AddressPage() {
  const [addresses, setAddresses] = useState<주소[] | null>(null)

  const handleSaveClick = (id: string) => {
    // save
  }
  const handleUnsaveClick = (id: string) => {
    // unsave
  }

  useEffect(() => {
    ;(async () => {
      const addresses = await fetchAddressList()

      setAddresses(addresses)
    })()
  }, [])

  return (
    <section>
      <h1>주소목록</h1>
      <ul>
        {addresses != null
          ? addresses.map(({ id, address, saved }) => {
              return (
                <li key={id}>
                  {address}
                  <button onClick={saved ? handleUnsaveClick : handleSaveClick}>
                    {saved ? '저장' : '삭제'}
                  </button>
                </li>
              )
            })
          : null}
      </ul>
    </section>
  )
}

주소 목록을 렌더링하는 AddressPage 컴포넌트는 외부에서 데이터를 가져오고, 그 데이터를 UI로 표현하며 사용자의 인터랙션을 받는다.

데이터 중심으로 컴포넌트 분리하기

'주소목록', '주소'라는 두 개의 데이터를 기준으로 컴포넌트를 분리해보면 어떨까? AddressPage는 다음과 같이 깔끔해질 수 있다.

function AddressPage() {
  return (
    <section>
      <h1>주소목록</h1>
      <AddressList />
    </section>
  )
}

주소목록 데이터를 관리하는 AddressList 컴포넌트로 분리했다.

function AddressList() {
  const [addresses, setAddresses] = useState<주소[] | null>(null);
  // call fetchAddressList

  return (
    <ul>
      {addresses.map(address => {
        return (
          <AddressItem
            key={address.id}
      address={address.address}
            saved={address.address}
            onSaveClick={handleSaveClick}
            onUnsaveClick={handleUnsaveClick}
            {...address}
          />
        );
      })}
    </ul>
  );
}

여기에서 중요한 부분은 주소 목록 데이터를 AddressPage에서 Props로 내려받는 것이 아니라 AddressList가 스스로 데이터를 가져올 수 있도록 한 것이다. 이 AddressList 컴포넌트는 주소 데이터 하나를 관리하는 AddressItem으로 분리할 수 있다.

// 1번 AddressItem
function AddressItem({ address, saved, onSaveClick, onUnsaveClick }: Props) {
  return (
    <li>
      {address}
      <button onClick={saved ? onUnsaveClick : onSaveClick}>
        {saved ? '저장' : '삭제'}
      </button>
    </li>
  )
}

분리를 하고 보니 두 가지 문제점이 보인다.

  1. onSaveClick, onUnSaveClick 부분이 여전히 AddressList 컴포넌트에 정의가 되어있다. 사실 이 핸들러는 주소를 저장하고 저장을 해지하는 액션을 수행하기 때문에 AddressList 컴포넌트의 역할이라기보다 AddressItem의 역할이라고 볼 수 있다.
  2. AddressItem에서 보여줘야 하는 주소 데이터가 추가된다면 어떤 작업을 해줘야 할까? 먼저 AddressItem에서 필요해진 데이터에 대해 props를 정의해주고 그것을 보여주도록 수정해준 다음, AddressList에서 props를 전달해줘야 한다. 하나의 변경이 두 개의 컴포넌트를 변경시키는 것이다.

두 번째 문제점을 살펴보자. 변경이 되면 기존과 동일하다고 보장할 수 없다. 변경으로 인해 두 가지 컴포넌트를 확인해야 하고 두 곳에 버그가 발생할 가능성이 생긴 것이다.

왜 하나의 변경 사항을 반영하기 위해 두 컴포넌트가 변경되어야 했을까? 이 변경 사항으로 인해 두 개의 컴포넌트가 변경되었지만 더 늘어날 수도 있지 않을까?

역할 중심으로 분리하기

핸들러를 AddressItem 컴포넌트에 정의해보면 어떨까?

// 2번 AddressItem
function AddressItem({ id, address, saved }: 주소) {
  const handleSaveClick = () => {
    // save
  }
  const handleUnsaveClick = () => {
    // unsave
  }
  return (
    <li key={id}>
      {address}
      <button onClick={saved ? handleSaveClick : handleUnsaveClick}>
        {saved ? '저장' : '삭제'}
      </button>
    </li>
  )
}

사용자의 인터랙션이 주소 데이터를 기준으로 발생하기 때문에 각각 핸들러에 인자로 전달받고 있던 id: string이 필요 없어졌다. AddressItem 컴포넌트에 위치하면서 역할에 따라 핸들러가 정의되었고, 불필요한 인자가 사라진 것이다.

props도 주소 데이터를 전부 받을 수 있도록 수정되었다. 공통의 주소 모델을 기준으로 인터페이스가 설계되었기 때문에 보여줘야 하는 주소 데이터가 추가된다면 AddressItem 컴포넌트만 수정하면 된다.

여기서 한 가지 살펴볼 수 있었던 것은 데이터 중심의 설계를 하기 위해선 데이터 모델이 Single Source of Truth로 관리되어야 한다는 부분이다.

이 AddressItem을 사용하는 AddressList 컴포넌트는 다음과 같이 재작성된다.

function AddressList() {
  const [addresses, setAddresses] = useState<주소[] | null>(null);
  // call fetchAddressList

  return (
    <ul>
      {addresses.map(address => {
        return <AddressItem key={address.id} {...address} />;
      })}
    </ul>
  );
}

이제 AddressItem은 자신이 맡은 역할만 하고 있을까? 주소 아이템 컴포넌트는 주소를 보여주는 역할을 한다. 그와 동시에 주소를 저장하고 삭제하는 역할도 수행하고 있다. 이 저장, 삭제의 역할은 버튼에게 위임해줄 수 있지 않을까? 다시 핸들러를 역할에 맞게 옮겨보자.

// 3번 AddressItem
function AddressItem({ id, address, saved }: 주소) {
  return (
    <li key={id}>
      {address}
      {saved ? <SaveAddressButton id={id} /> : <UnsaveAddressButton id={id} />}
    </li>
  )
}

function SaveAddressButton({ id }: { id: string }) {
  const handleSaveClick = () => {
    // save
  }
  return <button onClick={handleSaveClick}>저장</button>
}

function UnsaveAddressButton({ id }: { id: string }) {
  const handleUnsaveClick = () => {
    // unsave
  }
  return <button onClick={handleUnsaveClick}>삭제</button>
}

데이터를 중심으로, 컴포넌트의 역할을 중심으로 컴포넌트를 작게 나누어봤다. 컴포넌트를 잘 나눈 것일까?

컴포넌트로 개발하는 이유 중 하나는 만들어둔 컴포넌트를 재사용하여 개발 생산성을 높이는 것이었다. 우리가 나눈 컴포넌트는 재사용이 가능할까?

2. 일반적인 인터페이스 설계

데이터 중심으로 컴포넌트를 재구성했다면 이 컴포넌트들이 재사용 가능한지, 변경에 대응할 수 있는지 살펴봐야 한다. 그러기 위해서는 컴포넌트가 좋은 인터페이스를 가지고 있어야 한다. 좋은 인터페이스란 어떤 것일까?

컴포넌트의 인터페이스

  1. 인터페이스는 사용하는 쪽을 위한 것이다.
  2. 컴포넌트를 사용하는 쪽에선 인터페이스를 보고 어떻게 동작할지 예상 가능해야 한다.

기본에 근거할수록 이해하기 쉽다. 웹의 문서는 HTML로 작성된다. 표준 인터페이스를 따라 인터페이스를 결정하면 의미를 파악하고자 하는 노력 없이 그 의미를 이해할 수 있다.

일반적인 인터페이스

'일반적인' 인터페이스란 구체적으로 다음 두 가지를 의미한다.

  1. 특정 도메인에 대한 맥락을 가지고 있지 않아서 어느 도메인에서든 사용할 수 있는 인터페이스
  2. 사용하는 입장에서 내부를 보지 않고도 이 컴포넌트의 역할을 이해할 수 있는 인터페이스

2-1. 도메인을 모르는 컴포넌트

우리가 방금 전 분리한 컴포넌트들은 주소 목록 페이지에서만 사용할 수 있지 다른 곳에서는 사용할 수 없다. 즉 컴포넌트로 개발하는 이점을 얻지 못한 것이다. 주소 목록을 보여주는 페이지와 동일하게 생겼지만 회원 목록을 보여주는 페이지가 있다면 사용자에게 보이는 인터페이스는 동일하더라도 컴포넌트를 처음부터 다시 만들어야 하는 것이다.

오히려 분리하기 전인 AddressPage 컴포넌트에 한눈에 더 알아보기 쉽기도 하다. 컴포넌트로 나누는 게 의미가 있는지 회의감이 들기도 한다.

문제 1. 결합되어 있는 의존성

문제가 발생한 원인은 바로 컴포넌트가 '주소'라는 특정 도메인과 강하게 결합돼있는 것이다. 컴포넌트 이름부터 주소~이지 않은가? 이 문제를 해결하기 위해 컴포넌트에서 도메인 맥락을 제거해보자.

우선, AddressItem 컴포넌트 이름부터 도메인 맥락을 제거하여 FlexListItem이라고 변경하자. 그리고 address라는 prop의 이름도 text 라는 이름으로 변경하자.

그런데 SaveAddressButton과 같이 주소 도메인과 강하게 결합되어 있는 컴포넌트를 직접 가져와 사용하는 부분이 있어서 컴포넌트 이름과 props 이름을 변경해도 소용이 없다.

이 부분을 수정하기 위해서 FlexListItem사용하는 쪽에서 이 SaveAddressButton 컴포넌트를 전달해주는 방식으로 변경하자.

// before: AddressItem
function FlexListItem({ text, button }: { text: string, button: ReactNode }) {
  return (
    <FlexLi>
      {text}
      {button}
    </FlexLi>
  )
}
const FlexLi = styled('li')``

AddressItem의 역할인 주소를 보여주는 부분은 text 라는 (주소와 관련없는 네이밍의) props를 통해 받도록 수정했고 저장, 저장해제 버튼은 button 이라는 props를 통해 합성할 수 있도록 수정했다.

  • addresstext
  • button props 추가

이렇게 수정된 FlexListItem 컴포넌트는 AddressList 컴포넌트에서도 사용하고 회원 목록처럼 다른 도메인에서도 가져다 사용할 수 있다.

function AddressList() {
  const [addresses, setAddresses] = useState<주소[] | null>(null);
  // call fetchAddressList

  return (
    <ul>
      {addresses.map(({ id, address, saved }) => {
        return (
          <FlexListItem
            key={id}
            text={address}
            button={saved ? <SaveAddressButton id={id} /> : <UnsaveAddressButton id={id} />}
          />
        );
      })}
    </ul>
  );
}
  • 도메인 맥락을 제거하자.
  • 의존성을 컴포넌트가 직접 참조하지 말고 외부로부터 주입받자.

문제 2. 확장 불가능한 구조

도메인 맥락이 제거됐기 때문에 여러 군데에서 사용할 수 있고 이럴 경우, 변경될 수 있는 여지가 많아진다. 말했듯이 사용자 인터페이스를 결정하는 요소는 정말 많기 때문이다.

예를 들어 FlexListItem 컴포넌트의 스타일에서 조금만 다른 경우가 있다면? 이 부분이 외부에서 수정 가능해야 이 컴포넌트를 가져다 사용할 수 있을 것이다. 그리고 재사용하여 개발 생산성을 높이는 것이 컴포넌트의 목적이다.

또한 text 부분이 지금은 텍스트로 주소만 보여주고 있다. 근데 회원 목록 화면에서는 이름과 이메일 두 줄을 보여줘야 한다면? (주소 밑에 우편번호를 보여주게 끔 요구 사항이 추가될 수도 있다.) 다른 UI에선 이 text에 대한 스타일만 달라질 수 있는 것이다.

조금 더 나아가 button 없이 사용하고 싶을 수도 있지 않을까?

How?

이런 상황이 발생할 때마다 Props를 추가해서 대응해줄 수도 있다. 이 방법은 재사용이 불가능해진 컴포넌트의 내부를 변경하는 방법이다. Props가 추가되면 추가될수록 그 컴포넌트 내부는 복잡해질 것이고 유지보수가 불가능한 몬스터 컴포넌트가 생겨난다.

컴포넌트를 하나 더 만들고 공통으로 사용하는 부분을 따로 분리하는 것도 하나의 방법이다. 이 방법은 컴포넌트를 더 작은 단위로 나누게 하는데, 이 경우 무수히 많은 컴포넌트가 생겨나게 되고 상황에 따라 재사용 가능한 컴포넌트가 무엇인지 알아보기 힘들어진다. 역할이 불분명해졌다는 신호이고 역할이 모호하기 때문에 이름을 짓기 어려워지는 문제도 함께 발생할 것이다.

여러 가지 요소가 사용자 인터페이스를 결정짓기 때문에 사용하는 쪽에서 결정할 수 있게 끔 주도권을 외부에 넘겨야 한다. 컴포넌트의 재사용성을 좀 더 높이려면 외부에서 많은 것을 결정하여 확장할 수 있도록 해야 한다.

확장

FlexListItem 컴포넌트를 확장 가능하도록 네 가지를 수정할 것이다.

interface Props extends LiHTMLAttributes<HTMLLIElement> {
  button?: ReactNode;
}

function FlexListItem({ children, button, ...props }: Props) {
  return (
    <FlexLi {...props}>
      {children}
      {button}
    </FlexLi>
  )
}
  • extends LiHTMLAttributes
    • li 엘리먼트에 className을 통해 스타일을 커스텀한다던가 li attributes를 전달할 수 있게 끔 열어주면서 확장 가능하도록 수정했다.
  • text: stringchildren: ReactNode
    • string 타입의 text props 대신 children props를 통해 렌더링 될 수 있는 Node를 받을 수 있도록 수정했다.
  • required button props → optional button props
    • 버튼 컴포넌트도 optional props로 변경하여 필요없는 경우에도 이 컴포넌트를 가져다 사용할 수 있도록 수정했다.

거의 대부분의 주도권을 외부에 전달했다. 이제 FlexListItem 컴포넌트가 하는 역할이라곤 childrenbutton props로 렌더링되는 엘리먼트 간의 레이아웃을 결정하는 것 밖에 없다.

이제 본래 목적이었던 변경에 유연한 컴포넌트인지 다시 한번 되돌아볼 필요가 있다.

컴포넌트가 확장 가능하도록 합성 가능한 구조로 만들자.

2-2. 응집도 있는 컴포넌트

변경에 대응할 수 있으려면 컴포넌트 내부보다 외부로 노출되는 것에 신경 써야 한다. 컴포넌트에서 외부로 드러나는 것은 컴포넌트 네이밍과 Props 두 개이며 이것들은 컴포넌트의 역할을 이해하는 데에도 큰 역할을 한다. 위에서 정의한 인터페이스에서 한 가지를 더 추가해보면 다음과 같다.

인터페이스

  1. 인터페이스는 사용하는 쪽을 위한 것이다.
  2. 컴포넌트를 사용하는 쪽에선 인터페이스를 보고 어떻게 동작할지 예상 가능해야 한다.
  3. *인터페이스는 외부와의 의존성을 만든다.

내부 구현을 캡슐화

의존 관계가 생성되면 변경의 파장이 의존 방향에 따라 영향을 미치게 된다. 우리가 만든 FlexListItem 컴포넌트를 살펴보자.

이 컴포넌트는 내부의 li 엘리먼트 스타일이 flex 로 구현되어서 Flex-*라는 네이밍이 되었다. 컴포넌트 네이밍 또한 인터페이스이기 때문에 외부와의 의존성을 만들게 된다. 내부 구현이 외부로 노출되면서 내부 구현과 외부와의 의존성이 생긴 것이다.

한 가지 상활을 예로 들어보면 FlexListItem 내부 구현이 flex 스타일이 아닌 grid로 변경되었을 때, 더 이상 이 컴포넌트는 Flex로 구현되어있지 않기 때문에 네이밍이 변경될 것이다. 그리고 이 변경은 이 컴포넌트를 사용하고 있는 모든 곳에서 영향을 미치게 된다.

이 컴포넌트의 이름만 FlexListItem에서 ListItem 으로 수정하면 내부의 어떤 스타일을 수정하더라도 사용하는 쪽에 영향을 미치지 않게 된다. 내부 구현을 감췄으니 내부 구현과 이를 사용하는 외부와의 의존성이 사라졌고 내부 변경이 외부에 영향을 미치지 않는 것이다.

구현을 캡슐화하여 내부의 변경이 외부에 영향을 미치지 않도록 해야 한다.

예상 가능한 Props 디자인

ListItem 컴포넌트를 사용하는 입장에선 내부 구현을 보지 않고서야 이 button props로 전달해준 컴포넌트가 어디에, 어떻게 렌더링 될지 알 수 없다.

컴포넌트의 역할을 드러내는 네이밍으로 수정해보자.

1. 기본 attributes

기본 attribute 중 역할과 일치하는 것이 있으면 그것을 사용하는 것이 가장 좋다. 가장 일반적이기 때문이고 그 동작을 예측하기 쉽기 때문이다.

예를 들어 <select> 엘리먼트 역할을 수행하는 컴포넌트의 props 디자인은 select 엘리먼트를 따라가는 것이다. onValueChange와 같은 네이밍이 아닌 onChange 라는 이름으로 디자인해주는 것이다.

2. 역할이 드러나는 네이밍

모든 props가 기본 attribute에 해당되지 않을 수 있다. 그럴 땐 역할이 잘 드러나는 네이밍을 고민해볼 수 있다.

HTML에서 li 엘리먼트에는 button 이라는 attribute가 없기 때문에 ListItem 컴포넌트의 역할로부터 props 네이밍을 다시 고민해볼 필요가 있다.

flex 스타일로 button 엘리먼트는 우측에 렌더링 되기 때문에 right 라는 표현을 통해 역할을 드러낼 수 있지 않을까? button 이라는 제약 사항을 제거함으로써 button 타입의 컴포넌트가 아닌 다른 컴포넌트도 전달받을 수 있어 확장 가능하도록 수정할 수 있을 것이다. (*부록 A. right라는 이름의 Prop 참고)

3. 널리 사용되는 네이밍

디자인 시스템 오픈소스 라이브러리를 참고해보는 것이 도움이 된다. 여러 가지 디자인 시스템에서 사용되는 네이밍을 혼용하는 것은 일관성이 깨질 수 있지만 특정 상황에 대한 네이밍을 참고한다면 좀 더 일반적인 인터페이스로 Props를 디자인할 수 있다. (*부록 C. 참고하기 좋은 디자인 시스템)

역할은 드러내고 구현은 감추어 일반적인 인터페이스를 설계하자.

되돌아보기

최종적으로 만들어진 ListItem 컴포넌트와 이를 사용하는 AddressList 컴포넌트이다.

// /components/ListItem.tsx
interface Props extends LiHTMLAttributes<HTMLLIElement> {
  right: ReactNode
}

function ListItem({ children, right, ...props }: Props) {
  return (
    <StyledLi {...props}>
      {children}
      {right}
    </StyledLi>
  )
}

// /pages/address/AddressList.tsx
function AddressList() {
  const [addresses, setAddresses] = useState<주소[] | null>(null)
  // call fetchAddressList

  return (
    <ul>
      {addresses.map(({ id, address, saved }) => {
        return (
          <ListItem
            key={id}
            right={
              saved ? (
                <SaveAddressButton id={id} />
              ) : (
                <UnsaveAddressButton id={id} />
              )
            }
          >
            {address}
          </ListItem>
        )
      })}
    </ul>
  )
}

몇 가지 의문이 들 수 있는 글이라서 예상 가능한 생각들에 대한 생각을 적어본다.

생각 1. 컴포넌트의 책임은 뭐지?

우리는 변경에 유연한 컴포넌트가 필요했고 역할과 책임에 따라 분리하는 과정을 거쳤다. 역할에 대해선 많은 이야기를 나눈 것 같은데, 책임에 대한 이야기는 하지 않았다. 컴포넌트의 책임은 무엇일까? 책임이 잘 나뉘었다는 것은 어떻게 확인해볼 수 있을까?

카오스 엔지니어링처럼 애플리케이션에 변경을 가해보자. 그 변경이 영향을 미치는 부분이 애플리케이션 전반에 영향을 미치는가? 그 변경을 반영하기 위해선 특정 컴포넌트 하나만 수정하면 되는가?

예를 들어서, '저장하기 버튼의 스타일'이 변경된다면 무엇을 변경해야 하는가?

  • Before) AddressPage의 button element에서 saved라는 값을 기준으로 스타일을 변경해준다.
  • After) SaveAddressButton의 스타일을 변경한다.

책임이란 구현을 변경해야 하는 이유라고 할 수 있다. 역할에 맞게 컴포넌트가 분리되었다면 책임들이 응집도 높게 구성되어 있을 것이고 변경으로 인한 영향이 좁을 것이다. 변경으로 인한 영향이 좁으면 좁을수록 변경에 유연하다.

컴포넌트를 변경해야 하는 이유가 하나인가?

생각 2. 일어나지 않을 일 아닌가요?

  • 회원 목록을 보여주는 페이지가 있다면?
  • 컴포넌트의 스타일에서 조금만 다른 경우가 있다면?
  • 회원 목록 화면에서는 이름과 이메일 두 줄을 보여줘야 한다면?

주소 목록 화면의 구조를 변경하며 여러 가지 가정을 했다. 아직 일어나지 않은 상황에 대해 대비한 것인데, 이것을 성급한 추상화라고 오해하는 사람이 있을까 봐 이 섹션을 추가한다.

앞선 가정들은 소프트웨어는 변한다는 대전제 하에 진행되었다는 것을 상기시킬 필요가 있다.

추상화란 불필요한 정보를 제거하고 문제 해결에 필요한 정보만 남기는 작업이다. AddressListItem을 ListItem으로 변경하면서 ListItem 컴포넌트는 자연스럽게 레이아웃이라는 하나의 문제에 집중했다. 그리고 그 외의 것들은 제거하고 외부에 위임하였다. 그 결과 ListItem은 요소 간 레이아웃을 담당하게 된 것이다.

추상화가 성급했다고 결론이 나는 상황들은 이와는 다른 방향으로 작업이 이뤄졌다. 역할을 기준으로 추상화한 것이 아니라 중복을 기반으로 추출한 것이다. (*부록 B. 성급한 추상화와 잘못된 추출)

성급한 추상화라는 것은 없다. 잘못된 추출만 있을 뿐.

생각 3. 이 컴포넌트는 여기에서만 쓰여요

1단계에서 만든 AddressItem 컴포넌트는 '여기'에서만 사용되는 컴포넌트였다. 주소 목록을 보여주는 화면에서만 사용되는 컴포넌트였기 때문에 이 페이지를 작성하는 시점에는 재사용할 일이 없는 컴포넌트였던 것이다.

그렇다면 컴포넌트로 분리해야 할까? 여기에서만 사용된다면 컴포넌트로 분리할 필요가 없는 것은 아닐까?

물론 컴포넌트를 분리하는 이유가 재사용에만 있는 것은 아니다. 복잡해진 컴포넌트의 복잡도를 낮추기 위해 적당히 컴포넌트로 분리하는 경우도 있기 때문이다. 보통 이렇게 분리된 컴포넌트는 기존 컴포넌트의 복잡도를 낮춰 가독성을 좋게 한다는 느낌을 준다.

컴포넌트를 추출할 때 두 컴포넌트가 의존 관계에 있는지 먼저 살펴봐야 한다. 사용하는 컴포넌트와 추출된 컴포넌트 간에 의존 관계가 형성되면 변경에 취약한 구조가 된다. 그리고 이 의존 관계는 일반적이지 않은 인터페이스로 드러나게 되며 이로 인해 필연적으로 내부를 들여다봐야 전반적인 로직이 이해된다. 이것을 가독성이 좋아졌다고 말할 수 있을까?

컴포넌트가 복잡해졌다면 먼저 UI 로직을 추상화하여 복잡도를 낮출 수는 없을지 고민하자. 복잡도를 컴포넌트를 설계하는 방법은 여러 가지이고 각각의 트레이드오프를 계산하여 컴포넌트를 설계하자.

여기에서만 사용되는 컴포넌트라면 분리해야 할 필요가 있을까? 고민해보자.

생각 4. 나중에 리팩토링 할게요

추후 재사용이 필요해졌을 때 리팩터링 하는 것과 미리 하는 것의 차이일 뿐이지 않을까? 조삼모사 아니냐는 것이다.

그러나 리팩터링은 엄청나게 큰 변화를 일으키는 작업이다. 변경이 없는데도 변경이 발생하는 것이다. 이 변경으로 인해 문제가 없는지 살펴봐야 하는 사용자 인터페이스가 많아질수록 버그가 발생할 확률이 높아진다. 이것이 우리가 이상으로 추구했던 지속 가능한 소프트웨어인지 되돌아볼 필요가 있다.

리팩터링은 분명 필요한 작업이다. 그러나 아쉽게도 '나중에' 리팩터링 할 시간은 쉽게 주어지지 않는다.

변경은 버그를 부른다.

생각 5. 이 코드는 계속 반복되는데요?

앞서 잘못된 추출에 대한 이야기를 했다. 그러나 분명 추출이 필요할 때가 존재한다. 도메인 간 반복되어 사용되는 코드가 있을 경우이다. 이럴 경우에는 우선 UI 기반의 추상화를 먼저 진행한 후, 그 컴포넌트를 사용하여 반복되는 부분을 추출하여 관리할 수 있다.

도메인 간 공통으로 사용되는 코드는 확장 가능성보다는 공통으로 사용되는 코드들이 응집도 있게 관리되는지 또한 중요하다. Compound Component Pattern을 통해 관리하는 것도 하나의 방법이며 도메인 간 중복되는 코드를 따로 Top Level 디렉토리에서 관리할 수도 있다.

Action Item

몇 가지 액션 아이템을 도출해볼 수 있다.

  1. 공통 컴포넌트로 관리하고 있는 컴포넌트들이 도메인을 알고 있는지 살펴보자.
  2. 컴포넌트 이름이, Props 이름이 일반적이지 않은 네이밍으로 이루어져 있는지 살펴보자.
  3. Interface Driven Development.

Interface Driven Development

테스트 코드 내용도 함께 포함되면 좋았을 것 같은데 이 부분은 아직 경험이 부족해 추가하지 못했다. TDD의 개념을 빌려와서 interface driven development라는 용어를 만들어봤다.

구현은 일단 미루고 구현하고자 하는 컴포넌트를 어떻게 사용할지 먼저 정의를 해보자. 데이터를 보여주고자 하는 UI가 어떤 구조로 되어있고 이를 표현하기 위해 어떤 Props가 필요할지, 이름은 무엇이어야 할지 고민해보는 것이다. UI에 먼저 집중하게 되면서 컴포넌트에 도메인 맥락이 섞이지 않게 되고 일반적인 인터페이스를 설계할 수 있게 된다. 주소 목록 페이지를 구현하기 전에 주소 데이터를 보여주기 위한 ListItem 컴포넌트의 인터페이스를 먼저 고민해보는 것이다.

마무리

너무 간단한 예제를 통해 살펴봐서 어느 정도 공감이 될지는 잘 모르겠다.

유연한 설계는 복잡한 설계라는 이면을 숨기고 있다. 유연성은 항상 복잡성을 수반한다. 항상 트레이드오프를 잘 계산해서 유리한 쪽으로 설계를 하자.

Tip: 이 글은 사실 Stop Using Atomic Design Pattern의 후속 글이다.

본문에서 다루고 싶지만 글의 흐름을 방해할 수 있다고 생각되는 부분을 부록으로 정리해봤다.

부록 A. right라는 이름의 Prop

ListItem 컴포넌트의 props 중 right 라는 것을 만들었다. 추상화 단계가 높아지면서 여러 맥락들이 제거된 것이다. 조금 더 구체화하기 위해 rightNode라고 하여 타입을 드러낼 수도 있지만 타입은 TypeScript를 사용한다면 충분히 알 수 있는 정보이기 때문에 네이밍에서 드러내지 않아도 된다.

다만 좀 더 ListItem의 역할을 드러낸다는 측면에서 이름을 구체화할 수는 있을 것이다.

  • ListItem을 제어하는 무언가 라면 → rightControls
  • ListItem을 꾸며주는 무언가 → rightAddons, rightAccessory

부록 B. 성급한 추상화와 잘못된 추출

성급한 추상화라는 말이 어디에서 유래됐는지 잘 모르겠지만 개인적으로는 와닿지 않는 내용이었다. 추상화라는 단어가 여러 곳에서 사용되다 보니 다른 의미로 종종 사용되는 것 같다. 앞서 소개한 이 글Don't repeat yourself 와 맥락이 이어지는데, 이 부분은 추상화라는 단어보다는 '추출'이라는 단어가 더 어울린다고 생각한다.

그리고 글에서 말한 성급한 추상화는 잘못된 추출이라고 할 수 있고 다음과 같은 상황을 말한다.

초기의 AddressPage에서 회원목록 페이지가 추가되어 작업을 하게 되었다.

  1. UserPage를 만든다.
  2. AddressPage의 li 태그를 컴포넌트화 하여 Item 컴포넌트를 만든다.
  3. Item 컴포넌트에 buttonType props를 추가하여 저장, 삭제, 즐겨찾기 추가 등에 대해 정의한다.
  4. Item 컴포넌트에서는 이 butttonType에 따라 알맞은 핸들러를 추가하고 버튼 엘리먼트를 렌더링한다.
  5. 회원 목록일 때 스타일에 대한 custom이 필요하게 되어 itemType props를 추가하여 주소, 회원임을 구분한다.
  6. ...

시간이 지날수록 Item 컴포넌트는 몬스터 컴포넌트가 될 것이다.

부록 C. 참고하기 좋은 디자인 시스템

부록 D. 더 읽어보기