Redux 상태 관리 라이브러리 이해하기

heyj·2024년 12월 13일
0

React 공부하기

목록 보기
8/10
post-thumbnail

리덕스란? 왜 사용하는 걸까?

Redux is a pattern and library for managing and updating global application state, where the UI triggers events called "actions" to describe what happened, and separate update logic called "reducers" updates the state in response. It serves as a centralized store for state that needs to be used across your entire application, with rules ensuring that the state can only be updated in a predictable fashion.

리덕스란 애플리케이션의 전역 상태를 관리하고 업데이트 하기 위한 라이브러리입니다. 리덕스는 애플리케이션 전역에서 사용되는 상태값을 중앙 저장소에서 관리하며, 이 상태값들이 예측 가능한 방식으로만 업데이트 될 수 있도록 규칙을 제공합니다.

그렇다면 리덕스는 왜 만들어졌을까요?

SPA의 등장 이후 다양한 요구사항들을 적용하면서 웹사이트 규모가 점점 커지게 되었습니다. 규모가 커짐에 따라 관리해야할 state, 즉 상태값 역시 많아지는 것은 필연적이죠. 증가하는 상태값을 관리해야 하니 상태관리의 복잡도도 크게 증가하게 됩니다.

아래 몇 가지 예시를 보겠습니다.

1. Props Drilling
Props drilling은 데이터를 전달하기 위해 필요한 과정을 설명하는 용어입니다. 컴포넌트 트리에서 데이터를 하위 컴포넌트로 전달하기 위해 중간 컴포넌트를 통해 프로퍼티를 내려주는 것을 의미하는데요.

아래 예시를 한 번 볼까요? 각각 조부모, 부모, 자식, 손자 컴포넌트입니다. user 데이터가 조부모에서 부모로, 부모에서 자식으로, 또 자식에서 손자로 전달되는 것을 확인할 수 있습니다.

function GrandParent() {
  const [user, setUser] = useState({ name: 'John', age: 30 });

  return (
    <Parent user={user} setUser={setUser} />
  );
}

function Parent({ user, setUser }) {
  return (
    <Child user={user} setUser={setUser} />
  );
}

function Child({ user, setUser }) {
  return (
    <GrandChild user={user} setUser={setUser} />
  );
}

// GrandChild 컴포넌트는 user 데이터가 필요하지만, 
// 중간의 Parent와 Child는 단순히 props를 전달하기만 함
function GrandChild({ user, setUser }) {
  return <div>{user.name}</div>;
}

문제는 부모와 자식은 user 데이터가 필요 없는 경우에도 손자 컴포넌트가 user 데이터가 필요해 props로 전달하는 경우가 생긴다는 것이죠. 또한 이렇게 전달을 할 경우, 1) 프로퍼티 데이터 형식 변경이 어려움, 2) 중간 컴포넌트에 불필요한 프로퍼티 전달 3) 누락된 프로퍼티 인지가 어려움, 4) 프로퍼티 이름 변경 추적이 어려움 등의 문제가 생길 수 있습니다.

2. 복잡한 객체 상태를 다룰 때
아래의 컴포넌트의 user는 user의 다양한 정보를 담고 있는 객체입니다.
이 객체의 일부값을 업데이트 하거나 전체를 업데이트 하려면 코드가 굉장히 복잡해지고, 잘못된 방법으로 중첩된 객체의 일부만 업데이트할 경우 다른 데이터의 손실이 있을 수 있습니다.

function UserProfile() {
  const [user, setUser] = useState({
    name: 'John',
    address: {
      street: '123 Main St',
      city: 'Boston',
      country: 'USA'
    },
    preferences: {
      theme: 'dark',
      notifications: {
        email: true,
        push: false
      }
    }
  });

  const updateNotifications = () => {
    // 잘못된 방법: 중첩된 객체의 일부만 업데이트하면 다른 데이터가 손실될 수 있음
    setUser({
      ...user,
      preferences: {
        notifications: {
          push: true
        }
      }
    }); // email 설정이 사라짐

    // 올바른 방법이지만 매우 복잡해짐
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        notifications: {
          ...user.preferences.notifications,
          push: true
        }
      }
    });
  };
}

3. 여러 컴포넌트에서 동일한 상태값을 필요로 할 때
아래는 여러 컴포넌트가 theme과 user를 필요로 하는 경우입니다. 이 경우 모든 컴포넌트에 프로퍼티를 전달해줘야 한다는 번거로움이 있습니다.

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState(null);

  return (
    <>
      <Header theme={theme} setTheme={setTheme} user={user} />
      <Sidebar theme={theme} user={user} />
      <Main theme={theme} user={user} />
      <Footer theme={theme} />
    </>
  );
}

웹사이트의 규모가 커지고 수많은 상태들을 관리하게 되면서, 개발자들은 언제 어디에서 어떻게 상태가 업데이트 되는지 파악하기 어려워질 수 밖에 없습니다. 만약 어디에선가 버그가 발생한다면 그 버그를 추적하는 것도 쉽지 않겠죠.또한 새로운 기능을 추가하는 것은 더더욱 어려울 수 있습니다.

이 수많은 상태들을 어떻게 하면 효율적으로 관리할 수 있을까를 고민한 끝에, 상태 관리만을 위한 기술들이 등장하는 데 그 중 하나가 Redux입니다.

Flux Architecture

Flux는 facebook에서 MVC 모델의 단점을 보완하기 위해 만들어낸 아키텍처입니다.

1. 기존 MVC의 단점


기존 MVC 패턴의 문제점은 1)양방향 데이터 흐름으로 복잡한 애플리케이션에서 데이터 흐름 추적이 어려움, 2)애플리케이션이 커질수록 model과 view 간 의존성이 복잡해짐, 3) 양방향 데이터 바인딩으로 인해 버그 추적이 어려움 등이 있었습니다.
이런 문제점들을 해결하기 위해 페이스북에서 단방향 데이터 흐름으로 애플리케이션을 예측 가능하도록 만드는 방법을 고안해 냈습니다.

2. Flux

Flux는 MVC와 다르게 데이터가 단방향으로 흐릅니다.
view에서 사용자가 상호작용을 할 때, 그 view는 중앙의 dispatch를 통해 action을 전파하게 됩니다. 애플리케이션의 데이터와 비즈니스 로직을 가지고 있는 store는 action이 전파되면 이 action에 영향이 있는 모든 view를 갱신하게 됩니다.

Redux는 Flux 패턴에서 영감을 받아 탄생했지만 순수한 Flux 구현체는 아닙니다. Flux와 달리 Redux는 디스패치를 사용하지 않고, 대신 순수 함수를 사용해 데이터 변경 함수를 정의합니다. 다만 store와 action은 동일하게 사용합니다.

Redux 구조 및 주요 원칙

우리가 Redux로 상태 관리를 할 때는 3가지 주요 원칙을 기억해야 합니다.
1) Single Source of Truth
2) State is Read-Only
3) Changes are made with Pure Functions

1. Single Source of Truth

애플리케이션 전체의 상태값들은 Store라고 불리는 자바스크립트 단일 객체에서 관리됩니다. 이 store가 바로 single source of truth(단 하나의 지식의 원천)이 됩니다. 즉, 진실이라고 여겨지는 값이 여기저기 흩어져있지 않고 단 한 곳에만 존재한다는 의미입니다.

이렇게 오직 한 곳에서 state가 관리되기 때문에 아래와 같은 이점이 있습니다.

  • 일관성: 하나의 스토어만 존재하기 때문에 전체 애플리케이션에서 상태의 일관성을 보장하기 쉽다.
  • 디버깅과 테스트가 용이: 변경사항을 추적하고 문제를 디버깅하기 쉽다. Redux DevTools와 같은 도구를 사용하면 시간에 따른 상태 변화를 시각화 할 수 있다.
  • 중앙에서 상태관리가 되어 상태 변화의 예측 가능성이 높아진다.

아래는 single store를 사용하지 않았을 때 문제가 발생하는 예시입니다.
내용이 동일한 상태값을 각각의 컴포넌트가 그 내부에서 관리하고 있기 때문에,
셋팅 페이지에서 유저 정보 업데이트가 발생한다고 해서 유저 프로필의 user 상태값이 업데이트 되지는 않습니다.

// UserProfile.js
function UserProfile() {
  const [user, setUser] = useState({
    name: 'John',
    email: 'john@example.com'
  });

  return <div>Welcome {user.name}!</div>;
}

// UserSettings.js
function UserSettings() {
  const [user, setUser] = useState({
    name: 'John',
    email: 'john@example.com'
  });

  const updateEmail = (newEmail) => {
    setUser({ ...user, email: newEmail });
    // UserProfile 컴포넌트의 상태는 업데이트되지 않음
  };

  return <input value={user.email} onChange={(e) => updateEmail(e.target.value)} />;
}

이 문제는 아래와 같이 Redux를 이용하면 하나의 상태값을 공유 받기 때문에, 두 컴포넌트에서 모두 업데이트가 발생하게 됩니다.

// userReducer.js
const initialState = {
  name: 'John',
  email: 'john@example.com'
};

const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'UPDATE_EMAIL':
      return {
        ...state,
        email: action.payload
      };
    default:
      return state;
  }
};

// UserProfile.js
function UserProfile() {
  const user = useSelector(state => state.user);
  return <div>Welcome {user.name}!</div>;
}

// UserSettings.js
function UserSettings() {
  const dispatch = useDispatch();
  const user = useSelector(state => state.user);

  const updateEmail = (newEmail) => {
    dispatch({ type: 'UPDATE_EMAIL', payload: newEmail });
    // 모든 컴포넌트에서 동일한 상태를 공유
  };

  return <input value={user.email} onChange={(e) => updateEmail(e.target.value)} />;
}

// App.js
function App() {
  return (
    <Provider store={store}>
      <div>
        <UserProfile />
        <UserSettings />
        <EmailNotifications /> {/* 새로운 컴포넌트도 동일한 상태 사용 가능 */}
      </div>
    </Provider>
  );
}

이렇게 하면 모든 컴포넌트가 동일한 사용자 데이터를 참조하게 되고, 한 컴포넌트에서 상태를 업데이트하면 모든 컴포넌트에 반영됩니다. 또한 새로운 컴포넌트도 동일한 상태값을 참조할 수 있어 확장에도 용이합니다.

또한 아래처럼 테스트도 간단하게 작성할 수 있습니다.

test('should update email', () => {
  const initialState = { name: 'John', email: 'john@example.com' };
  const action = { type: 'UPDATE_EMAIL', payload: 'new@example.com' };
  
  const newState = userReducer(initialState, action);
  
  expect(newState.email).toBe('new@example.com');
});

2. State is Read-Only

state는 직접 변경할 수 없습니다. 대신 상태를 변경하기 위해서는 사전에 미리 정의해둔 어떤 상황이 발생했을 경우, 사전에 정해진대로만 상태를 변경할 수 있습니다. 중요한 것은 상태를 변경할 상황과 변경 규칙이 미리 정의되어 있다는 것입니다. 이 규칙에 어긋나는 형태로는 상태값을 변경할 수 없습니다.

이렇게 되면 2가지 이점이 있습니다.

  • 예측가능성이 높아집니다. 상태값은 오직 action을 통해서만 변화될 수 있기 때문에 상태값이 언제 왜 변했는지 쉽게 이해할 수 있습니다.
  • 상태가 특정 값에서 어떻게 변경되었는지 그리고 어떻게 변경될 것인지 파악이 용이합니다.(Time-travel debugging)

아래는 간단한 todo-list입니다.
Redux로 상태관리를 하고 action과 reducer를 이용해 상태값을 변경시킵니다.
컴포넌트는 오직 값을 확인하고 dispatch를 통해 action 하는 방법으로만 상태를 업데이트 할 수 있습니다.

// actions.js
const toggleTodo = (id) => ({
  type: 'TOGGLE_TODO',
  payload: id
});

const addTodo = (text) => ({
  type: 'ADD_TODO',
  payload: {
    id: Date.now(),
    text,
    completed: false
  }
});

// reducer.js
const todoReducer = (state = [], action) => {
  switch (action.type) {
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.payload
          ? { ...todo, completed: !todo.completed }
          : todo
      );
      
    case 'ADD_TODO':
      return [...state, action.payload];
      
    default:
      return state;
  }
};

// TodoList.js
function TodoList() {
  const dispatch = useDispatch();
  const todos = useSelector(state => state.todos);

  const handleToggle = (id) => {
    dispatch(toggleTodo(id));
  };

  const handleAdd = (text) => {
    dispatch(addTodo(text));
  };

  return (
    <div>
      <AddTodoForm onSubmit={handleAdd} />
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          {...todo}
          onToggle={() => handleToggle(todo.id)}
        />
      ))}
    </div>
  );
}

Time-travel debugging은 store를 생성할 때 devtools를 이용할 수 있게 하면 가능해집니다.

const store = createStore(
  reducer,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);

3. Changes are made with pure function

앞서 예제에서 살펴본 것과 같이 reducer는 이전 상태와 action을 받아 다음 상태를 반환하는 순수 함수 입니다. Redux의 3번째 주요 원칙은 상태값의 변화는 오직 순수 함수를 통해 이뤄져야 한다는 것입니다.

순수함수를 확인하기 위한 간단한 예제인데요. 아래 함수를 보면, argument로 넘기는 값이 2, 3 동일하다면 언제나 5가 출력되는 것을 확인할 수 있습니다. 이처럼 순수함수는 상태를 직접 수정하지 않고, 부수 효과를 발생시키지 않으며, 외부 상태에 의존하지 않습니다.

function sum(a, b){
  return a + b;
}

console.log(sum(2, 3)); // 5
console.log(sum(2, 3)); // 5

Redux의 상태 변화는 오직 이 순수함수를 통해 이뤄진다는 것은, 상태 변화를 일으키는 모든 함수들은 입력값이 동일하다면 항상 같은 출력값을 리턴해야 한다는 뜻입니다.

4. 구조

앞서 밝힌 것과 같이 Redux는 단방향 데이터 흐름을 갖습니다.state는 특정 시점의 앱의 상태를 설명합니다. UI는 해당 상태를 기반으로 렌더링됩니다. 이후 무언가 이벤트가 발생했을 경우(사용자가 버튼을 클릭) 발생한 일에 기반하여 상태가 업데이트 됩니다. UI는 새로운 상태를 기반으로 다시 렌더링됩니다.

Redux 공식 홈페이지의 그림은 단방향 데이터 흐름에 대해 잘 설명해줍니다.

위의 단계를 좀 더 세부적으로 나누면 초기설정과 업데이트 과정으로 나눌 수 있습니다.
초기 설정시에는
1) Redux 스토어가 루트 리듀서 함수를 사용하여 생성됩니다.
2)스토어는 루트 리듀서를 한 번 호출하고, 반환값을 초기 state로 저장합니다.
3) UI가 처음 렌더링될 때 UI컴포넌트들은 Redux 스토어의 현재 상태에 접근하여 무엇을 렌더링할지 결정합니다. 또한 상태가 변경되었는지 알 수 있도록 향후 스토어 업데이트를 구독합니다.

업데이트 과정에서는
1) 앱에서 이벤트가 발생합니다(사용자가 버튼을 클릭).
2) 앱 내의 코드는 Redux 스토어에 action을 dispatch(보내다)합니다.
3) 스토어는 이전 state와 action으로 리듀서 함수를 실행하고, 반환값을 새로운 state로 저장합니다.
4) 스토어는 구독 중인 모든 UI부분에 스토어가 업데이트 되었다는 것을 알립니다.
5) 스토어에서 데이터가 필요한 각 UI 컴포넌트는 자신이 필요로 하는 상태값이 변경되었는지 확인합니다.
6) 데이터가 변경된 것을 확인한 각 컴포넌트는 새로운 데이터를 이용해 리렌더링을 수행하여 화면에 표시되는 내용을 업데이트 합니다.

결론

지금까지 살펴본 바와 같이 Redux를 사용하면 상태 관리가 예측 가능해지고, 중앙 집중식 상태관리로 props drilling과 같은 복잡한 데이터 전달을 피할 수 있고, 여러 컴포넌트에서 동일한 상태를 쉽게 공유할 수 있다는 것을 알게 되었습니다.

애플리케이션을 개발할 때는 유지보수성과 확장가능성을 높이는 것이 필수인데, 이 또한 Redux를 이용하면 향상됩니다.

순수 함수 기반으로 테스트 작성이 쉽고, 액션과 상태 변화를 명확히 분리할 수 있어 단위테스트가 용이 하다는 것을 알 수 있었습니다. 그리고 상태 로직을 컴포넌트와 분리하여 테스트 할 수 있구요. 무엇보다 Redux devtools라는 강력한 개발자 도구로 모든 상태 변화를 실시간으로 확인할 수 있다는 것이 큰 장점입니다.

컴포넌트간 복잡한 데이터 전달이 없고, 각 컴포넌트는 필요한 상태값이 있다면 구독의 형식으로 관찰할 수 있어 확장성에도 좋다고 할 수 있습니다.

다만, Redux를 적용하는 초기 세팅 과정이 복잡하다는 단점이 있어 신중하게 도입해야 할 것 같습니다. 액션 함수, 액션 생성 함수, 리듀서, 스토어.. 등등 작성해야할 코드가 많기 때문입니다. 물론 redux-toolkit을 이용하면 코드 작성량을 확실히 줄일 수 있습니다.

중요한 것은 중앙에서 꼭 관리해야 할 상태값이 있는지의 여부일 것 같습니다. 만약 각 컴포넌트에서 관리되어도 충분한 상태라면 굳이 Redux를 도입할 필요가 없어 보입니다. 또한 서버로부터 전송받은 데이터를 관리할 목적이라면 server-state를 관리하는 react-query를 이용하는 것도 답이 될 수 있을 것 같습니다. 결론적으로 프로젝트의 상태에 맞는 라이브러리를 찾고 그것을 적용시키는 것이 중요하지 않나 생각합니다.

https://redux.js.org/tutorials/essentials/part-1-overview-concepts
https://beomy.tistory.com/44
https://www.frontoverflow.com/document/1/%EC%B2%98%EC%9D%8C%20%EB%A7%8C%EB%82%9C%20%EB%A6%AC%EB%8D%95%EC%8A%A4%20(Redux)/chapter/2/Redux%20%EC%86%8C%EA%B0%9C/section/5/Flux%20architecture
https://haruair.github.io/flux/docs/overview.html
https://learnersbucket.com/tutorials/es6/what-are-pure-and-impure-function-in-javascript/
https://medium.com/@farihatulmaria/explaining-the-three-core-principles-of-redux-463adb309758

0개의 댓글