티스토리 뷰

Web/React

[React] 리덕스를 이용한 상태관리

풍요로운 해구름 2021. 7. 4. 01:58

Redux는 자바스크립트 앱의 State 관리 프레임워크입니다. 리덕스를 사용함으로서 리액트에서 State 변이와 State 전달에 있어 복잡성을 낮추어 주며, 리액트에서 데이터 로직과 비지니스 로직을 손쉽게 분리할 수 있는 방법이기도 합니다. 또한 지루하고 반복적이고 복잡한 앱의 테스트와 디버깅을 쉽게 만들어주며 테스트 코드를 통해 테스트를 자동화할 수 있게 도와줍니다.

리덕스 사용 시 준수해야 할 3원칙

  • 전체 State를 하나의 객체로 관리
    State를 하나의 객체로 관리함으로써 State 변화를 쉽게 추적하고 관리 할 수 있게 만들어줍니다. 리액트 프로그램을 특정 State로 손쉽게 재현할 수 있게 되며 테스트나 디버깅에 용이함을 제공합니다. 프로그램의 State 변화를 히스토리 처럼 관리할 수 있게 되며 실행취소나 다시실행과 같은 기능을 구현할 수 있게 됩니다. 또한 State를 직렬화하여 언제든지 State를 저장하고 언제든지 복구 할 수 있게 됩니다.
  • State는 불변객체로 관리해야 함
    State변경을 위해 State를 직접 수정해서는 안되며, 새로운 State 객체를 재생성하는 방법으로 State를 변경해야 합니다. 새로운 State 객체를 생성하는 방법은 당장은 번잡해 보이지만, 이를 통해 State를 비교하기 용이해지며 특정 State로 재현하거나 State변화 히스토리를 관리할 수 있게 만들수 있습니다.
  • State는 순수함수로 변경해야 함
    순수함수란 동일한 매개변수를 주었을 때 항상 같은 값을 리턴하는 함수를 말합니다. 또한 함수 내부에서 함수 외부에 존재하는 데이터나 State에 접근하고 수정하는 등의 Side-Effect를 발생시켜서는 안됩니다. 순수함수의 원칙을 지킴으로써 State변화를 쉽게 예측할 수 있게 되며, 테스트 코드 작성이 용이해지며, 특정 State의 재현이 단순해지는 것입니다.

리덕스의 State변화

리덕스에서 State를 변경하려면 Action 객체를 생성해야 합니다. Action은 아래와 같은 흐름을 거치며 State를 변경시키게 됩니다.

Action
Middleware
Reducer
Store
 

Action

Action은 리덕스가 수행해야 할 작업(동작)을 표현하는 객체입니다. Action에는 type 속성이 반드시 포함되어야 하며, 그 외에 State 변경에 필요한 속성을 포함할 수 있습니다. Action을 생성하여 리덕스의 dispatch() 메서드에 전달하면 리덕스는 State를 변경시키게 됩니다.

store.dispatch({ type:'user/ADD', email:'user@redux.com' });

Type 속성에는 Action을 구분하는 유일 값을 지정해야하며 여러 곳에서 재사용되기 때문에 상수로 선언해서 사용합니다. 또한 함수를 통해 Action을 생성하는 것이 일반적입니다.

export const ADD = 'user/ADD';
export const REMOVE = 'user/REMOVE';

export function addUser({ userId, email }) {
    return { type:ADD, userId, email };
}
export function removeUser({ userId }) {
    return { type:REMOVE, userId };
}

Middleware

Middleware는 Reducer가 Action을 처리하기 전에 호출되는 함수입니다. Middleware를 통해 로그를 기록하거나, 오류가 발생하면 서버로 전송하거나, 에러페이지로 라우팅하는 동작을 하거나, Action의 실행을 중단시킬 수도 있습니다. Middleware는 아래와 같은 구조를 가집니다.

const middleware = store => next => action => {
    ... 실행될 작업 ...

    return next(action);
}

위 함수를 쉽게 풀어서 작성하면 아래와 동일합니다.

const middleware = function(store){
    return function(next){
        return function(action){
            ... 실핼될 작업 ...
            
            return next(action);
        }
    }
}

next(action)코드를 통해 다음 Middleware가 실행되며 모든 미들웨가 실행되면 Reducer를 통해 Action이 스토어에 반영됩니다. 만약 next(action) 호출을 생략하면 남아있는 Middleware가 실행되지 않으며 스토어에 Action이 반영되지 않습니다. 이러한 Middleware 호출 구조를 그림으로 표현하면 아래와 같습니다.

return middleware1(action) ←Middleware1 실행
return middleware2(action) ←Middleware2 실행
return middleware...N(action) ←MiddlewareN 실행
return reducer(action) ←스토어에 Action반영

위의 그림과 같은 Middleware 호출구조를 명확하게 이해하는 것이 중요합니다. Middleware는 고차함수이며, 함수를 전달받아 기능을 더한 후 함수를 반환하는 구조를 가집니다.

로그를 기록하는 Middleware를 작성하면 다음과 같습니다. Middleware는 applyMiddleware() 함수를 사용하여 Store에 반영하게 됩니다.

//Middleware 1: Action 반영 전후로 로그를 출력
const showLog = store => next => action => {
    console.log('Middleware (' + action.type + ') start');
    const nextAction = next(action);    
    console.log('Middleware (' + action.type + ') end');

    return nextAction;
}

//Middleware 2: Action 실행 중 오류를 로그로 출력
const showError => store => next => action => {
    try { return next(action); }
    catch(ex) { console.log('Middleware error: ' + ex.toString()); }
}

//Store 생성 시 Middleware 반영
const store = createStore(reducer, applyMiddleware(showLog, showError));
store.dispatch({ type:'ADD', email:'example@react.com' });

Reducer

입력받은 Action을 바탕으로 새로운 State를 생성하는 함수를 Reducer라고 합니다. Reducer는 순수함수로 작성해야 하며 Side-Effect를 포함해서는 안됩니다.

//최초 State 값
const initialState = {
    users:[]
};

//Reducer 작성
function reducer(state = initialState, action) {
    //action.type에 따라 새로운 state 생성
    switch(action.type) {
        case 'ADD': return {
            ...state,
            users:[
                ...state.users,
                { email:action.email }
            ],
        }
        case 'REMOVE': return {
            ...state,
            users:state.users.filter(t => t.email !== action.email),
        },
        case 'CLEAR': return {
            ...state,
            users:[],
        };

        //변경사항이 없으면 state 그대로 반환
        default: return state;
    }
}

//Reducer를 바탕으로 Store 생성
const store = createStore(reducer);

리덕스의 3원칙에 따르면 State는 불변객체로 관리해야합니다. 따라서 Reducer에서는 직접 State를 수정하지 않고 새로운 State를 생성하여 반환합니다. 하지만 State 구조가 복잡해질수록 새로운 State 생성이 복잡해집니다. 이를 위해 Immer(이머) 패키지를 사용합니다.

import produce from 'immer';

const state = {
    users: [
        { id:1, name:'user1', jobTitles:[ 'Sales Manager' ] },
        { id:2, name:'user2', jobTitles:[ 'Project Manager', 'Engineer' ] }
    ],
};

//Immer를 통한 상태 변경
const newState = produce(users, draft => {
    var user = draft.users.find(user => user.id === 1);
    user.jobTitles.push('Account Manager');
});

//Immer를 사용하지 않는 방법
const newState2 = {
    ...state,
    users: users.map(user =>
        user.id === 1 
            ? { ...user, jobTitles: [ ...user.jobTitles, 'Acccount Manager' ] }
            : { ...user, jobTitles: [ ...user.jobTitles ] }
    ),
};

Reducer 함수는 구조가 복잡해질 수 밖에 없습니다. 이를 간결하게 만들어주는 패키지들이 존재하며 redux-toolkit 혹은 typesafe-actions 패키지를 주로 사용합니다. 이 패키지에 포함된 createReducer() 함수를 사용하면 Reducer 생성을 간단히 할 수 있습니다. createReducer()함수는 내부적으로 Immer를 사용하고 있으며 아래와 같이 심플하게 Reducer를 작성할 수 있습니다.

//createReducer를 통한 Reducer 생성
const reducer = createReducer(initialState, {
  ['ADD']: (state, action) => state.users.push(action.user),
  ['REMOVE']: (state, action) => (state.users = state.users.filter(user => user.id !== action.id)),
})

Redux Toolkit을 추가하지 않더라도 아래와 같이 직접 createReducer()를 정의하여 사용할 수 있습니다.

import produce from 'immer';

function createReducer(initialState, handlerMap) {
    return function(state = initialState, action){
        return produce(state, draft => {
            const handler = handlerMap[action.type];
            if(handler) {
                handler(draft, action);
            }
        });
    };
}

Store

리덕스에서 State 값을 포함하고 있는 저장소 객체입니다. Store는 크게 4가지 함수를 제공합니다.

  • dispatch(action) : Store에 저장된 State를 변경하기 위한 메서드입니다.
  • subscript(listener) : State가 변할 때 마다 호출될 이벤트를 등록합니다.
  • getState() : Store에 포함되어 있는 State를 반환합니다.
  • replaceReducer(nextReducer) : Store에 지정되어 있는 Reducer 함수를 변경합니다.

리덕스 3원칙 중 전체 State를 하나로 관리해야 한다는 원칙이 있지만 기술적으로는 여러 개의 State를 만들 수 있습니다. 하지만 단순함을 유지하고 생산성을 높이기 위해 하나의 State 객체로 관리할 것을 권장하고 있습니다.

State를 나누어 관리하기

프로그램의 크기가 커지면 State 관리 코드를 여러 파일로 분산하여 작성하는 것이 일반적입니다. 리덕스에서는 Action 생성 함수와 Reducer함수를 여러 파일로 분산하여 관리할 수 있습니다.

combineReducer() 함수를 사용하면 여러 개의 Reducer 함수를 하나의 Store에 지정할 수 있습니다.

 

 

 

 

 

 

'Web > React' 카테고리의 다른 글

[React] ASP.NET Core React Project Template 사용  (0) 2021.07.23
[React] package.json  (0) 2021.07.15
[React] npm 설치 중 이슈 - fsevents  (0) 2021.07.07
[React] 리덕스를 이용한 상태관리  (0) 2021.07.04
[React] 클래스형 컴포넌트  (0) 2021.06.23
[React] useEffect()  (0) 2021.06.22
[React] useEffect()와 Side-Effect  (1) 2021.06.18
댓글
댓글쓰기 폼