Redux 이해하기 - (2)

cansweep·2022년 9월 14일
post-thumbnail

Redux Terms and Concepts

State Management

function Counter() {
  // State: a counter value
  const [counter, setCounter] = useState(0)

  // Action: code that causes an update to the state when something happens
  const increment = () => {
    setCounter(prevCounter => prevCounter + 1)
  }

  // View: the UI definition
  return (
    <div>
      Value: {counter} <button onClick={increment}>Increment</button>
    </div>
  )
}

위 코드는 세 가지로 구성되어 있다.

  • state : 앱을 구동하는 근원
  • view : 현재 state를 기반으로 한 UI의 선언적 설명
  • actions : 사용자 입력에 따라 발생하는 이벤트, state의 업데이트를 트리거 함


state, view, actions의 흐름은 단방향으로 이루어진다.

1. state는 특정 시점의 앱의 상태를 나타내며 UI는 state를 기반으로 렌더링된다.
2. 특정 이벤트(ex. 유저가 버튼을 누름)가 발생하면 그에 따라 state가 업데이트 된다.
3. UI는 새로운 state를 기반으로 리렌더링 한다.

하지만 동일한 state를 공유하는 여러 컴포넌트가 있을 경우, 특히 컴포넌트들이 앱의 여기저기에 존재하는 경우 단순성이 무너질 수 있다.

이 경우 상태 끌어올리기(lifting state up)로 해결할 수 있으나 이것이 항상 해결법이 되는 건 아니다.

문제를 해결할 수 있는 한 가지 방법은 컴포넌트가 공유하고 있는 state를 추출한 뒤 컴포넌트 트리 바깥쪽에 두는 것이다.
이를 통해 컴포넌트 트리는 "view"가 되고 컴포넌트들은 컴포넌트 트리에서의 그들의 위치와 관계없이 state에 접근하거나 action을 발생시킬 수 있다.

이처럼 상태 관리에 관련된 개념을 정의, 분리하고 view와 state 간의 독립성을 유지하는 규칙을 적용하여 코드에 구조와 유지 가능성을 제공한다.

이것이 Redux의 기본 아이디어이다.
즉, 앱의 전역 상태를 포함하는 중앙 집중식 단일 장소와 코드를 예측 가능하게 만들기 위해 state를 업데이트할 때 따라야 하는 특정 패턴이다.

Immutability

기본적으로 자바스크립트의 객체나 배열은 Mutable하다.
변경 전과 후의 메모리 참조는 동일하지만 그 안의 값을 변경할 수 있다는 것이다.

값을 변경할 수 없도록 업데이트하려면 기존 객체나 배열의 복사본을 만든 후 복사본의 값을 변경해야 한다.
보통 이 때 자바스크립트의 spread operator를 사용한다.

이것을 말하는 이유는, Redux가 모든 state의 업데이트를 immutable하게 이루어지길 원하기 때문이다.

Immutability, 불변성의 이점은 변경되지 않는 데이터가 변경할 가능성이 있는 데이터보다 추론하기 쉽기 때문에 앱의 성능을 향상할 수 있고 디버깅과 프로그래밍을 단순화할 수 있다는 것이다.

특히 웹에서는 DOM을 업데이트하는 프로세스의 비용이 비싸기 때문에 반드시 필요한 경우에만 DOM을 업데이트하는 것이 좋다.

따라서 Immutabe한 업데이트를 사용하여 변경 사항을 감지하도록 하고 이에 따라 필요한 경우에만 변경 사항을 적용하는 것이다.

또한 Redux는 얕은 동등성 검사를 시행한다.
Redux의 combineReducers는 호출하는 reducer로 인한 변경 사항을 얕게 확인한다.

만약 이때 Mutable한 업데이트를 사용한다면 함수에 전달된 객체가 변경되는지 여부를 감지하기 위해 얕은 동등성 검사를 시행할 수 없다.

이것이 Redux에서는 문제가 되지는 않지만 React-Redux와 같이 store에 의존하는 라이브러리에서는 문제가 된다.

특히 combineReducers에 묶인 reducer 함수에 mutable한 객체가 주어졌다면 reducer 안에서 직접적으로 이를 수정하고 반환할 수 있다.

하지만 위의 경우 항상 얕은 동등성 검사를 통과한다.
변경 전과 후 동일한 객체에서 안의 값만 변경되는 것이기 때문에 변경 후에도 동일한 객체라고 알고있기 때문이다.

변경이 감지되지 않으니 구독한 UI 컴포넌트들에 변경사항이 전달되지 않고 state가 업데이트되었음에도 새로운 데이터를 사용해 리렌더링할 수 없다.

마지막으로 Redux에 불변성이 필요한 이유는 Time-travel debugging을 위해서이다.

지난 포스팅에서 Redux는 Redux DevTools Extension이라는 패키지를 제공하고 DevTools를 사용하여 시간이 지남에 따라 Redux 저장소의 상태가 어떻게 변화하는지를 알 수 있다고 했었다.

이렇게 Time-travel debugging을 사용하기 위해서는 state의 변경 사항을 기록해둘 필요가 있고 또 이를 기록하기 위해 reducer는 순수함수여야 한다.

Terminology

먼저, Redux가 동작하는 원리를 알아보기 위해 알아야 할 개념들이 존재한다.

Actions

action은 type 필드를 가지고 있는 자바스크립트의 객체이다.
action을 앱에서 일어나는 이벤트를 설명하는 이벤트로 생각해도 된다.

type 필드는 action을 설명할 수 있는 문자열이다.
주로 "domain/eventName"의 형식을 사용한다. (ex. "todos/todoAdded")

action 객체는 type 외에도 발생한 이벤트에 대한 추가 정보를 가지고 있을 수 있으며 필드명으로 payload를 많이 사용한다.

const addTodoAction = {
  type: 'todos/todoAdded',
  payload: 'Buy milk'
}

Action Creators

action creator는 action 객체를 생성하고 반환하는 함수이다.
일반적으로 action을 따로 정의할 필요없이 아래와 같이 사용한다.

const addTodo = text => {
  return {
    type: 'todos/todoAdded',
    payload: text
  }
}

Reducers

reducer는 현재 state와 action 객체를 받아 state를 업데이트하는 방법을 결정하고 필요한 경우 새로운 state를 반환하는 함수이다. (state, action) => newState
reducer를 action type에 따라 이벤트를 처리하는 이벤트 리스너라고 생각해도 된다.

reducer는 아래의 특정 규칙을 따른다.

  • state와 action을 기반으로 오직 새로운 state를 계산하는 로직만이 존재해야 한다.
  • 기존의 state를 수정할 수 없다. 대신, state의 복사본을 수정하는 immutable update를 수행할 수 있다.
  • 비동기 로직이거나 랜덤한 값을 계산하거나 기타 부작용이 없어야 한다.

부작용(side effect)은 함수에서 값을 리턴하는 것 외에 일어나는 다른 것들을 말한다.
예를 들어, console.log()를 사용하거나 파일을 저장하거나 HTTP 요청을 보내는 것들이 있다.

일반적으로 reducer 함수 내부의 로직은 비슷한 단계를 거친다.

1. reducer와 action이 관련있을 경우
   - state의 복사본을 만들고 복사본을 수정하여 새로운 state를 만든 후 리턴한다.
2. reducer와 action이 관련없을 경우
   - 기존 state를 변경하지 않고 리턴한다.

action의 type에 따라 각각의 state를 리턴하기 위해 if/else문, switch문 등을 사용한다.

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
  // Check to see if the reducer cares about this action
  if (action.type === 'counter/increment') {
    // If so, make a copy of `state`
    return {
      ...state,
      // and update the copy with the new value
      value: state.value + 1
    }
  }
  // otherwise return the existing state unchanged
  return state
}

Store

store는 현재 state를 저장하고 있는 객체이다.
store는 reducer를 전달하며 생성되고 현재 state 값을 반환하는 getState라는 메서드를 가진다.

store는 어플리케이션의 전역 state를 저장하는 컨테이너이다.
좀 더 정확히 말하자면 store는 현재 state를 저장하는, 몇 가지 특수한 함수와 기능을 가지는 자바스크립트 객체이다.

store 내부의 state를 직접적으로 수정하거나 변경해서는 안되며, 변경이 필요할 때 state를 업데이트하는 유일한 방법은 어플리케이션에서 일어난 이벤트를 설명하는 action 객체를 만든 다음 store에 일어난 이벤트를 전달하는 것이다.

store에 일어난 이벤트를 전달하는 방법은 store의 dispatch() 메서드를 호출해 action 객체를 넘기는 것이다.

Dispatch

dispatch는 store의 메서드 중 하나이다.

action이 전달되면 store는 reducer 함수를 실행하고 이전 state와 전달받은 action을 기반으로 새로운 state를 계산, 반환한다.
그리고 이 새로운 state를 getState() 메서드를 통해 확인할 수 있다.

const increment = () => {
  return {
    type: 'counter/increment'
  }
}

store.dispatch(increment())

console.log(store.getState())

Selectors

selector는 store의 state에서 특정 정보를 추출하는 함수이다.
앱이 커질 수록 앱의 여러 부분에서 동일한 데이터를 읽어야 하므로 반복되는 로직을 피하는데 도움을 줄 수 있다.

const selectCounterValue = state => state.value

const currentValue = selectCounterValue(store.getState())
console.log(currentValue)

Redux Application Data Flow

  • 초기 설정
    1. Redux의 store는 root reducer 함수를 사용하여 생성된다.
    2. store는 root reducer 함수를 한 번 호출하고, 초기 값을 가지는 state를 생성한다.
    3. UI가 처음 렌더링되면 UI 컴포넌트는 store의 현재 state에 접근하고 해당 데이터를 사용하여 렌더링할 항목을 결정한다.
    4. 그리고 state가 변경되었는지 알기 위해 store를 구독한다.
  • 변경이 있을 때
    1. 사용자의 상호작용이 발생한다.
    2. dispatch는 action을 Redux store에 전달한다.
    3. store는 이전 state와 action을 reducer 함수에 전달해 새로운 state로 수정한다.
    4. 이후 store는 구독한 UI 컴포넌트들에 변경이 있어났음을 알린다.
    5. UI 컴포넌트들은 화면을 그리는데 필요한 데이터가 변경되었는지 확인한다.
    6. 데이터가 변경되었다면 새로운 데이터를 사용하여 리렌더링한다.

📎 관련 링크

Redux 공식문서 - Redux 핵심
Redux FAQ: Immutable Data
++ 22.09.17 내용 추가(store)
Redux 공식문서 - Redux 기반(Overview)
++ 22.09.19 내용 추가(reducer)
Redux 공식문서 - Redux 기반(State, Actions, and Reducers)

profile
하고 싶은 건 다 해보자! 를 달고 사는 프론트엔드 개발자입니다.

0개의 댓글