Featured image of post [Redux] 19. createAsyncThunk - 비동기 로직 단순화

[Redux] 19. createAsyncThunk - 비동기 로직 단순화

09편에서 손으로 만들었던 시작·성공·실패 세 액션과 04편의 async/await를 createAsyncThunk 하나로 대체합니다. extraReducers의 pending/fulfilled/rejected 케이스, unwrap, condition/signal로 중복 요청을 막는 방법까지 다룹니다.

19. createAsyncThunk - 비동기 로직 단순화

09편에서 비동기 흐름을 “시작(pending) → 성공(fulfilled) → 실패(rejected)“라는 세 개의 동기 액션으로 손수 나눠 dispatch했습니다. 이 편은 이 반복적인 패턴을 createAsyncThunk 하나로 대체합니다.

학습 목표

  • createAsyncThunk가 API 호출 함수 하나로부터 pending/fulfilled/rejected 액션을 자동 생성하는 원리를 설명할 수 있다.
  • extraReducers에서 이 세 상태를 처리해 로딩 상태를 관리할 수 있다.
  • rejectWithValue로 에러 정보를 커스터마이징해 전달할 수 있다.

09편 패턴 복습: 손으로 만든 비동기 액션 3종

09편에서는 user 도메인으로 이 패턴을 봤습니다. 이번 편은 20편의 실습 프로젝트와 이어지도록 todos 도메인으로 같은 패턴을 다시 짜봅니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 09편과 동일한 패턴을 todos 도메인으로 다시 쓴 순수 Redux 비동기 흐름
const todosLoadStarted = () => ({ type: "todos/loadStarted" });
const todosLoadSucceeded = (todos) => ({ type: "todos/loadSucceeded", payload: todos });
const todosLoadFailed = (error) => ({ type: "todos/loadFailed", payload: error, error: true });

function fetchTodos() {
  return async (dispatch) => {
    dispatch(todosLoadStarted());
    try {
      const response = await fetch("/api/todos"); // 04편: async/await
      const todos = await response.json();
      dispatch(todosLoadSucceeded(todos));
    } catch (error) {
      dispatch(todosLoadFailed(error.message));
    }
  };
}

액션 생성자 3개, thunk 함수 1개, 그리고 이 세 액션에 대응하는 리듀서 케이스까지 — API 호출 하나를 다루는 데 상당히 많은 코드가 필요했습니다.

createAsyncThunk: 하나의 비동기 함수로 액션 3종 생성

1
2
3
4
5
6
7
8
9
import { createAsyncThunk } from "@reduxjs/toolkit";

export const fetchTodos = createAsyncThunk(
  "todos/fetchTodos",      // 액션 타입 접두사 — pending/fulfilled/rejected가 이로부터 파생됨
  async () => {
    const response = await fetch("/api/todos"); // 04편의 async/await를 그대로 사용
    return await response.json(); // 이 반환값이 fulfilled 액션의 payload가 된다
  }
);

createAsyncThunk("todos/fetchTodos", asyncFn)을 호출하면 다음 세 액션 타입이 자동으로 만들어집니다.

자동 생성되는 액션 타입발생 시점
todos/fetchTodos/pendingtodosLoadStarted에 대응, 호출 시작 시
todos/fetchTodos/fulfilledtodosLoadSucceeded에 대응, return한 값이 payload
todos/fetchTodos/rejectedtodosLoadFailed에 대응, 예외 발생 시

액션 생성자 3개와 try/catch를 손으로 쓸 필요 없이, async 함수 하나만 작성하면 됩니다.

extraReducers로 세 상태 처리하기

17편에서 짧게 언급한 extraReducers가 바로 이 세 액션을 처리하는 곳입니다. 로딩 상태를 관리하는 표준 패턴은 다음과 같습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchTodos = createAsyncThunk("todos/fetchTodos", async () => {
  const response = await fetch("/api/todos");
  return await response.json();
});

const todosSlice = createSlice({
  name: "todos",
  initialState: {
    items: [],
    status: "idle", // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
  },
  reducers: {
    // 17편에서 다룬 동기 액션들(todoAdded 등)은 여기에 그대로 둔다
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = "loading";
        state.error = null;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.items = action.payload; // async 함수가 return한 값
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.error.message; // 예외 발생 시 자동으로 채워지는 에러 정보
      });
  },
});

export default todosSlice.reducer;

builder.addCase(actionCreator, reducerFn) 형태로 각 상태 전이를 처리합니다. fetchTodos.pending, fetchTodos.fulfilled, fetchTodos.rejectedcreateAsyncThunk가 자동으로 붙여준 속성으로, 17편의 createAction으로 만든 액션과 마찬가지로 addCase의 첫 인자로 쓸 수 있습니다.

컴포넌트에서 사용하기

fetchTodos를 dispatch하는 쪽은 09편의 thunk를 dispatch하던 방식과 동일합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchTodos } from "./todosSlice";

function TodoList() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector((state) => state.todos);

  useEffect(() => {
    if (status === "idle") {
      dispatch(fetchTodos()); // configureStore(18편)에 기본 포함된 thunk 미들웨어가 처리
    }
  }, [status, dispatch]);

  if (status === "loading") return <p>로딩 ...</p>;
  if (status === "failed") return <p>에러: {error}</p>;

  return (
    <ul>
      {items.map((todo) => <li key={todo.id}>{todo.text}</li>)}
    </ul>
  );
}

status 필드로 로딩/성공/실패 상태를 명시적으로 구분하는 것이 핵심입니다. 단순히 items.length === 0으로 “로딩 중"을 판단하면, “로딩이 끝났지만 결과가 빈 배열인 경우"와 구분할 수 없기 때문입니다.

rejectWithValue: 에러 정보 커스터마이징

기본적으로 rejected 액션의 action.error.message는 JS 예외 객체의 메시지를 그대로 담습니다. 서버가 반환하는 구조화된 에러 응답(예: { code: "NOT_FOUND", detail: "..." })을 그대로 전달하고 싶다면 rejectWithValue를 씁니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const fetchTodos = createAsyncThunk(
  "todos/fetchTodos",
  async (_, { rejectWithValue }) => {
    const response = await fetch("/api/todos");
    if (!response.ok) {
      const errorBody = await response.json();
      return rejectWithValue(errorBody); // action.payload로 전달됨 (action.error가 아님)
    }
    return await response.json();
  }
);

이렇게 rejectWithValue로 감싼 값은 리듀서 쪽에서도 짝을 맞춰 읽어야 합니다. extraReducersrejected 케이스에서 action.payload를 우선 확인하도록 고칩니다.

1
2
3
4
5
.addCase(fetchTodos.rejected, (state, action) => {
  state.status = "failed";
  // rejectWithValue를 썼다면 action.payload에, 아니면 action.error.message에 담긴다
  state.error = action.payload ?? action.error.message;
})

rejectWithValue로 반환한 값은 action.error가 아니라 **action.payload**에 담긴다는 점이 자주 헷갈리는 부분입니다. 이 구분은 리듀서에서 에러를 처리할 때 어느 필드를 읽어야 하는지 명확히 해줍니다.

인자를 받는 thunk

API 호출에 인자가 필요하면 async 함수의 첫 번째 매개변수로 받습니다.

1
2
3
4
5
6
7
export const fetchTodoById = createAsyncThunk(
  "todos/fetchTodoById",
  async (todoId) => { // dispatch(fetchTodoById(3))일 때 todoId === 3
    const response = await fetch(`/api/todos/${todoId}`);
    return await response.json();
  }
);

두 번째 매개변수는 { dispatch, getState, rejectWithValue, ... }를 담은 thunkAPI 객체로, 위 rejectWithValue 예시에서 이미 사용했습니다.

unwrap: 호출 지점에서 곧바로 성공/실패를 처리하기

지금까지는 extraReducers에서만 성공/실패를 처리했습니다. 하지만 폼 제출처럼 “이 dispatch가 성공했는지 바로 이 자리에서 알아야 하는” 상황도 자주 있습니다. dispatch(thunk())가 반환하는 Promise는 기본적으로 성공하든 실패하든 resolve되기 때문에(리듀서가 항상 fulfilledrejected 액션을 받아야 하므로), 일반적인 try/catch로는 실패를 잡을 수 없습니다. 이때 .unwrap()을 씁니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function SubmitButton() {
  const dispatch = useDispatch();

  const handleSubmit = async () => {
    try {
      const todo = await dispatch(addTodo(text)).unwrap(); // fulfilled면 payload를, rejected면 예외를 던진다
      showToast(`"${todo.text}" 추가됨`);
    } catch (error) {
      showToast(`추가 실패: ${error.message}`); // rejectWithValue를 썼다면 error가 그 값
    }
  };

  return <button onClick={handleSubmit}>추가</button>;
}

.unwrap()이 없으면 dispatch(addTodo(text))가 반환하는 Promise는 실패했을 때도 그냥 rejected 액션 객체를 담은 채 정상적으로 resolve되므로, 컴포넌트 코드에서 try/catch로 실패를 감지할 수 없습니다. .unwrap()은 이 Promise를 “진짜 성공하면 resolve, 진짜 실패하면 reject"하는 일반적인 Promise로 바꿔줘서, 컴포넌트가 익숙한 try/catch로 후속 처리를 할 수 있게 해줍니다.

중복 요청 방지와 취소: condition과 signal

같은 요청이 이미 진행 중일 때 중복 dispatch를 막고 싶다면, condition 옵션으로 실행 여부를 직접 판단할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const fetchTodos = createAsyncThunk(
  "todos/fetchTodos",
  async () => {
    const response = await fetch("/api/todos");
    return await response.json();
  },
  {
    condition: (_, { getState }) => {
      const { status } = getState().todos;
      return status !== "loading"; // false를 반환하면 이 dispatch 자체가 실행되지 않는다
    },
  }
);

conditionfalse를 반환하면 pending조차 dispatch되지 않고 요청 자체가 취소됩니다 — 22편의 thunk에서 getState로 직접 가드를 작성했던 것과 같은 목적을, createAsyncThunk의 옵션으로 선언적으로 표현한 것입니다. 진행 중인 요청을 명시적으로 중단하고 싶다면, thunkAPI가 제공하는 signal(표준 AbortController의 signal)을 fetch에 그대로 전달합니다.

1
2
3
4
5
6
7
export const fetchTodos = createAsyncThunk(
  "todos/fetchTodos",
  async (_, { signal }) => {
    const response = await fetch("/api/todos", { signal }); // dispatch(fetchTodos.abort())로 취소하면 fetch도 함께 중단됨
    return await response.json();
  }
);

실무 체크리스트

  • API 호출을 09편 스타일의 손으로 만든 3-액션 thunk 대신 createAsyncThunk로 작성하고 있는가?
  • 로딩 상태를 boolean(isLoading)이 아니라 'idle'/'loading'/'succeeded'/'failed' 같은 명시적 상태로 관리해, “로딩 끝 + 결과 없음"과 “로딩 중"을 구분하고 있는가?
  • 서버의 구조화된 에러 응답을 그대로 전달해야 한다면 rejectWithValue를 사용하고 있는가?
  • 폼 제출처럼 dispatch 지점에서 바로 성공/실패를 알아야 하는 곳에서 .unwrap()을 쓰고 있는가?
  • 이미 진행 중인 요청의 중복 dispatch를 막아야 한다면 condition 옵션을 검토했는가?

연습 과제

기초(★☆☆)

  • 09편의 fetchTodos thunk를 createAsyncThunk 버전으로 다시 작성해보세요.

중급(★★☆)

  • status 필드를 활용해 “로딩 중”, “에러 발생”, “빈 목록”, “정상 목록” 네 가지 UI 상태를 모두 렌더링하는 컴포넌트를 작성해보세요.

고급(★★★)

  • 서버가 404일 때 { code: "NOT_FOUND" }를 응답한다고 가정하고, rejectWithValue로 이 정보를 받아 “해당 항목을 찾을 수 없습니다"라는 전용 메시지를 표시해보세요.
  • fetchTodoscondition 옵션을 추가해, status"loading"일 때 같은 thunk를 두 번 연속 dispatch해도 네트워크 요청이 한 번만 발생하는지 확인해보세요.

요약

  • createAsyncThunk는 API 호출 함수 하나로부터 pending/fulfilled/rejected 세 액션을 자동 생성한다.
  • extraReducersaddCase로 이 세 상태를 처리해 로딩 상태를 명시적으로 관리한다.
  • rejectWithValue로 반환한 값은 action.error가 아니라 action.payload에 담긴다.
  • .unwrap()으로 dispatch 지점에서 바로 성공/실패를 try/catch로 처리할 수 있고, condition/signal로 중복 요청 방지와 취소를 구현할 수 있다.

참고 문헌 및 출처(추천)

  • Redux Toolkit 공식 문서, “createAsyncThunk” API 레퍼런스
  • Redux Toolkit 공식 문서, “Redux Essentials, Part 5: Async Logic and Data Fetching”
  • Redux Toolkit 공식 문서, “rejectWithValue”

다음 글