본문 바로가기
개발/React Native

[React-Native, RN, Typescript] PokeAPI로 배우는 Redux-Toolkit 비동기처리

by 세크레투스 2024. 8. 2.
반응형
SMALL

Redux Toolkit Thunk로 PokeAPI를 비동기처리하여 포켓몬도감 앱을 만들어보자..!

Redux-Toolkit 이란?

리덕스 개발팀에서 만든 공식 라이브러리.

기존의 리덕스의 불편함을 개선하여 모듈 작성 시 액션 타입, 액션 생성 함수, 리듀서를 한 번에 작성할 수 있고,

상태를 업데이트할 때 불변성에 대해 신경쓰지 않아도 된다는 장점이 있다.

 

Redux-Toolkit으로 비동기처리 하는 법?

그것은 바로, Redux Thunk를 사용하는 것이다.

createAsyncThunk와 createSlice를 사용하여 Redux-Toolkit만으로 비동기 처리를 쉽게 할 수 있으며,

Redux-Saga에서만 사용할 수 있던 기능(이미 호출한 API 취소하기 등)까지 사용할 수 있다.

 

PokeAPI로 Redux-Toolkit Thunk로 비동기 처리 실습하기

1. 새 프로젝트 생성

npx react-native init PokeAPIExample

React Native 프로젝트를 생성한다.

이름은 'PokeAPIExample'로 하겠다. (다른 이름으로 바꿔도 상관 없다.)

 

VSCode로 방금 생성한 프로젝트를 열고, 터미널에 다음 패키지들을 설치한다.

npm install @reduxjs/toolkit react-redux axios

 

2. Redux 설정

src 폴더 하단에 store 폴더를 생성하고,

그 안에 애플리케이션의 상태를 관리할 store.ts 파일을 생성한다.

그리고 그 안에 다음과 같이 코드를 작성한다.

// src/store/store.ts
// ①
import {configureStore} from '@reduxjs/toolkit';

// ②
const store = configureStore({
  reducer: {},
});

// ③
export type RootState = ReturnType<typeof store.getState>;

// ④
export type AppDispatch = typeof store.dispatch;

// ⑤
export default store;

① : configureStore를 사용하여 Redux 스토어를 설정한다.

② : 스토어는 여러 개의 리듀서로 나눌 수 있으며, 현재 우리 프로젝트에서는 pokemon의 리듀서만 사용할 계획이다.

③ : store.getState를 호출해서 얻은 상태 타입으로 RootState의 타입을 정의한다. ReturnType<typeof store.getState>는 store.getState의 반환 타입을 추론하여 얻는다.

④ : AppDispatch의 타입을 정의한다. store.dispatch의 타입이다.

⑤ : 생성한 스토어를 default로 export 한다. 이 스토어는 애플리케이션에서 상태관리를 위해 사용된다.

 

다음으로, src 폴더 하단에 slices 폴더를 생성하여

그 안에 우리가 구현할 핵심 기능인 PokeAPI의 비동기 액션을 정의할 pokemonSlice.ts 파일을 생성한다.

 

Documentation - PokéAPI

If you were using v1 of this API, please switch to v2 (this page). Read more… Quick tip: Use your browser's "find on page" feature to search for specific resource types (Ctrl+F or Cmd+F). Information This is a consumption-only API — only the HTTP GET m

pokeapi.co

PokeAPI의 문서를 살펴보면,

특정 포켓몬 데이터를 가져오기 위해 https://pokeapi.co/api/v2/pokemon/{id or name}/ 엔드포인트를 사용하고,

해당 API 호출에 대한 응답에서 'sprites' 객체를 찾으면 포켓몬의 이미지 URL을 가져올 수 있다.

또한, 상세정보 응답에서 'species' 객체의  'url'을 사용해 종 정보를 가져올 수 있으며,

종 정보 응답에서 'names' 배열을 확인해보면 'language.name'이 'ko'인 항목을 찾아 한글 이름을 추출할 수 있다.

 

이 정보들을 기반으로 pokemonSlice.ts 파일을 코딩해볼것이다.

// src/slices/pokemonSlice.ts
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit';
import axios from 'axios';

// Pokemon 데이터의 타입을 정의한다. name은 포켓몬의 이름, image는 포켓몬의 이미지 URL이다.
interface Pokemon {
  name: string;
  image: string;
}

// 포켓몬 상태의 타입을 정의한다. data는 포켓몬 배열, loading은 로딩 상태, error는 에러 메시지, page는 현재 페이지 번호이다.
interface PokemonState {
  data: Pokemon[];
  loading: boolean;
  error: string | null;
  page: number;
}

// PokemonState의 초기 상태를 정의한다.
const initialState: PokemonState = {
  data: [],
  loading: false,
  error: null,
  page: 1,
};

// createAsyncThunk를 사용해 fetchPokemon이라는 비동기 thunk를 생성한다.
// 이 thunk는 페이지 번호를 받아서 해당 페이지의 포켓몬 데이터를 가져온다.
export const fetchPokemon = createAsyncThunk(
  'pokemon/fetchPokemon',
  async (page: number) => {
    const offset = (page - 1) * 12; // 페이지에 따라 offset을 계산합니다.
    const response = await axios.get(
      `https://pokeapi.co/api/v2/pokemon?limit=12&offset=${offset}`,
    );
    const pokemonData = await Promise.all(
      response.data.results.map(
        async (pokemon: {name: string; url: string}) => {
          const pokemonDetail = await axios.get(pokemon.url);
          const speciesDetail = await axios.get(pokemonDetail.data.species.url);
          const koreanNameEntry = speciesDetail.data.names.find(
            (name: {language: {name: string}}) => name.language.name === 'ko',
          );
          const koreanName = koreanNameEntry
            ? koreanNameEntry.name
            : pokemon.name;
          return {
            name: koreanName,
            image: pokemonDetail.data.sprites.front_default,
          };
        },
      ),
    );
    return pokemonData;
  },
);

// createSlice를 사용해 pokemon 슬라이스를 생성한다.
// 슬라이스는 상태와 관련된 리듀서 및 액션을 포함한다.
const pokemonSlice = createSlice({
  name: 'pokemon', // 슬라이스의 이름
  initialState, // 슬라이스의 초기 상태
  reducers: {
    // 페이지를 설정하는 리듀서
    setPage: (state, action) => {
      state.page = action.payload;
    },
  },
  // 비동기 작업 및 외부 액션을 처리하는 extraReducers
  extraReducers: builder => {
    builder
      // fetchPokemon이 pending 상태일 때, 로딩 상태를 true로 설정한다.
      .addCase(fetchPokemon.pending, state => {
        state.loading = true;
        state.error = null;
      })
      // fetchPokemon이 fulfilled 상태일 때, 로딩 상태를 false로 설정하고, 데이터를 업데이트한다.
      .addCase(fetchPokemon.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      // fetchPokemon이 rejected 상태일 때, 로딩 상태를 false로 설정하고, 에러 메시지를 업데이트한다.
      .addCase(fetchPokemon.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Failed to fetch Pokémon';
      });
  },
});

// setPage 액션을 export한다. 이 액션은 페이지를 설정하는 데 사용된다.
export const {setPage} = pokemonSlice.actions;

// 슬라이스의 리듀서를 default로 export한다. 이 리듀서는 configureStore에서 사용된다.
export default pokemonSlice.reducer;

 

3. 커스텀 훅 작성(useAppDispatch.ts)

다음으로는, 타입이 지정된 dispatch 함수를 사용할 수 있도록 커스텀 훅을 만들 것이다.

// src/hooks/useAppDispatch.ts
import {useDispatch} from 'react-redux';
import type {AppDispatch} from '../store/store';

// useDispatch 훅을 사용해서 AppDispatch 타입을 명시한 커스텀 훅을 생성합니다.
// 이 훅은 앱에서 dispatch를 사용할 때 타입 안전성을 제공합니다.
const useAppDispatch = () => useDispatch<AppDispatch>();

// 생성한 커스텀 훅을 default로 export합니다. 이 훅을 사용하면 타입이 지정된 dispatch 함수를 사용할 수 있습니다.
export default useAppDispatch;

 

 

4. PokemonList 컴포넌트(App.tsx) 개발

마지막으로, Redux 상태를 가져와서 데이터를 fetch 시키고,

포켓몬 리스트를 화면에 표시하여 포켓몬 도감을 만들어 보도록 하겠다.

// App.tsx
import React, {useEffect} from 'react';
import {
  SafeAreaView,
  StyleSheet,
  Text,
  FlatList,
  ActivityIndicator,
  View,
  Image,
  Button,
} from 'react-native';
import {useSelector} from 'react-redux';
import {RootState} from './src/store/store';
import {fetchPokemon, setPage} from './src/slices/pokemonSlice';
import {Provider} from 'react-redux';
import store from './src/store/store';
import useAppDispatch from './src/hooks/useAppDispatch';

const PokemonList = () => {
  // useAppDispatch 훅을 사용해 dispatch 함수를 가져온다.
  const dispatch = useAppDispatch();
  // useSelector 훅을 사용해 Redux 상태에서 pokemon 상태를 가져온다.
  const {data, loading, error, page} = useSelector(
    (state: RootState) => state.pokemon,
  );

  // 컴포넌트가 처음 렌더링되거나 페이지 번호가 변경될 때 fetchPokemon 액션을 dispatch한다.
  useEffect(() => {
    dispatch(fetchPokemon(page));
  }, [dispatch, page]);

  //페이지를 증가시키는 핸들러 함수
  const handleNextPage = () => {
    dispatch(setPage(page + 1));
  };

  //페이지를 감소시키는 핸들러 함수
  const handlePrevPage = () => {
    if (page > 1) {
      dispatch(setPage(page - 1));
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      {loading ? (
        // 로딩 중일 때 ActivityIndicator를 표시한다.
        <ActivityIndicator size="large" color="#0000ff" />
      ) : error ? (
        // 에러가 발생한 경우 에러 메세지를 표시한다.
        <Text style={styles.error}>{error}</Text>
      ) : (
        <View style={styles.body}>
          <Text style={styles.title}>포켓몬도감</Text>
          <FlatList
            data={data}
            keyExtractor={item => item.name} // 각 아이템의 고유한 key를 설정한다.
            renderItem={({item}) => (
              <View style={styles.itemContainer}>
                <Image source={{uri: item.image}} style={styles.image} />
                <Text style={styles.item}>{item.name}</Text>
              </View>
            )}
            numColumns={3} // 아이템을 3열로 표시한다.
            contentContainerStyle={styles.list}
            scrollEnabled={false}
          />
          <View style={styles.pagination}>
            <Button
              title="Previous"
              onPress={handlePrevPage}
              disabled={page === 1} // 현재 페이지가 1이면 Previous 버튼을 비활성화한다.
            />
            <Text style={styles.pageNumber}>Page {page}</Text>
            <Button title="Next" onPress={handleNextPage} />
          </View>
        </View>
      )}
    </SafeAreaView>
  );
};

const App = () => (
  // Provider 컴포넌트를 사용해 애플리케이션에 Redux 스토어를 제공한다.
  <Provider store={store}>
    <PokemonList />
  </Provider>
);

// StyleSheet를 사용해 스타일을 정의한다.
const styles = StyleSheet.create({
  body: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  title: {
    fontSize: 30,
    marginVertical: 20,
  },
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  list: {
    justifyContent: 'center',
    alignItems: 'center',
  },
  itemContainer: {
    alignItems: 'center',
    padding: 10,
    width: 120,
    borderWidth: 1,
    borderColor: '#000000',
    margin: 5,
    borderRadius: 10,
  },
  item: {
    padding: 10,
    fontSize: 15,
    height: 44,
  },
  image: {
    width: 50,
    height: 50,
  },
  error: {
    color: 'red',
    fontSize: 18,
  },
  pagination: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginVertical: 30,
  },
  pageNumber: {
    fontSize: 18,
    marginHorizontal: 10,
  },
});

// App 컴포넌트를 default로 export한다.
export default App;

 

 

이렇게 하면 PokeAPI를 활용한 포켓몬도감 앱이 완성된다..!

 

마지막으로,

npm run android 또는 npm run ios를 통해 에뮬레이터 또는 시뮬레이터로 앱이 잘 만들어졌는지 확인해보자.

 

만약에 블로그 포스팅대로 잘 따라왔다면 다음과 같은 결과물이 나올 것이다.

 

이상으로 블로그 포스팅을 마치도록 하겠다.

 

끝!

반응형
LIST