가이드라인 작성 계기
지난 봄, 여름에 걸쳐 혼자서 디자인 시스템을 만들면서 수 많은 고민을 하고 작은 결정과 선뜻 답을 내기 어려운 어려운 문제들을 만나곤 했다.
머리를 맞대고 궁리해도 아직까지도 알쏭달쏭한 문제들도 있지만, 컴포넌트 개발 중 했던 많은 고민과 참고한 소스코드 등을 바탕으로 나름대로의 기준을 만들어가게 되었다.
그러던 중, 디자인 시스템 컴포넌트 설계 경험을 바탕으로 다른 팀원분의 MR에 코드리뷰를 하면서 비슷한 내용의 코멘트를 달게 되는 일들이 생겼고, 내가 했던 고민의 시간을 다른 팀원이 반복하지 않았으면 했다. 설계 측면에서 네이밍이나 설계 측면에서 모두가 쉽게 놓칠수 있는 부분에서 발생하는 코스트를 줄이고, 나 또한 코드리뷰 리소스도 최소화하고자 했다.
또한 가이드라인이 있을 때 서로 다른 코드 스타일, 네이밍 습관에 따라 API또한 파편화 되는 것을 막을 수 있다. 일관된 API가 제공된다면, 처음 사용하는 컴포넌트더라도 사용자가 예상하는 방식으로 구현되어 문서를 굳이 찾아 읽거나 레포를 찾아 본다든지 하는 비용을 최소화 할 수 있다.
이러한 사용자 측면의 비용과 기여 측면의 비용을 최소화할 수 있도록 ’디자인 시스템 설계 가이드라인‘을 만들어야겠다고 생각하게 되었다.
가이드라인의 기반, MUI API design Guideline
그러한 생각이 든지 얼마 안되어, MUI의 API design Guideline (opens in a new tab)을 발견하게 되었다.
신기하게도, 해당 문서에는 그동안 내가 컴포넌트를 설계하면서 했던 고민들이나 일관되지 않아 궁금했던 API 네이밍에 해당하는 원칙, rule들이 적혀있었다.
EDS(Elice Design System)은 Material-UI를 기반으로 만들어졌기 때문에, 우리는 최대한 MUI의 컴포넌트 네이밍, 프롭 네이밍을 참고하여 사용자의 러닝커브를 최소화하고 있다.
예를 들어, 한 컴포넌트가 특정 프롭에 따라 다른 종류의 형태를 갖게 된다면
type , shape 보다는 variant 라는 네이밍을 사용한다.
또한, 전 세계의 수많은 사용자를 보유한 MUI사에서는 컴포넌트 네이밍 하나에도 많은 고민이 담겨있을 것이기 때문에, 이들의 가이드라인은 신뢰할만 하다고 생각했다. (물론 레거시도 존재..)
가이드라인의 주요 내용
합성으로 의존성 역전시키기
이전 어떤 기술블로그에서 ‘children 은 리액트의 꽃이다’라고 했던 문장은 아직도 생각날 정도로 그 위대함(?)을 잘 설명해준다.
이전 디자인 시스템의 가장 큰 문제점이었던 ‘프롭 지옥’은 컴포넌트를 책임과 역할에 따라 적절하게 분리하지 않고 여러 커스텀 니즈에 맞춰 구현하기 위해 프롭을 점차 늘려갔기 때문에 발생했다.
여러 역할을 동시에 떠맡는 무거운 컴포넌트는 처음엔 단순하고 명쾌해보일지더라도, 기능 요구 사항이 추가되어갈수록 추가적으로 고려해야할 사항들이 늘어나기 마련이며, 그 역할과 책임이 불분명해지기 쉽다.
이전 디자인 시스템의 문제를 반복하지 않기 위해서는, 최소한의 단위로 (책임이 분리될 수 있는 한에서) 나누는 과정이 필요했다. 그래서 디자이너분께서 하나의 컴포넌트로 전달해주신 시안도, 여러 컴포넌트로 해체(?)되었고, 어쩔 수 없이 피그마에도 이에 맞게 컴포넌트를 분리해달라고 요청드리기도 했다.
예시
Need consideration
<Dialog
title=”title”
content=”content”
actions={[
{label: ‘cancel’, onClick: handleCancelClick, color: 'secondary'},
{label: 'submit', onClick: handleSubmitClick, color: 'primary', disabled: !isDirty}
]}
titleProps={{
disableTypography: true,
}}
contentProps={{ divider: true }}
/>Recommended
<Dialog>
<DialogTitle disableTypography>
<Stack direction="row" justifyContent="space-between">
<Typography>Title</Typography>
<EliceIcon icon={faArrowRight} />
</Stack>
</DialogTitle>
<DialogContent divider>some contents inside the dialog</DialogContent>
<DialogActions>
<Button onClick={handleCancelClick} color="secondary">cancel</Button>
<Button onClick={handleSubmitClick} color="primary" disabled={!isDirty}>cancel</Button>
</DialogActions>
</Dialog>title, content, action을 각각 프롭으로 받아 Dialog 컴포넌트 내부에서 렌더 로직을 구성하는 경우, 각각 구성요소의 여러 케이스와 요구사항을 고려한 로직들을 포함하게 된다. 이런 로직은 Dialog 내부에 있기 때문에 컴포넌트의 구조가 비교적 명시적으로 보이지 않는다.
title 의 스타일을 수정하고 싶은 경우, 이를 지원하기 위한 별도의 프롭을 파야한다. titleProps 이나 slotProps.title 과같이 하나의 프롭이 늘어나게된다.
이처럼 각 요소를 ‘어떻게 보여줄 것인지’에 대한 로직은 Dialog 내부에 있고, 사용하는 곳에서는 알 수없다. title, description, actions 요소는 Dialog에 의존하고 있는 것.
title을 아주 작은 폰트사이즈로, description을 빨간 텍스트로 보여줄지는 사용하는 입장에선 알 수 없다. 만약 이를 수정하고자 한다면, titleProps 와 같이 커스텀을 지원하는 프롭이 있다면 이를 사용해서 해당 요소를 조정할 수 있겠다.
위와 같이 title, content, action이 세 가지 요소를 모두 관리하는 Dialog 컴포넌트는 내부 구현로직이 무거워지고, 유지보수의 비용도 증가하게 된다.
이 때! children 을 사용하여 Dialog 내부 요소들이 어떻게 구현될 것인가에 대한 것을 외부로부터 받음으로써, 그에 대한 무거운 책임들을 Dialog 가 아닌 children에 오는 요소에게 역전시킬 수있다. (두 번째 코드)
이렇게 각 요소들을 책임에 따라 분리한다면 컴포넌트의 복잡성을 줄일 수 있을 뿐 아니라, 코드만 보아도 컴포넌트의 모습을 한눈에 파악하기 쉽게 해준다.
합성이 항상 Best Practice는 아니다.
주의해야할 점은, 합성 방식이 항상 최선이 아니라는 것이다. 필요 이상으로 너무 잘게 쪼갠다면 알아야 할 컴포넌트 명도 많아지고, 작성해야할 코드 수도 늘어나게 된다. 빠르고 효율적으로 개발하기 위한 디자인 시스템이, 사용하기 어려워지는 것은 항상 주의해야한다.
또한, Form 요소에서 사용될 컴포넌트를 개발하면서 children을 사용한 합성방식이 오히려 구현 방식을 더 복잡하게 만드는 경우를 맞닥뜨렸고, 이를 통해 다음과 같이 ’합성을 권장하되 렌더용 prop을 사용하는 것을 허용하는’ 기준을 세우게 되었다.
어떤 경우 합성보다 prop을 받는 방식을 사용할 수 있도록 제한했을까?
1. 특정 컴포넌트만 제한적으로 받으며, 서로 긴밀히 연결되어야 하는데..
TextField 컴포넌트를 직접 구현한다고 했을 때, Dialog와는 다르게 내부에 올 요소들은 위외 같이 예상가능하며, 모든 것이 들어올 수 있게 허용할 필요가 없다. Dialog는 화면에 나타나는 방식에 대한역할만 있을 뿐, 무엇을 담고 있을지에 대해서는 열려있다.
(material-ui에서는 이러한 컴포넌트 계열을 Surface 컴포넌트로 분류한다)
반면, TextField의 역할은 비교적 분명하다. input과 그와 관련된 label, description이 input의 상태에 따라 호응하며 변해야한다.
form 요소의 경우, aria attribute로 관련된 요소들을 적절하게 묶어주어야하는 경우가 있다. 예를 들어, input의 label과 해당 input에 설명이나 에러메시지를 제공하는 helperText가 그렇다.
MUI 가이드라인에서도 특정 요소를 제한하여 받고 싶은 경우로 Tab 컴포넌트를 예시로 들고 있다.
Composition
You may have noticed some inconsistency in the API regarding composing components. To provide some transparency, we have been using the following rules when designing the API:
- Using the
childrenprop is the idiomatic way to do composition with React.- Sometimes we only need limited child composition, for instance when we don't need to allow child order permutations. In this case, providing explicit props makes the implementation simpler and more performant; for example, the
Tabtakes aniconand alabelprop.- API consistency matters.
이를 통해 알 수 있는 것은, children으로 설계된 컴포넌트는 MUI에서 다양한 사용 케이스가 있을 것을 예상하고 있다는 것이다.
2**. 컴포넌트 특성상 children을 이미 사용하고 있는 경우가 있다.**
<Tooltip title=”tooltip text”>
<Icon />
</Tooltip>Tooltip처럼 이미 children을 사용중인 컴포넌트라면 별도 프롭을 사용해야한다.
물론 children을 못 쓴다고 해서, 합성이 불가능한 것은 아니다. children도 컴포넌트의 프롭 중 하나다.
만약, title prop의 타입이 React.Node라면, 어떤 것도 조합해서 넘길 수 있다.
Mui의 경우 많은 다양한 요구사항을 가진 유저들이 사용하는 UI 라이브러리이기 때문에 대부분의 프롭을 이렇게 허용해놓았다. (나는 이걸 구멍을 뚫어놓는다고 표현한다.)
경우에 따라서는, React.ElementType<{props}>으로 특정 컴포넌트가 오도록 제한할 수 있다.(ex. React.Element<TooltipContentProps> )
<Tooltip title={
<TooltipContent>
<TooltipImage src="" />
<TooltipContentText>text</TooltipContentText>
</TooltipContent>
}>
<Icon />
</Tooltip>여기서 잠깐… React.cloneElement 방식의 문제점
만약 TextField의 내부 요소들을 모두 children 으로 받고, 이를 내부에서 cloneElement로 필요한 속성을 넘겨준다면, 다음과 같은 문제점이 생긴다.
React.Children.map(opens in a new tab) 으로 자식요소들을 가져와서, 조건문으로 element type별로(label, helperText, input)분리한 후,React.cloneElement로 추가적으로 필요한 속성을(aria-describedby,id) 넘겨줘야한다.- 하지만 위의 로직은 가독성도 좋지않을 뿐더러
- children이
<div>나 다른 요소로 감싸져 있는 경우를 고려한다면, (1겹, 2겹, 3겹.. ) 제대로 동작하지 않는다. children이 몇번 감싸져 있는지 알기 어렵다. 이 경우 엉뚱한 곳에 필요한 속성을 넘기게 될 가능성이 있고 현실적으로 nested 구조를 모두 반영할 수 없다. - helperText가 사용되지 않았을 땐 자식요소에 온 input 요소(
OutlinedInput)에aria-describedby에 helperText요소의 id를 넘기지 않아야하는데, React.Children.map을 사용하는 한, 쌓임 구조를 그대로 반영하여 로직을 작성해야해서 조건 처리가 어렵다. - React 공식 문서에서는 사실 React.cloneElement 사용을 권장하고 있지 않다.
children과 cloneElement 사용 예시
<FormTextField required>
<FormInputLabel>label</FormInputLabel>
<OutlinedInput value={value} onChange={onChange} />
<FormHelperText>This is a helperText</FormHelperText>
</FormTextField>
//
const FormTextField: React.FC = ({children}) => {
const inputId = useId();
const helperTextId = `${inputId}-description`
React.Children.map(children, () => {
// ...
if (children.type === 'FormHelperText') {
return React.cloneElement(children, {
...children.props,
id: helperTextId,
})
}
// else : input 요소 - 하나의 타입으로 특정하기 어려움.
// children으로 넘어오는 구조상 input 요소가 먼저, aria-describedby에 helperTextId가 없이 리턴된다.
return React.cloneElement(children, {
...children.props,
aria-describedby: helperTextId,
}
})prop 방식
<TextField
required
label=”label”
helperText=”this is helperText”
value={value}
onChange={onChange}
/>
//
cosnt TextField: React.FC = () => {
return (
<FormControl>
{label ? <FormInputLabel htmlFor={inputId} children={label}/> : null}
<OutlinedInput aria-desribedby={helperTextId} id={inputId}/>
{helperText ? <FormHelperText id={helperTextId} children={helperText} /> : null}
</FormControl>
)
}React.children.map()과 cloneElement를 사용하면 위와 같은 한계점을 발견할 수있는데, *cloneElement 방식은 React에서 deprecated시켰고, renderProp 패턴을 사용한는 것을 권장하고있다. 위의 문제점들은 Context API를 사용한다면 해결할 수 있고, 경우에 따라서는 renderProp 패턴을 사용할 수도 있겠다. (나의 경우, TextField에 오는 요소는 한정적이기 때문에 prop으로 나타낼 요소를 받았다.)
이처럼 컴포넌트 설계시에는 상태를 관리하고, UI를 표현하기 위한 로직들이 하나의 컴포넌트에 너무 많은 의존을 하고 있지는 않은지 경계해야 한다.
SOLID 원칙 중 SRP(Single Responsibility Pricinple)를 위배하고 있지는 않은지 점검하고, 하나의 컴포넌트가 너무 많은 역할을 하고 있다면 컴포넌트를 쪼갤 때가 되었다는 신호라는 것을 염두에 두면 좋을 것 같다.
TL;DR
- 컴포넌트 하나가 너무 많은 책임과 역할을 갖고 있다면 그 컴포넌트는 훗날 유지보수, 사용성에 문제를 겪을 가능성이 크다.
- 처음 설계시 간단해보이는 컴포넌트도, 커스텀과 기능 요구사항이 생기면서 복잡해질 수 있다.
- 이를 경계하고, 컴포넌트의 역할과 책임이 명확해지도록 적절하게 분리하는 것이 디자인 시스템 컴포넌트 설계시 중요한 부분이다.
- 이 때, 컴포넌트를 atomic하게 분리하고, children을 사용한 컴포넌트 합성 방식으로 들어오는 요소에 더 개방적으로 받으며 커스텀/기능 요구사항에 유연하게 대응할 수 있다.
- 또한, 합성방식을 사용하면 jsx 코드만 보고도 어떤 방식으로 동작할지와 구조가 더 눈에 명확히 읽힌다.
- 하지만 합성 방식이 무조건 좋다! prop 추가는 배척해야한다!고 생각하기보다, 구현하려는 컴포넌트의 특성과 요구사항을 이해하고 설계하는 것을 추천한다.
개인적으론 다음 두 글을 읽고 리액트의 컴포넌트 설계와 합성 방식에 대해서 잘 정리되어있다고 생각해서 읽어보길 추천드립니다.
프론트엔드와 SOLID 원칙 | 카카오엔터테인먼트 FE 기술블로그 (opens in a new tab)
합성 컴포넌트로 재사용성 극대화하기 | 카카오엔터테인먼트 FE 기술블로그 (opens in a new tab)
작성을 하다보니 생각보다 글이 길어져서, 2부 (Prop 네이밍과 설계 원칙)에 이어서 작성하겠습니다.
긴 글 읽어주셔서 감사하고, 이 글이 누군가에게 도움이 되기를 바랍니다 :) 피드백과 경험 공유는 언제나 환영입니다 😄