더 나은 컴포넌트 설계하기

Juno·2022년 6월 26일
52
post-custom-banner

✨ 들어가기

여러분들은 개발을 하면서 얼마나 설계에 대해 고민하시나요? 자판으로 코드를 입력하기 전까지 어느정도의 시간을 고민하시는지 궁금합니다.

개발자로서 일을 시작하기 이전에 여러 대외활동을 했었는데, 장기 해커톤을 하던 사이드 프로젝트를 하던 항상 이에 대한 갈증이 있었습니다. 그래서 어떻게 해야 돌아가기만 하는 코드에서 유지보수가 쉽고 새로운 기능을 붙이기도 좋은 코드를 짤 수 있을까 하는 고민을 하려고 노력했는데, 그 어떻게에 대한 답을 내리지 못하고 결국 시간에 쫓겨 추후 리팩토링을 기약하며 돌아게가만 하는 코드를 완성시켰던 것 같아요. 협업 팀에서도 항상 일정이 있었고 확장성이 있는 기획이라도 추후에 이어 지는 것은 미지수였기 때문에 일정에 맞춰 어떻게든 만드는게 팀에게는 더 중요했습니다.

고민하기엔 시간이 충분하지 않았고, 더 좋은 코드와 설계에 대한 갈증은 커져갔기 때문에 실무를 경험하고 싶었습니다. 그리고 저는 이제 4개월차에 들어선 프론트엔드 개발자가 되었습니다. 프로젝트를 시작할 때 컨벤션을 어떻게 정해야 할지, 폴더 구조는 어떤식으로 정하는게 좋을지 실무자들의 코드는 어떨지 막연히 궁금했고 그런 점들은 규칙같은 부분이라 생각하여 금방 배울 수 있을거라고 생각했습니다.

하지만 그 이상으로 설계적인 부분들이 정말 중요하다는 걸 많이 느끼게 되었고, 처음 입사했을 때 온보딩 프로세스를 겪으면서 "객체지향의 사실과 오해" 라는 책을 추천 받는 것을 시작으로 함수형 프로그래밍을 중심으로 개발하는 프론트엔드 생태계에서도 객체지향의 개념은 컴포넌트를 설계하는데에 있어서는 중요하다는 걸 알게 되었습니다.

Thinking in React (doc)

먼저 공식문서에 나와있는 React로 코드를 작성하는 방법, 가이드라인을 잡고 자세한 예시와 함께 설명드리도록 하겠습니다.

  • 컴포넌트의 개념
    • 어떠한 시스템을 구성하는 요소 중 하나의 단위이다.
    • 공통되는 부분을 모듈화 하여 재사용할 수 있는 단위이다.
    • 데이터를 뿌리기 위한 UI 의 요소이다. - 각 컴포넌트가 데이터 모델의 한 조각을 나타내도록 분리할 것
  • 선언형
    • 선언형과 명령형 React 공식문서의 메인에 React는 선언형 으로 프로그래밍 된다고 나와있다.

명령형 프로그래밍무엇을 어떻게 할 것인가에 가깝고,
선언형 프로그래밍무엇을 할 것인가와 가깝다.

명령형 프로그래밍 예시

        function double (arr) { 
        	let result = []; 
        	for (let i=0; i<arr.length; i++) { 
        		result.push(arr[i] * 2) }
        		return (result); 
        }
  • 다음과 같이 무엇을 어떻게 할지까지 세세하게 지정한다.

선언형 프로그래밍 예시

        function double (arr){
        	return arr.map(x => x*2);
        }
  • 결과만 기술할 뿐 어떻게 해야하는지는 기술하지 않는 프로그래밍 방법이다.
  • 의도에 집중한 프로그래밍 방법이다.
    • 설계만 잘 하면 React는 데이터가 변경됨에 따라 적절한 컴포넌트만 효율적으로 갱신하고 렌더링한다.
    • 선언형 뷰는 코드를 예측가능하고 디버깅하기 쉽게 만들어 준다.
    • 훅도 선언형에 맞는 구현체라고 생각한다.
      • useState 상태 사용을 선언한다.
      • useEffect 부수효과 발생을 선언한다.
    • 하지만 실제 상태관리, 부수효과 발생 등은 React가 수행한다.
  • 상속인 아닌 합성을 통한 구현
    • Dialog와 같은 경우 박스 영역에 어떤게 올지 알 수 없다.
    • children or ReactNode 등을 통해서 사용하는 개발자의 입장에서 넣어줄 수 있도록 위임
    • 컴포넌트들의 합성을 통해서 어플리케이션을 만들 수 있는게 React의 매력이다.
  • 어떤 것이 컴포넌트가 되어야 하는가: 단일 책임 원칙(SRP)
    • 하나의 컴포넌트는 한 가지 일을 하는게 이상적이다.
    • 하나의 컴포넌트가 커지게 된다면, 하는 역할과 맡은 책임이 너무 많다면 작은 하위 컴포넌트로 분리하는 것이 좋다.
  • 반복을 줄이자 : DRY
  • 단방향 데이터 바인딩
    • 처음 설계가 어려울 수 있지만, 의존성이 한쪽으로만 흘러가기 때문에 코드를 수정할 일이 있을때 데이터의 흐름대로 수정하면 된다.
    • 양방향 데이터 바인딩인 경우 A를 수정하면 B를 수정해야 하고, B를 수정하면 또 A를 수정해야 하는 일이 생길 수 있다.(A-B가 서로 의존하는 경우)

컴포넌트의 역할과 책임

컴포넌트

어떤 시스템을 구성하는 여러 요소 중 하나를 컴포넌트라고 할 수 있습니다.

그렇다면 React 의 경우에는 시스템을 UI로 볼 수 있기 때문에, UI를 구성하고 있는 요소를 컴포넌트라고 할 수 있겠네요!

컴포넌트로 나누어 개발하는 이유

어느 제품이든 UI에는 일정한 패턴을 두고 반복도는 요소들이 존재합니다. 그렇기 때문에 반복되는 요소들을 매번 개발하기보다는, 미리 만들어둔 요소를 재사용하여 개발 리소스를 줄일 수 있습니다. 실제로 잘 추상화하여 만들어둔 컴포넌트를 재사용 함으로써 개발할 수 있는 경우가 많았습니다.

여기서 포인트는 잘 추상화 했을 때 입니다. 추상화가 잘 이루어져야 필요한 곳에 적절하게 재사용하여 UI의 일관성을 유지하고 리소스를 절감할 수 있습니다. UI를 결정짓는 요소들이 정말 많기 때문에 수 많은 요소들 중 하나라도 달라지면 재사용이 어렵기 때문이죠.

이제부터 들어가기에서 말했던 "어떻게 잘 나눌 수 있을까?" 에 대한 고민을 조금이나마 해결하고자 몇가지 방법들을 같이 이야기하고자 합니다. 먼저 역할과 책임에 따라서 분리한다는 소프트웨어 기본원칙을 UI 컴포넌트에도 적용해보면 어떨까요?

역할과 책임

프론트엔드 어플리케이션이 복잡해지면서 수많은 데이터들을 관리하게 되었습니다. 데이터를 가져오고 이를 보여주는 부분이 복잡해지자 이 부분을 추상화 하려는 노력들이 생겨났습니다. React와 같은 라이브러리를 사용함으로써 이를 컴포넌트 기반으로 추상화 하는 것이죠. 그러면서 크게 다음의 세 가지 정도의 역할을 하게 되었습니다.

  1. 외부로 부터 주입된 데이터를 관리한다.
    • 서버 즉, api 요청으로 응답된 데이터나 부모 컴포넌트로 부터 주입받은 데이터를 관리한다.
  2. 데이터를 UI로 표현한다.
    • React는 화면에 보이는 페이지 중심이 아닌 데이터 중심으로 돌아가기 때문에 컴포넌트에서 관리하고 있는 데이터를 어떻게 UI로 보여줄지 선언적으로 작성한다.
  3. 사용자로부터 인터렉션을 받는다.
    • 사용자로부터 어떤 인터렉션을 받을지 이벤트 핸들러를 정의해 준다. 이벤트 핸들러에 사용자의 인터렉션이 발생했을 때 어떠한 동작을 할지 명시해줄 수 있고, 이는 데이터를 조작하는 경우도 있다.

컴포넌트의 역할을 나열해 보았을 때 데이터 가 빠지지 않습니다. 컴포넌트를 데이터 기준으로 나눠야 역할을 기반으로 컴포넌트를 분리할 수 있겠다 라고 추측됩니다. 그럼 컴포넌트를 데이터 중심으로 나눠서 역할과 책임을 분리해 볼까요?

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

1. 데이터 기반 설계

외부로부터 주소 목록을 가져와 렌더링하는 페이지를 컴포넌트로 분리해 보겠습니다.

// AddressPage.tsx
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로 표현하며 사용자의 인터랙션을 받고 있습니다.

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

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

이번엔 주소목록, 주소 라는 두개의 데이터를 기준으로 컴포넌트를 분리 해 보았습니다.

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

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

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

여기에서 주목할 만한 부분은, AddressPage에서 데이터를 불러와 props로 AddressList에 넘겨준게 아니라 스스로 AddressList가 가져올 수 있도록 한 것입니다.

이 컴포넌트 에서도 또한 AddressItem 컴포넌트를 만들어서 하나의 주소를 보여주는 컴포넌트로 또 분리해낼 수 있겠습니다.

// 1번 AddressItem
interface Props {
	name: string;
	location: string;
	price: number;
	// ...
	address: 주소;
}

function AddressItem({ name, location, price, saved, onSaveClick, onUnsaveClick }: Props) {
  return (
    <li>
			<span>{name}<span>
			<span>{location}<span>
			<span>{price}<span>
      <button onClick={saved ? onUnsaveClick : onSaveClick}>
        {saved ? '저장' : '삭제'}
      </button>
    </li>)
}

여기에 아직 두 가지 정도 고려해볼 만한 점이 남아있습니다.

  1. 하나의 주소를 저장하고 삭제하는(개별) 사용자의 인터렉션은 AddressList 보단 AddressItem 의 역할에 더 어울리는 것 같습니다.
  2. AddressItem에서 보여줘야하는 주소 데이터가 추가되거나 삭제되는 등의 변경사항이 있으면, AddressItem 의 interface를 수정해 주고, AddressList 에서 props로 넘겨주는 작업이 필요합니다. 단순히 보여줄 데이터가 하나 추가된 작업이지만, 두 컴포넌트를 변경시켜주는 작업인 것이죠.
  • 그래프큐엘로 해결하기
    interface Props {
    	주소: AddressItem_주소fragment;
    }
    
    function AddressItem({ address, saved, onSaveClick, onUnsaveClick }: Props) {
      return (
        <li>
          {address}
          <button onClick={saved ? onUnsaveClick : onSaveClick}>
            {saved ? '저장' : '삭제'}
          </button>
        </li>)
    }
    
    gql`
    	fragment AddressItem_주소 { # 어떤 컴포넌트에서 어떤 props 이름으로 쓰이는지 확인 가능
    		name
    		location
    		price # 얘만 추가해주면 된다.
    	}
    `

역할 중심으로 분리하기

// 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 의 인자를 이벤트 핸들러가 알 필요가 없어졌습니다. 불필요한 인자가 삭제된 것이죠.

interface 도 주소 전부를 받아오도록 수정되었습니다. 공통의 주소 모델을 AddressList 에서 받아오고, 그대로 데이터를 Item에 넘겨주었기 때문에 더 보여주고 싶은 데이터만 UI에 새로 추가하면 그만입니다.

여기서 볼 수 있던 사실은, 데이터 중심의 모델은 진리의 원천(Single Source of Truth)로 관리되어야 한다는 사실입니다.

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

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

AddressList는 다음과 같이 개별 아이템 저장 / 삭제 역할을 했던 이벤트 핸들러를 없애고 다음과 같은 모습을 띄게 되었습니다.

// 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>
}

여기서 조금만 더 역할을 생각해 볼게요. AddressItem의 원래 의도한 역할은 개별 주소 데이터를 보여주는 것 이었습니다. 그런데 저장과 삭제 하는 역할까지 수행하고 있기 때문에 이를 버튼 컴포넌트로 분리하여 역할을 위임해줄 수 있습니다.

해당 예시에서는 주석으로 역할만 적혀있었지만, 수정하는 역할의 api 요청을 보낸다면 더욱 더 분리해주는 게 좋을 것 같습니다.

여기까지 고생 많으셨습니다. 우리는 여태까지 다음과 같은 기준으로 컴포넌트를 분리해 보았습니다.

  1. 컴포넌트가 의존하고 있는 데이터를 기준으로 분리한다.
  2. 컴포넌트의 이름에 맞는 역할을 기준으로 분리한다. (+ 단일 책임 원칙)

역할에 맞게 잘 분리한 것에는 이견이 없지만, 컴포넌트의 중요한 의미 중 하나는 재사용 에 있습니다. 우리가 나눈 컴포넌트는 과연 재사용이 가능할까요?

2. 일반적인(general한) 인터페이스 설계

앞에서 데이터 중심으로 컴포넌트를 나누어 설계하였습니다. 재사용 의 측면에서 보면 이 컴포넌트가 재사용이 가능한지, 변경에 대응할 수 있는지 살펴봐야 합니다. 그러기 위해선 좋은 인터페이스를 가지고 있어야 하는데, 여기서는 props컴포넌트의 네이밍이 인터페이스 역할을 수행하고 있습니다. 그렇다면, 좋은 인터페이스란 무엇일까요?

컴포넌트의 인터페이스

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

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

위의 예시에서 분리한 컴포넌트들은 '주소' 라는 도메인을 가진 페이지에서만 사용할 수 있습니다. 같은 도메인 아래에서 데이터 그리고 역할에 따라 의존성은 잘 분리해 냈지만, 특정한 도메인을 알고 있는 컴포넌트 이기 때문에 다른 도메인에서는 재사용할 수 없겠죠. 예를 들어 주소 목록을 보여주는 UI는 같은데 데이터의 종류만 다른 페이지가 있다면 사용자에게 보이는 인터페이스는 동일하더라도 컴포넌트를 새로 만들어야 합니다.

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

방금 언급했던 것 처럼 재사용이 불가능한 이유는 컴포넌트가 '주소' 라는 특정 도메인과 강하게 결합되어 있기 때문입니다. 이 문제를 해결하기 위해 컴포넌트에서 도메인 맥락을 제거해보겠습니다.

// before: AddressItem
function FlexListItem({ text, button }: { text: string, button: ReactNode }) {
  return (
    <FlexLi>
      {text}
      {button}
    </FlexLi>
  )
}

const FlexLi = styled('li')``
  1. 컴포넌트 이름에서 도메인 맥락을 제거해 FlexListItem 이라는 이름으로 변경하였습니다.
  2. address 라는 props의 이름도 text로 도메인 맥락을 제거해 주었습니다.
  3. SaveAddressButton 이라는 컴포넌트를 해당 컴포넌트에 직접 불러와서 사용중이었는데, 이 역시 주소 도메인과 강하게 연결되어 있으므로, 사용하는 개발자 쪽에서 Button 이라는 것만 알려주고 직접 넣어서 사용할 수 있도록 변경하였습니다. (이렇게 사용하는 입장에서 조합하여 사용하는 것을 컴포넌트의 합성이라고 합니다.)

자, 이제 어떤가요? 회원 목록과 같이 다른 도메인을 나타내는 페이지에서도 재사용할 수 있게 되었습니다.

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>);
}

정리하자면 우린 재사용성을 높이기 위해 다음과 같은 두가지 방법을 사용했습니다.

  1. 도메인 맥락을 제거한다.
  2. 의존성을 컴포넌트가 직접 참조하지 말고 외부로부터 주입받자.

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

도메인 맥락을 제거함에 따라 재사용성이 높아졌기 때문에 (많은 곳에서 사용할 수 있기 때문에) 변경될 여지가 많습니다. 사용할 수 있는 곳이 많아진 만큼, 변경에 대한 요구가 점점 더 많아지겠죠.

예를 들어 FlexListItem 컴포넌트에서 스타일이 조금만 수정된다면 이 부분을 외부에서 수정할 수 있어야 컴포넌트를 사용할 수 있을 것입니다. 또한 text 부분에 단순한 text ui 만 오는게 아니라 textArea로 여러줄을 오게 하고 싶을 수도 있고, 단순 스타일링이 조금씩 바꿔달라는 요구사항이 있을 수도 있겠죠. (소프트웨어는 끊임없이 변화한다..) 그리고 button 없이도 사용할 수 있지 않을까요?!

How?

이런 상황이 발생할 때 마다 props를 추가하여 문제를 해결할 수 있을 것 입니다. props로 요구사항을 조건으로 받아와서 조건부 렌더링으로 해결할 수 있겠죠. 하지만 이는 재사용이 불가능해진 컴포넌트를 수정하는 방법에 해당합니다.

왜냐하면, props가 추가될 때 마다 해당 props에 의존해서 컴포넌트 내부 구현은 복잡해질 것이고 그렇게 되면 오히려 재사용하기 어려운 컴포넌트가 되어버립니다.

따라서 이렇게 여러가지 요소가 인터페이스를 결정하기 때문에 컴포넌트를 사용하는 입장에서 결정할 수 있도록 주도권을 외부에 넘겨주어야 합니다. 즉, 주도권을 외부에 넘김으로써 외부에서 많은 것을 결정하여 확장할 수 있도록 해야 하는 것입니다.

확장

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

function FlexListItem({ children, button, ...props }: Props) {
  return (
    <FlexLi {...props}>
      {children}
      {button}
    </FlexLi>
  )
}

FlexListItem을 확장 가능하도록 몇 가지 수정작업을 진행해 보겠습니다.

  1. extends LiHTMLAttributes 를 통해 스타일 커스텀(= style)이나 li 태그의 어트리뷰트를 사용할 수 있게 되었습니다.
  2. text: stringchildren: ReactNode 로 바꿔주어 들어올 수 있는 내용을 한정짓지 않고 외부에 주도권을 넘겨주었습니다.
  3. button 을 typescript의 optional props로 변경하였습니다. 필요한 경우에만 button props를 주입하여 사용할 수 있도록 변경한 것이죠.

거의 대부분의 주도권을 외부에 넘기도록 설계를 바꾸었습니다. 이제 FlexListItem은 사실 list의 스타일링과 더불어 children과 button 간의 레이아웃을 결정하는 역할만 지니게 되었습니다.

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

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

변경에 대응할 수 있으려면, 컴포넌트 내부보다 외부로 노출되는 것에 신경을 써야합니다. 컴포넌트가 외부로 노출하고 있는 정보는 props(interface)와 컴포넌트의 네이밍 입니다. 이것들은 컴포넌트의 역할을 이해하는데도 큰 도움을 주게 됩니다. 따라서 위에서 정의한 인터페이스의 정의에 한 가지를 더 추가할 수 있겠습니다.

인터페이스

  1. 인터페이스는 사용하는 쪽을 위해 존재합니다.
  2. 사용자는 인터페이스를 보고 컴포넌트의 역할을 예측할 수 있습니다. (인터페이스를 보고 예측가능해야 합니다.)
    • 인터페이스는 외부와의 의존성을 만듭니다.

내부의 구현을 감추자. (캡슐화)

FlexListItem 의 경우를 확인해 보겠습니다. 방금 언급한 컴포넌트의 네이밍 때문에 해당 컴포넌트는 flex 스타일로 레이아웃을 구성하고 있는 것이 예측가능합니다. 네이밍에 의해 내부의 구현이 바깥으로 노출된 것이죠.

이때 만약 flex 가 아닌 grid 스타일을 적용해야 하는 요구사항이 들어온다면 어떻게 해야 할까요? 그러면 네이밍을 변경하는 것이 불가피 할 것이고, 사용중인 모든 곳에 영향을 미치게 됩니다.

ListItem 으로 바꾼다면 어떨까요? Style에 관련된 맥락을 제거하여 내부의 구현을 숨기고 나니까 외부와의 의존성이 사라졌고 내부가 flex → grid로 변경되더라도 큰 문제가 생기지 않을 것 입니다. (물론 한단계 더 추상화를 해서 내부 구현을 감추고 외부에서 스타일을 주입받는 방식도 있겠습니다.)

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

😅 취준생 시절 작성했던 코드...

    // Header.tsx
    interface HeaderProps {
    	isMine: boolean;
    	isMyPage: boolean; // 인터페이스는 외부와의 의존성을 만드는데, 이는 MyPage와 강하게 의존하게 된다. 내부 구현(mypage와 의존하고 있는 코드)이 외부로 노출된 것
    	// ... 앞으로 또 요구사항이 추가된다면..?
    }
    
    export default function Header(({ isMine, isMypage }){
    	return <></>;
    }
    // 내부의 구현이 변경되면, 외부에 의존하고 있기 때문에 변경에 대응하기 쉽지 않다.

Header 컴포넌트가 있었습니다. 재사용되는 컴포넌트는 아니었지만, 공통의 컴포넌트였고 페이지가 새로 생겨날 때 마다 조금씩 다른 형태의 모습을 보여주고 있었습니다.

공유하기 페이지에서는 검색 아이콘과 사용자 아이콘이 빠졌고, 마이페이지에서는 검색 아이콘이 빠졌습니다. 처음에 헤더를 만든 친구가 isMine 이라는 boolean 값을 props로 받아와서 해결했습니다.

하지만 요구사항은 마이페이지까지 추가되었고, isMyPage 라는 props가 추가됨으로써 점점 읽기 어렵고 props에 의존한 복잡한 컴포넌트가 되어갔습니다.

요구사항에 맞춰 내부의 구현을 변경하려고 하면, 외부에 의존하고 있는 isMine 때문에 isMypage를 통해 요구사항을 맞춰주려고 하면 외부의 변화까지 고려해야 해서 간단한 요구사항도 수정하기 어려웠습니다.

예상 가능한 props 설계

ListItem 의 인터페이스(props, 컴포넌트 네이밍)만 보고서는 사용자가 button props가 어디서 어떻게 사용될지 예측하기 어렵습니다. 따라서 인터페이스의 특징인 "사용자는 인터페이스를 보고 컴포넌트의 역할을 예측할 수 있습니다" 에 맞게 컴포넌트에서의 역할이 드러나도록 네이밍을 수정해보도록 하겠습니다.

  1. 기본 attributes
    • 기본 HTML attributes의 역할과 일치하는 것이 있으면 네이밍을 같게 가져가는 것이 좋습니다.
    • 예를 들어 input 태그에 들어갈 핸들러를 props로 받아올 때 onChangeValue 보단, 네이밍 그대로 onChange 로 지정해주어 역할을 예측할 수 있도록 하는 것이죠. 내부의 구현을 들여다 보지 않고서도 말이죠!
  2. 역할이 드러나는 네이밍
    • li 태그의 attributes로 button이 일반적으로 위치하고 있진 않습니다. 기본 attributes에 없는 경우에 해당합니다.
    • 이럴 경우에 재엽님은 right 라는 props 네이밍을 예시로 들어주셨습니다. li 태그와 button의 조합으론 button이 어떤 역할을 하는지 예측하기 어렵기 떄문에, right 라는 이름을 주어 리스트의 오른쪽에 위치한 컴포넌트라는 역할을 알릴 수 있을 것입니다.
    • 또한, button의 역할에만 한정되었던 props가 위치적인 역할만 드러내어 버튼 이외에도 다른 컴포넌트를 위치시킬 수 있도록 확장에도 열려있게 됩니다.
  3. 널리 사용되는 네이밍
    • 디자인 시스템 open source를 참고하는 것도 큰 도움이 될 수 있습니다. 충분히 많이 고민하고 만들어진 시스템들 이기 때문에 참고한다면 도움이 될 것 같아요.

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

정리하기

// /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>
  )
}

추가하기

해당 글에서는

  • 컴포넌트를 어떻게 하면 잘 나눌 수 있을까?
  • 컴포넌트를 어떻게 하면 재사용할 수 있을까?
  • 어떻게 하면 좀 더 변경에 유연한 컴포넌트를 만들 수 있을까?

에 초점을 맞춘 글이에요. 은수가 마지막에 질문해줬던, 꼭 도메인 맥락을 제거해야 하는가? 에 대해서 답변을 글에 답변을 생략해버려서 첨언하자면, "이 컴포넌트는 다른곳에서 재사용 될 여지가 없는 컴포넌트인데?" 라는 생각이 든다면 굳이 이걸 재사용하기 위해 추상화 작업을 하면서 개발자의 리소스를 쏟을 필요는 없다고 생각합니다.

컴포넌트는 어쩔 수 없이 데이터와 도메인에 의존하고 있는데, 이를 재사용이 되지 않을 컴포넌트에서 까지 추상화해낼 필요는 없다고 생각합니다! 우리의 리소스 또한 중요하기 때문입니다.

reference

profile
사실은 내가 보려고 기록한 것 😆
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 8월 29일

글 감명깊게 잘 봤습니다!
한가지 든 생각은 컴포넌트의 주도권을 외부에 위임하면 그 위임하는 쪽 코드가 매우 비대해질 것이라는 예상이 되어요.
Juno님은 이에 대해서 어떻게 생각하시나요? 저는 아직 그 답을 찾지 못해서 실제 프로젝트 예제를 보고 감을 익혀보고 싶은데 위 컴포넌트 설계 원칙대로 구현한 코드를 볼 수 있을까요?

답글 달기