상태 끌어올리기의 역설
공유가 필요하다는 까닭만으로 상태를 부모로 올렸을 때 생기는 문제를 객체지향 관점으로 확인하고, FACC(Function As Child Component)로 개선해보았습니다.
로딩중...
오래 걸리지 않기를 바라요..!
공유가 필요하다는 까닭만으로 상태를 부모로 올렸을 때 생기는 문제를 객체지향 관점으로 확인하고, FACC(Function As Child Component)로 개선해보았습니다.
조영호 저자의 『Object』 1장을 읽고, 책에서 배운 객체지향 개념을 리액트에 맞춰 적용해보며 쓴 글입니다. 객체지향의 시각으로 리액트를 해석한 시도인 만큼, 반박 의견은 언제나 환영합니다!
리액트의 데이터는 부모에서 자식으로, 위에서 아래로만 이동한다. 이로 인해서 데이터가 어디에서 만들어져 어디로 전달되는지 추적하기 쉬워진다는 장점이 있지만 실제로 개발하다 보면 컴포넌트별로 공유가 필요한 데이터가 반드시 생기고 단방향으로만 흐르는 리액트의 특성 때문에 부모-자식 관계가 아닌 컴포넌트끼리는 데이터를 직접 주고받을 수 없다. 리액트 공식 문서는 이런 상태 공유 문제를 상태 끌어올리기(Lifting State Up)로 해결하라고 안내하는데, 공유가 필요한 상태를 가장 가까운 공통 부모로 올린 다음 부모가 props로 각 자식에게 내려주는 방식이다.


이미지 출처는 리액트 공식 문서의 컴포넌트 간 State 공유하기 문서다.
하지만 과연 상태를 올리는 선택이 모든 순간에 최선일까?
이번 글에서는 리액트 공식 문서에서 설명하는 상태 끌어올리기가 어떻게 리액트 코드를 복잡하게 만들고 수정하기 어렵게 만드는지를 객체지향 관점으로 확인하고, 문제점마다 찾은 개선 방향을 따라 합성 방식 중 하나인 FACC(Function As Child Component)로 실제로 개선해보는 내용을 담았다.
주문 페이지에 배달 주문과 포장 주문을 전환하는 탭이 있다고 하자. 실제 서비스에서도 흔히 볼 수 있는 UI인데, 아래는 배달의민족 앱의 주문내역 화면이다.

화면을 보면, 선택된 탭이 배달/픽업에서 장보기/쇼핑으로 바뀌면 하단 영역도 선택된 값에 맞추어서 주문 내역을 다르게 보여주고 있다.
탭바는 어떤 아이템이 선택됐는지 알아야 선택된 탭에 강조 표시를 그릴 수 있고 탭 하단의 컨텐츠 영역도 같은 값을 알아야 배달/픽업 리스트를 알 수 있다.
작성할 코드를 미리 간단히 생각해보면 "어떤 아이템을 선택했는가"에 대한 상태를 탭바와 컨텐츠 영역 두 형제 컴포넌트가 함께 사용해야 한다.

형제끼리는 상태를 직접 주고받을 수 없으니, 공식 문서의 안내를 그대로 따르면 선택 상태는 두 컴포넌트의 공통 부모로 올라가게 된다.

공식 문서의 안내대로 선택 상태를 공통 부모인 OrderPage로 올려서 작성해보자.
function OrderPage() {
// 탭의 선택 상태를 탭이 아니라 부모인 OrderPage가 선언한다.
const [activeTab, setActiveTab] = useState("delivery");
return (
<div>
{/* 현재 값과 변경 함수를 props로 내려준다. */}
<TabBar items={TAB_ITEMS} activeTab={activeTab} onChange={(tab) => setActiveTab(tab)} />
{/* 탭바에 내려준 것과 같은 값으로 컨텐츠 영역을 분기한다. */}
<TabContent activeTab={activeTab} />
</div>
);
}
function TabContent(props) {
return (
<>
{props.activeTab === "delivery" && <DeliveryPanel />}
{props.activeTab === "pickup" && <PickupPanel />}
</>
);
}
function TabBar({ items, activeTab, onChange }: TabBarProps) {
return (
<ul role="tablist"> // 깨알 접근성
{items.map((item) => (
<li key={item.id}>
<button
role="tab"
// 어떤 탭이 선택됐는지는 부모가 내려준 값으로만 알 수 있다.
aria-selected={item.id === activeTab}
// 선택을 바꿀 때도 스스로 바꾸지 못하고 부모에게 요청만 한다.
onClick={() => onChange(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
);
}OrderPage가 useState로 선택 상태를 들고 탭바에는 현재 값과 변경 함수를 내려주고 컨텐츠 영역은 같은 값으로 분기한다. 동작에는 아무 문제가 없고 오히려 공식 문서의 그림과 같은 모양이라 잘 만든 코드처럼 보일 수도 있다.
그런데 어느날 "선택이 바뀔 때마다 로그를 남겨달라"는 요구가 들어왔다고 해보자. 위의 코드에서 어느 컴포넌트를 수정해야할까? 분명 탭의 동작에 대한 요구인데 위 구조에서 고쳐야 하는 위치는 TabBar가 아니라 상태를 들고 있는 OrderPage를 수정해야한다. 또한 탭을 쓰는 화면이 여러 곳이었다면 고쳐야 할 부모도 그만큼 늘어날 것이다.
객체지향 설계에서는 객체가 어떤 데이터를 가지느냐보다 객체에 어떤 책임을 할당할 것이냐가 중요하다. "어떤 아이템이 선택됐는가"라는 데이터는 탭이라는 UI의 동작 규칙에서 나온 데이터이므로, 이 데이터를 책임져야할 컴포넌트는 위의 코드와 같은 OrderPage가 아니라 탭 컴포넌트가 되어야 한다.
OrderPage의 책임은 주문이라는 도메인의 화면을 구성하는 일이지, 탭 내부의 선택 흐름을 관리하는 일까지 포함하지 않는다. 그런데 상태를 올리는 순간 부모는 자기 책임이 아닌 데이터를 떠맡게 되고 탭의 입장에서는 자기가 책임져야 할 데이터를 빼앗기게 된다고 볼 수 있다.
적절한 객체에 적절한 책임을 할당하는 것이 객체지향 설계의 목표라면, 맹목적으로 상태 끌어올리기를 하면 각 컴포넌트의 책임이 흐릿해지고 역할이 자연스럽게 많아지게 된다. 각 컴포넌트의 책임 범위가 어디까지인지를 인지하고, 해당 책임이 다른 곳으로 세나가지 않도록 수정해야한다.
밀접하게 연관된 작업만 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 응집도가 높다고 말한다. 응집도가 높으면 하나의 변경 이유에 대응하는 코드가 한 모듈 안에 모이기 때문에 변경이 필요할 때 그 모듈 하나만 수정하면 되지만, 응집도가 낮으면 변경 이유가 서로 다른 코드들이 한 모듈 안에 섞이기 때문에 하나를 고칠 때마다 관련 없는 코드까지 함께 들여다봐야 한다.
또한 한 모듈이 다른 모듈의 내부 사정을 많이 알수록 두 모듈의 결합도가 높다고 말하고, 결합도가 높을수록 함께 변경될 확률이 높아지기 때문에 변경이 어려워진다. 반대로 결합도를 낮추면 한 모듈의 변경이 다른 모듈로 번지지 않으므로 변경의 범위를 좁게 유지할 수 있다. 두 개념은 따로 움직이지 않는데, 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 연관된 데이터와 작업이 한곳에 모여 응집도가 높아지고, 동시에 다른 객체가 내부 사정을 알 필요가 없어지므로 결합도는 자연스럽게 낮아진다.
이런 기준으로 위 코드를 다시 보자. OrderPage는 주문 화면 구성이라는 원래 작업에 더해 탭 상태 관리라는 연관성 없는 작업까지 수행하고 있으므로 응집도가 낮다. 주문 화면의 구성이 바뀌어도 OrderPage를 수정해야 하고 탭의 동작 규칙이 바뀌어도 OrderPage를 수정해야 하니, 수정할 때마다 서로 관련 없는 코드가 한 컴포넌트 안에 들어있다.
반대로 TabBar는 자기 데이터를 하나도 갖지 못한 채 부모가 내려주는 값과 콜백에 전적으로 기대어 동작하므로 부모와 강하게 결합되어 있다. 상태를 올린 구조에서는 탭에 선택 상태가 존재한다는 사실, 상태가 문자열 id로 관리된다는 사실, onChange 콜백으로 상태가 바뀐다는 사실까지 탭 내부의 많은 세부 사항들이 부모에게 노출된다.
OrderPage는 탭의 인터페이스가 아니라 구현에 의존하게 되고 구현에 의존하는 두 코드는 한쪽이 변경되면 다른 쪽도 함께 변경된다. 예를 들어 선택 값을 문자열에서 { id, label } 객체로 바꾸는 작은 결정만 해도, 탭 내부가 아니라 탭을 사용하는 모든 부모의 useState 선언과 분기 코드가 전부 수정 대상이 된다.
즉 우리는 탭의 데이터와 탭의 작업을 탭 안으로 다시 모아 응집도를 높이고, 부모가 탭의 내부 사정을 몰라도 되도록 감출 것은 감추고 밖으로 공개하는 창구를 최소한으로 줄여 결합도를 낮춰야 한다.
프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라 부른다. 데이터와 프로세스가 떨어져 있으면 프로세스는 자신이 소유하지 않은 데이터의 모양을 직접 들여다보며 동작할 수밖에 없고, 데이터를 사용하는 모듈이 늘어날수록 데이터의 모양을 알고 있는 코드도 함께 늘어난다. 그래서 절차적으로 작성된 코드에서는 데이터의 작은 변경 하나가 데이터를 사용하는 모든 모듈로 퍼져나가고, 변경의 영향을 지역적으로 고립시키기 어려워진다.
위 코드에서 선택 상태라는 데이터는 OrderPage에 있고 상태를 읽어 강조 표시를 그리고 클릭에 반응해 상태를 바꾸는 프로세스는 TabBar에 있으므로, 데이터와 프로세스가 서로 다른 컴포넌트로 갈라져 있다. TabBar는 받은 값을 표시하고 "바꿔달라"고 요청만 할 뿐 스스로는 아무것도 결정하지 못하는 수동적인 존재가 되고 데이터를 필요한 곳에서 가져와 필요한 대로 사용하는 절차지향 코드와 같은 모양이 된다. 실제로 선택 상태의 모양이나 규칙이 바뀌면 상태를 선언한 부모, 상태를 그리는 탭바, 상태로 분기하는 컨텐츠 영역까지 흩어져 있는 모든 사용처가 함께 영향을 받는다. 변경의 영향을 한곳에 가두려면 데이터와, 데이터를 다루는 프로세스를 같은 모듈 안에 두어야 한다.
상태 없는 TabBar를 다른 화면에서 다시 쓰려면, 사용하는 화면마다 부모가 useState를 선언하고 activeTab과 onChange를 올바르게 연결하고 같은 값으로 컨텐츠 분기까지 작성해줘야 한다. 재사용성이 좋기 위해서는 가져다 쓰는 쪽이 내부 규칙을 몰라도 되는 상태여야 한다. 상태가 없는 TabBar를 사용하는 쪽이 매번 올바른 자료구조의 상태를 직접 정의하고, 올바르게 내려줘야만 동작한다. 이렇게 어렵게 재사용을 해서 사용처가 늘어날수록 같은 상태 관리 하는 코드가 복제되고 복제된 코드 하나하나가 휴먼 에러를 범할 수 있는 위험한 코드가 된다.
객체지향에서는 소프트웨어의 객체를 현실의 수동적인 사물이 아니라 스스로 판단하고 스스로 행동하는 능동적인 존재로 바라보는데, 이렇게 사물에 사람의 성질을 부여하는 관점을 의인화라 부른다. 현실의 탭은 사람이 눌러줘야만 바뀌는 수동적인 그림이지만, 소프트웨어 세계의 탭 컴포넌트는 자신의 선택 상태를 스스로 기억하고 클릭에 반응해 스스로 갱신하도록 만들 수 있다.
그래서 우리는 컴포넌트를 자율적인 존재로 만드는 방향으로 개선해야 한다. 자율적인 존재란 자신에게 할당된 책임을 자신의 데이터와 자신의 행동으로 스스로 수행하는 객체를 말한다. 앞에서 정리한 개선 방향을 모으면 탭 컴포넌트는 어떤 아이템이 선택됐는지 스스로 기억하고, 클릭이 일어나면 선택을 스스로 갱신하고, 선택 값이 필요한 쪽에는 결과만 알려주는 책임을 가져야 한다.
그런데 단순히 선택 상태를 탭이 소유하면 그 상태를 공유하기가 어려워진다. 리액트의 데이터는 위에서 아래로만 흐르는데, 선택 값을 탭이 소유해버리면 형제인 컨텐츠 영역은 선택 값을 어떻게 읽어야 할까?
리액트는 children으로 컴포넌트를 조립하는 합성을 지원하는데, children을 일반 노드가 아니라 함수로 받으면 탭이 상태를 소유한 채로 함수의 인자에 실어 필요한 값만 바깥에 건네줄 수 있다.
children을 함수로 받는 컴포넌트를 FACC(Function As Child Component)라 부른다. 이런 이름은 Merrick Christensen이 2016년에 쓴 Function as Child Components라는 글에서 널리 알려졌고, 리액트 공식 문서도 children을 함수로 받는 비슷한 패턴을 Render Props 문서에서 함께 다루고 있다.
https://reactpatterns.js.org/docs/function-as-child-component/
탭이 선택 상태를 자기 안에서 소유하고 함께 그려야 하는 컨텐츠는 children 함수의 인자로 선택 값을 전달받아 그리도록 바꿔보자.
interface TabsProps {
items: TabItem[];
defaultTab?: string;
// children을 ReactNode가 아니라 선택 값을 인자로 받는 함수로 받는다.
children: (activeTab: string) => ReactNode;
}
function Tabs({ items, defaultTab, children }: TabsProps) {
// 선택 상태를 탭이 직접 소유한다.
const [activeTab, setActiveTab] = useState(defaultTab ?? items[0].id);
return (
<div>
<ul role="tablist">
{items.map((item) => (
<li key={item.id}>
<button
role="tab"
aria-selected={item.id === activeTab}
// 선택 변경도 부모에게 요청하지 않고 탭 내부에서 스스로 처리한다.
onClick={() => setActiveTab(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
{/* 선택 값이 밖으로 나가는 통로는 children 함수의 인자 하나뿐이다. */}
{children(activeTab)}
</div>
);
}사용하는 쪽은 선택 값을 인자로 받아 무엇을 그릴지만 결정하면 된다.
function OrderPage() {
return (
<Tabs items={TAB_ITEMS}>
{/* 선택 상태를 소유하지 않고, 인자로 받은 값으로 그릴 것만 결정한다. */}
{(activeTab) => (
<>
{activeTab === "delivery" && <DeliveryPanel />}
{activeTab === "pickup" && <PickupPanel />}
</>
)}
</Tabs>
);
}선택 상태는 Tabs 내부에만 존재하고 밖으로 나가는 통로는 children 함수의 인자 하나뿐이다. 상태를 부모로 올리지 않았는데도 탭바와 컨텐츠가 같은 선택 값을 함께 사용하고 있고 OrderPage는 선택 값을 받아 무엇을 그릴지만 결정하면 된다.
먼저 선택 상태가 원래 주인인 탭에게 돌아갔고 OrderPage에는 주문 화면 구성이라는 자기 책임만 남았다. 선택 상태라는 데이터와, 상태를 그리고 바꾸는 프로세스가 Tabs라는 하나의 컴포넌트 안에 모였으므로 절차적이던 구조가 해소되고 응집도가 올라갔다. 탭은 자기 데이터를 스스로 책임지는 자율적인 컴포넌트가 됐고 "탭이 알아서 선택을 관리한다"는 우리의 예상대로 동작하므로 Tabs의 시그니처만 읽어도 탭의 동작 전체가 이해된다.
이렇게 상태가 어디에 어떻게 저장되고 어떤 방식으로 바뀌는지를 전부 Tabs 내부에 감추고 밖으로는 children 함수의 인자라는 소통 창구만 공개하는 방식을 캡슐화라 부른다. 캡슐화의 목적은 내부에 접근하는 것을 제한해서 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 만드는 데 있다. 캡슐화 덕분에 OrderPage는 더 이상 탭의 구현에 의존하지 않고 인터페이스에만 의존하므로, 탭 내부가 어떻게 바뀌어도 인자로 넘어오는 값의 약속만 지켜지면 함께 변경될 필요가 없다. 결합도가 내려간 만큼 변경도 지역적으로 고립된다. 도입에서 부모를 전부 찾아다니게 만들었던 로그 요구가 다시 들어와도 이제 수정 위치는 Tabs 내부 한 곳이고 탭을 사용하는 10곳의 화면은 전혀 코드 변경이 필요하지 않다.
재사용성 또한 개선됐다. 사용하는 쪽은 useState를 선언할 필요도, onChange를 연결할 필요도 없이 아이템 목록을 넘기고 선택 값으로 무엇을 그릴지만 결정하면 되므로, 어느 화면에 가져다 놓아도 같은 방식으로 동작한다. 부품을 가져다 쓸 때마다 내부 규칙을 다시 알아야 했던 컴포넌트가, 이제는 그냥 가져다 쓰면 되는 컴포넌트가 됐다.
공유가 필요하다는 까닭만으로 상태를 위로 올리면, 자기 책임이 아닌 데이터를 부모가 떠맡으면서 컴포넌트마다 책임이 흐려지고 연관된 데이터와 프로세스가 갈라지면서 응집도는 낮아지고 결합도는 높아진다. 이렇게 흐트러진 구조는 요구사항이 바뀔 때마다 수정 범위가 넓어지는 변경 비용으로 돌아올 것이다. 우리가 작성한 코드는 언젠가 반드시 수정될 것이기에, 시간이 지날수록 변경을 받아들이기 어려운 코드가 되어갈 것이다. 그래서 우리는 컴포넌트를 부모가 시키는 대로 그리기만 하는 수동적인 부품이 아니라 자신의 데이터를 스스로 책임지는 자율적인 객체로 바라보고 상태를 올리기 전에 지금 올리려는 상태의 책임은 누구에게 있는지를 먼저 생각해볼 필요가 있다.
상태를 가지고 있어야 하는 책임을 가지는 컴포넌트가 하위 컴포넌트라면, 형제와 상태를 공유해야 한다는 요구 때문에 소유까지 부모에게 넘길 필요는 없다. children을 함수로 받는 FACC를 사용하면 상태를 책임질 컴포넌트가 소유를 그대로 유지한 채 바깥에는 필요한 값만 건네주므로, 책임 할당과 상태 공유라는 두 요구를 함께 만족시키도록 수정할 수 있다.
하지만 모든 경우에 FACC가 상태를 올리지 않고 공유하는 최선의 방법이 되지는 않는다. 트리에서 너무 멀리 떨어진 컴포넌트와 공유해야 하는 상태, 화면 곳곳에서 전역으로 사용되어야 하는 데이터, 원본이 서버에 있는 상태, URL 쿼리와 동기화되어야 하는 데이터와 같은 경우에는 상태의 책임을 하위 컴포넌트 하나로 정하기 어려우므로, 소유를 유지하는 방식이 아닌 각 상황에 맞는 다른 전략이 필요하다.