React 가 대중화된 이후로 React 의 옆에는 항상 전역 상태 관리 라이브러리가 따라다녔습니다. React 의 초기부터 존재하던 Flummox 부터, 이후 몇 년간 리액트 생태계를 지배했던 Redux, 그리고 그 시절 3대장 MobX 와 Recoil, 그리고 최근 유행하는 Zustand 와 Jotai, 그리고 그 외 Sangte, XState, Valtio...
라이브러리와 자세한 사용 방식은 계속 변했지만 주된 이유는 항상 아래와 같았는데요,
그런데 단점은 없을까요? 과연 상태를 변경하는 함수를 prop 으로 내려주는 것보다 전역 상태 관리 도구를 이용하는 게 나을까요? prop drilling 은 정말 나쁜 걸까요? 불필요한 리렌더는 정말 막아야 하는 걸까요?
저는 3년 반 전에 처음으로 리액트 생태계에 redux
로 입문한 후로 1년간은 리덕스만 이용했고, 이후 1년 반 정도는 redux
와 react-query
를 섞어서 이용했고, 지금은 오로지 react-query
만 이용하고 있는데요, 과거보다 훨씬 개발 속도가 빨라졌고 안정성도 높아졌습니다.
이번 글에서는 전역 상태 관리 도구보다 useState
, useReducer
, props
등을 사용하는 게 더 좋을 수 있다는 이야기를 해 보려 합니다.
투두 리스트 어플리케이션에서 현재 조회 중인 투두 아이템 정보를 전역 상태에 저장한다고 해 보겠습니다. 투두 조회 컴포넌트는 아래와 같이 생겼을 것입니다.
import { useSelector } from 'react-redux';
const TodoDetail = () => {
const todo = useSelector((store) => store.todo);
// ...
예시로는 redux 를 들었지만, 사실 redux 인지 아닌지는 중요하지 않습니다. 아무튼 이 컴포넌트는 아래의 지식에 의존합니다.
누군가 전역 상태에 todo 데이터를 넣어뒀다
그 "누군가"는 아마도 부모의 누군가일 것입니다.
const TodoPage = ({ id }: Props) => {
const dispatch = useDispatch();
useEffect(() => {
let ignore = false;
getTodo(id).then((todo) => dispatch({ type: 'setTodo', todo }));
// ...
이 상황 자체가 문제입니다. 자식 컴포넌트는 누가 넣어줬는지도 모를 전역 상태를 이용하고, 부모 컴포넌트는 누가 쓸지도 모르면서 전역 상태에 todo 를 넣어둡니다.
나중에 기획이 변경되어서 자식 컴포넌트가 todo 객체가 필요없어져서 지웠을 때, 부모 컴포넌트는 "내가 이제 todo 객체를 안 넣어줘도 된다" 라는 걸 알 수 있을까요? 자연스럽게 알 수 있기엔 두 컴포넌트의 거리가 너무 멀고, 개발자가 직접 한땀한땀 todo
라는 상태의 사용처를 찾아다녀야 합니다.
반면 props 를 이용하면 이 문제는 사라집니다. props 는 누가 누구에게 데이터를 전달하는지 훌륭하게 명시합니다. 평범하게 구현했다면 props 에 있는 데이터는 반드시 부모가 넘겨준 데이터입니다.
프롭을 이용하지 않아도 되게 되어 사용처를 지웠을 때 우리가 해야 하는 일은 단순히 빨간 줄을 따라 부모로 올라가면서 drilling 된 데이터를 지우고, 지우고, 지우고, ..., 그리고 끝에는 데이터를 처음 가지고 있던 부모가 더 이상 데이터를 페치하지 않도록 지워버리면 됩니다.
위에서 만들었던 TodoDetail
코드를 다시 한 번 볼까요?
import { useSelector } from 'react-redux';
const TodoDetail = () => {
const todo = useSelector((store) => store.todo);
const [isCollapsed, toggleCollapse] = useReducer((c) => !c, false);
if (!todo) return;
return //...
}
이 코드에서 보이는 TodoDetail
컴포넌트의 역할은 다음과 같습니다.
todo
라는 값을 가져옵니다.todo
가 없을 경우에는 null을 반환합니다. (일반적으로 서버 데이터는 api 요청이 완료되기 전까진 없으므로 이런 처리가 필요합니다)너무 난잡합니다. 만약 나중에 모종의 이유로 todo
데이터를 해당 전역 상태에 못 넣어두는 일이 일어나면 어떻게 될까요? 가령 현재 todo 가 보이면서 동시에 다음 todo가 옆에 흐리게 떠야 하는 ui라서, 현재 todo 데이터가 남아 있어야 하기 때문에 store.todo
자리를 쓸 수 없다거나요. 그러면 코드를 복사해서 이런 걸 만들어야 할까요?
import { useSelector } from 'react-redux';
const NextTodoDetail = () => {
const todo = useSelector((store) => store.nextTodo);
const [isCollapsed, toggleCollapse] = useReducer((c) => !c, false);
if (!todo) return;
return //...
}
보기에도 이상하고, DRY 도 위반하고, 여러모로 좋지 않은 코드입니다.
const TodoDetail = ({ todo }: { todo: Todo }) => {
const [isCollapsed, toggleCollapse] = useReducer((c) => !c, false);
return //...
}
짜잔, 어떤 상황이든 아무 관계 없이 편하게 재사용할 수 있는 코드입니다. 우리는 아무 제약 없이 todo 객체만 있다면 TodoDetail
컴포넌트를 호출해서 이용할 수 있습니다.
전역 상태의 타입은 프로젝트의 어떤 곳에서 봐도 하나뿐입니다. 가장 간단한 예로, todo 를 페치해서 전역 상태에 집어넣는 경우 초기에는 todo 데이터가 없었을 것입니다. 따라서 todo의 타입은 아래와 같이 정의되어야 합니다.
type TodoState = {
todo: Todo | undefined;
}
이러면 받는 쪽에서는 어떨까요? 부모 컴포넌트에서 이미 로딩 처리를 해 줬기 때문에 TodoDetail
컴포넌트가 마운트된 시점에 이미 todo
데이터가 있어야 한다고 해 보겠습니다.
import { useSelector } from 'react-redux';
const TodoDetail = () => {
const todo = useSelector((store) => store.todo);
return <div>{todo.title}</div> // TSError: todo 는 undefined 일 수 있습니다.
개발자에게는 몇 가지 선택지가 있습니다.
// 타입 가드
// "아니 todo 가 undefined 일 리가 없는데 왜 이래야 돼?"
if (!todo) return;
// 타입 단언
// 매우 찝찝한 방식. 추후 Todo 가 실제로 undefined 일 수 있게 된다면?
const todo = useSelector((store) => store.todo) as Todo;
어느 쪽이든 그리 좋아 보이진 않습니다.
반면 props 의 장점 중 하나가 타입이 좁혀진 채로 전달된다는 것입니다.
const TodoPage = () => {
// ...
return todo ? <TodoDetail todo={todo} /> : 'loading...';
}
const TodoDetail = ({ todo }: { todo: Todo }) => {
// 짜잔, undefined 일 수가 없습니다.
return <div>{todo.title}</div>
}
그리고 자식 컴포넌트는 "실제로 내가 받을 값"에만 집중하면 됩니다.
이번에는 투두 상세를 보여주는 모달이 있다고 생각해 보겠습니다.
const TodoPage = () => {
const { isOpen } = useSelector((store) => store.modal);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch({ type: 'openModal' })}>열기</button>
{isOpen && <TodoModal />}
</div>
);
};
const TodoModal = () => {
const dispatch = useDispatch();
return <Modal onClose={() => dispatch({ type: 'closeModal' })} />;
};
분명 TodoModal
컴포넌트가 쓰는 기능은 모달을 닫는 것 뿐입니다. 하지만 지금 구조에서 TodoModal
컴포넌트는 모달을 닫을 수도 있고, type 에 openModal
을 넣어주면 모달을 열 수도 있습니다. 이렇게 컴포넌트가 너무 많은 기능을 가지고 있다면 개발자가 실수해도 에디터가 오류를 알려주지 않기 때문에 관리가 어려워집니다.
const TodoPage = () => {
const { isOpen } = useSelector((store) => store.modal);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch({ type: 'openModal' })}>열기</button>
{isOpen && <TodoModal onClose={() => dispatch({ type: 'closeModal' })} />}
</div>
);
};
const TodoModal = ({ onClose }: { onClose: () => void }) => {
return <Modal onClose={onClose} />;
};
props 였다면 문제가 사라집니다. TodoPage 에서도 로컬 상태로 모달을 관리해도 되는데, 이건 이번 섹션에서 말하고자 하는 내용은 아닙니다. TodoModal
입장에서 이제 이 컴포넌트는 onClose
만 할 수 있습니다. 핸들러의 타입이 좁아졌기에 할 수 있는 게 명확해진 것입니다.
일반적으로 전역 상태를 이용할 경우 페이지 이동 시 상태가 초기화되어야 하는데요, 이를 위해 이런 코드가 작성되곤 합니다.
useEffect(() => {
return () => dispatch({ type: 'clearTodos' });
}, []);
하지만 useEffect는 원래 철학상 여러 번 수행될 수 있기에 의미적으로도 이상하고, 그냥 이런 코드를 추가적으로 짜 줘야 한다는 것부터 이상합니다.
const [state, setState] = useState();
로컬 상태는 컴포넌트가 언마운트되면 자동으로 사라집니다. 문제 해결!
전역 상태를 이용하면 모든 컴포넌트가 모든 전역 상태에 접근할 수 있게 됩니다. 라이브러리와 무관하게, 항상 그럴 수밖에 없습니다. 그게 전역 상태 관리의 정의이니까요. 당장 접근하려면 추가적인 import
문 등이 필요하더라도, 파일 자체에는 모든 전역 상태에 접근하는 권한이 있는 것입니다.
이 때문에 누군가의 실수로 다른 컴포넌트에 영향이 생길 수 있고, 이로 인해 의도치 않은 버그가 발생할 수 있습니다.
로컬 상태를 이용하면서 애초에 권한이 있는 핸들러를 prop 으로 넘기지 않았다면 상위 컴포넌트 / 형제 컴포넌트의 상태에 접근하는 게 아예 불가능합니다. 4번 항목과 연결하여, props 를 이용했을 때는 자식 컴포넌트가 정해진 일만 할 수 있고 월권하지 못하도록 제한을 걸어버리는 것이 가능합니다.
"로컬 상태를 이용했어도 실수하면 똑같지 않나?" 라는 생각이 들 수 있지만, 개발팀은 CODEOWNER를 보통 파일/폴더 단위로 관리한다는 것을 생각하면 좋을 것 같습니다. 다른 사람이 owner 인 파일을 건드리는 실수와 내가 owner인 파일을 건드리는 실수는 일어날 가능성에 큰 차이가 있습니다.
아무튼 "실수하지 않으면 된다" 보다는 "실수할 수 없게 하는 것" 이 더 낫기에, 이런 상황 역시 로컬 상태가 더 우수합니다.
Context API로 전역상태관리를 흉내내는 나쁜 방식을 제외한다면, 전역 상태관리는 반드시 라이브러리를 써야만 적용할 수 있습니다. 그리고 상당히 많은 로직이 상태를 관리하는 데에 쓰이기에 코드 전체가 상태관리 라이브러리에 상당히 의존하게 되는데요, 이는 나중에 상태관리 라이브러리를 변경해야 할 때 치명적으로 다가옵니다.
상태관리 라이브러리를 변경하지 않으면 된다고 생각할 수 있지만, 생각보다 개발자들은 유행에 민감하고 항상 더 좋은 도구를 원하기 때문에 그러기는 쉽지 않습니다. 더해서 개발자들의 의지가 아니어도 라이브러리 버전 충돌 문제나 지원 중단 등 예상치 못한 외부 요인은 항상 발생합니다.
전역 상태 관리 라이브러리의 장점으로 뽑히는 게 "불필요한 리렌더를 막는다" 가 있습니다. 분명 맞는 말이고 전역 상태 관리 라이브러리를 이용하면 성능상 이점이 있습니다.
하지만 불필요한 리렌더링을 해도 어차피 유저가 체감할 수 있을 만큼 성능이 나빠지지 않습니다. 생각보다 불필요한 리렌더링은 그냥 "해도 괜찮은" 수준입니다. 그에 반해 전역 상태 관리 라이브러리가 가지는 단점은 명확합니다.
개인적인 의견으로는, 이 정도의 성능 차이를 챙겨야 하는 상황이라면 차라리 리액트를 사용하지 않는 게 좋을 수 있습니다.
prop drilling 은 알려진 것만큼 심각한 문제가 아닙니다.
전역 상태가 디버깅하기 어려운 버그를 낳고 생산성을 떨어트리고 레거시를 생산하는 것에 비해, prop drilling 은 그냥 "귀찮다" 정도의 수준입니다.
전역 상태를 이용해서 생긴 레거시 코드는 고치기 어렵지만, prop drilling 때문에 생긴 문제는 그냥 귀찮지만 파일 4개 바꿔주면 해결됩니다.
예전에는 이 문제가 심각할 수 있었지만 요즘처럼 TypeScript 가 보편화된 상황에서 이건 전혀 문제가 되지 않습니다. 개발자가 실수했다면 TypeScript가 모두 알려줄 테니까요!
만약 2048 같은 게임이나 노션같은 복잡한 어플리케이션을 만든다면 어떨까요? 먼 곳에 있는 컴포넌트들이 서로 영향을 미쳐야 하고, 그러면서도 성능이 중요한 상황이라면요. 이런 상황에는 위에서 설명한 단점들보다 전역 상태 관리 도구가 가져다주는 이점이 클 수 있습니다.
지역 상태를 이용하면 api 응답을 캐싱할 수 없고 따라서 불필요한 호출이 많이 발생할 수 있습니다. 이걸 해결하고자 모든 api 응답을 최상단에서 하는 것은 DX나 colocation 측면에서 좋지 않습니다.
이런 문제 때문에, 저 역시도 @tanstack/react-query
와 같은 서버 상태 관리 라이브러리는 이용하는 편입니다. 이런 라이브러리는 컴포넌트에게 권한이 많이 주어지지 않고 일반적인 경우 queryFn
을 통해서만 값이 설정되기에 앞에서 말한 전역 상태 관리의 문제들이 훨씬 약하게 다가옵니다.
최근에는 next js app router 에서 react server component 를 사용할 수 있게 지원하는데요, 이게 하나의 게임 체인저가 되어 @tanstack/react-query
같은 라이브러리조차 필요하지 않게 될 수도 있지 않을까 기대합니다.
전역 상태 관리 도구는 매우 보편적으로 이용되어 왔지만, 치명적인 단점들이 분명 존재합니다. 코드의 결합도를 높이고, 변경하기 어려운 코드를 낳고, 버그가 생기기 쉽게 만들고, 타입을 좁혀주지 않아 개발 생산성을 떨어트립니다. 최근 프론트엔드 트렌드에서는 이런 리스크를 감수하면서까지 전역 상태가 필요한 경우는 많지 않아 보입니다.
물론 전역 상태 관리라는 것 자체가 나쁘기만 한 것은 아니기에 전역 상태 관리 도구가 어울리는 기능도 있습니다. 이런 경우에는 좋은 패턴으로 전역 상태 관리 도구를 잘 이용하는 게 좋겠습니다.
도구를 적절하게 활용하되, 항상 도구를 비판적으로 바라보며 장단점을 인지하고 개발을 하면 더 편하고 재미있게 개발할 수 있을 것입니다.
코드의 결합도를 줄여라
부끄럼쟁이 코드를 작성하라. 즉, 불필요한 것은 다른 모듈에 보여 주지 않으며, 다른 모듈의 구현에 의존하지 않는 코드를 작성하라.
[실용주의 프로그래머] 61p 중
흥미롭게 읽었습니다. 좋은 내용 감사합니다!
한 가지 궁금한 게 있어 답변 부탁드립니다.
부모 컴포넌트의 상태를 여러 개의 자식 컴포넌트가 읽을 수 있도록 Context API를 사용하는 건데 왜 전역 상태를 관리하기 위해 Context API를 사용하는 건 왜 나쁜 예인지 알려주실 수 있을까요?
그리고 전역 상태 관리를 "흉내낸다"고 표현하신 이유도 알고 싶습니다. 😁
공부하는데 많은 도움이 되었습니다
react-query 가 무엇인지 궁금증이 생겨서 검색 도중 백엔드와의 데이터 통신을 효율적으로 도와주는 라이브러리라고 알게 되었습니다 근데 만약 리액트에서 로그인 정보를 받아와서 세션관리 하는경우 Query Client로 전역설정을 하는것으로 알고 있는데 전역상태관리를 사용하지 않고 react-query 만 사용하신다는 말씀은 어떻게 사용하시는지가 궁금합니다
안녕하세요! 우선 기술해주신 내용 정말 잘 읽었습니다
내용중에 "Context API로 전역을 흉내내는 나쁜 방식" 이라고 작성해주셨는데,
해당 내용에 대한 이유가 궁금합니다!
저도 최근 고민중인 내용이었는데 좋은 글 감사합니다!