HuriHuchi
profile

좋은 컴포넌트 설계에 대한 고찰

컴포넌트를 설계하는 것은 회사를 경영하는 것과 유사하다

March 18, 2024

조금 과장을 보태자면, 프론트엔드 개발자는 컴포넌트 설계만 잘해도 밥벌어 먹고산다. 그만큼 복잡한 프론트엔드 애플리케이션에서 컴포넌트를 제대로 설계하는 것은 어렵고도 중요하다. 프로젝트 규모가 커질수록 잘못 설계된 컴포넌트가 발목을 잡기도 하고, 잘 설계된 컴포넌트가 개발 속도를 획기적으로 개선해주기도 한다.

여러 컴포넌트를 개발하며 든 생각이 있다. 바로 컴포넌트를 설계하고 다루는 것은 하나의 회사를 경영하는 것과 유사하다는 것이다. 개발자는 회사의 대표고 컴포넌트는 회사의 직원이다. 대표의 일은 직원들에게 적절한 책임과 권한을 부여하고 효과적으로 협력하게 하여 문제를 해결해내는 것이다. 좋은 직원을 뽑아 제대로 된 구조를 만들어주면 직원은 혼자서 여러 일을 처리하기도 하고, 서로 협력하기도 하며 어려운 문제도 곧잘 해결해낸다. 이번 글에서는 회사 경영에 빗대어 좋은 컴포넌트의 설계에 대해 고민해보겠다.


1. 책임과 권한은 적절히 위임해야 한다.

회사 생활에서 적절한 위임은 중요하다. 회사가 작을 때는 대표가 직원 옆에서 일일이 일을 가르친다. 하지만 회사가 커지고 직원이 늘어나게 되면 대표가 더 이상 일일이 개별 직원의 세부적인 일에 신경을 쓸 시간이 없어진다. 대표는 구체적인 일은 위임하고 더 중요한 일을 고민해야 한다.

컴포넌트의 세계도 유사하다. 모든 비즈니스 로직과 상태를 상위 컴포넌트가 관리하게 되면 복잡도가 증가하고 변경에 취약해진다. 하위 컴포넌트가 자체적으로 처리할 수 있는 상태나 액션은 위임하는 게 좋다. 그리고 상위 컴포넌트는 더 추상적인 레벨의 로직을 고민해야 한다.

아래와 같은 컴포넌트가 있다고 생각해보자. 할일 목록을 보여주는 페이지 컴포넌트다.

export default function TodoPage() {
const [todos, setTodos] = useState<Todo[]>([])

useEffect(() => {
fetchTodos().then((todos) => setTodos(todos))
}, [])

const completeTodo = (id: number) => {
// 완료 처리
}

const deleteTodo = (id: number) => {
// 삭제 처리
}

return (
<div>
<h1>할일 목록</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={completeTodo}>완료</button>
<button onClick={deleteTodo}>삭제</button>
</li>
))}
</ul>
</div>
)
}

할일 목록을 받아와서 화면에 보여주고 있다. 비록 지금은 간단하지만 복잡한 요구사항이 계속 추가되다보면 해당 컴포넌트가 너무 많은 역할을 하게 될 수 있다. 하위 컴포넌트로 위임할 수 있는 부분이 없을까? 생각해보면 TodoPage 컴포넌트의 "주요한" 역할은 전체 화면의 레이아웃을 잡고, 화면의 메타 정보를 적절히 보여주는 것이다. 실제로 할일 목록을 보여주는 일은 하위 컴포넌트로 위임해도 된다. TodoPage에 있던 데이터 페칭 함수와 이벤트 핸들러를 TodoList 컴포넌트로 위임했다.

export default function TodoPage() {
return (
<div>
<h1>할일 목록</h1>
<TodoList />
</div>
)
}

function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])

useEffect(() => {
fetchTodos().then((todos) => setTodos(todos))
}, [])

const completeTodo = (id: number) => {
// 완료 처리
}

const deleteTodo = (id: number) => {
// 삭제 처리
}

return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={completeTodo}>완료</button>
<button onClick={deleteTodo}>삭제</button>
</li>
))}
</ul>
)
}

이제 TodoPage는 해당 페이지의 구조와 적절한 타이틀을 보여주는 본래의 역할만 수행하게 되었다. 이번에는 TodoList가 적절한 역할을 하고 있는지 생각해보자. 너무 많은 일을 하고 있거나 하위 컴포넌트로 위임할 여지가 있다면 그렇게 수정해줘야 한다. TodoList 컴포넌트의 역할은 여러 개의 Todo라는 요소를 나열하는 것이다. TodoList가 궁금한 것은 본인이 그릴 Todo라는 요소이지, Todo 안에서 무슨 일이 일어날 수 있는지가 아니다. 이번에는 아래와 같이 수정해보자.

export default function TodoPage() {
return (
<div>
<h1>할일 목록</h1>
<TodoList />
</div>
)
}

function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])

useEffect(() => {
fetchTodos().then((todos) => setTodos(todos))
}, [])

return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
}

function TodoItem({ todo }: { todo: Todo }) {
const completeTodo = (id: number) => {
// 완료 처리
}

const deleteTodo = (id: number) => {
// 삭제 처리
}
return (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={() => completeTodo(todo.id)}>완료</button>
<button onClick={() => deleteTodo(todo.id)}>삭제</button>
</li>
)
}

TodoItem 이라는 하위 컴포넌트가 생겼다. TodoItem은 개별 할일 목록의 UI와 동작을 자체적으로 결정한다. 비로소 TodoPage > TodoList > TodoItem이 적절한 위임 구조 속에서 유기적으로 동작하게 되었다. 컴포넌트는 자신의 역할에 맞는 로직만 수행한다. 그리고 하위 컴포넌트가 할 수 있는 일은, 그리고 그러는 것이 더 적합한 일은 위임한다. 이를 통해 개별 컴포넌트의 복잡도가 줄어들고 이해하기 쉬워졌다.

여기서 주의할 점은 "위임할 수 있는 일은 무조건 위임한다"라는 명제는 참이 아니라는 사실이다. 상황에 따라 부모 컴포넌트가 상태와 액션을 보유하고 자식 컴포넌트를 pure하게 만드는 것이 유리한 때도 있다. 이 섹션의 핵심은 "컴포넌트의 적절한 역할과 위임에 대해서 항상 고려해보라"는 것에 더 가깝다.

해당 섹션의 주제와 관련해서 좋은 블로그 글을 발견했다. Kent C. Dodds의 State Colocation will make your React app faster 라는 글인데, 관련 있는 코드를 최대한 가까이 두면 성능과 유지 보수에 이점이 있다고 설명한다. 한 번씩 읽어보는 것을 추천한다.


2. 유연한 인재를 채용한다.

회사를 운영할 때는 유연한 협업이 가능하거나, 다양한 상황에 대처할 수 있는 인재를 채용해야 한다. 비즈니스를 하다 보면 매 순간 새로운 일이 생기고 그에 적절히 대처할 수 있는 유연함이 필요하기 때문이다. 만약 구성원이 본인이 맡은 일 밖에 해내지 못한다면, 새로운 일이 생길 때마다 직원을 채용해야 한다. 이는 회사 입장에서 엄청난 비효율이다.

컴포넌트를 설계할 때도 해당 컴포넌트의 유연함 즉, 재사용성 에 대해 고려해야 한다. 컴포넌트가 너무 많은 도메인 정보와 맥락 정보를 가지고 있다면 다른 곳에서 사용되기 어렵다. 유사한 역할을 하는 컴포넌트임에도 맥락이 달라지는 순간 새로운 컴포넌트를 개발해야 하기 때문이다.

이번에는 여러 개의 Article을 보여주는 페이지를 만들어야 된다고 해보자.

export default function ArticlePage() {
return (
<div>
<h1>기사 목록</h1>
<ArticleList />
</div>
)
}

function ArticleList() {
const [articles, setArticles] = useState<Article[]>([])

useEffect(() => {
fetchArticles().then((article) => setArticles(article))
}, [])

return (
<ul>
{articles.map((article) => (
<ArticleItem key={article.id} article={article} />
))}
</ul>
)
}

function ArticleItem({ article }: { article: Article }) {
const handleClickArticle = (id: number) => {
// 상세 페이지로
}
return (
<li key={article.id} onClick={() => handleClick(id)}>
<h2>{article.title}</h2>
<p>{article.description}</p>
</li>
)
}

만들고보니 어딘가 익숙하다. 첫번째 섹션에서 만들었던 TodoPage 와 굉장히 유사하다. 달라진 것이라곤 컴포넌트들의 이름과, fetch하는 데이터, 그리고 약간의 UI 정도다. 이 정도 차이때문에 완전히 새로운 컴포넌트를 구현해야 하는 것이 다소 비효율적으로 느껴진다. 컴포넌트를 만드는 것을 회사에서 사람 한명을 채용하는 것이라고 생각해보자. 정말 이 정도 일로 한 사람을 더 채용할 것인가?

하나의 컴포넌트를 재사용해서 두 가지 상황을 모두 대처할 수 없을지 생각해보자. 그러러면 보다 추상적인 레벨에서 두 페이지의 공통점과 차이점에 대해 생각해보아야 한다. 우선, 두 페이지의 공통점은 다음과 같다.

  • 두 페이지 모두 "여러 개의 동일한 아이템을 나열하고 있다."

위를 달성하는 공통 컴포넌트를 만들어보자. 컴포넌트의 요구사항은 같은 타입을 가진 여러 개의 요소를 나열해주는 것이다.

function ItemList<T extends {}>({ items }: { items: T[] }) {
return (
<ul>
{items.map((item) => (
<아이템 key={item.id}></아이템>
))}
</ul>
)
}

요구사항에 충실해 ItemList라는 컴포넌트를 만들었다. 그런데 문제가 생겼다. ItemList 컴포넌트는 items라는 배열 요소의 타입도 알지 못하고, 아이템이라는 컴포넌트도 알지 못한다. 이대로라면 제대로 된 컴포넌트를 만들 수 없다. ItemList 컴포넌트를 개선하려면 이제 차이점에 대해 살펴보아야 한다.

  1. 두 페이지는 제목이 다르다.
  2. 개별 아이템을 그리는 재료(데이터)가 다르다.
  3. 개별 아이템의 UI 및 이벤트 핸들러가 다르다.

ItemList 컴포넌트는 스스로 모든 차이점들을 대응하지 못한다. 스스로 못하기 때문에 누군가 알려줘야 한다. 먼저, ItemList 를 사용하는 상위 컴포넌트가 필요한 데이터를 넘겨주는 식으로 바꿔보자. 아래처럼 수정하면 차이점 1, 2는 해결이 가능하다.

export default function ArticlePage() {
const [articles, setArticles] = useState<Article[]>([])

useEffect(() => {
fetchArticles().then((article) => setArticles(article))
}, [])

return (
<div>
<h1>기사 목록</h1>
<ItemList items={articles} />
</div>
)
}

그렇다면 차이점 3번은 어떻게 해결할 수 있을까? 개별 아이템을 그리는 방법을 컴포넌트 외부에서 결정하려면 props를 통해 아이템을 렌더하는 함수를 넘겨주면 된다. 다시 ItemList 컴포넌트를 아래처럼 수정해보자.

type Identifier = { id: number }

interface Props<T extends Identifier> {
items: T[]
renderItem: (item: T) => ReactNode
}

function ItemList<T extends Identifier>({ items, renderItem }: Props<T>) {
return <ul>{items.map((item) => renderItem(item))}</ul>
}

주요한 변경점은 renderItem이라는 함수를 props를 통해 전달받는 것이다. renderItem 함수는 마치 ItemList 컴포넌트에게 "어떻게 그릴지는 내가 결정할테니 너는 지금 무슨 item을 그리면 되는지만 알려줘"라고 말하는 것과 같다. 이제 정말로 유연해진 ItemList 컴포넌트로 ArticlePage를 완성해보자.

export default function ArticlePage() {
const [articles, setArticles] = useState<Article[]>([])

useEffect(() => {
fetchArticles().then((article) => setArticles(article))
}, [])

const handleClick = (id: number) => {
// 상세 페이지로 이동
}

return (
<div>
<h1>기사 목록</h1>
<ItemList
items={articles}
renderItem={(article: Article) => (
<li key={article.id} onClick={() => handleClick(id)}>
<h2>{article.title}</h2>
<p>{article.description}</p>
</li>
)}
/>
</div>
)
}

ArticlePage에서 어떤 아이템을 그릴 것인지 props를 통해 알려주고 있다. 그렇다면 TodoPage도 동일한 컴포넌트로 구현할 수 있는지 살펴보자.

export default function TodoPage() {
const [todos, setTodos] = useState<Todo[]>([])

useEffect(() => {
fetchTodos().then((todos) => setTodos(todos))
}, [])

const completeTodo = (id: number) => {
// 완료 처리
}

const deleteTodo = (id: number) => {
// 삭제 처리
}

return (
<div>
<h1>할일 목록</h1>
<ItemList
items={todos}
renderItem={(todo: Todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={completeTodo}>완료</button>
<button onClick={deleteTodo}>삭제</button>
</li>
)}
/>
</div>
)
}

가능하다. 하나의 ItemList 컴포넌트로 두 페이지를 구현했다. 이제 같은 타입을 가진 요소를 나열하는 요구사항이 생긴다면 ItemList 컴포넌트를 사용하면 된다.

사실 위 예제는 너무 간단해서 ItemList 컴포넌트를 공통 컴포넌트로 추출한 것과 하지 않은 것에 큰 차이가 없다. 하지만 만약 ItemList 컴포넌트에 복잡한 스타일링과 로직이 추가된다면 비로소 재사용가능한 컴포넌트의 진가가 발휘될 것이다.


3. 좋은 협업 시스템을 만들어야 한다.

회사는 수많은 사람들의 협업으로 돌아가는 곳이다. 우리는 말로 또는 글로 동료들과 소통한다. 그리고 이 소통하는 방식을 잘 설계하는 것이 무엇보다 중요하다. 문제가 생겼을 때 누구에게, 어떤 식으로, 무엇을 요청할 지 알지 못한다면 문제를 해결하는 데 굉장히 오래걸릴 것이다. 회사를 운영하는 입장에서는 개별 구성원의 역할 그리고 구성원 간에 소통하는 프로토콜을 제대로 설계해야 한다. 그래야 효율적인 협업을 통해 빠르게 문제를 해결할 수 있다.

컴포넌트의 세계에선 인터페이스에 대한 이야기다. 인터페이스는 컴포넌트가 외부 세계와 소통하는 방법이다. 일종의 "사용 설명서" 같은 것이라고 할 수 있다. 컴포넌트의 인터페이스만 보고도 컴포넌트의 역할과 대략적인 동작 방식을 파악할 수 있어야 한다. 이때 인터페이스는 컴포넌트의 이름과 props에 해당한다.

섹션 2에서 살펴보았던 ItemList 를 다시 가져와보자.

interface Props<T> {
items: T[]
renderItem: (item: T) => ReactNode
}

function ItemList<T>({ items, renderItem }: Props<T>) {
return <ul className='flex flex-row'>{items.map((item) => renderItem(item))}</ul>
}

유일하게 수정한 것이 있는데, ul 태그에 flex-row 스타일을 준 것이다. 그로 인해 해당 컴포넌트는 아이템을 수평 방향으로 나열하도록 변경되었다. 약간의 변화지만 원래 ItemList의 동작을 기대하던 사람은 변경된 동작을 전혀 예상할 수 없게 되었다.

<ItemList items={[1, 2, 3]} renderItem={(num) => <li>{num}</li>} />

// 예상
// 1
// 2
// 3

// 실제
// 1 2 3

이것은 좋지 못한 인터페이스의 예시다. 컴포넌트는 인터페이스를 통해 외부 세계와 소통한다. 스스로를 명확하게 드러내지 않으면 제대로 된 소통을 할 수 없게 된다. 이번에는 아래와 같이 수정해보자

interface Props<T> {
items: T[]
renderItem: (item: T) => ReactNode
}

function HorizontalItemList<T>({ items, renderItem }: Props<T>) {
return <ul className='flex flex-row'>{items.map((item) => renderItem(item))}</ul>
}

컴포넌트의 이름을 HorizontalItemList로 변경함으로써 컴포넌트의 동작방식을 보다 명확하게 드러냈다. 이제 컴포넌트를 사용하는 쪽에서 item이 수평 방향으로 나열될 것이라고 기대할 수 있다. 만약, 수평, 수직 방향을 자유롭게 선택하고 싶다면 어떻게 해야할까? 다시 한번 강조하지만, 좋은 협업 시스템을 만드려면 인터페이스를 통해 스스로의 동작 방식을 드러내야 한다.

interface Props<T> {
items: T[]
renderItem: (item: T) => ReactNode
flexDirection: 'row' | 'column'
}

function FlexItemList<T>({ items, renderItem, flexDirection }: Props<T>) {
return <ul className={`flex flex-${flexDirection}`}>{items.map((item) => renderItem(item))}</ul>
}

컴포넌트의 이름에 Flex라는 개념을 노출함으로써 flex 스타일이 적용된 list란 것을 유추할 수 있다. 그리고 props로 flexDirection을 선택할 수 있게 하여 외부에서 컴포넌트의 동작방식을 쉽게 이해할 수 있게 했다.

trade-off

FlexItemList 컴포넌트를 보고 혹자는 이렇게 이야기 할 수도 있다. "그냥 ul의 속성 전체를 props로 받으면 안되나? 그럼 더 유연한 컴포넌트가 될텐데." 물론 가능하다!

interface Props<T> extends HTMLAttributes<HTMLUListElement> {
items: T[]
renderItem: (item: T) => ReactNode
}

function FlexItemList<T>({ items, renderItem, ...props }: Props<T>) {
return (
<ul className={['flex', props.className ?? ''].join(' ')} {...props}>
{items.map((item) => renderItem(item))}
</ul>
)
}

이렇게 변경하면 flexDirection 같은 props를 넘길 필요도 없고 다양한 속성을 추가할 수 있는 유연함도 생긴다. 그런데 이렇게 바꾸는 것이 더 좋은 선택일까? 그건 설계하는 사람이 어떤 의도를 가지고 있는지에 따라 달라진다. ul 태그의 모든 속성을 props로 받으면서 FlexItemList 는 유연해짐과 동시에 예상하기 어려워졌다. 사용되는 곳에 따라 애초에 설계한 의도에서 벗어나는 동작을 할 수 있게 되었다. 의도하지 않은 유연함보다는 적지만 명확한 인터페이스를 부여하는 것이 더 나은 선택일 수도 있다.

이는 마치 동료들에게 '저 개발자에요'라고 말하는 것과, '저는 A 앱을 개발하는 프론트엔드 개발자에요'라고 말하는 것의 차이와 같다. 후자의 경우 동료들의 질문이 예상 가능해진다. A 앱에 이슈가 생겼을 때 찾아올 확률이 높다. 전자의 경우 동료들의 질문이 다소 예상 불가능하다. 데이터베이스부터 인프라, 서버를 가리지 않고 다양한 질문이 들어올 것이다. 그를 의도했다면 상관없다. 하지만 만약 프론트엔드 개발자였다면, 꽤나 곤란한 상황이 생길 수 있다.

다시 한번 말하지만 좋은 인터페이스란 사용하는 쪽에서 그를 보고 컴포넌트의 동작을 파악할 수 있어야 한다. 항상 사용하는 쪽에서 고민해보자.


정리

이번 글에서는 회사 경영이라는 은유를 사용해 좋은 컴포넌트 설계에 대해 생각해보았다. 때로는 완전히 다른 분야에서 좋은 영감을 받기도 한다. 앞으로 컴포넌트의 설계를 고민할 때, 내가 하나의 조직을 운영하고 있고 직원들간의 적절한 협력 구조를 설계하고 있다고 상상해보자. 고민하던 부분에서 참신한 해결책이 떠오를지 모를 일이다. 마지막으로 위 내용을 다시 한번 정리해보자.

1. 책임과 권한은 적절히 위임해야 한다. 컴포넌트의 역할에 대해 생각해보고 다른 컴포넌트가 해당 역할을 맡는 것이 더 적절하지는 않은지 고민해봐야 한다. 그리고 적절한 위임을 통해 개별 컴포넌트의 복잡도를 낮추고 책임과 역할을 명확히 드러낼 수 있는지 생각해보자.

2. 유연한 인재를 채용한다. 하나의 컴포넌트를 재사용할 수 있는지 고민해봐야 한다. 유사한 역할을 하는 컴포넌트가 여러 개가 있다면 그를 하나로 합칠 수는 없을지 생각해보자.

3. 좋은 협업 시스템을 만들어야 한다. 인터페이스는 컴포넌트가 외부 세계와 소통하는 방법이자, 자신의 동작을 설명하는 표현이다. 잘 구성된 인터페이스는 컴포넌트의 내부 구현을 살펴보지 않고도 동작 방식을 쉽게 이해하게 도와주고, 궁극적으로 컴포넌트들 간의 협력에 기여한다.

오늘 소개한 내용은 결코 절대적인 원칙이 아니며 심지어 상황에 따라 틀린 내용이 될 수도 있다. 그저 한 명의 개발자의 생각으로 가볍고 비판적으로 받아들이면 된다. 앞으로도 컴포넌트 설계와 관련해서 생각나는 내용이 있다면 공유해보려고 한다.