상태관리 Redux (1)

dante Yoon·2022년 1월 14일
2

redux

목록 보기
1/3
post-thumbnail

리덕스: 웹 페이지에서 관리하는 내부 상태가 복잡해짐에 따라 생긴 중앙 관리형 상태 관리 툴

웹의 상태 관리

웹의 상태 하나하나는 UI의 변경과 밀접하게 연관되어 있기 때문에 수백가지의 상태 변경 로직에 UI 변경을 하나한 매핑해주는 것은 굉장히 고단한 일이다.

바닐라 자바스크립트를 사용하거나 jQuery를 사용해 돔을 하나하나 조작하는 명령형 프로그래밍 방식에서는, 프로젝트가 고도화되고 협업하는 인원이 많아짐에 따라 팀 단위로 이해하고 공유해야할 코드의 수가 매우 많아지게 되며, (비즈니스 로직 + UI 조작 + HTML 태그) 자바스크립트를 이용해 핵심 로직을 작성하는 개발자에게 MVC 패턴을 지켜가며 변경이 용이한 애자일 소프트웨어 개발을 준수하는 것은 매우 힘든 일이 되게 된다.

현대의 웹 프로그래밍에서 프론트엔드 프레임워크라고 불리는 React는 위의 문제에서 상태와 돔 조작을 자동으로 매핑해주는 역할을 하며 MVC의 구분을 사용자가 엄격하게 구현해야할 이유가 없어졌다.
엄밀히 말하자면 웹 생태계의 복잡화는 가속화 되었으나 개발자는 오히려 기존의 MVC 구조를 잡는 데 더욱 많은 시간을 쏟는 것이 아닌, 새로운 팀원에게 어떻게하면 우리의 복잡한 프로젝트 구조를 잘 이해시켜줄 수 있을까에 대한 고민에 시간을 더 할애할 수 있게 되어, 보기 정갈하고 깔끔한 폴더 구조를 잡아보거나 실제 비즈니스 로직과 밀접하게 연관되어 있는 내부 상태 구조에 대해 더 깊은 고민할 수 있게 되었다.

리엑트, 앵귤러, 뷰와 같은 프레임워크는 내부적으로 Subscriber, Listener 의 역할을 나누어 내부 상태의 변경이 일어나면 자동으로 메모리에서 변경된 부분을 감지해 실제로 변화가 필요한 부분을 diffing algorithm을 이용해 실제 돔에 적용하는 가상 돔을 사용한다.

리엑트 생태계가 기존 웹 생태계에 생산성의 증대에 큰 기여를 한 부분이 바로 이 가상돔이며, 리엑트의 장점을 물어볼 때 리엑트는 가상돔을 쓰니까 무조건 바닐라 자바스크립트 보다 훨씬 빠르지롱 암튼 리엑트 짱짱임 과 같은 답변들은 공감 가능한 부분이 있으나 사실 DX(Developer Experience)측면에서 리엑트의 장점을 제대로 설명하지 못했다고 할 수 있다.

이벤트 버스 패턴

앞서 말한 상태 변화에 따른 자동 UI 변경은 DX 측면에서 리엑트가 생산성을 크게 기여하는 이유이다.
리엑트가 상태 변화를 파악하는 것은 이들을 구독 하고 있는 것인데, 발행구독은 웹 개발에서 매우 많은 비중을 차지한다고 본다. 사실상 싱글페이지 어플리케이션은 구독,발행 두 단어들의 합으로 이루어져 있다고 보아도 과언이 아니다.

모던 프론트엔드에서는 하나의 페이지를 작은 조각들로 나누어 작업하는데, 이러한 조각을 컴포넌트라 하며 각 컴포넌트는 본인이 맡은 UI를 제대로 렌더링해야 하는 책임을 가진 객체이다.

컴포넌트는 독립적으로 활동하기도 하지만 다수의 상황에서 서로 소통해야 하는데, 이때 컴포넌트 끼리의 커뮤니케이션을 하게 하는 여러 방법 중 한가지가 이벤트 버스 패턴을 구축하는 것이며, 이 패턴은 소프트웨어 공학의 여러 분야에 걸쳐 사용된다.

다음은 가장 간단한 구조의 이벤트 버스 패턴을 구현한 예제 코드이다.
장점은 이벤트를 구독한 모든 객체들에게 EventBus 객체의 notify를 호출하는 것으로 모든 구독자들에게 이벤트를 전달할 수 있다는 것이며, 단점은 발행된 데이터의 종류와 상관없이 모든 구독자들이 강제적으로 해당 데이터를 처리해야 한다는 것이다.

interface CustomEvent {
  type: string;
  data: string;
}

class EventBus {
  subscribers;
  
  notify(event: CustomEvent) {
    this.subscribers.forEach((subscriber) => {
      subscriber.callback(event);
    });
  }
}

프란세스코 스트라츨로가 지은 프레임워크 없는 프론트엔드 개발에서도 언급되었듯이, 리덕스의 아키텍처는 이벤트 버스 패턴과 매우 흡사하다.
앱 하나당 오직 한개가 존재하는 리덕스 스토어는 중앙 집중형으로 해당 스토어에 담긴 상태의 변경을 모든 컴포넌트에 전파할 수 있으며,
위에서 말한 단점을 리듀서라고 하는 필터링을 담당하는 객체를 이용하여 각 컴포넌트가 본인에게 관심있는 데이터만 받아 처리하게 함으로 보완한다.

리덕스와 이벤트 버스는 결국 모두 하나의 상태저장소에서 파생되는 새로운 state의 생성 이벤트를 각 컴포넌트에서 받게 한다는 점에서 동일하다.

이벤트 버스 아키텍처에서 이벤트 버스 객체와 이벤트 객체는 '객체'를 정의함에 있어 클래스를 사용하는 것이 일반적이나, 리덕스에서는 단일 함수를 이용해 각 상태들을 업데이트하고 전파한다.

소프트웨어 아키텍처면에서 과거부터 널리 알려졌던 이벤트 버스 아키텍쳐와 흡사함에도 불구하고 많은 사람들이 리덕스를 기피하는 이유는 작성해야 하는 보일러 플레이트 관련 코드가 매우 많기 때문이다. 하지만 그럼에도 불구하고 리덕스가 주는 상태 관리의 편리함과 이 기술을 이루는 각 구성요소가 어떤 원리로 굴러가는지 살펴보는 것은 싱글 페이지 웹 어플리케이션을 만드는 개발자의 입장에서 한번쯤 꼭 공부해볼만한 요소이다. 왜냐하면 리덕스는 어느날 갑자기 하늘에서 떨어진 운석과 같은 존재가 아닌, 오랜 기간 소프트웨어 공학에서 사용해오던 아키텍쳐를 차용하여 웹 생태계의 문제를 해결하기 위해 만들어진 잘 가공된 모범 사례이기 때문이다.

이제 리덕스의 기본적인 개념들과 규칙들을 살펴보자.
참고한 문서는 리엑트 공식 도큐먼트이, 예제 코드 또한 해당 페이지에서 안내하는 자료들에서 가져왔다.

The Core of Redux

리덕스는 플럭스 아키텍처가 기반이 되어 설계되었다. 설계된 목적은 make state mutations predictable 이며, 이를 완수하기 위해서 리엑트는 아래와 같은 규칙을 비강제적으로 세웠다.

Basic Redux principles

  • single source of truth: 앱 전체에서 상태 관리 저장소는 단일 저장소로 존재한다.
  • state is read-only: 상태를 변경하는 방법은 action을 reducer에 전파하는 것이 유일하다.
  • changes are made with pure functions: 상태 변경 로직을 정의하는 리듀서들은 순수함수로 작성되어야 한다.

위에 언급했지만 리덕스는 위의 세가지 규칙을 강제하지 않는다. 다만 위의 규칙을 따르는 것이 좋은 리덕스 스토어를 설계하는 지지대를 만들어 줄 것이다.

세번째 원칙에 대해 좀 더 이야기하면, 상태 변경을 예측하기 용이하기 위해 좋은 리덕스 설계를 위해서는 모든 상태 업데이트 로직을 동기적으로 동작하게 해야 한다. 이런 의미에서 상태 업데이트의 주체가 되는 리듀서는 모든 사이드이펙트가 제외된 순수함수로 작성이 되어야 한다.

플럭스 아키텍처와 동일하게 action 객체를 통해 상태를 업데이트 하는데, action.type의 값은 의미적으로 명료해야 한다. (semantic) 이 말인즉슨 심볼이나 숫자보다 문자열을 이용해 type을 만드는게 더 나은 선택이라는 것이다.

함수형 프로그래밍이 용이하게 설계되었다.

리듀서와 상태는 각각 순수함수, 불변성을 띄어야 한다. 이 두 단어는 FP에서 자주 등장하는 중요한 개념이다. 리덕스를 사용하기 위해 FP의 심오한 개념들 (monads, endofunctors)까지 알아야 하는 부담을 가질 필요는 없으나, 리덕스는 분명 함수형 프로그래밍의 개념들을 소개하고 있다.

테스트 코드를 작성하기 용이하게 설계되었다.

리듀서가 순수함수로 작성되었다 사실은 함수가 사이드이펙트에 의존하지 않음으로 독립적으로 테스트하기 용이하다는 장점을 준다.

createStore

createStore 함수의 목적을 분명하게 나타내면서 가장 간단한 형태로 구현하라고 한다면 다음과 같이 작성할 수 있다.

function createStore(reducer) {
  var state;
  var listeners = [];
  
  function getState() {
    return state
  }
  
  function subscribe(listener) {
    listeners.push(listener);
    return function unsubscribe() {
      var index = listeners.indexOf(listener);
      listeners.splice(index,1);
    }
  }
  
  function dispatch(action) {
    state = reducer(state,action);
    listeners.forEach(listener => listener());
  }
  
  dispatch({});
  
  return { dispatch, subscribe, getState }
}

코드를 보면, 앞서 보았던 이벤트 버스 예제에서 나타나는 subscribe, listener들이 동일한 개념으로 존재함을 알 수 있다. 리덕스는 리엑트와 함께 사용되어야 한다는 조건이 있는 것도 아니고, 그저 소프트웨어 문제를 해결하기 위한 하나의 도구이다. 해당 링크는 바닐라 자바스크립트에서 작성된 리덕스 코드이다.

createStore에서 action(액션)은

  • 순수한 객체이어야 하며 (object literal)
  • action 객체의 type 키값이 참조하는 값은 undefined가 되어서는 안된다.
    type은 액션들이 서로 다르다는 것을 reducer에 알려주는 유일한 정보이기 때문에 undefined가 되어서는 안되며 각기 다른 값을 가지고 있어야 한다.

combineReducers

여러 리듀서들을 하나로 모으는 모듈이다. 전달되는 리듀서들은 unknown 타입을 가진 액션에 적절하게 대처하도록 undefined를 반환하면 안된다. 리듀서에서 반환되는 객체들은 기존 상태 값들과 reference equality 비교를 해야 함으로 리듀서에서 가공되는 상태들은 기존 상태를 변경하는 것이 아닌 새로운 상태 값으로 반환해야 하며, 상태 변경이 필요 없을 때는 이전 상태를 그대로 반환한다.

connect

리덕스 스토어가 이벤트 버스 역할을 해 새로운 상태를 반환했을때 이를 UI 컴포넌트가 감지할 수 있게 바인딩을 해주는 모듈이다.
앱의 상태 값을 UI 컴포넌트와 바인딩 한다고 했을때 react hook에서 제공하는 contextAPI와 동일하다고 생각할 수 있지만, 실제 컴포넌트가 사용하지 않은 어떤 상태의 업데이트가 일어났을 때 리렌더링이 필요하지 않다면 생략하게 해주는 리덕스와 다르게 contextAPI는 Provider로 래핑된 자식 컴포넌트라면 무조건 리렌더링이 일어난다. 리덕스가 contextAPI 보다 성능적으로 우수한 부분을 보이게 하는 부분이 이 connect 모듈이다.

앞서 말한 리덕스의 reference equality check가 이 connect 모듈에서 행해진다.
상태 변경을 위해서는 dispatch라고 하는 함수에 action과 payload를 담아서 호출해야 하는데, 이 행위를 dispatch 한다고 한다. action이 dispatch 되면 이 스토어를 구독하고 있는 subscribers에게 알람이 간다. connect 함수는 루트 상태 객체가 바뀌었는지 확인하고, 변경점이 보이지 않으면, 상태가 변경되지 않았다고 판단하고 리렌더링을 야기시키지 않는다. connect 함수에서 reference equality check가 일어나기 때문에 combinedReducers에 속한 리듀서들은 상태가 변경되지 않았을 경우, 기존 상태 객체를 그대로 반환해야 한다.

connect 함수를 컴포넌트에 바인딩 하는 모습은 다음과 같다.
참고 문서

const SomeComponent = () => {
  ... 
}

const mapStateToProps = (state, ownProps) => {
 	return {
      count: state.countMap[ownProps.id];
}
  
const mapDispatchToProps = (dispatch, ownProps) => {
  return {
    increment: () => dispatch({type: "INCREMENT"}),
    decrement: () => dispatch({type: "DECREMENT}),
    reset: () => dispatch({type: "RESET"}),
}
  
export default connect(mapStateToProps, mapDispatchToProps)(SomeComponent);

mapStateToProps

함수가 connect 함수에 제공될 경우, wrapper component는 리덕스 스토어의 업데이트를 구독하기 시작한다. mapStateToProps에서 반환하는 값은 컴포넌트 props와 병합되기 때문에 plain object이어야 한다. 해당 컴포넌트가 스토어 구독을 하지 않아도 된다면, mapStateToProps 대신 nullish한 값을 넣어주면 된다.

mapStateToProps는 위에서 보는 것과 같이 최대 두개의 인자를 가질 수 있다. 보다시피 state, ownProps는 적절하게 네이밍이 되었기에 부가설명을 하지 않아도 되지만, 인자 갯수에 따라 mapStateToProps가 호출 시점에서 차이점이 생긴다. 대단히 중요한 부분이나 튜토리얼을 할때 흔히 언급되지 않는 부분이니 주의하자.

  • 첫 인자인 state만 있을 경우, 스토어의 상태가 변경될때마다 호출된다.
  • 두번째 인자인 ownProps 또한 있을 경우, 스토어 상태 혹은 컴포넌트 props가 변경될 때마다 호출된다.

mapDispatchToProps

는 예제 코드에서 객체를 리턴하는 함수로 정의되었지만, 객체로도 정의될 수 있다.
mapStateToProps가 생략되어도 컴포넌트는 props로 dispatch를 받아온다.

const mapDispatchToProps = {
  addTodo,
  deleteTodo,
  toggleTodo,
}

export default connect(null, mapDispatchToProps)(TodoApp)

위의 코드와 같이 객체로 전달될 경우, 내부적으로 bindActionCreators를 호출해 전달된 mapStateToProps와 dispatch를 결합한다.

// internally, React-Redux calls bindActionCreators
// to bind the action creators to the dispatch of your store
bindActionCreators(mapDispatchToProps, dispatch)


> mapStateToProps, mapDispatchToProps는 어떻게 함수가 정의 됨에 따라 두번째 인자인 ownProps를 받아올 수 있는지 여부가 달라진다.

```js
function mapStateToProps(state) {
  console.log(state) // state
  console.log(arguments[1]) // undefined
}

const mapStateToProps() {
  console.log(arguments[0]) // state
  console.log(arguments[1]) // ownProps
}

const mapStateToProps(state, ownProps) {
  console.log(state) // state
  console.log(ownProps) // ownProps
}

const mapStateToProps(...args) {
  console.log(args[0]) // state
  console.log(args[1]) // state
}

mergeProps

connect 함수의 세번째 인자로 mergeProps가 올 수 있는데, mapStateToProps의 반환 값을 mergeProps의 두번째 인자로 조회할 수 있다.
connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(SomeComponent)

connect로 감싸진 컴포넌트는 부모 컴포넌트에서 전달하는 props와 mapStateToProps, mapDispatchToProps에서 전달하는 props에 접근할 수 있는데, mergeProps는 컴포넌트가 최종적으로 접근 가능한 props를 결정하는 역할을 한다.

stateProps, dispatchProps, ownProps를 인자로 받을 수 있으며 컴포넌트 내부에서는 mergedProps로 컴포넌트 내부에서 조회 가능하다.

options

  • context
  • pure: 디폴트로 true 값을 가지고 있다. ContextAPI의 경우 Provider로 둘러싸인 내부 컴포넌트는 부모 컴포넌트에서 전달되는 props의 변화와 상관없이 Provider에 전달된 context가 변경되면 해당 컴포넌트 또한 리렌더링되는데, 이를 막기 위해 pure component로 만들어야 한다. 리덕스는 이러한 pure component를 디폴트로 만들어주므로 성능 면에서 장점을 가진다.
  • pure 옵션은 삭제되었다. 프로덕션이 아닌 환경에서 pure 옵션을 사용하면 에러가 나올 것이다. 약 4개월 전부터react-redux의 connect 함수는 항상 pure 옵션이 사용된다. *
  • areStatesEqual: reference equality check을 하기 때문에 디폴트는 (next, prev) => prev === next 이다. deep equality check이나 성능면에서 장점을 얻기위해 다른 비교 방식이 필요하다면 이 옵션을 사용해야 한다.
// 1.
const areStatesEqual = (next, prev) => prev.entities.todos === next.entities.todos;

// 2. 리듀서 내부에서 상태 변경을 하는 impure reducer function을 만들 경우, connect 함수에게 이 부분을 명시해줘야 한다. 다음처럼 작성한다면, action dispatch의 종류에 상관없이 언제나 리렌더링을 하게 된다.
const areStatesEqual = () => false
  • areOwnPropsEqual: 기본적으로 컴포넌트에 전달되는 props는 shallow equality check를 한다.
    컴포넌트에 전달되는 특정 props에 대해 whitelist를 작성해야 할때 mapStateToProps, mapDispatchToProps, mergeProps와 함께 쓰인다.

connect 함수 배치하기

루트 컴포넌트에 connect 함수를 배치하는 것은 리덕스 스토어의 상태 변경에 따라 항상 컴포넌트 전체가 리렌더링 되기 때문에 좋지 않은 선택이다. 실제 스토어의 내부 상태를 사용하는 여러 컴포넌트에 나누어 connect 함수를 배치하는 것이 좋은 선택이다.

다음은 공식 문서의 일부분이다.

import * as actionCreators from './actionCreators'

export default connect(null, actionCreators)(TodoApp)

Don’t do this! It kills any performance optimizations because TodoApp will rerender after every state change. It’s better to have more granular connect() on several components in your view hierarchy that each only listen to a relevant slice of the state.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글