프론트 프로젝트를 하다보면 매번 고민하는 것 중 하나가 효율적인 폴더 및 파일 구조이다.
나도 해당 질문에 대해 자세히 알지 못해서 매번 고민하던 와중, 디자인 시스템을 공부하면서 아토믹 디자인에 대해 알게 되었다.
더 알아본 결과 정말 괜찮은 구조라 생각되었고 내 프로젝트에 도입해 보기로 하였다.
아토믹 디자인(Atomic Design) 이란?
아토믹 디자인은 화학적 관점에서 아이디어를 얻은 디자인 시스템이다.
아토믹 디자인은 위와 같은 단계별 기준을 가지고 있다.
우리들의 궁극적인 목표인 Pages를 만들기 위해 Atom부터 시작하여 구체화하는 과정을 거치게 된다.
Atom
Atom이란 더 이상 분해할 수 없는 기본 컴포넌트이다.
위와 같이 label, input, button과 같이 기본 HTML element 태그 혹은 글꼴, 애니메이션, 컬러 팔레트, 레이아웃과 같이 추상적인 요소도 포함될 수 있다.
Atom은 단독적으로 사용하기에는 적합하지 않을 수도 있고 Molecules, Organisms 등 뒤에 나올 여러 단위와 결합되어 사용되는 경우가 많다.
Molecule
화학적 관점에서와 마찬가지로 Molecule은 여러 Atom으로 구성된다.
위와 같이 Atom인 label, input, button 이 모여 검색 서치바 Molecule를 생성해낼 수 있다.
Molecule은 SRP( Single Responsibility Principle) 원칙에 따라 한가지 일을 하며, 위 예시의 경우에는 '키워드 검색' 이라는 한가지의 일을 맡아 여러 곳에서 재사용될 수 있다.
Molecule의 SRP는 재사용성과 UI에서의 일관성, 테스트하기 쉬운 조건이라는 이점을 가진다.
Organism
여러 Atom, Molecule,Organism들이 모여 Organism을 구성할 수 있다.
위 예시에서는 Atom인 로고, Molecule인 네비게이션과 서치바를 사용하여 헤더 Organism을 생성하였다.
Organism은 앞 단계보다 좀 더 복잡하고 서비스에서 표현될 수 있는 명확한 영역과 특정 컨텍스트를 가지기 때문에 상대적으로 재사용성이 낮아지는 특징을 가진다.
Template
최종 Page를 만들기 전 여러 Organism, Molecule로 이루어진 틀이다.
위처럼 실제 컴포넌트를 레이아웃에 배치하고 구조를 잡는 와이어 프레임으로, 페이지의 스켈레톤이라고 생각하면 될 것 같다.
Page
최종 목표인 Page이다.
위와 같이 실제 콘텐츠를 담고 있으며, template의 인스턴스라고 할 수 있다.
아토믹 디자인을 고안한 Brad frost는 Atomic design is not a linear process 즉, 1. Atom, 2. Molecule.. 이렇게 단계별로 만들어나아가는 선형적인 과정이 아니라고 강조하고 있다.
해당 디자인 시스템을 따름으로써 컴포넌트별 역할을 잘 분리함으로써 가독성이 높은 코드를 작성할 수 있었으며, 재사용이 높은 컴포넌트들을 통해 작업 속도를 단축 시킬 수 있었다.
다만, 해당 과정이 매끄럽게 흘러가지만은 않았으며, 모든 부분에 다 알맞게 적용되지는 않았다.
내 코드에 아토믹 디자인을 입히며 발생한 여러 문제들을 소개해보려고 한다.
아토믹 디자인(Atomic Design) 도입하기
아토믹 디자인을 도입하면서 가장 첫번째로 했던 고민은 과연 폴더와 파일 구조는 어떻게 짜는 것이 좋을까라는 의문이였다.
나는 내가 짠 것이기 때문에 당연히 쉽게 알아볼 수 있겠지만, 항상 남이 봤을 때 최대한 쉽게 코드를 파악할 수 있도록 노력하는 습관을 가지는 것이 중요하다고 생각했다.
여러 고민 끝에 내가 채택한 구조는 다음과 같다.
Atom & 자주 사용되는 Molecule 컴포넌트는 루트 경로의 common 폴더 내에 정리
Atom은 특정 상황에 구애받지 않고 다양한 파일에서 재사용되는 컴포넌트들이므로 루트 경로의 common 폴더에 정리해주었다.
내가 짠 폴더 구조이다.
common 폴더 내의 components 폴더에 정리하였으며, 내가 현재 하고 있는 프로젝트에는 레이아웃과 관련된 Atom or Molecule과 단순히 UI에 보여지는 Atom or Molecule로 성격이 나눠졌기 때문에 이를 분리하여 정리해주었다.
예를 들어 common -> components -> ui 폴더 내부는 위와 같이 Atom or Molecule 성격에 따라 폴더로 우선 정리 후 그 내부에 코드 작업을 해주었다.
특정 주제 내에서만 사용되는 Molecule & Organism & Template 들은 루트 경로의 feature폴더 내에 정리
나는 내 프로젝트에서는 크게 주제를 인증, 장소, 검색, 큐레이션, 기록 등으로 나누었다.
위처럼 루트 경로에 feature 폴더를 만든 후 주제에 맞게 세분화 해주었다.
나는 장소(place)와 관련되어 장소 상세 페이지를 한번 예로 들어보려 한다.
장소와 관련된 주제에서도 장소 상세, 장소 리뷰, 장소 정보 등으로 세부 주제가 나뉠 수가 있다.
장소와 관련된 컴포넌트들을 다 몰아넣기도 해보았지만, 구별이 어려워서 나는 세부 주제까지 나누어주었다.
위는 장소 상세 페이지의 일부이다.
위 폴더 중 PlaceDetail 폴더 내부에서 해당 부분을 구현해주었다.
위 부분 전체는 크게 Organism으로 분류할 수 있을 것 같다.
components 폴더 내부의 PlaceDetail 폴더는 위와 같이 구성되어 있는데, 내부 organisms 폴더에 위 부분 전체를 위해 PlaceKeywordEvaluation.tsx 라는 파일을 생성해주었다.
<PlaceKeywordEvaluation
mainText="키워드 평가"
id={detailData.info.id}
positiveEval={
detailData.info.positiveEval ? detailData.info.positiveEval[0] : null
}
negativeEval={
detailData.info.negativeEval ? detailData.info.negativeEval[0] : null
/>
결과적으로 Template 내부에서는 위와 같이 사용해줄 수 있겠다.
이 때 mainText를 props로 추가해 주었는데
위 텍스트는 해당 부분을 확인하기 위한 필수적인 데이터이므로 이를 외부에서 선언해 주게 하면 해당 부분이 어느 부분과 관련된 코드인지 더욱 수월하게 확인할 수 있다.
만약 외부에서 넘기지 않는다면
<PlaceKeywordEvaluation
id={detailData.info.id}
positiveEval={
detailData.info.positiveEval ? detailData.info.positiveEval[0] : null
}
negativeEval={
detailData.info.negativeEval ? detailData.info.negativeEval[0] : null
/>
위와 같이 선언해줘야 할텐데, 그럼 텍스트를 외부에서 넘겨줄 때보다 파악이 좀 더 어려울 것이다.
이제 컴포넌트들을 파악 했던 과정을 복기해보았다.
위처럼 동그라미 친 부분들은 모두 Atom으로 판단하여 common 폴더에 작성해주었다.
여기까지는 뭐 수월했다. 그런데 문제는 그 후 발생하였다.
Molecule과 Organism을 나누는 기준의 모호함
카카오 블로그에서는 해당 모호함을 해결하기 위해 '작성한 컴포넌트에 컨텍스트가 있는 경우에는 organism으로, 컨텍스트 없이 UI 적인 요소로 SRP를 지킬 수 있다면 재사용하기 쉬운 molecule로 작성' 한다고 한다.
위 기준으로도 구분하기 어려우면 우선은 Organism으로 분류한 뒤 의논을 통해 결정한다고 했다.
위 동그라미 친 그래프에 대해 약간 고민을 했다.
우선 '키워드 평가' 라는 명백한 컨텍스트 내에서의 그래프 아닌가? 라는 생각 때문에 Organism으로 구분하는 것이 맞을까라는 고민을 하였다.
그러나 UI 요소인 UP or DOWN 아이콘으로 키워드를 평가하는 한가지의 일을 하고 있기 때문에 SRP에 위반되지 않으며, 다른 페이지에서도 사용되고 있어서 재사용성이 높은 Molecule로 구분하기로 하였다.
이는 다른 페이지에서도 사용되어서 common 폴더에 구현해주었다.
위 과정을 통해 완성된 코드는 다음과 같다.
//Organism
export default function PlaceKeywordEvaluation({
id,
mainText,
positiveEval,
negativeEval,
}: Pick<PlaceDetailInfoProps, "id" | "positiveEval" | "negativeEval"> & {
mainText: string;
}) {
return (
<div className="pt-[3.6rem] px-[2rem] pb-[3rem]">
<h1 className="text-black headline2 mb-[1.2rem]">{mainText}</h1>
<div className="mb-[2rem]">
<div className="flex flex-col items-start gap-[0.4rem] mb-[0.4rem]">
{positiveEval &&
positiveEval.map((li, i) => (
<GraphUpDownVote
key={li[i] + i}
evaluation={li[0]}
percentage={li[1] + "%"}
like={true}
/>
))}
</div>
<div className="flex flex-col items-end">
{negativeEval &&
negativeEval.map((li, i) => (
<GraphUpDownVote
key={li[i] + i}
evaluation={li[0]}
percentage={li[1] + "%"}
like={false}
className={i === 0 ? "mb-[0.4rem]" : ""}
/>
))}
</div>
</div>
<LinkLayout routeUrl={`/place/${id}/more`} prefetch>
<Button variant="line" className="w-full h-[4rem]">
기록 전체 보기
</Button>
</LinkLayout>
</div>
);
}
위는 아토믹 디자인을 내가 생각할 때 편했던 구조로 변형하여 사용하고 있을 뿐이며, 절대 정답이 아니다.
대략적인 과정을 보여줄 뿐이며, 팀원들과 적극적인 소통을 통해 최적의 방안을 찾는 것이 옳은 방법일 것이다.
미세하게 다른 디자인의 Organism 처리
작업을 하다보면 비슷한 디자인인데 조금씩만 다른 경우가 종종 있어서 애먹는 경우가 꽤 있다.
내가 하는 프로젝트 내의 큐레이션 메뉴를 클릭하면 나오는 모달창이다.
interface ModalContentProps {
contentBox: {
icon: ReactNode;
text: string;
onClick: () => void;
className?: string;
}[];
}
export default function ModalContent({ contentBox }: ModalContentProps) {
return (
<div className="pl-[2rem] pt-[1.8rem]">
{contentBox.map((box, i) => (
<div
key={box.text + i}
className={twMerge("flex items-center", box.className)}
onClick={box.onClick}
>
{box.icon}
<span className="body1 text-black ml-[1.2rem]">{box.text}</span>
</div>
))}
</div>
);
}
위와 같이 모달안에 들어가는 내용을 ModalContent라는 컴포넌트의 prop인 contentBox로 전달하여 처리하고 있다.
그런데 문제 발생!
다른 곳에서는 '링크복사' 컨텐츠가 빠진 모달을 띄워야만 했다.
오케이 뭐 이정도는 prop 하나만 추가해줘서 해결할 수는 있겠다.
hasCopyLinkContent라는 boolean prop을 하나 추가해준 뒤
{contentBox.map((box, i) => {
if (i === contentBox.length - 1 && !hasCopyLinkContent) return;
return (
<div
key={box.text + i}
className={twMerge("flex items-center", box.className)}
onClick={box.onClick}
>
{box.icon}
<span className="body1 text-black ml-[1.2rem]">{box.text}</span>
</div>
);
})}
위와 같이 분기처리를 하여 처리해주었다.
그런데 또 문제가 발생하였다.
검색 기능에서 사용되는 모달 중 하나이다.
분명 위와 디자인은 매우 비슷한데 은근히 다른 부분이 꽤 있다.
우선 각 컨텐츠에는 icon이 없고 Divider가 추가되었으며, 선택된 컨텐츠에는 체크표시가 있고 선택되지 않은 컨텐츠의 텍스트는 연하게 띄워줘야 했다.
물론 위의 모달도 boolean 타입의 hasDivider prop, 각 컨텐츠 내의 hasIcon, isChecked 항목을 추가해줘서 해결할 수 있을 것이다.
다만 위의 방법으로 해결하는 것은 확장성을 고려했을 때 좋지 않은 해결방법이다.
추후에 디자인이 더욱 변화된 모달들이 나오면 얼마나 많은 props를 추가해야 될지 아찔하기도 하다.
합성 컴포넌트의 도입(Compound Component)
카카오 블로그의 도움을 받아 확장성에 좋은 구조인 합성 컴포넌트를 도입하였다.
서브 컴포넌트 구현
interface IconBoxProps {
icon: ReactNode;
text: string;
className?: string;
textClassName?: string;
onClick?: () => void;
}
function IconBox({
icon,
text,
className,
textClassName,
onClick,
}: IconBoxProps) {
return (
<div className={twMerge("flex items-center", className)} onClick={onClick}>
{icon}
<span className={twMerge("body1 text-black ml-[1.2rem]", textClassName)}>
{text}
</span>
</div>
);
}
interface CheckBoxProps {
text?: string;
isClicked?: boolean;
className?: string;
onClick?: () => void;
}
function CheckBox({ text, isClicked, className, onClick }: CheckBoxProps) {
return (
<div className={twMerge("flex items-center", className)} onClick={onClick}>
<span
className={twMerge(
"body1-medium",
isClicked ? "text-black mr-[8px]" : "text-text-gray-6"
)}
>
{text}
</span>
{isClicked && <CheckMediumIcon />}
</div>
);
}
interface ModalDividerProps {
className?: string;
}
function ModalDivider({ className }: ModalDividerProps) {
<Divider className={twMerge("h-[1px] bg-line-gray-3", className)} />;
}
위와 같이 ModalContent를 구성하는 컴포넌트들을 만들어주었다.
현재 사용하고 있는 아이콘이 있는 IconBox, 체크 표시가 나와야 하는 CheckBox, Divider를 표시해주는 ModalDivider를 생성해주었다.
메인 컴포넌트 구현
메인 컴포넌트는 서브 컴포넌트들을 묶어서 화면에 적절하게 보이도록 하는 Wrapper 성격의 컴포넌트이다.
interface ModalContentMainProps {
children?: ReactNode;
}
function ModalContentMain({ children }: ModalContentMainProps) {
return <div className="pl-[2rem] pt-[1.8rem]">{children}</div>;
}
위와 같이 생성해주었다.
서브 & 메인 컴포넌트들을 한번에 export
export const ModalContent = Object.assign(ModalContentMain, {
IconBox,
CheckBox,
ModalDivider,
});
위와 같이 묶어서 export 해주었다.
위 방법의 export를 통해 사용하는 곳에서 서브 컴포넌트가 ModalContent의 서브 컴포넌트임을 좀 더 확실하게 알 수 있다.
<Modal ref={ref}>
<ModalContent>
<ModalContent.IconBox
icon={<EditIcon />}
text="편집하기"
className="mb-[2rem]"
onClick={handleCurationEditClick}
/>
<ModalContent.IconBox
icon={<DeleteIcon />}
text="삭제하기"
className={hasCopyLink ? "mb-[2rem]" : undefined}
onClick={handleCurationDeleteClick}
/>
{hasCopyLink && (
<ModalContent.IconBox
icon={<ShareIcon />}
text="링크복사"
onClick={handleLinkCopyClick}
/>
)}
</ModalContent>
</Modal>
큐레이션 메뉴 모달 코드를 위와 같이 수정해 주었다.
<Modal ref={ref}>
<ModalContent>
<ModalContent.CheckBox
text="리뷰 최신순"
isClicked={sortState === "RECENT"}
className="pt-[8px] pb-[20px]"
onClick={handleSortByRecentClick}
/>
<ModalContent.ModalDivider />
<ModalContent.CheckBox
text="인기순"
isClicked={sortState === "HOT"}
className="pt-[20px]"
onClick={handleSortByHotClick}
/>
</ModalContent>
</Modal>
검색 조건 변경 모달은 위와 같이 변경해주었다.
위처럼 합성 컴포넌트를 적용하면 디자인이 아무리 복잡한 ModalContent를 가진 모달을 구현하더라도, props를 계속 추가하는 비효율적인 작업 없이 서브 컴포넌트들의 조합을 통해 어느 디자인이던지 쉽게 구현해 낼 수 있다.
일반적인 상황에서는 prop을 사용한 방식으로도 충분히 직관적으로 개발이 가능하고 스토리북에서 테스트하기도 훨씬 용이하지만, 복잡한 상황에서는 합성 컴포넌트의 도입이 큰 이점을 가져다 줄 수 있어보인다.
이처럼 아토믹 디자인에 대해 공부해보고, 또 내 코드에 적용해보았다.
물론 주관적인 견해가 들어가야 하는 부분이 상당히 많아 팀원들과 의견을 나누며 채택해 나가야겠지만, 기본적인 프로젝트의 구조를 다져나가는데는 매우 훌륭한 디자인 시스템이라고 생각했고, 해당 구조로 계속 리팩토링 해나가야겠다!
참고
https://atomicdesign.bradfrost.com/chapter-2/
Atomic Design Methodology | Atomic Design by Brad Frost
Learn how to create and maintain digital design systems, allowing your team to roll out higher quality, more consistent UIs faster than ever before.
atomicdesign.bradfrost.com
https://fe-developers.kakaoent.com/2022/220505-how-page-part-use-atomic-design-system/
아토믹 디자인을 활용한 디자인 시스템 도입기 | 카카오엔터테인먼트 FE 기술블로그
정호일(harry) 카카오페이지에서 웹 프론트엔드를 개발하고 있습니다. 집보다 밖에 돌아다니는 걸 좋아합니다.
fe-developers.kakaoent.com
https://fe-developers.kakaoent.com/2022/220731-composition-component/
합성 컴포넌트로 재사용성 극대화하기 | 카카오엔터테인먼트 FE 기술블로그
방경민(Kai) 사용자들에게 보이는 부분을 개발한다는 데서 프론트엔드 개발자의 매력을 듬뿍 느끼고 있습니다.
fe-developers.kakaoent.com
'FrontEnd > React' 카테고리의 다른 글
효율적인 협업을 위하여 - 1 (Feat: Git Merge) (2) | 2024.07.03 |
---|---|
HTTP 캐시 다루기 (2) | 2024.06.13 |
React 렌더링 최적화에 대한 고찰 (2) | 2024.04.10 |
Context API 에서 Recoil로 마이그레이션 일지 (3) | 2024.04.06 |
React 에서 Context API 효율적으로 사용하기 (0) | 2024.04.04 |