-
Notifications
You must be signed in to change notification settings - Fork 28
Redux 계열 라이브러리 활용하기
redux에는 미들웨어라는 개념이 존재한다. Context API 에서의 useReducer 에서 사용했던 리듀서 함수를 사용한다. 리덕스의 미들웨어를 사용하면 액션 객체가 리듀서에서 처리되기 전에 원하는 작업을 수행할 수 있다.
- 특정 조건에 따라 액션이 무시되게 만들 수 있음
- 액션을 콘솔에 출력하거나, 서버쪽에 로깅을 할 수 있음
- 액션이 디스패치 됐을 때 이를 수정해서 리듀서에게 전달되도록 할 수 있음
- 특정 액션이 발생했을 때 이에 기반하여 다른 액션이 발생되도록 할 수 있음
- 특정 액션이 발생했을 때 특정 자바스크립트 함수를 실행시킬 수 있음
미들웨어는 주로 비동기 작업을 처리하기 위해 사용한다. 이번 프로젝트에서는 비동기 제어를 쉽게 다루고, 테스트하고, 실패에 대응할 수 있게 도와주는 라이브러리인 redux-saga를 사용하였다.
Context API 와 useReducer 를 사용하면 Context를 새로 만들고, Provider 설정을 하고, useContext와 같은 Hook을 사용했다. react-redux에서는 비슷한 작업을 편하게 해주는 여러 기능이 존재한다.
connect 함수를 사용하면 리덕스의 상태 생성 또는 액션 함수를 컴포넌트의 props로 받아올 수 있고, useSelector, useDispatch , useStore 와 같은 Hooks를 사용하면 편리하게 상태를 조회하고 액션을 디스패치 할 수 있다.
또한 connect 또는 useSelector 함수를 사용하면 선택한 상태가 바뀔때만 컴포넌트가 리렌더링된다. 반면에 Context API 에서는 그러한 최적화가 되어 있지 않기 때문에 Context가 가지는 상태가 바뀌면 해당 Context의 Provider 내부 컴포넌트들이 모두 리렌더링된다.
Context API 를 사용하여 글로벌 상태 관리를 한다면 일반적으로 기능별로 Context를 만들어서 사용한다. 반면 리덕스는 글로벌 상태를 하나의 커다란 상태 객체에 넣어서 사용하는 것이 필수이다. 때문에 매번 Context를 새로 만드는 수고로움을 덜 수 있다.
마찬가지로 reducer도 하나, 이를 호출하는 dispatch도 하나이다.
리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경 되는 것을 감지하기 위하여 shallow equality 검사를 하기 때문이다. 이를 통하여 객체의 변화를 감지 할 때 객체의 깊숙한 안쪽까지 비교를 하는 것이 아니라 겉핥기 식으로 비교를 하여 좋은 성능을 유지할 수 있는 것이다.
- 리듀서 함수는 이전 상태와, 액션 객체를 파라미터로 받음
- 이전의 상태는 절대로 변경하지 않고, 새로운 상태 객체를 만들어서 반환
- 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과값을 반환
- 상태 업데이트를 하는 것이 목적으로, 내부에서 비동기 작업과 같은 side-effect를 발생시켜서는 안 됨
client/src/store/originalVideo/reducer.ts
import {
FETCH_START,
SET_VIDEO,
...
} from './actionTypes';
import { OriginalVideoAction } from './actions';
export interface OriginalVideoState {
video: File;
URL: string;
name: string;
length: number;
message: Message;
}
const initialState: OriginalVideoState = {
video: null,
URL: null,
name: '',
length: 0,
message: Message.OK,
};
export default ( // 이것이 reducer
state: OriginalVideoState = initialState,
action: OriginalVideoAction
): OriginalVideoState => {
switch (action.type) {
case FETCH_START:
return { // 새로운 객체를 만들어서 반환
...state,
message: Message.DOWNLOADING,
};
case SET_VIDEO:
return { // 내용이 필요하면 action.payload에 담는 것이 관례
video: action.payload.video, // ...action.payload로도 작성 가능
URL: action.payload.URL,
name: action.payload.name,
length: state.length,
message: Message.LOADING,
};
...
default:
return state; // 다른 종류의 action에는 반응하지 않음한 프로젝트에 여러 개의 리듀서가 있는 경우 이를 하나의 리듀서로 합쳐서 사용한다. 합쳐진 리듀서를 루트 리듀서라고 한다.
리듀서를 합치는 작업은 리덕스에 내장되어있는 combineReducers라는 함수를 사용
client/src/store/reducer.ts
import { combineReducers } from 'redux';
import originalVideo, { OriginalVideoState } from './originalVideo/reducer';
export interface RootState {
originalVideo: OriginalVideoState; // selector에서 사용
}
const reducers = {
originalVideo, // state.originalVideo.* 로 접근
};
export default combineReducers(reducers);미들웨어로 redux-saga를 추가하는 코드는 아래와 같다.
client/src/store/index.ts
import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import reducer from './reducer';
import rootSaga from './sagas';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(rootSaga);
export default store;client/src/App.tsx
import React from 'react';
import { Provider } from 'react-redux';
import store from '@/store';
import Thumbnail from '@/components/atoms/Thumbnail';
...
import GlobalStyle from '@/theme/globalStyle';
'@/components/organisms/VideoContainer';
const App: React.FC = () => {
return (
<Provider store={store}> // 여기서 전역으로 제공
<GlobalStyle />
...
<Thumbnail />
</Provider>
);
};
export default App;동시에 관리해야 할 값들을 하나의 상태 객체로 묶어 아래와 같은 구성으로 만드는 것이 권장되는 방법의 한 가지이다.
-
actions.ts: dispatch의 인자로 넘겨줄 action을 생성하는 함수들 -
actionTypes.ts: reducer에서 사용하는 action의 type 모음 -
reducer.ts: 각 action 종류별로 새로운 상태를 만들어 반환하는 함수 -
sagas.ts:redux-saga를 사용하는 경우 비동기 로직 처리 -
selectors.ts: state에서 특정 field(s)에 접근하는 함수들
현재 프로젝트에서 originalVideo 스토어의 코드를 살펴보자.
reducer.ts는 위에 reducer 설명하는 곳,
sagas.ts는 아래에 redux-saga 설명하는 곳에 있다.
client/store/originalVideo/actions.ts
import {
SET_VIDEO,
...
ERROR,
} from './actionTypes';
// actions
export const setVideo = (video: File, URL: string) => ({
type: SET_VIDEO,
payload: {
video,
URL,
name: video.name,
},
});
...
export const error = () => ({ type: ERROR });
// type of actions
type SetVideoAction = {
type: typeof SET_VIDEO; // 이렇게 하면 reducer 각 case에서 타입 인식
payload: {
video: File;
name: string;
URL: string;
};
};
...
type ErrorAction = {
type: typeof ERROR;
};
export type OriginalVideoAction =
| SetVideoAction
| ...
| ErrorAction;client/store/originalVideo/actionTypes.ts
export const SET_VIDEO = 'original/SET_VIDEO'; // 디버깅에 도움
...
export const ERROR = 'original/ERROR';client/store/selectors.ts
현재 프로젝트에서는 selector의 종류가 많거나 로직이 크게 복잡하지 않을 것 같아 store root 디렉토리에서 하나의 파일로 모아 두기로 하였다.
selector를 만들고 import해서 사용하면 똑같은 property를 여러 component에서 접근하는 데 store 구조가 변경되는 경우에도 하나의 파일만 수정하면 되는 이점이 있다.
또한, todo App에서 완료된 것만 보고 싶다고 할 때 그 목록을
const getCompletedTodos = state => state.todos.filter(todo => todo.completed)
로 접근하는 것과 같이 복잡한 로직을 selector로 분리하면 코드의 가독성도 좋아진다.
import { RootState } from './reducer';
// originalVideo
export const getName = (state: RootState) => state.originalVideo.name;
export const getInfo = (state: RootState) => {
const { URL, length } = state.originalVideo;
return { URL, length };
};
export const getFile = (state: RootState) => state.originalVideo.video;
export const getURL = (state: RootState) => state.originalVideo.URL;react-redux는 React를 위한 공식 Redux UI binding 라이브러리로, 각 컴포넌트에서 상태 업데이트를 구독하고 콜백에서 현재 상태를 받아와 필요한 데이터를 추출하고, 필요하면 다시 렌더링하며, UI input이 발생하면 액션을 dispatch하는, 이러한 번거롭고 반복적인 작업을 줄이기 위해 도입할 필요가 있다. 또한, 복잡한 성능 최적화 로직을 직접 신경쓰지 않아도 된다는 장점도 있다.
클래스형 컴포넌트를 redux에 연결하기 위해서는 connect, 함수형 컴포넌트를 연결하기 위해서는useSelector, useDispatch 를 사용한다. Provider에 props로 루트 스토어를 넘겨주어 App을 감싸면 하위에 있는 모든 컴포넌트에서 store 접근이 가능해진다.
리덕스 스토어의 상태를 조회하는 Hook이다.
const result: any = useSelector(selector: Function, equalityFn?: Function)- selector
- 순수함수
- 반환값 :
useSelector()hook의 반환값 - 이전 selector의 결과 값과 현재 결과 값을 강한 참조 비교(
===) -> 다르다면 re-rendering - 하나의 컴포넌트 내에서
useSelector()를 여러 번 호출할 수 있다.- 개별적으로 store를 subscribe
- equalityFn
- 아래에서 설명
const getInfo = useSelector((state: RootState) => {
URL: state.originalVideo.URL
length: state.originalVideo.length
});위와 같이 useSelector를 사용한다면, 매번 렌더링 될 때 마다 새로운 객체를 만드는 것이기 때문에 상태가 바뀌었는지 바뀌지 않았는지 확인할 수 없어 불필요한 렌더링이 이루어진다.
이를 최적화하기 위해서는 두 가지 방법이 존재한다.
-
useSelector를 여러 번 사용한다.
const URL = useSelector((state: RootState) => URL: state.originalVideo.URL);
const length = useSelector((state: RootState) => URL: state.originalVideo.length);-
react-redux의shallowEqual함수를useSelector의 두번째 인자로 전달해준다.
import { useSelector, shallowEqual } from 'react-redux';
...
const getInfo = useSelector((state: RootState) => {
URL: state.originalVideo.URL
length: state.originalVideo.length
}, shallowEqual);useSelector의 두 번째 파라미터는 equalityFn인데,
equalityFn?: (left: any, right: any) => boolean이전 값과 다음 값을 비교하여 true가 나오면 re-rendering을 하지 않고, false가 나오면 re-rendering을 한다. shallowEqual은 react-redux에 내장된 함수인데 객체 안의 가장 겉에 있는 값들을 모두 비교해준다.
const obj = {
a: {
x: 3,
y: 2,
z: 1
},
b: 1,
c: [{ id: 1 }]
}예를 들어, 위와 같은 객체가 있다면 가장 겉에 있는 값인 obj.a, obj.b, obj.c만을 비교한다는 뜻이다.
위의 두 가지 방식 중 하나를 사용하여 최적화를 하면, 해당 컴포넌트가 필요한 상황(위에서는 URL이나 length가 변경된 상황)에만 re-rendering이 될 것이다.
리덕스 스토어의 dispatch 를 함수에서 사용 할 수 있게 해주는 Hook이다. (Redux store의 dispatch function에 대한 참조를 리턴)
Context API에서의 useReducer가 반환하는 dispatch와 같은 역할을 한다고 볼 수 있을 것 같다.
const dispatch = useDispatch()
// ex)
dispatch({type: LOAD_METADATA, payload: { length }})- 일반적으로
dispatch의 인자로 action의 type과 상태 변경에 필요한 정보를 payload에 담아 넘겨준다.
- 사용 예시
const UploadArea: React.FC = () => {
const name = useSelector(getName);
const dispatch = useDispatch();
...
const handleChange = () => {
const localFile: File = ref.current?.files[0];
if (localFile) {
const objectURL = URL.createObjectURL(localFile);
dispatch(setVideo(localFile, objectURL));
}
};
return (
...
);
};const setVideo = (video: File, URL: string) => ({
type: SET_VIDEO,
payload: {
video,
URL,
name: video.name,
},
});
const getName = (state: RootState) => state.originalVideo.name;redux-saga 는 리덕스 애플리케이션의 사이드 이펙트, 예를 들면 데이터 fetching이나 브라우저 캐시에 접근하는 것과 같이 순수하지 않은 비동기 동작들을 다루는 라이브러리이다.
saga는 애플리케이션에서 사이드 이펙트만을 담당하는 별도의 쓰레드와 같은 것으로, 미들웨어로써 리덕스 액션이 dispatch되는 것을 감지(take)하여 비동기 작업을 실행하고, 대기하고, 취소할 수도 있게 한다. 또한, 리덕스 애플리케이션의 상태에 접근하거나 새로운 액션을 dispatch하는 것도 가능하다.
redux-saga는 비동기 흐름을 쉽게 읽고, 쓰고, 테스트 할 수 있게 도와주는 ES6의 Generator를 사용한다. Generator를 사용함으로써, 비동기 흐름은 표준 동기식 자바스크립트 코드처럼 보이게 된다. (async/await와 비슷하지만, generator는 우리가 필요한 몇가지 기능을 더 가지고 있다.)
데이터 fetching을 관리하기 위해 redux-thunk를 써본 적이 있을 수 있다. redux-thunk와는 대조적으로, 콜백 지옥에 빠지지 않으면서 비동기 흐름들을 쉽게 테스트할 수 있고 액션들을 순수하게 유지한다.
redux-saga 를 사용하면 다음과 같은 작업들을 할 수 있다.
- 비동기 작업을 할 때 기존 요청을 취소 처리 할 수 있다
- 특정 액션이 발생했을 때 이에 따라 다른 액션이 디스패치되게끔 하거나, 자바스크립트 코드를 실행 할 수 있다.
- API 요청이 실패했을 때 재요청하는 작업을 할 수 있다.
redux-saga는 스토어에 지정된 액션들이 dispatch되었을 때 특정한 동작을 수행할 수 있도록 액션들을 모니터링하는 헬퍼 함수들을 제공한다.
takeEvery는 특정 액션 타입에 대해 dispatch되는 모든 액션들을 처리하는 함수이다. 예를 들어, 버튼이 한 번 눌릴 때마다 그 횟수만큼 서버에 요청을 보내야하는 액션이 있다면 takeEvery를 통해 해당 액션을 모니터링 하는 것이 적합할 것이다.
takeLatest는 특정 액션 타입에 대해 dispatch된 가장 마지막 액션만을 처리하는 함수이다. 예를 들어, 특정 액션을 처리하고 있는 동안 동일한 타입의 액션이 다시 dispatch되면 기존에 하던 작업을 무시하고 새로운 작업을 시작한다.
- 아래의 각 함수는 일반 JavaScript 개체를 반환하고 실행을 수행하지 않는다.
- 상기 실행은 상기 반복 과정 동안 미들웨어에 의해 수행된다.
- 미들웨어는 각 효과 설명을 검토하고 적절한 작업을 수행한다.
미들웨어가 인자로 args를 사용하여 fn 함수를 호출하도록 지시하는 Effect description을 만든다.
call은 오브젝트 메소드 호출을 지원한다. 아래와 같이 사용하여 호출된 함수에 this컨텍스를 사용할 수 있다.
yield call([obj, obj.method], arg1, arg2, ...)
// 아래와 같이 호출된다고 볼 수 있다.
obj.method(arg1, arg2 ...)똑같은 기능을 하는 apply alias 함수도 있다.
yield apply(obj, obj.method, [arg1, arg2, ...])call과 apply는 Promise들을 리턴하는 함수들에 적당하다.
또다른 함수 cps(Continuation Passing Style) 는 노드 스타일의 함수들을 다루기 위해 쓰일 수 있다. (ex: fn(...args, callback), callback => (error, result) => ())
call 은 이펙트에 대한 설명을 생성한다. Redux 에서와 마찬가지로, 스토어에 의해 실행될 액션을 설명하는 순수 객체를 만들기 위해 액션 생성자(action creator)들을 사용하고, call 은 함수 호출을 설명하는 순수 객체를 생성한다. redux-saga 미들웨어는 함수 호출과 제너레이터를 resolve 된 응답과 함께 재가동시킨다.
여기서 redux-saga 미들웨어는 함수는 아래의 sagaMiddleware를 뜻한다.
import rootSaga from './sagas';
import createSagaMiddleware from 'redux-saga';
const sagaMiddleware = createSagaMiddleware();
sagaMiddleware.run(rootSaga);call 은 그저 순수 객체만 리턴하는 함수기 때문에 제너레이터를 Redux 환경 바깥에서 쉽게 테스트하게 만든다.
dispatch 이펙트를 생성하는 함수이다.
action에 dispatch에 들어갈 객체를 넣어 호출하면, 미들웨어가 action을 인자로 실제 dispatch를 수행한다.
- ex)
function* load(action) {
try {
const duration = yield call(waitMetadataLoading, action.payload.URL);
yield put(loadMetadata(duration));
} catch (err) {
console.log(err);
yield put(error());
}
}const loadMetadata = length => ({
type: LOAD_METADATA,
payload: { length },
});
const error = () => ({ type: ERROR });- 비디오 파일을
HTMLInputElement에 업로드하면.files속성으로 접근이 가능하다. -
URL.createObjectURL(file)으로 반환된 URL을HTMLVideoElement의.src속성으로 지정하면 로딩이 시작된다. -
loadedmetadata이벤트가 발생한 시점에는.duration,.videoWidth,.videoHeight속성에 접근이 가능하다. -
duration을 n(ex. 30)등분한 값들로.currentTime속성을 설정하면 n+1장의 썸네일을 얻을 수 있다.
여기서 UI component가 당장 동기적으로 할 수 있는 것은 1번까지다. 나머지는 비동기 로직으로 진행해야 하는데, 이 코드를 component에 작성한다면 가독성이 굉장히 떨어지게 된다. 2, 3번의 작업을 redux-saga에게 떠넘겨 보자. 3번은 리팩토링 해야돼서 사실상 미구현이다
client/src/store/originalVideo/sagas.ts
import { put, call, takeEvery } from 'redux-saga/effects';
import video from '@/video/video'; // wrapper class instance
import { loadMetadata, error } from './actions';
import { SET_VIDEO } from './actionTypes';
const TIMEOUT = 10_000; // metadata를 10초 안에 읽지 못하면 포기
function waitMetadataLoading(objectURL) {
return new Promise<number>((resolve, reject) => {
const timer = setTimeout(reject, TIMEOUT, 'loading metadata timeout');
video.setSrc(objectURL); // 이제 (metadata) 로딩 시작
video.addEventListener(
'loadedmetadata',
({ target }: Event) => {
clearTimeout(timer); // 10초 내에 수행된다면...
resolve((target as HTMLVideoElement).duration);
},
{ once: true } // 한 번만 등록
);
});
}
function* load(action) {
try {
const duration = yield call(waitMetadataLoading, action.payload.URL);
yield put(loadMetadata(duration)); // 길이 정보 담아서 dispatch
// TODO: yield call(getThumbnails, action.video);
// TODO: yield put(loadSuccess());
} catch (err) {
console.log(err); // 엉엉 망했어요
yield put(error()); // state를 초기 상태로
}
}
export default function* watchSetVideo() {
yield takeLatest(SET_VIDEO, load); // 최신의 video.src로
}client/src/components/molecules/UploadArea.tsx
const UploadArea: React.FC = () => {
const [visible, setVisible] = useState(false);
const name = useSelector(getName);
const dispatch = useDispatch();
const ref: React.RefObject<HTMLInputElement> = createRef();
const handleChange = () => { // 업로드된 파일이 바뀔 때마다 실행
const localFile: File = ref.current?.files[0]; // (0번)
if (localFile) {
const objectURL = URL.createObjectURL(localFile);
dispatch(setVideo(localFile, objectURL)); // file을 담아 dispatch (1번)
} else dispatch(reset());
setVisible(false);
};
return (
<StyledDiv>
<StyledP>{name}</StyledP>
<Button
message="불러오기"
onClick={() => setVisible(!visible)}
type="default"
/>
{visible && <FileInput ref={ref} handleChange={handleChange} />}
</StyledDiv>
);
};참조 https://ko.redux.js.org/tutorials/essentials/part-2-app-structure/ https://react.vlpt.us/redux/ https://codesandbox.io/s/9on71rvnyo https://codesandbox.io/s/9on71rvnyo?file=/src/components/AddTodo.js https://mskims.github.io/redux-saga-in-korean/ https://react.vlpt.us/redux-middleware/10-redux-saga.html https://dailyhotel.io/how-to-use-redux-saga-63a6078c74b3 https://meetup.toast.com/posts/73