TIL/[TIL]

[TIL]Mini Cart 프로젝트, json-server, API Layer 분리, Zustand persist

namerong 2026. 4. 28. 14:17

1. 오늘 학습 주제

장바구니 프로젝트를 진행하면서, 실제 서비스 구조와 비슷하게 데이터를 다루는 흐름을 학습했다.
특히 json-server로 상품 데이터를 구성하고, API 요청 로직을 별도 파일로 분리했으며, Zustand의 persist 미들웨어를 사용해 장바구니 상태를 유지하는 구조를 익혔다.

  • data.json으로 상품 데이터 구성하기
  • json-server를 통해 상품 목록 API 연결하기
  • src/api/products.js로 API Layer 분리하기
  • Zustand store로 장바구니 전역 상태 관리하기
  • persist 미들웨어로 새로고침 후에도 cart 유지하기
  • 상품 목록 페이지와 장바구니 페이지의 데이터 흐름 이해하기

2. json-server와 data.json

실제 백엔드 없이도 상품 데이터를 불러오기 위해 json-server를 사용하는 구조를 학습했다.
data.json 파일은 가짜 데이터베이스 역할을 하며, JSON 구조를 기반으로 REST API처럼 동작한다.

{
  "products": [
    {
      "id": 1,
      "name": "베이직 티셔츠",
      "price": 19900,
      "image": "tshirt.jpg"
    },
    {
      "id": 2,
      "name": "와이드 데님 팬츠",
      "price": 45900,
      "image": "denim.jpg"
    }
  ]
}
  • data.json은 가짜 데이터 저장소 역할을 한다.
  • products 키가 하나의 리소스가 된다.
  • 프론트엔드에서는 실제 API처럼 요청을 보내 데이터를 가져올 수 있다.

3. API Layer 분리

처음에는 page.js 안에서 직접 fetch를 사용했지만, 이후 API 요청 로직을 별도 파일로 분리하는 구조를 적용했다.
src/api/products.js에서 상품 목록 요청을 관리했다.

export async function fetchProducts() {
  const response = await fetch("http://localhost:3001/products", {
    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error("상품 데이터를 불러오지 못했습니다.");
  }

  return response.json();
}
  • API 요청 로직을 별도 파일로 분리하면 유지보수가 쉬워진다.
  • 페이지 컴포넌트는 화면 렌더링에 더 집중할 수 있다.
  • 나중에 백엔드 주소가 바뀌어도 API 파일만 수정하면 된다.

4. 상품 목록 조회

메인 페이지에서는 fetchProducts()를 호출해 상품 목록을 받아오고, ProductList를 통해 화면에 렌더링했다.

import { fetchProducts } from "@/api/products";

export default async function Home() {
  const products = await fetchProducts();

  return <ProductList products={products} />;
}
  • 상품 목록 데이터는 API Layer를 통해 가져온다.
  • 메인 페이지는 가져온 데이터를 컴포넌트에 전달하는 역할을 한다.
  • 데이터 요청과 UI 렌더링의 역할을 나누는 것이 중요하다.

5. 장바구니 전역 상태 관리

장바구니는 여러 페이지에서 같은 상태를 공유해야 하므로 Zustand를 사용했다.
useCartStore를 만들어 cart 상태와 관련 함수들을 한 곳에서 관리했다.

export const useCartStore = create(
  persist(
    (set, get) => ({
      cart: [],
      addToCart: (product) => {},
      removeFromCart: (productId) => {},
      increaseQty: (productId) => {},
      decreaseQty: (productId) => {},
      clearCart: () => set({ cart: [] }),
      getTotalItems: () => {},
      getTotalPrice: () => {},
    }),
    {
      name: "cart-storage",
    }
  )
);
  • 장바구니 상태는 Zustand store에서 전역으로 관리한다.
  • 상품 추가, 삭제, 수량 변경 로직을 store 안에 모아둘 수 있다.
  • 여러 컴포넌트에서 같은 상태를 쉽게 공유할 수 있다.

6. addToCart 동작 방식

상품을 장바구니에 담을 때는 무조건 새로 추가하는 것이 아니라, 이미 같은 상품이 있으면 수량만 증가시키도록 구현했다.

const existingItem = state.cart.find((item) => item.id === product.id);
  • 같은 상품이 이미 있으면 quantity를 증가시킨다.
  • 없는 상품이면 새 항목으로 추가한다.
  • 장바구니에서 중복 상품이 여러 줄 생기지 않도록 처리할 수 있다.

7. 수량 증가, 감소, 삭제

장바구니 페이지에서는 각 상품별로 수량 증가, 수량 감소, 삭제 기능을 구현했다.

increaseQty: (productId) =>
  set((state) => ({
    cart: state.cart.map((item) =>
      item.id === productId
        ? { ...item, quantity: item.quantity + 1 }
        : item
    ),
  }))
decreaseQty: (productId) =>
  set((state) => ({
    cart: state.cart.flatMap((item) => {
      if (item.id !== productId) return [item];
      if (item.quantity === 1) return [];
      return [{ ...item, quantity: item.quantity - 1 }];
    }),
  }))
  • 수량 증가는 해당 상품의 quantity를 1 증가시킨다.
  • 수량 감소는 1보다 클 때는 감소, 1일 때는 삭제 처리한다.
  • 상품 삭제는 filter()를 사용해 장바구니에서 제거한다.

8. 총 상품 수와 총 결제 금액 계산

장바구니에는 단순 목록만 필요한 것이 아니라, 총 수량과 총 금액 같은 파생값도 필요하다.
이 계산을 컴포넌트가 아니라 store에서 처리하도록 구성했다.

getTotalItems: () =>
  get().cart.reduce((total, item) => total + item.quantity, 0),
getTotalPrice: () =>
  get().cart.reduce(
    (total, item) => total + item.price * item.quantity,
    0
  ),
  • 총 수량은 각 상품의 quantity 합으로 계산한다.
  • 총 금액은 price * quantity의 합으로 계산한다.
  • 계산 로직을 store에 두면 여러 컴포넌트에서 재사용하기 쉽다.

9. Zustand persist 미들웨어

Zustand의 persist 미들웨어를 사용해 장바구니 상태를 브라우저 저장소에 유지하는 방법도 학습했다.

import { persist } from "zustand/middleware";
persist(
  (set, get) => ({
    cart: [],
  }),
  {
    name: "cart-storage",
  }
)
  • persist는 Zustand 상태를 localStorage에 저장하는 기능이다.
  • 페이지를 새로고침해도 cart 상태를 복원할 수 있다.
  • 장바구니, 테마, 로그인 유지 같은 기능에 유용하다.

10. persist의 동작 방식

cart 상태가 바뀔 때마다 브라우저 저장소에도 함께 반영되도록 설정했다.
저장 키 이름은 cart-storage로 지정했다.

  • 저장소에 어떤 이름으로 저장할지 직접 정할 수 있다.
  • 상태가 바뀌면 브라우저 저장소에도 반영된다.
  • 다음에 다시 페이지를 열어도 이전 cart 상태를 유지할 수 있다.

11. 장바구니 페이지 구성

장바구니 페이지는 크게 두 부분으로 나뉜다.

  • 상품 목록 영역
  • Summary 영역

CartItem에서는 상품명, 가격, 수량, 소계를 보여주고 수량 증가/감소, 삭제 기능을 연결했다.
CartSummary에서는 총 수량, 총 금액, 결제하기 버튼을 보여주도록 구성했다.

  • CartItem은 개별 상품 단위의 UI와 동작을 담당한다.
  • CartSummary는 전체 합계 정보를 보여준다.
  • 기능별로 컴포넌트를 분리하면 역할이 더 명확해진다.

12. 결제하기 버튼과 홈 이동

장바구니 페이지에 홈으로 돌아가기 버튼을 추가하고, 결제하기 버튼을 누르면 총 결제 금액이 alert로 뜨도록 구현했다.

window.alert(`총 결제 금액은 ${totalPrice.toLocaleString("ko-KR")}원입니다.`);
  • 홈 버튼으로 메인 페이지와 장바구니 페이지 이동 흐름을 만들 수 있다.
  • 결제 버튼은 현재 총 금액이 정상적으로 계산되는지 확인하는 테스트 역할도 한다.
  • 기능 구현 후 사용자 동작 흐름까지 점검하는 것이 중요하다.

13. 오늘 학습 흐름 정리

13.1 서버 데이터 연결

  • data.json으로 상품 데이터 준비
  • json-server로 가짜 API 구성
  • src/api/products.js로 요청 함수 분리
  • 메인 페이지에서 상품 목록 조회

13.2 클라이언트 상태 관리

  • Zustand store로 cart 상태 관리
  • persist 미들웨어로 상태 유지
  • /carts 페이지에서 상품 목록, 수량 조절, 삭제, 합계 계산 구현

14. 오늘 배운 핵심 정리

  1. data.json은 json-server에서 가짜 데이터베이스 역할을 한다.
  2. 상품 데이터는 http://localhost:3001/products로 조회할 수 있다.
  3. API 요청 로직은 src/api/products.js처럼 별도 파일로 분리하면 좋다.
  4. 메인 페이지는 API 호출 함수만 가져와 데이터를 렌더링하는 역할을 한다.
  5. 장바구니 상태는 Zustand store인 useCartStore에서 전역으로 관리한다.
  6. addToCart는 같은 상품이 있으면 새로 추가하지 않고 수량만 증가시킨다.
  7. increaseQty, decreaseQty, removeFromCart로 장바구니 조작이 가능하다.
  8. 총 수량과 총 결제 금액은 store에서 계산하는 파생값이다.
  9. persist 미들웨어를 사용하면 cart 상태를 localStorage에 저장할 수 있다.
  10. 새로고침 후에도 장바구니 상태가 유지되는 이유는 persist 덕분이다.
  11. 기능 구현뿐 아니라 API Layer 분리와 데이터 흐름 설계가 중요하다는 점을 배웠다.