해당 포스팅에는 과제에 대한 코드가 포함되어 있습니다!
4주차에는 리덕스에 대해서 배우는 시간이었다. SeSAC에서 3개월 교육을 받았을 때, 리덕스에 대해서 배우긴 했었다. 그 당시에는 아직 리액트도 제대로 사용하지 못할 때여서 (지금이라고 리액트를 잘 사용하는 것은 아니지만...ㅎ) 리덕스에 대해서는 더더욱 이해하기 힘들었기 때문에 다 한 귀로 듣고 한 귀로 흘려보냈다. 그렇기 때문에 이번 4주차에 배우는 리덕스는 나에게는 정말 중요한 시간이었다.
Redux에 대해서 공부하기 전, flux에 대한 특징을 알아야 된다. Redux는 Flux의 중요한 특징들로부터 영감을 얻었다고 한다. Flux의 저장소가 Redux에서는 리듀서라고 볼 수 있다.
간략하게 flux 사이트를 통해서 이해한 것을 정리해보았다.
Flux는 Facebook에서 클라이언트-사이드 웹 어플리케이션을 만들기 위해 사용하는 어플리케이션 아키텍처다. 단방향 데이터 흐름을 활용해서 뷰 컴포넌트를 구성하는 React를 보완하는 역할을 한다. Flux 어플리케이션은 Dispatcher, Stores, Views(React 컴포넌트)로 구성되어 있다.
Flux는 MVC(Model-View-Controller)와 다르게 단방향 데이터 흐름이다. React view에서 사용자가 상호작용을 할 때, view는 중앙의 dispatcher를 통해 action을 전파하게 되고, store는 action이 전파되면 이 action에 영향이 있는 모든 view를 갱신한다.
setState()
메소드를 호출하고 컴포넌트를 다시 렌더링한다.리덕스 는 애플리케이션의 상태를 관리하고 업데이트하기 위한 패턴이자 라이브러리이며, actions
라고 불리는 이벤트를 사용한다. 상태를 예측 가능한 방식으로 업데이트 할 수 있도록 규칙을 사용하여 상태를 중앙 집중식으로 저장하는 역할을 한다.
Redux 설치
$npm i redux react-redux
tool들과 마찬가지로 장단점이 있다. 어떠한 상황에서 이점이 있는지, 이점은 무엇인지 정리해보았다.
언제, 어디서, 왜, 어떻게
업데이트 되는지, 변경될 때 로직이 어떻게 작동하는지 쉽게 이해할 수 있음위 이점은 리덕스 공식 홈페이지를 통해 정리한 이점이다.
코드숨에서는 이점을 이렇게 요약했다.
→ 사실 이점을 요약한 것보다는 App의 관심사가 아니기 때문에 리덕스를 사용해야 된다는 것을 전달하고 싶은 것 같다.
리덕스를 사용할 때 따라야 할 3가지 원칙이 있다.
State is read-only
Action은 type 유형을 가지고 있는 javascript 객체이다. 애플리케이션에서 발생한 것을 설명하는 이벤트로 생각할 수 있다.
type 유형은 "todos/todoAdded"
와 같이 action에 설명하는 이름 문자열이어야 한다. "domain/eventName"
처럼 type 문자열을 작성해야 된다. domain
부분에는 action이 속한 기능 또는 카테고리이고, eventName
부분에는 발생한 특정 작업이어야 된다.
action 객체는 추가적인 정보를 가진 field가 있고, 그걸 payload라고 부른다.
type: (string)
payload: object
const addTodoAction = {
type: 'todos/todoAdded',
paylod: 'Buy milk',
}
export function changeTaskTitle(taskTitle) {
return {
type: 'changeTaskTitle',
payload: { taskTitle },
};
}
요약하자면 action 객체는 type, payload 속성으로 구성되는데 type은 어떤 액션인지 구별할 수 있는 문자열 값, payload 안에는 변경할 상태값(불변 객체)이 전달된다. 상태값을 수정하는 유일한 방법은 액션 객체와 함께 dispatch 메서드를 호출하는 것이다!
현재 state와 action 객체를 받는 함수이다.
(state, action) => newState
현재 state와 action을 argument로 사용하고, 새로운 상태를 반환하는 함수
Redux 앱은 나중에 createStore에 전달할 root reducer 함수만 있다. root reducer 함수는 dispatch 된 모든 action을 핸들링하고, 매번 전체 새 상태값이 어떻게 되어야 하는지 계산한다.
reducer 구조
function reducer(state, action) {
// ...
return state;
}
초기 상태값이 필요하기 때문에 ES6 인수 구문을 사용해서 초기 상태를 제공할 수 있다.
(state = initialState, action) => {}
const initialState = {
newId: 100,
taskTitle: '',
tasks: [],
};
const reducers = {
changeTaskTitle: (state, action) => ({
...state,
taskTitle: action.payload.taskTitle,
}),
addTask: (state) => {
const { newId, taskTitle, tasks } = state;
if (!taskTitle) {
return state;
}
return {
...state,
newId: newId + 1,
taskTitle: '',
tasks: [...tasks, { id: newId, title: taskTitle }],
};
},
deleteTask: (state, action) => {
const { tasks } = state;
return {
...state,
tasks: tasks.filter((task) => task.id !== action.payload.id),
};
},
};
export default function reducer(state = initialState, action) {
if (!action || !reducers[action.type]) {
return state;
}
return reducers[action.type](state, action);
}
store는 리덕스의 상태값을 갖는 객체이다. store는 action이 발생하면 미들웨어함수를 실행하고, reducer를 실행해서 상태값을 새로운 값으로 변경한다.
import { createStore } from 'redux';
import reducer from './reducer';
const store = createStore(reducer);
export default store;
getState
메소드를 호출하면 현재 상태의 값이 반환된다.<Provider>
컴포넌트를 사용하면 하위 컴포넌트들이 Provider를 통해서 Redux 저장소에 접근할 수 있게 만들어준다.
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import store from './store';
const root = createRoot(document.getElementById('app'));
root.render(
<Provider store={store}>
<App />
</Provider>,
);
useSelector
selector 함수를 사용하여 Redux store에서 데이터를 가져올 수 있다. 단일 function component 내에서 useSelector()
를 여러번 호출할 수 있다.
import React from 'react';
import { useSelector } from 'react-redux';
export const Counter = () => {
const counter = useSelector((state) => state.counter)
return (
<div>{counter}</div>
)
}
// useSelector 부분을 이렇게도 작성할 수 있다.
// selector를 이용해서 가져올 데이터가 많은 경우에 이렇게 작성하면 용이!
const { counter } = useSelector((state) => counter: state.counter)
useDispatch
useDispatch hook은 Redux store에서 dispatch function에 대한 참조를 반환한다. 필요에 따라 action을 전달하는데 사용할 수 있다.
import React from 'react'
import { useDispatch } from 'react-redux'
export const CounterComponent = ({ value }) => {
const dispatch = useDispatch()
return (
<div>
<span>{value}</span>
<button onClick={() => dispatch({ type: 'increment-counter' })}>
Increment counter
</button>
</div>
)
}
To-do 리스트와 input에 레스토랑 정보를 입력해서 추가하는 기능을 구현하는 것이 과제였다. (테스트 코드는 필수 작성)
export default function reducer(state = initialState, action) {
if (action.type === 'changeTaskTitle') {
return {
...state,
taskTitle: action.payload.taskTitle,
};
}
if (action.type === 'addTask') {
const { newId, taskTitle, tasks } = state;
if (!taskTitle) {
return state;
}
return {
...state,
newId: newId + 1,
taskTitle: '',
tasks: [...tasks, { id: newId, title: taskTitle }],
};
}
if (action.type === 'deleteTask') {
const { tasks } = state;
return {
...state,
tasks: tasks.filter((task) => task.id !== action.payload.id),
};
}
return state;
}
하지만 이 경우에는 수 많은 if 조건문을 사용하는 것보다는 객체리터럴을 사용하는 게 더 좋다는 피드백을 받았다.
그래서 피드백에 따라 아래처럼 객체 리터럴을 이용해서 action creators를 reducers
변수에 할당했다.
const reducers = {
changeTaskTitle: (state, action) => ({
...state,
taskTitle: action.payload.taskTitle,
}),
addTask: (state) => {
const { newId, taskTitle, tasks } = state;
if (!taskTitle) {
return state;
}
return {
...state,
newId: newId + 1,
taskTitle: '',
tasks: [...tasks, { id: newId, title: taskTitle }],
};
},
deleteTask: (state, action) => {
const { tasks } = state;
return {
...state,
tasks: tasks.filter((task) => task.id !== action.payload.id),
};
},
};
export default function reducer(state = initialState, action) {
if (!action || !reducers[action.type]) {
return state;
}
return reducers[action.type](state, action);
}
사실 수 많은 if 조건문을 어떻게 리팩토링 할 수 있을까 생각하다가, switch문을 처음에는 생각했었다. 하지만 객체 리터럴을 이용하면 유지보수가 용이하고 가독성이 높아진다는 포스팅을 보게 되었다.
참고 - switch문을 object 리터럴로 바꾸기
switch문의 문제
switch문은 break
키워드를 수동으로 추가해줘야 하고, 각 case 내의 구문으로 인해 디버깅이 어렵고 오류가 중첩될 수 있는 문제들이 있다.
객체는 유연하고, 가독성과 유지 보수성이 뛰어나고, 각 case마다 break 키워드가 필요 없다. 또한 case가 증가할수록 객체를 이용할 경우, switch문을 이용하는 것보다 평균 비용이 더 좋아진다. (switch문의 경우는 일치와 중단이 발생할 때까지 각 case를 평가하기 때문)
예시)
function getDrink(type) {
const drinks = {
'coke': 'Coke',
'pepsi': 'Pepsi',
'lemonade': 'Lemonade',
'default': 'Default Item'
};
return drinks[type] || drinks['default'];
}
const drink = getDrink('pepsi');
console.log(drink);
과제를 하면서 reducer 함수 부분에 조건문을 설정할 때, 삼항연산자를 이용했었다.
export default function reducer(state = initialState, action) {
return reducers[action.type] ? reducers[action.type](state, action) : state;
}
하지만 이러한 부분도 삼항연산자보다는 if 조건문을 이용하는 것을 권장한다고 하였고, reducers[action.type]
자체가 없을 때만 조건을 설정한 것이기 때문에 action의 type이 없을 때의 조건도 추가해서 작성해야 된다는 피드백을 받았다.
실제로도 이렇게 코드를 구현했을 때, 처음에는 action의 일치하는 타입이 없기 때문에 에러가 발생한다.
그래서 이렇게 리팩토링 했다.
export default function reducer(state = initialState, action) {
if (!action || !reducers[action.type]) {
return state;
}
return reducers[action.type](state, action);
}
// 처음에는 옵셔널체이닝을 이용해서 리팩토링 했었음
export default function reducer(state = initialState, action) {
return reducers[action?.type] ? reducers[action.type](state, action) : state
}
// 두번째에는 hasOwnProperty 메서드를 이용해서 리팩토링 했음
export default function reducer(state = initialState, action) {
if(!action.hasOwnProperty('type') || !reducers[action.type]) {
return state;
}
return reducers[action.type](state, action);
}
리팩토링을 다양하게 해보는 과정에서 hasOwnProperty
메소드를 사용할 때, no-prototype-builtins
경고 표시를 볼 수 있었다.
해당 eslint 경고를 검색해보니 권장하는 사용방식을 찾을 수 있었다.
ECMA script 5.1에서는Object.create
가 추가되어 , 객체를 생성할 수 있다. [[Prototype]].Object.create(null)
는 Map으로 사용될 객체를 만드는데 사용되는 공통 패턴이다. Object.prototype
으로부터 속성을 가진다고 가정할 때, 에러가 발생할 수 있다.
이 규칙은 일부 Object.prototype
메서드를 객체에서 직접 호출하지 못하도록 하고 있다.
게다가 객체는 Object.prototype에 내장된 것을 섀도우 하는 속성이 있을 수 있고, 잠재적으로 의도하지 않은 동작이나 보안 서비스 거부 취약성을 발생시킬 수 있다.
예를 들면, { "hasOwnProperty" : 1 }
같은 JSON 값을 전송하여 서버가 충돌(crash)할 수 있기 때문에 hasOwnProperty
를 직접 호출하는 것은 안전하지 않다.
이러한 버그를 방지하기 위해서는 Object.prototype
에서 이러한 메서드를 호출하는 것이 좋다. foo.hasOwnProperty('bar')
를 Object.prototype.hasOwnPrototype.call(foo, 'bar')
이렇게 써야 된다.
그래서 이렇게 바꿔서 hasOwnProperty
속성을 사용할 수 있었다.
export default function reducer(state = initialState, action) {
if (!Object.prototype.hasOwnProperty.call(action, 'type') || reducers[action.type]) {
return state;
}
return reducers[action.type](state, action);
}
export function changeTaskTitle(taskTitle) {
return {
type: 'changeTaskTitle',
payload: { taskTitle },
};
}
export function addTask() {
return {
type: 'addTask',
};
}
export function deleteTask(id) {
return {
type: 'deleteTask',
payload: {
id,
},
};
}
여러 input을 제어해야 할 때는 각 엘리먼트에 name 속성을 추가하고, event.target.name
을 통해서 핸들러가 어떤 작업을 할 지 선택할 수 있게 할 수 있다.
리덕스가 익숙하지 않은 상태에서 테스트 파일을 작성하는 것은 더 어려웠다. useSelector와 useDispatch를 mocking 해줘야 됐다.
// __mocks__ / react-redux.js 파일
export const useDispatch = jest.fn();
export const useSelector = jest.fn();
useDispatch.mockImplementation(() => () => ())
이렇게 fucntion을 반환하는 구조인데, 복잡하니까 dispatch를 분리해서 dispatch를 돌려주도록 모양을 바꿔서 사용한다.
const dispatch = jest.fn();
useDispatch.mockImplementation(() => dispatch)
// test 파일
import { useDispatch, useSelector } from 'react-redux';
jest.mock('react-redux');
describe('테스트명', () => {
const dispatch = jest.fn();
useDispatch.mockImplementation(() => dispatch);
useSelector.mockImplementation((selector) => selector({
taskTitle: 'todo제목'
}));
});
mockFn.mockImplementation(fn)
모의 실행으로 사용해야 하는 함수를 호용한다. mock이 실행될 때, 구현도 실행된다는 것이 유일하게 다른 점이다.
이렇게 4주차를 하면서, 느꼈던 점은 어려웠다. 그래서 두 번째 과제는 토요일날 제출하게 되면서, 많은 피드백을 받지 못했었다...흑흑... 과
그리고 코드숨에 있는 강의로는 리덕스에 대한 이해가 어려웠기 때문에, 생활코딩 유투브를 통해서 도움을 받았었다. 이렇게 4주차를 보내고, 과제 풀이 영상을 보면서 "TDD는 이렇게 하는 거구나"를 다시 깨달았고, if문이나 switch문을 이용할 때는 객체 리터럴을 이용하는 게 유용하다는 것을 새로 알게 되었다. 또한 혼자 다른 방식을 찾다가 발견한 eslint 경고 덕분에 hasOwnProperty의 작성 권장방식도 새로 알게 되었다.