Http request를 보낼 때 reducer 함수
내부에서 작성할 수 없다. 왜냐하면 http request는 비동기적인 작업으로 진행되는 반면 Reducer함수는 동기적으로 작동하기 때문이다. 그렇다면 redux와 Reducer함수를 사용해서 http request를 전송하려고 할 때 이러한 side effect 코드는 어디에 작성해야 할까?
useEffect
를 사용해서 작성한다action creator 함수
를 구현한다.유저가 장바구니에 저장한 항목이 새로고침하여도 유지될 수 있도록 firebase 백엔드와 연결하는 코드를 작성해보려 한다.
✅ 요구사항
- 장바구니에 항목을 추가하거나 삭제할 때마다 백엔드에 업데이트하여 저장하기
- 새로고침 시 백엔드에 저장된 항목을 장바구니에 불러오기
만약 버튼을 눌러서 장바구니에 추가하는 로직의 경우, useEffect
를 사용하여 추가하는 버튼이 있는 component 내부에서 firebase에 http request를 보낼 수 있다. 하지만 이 경우의 문제점은 단순히 firebase 데이터베이스에 버튼이 눌린 아이템에 대한 정보만이 추가된다는 것이다. 해당 아이템이 이미 장바구니에 있어서 수량만 추가하면 되는지 아니면 아예 없던 아이템이어서 새롭게 추가를 해야하는지 등의 로직은 firebase 자체에 없기 때문이다.
따라서 이와같은 경우에는 프론트엔드단에서 데이터를 정제하고 백엔드 서버로 보내야 한다. 이 경우 어느 곳에 데이터를 정제하고 데이터를 보내는 코드를 작성해야할지에 대해 고민해보아야 한다.
🧐 데이터 변환하는 작업을 어디에 둘까?
일반적으로 데이터 변환만을 작업해야 하는 경우는 component 내부에 작성하기보다는
reducer
를 사용하여 작성하는 경우가 더 많을 것이다. 왜냐하면 이러한 작업은 동기적인 작업이고reducer
내부에서도 얼마든지 실행할 수 있기 때문이다.
하지만 side effect가 있어서 백엔드에 데이터를 전송까지 해야 하는 경우에는Reducer
내부에 Fetch 등 Http request를 보내는 코드를 절대 넣을 수 없다. 이러한 비동기적인 작업은 동기적인 작업을 하는 reducer에서 작동시킬 수 없기 때문이다. 따라서 이 경우에는 외부 component에서 action을 조작하여 http request를 보내는 방식을 사용하는 경우가 많다.
이렇게 경우에 따라서 두 가지 중 사용하는 방식이 달라지게 된다.
component 내부에서 add하는 handler가 실행될 때, 데이터를 정제하는 방법이 있다. 기존에 백엔드에 데이터를 보내지 않았을 때는 Reducer
안에 정의해서 사용하였었는데 그 부분을 component로 가져와서 내부에서 실행하는 것이다.
이때 주의할 점은 우리는 절대! 기존의 state에 직접적으로 접근해서 변환하면 안된다. reducer
내부에서 사용할 때는 그렇게 작성해도 reducer
가 내부적으로 정석적인 방식으로 (복사해서 상태 업데이트하기) 작동되었지만, 이제 reducer
내부가 아닌 component의 일반 함수에서 store에 저장된 state를 업데이트 하는 것이므로 새로운 상수에 (다른 메모리 공간) state를 복사해서 저장하고 복사한 값으로 데이터 조작을 한 후, 다시 reducer
로 보내서 reducer
에서 받은 복사된 값으로 state를 업데이트하는 방식으로 사용해야 한다!
// ProductItem.js
import { useDispatch, useSelector } from 'react-redux';
import { cartActions } from '../../store/cart-slice';
import Card from '../UI/Card';
import classes from './ProductItem.module.css';
const ProductItem = (props) => {
// store의 state값 가져오기
const cart = useSelector((state) => state.cart);
const dispatch = useDispatch();
const { title, price, description, id } = props;
const addToCartHandler = () => {
// 절대로 reducer외부에서 직접적으로 store의 state값에 접근해서 변경하면 안된다!
// cart.totalQuantity = cart.totalQuantity + 1 -> 안됨
// 따라서 아래와 같이 새로운 상수값을 만들어서 일단 저장한다. (저장과 동시에 업데이트가 아니라는 말!)
const newTotalQuantity = cart.totalQuantity + 1;
// 기존 state값을 변경시키지 않기 위해 slice를 통해 새로운 배열로 받아와서 저장
const updatedItems = cart.items.slice();
const existingItem = updatedItems.find((item) => item.id === id);
if (existingItem) {
// 객체는 참조값이므로 메모리상 다른 값으로 저장하기 위해 spread문법 사용
const updatedItem = { ...existingItem };
updatedItem.quantity++;
updatedItem.totalPrice = updatedItem.totalPrice + price;
const existingItemIndex = updatedItems.findIndex(
(item) => item.id === id
);
updatedItems[existingItemIndex] = updatedItem;
} else {
updatedItems.push({
id: id,
price: price,
quantity: 1,
totalPrice: price,
name: title,
});
}
const newCart = {
totalQuantity: newTotalQuantity,
items: updatedItems,
};
dispatch(cartActions.replaceCart(newCart));
return ( ...
<button onClick={addToCartHandler}>Add to Cart</button>
);
};
export default ProductItem;
이 방법의 문제점은, 장바구니를 업데이트 하는 것과 관련된 모든 component에 해당 로직을 다 중복 작성해야 한다는 점이다. 물론 따로 파일로 빼서 export한 후 사용하여 코드 중복을 줄일 순 있지만, 그렇게 할 경우 사실상 redux가 하는 일은 크지 않다. Reducer 함수
가 하던 작업을 일반 component들이 하게되므로 Reducer함수
가 매우 간단해지고, redux의 효용을 크게 볼 수 없다는 문제가 있다..
그리고 개인적인 생각으로는, 객체값과 메모리값등을 고려하면서 코드를 작성해야 하므로 일반적인 reducer를 쓰는 것 보다 훨씬 복잡하게 코드가 작성된다고 생각한다,, 😅
- 일단 프론트 단에서 작업을 수행
- 리덕스가 store를 업데이트 한다
- 서버에 요청 보낸다
reducer 함수
내부에서 할 수 없기 때문에 컴포넌트 내부나 완전히 다른 파일에서 수행할 수 있다.이번에는 가장 상위 컴포넌트인 App.js에서
useSelector
를 통해서 전체 장바구니의 상태가 업데이트될 때마다 최신의 state값을 전달받고, api에 요청을 보내는 방식으로 작성해보려 한다.
이렇게 순서를 변경할 경우, reducer
내부에 데이터 변환하는 큰 로직을 저장할 수 있고, 프론트 단에서는 그저 버튼이 클릭 됐을 때 reducer
를 실행시키는 액션만을 보내면된다. 또한, useEffect
와 useSelector
를 사용하여 가장 최신의 state를 백엔드에 요청으로 보낼 수 있으므로 코드 분리가 효율적이고 확실하게 될 수 있다.
// App.js
import Cart from './components/Cart/Cart';
import Layout from './components/Layout/Layout';
import Products from './components/Shop/Products';
import { uiActions } from './store/ui-slice';
import Notification from './components/UI/Notification';
import { Fragment, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
// 초기실행 막기 위한 전역변수
let isInitial = true;
function App() {
const dispatch = useDispatch();
const showCart = useSelector((state) => state.ui.cartIsVisible);
// useSelector를 사용해서 새로운 값이 업데이트 될 때마다 cart 갱신 됨
const cart = useSelector(state => state.cart);
const notification = useSelector(state => state.ui.notification);
// dependency에 cart를 넣었기 때문에 cart가 업데이트 될 때마다 PUT 요청을 보내서 백엔드에 데이터 전송
useEffect(() => {
const sendCartData = async () => {
dispatch(
uiActions.showNotification({
status: 'pending',
title: 'Sending...',
message: '장바구니 전송중'
})
);
const response = await fetch('https://react-http-ab392-default-rtdb.firebaseio.com/cart.json', {
method: 'PUT', // POST는 데이터를 추가, PUT는 데이터를 오버라이드
body: JSON.stringify(cart),
});
if (!response.ok) {
throw new Error('장바구니 전송 실패')
}
dispatch(
uiActions.showNotification({
status: 'success',
title: 'Success...',
message: '장바구니 전송 성공!'
})
);
};
// 초기 실행 막기!
if (isInitial) {
isInitial = false;
return;
}
sendCartData().catch(error => {
dispatch(
uiActions.showNotification({
status: 'error',
title: 'Error!',
message: '장바구니 전송 실패'
})
);
});
}, [cart, dispatch]);
return (
<Fragment>
{notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
<Layout>
{showCart && <Cart />}
<Products />
</Layout>
</Fragment>
);
}
export default App;
이렇게 fetch와 관련된 모든 로직을 컴포넌트에 넣을 시의 문제점도 있다.
따라서 두 번째 방법인 action
을 직접 작성해서 사용하는 방식도 알아보자. 이때까지 Redux toolkit을 사용하면서는 .action
이라는 메소드를 사용하여 제공하는 action 메소드를 사용하였다. 하지만 redux toolkit이 제공하는 메소드가 아닌, 직접 action을 만들 수 있고 이는 thunk
를 생성하는 작업이다.
thunk
는 다른 작업이 완료될 때까지 작업을 지연시키는 단순한 함수이다.
action creator
를 thunk
로 만들어서 함수가 즉시 action을 만드는 것이 아니라 action 함수를 return하게 한다. 이렇게 action을 지연시키는 이유는 실제 action을 보내기 전에 다른 작업을 실행하고 싶을 경우가 있기 때문이다.
또한, thunk
를 이용한다면 side effect를 slice를 정의한 파일 내부에서 보낼 수 있다. 이전에는 component 내부에서 useEffect를 사용하여 side effect (http request)등을 처리했다면 이제는 slice를 정의한 파일 내부에서 thunk를 통해서 http request를 보내는 등의 작업을 할 수 있다. 이렇게 할 경우 component가 간단해질 수 있기 때문에 로직을 분리하기 좋다.
// App.js
// 초기실행 막기 위한 전역변수
let isInitial = true;
function App () {
// store의 state값 받아오기
const cart = useSelector(state => state.cart);
useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}
// thunk를 호출!
dispatch(sendCartData(cart))
}, [cart, dispatch]);
}
기존의 dispatch라면 type등을 통해서 action 객체의 reducer에 접근해서 해당 reducer를 바로 실행하느 것인 반면, thunk
는 dispatch를 하면 thunk
함수에 접근 후, 내부에 있는 dispatch를 실행하게 된다.
// cart-slice.js
// slice
const cartSlice = createSlice(... 생략)
// side effect를 처리할 thunk
export const sendCartData = (cart) => {
// redux에 의해 제공되는 dispatch 메소드
return async(dispatch) => {
dispatch(
uiActions.showNotification({
status: 'pending',
title: 'Sending...',
message: '장바구니 전송중',
})
);
// http request 보내기
const sendRequest = async () => {
const response = await fetch('https://react-http-ab392-default-rtdb.firebaseio.com/cart.json', {
method: 'PUT', // POST는 데이터를 추가, PUT는 데이터를 오버라이드
body: JSON.stringify(cart),
});
if (!response.ok) {
throw new Error('장바구니 전송 실패');
}
};
}
...
}
// cart-actions.js
//thunk (데이터 받아오기)
export const fetchCartData = () => {
return async (dispatch) => {
// try-catch로 묶고 싶다면 async를 사용해야 한다
// 'GET'을 사용하기 때문에 메소드 지정 필요 없음
const fetchData = async () => {
const response = await fetch('https://react-http-ab392-default-rtdb.firebaseio.com/cart.json');
if (!response.ok) {
throw new Error('장바구니 항목을 가져올 수 없음')
}
const data = await response.json();
return data;
}
try {
const cartData = await fetchData();
// 기존이라면 firebase의 데이터를 component의 데이터에서 사용하는 key로 변환하는 과정 필요
// 하지만 'PUT'으로 firebase에 장바구니 항목 전송하므로 해당 과정 생략 가능
// 장바구니가 비어있을 때 에러 대비 [] 추가
dispatch(cartActions.replaceCart({ items: cartData.items || [],
totalQuantity: cartData.totalQuantity}));
} catch (error) {
dispatch(
uiActions.showNotification({
status: 'error',
title: 'Error!',
message: '장바구니 받아오기 실패',
})
);
}
};
};
이때 장바구니를 백으로 보내는 useEffect
의 dependency로 cart가 들어가있는데 장바구니를 백에서 받아와서 fetchCartData Thunk를 호출할 경우, 백에 있는 장바구니 정보로 기존의 장바구니 정보를 업데이트하게 된다. 이 과정은 cart가 변경되었음을 의미하고, dependency에 의해 백으로 데이터를 전송하는 useEffect도 실행되게 된다. 때문에 새로고침을 할 경우 백으로 데이터를 전송하지 않음에도 불구하고 전송중... 이라는 ui가 계속 뜨게 되는 문제가 발생한다. 따라서 cart slice에 changed라는 필드를 추가해서 해당 에러를 방지할 수 있다.
// App.js
function App() {
// 장바구니 백에서 받아오기
useEffect(() => {
dispatch(fetchCartData())
}, [dispatch])
// 장바구니 백으로 보내기
useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}
// cart가 change되었을 경우(장바구니 항목 추가 혹은 삭제)에만 sendCartData하기
if (cart.changed) {
dispatch(sendCartData(cart))
}
}, [cart, dispatch]);
}
redux devTools
redux toolkit을 사용한다면 extension 설치만으로 사용 가능하다. 만약 redux만을 사용한다면 추가적으로 터미널에 입력하여 설치하는 과정이 필요하다!