본문으로 건너뛰기

Thunk을 사용한 로직 작성

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

배울 내용
  • "Thunk"이 무엇인지, Redux 로직 작성에 왜 사용되는지
  • Thunk 미들웨어의 작동 방식
  • Thunk 내에서 동기/비동기 로직 작성 기법
  • 일반적인 Thunk 사용 패턴

Thunk 개요

Thunk이란?

"Thunk"은 프로그래밍 용어로 "지연된 작업을 수행하는 코드 조각"을 의미합니다. 로직을 즉시 실행하는 대신, 작업을 나중에 수행할 수 있는 함수 본문이나 코드를 작성할 수 있습니다.

Redux에서 "Thunk"는 Redux 스토어의 dispatchgetState 메서드와 상호작용할 수 있는 로직을 포함한 함수 작성 패턴입니다.

Thunk 사용을 위해서는 구성 과정에서 redux-thunk 미들웨어를 Redux 스토어에 추가해야 합니다.

Thunk은 Redux 앱에서 비동기 로직 작성의 표준 접근 방식이며, 주로 데이터 가져오기에 사용됩니다. 그러나 다양한 작업에 활용할 수 있으며 동기 및 비동기 로직을 모두 포함할 수 있습니다.

Thunk 작성 방법

_Thunk 함수_는 두 가지 인자를 받는 함수입니다: Redux 스토어의 dispatch 메서드와 getState 메서드입니다. 애플리케이션 코드에서 Thunk 함수를 직접 호출하지 않으며, 대신 store.dispatch()에 전달합니다:

Dispatching thunk functions
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}

store.dispatch(thunkFunction)

Thunk 함수는 동기/비동기 로직을 자유롭게 포함할 수 있으며, 언제든지 dispatchgetState를 호출할 수 있습니다.

일반적으로 Redux 코드에서 액션 객체를 수동 작성 대신 디스패치용 액션 객체를 생성하는 액션 생성자를 사용하는 것과 마찬가지로, _Thunk 액션 생성자_를 사용해 디스패치될 Thunk 함수를 생성합니다. Thunk 액션 생성자는 일부 인자를 받아 새 Thunk 함수를 반환하는 함수입니다. Thunk는 일반적으로 액션 생성자에 전달된 인자들을 클로저로 캡처하여 로직에서 사용할 수 있게 합니다:

Thunk action creators and thunk functions
// fetchTodoById is the "thunk action creator"
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk is the "thunk function"
return async function fetchTodoByIdThunk(dispatch, getState) {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
}

Thunk 함수와 액션 생성자는 function 키워드나 화살표 함수로 작성할 수 있으며, 이 둘 사이에 의미상 차이는 없습니다. 동일한 fetchTodoById Thunk를 화살표 함수로 작성하면 다음과 같습니다:

Writing thunks using arrow functions
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}

두 경우 모두 Thunk는 다른 Redux 액션과 동일한 방식으로 액션 생성자를 호출하여 디스패치합니다:

function TodoComponent({ todoId }) {
const dispatch = useDispatch()

const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}

Thunk 사용 이유

Thunk을 사용하면 UI 레이어와 분리된 Redux 관련 추가 로직을 작성할 수 있습니다. 이 로직은 비동기 요청이나 난수 생성 같은 사이드 이펙트뿐만 아니라, 여러 액션을 디스패치하거나 Redux 스토어 상태에 접근해야 하는 로직도 포함될 수 있습니다.

Redux 리듀서는 사이드 이펙트를 포함해서는 안 되지만, 실제 애플리케이션에는 사이드 이펙트가 있는 로직이 필요합니다. 일부는 컴포넌트 내부에 있을 수 있지만, 일부는 UI 레이어 외부에 존재해야 합니다. Thunk(및 다른 Redux 미들웨어)는 이러한 사이드 이펙트를 배치할 장소를 제공합니다.

클릭 핸들러나 useEffect 훅에서 비동기 요청을 수행하고 결과를 처리하는 등 로직을 컴포넌트 내에 직접 두는 경우가 흔합니다. 그러나 가능한 한 많은 로직을 UI 레이어 외부로 옮기는 것이 종종 필요합니다. 이는 로직의 테스트 용이성을 높이거나, UI 레이어를 최대한 얇고 "표현적"으로 유지하거나, 코드 재사용과 공유를 개선하기 위해 수행될 수 있습니다.

어떤 의미에서 썽크(thunk)는 어떤 Redux 스토어가 사용될지 미리 알 필요 없이 Redux 스토어와 상호작용해야 하는 모든 코드를 작성할 수 있는 허점입니다. 이로 인해 로직이 특정 Redux 스토어 인스턴스에 종속되지 않고 재사용 가능하게 유지됩니다.

Detailed Explanation: Thunks, Connect, and "Container Components"

Historically, another reason to use thunks was to help keep React components "unaware of Redux". The connect API allowed passing action creators and "binding" them to automatically dispatch actions when called. Since components typically did not have access to dispatch internally, passing thunks to connect made it possible for components to just call this.props.doSomething(), without needing to know if it was a callback from a parent, dispatching a plain Redux action, dispatching a thunk performing sync or async logic, or a mock function in a test.

With the arrival of the React-Redux hooks API, that situation has changed. The community has switched away from the "container/presentational" pattern in general, and components now have access to dispatch directly via the useDispatch hook. This does mean that it's possible to have more logic directly inside of a component, such as an async fetch + dispatch of the results. However, thunks have access to getState, which components do not, and there's still value in moving that logic outside of components.

썽크 사용 사례

썽크는 임의의 로직을 포함할 수 있는 범용 도구이므로 다양한 목적으로 사용될 수 있습니다. 가장 일반적인 사용 사례는 다음과 같습니다:

  • 컴포넌트에서 복잡한 로직 분리하기

  • 비동기 요청 또는 기타 비동기 로직 수행하기

  • 연속적으로 또는 시간차를 두고 여러 액션을 디스패치해야 하는 로직 작성하기

  • 결정을 내리거나 액션에 다른 상태 값을 포함시키기 위해 getState 접근이 필요한 로직 작성하기

썽크는 "일회성" 함수로 생명주기 개념이 없습니다. 또한 다른 디스패치된 액션을 인식하지 못합니다. 따라서 웹소켓과 같은 지속적 연결 초기화에는 일반적으로 사용되지 않으며, 다른 액션에 응답하는 데도 사용할 수 없습니다.

썽크는 복잡한 동기 로직과 표준 AJAX 요청 수행 및 결과에 기반한 액션 디스패치 같은 단순~중간 수준의 비동기 로직에 가장 적합합니다.

Redux 썽크 미들웨어

썽크 함수를 디스패치하려면 redux-thunk 미들웨어가 Redux 스토어 설정 과정에 포함되어야 합니다.

미들웨어 추가하기

Redux Toolkit의 configureStore API는 스토어 생성 시 자동으로 썽크 미들웨어를 추가하므로 일반적으로 별도 설정이 필요하지 않습니다.

수동으로 썽크 미들웨어를 추가해야 할 경우, 설정 과정에서 applyMiddleware()에 썽크 미들웨어를 전달하면 됩니다.

미들웨어 작동 원리

먼저 Redux 미들웨어의 일반적인 작동 방식을 살펴보겠습니다.

Redux 미들웨어는 모두 3단계 중첩 함수 시리즈로 작성됩니다:

  • 외부 함수는 {dispatch, getState}를 포함한 "스토어 API" 객체를 받습니다

  • 중간 함수는 체인 내 다음 미들웨어(또는 실제 store.dispatch 메서드)를 next로 받습니다

  • 내부 함수는 미들웨어 체인을 통과하는 각 action으로 호출됩니다

미들웨어는 액션 객체가 아닌 값을 store.dispatch()에 전달할 수 있도록 허용할 수 있으며, 이때 미들웨어가 해당 값을 가로채서 리듀서에 도달하지 못하게 한다는 점이 중요합니다.

이를 염두에 두고 썽크 미들웨어의 구체적인 작동을 살펴보겠습니다.

실제 썽크 미들웨어 구현은 매우 간단합니다(약 10줄). 주석을 추가한 소스 코드는 다음과 같습니다:

Redux thunk middleware implementation, annotated
// standard middleware definition, with 3 nested functions:
// 1) Accepts `{dispatch, getState}`
// 2) Accepts `next`
// 3) Accepts `action`
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(dispatch, getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

다시 말해:

  • 함수를 dispatch에 전달하면 썽크 미들웨어가 액션 객체 대신 함수임을 인지하고 가로챈 뒤 (dispatch, getState)를 인자로 해당 함수를 호출합니다

  • 일반 액션 객체(또는 다른 값)인 경우 체인의 다음 미들웨어로 전달됩니다

썽크에 설정 값 주입하기

썽크 미들웨어에는 하나의 커스터마이징 옵션이 있습니다. 설정 시점에 썽크 미들웨어의 커스텀 인스턴스를 생성하고 "추가 인자(extra argument)"를 미들웨어에 주입할 수 있습니다. 그러면 미들웨어가 이 추가 값을 모든 썽크 함수의 세 번째 인자로 주입합니다. 이는 주로 API 서비스 계층을 썽크 함수에 주입하여 API 메서드에 대한 하드코딩 종속성을 제거할 때 사용됩니다:

Thunk setup with an extra argument
import { withExtraArgument } from 'redux-thunk'

const serviceApi = createServiceApi('/some/url')

const thunkMiddlewareWithArg = withExtraArgument({ serviceApi })

Redux Toolkit의 configureStoregetDefaultMiddleware의 미들웨어 커스터마이제이션 일부로 이 기능을 지원합니다:

Thunk extra argument with configureStore
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})

추가 인수 값은 하나만 가능합니다. 여러 값을 전달해야 할 경우 해당 값들을 포함하는 객체를 전달하세요.

그러면 썽크 함수는 해당 추가 값을 세 번째 인수로 받게 됩니다:

Thunk function with extra argument
export const fetchTodoById =
todoId => async (dispatch, getState, extraArgument) => {
// In this example, the extra arg is an object with an API service inside
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}

썽크 사용 패턴

액션 디스패치하기

썽크는 dispatch 메서드에 접근할 수 있습니다. 이를 통해 액션이나 다른 썽크를 디스패치할 수 있습니다. 이는 여러 액션을 연속으로 디스패치하거나(비록 이 패턴은 최소화해야 하지만), 프로세스의 여러 지점에서 디스패치가 필요한 복잡한 로직을 조율할 때 유용합니다.

Example: thunks dispatching actions and thunks
// An example of a thunk dispatching other action creators,
// which may or may not be thunks themselves. No async code, just
// orchestration of higher-level synchronous logic.
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}

상태 접근하기

컴포넌트와 달리 썽크는 getState에도 접근할 수 있습니다. 현재 루트 Redux 상태 값을 가져오기 위해 언제든지 호출할 수 있으며, 현재 상태를 기반으로 조건부 로직을 실행할 때 유용합니다. 썽크 내부에서 상태를 읽을 때는 중첩된 상태 필드에 직접 접근하기보다 셀렉터 함수를 사용하는 것이 일반적이지만, 두 접근 방식 모두 괜찮습니다.

Example: Conditional dispatching based on state
const MAX_TODOS = 5

function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()

// Could also check `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}

가급적 로직을 리듀서에 최대한 배치하는 것이 바람직하지만, 썽크 내부에 추가 로직을 포함해도 괜찮습니다.

리듀서가 액션을 처리하면 상태가 즉시 동기적으로 업데이트되므로, 디스패치 후 getState를 호출해 업데이트된 상태를 가져올 수 있습니다.

Example: checking state after dispatch
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())

const secondState = getState()

if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}

썽크에서 상태에 접근하는 또 다른 이유는 추가 정보로 액션을 채우기 위함입니다. 때로는 슬라이스 리듀서가 자체 상태 슬라이스에 없는 값을 읽어야 할 때가 있는데, 이에 대한 해결 방법으로 썽크를 디스패치하고 상태에서 필요한 값을 추출한 뒤 추가 정보를 포함한 일반 액션을 디스패치할 수 있습니다.

Example: actions containing cross-slice data
// One solution to the "cross-slice state in reducers" problem:
// read the current state in a thunk, and include all the necessary
// data in the action
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// Read both slices out of state
const { a, b } = state

// Include data from both slices in the action
dispatch(actionThatNeedsMoreData(a, b))
}
}

비동기 로직과 부수 효과

썽크는 비동기 로직과 localStorage 업데이트 같은 부수 효과를 포함할 수 있습니다. someResponsePromise.then() 같은 Promise 체이닝을 사용할 수 있지만, 가독성을 위해 일반적으로 async/await 구문을 선호합니다.

비동기 요청 시 로딩 상태 추적을 위해 요청 전후로 액션을 디스패치하는 것이 표준입니다. 일반적으로 요청 전 "pending" 액션을 디스패치하고 로딩 상태를 "진행 중"으로 표시합니다. 요청이 성공하면 결과 데이터와 함께 "fulfilled" 액션을, 오류 발생 시 오류 정보와 함께 "rejected" 액션을 디스패치합니다.

여기서 오류 처리는 생각보다 까다롭습니다. resPromise.then(dispatchFulfilled).catch(dispatchRejected)처럼 체인하면 "fulfilled" 액션 처리 과정에서 네트워크 외 오류가 발생해도 "rejected" 액션이 디스패치될 수 있습니다. 요청 자체와 관련된 오류만 처리하려면 .then()의 두 번째 인수를 사용하는 것이 더 좋습니다:

Example: async request with promise chaining
function fetchData(someValue) {
return (dispatch, getState) => {
dispatch(requestStarted())

myAjaxLib.post('/someEndpoint', { data: someValue }).then(
response => dispatch(requestSucceeded(response.data)),
error => dispatch(requestFailed(error.message))
)
}
}

async/await을 사용할 때는 try/catch 로직 구성 방식 때문에 더 까다로울 수 있습니다. catch 블록이 네트워크 수준 오류만 처리하도록 하려면, 오류 발생 시 썽크가 조기에 반환하고 "fulfilled" 액션은 마지막에만 발생하도록 로직을 재구성해야 할 수 있습니다:

Example: error handling with async/await
function fetchData(someValue) {
return async (dispatch, getState) => {
dispatch(requestStarted())

// Have to declare the response variable outside the try block
let response

try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// Ensure we only catch network errors
dispatch(requestFailed(error.message))
// Bail out early on failure
return
}

// We now have the result and there's no error. Dispatch "fulfilled".
dispatch(requestSucceeded(response.data))
}
}

이 문제는 Redux나 썽크에만 국한되지 않으며, React 컴포넌트 상태를 다루거나 성공한 결과에 대한 추가 처리가 필요한 다른 로직에도 적용될 수 있습니다.

이 패턴은 작성하고 읽기에 다소 어색합니다. 대부분의 경우 요청과 dispatch(requestSucceeded())가 연속되는 일반적인 try/catch 패턴으로도 처리할 수 있지만, 이 문제가 발생할 수 있다는 점을 알아두는 것이 좋습니다.

썽크에서 값 반환하기

기본적으로 store.dispatch(action)는 실제 액션 객체를 반환합니다. 미들웨어는 dispatch에서 반환되는 값을 재정의하고 대체할 수 있습니다. 예를 들어 미들웨어가 항상 42를 반환하도록 설정할 수 있습니다:

Middleware return values
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}

// later
const result = dispatch(anyAction())
console.log(result) // 42

썽크 미들웨어는 호출된 썽크 함수가 반환하는 값을 그대로 반환함으로써 이를 구현합니다.

가장 흔한 사용 사례는 썽크에서 프로미스를 반환하는 것입니다. 이렇게 하면 썽크를 디스패치한 코드가 프로미스 완료를 기다릴 수 있어 비동기 작업이 완료되었음을 알 수 있습니다. 컴포넌트에서 추가 작업을 조율할 때 자주 활용됩니다:

Example: Awaiting a thunk result promise
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}

여기서 활용할 수 있는 유용한 기법도 있습니다: dispatch에만 접근 가능할 때 Redux 상태에서 일회성 선택을 수행하는 방법으로 썽크를 재활용할 수 있습니다. 썽크를 디스패치하면 썽크의 반환값이 돌아오므로, 셀렉터를 인수로 받아 즉시 상태에 적용하고 결과를 반환하는 썽크를 작성할 수 있습니다. dispatch는 접근 가능하지만 getState는 접근할 수 없는 React 컴포넌트에서 유용할 수 있습니다.

Example: reusing thunks for selecting data
// In your Redux slices:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}

// in a component
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// do more logic with this data
}

이 방법이 반드시 권장되는 사례는 아니지만, 문법적으로 유효하며 정상적으로 작동합니다.

createAsyncThunk 사용하기

썽크로 비동기 로직을 작성하는 것은 다소 번거로울 수 있습니다. 각 썽크는 일반적으로 "pending/fulfilled/rejected"에 대응하는 세 가지 액션 유형 + 매칭 액션 생성자와 실제 썽크 액션 생성자 + 썽크 함수를 정의해야 합니다. 오류 처리 관련 예외 사항도 다뤄야 합니다.

Redux Toolkit은 createAsyncThunk API를 제공하여 이러한 액션 생성 프로세스를 추상화하고, Promise 생명주기에 기반한 디스패치를 처리하며, 오류를 올바르게 처리합니다. 이 API는 부분 액션 유형 문자열(pending, fulfilled, rejected 액션 유형 생성에 사용됨)과 실제 비동기 요청을 수행한 후 Promise를 반환하는 "페이로드 생성 콜백"을 인수로 받습니다. 그런 다음 요청 전후에 적절한 인수로 액션을 자동으로 디스패치합니다.

이는 비동기 요청이라는 특정 사용 사례를 위한 추상화이므로, createAsyncThunk가 썽크의 모든 가능한 사용 사례를 해결하지는 않습니다. 동기 로직이나 기타 사용자 정의 동작이 필요한 경우 직접 "일반" 썽크를 작성해야 합니다.

썽크 액션 생성자에는 pending, fulfilled, rejected에 대한 액션 생성자가 첨부됩니다. createSliceextraReducers 옵션을 사용해 이러한 액션 유형을 수신하고 슬라이스 상태를 업데이트할 수 있습니다.

Example: createAsyncThunk
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// omit imports and state

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})

RTK Query로 데이터 가져오기

Redux Toolkit에는 새로운 RTK Query 데이터 가져오기 API가 있습니다. RTK Query는 Redux 앱을 위한 전용 데이터 가져오기 및 캐싱 솔루션으로, 데이터 관리를 위한 썽크나 리듀서 작성 자체를 불필요하게 만들 수 있습니다.

RTK Query는 내부적으로 모든 요청에 createAsyncThunk를 사용하며, 캐시 데이터 수명을 관리하는 사용자 정의 미들웨어를 함께 활용합니다.

먼저 앱이 통신할 서버 엔드포인트 정의를 포함한 "API 슬라이스"를 생성합니다. 각 엔드포인트는 useGetPokemonByNameQuery처럼 엔드포인트와 요청 유형에 기반한 이름의 React 훅을 자동으로 생성합니다:

RTK Query: API slice (pokemonSlice.js)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query({
query: (name: string) => `pokemon/${name}`
})
})
})

export const { useGetPokemonByNameQuery } = pokemonApi

그런 다음 생성된 API 슬라이스 리듀서와 사용자 정의 미들웨어를 스토어에 추가합니다:

RTK Query: store setup
import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'

export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})

마지막으로 자동 생성된 React 훅을 컴포넌트로 가져와 호출합니다. 훅은 컴포넌트 마운트 시 자동으로 데이터를 가져오며, 동일한 인수로 동일한 훅을 사용하는 여러 컴포넌트가 있다면 캐시된 결과를 공유합니다:

RTK Query: using fetching hooks
import { useGetPokemonByNameQuery } from './services/pokemon'

export default function Pokemon() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')

// rendering logic
}

RTK Query를 직접 시도해보고 앱의 데이터 가져오기 코드를 단순화하는 데 도움이 되는지 확인해보시기 바랍니다.

추가 정보