이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
보일러플레이트 줄이기
Redux는 부분적으로 Flux에서 영감을 받았으며, Flux에 대한 가장 흔한 불만은 많은 보일러플레이트 코드를 작성하게 만든다는 점입니다. 이 레시피에서는 개인적인 스타일, 팀 선호도, 장기적인 유지보수성 등을 고려하여 Redux가 어떻게 코드의 상세함 수준을 선택할 수 있게 하는지 살펴보겠습니다.
액션
액션은 애플리케이션에서 발생한 일을 설명하는 일반 객체이며, 데이터 변경 의도를 표현하는 유일한 방법입니다. 액션이 디스패치해야 하는 객체라는 점은 보일러플레이트가 아니라 Redux의 기본 설계 선택 중 하나입니다.
액션 객체 개념 없이 Flux와 유사하다고 주장하는 프레임워크도 있습니다. 예측 가능성 측면에서 이는 Flux나 Redux보다 퇴보한 것입니다. 직렬화 가능한 일반 객체 액션이 없다면 사용자 세션 기록 및 재생이나 시간 여행이 포함된 핫 리로딩 구현이 불가능합니다. 데이터를 직접 수정하려는 경우 Redux가 필요 없습니다.
액션은 다음과 같이 생겼습니다:
{ type: 'ADD_TODO', text: 'Use Redux' }
{ type: 'REMOVE_TODO', id: 42 }
{ type: 'LOAD_ARTICLE', response: { ... } }
액션이 리듀서(또는 Flux의 스토어)가 식별하는 데 도움이 되는 상수 타입을 가지는 것이 일반적인 관례입니다. 문자열은 직렬화 가능하므로 액션 타입에 Symbols보다 문자열 사용을 권장합니다. Symbols를 사용하면 기록 및 재생이 필요 이상으로 어려워집니다.
Flux에서는 전통적으로 모든 액션 타입을 문자열 상수로 정의하는 것이 일반적입니다:
const ADD_TODO = 'ADD_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
const LOAD_ARTICLE = 'LOAD_ARTICLE'
이것이 왜 유용할까요? 상수가 불필요하다는 주장이 많으며, 소규모 프로젝트에서는 사실일 수 있습니다. 대규모 프로젝트에서는 액션 타입을 상수로 정의할 때 다음과 같은 이점이 있습니다:
-
모든 액션 타입이 한 곳에 모여 이름 지정의 일관성을 유지하는 데 도움이 됩니다.
-
새 기능 작업 전에 기존 모든 액션을 확인하고 싶을 때가 있습니다. 필요한 액션이 이미 팀원이 추가했는데 본인이 몰랐을 수 있습니다.
-
풀 리퀘스트에서 추가/제거/변경된 액션 타입 목록은 팀 전체가 새 기능의 범위와 구현을 파악하는 데 도움이 됩니다.
-
액션 상수 가져오기 과정에서 오타가 발생하면
undefined가 반환됩니다. Redux는 이러한 액션 디스패치 시 즉시 오류를 발생시키므로 실수를 더 빨리 발견할 수 있습니다.
프로젝트 관례 선택은 여러분의 몫입니다. 인라인 문자열로 시작한 후 상수로 전환하고, 나중에 단일 파일로 그룹화할 수 있습니다. Redux는 여기에 특별한 의견이 없으므로 최선의 판단을 사용하세요.
액션 생성자
액션을 디스패치하는 위치에서 인라인으로 액션 객체를 생성하는 대신, 이를 생성하는 함수를 만드는 것도 또 다른 일반적인 관례입니다.
예를 들어 객체 리터럴로 dispatch를 호출하는 대신:
// somewhere in an event handler
dispatch({
type: 'ADD_TODO',
text: 'Use Redux'
})
별도 파일에 액션 생성자를 작성하고 컴포넌트로 가져올 수 있습니다:
actionCreators.js
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
AddTodo.js
import { addTodo } from './actionCreators'
// somewhere in an event handler
dispatch(addTodo('Use Redux'))
액션 생성자는 종종 보일러플레이트로 비판받습니다. 하지만 꼭 작성할 필요는 없습니다! 프로젝트에 더 적합하다면 객체 리터럴을 사용해도 됩니다. 다만 액션 생성자 작성의 몇 가지 이점을 알아두시기 바랍니다.
디자이너가 프로토타입 검토 후 돌아와 최대 3개의 할 일만 허용해야 한다고 알려주는 시나리오를 가정해 보겠습니다. redux-thunk 미들웨어로 액션 생성자를 콜백 형식으로 재작성하고 조기 종료를 추가하여 이를 적용할 수 있습니다:
function addTodoWithoutCheck(text) {
return {
type: 'ADD_TODO',
text
}
}
export function addTodo(text) {
// This form is allowed by Redux Thunk middleware
// described below in “Async Action Creators” section.
return function (dispatch, getState) {
if (getState().todos.length === 3) {
// Exit early
return
}
dispatch(addTodoWithoutCheck(text))
}
}
방금 addTodo 액션 생성자의 동작을 수정했는데, 호출 코드에는 완전히 투명하게 변경되었습니다. 할 일이 추가되는 모든 위치를 일일이 확인하며 검사 로직이 있는지 걱정할 필요가 없습니다. 액션 생성자는 액션을 디스패치하는 실제 컴포넌트와 액션 발생 주변의 추가 로직을 분리해줍니다. 이는 애플리케이션이 급격히 개발 중이고 요구사항이 자주 변경될 때 매우 유용합니다.
액션 생성자 생성하기
Flummox 같은 일부 프레임워크는 액션 생성자 함수 정의에서 액션 타입 상수를 자동으로 생성합니다. 이는 ADD_TODO 상수와 addTodo() 액션 생성자를 모두 정의할 필요가 없다는 아이디어입니다. 내부적으로는 여전히 액션 타입 상수가 생성되지만, 암시적으로 생성되므로 간접적인 계층이 추가되어 혼란을 일으킬 수 있습니다. 우리는 액션 타입 상수를 명시적으로 생성할 것을 권장합니다.
간단한 액션 생성자를 작성하는 것은 지루할 수 있으며 종종 중복된 보일러플레이트 코드를 생성하게 됩니다:
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
export function editTodo(id, text) {
return {
type: 'EDIT_TODO',
id,
text
}
}
export function removeTodo(id) {
return {
type: 'REMOVE_TODO',
id
}
}
항상 액션 생성자를 생성하는 함수를 작성할 수 있습니다.
function makeActionCreator(type, ...argNames) {
return function (...args) {
const action = { type }
argNames.forEach((arg, index) => {
action[argNames[index]] = args[index]
})
return action
}
}
const ADD_TODO = 'ADD_TODO'
const EDIT_TODO = 'EDIT_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
export const addTodo = makeActionCreator(ADD_TODO, 'text')
export const editTodo = makeActionCreator(EDIT_TODO, 'id', 'text')
export const removeTodo = makeActionCreator(REMOVE_TODO, 'id')
액션 생성자 생성을 돕는 redux-act나 redux-actions 같은 유틸리티 라이브러리도 있습니다. 이들은 보일러플레이트 코드를 줄이고 Flux Standard Action (FSA) 같은 표준을 준수하도록 도와줍니다.
비동기 액션 생성자
미들웨어는 디스패치되기 전에 모든 액션 객체를 해석하는 커스텀 로직을 주입할 수 있게 합니다. 비동기 액션은 미들웨어의 가장 흔한 사용 사례입니다.
미들웨어 없이는 dispatch가 평범한 객체만 받아들이므로, 컴포넌트 내부에서 AJAX 호출을 수행해야 합니다:
actionCreators.js
export function loadPostsSuccess(userId, response) {
return {
type: 'LOAD_POSTS_SUCCESS',
userId,
response
}
}
export function loadPostsFailure(userId, error) {
return {
type: 'LOAD_POSTS_FAILURE',
userId,
error
}
}
export function loadPostsRequest(userId) {
return {
type: 'LOAD_POSTS_REQUEST',
userId
}
}
UserInfo.js
import { Component } from 'react'
import { connect } from 'react-redux'
import {
loadPostsRequest,
loadPostsSuccess,
loadPostsFailure
} from './actionCreators'
class Posts extends Component {
loadData(userId) {
// Injected into props by React Redux `connect()` call:
const { dispatch, posts } = this.props
if (posts[userId]) {
// There is cached data! Don't do anything.
return
}
// Reducer can react to this action by setting
// `isFetching` and thus letting us show a spinner.
dispatch(loadPostsRequest(userId))
// Reducer can react to these actions by filling the `users`.
fetch(`http://myapi.com/users/${userId}/posts`).then(
response => dispatch(loadPostsSuccess(userId, response)),
error => dispatch(loadPostsFailure(userId, error))
)
}
componentDidMount() {
this.loadData(this.props.userId)
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.loadData(this.props.userId)
}
}
render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}
const posts = this.props.posts.map(post => (
<Post post={post} key={post.id} />
))
return <div>{posts}</div>
}
}
export default connect(state => ({
posts: state.posts,
isFetching: state.isFetching
}))(Posts)
그러나 서로 다른 컴포넌트들이 동일한 API 엔드포인트에서 데이터를 요청하기 때문에 이는 금방 반복적이 됩니다. 게다가 캐시된 데이터가 있을 때 조기 종료 같은 로직을 여러 컴포넌트에서 재사용하고 싶습니다.
미들웨어는 더 표현력 있고 잠재적으로 비동기적인 액션 생성자를 작성할 수 있게 합니다. 평범한 객체 외의 것을 디스패치할 수 있게 하며 그 값들을 해석합니다. 예를 들어 미들웨어는 디스패치된 Promise를 "잡아" 요청 및 성공/실패 액션 쌍으로 변환할 수 있습니다.
가장 간단한 미들웨어 예시는 redux-thunk입니다. "Thunk" 미들웨어는 액션 생성자를 "thunk" 즉 함수를 반환하는 함수로 작성할 수 있게 합니다. 이는 제어를 반전시킵니다: dispatch를 인자로 받게 되므로 여러 번 디스패치하는 액션 생성자를 작성할 수 있습니다.
주의
Thunk 미들웨어는 미들웨어의 한 예시일 뿐입니다. 미들웨어의 핵심은 "함수를 디스패치하게 하는 것"이 아닙니다. 사용하는 특정 미들웨어가 처리 방법을 아는 무엇이든 디스패치할 수 있게 하는 것입니다. Thunk 미들웨어는 함수를 디스패치할 때 특정 동작을 추가하지만, 실제 동작은 사용하는 미들웨어에 따라 달라집니다.
다음은 redux-thunk로 재작성한 코드입니다:
actionCreators.js
export function loadPosts(userId) {
// Interpreted by the thunk middleware:
return function (dispatch, getState) {
const { posts } = getState()
if (posts[userId]) {
// There is cached data! Don't do anything.
return
}
dispatch({
type: 'LOAD_POSTS_REQUEST',
userId
})
// Dispatch vanilla actions asynchronously
fetch(`http://myapi.com/users/${userId}/posts`).then(
response =>
dispatch({
type: 'LOAD_POSTS_SUCCESS',
userId,
response
}),
error =>
dispatch({
type: 'LOAD_POSTS_FAILURE',
userId,
error
})
)
}
}
UserInfo.js
import { Component } from 'react'
import { connect } from 'react-redux'
import { loadPosts } from './actionCreators'
class Posts extends Component {
componentDidMount() {
this.props.dispatch(loadPosts(this.props.userId))
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.props.dispatch(loadPosts(this.props.userId))
}
}
render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}
const posts = this.props.posts.map(post => (
<Post post={post} key={post.id} />
))
return <div>{posts}</div>
}
}
export default connect(state => ({
posts: state.posts,
isFetching: state.isFetching
}))(Posts)
이렇게 하면 타이핑이 훨씬 줄어듭니다! 원한다면 컨테이너 loadPosts 액션 생성자에서 사용할 loadPostsSuccess 같은 "평범한" 액션 생성자를 여전히 유지할 수 있습니다.
마지막으로 자신만의 미들웨어를 작성할 수 있습니다. 위 패턴을 일반화하고 비동기 액션 생성자를 다음과 같이 기술하고 싶다고 가정해보세요:
export function loadPosts(userId) {
return {
// Types of actions to emit before and after
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
// Check the cache (optional):
shouldCallAPI: state => !state.posts[userId],
// Perform the fetching:
callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
// Arguments to inject in begin/end actions
payload: { userId }
}
}
이러한 액션을 해석하는 미들웨어는 다음과 같이 생겼을 수 있습니다:
function callAPIMiddleware({ dispatch, getState }) {
return next => action => {
const { types, callAPI, shouldCallAPI = () => true, payload = {} } = action
if (!types) {
// Normal action: pass it on
return next(action)
}
if (
!Array.isArray(types) ||
types.length !== 3 ||
!types.every(type => typeof type === 'string')
) {
throw new Error('Expected an array of three string types.')
}
if (typeof callAPI !== 'function') {
throw new Error('Expected callAPI to be a function.')
}
if (!shouldCallAPI(getState())) {
return
}
const [requestType, successType, failureType] = types
dispatch(
Object.assign({}, payload, {
type: requestType
})
)
return callAPI().then(
response =>
dispatch(
Object.assign({}, payload, {
response,
type: successType
})
),
error =>
dispatch(
Object.assign({}, payload, {
error,
type: failureType
})
)
)
}
}
이 미들웨어를 applyMiddleware(...middlewares)에 한 번 전달하면, 모든 API 호출 액션 생성자를 동일한 방식으로 작성할 수 있습니다:
export function loadPosts(userId) {
return {
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
shouldCallAPI: state => !state.posts[userId],
callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
payload: { userId }
}
}
export function loadComments(postId) {
return {
types: [
'LOAD_COMMENTS_REQUEST',
'LOAD_COMMENTS_SUCCESS',
'LOAD_COMMENTS_FAILURE'
],
shouldCallAPI: state => !state.comments[postId],
callAPI: () => fetch(`http://myapi.com/posts/${postId}/comments`),
payload: { postId }
}
}
export function addComment(postId, message) {
return {
types: [
'ADD_COMMENT_REQUEST',
'ADD_COMMENT_SUCCESS',
'ADD_COMMENT_FAILURE'
],
callAPI: () =>
fetch(`http://myapi.com/posts/${postId}/comments`, {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ message })
}),
payload: { postId, message }
}
}
리듀서
Redux는 업데이트 로직을 함수로 표현함으로써 Flux 스토어의 상용구를 상당히 줄여줍니다. 함수는 객체보다 간단하며 클래스보다 훨씬 단순합니다.
다음 Flux 스토어 예시를 고려해보세요:
const _todos = []
const TodoStore = Object.assign({}, EventEmitter.prototype, {
getAll() {
return _todos
}
})
AppDispatcher.register(function (action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
const text = action.text.trim()
_todos.push(text)
TodoStore.emitChange()
}
})
export default TodoStore
Redux에서는 동일한 업데이트 로직을 리듀서 함수로 표현할 수 있습니다:
export function todos(state = [], action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
const text = action.text.trim()
return [...state, text]
default:
return state
}
}
switch 문이 진정한 상용구는 아닙니다. Flux의 진정한 상용구는 개념적입니다: 업데이트를 발생시켜야 하는 필요성, 디스패처에 스토어를 등록해야 하는 필요성, 스토어가 객체여야 하는 필요성(그리고 유니버설 앱을 만들 때 발생하는 복잡성들).
문서에서 switch 문을 사용하는지 여부에 따라 여전히 Flux 프레임워크를 선택하는 경우가 많은 것은 유감스러운 일입니다. switch가 마음에 들지 않는다면 아래에서 보여드릴 단일 함수로 해결할 수 있습니다.
리듀서 생성하기
액션 타입을 핸들러에 매핑하는 객체로 리듀서를 표현할 수 있는 함수를 작성해 보겠습니다. 예를 들어 todos 리듀서를 다음과 같이 정의하고 싶다면:
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
}
}
}
어렵지 않았죠? Redux는 기본적으로 이런 헬퍼 함수를 제공하지 않습니다. 다양한 방식으로 작성할 수 있기 때문입니다. 서버 상태를 하이드레이션하기 위해 일반 JS 객체를 Immutable 객체로 자동 변환하고 싶을 수도 있습니다. 반환된 상태를 현재 상태와 병합하고 싶을 수도 있습니다. "모든 처리" 핸들러에 대한 다양한 접근 방식이 있을 수 있습니다. 이 모든 것은 특정 프로젝트에서 팀이 선택한 컨벤션에 따라 달라집니다.
Redux 리듀서 API는 (state, action) => newState이지만, 이러한 리듀서를 어떻게 생성할지는 여러분에게 달려 있습니다.