티스토리 뷰
배경
약 1년 반 전 컴포넌트를 잘 만드는 방법(리액트) 글을 썼다. 우연히 출퇴근길 개발 읽기에 올라갔고 많은 사람들이 내가 쓴 글을 읽었다. 1년 반이 지난 지금 나는 컴포넌트에 대해서 얼마나 잘 알고 있을까? 2편을 적어보면서 스스로 판단해보기로 했다.
컴포넌트를 잘 만드는 방법
2편인 만큼 아주 기본적인 내용은 생략한다. 실무에서 누구나 겪을만한 내용들을 다뤄보겠다.
composition pattern
정의
컴포지트 패턴(Composite pattern)이란 객체들의 관계를 트리 구조로 구성하여 부분-전체 계층을 표현하는 패턴으로, 사용자가 단일 객체와 복합 객체 모두 동일하게 다루도록 한다.
- by 위키피디아
정의만 봐서는 알기 힘드니 어떤 경우에 사용할 수 있는지 또한 어떤 장점이 있는지 예제를 통해 알아보자.
ex) Menu
- Menu라는 컴포넌트가 정의되어 있다.
- Menu에는 항상(대부분) MenuList라는 컴포넌트를 감싸서 사용한다.
- Menu가 여러 개 있을 때는 MenuHeader라는 컴포넌트를 같이 사용한다.
사실 크게 문제가 있는 것은 아니다. 그래도 문제가 되는 부분을 찾아보자.
나는 Menu, MenuList, MenuHeader라는 컴포넌트 세 개를 모두 알아야만 한다. 그런데 굳이 그럴 필요가 있을까? Menu가 기본 컴포넌트고 MenuList와 MenuHeader는 메뉴를 사용할 때 부가적으로 사용되는 컴포넌트일 뿐이다.
또한 Menu라는 컴포넌트를 사용할 때 인터페이스에 MenuList와 MenuHeader가 없기 때문에 사용법이 익숙하지 않은 사람은 컴포넌트를 찾아야 한다.
이제 composition pattern을 적용해 보자. 코드는 어렵지 않다. 컴포넌트를 확장하는 방법은 여러 가지 방법이 있는데 가장 많이 사용되는 두 가지 방법을 가져왔다.
방법1은 자바스크립트 함수의 특징을 이용하는 것이다. 함수는 일급 객체이기 때문에 별다른 처리 없이 composition pattern을 적용할 수 있다.
방법2는 Object.assign을 이용해 확장하는 방법이다. 이 방법은 1번 방법에 비해서 약간 복잡하지만 자유롭게 객체를 확장할 수 있다는 장점이 있다.
// Menu/index.ts
import { ReactNode } from 'react';
function Menu() {
return <div>menu</div>;
}
function MenuList({ children }: { children: ReactNode }) {
return <div>{children}</div>;
}
function MenuHeader() {
return <div>header</div>;
}
// 방법1
Menu.List = MenuList;
Menu.Header = MenuHeader;
// 방법2
// const Menu = Object.assign(_Menu, { List: MenuList, Header: MenuHeader });
// const Menu = Object.assign(_Menu, { List: { FullList, MediumList, SmallList } });
// component.tsx
function Component() {
return (
<Menu.List>
<Menu.Header />
<Menu />
<Menu />
<Menu />
</Menu.List>
);
}
ex) 실무에서 사용하기
Menu같은 공통 컴포넌트가 아니더라도 사용할 수 있는 곳이 생각보다 많이 있다. 이번에도 예제와 함께 살펴보자.
Foo
- 복잡한 Foo라는 컴포넌트를 만들었다.
- 한 달 후에 Foo 컴포넌트를 재사용할 일이 생겼다. 여유도 없고 데이터 적으로나 UI 적으로나 다른 부분이 없어서 컴포넌트를 그대로 재사용했다.
- 한달후에 Foo 컴포넌트와 유사한 컴포넌트를 만들어야한다. 그런데 이때는 데이터와 UI가 조금씩 다르다.
여러 가지 해결 방법이 있지만 다음과 같이 해결한다고 가정해 보자.
데이터는 props로 주입받거나 type으로 분기를 치기. + 컴포넌트는 여러 컴포넌트를 조합해서 사용하기.
ex) FooWrapper, FooHeader, FooInner
이때 Foo에 composition pattern을 적용하면 Foo.Wrapper, Foo.Header, Foo.Inner로 접근할 수 있다.
function Component() {
// 생략
return (
<Foo.Wrapper>
<Foo.Header header={header} />
<Foo.Inner>
<Foo data={data} />
</Foo.Inner>
</Foo.Wrapper>
);
}
찾아보니 내 레포에도 composition 패턴을 적용한 코드가 있어서 링크를 올린다.
export const TodoCard = Object.assign(_TodoCard, {
ListContainer: TodoCardListContainer,
List: TodoCardList,
});
또한 컴포넌트를 만들 때 해당 컴포넌트에 해당하는 스켈레톤 로딩을 만드는 경우도 많다. 이럴 때 굳이 불필요하게 두 개의 컴포넌트를 외부에 노출할 게 아니라 하나의 컴포넌트에서 스켈레톤 UI를 같이 관리할 수도 있다.
headless UI
정의
headless UI는 기능은 있지만 스타일은 없는 UI을 의미한다.
디자인은 그 회사의 정체성을 보여주는 얼굴과도 같은 역할을 한다. 그렇기 때문에 보통 회사에서는 UI 라이브러리를 사용하지 않는다. 그런데 table, calendar, datepicker 등의 몇몇 컴포넌트는 직접 구현하는 게 꽤 복잡하다. 그래서 UI를 커스텀하는데 드는 공수를 들이면서 UI 라이브러리를 도입하기도 한다.
그런데 고생해서 디자인을 수정해놨지만 UI는 계속 변경된다. 그리고 그때마다 라이브러리의 UI를 변경하는 것은 쉽지 않거나 불가능한 경우도 있다. 그러면 결국 자체 구현을 해야한다. 그런데 기존 라이브러리에서 있는 모든 기능을 대체하려면 꽤 많은 공수가 들 수 있다.
이럴 때 필요한 게 headless UI다. 필요한 기능만 제공하고 스타일은 외부에서 주입하게 만들면 기능을 구현해야 할 공수도 없어지고 UI를 커스텀해야할 공수도 사라진다.
ex) table
@tanstack/react-table의 가장 기본적인 예시 코드
function App() {
const [data] = React.useState(() => [...defaultData])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
<tfoot>
{table.getFooterGroups().map(footerGroup => (
<tr key={footerGroup.id}>
{footerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext()
)}
</th>
))}
</tr>
))}
</tfoot>
</table>
)
}
테이블의 복잡한 기능들은 hook에서 지원한다. 그리고 UI는 따로 관리한다. 그렇기 때문에 UI의 자유도를 100% 가져가면서 기능을 직접 구현할 공수도 적어진다.
https://tanstack.com/table/latest/docs/framework/react/examples/basic
ps. 아래는 @tanstack/react-table을 연습한 레포다.
https://github.com/yoonminsang/TIL/blob/main/react-table(v-8)/README.md
ex) tree
외부 라이브러리는 도입만 하면 되지만 라이브러리가 없는 경우도 있고 팀에서 자체적으로 만들어야 하는 경우도 있을 것이다. tree라는 컴포넌트로 예시를 들어보자. tree와 UI를 결합해서 컴포넌트를 만들 수도 있지만 tree의 기능만 존재하는 컴포넌트와 UI를 결합한 컴포넌트를 분리하면 재사용이 가능해지고 테스트도 쉬워지고 디버깅도 쉬워진다. 복잡한 컴포넌트를 구현해야 한다면 headless로 UI를 분리할 수 있는지 한 번씩 고민해 보자.
다음은 atlassian에서 만든 tree 컴포넌트다. 해당 인터페이스를 참고해서 headless tree를 만들고 tree를 이용해 컴포넌트를 몇 개 만들어보면 headless의 장점을 몸으로 느낄 수 있다.
https://atlaskit.atlassian.com/packages/confluence/tree
headless UI와 composition pattern
headless UI와 composition pattern은 두 개를 사용할 때 효과가 배가 된다. tailwind에서 만든 headlessui 라이브러리만 봐도 composition pattern을 적용한 것이 보인다.
https://headlessui.com/react/menu
headless UI + composition pattern vs options
컴포넌트에 composition pattern을 쓰는 경우와 options같은 props를 쓰는 두 가지 경우로 나눠진다. 두 가지 방법은 모두 장단점이 있는 방법이다. options는 사용하기 편리하다는 장점이 있고 확장이 어렵다는 단점이 있다. 반대로 composition pattern은 사용하기 불편하다는 단점이 있고(비교적) 확장이 쉽다는 장점이 있다. 예시를 통해 자세히 알아보자.
ex) select
먼저 antd 라이브러리의 select 사용법을 살펴보자.
// works when >=5.11.0, recommended ✅
return <Select options={[{ value: 'sample', label: <span>sample</span> }]} />;
// works when <5.11.0, deprecated when >=5.11.0 🙅🏻♀️
return (
<Select onChange={onChange}>
<Select.Option value="sample">Sample</Select.Option>
</Select>
);
과거에는 composition pattern을 사용했는데 현재는 options라는 props로 대체하고 있다.
내 생각에 변경한 이유는 다음과 같다.
1. 코드양이 적다.
2. 처음 사용하는 사람의 입장에서 options가 더 쉽다.
이제 가정을 해보자.
- antd를 사용해서 select를 구현하기로 했다.
- antd select와 유사하지만 UI를 커스텀 해야 할 필요가 생겼다.
- select의 options 별로 카테고리화해서 헤더를 보여주고 싶다.
이 요구사항을 충족시키기 위해서는 options가 아니라 composition pattern으로 만들어야한다.
function Component() {
return (
<Select>
<SelectHeader>헤더</SelectHeader>
<Select.Option value="sample">
<div
css={css`
color: red;
`}
>
sample
</div>
</Select.Option>
</Select>
);
}
antd개발자들도 위와 같은 문제점을 당연히 알고 있다. 그럼에도 불구하고 props로 options를 받게 한 이유는 뭘까?
내 생각에 antd는 예외적인 디자인을 충족시켜 줄 필요가 없다. antd를 사용하는 회사들은 UI의 높은 자유도를 요구하지 않을 것이다. 또한 모든 커스텀이 가능하지는 않지만 나름대로 인터페이스를 정의해서 커스텀을 가능하게 해놨다.
그렇다면 우리는 회사에서 Select 컴포넌트를 만들 때 어떻게 만들어야 할까? 내 생각엔 처음 디자인 시스템을 만들때는 모든 확장 가능성을 열어놓는 게 좋은 것 같다. 그리고 이미 안정화가 된 디자인 시스템을 만들 때는 options로만 처리해도 문제가 없을 것 같다.
https://ant.design/components/select
컴포넌트에 예외케이스가 들어올때
UI에 변화가 있을 때는 세 가지 방법을 선택할 수 있다.
1. props를 추가해서 분기 처리하기
2. 새로운 컴포넌트를 만들기
3. 공통 컴포넌트를 만들고 공통 사항만 추출해서 새로운 컴포넌트 만들기.
모두 선택할 수 있는 방법이며 상황에 따라 적절하게 선택해야 한다. 정확히 어떤 상황에는 어떻게 해야 한다고 말할 수는 없지만 몇 가지 가이드를 적어보겠다.
- 꽤 크기가 있는 컴포넌트를 만들었는데 특정 경우에만 일부 UI가 다르다.
=> 이런 경우는 그냥 props를 추가해서 분기 처리를 하는 게 편하다. 미래를 생각하면서 컴포넌트 구조를 변경하는 것보다는 가볍게 처리하는게 좋아보인다. 가능하다면 children을 props로 받아서 처리하는 것도 좋은 방법이다.
Q: UI에 변화가 있을 때마다 계속 props를 추가하게 되면 나중에는 유지보수가 불가능한 컴포넌트가 돼버릴 것 같아요.
A: 그 정도의 변화가 있으면 컴포넌트를 갈아엎어야 합니다. 처음에 한 번 정도 분기를 치는 건 문제가 없어요.
- ListItem 디자인 컴포넌트를 만들었는데 이번에 들어가는 화면에서는 ListItem의 스타일이 기존 UI와 다르다.
=> 해당 페이지에서만 사용하는 새로운 컴포넌트를 만드는 것도 방법이다. 공통 컴포넌트라면 디자인시스템에서 정의한 규칙이 있을 것이고 그 규칙을 따르지 않았다면 그 시점에 이미 ListItem 컴포넌트가 아니다. 가장 좋은 건 만들어놓은 컴포넌트를 사용하는 것이지만 어쩔 수 없을 때는 새롭게 만들고 해당 상황을 디자이너나 다른 개발자에게 공유하자.
Q: 예외가 생길 때마다 매번 컴포넌트를 만드는 건 좋은 방법이 아닌 것 같아요.
A: 동의합니다. 하지만 디자인 시스템은 처음부터 완벽할 수 없고 이 부분은 프론트 개발자의 영역도 아닙니다. 다만 어떤 경우에 예외 상황이 발생했고 이걸 해결해야 한다고 제안 정도는 할 수 있을 것 같아요. 또한 기존 컴포넌트를 커스텀해서 쓰는 것도 좋은 방법이지만 컴포넌트가 수정되면서 예상치 못한 변경 사항을 야기할 수도 있습니다. 그렇기 때문에 때로는 아얘 새롭게 만드는게 좋은 경우도 있을 것 같아요.
- Editor 컴포넌트가 존재하며 A도메인 여러 곳에서 사용되고 있다.
- B라는 도메인에 Editor 컴포넌트가 들어가게 되었다. 기존의 Editor와 공통된 부분이 있지만 일부 요구사항이 다르다.
=> A와 B에서 공통적으로 사용되고 있는 Editor 부분만 공통 컴포넌트로 추출하고 AEditor, BEditor 컴포넌트에서 해당 Editor를 이용해서 컴포넌트를 만들 수 있다.
코어 컴포넌트
컴포넌트는 시간이 지날수록 비대해질 수 밖에 없다. 그럴 때 사용할 수 있는 방법이 코어 컴포넌트를 이용한 확장이다.
ex) input
- Input이라는 디자인 컴포넌트를 만들었다.
- 인풋을 클릭하면 복사가 되는 CopyInput 컴포넌트를 만들라는 요구사항을 받았다.
이럴 때는 굳이 새로운 CopyInput이라는 컴포넌트를 만들 필요가 없다. CopyInput이라는 컴포넌트에서 Input을 렌더링하고 추가적인 기능만 이벤트핸들러에 붙여주면 된다.
ex) ListItem
- ListItem이라는 디자인 컴포넌트를 만들었다.
- 앞에 스타일이 다른 숫자가 붙는 ListItem 컴포넌트를 구현해야하는 요구사항을 받았다.
이때는 위 컴포넌트가 쓰이는 곳에서 NumberListItem이라는 컴포넌트를 만들고 사용하면 된다.
또한 위 컴포넌트가 여러곳에서 자주 사용된다면 디자이너에게 컴포넌트화하자는 제안을 할 수도 있고 프론트엔드에서만 자체적으로 컴포넌트를 정의할 수도 있다.
컴포넌트의 확장이 깊어질 때
이런 식으로 코어 컴포넌트를 확장해서 만드는 것은 디자인 일관성을 지키면서 중복을 제거하는 좋은 방법이다. 하지만 컴포넌트를 만들다 보면 이런 컴포넌트의 확장이 지나치게 깊어질 때가 있다. 컴포넌트는 깊어지면 깊어질수록 유지보수가 힘들어진다.
ListItem > ListItem1 > ListItem2 > ListItem3 이런 식으로 컴포넌트를 확장했다면 LIstItem3를 ListItem에서 바로 확장해서 사용하는 건 어떨까? 비록 두단계의 중복은 만들어졌지만 컴포넌트가 수직적으로 확장된 것을 막을 수 있다.
만약 기능이 결합되어 있다면 customhook을 이용해 컴포넌트를 수평적으로 확장하는 것도 가능하다.
function ListItem4() {
const listItem1Data = useListItem1();
const listItem2Data = useListItem2();
// 생략
return <ListItem {...} />
}
중복 컴포넌트와 DRY 원칙
DRY(Don't repeat Yourself) 원칙은 모든 개발자가 알고 실천하는 원칙이다. 컴포넌트도 예외는 아니며 중복 컴포넌트를 만드는 것은 지양해야 한다. 하지만 모든 경우에 그 원칙을 적용해야 할까? 내 생각엔 그렇지 않다.
ex)
Foo(`Composition pattern의 ex) 실무에서 사용하기` 예시와 동일한 가정)
- 복잡한 Foo라는 컴포넌트를 만들었다.
- 한 달 후에 Foo 컴포넌트를 재사용할 일이 생겼다. 여유도 없고 데이터 적으로나 UI 적으로나 다른 부분이 없어서 컴포넌트를 그대로 재사용했다.
- 한달후에 Foo 컴포넌트와 유사한 컴포넌트를 만들어야한다. 그런데 이때는 데이터와 UI가 조금씩 다르다.
composition 패턴을 적용할 수도 있지만 경우에 따라 컴포넌트를 복사 붙여넣기하고 필요한 부분만 수정하는 게 더 좋을 수도 있다.
중복 컴포넌트를 만들게 되면 컴포넌트를 만드는 비용이 적게 든다. 신규 개발 일정이 빠듯하다면 이는 굉장한 장점이다.
해당 컴포넌트가 얼마나 어떻게 더 확장될지 모르는 상황에서 해당 요구사항만 가지고 컴포넌트를 만들었다가는 나중에 계속 변경에 대응해야 한다. 이는 확장성 있게 컴포넌트를 만들어도 마찬가지다. 디자이너와 어떤식으로 확장될지 얘기해보는 것도 좋은 방법이지만 안타깝게도 디자이너 역시 모든 것을 예측할 수는 없다. 그렇기 때문에 계속해서 확장되는 컴포넌트를 만들 때는 중복 컴포넌트를 만드는 것도 나쁘지 않은 선택이라고 생각한다.
중복을 언제까지 방치해야 되는가
위에서 중복 컴포넌트를 만드는 게 때로는 더 좋을 수도 있다고 했지만 역시나 장기적으로 봤을 때는 좋지 않다. 해당 컴포넌트가 충분히 여러곳에 쓰여서 안정화되었다는 생각이 들면 그때 중복 제거를 해도 늦지 않는다. 아마 그때는 중복을 제거하기가 어렵지 않을 것이다. 또한 컴포넌트 전체가 변경되는 리팩터링작업이기 때문에 테스트 코드를 작성하고 qa가 필요하다는 것을 팀에게 알리자.
controlled 컴포넌트와 uncontrolled 컴포넌트
정의
controlled 컴포넌트: 외부에서 props를 넘겨서 상위 컴포넌트에서 제어할 수 있는 컴포넌트를 의미한다.
uncontrolled 컴포넌트: 내부에서 상태를 가지고 있어서 제어할 수 없는 컴포넌트를 의미한다.
장단점
controlled 컴포넌트는 외부에서 데이터를 넘길 수 있기 때문에 상위 컴포넌트에서 제어할 수 있는 게 장점이다. 하지만 부모 컴포넌트에서 상태가 변경되면 모든 하위 컴포넌트가 리렌더링 된다.
uncontrolled 컴포넌트는 리렌더링 범위를 줄일 수 있다. 하지만 외부에서 제어하지 못하는 게 단점이다.
두 컴포넌트는 반대되는 컴포넌트 개념이고 각자의 장단점이 뚜렷하다. 두 개의 장점만을 뽑아서 사용할 수는 없을까?
controlled 컴포넌트 보완하기
react-hook-form
react-hook-form에서는 form의 상태를 react state로 관리하지 않는다. 그렇기 때문에 controlled 컴포넌트를 사용할 때도 리렌더링 없이(경우에 따라) 사용할 수 있다.
uncontrolled 컴포넌트 보완하기
state 끌어올리기
react는 데이터가 단방향으로 흐른다. 그렇기 때문에 일반적으로 uncontrolled 컴포넌트를 사용할 때 제어할 수 없는 것이다. 하지만 state 끌어올리기 기법을 이용해서 부모 컴포넌트로 데이터를 끌어올릴 수 있다.
function Component() {
// 생략
return <UnControlledInput onChange={handleChange} />;
}
ps. 이벤트 핸들러가 아니라 useEffect로 state 끌어올리기를 할 수도 있다. 하지만 이런 경우 되도록 useEffect의 사용은 지양하는 게 좋다. (공식 문서 참조)
https://react.dev/learn/removing-effect-dependencies
memozation 사용하고 controlled 컴포넌트로 확장하기
controlled와 uncontrolled 컴포넌트 두 가지를 같이 사용하는 방법도 있다. 그건 바로 memozation을 사용하는 것이다.
memo된 data를 넘기면 해당 컴포넌트에서는 uncontrolled로 동작한다. 그리고 외부에서 데이터를 변경하면 uncontrolled 컴포넌트의 상태도 변경된다.
ex) Tree
트리 컴포넌트에서 트리를 여닫는 행위는 굉장히 자연스러운 행위다. 그렇기 때문에 uncontrolled로 트리 내부 상태를 변경할 수 있게 구현하는 게 자연스러워 보인다.
그런데 만약 트리 외부에서 트리를 모두 열어달라는 요구사항이 들어오면 어떻게 해야 할까? 이럴 때는 tree 상태를 변경시켜서 해결해야한다. 즉, 일반적으로 사용할 때는 uncontrolled로 사용되고 memo된 데이터 자체를 변경하면 해당 상태로 초기 값을 변경하는 형태다.
가볍게 코드로 구현해보면 다음과 같이 작성할 수 있다.
const [treeState, setTreeState] = useState<TreeData<T>>(initialTree);
const handleOpenTree = () => {};
const handleCloseTree = () =>
useEffect(() => {
setTreeState(initialTree);
}, [initialTree]);
디자이너와 프론트 개발자 그리고 컴포넌트
디자이너가 기능과 UI라는 요구사항을 주면 개발자는 그 요구사항을 충족시키는 컴포넌트를 만든다. 즉 컴포넌트는 프론트 개발자 혼자 만드는 것이 아니다. 그렇기 때문에 개발자들끼리 컴포넌트 논의를 하는 것도 좋지만 때로는 디자이너와도 논의를 해야 한다. 특히 디자인 시스템을 만드는 회사라면 더욱더 논의가 활발하게 이루어져야 한다. 가이드라인이 나와 있다고 하더라도 그게 모든 생각을 대변할 수는 없다. 그래서 컴포넌트를 만들 때는 여러 상황들을 가정해 보고 서로 논의하는 시간이 필요하다.
개발하다 보면 컴포넌트화할 수 있는 것들도 많이 보일 것이다. 그럴 때는 디자이너에게 제안하는 것도 좋은 방법이다. 뭔가 이유가 있어서 그럴 수도 있지만 놓치고 있어서 디자인 컴포넌트화하지 못한 경우도 있을 것이다.
이때 주의할 점은 프론트엔드 개발자끼리 소통해서 컴포넌트화해버리면 디자이너의 의도와 다르게 개발이 되고 나중에는 더 큰 후폭풍을 맞을 수 있다. 그렇기 때문에 소통이 정말 중요하다.
다음글
요구사항을 가정하고 input, table, select 컴포넌트를 만들어보고 이를 3편에 올려보겠다. 언제쯤 올릴수있을지는 모르겠다. 빠르면 내년정도??
'기술' 카테고리의 다른 글
3년차 프론트엔드 개발자가 보는 테스트코드(feat. 유의미한 테스트코드) (5) | 2024.09.12 |
---|---|
라이브러리없이 리액트 만들기(클래스형 컴포넌트) (0) | 2022.08.14 |
컴포넌트를 잘 만드는 방법(리액트) (7) | 2022.07.16 |