Back

자아가 없는 컴포넌트와 객체지향 컴포넌트

부제: 공통 컴포넌트는 허상이다.

언제나 처음 상태 그대로 존재하는 컴포넌트는 존재할 수 없다. 디자인을 하는 디자이너, 기획을 하는 기획자, 개발을 하는 개발자는 영원할 수 없다. 그 말인 즉슨 영원한 공통 컴포넌트는 존재하지 않는 다는 소리다. 우리는 그때는 맞고 지금은 틀리다라는 사실을 가슴속에 고이 품고 개발을 해야한다. 시간에 따라 옳고 그름이 달라진다. 우리가 지금 작성하는 코드도, 전임자의 코드도 마찬가지다.

그렇다면 진정한 의미에서 ‘공통 컴포넌트’란 무엇일까?

공통의 의미를 사전에서 찾아봤다.

공ː통, 共通 둘 또는 그 이상의 것에서 두루 해당되고 통용되는 일.

단어의 의미에 꼭 맞는 공통 컴포넌트가 뭔지 지금부터 한 번 알아보자.

용어 정리

우선 앞으로 사용할 용어에 대해서 정리부터 하겠다. 참고로 지금 풀어내고 있는 논리는 업계에서 사용하는 정확한 단어나 용어가 아니며, 개인이 멋대로 정의한 단어라는 것을 알아주시길 바란다…

뷰 컴포넌트 : HTML / CSS / 그리고 아주 작은 UX를 반영하고 있는 컴포넌트(체크박스 클릭) 컨트롤러 컴포넌트 : 뷰 컴포넌트에 역할과 자아를 부여하는 컴포넌트 공통 컴포넌트 : 필자가 허상이라고 생각하는 개념. 부정적인 의미로 사용하고 있다.

재사용이라는 함정

우리는 리액트를 처음 배울 때 컴포넌트는 재사용 가능하다고 배웠다. 하지만 곰곰이 생각해 보자. 우리는 이 재사용이라는 단어에 너무 도취되어 있는 게 아닐까? 개발자는 똑같은 코드를 작성하는 걸 죽을 만큼 싫어한다. 하지만 똑같은 코드를 작성해야 복잡도를 줄일 수 있다. 이게 어떤 말인지는 아래서 차차 설명해 보겠다.

사실 리액트에서 재사용 가능한 컴포넌트는 아주 단순해야한다. HTML과 CSS를 제공하는 뷰 컴포넌트만이 비로소 재사용 가능하다.

뷰 컴포넌트는 로직을 담고있으면 안되며, 아주 멍청해야 한다. 이 말인 즉슨 뷰 컴포넌트는 자체로 순수함수를 추구해야 한다는 것이다. 언제든지 변경될 수 있는 로직을 포함하고 있는 컴포넌트는 지금은 재사용 가능하겠지만 언젠가는 상하기 마련이다.

뷰 컴포넌트는 자아를 가지고 있으면 안된다. 로직을 담당하는 커스텀 hook또한 뷰 컴포넌트에 있으면 안되며, 오롯이 ‘보여지는’ View 껍데기 역할을 하고 자아는 Controller에 넘겨버리자.

🤔 그렇다면 어떻게 로직을 위임할 수 있나?
💡간단하다. 뷰 컴포넌트를 로직을 둘둘 싸고 있는 이불로 한겹 감싸면 된다. 이걸 컨트롤러 컴포넌트라고 부른다.

🤔 사용할 때 마다 미묘하게 기능이나 디자인이 다른 컴포넌트를 어떻게 재사용 할 수 있나?
💡간단하다. 컨트롤러 컴포넌트를 여러 개 만들면 된다.

코드의 중복을 최소화 하는 게 좋은 개발인데 중복으로 컴포넌트를 만들라고? 이런생각을 할 수도 있겠다. 하지만 Simple is the Best라고 했다. 읽기 좋은 코드가 결국 좋은 코드다.

공통 컴포넌트가 망가지는 과정

아래는 내가 면접에서 자주 써먹는 단골 면접 질문이다.

ex) 공통 컴포넌트가 망가지는 상황 시뮬레이션… 하나의 화면에서만 쓰일것으로 생각한 폼 컴포넌트가 있었음 -> 어느날 기획자가 해당 폼 컴포넌트를 다른 곳에서도 사용해달라고 요청함 -> 폼 내에서 사용되는 api 스펙도 다르고, 디자인도 미묘하게 조금씩 다름 -> 이런 상황에 놓여져있을 경우 OO님께서는 어떤 패턴으로 컴포넌트를 설계하실건지?

이런 말을 하면 안되지만 여기서 Props어쩌구를 답한다면 컴포넌트에 속아서 호되게 당한 경험이 없구나… 하는 생각이 든다. 개발을 하다보면 이런 상황을 수시로 마주하게 될것이다.

변화에 유연한 뷰 컴포넌트를 설계하고자 한다면 Headless로 구현하면 거진 다 해결된다. Headless한 형태를 가진 컴포넌트만이 리액트에서 유일하게 ‘공통으로 사용할 수 있는’ primitive한 컴포넌트가 된다.

위에서 잘 만들어진 뷰 컴포넌트는 컨트롤러 컴포넌트가 명령한 대로만 움직여야 한다. 아주 기본적인 동작만을 수용하는 것이다.

예를들어 뷰 컴포넌트의 예시로 아주 간단하게 폼을 헤드리스 형태로 만들어보겠다.

// Form.tsx - Form의 Root가 되는 컴포넌트

const Form = ({ onSubmit, formValues, children }: Props) => (
  <FormProvider value={{ onSubmit, formValues }}>{children}</FormProvider>
);

Form.Input = Input;
Form.Selector = Selector;
Form.Textbox = Textbox;
Form.Image = Image;
Form.SubmitButton = SubmitButton;

export default Form;

위 코드는 Form의 최상위에 존재하는 Root 뷰 컴포넌트다. 전달 받는 Props를 보자. 아주아주아주 단순하다. 이 형태의 컴포넌트는 form을 submit 할때 “뭘 할건지”, form의 Value로 “뭐가 들어올지” 전혀 알 수가 없다. 단순한 뼈대의 형태를 제공하는것. 이게 뷰 컴포넌트의 역할이다.

이 헤드리스한 Form 컴포넌트는 어떻게 사용될까? 아래는 뷰 컴포넌트를 조합해서 사용하는 컨트롤러 컴포넌트의 예시이다

const DefaultForm = () => {


  const formData = {
    ....
  }

  const onSubmitForm = () => {
    // 클라이언트 로거 찍고
    // 서버에 전송하고
    // 서버에서 받은 응답을 가공하고
    // try catch로 에러 처리하고
    // 성공하면 성공 모달을 띄우고
    // 실패하면 실패 토스트를 띄우고
  }

  return (
    <Form
      onSubmit={onSubmitForm}
      formValues={formData}
    >
      <Flex>
        <Form.label label="캐릭터 이름" />
        <Form.Input />
      </Flex>
      <Flex>
        <Form.label label="캐릭터 성별" />
        <Form.Selector />
      </Flex>
      ....
      <Form.SubmitButton />
    </Form>
  );
}

어떤 생각이 드나? 컨트롤러 컴포넌트는 뷰 컴포넌트 대신 멋지게 역할을 수행하고 있다. 뷰 컴포넌트는 onSubmit이 어떤 동작을 하는지 몰라도 부여받은 임무를 그대로 수행할 뿐이다. 사실 우리는 이미 이게 뭔지 알고 있다. 리액트 라이프사이클을 준수하던 시절, class로 로직을 짰던 시절 우리는 이걸 Container/Component라고 불러왔다.(Smart/Dumb 컴포넌트라고 부르기도 한다)

좀더 난해한 상황을 만들어보자. 위의 컨트롤러 컴포넌트의 이름은 DefaultForm다. 어느날 아래의 상황이 발생해버렸다.

-> 어느날 기획자가 해당 폼 컴포넌트를 다른 곳에서도 사용해달라고 요청함 -> 폼 내에서 사용되는 api 스펙도 다르고, 디자인도 미묘하게 조금씩 다름

열심히 만들었던 DefaultForm을 재사용할 좋은 타이밍이다! 이날을 위해 컴파운드 컴포넌트로 만들었다. 조건에 따른 분기처리는 너무너무 간단하다. 요구사항에 따라 DefaultForm내에서 UI 분기를 추가한다.

// FormType에 따라 조건부 렌더링을 수행하는 컨트롤러 컴포넌트
const DefaultForm = ({
  formType,
}: {
  formType: '캐릭터생성폼' | '회원가입폼';
}) => (
  const formData = {
    ...
  }

  const onSubmitForm = () => {
    ...
  }

  <Form onSubmit={onSubmitForm} formValues={formData}>
    <Flex>
      {formType === '캐릭터생성폼' ? (
        <Form.label label="캐릭터 이름" />
      ) : (
        <Form.label label="이름" />
      )}
      <Form.Input />
    </Flex>
    <Flex>
      {formType === '캐릭터생성폼' ? (
        <Form.label label="캐릭터 성별" />
      ) : (
        <Form.label label="자기소개" />
      )}
    </Flex>
    {formType === '회원가입폼' && (
      <EventBanner>지금 가입하시면 100만원 상당의 선물이 와르르~!</EventBanner>
    )}
    <Flex>
      <Form.label label="캐릭터 성별" />
      <Form.Selector />
    </Flex>
    ....
    <Form.SubmitButton />
  </Form>
);

formType이라는 Props를 받아 타입에 따른 조건부 렌더링을 충실해 수행했다. 이제 이 컴포넌트는 공통 컴포넌트로서 type에 따라 미묘하게 다른 폼을 렌더한다. 개발자는 빠르게 요청사항을 반영할 수 있어 뿌듯하다.

어느날 기존에 사용하던 캐릭터 생성 폼과 회원가입 폼 말고 새롭게 ‘이벤트 신청 폼’을 만들어달라는 요청이 왔다. 디자인상 이 세가지의 폼은 거의 차이가 없을테니 기획자와 디자이너는 당연히 똑같은 디자인의 폼을 만들어달라고 요구한다. 하지만 레이아웃이 다른. 그리고 이번엔 모바일 반응형도 반영해야한다.

개발자는 조금 갸우뚱하지만 잘 만들어둔 DefaultForm 컴포넌트를 수정하면 되겠지 하고 싱글벙글 코드를 짠다

// 세 개의 FormType에 따라 조건부 렌더링을 수행하는 컨트롤러 컴포넌트
const DefaultForm = ({
  formType,
}: {
  formType: '캐릭터생성폼' | '회원가입폼' | '이벤트신청폼';
}) => {
  const formData = {
    ...
  }

  const onSubmitForm = () => {
    ...
  }

  const isDesktop = useDesktop();

  const is캐릭터생성폼 = formType === '캐릭터생성폼';
  const is회원가입폼 = formType === '회원가입폼';
  const is이벤트신청폼 = formType === '이벤트신청폼';

  return (
    <Form onSubmit={onSubmitForm} formValues={formData}>
      <FormContainer resize={isDesktop && is이벤트신청폼}>

        <Flex>
          {is캐릭터생성폼 && <Form.label label="캐릭터 이름" />}
          {(is회원가입폼 || is이벤트신청폼) && <Form.label label="이름" />}
          <Form.Input />
        </Flex>

        <Flex>
          {is캐릭터생성폼 ? (
            <Form.label label="캐릭터 성별" />
          ) : (
            <Form.label label="자기소개" />
          )}
        </Flex>

        {is회원가입폼 && (
          <EventBanner>
            지금 가입하시면 100만원 상당의 선물이 와르르~!
          </EventBanner>
        )}

        {is캐릭터생성폼 && (
          <Flex>
            <Form.label label="캐릭터 성별" />
            <Form.Selector />
          </Flex>
        )}

        {is이벤트신청폼 && <Form.Image src="" alt="이벤트 홍보 배너" />}
        ....
        <Form.SubmitButton />
      </FormContainer>
    </Form>
  );
};

이번엔 폼이 복잡해진걸 인지하고 ‘is캐릭터생성폼’처럼 flag 변수도 정의하고, 가독성을 확보하기 위해 컴포넌트 사이에 공백도 추가했다.

이 컨트롤러 컴포넌트는 컨텍스트를 가지고 있는 창조주 개발자에 의해 그래도 어느정도 유지보수도 하고, 확장도 하며 잘 사용됐다. 그러던 어느날 창조주 개발자는 다른 회사로 이직을 하게 되고 신규 주니어 개발자가 새로운 폼을 만드는 태스크를 수행하게 된다. 코드의 원천을 본 신규 개발자는 코드 복잡도에 기함하게 되고, 전임자를 저주하며 밤을 새면서 태스크를 수행한다.

어디서 많이 본 것 같은 스토리 아닌가? 그렇다 내 얘기다.

레거시는 처음부터 레거시가 아니었다

왜 이 컨트롤러 컴포넌트는 ‘레거시 컴포넌트’가 되버린걸까? 분명 뷰 컴포넌트와 분리되서 훌륭하게 역할을 수행하고 있었는데 말이다. 문제는 뷰 컴포넌트가 아닌 컨트롤러 컴포넌트를 ‘공통 컴포넌트’라고 생각했기 때문이다. 공통 컴포넌트에 대한 믿음은 만악의 근원이다. 로직을 담고 있는 컴포넌트는 절대 공통으로 사용될 수 없다. 사용한다면 우리는 언제나 영겁의 윤회를 순회하며 눈물을 흘리게 될 뿐이다.

Default따위는 존재하지 않는다. 뷰 컴포넌트를 래핑한 컨트롤러 컴포넌트는 반드시 목적별로 카테고라이징 되어야한다. 컴포넌트의 이름을 DefaultForm 따위로 짓게 된다면 이런 사달이 난다. 복잡도를 잡기 위해선 컨트롤러 컴포넌트는 목적에 맞도록 찢어져야만 한다.

이게 옳게된 컨트롤러 컴포넌트 설계이다.

중복 코드가 생긴다고, 디자인이 비슷하다고 새로운 컴포넌트를 만드는걸 무서워 하지 말고, 과감하게 목적에 따라 분리해보자. 의도가 명확하게 살아있는 코드는 누가 읽어도 자연스럽게 읽히게 될것이다. 위에서 아래로 텍스트가 흐르게 만들자. 스크롤을 위아래로 왔다갔다하게 하고, 눈이 조금이라도 흐려진다고 길을 잃는다면 그건 코드를 짠 사람의 잘못이다.

객체지향 컴포넌트

나는 이 문제에 대한 답을 객체지향 프로그래밍에서 찾고있다. 그 중에 다형성이라는 주요 개념을 보다보니 위에서 나열한 주장과 개념적으로 크게 다르지 않았다.

다형성을 이해하기 위해선 우선 ‘객체 지향’이란 말부터 알아야 한다. 객체 지향은 마치 레고 블록을 조립해서 여러 가지 모양을 만드는 것처럼, 컴퓨터 프로그램을 여러 개의 ‘객체’라고 하는 작은 부품으로 만드는 방법이다. 그리고 이 객체들은 각각의 역할을 가지고 있다.

그럼 이제 ‘다형성’인데, 풀어보면 ‘여러 가지 형태를 가질 수 있는 성질’이라는 뜻이다. 다형성을 쉽게 설명하려면 “역할에 따라 다른 행동을 할 수 있게 하는 것”이라고 할 수 있다.

예를 들어보면, ‘그림을 그릴 수 있는’이라는 역할이 있다. 색칠하기, 선 긋기 같은 역할이다. 이 역할을 할 수 있는 여러 가지 도구가 있다. 크레용, 색연필, 마커 등등… 이 도구들은 모두 ‘그림을 그릴 수 있는’이라는 같은 역할을 가지고 있지만, 실제로 그림을 그릴 때 각기 다른 모습을 보여준다. 크레용은 굵고 뭉게지는 그림을, 색연필은 섬세한 그림을, 마커는 선명하고 또렷한 그림을 그린다.

이처럼, 같은 역할을 하면서도 상황에 따라 다른 방식으로 그 역할을 수행하는 것을 다형성이라고 한다. 컴퓨터 프로그래밍에서도 이런 다형성을 사용해서, 한 가지 역할에 대해 여러 가지 객체가 각기 다른 방식으로 그 역할을 수행하게끔 설계할 수 있다.

개념상 여기서 Form 이라는 인터페이스는 실제로 클래스는 아니지만 어떤 계약이나 규약을 의미한다.

다른 컨트롤러 컴포넌트들이 이 인터페이스나 규약을 준수하면서, 각기 다른 기능을 제공할 수 있도록 설계한다면 공통 컴포넌트의 같지만 다른 모호한 사용을 효과적으로 관리할 수 있을것이다.

새 인스턴스를 new 생성자 메서드를 사용해 만드는 것 처럼, 컨트롤러 컴포넌트도 목적에 따라 여러 개 생성하는 것에 두려움을 가지지 말자

개인적인 생각으로는 컨트롤러 컴포넌트를 여러 개 생성하는 건 아래와 크게 다르지 않은 것 같다.

const CharacterSelectForm = new Form("CharacterSelect");
const SignUpSelectForm = new Form("SignUp");
const EventForm = new Form("EventForm");