[React] 변경에 유연하게 대응하는 컴포넌트 만들기

SOPT·2023년 1월 1일
1
post-thumbnail

✨ 들어가며

여러분은 컴포넌트를 어떤 기준으로 분리하나요?

저는 코드가 너무 길어지거나, 중복이 많은 부분을 컴포넌트로 분리하는 것 같아요.

그런데 이 방법이 과연 맞는 방법인지, 다른 사람들은 어떤 식으로 컴포넌트를 만드는 지에 대해 궁금해졌어요.

좋은 컴포넌트는 어떻게 만들 수 있는지 [토스 | 지속 가능한 성장과 컴포넌트](https://www.youtube.com/watch?v=fR8tsJ2r7Eg) 를 보고 정리한 것을 공유하고자 합니다.

✨ 변경에 대응할 수 있는 컴포넌트

제품을 성장시키기 위해서 제품의 변경은 필연적입니다.

제품의 변경사항은 이전에 놓쳤던 고객의 니즈를 발견한 것과 같아요.

우리는 특정 프로덕트의 사용자에 대해 모르기 때문에 어떤 니즈가 있는지, 어떤 변경이 발생할 지 예측할 수 없습니다.

따라서 변경을 예측하기 보다는 대응해야 합니다.

제품이 만들어지는 흐름

여러분은 어떤 기준으로 코드를 분리하나요?

다음과 같은 경우에 코드를 분리할 수 있을 것 같아요.

- 중복되는 게 많아서
- 코드가 너무 크고 길어서

하지만 이러한 이유로 코드를 분리하면 아주 많은 props를 컴포넌트에 주입해야 하고, 나중에 코드를 보면 이게 무엇을 의미하는 지 일일이 해석해야 하는 번거로움이 생길 거에요.

또 다른 사람이 내 코드를 보면 절대 이해하지 못할 지도 모릅니다.

만들다보니 페이지가 너무 커져서, 적당히 덜어내다 보니 이러한 결과를 낳게 됩니다.

변경에 대응하는 컴포넌트 만들기

그렇다면 변경에 유연하게 대응하도록 짜여진 컴포넌트는 어떤 특징을 갖고 있을까요?

  1. Headless 기반의 추상화하기 : 변하는 것 vs 상대적으로 변하지 않는 것
  2. 한 가지 역할만 하기 : 또는 한 가지 역할만 하는 컴포넌트의 조합으로 구성하기
  3. 도메인 분리하기 : 도메인을 포함하는 컴포넌트와 그렇지 않는 컴포넌트 분리하기

다음과 같은 3가지 특징을 가진 컴포넌트를 차례대로 살펴볼게요.

1. Headless UI 기반의 추상화하기

컴포넌트는 크게 3가지의 역할을 합니다.

데이터 - UI- 사용자

컴포넌트는 데이터를 관리합니다. 그리고 UI(User Interface)를 통해 데이터를 어떻게 보여줄 지 관리해요.

어떻게 보여질 지 정의하는 부분은 디자인에 의존합니다.
이렇게 디자인에 의존하는 UI를 컴포넌트가 관리하는 데이터로 분리해보면 어떨까요?

달력 컴포넌트를 만든다고 가정하면, 데이터 자체는 변하지 않지만 UI는 언제든지 변경될 수 있습니다.
따라서 변경에 유연하게 대응하기 위해 이 둘을 분리해보면 좋을 것 같아요.

2x2 배열로 Date 객체를 만들 수 있어요. 현재 날짜에 대한 값도 함께 추상화해볼 수 있습니다.

그리고 데이터는 useCalendar라는 hooks로 관리할 수 있습니다. 이를 어떻게 보여줄 지, 즉 UI만 정의하면 됩니다.

export default function Calendar() {
  const { headers, body, view } = useCalendar();
  return (
    <Table>
      <Thead>
        <Tr>
          {headers.weekDays.map(({ key, value }) => {
            return <Th key={key}>{format(value, "E", { locale })}</Th>;
          })}
        </Tr>
      </Thead>
      <Tbody>
        {body.value.map(({ key, value: days }) => (
          <Tr key={key}>
            {days.map(({ key, value }) => (
              <Td key={key}>{getDate(value)}</Td>
            ))}
          </Tr>
        ))}
      </Tbody>
    </Table>
  );
}

만약 변경사항이 생겨 디자인이 달라지더라도, 혹은 비슷한 컴포넌트이지만 디자인이 다르더라도 hook을 만들어 놓으면 가져다 쓰기만 하면 됩니다.
달력을 구성하는 데 필요한 값을 계산하는 역할을 useCalendar hooks에 위임한 것으로 볼 수 있어요.

이와 같이 UI를 관심사에서 제외하여 오로지 데이터에만 집중해서 모듈화하는 것을 Headless라고 합니다.


UI와 사용자가 상호작용 하는 부분도 분리해볼게요.

interface Props extends ComponentProps<typeof Button> {
  onLongPress?: (event: LongPressEvent) => void;
}
export function PressButton({ onLongPress, ...props }: Props) {
  return (
    <Button
      onKeyDown={(e) => {
        // ...
      }}
      onKeyUp={(e) => {
        // ...
      }}
      onMouseDown={(e) => {
        // ...
      }}
      onMouseUp={(e) => {
        // ...
      }}
      {...props}
    />
  );
}

길게 꾹 누르는 onLongPress라는 상호작용을 정의하고 싶은데, 이미 많은 로직이 들어가있다면 컴포넌트 내부가 복잡하고 코드가 지저분해질 수 있어요.

이런 경우 onLongPress 로직을 컴포넌트 내부에서 정의하기 보다는, hooks로 분리하여 리턴값을 적용하기만 하면 UI와 데이터를 분리할 수 있습니다.


interface Props extends ComponentProps<typeof Button> {
 onLongPress?: (event: LongPressEvent) => void;
}
export function PressButton(props: Props) {
 const longPressProps = useLongPress();
 return <Button {...longPressProps} {...props} />
}
function useLongPress() {
 return {
	// ...
 )
}

이렇게 로직을 따로 분리해두면 다른 컴포넌트에서 LongPress 이벤트를 적용하려고 할 때 hook을 가져다 쓰기만 하면 된다는 장점도 있어요.

hooks로 모듈화하는 내용을 길게 소개한 이유는 변경에 유연해지려면 각 모듈이 한 가지 일만 하는 것이 중요하기 때문입니다.

2. Composition

그렇다면 복잡한 컴포넌트가 한 가지 역할만 하려면 어떻게 해야할까요?

function DateSelect() {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState("이번 주");
  const options = ["오늘", "이번 주", "이번 달", "올해"];
  return (
    <>
      <SelectButton value={selected} />
      {isOpen && (
        <Options onClick={() => setIsOpen(false)}>
          {options.map((option) => {
            return (
              <Button
                selected={selected === option}
                onClick={() => setSelected(option)}
              >
                {value}
              </Button>
            );
          })}
        </Options>
      )}
    </>
  );
}

위 코드는 재사용이 어렵습니다. 만약 해당 컴포넌트를 다른 컴포넌트에서도 사용한다면 어떨까요?

일자를 선택하는 부분이 다른 컴포넌트로 바뀌거나, 새로운 기능을 하는 버튼이 추가되는 등의 변경이 생긴다면 대응이 어렵게 될 거에요.

function Select(props) {
  const { isOpen, trigger, value, onClick, options,handleIsOpen } = props;
  return (
    <Dropdown value={value}>
      <Trigger>{trigger}</Trigger>
      <Menu onClick={onClick} isOpen={isOpen}>
        {options.map((option) => (
          <Item key={option} isSelected={option === value} onClick={handleIsOpen}>
            {option}
          </Item>
        ))}
      </Menu>
    </Dropdown>
  );
}
  • 메뉴의 노출 여부를 제어하는 상태인 isOpen ->조건부 렌더링 말고 함수로 처리 ,
  • 상태를 바꾸기 위한 상호작용 trigger -> Trigger,
  • 옵션 영역 menu -> Menu,
  • 메뉴를 구성하는 각각의 아이템 item -> Item

이렇게 다루고 있는 데이터, 담당하고 있는 역할을 기준으로 분리해볼 수 있어요.

function Velog() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedDate, setSelectedDate] = useState("이번 주");
  const dates = ["오늘", "이번 주", "이번 달", "올해"];

  const handleSelectedDate = (e) => {
    setSelectedDate(e.target.innerText);
  };

  const handleIsOpen = () => {
    setIsOpen((prev) => !prev);
  };

  return (
    <Root>
      <Select
        trigger={<DropBox value={selectedDate} handleIsOpen={handleIsOpen} />}
        isOpen={isOpen}
        value={selectedDate}
        handleIsOpen={handleIsOpen}
        onClick={handleSelectedDate}
        options={dates}
      />
    </Root>
  );
}

trigger로 전달한 DropBox컴포넌트와 Select컴포넌트는 서로의 존재를 알지 못합니다. 따라서 변경이 발생해도 서로 영향을 주지 않을 거에요.

이와 같이 합성(Composition)이 가능하도록 컴포넌트를 만들면 재사용하기도 좋고, 확장하는 데에도 좋아요.

3. 도메인 분리하기(인터페이스를 일반화하자)

여기서 도메인은 다루고 있는 비즈니스(ex. velog의 일자별 드롭박스에서는 일자별 이 도메인)와 관련된 부분이라고 생각하면 됩니다.

컴포넌트를 주입받은 것처럼 데이터도 주입받으면 어떨까요?

‘Date’라는 도메인을 분리해볼게요.

다음과 같은 props를 전달했다고 가정하고 props 네이밍에서 도메인 맥락을 제거해볼게요.

interface Props {
- selectedDates: string[];
+ value: string[]
- onDateChange: (selecteds: string[]) => void;
+ onChange: (value: string[]) => void;
- datesOptions: Array<{ label: string }>;
+ options: Array<{ label: string }>
}

도메인 맥락을 지우다보면 일반적인 이름으로 변경되고, 컴포넌트 인터페이스는 일반적일수록 이해하기 쉽습니다.

컴포넌트 네이밍, props 네이밍을 신중히 고민하는 연습이 필요할 것 같아요.

Action Item

좋은 코드, 컴포넌트를 작성하기 위한 두 가지 Action Item을 소개합니다.

인터페이스를 먼저 고민하자

이미 컴포넌트가 존재한다고 가정하고 해당 컴포넌트를 고민해보는 건 어떨까요.

  • 의도가 무엇인가?

  • 컴포넌트의 기능은 무엇인가?

  • 어떻게 표현되어야 하는가?

    이 3가지가 구현보다 중요한 요소들입니다. 변경하려고 할 때 파악해야 하는 것들이기 때문이에요.

컴포넌트를 나누는 이유에 대해 다시 생각하자

  • 컴포넌트로 빼면 실제로 복잡도를 낮추는지 생각하기
  • 컴포넌트로 빼면 재사용 가능한 컴포넌트인지 고민하기
  • 꼭 분리해야 하는지 고민하기

잘 만든 컴포넌트는 함께 일하는 동료에게도 도움이 될 수 있스빈다.

따라서 변경에 유연한 컴포넌트는 비즈니스를 안정적으로 만드는 데에도 도움이 됩니다.

✨ 마무리

  • 컴포넌트가 변경에 유연하기 위한 세 가지 특징
  1. Headless 기반의 추상화하기
  2. 한 가지 역할만 하기
  3. 도메인 분리하기
  • 시도해볼 수 있는 Todo List
  1. 인터페이스 먼저 고민하기
  2. 분리하기 전 다시 생각하기
    ⑴ 복잡도를 낮추는가?
    ⑵ 재사용 가능한 컴포넌트가 만들어지는가?

[토스 | 지속 가능한 성장과 컴포넌트](https://www.youtube.com/watch?v=fR8tsJ2r7Eg) 를 보고 영상에 등장하는 수도코드를 해석해서 velog의 일자 선택 드롭박스 컴포넌트를 변경에 유연히 대응할 수 있게 구현해본 내용을 정리해보았습니다.
앞으로 컴포넌트를 만들 때 무작정 구현하기보다는 컴포넌트의 기능과 분리에 대해 고민하고 설계해보는 시간을 가져야겠습니다.


작성자
IN SOPT WEB, OB 이서영

profile
IT 대학생벤처창업동아리 SOPT의 공식 블로그입니다.

0개의 댓글