1. 배경
charkra, radix-ui, toss-emotion-utils 등 UI Library에는 Flex 컴포넌트가 있습니다. css 속성 중 하나인 flex인데 굳이 Flex컴포넌트를 만들어서 사용하는 이유가 무엇인지 곰곰히 생각해보았습니다.
아래는 flex스타일 속성을 적용한 컴포넌트와 flex스타일 속성을 추상화한 Flex 컴포넌트를 활용한 컴포넌트입니다.
tailwind를 사용하였습니다.
<div className="flex gap-10">
{noticeList.map(({ title, onClick }) => (
<NoticeItem key={title} title={title} onClick={onClick} />
))}
</div>
<Flex className="gap-10">
{noticeList.map(({ title, onClick }) => (
<NoticeItem key={title} title={title} onClick={onClick} />
))}
</Flex>
위 두 코드 중 어떤 것이 더 좋은 코드라고 할 수 있을까요? 가독성과 선언성의 측면에서 생각해보았습니다.
가독성: Flex는 이름 그 자체로 목적을 전달하기에, 코드를 읽는 사람이Flex컴포넌트를 보고 해당 부분이 flex 레이아웃을 위한 것임을 즉시 알 수 있습니다.선언성: Flex는 flex 레이아웃을 위한 것임을 선언하는 것처럼 보입니다. 그렇기에 코드를 읽는 사람에게 내부 구현의 세부 사항 없이도 의도를 명확하게 전달할 수 있습니다.
2. 선언적인 코드
선언적인 코드는 프론트엔드 개발을 하다보면 자주 만나게 되는 개념입니다. 특히 React 생태계에서 웹 서비스를 개발하다보면 선언적인 코드에 대해 고민하게 되는데요.
선언적인 코드는 다른 말로하면 ‘추상화 레벨이 높아진’코드라고 할 수 있습니다.
// A
sum([1,2,3]);
// B
function sum(nums:number[]){
let result = 0;
for (const num of nums){
result += num;
}
return result;
}
여기에서 sum함수는 초기값이 0이고, 배열이 가지는 각각의 원소를 순회하면서 결과값에 더하는 작업을 추상화합니다. 덕분에 sum을 다루는 사람을 복잡한 제어 흐름을 이해할 필요 없이, “배열의 합을 구한다”라고 하는 동작에 집중하여 함수를 사용할 수 있습니다.
더 나아가서, sum함수 내부의 for ..of문도 선언적인 코드로 볼 수 있습니다.
for (const num of nums){
/* 동작 ... */
}
nums배열을 순회하며 요소를 순회하는 동작을 추상화하고 있기 때문입니다.
선언적인 코드가 항상 좋은 것은 아닙니다. 하지만, 추상화가 항상 좋은 것이 아닌 것처럼 선언적인 코드도 잘 쓰는 것이 중요하다고 생각합니다.
좋은 코드를 판단하는 제1원칙을 “수정하기 쉬운 코드”라고 생각합니다. 비즈니스 요구사항은, 특히 프론트엔드는 항상 빠르게 변하기 때문에 개발자가 기민하게 대응하는 것이 중요하기 때문입니다.
그러면 선언적인 코드가 언제 수정하기 쉽고, 언제 그렇지 않은지 살펴볼게요.
<TextFieldController
register = {...register}
label = {label}
caption = {caption}
/>
위 컴포넌트는 input태그를 가진 입력필드를 하나의 컴포넌트로 추상화했기 때문에 선언적인 컴포넌트로 볼 수 있습니다.
이 코드는 수정하기 쉬울까요?
먼저 입력 필드를 여러 곳에서 사용한다면 각각의 입력 필드를 중복해서 개발할 필요 없이 한 번만 개발하면 되기 때문에 효율적일 것입니다. 또한, 입력 필드에 변경이 생긴다고 하더라도 한 곳에서만 바꾸면 다른 화면들에 모두 반영되기 때문에 수정할 수 있을 것입니다.
수정하기 어려운 지점은 없을까요?
화면마다 입력 필드가 조금씩 다르다면, 공통화된 것이 오히려 코드의 복잡함을 가져올 수 있습니다. 예를 들어, 회원가입 페이지에서는 label이 좌측에 위치하지만 다른 페이지에서는 우측에 위치한다면, TextField컴포넌트 내부를 수정해야 하고, TextField컴포넌트를 사용하던 모든 컴포넌트에서 수정을 거쳐야합니다.
그래서, 이러한 부분은 시스템 디자인이 구축이 되어있다면 디자이너와 함께 이야기를 하여 통일성을 가지도록 하거나 미리 이야기를 많이 하여 맞추어보는 것이 중요합니다.
반면에 아래와 같이 입력 필드에서 바뀔 수 있는 부분이 많다면, 내부 구현과 인터페이스도 복잡해지고 쓰는 쪽에서도 불편할 것입니다.
<TextFieldController
register = {...register}
onError = {handleError}
onSuccess = {handleSuccess}
/* 많은 props들 */
label = {label}
caption = {caption}
/>
3. 선언적인 컴포넌트, Flex를 만들어보자
flex속성을 선언적으로 표현한 Flex 컴포넌트를 만들 것입니다. props로 어떤 것들을 넘길 수 있을까요? 가장 자주쓰는 값들은 다음과 같습니다.
direction: 'row' | 'column'row: flex 속성에 자동으로 설정이 되는 속성입니다. 메인축을 가로로 합니다.
column: 메인축을 세로로 합니다.
justify-content: 'center' | 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'center: 아이템들을 메인축의 가운데로 정렬합니다.
flex-start(default) : 아이템들을 메인축의 시작점으로 정렬합니다.flex-end: 아이템들을 메인축의 끝점으로 정렬합니다.space-between: 아이템들을 메인축의 끝점으로 정렬합니다.space-around: 아이템들의 둘레에 균일한 간격을 만들어 줍니다.space-evenly: 아이템들의 사이와 양 끝에 균일한 간격을 만들어줍니다.
aligns-items: ‘stretch’ | 'center' | 'flex-start' | 'flex-end' | ‘baseline’stretch(default) : 수직축 방향으로 끝까지 쭈욱 늘어납니다.
center: 아이템들을 수직축의 가운데로 정렬합니다.flex-start: 아이템들을 수직축의 시작점으로 정렬합니다.flex-end: 아이템들을 수직축의 끝점으로 정렬합니다.baseline: 아이템들을 텍스트 베이스라인 기준으로 정렬합니다.
wrap: 'wrap' | 'nowrap' | 'wrap-reverse'wrap: 박스를 넘칠 경우 줄바꿈을 합니다.
nowrap: 박스를 넘칠 경우 줄바꿈을 하지 않습니다.
더 많은 속성이 있지만, 자주 사용하는 속성들만 우선 골랐습니다.
1. props로 css 속성 전달받기
Gloddy프로젝트에서 tailwindcss를 사용하고 있기에, 프론트엔드 팀원들 모두 tailwindcss 문법에 익숙해져있습니다. 그래서, tailwindcss문법에 크게 벗어나지 않도록 props를 설정하기 위해 노력하였습니다.
예를 들어, css에서는 justify-conent : space-between인 문법이 tailwindcss에서는 justify-between입니다. 기존 css문법대로 하면 코드가 더 길어지고 팀원들에게 익숙하지 않을 것이라 생각하여 justify:between으로 tailwindcss문법에 크게 어긋나지 않도록 하였습니다.
interface FlexProps{
direction?: 'row' | 'col';
justify?: 'center' | 'start' | 'end' | 'between' | 'around' | 'evenly';
align?: 'center' | 'start' | 'end' | 'baseline';
wrap?: 'wrap' | 'nowrap';
}
export default function Flex({
children,
direction,
justify,
align,
wrap,
}: PropsWithChildren<FlexProps>) {
return (
<div
className={cn(
'flex',
{
'justify-center': justify === 'center',
'justify-start': justify === 'start',
'justify-end': justify === 'end',
'justify-between': justify === 'between',
'justify-around': justify === 'around',
'justify-evenly': justify === 'evenly',
'justify-stretch': justify === 'stretch',
},
// ..
)
>{children}</div>
)
);
2. 확장 가능한 컴포넌트 만들기
Flex컴포넌트는 대부분의 곳에서 사용할 수 있고 이럴 경우, 변경될 수 있는 여자기 많다집니다. 사용자 인터페이스를 결정하는 요소는 정말 많이 깨문입니다.
예를 들어 Flex컴포넌트의 스타일에서 조금만 다른 경우가 있다면? 이 부분이 외부에서 수정가능해야 이 컴포넌트를 사용할 수 있을 것입니다. 그리고 재사용하여 개발 생산성을 높이는 것이 컴포넌트의 목적입니다.
뿐만 아니라 Flex컴포넌트에 onClick, onMouseEnter 등의 다양한 props를 넘길 수도 있고, ref를 넘겨 DOM을 직접 조작할 수도 있습니다.
이런 상황이 발생할 때마다 props를 추가해서 대응해줄 수도 있습니다. 하지만 이 방법은 재사용이 불가능해진 컴포넌트의 내부를 변경하는 방법입니다. props가 추가되면 추가될수록 그 컴포넌트 내부는 복잡해질 것이고 유지보수가 불가능한 몬스터 컴포넌트가 생겨납니다.
컴포넌트를 하나 더 만들고 공통으로 사용하는 부분을 따로 분리하는 것도 하나의 방법입니다. 이 방법은 컴포넌트를 더 작은 단위로 나누고, 이 경우 무수히 많은 컴포넌트가 생겨나게 되고 상황에 따라 재사용 가능한 컴포넌트가 무엇인지 알아보기 힘들어집니다. 역할이 불분명해졌다는 신호이고 역할이 모호하기 때문에 이름을 짓기 어려워지는 문제도 함께 발생할 것입니다.
여러 가지 요소가 사용자 인터페이스를 결정짓기 때문에 사용하는 쪽에서 결정할 수 있게 끔 주도권을 외부에 넘겨야 합니다. 컴포넌트의 재사용성을 높이려면 외부에서 많은 것을 결정하여 확장할 수 있도록 해야합니다.
1. 다양한 props를 받을 수 있도록 확장
props의 마지막에 …props 전개연산자를 사용하여 앞선 props에서 받지 못한 props들을 받습니다. 그리고 이 …props를 사용할 태그에 props를 통째로 넘겨줍니다.
export default function Flex(
{
{/* 전달받을 props */}
...props
}:FlexProps){
return (
<div {/* 전달받은 props */} {...props}>{children}</div>)
}
2. 스타일 속성 확장
Flex컴포넌트의 background색상을 변경할 수도 있고, height값을 변경할 수 있습니다. 이에 따라 스타일 속성을 확장해줍니다.
export default function Flex({
{/* 전달받을 props */}
className
},ref){
return
// ..
<div
className=cn('flex', {/* // 스타일 속성 .. */} ,className
)>
</div>
)
}
-
cn이 궁금하다면?
3. ref 확장
forwardRef로 컴포넌트를 감싸고, ref타입을 정의합니다. 이 때, ref의 타입은React.ComponentPropsWithRef<T>['ref']입니다. 이 type에 대해 알아볼까요?
`React.ComponentPropsWithRef['ref']` 에 대한 분석
// node_modules/@types/react/index.d.ts
type ComponentPropsWithRef<T extends ElementType> =
T extends (new (props: infer P) => Component<any, any>)
? PropsWithoutRef<P> & RefAttributes<InstanceType<T>>
: PropsWithRef<ComponentProps<T>>;
- 타입 매개변수
T extends ElementType: T라는 타입 매개변수를 사용하며, 이 T는 ElementType을 확장한다.- ElementType : JSX 내장 컴포넌트 또는 사용자 정의 컴포넌트를 둘 다 받을 수 있는 타입
T extends (new (props: infer P) => Component<any, any>): 조건부 타입을 사용한다. T 클래스가 class컴포넌트인지 검사하는 조건이다. 조건이 참일 경우 class컴포넌트, 거짓일 경우 함수형 컴포넌트에 해당한다.
- class컴포넌트의 경우,
PropsWithoutRef<P> & RefAttributes<InstanceType<T>>를 반환한다.- Ref를 제외한 타입과 T인스턴스 타입에 대한 참조 속성이다.
- 함수형 컴포넌트의 경우,
PropsWithRef<ComponentProps<T>>를 반환한다.- T컴포넌트의 프로퍼티 타입의 Ref타입을 반환한다.
-
사실, 결론만 놓고보면
PropsWithRef<ComponentProps<T>>타입으로 선언해도 될 듯싶다. 하지만, react에서는 React.ComponentPropsWithRef사용을 권고하고 있는 것을 확인할 수 있다.
export default forwardRef(function Flex(
props, ref: React.ComponentPropsWithRef<T>['ref']
){
//..
}
);
4. Polymorphic한 컴포넌트로 만들기
Polymorphic한 컴포넌트 또한 3번 주제인 ‘확장’의 연장선입니다. 지금까지는 스타일 속성, 넘겨주는 props를 확장하는 것이었다면, Polymorphic한 컴포넌트는 Flex컴포넌트를 div태그 뿐 아니라 p태그, span태그로도 사용할 수 있도록 확장하는 것입니다.
Polymorphic은 다형성이란 뜻으로 여러 개의 형태를 가지는 것을 의미합니다. 다시 말해 Polymorphic한 컴포넌트란 다양한 형태의 UI컴포넌트를 의미합니다.
1. Javascript로 구현하기
Javascript에선 Type-safe에 자유롭기 때문에 Polymorphic컴포넌트를 구현하는 것이 어렵지 않습니다. 이런 부분은 Javascript의 약점이지만 한편으로는 구현의 편리함으로서 강점이 될 수 있습니다. 다음과 같이 아주 간단하게 Polymorphic한 컴포넌트를 만들 수 있습니다.
export default forwardRef(function View({as, children, ...props}, ref){
const Element = as || 'div';
return <Element {...props} ref={ref}>{children}</Element>
})
여기서 구현한 View컴포넌트는 React에서 가장 추상적인 컴포넌트입니다.. as를 통해 기본 내장된 컴포넌트를 포함하여 어떠한 컴포넌트로도 될 수 있습니다. 만약 생략한다면 기본적으로 div를 사용하게 됩니다. 이때, 필요한 속성이 있다면 자유롭게 넘길 수 있도록 컴포넌트를 작성하고 forwardRef를 통해 부모 컴포넌트에서 요소에 접근할 수 있도록 만들었습니다. 이 컴포넌트는 다음과 같이 사용할 수 있습니다.
import {View} from './View';
export default App () {
return <View as='a' href='https://guesung.oopy.io'>Click me!</View>
}
코드를 살펴보면 as를 통해 View 컴포넌트에 사용되는 요소를 a 태그로 변경하고 href 속성을 사용한 것을 볼 수 있습니다. 그럼 이 코드를 실행하면 Click Me!라는 링크가 보이게 됩니다. 사실 이렇게만 사용하면 왜 사용하는지 이해가 안가는 것이 당연합니다. 그냥 바로 a 태그를 쓰면 되니 번거롭게 컴포넌트를 만들 필요가 없기 때문입니다. 그렇지만 위 코드를 응용하여 다음과 같이 사용하는 것도 가능합니다.
// Button.jsx
export default function Button ({ as, ...props }) {
const Element = as || 'button';
return (
<Element
style={{ backgroundColor: 'black', color: 'white' }}
{...props}
/>
);
}
// App.jsx
import { Button } from './Button';
const App = () => {
return (
<div>
// 마치 앵커 태그처럼 사용할 수 있다.
<Button as="a" href="https://oopy.guesung.io">Click Me!</Button>
</div>
);
}
Javascript를 쓸 때 아쉬운 점은 IntelliSense를 사용할 수 없다는 점입니다. 어느 정도 자동 완성을 해주긴 하지만 Typescript의 강력함에 비하면 좀 아쉽습니다. 위 코드도 as를 통해 다른 요소를 사용하도록 변경했지만 어떤 속성을 넘길 수 있을지는 개발자가 잘 판단하여야 합니다. 혹은 개발자가 오타를 내어 잘못된 값을 as로 전달할 수도 있습니다. 이러한 문제는 Typescript를 통해 type-safe한 polymorphic컴포넌트를 구현하면 해결할 수 있습니다.
2. Typescript로 구현하기
as를 props로 전달받습니다. 이 전달받은 as를 제너럴 타입 T로 설정합니다. 이 T는 React.ElementType을 확장함으로써 T에 다양한 React컴포넌트나 HTML 엘리먼트에 대한 프로퍼티를 정의할 수 있습니다.
또한, React.HTHMLAttributes
interface FlexProps<T extends React.ElementType> extends React.HTMLAttributes<T> {
as?: T;
// ..
}
Flex의 제네릭 타입으로 Flexprops의 제네릭 타입과 동일하게 React.ElementType을 확장한 T를 설정합니다. props의 타입은 위에서 선언한 FlexProps<T>와 React.ComponentPropsWithoutRef<T>의 유니온 타입입니다.
`React.ComponentPropsWithoutRef`에 대한 분석
React.ComponentPropsWithoutRef
// node_modules/@types/react/index.d.ts
type ComponentPropsWithoutRef<T extends ElementType>
= PropsWithoutRef<ComponentProps<T>>;
type PropsWithoutRef<P> =
P extends any ? ('ref' extends keyof P ? Omit<P, 'ref'> : P) : P;
export default forwardRef(function Flex<T extends React.ElementType>(
{
as,
// ..
...props
}: FlexProps<T> & React.ComponentPropsWithoutRef<T>,
ref: React.ComponentPropsWithRef<T>['ref']
) {
5. 실제 사용
<Flex align="center" className="gap-12 pb-4 pt-6" ref={ref}>
{/* ... */}
</Flex>
6. 기능 개선
1. 강제 타입 추론
태그를 as props에 의해 변경이 되는 Polymorphic한 React 컴포넌트에서 forwardRef를 사용하게 될 경우 타입 추론이 되지 않는 이슈가 있습니다.
forwardRef가 호출함수이기 때문에 제네릭 타입 T가 전달되지 않기 때문에 타입 추론이 안된다는 내용의 글을 읽었습니다. 따라서, 타입 단언을 이용하여 직접 타입을 정의해줘야 한다고 합니다.
위에서 선언한 컴포넌트에 타입 단언으로 다음과 같이 작성하였습니다.
as <T extends React.ElementType>(
props: StrictPropsWithChildren<FlexProps<T> & React.ComponentPropsWithoutRef<T>>
) => JSX.Element;
타입 단언 이후, 타입 추론이 정상적으로 동작함을 확인할 수 있습니다.
7. 맺으며
선언적으로 작성하기 위해 Flex컴포넌트를 만들고, 이를 확장성 있는 컴포넌트로 만들기 위해 Polymorphic하게 작성해보았습니다.
Flex컴포넌트는 어디에서든 사용이 될 수 있습니다. 모든 div 뿐 아니라 p, span 등 모든 태그를 대체할 수도 있습니다. 그렇게 되면 코드는 Flex밭이 되어버릴 것입니다. 그래서 이에 대해 팀원과 적절한 컨벤션을 정하는 것이 필수적이라 생각합니다. 우선 저희 팀은 Flex의 direction을 사용하지 않고 default값인 row인 경우에만 사용하기로 결정했습니다. 각 팀의 특성 혹은 성격에 따라 이러한 규칙을 정하여 Flex컴포넌트를 중구난방으로 사용하는 일이 없도록 하는 것이 중요해 보입니다.
Box, Grid, Container같은 Layout도 선언적인 컴포넌트로 만들까 고민을 해보았지만, tailwindcss를 사용해서 그런지 이러한 컴포넌트들에 대해서는 아직까지 필요성을 느끼지 못하였습니다. tailwindcss는 className으로 width, height를 설정하면 즉시 확인할 수 있기에 더 이상의 Layout컴포넌트는 불필요하다고 생각했습니다.
공통 컴포넌트를 만들 때, 앞으로 필요에 따라 Polymorphic한 컴포넌트를 만들면 보다 확장성 있게, 다채로운 컴포넌트를 만들 수 있을 것임을 기대하며 끝을 맺습니다.