Home
PostsAbout
react
book

03. 리액트 컨텍스트를 이용한 컴포넌트 상태 공유

다이시 카토의 리액트 훅을 활용한 마이크로 상태 관리 2부

2024-06-06

리액트는 16.3 버전부터 컨텍스트(context)라는 기능을 제공하기 시작했다. 컨텍스트는 딱히 상태와 관련은 없지만 props를 대신해서 컴포넌트 간에 데이터를 전달하는 것이 가능하다. 컨텍스트를 컴포넌트 상태와 결합하면 전역 상태를 제공할 수 있다.

사실 첫 문단부터 스스로에게 충격을 받았습니다. 여태까지 리액트 컨텍스트는 전역 상태를 위해 나온 기능이라고 생각하고 있었기 때문입니다. 하지만 컨텍스트를 전역 상태를 위해 설계된 것이 아니었던 거죠. 컨텍스트를 컴포넌트 상태와 결합하면 그제서야 전역 상태를 제공할 수 있는데, 아무 생각 없이 예제만 따라해봤던 터라, 또는 컨텍스트를 그다지 많이 사용해보지 않았던 터라 이러한 설계의 배경도 알지 못했습니다.

여튼 책에서는 이번 장의 목표는 전역 상태를 위한 컨텍스트 사용에 자신감을 갖게 만드는 것이라고 하며 이번 장을 시작합니다.

useState와 useContext 탐구하기

useContext 없이 useState 사용하기

const Component1 = ({ count, setCount }) => }
	return (
		<div>
			{count}
			<button onClick={() => setCount((c) => c + 1)}>
				Increment Count
			</button>
		</div>
	);
};

const Component1 = ({ count, setCount }) => }
	return (
		<div>
			{count}
			<button onClick={() => setCount((c) => c + 1)}>
				Increment Count
			</button>
		</div>
	);
};

const Parent = ({ count, setCount }) => {
	return (
		<>
			<Component1 count={count} setCount={setCount} />
			<Component2 count={count} setCount={setCount} />
		</>
	);
};

const App = () => {
	const [count, setCount] = useState(0);
	return <Parent count={count} setCount={setCount} />;
};

위 예제에서 Parent 컴포넌트는 count 상태를 알 필요가 없음에도 불구하고 count 상태를 전달받아 이를 자식 컴포넌트로 전달하고 있습니다. 물론 지금처럼 규모가 매우 작은 애플리케이션에서는 문제가 없을 수 있습니다. 하지만 애플리케이션의 규모가 커져서 트리 아래로 props를 깊게 전달해야 한다면 분명 문제가 발생할 수 있습니다. 상태를 알 필요가 없는 컴포넌트가 중간자의 역할로 상태를 가지고 있어야 될 뿐만 아니라, 상태를 가지고 있기 때문에 상태가 변경되면 상태를 전혀 사용하지 않고 있음에도 불구하고 리렌더링될 수 있습니다.

위 예시는 2장에서도 나왔던 예시로 useContext를 사용하지 않았을 때의 단점을 설명하는 예시입니다.

정적 값을 이용해 useContext 사용하기

리액트 컨텍스트는 props를 제거하는 데 유용하다. 컨텍스트를 사용하면 props를 사용하지 않고도 부모 컴포넌트에서 트리 아래에 있는 자식 컴포넌트로 값을 전달하는 것이 가능하다.

앞서 봤던 것처럼 컨텍스트는 전역 상태를 위해 설계된 기능이 아닙니다. 그렇기 때문에 이번 소제목에서는 정적 값을 이용하는 방법을 먼저 소개합니다.

리액트 컨텍스트에는 다양한 값을 제공하는 여러 개의 공급자가 있습니다. 공급자는 중첩될 수 있고, 소비자 컴포넌트(useContext가 있는 컴포넌트를 의미)는 컴포넌트 트리 중에서 가장 가까운 공급자를 선택해 컨텍스트 값을 가져옵니다. 컨텍스트를 소비하는 useContext가 있는 컴포넌트는 오직 하나만 존재하며, 그 컴포넌트는 여러 곳에서 사용될 수 있습니다.

이 점이 책에서 강조하는 내용입니다. 여러 공급자와 소비자 컴포넌트를 재사용하는 것은 리액트 컨텍스트의 중요한 기능이며, 이러한 기능이 중요하지 않은 프로젝트라면 굳이 리액트 컨텍스트를 사용할 필요가 없습니다.

useContext와 함께 useState 사용하기

컴포넌트들은 가장 가까운 공급자로부터 컨텍스트 값을 가져온다. 여러 공급자를 사용해 격리된 카운트 상태를 제공할 수 있고, 이는 다시 한번 말하지만 리액트 컨텍스트를 사용하는 중요한 이유다.

const CountStateContext = createContext({
	count: 0,
	setCount: () => {},
});

useState와 useContext를 함께 사용하기 위해서는 이와 같이 상태 값과 갱신 함수를 모두 가지는 컨텍스트를 정의합니다. 이때 위 예시에서는 정적 count 값과 비어 있는 setCount 함수를 받습니다.

const App = () => {
	const [count, setCount] = useState(0);
	return (
		<CountStateContext.Provider
			value={{ count, setCount }}
		>
			<Parent />
		</CountStateContext.Provider>
	);
};

그리고 이렇게 Provider에 전달하는 컨텍스트 값은 count와 setCount를 포함하는 객체이며 기본값과 동일한 구조를 갖습니다.

const Parent = () => (
	<>
		<Component1 />
		<Component2 />
	</>
);

const Component1 = () => {
	const { count, setCount } = useContext(CountStateContext);
	return (
		<div>
			{count}
			<button onClick={() => setCount((c) => c + 1)}>
				+1
			</button>
		</div>
	);
};

const Component2 = () => {
	const { count, setCount } = useContext(CountStateContext);
	return (
		<div>
			{count}
			<button onClick={() => setCount((c) => c + 2)}>
				+2
			</button>
		</div>
	);
};

컨텍스트를 이용하면 이렇게 Parent 컴포넌트가 상태를 가지고 있지 않으며 props를 전달할 필요도 없습니다. 불필요한 props 전달이 없어지고 코드가 한결 깔끔해진 느낌이 듭니다.

컨텍스트 이해하기

컨텍스트 전파의 작동 방식

컨텍스트 공급자를 사용할 경우 컨텍스트 값을 갱신할 수 있다. 컨텍스트 공급자가 새로운 컨텍스트 값을 받으면 모든 컨텍스트 소비자 컴포넌트가 리렌더링된다. 자식 컴포넌트가 리렌더링되는 이유는 두 가지다. 하나는 부모 컴포넌트 때문이고 또 다른 하나는 컨텍스트 때문이다.

해당 소제목에서는 memo 를 이용해 메모이제이션된 컴포넌트를 만들고 컨텍스트가 변경될 때 리렌더링이 어떻게 발생하는지를 살펴봅니다. 흥미로운 점은 아무리 메모이제이션이 된 컴포넌트라도 내부 컨텍스트 소비자가 리렌더링되는 것을 막지 못한다는 점입니다.

컨텍스트 공급자가 새로운 컨텍스트 값을 갖게 되면 모든 컨텍스트 소비자는 새로운 값을 받고 리렌더링된다는 점을 다시 한 번 짚고, 컨텍스트 전파의 작동 방식과 한계를 이해할 필요가 있겠습니다.

컨텍스트에 객체를 사용할 때의 한계점

컨텍스트 값에 기본값을 사용하는 것을 직관적이지만 객체 값을 사용할 때는 주의가 필요할 수 있다. 객체에는 여러 가지 값을 포함할 수 있으며, 컨텍스트 소비자는 모든 값을 사용하지 않을 수 있다.

컨텍스트의 기본값에 객체를 사용하는 경우가 있을 수 있습니다. 이때 컨텍스트 소비자 컴포넌트가 객체의 일부 프로퍼티만을 사용한다면 어떻게 될까요? 가장 이상적인 경우는 컨텍스트에서 사용 중인 프로퍼티가 업데이트됐을 때에만 리렌더링이 발생하는 것이지만 실제로는 그렇지 않습니다. 소비자 컴포넌트에서 사용 중인 프로퍼티가 아니더라도 업데이트가 발생한다면 리렌더링이 함께 발생합니다. 반드시 알아두어야할 포인트입니다.

책에서는 추가적인 리렌더링이라는 별도의 설명을 덧붙입니다. 저자는 보통 사용자가 추가 리렌더링을 알아차리지 못하는 경우가 많아 성능에 큰 문제가 없다면 대체로 괜찮으며, 오히려 몇 번의 추가 리렌더링을 피하기 위해 오버엔지니어링을 하는 것은 현실적으로 해결할 가치가 없을 수도 있다고 설명합니다.

전역 상태를 위한 컨텍스트 만들기

작은 상태 조각 만들기

합쳐진 큰 객체를 사용하는 대신 각 조각에 대한 컨텍스트와 전역 상태를 만들 수 있다.

전역 상태를 여러 조각으로 나누면 리렌더링 문제를 해결할 수 있습니다. 다음은 코드 예제입니다.

type CountContextType = [
	number,
	Dispatch<SetStateAction<number>>
];

const Count1Context = createContext<CountContextType>([
	0,
	() => {}
]);

const Count2Context = createContext<CountContextType>([
	0,
	() => {}
]);

이렇게 각각의 컨텍스트를 만들 수 있습니다. 그리고 다음과 같이 Provider도 만들 수 있겠죠.

const Count1Provider = ({
	children
}: {
	children: ReactNode
}) => {
	const [count1, setCount1] = useState(0);
	return (
		<Count1Context.Provider value={[count1, setCount1]}>
			{children}
		</Count1Context.Provider>
	);
};

const Count2Provider = ({
	children
}: {
	children: ReactNode
}) => {
	const [count2, setCount2] = useState(0);
	return (
		<Count2Context.Provider value={[count2, setCount2]}>
			{children}
		</Count2Context.Provider>
	);
};

그리고 App 컴포넌트를 다음과 같이 작성합니다.

const App = () => (
	<Count1Provider>
		<Count2Provider>
			<Parent />
		</Count2Provider>
	</Count1Provider>
);

이렇게 되면, 이전 절에서 나왔던 리렌더링 문제가 발생하지 않습니다. 하지만 공급자 컴포넌트가 계속해서 중첩될 위험이 있습니다. 이를 완환하는 방법은 이후 절에 계속됩니다.

useReducer로 하나의 상태를 만들고 여러 개의 컨텍스트로 전파하기

두 번째 해결책은 단일 상태를 만들고 여러 컨텍스트를 사용해 상태 조각을 배포하는 것이다. 이 경우 상태를 갱신하는 함수를 배포하는 것은 별도의 컨텍스트로 해야 한다.

이 절에서는 useReducer를 활용하여 상태 조각을 위한 컨텍스트, 디스패치 함수를 위한 컨텍스트를 생성하는 방법을 소개합니다.

type Action = { type: "INC1" } | { type: "INC2" };

const Count1Context = createContext<number>(0);
const Count2Context = createContext<number>(0);
const DispatchContext = createContext<Dispatch<Action>>(
	() => {}
};

카운트가 더 많을 경우 카운트 컨텍스트를 더 만들어야 하지만 실행 컨텍스트는 하나만 있어도 됩니다. 그럼 이제 Provider 컴포넌트는 어떻게 정의할까요? 여기서 useReducer를 사용합니다.

const Provider = ({ children }: {children: ReactNode }) => {
	const [state, dispatch] = useReducer(
		(
			prev: { count1: number; count2: number },
			action: Action
		) => {
			if (action.type === "INC1") {
				return { ...prev, count1: prev.count1 + 1 };
			}
			if (action.type === "INC2") {
				return { ...prev, count2: prev.count2 + 1 };
			}
			throw new Error("no matching action");
		},
		{
			count1: 0,
			count2: 0,
		}
	);
	
	return (
		<DispatchContext.Provider value={dispatch}>
			<Count1Context.Provider value={state.count1}>
				<Count2Context.Provider value={state.count2}>
					{children}
				</Count2Context.Provider>
			</Count1Context.Provider>
		</DispatchContext.Provider>
	);
};

이렇게 하면 중첩된 공급자가 각 상태 조각과 하나의 실행 함수를 제공합니다. 불필요한 리렌더링 문제가 없어지며 count1이 변경되면 Counter1만 리렌더링되고 Counter2는 영향을 받지 않습니다.

여러 상태를 사용하는 것보다 단일 상태를 사용할 때의 장점은 단일 상태에서는 하나의 액션으로 여러 조각을 갱신할 수 있다는 것입니다.

컨텍스트 사용을 위한 모범 사례

사용자 정의 훅과 공급자 컴포넌트 만들기

이번 소제목에서는 공급자 컴포넌트뿐만 아니라 컨텍스트 값에 접근하기 위한 사용자 정의 훅을 명시적으로 생성합니다.

우선 컨텍스트를 생성합니다. 컨텍스트의 기본값을 null로 주었는데, 이는 기본값을 사용할 수 없고 공급자가 항상 필요하다는 것을 나타냅니다.

type CountContextType = [
	number,
	Dispatch<SetStateAction<number>>
];

const Count1context = createContext<
	CountContextType | null
>(null);

그런 다음 Count1Provider를 정의해서 useState로 상태를 생성하고 Count1Context.Provider에 전달합니다.

export const Count1Provider = ({
	children
}: {
	children: ReactNode
}) => (
	<Count1Context.Provider value={useState(0)}>
		{children}
	</Count1Context.Provider>
);

그 다음에는 Count1Context에서 값을 가져오기 위해 useCount1 훅을 정의합니다.

export const useCount1 = () => {
	const value = useContext(Count1Context);
	if (value === null) throw new Error("Provider missing");
	return value;
};

그리고 이걸 Counter1 컴포넌트에서 사용하면 Counter1은 useCount1 훅에 숨겨진 컨텍스트에 대해서는 알지 못합니다.

const Count1 = () => {
	const [count1, setCount1] = useCount1();
	return (
		...
	);
};

사용자 정의 훅이 있는 팩토리 패턴

이처럼 사용자 정의 훅과 공급자 컴포넌트를 만드는 것은 다소 반복적인 작업입니다. 이를 createStateContext라는 함수를 통해 추상화할 수 있습니다.

createStateContext 함수는 초깃값을 받아 상태를 반환하는 useValue 사용자 정의 훅을 사용합니다. createStateContext 함수는 상태를 가져오는 사용자 정의 훅과 공급자 컴포넌트 튜플을 반환합니다. 또한 공급자 컴포넌트에서는 useValue에 전달되는 선택적인 initialValue를 받는 새로운 기능을 제공합니다. 이를 통해 생성 시 초깃값을 정의하는 대신 런타임에 상태의 초깃값을 설정할 수 있습니다.

const createStateContext = (
	useValue: (init) => State,
) => {
	const StateContext = createContext(null);
	const StateProvider = ({
		initialValue,
		children,
	}) => (
		<StateContext.Provider value={useValue(initialValue)}>
			{children}
		</StateContext.Provider>
	);
	
	const useContextState = () => {
		const value = useContext(StateContext);
		if (value === null) throw new Error("Provider missing");
		return value;
	};
	return [StateProvider, useContextState] as const;
};

이렇게 정의한 createStateContext를 어떻게 사용하는지 살펴보겠습니다. 다음과 같이 사용자 정의 훅을 정의하고 선택적인 init 매개변수를 받습니다.

const useNumberState = (init) => useState(init || 0);

이제 createStateContext에 useNumberState를 전달하면 원하는 만큼 상태 컨텍스트를 만들 수 있습니다.

const [Count1Provider, useCount1] = createStateContext(useNumberState);
const [Count2Provider, useCount2] = createStateContext(useNumberState);

다음은 타입스크립트로 작성한 createStateContext입니다. 타입스크립트는 타입에 대한 추가적인 검사를 제공하며 개발자들은 타입 검사를 통해 더 나은 경험을 얻을 수 있습니다.

const createStateContext = <Value, State>(
	useValue: (init?: Value) => State,
) => {
	const StateContext = createContext<State | null>(null);
	const StateProvider = ({
		initialValue,
		children,
	}: {
		initialValue?: Value;
		children: ReactNode;
	}) => (
		<StateContext.Provider value={useValue(initialValue)}>
			{children}
		</StateContext.Provider>
	);
	
	const useContextState = () => {
		const value = useContext(StateContext);
		if (value === null) throw new Error("Provider missing");
		return value;
	};
	return [StateProvider, useContextState] as const;
};

const useNumberState = (init?: number) => useState(init || 0);

reduceRight를 이용한 공급자 중첩 방치

많은 컨텍스트 공급자가 중첩되면 다음과 같은 코드가 형성됩니다.

const App = () => (
	<Count1Provider initialValue={10}>
		<Count2Provider initialValue={20}>
			<Count3Provider initialValue={30}>
				<Count4Provider initialValue={40}>
					<Count5Provider initialValue={50}>
						<Parent />
					</Count5Provider>
				</Count4Provider>
			</Count3Provider>
		</Count2Provider>
	</Count1Provider>
);

물론 전혀 잘못된 코드는 아니지만, 가독성이 좋지 않아 코딩할 때 불편할 수 있습니다. 이러한 코딩 스타일을 완화하기 위해 reduceRight를 사용할 수 있습니다.

const App = () => {
	const providers = [
		[Count1Provider, { initialValue: 10 }],
		[Count2Provider, { initialValue: 20 }],
		[Count3Provider, { initialValue: 30 }],
		[Count4Provider, { initialValue: 40 }],
		[Count5Provider, { initialValue: 50 }],
	] as const;
	
	return providers.reduceRight(
		(children, [Component, props]) =>
			createElement(Component, props, children),
		<Parent />,
	);
};

정리

리액트 컨텍스트에 대한 오해를 풀고 부족했던 이해를 채울 수 있었던 장이었습니다. 리액트 컨텍스트는 애초에 전역 상태를 위한 설계는 아니었으며 전역 상태로 사용하려면 지역 상태와 조합하여 사용해야 합니다.

컨텍스트를 사용할 때 불필요한 리렌더링이 발생할 위험이 있어 몇 가지 패턴을 확인하였고, 이를 해결할 수 있는 패턴 또한 확인하였습니다.

createStateContext 팩토리 패턴을 만드는 과정은 어려워서 한 번에 읽히지는 않았습니다. 구현 능력을 더욱 키워야겠다는 생각이 들었습니다.