[React] 리액트 Atomic Design Pattern 설계
프로젝트의 규모가 클수록, 또는 지속적인 버전 관리와 고도화가 필요할 경우 개발 전 프로젝트 설계는 매우 중요하다고 생각한다.
또한 컴포넌트를 구조적으로 설계하여 최하단에서 최대한 재사용이 가능한 컴포넌트들을 확장성 있게 만들어야 한다.
기능은 같지만 디자인이 조금씩 달라지는 Tab, Select box, Modal, Button, Input, 여러가지 Form들을 매번 새로 생성하는 것은 매우 비효율적이고 프로젝트의 코드를 지저분하게 만든다.
https://fe-developers.kakaoent.com/2022/220505-how-page-part-use-atomic-design-system/
나는 카카오 FE 기술 블로그를 참고하여 어떤 식으로 아토믹 디자인패턴을 설계하면 좋을지 고민했다.
사실 컴포넌트를 쪼개 구조화하여 설계하고 최대한 확장성 있고 재사용 가능한 컴포넌트를 만들어 깔끔하게 조립해나가는 것이 목적이긴 하지만, 아톰도 설계 나름이라 어느 부분까지를 Atoms, 어느 부분까지를 Molecues, organisms로 구성해야 할지 고민이 많이 되었다.
KAKAO FE 기술 블로그에서는 다음과 같이 Atom을 정의했다.
Atom
atom은 더 이상 분해할 수 없는 기본 컴포넌트입니다. label, input, button과 같이 기본 HTML element 태그 혹은 글꼴, 애니메이션, 컬러 팔레트, 레이아웃과 같이 추상적인 요소도 포함될 수 있습니다. atom은 모든 기본 스타일을 한눈에 보여주므로 디자인 시스템을 개발할 때 유용하게 사용됩니다. atom은 추상적인 개념을 표현할 수 있는데 이것을 단일 컴포넌트로 사용하기엔 어려운 경우가 있습니다. 예를 들어 레이아웃과 같은 atom 그 자체로는 실제 페이지에서 바로 사용하기에 유용하지 않을 수 있습니다. 또한 atom을 다른 atom과 결합한 (뒤에 설명할) molecule 혹은 organism 단위에서 여러 단위와 결합하여 유용하게 사용될 수 있습니다.
기본적으로 Atom은 더 이상 분해할 수 없는 기본 컴포넌트여야한다. 다시 말하면 최하위 depth에서 사용되는 컴포넌트가 되어야한다. 나는 다른 요소들은 제외하고 기본적인 HTML element들, 그 중 Form 요소(checkbox, radio, input, select ..등등) + Button을 Atom으로 사용하였다.
특정 컴포넌트에 Form 요소가 존재하면 Formik과 함께 무조건 위 아톰을 사용하였다.
Atom에서는 디자인을 정의하지 않았다. 기본적으로 적용되는 element들의 default style을 제거하는 정도로 끝냈으며, 그 외 Atom이 사용되는 컴포넌트의 Form 요소 관련 디자인들은 Atom또는 그 윗 단계의 컴포넌트를 선언해주는 부분에서 작성하였다. Atom은 제빵과 비유하자면, 밀가루의 역할을 하는 셈인 것이다.
나는 Atom이 사용된 상위 요소를 Field로 정의하였다.
네이버 검색창을 예시로 들면,
input Atom을 사용해 좌측에 로고를, 우측에 아이콘을 넣어 하나의 Field를 만드는 것이다. 하나의 Field 또한 범용적으로 사용할 수 있어야 하므로, Input의 왼쪽, 오른쪽에 무언가를 받아서 넣어줄 수 있도록 설계해주며, 이외의 모든 props는 input atom으로 그대로 내려준다. 또한 모든 CSS 작업은 Field를 선언하는 컴포넌트 단계에서 처리해주었다.
로그인 창도 마찬가지로 input Atom을 사용하여, 여러가지 함수와 추가 props들을 받아 Field로 만들어주면 될 것이다. 이 때 로그인 버튼 자체는 Button 아톰을 사용하며, Field와는 분리가 되어야 한다.
결국 Atom 요소를 다루는것은 Form 요소들을 다루는 것이다. 여러개의 Field 안에는 Form 요소들이 들어있을 것이고, 상태를 편리하게 관리하기 위해 나는 Formik 라이브러리를 사용했다.
https://formik.org/docs/api/useFormik
여러개의 Field를 formik의 form으로 감싸면 하위 Field들의 상태를 여러 유틸 함수들을 사용하여 편하게 관리할 수 있으며, 어느 부분에서나 접근이 가능해진다.
import React, { Ref } from "react";
export interface IInputProps extends React.ComponentPropsWithRef<"input"> {
className?: string;
startAdornment?: string | JSX.Element | JSX.Element[];
endAdornment?: string | JSX.Element | JSX.Element[];
innerRef?: Ref<HTMLInputElement>;
}
const Input = ({
className,
startAdornment,
endAdornment,
innerRef,
...props
}: IInputProps) => {
return (
<div
className={className}
onClick={(e: any) => {
const input = e.target.children[0];
if (input) input.focus();
}}
>
{startAdornment && <div className="startAdornment">{startAdornment}</div>}
<input ref={innerRef} {...props} />
{endAdornment && <div className="endAdornment">{endAdornment}</div>}
</div>
);
};
export default React.forwardRef<HTMLInputElement, IInputProps>((props, ref) => {
return <Input innerRef={ref} {...props} />;
});
위는 input Atom의 예시이다. className또한 props로 받아 CSS를 아톰 혹은 필드를 선언하는 부분에서 작성할 수 있도록 한다.
이외의 추가적인 props가 있을 경우, input 태그 안으로 그대로 넘겨준다.
Field에서는 위 Atom을 사용하고, 추가적으로 Input에 붙어야 할 Title, schema 처리, Input에 붙는 button, Modal 등을 붙이면 된다.
중요한 점은 Atom과 마찬가지로, 다른 컴포넌트에서도 해당 Field를 사용할 수 있게 확장성 있는 Field로 설계한다.
interface IProps {
options: { name: string; value: string | number; key?: string }[] | undefined;
defaultValue?: string | number;
className?: string;
name: string;
onChange?: any;
}
function DefaultRadio({
options,
defaultValue,
className,
name,
...props
}: IProps) {
return (
<div className={styles.wrapper}>
<div className={className}>
{_.map(options, (option) => (
<div key={option.key || option.name}>
<input
defaultChecked={defaultValue === option.name}
id={option.key || option.name}
type="radio"
name={name}
value={option.name}
className={styles.radioinput}
{...props}
/>
<label htmlFor={option.key || option.name}> {option.name}</label>
</div>
))}
</div>
</div>
);
}
export default DefaultRadio;
위는 radio Atom의 예시이다. 해당 아톰을 사용한다면, Tab 기능을 하는 컴포넌트를 만들 수 있다.
Form 요소들을 주로 아톰으로 사용하면, formik과 같은 form 요소 상태 관리 라이브러리와 함께 사용할 때 더욱 강력해지는 것 같다.
Atom과 Field만 정의를 잘 해둔다면, 필요한 곳에서 적절하게 꺼내 쓸 수 있다는게 큰 매력인 것 같다. 물론 아톰과 필드를 설계할 때는 많은 고민이 필요할 것이다.
interface IProps {
title?: string;
className?: string;
options: { name: string; value: string | number; key?: string }[] | undefined;
defaultValue?: string[];
name: string;
onChange?: any;
checkboxPosition?: "left" | "right";
}
const ExampleField = ({
title,
className,
options,
defaultValue,
name,
checkboxPosition,
...props
}: IProps) => {
const [isAccordionOpen, setIsAccordionOpen] = useState<boolean>(false);
return (
<div className={styles.wrapper}>
{title && (
<div className={styles.info}>
<div className={styles.title}>{title}</div>
</div>
)}
<Accordion isOpen={isAccordionOpen}>
<DefaultCheckbox
className={className}
options={options}
name={name}
defaultValue={defaultValue}
onChange={onchange}
checkboxPosition={checkboxPosition}
{...props}
/>
</Accordion>
</div>
);
};
export default ExampleField;
위는 checkbox Atom을 사용한 Field의 예시이다.
아톰에 title, Accordion 등의 기능을 추가하여 필드를 만들어주었고, 컴포넌트에 필드를 선언하여 사용했다.
여러개의 필드는 필드들이 정의된 페이지나 컴포넌트에서 formik의 form으로 묶어 상태를 관리해준다.
이렇게 form 요소들을 Atomic하게 사용해보았다. 확실히 새로 생성되는 파일이나 컴포넌트, 코드의 양이 줄고 훨씬 깔끔해진다는 느낌을 받을 수 있었다. 또한 유지보수가 훨씬 간편해지는 이점이 있다.
사실 React를 사용한다면 컴포넌트를 Atomic하게 가져가는 것은 필수이지만, 어떤 식으로 설계할지,,, Field 또는 Molecues, organisms를 어떤 식으로 정의할지 등의 차이가 프로젝트를 깔끔하게 관리하는 부분에 있어 중요한 것 같다.
디자인을 받고 난 후 어떤 부분을 atom으로 정의할 지, 어떤 부분을 Molecues(Field), organisms로 정의할 지 많은 고민을 해보면 좋을 것이다.