#2 Redux 응용
React-redux
리덕스의 상탯값은 불변 객체입니다. 상탯값이 불변 객체이면 값의 변경 여부를 빠르게 확인할 수 있고, 이는 리액트의 렌더링 선능을 좋게 만드는 요인이 됩니다.
리액트에서 리덕스를 사용할 때 react-redux 패키지를 사용하지만, 없이도 리덕스를 사용할 수 있습니다.
먼저 react-redux 패키지 없이 간단한 프로그램을 만들어보고, react-redux 패키지를 사용해 리팩터링 해보도록 하겠습니다.
1. react-redux 패키지 없이 직접 구현하기
먼저 react-redux가 하는 일을 이해하기 위해 패키지의 도움 없이 리덕스를 리액트에 적용하는 코드를 직접 작성해보겠습니다.
우선 Redux #1에서 작성했던 index.js 파일의 스토어 객체를 별도의 파일로 분리하겠습니다.
// common\store.js
import { createStore, combineReducers } from "redux";
import timelineReducer from "../timeline/state";
import friendReducer from "../friend/state";
const reducer = combineReducers({
timeline: timelineReducer,
friend: friendReducer
});
const store = createStore(reducer);
export default store;
이제 타임라인 컴포넌트를 만들어보겠습니다. timeline 폴더 밑에 component, container 폴더를 각각 만듭니다.
프레젠테이션 컴포넌트는 component 폴더에, 컨테이너 컴포넌트는 container 폴더에 넣도록 하겠습니다.
다음과 같이 component 폴더 밑에 TimelineList.js 파일을 만듭니다.
TimelineList 컴포넌트는 타임라인 배열을 Props로 전달받아 화면에 그리는 프레젠테이션 컴포넌트입니다.
// timeline\component\TimelineList.js
import React from "react";
function TimelineList({ timelines }) {
return (
<ul>
{timelines.map(timeline => (
<li key={timeline.id}>{timeline.desc}</li>
))}
</ul>
)
}
export default TimelineList;
리덕스의 상탯값에 접근하는 container 폴더 밑에 TimelineMain.js 파일을 만들겠습니다.
getNextTimeline 함수를 통해 필요할 때마다 타임라인 데이터를 가져옵니다.
useEffect 훅 내부에서 액션이 처리될 때마다 화면을 다시 그리기 위해 subscribe 메서드를 사용했습니다.
리덕스 상태가 변경되면 무조건 컴포넌트를 렌더링하기 위해 forceUpdate 함수를 사용한 것입니다.
또한 컴포넌트가 언마운트될 때 subscribe 메서드에 동록된 이벤트 처리 함수를 해제합니다.
// timeline\container\TimelineMain.js
import React, { useEffect, useReducer } from "react";
import store from "../../common/store";
import { getNextTimeline } from "../../common/mockData";
import { addTimeline } from '../state';
import TimelineList from '../component/timelineList';
export default function TimelineMain() {
const [, forceUpdate] = useReducer(v => v + 1, 0);
useEffect(() => {
const unsubscribe = store.subscribe(() => forceUpdate());
return () => unsubscribe();
}, [])
function onAdd() {
const timeline = getNextTimeline();
store.dispatch(addTimeline(timeline));
}
console.log("TimelineMain render");
const timelines = store.getState().timeline.timelines;
return (
<div>
<button onClick={onAdd}>타임라인 추가</button>
<TimelineList timelines={timelines} />
</div>
)
}
마찬가지로 friend 폴더 밑에 각각 component 폴더와 container 폴더를 만들고 위에서 만든 것과 동일하게 FriendList.js 파일과 FriendMain.js 파일을 만듭니다,
// friend\coponent\FriendList.js
import React from "react";
function FriendList({ friends }) {
return (
<ul>
{friends.map(friend => (
<li key={friend.id}>{friend.name}</li>
))}
</ul>
)
}
export default FriendList;
// ==============================================================================
// friend/container/FriendMain.js
import React, { useEffect, useReducer } from "react";
import store from "../../common/store";
import { getNextFriend } from "../../common/mockData";
import { addFriend } from '../state';
import FriendList from '../component/FriendList';
export default function FriendMain() {
const [, forceUpdate] = useReducer(v => v + 1, 0);
useEffect(() => {
// 액션이 발생하면 forceUpdate
const unsubscribe = store.subscribe(() => forceUpdate());
return () => unsubscribe();
}, []);
function onAdd() {
const friend = getNextFriend();
store.dispatch(addFriend(friend));
}
console.log("FriendMain render");
const friends = store.getState().friend.friends;
return (
<div>
<button onClick={onAdd}>친구 추가</button>
<FriendList friends={friends} />
</div>
)
}
이제 common 폴더 밑에 mockData.js 파일을 만들고 위에서 import 해논 함수들을 구현해보도록 하겠습니다.
우선 친구목록과 타임라인 기본 데이터를 정의합니다. 이어서 getNextFriend, getNextTimeline 함수를 생성하는 makeDataGenerator 함수를 정의합니다. 이 함수가 리턴하는 getNextData 함수는 items와 itemIndex 변수를 기억하는 클로저입니다.
const friends = [
{ name: "황승수", age: 25 },
{ name: "김주완", age: 24 },
{ name: "김수연", age: 23 },
{ name: "신연수", age: 22 },
{ name: "양재황", age: 21 },
{ name: "이중훈", age: 20 },
{ name: "이성진", age: 29 },
{ name: "나한범", age: 28 },
{ name: "엄선명", age: 27 },
{ name: "강현우", age: 26 }
];
const timelines = [
{ desc: "점심이 맛있었네요", likes: 0 },
{ desc: "구우우우우우웃", likes: 15 },
{ desc: "강원도로 놀러오세요", likes: 10 },
{ desc: "짬뽕 순두부 존맛", likes: 20 },
{ desc: "순두부 젤라또 맛남", likes: 30 }
];
function makeDataGenerator(items) {
let itemIndex = 0;
return function getNextData() {
const item = items[itemIndex++ % items.length];
return { ...item, id: itemIndex };
};
}
export const getNextFriend = makeDataGenerator(friends);
export const getNextTimeline = makeDataGenerator(timelines);
이어서 index.js 파일을 다음과 같이 구성한 후 npm start 명령어를 실행해보겠습니다.
// index.js
import React from "react";
import ReactDOM from 'react-dom';
import TimelineMain from './timeline/container/TimelineMain';
import FriendMain from './friend/container/FriendMain';
ReactDOM.render(
<div>
<FriendMain />
<TimelineMain />
</div>,
document.getElementById("root")
)
버튼을 누를 때마다 정상적으로 추가됩니다.
그런데 한가지 문제점이 있습니다. 로그를 확인해보면 타임라인 추가 버튼을 눌렀을 뿐인데 불필요하게 FriendMain 컴포넌트 함수까지 호출되는 것을 볼 수 있습니다.
불필요하게 FriendMain 컴포넌트가 호출되지 않도록 하기 위해서 상탯값 변경 여부를 검사하도록 하겠습니다.
FriendMain.js 파일 내의 useEffect 훅을 다음과 같이 수정하도록 하겠습니다. 이제 타임라인 추가 버튼을 눌러도 FriendMain 컴포넌트는 호출되지 않는 것을 확인할 수 있습니다.
마찬가지로 TimelineMain 컴포넌트에서도 아래와 같이 바꿔줍니다.
// FriendMain.js
useEffect(() => {
let prevFriends = store.getState().friend.friends;
const unsubscribe = store.subscribe(() => {
const friends = store.getState().friend.friends;
if (prevFriends !== friends) {
forceUpdate();
}
prevFriends = friends;
});
return () => unsubscribe();
}, []);
// ============================================================================
// TimelineMain.js
useEffect(() => {
let prevTimelines = store.getState().timeline.timelines;
const unsubscribe = store.subscribe(() => {
const timelines = store.getState().timeline.timelines;
if (prevTimelines !== timelines) {
forceUpdate();
}
prevTimelines = timelines;
})
return () => unsubscribe();
}, [])
지금까지 react-redux 패키지 없이 친구목록과 타임라인을 추가하는 간단한 프로그램을 만들어 보았습니다.
지금부터는 react-redux 패키지를 사용해서 지금까지 작성한 프로그램을 리팩터링 해보도록 하겠습니다.
먼저 react-redux 패키지를 설치해주겠습니다.
npm install react-redux
리팩터링을 위해 가장 먼저 작성할 코드는 react-redux에서 제공하는 Provider 컴포넌트를 사용하는 코드입니다.
Provider 컴포넌트는 하위에 있는 컴포넌트의 리덕스 상탯값이 변경되면 자동으로 컴포넌트를 재호출합니다.
스토어 객체를 Provider 컴포넌트의 속성값으로 넣으면 Provider 컴포넌트는 스토어 객체의 subscribe 메서드를 호출해서 액션 처리가 끝날 때마다 알림을 받은 다음, 컨텍스트 API를 사용해서 리덕스의 상탯값을 하위 컴포넌트로 전달합니다.
index.js 파일을 다음과 같이 수정하겠습니다.
import React from "react";
import ReactDOM from 'react-dom';
import TimelineMain from './timeline/container/TimelineMain';
import FriendMain from './friend/container/FriendMain';
import store from "./common/store";
import { Provider } from "react-redux";
ReactDOM.render(
<Provider store={store}>
<div>
<FriendMain />
<TimelineMain />
</div>
</Provider>,
document.getElementById("root")
)
이제 FriendMain 컴포넌트가 react-redux를 사용하도록 수정하겠습니다.
컴포넌트가 리덕스 상탯값 변경에 반응하기 위해 react-redux에서 제공하는 useSelector 훅을 사용하였습니다.
useSelector 훅에 입력하는 함수를 "선택자 함수"라고 부르며 이 함수가 반환하는 값이 그대로 훅의 반환값으로 사용됩니다. useSelector 훅은 리덕스의 상탯값이 변경되면 이전 반환값과 새로운 반환값을 비교한 후 두 값이 다른 경우에만 컴포넌트를 재렌더링합니다. (메모이제이션 기능)
액션을 발생시키는 dispatch 함수를 사용하기 위해 useDispatch 훅을 사용하였습니다.
useDispatch 훅을 호출하면 dispatch 함수를 반환합니다. dipatch 함수를 이용해서 친구를 추가하는 액션을 발생시킵니다.
import React from "react";
import { getNextFriend } from "../../common/mockData";
import { addFriend } from '../state';
import FriendList from '../component/FriendList';
import { useSelector, useDispatch } from "react-redux";
export default function FriendMain() {
const friends = useSelector(state => state.friend.friends);
const dispatch = useDispatch();
function onAdd() {
const friend = getNextFriend();
dispatch(addFriend(friend));
}
console.log("FriendMain render");
return (
<div>
<button onClick={onAdd}>친구 추가</button>
<FriendList friends={friends} />
</div>
)
}
useSelector 훅으로 여러 상탯값 반환하기
useSelector 훅으로 여러 상탯값을 가져오려면 선택자 함수가 객체를 반환하면 될 것입니다.
하지만 객체 리터럴 문법을 이용하면 실제 상탯값이 변경되지 않아도 매번 새로운 객체가 반환되기 때문에 문제가 됩니다. useSelector 훅의 두번째 매개변수는 컴포넌트 렌더링 여부를 판단하는 역할을 합니다. 이 매개변수를 입력하지 않으면 참조값만 비교하는 단순 비교 함수가 사용됩니다. 따라서 선택자 함수가 객체 리터럴을 반환했을 때 컴포넌트가 불필요하게 자주 렌더링되는 것입니다. 이때 react-redux에서 제공하는 shallowEqual 함수를 입력하면 이를 막을 수 있습니다.
간단히 shallowEqual 함수의 사용법을 알아보도록 하겠습니다.
shallowEqual 함수가 useSelector 훅의 두번째 매개변수로 들어가게 되면 배열의 각 원소가 변경되었는지를 검사합니다. 원한다면 배열이 아닌 객체도 사용할 수 있습니다.
만약 shallowEqual 함수를 자주 사용한다면 커스텀 훅으로 만들어서 사용할 수도 있습니다.
import { shallowEqual } from 'react-redux';
export default function MyComponent() {
const [value1, value2, value3] = useSelector(
state => [state.value1, state.value2, state.value3],
shallowEqual
);
}
// ================================================================
function useMySelector(selector) {
return useSelector(selector, shallowEqual)
}
function MyComponent() {
const [value1, value2] = useMySelector(state => [state.value1, state.value2]);
// 상탯값이 하나라면 단순 비교 한번이면 충분하므로 오히려 비효율적!
// 반환값을 배열로 감싸서 해결
const [value4] = useMySelector(state => [state.value4]);
}
reselect 패키지로 선택자 함수 만들기
리덕스에 저장된 데이터를 화면에 보여줄 때 다양한 형식으로 가공할 필요가 있습니다.
예로, 친구 목록을 보여줄 때 여러 필터 옵션을 적용하는 것이 있습니다. 내 위치에서 10km 이내 친구들만 보여주거나, 성별이나 나이로 필터링하는 경우입니다.
reselect 패키지는 특히 리덕스의 원본 데이터를 다양한 형태로 가공하는 경우 사용합니다.
우리가 이전에 구현한 친구목록에 연령 제한 옵션과 개수 제한 옵션을 설정해보도록 하겠습니다.먼저 reselect 패키지 없이 해당 기능을 구현해보고, reselect 패키지를 사용하는 코드로 리팩터링 해보죠.
reselect 패키지 없이 구현하기
우선 옵션을 선택할 수 있는 기능을 가진 컴포넌트를 하나 만들겠습니다.friend/component 폴더 밑에 NumberSelect.js 파일을 만들겠습니다.
import React from "react";
export default function NumberSelect({ value, options, postfix, onChange }) {
return (
<div>
<select
onChange={e => {
const value = Number(e.currentTarget.value);
onChange(value);
}}
value={value}
>
{options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{postfix}
</div>
)
}
friend 폴더 밑에 common.js 파일을 만들고 friend 폴더 밑 여러 파일에서 공통으로 사용되는 상숫값을 이 파일에서 정의하도록 하겠습니다.
export const MAX_AGE_LIMIT = 30;
export const MAX_SHOW_LIMIT = 8;
연령 제한과 개수 제한 정보를 관리하기 위해 friend/state.js 파일을 다음과 같이 수정합니다.
import createItemsLogic from "../common/createItemsLogic";
import createReducer from '../common/createReducer';
import mergeReducers from '../common/mergeReducers';
import { MAX_AGE_LIMIT, MAX_SHOW_LIMIT } from './common';
const { add, remove, edit, reducer: friendsReducer } = createItemsLogic("friends");
const SET_AGE_LIMIT = "friend/SET_AGE_LIMIT";
const SET_SHOW_LIMIT = "friend/SET_SHOW_LIMIT";
export const addFriend = add;
export const removeFriend = remove;
export const editFriend = edit;
export const setAgeLimit = ageLimit => ({ type: SET_AGE_LIMIT, ageLimit });
export const setShowLimit = showLimit => ({ type: SET_SHOW_LIMIT, showLimit });
const INITIAL_STATE = { ageLimit: MAX_AGE_LIMIT, showLimit: MAX_SHOW_LIMIT };
const reducer = createReducer(INITIAL_STATE, {
[SET_AGE_LIMIT]: (state, action) => (state.ageLimit = action.ageLimit),
[SET_SHOW_LIMIT]: (state, action) => (state.showLimit = action.showLimit)
});
const reducers = [reducer, friendsReducer];
export default mergeReducers(reducers);
마지막으로 FriendMain.js 파일을 다음과 같이 수정합니다.
useSelector 훅 내부 선택자 함수의 코드에서 리덕스에 저장된 원본 데이터를 화면에 보여줄 데이터로 가공하고 있습니다. 한가지 문제점은 리덕스의 액션이 처리될 때마다 새로운 목록을 만드는 연산이 수행된다는 점입니다.
친구 목록이 변경되지 않았을 때도 새로운 목록을 만드는 연산이 수행되므로 불필요한 연산이 증가합니다.
import React from "react";
import { getNextFriend } from "../../common/mockData";
import { addFriend, setAgeLimit, setShowLimit } from '../state';
import FriendList from '../component/FriendList';
import { useSelector, useDispatch, shallowEqual } from "react-redux";
import NumberSelect from '../component/NumberSelect';
import { MAX_AGE_LIMIT, MAX_SHOW_LIMIT } from '../common';
export default function FriendMain() {
const [
ageLimit,
showLimit,
friendsWithAgeLimit,
friendsWithAgeShowLimit
] = useSelector(state => {
const { friends, ageLimit, showLimit } = state.friend;
const friendsWithAgeLimit = friends.filter(
friend => friend.age <= ageLimit
);
return [
ageLimit,
showLimit,
friendsWithAgeLimit,
friendsWithAgeLimit.slice(0, showLimit)
];
}, shallowEqual)
const dispatch = useDispatch();
function onAdd() {
const friend = getNextFriend();
dispatch(addFriend(friend));
}
return (
<div>
<button onClick={onAdd}>친구 추가</button>
<NumberSelect
onChange={v => dispatch(setAgeLimit(v))}
value={ageLimit}
options={AGE_LIMIT_OPTIONS}
postfix="세 이하만 보기"
/>
<FriendList friends={friendsWithAgeLimit} />
<NumberSelect
onChange={v => dispatch(setShowLimit(v))}
value={showLimit}
options={SHOW_LIMIT_OPTIONS}
postfix="명 이하만 보기 (연령 제한 적용)"
/>
<FriendList friends={friendsWithAgeShowLimit} />
</div>
)
}
const AGE_LIMIT_OPTIONS = [15, 20, 25, MAX_AGE_LIMIT];
const SHOW_LIMIT_OPTIONS = [2, 4, 6, MAX_SHOW_LIMIT];
reselect 패키지 사용하기
지금까지 작성한 코드를 reselect 패키지를 사용하는 코드로 리팩터링 해보겠습니다. 먼저 reselect 패키지를 설치해줍니다.
npm install reselect
reselect 패키지로 선택자 함수를 작성합니다.
이번 예제에서는 연령 제한이 적용된 친구 목록을 반환해주는 선택자 함수와, 연령 제한과 개수 제한이 모두 적용된 친구 목록을 반환해주는 선택자 함수가 필요합니다.
폴더 구조를 조금 변경하겠습니다.
상탯값을 처리하는 파일을 한곳으로 모으기 위해 friend 폴더 밑에 state 폴더를 만들고 friend/state.js 파일을 friend/state/index.js 경로로 이동하겠습니다. 그리고 friend/state 폴더 밑에 선택자 함수를 작성할 selector.js 파일을 만들고 다음과 같이 코드를 입력하겠습니다.
// reselect 패키지의 createSelector 함수를 이용해서 선택자 함수를 만든다.
import { createSelector } from "reselect";
// 상탯값에 있는 데이터를 단순히 전달하는 함수
export const getFriends = state => state.friend.friends;
export const getAgeLimit = state => state.friend.ageLimit;
export const getShowLimit = state => state.friend.showLimit;
// 연령 제한이 적용된 친구 목록을 반환해주는 선택자 함수
// 배열의 각 함수가 반환하는 값이 아래 함수의 각 인수로 전달됨
export const getFriendsWithAgeLimit = createSelector(
[getFriends, getAgeLimit],
(friends, ageLimit) => friends.filter(friend => friend.age <= ageLimit)
)
export const getFriendsWithAgeShowLimit = createSelector(
[getFriendsWithAgeLimit, getShowLimit],
(friendsWithAgeLimit, showLimit) => friendsWithAgeLimit.slice(0, showLimit)
)
reselect 패키지는 메모이제이션 기능이 있습니다. 연산에 사용되는 데이터가 변경된 경우에만 연산을 수행하고 변경되지 않았다면 이전 결괏값을 재사용합니다. 위에서 getFriendsWithAgeLimit 함수는 friends, ageLimit이 변경된 경우에만 연산을 수행합니다.
이렇게 선택자 함수를 따로 정의해 놓으면 여러 컴포넌트에서 쉽게 재사용할 수 있고, 데이터를 가공하는 코드를 컴포넌토 파일에서 분리했기 때문에 컴포넌트 파일에서는 UI 코드에 더 집중할 수 있게 된다는 장점이 있습니다.
이어서 FriendMain.js 파일의 선택자 함수를 다음과 같이 수정하겠습니다.
// ....
import { getFriendsWithAgeLimit,
getFriendsWithAgeShowLimit,
getAgeLimit,
getShowLimit }
from '../state/selector';
// ...
const ageLimit = useSelector(getAgeLimit);
const showLimit = useSelector(getShowLimit);
const friendsWithAgeLimit = useSelector(getFriendsWithAgeLimit);
const friendsWithAgeShowLimit = useSelector(getFriendsWithAgeShowLimit);
// ...