React 에러 처리 try catch 와 redux 그리고 mobx

반응형

가장 기본적으로 javascript 의 기본 Error 객체의 구조는 다음과 같다.

 

new Error() 해보면.
message, name, code 정도가 있다 실제로는 message 와 name 만 있다. name 은 기본 Error 이고 message 는 기본 '' 이다.

 

리액트에서 에러 처리에 대한 고려는 곧 redux 를 사용할 것인가 라는 고려까지 하게 만든다. 그래서 전체적인 구성에 있어서 큰 영향을 미치는 사안이다. 리액트의 useState, useEffect, useContext, useMemo, useCallback, useReducer 등과 같은 훅과 함께 react-redux 의 useDispatch, useSelector 훅은 너무나도 보편적으로 잘 사용되고 있기에 리덕스를 사용하는 것이 정석적임은 분명해 보인다.

 

컴포넌트 단에서 서버에 로그인 요청을 하고 그 응답으로 이미 존재하는 아이디 라는 응답을 받거나 로그인이 제대로 되었다는 응답을 받거나 또는 500 에러 같은 것을 받았을 경우를 생각해 보자. async await 함수로 구성된 로직을 try catch 로 감싸서 reject 이거나 예기치 않은 에러일 경우 catch 단에서 처리하는 것이 보통의 쓰레드 기반의 imperative 명령형 프로그래밍의 코딩 방식일 것이다. 이렇게 비동기적인 서버의 응답이 도착하였을 때 그 응답에 따라 UI 단에서 메시지를 보여줄 수 있다면 얼마나 편할 것인가? 하지만 이미 리액트를 사용한다는 것은 다른 글에서 React 와 함수형 프로그래밍 FunctionalProgramming vs OOP  단에서 설명했듯 함수형 프로그래밍 패러다임을 중점으로 적용한다는 의미이다. 쓰레드를 사용하는 명령형 방식과 같이 하나의 로직의 실타래를 위한 공간이 전적으로 주어져서 순차적으로 if, switch 할 수 있는 방식이 아니라 이미 핵심이 되는 상태 변화에 대한 로직은 순수함수로 넣어놓고 비동기 결과에 대한 if, switch 와 같은 side effect 들은 그것을 처리하기 위해 따로 다루는 방식으로 구현된다.

 

비동기적 응답에 대한 조건적인 처리를 하나의 쓰레드 안에서 순차적으로 제어할 수 있다면 개발 하는 입장에서는 매우 편리하다. 하지만 이러한 명령형 방식은 멀티 쓰레드 프로그래밍에서의 세마포어와 같이 공유자원에 대한 제어에 있어 매우 난해하게 만든다. 하지만 명령형 함수형 프로그래밍의 경우 공유자원(상태) 를 중심으로 순수함수와 불변변수를 사용하도록 하기에 이러한 문제를 매우 단순화 시켜줄 수 있다.

 

따라서 리액트 컴포넌트 단에서도 useEffect 를 사용하여 속성값, 상태값을 제외한 다른 외부 효과 들은 분리 시키듯 리덕스의 전역 상태에 대해서도 리덕스의 store 를 제외한 다른 외부 효과들은 saga, thunk, observable 과 같은 것을 사용하여 분리 시킨다.

 

try catch 를 사용하여 컴포넌트 단에서 서버 응답의 조건에 따라 코딩할 수 있다면 편리할 수 있지만 전체적인 맥락에서 리액트와 리덕스를 사용하는 한 이런 방식은 불가능하다. 즉 리액트 컴포넌트에서 액션을 dispatch 하였고 해당 액션이 saga, thunk 의 미들웨어에서 다루어질 액션 type 이라할 때 saga, thunk 에서 이를 try catch 로 감싸고 그 안에서 어떠한 처리를 할 수는 있지만 컴포넌트 단에서 그 모든 제어의 흐름을 try catch 로서 계층적으로 가질 수는 없다. 따라서 서버 응답의 조건에 따른 처리 결과 또한 하나의 상태로 만들어 관리하는 것이 좋다.

 

export default function TimelineMain() {
  const dispatch = useDispatch();
  const timelines = useSelector(state => state.timeline.timelines);
  const isLoading = useSelector(state => state.timeline.isLoading);
  const error = useSelector(state => state.timeline.error);
  
  // ...
  
    return (
    <div>
      <button onClick={onAdd}>타임라인 추가</button>
      <TimelineList timelines={timelines} onLike={onLike} />
      {isLoading && <p>전송 중...</p>}
      {!!error && <p>에러 발생: {error}</p>}
    </div>
  )
}

 

import { createReducer, createSetValueAction, setValueReducer } from "../../common/redux-helper";

// action type 과 action creator 객체로 묶어준다. saga 에서 사용하기 위함.
export const types = {
  ADD: 'timeline/ADD',
  REMOVE: 'timeline/REMOVE',
  EDIT: 'timeline/EDIT',
  INCREASE_NEXT_PAGE: 'timeline/INCREASE_NEXT_PAGE',
  REQUEST_LIKE: 'timeline/REQUEST_LIKE',
  ADD_LIKE: 'timeline/ADD_LIKE',
  SET_LOADING: 'timeline/SET_LOADING',
  SET_VALUE: 'timeline/SET_VALUE',
};


export const actions = {
  addTimeline: timeline => ({ type: types.ADD, timeline }),
  removeTimeline: timeline => ({ type: types.REMOVE, timeline }),
  editTimeline: timeline => ({ type: types.EDIT, timeline }),
  increaseNextPage: () => ({ type: types.INCREASE_NEXT_PAGE }),
  requestLike: timeline => ({ type: types.REQUEST_LIKE, timeline }),
  addLike: (timelineId, value) => ({ type: types.ADD_LIKE, timelineId, value }),
  setLoading: isLoading => ({
    type: types.SET_LOADING,
    isLoading,
  }),
  setValue: createSetValueAction(types.SET_VALUE),
};

// reducer 들.
const INITIAL_STATE = { timelines: [], nextPage: 0 , isLoading: false, error: '' };
const reducer = createReducer(INITIAL_STATE, {
  [types.ADD]: (state, action) => state.timelines.push(action.timeline),
  [types.REMOVE]: (state, action) => (state.timelines = state.timelines.filter(timeline => timeline.id !== action.timeline.id)),
  [types.EDIT]: (state, action) => {
    const index = state.timelines.findIndex(timeline => timeline.id === action.timeline.id);
    if (index >= 0) state.timelines[index] = action.timeline;
  },
  [types.INCREASE_NEXT_PAGE]: (state, action) => (state.nextPage += 1),
  // types.REQUEST_LIKE 는 saga 쪽에서 사용할 것임.
  [types.ADD_LIKE]: (state, action) => {
    const timeline = state.timelines.find(item => item.id === action.timelineId);
    if (timeline) {
      timeline.likes += action.value;
    }
  },
  [types.SET_LOADING]: (state, action) => (state.isLoading = action.isLoading),
  [types.SET_VALUE]: setValueReducer,
});
export default reducer;

 

import { all, call, put, takeLeading } from 'redux-saga/effects';
import { actions, types } from './index';
import { callApiLike } from '../../common/api';

export function* fetchData(action) {
  // put 은 리덕스 액션을 발생시키는 함수.
  yield put(actions.setLoading(true));
  // callApiLike 하기 전에 positive 방식 (API 성공했다고 사정하고 미리 반영하는 방식)으로 like 카운트를 하나 증가시키는 것.
  yield put(actions.addLike(action.timeline.id, 1));
  yield put(actions.setValue('error', ''));
  try {
    // call 이펙트는 함수를 실행시켜주는 함수.
    yield call(callApiLike);
  } catch (error) {
    yield put(actions.setValue('error', error));
    yield put(actions.addLike(action.timeline.id, -1));
  }
  yield put(actions.setLoading(false));
}

export default function* () {
  // takeLeading 이펙트는 아직 처리되고 있는 액션이 있을 때 그 사이에 들어온 액션은 무시가 된다.
  // 그 처음에 들어온 액션에 우선 순위를 높게 줘서 처리를 하는 것.
  yield all([takeLeading(types.REQUEST_LIKE, fetchData)]);

  // takeLatest 는 뒤에 들어온 것에 우선순위를 더 높게 준다. 처리 중인 것을 취소시키고 새로 들어온 것을
  // 다시 처리하는 것.
  // yield all([takeLatest(types.REQUEST_LIKE, fetchData)]);
}

 

import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
import timelineReducer from '../timeline/state';
import timelineSaga from '../timeline/state/saga';
import friendReducer from '../friend/state';
import createSagaMiddleware from 'redux-saga';
import { all } from 'redux-saga/effects';

const reducer = combineReducers({
  timeline: timelineReducer,
  friend: friendReducer,
});
// window.__REDUX_DEVTOOLS_EXTENSION__?.() 이 미들웨어는 redux dev tools 크롬 확장 프로그램 사용하기 위한 것.
// const store = createStore(reducer, window.__REDUX_DEVTOOLS_EXTENSION__?.());

const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  reducer,
  composeEnhancers(applyMiddleware(sagaMiddleware)),
);

function* rootSaga() {
  yield all([timelineSaga()]);
}
sagaMiddleware.run(rootSaga);

export default store;

 

// 예외처리.
// 50% 의 확률로 예외가 발생할 수 있는 상황을 만듬.
export function callApiLike() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() * 10 < 5) {
        resolve();
      } else {
        reject('callApiLike 실패');
      }
    }, 1000);
  });
}

 

 

따라서 컴포넌트 안에서 try catch 를 통해 하나의 제어 흐름으로 개발하기 위해 mobx 를 사용한다던가 하는 것은 그 동기에 있어서는 잘못된 방법이다. redux 와 mobx 의 차이라 하면 redux 의 store (observable) 는 상태변화 (event) 를 emit (dispatch) 할 때 구독 중인 listener (subscriber, observer) 가 불리는 것이고 mobx 의 store (observable) 은 상태변화 (event) 를 emit (proxy trap) 할 때 구독 중인 listener (subscriber, observer) 가 불린다는 것이다. 중요한 차이는 redux 는 상태변화를 시키고자 할 때 dispatch 를 명시적으로 부르도록 강제하는데 mobx 는 proxy object 로 감싸서 상태변화를 감지하는 방식으로 돌아간다는 점이다.

 

redux 라는 건 action 과 reducer 를 통한 historical 한 전역 store 관리 라이브러리이고 mobx-state-tree 는 action 과 historical 한 부분에 대해서는 관심을 많이 두지 않지만 좀 더 캡슐화에 중점을 둔 것이다. 하지만 리덕스가 더 직관적이고 명확하며 side effect 를 확실하게 분리시키기 때문에 더 나아보인다. mobx 의 경우 side effect 를 분리시키는 라이브러리가 존재하는지 아직 찾아보지는 않았다. 기존 함수형 프로그래밍 관련 글에서도 언급했듯이 추상화 캡슐화를 위해 전역 상태 관리의 무결성을 훼손하면 안된다.

 

여러 계층적 구조를 가지는 UI 에 있어 리덕스와 같이 전역 상태를 두고 관리하는 것이 좋으며 전역 상태의 공유 자원 관리를 오류 없이 할 수 있는 것이 매우 중요하여 상태와 그 상태를 변화시키는 순수함수/불변변수로 구성된 리듀서 그리고 그 외의 부수효과를 구분하여 처리하는 것이 필요하다.

 

리덕스 미들웨어를 사용하여 에러처리를 이런식으로도 해줄 수 있다.

import { createStore, applyMiddleware } from 'redux';
// 예외 발생 시 서버로 예외 정보를 전송하는 미들웨어
const reportCrash = store => next => action => {
  try {
    return next(action);
  } catch (err) {
    // 서버로 예외 정보 전송
  }
};

 

express 의 미들웨어 또한 리덕스의 미들웨어와 매우 유사한 형태를 가지고 있다. 하지만 express 의 try catch 에러 처리에 대한 부분은 리액트 리덕스의 경우와 다르게 비동기 응답의 조건에 따른 처리를 하나의 계층적 try catch 흐름으로 제어가 가능하다. async await 나 Promise 의 경우 Promise 의 에러나 reject 의 경우 에러로 간주되어 catch 되기 때문에 이렇게 로직 구성을 하면 충분히 원하는 식으로 구성이 가능하다. 아마도 Router 단 이후부터의 로직이 이렇게 구성될 것이다. 리액트 리덕스와 다른 점은 리액트와 리덕스는 전역 상태 공유 자원에 대한 고려로 함수형 프로그래밍 패러다임을 중점적으로 로직 구성을 하기 때문에 하나의 쓰레드 흐름의 제어 보다 전역 상태 공유 자원의 무결성을 더 중요하게 생각하기 때문이다. 이는 동시다발적인 비동기 이벤트 속에서 수많은 공유될 수 있는 상태값들이 존재하기 때문이며 UI 에서는 이것이 매우 중요하다. UI 컴포넌트는 상태와 함수를 가지고 있는 것 이라고 볼 수도 있다.

 

express 의 try catch 에러 처리 그리고 async await Promise 를 고려한 로직의 흐름의 구성을 어떻게 해야하는가에 대해서는 

express 와 async await 그리고 error 처리    글을 참고.

 

 

 

 

반응형

댓글

Designed by JB FACTORY