셀렉터로 데이터 파생하기
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
- 효율적인 Redux 아키텍처가 상태를 최소화하고 추가 데이터를 파생하는 이유
- 셀렉터 함수를 사용해 데이터를 파생하고 조회 로직을 캡슐화하는 원칙
- 최적화를 위해 메모이제이션 셀렉터 작성을 위한 Reselect 라이브러리 사용법
- Reselect의 고급 활용 기법
- 셀렉터 생성에 유용한 추가 도구 및 라이브러리
- 셀렉터 작성 모범 사례
데이터 파생하기
Redux 애플리케이션은 Redux 상태를 최소한으로 유지하고 가능한 경우 항상 상태에서 추가 값을 파생하도록 권장합니다.
여기에는 필터링된 목록 계산이나 값 합산 등이 포함됩니다. 예를 들어 Todo 앱은 상태에 할 일 객체의 원본 목록을 유지하지만, 상태 업데이트 시마다 상태 외부에서 필터링된 할 일 목록을 파생합니다. 마찬가지로 모든 할 일 완료 여부 확인이나 남은 할 일 개수 계산도 스토어 외부에서 수행 가능합니다.
이것의 장점은 다음과 같습니다:
-
실제 상태를 더 쉽게 읽을 수 있음
-
추가 값 계산 및 다른 데이터와의 동기화에 필요한 로직 감소
-
참조용 원본 상태가 그대로 유지되며 대체되지 않음
이 원칙은 React 상태에도 동일하게 적용됩니다! 사용자들은 종종 useEffect 훅을 정의해 상태 값 변경을 기다린 후 setAllCompleted(allCompleted)처럼 파생된 값을 상태로 설정하려 합니다. 대신 렌더링 과정에서 직접 값을 파생해 사용하면 상태에 저장할 필요가 전혀 없습니다:
function TodoList() {
const [todos, setTodos] = useState([])
// Derive the data while rendering
const allTodosCompleted = todos.every(todo => todo.completed)
// render with this value
}
셀렉터로 파생 데이터 계산하기
일반적인 Redux 애플리케이션에서 데이터 파생 로직은 일반적으로 셀렉터 라는 함수로 작성됩니다.
셀렉터는 주로 상태에서 특정 값 조회 로직 캡슐화, 실제 값 파생 로직 구현, 불필요한 재계산 방지를 통한 성능 개선에 사용됩니다.
모든 상태 조회에 셀렉터를 사용할 필요는 없지만, 셀렉터는 표준 패턴이며 널리 사용됩니다.
기본 셀렉터 개념
"셀렉터 함수"란 Redux 스토어 상태(또는 일부 상태)를 인자로 받아 해당 상태를 기반으로 데이터를 반환하는 모든 함수를 의미합니다.
셀렉터를 작성할 때 특별한 라이브러리가 필요하지 않으며, 화살표 함수나 function 키워드 중 어느 것을 사용해도 무관합니다. 예를 들어 다음은 모두 유효한 셀렉터 함수입니다:
// Arrow function, direct lookup
const selectEntities = state => state.entities
// Function declaration, mapping over an array to derive values
function selectItemIds(state) {
return state.items.map(item => item.id)
}
// Function declaration, encapsulating a deep lookup
function selectSomeSpecificField(state) {
return state.some.deeply.nested.field
}
// Arrow function, deriving values from an array
const selectItemsWhoseNamesStartWith = (items, namePrefix) =>
items.filter(item => item.name.startsWith(namePrefix))
셀렉터 함수 이름은 자유롭게 지을 수 있습니다. 다만 셀렉터 함수명에 select 접두사를 붙이고 선택 대상 값에 대한 설명을 조합할 것을 권장합니다. 대표적인 예로 selectTodoById, selectFilteredTodos, selectVisibleTodos 등이 있습니다.
React-Redux의 useSelector 훅을 사용해 본 적이 있다면 셀렉터 함수의 기본 개념에 이미 익숙할 것입니다. useSelector에 전달하는 함수는 반드시 셀렉터여야 합니다:
function TodoList() {
// This anonymous arrow function is a selector!
const todos = useSelector(state => state.todos)
}
셀렉터 함수는 일반적으로 Redux 애플리케이션의 두 가지 위치에서 정의됩니다:
-
리듀서 로직과 함께 슬라이스 파일 내부
-
컴포넌트 파일 내부 (컴포넌트 외부 또는
useSelector호출 인라인)
셀렉터 함수는 Redux 루트 상태 전체에 접근할 수 있는 모든 곳에서 사용할 수 있습니다. 이는 useSelector 훅, connect의 mapState 함수, 미들웨어, 썽크, 사가 등을 포함합니다. 예를 들어 썽크와 미들웨어는 getState 인자에 접근할 수 있으므로 여기서 셀렉터를 호출할 수 있습니다:
function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()
const canAddTodos = selectCanAddTodos(state)
if (canAddTodos) {
dispatch(todoAdded(todoText))
}
}
}
일반적으로 리듀서 내부에서 셀렉터를 사용하는 것은 불가능합니다. 슬라이스 리듀서는 자신의 슬라이스 상태만 접근할 수 있지만, 대부분의 셀렉터는 전체 Redux 루트 상태를 인자로 기대하기 때문입니다.
셀렉터로 상태 구조 캡슐화하기
셀렉터 함수를 사용하는 첫 번째 이유는 Redux 상태 구조를 다룰 때 캡슐화와 재사용성을 위해서입니다.
useSelector 훅이 Redux 상태의 특정 부분을 매우 구체적으로 조회한다고 가정해 보겠습니다:
const data = useSelector(state => state.some.deeply.nested.field)
이 코드는 문법적으로 유효하고 잘 실행됩니다. 하지만 아키텍처적으로 최선의 방법은 아닐 수 있습니다. 해당 필드에 접근해야 하는 여러 컴포넌트가 있다고 상상해 보세요. 만약 그 상태가 위치한 장소를 변경해야 한다면 어떻게 될까요? 이제 해당 값을 참조하는 모든 useSelector 훅을 수정해야 합니다. 따라서 액션 생성자를 사용하여 액션 생성 세부사항을 캡슐화하듯이, 특정 상태가 어디에 위치하는지에 대한 지식을 캡슐화하기 위해 재사용 가능한 셀렉터를 정의하는 것이 좋습니다. 그런 다음 애플리케이션에서 해당 데이터를 검색해야 할 때마다 코드베이스 어디에서나 주어진 셀렉터 함수를 여러 번 사용할 수 있습니다.
이상적으로는 리듀서 함수와 셀렉터만이 정확한 상태 구조를 알아야 하므로, 상태 위치를 변경할 경우 이 두 로직만 업데이트하면 됩니다.
이러한 이유로 컴포넌트 내부에 항상 셀렉터를 정의하기보다 슬라이스 파일 내부에 재사용 가능한 셀렉터를 직접 정의하는 것이 좋습니다.
셀렉터에 대한 일반적인 설명은 "상태에 대한 쿼리" 라는 것입니다. 쿼리가 어떻게 필요한 데이터를 가져왔는지 정확히 알 필요 없이, 데이터를 요청하고 결과를 받았다는 점만 중요합니다.
메모이제이션으로 셀렉터 최적화하기
셀렉터 함수는 종종 상대적으로 "비용이 많이 드는" 계산을 수행하거나 새로운 객체 및 배열 참조를 생성하는 파생 값을 만들어야 합니다. 이는 여러 가지 이유로 애플리케이션 성능에 문제가 될 수 있습니다:
-
useSelector또는mapState와 함께 사용되는 셀렉터는 Redux 루트 상태의 어떤 섹션이 실제로 업데이트되었는지에 관계없이 모든 디스패치된 액션 이후에 다시 실행됩니다. 입력 상태 섹션이 변경되지 않았을 때 비용이 많이 드는 계산을 다시 실행하는 것은 CPU 시간 낭비이며, 대부분의 경우 입력이 변경되지 않았을 가능성이 매우 높습니다. -
useSelector와mapState는 반환 값의===참조 동등성 검사에 의존하여 컴포넌트 재렌더링 여부를 결정합니다. 셀렉터가 항상 새로운 참조를 반환하면 파생 데이터가 지난번과 사실상 동일하더라도 컴포넌트가 강제로 재렌더링됩니다. 이는 특히map()및filter()와 같은 배열 연산에서 흔히 발생하며, 이러한 연산은 새로운 배열 참조를 반환합니다.
예를 들어 이 컴포넌트는 useSelector 호출이 항상 새로운 배열 참조를 반환하므로 잘못 작성되었습니다. 즉, 입력 state.todos 슬라이스가 변경되지 않았더라도 컴포넌트는 모든 디스패치된 액션 이후에 재렌더링됩니다:
function TodoList() {
// ❌ WARNING: this _always_ returns a new reference, so it will _always_ re-render!
const completedTodos = useSelector(state =>
state.todos.filter(todo => todo.completed)
)
}
데이터를 변환하기 위해 "비용이 많이 드는" 작업을 수행해야 하는 컴포넌트의 또 다른 예시입니다:
function ExampleComplexComponent() {
const data = useSelector(state => {
const initialData = state.data
const filteredData = expensiveFiltering(initialData)
const sortedData = expensiveSorting(filteredData)
const transformedData = expensiveTransformation(sortedData)
return transformedData
})
}
마찬가지로 이 "비용이 많이 드는" 로직은 모든 디스패치된 액션 이후에 다시 실행됩니다. 새로운 참조를 생성할 뿐만 아니라 state.data가 실제로 변경되지 않는 한 수행할 필요가 없는 작업입니다.
이러한 이유로 동일한 입력이 전달될 경우 결과 재계산을 피할 수 있는 최적화된 셀렉터를 작성할 방법이 필요합니다. 바로 여기서 메모이제이션 개념이 등장합니다.
메모이제이션은 캐싱의 한 형태입니다. 함수의 입력값을 추적하고, 해당 입력값과 결과를 나중에 참조할 수 있도록 저장하는 과정을 포함합니다. 함수가 이전과 동일한 입력값으로 호출되면 실제 작업을 건너뛰고, 해당 입력값을 마지막으로 받았을 때 생성한 결과를 반환합니다. 입력값이 변경된 경우에만 작업을 수행하고, 입력값이 동일할 때는 일관되게 동일한 결과 참조를 반환함으로써 성능을 최적화합니다.
다음으로 메모이제이션된 셀렉터를 작성하는 몇 가지 방법을 살펴보겠습니다.
Reselect로 메모이제이션된 셀렉터 작성하기
Redux 생태계는 전통적으로 메모이제이션된 셀렉터 함수를 생성하기 위해 Reselect 라이브러리를 사용해 왔습니다. 유사한 다른 라이브러리와 Reselect를 기반으로 한 다양한 변형 및 래퍼도 존재합니다. 이에 대해서는 나중에 살펴보겠습니다.
createSelector 개요
Reselect는 createSelector라는 함수를 제공하여 메모이제이션된 셀렉터를 생성합니다. createSelector는 하나 이상의 "입력 셀렉터" 함수와 "출력 셀렉터" 함수를 받아 사용할 수 있는 새로운 셀렉터 함수를 반환합니다.
createSelector는 공식 Redux Toolkit 패키지의 일부로 포함되어 있으며, 사용 편의를 위해 재익스포트됩니다.
createSelector는 여러 입력 셀렉터를 받을 수 있으며, 별도의 인수로 제공하거나 배열 형태로 전달할 수 있습니다. 모든 입력 셀렉터의 결과는 출력 셀렉터에 개별 인수로 전달됩니다:
const selectA = state => state.a
const selectB = state => state.b
const selectC = state => state.c
const selectABC = createSelector([selectA, selectB, selectC], (a, b, c) => {
// do something with a, b, and c, and return a result
return a + b + c
})
// Call the selector function and get a result
const abc = selectABC(state)
// could also be written as separate arguments, and works exactly the same
const selectABC2 = createSelector(selectA, selectB, selectC, (a, b, c) => {
// do something with a, b, and c, and return a result
return a + b + c
})
셀렉터를 호출하면 Reselect는 제공된 모든 인수를 사용해 입력 셀렉터를 실행하고 반환된 값을 확인합니다. 이전과 === 비교하여 변경된 결과가 하나라도 있으면 출력 셀렉터를 다시 실행하고 해당 결과를 인수로 전달합니다. 모든 결과가 이전과 동일하면 출력 셀렉터 재실행을 건너뛰고 이전에 캐시된 최종 결과를 반환합니다.
이는 "입력 셀렉터"는 주로 값을 추출해 반환하는 역할을 하고, "출력 셀렉터"가 변환 작업을 수행해야 함을 의미합니다.
값을 추출하거나 일부 파생 작업을 수행하는 "입력 셀렉터"와 결과를 그대로 반환하는 "출력 셀렉터"를 작성하는 것은 흔히 발생하는 실수입니다:
// ❌ BROKEN: this will not memoize correctly, and does nothing useful!
const brokenSelector = createSelector(
state => state.todos,
todos => todos
)
입력값을 그대로 반환하는 모든 "출력 셀렉터"는 잘못된 사용법입니다! 출력 셀렉터에는 항상 변환 로직이 포함되어야 합니다.
마찬가지로 메모이제이션된 셀렉터는 절대 state => state를 입력으로 사용해서는 안 됩니다! 이는 셀렉터가 항상 재계산되도록 강제합니다.
일반적인 Reselect 사용법에서는 상태 객체 내부에 중첩된 값을 반환하는 간단한 함수를 최상위 "입력 셀렉터"로 작성합니다. 그런 다음 createSelector를 사용해 하나 이상의 값을 입력으로 받아 새로운 파생 값을 생성하는 메모이제이션된 셀렉터를 생성합니다:
const selectTodos = state => state.todos.items
const selectCurrentUser = state => state.users.currentUser
const selectTodosForCurrentUser = createSelector(
[selectTodos, selectCurrentUser],
(todos, currentUser) => {
console.log('Output selector running')
return todos.filter(todo => todo.ownerId === currentUser.userId)
}
)
const todosForCurrentUser1 = selectTodosForCurrentUser(state)
// Log: "Output selector running"
const todosForCurrentUser2 = selectTodosForCurrentUser(state)
// No log output
console.log(todosForCurrentUser1 === todosForCurrentUser2)
// true
selectTodosForCurrentUser를 두 번째 호출할 때 "출력 셀렉터"가 실행되지 않았음에 유의하세요. selectTodos와 selectCurrentUser의 결과가 첫 번째 호출과 동일했기 때문에 selectTodosForCurrentUser는 첫 번째 호출의 메모이제이션된 결과를 반환할 수 있었습니다.
createSelector의 동작 방식
기본적으로 createSelector는 가장 최근의 매개변수 집합만 메모이제이션한다는 점을 주목해야 합니다. 즉, 다른 입력값으로 셀렉터를 반복 호출하면 결과는 반환하지만 출력 셀렉터를 계속 재실행해야 결과를 생성하게 됩니다:
const a = someSelector(state, 1) // first call, not memoized
const b = someSelector(state, 1) // same inputs, memoized
const c = someSelector(state, 2) // different inputs, not memoized
const d = someSelector(state, 1) // different inputs from last time, not memoized
또한 셀렉터에 여러 인수를 전달할 수 있습니다. Reselect는 모든 입력 셀렉터를 해당 정확한 입력값으로 호출합니다:
const selectItems = state => state.items
const selectItemId = (state, itemId) => itemId
const selectItemById = createSelector(
[selectItems, selectItemId],
(items, itemId) => items[itemId]
)
const item = selectItemById(state, 42)
/*
Internally, Reselect does something like this:
const firstArg = selectItems(state, 42);
const secondArg = selectItemId(state, 42);
const result = outputSelector(firstArg, secondArg);
return result;
*/
이 때문에 제공하는 모든 "입력 셀렉터"가 동일한 유형의 매개변수를 수용하는 것이 중요합니다. 그렇지 않으면 셀렉터가 오작동할 수 있습니다.
const selectItems = state => state.items
// expects a number as the second argument
const selectItemId = (state, itemId) => itemId
// expects an object as the second argument
const selectOtherField = (state, someObject) => someObject.someField
const selectItemById = createSelector(
[selectItems, selectItemId, selectOtherField],
(items, itemId, someField) => items[itemId]
)
이 예시에서 selectItemId는 두 번째 인자로 단순한 값을 기대하는 반면, selectOtherField는 두 번째 인자가 객체일 것이라고 예상합니다. selectItemById(state, 42)를 호출하면 selectOtherField가 42.someField에 접근하려 시도하면서 오류가 발생합니다.
Reselect 사용 패턴과 제약 사항
셀렉터 중첩
createSelector로 생성된 셀렉터를 다른 셀렉터의 입력값으로 사용할 수 있습니다. 이 예시에서는 selectCompletedTodos 셀렉터가 selectCompletedTodoDescriptions의 입력값으로 사용됩니다:
const selectTodos = state => state.todos
const selectCompletedTodos = createSelector([selectTodos], todos =>
todos.filter(todo => todo.completed)
)
const selectCompletedTodoDescriptions = createSelector(
[selectCompletedTodos],
completedTodos => completedTodos.map(todo => todo.text)
)
매개변수 전달
Reselect로 생성된 셀렉터 함수는 원하는 만큼의 인자로 호출할 수 있습니다: selectThings(a, b, c, d, e). 하지만 출력값 재계산의 기준은 인자 개수나 참조 변경 여부가 아닙니다. 핵심은 정의된 "입력 셀렉터"와 _그 결과값_의 변경 여부입니다. 마찬가지로 "출력 셀렉터"의 인자는 전적으로 입력 셀렉터 반환값에 의해 결정됩니다.
따라서 출력 셀렉터에 추가 매개변수를 전달하려면, 원본 셀렉터 인자에서 해당 값을 추출하는 입력 셀렉터를 정의해야 합니다:
const selectItemsByCategory = createSelector(
[
// Usual first input - extract value from `state`
state => state.items,
// Take the second arg, `category`, and forward to the output selector
(state, category) => category
],
// Output selector gets (`items, category)` as args
(items, category) => items.filter(item => item.category === category)
)
이제 셀렉터를 다음과 같이 사용할 수 있습니다:
const electronicItems = selectItemsByCategory(state, "electronics");
일관성을 위해 selectThings(state, otherArgs)처럼 추가 매개변수를 단일 객체로 전달하고, otherArgs 객체에서 값을 추출하는 방식을 고려해볼 수 있습니다.
셀렉터 팩토리
createSelector의 기본 캐시 크기는 1이며, 이는 각 셀렉터 인스턴스마다 개별적으로 적용됩니다. 따라서 단일 셀렉터 함수를 서로 다른 입력값으로 여러 곳에서 재사용할 때 문제가 발생할 수 있습니다.
한 가지 해결책은 "셀렉터 팩토리"를 만드는 것입니다. 이는 createSelector()를 실행하고 호출할 때마다 새로운 고유 셀렉터 인스턴스를 생성하는 함수입니다:
const makeSelectItemsByCategory = () => {
const selectItemsByCategory = createSelector(
[state => state.items, (state, category) => category],
(items, category) => items.filter(item => item.category === category)
)
return selectItemsByCategory
}
이는 특히 여러 유사 UI 컴포넌트가 props 기반으로 서로 다른 데이터 하위 집합을 파생해야 할 때 유용합니다.
대체 셀렉터 라이브러리
Reselect가 Redux와 함께 가장 널리 사용되는 셀렉터 라이브러리이지만, 유사한 문제를 해결하거나 Reselect 기능을 확장하는 많은 다른 라이브러리들이 존재합니다.
proxy-memoize
proxy-memoize는 비교적 새로운 메모이제이션 셀렉터 라이브러리로 독특한 구현 방식을 채택했습니다. ES2015 Proxy 객체를 활용해 중첩 값 읽기 시도를 추적하고, 이후 호출 시 변경된 중첩 값만 비교합니다. 경우에 따라 Reselect보다 우수한 결과를 제공할 수 있습니다.
할 일 설명 배열을 파생하는 셀렉터 예시:
import { createSelector } from 'reselect'
const selectTodoDescriptionsReselect = createSelector(
[state => state.todos],
todos => todos.map(todo => todo.text)
)
안타깝게도 이 방식은 todo.completed 플래그 토글처럼 state.todos 내 다른 값이 변경될 때마다 파생 배열을 재계산합니다. 파생 배열의 _내용_은 동일하지만 입력 todos 배열이 변경되었기 때문에 새로운 참조의 출력 배열을 계산해야 합니다.
proxy-memoize로 동일한 셀렉터를 작성하면 다음과 같습니다:
import { memoize } from 'proxy-memoize'
const selectTodoDescriptionsProxy = memoize(state =>
state.todos.map(todo => todo.text)
)
Reselect와 달리 proxy-memoize는 todo.text 필드만 접근되고 있음을 감지하며, 오직 todo.text 필드가 변경될 때만 재계산을 수행합니다.
또한 내장된 size 옵션으로 단일 셀렉터 인스턴스에 대한 원하는 캐시 크기를 설정할 수 있습니다.
Reselect와 비교했을 때 장단점이 있습니다:
-
모든 값은 단일 객체 인자로 전달됩니다
-
ES2015
Proxy객체 지원 환경이 필요합니다(IE11 미지원) -
Reselect가 더 명시적인 반면, 더 많은 "마법"을 사용합니다
-
Proxy기반 추적 동작과 관련된 몇 가지 주의 사항이 존재합니다 -
더 최신 기술이며 덜 널리 사용됨
그럼에도 불구하고, 공식적으로 proxy-memoize를 Reselect의 실행 가능한 대안으로 고려해 볼 것을 권장합니다.
re-reselect
https://github.com/toomuchdesign/re-reselect 라이브러리는 '키 선택자'를 정의할 수 있도록 하여 Reselect의 캐싱 동작을 개선합니다. 이를 통해 내부적으로 여러 Reselect 셀렉터 인스턴스를 관리할 수 있으며, 여러 컴포넌트에서의 사용을 단순화하는 데 도움이 됩니다.
import { createCachedSelector } from 're-reselect'
const getUsersByLibrary = createCachedSelector(
// inputSelectors
getUsers,
getLibraryId,
// resultFunc
(users, libraryId) => expensiveComputation(users, libraryId)
)(
// re-reselect keySelector (receives selectors' arguments)
// Use "libraryName" as cacheKey
(_state_, libraryName) => libraryName
)
reselect-tools
때로는 여러 Reselect 셀렉터가 서로 어떻게 관련되어 있는지, 그리고 무엇이 셀렉터의 재계산을 유발했는지 추적하기 어려울 수 있습니다. https://github.com/skortchmark9/reselect-tools 는 셀렉터 의존성을 추적하고, 이러한 관계를 시각화하고 셀렉터 값을 확인하는 데 도움이 되는 자체 DevTools를 제공합니다.
redux-views
https://github.com/josepot/redux-views 는 re-reselect와 유사하게 일관된 캐싱을 위해 각 항목에 대한 고유 키를 선택하는 방법을 제공합니다. 이는 Reselect의 거의 대체 가능한 드롭인 대체재로 설계되었으며, 실제로 잠재적인 Reselect 버전 5의 옵션으로 제안되었습니다.
Reselect v5 제안
Reselect 저장소에서 향후 Reselect 버전의 잠재적 개선 사항을 파악하기 위한 로드맵 논의를 열었습니다. 여기에는 더 큰 캐시 크기를 더 잘 지원하기 위해 API를 개선하고, 코드베이스를 TypeScript로 재작성하는 것과 같은 다른 가능한 개선 사항이 포함됩니다. 해당 논의에 추가 커뮤니티 피드백을 환영합니다:
Reselect v5 로드맵 논의: 목표 및 API 디자인
React-Redux와 함께 셀렉터 사용하기
매개변수를 사용하여 셀렉터 호출하기
셀렉터 함수에 추가 인수를 전달하고 싶은 경우가 흔합니다. 그러나 useSelector는 항상 제공된 셀렉터 함수를 하나의 인수, 즉 Redux 루트 state와 함께 호출합니다.
가장 간단한 해결책은 익명 셀렉터를 useSelector에 전달한 다음, state와 추가 인수를 모두 사용하여 실제 셀렉터를 즉시 호출하는 것입니다:
import { selectTodoById } from './todosSlice'
function TodoListitem({ todoId }) {
// Captures `todoId` from scope, gets `state` as an arg, and forwards both
// to the actual selector function to extract the result
const todo = useSelector(state => selectTodoById(state, todoId))
}
고유한 셀렉터 인스턴스 생성하기
셀렉터 함수를 여러 컴포넌트에서 재사용해야 하는 경우가 많습니다. 컴포넌트가 모두 서로 다른 인수로 셀렉터를 호출하면 메모이제이션이 깨집니다. 셀렉터가 같은 인수를 연속으로 여러 번 보지 못하기 때문에 캐시된 값을 반환할 수 없게 됩니다.
여기서 표준 접근 방식은 컴포넌트에 메모이즈된 셀렉터의 고유 인스턴스를 생성한 다음, 이를 useSelector와 함께 사용하는 것입니다. 이를 통해 각 컴포넌트는 자신의 셀렉터 인스턴스에 일관되게 같은 인수를 전달할 수 있으며, 해당 셀렉터는 결과를 올바르게 메모이즈할 수 있습니다.
함수 컴포넌트의 경우, 일반적으로 useMemo 또는 useCallback을 사용하여 수행됩니다:
import { makeSelectItemsByCategory } from './categoriesSlice'
function CategoryList({ category }) {
// Create a new memoized selector, for each component instance, on mount
const selectItemsByCategory = useMemo(makeSelectItemsByCategory, [])
const itemsByCategory = useSelector(state =>
selectItemsByCategory(state, category)
)
}
connect를 사용하는 클래스 컴포넌트의 경우, mapState에 대한 고급 '팩토리 함수' 구문으로 수행할 수 있습니다. mapState 함수가 첫 번째 호출에서 새로운 함수를 반환하면, 그 함수가 실제 mapState 함수로 사용됩니다. 이는 새로운 셀렉터 인스턴스를 생성할 수 있는 클로저를 제공합니다:
import { makeSelectItemsByCategory } from './categoriesSlice'
const makeMapState = (state, ownProps) => {
// Closure - create a new unique selector instance here,
// and this will run once for every component instance
const selectItemsByCategory = makeSelectItemsByCategory()
const realMapState = (state, ownProps) => {
return {
itemsByCategory: selectItemsByCategory(state, ownProps.category)
}
}
// Returning a function here will tell `connect` to use it as
// `mapState` instead of the original one given to `connect`
return realMapState
}
export default connect(makeMapState)(CategoryList)
셀렉터 효과적으로 사용하기
셀렉터는 Redux 애플리케이션에서 흔한 패턴이지만, 종종 오용되거나 오해됩니다. 셀렉터 함수를 올바르게 사용하기 위한 몇 가지 지침은 다음과 같습니다.
리듀서와 함께 셀렉터 정의하기
셀렉터 함수는 종종 UI 계층에서 useSelector 호출 내부에 직접 정의됩니다. 그러나 이는 서로 다른 파일에 정의된 셀렉터 간에 중복이 발생할 수 있고, 함수가 익명으로 남는다는 것을 의미합니다.
다른 함수와 마찬가지로, 컴포넌트 외부로 익명 함수를 추출하여 이름을 부여할 수 있습니다:
const selectTodos = state => state.todos
function TodoList() {
const todos = useSelector(selectTodos)
}
그러나 애플리케이션의 여러 부분이 동일한 조회를 사용하고자 할 수 있습니다. 또한 개념적으로 todos 상태가 어떻게 구성되는지에 대한 지식을 todosSlice 파일 내부의 구현 세부 사항으로 유지하여 모두 한곳에 있도록 할 수 있습니다.
이러한 이유로 해당 리듀서와 함께 재사용 가능한 셀렉터를 정의하는 것이 좋습니다. 이 경우 todosSlice 파일에서 selectTodos를 내보낼 수 있습니다:
import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload)
}
}
})
export const { todoAdded } = todosSlice.actions
export default todosSlice.reducer
// Export a reusable selector here
export const selectTodos = state => state.todos
이렇게 하면 할 일 슬라이스 상태 구조를 업데이트할 때 관련 셀렉터가 바로 여기에 있으므로 다른 부분의 수정을 최소화하면서 동시에 업데이트할 수 있습니다.
셀렉터 사용의 균형
애플리케이션에 셀렉터를 지나치게 많이 추가하는 것은 바람직하지 않습니다. 모든 단일 필드마다 별도의 셀렉터 함수를 만드는 것은 좋은 방법이 아닙니다! 이는 결국 Redux를 모든 필드에 대해 getter/setter 함수가 있는 Java 클래스처럼 만들어 버립니다. 코드가 _개선_되지 않을 뿐만 아니라 오히려 _악화_될 수 있습니다—수많은 추가 셀렉터를 유지보수하는 것은 상당한 추가 작업이며, 어떤 값이 어디에서 사용되는지 추적하기 어려워집니다.
마찬가지로, 모든 셀렉터를 메모이제이션하지 마세요!. 메모이제이션은 셀렉터가 실행될 때마다 새로운 참조를 반환하거나, 실행하는 계산 로직이 비용이 많이 드는 경우에만 필요합니다. 직접 조회하고 값을 반환하는 셀렉터 함수는 메모이제이션되지 않은 일반 함수여야 합니다
메모이제이션이 필요할 때와 필요하지 않을 때의 예시:
// ❌ DO NOT memoize: will always return a consistent reference
const selectTodos = state => state.todos
const selectNestedValue = state => state.some.deeply.nested.field
const selectTodoById = (state, todoId) => state.todos[todoId]
// 🤔 MAYBE memoize: deriving data, but will return a consistent result.
// Memoization might be useful if the selector is used in many places
// or the list being iterated over is long.
const selectItemsTotal = state => {
return state.items.reduce((result, item) => {
return result + item.total
}, 0)
}
const selectAllCompleted = state => state.todos.every(todo => todo.completed)
// ✅ SHOULD memoize: returns new references when called
const selectTodoDescriptions = state => state.todos.map(todo => todo.text)
컴포넌트 요구사항에 맞게 상태 재구성
셀렉터는 직접 조회에 국한되지 않습니다—필요한 변환 로직을 _내부에서 수행_할 수 있습니다. 이는 특정 컴포넌트가 필요로 하는 데이터를 준비하는 데 특히 유용합니다.
Redux 상태는 종종 "원시" 형태로 데이터를 보유합니다. 왜냐하면 상태는 최소한으로 유지해야 하며, 많은 컴포넌트가 동일한 데이터를 다르게 표시할 수 있기 때문입니다. 셀렉터를 사용해 상태를 _추출_할 뿐만 아니라 해당 컴포넌트의 요구사항에 맞게 _재구성_할 수 있습니다. 여기에는 루트 상태의 여러 슬라이스에서 데이터를 가져오기, 특정 값 추출, 다른 데이터 조각 병합 또는 유용한 기타 변환이 포함될 수 있습니다.
컴포넌트가 이러한 로직의 일부를 포함하는 것은 괜찮지만, 재사용성과 테스트 용이성을 위해 모든 변환 로직을 별도의 셀렉터로 분리하는 것이 유리할 수 있습니다.
필요한 경우 셀렉터 전역화
슬라이스 리듀서와 셀렉터 작성 사이에는 본질적인 불균형이 존재합니다. 슬라이스 리듀서는 자체 상태 부분만 인식합니다—리듀서에게 state는 todoSlice의 할 일 배열처럼 존재하는 전부입니다. 반면 셀렉터는 일반적으로 전체 Redux 루트 상태를 인수로 받도록 작성됩니다. 이는 루트 상태에서 이 슬라이스의 데이터 위치(예: state.todos)를 알아야 함을 의미하며, 이 위치는 루트 리듀서가 생성될 때까지(일반적으로 앱 전체 스토어 설정 로직에서) 정의되지 않습니다.
일반적인 슬라이스 파일에는 종종 이 두 패턴이 나란히 존재합니다. 이는 특히 소규모 또는 중간 규모 앱에서 괜찮습니다. 하지만 앱 아키텍처에 따라 셀렉터가 상태 위치를 인식하지 않도록 추가로 추상화할 수 있습니다—셀렉터에 전달하기만 하면 됩니다.
이 패턴을 "셀렉터 전역화"라고 합니다. "전역화된" 셀렉터는 Redux 루트 상태를 인수로 받고, 실제 로직을 수행하기 위해 관련 상태 슬라이스를 찾는 방법을 알고 있습니다. "로컬라이즈드" 셀렉터는 루트 상태 내 위치를 알지 못하거나 신경 쓰지 않고 _상태 일부_만 인수로 기대합니다:
// "Globalized" - accepts root state, knows to find data at `state.todos`
const selectAllTodosCompletedGlobalized = state =>
state.todos.every(todo => todo.completed)
// "Localized" - only accepts `todos` as argument, doesn't know where that came from
const selectAllTodosCompletedLocalized = todos =>
todos.every(todo => todo.completed)
"로컬라이즈드" 셀렉터는 올바른 상태 슬라이스를 검색하여 전달하는 방법을 아는 함수로 감싸 "전역화된" 셀렉터로 변환할 수 있습니다.
Redux Toolkit의 createEntityAdapter API는 이 패턴의 예시입니다. 인자 없이 todosAdapter.getSelectors()를 호출하면 _엔티티 슬라이스 상태_를 인자로 기대하는 "로컬라이즈드" 셀렉터 집합을 반환합니다. todosAdapter.getSelectors(state => state.todos)를 호출하면 _Redux 루트 상태_를 인자로 기대하는 "글로벌라이즈드" 셀렉터 집합을 반환합니다.
셀렉터의 "로컬라이즈드" 버전을 사용하면 추가 이점이 있을 수 있습니다. 예를 들어, 스토어에 중첩된 createEntityAdapter 데이터의 여러 복사본을 유지하는 고급 시나리오(예: 방을 추적하는 chatRoomsAdapter가 있고 각 방 정의에 메시지를 저장하는 chatMessagesAdapter 상태가 있음)가 있습니다. 각 방의 메시지를 직접 조회할 수는 없습니다. 먼저 방 객체를 검색한 후 그 안에서 메시지를 선택해야 합니다. 메시지에 대한 "로컬라이즈드" 셀렉터 집합이 있다면 이 과정이 더 간편해집니다.
추가 정보
-
셀렉터 라이브러리:
- Reselect: https://github.com/reduxjs/reselect
proxy-memoize: https://github.com/dai-shi/proxy-memoizere-reselect: https://github.com/toomuchdesign/re-reselectreselect-tools: https://github.com/skortchmark9/reselect-toolsredux-views: https://github.com/josepot/redux-views
-
Randy Coulman은 셀렉터 아키텍처와 Redux 셀렉터 글로벌화 접근법의 장단점에 관한 훌륭한 블로그 시리즈를 작성했습니다: