Idiomatic Redux: The Tao of Redux, Part 2 - Practice and Philosophy를 읽고 개인 의견으로 살을 붙여 정리한 포스팅입니다.
액션은 상태가 업데이트 되어야 할 정보를 유일하게 리덕스에서 표현할 수 있는 pain object (object literal)이다. 리덕스는 상태 업데이트를 위해 액션 사용을 강제하지 않는다. 하지만 이전 포스팅에서도 action.type이 시멘틱한 네이밍으로 지어져야 하는 이유와 동일한 이유로 만약 액션을 사용하지 않는다면 리덕스가 만들어지게 된 가장 주요한 이유 - 상태 변화를 예측하고 디버깅하기 쉽게 한다. 의 장점을 포기하는 것으로 리덕스를 사용해야 할 이유가 사라진다.
If there are no serializable plain object actions, it is impossible to record and replay user sessions, or to implement hot reloading with time travel. If you'd rather modify data directly, you don't need Redux.
나의 앞선 리덕스(1) 포스팅에서도 말했지만 액션은 시멘틱하게 작성하는게 옳다.
이 부분은 코더마다 생각하는 중요도가 다르므로 의견이 다른 사람을 만난다면 절충안을 잘 찾는게 중요한 것 같다.
한가지 꼭 준수해야 할 것은 SET 으로 시작하는 타입의 단어들은 what happened
와 how the state changes
두 가지를 모두 포함하기 때문에 의미가 모호하여 사용을 지양해야 한다.
리스트 A,B,C가 존재하며 각 리스트를 동시가 아닌 개별적으로 업데이트 하는 요구사항이 있다고 할때 SET_LIST 라든가 LIST_ITEM_INSERT와 같은 문맥은 리듀서가 어떤 리스트를 업데이트 해야 하는 정보를 충분히 전달하지 못할 뿐더러, 코드를 작성하는 사람으로 하여금 리듀서를 작성할 때 type 정보말고도 부가적인 정보를 토대로 로직을 작성하게 한다.
첫번째 포스팅의 서두에서도 언급했듯이 리덕스는 event-based / publish-subscribe 기반의 시스템이다. 이벤트에 해당하는 액션에 올바른 정보가 포함되지 않는다면 시스템이 제대로 동작하지 않을 가능성을 열어두는 것이다.
앞선 리스트 예제의 경우 b/LIST_ITEM_INSERT 라던가 listId : "b" 와 같은 형태가 더욱 적절하다고 할 수 있다.
로딩 스피너가 필요한 경우, 컴포넌트 내부에 [isLoading, setIsLoading] = useState();
과 같이 로딩 상태를 나타내는 변수를 별도로 할당할 수 있겠지만,
REQUEST_START, REQUEST_SUCCEEDED, REQUEST_FAILED와 같이 개별 AJAX 요청에 대한 상태를 나타내는 액션을 만들어 두는 것이 UI 업데이트 시키는데 더 편한 방법이다.
액션 타입에는 의미가 불분명한 심볼 사용을 지양하고 시멘틱한 의미가 잘 전달 될 수 있는 문자열 값을 선언해야 한다.
const ADD_TODO = 'ADD_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
const LOAD_ARTICLE = 'LOAD_ARTICLE'
규모가 작은 프로젝트에는 이러한 const value가 불필요하다고 느껴질 수 있으나 프로젝트의 규모와 관계없이 일관된 스타일을 유지하는 것이 좋은 것 같다.
리덕스를 떠올릴때 순수함수! 순수함수!
를 의무적으로 함께 연상하는 어떤 강박증 비슷한게 생기는 것 같다.
리덕스 !== 순수함수
다음은 계속 반복해서 언급하는 부분이 될 것 같다.
리듀서는 순수함수로 작성해야 한다. -> 리듀서는 순수함수로 작성하는 것이 좋다.
action creator는 순수함수로 작성해야 한다. -> 순수함수가 아니어도 좋다.
함수 내부에서 (inline) action을 직접 조합해도 상관 없으나,
dispatch({
type: 'ADD_TODO',
text: 'Use Redux'
})
다른 파일에 액션을 생성하는 함수를 모듈로 만들어서 사용하는 것이 더 나은 선택이다.
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
Dan Abramov는 stackoverflow에서
Middleware를 사용하면 action object가 dispatch되기 전에 intercept해서 임의의 동작을 추가로 수행할 수 있다.
리엑트 창시자들은 리덕스의 장점(상태 변경을 예측 가능하게, 디버깅 쉽게)를 위해 사이드 이펙트를 발생시키는 로직을 리듀서와 분리해야 했다. 하지만 rx와 같은 허들이 높은 라이브러리를 강제하고 싶지 않았기에, 사용자가 자유롭게 작성해서 실제 dispatch하기 이전 단계에 추가시킬 수 있는 middleware라는 개념을 만들었다.
middleware가 없다면, 리듀서를 순수함수로 유지하기 위해 액션을 디스패치 하기 이전에 인라인 함수 내부에서 비동기 동작을 받은 이후 해당 결과값을 이용해 액션을 만들어 디스패치 해야 할 것이다. 이건 상당히 번거로운 일인데, 간단하게 아래같이 인라인 함수를 만든다면 문제가 없어보이지만,
const handleClick = async () => {
const { data } = await fetchAPI();
const action = createAction(data); // custom action creators
dispatch(action);
}
http 통신이 완료되기 이전에 표시될 로딩바가 필요하다면
const handleClick = async () => {
const {ready, complete} = createLoadingAction();
dispatch(ready);
const { data } = await fetchAPI();
dispatch(complete);
const action = createAction(data); // custom action creators
dispatch(action);
}
에러 처리가 필요하다면
const handleClick = async () => {
const {ready, complete} = createLoadingAction();
dispatch(ready);
try {
const { data } = await fetchAPI();
dispatch(complete);
const action = createAction(data); // custom action creators
dispatch(action);
}
catch(error) {
const errorAction = createErrorAction();
dispatch(errorAction);
}
}
인라인 함수 하나를 만드는데 상태 업데이트 로직과 사이드 이펙트 로직이 얽히게 되어 가독성과 코드의 질이 거의 노답이 된다고 할 수 있다. 로직이 길어지면 선언형 작성되는 리엑트가 어느 순간 명령형에 가깝게 되어버린다.
이걸 미들웨어로 처리하면, 간단한 thunk를 작성하는 것만으로도 다른 컴포넌트들 간에 재활용이 가능한 로직이 된다.
const handleClick = () => {
const fetchLoginAction = createLoginAction();
dispatch(fetchLoginAction);
}
ducks 패턴은 보일러 플레이트 파일의 수를 줄이기 위해 action, action creator, reducer를 한 파일 안에 모두 정의해 사용하는 것이다.
덕스 패턴을 사용하던 사용하지 않던 사용자의 마음이나, 하나의 액션로 여러 개의 리듀서들을 사용할 수 없다는 단점이 존재한다. export를 사용하면 되나 덕스패턴은 하나의 파일 안에서 리듀서, 액션, 액션 크리에이터들이 서로 소통한다는 걸 제한하는 패턴이므로 파일간의 장벽을 뛰어넘어 소통하는 모듈의 연결의 수가 많아진다면, 덕스패턴을 사용해야 하는 이유가 있을까 싶다.
또 다른 패턴인 feature-folder type approach은 하나의 폴더에 관련 피쳐에 해당하는 코드들을 다 넣되 액션, 리듀서, 액션 크리에이터를 별도의 파일에 작성하는 것이다.
여러 개의 리듀서를 생산한다고 할지라도 결국 하나의 앱에서 사용하는 리듀서는 결국 최대 한 개이다. (억지로 만든다면 두 개 이상도 가능하다.) createStore 에 인자로 전달되는 (state, action) => newState 시그니처 함수가 root reducer가 된다.
나는 변화에 유연하고 유지보수가 용이한 코드를 위해 이 루트 리듀서를 작은 슬라이스들로 만든다.
리듀서에 대한 가장 흔한 오해 중 하나는, 리듀서는 switch-case 문으로 작성된 순수함수
이다.
리듀서가 전달된 액션의 타입에 따라 적절한 로직을 수행하는 책임을 완수할 수 있다면
switch case 문을 고집하지 않아도 괜찮다.
If you don't like switch, you can solve this with a single function, as we show below.
export const todos = createReducer([], {
[ActionTypes.ADD_TODO]: (state, action) => {
const text = action.text.trim();
return [...state, text];
}
})
function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if(handlers.hasOwnProperty(action.type) {
return handlers[action.type](state,action);
}
else {
return state;
}
}
}
컴포넌트에 직접 async logic을 작성해도 되지만, Async Action Creators
섹션에서 이야기했듯이 여러 줄에 걸친 로직을 컴포넌트마다 다시 작성해주는 것은 대단히 피곤하고 귀찮은 일이다.
다른 모듈과 동일하게 async 로직만 다른 파일에 분리한 후 임포트해서 사용하는 것도 방법이 될 수 있겠지만, async logic이 스토어의 상태와, dispatch등 스토어 콘텍스트와 연결해주는 과정을 별도로 구현해야 한다.
미들웨어는 action이 생성되고 dispatch되고 실제 state가 변경되는 것을 하나의 파이프라인으로 봤을 때 dispatch 되기 이전 단계에 들어가게 되는데, 스토어 콘텍스트에 접근이 가능하므로 별도로 분리하여 사용하는 모듈에 비교해볼 때 로직을 작성할 때 고민해야 할 부분이 줄어든다.
다시 한번 말하지만 미들웨어 작성은 필수가 아니다. 리덕스는 라이브러리 사용을 위해 그 어떤 아키텍쳐의 선결조건도 강요하지 않는다. 하지만 리덕스가 생성된 유래와 근본적인 이유를 생각해봤을때 idiomatic한 방법론을 따르는게 best practice라는 사실을 염두에 두어야 한다.
리덕스에서 사이드 이펙트를 처리하기 위해 사용하는 방법들을 나열해보면,
평범한 인라인 함수이다. async logic을 사용하며, redux-thunk를 이용해 dispatch에 함수를 넘겨주면 해당 함수가 실행된 이후 스토어에 diaptch가 된다. 버튼 액션이나 AJAX 요청에 많이 사용된다.
dispatch의 인자로 promise를 사용한다. promise의 결과값으로 산출되는 resolve,reject된 값들로 action을 구성할때 많이 사용된다. 주요 라이브러리로는 redux-promise가 있다.
ES6 문법인 generator를 사용한다. 유명한 라이브러리로는 redux-saga가 있다. 러닝커브가 높다고 생각한다.
함수형 리엑티브 프로그래밍 라이브러리를 사용한다. RxJS를 사용한다고 생각하면 되며, redux-observable이 유명하다. 러닝커브가 높다고 생각한다.
블로그 포스팅에 대한 responsePayload를 UI단에서 다룬다고 할 때,
const blogPosts = [
{
id: 'post1',
author: { username: 'user1', name: 'User 1' },
body: '......',
comments: [
{
id: 'comment1',
author: { username: 'user2', name: 'User 2' },
comment: '.....'
},
{
id: 'comment2',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
}
]
},
{
id: 'post2',
author: { username: 'user2', name: 'User 2' },
body: '......',
comments: [
{
id: 'comment3',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
},
{
id: 'comment4',
author: { username: 'user1', name: 'User 1' },
comment: '.....'
},
{
id: 'comment5',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
}
]
}
// and repeat many times
]
데이터 구조 계층이 깊어질 수록 다음과 같은 문제점들이 커진다.
앞서 보았단 데이터를 normalize해보자.
{
posts : {
byId : {
"post1" : {
id : "post1",
author : "user1",
body : "......",
comments : ["comment1", "comment2"]
},
"post2" : {
id : "post2",
author : "user2",
body : "......",
comments : ["comment3", "comment4", "comment5"]
}
},
allIds : ["post1", "post2"]
},
comments : {
byId : {
"comment1" : {
id : "comment1",
author : "user2",
comment : ".....",
},
"comment2" : {
id : "comment2",
author : "user3",
comment : ".....",
},
"comment3" : {
id : "comment3",
author : "user3",
comment : ".....",
},
"comment4" : {
id : "comment4",
author : "user1",
comment : ".....",
},
"comment5" : {
id : "comment5",
author : "user3",
comment : ".....",
},
},
allIds : ["comment1", "comment2", "comment3", "comment4", "comment5"]
},
users : {
byId : {
"user1" : {
username : "user1",
name : "User 1",
},
"user2" : {
username : "user2",
name : "User 2",
},
"user3" : {
username : "user3",
name : "User 3",
}
},
allIds : ["user1", "user2", "user3"]
}
}
참고하면 좋은 글 - Structuring Reducers - Normalizing State Shape
state.a.b.c.d 와 같이 state tree의 깊은 레벨에 있는 상태를 직접 참조할 수 있으나, 복잡한 상태 구조를 일일이 꿰고 있어야 하기 때문에 캡슐화와 추상화를 통해 해당 필드를 참조할 수 있는 함수를 만드는 것이 더 좋은 방법이다.
createStore를 사용해 만들어진 store를 각 컴포넌트에서 직접 임포트해서 사용하는 것도 가능하나, 컴포넌트가 해당 스토어의 변화를 구독할 수 있게 만들어줘야 하고 필요한 데이터를 참조하기 위한 Selector Functions를 컴포넌트 레벨에 직접 만들어줘야 한다. react-redux의 connect, mapState, mapDispatch는 소프트웨어 공학의 추상화와 캡슐화를 고려해 만들어진 좋은 도구다. 이 툴을 사용하면 더 좋은 질의 소프트웨어를 만들 수 있게된다.
connect 함수를 사용함으로 인해 컴포넌트에서 스토어 변화를 구독을 위한 코드를 별도로 작성하지 않아도 되며, mapStateProps를 이용해 필요한 데이터를 가공해서 가져올 수 있다.
리덕스를 사용할 때는 클래스 객체를 사용해야 할 이유가 없다. 사용하게 된다면 그것은 아마 개발자가 OOP에 익숙하기 때문일 것이다. 하나의 액션이 dispatch 되었을때 여러 리듀서가 이 액션에 대해 반응해야 하지만, 1:1 관계를 지원하는 OOP 라이브러리에서는 이를 지원하지 않는다.