반찬 투정 하던 '상남자' 개발자의 최후 - 상태관리(Context API) (카카오 테크 캠퍼스)

김규회·2024년 7월 5일
1

카카오테크캠퍼스

목록 보기
2/4
post-thumbnail

난 사실 Zustand 러버이다. 그래서 상태관리를 사용할 때 항상 zustand나 Recoil만 써왔다.
사실 Redux도 써보고 ContextAPI도 공부해왔었지만 상대적인 난이도와 재렌더링에 대한 이슈때문에 굳이 두 상태관리를 써야될까라는 생각을 하고 있었다.

하지만 이번에 카카오 테크 캠퍼스 과제를 하게 되면서 해당 조건문에 Context API를 사용하여 상태관리를 구현하게 되었다. 그래서 좀 불만이 있었다. 왜 편한 라이브러리 두고 내장 기능을 쓸까? 라는 고민을 했었다.

그렇지만 최근에 들어간 리액트 오픈 카카오톡방에서도 토스 개발자 한 분께서 저희 팀은 Context API를 사용한다고 해왔었고, 또한 카카오테크캠퍼스에서도 강사분들께서 ContextAPI를 권장을 하고 있어서 왜 React 자체의 상태관리인 Context API에 대해서 쓸 까에 대해서 고민이 되었다.
좀 충격적인 부분은 오픈톡방에서 상태관리를 전역적으로 굳이 관리를 해야되나요? 라는 대답이었었는데 결국 Context API의 재 렌더링 문제로 나오게 된것들이 서드파티의 라이브러리들인 redux, jotai, zustand, recoil 이런 것들이 아닌가에 대해서 의문점이 생기게 되었다.

리액트에서의 Props와 State

React에서 Props와 State는 부모 컴포넌트와 자식 컴포넌트 또는 한 컴포넌트 안에서 데이터를 다루기 위해서 사용된다. 이때 이 Props와 State를 사용하게 되면 부모 컴포넌트에서 자식 컴포넌트로 데이터가 흐르게 된다.

이떄 만약 다른 컴포넌트에서 데이터를 사용하고 싶은 경우 사용하고 싶은 데이터와 이 데이터를 사용할 컴포넌트를 공통 부모 State에 만들고 사용하고자 하는 데이터의 Props를 전달하면 이 문제를 해결할 수 있게 된다.

하지만 이처럼 컴포넌트 사이에 공유되는 데이터를 위해 매번 부모 컴포넌트를 수정하고 하위 모든 컴포넌트에 데이터를 Props로 전달하는 것은 매우 비효율적이다.

GitHub - KimKyuHoi/Matcher_FE: 예약과 구인구직을 동시에 한눈에 알아보는 사이트 으라차차입니다.

이 부분은 본인이 실제로 겪었던 내용인데 진짜 상태관리 라이브러리에 대해 잘 모르고 막 엄청 잘 활용 못하던 시절 부모 컴포넌트에게 데이터를 전달하기 위해 진짜 힘들어 죽는 줄 알았다.

이 문제를 해결하기 위해 React에서는 Flux 패턴이라는 개념을 도입하기 시작했고 그에 걸맞는 Context API를 제공하기 시작했다.

Context API

Context API는 부모 컴포넌트로부터 자식 컴포넌트로 전달되는 데이터의 흐름과는 상관없이 전역적인 데이터를 다룰 때 사용한다. 전역 데이터를 Context에 저장한 뒤에, 데이터가 필요한 컴포넌트에서 해당 데이터를 불러와서 사용할 수 있다. 어찌보면 Redux, Zustand의 Store 폴더에서 전역적으로 관리하는 개념과 뭔가 비슷한 느낌이 든다.

React에서는 Context API를 사용하기 위해서는 Context의 Provider와 Consumer를 사용해야한다. 이때 Context에 저장된 데이터를 사용하기 위해서는 공통 부모 컴포넌트에 Context의 Provider를 사용하여 데이터를 제공해야 되고, 데이터를 사용하려는 컴포넌트는 Context 의 Consumer를 사용해서 실제 데이터를 사용하게 된다.


짤막한 용어 정리

  • Provider: Context를 생성하고, 이를 하위 컴포넌트들에게 제공하는 컴포넌트를 말한다. 전역적으로 관리할 상태를 value prop으로 넘겨주면, 하위 컴포넌트들이 이 상태를 사용할 수 있게 된다.
  • Consumer: Provider에서 제공하는 상태를 사용하는 컴포넌트를 말한다. Provider의 상태를 구독하고, 상태가 변경될 때마다 재렌더링 된다.

약간 예시로 들자면 이런 느낌이다.

  • 엄마와 형제들이 있는데 Context가 첫째인 느낌이고 나머지 자식 컴포넌트들이 나머지 형제들이라고 비유를 하자면 애들끼리 사고 치는 것을 방지하기 위해서 큰 형이 우선 부모님한테 우리 이거 할꺼에요~라고 보고를 한 뒤 나머지 형제들한테 할 내용들을 보내주는 느낌이다. (뭔가 느낌이 참~ 군대스럽다. 선 보고 후 조치라는 느낌?)

Context 사용법

import { createContext, useContext, useEffect, useState } from 'react';

interface AuthContextType {
  isLoggedIn: boolean;
  userId: string | null;
  login: (id: string) => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [userId, setUserId] = useState<string | null>(null);

  useEffect(() => {
    const token = sessionStorage.getItem('authToken');
    if (token) {
      setIsLoggedIn(true);
      setUserId(token);
    }
  }, []);

  const login = (id: string) => {
    sessionStorage.setItem('authToken', id);
    setIsLoggedIn(true);
    setUserId(id);
  };

  const logout = () => {
    sessionStorage.removeItem('authToken');
    setIsLoggedIn(false);
    setUserId(null);
  };

  return (
    <AuthContext.Provider value={{ isLoggedIn, userId, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

이번에 과제를 사용하면서 로그인 관련해서 커스텀 훅 코드를 짜게 되었었다.

이때 보면

import { createContext, useContext, useEffect, useState } from 'react';

const AuthContext = createContext<AuthContextType | undefined>(undefined);

createContext라는 함수를 선언하여 사용할 수 있다.

그리고

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [userId, setUserId] = useState<string | null>(null);

  useEffect(() => {
    const token = sessionStorage.getItem('authToken');
    if (token) {
      setIsLoggedIn(true);
      setUserId(token);
    }
  }, []);

  const login = (id: string) => {
    sessionStorage.setItem('authToken', id);
    setIsLoggedIn(true);
    setUserId(id);
  };

  const logout = () => {
    sessionStorage.removeItem('authToken');
    setIsLoggedIn(false);
    setUserId(null);
  };

  return (
    <AuthContext.Provider value={{ isLoggedIn, userId, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

로직을 작성한 뒤 AuthContext.Provider를 통해 Children을 감싸준다. 그리고 로직들을 value 즉 상태관리 및 함수들을 넘겨주게 된다.

마지막으로

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

useAuth 훅을 통해서 컴포넌트 내에서 사용하고 싶은 함수나 State들을 선언을 통해 가지고 와서 사용할 수 있다.

Ex)

// src/hooks/custom-hooks/useLogin.ts
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { useAuth } from '@/contexts/AuthContext';

const useLogin = () => {
  const [name, setName] = useState<string>('');
  const [password, setPassword] = useState<string>('');
  const navigate = useNavigate();
  const location = useLocation();
  const { login } = useAuth();

  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setName(event.target.value);
  };

  const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(event.target.value);
  };

  const handleLoginClick = () => {
    if (name.length === 0 || password.length === 0) {
      alert('아이디와 비밀번호를 입력해주세요.');
    } else {
      login(name);
      const from = location.state?.from?.pathname || '/';
      navigate(from, { replace: true });
    }
  };

  return {
    name,
    password,
    handleNameChange,
    handlePasswordChange,
    handleLoginClick,
  };
};

export default useLogin;

Login 커스텀 훅인데 따로 Context API를 통해서 그냥 선언을 통해 넘겨주는 모습이다.

Context API의 불편한 진실

이 분의 글과 코드를 참고하였고 직접 코드를 쳐보았다.

우선 똑같이 counterContext.tsx 파일을 만들어 준다.

import { createContext, useState } from 'react';

export const CounterValueContext = createContext(0);
export const CounterActionContext = createContext(() => {});

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [counter, setCounter] = useState(0);

  const increase = () => {
    setCounter((prev) => prev + 1);
  };

  console.log('Provider rendered');

  return (
    <CounterValueContext.Provider value={counter}>
      <CounterActionContext.Provider value={increase}>
        {children}
      </CounterActionContext.Provider>
    </CounterValueContext.Provider>
  );
}

Component.tsx 파일을 따로 만들었고

import { useContext } from 'react';
import { CounterActionContext, CounterValueContext } from './counterContext';

export const MyComponent1 = () => {
  console.log('Component 1 rendered');

  return <div> Component 1 </div>;
};

export const MyComponent2 = ({ children }: { children: React.ReactNode }) => {
  console.log('Component 2 rendered');

  return <div>Component 2{children}</div>;
};

export const MyComponent3 = () => {
  const counter = useContext(CounterValueContext);

  console.log('Component 3 rendered');

  return <div> Component 3. Count: {counter} </div>;
};

export const MyComponent4 = () => {
  const increase = useContext(CounterActionContext);
  console.log('Component 4 rendered');

  return <div> Component 4 </div>;
};

export const MyButton = () => {
  const increase = useContext(CounterActionContext);

  console.log('My Button rendered');

  return <button onClick={() => increase()}> + </button>;
};

최종적으로 App.tsx 파일을 만들었다.

import {
  MyButton,
  MyComponent1,
  MyComponent2,
  MyComponent3,
  MyComponent4,
} from './Component';
import { CounterProvider } from './counterContext';

function App() {
  return (
    <CounterProvider>
      <MyComponent1 />
      <MyComponent2>
        <MyComponent3 />
      </MyComponent2>
      <MyComponent4 />
      <MyButton />
    </CounterProvider>
  );
}

export default App;

이 때 컴포넌트 5개는 각각의 조건들이 있다.

  • MyComponent1은 어떤 Context를 사용하지 않았다.
  • MyComponent2는 어떤 Context도 가져다 사용하지 않았지만, Context를 가져다 사용하는 children 컴포넌트를 렌더링한다.
  • MyComponent3는 CounterValueContext를 가져다 사용한다.
  • MyComponent4는 CounterActionContext를 가져오지만 사용하지 않는다.
  • MyButton은 CounterActionContext를 가져오고 클릭시 increase 함수를 실행한다.

과연 여기서 MyButton을 클릭해서 increase 함수를 실행시켜 counter값을 변경했을 때, 다음 중 리렌더링이 되는 컴포넌트는 무엇일까?

우선 CounterProvider가 리렌더링 되는 이유는 setCounter 함수가 실행되기 때문에 어쩔수 없이 발생한다.

또한 MyComponent3도 CounterValueContext를 가져다 사용하여 리렌더링이 되었다. 하지만 MyComponent2는 리렌더링이 되지 않았다. 여기서 Context의 값이 변경된 경우, 해당 Context의 Consumer만 영향을 받는다는 것이다.


Q. 왜 MyComponent4와 MyButton은 리렌더링이 될까?

CounterProvider가 리렌더링이 되면서 increase 함수가 새로 생성이 되어버린다.

이때 increase의 참조가 달라지면서 CounterActionContext의 값도 변경이 되어 버리기 때문에 두 컴포넌트도 리렌더링이 되어버린다. 이를 방지하기 위해서는 useCallback함수를 사용하게 되면 increase 함수를 실행시켜도 리렌더링되지 않을 수 있다.

 export function CounterProvider({children}: {children: React.ReactNode}){
        const [counter, setCounter] = useState(0);
    
        const increase = useCallback( () => {
            setCounter((prev) => prev + 1);
        }, [])
    
        console.log('Provider rendered')
    
        return (
            <CounterValueContext.Provider value={counter}>
                <CounterActionContext.Provider value={increase}>
                    {children}
                </CounterActionContext.Provider>
            </CounterValueContext.Provider>
        )
    }

그러면 진짜 ContextAPI의 문제점이 무엇일까?

import {createContext, useState} from "react";

export const userContext = createContext({name: '', email: ''});
export const userActionContext = createContext((value: string) => {});

export function UserContextProvider({children}: {children: React.ReactNode}){
    const [userName, setUserName] = useState('John');
    const [userEmail, setUserEmail] = useState('john@example.com');

    return (
        <userContext.Provider value={{name: userName, email: userEmail}}>
            <userActionContext.Provider value={setUserName}>
                {children}
            </userActionContext.Provider>
        </userContext.Provider>
    )
}
function UserNameComponent(){
  const {name} = useContext(userContext);
  console.log('UserNameComponent rendered');

  return <div> User Name: {name} </div>}

function UserEmailComponent(){
  const {email} = useContext(userContext);
  console.log('UserEmailComponent rendered');

  return <div> User Email: {email} </div>}

function UserNameButton(){
  const setName = useContext(userActionContext);
  console.log('UserNameButton rendered');

  return <button onClick={() => setName('Hello')}> Set Name </button>}

export default function App(){
  return (
    <UserContextProvider>
      <UserNameComponent />
      <UserEmailComponent />
      <UserNameButton />
    </UserContextProvider>)
}

이 두 코드를 보면 Context가 객체일때 객체 일부의 프로퍼티만 업데이트 되더라도 해당 Context를 가져다가 사용하는 모든 Consumer가 리렌더링된다는 것이다.

이떄 userName을 변경하게 되면

UserNameComponent와, UserEmailComponent가 리렌더링된다.

만약 이런 문제로 인해서 하나의 value로 provide를 해주게 될 경우 불필요한 리렌더링이 야기된다. 만약 상태가 여러개일 경우에 provider도 그만큼 많아지게 되는데, 코드의 수가 무한방대해지고 많아지게 된 상태에서 provider 개수가 몇 개 인지 모르는 상태에서 새로운 provider가 필요하게 된다면?
새로운 node를 트리 중간에 삽입해야 되는데, 큰 렌더링 이슈가 발생할 것이다.

또한 Provider와 Consumer 내에서 Provider는 무조건 Consumer 상위에 있어야되기 때문에 코드 스플리팅하기가 매우 어려워질 수 있다.

정리

Context API를 사용할떄 특정 Context의 값이 변경되면, 기본적으로 해당 Context의 Consumer라면 전부 리렌더링이 되어버린다. 하지만 Consumer의 자식 컴포넌트 같은 경우에는 리액트의 리렌더링 매커니즘 때문에 Context를 가져와서 사용하지 않아도 리렌더링이 된다.

그래서 결국에 진짜 문제점은 Context의 값 중 일부만 가져와서 사용해도 다른 값이 업데이트 될 경우 리렌더링이 된다는 점이다. 그렇기 때문에 Context를 분리해서 생성해주는 것이 매우 중요한 것 같다.

결론 및 내 생각

리액트를 처음 공부할 떄 리렌더링이라는 문제가 발생하는 경우가 결국 해당 Context의 Consumer라면 전부 리렌더링이 되어버린다는 사실에 대해서 이제서야 깨달음을 알게된 것 같다는 생각이 든다.

결국에는 Context API를 통해 코드를 짜기 위해서는 아주 치밀하게 고려하여 코드를 짜야겠다는 생각이 들었다. Context API의 이상적인 방안에 맞게 코드를 짜게 되면 아주 클린한 코드가 되지 않을까라는 생각과 동시에 매우 끈끈한 코드가 되지 않을까 라는 생각이 들었다.

그러면서 동시에 이렇게 코드를 짤 빠에 굳이 Zustand, Recoil, Redux를 버려가면서 까지 사용할 필요가 있을까,,,?라는 고민과 함께 막 그렇다고 해서 엄청 Context API에 대해서 무조건 나쁘다, 다른 서드파티 라이브러리를 사용하는 것이 좋겠다라는 생각이 들게 되었다. 왜냐하면 리렌더링 부분쪽이야 useCallback이랑 useMemo를 사용해서 리렌더링을 막으면 되니까? 라는 생각이다.
뭐 결과론적으로 얘기하자면 그냥 프로젝트에 맞는 상태관리를 쓰면 될 것 같다.
약간 세상의 나쁜 상태관리는 없는 느낌이랄까?

참고 글

profile
프론트엔드 Developer

0개의 댓글