홈으로

[번역] 리덕스 스타일 가이드

2020년 03월 22일

리덕스 스타일 가이드

소개

이 문서는 리덕스 코드를 작성하는 공식적인 스타일 가이드이다. 우리가 추천하는 패턴과 최선의 방식 및 리덕스 어플리케이션을 만드는 접근법들을 설명한다.

리덕스의 코어 라이브러리와 대부분의 리덕스 문서에는 그리 의견이 많지 않다. 리덕스를 사용하는 방법은 굉장히 많았지만 많은 시간 동안 “올바르게” 사용하는 방법은 없었다.

그러나, 시간과 경험이 일부 주제들에 대해서 특정한 접근방법들이 다른 것들보다 더 낫다는 것을 보여주었다. 또한, 많은 개발자들이 설계에 힘을 들이지 않도록 공식적인 가이드를 제공해달라고 요청해왔다.

이를 염두에 두고, 에러와 쓸데없는 시간 낭비 및 안티-패턴들을 피하기 위한 권장사항들을 모았다. 또한 각 팀의 선호사항이 다르고 다른 프로젝트들이 각각 다른 요구조건들이 있기 때문에 모든 것에 적합한 스타일 가이드는 없는 것을 알고 있다. 따라서, 권장사항들을 따르기를 권하지만, 자신의 상황을 잘 파악해서 이것들이 필요사항에 맞는지 정하는 시간을 가져야 할 것이다.

마지막으로, 이 페이지를 만드는에 영감을 주었던 뷰 스타일 가이드의 작성자들에게 고마움을 표한다.

규칙 카테고리

규칙을 3가지 카테고리로 나누었다.

우선순위 A: 필수

이 규칙들은 에러를 방지하는데 도움이 되므로, 학습비용을 감수하더라도 꼭 배워야 한다. 예외사항이 있을 수 있지만, 그런 경우는 매우 드물어야 하며 자바스크립트와 리덕스에 전문적인 지식을 가진 사람에 의해서만 이루어져야 한다.

우선순위 B: 강력 추천

이 규칙들은 대부분의 프로젝트에서 가독성 및/또는 개발자의 경험을 개선시킨다는 것이 밝혀졌다. 규칙을 위반해도 코드는 동작하겠지만, 위반하는 경우가 드물어야 하며 그 위반사유가 잘 정당화되어야 한다. 합리적으로 가능하다면 이 규칙을 따르도록 하라.

우선순위 C: 권장

여러개의 동일하게 좋은 옵션들이 있다면, 일관성을 유지하기 위해 아무거나 선택해도 된다. 이 규칙들에서, 각각의 가능한 옵션들을 설명하고 기본적인 선택을 제안한다. 이 말은 즉, 코드를 짤 때 일관적이며 합당한 이유를 갖고 있다면 다른 선택을 해도 상관없다는 뜻이다. 그래도 좋은 이유가 있는 것이 좋다!

우선순위 A의 규칙: 필수

상태를 직접 변경하지 마라.

상태를 직접 바꾸는 행위는 리덕스 어플리케이션에서 발생하는 가장 흔한 버그 중 하나로, 컴포넌트로 하여금 다시 렌더링 되는 것을 막거나 리덕스 개발자 도구에서의 시간여행(time-travel) 디버깅을 고장낼 수 있다. 리듀서들과 모든 다른 코드에서 상태 값의 직접적인 변경은 반드시 피해야 한다.

개발하는 동안, 상태변화를 감지하기 위해 redux-immutable-state-invariant 를 사용하고 우발적인 상태변경을 막기 위해 Immer 를 사용하라.

참고 : 복사한 값 을 변경하는 것은 괜찮다 - 불변 업데이트 로직의 일반적인 경우이다. 또한, Immer 라이브러리를 사용하고 있다면, 실제 값은 변경되지 않기 때문에 “직접 변경하는” 로직을 작성해도 괜찮다 - Immer가 안전하게 변화를 추적하고 내부에서 불변 업데이트된 값들을 만들어낸다.

리듀서들은 부수효과를 가지면 안된다.

리듀서의 함수들은 오직 그것들의 stateaction 인자들에만 의존해야 하고 그 인자들을 기반으로 계산한 새로운 상태만을 반환해야 한다. 그것들은 절대로 비동기 로직(Ajax 호출, 타임아웃, Promises 등), 랜덤 값 생성(Date.now(), Math.random()), 리듀서 밖의 값 변경, 또는 리듀서 함수의 외부 스코프에 있는 것들을 변경할 수 있는 코드를 실행해서는 안된다.

참고 : 리듀서가 외부에 있는 함수들을 호출하는 것은 가능한데 예를 들어, 유틸리티 함수들이나 라이브러리로 가져온 경우이다. 물론, 이것들도 같은 규칙을 따르는 한해서이다.

자세한 설명 이 규칙의 목적은 리듀서가 호출됬을 때 예측가능하게 만들기 위함이다. 예를 들어, 당신이 시간여행 디버깅을 하고 있다고 하면, 리듀서 함수들은 "현재" 상태 값들을 만들어내도록 이전 액션들과 함께 여러번 호출될 수 있다. 만약 리듀서가 부수효과를 가진다면, 디버깅 할 동안 그런 효과들이 발생할 수 있고 어플리케이션으로 하여금 예상못한 결과를 만들어내게 된다.
이 규칙에 대한 애매한 부분이 있긴 하다. 엄밀히 말하자면, console.log(state) 와 같은 코드는 부수 효과이지만 실제로 어플리케이션의 동작방식에 아무런 영향을 주지 않는다.

직렬화-불가능한 값들을 상태나 액션에 넣지 마라.

Promises, Symbols, Maps/Sets, 함수들, 또는 클래스 인스턴스와 같은 직렬화-불가능한 것들을 리덕스 스토어의 상태나 디스패치되는 액션에 넣는 것을 피하라. 이것은 리덕스 개발자 도구로 디버깅하는 것과 같은 기능이 제대로 동작하게 한다. 또한 UI도 예상한 것처럼 업데이트되게 한다.

예외 : 리듀서에 도달하기 전, 액션을 미들웨어가 가로채서 중지시킨다면, 직렬화-불가능한 값들을 액션에 넣을수 있다. 예를 들어, redux-thunkredux-promise 같은 것들이 있다.

하나의 어플리케이션은 오직 하나의 스토어를 가져야 한다.

표준 리덕스 어플리케이션은 오직 하나의 리덕스 스토어 인스턴스를 가져야 하며 이는 어플리케이션 전체에서 사용된다. 일반적으로 store.js 와 같은 별도의 파일에 정의되어야 한다.

이상적으로, 로직 중에서 스토어를 직접적으로 가져오는 경우는 없다. <Provider> 를 통해서 리액트 컴포넌트 트리로 전달되거나 thunk와 같은 미들웨어를 통해 간접적으로 참조되어야 한다. 드물게, 다른 로직 파일에서 가져올 수 있지만, 최후의 수단이어야 한다.

우선순위 B의 규칙: 강력 추천

리덕스 로직을 작성하는데 Redux Toolkit을 사용하자.

Redux ToolKit, RTK 은 리덕스 사용을 위해 추천하는 도구의 세트이다. 우리가 제안한 최선의 방법들을 사용하는 함수들을 가지고 있으며 상태변화를 감지하도록 스토어를 설정하고 리덕스 개발자 도구 사용을 가능하게 할 뿐 아니라, Immer를 사용하여 불변 업데이트 로직을 단순화하는 등의 여러가지 유용한 기능들을 가지고 있다.

RTK가 필요없을 수도 있고, 다른 접근법들이 바람직하다면 그것들을 사용해도 상관없지만 RTK를 사용하는 것이 로직을 단순하게 만들고 어플리케이션으로 하여금 좋은 설정을 기본으로 가지도록 보장해줄 것이다.

불변 업데이트를 하는데 Immer를 사용하자.

불변 업데이트 로직을 직접 작성하는 것은 항상 어렵고 에러가 발생할 여지가 있다. Immer 는 “직접 변경하는” 로직을 사용해서 간단하게 불변 로직을 작성하도록 도와주고, 심지어 개발중에 어플리케이션 어디에서나 상태변화를 감지하도록 상태를 얼린다(freeze). 불변 업데이트 로직을 작성하는데 Redux Toolkit 의 일부인 Immer를 사용하는 것을 추천한다.

Feature/Ducks 폴더로 파일을 구조화하자.

리덕스 그 자체는 어플리케이션의 폴더나 파일이 어떻게 구조화하는지에 상관하지 않는다. 그러나, 주어진 기능에 대해 동일한 로직들이 한곳에 위치하도록 하는 것은 코드를 보다 쉽게 유지보수하게 만들어준다.

이러한 이유로, 대부분의 어플리케이션들이 코드의 “타입” (리듀서, 액션 등)에 따라 폴더를 만들어 로직을 분리하기 보다 “feature 폴더” 접근법이나 (기능에 대한 모든 파일들을 하나의 폴더에 넣는 것) “ducks” 패턴 (하나의 파일에 기능에 대한 모든 리덕스 로직을 넣는 것) 을 사용해서 파일들을 구조화하는 것을 추천한다.

자세한 설명 폴더구조의 예시는 다음과 같을 것이다.
  • /src
    • index.tsx
    • /app
      • store.ts
      • rootReducer.ts
      • App.tsx
    • /common
      • 훅, 포괄적인 컴포넌트, 유틸 등
    • /features
      • /todos
        • todosSlice.ts
        • Todos.tsx
/app은 앱 전체적인 설정과 다른 폴더들에 의존하는 레이아웃을 포함한다.
/common은 포괄적이거나 재사용 가능한 유틸 및 컴포넌트를 포함한다.
/features는 특정 기능과 관련된 모든 기능들을 포함한 폴더를 가진다. 이 예시에서, todosSlice.ts는 "duck"-스타일의 파일이며 여기서 RTK의 createSlice() 함수를 호출하고 이로 인해 생성된 리듀서와 액션 생성자를 내보낸다.

리듀서에 가능한 많은 로직을 넣자.

가능하면 새로운 상태를 계산하는 로직을, 액션을 준비하고 디스패치하는 코드 (클릭 핸들러와 같은) 가 아니라 해당 로직에 적합한 리듀서에 많이 넣도록 하자. 이는 대부분의 어플리케이션 로직을 쉽게 테스트 할 수 있도록 해주며, 시간여행 디버깅을 보다 효과적으로 도와주고 변종(mutation)이나 버그로 이어질 수 있는 흔한 실수들을 방지하는데 도움이 된다.

새로운 상태 전체 또는 일부가 먼저 계산되어야 하는 경우 (유일무이한 아이디 값을 생성해내는 경우와 같은) 가 예외이긴 하지만 최소한으로 지켜줘야 한다.

자세한 설명

리덕스 코어는 새로운 상태 값이 리듀서 안에서 계산되는지 아니면 액션 생성 로직 안에서 계산되는지에는 신경쓰지 않는다. 예를들어, 할일목록 앱의 경우, “할일 토글” 액션을 위한 로직은 할일목록의 배열을 불변 업데이트하는 것을 요구한다. 액션이 할일의 ID만을 포함하고 리듀서 안에서 새로운 배열을 계산할 수 있다.

// 클릭 핸들러:
const onTodoClicked = (id) => {
    dispatch({type: "todos/toggleTodo", payload: {id}})
}

// 리듀서:
case "todos/toggleTodo": {
    return state.map(todo => {
        if(todo.id !== action.payload.id) return todo;

        return {...todo, id: action.payload.id};
    })
}

또한 새로운 배열을 먼저 계산해서 전체배열을 액션에 넣어도 된다.

// 클릭 핸들러:
const onTodoClicked = id => {
  const newTodos = todos.map(todo => {
    if (todo.id !== id) return todo

    return { ...todo, id }
  })

  dispatch({ type: 'todos/toggleTodo', payload: { todos: newTodos } })
}

// 리듀서:
case "todos/toggleTodo":
    return action.payload.todos;

그러나, 여러가지 이유로 리듀서안에서 로직을 수행하는 것이 바람직하다:

  • 리듀서는 순수함수이기 때문에 항상 테스트하기가 쉽다 - const result = reducer(testState, action) 만 호출하면 되고, 결과를 원하는 값으로 단언(assert)하면 된다. 따라서, 리듀서 안에 로직을 많이 넣을 수록, 테스트하기 쉬운 로직이 많아지는 것이다.
  • 리덕스의 상태는 항상 불변 업데이트 규칙 을 따라야 한다. 대부분의 리덕스 사용자들은 리듀서 안에서 해당 규칙들을 따라야 하는 것은 알지만, 새로운 상태가 리듀서 밖에서 계산될 경우에 또한 이 규칙들을 따라야 하는지에 대해선 잘 모른다. 이는 우발적인 상태변경이나, 심지어는 리덕스 스토어에서 값을 읽어서 액션 안에 바로 전달하는 것과 같은 실수로 이어지기 쉽다. 리듀서안에서 모든 상태변경 로직을 수행하는 것은 이런 실수들을 방지한다.
  • Redux Toolkit 또는 Immer를 사용한다면, 리듀서 안에서 불변 업데이트 로직을 작성하기 쉽고 Immer는 상태를 얼릴뿐만 아니라 우발적인 상태변경을 잡아낼 것이다.
  • 시간여행 디버깅에서 디스패치된 액션을 “실행 취소” 하고 다른 작업들을 하거나 그 액션을 “다시 실행” 할 수 있다. 또한, 리듀서들의 핫-리로딩(hot-reloading)은 기존의 액션들로 새로운 리듀서를 재실행할 수 있다. 올바른 액션을 가졌지만 리듀서에 버그가 있다면, 리듀서의 버그를 고치고 핫-리로드를 해서 제대로된 상태값을 얻어낼 수 있다. 액션 자체가 잘못되었다면, 액션이 디스패치되는 단계를 다시 실행하여야 한다. 따라서, 리듀서에 더 많은 로직이 있는 것이 디버깅 하기 쉬운것이다.
  • 마지막으로, 리듀서안에 로직을 넣으면 전체 코드에 로직이 흩어지는 대신 업데이트 로직이 어디있는지 바로 알 수 있다.

리듀서는 상태모양(State Shape)을 가져야 한다.

리덕스의 루트 상태는 루트 리듀서 함수 1개가 가지고 계산한다. 유지보수를 위해, 루트 리듀서는 키/값 “슬라이스(slices)“에 의해 분할되며, 각각의 “슬라이스 리듀서”는 초기값을 제공하고 해당 상태에 대한 업데이트를 계산한다.

또한, 슬라이스 리듀서는 계산된 상태의 일부로 반환되는 값들을 제어해야 한다. return action.payloadreturn {...state, ...action.payload} 와 같은 “보이지 않는 spread 및 리턴”의 사용을 최소화 해야 하는데 그 이유는, 컨텐츠를 올바른 포맷으로 수정하기 위해 액션을 디스패치하는 코드에 의존하고, 리듀서는 상태가 어떻게 생겼는지 신경을 쓰지 않기 때문이다. 액션의 컨텐츠가 잘못되었다면 이는 버그로 이어질 수 있다.

참고 : “spread 리턴” 리듀서는 폼의 데이터를 수정하는 것과 같은 경우에 합리적인 선택이 될 수 있는데, 폼의 각 필드에 대해 별도의 액션 타입을 작성하는 것은 시간낭비가 될 수 있고 별로 이득이 없기 때문이다.

자세한 설명

“현재 사용자” 리듀서가 아래와 같이 생겼다고 하자:

const initialState = {
    firstName: null,
    lastName: null,
    age: null,
};

export default usersReducer = (state = initialState, action) {
    switch(action.type) {
        case "users/userLoggedIn": {
            return action.payload;
        }
        default: return state;
    }
}

이 예시에서, 리듀서는 완전히 action.payload 가 올바른 포맷을 가진 객체라고 가정한다. 그러나, 코드 일부분이 “사용자” 객체가 아니라 “할일” 객체를 액션에 넣어 디스패치했다고 해보자.

dispatch({
  type: 'users/userLoggedIn',
  payload: {
    id: 42,
    text: 'Buy milk'
  }
})

리듀서는 보이지 않게 “할일”을 반환할 것이고, 앱의 나머지 부분은 스토어에서 “사용자”를 가져오려 할 때 “할일”을 가져오게 되므로 앱이 깨질 것이다. action.payload 가 올바른 필드를 가졌는지 유효성 체크를 한다거나 이름으로 올바른 필드를 읽는 등의 방법을 사용한다면 적어도 부분적으로는 고칠 수 있을 것이다. 하지만 이러한 방법은 안전함을 위해서 코드를 더 추가해야 하는가에 대한 이율배반(trade-off)을 고민하게 된다. 정적 타이핑을 사용한다면 이러한 종류의 코드를 더욱 안전하고 보다 수용가능하게 만든다. 리듀서가 actionPayloadAction<User> 라는 것을 안다면, return action.payload안전할 수밖에 없다.

저장된 데이터 기반으로 상태 슬라이스들의 이름을 짓자.

리듀서는 상태모양을 가져야 한다 에서 말했던 것처럼, 리듀서 로직을 분리하는 표준적인 접근방식은 상태의 “슬라이스” 기반이다. 마찬가지로, combineReducers 는 이러한 슬라이스 리듀서들을 하나의 거대한 리듀서 함수로 합치는 표준함수이다.

combineReducers 에 넘겨지는 객체의 키 이름은 결과적으로 나올 상태 객체의 키 이름을 정의한다. 내부에 저장되는 데이터의 이름을 따서 키 이름을 지정하도록 하고, “reducer” 와 같은 이름은 피하자. 객체가 {userReducer: {}, postsReducer: {}} 보다 {users: {}, posts: {}} 같이 생겨야 한다는 말이다.

자세한 설명

ES6 객체 리터럴은 키 이름과 값을 한번에 정의하는 단축기능을 제공한다:

const data = 42;
const obj = { data }
// {data: data}와 같다.

combineReducers 는 리듀서 함수가 모두 들어있는 객체를 받고 키 이름과 동일한 상태 객체들을 생성한다. 함수 객체의 키 이름이 상태 객체의 키 이름을 정의한다는 것을 의미한다. 이는 리듀서를 “reducer”와 같은 변수이름으로 가져와서 객체 리터럴 단축기능을 사용하여 combineReducers 에게 넘기는 실수를 발생시킨다.

import usersReducer from 'features/users/usersSlice'

const rootReducer = combineReducers({
  usersReducer
})

이 경우에, 객체 리터럴 단축기능은 {usersReducer: usersReducer} 와 같은 객체를 만든다. 따라서, “usersReducer”는 상태객체의 키 이름이 된다. 이는 불필요하고 쓸모가 없다. 대신에, 안에 있는 데이터에만 관련있도록 이름을 정의하자. 우리는 명시적으로 key: value 문법을 사용하는 것을 제안한다.

import usersReducer from 'features/users/usersSlice'
import postsReducer from 'features/posts/postsSlice'

const rootReducer = combineReducers({
  users: usersReducer,
  posts: postsReducer
})

코드를 더 작성하긴 하지만, 가장 이해하기 쉬운 코드와 상태 정의이다.

리듀서를 상태기계(State Machine)처럼 다루자.

많은 리덕스의 리듀서들이 “조건없이” 작성되고는 한다. 현재 상태가 어떤지에 대한 아무런 로직 없이 디스패치된 액션만 보고 새로운 상태 값을 계산한다. 이런 습관은 버그를 일으킬 수 있는데, 일부 액션들이 앱의 나머지 로직에 의존하므로 특정 시점에 개념적으로 “유효”하지 않기 때문이다. 예를들어, “요청 성공” 액션은 상태가 이미 “로딩중” 이라고 말하는 경우에만 새로 계산되어야 하며, “항목 업데이트” 액션은 항목이 “수정되는 중” 일 때만 디스패치되어야 한다.

이를 고치기 위해서, 액션자체를 조건없이 다루지 말고 현재 상태 그리고 디스패치된 액션 모두의 조합이 새로운 상태 값이 실제로 계산되어야 하는지 여부를 결정하는 “상태 기계” 처럼 다뤄야 한다.

자세한 설명

유한 상태 기계(Finite State Machine) 는 특정 시점에 “유한한 상태들” 중 하나의 상태를 가져야 하는 모델을 구성하는 유용한 방법이다. 예를들어, fetchUserReducer 를 가지고 있다면, 유한한 상태들은 다음이 될 수 있다:

  • "idle" (시작하지 않은 상태)
  • "loading" (사용자 정보를 가져오고 있는 상태)
  • "success" (사용자 정보를 성공적으로 가져옴)
  • "failure" (사용자 정보를 가져오는데 실패함)

위와 같은 상태들을 명확하게 만들고 불가능한 상태들을 불가능하게 만들기 위해선, 유한한 상태를 가지고 있도록 속성을 명시할 수 있다.

const initialUserState = {
  status: 'idle', // 명시적인 유한 상태
  user: null,
  error: null
}

타입스크립트를 사용한다면, 각각의 유한 상태를 표현하는데 식별 유니온 을 사용해서 쉽게 만들 수 있다. 예를 들어, state.status === 'success' 라면 state.user 가 정의되었다고 생각하지 state.error 가 참이라고 생각하진 않을 것이다. 이를 타입으로 강제할 수 있다.

보통, 리듀서 로직은 액션을 먼저 고려해서 작성된다. 그러나 상태기계로 로직을 모델링할 때는, 상태를 먼저 고려하는 것이 중요하다. 각 상태에 대해서 “유한 상태 리듀서”를 만드는 것은 각 상태에 따른 행위를 캡슐화하도록 도와준다:

import {
  FETCH_USER,
  // ...
} from './actions'

const IDLE_STATUS = 'idle';
const LOADING_STATUS = 'loading';
const SUCCESS_STATUS = 'success';
const FAILURE_STATUS = 'failure';

const fetchIdleUserReducer = (state, action) => {
  // state.status는 "idle"이다.
  switch (action.type) {
    case FETCH_USER:
      return {
        ...state,
        status: LOADING_STATUS
      }
    }
    default:
      return state;
  }
}

// ... 다른 리듀서들

const fetchUserReducer = (state, action) => {
  switch (state.status) {
    case IDLE_STATUS:
      return fetchIdleUserReducer(state, action);
    case LOADING_STATUS:
      return fetchLoadingUserReducer(state, action);
    case SUCCESS_STATUS:
      return fetchSuccessUserReducer(state, action);
    case FAILURE_STATUS:
      return fetchFailureUserReducer(state, action);
    default:
      // 여기는 절대 도달하면 안된다.
      return state;
  }
}

이제, 액션이 아닌 상태에 따라 행위를 정의했기 때문에 불가능한 전환도 막을 수 있다. 예를 들어, FETCH_USER 액션은 status === LOADING_STATUS 일 때 어떠한 효과도 없어야 하며, 실수로 예외 경우(edge case)를 만들어냐자 않고 이를 강제할 수 있다.

복잡한 중첩/관계형 상태를 정규화하자.

많은 어플리케이션들은 스토어 안에 복잡한 데이터를 저장할 때가 있다. 그 데이터는 API로부터 중첩된 형태로 받아지거나 데이터 안의 요소들 사이의 관계를 가지고 있다. (Users, Posts, Comments 를 포함한 블로그가 이에 속한다.)

그런 데이터를 스토어에 “정규화된” 형태 로 저장하자. ID를 통해서 항목을 찾거나 스토어의 단일 항목의 업데이트를 더 쉽게 해주고, 궁극적으로 더 나은 퍼포먼스 패턴으로 이어진다.

액션을 세터(Setter)가 아닌 이벤트로 모델링하자.

리덕스는 action.type 필드의 내용이 뭔지에는 관심이 없다 - 그냥 정의되어야 하는 것이다. 액션을 현재형 ("users/update"), 과거형("users/updated"), 이벤트로 표현된 경우("upload/progress"), 또는 “세터”로 다뤄진 경우("users/setUserName")로 작성할 수 있다. 어플리케이션에서 주어진 액션이 무엇을 의미하는지를 결정하고 어떻게 모델링할 것인지는 당신에게 달렸다.

그러나, 액션을 “세터” 보다 “일어나는 이벤트를 묘사하는” 방식으로 다룰 것을 추천한다. 액션을 “이벤트” 처럼 다루는 것은 일반적으로 더 의미있는 액션 이름, 디스패치되는 전체 액션 수 감소, 그리고 더 의미있는 액션 로그 기록으로 이어지게 된다. “세터”를 작성하는 것은 너무 많은 개별 액션타입들, 너무 많은 디스패치들, 그리고 덜 의미있는 액션 로그를 초래한다.

자세한 설명

식당 앱을 개발하는데, 누군가가 피자와 음료 1병을 주문했다고 하자. 액션을 다음과 같이 디스패치 할 수 있다:

{ type: "food/orderAdded",  payload: {pizza: 1, coke: 1} }

또는 다음과 같이 디스패치할 수 있다:

{
    type: "orders/setPizzasOrdered",
    payload: {
        amount: getState().orders.pizza + 1,
    }
}

{
    type: "orders/setCokesOrdered",
    payload: {
        amount: getState().orders.coke + 1,
    }
}

첫번째 예시는 “이벤트”가 될 것이다. “이봐, 누군가가 피자와 음료를 주문했어, 어떻게든 처리해줘”. 두번째는 “세터”의 예시이다. “‘피자주문’과 ‘음료주문’ 이라는 필드를 알고 있어, 그리고 너에게 그것들의 현재 값을 이 숫자들로 설정하라고 명령하는 중이야”.

“이벤트” 접근방식은 단일 액션만이 디스패치되면 되고 훨씬 유연하다. 얼마나 많은 피자가 먼저 주문되었든지 상관없다. 요리가 불가능하다면, 주문은 무시되는 것이다. “세터” 접근방식은 클라이언트의 코드가 실제 상태구조가 어떤지에 대해 더 알아야 하고 “올바른” 값이 어떤것이 되어야 하는지를 알아야 하며, “트랜잭션”을 완료하기 위해 여러개의 액션을 디스패치하게 된다.

의미있는 액션 이름을 사용하자.

action.type 필드는 2가지 목적을 가지고 쓰인다:

  • 리듀서의 로직은 액션이 새로운 상태를 계산하는 데 쓰여야 하는가를 보기 위해서 액션 타입을 체크한다.
  • 액션 타입들은 개발자로 하여금 읽게 하기 위해 리덕스 개발자 도구 로그에 보여진다.

액션을 세터(Setter)가 아닌 이벤트로 모델링하자. 에 따라서, type 필드의 실제 내용은 리덕스 자체에 상관이 없다. 그러나, type 값은 개발자에게 상관이 있다. 액션들은 의미있고 유용한 정보를 주도록, 설명하는 방식의 type 필드가 작성되어야 한다. 이상적으로, 디스패치되는 액션 타입들을 읽을 수 있어야 하며, 각 액션의 내용을 보지 않고도 어플리케이션에서 어떤 일들이 일어나는지에 대해 명확히 이해하여야 한다. "SET_DATA""UPDATE_STORE" 와 같은 포괄적인 액션 이름은 피하는 것이 좋은데, 이는 어떤 일이 일어나는지에 대해 의미있는 정보를 제공하지 않기 때문이다.

많은 리듀서들이 동일한 액션에 반응하도록 만들자.

리덕스의 리듀서 로직은 많은 작은 리듀서들로 쪼개지게 되는데, 각각은 독립적으로 소유하고 있는 상태트리를 업데이트하고 루트 리듀서 함수를 형성하기 위해서 모아진다. 액션이 디스패치되면, 전부 혹은 일부 리듀서들에 의해 다뤄지거나 어떤 리듀서에 의해서도 다뤄지지 않을 수 있다.

이것의 일부분으로, 개발자는 가능한 많은 리듀서 함수들이 모두 동일한 액션을 별도로 다루게 하는 것이 좋다. 실제로, 경험을 통해 대부분의 액션들이 보통 단일 리듀서 함수에 의해서 다뤄진다는 것을 보여주었으며 이는 물론 괜찮다. 하지만, “이벤트”로 액션을 모델링하는 것과 많은 리듀서들에게 이러한 액션들에 대해 반응하도록 하는 것은 어플리케이션의 코드로 하여금 규모가변성(Scalability)을 더 낫게 만들고, 하나의 의미있는 업데이트를 위해서 여러개의 액션을 디스패치하는데 필요한 시간을 최소화시켜준다.

연속적으로 많은 액션들을 디스패치하는 것을 피하자.

거대하고 개념적인 “트랜잭션” 형태의 업데이트를 수행하기 위해서 줄줄이 액션을 디스패치하는 것을 피하자. 가능하긴 하지만, 상당한 UI 업데이트 비용을 발생시키고, 일부의 중간 상태들이 잠재적으로 다른 로직에 의해 유효하지 않을 수 있다. 적절한 상태 업데이트를 한번에 하는 단일 “이벤트”-타입 액션을 디스패치하거나, 단일 UI 업데이트를 위해 다수의 액션을 디스패치하는데 액션 일괄처리 애드온을 사용하는 것을 고려해보자.

자세한 설명

줄줄이 얼마나 많은 액션을 디스패치하는지에 대해 제약은 없다. 하지만, 디스패치되는 액션이 스토어를 구독(subscribe)하고 있는 모든 콜백함수를 실행시킬 수 있고 (보통 리덕스에 연결된 UI 컴포넌트당 1개 이상) 일반적으로 UI 업데이트를 발생시킨다.

리액트의 이벤트 핸들러들로부터 큐잉(큐에 집어넣은 것)된 UI 업데이트는 보통 단일 리액트 렌더링으로 일괄처리되지만, 외부에서 큐잉된 업데이트는 그렇지 않다. 이런 경우는 대부분의 async 함수들, 타임아웃 콜백함수들, 리액트가 아닌 코드를 포함한다. 이런 상황에서, 각 디스패치는 디스패치가 끝나기 전에 동기적인 리액트 렌더링을 발생시키는데, 이는 퍼포먼스 감소로 이어진다.

또한, 거대한 “트랜잭션”-스타일 업데이트의 개념적인 부분인 다수의 디스패치들은 유효하다고 생각되지 않는 중간 상태들을 초래할 것이다. 예를들어, "UPDATE_A", "UPDATE_B", 그리고 "UPDATE_C" 가 줄줄이 디스패치 되면, 어떤 코드는 a, b, 그리고 c 가 같이 업데이트되는 것을 기대할테지만, 처음 2개의 디스패치 이후의 상태는 1개 또는 2개만 업데이트가 되었기 때문에 불완전하다.

연속적인 디스패치가 정말 필요하다면, 어떤 방식으로든 업데이트를 일괄처리하는 것을 고려하자. 경우에 따라 다른데, 리액트의 렌더링을 일괄처리하거나(아마도 React-Redux의 batch() 사용), 스토어에게 알리는 콜백함수를 늦추거나(debounce), 여러개의 액션을 하나로 묶은 거대한 단일 디스패치로 사용해서 한번의 알림만 줄 수 있다. 추가적인 예시들과 링크는 “스토어의 업데이트 이벤트를 줄이기”에 관한 FAQ 를 보라.

각 상태가 어디에 있어야 하는지를 평가하자.

리덕스의 3가지 원칙 은 “어플리케이션 전체의 상태는 하나의 트리에 저장된다” 라고 말한다. 이 문구는 지나치게 해석되었다. 이는 문자적으로 전체 앱의 모든 값이 반드시 리덕스 스토어에 저장되어야 한다는 것을 의미하지 않는다. 대신에, 글로벌하고 앱 전체에서 사용한다고 생각하는 값들이 한곳에 위치해야 한다는 것을 뜻한다. 일반적으로, “로컬” 값들은 가장 가까운 UI 컴포넌트에 있어야 한다.

때문에, 리덕스 스토어에 어떤 상태가 있어야 하는지, 컴포넌트에는 어떤 상태가 있어야 하는지는 개발자에게 달렸다. 각 상태를 평가하고 어디에 있어야 하는지를 결정하는데 도움을 주는 이 규칙들을 사용하라.

스토어에서 데이터를 읽기 위해 더 많은 컴포넌트를 연결하자.

리덕스를 구독하는 더 많은 UI 컴포넌트를 가지고 더 정밀한 수준으로 데이터를 읽는 것을 선호하라. 이는 보통 더 나은 UI 퍼포먼스로 이어지는데, 주어진 상태가 변화함에 따라 렌더링 되는 컴포넌트가 더 적어지기 때문이다.

예를들어, <UserList> 컴포넌트를 연결해서 사용자 배열을 읽어들이기 보다, <UserList> 로 하여금 사용자 ID 목록을 가져오게 하여 각 항목들을 <UserListItem userId={userId}> 로 렌더링 하고 <UserListItem> 을 연결하여 스토어로부터 이 컴포넌트만의 사용자 정보를 가져오게 하는 방식이다.

이는 React-Redux의 connect() API 와 useSelector() 훅에 모두 적용된다.

mapDispatchconnect 의 객체 단축기능을 사용하자.

connectmapDispatch 인자는 dispatch 를 인자로 갖는 함수 또는 액션 생성자들을 포함하는 객체가 될 수 있다. 우리는 항상 “객체 단축기능” 형태의 mapDispatch 를 사용할 것을 추천하는데, 이는 코드를 상당히 간단하게 만들기 때문이다. 실제로 mapDispatch 를 함수로 쓰는 경우는 거의 없다.

함수 컴포넌트에서 useSelector 를 여러번 호출하자.

useSelector 훅을 사용해서 데이터를 가져올 때, 하나의 거대한 useSelector 로 객체에 여러개의 결과를 담아 가져오기 보다 useSelector 를 여러번 호출해서 작은 양의 데이터를 가져오자. mapState 와 다르게, useSelector 는 객체를 리턴할 필요가 없고, selector들이 더 작은 값들을 읽게 되면 주어진 상태 변경으로 인해 컴포넌트가 렌더링될 가능성이 낮다는 것을 의미한다.

그러나, 정밀함(granularity)의 적절한 균형을 맞추도록 하라. 단일 컴포넌트가 상태 슬라이스의 모든 필드가 필요하다면, 각 필드에 별도의 selector를 사용하지 말고 하나의 useSelector 를 사용해서 전체 슬라이스를 가져와라.

정적 타이핑을 사용하자.

그냥 자바스크립트보다 타입스크립트나 Flow와 같은 정적 타입 시스템을 사용하자. 타입 시스템은 흔하게 발생하는 많은 실수들을 잡아주며, 코드의 문서화를 개선하고, 궁극적으로 장기간의 더 나은 유지보수성으로 이어진다. 리덕스와 React-Redux가 원래 일반 자바스크립트를 염두에 두고 설계되었지만, 타입스크립트와 Flow 둘다에서도 잘 동작한다. Redux Toolkit은 타입스크립트로 작성되었고 최소한의 타입선언을 추가해서 좋은 타입 안정성을 제공하도록 설계되었다.

디버깅을 위해 리덕스 개발자 도구 확장 프로그램을 사용하자.

리덕스 스토어로 하여금 리덕스 개발자도구 확장 프로그램으로 디버깅 을 하도록 설정하자. 개발자에게 다음 것들을 보여준다:

또한, 개발자도구는 “시간여행 디버깅”을 통해서, 전체 앱의 상태와 각 시점에 따른 UI를 보기 위해 액션 히스토리를 앞뒤로 보게 해준다.

리덕스는 분명히 이런 종류의 디버깅을 가능하게 하도록 설계되었고 개발자도구는 리덕스를 사용하는 가장 강력한 이유중에 하나이다.

상태에 일반 자바스크립트 객체를 사용하자.

상태 트리를 위해서, Immutable.js와 같은 특별한 라이브러리 대신에 일반 자바스크립트 객체와 배열을 사용하자. Immutable.js를 사용하는 잠재적 이점이 있긴 하지만, 쉬운 참조 비교와 같이 일반적으로 언급되는 대부분의 목표는 일반적으로 불변 업데이트의 속성이며 특정 라이브러리를 필요로 하지 않는다. 또한 이를 통해 번들 크기를 더 작게 줄이고 데이터의 형변환으로부터 오는 복잡성을 줄일 수 있다.

위에서 말한것 처럼, 불변 업데이트 로직을 단순화하고 싶다면, Redux Toolkit의 일부로 Immer를 사용할 것을 추천한다.

자세한 설명

Immutable.js는 처음부터 리덕스 앱들에서 거의 빈번하게 사용되어왔다. Immutable.js를 사용하는 여러가지 이유가 있다:

  • 값싼 참조 비교로부터 오는 퍼포먼스 개선
  • 전문화된 데이터 구조를 통한 업데이트에서 오는 퍼포먼스 개선
  • 우발적 상태변경 방지
  • setIn() 과 같은 API들을 통한 보다 쉬운 중첩 업데이트

이러한 이유들에 유효한 부분들이 있지만, 실제로는 말한 것처럼 그렇게 좋지는 않고 여러가지 안 좋은 점이 있다:

  • 값싼 참조 비교는 Immutable.js 뿐만 아니라 다른 불변 업데이트의 속성이기도 하다.
  • 우발적 상태변경은 다른 방법으로도 막을 수 있는데, Immer (에러 발생 가능성이 높은 수동 복사로직을 제거하고, 개발 중에 기본적으로 깊게-얼리는) 나 redux-immutable-state-invariant (변경에 대해서 상태를 체크하는) 가 있다.
  • Immer는 setIn() 과 같은 필요성을 제거하고 전반적으로 보다 간단한 업데이트 로직을 제공한다.
  • Immutable.js는 굉장히 큰 번들 사이즈를 갖는다.
  • API가 상당히 복잡하다.
  • API가 어플리케이션 코드를 “전염” 시킨다. 모든 로직이 일반 자바스크립트 객체를 다루는지 불변객체를 다루는지 알아야 한다.
  • 불변객체를 자바스크립트 객체로 변환하는 것은 상대적으로 비싸고, 항상 완전히 새롭고 깊은 객체 참조를 만들어낸다.
  • 라이브러리에 대해 지속적인 유지보수가 부족하다.

Immutable.js를 사용하는 가장 강력한 이유는 매우 거대한 객체들의 빠른 업데이트이다. (수만개의 키들) 대부분의 어플리케이션들은 그렇게 거대한 객체들을 다루지 않는다. 전반적으로, Immutable.js는 실용적인 이득이 거의 없는데 너무 많은 오버헤드를 더한다. Immer가 훨씬 더 나은 옵션이다.

우선순위 C의 규칙: 권장

액션타입을 도메인/이벤트이름 형식으로 작성하는 것이 좋다.

원래의 리덕스 문서와 예제들은 액션타입을 정의할 때 "ADD_TODO""INCREMENT" 와 같은 일반적으로 “SCREAMINGSNAKECASE” 컨벤션을 사용했다. 이는 상수값을 선언하는 대부분의 프로그래밍 언어들 컨벤션과 맞다. 단점은 대문자 문자열은 읽기 힘들다는 것이다.

다른 커뮤니티들은 다른 컨벤션을 채택하였는데, 액션이 관련된 “기능” 또는 “도메인”과 함께 특정한 액션타입을 사용하는 것이다. NgRx 커뮤니티는 "[도메인] 액션타입" 과 같은 패턴을 사용했는데, 예를 들면 "[Login Page] Login" 과 같은 것이다. "도메인:액션" 과 같은 패턴들 또한 사용되었다.

Redux Toolkit의 createSlice 함수는 "todos/addTodo" 와 같은 "도메인/액션" 형태의 액션 타입들을 만들어낸다. 앞으로, 우리는 가독성을 위해서 "도메인/액션" 컨벤션을 사용할 것을 제안한다.

Flux Standard Action 컨벤션을 사용해서 액션을 작성하는 것이 좋다.

원래의 “Flux 구조” 문서는 액션객체가 type 필드를 가져야 하는 것만을 명시했고, 액션의 필드에서 어떤 종류의 필드나 이름을 짓는 컨벤션(naming convention)이 사용되어야 하는가에 대한 가이드는 주지 않았다. 일관성을 제공하기 위해, Andrew Clark는 리덕스를 개발할 때 “Flux Standard Actions, FSA” 라는 컨벤션을 만들었다. 요약하자면, FSA는 다음과 같이 말한다. 액션은:

  • 반드시 항상 payload 필드에 데이터를 넣어야 한다.
  • 추가적인 정보를 위해 meta 필드를 가질 수 있다.
  • 액션이 실패와 같은 것을 나타내기 위해 error 필드를 가질 수 있다.

리덕스 생태계의 많은 라이브러리들은 FSA를 채택했고, Redux Toolkit도 FSA 포맷에 맞는 액션 생성자들을 만들어낸다.

일관성을 위해서 FSA-포맷이 적용된 액션 사용하자.

참고 : FSA 스펙은 “에러” 액션들이 error: true 로 설정되어야 하고, 액션의 “유효한” 형태로서 동일한 액션 타입을 사용해야 한다고 말한다. 실제로는, 대부분의 개발자들이 “성공”과 “에러”의 경우에 대해 별도의 액션타입을 작성한다. 어느 것이든 괜찮다.

액션 생성자를 사용하는 것이 좋다.

“액션 생성자” 함수는 원래의 “Flux 구조” 접근방식에서 시작되었다. 리덕스에서, 액션 생성자들은 엄격하게 요구되지 않는다. 컴포넌트들이 다른 로직은 항상 인라인 방식으로 작성된 액션 객체를 가지고 dispatch({type: "some/action"}) 을 사용할 수 있다.

그러나, 액션 생성자들을 사용하는 것이 일관성을 제공하며, 특히 액션의 내용을 채우는데 (유일무이한 ID값을 생성하는 것과 같은) 필요한 어떤 준비나 추가적인 로직의 경우에 적용된다.

어떠한 액션을 디스패치하던간에 액션 생성자들을 사용자. 그러나, 액션 생성자들을 손으로 작성하기보다, 액션 생성자와 타입을 자동으로 만들어주는 Redux Toolkit의 createSlice 함수를 사용하는 것을 추천한다.

비동기 로직에 Thunk를 사용하는 것이 좋다.

리덕스는 확장 가능하도록 설계되었고, 미들웨어 API는 리덕스 스토어에 연결되어 비동기 로직들의 여러가지 형태들을 허용하도록 만들어졌다. 그러한 점에서, 사용자들은 필요에 적합하지 않다면, RxJS와 같은 특정 라이브러리를 배울 필요가 없다.

이는 다양한 리덕스 비동기 미들웨어 애드온을 만들어지게 하였고, 어떤 비동기 미들웨어를 사용해야 하는가에 대한 혼동과 질문들을 발생시켰다.

기본적으로 Thunk 미들웨어를 사용하는 것 을 추천하는데 , 대부분의 경우에 충분하기 때문이다. (기본적인 Ajax 데이터 요청) 또한, thunk의 async/await 과 같은 문법은 가독성을 높여준다.

취소, 디바운스, 액션이 디스패치된 다음 수행하는 로직, “백그라운드-쓰레드”-타입의 행위와 같은 상당히 복잡한 비동기 작업이 필요하다면, Redux-Saga나 Redux-Observable과 같은 비동기 미들웨어를 추가하는 것을 고려하라.

복잡한 로직은 컴포넌트 밖으로 옮기는 것이 좋다.

우리는 전통적으로 가능한 한 많은 로직을 컴포넌트 밖에 놓는 것을 제안해왔다. 많은 컴포넌트들이 props로 데이터를 받아서 UI를 보여주는 “container/presentational” 패턴을 권장하는 것의 일부분이었을 뿐만 아니라, 클래스 컴포넌트의 라이프사이클 메서드에서 비동기 로직을 다루는 것이 유지보수하기 어려워지기 때문이다.

여전히 복잡한 동기 및 비동기 로직을 thunk를 사용해서 컴포넌트 밖으로 옮기는 것을 권장한다. 로직의 스토어의 상태를 읽어오는 경우라면 더욱 확실하다.

그러나, 리액트 훅스의 사용은 컴포넌트에서 바로 데이터를 가져오는 로직을 좀 더 쉽게 만들고, 일부 경우에선 thunk의 필요성을 대체할수도 있다.

스토어의 상태를 읽기 위해 Selector 함수를 사용하는 것이 좋다.

“Selector 함수”는 리덕스 스토어에서 상태를 가져오고 상태에서 더 많은 데이터를 가져오는 것을 캡슐화한 강력한 도구이다. 또한, Reselect와 같은 라이브러리는 퍼포먼스 최적화 측면에서 중요한, 인풋이 바뀔 때만 결과를 다시 계산하도록 하는 memoized selector 함수를 만들도록 도와준다.

가능한 언제든지 스토어의 상태를 읽어오는 memoized selector 함수를 사용할 것을 강력히 추천하고, 이를 Reselect로 만들기를 추천한다.

그러나, 상태의 모든 필드에 selector 함수를 반드시 작성할 필요는 없다. 필드를 얼마나 사용하고 업데이트 하는지, selector가 주는 실제적인 이득이 얼마나 되는지를 생각해서 합리적인 정밀함(granularity)의 균형을 찾아라.

리덕스 안에 폼의 상태를 넣는 것을 피하는 것이 좋다.

대부분의 폼 상태는 리덕스로 가면 안된다. 대부분의 경우에, 해당 데이터는 글로벌하지도 않고, 캐싱되지도 않고, 한번에 여러가지 컴포넌트들에 의해 사용되지도 않는다. 또한, 폼을 리덕스에 연결하는 것은 모든 변경 이벤트에 대해 액션을 디스패치하게 되며 이는 퍼포먼스 오버헤드를 일으키고 어떠한 이득도 없다. (아마도 name: "Mark" 에서 name: "Mar" 로 이동하는데 시간여행 역행이 필요없을 것이다.)

데이터가 궁극적으로 리덕스 안에 들어갔다 해도, 폼이 자기 자신의 상태들을 로컬 컴포넌트의 상태에서 변경하도록 하고 사용자가 폼을 완성하면 그제서야 리덕스의 스토어를 업데이트 하도록 액션을 디스패치하라.

리덕스에 폼의 상태를 저장하는 경우가 적합할 때가 있는데, 수정된 항목 속성의 WYSIWYG 실시간 미리보기와 같은 경우이다. 하지만, 대부분의 경우에는 필요하지 않다.

Loading script...