blog.yoouyeon
about/tags
Back to blog
May 16, 2026

모또 디자인 시스템 구축기 2편 — 공통 컴포넌트의 경계 나누기

들어가며

토큰 구조를 설계하고 CSS Variable 기반의 구조를 만들어 둔 이후에는 본격적으로 공통 컴포넌트를 만들고 기존 구조를 걷어내는 작업을 시작했다.

예상치 못하게 구조 설계에 시간을 많이 쓰게 되었지만 그래도 설계 이후에는 고민할 거리가 좀 줄어들 것이라고 생각했는데... 기존 공통 컴포넌트를 살펴보다보니 구현 전에 먼저 생각해 봐야 할 것이 있었다. "어떤 컴포넌트를 디자인 시스템으로 올려야 하는가?" 이 글에서는 이 질문에 답하기 위해서 생각했던 것들을 정리해 보려고 한다.

기존 공통 컴포넌트를 다시 살펴보자

기존 프로젝트에는 페이지 전반적으로 쓰이는 컴포넌트를 모아 둔 src/shared/ui 디렉토리가 있었다. 이 디렉토리 안에는 23개의 컴포넌트가 있었고, 컴포넌트 수도 많지 않았고, 기존 것들을 그대로 가져가면 된다고 생각했다.

그런데 막상 하나씩 살펴보니 이 컴포넌트가 공통에 있어야 하는게 맞나? 싶은 것들이 보이기 시작했다...

ShareButton을 예로 들면, 이 공유 버튼은 정산 공유 페이지, 정산 상세 페이지 같은 여러 페이지에서 공통적으로 사용되는 버튼이기 때문에 shared/ui에 존재하고 있었다. 하지만 실제 코드를 보면 카카오 공유, 슬랙 공유, 클립보드 복사, 토스트 호출, 모달 흐름이 전부 들어가 있는 꽤 무거운 버튼이었다. 이것을 공통 컴포넌트라고 해야 할까? 아니면 공유 기능의 일부라고 해야 할까?

이런 컴포넌트들이 있었기 때문에 무작정 shared/ui의 모든 컴포넌트를 전부 디자인 시스템으로 옮기면 안되겠다는 생각이 들었다. 그래서 분류 기준부터 세우기로 했다.

기준 세우기 : 어떤 컴포넌트가 디자인 시스템에 올라갈 수 있을까

디자인 시스템 컴포넌트와 아닌 컴포넌트를 구분하는 핵심 기준은 "디자인 시스템 컴포넌트는 도메인을 몰라야 한다" 라는 것이었다.

구체적으로는 이런 기준을 가지고 판단했다.

이런 기준을 세우고 특정 도메인 명칭이나 앱 흐름이 들어가 있는 컴포넌트나, feature 동작을 직접 수행하는 컴포넌트를 골라냈다.

디자인 시스템에서 제외한 컴포넌트들

ShareButton은 feature/share라는 새로운 feature 디렉토리를 만들어서 이동시켰다. 재사용되는 컴포넌트긴 하지만 공유 기능에 깊게 관여하고 있기 때문에 feature 레이어가 좀 더 잘 어울린다고 생각했기 때문이다. 이 이동과 함께 다른 레이어에 분산되어 있던 공유 관련 유틸리티들도 함께 feature/share로 이동시켜줬다.

Feature 컴포넌트가 아닌 local styled-component로 전환한 것들도 있었다. InputGroup, Flex, Text 같은 컴포넌트들이었다. 특히 Flex와 Text는 고민이 좀 더 많았는데, 이 부분은 따로 정리해보려고 한다. InputGroup은 Figma 디자인에는 공통 컴포넌트로 정리되어 있긴 했는데, 실제 사용처를 확인해보니 단순한 Wrapper로만 동작하고 있어서 오히려 불편했다. 단순한 Wrapper 컴포넌트를 공통으로 유지하면 props만 늘어나게 되면서 오히려 관리할 것들이 많아질 것이라고 생각했기 때문에 화면 맥락에 맞는 local wrapper 컴포넌트로 전환했다.

Text와 Flex 컴포넌트를 디자인 시스템에 포함시켜야 할까

이번 작업에서 가장 많이 고민한 부분이었다... 😭

기존 shared/ui에는 Text와 Flex 컴포넌트가 있었다. Text 컴포넌트는 variant에 따라서 정해진 타이포그래피 컴포넌트를 반환하는 것이었고, Flex 컴포넌트는 이름 그대로 자주 쓰이는 Flex 레이아웃을 반환하는 컴포넌트였다. 사실 원래 이 프로젝트에서는 스타일링 코드를 별도의 *.styles.ts 파일에 분리하는 컨벤션을 쓰고 있었는데, 그 컨벤션의 예외로 만들어진 컴포넌트라고도 할 수 있다. styled.div로 매번 스타일드 컴포넌트를 만들지 않고 <Flex gap={8} align="center"> 같은 식으로 JSX 안에서 바로 레이아웃을 잡을 수 있게 하는 편의를 위한 컴포넌트였고, 실제로 편했기에 두 컴포넌트 모두 정말 많은 곳에서 쓰이고 있었다.

그런데 개발할 때는 좋았지만 코드가 좀 오래되다 보니 문제가 이곳저곳에서 보이기 시작했다.

첫 번째는 스타일 코드가 분산되는 문제였다. 스타일링 코드가 *.styles.ts 에도 있고, JSX 파일의 Flex와 Text의 props로 흩어지게 됐다. 그래서 스타일을 확인하거나 디버깅하려면 두 파일을 오가야 하는 불편함이 있었다.

두 번째는 억지로 끼워맞춰야 하는 상황도 있었던 것이다. Flex나 Text 만으로 스타일링이 어려운 케이스에서는 그 컴포넌트를 쓰면서도 *.styles.ts에 다시 스타일 코드를 덧붙이는 경우도 있었다. 편의를 위해서 만든 건데 오히려 두 가지 방식이 섞이는 상황이 벌어진 것이다.

세 번째는 구현과 유지보수 복잡도 문제였다. 위 두번째 문제를 좀 해결하기 위해서 Flex와 Text가 처리할 수 있는 props를 계속해서 추가했다. 이렇게 props가 점점 늘어나다 보니 내부 구현이 정말 복잡해졌다. 솔직히 말하면 코드를 작성할 때 좀 편한 것에 비해서 관리 비용이 너무 컸다.

결국 두 컴포넌트 모두 새로운 디자인 시스템에는 올리지 않기로 했다. 대신 각 화면의 의미에 맞는 local styled component로 치환하는 방향을 선택했다.

// 이렇게 쓰는 대신
<Text color="semantic.text.strong" variant="title">캐릭터 도감</Text>
// 이렇게 쓴다
export const SectionTitle = styled.span`
${applyTypography('typography.title.small')};
color: ${getToken('fg.normal')};
`;

코드 중복이 좀 늘어나긴 했다. 하지만 스타일에 명확한 이름이 생기면서 이 텍스트가 어떤 역할인지 코드에서 바로 확인할 수 있게 되었다. 그리고 스타일 코드도 다시 *.styles.ts 한 곳으로 모이게 된다. 스타일링 편의보다는 코드의 올바른 위치에 좀 더 무게를 둔 결정이었다.

공통 컴포넌트의 API를 더 디자인 시스템 답게 다시 설계하기

제외한 것들이 있었던 반면, 기존 컴포넌트를 올리면서 API를 다시 설계한 케이스도 있었다.

BottomSheet는 기존에 setOpen을 외부에서 넘기는 방식이었다. 컴포넌트가 열려 있는 상태를 외부의 setter 함수로 제어하는 구조였는데, 이번에 onClose를 받는 방식으로 바꿨다.

열려 있는 상태인지 판단하는 구조는 사용하는 측이어야 한다고 생각했다. setOpen을 받으면 컴포넌트가 자기 상태를 외부 함수로 직접 조작하는 구조가 되는데, 이보다는 닫히는 이벤트만 컴포넌트가 알리고, 판단은 외부에서 하는 것이 더 명확한 역할 분리라는 생각이 들었다.

// before: 컴포넌트가 setter를 받아서 직접 상태를 닫음
const [isOpen, setIsOpen] = useState(false);
<BottomSheet setOpen={setIsOpen} />
// after: 컴포넌트는 닫힘 이벤트만 알리고, 판단은 외부가 함
const [isOpen, setIsOpen] = useState(false);
<BottomSheet isOpen={isOpen} onClose={() => setIsOpen(false)} />

ButtonGroup은 direction prop 하나로 버튼 레이아웃을 잡아주는 단순한 wrapper였다. 하지만 이 영역에 필요한 역할은 단순히 레이아웃 뿐 아니라 주요 액션과 보조 액션의 관계, 그리고 하단 고정 여부나 padding 여부 같은 것들도 함께 처리해야 했다. 기존 컴포넌트의 역할이 너무 단순하다보니 존재 자체가 희미해서 실제로 사용하던 곳도 한 군데밖에 없었다. 그래서 이 컴포넌트는 단순히 layout wrapper가 아니라 액션의 역할과 위치를 명시적으로 받는 ActionArea로 재설계했다.

// 이렇게 쓰는 대신
<ButtonGroup>
<Button variant="secondary" onClick={() => setOpen(false)}>닫기</Button>
<Button onClick={navigateToCharacter}>캐릭터 보기</Button>
</ButtonGroup>
// 이렇게 쓰게 되었다.
<ActionArea
layout="horizontal"
showBottomSafeArea={false}
hasHorizontalPadding={false}
mainAction={{
label: '캐릭터 보기',
onClick: navigateToCharacter,
}}
alternativeAction={{ label: '닫기', onClick: () => setOpen(false) }}
/>

Storybook으로 먼저 검증하기

공통 컴포넌트를 구현할 때 반드시 Story를 함께 만들도록 했는데, 물론 문서화를 위한 목적도 있었지만, 실제 앱에 적용하기 전에 Storybook에서 먼저 variant와 상태를 검증하기 위해서도 있었다.

앱의 특정 화면 데이터나 상태 없이 컴포넌트를 독립적으로 볼 수 있기 때문에 정말 이 컴포넌트가 도메인과 무관하게 동작하는지 자연스럽게 검증할 수 있었다. Storybook에 뭔가 router라던가, msw같은 것들을 설정해야 한다면 그 말은 컴포넌트가 너무 많은 것을 알고 있다는 뜻이기도 하기 때문이다.

.. 살짝 여담이지만 3년 전에 Storybook이라는 것을 처음 알게 되었을 때, 이렇게 도메인에 의존적인 컴포넌트에 대한 story를 만들다가 그냥 포기했던 기억이 있는데, 이번에는 이렇게 도메인 독립적인 컴포넌트를 만들어서 검증했다는 점이 좀 감회가 새로웠다. (3년 전의 Storybook의 첫 경험 기록이 궁금하다면... : https://blog.yoouyeon.dev/storybook-component-scenarios)

다음 글에 이어서...

이렇게 컴포넌트 구현과 이 컴포넌트를 사용하는 부분에서의 코드 치환까지 마치고 나서 돌아보니, 이번 작업에서 가장 시간을 많이 들인 일은 새 컴포넌트를 만드는 것 보다도 기존 컴포넌트의 책임을 다시 점검하는 데 썼던 것 같다.

그리고 공통화가 항상 좋은 것이 아니었다는 것도 새삼 느꼈다. 중복 제거보다 중요한 것은 코드가 올바른 위치에 있는 것이라는 생각도 들었다.

이렇게 공통 컴포넌트 구현과 1차 치환이 끝났지만, 아직 해야 할 일이 많이 남아 있었다. 앱 전반에 남아 있던 기존 theme 기반 스타일 의존성을 걷어내는 작업, 컴포넌트 작업 이후에야 드러난 layout 관련 문제들, 그리고 이 많은 작업들을 어떻게 혼자서 짧은 시간 안에 해낼 수 있었는지! 이 내용은 다음 글에서 이어서 정리해보려고 한다.

end