[Redux] 리덕스 왜 쓸까?

리액트와 리덕스의 상태 관리 차이 / Action, Reducer, Store, Dispatch, Subscribe

Posted by Wonyong Jang on October 21, 2021 · 11 mins read

이 글은 리덕스가 왜 필요한지 알아보고, 리덕스에서 사용되는 여러 키워드와 사용하게 될 때 지켜야할 규칙에 대해서 살펴볼 예정이다.
또한 이어지는 글에서는 리덕스 모듈을 만들고 직접 구현해볼 예정이다.

리덕스 왜 쓸까?

SPA(Single Page Application) 웹프로젝트가 유행하면서 컴포넌트 기반의 라이브러리들이 많이 생겨났다. 대표적인 예로 Vue, React 등이 있겠다.
사용방법은 다르지만 이 들의 기본적인 관심은 화면을 컴포넌트 단위로 구조화 하고 애플리케이션의 상태를 효과적으로 화면에 렌더링 하는데에 있다.
리액트를 이용해 UI를 컴포넌트 기반으로 제작하고 애플리케이션의 상태를 화면으로 렌더링하는 것은 어렵지 않게 할 수 있다. 하지만 애플리케이션 개발시에는 화면을 그려내는 것 외에도 외부 입력을 받아서(사용자 입력 등) 이에 따른 적절한 비지니스 로직을 처리하고 그 결과를 애플리케이션 상태에 반영하며 필요한 사이드이펙트들을 잘 처리하는 작업이 필요한데 리액트는 이에 대한 훌륭한 방법을 제공하지 않는다.(이는 리액트의 관심사가 아니다)

보통 리액트에서 애플리케이션을 만들 때, 기본적으로는 하나의 루트 컴포넌트(App.js)에서 상태를 관리한다.
예를 들어서, TodoList 프로젝트에서는, 다음과 같은 구조로 상태가 관리되고 있다.

스크린샷 2021-10-21 오후 11 13 40

스크린샷 2021-10-21 오후 11 13 49

리액트 프로젝트에서는 대부분의 작업을 할 때 부모 컴포넌트가 중간자 역할을 한다. 컴포넌트끼리 직접 소통하는 방법은 있긴 하지만, 그렇게 하면 코드가 굉장히 많이 꼬여버리기 때문에 절때 권장되지 않는 방식이다.

App에서는 input값과, 이를 변경하는 onChange 함수와, 새 아이템을 생성하는 onCreate 함수를 props로 Form에게 전달해준다. Form은 해당 함수와 값을 받아서 화면에 보여주고, 변경 이벤트가 일어나면 부모에게서 받은 onChange를 호출하여 App이 지닌 input 값을 업데이트 한다.
그렇게 input값을 수정하여 추가 버튼을 누르면, onCreate를 호출 하여 todos 배열을 업데이트 한다.
todos 배열이 업데이트 되면, 해당 배열이 TodoItemList 컴포넌트한테 전달이 되어 화면에 렌더링 된다.

이런식으로, App 컴포넌트를 거쳐서 건너건너 필요한 값을 업데이트하고, 리렌더링 하는 방식으로 프로젝트가 개발된다.
이러한 구조는 부모 컴포넌트에서 모든걸 관리하고 아래로 내려주는 방식이기 때문에, 매우 직관적이기도 하고 관리하는 것도 꽤 편하다. 그런데 문제는 앱의 규모가 커졌을 때 이다.

보여지는 컴포넌트의 개수가 늘어나고, 다루는 데이터도 늘어나고 그 데이터를 업데이트 하는 함수들도 늘어날 것이다. 그렇게 가다간 App 의 코드가 엄청나게 길어지고 이에 따라 유지보수 하는 것도 힘들 것이다.

예를 들어 다음과 같은 구조의 프로젝트가 있다고 생각해보자.

스크린샷 2021-10-21 오후 11 33 40

Root 컴포넌트에서 G 컴포넌트에게 어떠한 값을 전달해 줘야 하는 상황에는 어떻게 해야 할까?

스크린샷 2021-10-21 오후 11 33 46

A를 거치고 E를 거치고 G를 거쳐야 한다! 코드는 아래와 같을 것이다.

// App.js 에서 A 렌더링
<A value={5}>

// A.js 에서 E 렌더링
<E value={this.props.value} />

// E.js 에서 G 렌더링
<G value={this.props.value} />

그러다가 value라는 이름을 anotherValue라는 이름으로 바꾸는 일이 발생한다면?
파일 3개를 열어서 다 수정해야만 할 것이다.


리덕스를 쓰면, 상태관리를 컴포넌트 바깥에서 한다.

리덕스를 사용하면 상태값을 컴포넌트에 종속시키지 않고, 상태 관리를 컴포넌트 바깥에서 관리 할 수 있게 된다.

그림으로 설명하자면 다음과 같은 구조이다.
B에서 일어나는 변화가 G에 반영된다고 가정해보자.

스크린샷 2021-10-21 오후 11 45 46

리덕스를 프로젝트에 적용하게 되면 이렇게 스토어 라는 녀석이 생긴다.
리덕스에는 한 어플리케이션당 하나의 스토어를 만들게 된다. 스토어 안에는, 현재의 앱 상태와, 리듀서가 들어가 있고, 추가적으로 몇가지 내장 함수들이 있다.

스크린샷 2021-10-21 오후 11 45 55

G 컴포넌트는 스토어에 구독(subscribe)을 한다. 구독을 하는 과정에서 특정 함수가 스토어한테 전달이 된다. 그리고 나중에 스토어의 상태값에 변동이 생긴다면 전달 받았던 함수를 호출해준다.

구독은 스토어의 내장함수 중 하나이다. subscribe 함수는, 함수 형태의 값을 파라미터로 받아온다. subscribe 함수에 특정 함수를 전달해주면, 액션이 디스패치 되었을 때 마다 전달해준 함수가 호출된다.

액션과 디스패치는 아래에서 설명할 예정이다.

리액트에서 리덕스를 사용하게 될 때 보통 이 함수를 직접 사용하는 일은 별로 없다. 그 대신 react-redux라는 라이브러리에서 제공하는 connect 함수 또는 useSelector Hook을 사용하여 리덕스 스토어의 상태를 구독한다.

스크린샷 2021-10-21 오후 11 46 02

이제 B 컴포넌트에서 어떤 이벤트가 생겨서, 상태를 변화 할 일이 생겼다. 이 때 dispatch 라는 함수를 통하여 액션을 스토어한테 던져준다.
액션은 상태에 어떠한 변화가 필요하게 될 땐, 우리는 액션이란 것을 발생시킨다. 이는, 하나의 객체로 표현된다.
액션 객체는 필수적으로 type이라는 값을 가지고 있어야 한다.
예를 들어 {type: ‘INCREMENT’} 이런 객체를 전달 받게 된다면, 리덕스 스토어는 상태에 값을 더하는 액션을 참조하게 된다.

추가적으로, 상태값에 2를 더해야 한다면, 이러한 액션 객체를 만들게 된다.

{type: 'INCREMENT', diff: 2}   

그러면, 나중에 이 diff 값을 참고해서 기존 값에 2를 더하게 될 것이다. type을 제외한 값은 선택적(optional)인 값이다.

위의 dispatch에 대해서 좀더 설명하자면, 스토어의 내장 함수 중 하나이다.
dispatch는 액션을 발생시키는 것이라고 이해하면 된다. dispatch라는 함수에는 액션을 파라미터로 전달한다.
그렇게 호출을 하면, 스토어는 리듀서 함수를 실행시켜서 해당 액션을 처리하는 로직이 있다면 액션을 참고하여 새로운 상태를 만들어 준다.

스크린샷 2021-10-21 오후 11 46 10

액션 객체를 받으면 전달받은 액션의 타입에 따라 어떻게 상태를 업데이트 해야 할지 정의를 해줘야 할 것이다.
이러한 업데이트 로직을 정의하는 함수를 리듀서라고 부른다.
이 함수는 나중에 우리가 직접 구현 해야 한다. 예를 들어 type이 INCREMENT라는 액션이 들어오면 숫자를 더해주고, DECREMENT라는 액션이 들어오면 숫자를 감소시키는 그런 작업을 여기서 하면 된다.

리듀서 함수는 두가지 파라미터를 받는다.

  • state: 현재상태
  • action: 액션 객체

그리고, 이 두가지 파라미터를 참조하여, 새로운 상태 객체를 만들어서 이를 반환한다.
만약 카운터를 위한 리듀서를 작성한다면 다음과 같이 작성할 수 있다.

function counter(state, action) {
  switch (action.type) {
    case 'INCREASE':
      return state + 1;
    case 'DECREASE':
      return state - 1;
    default:       // 기존 state 그대로 반환   
      return state;
  }
}

스크린샷 2021-10-21 오후 11 46 17

상태에 변화가 생기면, 이전에 컴포넌트가 스토어한테 구독 할 때 전달해줬었던 함수 listener가 호출된다.
이를 통하여 컴포넌트는 새로운 상태를 받게되고, 이에 따라 컴포넌트는 리렌더링을 하게 된다.


리덕스의 3가지 규칙

리덕스를 프로젝트에서 사용하게 될 때 알아두고, 꼭 지켜야할 3가지 규칙이 있다.

1. 하나의 어플리케이션 안에는 하나의 스토어가 있다.

하나의 어플리케이션에선 단 한개의 스토어를 만들어서 사용한다. 여러개의 스토어를 사용하는 것은 사실 가능하기는 하나, 권장되지는 않는다. 특정 업데이트가 너무 빈번하게 일어나거나, 어플리케이션의 특정 부분을 완전히 분리시키게 될 때 여러개의 스토어를 만들 수도 있다. 하지만 그렇게 하면, 개발 도구를 활용하지 못하게 된다.

2. 상태는 읽기 전용이다.

리액트에서 state를 업데이트 해야 할 때, setState를 사용하고, 배열을 업데이트 해야 할 때는 배열 자체에 push를 직접하지 않고, concat 같은 함수를 사용하여 기존의 배열은 수정하지 않고 새로운 배열을 만들어서 교체하는 방식으로 업데이트를 한다. 엄청 깊은 구조로 되어 있는 객체를 업데이트를 할 때도 마찬가지로, 기존의 객체는 건드리지 않고 Object.assign을 사용하거나 spread연산자(…)를 사용하여 업데이트를 하곤 한다.

리덕스에서도 마찬가지이다. 기존의 상태는 건들이지 않고 새로운 상태를 생성하여 업데이트 해주는 방식으로 해주면, 나중에 개발자 도구를 통해서 뒤로 돌릴 수도 있고 다시 앞으로 돌릴 수도 있다.

리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경되는 것을 감지하기 위하여 shallow equality 검사를 하기 때문이다.
이를 통하여 객체의 변화를 감지 할 때 객체의 깊숙한 안쪽까지 비교를 하는 것이 아니라 겉핥기 식으로 비교를 하여 좋은 성능을 유지할 수 있는 것이다.

더 자세한 내용은 리액트의 불변함, 그리고 컴포넌트에서 Immutable.js 사용하기 포스트를 참고하자.

3. 변화를 일으키는 함수, 리듀서는 순수한 함수여야 한다.

순수한 함수, 라는 개념은 다음과 같다.

  • 리듀서 함수는 이전 상태와, 액션 객체를 파라미터로 받는다.
  • 이전의 상태는 절대로 건들이지 않고, 변화를 일으킨 새로운 상태 객체를 만들어서 반환한다.
  • 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과값을 반환해야만 한다.

3가지 사항을 주의해야 한다. 동일한 input이라면 언제나 동일한 output이 있어야 한다.
그런데 일부 로직들 중에서는 실행할 때마다 다른 결과값이 나타날 수 있다. new Date()를 사용한다던지..랜덤 숫자를 생성한다던지.. 혹은, 네트워크에 요청을 한다던지!
이러한 작업은 결코 순수하지 않은 작업이므로, 리듀서 함수의 바깥에서 처리해줘야 한다.
그런것을 하기 위해서, 리덕스 미들웨어를 사용하곤 한다. 이에 대해서는 나중에 다룰 예정이다.


정리

위 내용을 정리해보면, React만을 사용할 때는 부모에서 자식의 방향으로만 상태가 전달이 되었다면 리덕스를 사용하면 스토어를 사용하여 상태를 컴포넌트 구조의 바깥에 두고, 스토어를 중간자로 두고 상태를 업데이트 하거나, 새로운 상태를 전달받는다.
따라서, 여러 컴포넌트를 거쳐서 받아올 필요없이 아무리 깊숙한 컴포넌트에 있다 하더라도 직속 부모에게서 받아온 것 처럼 원하는 상태값을 골라서 props를 편하게 받아올 수 있다.


Reference

https://velopert.com/3528
https://min9nim.vercel.app/2020-04-23-redux-saga/