본문으로 건너뛰기

Redux

무료2017-06-24#Front-End#JS#Redux入门#Redux guide#Redux与Flux#Flux与Redux#Redux vs Flux

Flux에서 Redux까지

1. 역할

Flux와 마찬가지로 상태 관리 레이어로서 단방향 데이터 흐름에 강력한 제약을 부여합니다.

2. 출발점

MVC에서는 데이터(Model), 표현 계층(View), 로직(Controller) 사이에 명확한 경계가 있지만, 데이터 흐름은 양방향이며 이는 대규모 애플리케이션에서 특히 두드러집니다. 사용자 입력이나 내부 API 호출과 같은 하나의 변화가 애플리케이션의 여러 상태에 영향을 미칠 수 있으며, 예를 들어 양방향 데이터 바인딩은 유지보수와 디버깅이 어렵습니다.

하나의 모델이 다른 모델을 업데이트할 수 있다면, 뷰가 모델을 업데이트하고, 그 모델이 또 다른 모델을 업데이트하여 결국 또 다른 뷰의 업데이트를 유발할 수 있습니다. 결과적으로 특정 시점에 애플리케이션에서 정확히 어떤 일이 일어났는지 알기 어렵습니다. 언제, 왜, 어떻게 상태 변화가 일어났는지 알 수 없기 때문입니다. 시스템이 불투명해지면 버그를 재현하거나 새로운 기능을 추가하기가 매우 힘들어집니다.

강제적인 단방향 데이터 흐름을 통해 복잡성을 낮추고, 유지보수성과 코드 예측 가능성을 높이고자 하는 것이 Redux의 출발점입니다.

3. 핵심 이념

Redux는 *하나의 불변 상태 트리(Immutable State Tree)*로 전체 애플리케이션의 상태를 관리합니다. 직접 변경할 수 없으며, 변화가 필요할 때는 actionreducer를 통해 새로운 객체를 생성합니다. 구체적인 내용은 다음과 같습니다.

  • 애플리케이션의 상태 객체에는 setter가 없으며 직접 수정을 허용하지 않습니다.
  • dispatch action을 통해서만 상태를 수정할 수 있습니다.
  • reducer를 통해 actionstate를 연결합니다.
  • 상위 reducer가 하위 reducer들을 조직하여 reducer 트리를 형성하고, 단계별 계산을 통해 최종 state를 얻습니다.

함수형 reducer가 핵심입니다.

  • 작음 (단일 책임 원칙)
  • 순수함 (부작용이 없으며 환경에 영향을 주지 않음)
  • 독립적 (환경에 의존하지 않으며, 고정된 입력에 대해 고정된 출력을 반환함. 테스트가 용이하여 주어진 입력에 따른 반환값이 올바른지만 확인하면 됨)

순수 함수 제약은 강력한 디버깅 기능을 실현하게 해줍니다 (그렇지 않으면 상태 회복/롤백은 거의 불가능합니다). DevTools를 통해 변화를 정밀하게 추적할 수 있습니다.

  • 현재 state, 히스토리 action 및 그에 대응하는 state 표시
  • 특정 action을 건너뛰어 버그 상황을 신속하게 조합 (수동 준비 불필요)
  • 상태 초기화(Reset), 커밋(Commit), 롤백(Revert)
  • 핫 로딩(Hot Loading)을 통해 reducer 문제를 파악하고 수정한 내용을 즉시 반영

4. 구조

action  Flux와 마찬가지로 이벤트이며, type과 data(payload)를 가집니다.
        마찬가지로 수동으로 action을 dispatch합니다.
---
store   Flux와 기능은 같지만, 전역에 오직 하나만 존재하며 내부적으로는 하나의 불변 상태 트리입니다.
        action을 분배하고 listener를 등록합니다. 각 action은 층층이 쌓인 reducer를 거쳐 새로운 state가 됩니다.
---
reducer arr.reduce(callback, [initialValue])와 유사한 역할을 합니다.
        reducer는 callback에 해당하며, 현재 state와 action을 입력받아 새로운 state를 출력합니다.

reducer 개념은 Node.js의 미들웨어나 Gulp 플러그인과 유사합니다. 각 reducer는 상태 트리의 작은 부분을 담당하며, 일련의 reducer들을 직렬로 연결하여 (이전 reducer의 출력을 현재 reducer의 입력으로 사용) 최종 출력 state를 얻습니다.

reducerstate를 수정할 때마다 새로운 state 객체가 생성됩니다. 이전 값은 원래의 참조를 유지하고, 새로운 값이 생성되는 방식입니다.

엄격한 단방향 데이터 흐름:

                  call             new state
action --> store ------> reducers -----------> view

action 또한 최상위의 모든 reducer에게 전달되며 (Flux와 유사), 해당 서브 트리로 흘러 들어갑니다.

store는 조율을 담당합니다. 먼저 action과 현재 statereducer 트리로 전달하여 새로운 state를 얻고, 현재 state를 업데이트한 뒤 뷰에 업데이트를 알립니다 (React의 경우 setState()).

action

action은 어떤 일이 일어났는지 설명하는 역할을 합니다 (뉴스 헤드라인과 같습니다).

actionaction creator는 각각 전통적인 eventcreateEvent()에 대응합니다. action creator가 필요한 이유는 이식성과 테스트 가능성 때문입니다.

설계상 action creatorstore를 분리한 것은 서버 사이드 렌더링을 고려한 것입니다. 이렇게 하면 각 요청이 독립적인 store를 가지게 되며, 외부에서 action creatorstore를 바인딩합니다.

주의: 실무에서는 action 생성과 dispatch action을 분리해야 합니다. 필요한 상황(예: 자식 컴포넌트에 전달할 때 dispatch를 감추고 싶은 경우)을 위해 Redux는 이 둘을 묶어주는 bindActionCreators를 제공합니다.

또한, 비동기 시나리오를 고려해 봅시다.

  • action

    하나의 비동기 작업에는 3개의 action(또는 3가지 상태를 가진 1개의 action)이 필요할 수 있습니다. 시작/성공/실패에 따라 각각 로딩 표시/로딩 숨김 및 새 데이터 표시/로딩 숨김 및 에러 메시지 표시와 같은 UI 상태에 대응합니다.

  • view 업데이트 타이밍

    비동기 작업이 종료된 후, dispatch action을 통해 state를 수정하고 view를 업데이트합니다.

    여러 비동기 작업의 순서 문제를 고민할 필요가 없습니다. action 히스토리 기록을 보면 순서가 고정되어 있으며, 동기적 과정에서 dispatch 되었는지 비동기 과정에서 되었는지는 중요하지 않기 때문입니다.

동기 시나리오와 큰 차이는 없으며 단지 action이 좀 더 많을 뿐입니다. 일부 미들웨어(redux-thunk, redux-promise 등)는 비동기 제어를 형태적으로 더 우아하게 만들어줄 뿐, dispatch action 관점에서는 차이가 없습니다.

reducer

구체적인 상태 업데이트를 담당합니다 (action에 따라 state를 업데이트하여 action의 설명을 사실로 만듭니다).

Flux와 비교했을 때, Redux는 event emitter 대신 순수 함수인 reducer를 사용합니다.

  • 분해와 조합

    reducer를 쪼개어 상태를 분해하고, 다시 reducer들을 조합하여 (combineReducers() 유틸리티 함수) 상태 트리를 형성합니다. reducer 조합은 Redux 애플리케이션에서 매우 흔한 기본 패턴입니다.

    보통 하나의 reducer를 유사한 reducer 그룹으로 쪼개거나 reducer factory를 추상화합니다.

  • 단일 책임

    reducer는 전체 상태의 일부분만 책임집니다.

순수 함수 reducer의 구체적인 제약(함수형 프로그래밍의 순수 함수 개념과 동일)은 다음과 같습니다.

  • 인자를 수정하지 않습니다.
  • 단순한 계산만 수행하며, 라우팅 전환과 같은 다른 API 호출 등 부작용(Side Effect)을 섞지 않습니다.
  • 출력값이 입력값뿐만 아니라 환경에도 영향을 받는 순수하지 않은 메서드(예: Math.random(), new Date())를 호출하지 않습니다.

또한 reducerstate와 밀접한 관련이 있습니다. statereducer 트리의 계산 결과이므로, 전체 애플리케이션의 state 구조를 먼저 설계해야 합니다. 여기 몇 가지 유용한 팁이 있습니다.

  • state를 데이터 상태와 UI 상태로 나눕니다.

    UI 상태는 컴포넌트 내부에 유지할 수도 있고 상태 트리에 붙일 수도 있지만, 데이터 상태와 UI 상태를 구분하는 것을 고려해야 합니다.

    (단순한 시나리오나 로컬 UI 상태 변화는 store의 일부로 관리하기보다 컴포넌트 레벨에서 유지하는 것이 나을 수 있습니다.)

  • state를 데이터베이스처럼 생각합니다.

    복잡한 애플리케이션의 경우 state를 데이터베이스로 간주하고, 데이터를 저장할 때 인덱스를 생성하며 연관 데이터 간에는 ID를 통해 참조하도록 합니다. 이렇게 하면 독립성이 보장되고 상태 중첩을 줄일 수 있습니다. (중첩된 상태는 state 서브 트리를 점점 거대하게 만들지만, 데이터 테이블 + 관계 테이블 방식은 그렇지 않습니다.)

Store

actionreducer를 조직하고 listener를 지원하는 접착제 역할을 합니다.

세 가지 일을 담당합니다.

  • state를 보유하며 읽기 및 쓰기를 지원합니다 (getState()로 읽고, dispatch(action)로 씁니다).
  • action을 받았을 때 reducer를 스케줄링합니다.
  • listener를 등록/해제합니다 (상태가 변할 때마다 트리거됨).

5. 3가지 기본 원칙

애플리케이션 전체는 하나의 state 트리로 구성됩니다.

덕분에 다른 버전의 state를 생성(히스토리 보존)하기 쉽고, redo/undo 기능을 구현하기도 매우 쉽습니다.

state는 읽기 전용입니다.

  • 오직 action을 트리거해야만 state를 업데이트할 수 있습니다.
  • 변경 사항이 집중되고 엄격한 순서대로 발생합니다 (주의해야 할 경쟁 상태가 없습니다).
  • action은 평범한 객체이므로 로그를 기록하거나 직렬화할 수 있으며, 나중에 재생(디버깅/테스트)할 수도 있습니다.

reducer는 순수 함수여야 합니다.

stateaction을 입력받아 새로운 state를 출력합니다. 항상 새로운 객체를 반환하며 입력받은 state를 유지(수정)하지 않습니다.

따라서 reducer 실행 순서를 자유롭게 조정할 수 있으며, 영화를 보는 듯한 디버깅 제어가 가능해집니다.

6. react-redux

Redux는 React와 직접적인 관련이 없습니다. Redux는 상태 관리 레이어로서 Backbone, Angular, React 등 어떤 UI 솔루션과도 함께 사용할 수 있습니다.

react-redux는 new state -> view 부분을 처리합니다. 즉, 새로운 state가 생겼을 때 어떻게 뷰를 동기화할지를 다룹니다.

container

Flux와 마찬가지로 containerview 개념이 있습니다.

container는 뷰 로직을 포함하지 않고 store와 밀접하게 연결된 특수한 컴포넌트입니다. 로직상으로는 store.subscribe()를 통해 상태 트리의 일부를 읽어와 하위의 일반 컴포넌트(view)에게 props로 전달하는 역할을 합니다.

connect()

신기해 보이는 API로, 주로 세 가지 일을 합니다.

  • dispatchstate 데이터를 하위 일반 컴포넌트에 props로 주입합니다.
  • 가상 DOM 트리에 자동으로 container들을 삽입합니다.
  • 불필요한 업데이트를 방지하는 성능 최적화가 내장되어 있습니다 (shouldComponentUpdate 내장).

7. Redux와 Flux

공통점

  • 모델 업데이트 로직을 별도의 레이어로 추출했습니다 (Redux의 reducer, Flux의 store).
  • model을 직접 업데이트하는 것을 허용하지 않으며, 모든 변화를 action으로 설명하도록 요구합니다.
  • (state, action) => state라는 기본 접근 방식이 일치합니다.

차이점

  • Redux는 구체적인 구현체이고, Flux는 패턴입니다.

    Redux는 하나뿐이지만, Flux는 수십 가지의 구현체가 존재합니다.

  • Redux의 state는 하나의 트리입니다.

    Redux는 애플리케이션 상태를 하나의 트리에 매달아 관리하며 전역에 오직 하나의 store만 가집니다. 반면 Flux는 여러 개의 store를 가지며 상태 변경을 이벤트로 브로드캐스트하고, 컴포넌트는 이 이벤트를 구독하여 현재 상태를 동기화합니다.

  • Redux에는 dispatcher 개념이 없습니다.

    이벤트 트리거가 아닌 순수 함수에 의존하기 때문입니다. 순수 함수는 자유롭게 조합할 수 있어 별도의 순서 관리가 필요 없습니다. Flux에서는 dispatcheraction을 모든 store에 전달하는 책임을 집니다.

  • Redux는 state를 수동으로 수정하지 않는다고 가정합니다.

    이는 도덕적 제약으로, reducer 내부에서 state를 수정해서는 안 됩니다 (새 속성 추가는 가능하나 기존 속성 수정은 불가). 강력한 제약으로 두지 않은 이유는 특정 성능 시나리오를 고려한 것이며, 기술적으로 순수하지 않은 reducer를 작성하여 해결할 수는 있습니다. 하지만 reducer가 순수하지 않으면 순수 함수 조합 특성에 의존하는 강력한 디버깅 기능이 파괴되므로 절대로 권장하지 않습니다. state에 불변 데이터 구조 사용을 강제하지 않는 것은 성능(불변 관련 추가 처리)과 유연성(const, immutablejs 등과 함께 사용 가능)을 고려한 것입니다.

8. 질문과 고찰

1. state 변화 구독 메커니즘의 입도(Granularity) 제어는 어떻게 되나요?

subscribe(listener)는 전역의 완전한 state만 받을 수 있는데, 그렇다면 React의 setState() 입도는 어떻게 결정되고 서브 트리는 어떻게 나뉘나요?

수동으로 처리합니다. state 트리에 어떤 변화라도 생기면 모든 listener에게 알림이 가고, listener 내부에서 자신이 관심 있는 작은 state 조각이 변했는지 수동으로 판단합니다. 즉, 구독 메커니즘은 분배를 담당하지 않으며 수동 분배가 필요합니다.

2. react-redux의 는 어떻게 작동하나요?

추측건대 hostContainerInfo를 통한 흑마법일 것입니다. (틀렸습니다.) 그래서 render root 시에 Provider를 최상위 컨테이너로 두어야 합니다.

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

hostContainerInfo는 이렇게 생겼습니다.

function ReactDOMContainerInfo(topLevelWrapper, node) {
  var info = {
    _topLevelWrapper: topLevelWrapper,
    _idCounter: 1,
    _ownerDocument: node ? node.nodeType === DOC_NODE_TYPE ? node : node.ownerDocument : null,
    _node: node,
    _tag: node ? node.nodeName.toLowerCase() : null,
    _namespaceURI: node ? node.namespaceURI : null
  };
  if ("development" !== 'production') {
    info._ancestorInfo = node ? validateDOMNesting.updatedAncestorInfo(null, info._tag, null) : null;
  }
  return info;
}

(ReactDOM v15.5.4 소스 코드에서 발췌)

가상 DOM 트리의 모든 컴포넌트가 hostContainerInfo를 공유하므로 모든 container에서 store에 접근할 수 있습니다. 예제 코드는 Usage with React를 참조하세요.

react-redux의 실제 구현

제 추측이 틀렸네요. 여기를 직접 보세요.

내부 인스턴스는 비공개 속성(랜덤 키 __reactInternalInstance&<random>)이므로 컴포넌트가 hostContainerInfo에 접근할 수 없습니다. 하지만 React는 깊은 곳까지 props를 수동으로 전달해야 하는 상황을 위해 context라는 강화된 버전hostContainerInfo를 제공합니다. 대략 이런 식입니다.

// Provider
class Provider extends React.Component {
    constructor(props) {
        super(props);
    }
    // 최상위에서 수동으로 전달받은 store prop을 context 속성으로 설정
    getChildContext() {
        return {store: this.props.store};
    }
    render() {
        return this.props.children;
    }
}

// container
class Container extends React.Component {
    // context에서 store를 꺼내어 container의 prop으로 만듦
    // 이제 container 내부에서 this.props.store로 store에 접근 가능
    getDefaultProps() {
        return {
            store: this.context.store;
        }
    }
}

마치 store가 최상위에서 모든 컴포넌트를 관통하는 것처럼 작동합니다. 기술적으로는 일반 컴포넌트(view, container가 아닌 경우)에서도 this.context.store를 통해 직접 store에 접근할 수 있지만 (왜냐하면 context는 하위로 무조건 자동 전달되어 제어가 불가능하기 때문), 이는 권장되지 않는 방식입니다.

P.S. context가 어디에 쓰이는지 몰랐는데 이제야 이해가 가네요.

3. 트리 구조(무한 계층 전개)는 어떻게 처리하나요?

전형적인 비즈니스 시나리오인 무한 계층 트리 구조의 처리 기법은 state를 데이터베이스처럼 생각하는 것에 있습니다 (앞서 언급한 팁입니다).

Redux의 이념에 따라 treenodes로 평탄화(Flatten)해야 합니다. 굵은 입도라면 nodeId - children 형태가 될 것이고, 미세한 입도라면 nodeId - node 형태가 됩니다 (childrenchildrenIdList가 되고, 다시 전체 ID 테이블을 조회하여 children을 얻는 방식입니다).

평탄화는 중첩된 상태보다 유지보수가 훨씬 쉽습니다. 만약 트리 컴포넌트가 하나의 tree 객체에 대응된다면 (node들이 모두 tree 안에 있는 경우), 거대한 트리의 일부만 업데이트하는 작업이 매우 고통스러울 것입니다.

P.S. 3NF(제3정규형)가 프런트엔드에 적용될 수 있다니, 정말 놀랍습니다!

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성