소프트웨어 개발 중 설계에서 가장 중요한 것은 모듈화와 추상화 두 가지라고 할 수 있다. 웹 프론트엔드 업계는 이미 React, Vue.js, Angular와 같은 오픈소스 프레임워크를 통해 끝을 달리는 추상화와 모듈화를 보여주고 있다. 특히 모듈화 측면에서 세 프레임워크는 컴포 넌트 인터페이스를 매우 쉽게 제공하기 때문에 프레임워크 사용자는 효율적인 재사용이 가능하고 캡슐화된 컴포넌트를 아주 간단하게 만들 수 있다. 그렇기에 우리는 좋은 컴포넌트를 만들기 위해 올바른 방법과 규칙을 정하기만 하면 된다.
이번 포스팅의 핵심 키워드인 Atomic Design은 좀 더 효과적인 컴포넌트를 구성하기 위한 방법론이다. 최근들어 크게 유행하고 많은 기업이 도입하는 방법론이지만 Brad Frost가 2013년에 처음 공개했으니 생각보다 오래된 방법론이다. 아마 본격적인 컴포넌트 주도 개발 방식이 자리 잡게 된 후 주목을 받은 것 같다. Brad Frost가 직접 쓴 책을 따로 $10에 판매하기도 하니 관심이 있다면 구매해보는 것도 괜찮을 것 같다.
본론으로 돌아와 이번 포스트에서 다룰 이야기는 Atomic Design을 더 잘 쓰기 위한 방법에 대해 소개한다. 물론 그전에 Atomic Design이 무엇인지 간단히 알아보고 진행하도록 하자.
Atomic Design이란?
Atomic design is methodology for creating design systems.
- Brad Frost
Atomic Design은 그 이름처럼 화학 용어를 이용하여 설명하는 컴포넌트 관리(디자인 시스템) 방법론이다.
좌측부터 Atom(원자), Molecule(분자), Organism(유기체), Template, Page
로 이루어져있다. 기초적인 화학 내용이 기억 안나는 사람들을 위해 간단히 설명하자면 원자는 물질을 구성하는 가장 작은 입자고 원자가 모여 분자가 구성된다. 유기체는 활동하는 생명체를 의미하므로 훨씬 큰 개념이라 볼 수 있다.
어떻게보면 과거부터 우리가 해왔던 컴포넌트 구성 방법과 크게 다르지 않다. 보통 개발자들은 Atomic Design을 모르던 시절에도 본능적으로 작은 단위부터 큰 단위로 컴포넌트 단위를 나누고 상향식 접근을 통해 컴포넌트를 구성해왔다. 결국 Atomic Design은 우리가 본능적으로 해왔던 것을 직관적인 용어와 문서로 정리 한 것이라고 볼 수 있다.
Atom
Atom은 더 이상 쪼갤 수 없는 단위에 해당하는 요소에 해당한다. 혹은 애니메이션, 폰트, 색상 등과 같이 요소가 아닌 속성도 Atom에 해당된다. 가장 작은 단위인 만큼 가장 많이 재사용된다고 볼 수 있다.
Molecule
Molecule은 여러 개의 Atom이 모여 목적성이 있는 하나의 컴포넌트가 된다. 가급적 많은 곳에서 재사용되도록 컴포넌트를 구성하되 "한 가지 일을 한다"라는 원칙을 지키며 만드는 것이 좋다.
Organism
Molecule까지는 사용자에게 크게 의미 있는 인터페이스라고 볼 수는 없었다. Organism은 사용 자에게 의미 있는 정보를 제공하거나 인터렉션 할 수 있는 UI를 제공하는 등 서비스로서 의미를 가지는 인터페이스라고 볼 수 있다. 이 단계부터 재사용성이 크게 줄어든다.
Template
Template은 흔히 사용해왔던 Layout과 비슷한 개념으로 볼 수도 있다. 더 정확하게 말하자면 실제 콘텐츠가 입혀지기 전 UI 요소, 레이아웃, 기능이 어떻게 배치될지 정하는 와이어 프레임이라고 볼 수 있다. 따라서 자주 재사용되는 컴포넌트가 된다.
Page
Page는 사용자가 볼 수 있는 최종 콘텐츠가 보이는 화면이다. 사용자와 인터렉션이 발생하거나 API 호출 등을 통해 사이드 이펙트가 발생 할 수 밖에 없는 컴포넌트기도 하다. 일반적으론 하나의 URI 당 하나의 Page 컴포넌트만 존재한다.
Why Effective?
Atomic Design에 대한 설명만 본다면 컴포넌트를 나누는 단계가 명확해보이기에 크게 문제가 없어보인다. 하지만 실제 구현을 하다보면 애매모호한 부분이 생기고 팀 내에서도 해석이 달라지기에 결국 규칙을 만들게 된다.
그 원인을 보자면 Atomic Design은 방법론이지만 레이어 아키텍처 패턴으로 이루어진 컨셉으로도 볼 수 있기 때문이다. 우리는 이러한 컨셉을 구현하는 과정에서 개발 환경, 구성원 등 다양한 고려 사항이 생기 기 때문에 컨셉을 여러 방법으로 해석할 수 있고 심한 경우 컨셉을 잘못 이해할 수도 있다. 예를 들어, 유명한 아키텍처 패턴인 MVC(Model-View-Controller) 패턴을 구현하더라도 다양한 해석이 존재하고 애플이 iOS에 적합하도록 CocoaMVC1로 완전한 재해석한 것처럼 컨셉을 크게 바꿔버릴 수도 있다.
따라서 우리는 아키텍처를 올바르게 구현하기 위해 컨셉을 잘 이해하고 원칙을 정해야 한다. 필자는 Atomic Design을 적용하며 겪은 시행착오를 통해 나름 보완점과 설명이 필요한 부분을 정리하여 7가지 표준적인 규칙을 정했다.
Atom의 범위
앞서 Atom은 눈에 보이는 요소를 포함하여 애니메이션, 폰트, 색상 등과 같이 요소가 아닌 속성도 Atom에 해당된다고 설명했다. 최소 범위는 HTML 요소 혹은 속성 하나로 정할 수 있지만 최대 범위는 어디까지 정해야 할까? Atom의 경우 최대한 재사용이 가능하도록 만들어야 하지만 지나치면 오히려 사용하기 힘들어질 뿐이다. 필자의 경우 요소와 속성을 포함하여 UI가 시각적으로 하나의 덩어리이며 한 가지 역할을 한다면 Atom으로 인정하기로 규칙을 정했다.
예를 들어, 페이스북 게시물을 본다면 프로필 이미지가 보이는 아바타와 링크에 대한 정보를 볼 수 있는 아이콘 버튼은 여러 요소가 중첩되고 다양한 속성이 추가되었지만 시각적으로 하나의 덩어리로 포함되고 각각 프로필 이미지를 보여주는 것과 버튼이라는 한 가지 목적을 지니고 있기에 Atom이라 볼 수 있다.
Atom의 범위는 디자인 원칙과 크게 맞닿아있다. 위에 첨부된 사진을 보면 디자인 원칙에 따라 어떤 역할이든 통일된 버튼을 사용할 수 있고 역할별로 다른 버튼을 사용할 수도 있다. 전자의 경우 적합한 Atom 컴포넌트를 하나 만들면 되지만 후자의 경우 역할 별로 Atom을 만들지 Atom 하나가 옵션에 따라 변할지 정해야 한다. 좋은 선택을 하기 위해선 확장 가능성, 수정될 가능성 등 디자인 관점에서 여러 가지 상황을 고려해야 한다. 그렇기 때문에 우리가 Atom 컴포넌트를 잘 만들기 위해선 어느 정도 서비스에 적용되는 디자인 원칙을 이해할 필요2가 있다.
이 컴포넌트는 Molecule, Organism 어느 쪽?
Atomic Design 방법론으로 컴포넌트 시스템을 구성하다 보면 어느 순간 반드시 Molecule로 둘지 Organism에 둘지 고민하는 순간이 온다. 그런 경우 무의식적으로 컴포넌트가 차지하는 영역이 크다면 Organism에 넣고 아닌 경우 Molecule에 넣을 때가 많지만 별로 좋은 방법은 아니다.
다른 사례를 보면 React 용 Atomic Design 기반 Starter Kit인 ARc는 일단 너무 고민하지 말고 아무 곳에 넣고 추후 어떤 레이어에 속해야 하는지 알게 되면 정리하라고 권한다.
소프트웨어 개발자는 항상 바쁘기 때문에 ARc의 구분 방법도 괜찮다고 생각한다. 하지만 필자가 생각하는 구분법은 다음과 같다.
- Molecule은 데이터를 표시하고 이벤트를 받을 수 있지만 "하나의 역할"만을 가진다.
- Organism은 사용자에게 의미를 가지는 기능적 요구사항에 포함되는 경우에 해당되는 컴포넌트다.
네이버 메인을 필자 기준대로 분석한다면 다음과 같다. 파란색이 Organism 주황색이 Molecule이다.
컴포넌트의 복잡성이나 크기로만 생각한다면 광고 영역은 Organism이 아닌 Molecule로 들어갈 것이다. 하지만 사용자는 광고를 본다
, 광고는 주기적으로 변경된다
라는 기능적 요구사항을 가지는 컴포넌트기 때문에 필자는 Organism에 포함시켰다.
한편 날씨 정보를 보여주는 곳은 작은 영역을 차지하기에 Molecule처럼 보일 수 있다. 여기서 중요한 것은 해당 컴포넌트가 날씨 정보를 보여주는, 단순히 데이터에 맞춰 UI를 표시하는 하나의 역할
을 가진 컴포넌트라는 점이다. 이런 컴포넌트는 주입받은 데이터가 어제 날씨인지 오늘 날씨인지 심지어 아무렇게나 넣은 데이터인지 알 수 없다. 따라서 Molecule에 속한다. 하지만 현재 날씨 정보를 실시간으로 업데이트하며 보여준다
는 요구사항은 컴포넌트를 감싸는 Organism이 처리하게 된다. 이처럼 기능적 요구사항 관점에서 본다면 대부분은 결정할 수 있다. 하지만 그럼에도 불구하고 결정하기 어려운 컴포넌트가 등장한다면 ARc처럼 일단 아무곳이나 넣고 나중에 고민해보자.
순수한 컴포넌트
일반적으로 컴퓨터 공학 세계에서 Side Effect는 컨텍스트가 외부의 영향으로 상태가 변경되는 것을 의미한다.
let tax = 0.5;
function add(a, b) {
// add 함수는 외부 변수 tax에 영향을 받기 때문에
// Side Effect가 발생하는 함수라고 할 수 있다.
return a + b + tax;
}
function minus(a, b) {
// minus 함수는 외부에 의해 로직이 변경되지 않기에
// Side Effect가 없는 함수라고 할 수 있다.
return a - b;
}
Side Effect가 발생하는 요소로 위 처럼 글로벌 변수를 함수 내에 포함시키나 외부 이벤트가 개입되는 등 다양한 원인이 있지만 일반적으로 볼 수 있는 원인은 네트워크 통신
이라고 할 수 있다.
async function getData() {
const response = await fetch('/api'); // 어떤 결과일지는 미지수
return response.json()
}
위 코드처럼 네트워크 통신을 통해 데이터를 받아올 경우 여러 상황이 발생할 수 있다.
- 정상적으로 데이터를 받는다.
- 서버에 에러가 발생한다.
- 통신이 불가능한 환경이다.
- 통신 도중 회선이 끊겼다.
- ...
통신이 실패할 수 있는 원인은 무수히 많다. 이처럼 외부 요인에 의해 로직의 결과가 달라질 수 있기에 네트워크 통신은 대표적인 Side Effect라고 할 수 있다. 우리는 효율적인 재사용과 안정적인 컴포넌트를 만들기 위해 Side Effect가 존재하지 않는 컴포넌트로 만들 필요가 있다. 필자는 사내에서 이러한 컴포넌트를 순수 컴포넌트3라고 부른다.
Page 컴포넌트는 외부 데이터를 받아 사용자에게 보여줘야 하기 때문에 어쩔 수 없이 Side Effect가 발생한다. 하지만 Atom, Molecule, Organism은 Page로부터 데이터를 주입받을 수 있기에 Side Effect 없이 순수한 컴포넌트를 만들 수 있다. 순수 컴포넌트는 외부 개입이 없어도 독립적으로 존재할 수 있기 때문에 Storybook 등을 통한 테스트도 편리하다. 참고로 순수 컴포넌트로 유지되기 위해선 상태 관리 라이브러리를 통해 데이터를 전달받는 것도 금지한다.
때로는 더러운 컴포넌트도 필요하다
필자는 Side Effect가 발생하는 컴포넌트를 더러운 컴포넌트라고 부른다. 어감이 조금 강한 듯한 기분도 들지만 실제로 Side Effect가 발생하는 로직이 포함되면 코드가 더러워지기 때문에 적합하다는 생각이 든다. 여하튼, 세상엔 때때로 때묻은 사람도 필요한 법이다. 마찬가지로 상황에 따라 컴포넌트 세상에도 더러운 컴포넌트가 필요하다.
Atomic Design 설계 원칙대로 작업한다면 Page에서 시작하여 Atom까지 데이터를 주입받게 된다. 때문에, 데이터를 전달하기 위해 매번 깊게 들어가야 하거나 매 페이지마다 호출해야 하는 API가 있다면 상당히 귀찮아진다.
네이버 웹툰 페이지를 예로 들자면 내가 월요웹툰 페이지로 접속하던 목요웹툰 페이지로 접속하던 우측 아래 인기급상승 만화 영역은 항상 데이터를 받아와야 한다. 이런 경우 Layout에 해당하는 Template에서 데이터를 호출할 수도 있지만 우리는 Template 컴포넌트를 순수 컴포넌트로 사용하기로 약속했기 때문에 항상 Page를 통해 데이터를 주입받아야 한다.
그렇기 때문에 Page는 너무 많은 책임을 지고있다고 볼 수 있다. 따라서 책임을 분산하고 사용 편의성을 위한 Wrapped
라는 레이어를 하나 더 두기로 결정했다. Wrapped는 Side Effect를 허락한 컴포넌트로, 기존 순수 컴포넌트를 감싸서 Side Effect가 발생하는 새로운 컴포넌트로 만든다. 만약 다시 네이버 웹툰을 예로 들자면 Layout에 해당하는 Template을 감싸 인 기급상승 웹툰 데이터를 받아오는 네트워크 로직을 추가하는 Wrapped 컴포넌트를 만들 수 있다. 혹은 하위 레이어까지 데이터를 전달하는 경우가 많아 Redux
나 MobX
같은 상태 관리 라이브러리를 사용하거나 Context API
를 사용하는 경우에도 Wrapped 컴포넌트로 만들어서 사용할 수 있다.
애매한 Template 해석
Atomic Design 설명을 볼 때 Template은 완전히 Layout의 용도로 사용되고 있다. 실제로 구현할 때도 Layout 용도로 사용될 때가 많지만 작업하다 보면 다음과 같은 의문을 품을 수 있다.
- Template에 또 Template을 추가해도 괜찮은가?
- Page에 대응되는 Template이 아닌 부분 영역에 대한 Template을 만들어도 되는가?
결론부터 말하자면 1
의 경우 Page 영역에서 조합하는 것을 추천하고 2
의 경우 만드는 것을 추천한다.
Slack을 예로 들면 Template으로 사용될 수 있는 영역이 세 곳이나 존재한다. 각각 워크스페이스 목록 영역, 채널 목록 영역, 채팅 영역이다. 만약 이 구조를 코드로 구현한다면 다음처럼 될 것이다.
const Page = () => (
// 대충 이런 형태가 될 것이다
<WorkspaceListTemplate>
<ChannelListTemplate>
<ChatRoomTemplate>
<Content />
</ChatRoomTemplate>
</ChannelListTemplate>
</WorkspaceListTemplate>
);
export default Page;
따라서 꼭 Page에 대응되지 않더라도 부분적으로 와이어 프레임을 구성할 수 있다면 Template으로 만드는 것을 권장한다. 그리고 만들어진 Template들은 Page에서 조합하여 사용하면 된다.
요소 반복 컴포넌트 사용법
컴포넌트 리스트를 보여줘야 하는 경우 또 다시 우리는 고민을 하게 된다.
- Organism 하위에 Molecule로 구현해야 할지
- Template에 Organism을 반복할지
만약 정하더라도 결국 또 다시 고민에 빠지게 된다.
- Organism 하위에 Molecule을 두려했더니 Molecule치고 너무 큰 것 같다면?
- Template에 Organism을 반복하려 했더니 재사용이 전혀 없어서 Template을 사용하기 애매하다면?
결국 요소를 반복하는 컴포넌트를 구성할 때는 다음과 같이 재사용성
을 고려하여 만들면 된다.
재사용이 없는 컴포넌트의 경우
위 뉴스 컴포넌트를 보면 리스트 요소로 표현되는 뉴스 하나가 Molecule로 처리하기엔 너무 크다. 오히려 사용자에게 의미를 가진다는 점에서 Organism에 적합하다. 그래서 Template을 사용하려 했지만 다른 곳에서 재사용되지 않는 경우 사용하기 애매해진다.
그래서 다른 곳에서 재사용할 가능성이 없는 컴포넌트라면 Organism으로 작성하되 내부 리스트 아이템을 컴포넌트 종속적인 내부 컴포넌트로 만드는 것이 좋다. 이때 내부 컴포넌트는 절대 외부에서 사용되지 않도록 주의한다.
- Organisms
- News
- index.tsx
- ListItem.tsx
import ListItem from './ListItem.tsx';
const News = ({ news }) => (
<div>
{news.map(item => (<ListItem data={item} />))}
</div>
);
export default News;
디렉토리 구조나 코드는 위와 같이 작성할 수 있다.
재사용되는 컴포넌트의 경우
만약 내부 리스트 아이템이 여러 곳에서 재사용된다면 Template을 이용하여 감싸고 내부 리스트 아이템은 Organism이나 Molecule 컴포넌트로 만들어 사용하는 것이 좋다.
Modal, Tooltip과 같은 UI 처리
컴포넌트 레이어를 결정할 때 가장 애매모호한 컴포넌트는 Modal과 Tooltip, Popover와 같은 사용자 인터렉션에 의해 등장하는 컴포넌트다. 특히 Modal같은 경우 내용이 복잡할 수 있는데 이런 경우 레이어 의존성이 역전되어 버린다. 따라서 필자가 추천하는 방법은 Modal, Popover와 같은 컴포넌트는 더러운 컴포넌트에서 주입하는 방식이다.
// Bad : 컴포넌트에서 직접 사용하는 대신
export default () => {
const [visible, setVisible] = useState(false);
return (
<div>
<button onClick={() => setVisible(true)}>Click me</button>
<Modal visible={visible}>
<Content />
</Modal>
</div>
)
};
// Good : 더러운 컴포넌트에서 주입하는 것을 추천
const Component = ({ onClick, modal }) => {
return (
<div>
<button onClick={onClick}>Click me</button>
{modal}
</div>
)
};
const Wrapped = () => {
const [visible, setVisible] = useState(false);
const ModalComponent = ({ visible }) => (
<Modal visible={visible}>
<Content />
</Modal>
);
const handleClick = () => {
setVisible(!visible);
};
return (
<div>
<Component onClick={handleClick} modal={<ModalComponent visible={visible} />} />
</div>
);
};
export default Wrapped
참고로 Modal, Popover는 Template과 같은 역할을 하기 때문에 별도 구현한다면 Template 레이어에 위치한다.
마치며
필자는 사내에서 효율적으로 작업하기 위해 위와 같은 규칙을 만들었지만 여러분이 규칙을 꼭 따라야 하는 것은 아니다. 위 규칙은 필자가 React
로 업무를 하며 필요하다고 느낀 규칙이기 때문에 다른 팀에서 위 규칙을 이용하여 작업할 땐 안 맞는 부분이 생길 수도 있다. 실제로 Atomic Design을 재해석하여 사용하는 여러 방식이 존재하며 필자의 해석과는 다르지만 잘 사용하고 있는 사람들이 많다.
중요한 것은 Atomic Design을 포함하여 다양한 방법론이나 패턴을 팀에 적용할 때 맹목적으로 따르면 안된다는 것이다. 주로 대중적으로 알려진 것은 범용성을 위해 핵심적인 것만 다루기 때문에 이를 업무에 적용하기 위해선 팀 구성원에 맞는 규칙을 새롭게 정의해야 한다. 꼭 Atomic Design이 아니더라도 이 사실을 꼭 기억해두길 바란다.