메가바이트 스쿨 16주차 (03/29) 미니 프로젝트 해설

정영찬·2023년 4월 2일
0
post-thumbnail

미세먼지 api를 활용해서 서비스를 만드는 과제를 제출한 다음, 좀더 코드를 수정해서 제작해보고자 그 과정을 여기에 기록한다.

0. 뭘?

어떤 기능을 만들기 위해서 일련의 작업을 진행해야하는지 파악하는것이 최우선

무엇을 하고싶니?

  • 단일 지역에 해당하는 미세먼지 정보만 보여주는 페이지가 있으면 좋겠다.
    - 이 때 사용자가 시/도 와 지역을 선택하면 그에 해당하는 정보를 보여주는 방식으로 하고싶다.

    • 그리고 미세먼지 정보의 등급을 표시해주는 정보 pm10Grade의 값에 따라 다른 키워드를 보여주면서, 색상까지 변경해보고 싶다.
  • 복수 지역에 해당하는 미세먼지 정보를 보여주는 페이지가 있으면 좋겠다.
    - 이 때 사용자가 시/도 만 선택하면, 선택된 시/도 하위의 모든 지역의 데이터를 보여주는 방식으로 하고 싶다.

  • 사용자가 즐겨찾기로 원하는 지역을 추가하며, 즐겨찾기로 추가한 항목만들 보여주는 페이지가 있으면 좋겠다.
    - 복수 지역의 정보를 보여주는 페이지에서 각각의 컴포넌트에 즐겨찾기 버튼을 넣어서 클릭하면 추가되는 방식으로 해보고 싶다.

    요약

    • 단일 지역 미세먼지 정보 페이지
    • 복수 지역 미세먼지 정보 페이지
    • 즐겨찾기 미세먼지 정보 페이지

    좋아. 이제 내가 뭘 하고싶은지 알았으니 이제 시작해보자. 일단 내가 제일 먼저 필요한건 뭘까?

    데이터!

1. api 로부터 데이터 가져오기

프록시 설정

한국 환경공단 에어코리아에서 제공하는 미세먼지 정보 api를 사용하여 데이터를 가져온다.

  • 이때 공적으로 제공하는 api의 url은 그냥 호출하면 대부분 cors 오류가 발생하므로 프록시 설정을 해주는 것이 좋다고 한다.

따라서 프록시 설정을 먼저 진행한다.
vite.config.js

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [react()],
    resolve: {
      // /src 경로를 @로 대체한다는 뜻
        alias: [{ find: '@', replacement: '/src' }],
    },
    server: {
        port: 3000,
        proxy: {
            '/api': {
                target: 'https://apis.data.go.kr',
                changeOrigin: true,
                rewrite: (path) => path.replace(/^\/api/, ''),
            }
        }
    },
})

/api라는 경로를 https://apis.data.go.kr 로 선언하여 로컬에서 접근할때 cors 오류가 없도록 한다.

api 데이터 가져오기 (rtk query 사용)

먼저 api를 요청할때 필요한 파라미터를 객체로 따로 선언했다.

const args = {
  serviceKey: import.meta.env.VITE_SERVICE_KEY,
  returnType: 'json',
  numOfRows: '100',
  pageNo: '1',
  ver: '1.0',
}

VITE 환경에서 환경변수를 지정하고 싶으면 반드시 변수의 접두사를 VITE_ 로 시작해줘야한다.
또한 process.env가 아니라 meta.env로 설정해야한다.

그전에 잠깐,

무슨 데이터가 필요하지?

  • 시/도 와 지역을 선택했을때의 단일 지역 데이터
  • 선택된 시/도 하위의 전체 지역 데이터
  • 즐겨찾기로 추가된 것들만 모아놓은 데이터

첫번째와 2번째 부터 api를 통해 데이터를 가져오는 로직을 작성한다.

api 구조상 시/도 이름(sidoName)을 입력하면 numOfRows만큼 sidoName 에 속한 지역(stationName)을 얻을수 있기 때문에 2번째에 해당하는 데이터를 얻는 로직을 작성할 것이다.

export const dustApi = createApi({
  reducerPath: 'dustApi',
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_API_URL,
    paramsSerializer: (params) => qs.stringify(params, { encode: false })
  }),
  endpoints: (builder) => ({
    getDusts: builder.query({
      query: (sidoName) => {
        return {
          url: '',
          params: {...args, sidoName},
        }
      },
      transformResponse: responseData => {
        return responseData['response']['body']['items']
      }
    }),
    // 한 쿼리에서 여러 번 요청을 보내고자 할 때 아래와 같이 사용할 수 있습니다.
    getMultipleDusts: builder.query({
      async queryFn(_arg, _queryApi, _extraOptions, fetchWithBQ) {
        // 즐겨찾기를 서울 , 부산, 했다면 각각 요청을 보내고 응답을 받아야함
        // 요청을 여러번 보내는 api를 만들자!
        // _arg = ['서울', '부산', '대구']
        // 배열에 대해서 redux 함수를 통해서 요정을 보내고 그 응답을 쌓는다.
        // sidoName 이 ['서울', '부산', '대구'] 이런식으로 들어옴

        const result = await _arg.reduce(async (promise, sidoName) => {
          const argResult = await fetchWithBQ({
            url: '',
            params: {...args, sidoName: sidoName}
          })
          // 에러처리
          if (argResult.error) return {error: argResult.error};
          // 앞서 처리된 Promisedata 받아오기
          const promiseData = await promise.then()
          return Promise.resolve([...promiseData, ...argResult.data['response']['body']['items']])
        }, Promise.resolve([]))
        return result.error ? { error: result.error } : { data: result }
      }
    }),
  }),
});

paramSerializer : 배열을 파라미터로 전송할때 간단하게 사용할수 있도록 qs 모듈을 추가해서 stringify 한다. 이때 uri로 encode 하는 옵션은 false로 지정했다.

endpoint :인자로부터 쿼리 매개변수를 생성하고 캐싱을 위해 응답을 변환하는 방법을 포함하여 API 엔드포인트가 미리 정의된다.

getDust : sidoName 을 파라미터로 지정하고 파라미터에는 미리 선언했던 args와 함께 sidoName을 추가한다

transformResponse : createApi의 개별 엔드포인트는 쿼리 또는 변이가 캐시에 도달하기 전에 반환된 오류를 조작할수있는데 이것을 담당하는 기능이다. 오류라고 설명했지만 쿼리를 통해 리턴되는 값중에서 오류가 포함되어있으므로 정상적으로 동작하는 데이터를 입맛에 맛게 가공할수 있다.

현재 사용하는 api로부터 데이터를 받아올때 미세먼지의 정보를 얻으려면 response.body.items라는 험난한 객체들을 뚫어야한다.

하지만 이 속성을 사용해서 데이터를 감싸는 껍질을 벗길수 있게 되는 것이다.

transformResponse: responseData => {
return responseData['response']['body']['items']
}

getMultipleDusts : 한 쿼리에서 여러번 요청을 보내고싶을때 작성하는 방법이다. 여기서는 endpoint를 사용하는 대신에 queryFn을 사용하는데 이것은 엔드포인트에 대해 baseQuery를 완전히 우회하는 인라인 함수로써 쿼리 대신 사용이 가능하다!

파라미터로 입력한 _arg 배열을 reduce 메소드를 사용해서 반복적으로 api를 호출하고, 최종 결과물을 합쳐서 리턴하게 된다!
argResult 가 api 호출의 결과물이고, 껍질을 벗겨서 리턴한다.

2. 상태 관리 제작하기

내가 어떤 데이터를 전역으로 관리할 것인가?

  • 단일 지역정보
    - sidoNamestationName
  • 복수 지역 데이터를 표시하기 위한 sidoName
  • 즐겨찾기 데이터 정보
    - 처음에는 비어있다가 추가됨.
    • sidoName, stationName
    • 사용자가 즐겨찾기한 지역의 정보를 저장했다가, 즐겨찾기 페이지로 들어가면 이를 인자로 사용하여 데이터를 반환해야 하기 때문!

locationSlice.js

import { createSlice } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';

const initialState = {
  myLocation: {sidoName: '서울',stationName: '강남구'},
  allLocation: {sidoName: '서울'}
}

export const locationSlice = createSlice({
  name: 'location',
  initialState,
  reducers: {
    setMyLocation: (state, action) => {
      state.myLocation.sidoName = action.payload.sidoName;
      state.myLocation.stationName = action.payload.stationName;
    },
    setAllLocation: (state, action) => {
      state.allLocation.sidoName = action.payload.sidoName;
    },
  },
});

export const { setMyLocation, setAllLocation } = locationSlice.actions;

export function useLocationSlice() {
  const allLocation = useSelector((state) => state.location.allLocation);
  const myLocation = useSelector((state) => state.location.myLocation);
  const dispatch = useDispatch();

  return {
    allLocation,
    myLocation,
    dispatch,
  };
}

export default locationSlice.reducer;

favoriteSlice.js

import { createSlice } from '@reduxjs/toolkit';
import { useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';

const initialState = {
  locations: []
};

export const favoriteSlice = createSlice({
  name: 'favorite',
  initialState,
  reducers: {
    addFavoriteItem: (state, action) => {
        state.locations = [...state.locations, action.payload]
    },
    deleteFavoriteItem: (state, action) => {
        state.locations = state.locations.filter((element) => !(element.stationName === action.payload.stationName && element.sidoName === action.payload.sidoName))
    }
  },
});

export const { addFavoriteItem, deleteFavoriteItem } = favoriteSlice.actions;

export function useFavoriteSlice() {
  const favorite = useSelector((state) => state.favorite.locations);
  const dispatch = useDispatch();
  const favoriteLocations = useMemo(() => favorite.reduce((acc, {sidoName, stationName}) => 
    acc[sidoName] ? { ...acc, [sidoName]: [...acc[sidoName], stationName]} : { ...acc, [sidoName]: [stationName]}
  , {}), [favorite])
  const favoriteSidos = useMemo(() => Object.keys(favoriteLocations), [favoriteLocations])

  return {
    favorite,
    dispatch,
    favoriteLocations,
    favoriteSidos
  };
}

export default favoriteSlice.reducer;

dustSlice.js

import { createSelector } from 'reselect'

export const selectDustByStation = createSelector([(res) => res.data, (res, stationName) => stationName], (dusts, stationName) =>
    dusts?.find((dust) => dust.stationName === stationName) ? dusts?.find((dust) => dust.stationName === stationName) : dusts?.[0],
)

export const selectDustByStations = createSelector([(res) => res.data, (res, stationList) => stationList], (dusts, stationList) =>
    dusts?.filter((dust) => stationList.some((station) => station.sidoName === dust.sidoName && station.stationName === dust.stationName)),
)

export const returnOnlyStations = createSelector([(res) => res.data], (dusts) => dusts?.map((dust) => dust.stationName))

createSelector?

selecotr에 메모리제이션을 더한 것으로 렌더링 최적화에 기여할수 있는 기능을 제공한다.

만약 sidoName 하위에 stationName이 존재하지 않을 경우에는 sidoName을 통해 출력한 dusts의 첫번째 요소를 데이터로 사용하게 설정했다.

3. api 데이터를 사용해서 컴포넌트 제작하기

DustCard

import React, { useMemo, useState } from 'react'
import Box from '@mui/material/Box'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card'
import StarRateIcon from '@mui/icons-material/StarRate'
import { Container, ToggleButton } from '@mui/material'
import { GRADE } from './../constants/pmgrade'
import { addFavoriteItem, deleteFavoriteItem } from '../store/slices/favoriteSlice'
import { useCallback } from 'react'

function DustCard({ dust, favorite, dispatch, single }) {
    const [selected, setSelected] = useState(false)
    const isFavorite = useMemo(() => favorite?.some((element) => element.sidoName === dust.sidoName && element.stationName === dust.stationName), [favorite, dust])

    const { sidoName, stationName, pm10Grade, pm10Value, dataTime } = dust
    const handleFavorite = useCallback(() => {
        isFavorite ? dispatch(deleteFavoriteItem({ sidoName: dust.sidoName, stationName: dust.stationName })) : dispatch(addFavoriteItem({ sidoName: dust.sidoName, stationName: dust.stationName }))
    }, [isFavorite, dust])
    return (
        // 카드 컴포넌트
        <Card sx={{ minWidth: 275, marginBottom: '20px', background: '#6c770b' }}>
            <CardContent>
                <Box sx={{ display: 'flex', justifyContent: 'center', gap: '10px' }}>
                    <Typography sx={{ fontSize: 20 }} color="white" gutterBottom>
                        {sidoName}
                    </Typography>
                    <Typography sx={{ fontSize: 15, paddingTop: '5px' }} color="white" gutterBottom>
                        {stationName}
                    </Typography>
                </Box>
                <Container maxWidth="sm" sx={{ display: ' flex', alignItems: 'center', justifyContent: 'center' }}>
                    <Box
                        sx={{
                            display: 'flex',
                            justifyContent: 'center',
                            alignItems: 'center',
                            width: 150,
                            height: 100,
                            borderRadius: 5,
                            backgroundColor: 'primary.dark',
                            flexDirection: 'column',
                        }}
                    >
                        <Typography variant="h5" component="div" color="white" fontWeight="700">
                            {pm10Grade === null ? '측정불가' : GRADE[pm10Grade]}
                        </Typography>
                    </Box>
                </Container>
                <Typography sx={{ mb: 1.5 }} color="white" marginTop="10px">
                    미세먼지 수치 : {pm10Value}
                </Typography>
                <Typography sx={{ mb: 1.5 }} color="white" marginTop="10px">
                    ({dataTime})
                </Typography>
                {!single && (
                    <ToggleButton
                        value="check"
                        selected={isFavorite}
                        onChange={() => {
                            handleFavorite()
                        }}
                    >
                        <StarRateIcon />
                    </ToggleButton>
                )}
            </CardContent>
        </Card>
    )
}

export default DustCard

받아오는 props 목록
dust : 단일 지역에 대한 미세먼지 정보 데이터
favorite : 즐겨찾기 데이터, createSlice를통해서 생성된 initalState 배열
dispatch : 액션함수, 여기서는 즐겨찾기 데이터를 추가/삭제 하는 액션 함수를 사용한다.

single : single 의 존재 여부에 따라서 select 컴포넌트의 요소가 결정된다. (복수 지역 페이지에는 시/도 만 있지만 단일지역 선택페이지에는 시/도 지역이 둘다 존재함)

LocationSelect.jsx

import React, { useEffect, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import FormControl from '@mui/material/FormControl'
import Select from '@mui/material/Select'
import { setAllLocation, setMyLocation } from '../store/slices/locationSlice'
import { useCallback } from 'react'

function LocationSelect({ location, dispatch, single, stationList }) {
    const [currentLocation, setCurrentLocation] = useState(location)

    /** sido 를 선택했을때 sido의 값을 변경하는 */
    const handleSido = (event) => {}

    /** 선택된 stationName을 바탕으로 pmData를 변경하는 메소드 */
    const handleStation = (event) => {}

    const onLocationChange = (e) => {
        const { name, value } = e.target

        setCurrentLocation((prevState) => ({ ...prevState, [name]: value }))
        if (!single) dispatch(setAllLocation({ ...currentLocation, [name]: value }))
        if (single) dispatch(setMyLocation({ ...currentLocation, [name]: value }))
    }

    return (
        <Box sx={{ minWidth: 300, marginBottom: '10px' }}>
            <FormControl sx={{ minWidth: 120 }}>
                <InputLabel id="demo-simple-select-label">/</InputLabel>
                <Select labelId="demo-simple-select-label" id="demo-simple-select" value={currentLocation.sidoName} name="sidoName" onChange={onLocationChange}>
                    <MenuItem value={'경북'}>경북</MenuItem>
                    <MenuItem value={'대전'}>대전</MenuItem>
                    <MenuItem value={'충북'}>충북</MenuItem>
                    <MenuItem value={'충남'}>충남</MenuItem>
                    <MenuItem value={'경남'}>경남</MenuItem>
                    <MenuItem value={'울산'}>울산</MenuItem>
                    <MenuItem value={'광주'}>광주</MenuItem>
                    <MenuItem value={'전북'}>전북</MenuItem>
                    <MenuItem value={'전남'}>전남</MenuItem>
                    <MenuItem value={'제주'}>제주</MenuItem>
                    <MenuItem value={'대구'}>대구</MenuItem>
                    <MenuItem value={'서울'}>서울</MenuItem>
                    <MenuItem value={'경기'}>경기</MenuItem>
                    <MenuItem value={'강원'}>강원</MenuItem>
                    <MenuItem value={'부산'}>부산</MenuItem>
                    <MenuItem value={'세종'}>세종</MenuItem>
                    <MenuItem value={'인천'}>인천</MenuItem>
                </Select>
            </FormControl>
            {single ? (
                <FormControl sx={{ minWidth: 120 }}>
                    <InputLabel id="demo-simple-select-label">지역 선택</InputLabel>
                    <Select labelId="demo-simple-select-label" id="demo-simple-select" value={currentLocation.stationName} name="stationName" onChange={onLocationChange}>
                        {stationList?.map((item) => {
                            return (
                                <MenuItem key={item} value={item}>
                                    {item}
                                </MenuItem>
                            )
                        })}
                    </Select>
                </FormControl>
            ) : null}
        </Box>
    )
}

export default LocationSelect

props 목록
location : createSlice를 통해 생성된 locationSlice에서 가져온 initialState 데이터 값이다. 시/도, 지역의 기본 데이터가 들어있다.
dispatch : locationSlice로부터 가져온 dispatch 함수를 사용한다. single값에 따라서 동작하는 함수를 다르게 정의하며, 선택된 시/도 , 지역값을 업데이트 해주는 역할을 맡고 있다.

single : 단일/복수 지역인지를 파악하기 위한 변수이다.

stationList : 페이지 파일로부터 가져온 지역 배열 데이터이다. 사용자가 원하는 시/도를 선택한 경우 그에 대한 배열 데이터가 전달되며 이를 map 메소드를 사용해서 지역 option 이 생성된다.

  1. 라우터를 통해서 페이지 제작하기
    SingleDust.jsx
import React, { useEffect } from 'react'
import { useGetDustQuery } from '../store/apis/axios'
import DustCard from '../components/DustCard'
import LocationSelect from '../components/LocationSelect'
import { useLocationSlice } from '../store/slices/locationSlice'
import { returnOnlyStations, selectDustByStation } from '../store/slices/dustSlice'

function SingleDust() {
    const { myLocation, dispatch } = useLocationSlice()
    // 단일 지역의 미세먼지 정보를 가져오는 쿼리
    const {
        data: dust,
        isLoading,
        isError,
    } = useGetDustQuery(myLocation.sidoName, {
        selectFromResult: (result) => ({
            ...result,
            data: selectDustByStation(result, myLocation.stationName),
        }),
    })

    /* const { data: dustList } = useGetDustQuery(myLocation.sidoName) */
    // 선택된 시/도 내의 모든 지역의 이름을 가져오는 쿼리
    const { data: stationList } = useGetDustQuery(myLocation.sidoName, {
        selectFromResult: (result) => ({
            ...result,
            data: returnOnlyStations(result),
        }),
    })

    if (isLoading) return <div>로딩중</div>

    if (isError) return <div>에러발생</div>
    return (
        <div>
            <LocationSelect location={myLocation} dispatch={dispatch} single={true} stationList={stationList} />
            <DustCard dust={dust} single={true} />
        </div>
    )
}

export default SingleDust

단일 지역에 대한 미세먼지 정보를 보여주는 페이지이다.

  • rtk query를 사용하여 가져온 데이터와 locationSlice의 initialState 데이터, dispatch를 가져와서 하위 컴포넌트의 props로 전달한다.

  • dust : rtk query를 통해 가져온 데이터로 selectDustByStation을 통해 단일 지역의 데이터를 가져온다.

  • stationList : 시/도 에 대한 전체 지역 목록 배열 데이터

페이지 관련 코드는 거의 비슷해서 설명으로 대체함
MultiDust.jsx
단일지역 페이지와는 다르게 allLocation, favorite 이라는 initalState를 가져와서 props 로 전달했다. 즐겨찾기 기능을 위해서도 있고, 단일 지역 페이지와 복수 지역 페이지의 state 를 독립적으로 관리하기 위한 목적이다.

FavortieDust.jsx

FavoriteSlice 로부터 favorite, favoriteSidos, dispatch를 가져온다.

favoriteSidos : favorite reduce 메소드로 여러개의 객체 데이터로 이루어진 favoriteLocations를 key 값만 사용하기 위해서 Object.keys 를 사용하여 배열값만 가져온 것이다. 이것을 통해서 useGetMultipleDustsQuery를 통해 여러번 데이터를 호출하되, seleCtDustByStations를 사용해서 해당 favorite지역에 대한 데이터만 리턴되는 것으로 사용한다.

profile
개발자 꿈나무

0개의 댓글