컴포넌트 여행기(2) – jsx 가독성 높이기

어떤 기준으로 컴포넌트를 분리할 것인가?

결론

  1. conditional rendering은 즉시 실행 함수로 구현합니다
  2. jsx쪽에 스타일 코드는 가능한 제거하고, 역할로 추상화된 컴포넌트들만 둡니다.

Conditional Rendering

함수로 빼는 방법

const renderHeading = () => {
  if (isRegistered) return <AlreadyRegistered />;
  if (!isOpen) return <Closed />;
  if (!enrollmentEndDate) return <Open />;
  return <EnrollmentEndDate theme={theme} enrollmentEndDate={enrollmentEndDate} />;
};

return (
  <Card backgroundColor={theme.colors.white} shadow>
    <Card.Heading>{renderHeading()}</Card.Heading>
    <Card.Content>
      ...
    </Card.Content>
  </Card>
);

조건이 많을때 이렇게 함수로 빼내서(renderHeading) 호출하는 경우가 많습니다. 하지만 개인적으로 위쪽으로 스크롤을 하고 아래쪽으로 다시 내려오는 과정에서 기억해야 하는 비용이 발생한다고 생각합니다.

장황하게 설명을 하자면

  1. jsx코드를 읽다가
  2. 함수를 만나면
  3. 현재 이 함수가 호출된 환경(감싸고 있는 부모 컴포넌트)을 기억하고
  4. 함수의 구현부를 따라 올라간뒤
  5. 그 함수에서 어떤 컴포넌트를 렌더링 하는지 계산하고
  6. 그 계산 결과를 기억한뒤
  7. 아래로 내려와서 머리속에 기억된 렌더링 결과를 함수 호출된 위치에 대입을 합니다.

스크롤을 올렸다 내렸다 하는 비용 뿐만 아니라, 이렇게 기억해야되는 포인트들이 생기는 비용도 작지만 분명히 있다고 생각합니다.

삼항연산자로 표현하기

return (
  <Card backgroundColor={theme.colors.white} shadow>
    <Card.Heading>
      {isRegistered ? (
        <AlreadyRegistered />
      ) : isOpen ? (
        <Closed />
      ) : !enrollmentEndDate ? (
        <Open />
      ) : (
        <EnrollmentEndDate theme={theme} enrollmentEndDate={enrollmentEndDate} />
      )}
    </Card.Heading>
    <Card.Content>
      ...
    </Card.Content>
  </Card>
);

jsx안에 표현하면 함수로 뺐을때보다 스크롤 하는 비용과 맥락을 기억해야 하는 비용이 줄어드는 장점이 있습니다.

하지만 가독성에 관해 서는 팀원간의 의견차가 있을 수 있습니다.

삼항연산자에 익숙한 사람들은 저 코드를 자연스럽게 받아들이지만, 그렇지 않은 사람들에게는 직관적이지 않기 때문에 가독성이 안좋다고 느낄것입니다.

이와 관련해서 서로 대립하는 의견이 있습니다.

삼항연산자가 좋다는 의견 vs 가독성이 떨어진다는 의견

삼항연산자가 좋다는 의견

  • 삼항연산자를 연결하는 편이 if-else보다 작성하기 쉽다
  • 코드를 더 적게 작성하기 때문에 버그가 발생할 확률도 낮아진다
  • 임시 변수가 필요 없다

삼항연산자는 가독성이 떨어진다는 의견

  • 읽기가 어렵다. 왜냐하면 ?: 에 담긴 내제적인 의미(if-else)를 고려하면서 읽어야 하기 때문이다.
  • if-else문은 생각의 흐름과 코드가 일치하기 때문에 더 잘 읽힌다
  • 버그는 가독성이 떨어지는 코드에서 더 발생한다. 고로 삼항연산자를 많이 쓰면 쓸수록 버그가 더 발생할 확률이 높다.
// 읽기가 어려움
const withTernary = ({
  numberA, numberB, numberC
}) => (
  numberA < 50
    ? numberB + numberA
    : numberB < numberA
    ? numberB
    : numberC
);

// 비교적 읽기 쉬움
const withIf = ({
  numberA, numberB, numberC
}) => {
  if (numberA < 50) {
    return numberB + numberA;
  }

  if (numberB < numberA) {
    return numberB;
  }

  return numberC;
};

보시면, 삼항연산자 코드가 더 짧긴 하지만 덜 직관적입니다.

덜 직관적이라는 의미는

  1. condition ? 를 if (condition) 로 해석하는 비용
  2. :를 else( = 그게 아니라)로 읽어야 하는 비용
  3. : 이전은 return으로 끝난것이라고 해석해야 하는 비용

결과적으로 삼항연산자 보다는 if-else문이 뇌가 조건문을 이해하는 흐름과 일치하기 때문에 if-else문이 더 가독성이 좋다고 생각합니다.

다만, if-else하나 라면 삼항연산자로 짧게 쓰는것도 좋다고 생각합니다.

if-else로 표현하기

함수로 빼는 방법 외에도 jsx안에서 if-else로 표현하는 방법이 두가지 있습니다.

즉시 실행 함수

return (
  <Card backgroundColor={theme.colors.white} shadow>
    <Card.Heading>
      {(() => {
        if (isRegistered) return <AlreadyRegistered />;
        if (!isOpen) return <Closed />;
        if (!enrollmentEndDate) return <Open />;
        return <EnrollmentEndDate theme={theme} enrollmentEndDate={enrollmentEndDate} />;
      })()}
    </Card.Heading>
    <Card.Content>
      ...
    </Card.Content>
  </Card>
);

이렇게 즉시 실행 함수로 쓰면 jsx에서 if문을 사용할 수 있습니다.

do expression

아직 ts39 proposal stage 1이지만 d0-expression 문법도 있습니다.

babel-plugin을 통해 사용할 수 있습니다.

return (
  <Card backgroundColor={theme.colors.white} shadow>
    <Card.Heading>
      {do {
        if (isRegistered) {
          <AlreadyRegistered />;
        }
        else if (!isOpen) {
          <Closed />;
        }
        else if (!enrollmentEndDate) {
          <Open />;
        }
        <EnrollmentEndDate theme={theme} enrollmentEndDate={enrollmentEndDate} />;
      }}
    </Card.Heading>
    <Card.Content>
      ...
    </Card.Content>
  </Card>
);

즉시 실행 함수보다는 조금 더 눈에 잘들어 옵니다.

하지만 저희 프로젝트에서는 babel을 사용하지 않을 뿐더러 typescript를 사용하고 있기 때문에 아마 VSC에서 빨간줄이 마구마구 뜰것으로 예상됩니다.

typescript는 proposal stage 3까지는 올라가야 지원을 해줄 수도 있다고 하는데, 아직 몇년동안 stage-1에 머물러 있으니,, 한동안은 typescript에서 이 문법을 사용하지는 못할듯 싶습니다.

Razor Template Engine

microsoft의 razor template engine은 이런식으로 코드를 작성할 수 있다고 합니다. link

<div>
    @if(this.props.count) {
      <span>@this.props.count is even</span>
    }
</div>

아직 babel에서 이런 플러그인을 만들지는 않았지만, 이런식으로 작성할 수 있다면 직관적이고 편할듯 싶습니다.

Conditional Rendering에 대한 결론

  • if-else 한개 = 삼항연산자
  • 두개 이상 혹은 nested = 즉시실행 함수

jsx에는 컴포넌트의 역할이 드러나도록 한다

추가하기 버튼을 만드는 경우를 예로 들면,

<Button type="button" variant="primary">추가하기</Button>

vs

<AddButton />

전자보다는 후자가 더 읽기 좋고 역할이 분명하게 드러납니다.

조금 더 자세한 예시를 들어보겠습니다.

먼저, 추상화 하지 않은 코드입니다.

const MyCard: React.FC<UserInfoItemProps> = ({ children, heartCount, src, name, size }) => {
  return (
    <PageWrapper>
      <Heart> // => 추상화 할 수 있습니다
        <FillHeartIcon width="1.5rem" height="1.5rem" fill={theme.color.red} />
        <span>{heartCount}</span>
      </Heart>
      <Link to={PATH.COMMUNITY_PUBLISH}> // => 추상화 할 수 있습니다
        <TextButton variant="primary" custom={{ fontSize: 'lg' }}>
          글쓰기
        </TextButton>
      </Link>
      <Button // => 추상화 할 수 있습니다
        size="full"
        backgroundColor="blue"
        onClick={() =>
          openModal({
            content: <CardAddForm onSubmit={createCard} />,
            title: workbookName,
            closeIcon: 'back',
            type: 'full',
          })
        }
      >
        새로운 카드 추가하기
      </Button>
    </PageWrapper>
  );
}

추상화를 하지 않았기 때문에, 핵심이 가려집니다. 컴포넌트가 어떻게 생겼고 구체적으로 어떻게 동작할지에 대해서는 더 빠르게 파악할 수 있으나, 이런 정보가 너무 많아서 컴포넌트 핵심 기능과 정체성이 가려집니다.

이 컴포넌트를 추상화 하면 다음과 같습니다.

const MyCard: React.FC<UserInfoItemProps> = ({ children, heartCount, src, name, size }) => {
  return (
    <PageWrapper>
      <HeartCounter count={heartCount} />
      <WriteArticleLink />
      <AddNewCardButton />
    </PageWrapper>
  );
}

const HeartCounter = () => (
  <Heart>
    <FillHeartIcon width="1.5rem" height="1.5rem" fill={theme.color.red} />
    <span>{heartCount}</span>
  </Heart>
);

const WriteArticleLink = () => (
  <Link to={PATH.COMMUNITY_PUBLISH}>
    <TextButton variant="primary">
      글쓰기
    </TextButton>
  </Link>
);

const AddNewCardButton => () => (
  <Button
    size="full"
    backgroundColor="blue"
    onClick={() =>
      openModal({
        content: <CardAddForm onSubmit={createCard} />,
        title: workbookName,
        closeIcon: 'back',
        type: 'full',
      })
    }
  >
    새로운 카드 추가하기
  </Button>
)

기능별로 함수를 나누듯, 이렇게 컴포넌트를 추상화(분리)하면 컴포넌트의 구조를 더 쉽게 파악 할 수 있습니다.

언제나 분리하는것이 좋을까?

항상 함수로 나누는것이 좋은일은 아닌것처럼, 컴포넌트가 작은 경우에는 구태여 분리하지 않아도 됩니다. 예를들어,

const DeadLineField = () => (
  <Flex columnGap="10px" alignItems="center">
    <Label htmlFor={ENROLLMENT_END_DATE}>마감일자 :</Label>
    <Input
      id={ENROLLMENT_END_DATE}
      type="date"
      defaultValue={originalEnrollmentEndDate}
      {...register(ENROLLMENT_END_DATE, {
        min: minEndDate,
        max: maxEndDate,
      })}
    />
  </Flex>
);

이렇게만 해도 코드가 짧기 때문에 어떤 컴포넌트인지 파악하는데 어려움이 없습니다.

그리고 레이아웃을 기준으로 묶을 수 있는 경우 또한 따로 분리하지 않아도 됩니다. Layout컴포넌트인 Flex나 Grid등을 활용할 수 있기 때문입니다.

const UserInfoItem = ({ children, src, name, size }) => {
  return (
    <Self>
      <Flex columnGap="8px">
        <Avatar src={src} name={name} size={size} />
        <Flex.Item flexGrow={1}>
          <Flex flexDirection="column" columnGap="2px">
            {children}
          </Flex>
        </Flex.Item>
      </Flex>
    </Self>
  );
}

하지만 아주 가끔 도메인이 다른 여러 컴포넌트(A, B, C)가 오로지 스타일때문에 한 장소에 묶여있는 경우가있습니다. 이런 경우에는 어떻게든 네이밍을 지어서 스타일을 주면 좋겠지만, 어려운 경우에는 즉석에서 css props를 사용합니다. (emotion, styled-component 에서 지원합니다)

return (
  <div
    css={css`
      position: relative;
    `}
  >
    <LetterCounter count={count} maxCount={maxCount} />
    <ExceprtTextArea
      isValid={isValid}
      defaultValue={originalExcerpt ?? ''}
      register={register}
      onChange={handleExcerptChange}
    />
  </div>
);

만약에 css props를 더 짧게 쓰고 싶다면

twin.macro를 사용해 tailwind처럼 css를 더 간결하게 작성할 수도 있습니다.

return (
  <div tw="relative">
    <LetterCounter count={count} maxCount={maxCount} />
    <ExceprtTextArea
      isValid={isValid}
      defaultValue={originalExcerpt ?? ''}
      register={register}
      onChange={handleExcerptChange}
    />
  </div>
);


이상입니다. 읽어주셔서 감사합니다 😀

Leave a Reply

Your email address will not be published.