
SHOPSHOP OPEN MARKET은 React를 사용하여 판매자와 구매자를 구별해 상품 등록, 결제, 상품에 대한 CRUD를 직접 구현해보는 프로젝트다!
구현 기간은 이런 저런 일로 빡세게 하지 못해 한달 반 이상이 걸렸다. 아래의 구현 페이지를 보면... 증말... 여러 정보들을 긁어 모으고, 코드를 요렇게 저렇게 우당탕탕 짜보고, 선배들의 코드를 분석하면서 그래도... 혼자 해내었다!! 장하구나!! 무하하하!!

그래서 슬며시 올라가는 입꼬리의 뿌듯함과 결과물을 보면 아직 갈 길이 멀었다는 두려움과 설레임이 함께 몰려온다...! 😉
리팩토링을 진행하면서 항상 나는 이렇게 생각한다... 처음 만들 때 이렇게 코드를 짜면 얼마나 좋아? 아~놔~ 애초에 이 라이브러리 도입할걸! 나는 바보인가? 뭐야? 왜 같은 코드를 알아보지 못해?!! 🤪
근데... 뭐 이러면서 성장하는 것 아니겠는가...? 첨부터 누가 잘해... 그치만 잘하는 사람도 있겠지... 하지만 예전의 나를 뒤돌아보면 개인 프로젝트 결과물이 있다는 것만으로 부처핸섭해야 한다 🤚🏻
그리고 친구들에게 유저 테스트를 권해보면 사용자의 입장에서 아직 부족한 게 많아 보였다. 특히 쇼핑몰 같은 경우 사용자의 눈길을 끄는 이미지가 페이지 절반을 차지하는데, 이미지 처리 관련해서 많이 아쉽다. 그럼에도 불구하고, 제공되는 API가 있어서 개인 프로젝트를 통해 다양한 기능들을 구현하는 것만으로 뼈대에 붙은 살을 열심히 찌우는 역할을 한 것 같다 🤗
| 구매자 로그인 | 구매자 회원가입 |
|---|---|
| 판매자 로그인 | 판매자 회원가입 |
|---|---|
| 로그아웃 | 검색 |
|---|---|
| 판매자 로그인 시 | 비로그인 시 |
|---|---|
| 구매자 로그인 시 | 구매자 장바구니 수량 수정 |
|---|---|
| 구매자 장바구니 수량 누적 | 구매자 장바구니 삭제 |
|---|---|
| 구매자 장바구니 개별 주문 | 구매자 장바구니 전체 주문 |
|---|---|
| 구매자 장바구니 결제 | 판매자 상품 등록 |
|---|---|
| 판매자 상품 수정 | 판매자 상품 삭제 |
|---|---|
| 404 Error | ComingSoon |
|---|---|
🛍️ SHOP-SHOP
├─ 📦 public
│ ├─ 🧾 _redirects
│ ├─ ⭐ favicon.ico
│ └─ 📄 index.html
├─ 📦 src
│ ├─ 📂 api
│ ├─ 📂 assets
│ │ ├─ 📂 icons
│ │ └─ 📂 images
│ ├─ 📂 auth
│ ├─ 📂 components
│ │ ├─ 📂 CartBox
│ │ ├─ 📂 common
│ │ │ ├─ 📂 Button
│ │ │ ├─ 📂 Footer
│ │ │ ├─ 📂 Header
│ │ │ ├─ 📂 Loading
│ │ │ ├─ 📂 MetaTag
│ │ │ └─ 📂 Modal
│ │ ├─ 📂 DropDown
│ │ ├─ 📂 Login
│ │ ├─ 📂 PaymentBox
│ │ ├─ 📂 ProductDetail
│ │ ├─ 📂 ProductList
│ │ ├─ 📂 SellerItem
│ │ ├─ 📂 SignUp
│ │ └─ 📂 Slider
│ ├─ 📂 pages
│ │ ├─ 📂 CartPage
│ │ ├─ 📂 ErrorPage
│ │ ├─ 📂 HomePage
│ │ ├─ 📂 LoginPage
│ │ ├─ 📂 PaymentPage
│ │ ├─ 📂 ProductPage
│ │ ├─ 📂 ProductUploadPage
│ │ ├─ 📂 SearchPage
│ │ ├─ 📂 SellerCenterPage
│ │ └─ 📂 SignUpPage
│ ├─ 📂 redux
│ │ ├─ 📂 Slices
│ │ └─ 📜 store.js
│ ├─ 📂 routes
│ ├─ 📂 services
│ ├─ 📂 styles
| ├─ 📜 App.js
| ├─ 📜 axios.js
| └─ 📜 index.js

제공된 API 사용
"@reduxjs/toolkit": "^1.9.6",
"axios": "^1.5.0",
"react": "^18.2.0",
"react-cookie": "^6.1.1",
"react-daum-postcode": "^3.1.3",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.4",
"react-hook-form": "^7.46.2",
"react-loading-skeleton": "^3.3.1",
"react-redux": "^8.1.3",
"react-router-dom": "^6.16.0",
"react-scripts": "5.0.1",
"react-slick": "^0.29.0",
"react-spinners": "^0.13.8",
"slick-carousel": "^1.8.1",
"styled-components": "^5.3.11",
"styled-normalize": "^8.0.7",
처음에 리덕스로 장바구니와 상품 관련 상태를 만들었는데, 액션 타입 정의와 액션 유형마다 생성자 함수를 만들고, 각 리듀서는 해당 액션 유형에 따라 상태를 업데이트까지... 확실히 복잡하고 거쳐야하는 단계가 많았다.
그래서 딱 한 걸음 더 나아가보자는 생각으로 프로젝트 중후반에 리덕스 툴킷으로 전환했다.
리덕스 툴킷으로 전환하면서 명확한 이점은 리덕스의 보일러 플레이트 코드를 줄여주고 상태를 업데이트할 때 기존 상태를 변경하지 않고 새로운 상태 객체를 반환해야하는데, 리덕스 툴킷은 내부적으로 Immer 라이브러리를 사용해 불변성을 유지하기 때문에 더 직관적으로 상태를 업데이트할 수 있다는 것이다!
아래는 변경 전과 후 코드다.
// ActionTypes.js
export const SET_PRODUCTS = 'SET_PRODUCTS';
export const GET_PRODUCTS = 'GET_PRODUCTS';
export const SET_CARTS = 'SET_CARTS';
// Actions.js
export const setProducts = (products) => ({
type: SET_PRODUCTS,
payload: products,
});
export const getProducts = (products) => ({
type: GET_PRODUCTS,
payload: products,
});
export const setCarts = (carts) => ({
type: SET_CARTS,
payload: carts,
});
// Reducers.js
const initialState = {
products: [],
carts: [],
};
export const productsReducer = (state = initialState, action) => {
switch (action.type) {
case SET_PRODUCTS:
return {
...state,
products: action.payload,
};
default:
return state;
}
};
export const productDetailReducer = (state = initialState, action) => {
switch (action.type) {
case GET_PRODUCTS:
return {
...state,
products: action.payload,
};
default:
return state;
}
};
export const cartsReducer = (state = initialState, action) => {
switch (action.type) {
case SET_CARTS:
return {
...state,
carts: action.payload,
};
default:
return state;
}
};
// slces.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
products: [],
carts: [],
};
export const productSlice = createSlice({
name: 'products',
initialState,
reducers: {
setProducts: (state, action) => {
state.products = action.payload;
},
},
});
export const { setProducts } = productSlice.actions;
export const productDetailSlice = createSlice({
name: 'productsDetail',
initialState,
reducers: {
getProducts: (state, action) => {
state.products = action.payload;
},
},
});
export const { getProducts } = productDetailSlice.actions;
export const cartSlice = createSlice({
name: 'carts',
initialState,
reducers: {
setCarts: (state, action) => {
state.carts = action.payload;
},
},
});
export const { setCarts } = cartSlice.actions;
컴포넌트에서 사용할 때는 일단 useEffect 내부에서 api 파일에서 가져온 getAllProduct 함수를 호출해 서버에서 상품 목록을 가져온다.
데이터가 성공적으로 가져와지면, dispatch 를 사용해 setProducts 액션을 호출해 상태를 업데이트한다. 가져온 상품 목록은 useSelector 훅을 사용해 상태를 가져오게 된다.
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import * as S from './ProductList.style';
import { getAllProduct } from '../../api/product';
import { setProducts } from '../../redux/slices/slices';
import Loading from '../common/Loading/Loading';
import heart from '../../assets/icons/icon-heart.svg';
import heartOn from '../../assets/icons/icon-heart-on.svg';
const ProductList = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [likedProduct, setLikedProduct] = useState({});
useEffect(() => {
getAllProduct()
.then((response) => {
dispatch(setProducts(response));
setLoading(false);
})
.catch((error) => {
console.error('데이터 불러오는 중 에러 발생', error);
setLoading(false);
});
}, [dispatch]);
const products = useSelector((state) => state.products.products);
const toggleLike = (productId) => {
setLikedProduct((prevLikedProduct) => ({
...prevLikedProduct,
[productId]: !prevLikedProduct[productId],
}));
};
return (
<S.Container>
{loading && <Loading />}
<S.ProductList>
{products &&
products.map((item) => (
<S.ProductItem
key={item.product_id}
onClick={() => {
window.scrollTo(0, 0);
navigate(`/productDetail/${item.product_id}`);
}}
>
<S.ProductImg
src={item.image}
alt={item.product_name}
loading={products.indexOf(item) < 3 ? 'eager' : 'lazy'}
/>
<S.ProductStore>{item.store_name}</S.ProductStore>
<S.ProductName className="product-name sl-ellipsis">
{item.product_name}
</S.ProductName>
<S.LikeBtn
onClick={(e) => {
e.stopPropagation();
toggleLike(item.product_id);
}}
>
{likedProduct[item.product_id] ? (
<S.LikeImg src={heartOn} alt="채워진 빨강 하트 아이콘" />
) : (
<S.LikeImg src={heart} alt="빈 하트 아이콘" />
)}
</S.LikeBtn>
<S.ProductPrice>
<strong>
{item.price.toLocaleString()}
<span>원</span>
</strong>
</S.ProductPrice>
</S.ProductItem>
))}
</S.ProductList>
</S.Container>
);
};
export default ProductList;
API 관련 cart.js, order.js, product.js, seller.js, useAuth.js 파일 등을 각각의 기능 또는 역할에 따라 분리했다. 처음에 컴포넌트에서 API 호출을 직접 처리하다가 아차차! 하면서 바로 분리작업을 들어갔다는 사실...!
왜냐하면 팀 프로젝트를 했을 때 분리해서 사용하니 확실히 코드를 특정 기능과 엔드포인트 관련된 작업을 할 때 편리했어서 이번 개인 프로젝트에서도 나눠야 한다는 호르몬이 갑자기 뿜뿜했다.
이렇게 파일을 분리하면 코드의 모듈성과 가독성을 높일 수 있으며, 특히 숍숍 오픈마켓은 구매자와 판매자가 나눠져 있기에 더 효율적으로 사용했다!
아래의 각 파일은 해당 기능과 관련된 API 요청을 처리하며, 이러한 구조를 통해 코드베이스를 관리하기 쉽고, 협업할 때도 각 파일이 어떤 역할을 수행하는지 명확히 파악할 수 있다.
또한 새로운 기능이나 엔드포인트를 추가할 때 해당 파일만 수정하면 되므로 다른 파일에 영향을 미치지 않아 코드의 일부를 변경하거나 확장할 때 발생할 수 있는 위험을 최소화할 수 있다는 이점도 있다!
아래를 예시로 사용할 때는 axios 라이브러리를 기반으로 생성한 인스턴스를 import 해서 상품 데이터를 서버로부터 가져오는 비동기 함수를 만들고, axiosInstance를 사용해 서버의 엔드포인트로 GET 요청을 한다. 그리고 try / catch 문을 사용해 예외처리 한다.
import { axiosInstance } from '../axios';
// 상품 전체 불러오기
export const getAllProduct = async () => {
try {
const res = await axiosInstance.get('products/');
return res.data.results;
} catch (error) {
return error.response;
}
};
상품 목록을 가져오는 컴포넌트에서 사용할 때 ProductList 컴포넌트 코드를 보면 위 설명과 동일하게 getAllProduct 함수를 호출해 서버에서 상품 목록을 가져오면 된다.
import { getAllProduct } from '../../api/product';
const ProductList = () => {
useEffect(() => {
getAllProduct()
.then((response) => {
dispatch(setProducts(response));
setLoading(false);
})
.catch((error) => {
console.error('데이터 불러오는 중 에러 발생', error);
setLoading(false);
});
}, [dispatch]);
return (
...
);
};
조건은 !
장바구니 즉, CartButton을 눌렀을 때, 만약 사용자가 로그인하지 않았다면 openLoginModal을 호출하고, 그게 아니면 addCart 함수를 호출해야 한다.
그리고 동일한 상품일 경우, /cart 페이지로 이동 시 이미 존재하는 상품의 변경된 수량의 업데이트가 반영되어야 하고, 다른 상품일 경우 바로 추가되어야 한다.
문제는 !
장바구니 버튼을 눌렀을 때, /cart 페이지로 이동한 뒤 동일한 상품인데도 변경된 수량의 업데이트가 반영되지 않고, 새로고침을 해야지만 업데이트가 반영되는 상황이다..... 😂😓😭
해결은 !
바로 바보같은 나의 실수~ 데헷 😉 리덕스 툴킷으로 만들어 둔 carts 장바구니를 이용해서 업데이트 해야하는데, 혼자 useState 훅으로 상태를 또 만들어서 장바구니를 업데이트 하려 했다.
아래는 바보같은 녀석의 코드 수정 전과 후이다.
// 해당 컴포넌트에서 문제의 코드를 간추려 모아보면...
// 나는 왜 장바구니 상태를 또 만들고 있는가?
const [cart, setCart] = useState([]);
// postAddCart 함수를 호출하고,
// 해당 함수가 완료되면 navigate가 실행되지만, 비동기 처리가 아님
const addCart = () => {
postAddCart(token, product_id, quantity).then(() => {
navigate('/cart');
});
};
// getCartList 함수를 호출하고,
// 해당 함수가 반환하는 Promise를 관리하지 않아서,
// 함수 내에서 어떤 작업이 이루어져도 완료 여부에 대한 처리가 없음
useEffect(() => {
getCartList(token);
}, [token]);
// 일단 필요한 모듈을 임포트하자
import { useDispatch } from 'react-redux';
import { setCarts } from '../../../redux/slices/slices';
// useDispatch 훅을 이용해 Redux의 store로부터 dispatch 함수를 얻어오자
const dispatch = useDispatch();
// addCart 함수를 비동기 처리하자
const addCart = async () => {
try {
postAddCart(token, product_id, quantity).then(() => {
navigate('/cart');
});
} catch (error) {
console.error('장바구니에 추가 중 에러 발생', error);
}
};
// getCartList 함수를 호출하고,
// 해당 함수가 반환하는 Promise가 완료될 때까지 대기하고 있으며,
// Promise가 완료되면 then 블록이 실행되어 dispatch가 호출됨
// 비동기 작업이 완료된 후에 Redux 상태를 업데이트할 수 있음
useEffect(() => {
getCartList(token).then((cartData) => {
dispatch(setCarts(cartData)); // cartData 변수에 카트 정보를 저장
});
}, [token, dispatch]);
문제는!
Netlify에 배포 후, 사이트에 들어가면 등장하는 메인 홈 페이지에서 슬라이더 이미지가 나타나기까지 로딩 시간이 너무 걸린다.
일단 이미지 크기가 커서 로딩 시간이 길어지기에 한국인이라면 끄고 다시 들어가기를 무한 반복할 것임이 분명하고, 문제는 다시 들어가도 여전히 로딩 시간이 오래 걸린다 ♾️♾️
해결은!
이미지 포맷 최적화를 해보려 한다. 현재 배너 이미지 슬라이더 포맷은 전부 SVG다. SVG는 벡터 그래픽 형식으로 해상도에 따라 확대/축소에 적합하지만, 일반적으로 파일 크기가 크고, 특히 복잡한 이미지의 경우 로딩 시간이 오래 걸릴 수 있기에 배너 이미지일 경우 JPEG 혹은 WebP를 고려하는게 맞다.
WHY? 이 또한 근거를 살펴보니, JPEG는 비트맵 이미지로 파일 크기를 줄이고 모든 브라우저에서 지원되고, WebP는 JPEG보다 높은 압축률을 제공하면서도 더 높은 품질을 유지할 수 있지만, 모든 브라우저에서 지원되지는 않으니 조심하자!
이제 파일 형식을 변환해 보자. 이름 모를 곳에서 변환하려는데 파일 크기가 너무 커서 실패했기에 https://convertio.co/kr/ 여기서 파일 형식을 변환했다.
그리고 나서 https://kraken.io/ 여기서 이미지 크기를 압축하면 끝!
import banner1 from '../../assets/images/banner1.svg';
import banner2 from '../../assets/images/banner2.svg';
import banner3 from '../../assets/images/banner3.svg';
import banner4 from '../../assets/images/banner4.svg';
import banner5 from '../../assets/images/banner5.svg';
↓ ↓ ↓
import banner1 from '../../assets/images/banner1.webp';
import banner2 from '../../assets/images/banner2.webp';
import banner3 from '../../assets/images/banner3.webp';
import banner4 from '../../assets/images/banner4.webp';
import banner5 from '../../assets/images/banner5.webp';
그 다음, 이미지 Lazy Loading 설정은 브라우저에게 해당 이미지를 레이지 로딩으로 처리하도록 알려주는데, 이 속성은 이미지가 뷰포트에 나타날 때까지 로딩을 지연시키고, 사용자 경험을 향상시켜준다고 한다.
현재 코드에서는 이미지를 useEffect 훅을 통해 로딩하고, Skeleton 컴포넌트를 사용하여 로딩 중에는 스켈레톤을 보여주고, 로딩이 완료되면 실제 이미지를 렌더링하고 있으며 이미지 슬라이더의 이미지들이 순차적으로 전환되는 상황이고, 이미지 슬라이더 자체에 자동 전환 기능이 포함되어 있다.
각 이미지에 loading="lazy" 속성을 직접 추가하려 했는데, 메인 홈 페이지 같이 첫 시작 페이지에서의 배너 이미지는 일반적으로 중요한 이미지들을 즉시 로드하고자 할 때가 많기에 첫 번째 이미지는 즉시 로드되고, 나머지 이미지들은 레이지 로딩을 통해 필요한 시점에 로드되도록 한다.
<img key={i} src={img.src} alt={img.alt} />
↓ ↓ ↓
<img key={i} src={img.src} alt={img.alt} loading={i === 0 ? 'eager' : 'lazy'} />
이제 사이트를 들어가면 로딩 거의 없이 바로 이미지 슬라이더가 나타나는 것을 알 수 있다 😇
그 외에도 현재 로딩 시에 react-loading-skeleton 을 통해 스켈레톤을 적용했는데, 이게 개발 환경에서는 유용할 수 있지만, 실제 사용자에게 서비스를 제공할 때는 성능 저하를 가져올 수 있다고 합니다.
그래서 로딩 시에는 간단한 스피너를 사용하거나, 필요한 경우에만 react-loading-skeleton을 사용하는 것을 고려해야 하는데 라이브러리를 슬라이더에서만 아주 조금 사용하고 있기에 패스한다!