아토믹패턴 적용기 - boostcampwm-2021/WEB08-AgileStorming GitHub Wiki

작성자: 조성현

🧙‍♂️ 기존의 페이지별 컴포넌트 관리 방법

컴포넌트를 분리하고 관리하는 것에 대해 고민이 많았다. 재사용 되지 않을 컴포넌트는 어디에 두어야하며? 폴더 구조를 어떻게 해야되는 것인가?

그래서 사용하던 방식은 페이지별로 컴포넌트를 나눠서 보관하고 공용으로 재사용되는 컴포넌트는 common으로 빼는 것이었다.

이 방법 충분히 납득할만한 방법이지만, 재사용 컴포넌트가 많아 질수록 common이 복잡해진다는 단점이 있었다. 특히, css-in-js를 도입하여도 재사용 시키기가 애매했다.

그래서 컴포넌트 분리에 대한 관련 글을 읽다, 이 글을 읽고 아토믹패턴에 대해 알게 되었고 프로젝트에 적용해보기로 했다.

🧙‍♂️ 아토믹 패턴에 대해

조금만 검색을 해보면 자세히 알 수 있으니 간단히만 소개하자면, 아토믹패턴은 컴포넌트를 재사용 단위로 잘게 쪼개서 조합하는 방식으로 사용하는 방식이다.

아토믹 패턴을 적용하면서 느낄 수 있던 장점은 다음과 같다.

1. 컴포넌트를 조합해서 사용함으로 재사용성이 올라간다.

아토믹 패턴의 기본은 컴포넌트를 재사용할 수 있도록 디자인하는 것이다. 이는 컴포넌트를 만드는데는 시간이 더 들어간다. 좀 더 general한 컴포넌트를 만들어야 하기 때문이다.

예를 들어 로그인 버튼을 만든다면, 이를 버튼 컴포넌트로 만들어 클릭했을 때는 동작할 함수 등을 props로 받아서 로그인 버튼으로 재사용하도록 만드는 것이다. 초기에는 시간이 들지만 점점 컴포넌트들이 쌓일 수록 재사용성이 올라가면서 오히려 개발 속도는 향상된다.

2. 컴포넌트를 찾기가 쉽다.

컴포넌트 단위인 atom, molecule, oragnism, template 주어진 역할이 있고 제약이 있기 때문에 컴포넌트를 찾기가 쉽다. 이는 별거 아닌 장점인 것 같아도, 협업하는 인원이 많고 프로젝트가 복잡해질수록, 컴포넌트가 어디있는지 몰라 재사용을 못하는 경우가 많다.

예를 들어, 컴포넌트를 도메인으로 나눈 경우, 로그인 버튼을 재사용을 하고 싶은데, 이 버튼이 컴포넌트화 되어있는지, 로그인페이지 폴더에 있는지, 혹은 재사용을 위해 common 폴더에 있는 지 확인해야만 알 수가 있다. 아토믹 패턴을 통해 코드 위치를 찾는 시간을 줄이는 것도 큰 장점이다.

3. 스타일을 관리하기가 쉽다.

작은 단위 부터 재사용이 일어나기 때문에 획일화 된 테마를 서비스에 적용시키기가 쉽다. 예를 들어 input을 atom으로 만들어놓고, 로그인 페이지, 회원가입 페이지 등 다양한 곳에 활용된다면 모두 같은 스타일을 가질 수 있고, 후에 수정할 때에도 input 컴포넌트만 바꾸면 된다. 또한 중복되는 스타일 선언을 최소화 시킬 수도 있다.

🧙‍♂️ 아토믹패턴 예시

어떻게 아토믹 패턴을 적용했는지, 각 단위에 대해 예시를 들며 설명하겠다.

atom

가장 작은 단위, HTML 태그 하나 (p, button, input)이다. css-in-js를 사용한다면 태그 하나를 atom으로 만들어 재사용하기 쉽다.

다음은 TransparentButton atom 예시이다.

children과 onClick 함수를 받아 재사용을 할 수 있게 만들어 놨다. css-in-js는 style.ts 로 분리해놓았다. index에서 로직에 더 집중을 할 수 있다.

import { StyledButton } from './style';

interface IProps {
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  margin?: string;
  zIdx?: string;
}

const TransparentButton: React.FC<IProps> = ({ children, onClick, margin = '0', zIdx = '0' }) => {
  return (
    <StyledButton onClick={onClick} margin={margin} zIdx={zIdx}>
      {children}
    </StyledButton>
  );
};

export default TransparentButton;

molecule

atom들의 조합, 한 가지 기능을 가진다. 로직을 가질 수 없다. molecule 자체로는 페이지의 레이아웃이 될 수 없고, 어떤 페이지 요소의 일부로 사용된다.

IconButton으로 예를 들겠다. atom인 IconImg, TransparentButton의 조합으로 사용된다. 로직을 가질 수 없기 때문에 onClick 함수를 props로 받는다.

import { IconImg, TransparentButton } from 'components/atoms';

interface IProps {
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  imgSrc: string;
  altText: string;
  margin?: string;
  zIdx?: string;
  size?: { width: string; height: string };
}

const IconButton: React.FC<IProps> = ({ onClick, imgSrc, altText, margin, zIdx, size }) => {
  return (
    <TransparentButton onClick={onClick} margin={margin} zIdx={zIdx}>
      <IconImg imgSrc={imgSrc} altText={altText} size={size} />
    </TransparentButton>
  );
};

export default IconButton;

organism

atom, molcule 들의 조합. organism부터는 실질적인 레이아웃 단위를 갔는다.사용자 입장에서도 의미있는 페이지 레이아웃이여야한다.

이는 곧 재렌더링과도 관련있기 때문이라고 생각한다. organism부터는 로직을 가질 수 있는데, 로직을 가질 수 있다함은 재렌더링이 일어났을 때 안에 있는 함수들이 다시 선언이 일어난게 된다. 만약 atom부터 로직을 가질 수 있다면 함수들이 너무 자주 재선언이 되서 비효율적이다. 하지만 organism 정도 되면 재렌더링을 관리하기에도, 이에 의해 내부 함수들이 재선언되기에도 합리적인 단위라고 할 수 있겠다.

회원가입 모달창 예시이다.

organism부터는 재사용은 가능하지만 재활용성(확장성)은 크게 떨어진다. 어디서든 회원가입 모달창을 띄울 수 있지만, 회원가입 모달창을 더 확장시켜 다른 컴포넌트를 만들기는 어렵다.

회원가입시 입력에따라 실행되어야할 함수가 내부에 선언되어 있으며, 여러 atom과 molecule를 재사용하였다.

커스텀 훅을 사용하면 좀더 깔끔한 컴포넌트를 만들 수 있는 것도 확인할 수 있다. (또한 커스텀훅을 재사용할 수도 있다.)

import { AxiosError } from 'axios';
import React, { ChangeEvent, useRef } from 'react';
import { ModalOverlay, Input, Title } from 'components/atoms';
import { PopupLayout, TextButton } from 'components/molecules';
import useKeys from 'hooks/useKeys';
import useModal from 'hooks/useModal';
import useToast from 'hooks/useToast';
import { auth } from 'utils/api';

export interface IRegisterModalProps {
  key?: string;
}

interface INewUser {
  id: string;
  name: string;
}

const RegisterModal: React.FC<IRegisterModalProps> = () => {
  const newUser = useRef<INewUser>({ id: '', name: '' });
  const { hideModal } = useModal();
  const { setOnEscKey, setOnEnterKey } = useKeys();
  const { showMessage, showError } = useToast();

  const handleIdChange = (e: ChangeEvent<HTMLInputElement>) => (newUser.current.id = e.target.value);
  const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => (newUser.current.name = e.target.value);
  const handleSubmit = async () => {
    if (newUser.current.id === '' || newUser.current.name === '') {
      showMessage('아이디와 이름을 입력해주세요');
      return;
    }
    try {
      const res = await auth.register(newUser.current.id, newUser.current.name);
      if (res.status === 200) {
        showMessage('가입되었습니다.');
        hideModal();
      }
    } catch (err) {
      showError(err as Error | AxiosError);
    }
  };

  setOnEnterKey(handleSubmit);
  setOnEscKey(hideModal);

  return (
    <>
      <ModalOverlay visible={true} onClick={hideModal} />
      <PopupLayout title={'회원가입'} popupStyle={'modal'} onClose={hideModal}>
        <Title titleStyle={'normal'} margin={'2rem 0 0 0.5rem'}>
          아이디
        </Title>
        <Input inputStyle={'gray'} placeholder={'아이디를 입력해주세요'} margin={'1rem 0'} onChange={handleIdChange} />
        <Title titleStyle={'normal'} margin={'1rem 0 0 0.5rem'}>
          이름
        </Title>
        <Input inputStyle={'gray'} placeholder={'이름을 입력해주세요'} margin={'1rem 0'} onChange={handleNameChange} />
        <TextButton onClick={handleSubmit} text={'확인'} textColor={'red'} textWeight={'bold'} margin={'1rem 0 0 auto'} />
      </PopupLayout>
    </>
  );
};

export default RegisterModal;

template

atom, molcule, orgaism들의 조합. template은 페이지 요소들의 조합으로 보통 페이지에서 템프릿에 데이터를 주입해 재사용되는 데 사용한다.

예를 들어, 게시판 페이지가 있다면, template에서 글 목록, 다음 페이지 버튼 등 큰 덩어리의 레이아웃을 가지고 있고, 게시판 page에서 데이터를 불러와 template에 주입하는 식이다.

우리팀은 Recoil을 사용했기 때문에, 굳이 page에서 template에 데이터를 주입할 필요없이 상태값을 가져오게했다. 그래서 페이지의 필요성이 조금 모호한 포지션이 되었다.

또한, CommonLayout 컴포넌트를 만들어서 모든 페이지에서 공용으로 사용되는 레이아웃을 만들어 재사용한게 유용했다.

예시는 ChartContainer로 여러 차트 organism을 상태에 맞춰 렌더링하는 컴포넌트이다.

import React from 'react';
import { useRecoilValue } from 'recoil';
import ChartHeader from './ChartHeader';
import { StyledChartBackground, StyledChartContainer } from './style';
import { BurnDownChart, DoneTaskChart, PriorityChart, TaskRatioChart } from 'components/organisms';
import { selectedChartState } from 'recoil/chart';

const ChartContainer: React.FC = () => {
  const selectedChart = useRecoilValue(selectedChartState);
  const ChartComponent = (chartName: string) => {
    switch (chartName) {
      case 'task-ratio':
        return <TaskRatioChart />;
      case 'priority':
        return <PriorityChart />;
      case 'task-done':
        return <DoneTaskChart />;
      case 'burndown':
      default:
        return <BurnDownChart />;
    }
  };
  return (
    <StyledChartContainer>
      <ChartHeader />
      <StyledChartBackground>{ChartComponent(selectedChart)}</StyledChartBackground>
    </StyledChartContainer>
  );
};

export default ChartContainer;

🧙‍♂️ 아토믹패턴 적용하면서의 적응기

느꼈던 꿀팁...?은 다음과 같다.

합의된 원칙이 있어야한다.

아토믹 패턴 관련글들을 읽어보면 공통적으로 얘기하는 부분이 atom, molecule, organism, template, page 들을 구분하는 것이다. 이 컴포넌트가 molecule인지, organism인지 헷갈릴 수도, 이 컴포넌트는 페이지 레이아웃은 아닌데 로직을 가져야만할 수 도 있다. 우리 프로젝트처럼 store를 사용해서 page부터의 데이터 주입이 애매할 수도 있다.

우리팀은 그래서 회고를 통해, 합의된 원칙을 도출했다. 애매한 부분이 있다면 팀원들이 회고를 통해 원칙을 정하거나 수정하는 것을 추천한다. 꼭, 5가지 단위로 나눌 필요도 없고 molecule을 없앤다거나, 또 새로운 단위를 추가할 수 있다. molecule이 로직을 가질 수도 있다.

우리 팀 회고에서 적어 놓은 내용이다.

늘 그렇듯 정답은 없다. 모두가 공감하는 원칙을 만들자.

슈퍼 컴포넌트를 방지하자

초반에 미숙했던 경우 style 관련한 props를 너무 많이 사용하다보니, props가 너무 많아지는 경우가 생겼다. 이렇게 props가 많아지면 너무 많은 역할을 하는 슈퍼 컴포넌트가 아닌지 관심사분리를 고민해 보아야한다.

<button width={"100px"} height={"100px"} fontSize={"16px"} fontHeight={"10px"} color={"white"} marginTop={"20px"} onClick={onClickHandler} />

나는 스타일은 normal, large, small 등으로 추상화된 props로 받아 각각에 맞는 스타일을 적용시켜 스타일 관련 props를 줄였다. 예시는 가장 많이 재사용된 BoxButton 컴포넌트다.

보면 React의 ButtonHTMLAttributes 타입을 상속해 기존 버튼의 props를 ...props(rest 문법)을 사용해 처리 시켰고

btnStyle이라는 추상화된 props로 스타일을 받아온다.

// index.tsx
import React, { ButtonHTMLAttributes } from 'react';
import { StyledButton, TColor, TStyle } from './style';

interface IProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children?: React.ReactNode;
  btnStyle?: TStyle;
  color?: TColor;
  margin?: string;
}

const BoxButton: React.FC<IProps> = ({ children, margin = '0.5rem 0', color = 'white', btnStyle = 'normal', ...props }) => {
  return (
    <StyledButton btnStyle={btnStyle} color={color} margin={margin} {...props}>
      {children}
    </StyledButton>
  );
};

export default BoxButton;

style.ts에서는 추상화된 style props에 맞는 CSS옵션을 각각 지정해줌으로써 다양한 스타일을 사용할 수 있게 하였다.

//style.ts
import styled from '@emotion/styled';
import { ButtonHTMLAttributes } from 'react';
import { common } from 'styles';

export type TStyle = 'small' | 'normal' | 'large' | 'full';
export type TColor = 'white' | 'primary1' | 'primary2' | 'primary3';

interface IStyleProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  btnStyle: TStyle;
  color: TColor;
  margin?: string;
  bgColor?: string;
}

export const StyledButton = styled.button<IStyleProps>`
  all: unset;
  ${({ btnStyle }) => styleOptions[btnStyle]}
  ${({ color }) => colorOptions[color]}
  ${({ theme }) => theme.flex.center};
  ${({ theme }) => theme.shadow};
  margin: ${({ margin }) => margin};
  font-family: Noto Sans KR;
  border-radius: 0.5rem;
  font-weight: bold;
  cursor: pointer;
  gap: 0.5rem;

  :hover,
  :focus {
    filter: brightness(1.1);
  }
`;

const styleOptions: { [key in TStyle]: string } = {
  small: `
    padding: 0.4rem 0.8rem;
    font-size: ${common.fontSize.small};
  `,
  normal: `
    padding: 0.5rem 1rem;
    font-size: ${common.fontSize.normal};
  `,
  large: `
    padding: 0.5rem 1rem;
    font-size: ${common.fontSize.large};
  `,
  full: `
    width: 100%;
    padding: 0.9rem 0;
    font-size: ${common.fontSize.large};
  `,
};

const colorOptions: { [key in TColor]: string } = {
  white: `
      color: ${common.color.black};
      background-color: ${common.color.white};
    `,
  primary1: `
      color: ${common.color.white};
      background-color: ${common.color.primary1};
    `,
  primary2: `
      color: ${common.color.white};
      background-color: ${common.color.primary2};
    `,
  primary3: `
      color: ${common.color.black};
      background-color: ${common.color.primary3};
    `,
};

컴포넌트의 위치와 크기를 정하는 스타일 중에 margin 정도는 props로 받아서 처리하는게 재사용성에 좋다. 심지어 한방에 4방향의 값을 받을 수도 있지 않은가?

<button btnStyle={"large"} margin={"10px 0 0 10px"} onClick={onClickHandler}/>

복잡해진다면 분리하고 폴더로 묶자

개발 도중, 복잡한 컴포넌트를 만들게 되었는데, 컴포넌트를 쪼개자니 재사용할 것같은 컴포넌트도 아닌데 나누기가 마음이 불편했다. 하지만, 팀원들과의 토론 끝에 나누는 쪽을 택하게 되었는데 그 이유는 결국 누군가 문제를 고치려고 이 코드를 보았을 때 복잡한 큰 컴포넌트가 있는 것보다 molecule이나 atom으로 찾아 들어가는게 빠를 것이라는 결론 때문이었다.

이렇게 되면 atom이나 molecule이 비대해질 수 밖에 없는데, 이럴때는 비슷한 녀석들끼리 폴더로 묶어 해결했다. button들은 buttons로 modal은 modals로 폴더로 묶어두면 훨씬 편하다.

각 단위의 index.ts를 만들자.

예를 들면, atoms에 index.ts를 만들어 모든 atom들을 모아두고 import 해서 쓸 때는 atom에서 꺼내 쓰는것이다.

그렇다면 어떤 atom을 쓰는지, 어떤 molecule을 쓰는지 파악하기가 쉬워진다. 또한, 후에 폴더구조를 바꾼다거나 하는 과정에서도 index.ts에서만 바꾸면되니 코드 수정을 훨씬 줄일 수 있다.

🧙‍♂️ 결국 복잡도는 같다. 그 복잡도를 잘 분배해 놓은 것일 뿐

어떠한 패턴이 로직을 간단하게 만들거나 없애주지는 않는다. 결국 필요한 로직은 어딘가에 있을 수 밖에 없다. 아토믹 패턴도 마찬가지다. 복잡한 코드를 알맞은 서랍에 정리하는 것과 비슷하다. 어딘가에 복잡성은 존재한다. 하지만 팀원들간에 같은 패턴과 원칙을 공유함으로서 그걸 극복하고자 하는 것이다.

그러니, 아토믹 패턴이라는 원칙에 너무 몰입하지 않고, 팀원들과 해결하고자하는 문제에 대해 회고해보는 적용해 해결해나가는 과정이 필요한 것 같다.

리액트 컴포넌트에 관리의 필요성에 팀원들이 공감한다면 아토믹패턴을 도입해보는 것을 추천한다.

⚠️ **GitHub.com Fallback** ⚠️