이 글은 Tktodo의 블로그를 번역한 글입니다.
원문 링크: https://tkdodo.eu/blog/component-composition-is-great-btw
Photo by Glen Carrie
제가 처음 리액트를 배웠을 때, 리액트의 모든 장점들에 대해 들었습니다: 가상돔은 매우 빠르고, 단방향 데이터 흐름은 예측 가능하며, JSX는 자바스크립트에 마크업을 넣는 흥미로운 방법입니다.
하지만 리액트의 가장 큰 장점은 시간이 지나면서야 제대로 이해하게 되었습니다: 바로 컴포넌트를 다른 컴포넌트에 함께 구성할 수 있는 능력입니다.
이를 항상 익숙하게 사용했다면 이것이 장점이라는 것을 쉽게 놓칠 수 있습니다. 믿거나 말거나, 약 10년 전만 해도 컴포넌트의 로직, 스타일, 마크업을 하나의 컴포넌트로 묶는 것은 금기시되었습니다.
??? 그럼 관심사 분리는요 ???
네, 여전히 우리는 관심사를 분리합니다, 다만 이전과는 다르게 (아마도 더 나은 방식으로) 분리합니다. 제가 Max의 트윗에서 처음 본 이 그래픽은 이를 매우 잘 요약하고 있습니다:
Separation of concerns still exists, the question is just: where do you separate? Should the boundary really be the programming language we write in?
— Max Stoiber (@mxstbr) May 7, 2018
(attached graphic courtesy of @areaweb) pic.twitter.com/BzxYLyYM6X
모든 것은 코드 응집에 관한 것입니다. 버튼의 스타일들, 버튼이 클릭 되었을 때 일어나는 로직, 그리고 버튼의 마크업은 자연스럽게 함께 모여 하나의 버튼을 형성합니다. 이는 "애플리케이션의 모든 스타일이 여기 하나의 레이어에 있습니다"라는 것보다 훨씬 나은 그룹화입니다.
우리는 "컴포넌트 단위로 생각하는 것"의 진정한 의미를 이해하는 데는 시간이 좀 걸렸고, 아직도 그 경계가 어디인지 찾기가 어렵다고 생각합니다. "새로운" React 문서에는 Thinking in React에 대한 훌륭한 섹션이 있으며, 그들은 항상 첫 단계로 UI를 컴포넌트 계층 구조로 분해해야 한다고 강조합니다.
우리는 이것을 충분히 하지 않는 것 같습니다. 그래서 많은 애플리케이션들이 컴포넌트 구성을 어느 수준에서 멈추고 컴포넌트의 자연스러운 적:조건부 렌더링으로 개발합니다.
조건부 렌더링
JSX 내부에서, 우리는 다른 컴포넌트를 조건부로 렌더링할 수 있습니다. 이것은 새로운 것이 아니며, 그 자체로 나쁘거나 악한 것도 아닙니다. 다음과 같이 쇼핑 목록을 렌더링하고 조건부로 그 목록에 할당된 사람에 대한 사용자 정보를 추가하는 컴포넌트를 고려해봅시다:
export function ShoppingList(props: {
content: ShoppingList
assignee?: User
}) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{props.assignee ? <UserInfo {...props.assignee} /> : null}
{props.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</CardContent>
</Card>
)
}
저는 이것이 완전 괜찮다고 생각합니다. 만약 쇼핑 목록이 아무에게도 할당되지 않았다면, 우리는 그 부분의 렌더링을 그냥 생략하면 됩니다. 그럼 뭐가 문제일까요?
여러 상태를 조건부로 렌더링하기
저는 JSX 내부에서의 조건부 렌더링이 우리가 하나의 컴포넌트에서 다양한 상태를 렌더링하는 데 사용될 때 문제가 되기 시작한다고 생각합니다. 우리가 아래의 컴포넌트를 쿼리에서 쇼핑 목록 데이터를 직접 읽어 자체 포함형(self contained)으로 리팩터링한다고 가정합시다:
(역자 설명) self contained는 하나의 컴포넌트가 JSX, 마크업, 로직을 모두 포함하는 컴포넌트를 뜻하는 것 같습니다.
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
</CardContent>
</Card>
)
}
자체 포함형 컴포넌트는 훌륭합니다. 왜냐하면 애플리케이션에서 그 컴포넌트를 자유롭게 이동할 수 있고, 이 경우, 쿼리를 통해 자신의 요구 사항을 직접 읽기 때문입니다. 이 인라인 조건은 괜찮아 보입니다 (사실 그렇지 않습니다). 왜냐하면 우리는 기본적으로 data
대신 Skeleton
을 렌더링하고자 하기 때문입니다.
컴포넌트 발전시키기
여기서 한 가지 문제는 이 컴포넌트를 발전시키기 어렵다는 것입니다. 예, 우리가 미래를 볼 수는 없지만, 가장 흔히 하는 일(더 많은 기능 추가)을 쉽게 만드는 것은 매우 좋은 아이디어입니다.
그러니 또 다른 상태를 추가해봅시다 - API 호출에서 data
가 반환되지 않으면, 특별한 <EmptyScreen/>
을 렌더링하고 싶습니다. 기존 조건을 변경하는 것은 어렵지 않을 것입니다:
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{data ? (
data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
) : (
<EmptyScreen />
)}
</CardContent>
</Card>
)
}
물론 우리가 방금 야기한 버그 🐞를 빠르게 발견할 것입니다: 이 코드는 대기 중인 상태에서도 <EmptyScreen/>
을 보여줄 것입니다. 왜냐하면 pending
상태에서는 데이터도 없기 때문입니다. 또 다른 조건을 추가하여 쉽게 수정할 수 있습니다:
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{!data && !isPending ? <EmptyScreen /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
</CardContent>
</Card>
)
}
하지만 이것이 여전히 "하나의 컴포넌트"일까요? 읽기 쉽나요? 이 마크업에는 너무 많은 물음표와 느낌표가 있어서 머리가 조금 아픕니다. 인지 부하는 중요한 요소입니다. pending
상태나 empty
상태에서 사용자가 화면에서 무엇을 볼 수 있는지 쉽게 알 수 없습니다. 왜냐하면 먼저 이 모든 조건을 해석해야 하기 때문입니다.
저는 심지어 여기에 또 다른 상태를 추가하는 것에 대해서는 이야기하고 있지도 않습니다. 왜냐하면 우리는 각 단계를 (정신적으로) 거쳐야 하며, 그 새로운 상태에서도 이 부분을 렌더링하고 싶은지 확인해야 하기 때문입니다.
도면으로 돌아가기
이 시점에서, 저는 React 문서의 말을 들어 사용자가 화면에서 실제로 보는 것을 박스로 나누어 볼 것을 제안합니다. 이는 무엇이 충분히 관련되어 별도의 컴포넌트가 될 수 있는지에 대한 단서를 줄 수도 있습니다:
세 가지 상태 모두에서 우리는 공유되는 "레이아웃" - 빨간 부분을 렌더링하고자 합니다. 이것이 우리가 컴포넌트를 처음 만든 이유입니다 - 왜냐하면 우리는 렌더링 할 몇몇 공통 부분을 가지고 있기 때문입니다. 파란 부분은 세 가지 상태 사이에서 다른 것입니다. 그렇다면 동적 자식
을 허용하는 자체 레이아웃 컴포넌트로 빨간 부분을 추출하면 리팩토링은 어떻게 보일까요:
function Layout(props: { children: ReactNode }) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Layout>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{!data && !isPending ? <EmptyScreen /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
</Layout>
)
}
그것은... 혼란스럽습니다. 🫤 우리는 아무것도 달성하지 못한 것 같습니다 - 이것은 실제로 더 나아지지 않았습니다. 우리는 여전히 복잡한 조건부 렌더링을 이전과 같이 가지고 있습니다. 그래서 제가 어디로 가고 있는 걸까요?
조기 반환(Early Returns)의 구원
우리가 왜 처음에 이 모든 조건을 추가했는지 또한 생각해봅시다 🤔. 그것은 우리가 JSX 내부에 있기 때문이며, JSX 내부에서는 문(statement)이 아니라 표현식(expression)만 쓸 수 있기 때문입니다.
하지만 이제, 우리는 더 이상 JSX 내부에 있을 필요가 없습니다. 우리가 가진 유일한 JSX는 <Layout>
에 대한 단일 호출입니다. 우리는 그냥 <Layout>
을 복제하고 조기 반환을 사용할 수 있습니다:
function Layout(props: { children: ReactNode }) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
if (isPending) {
return (
<Layout>
<Skeleton />
</Layout>
)
}
if (!data) {
return (
<Layout>
<EmptyScreen />
</Layout>
)
}
return (
<Layout>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}
조기 반환은 컴포넌트의 컴포넌트의 다양한 상태를 표현하는 데 아주 좋습니다. 왜냐하면 조기반환은 우리를 위해 몇 가지를 달성해줄 수 있기 때문입니다:
인지 부하 감소
조기반환은 개발자가 따라갈 수 있는 명확한 경로를 보여줍니다. 아무것도 중첩되지 않습니다. async/await
처럼, 위에서 아래로 읽을 때 추론하기가 더 쉬워집니다. 반환이 있는 모든 if 문은 사용자가 볼 수 있는 한 가지 상태를 나타냅니다. 우리가 data.assignee
에 대한 확인도 마지막 분기로 이동시켰다는 것을 주목하세요. 그것은 우리가 실제로 UserInfo
를 렌더링하고자 하는 유일한 곳이기 때문입니다. 이전 버전에서는 그것이 명확하지 않았습니다.
쉬운 확장성
이제 오류 처리와 같은 더 많은 조건을 추가해도 다른 상태를 깨뜨릴까 두려워할 필요가 없습니다. 코드에 또 다른 if 문을 추가하는 것만큼 간단해집니다.
더 나은 타입 추론
마지막 data
에 대한 확인이 그냥 사라진 것을 주목하세요. 이는 TypeScript가 우리가 if (!data)
케이스를 처리한 후에는 data
가 정의되어 있어야 한다는 것을 알기 때문입니다. TypeScript는 우리가 단지 조건부로 무언가를 렌더링할 때는 도와줄 수 없습니다.
레이아웃 중복
일부 사람들은 각 분기에서 컴포넌트를 렌더링하는 코드의 중복에 대해 우려할 수 있습니다. 저는 그들이 잘못된 것에 초점을 맞추고 있다고 생각합니다. 중복은 괜찮을 뿐만 아니라, 컴포넌트가 약간의 차별화가 필요한 경우 더 잘 발전하도록 도울 것입니다. 예를 들어, data
에서 title
속성을 상단에 추가해봅시다:
function Layout(props: { children: ReactNode; title?: string }) {
return (
<Card>
<CardHeading>Welcome 👋 {title}</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
if (isPending) {
return (
<Layout>
<Skeleton />
</Layout>
)
}
if (!data) {
return (
<Layout>
<EmptyScreen />
</Layout>
)
}
return (
<Layout title={data.title}>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}
이것은 이전 버전에서 고려해야 할 또 다른 최상위 조건이 되었을 것입니다. Layout
컴포넌트에 더 많은 조건을 추가하는 것은 잘못된 추상화를 나타낼 수 있다는 점에 유의하세요. 이 시점에서는 다시 도면으로 돌아가는 것이 아마도 가장 좋을 것입니다.
오늘 배운 내용
아마도 이 글은 컴포넌트 구성보다 조기 반환에 대한 것일지도 모릅니다. 저는 둘 다에 관한 것이라고 생각합니다. 어떤 경우든, 상호 배타적인 상태에 대해 조건부 렌더링을 피하는 것에 관한 것입니다. 우리는 컴포넌트 조합 없이 그것을 할 수 없습니다. 그러니 도면을 건너뛰지 마세요. 그것은 당신의 가장 친한 친구입니다.
오늘은 여기까지입니다. 질문이 있으시면 트위터에서 저에게 연락하시거나 아래에 댓글을 남겨주세요. ⬇️
'React' 카테고리의 다른 글
React에서 virtual dom을 쓰는 이유 (2) | 2024.10.07 |
---|---|
React 19의 새로운 기능들 (1) | 2024.09.27 |
React Hook을 사용할 때 실수들 (2): useEffect (2) | 2024.09.10 |
React Hook을 사용할 때 실수들 (1): useState (1) | 2024.09.10 |
React Hook 이해하기 (4): useMemo, useCallback (0) | 2024.09.10 |