컴포넌트 컨벤션과 구조 개선하기

컴포넌트를 개선해 봅시다!

팀프로젝트를 하면서 컴포넌트가 일관되지 않는 모습을 많이 발견했습니다. 그래서 팀원과 논의하여 저희만의 컴포넌트 작성 방식을 만들기로 했습니다. React와 Emotion을 사용중이고 아래는 지금까지의 결론입니다.

레이아웃만을 위한 컴포넌트

이전에는 레이아웃을 잡기 위해서 Container라던지, Wrapper 라는 이름의 컴포넌트를 정의하고 css로 flex 속성을 주었습니다.

const Container = styled(div)`
  display: flex;
  flex-wrap: nowrap;
  justify-content: flex-start;
`;

그런데 이런 컴포넌트가 자주 반복되서 Flex 컴포넌트를 만들었습니다.

const Flex: React.FC<FlexBoxProps> = ({
  children = null,
  alignItems,
  justifyContent,
  flexDirection = 'row',
  flexWrap = 'nowrap',
  ...

이렇게 만들어 놓으니 css를 작성하는 코드가 많이 줄었고, 네이밍 걱정도 줄어서 좋았습니다.

<Flex flexDirection="column" columnGap="2px">
  {children}
</Flex>

추후에 Grid 컴포넌트도 개발할 예정입니다.

공통 컴포넌트

공통컴포넌트는 모양을 기준으로 나누었습니다. 도메인에 대한 내용을 빼면 모양(혹은 특정 기능)만 남기 때문입니다. UI라이브러리 처럼 만들고 있습니다.

컴포넌트 구조

컴포넌트의 구조에 대한 컨벤션입니다. 이 컨벤션을 적용해서 팀내의 일관성을 지킬 수 있었습니다.

import Button from '@components/button';
import UnorderedList from '@components/unorderd-list';

// Props를 맨 위에 둡니다
export type StudyListProps = {
  children?: React.ReactNode;
  variant?: 'primary' | 'secondary';
  onAddButtonClick: React.MouseEventHandler<HTMLButtonElement>;
  onRemoveButtonClick: React.MouseEventHandler<HTMLButtonElement>;
};

// Component는 화살표 함수로 표현하고, React.FC<Props>로 타입을 명시합니다
// - return type을 더 쉽게 강제할 수 있습니다
const StudyList: React.FC<StudyListProps> = ({
  children,
  variant,
  onAddButtonClick: handleAddButtonClick,
  onRemoveButtonClick: handleRemoveButtonClick,
}) => {
  return (
    <Self>
      <List>
        <Item>리팩토링 스터디</Item>
        <Item>프론트 스터디</Item>
        <Item>자바 스터디</Item>
      </List>
      <AddButton onClick={handleAddButtonClick}>추가하기</AddButton>
      <RemoveButton onClick={handleRemoveButtonClick}>삭제하기</RemoveButton>
    </Self>
  );
};

export default StudyList;

// styled component는 component에 같이 둡니다. 왜냐하면
// 1. 응집성 -> 위에서 바로 사용하는 컴포넌트들을 바로 아래쪽에 둠으로써 아주 약간이지만 응집성을 높입니다.
// 2. 캡슐화 -> 이 컴포넌트에서만 사용하는 하위컴포넌트(Self, StudyList, AddStudyButton ..)들은 외부에 노출하지 않습니다.
// 3. styled 파일을 따로 만들면, VSC에서 검색할때 조금 불편합니다.

// Self를 다른 컴포넌트에서 상속받아 사용하고 싶다면, (사용하는쪽에서 이곳의) Component를 import해서 상속 받으면 됩니다.
// 가장 바깥 컴포넌트는 Self로 명명합니다.
// 원래라면 S.StudyList라고 하는데 S를 빼고 싶은데 빼면 컴포넌트와 이름이 같아지기 때문에 Self로 명명했습니다.
const Self = styled.div`
  ${({ theme }) => css`
    padding: 10px;
    max-width: 500px;
    border: 2px solid ${theme.color.black}; // color는 theme에 정의한 color를 사용합니다
  `}
`;

type ListProps = {
  theme: Theme;
  children: React.ReactNode;
};
const List: React.FC<ListProps> = ({ theme, children }) => (
  // custom props는 허용된 css property만 넣을 수 있습니다.
  // css props를 사용하게 되면 아무거나 넣을 수 있기 때문에 디자인을 강제할 수 없게됩니다.
  <UnorderedList custom={{ marginBottom: '20px', backgroundColor: theme.colors.green }}>{children}</UnorderedList>
);

type ItemProps = {
  theme: Theme;
  children: React.ReactNode;
};
const Item: React.FC<ItemProps> = ({ theme, children }) => (
  <UnorderedList.Item custom={{ padding: '4px' }}>{children}</UnorderedList.Item>
);

type AddButtonProps = {
  children: React.ReactNode;
  onClick: React.MouseEventHandler<HTMLButtonElement>;
};

// 공통 컴포넌트는 이렇게 도메인에 대한 정보를 입혀서 활용합니다.
const AddButton = ({ children, onClick: handleClick }: AddButtonProps) => (
  <Button custom={{ transition: 'opacity ease 0.3s', hover: { opacity: 0.8 } }} variant="primary" onClick={handleClick}>
    {children}
  </Button>
);

type RemoveButtonProps = AddButtonProps;

const RemoveButton = ({ children, onClick: handleClick }: RemoveButtonProps) => (
  <Button custom={{ transition: 'opacity ease 0.3s', hover: { opacity: 0.8 } }} variant="danger" onClick={handleClick}>
    {children}
  </Button>
);

핵심은 2가지 입니다.

1. 도메인/역할 입히기

공통 컴포넌트를 가져와서 도메인 혹은 역할이 드러나는 이름으로 바꿉니다. 예를들어서, Button같은 경우 그냥 <Button onClick={handleAddButtonClick}> 보다는 <AddButton onClick={handleAddButtonClick}> 이 더 명시적이라고 생각합니다. 또한 추가적인 CSS가 있는 경우에 그것을 JSX 쪽에 드러내지 않을 수 있어서 가독성이 더 좋다고 느껴집니다.

<AddButton onClick={handleAddButtonClick}>추가하기</AddButton>

vs

<Button custom={{ padding: '6px' }} onClick={handleAddButtonClick} variant='primary'>추가하기</Button>

2. 지정된 스타일만 입력 가능

보통 props로 variation을 넘겨주는 방식을 많이 사용합니다. 그런데 종종 불가피하게 다른 스타일을 넣어줘야 할때가 있습니다. emotion에서는 이런 경우 css props를 활용할 수 있는데, 문제는 이렇게 하면 너무 자유를 줘서 전체적인 스타일의 일관성이 깨질 수 있습니다.

예를들어 버튼의 background-colorvariant로만 컨트롤 해야하는데, css={css'background-color: green;'} Emotion에서는 이렇게 작성할 수도 있습니다. 그래서 지정된 스타일만 커스텀 할 수 있도록 제한을 걸어두는 방법을 고안했습니다.

... 여러타입들
export const getResponsiveStyle = <AllowedCSSProperties extends KeyOfOriginalCSSProperties>(
  breakPoint: BreakPoint,
  styleObject: CSSPropertyWithValue<AllowedCSSProperties>,
) => {
  return css`
    ${mqDown(breakPoint)} {
      ${styleObject}
    }
  `;
};

export const resolveCustomCSS = <AllowedCSSProperties extends KeyOfOriginalCSSProperties>(
  custom?: CustomCSS<AllowedCSSProperties>,
) => {
  if (!custom) return css``;
  const { responsive, ...defaultStyle } = custom;

  if (responsive) { // 반응형 지원
    const { xs, sm, md, lg, xl } = responsive;
    const xsStyle = xs && getResponsiveStyle<AllowedCSSProperties>('xs', xs);
    const smStyle = sm && getResponsiveStyle<AllowedCSSProperties>('sm', sm);
    const mdStyle = md && getResponsiveStyle<AllowedCSSProperties>('md', md);
    const lgStyle = lg && getResponsiveStyle<AllowedCSSProperties>('lg', lg);
    const xlStyle = xl && getResponsiveStyle<AllowedCSSProperties>('xl', xl);

    return css`
      ${defaultStyle}
      // 우선순위가 중요합니다!! xl -> xs 순으로 놓아야 합니다
      ${xlStyle}
      ${lgStyle}
      ${mdStyle}
      ${smStyle}
      ${xsStyle}
    `;
  }

  return css`
    ${defaultStyle}
  `;
};
export type ImageProps = {
  shape: 'circular' | 'rectangular';
  src?: string | null;
  alt: string;
  custom?: CustomCSS<'marginBottom' | 'display'>; // 이렇게 custom가능한 css property를 넣어줍니다
};

const Image: React.FC<ImageProps> = ({
  shape = 'rectangular',
  src,
  alt,
  custom,
}) => {
  return (
    <Self
      css={resolveCustomCSS(custom)} // 여기서 resolve 해서 사용합니다
      src={src ?? notFoundImage}
      alt={alt}
      shape={shape}
    />
  );
};

그리고 사용하는 쪽에서

const Component = () => {
  return <Image custom={{ marginBottom: '20px', display: 'inline-block' }}></Image>;
};

marginBottom이나 display를 제외한 다른 css property를 입력하면 Typescript가 에러를 뿜어냅니다.

custom 스타일을 더 주고 싶을때는 Image.tsx에서 CustomCSS<'marginBottom' | 'display'> 여기에 추가하면 됩니다.

이렇게 하면 커스텀 스타일에 제한을 둘 수 있고, 이 제한을 벗어났는지 안벗어 났는지는 CustomCSS<'marginBottom' | 'display'>; 이곳을 보면 알 수 있습니다.

예를들어, CustomCSS<'marginBottom' | 'display' | 'border-radius'>; 을 보고

border-radius는 Image 컴포넌트의 본질을 건드는 부분이라서, 이것은 props로 받아야지 custom으로 받으면 안될것 같은데,,,’ 라고 빠르게 알아차릴 수 있게 됩니다.

다시말해서, 이런 제약을 두었음에도 어울리지 않는(본질을 해치는) 스타일을 준 경우 해당 컴포넌트에서 발견할 수 있다는 의미입니다.

남은일

  1. 각 컴포넌트들의 본질이 되는 부분들을 variant로 받기
  2. font-size, padding, margin 정량화 하기(10px -> md)
  3. grid 컴포넌트 만들기

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

Leave a Reply

Your email address will not be published.