Ch4 에서는 액션으로부터 계산을 어떻게 빼낼 수 있는지에 대해 소개하고 있다.
Ch4 요약
일반적으로 액션은 암묵적 입력과 출력을 갖는다. 이때 암묵적 입력과 출력은 인자와 return 을 제외한 나머지 입, 출력을 의미한다.
액션과 비즈니스 규칙을 분리하자.
계산은 암묵적 입력과 출력이 없어야 한다.
암묵적 입력은 인자로, 암묵적 출력은 리턴값으로 바꿀 수 있다.
액션에서 계산을 빼내는 작업은 1. 계산 코드를 찾아 빼낸다. 2.새 함수에 암묵적 입력과 출력을 찾는다. 3. 암묵적 입력은 인자로 암묵적 출력은 리턴값으로 바꾼다. 의 과정을 통해 진행한다.
CH4 에서 두가지 리팩토링 기법을 소개한다.
- 서브루틴 추출하기
- 함수 추출하기
서브루틴 추출하기
/// Original
function calc_cart_total() {
shopping_cart_total = 0;
for(var i = 0; i < shopping_cart.length; i++) {
var item = shopping_cart[i];
shopping_cart_total += item.price;
}
set_cart_total_dom();
update_shipping_icons();
update_tax_dom();
}
/// 암묵적 입,출력 제거
function calc_cart_total() {
shopping_cart_total = calc_total(shopping_cart);
set_cart_total_dom();
update_shipping_icons();
update_tax_dom();
}
function calc_total(cart) {
var total = 0;
for(var i = 0; i < cart.length; i++) {
var item = cart[i];
total += item.price;
}
return total;
}
함수 추출하기
/// Original
function add_item_to_cart(name, price) {
shopping_cart.push({
name: name,
price: price
});
calc_cart_total();
}
/// 암묵적 입, 출력 제거
function add_item_to_cart(name, price) {
shopping_cart = add_item(shopping_cart, name, price);
calc_cart_total();
}
function add_item(cart, name, price) {
var new_cart = cart.slice();
new_cart.push({
name: name,
price: price
});
return new_cart;
}
TMI 인데 이 리펙토링 기법을 보다가 꼭 리펙토링 2nd 책으로 북스터디를 진행해야겠다는 생각이 들었다.
책에 예시코드가 자세히 나와있어서 참 좋다. 액션으로부터 어떻게 계산을 추출했는지 이해하기 쉽다.
다음은 redux 를 이용해 todo 앱을 만들어보았을 때 만들었던 todosSlice.js 코드이다.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
todos: [
{ id: 0, text: "Learn React", completed: true },
{ id: 1, text: "Learn Redux", completed: false },
],
};
// Create a utility function to generate the next todo ID
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1);
return maxId + 1;
}
export const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
todoAdded: (state, action) => {
state.todos.push({
id: nextTodoId(state.todos),
text: action.payload,
completed: false,
});
},
todoToggled: (state, action) => {
const toggledTodo = state.todos.find(
(todo) => todo.id === action.payload
);
if (toggledTodo) {
toggledTodo.completed = !toggledTodo.completed;
}
},
todoDeleted: (state, action) => {
state.todos = state.todos.filter((todo) => todo.id !== action.payload);
},
allCompleted: (state) => {
state.todos = state.todos.map((todo) => ({
...todo,
completed: true,
}));
},
completedCleared: (state) => {
state.todos = state.todos.filter((todo) => !todo.completed);
},
},
});
export const maxId = (todos) =>
todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1;
export const todos = (state) => state.todos;
export const completedTodos = (state) =>
state.todos.filter((todo) => todo.completed === true);
export const {
todoAdded,
todoToggled,
todoDeleted,
allCompleted,
completedCleared,
} = todosSlice.actions;
export default todosSlice.reducer;
여기서 리듀서를 보니 함수인데 반환값이 없는 경우도 있고 전역 변수는 아니지만 비즈니스 로직을 분리할 수 있을 것 같아서 CH4 의 액션으로부터 계산 빼내기를 적용해보았다.
// 기존코드
allCompleted: (state) => {
state.todos = state.todos.map((todo) => ({
...todo,
completed: true,
}));
},
// 리펙토링한 코드
allCompleted: (state) => {
state.todos = todo_all_completed(state.todos);
},
// 함수 추출하기
function todo_all_completed(todos) { // 함수에 존재하던 암묵적 입력을 인자로 대체
const newTodos = todos.slice(); // 원본을 건드리지 않고 얕은 복사로 새 배열을 만들어줬다.
return newTodos.map((todo) => ({ // 명시적 출력을 생성
...todo,
completed: true,
}));
}
유사한 방법으로 각 리듀서들을 리펙토링해봤다.
// 원본
completedCleared: (state) => {
state.todos = state.todos.filter((todo) => !todo.completed);
},
// 리펙토링한 코드
completedCleared: (state) => {
state.todos = todo_completed_cleared(state.todos);
},
function todo_completed_cleared(todos) {
const newTodos = todos.slice();
return newTodos.filter((todo) => !todo.completed);
}
// 원본
todoDeleted: (state, action) => {
state.todos = state.todos.filter((todo) => todo.id !== action.payload);
},
// 리펙토링
todoDeleted: (state, action) => {
state.todos = todo_deleted(state.todos, action.payload);
},
function todo_deleted(todos, id) {
const newTodos = todos.slice();
return newTodos.filter((todo) => todo.id !== id);
}
사실 이 코드가 당연히 동작하겠지 싶어서 작성했다가 지금 리펙토링할때 새로 알게된 사실이 있다.
JavaScript 에서 Array.find(); 메서드의 반환값을 수정하면 원본도 수정된다는 것을 새로 알게 되었다.
// 원본
todoToggled: (state, action) => {
const toggledTodo = state.todos.find(
(todo) => todo.id === action.payload
);
if (toggledTodo) {
toggledTodo.completed = !toggledTodo.completed;
}
},
위의 코드를 보고 아.. state.todos 원본을 수정하지 않았네 코드 수정해야겠다.. 라고 생각하고 코드를 아래와 같이 수정하려고 했다.
todoToggled: (state, action) => {
state.todos = state.todos.map((todo) => {
return todo.id === action.payload
? (todo.completed = !todo.completed)
: todo;
});
},
그런데 동작하는 모습이 같아서 mdn 을 찾아봤다.
const inventory = [
{ name: "apples", quantity: 2 },
{ name: "bananas", quantity: 0 },
{ name: "cherries", quantity: 5 },
];
function isCherries(fruit) {
return fruit.name === "cherries";
}
console.log(inventory.find(isCherries));
// { name: 'cherries', quantity: 5 }
하단의 map, find, filter 가 얕은 복사를 한다고 생각했던 부분은 오해였다. 구체적으로는 Ch7 파트에 정리해두었다.
나는 find 가 배열에서 조건을 만족하는 해당 인덱스의 모든 값을 반환해주는 메서드라고 생각했는데 그 반환값을 직접 수정하면 원본도 수정이 된다.
filter 에 대해서도 같은 코드를 실행해봤는데 filter 의 반환값을 수정하면 원본도 수정된다.
???
이게뭐지 filter 도 반환값을 수정하니 원본도 같이 수정된다.
map 도 마찬가지로 map 의 반환값이 갖는 데이터가 원본과 달라졌으나 map 의 반환값을 수정하니 원본의 값도 같이 수정되었다.
갑자기 딴길로 샌 느낌이 있지만 find, filter, map 의 반환값이 원본과 같은 주소를 가리키고 있음을 알 수 있었다.
다시 리펙토링을 진행한 결과
// 원본
todoToggled: (state, action) => {
const toggledTodo = state.todos.find(
(todo) => todo.id === action.payload
);
if (toggledTodo) {
toggledTodo.completed = !toggledTodo.completed;
}
},
// 리펙토링
todoToggled: (state, action) => {
state.todos = todo_toggled(state.todos, action.payload);
},
function todo_toggled(todos, id) {
const newTodos = todos.slice();
const newToggledTodo = newTodos.find((todo) => todo.id === id);
if (newToggledTodo) newToggledTodo.completed = !newToggledTodo.completed;
return newTodos;
}
// 원본
todoAdded: (state, action) => {
state.todos.push({
id: nextTodoId(state.todos),
text: action.payload,
completed: false,
});
},
// 리펙토링
todoAdded: (state, action) => {
state.todos = todo_added(state.todos, action.payload);
},
function todo_added(todos, text) {
const newTodos = todos.slice();
newTodos.push({
id: nextTodoId(todos),
text,
completed: false,
});
return newTodos;
}
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1);
return maxId + 1;
}
확실히 액션에서 계산만 뽑아내니 테스트코드는 순수함수인 계산에 대해서만 작성하기 용이해졌다.