요즘에 개발 책을 보거나 면접을 보면서 느끼는 건데 개발자는 왜 라는 질문을 참 많이 해야하는거 같습니다. '왜 해당 전역 상태관리 라이브러리를 사용했나요?' 라고 물었을 때 자신있게 답해야 할 줄 알아야 합니다. 사실 저는 그런면에 있어서 소홀했던건 사실입니다. 남들 다 쓰는거, 제일 유명한거 가져다가 썼었는데 이제부터라도 제대로 알고 저 스스로 납득한 다음에 사용해야 되지 않을까 생각합니다.
그래서 이번 시간에는 Context API에 대해 좀 더 자세히 알아보고 왜 Context API 대신 Redux나 Recoil 같은 전역 상태 라이브러리를 많이 사용하는지 알아보도록 하겠습니다.
Context lets the parent component make some information available to any component in the tree below it—no matter how deep—without passing it explicitly through props. - 리액트 공식문서
사실 공식문서만 보더라도 Context API의 존재 이유에 대해 잘 나와있습니다.
일반적으로 부모 컴포넌트에서 자식 컴포넌트에게 props를 통해 정보를 전달합니다. 하지만 중간에 여러 컴포넌트를 거쳐야 하거나 애플리케이션의 여러 컴포넌트에서 동일한 정보를 필요로 하는 경우에는 props로만 해결하기에는 정말 불편할 것입니다. 계속 props로 넘겨줘야 하기 때문이죠. 흔히 전문 용어로 "prop drilling" 이라고 합니다.
Context API를 사용하면 그런 부분을 해소할 수 있습니다. 깊이 여부와 무관하게 데이터가 필요한 컴포넌트에서만 불러다가 사용할 수 있습니다. 그림을 보면 그 차이를 확연하게 알 수 있습니다.
간단하게 user 정보 중에 id, nickname를 Context API를 사용해 전역 상태로 관리해보도록 하겠습니다. 사용법은 간단합니다. (참고)
// UserContext.ts
import { createContext, useContext, useState } from "react";
interface UserContextState {
id: string;
nickname: string;
}
const initialState: UserContextState = {
id: "ckstn0777",
nickname: "기운찬곰",
};
const UserContext = createContext<UserContextState | null>(null);
interface Props {
children: React.ReactNode;
}
export function UserProvider({ children }: Props) {
const [state, setState] = useState<UserContextState>(initialState);
return <UserContext.Provider value={state}>{children}</UserContext.Provider>;
}
export function useUserContext() {
const context = useContext(UserContext);
if (context === null) {
throw new Error("useUserContext must be used within UserProvider");
}
return context;
}
아래는 useUserContext를 사용하는 코드입니다.
// Playground.tsx
import { useState } from "react";
import { UserProvider, useUserContext } from "./context";
function UserComponent() {
console.log("UserComponent render");
const userContext = useUserContext();
return (
<div>
<h2>user id : {userContext.id}</h2>
<h2>user nickname : {userContext.nickname}</h2>
</div>
);
}
export default function Playground() {
const [count, setCount] = useState(0);
return (
<>
<UserProvider>
<UserComponent />
</UserProvider>
<button onClick={() => setCount(count + 1)}>count : {count}</button>
</>
);
}
근데 여기서 궁금한 점이 있습니다. count 버튼을 누르면 Provider 상위 컴포넌트가 리렌더링되는데, Provider랑 UserComponent도 리렌더링 될까요? 🤔
부모 컴포넌트가 리렌더링 되는 것이기 때문에 모든 하위 컴포넌트도 리렌더링 됩니다.
const UserComponent = React.memo(_UserComponent);
이를 막기 위해서는 UserComponent를 React.memo로 감싸면 됩니다. 반면, UserProvider를 React.memo로 감싸면 효과가 없는데요. 즉, children을 props로 받는 컴포넌트는 React.memo 가 효과가 없다고 합니다. 이에 대해서는 매 렌더링 마다 children prop가 바뀌기 때문이겠죠?
구체적으로 설명하자면 사실 JSX라는 형태 자체가 React에서는 React.createElemet라는 형태로 표현됩니다. React.createElement는 매 렌더링 때마다 새로운 react element를 생성해 반환하는데 이 react element는 object 형태로 존재합니다. 즉, 결과적으로 children의 데이터 자체는 변경되지 않더라도 새롭게 react element를 반환할 때 object의 참조값이 변경되기 때문에 React.memo는 props가 변경되었다고 인식하여 매번 렌더링하게 되는 것입니다. (오...! 오늘도 새로운 사실을 알아가네요)
참고 : https://velog.io/@2ast/React-children-prop에-대한-고찰feat.-렌더링-최적화
이번에는 전역 상태 값을 수정할 수 있도록 하기 위해 actions를 추가하도록 하겠습니다. Provider 내부에 다음과 같이 추가해줍니다.
import React, { useMemo } from "react";
import { createContext, useContext, useState } from "react";
interface UserContextState {
id: string;
nickname: string;
}
interface UserContextActions {
change(key: keyof UserContextState, value: string): void;
reset(): void;
}
interface UserContextType {
state: UserContextState;
actions: UserContextActions;
}
const initialState: UserContextState = {
id: "ckstn0777",
nickname: "기운찬곰",
};
const UserContext = createContext<UserContextType | null>(null);
interface Props {
children: React.ReactNode;
}
export function UserProvider({ children }: Props) {
const [state, setState] = useState<UserContextState>(initialState);
const actions: UserContextActions = useMemo(
() => ({
change(key, value) {
setState((prev) => ({ ...prev, [key]: value }));
},
reset() {
setState(initialState);
},
}),
[]
);
const value = useMemo(() => ({ state, actions }), [state, actions]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
export function useUserContext() {
const context = useContext(UserContext);
if (context === null) {
throw new Error("useUserContext must be used within UserProvider");
}
return context;
}
이 때, useState, useMemo를 사용해서 UserProvider 리렌더링 시에도 하위에 Context API를 구독하는 컴포넌트가 최대한 영향을 받지 않도록 해줬습니다.
const UserComponent = React.memo(_UserComponent);
function _UserComponent() {
console.log("UserComponent render");
const { state: user, actions } = useUserContext();
return (
<div>
<h2>user id : {user.id}</h2>
<h2>user nickname : {user.nickname}</h2>
<button onClick={() => actions.change("id", "ckstn0778")}>
change userId
</button>
</div>
);
}
만약 useState, useMemo를 사용하지 않고 count 버튼을 클릭해보면 React.memo를 사용했더라도 UserComponent도 같이 렌더링 됩니다. 생각해보면 당연히 React.memo는 무쓸모가 되겠죠?
아래는 Context API의 최대 문제라고 할 수 있는 예시입니다.
// Playground.tsx
function PostComponent() {
console.log("PostComponent render");
const { actions } = useUserContext();
return (
<div>
<button onClick={() => actions.change("id", "ckstn0778")}>
change userId
</button>
</div>
);
}
function UserComponent() {
console.log("UserComponent render");
const { state: user } = useUserContext();
return (
<div>
<h2>user id : {user.id}</h2>
<h2>user nickname : {user.nickname}</h2>
</div>
);
}
export default function Playground() {
const [count, setCount] = useState(0);
return (
<>
<UserProvider>
<UserComponent />
<PostComponent />
</UserProvider>
<button onClick={() => setCount(count + 1)}>count : {count}</button>
</>
);
}
PostComponent와 UserComponent는 서로 필요로 하는 데이터가 다릅니다. 하지만 change userId 버튼을 클릭 시에 둘 다 리렌더링 되는 것을 알 수 있습니다.
“하지만 selector가 없는 React의 context API를 사용할 경우 최상단 state를 업데이트하면 하위의 모든 컴포넌트가 리렌더링된다는 꽤나 치명적인 성능 이슈가 발생합니다.” - 인프랩 기술블로그 참고
이를 해결하기 위해 아래처럼 Context를 분리할 수는 있겠지만 지저분해보이고 코드가 보기 깔끔하진 않습니다.
// 참고 : https://yrnana.dev/post/2021-08-21-context-api-redux
const ModalProvider = ({ children }) => {
const [show, setShow] = useState(false);
return (
<ModalStateContext.Provider value={show}>
<ModalDispatchContext.Provider value={setShow}>
{children}
</ModalDispatchContext.Provider>
</ModalStateContext>
)
}
거기다가 지금은 간단한 전역 상태 값이였지만 더 복잡한 상태를 관리한다면 어떨까요? object는 상태가 부분적으로 변경되어도 매번 새로 생성되기 때문에 컨텍스트를 사용하는 모든 컴포넌트가 리렌더링 될 것입니다. 그렇다고 매번 Context를 나누자니 컨텍스트를 추가할 때마다 프로바이더로 매번 감싸줘야하기 때문에 Provider hell을 야기할 수 있습니다.
change userId 버튼을 클릭해서 Context의 상태를 바꾸면 중간에 Context API를 사용하지 않는 컴포넌트는 어떻게 될까요?
// Playground.tsx
// const PostComponent = React.memo(_PostComponent);
function PostComponent() {
console.log("PostComponent render");
return (
<div>
<h2>PostComponent</h2>
<UserComponent />
</div>
);
}
function UserComponent() {
console.log("UserComponent render");
const { state: user, actions } = useUserContext();
return (
<div>
<h2>user id : {user.id}</h2>
<h2>user nickname : {user.nickname}</h2>
<button onClick={() => actions.change("id", "ckstn0778")}>
change userId
</button>
</div>
);
}
export default function Playground() {
const [count, setCount] = useState(0);
return (
<>
<UserProvider>
<PostComponent />
</UserProvider>
<button onClick={() => setCount(count + 1)}>count : {count}</button>
</>
);
}
다소 신기하게도(?) PostComponent는 리렌더링 되지 않았습니다.
UserProvider에서의 상태는 분명 바뀌었을텐데 어떻게 된 걸까요? 이 현상에 대해서 Children prop를 제대로 알 필요가 있었습니다.
참고 : https://yrnana.dev/post/2021-08-21-context-api-redux
참고 : https://velog.io/@2ast/React-children-prop에-대한-고찰feat.-렌더링-최적화
다음 코드를 살펴보겠습니다.
import React,{useState} from 'react';
const ChildComponent = () =>{
console.log("ChildComponent is rendering!");
return <div>Hello World!</div>
}
const ParentComponent = ({children}) =>{
console.log("ParentComponent is rendering!");
const [toggle, setToggle] = useState(false);
return <div>
{children}
<button onClick={()=>{setToggle(!toggle)}}>
re-render
</button>
</div>
}
const Container =() => {
return <div>
<ParentComponent>
<ChildComponent/>
</ParentComponent>
</div>
ParentComponent 는 children props를 사용해 ChildComponent를 받아서 렌더링시키고 있습니다. 그 때 ParentComponent 내부에 상태가 변경되면 ChildComponent에 영향이 있을까요, 없을까요? 🤔
결과는 영향이 없었습니다. "ParentComponent is rendering!"만 콘솔에 찍히고, ChildComponent는 찍히지 않았습니다. 사실 좀만 생각해보면 알 수 있는 사실인 거 같습니다. 아래 두 코드는 결국 같은 코드입니다.
<ParentComponent>
<ChildComponent/>
</ParentComponent>
<ParentComponent children={<ChildConponent/>}/>
children prop은 말 그대로 prop이고, 한번 전달된 prop은 상위 컴포넌트가 리렌더링 되지 않는한 갱신되지 않고 유지됩니다. 결국 ParentComponent 내부에 상태 변경은 영향이 없던 것이지요.
그런 의미에서 Context Provider도 마찬가지 아닐까요? UserProvider 내부에 상태 변경은 children에 영향을 주지 않습니다. 따라서 위에서 PostComponent는 영향을 받지 않은 것이죠. (오호... 메모...🖊️)
이번 시간에는 Context API를 여러 상황을 가정하면서 사용해보면서 어떻게 사용하면 좋을지, 치명적인 단점은 무엇인지 알아봤습니다. 그 외에도 부가적으로 children props에 대해 몰랐던 사실을 알 수 있는 시간이 된 거 같습니다.
어쨌거나 이러한 이유로 기능도 다양한 Redux나 Recoil 같은 전역 상태 라이브러리를 사용할 수 있겠지만, 여전히 Context API도 간단한 전역 상태 처리에는 사용하기 나쁘지 않은 녀석이라고 생각합니다. 이런 차이를 잘 알고 서비스 규모나 취향에 따라 선택하는게 중요하다고 생각되네요.
후속 편으로 Redux나 Recoil에 대해서도 실습해보면서 Context API와 제대로 된 비교를 해보는 시간이 있었으면 좋겠네요.
잘보고 갑니다 : )