Next.js로 쇼핑몰 만들기 #3 장바구니

7 분 소요

지난 시간에는 카탈로그의 두 페이지를 완성했습니다. 거기까지의 코드는 전부 Server Component 였습니다. 이번 글에서 처음으로 Client Component 가 등장합니다. 사용자가 상품을 담고, 수량을 바꾸고, 새로고침해도 그 상태가 살아남는 — 장바구니 를 만들 차례이기 때문입니다.

장바구니는 비로그인 쇼핑몰의 표준이 그렇듯 로컬에서만 유지 됩니다. 서버는 결제 직전까지 사용자의 카트를 알지 못합니다. localStorage 가 그 영속 계층을 맡습니다.

어디까지가 클라이언트인가 #

먼저 경계를 명확히 그립니다.

영역위치이유
상품 데이터ServerJSON 정적. 서버에서 읽는 게 자연스럽고 빠름
카탈로그 화면Server사용자 인터랙션 없음. SEO 친화
장바구니 상태Client (Context)사용자별로 다르고 빠르게 변함. 새로고침 시 유지 필요
장바구니 화면Client상태를 직접 렌더
“담기” 버튼Client클릭이 카트를 변경
헤더 수량 뱃지Client카트 변경에 즉시 반응

핵심 원칙은 “기본은 서버, 인터랙션이 필요한 작은 단위만 클라이언트” 입니다. 헤더 자체는 서버 컴포넌트로 두고, 그 안의 수량 뱃지만 클라이언트 컴포넌트로 떼어내는 식입니다.

CartProvider 설계 #

장바구니가 가질 데이터의 모양을 먼저 정합니다.

카트 항목 모양
{
  slug: 'minimal-tee-black',
  name: '미니멀 티셔츠 (블랙)',
  price: 24000,
  image: '/images/products/minimal-tee-black.jpg',
  quantity: 2,
}

상품 전체를 다 담을 필요는 없습니다. 체크아웃에 필요한 최소한 만 카트에 둡니다. 가격이 도중에 바뀔 가능성을 무시하면(학습 단계에서는 무시) 카트에 가격을 박아두는 게 가장 단순합니다.

src/app/components/CartProvider.js:

src/app/components/CartProvider.js
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

const CartContext = createContext(null);
const STORAGE_KEY = 'cart-v1';

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) setItems(JSON.parse(raw));
    } catch {
      // 손상된 JSON 은 무시하고 빈 카트로 시작
    }
    setHydrated(true);
  }, []);

  useEffect(() => {
    if (!hydrated) return;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
  }, [items, hydrated]);

  function addItem(product, quantity = 1) {
    setItems((prev) => {
      const found = prev.find((it) => it.slug === product.slug);
      if (found) {
        return prev.map((it) =>
          it.slug === product.slug ? { ...it, quantity: it.quantity + quantity } : it,
        );
      }
      return [
        ...prev,
        {
          slug: product.slug,
          name: product.name,
          price: product.price,
          image: product.image,
          quantity,
        },
      ];
    });
  }

  function updateQuantity(slug, quantity) {
    if (quantity <= 0) return removeItem(slug);
    setItems((prev) => prev.map((it) => (it.slug === slug ? { ...it, quantity } : it)));
  }

  function removeItem(slug) {
    setItems((prev) => prev.filter((it) => it.slug !== slug));
  }

  function clear() {
    setItems([]);
  }

  const totalQuantity = items.reduce((sum, it) => sum + it.quantity, 0);
  const totalPrice = items.reduce((sum, it) => sum + it.price * it.quantity, 0);

  const value = {
    items,
    hydrated,
    totalQuantity,
    totalPrice,
    addItem,
    updateQuantity,
    removeItem,
    clear,
  };

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart 는 CartProvider 내부에서만 호출 가능합니다');
  return ctx;
}

이 컴포넌트의 핵심은 두 개의 useEffect 입니다.

  • 첫 effect (마운트 1회): localStorage 에서 저장된 카트를 로드. 끝나면 hydrated 를 true 로.
  • 둘째 effect (items 변경 시마다): 카트를 localStorage 에 다시 저장. 단 hydrated 가 true 일 때만.

hydrated 가드를 두는 이유는 마운트 직후 첫 effect 가 실행되기 전에 둘째 effect 가 items: [] 를 localStorage 에 덮어쓰는 사고를 막기 위함입니다. 두 effect 가 모두 마운트 시 실행되더라도, hydrated 플래그를 두면 의도가 코드에 더 분명하게 남습니다.

useCart 가 Provider 외부에서 호출되면 null 컨텍스트가 반환되는데, 그대로 두면 ctx.items 같은 호출에서 모호한 에러가 납니다. 명시적으로 throw 하는 편이 디버깅을 쉽게 합니다.

Hydration 안전 패턴 #

서버 렌더 시점에는 localStorage 가 없으니 카트는 [] 입니다. 클라이언트가 마운트되면 localStorage 에서 카트를 로드하면서 화면이 바뀝니다. 그 차이가 사용자에게 깜빡임으로 보이는 게 신경 쓰일 수 있습니다.

대처 방법은 두 가지입니다.

  1. 수량 뱃지처럼 작은 요소는 그냥 hydrated 가 true 가 된 다음에만 그린다 — 첫 그림에는 뱃지가 잠시 안 보이고, 곧 정확한 숫자가 나타납니다.
  2. /cart 페이지처럼 카트가 주인공인 화면은 hydration 동안 “불러오는 중” 같은 placeholder 를 보여준다.

후자가 사용자 체감에 부드럽습니다. 실제 코드는 아래에서 적용합니다.

RootLayout 에 CartProvider 끼우기 #

장바구니 상태는 거의 모든 페이지에서 참조하므로 RootLayout 안에 Provider 를 둡니다.

src/app/layout.js (변경 부분):

src/app/layout.js
import Link from 'next/link';
import { CartProvider } from './components/CartProvider';
import CartBadge from './components/CartBadge';
import './globals.css';

export const metadata = {
  title: { default: '우리 쇼핑몰', template: '%s | 우리 쇼핑몰' },
  description: '학습용 쇼핑몰 빌드',
};

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <CartProvider>
          <header className="border-b">
            <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
              <Link href="/" className="font-bold">우리 쇼핑몰</Link>
              <nav className="flex items-center gap-4 text-sm">
                <Link href="/products">상품</Link>
                <Link href="/cart" className="relative">
                  장바구니
                  <CartBadge />
                </Link>
              </nav>
            </div>
          </header>
          {children}
        </CartProvider>
      </body>
    </html>
  );
}

'use client' 가 붙은 CartProvider 가 RootLayout 안에 들어가도 그 외부(헤더 마크업 등)는 여전히 서버에서 렌더됩니다. CartProvider 가 클라이언트 경계의 시작점일 뿐, 자식 컴포넌트가 자동으로 클라이언트가 되는 건 아닙니다. 자식이 서버 컴포넌트라면 그대로 서버에서 그려져 props 로 내려옵니다. 헤더의 정적 마크업이 그 예입니다.

CartBadge — 수량 뱃지 #

src/app/components/CartBadge.js:

src/app/components/CartBadge.js
'use client';

import { useCart } from './CartProvider';

export default function CartBadge() {
  const { totalQuantity, hydrated } = useCart();

  if (!hydrated || totalQuantity === 0) return null;

  return (
    <span className="absolute -right-3 -top-2 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
      {totalQuantity}
    </span>
  );
}

hydrated 가 false 면 뱃지를 그리지 않습니다. 서버 렌더 결과와 첫 클라이언트 렌더 결과가 모두 “뱃지 없음” 으로 일치해 hydration mismatch 가 생기지 않습니다.

AddToCartButton — “담기” 버튼 #

상세 페이지의 빈 버튼을 동작시킬 차례입니다. 페이지 자체는 서버 컴포넌트로 두고, 버튼만 클라이언트 컴포넌트로 떼어냅니다.

src/app/components/AddToCartButton.js:

src/app/components/AddToCartButton.js
'use client';

import { useState } from 'react';
import { useCart } from './CartProvider';

export default function AddToCartButton({ product }) {
  const { addItem } = useCart();
  const [justAdded, setJustAdded] = useState(false);

  function handleClick() {
    addItem(product, 1);
    setJustAdded(true);
    setTimeout(() => setJustAdded(false), 1500);
  }

  return (
    <button
      type="button"
      onClick={handleClick}
      className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
    >
      {justAdded ? '담겼습니다' : '장바구니에 담기'}
    </button>
  );
}

1.5 초 동안 “담겼습니다” 피드백을 보여주는 작은 디테일을 넣었습니다. 카트 페이지로 강제 이동시키지 않는 게 사용자 흐름에 부드럽습니다 — 다른 상품을 계속 둘러볼 수 있게.

상세 페이지(src/app/products/[slug]/page.js)에서 비활성 버튼이 있던 부분을 다음과 같이 바꿉니다.

src/app/products/[slug]/page.js — 변경 부분
import AddToCartButton from '../../components/AddToCartButton';

// ...

{isOutOfStock ? (
  <button type="button" disabled className="rounded-md bg-gray-300 px-4 py-3 text-white">
    품절
  </button>
) : (
  <AddToCartButton
    product={{
      slug: product.slug,
      name: product.name,
      price: product.price,
      image: product.image,
    }}
  />
)}

product 전체가 아니라 카트에 필요한 필드만 골라 넘깁니다. Server → Client 경계를 통과하는 props 는 직렬화 가능해야 하니, 불필요한 필드까지 넘기지 않는 습관이 좋습니다.

/cart — 장바구니 페이지 #

src/app/cart/page.js
'use client';

import Link from 'next/link';
import Image from 'next/image';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';

export default function CartPage() {
  const { items, hydrated, totalPrice, updateQuantity, removeItem } = useCart();

  if (!hydrated) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">장바구니</h1>
        <p className="mt-4 text-gray-500">불러오는 </p>
      </main>
    );
  }

  if (items.length === 0) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">장바구니</h1>
        <p className="mt-4 text-gray-500">장바구니가 비어 있습니다.</p>
        <Link
          href="/products"
          className="mt-4 inline-block rounded-md border px-4 py-2 text-sm"
        >
          상품 둘러보기
        </Link>
      </main>
    );
  }

  return (
    <main className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-2xl font-bold">장바구니</h1>

      <ul className="mt-6 divide-y border-y">
        {items.map((it) => (
          <li key={it.slug} className="flex items-center gap-4 py-4">
            <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded bg-gray-100">
              <Image src={it.image} alt={it.name} fill sizes="80px" className="object-cover" />
            </div>
            <div className="flex-1">
              <Link href={`/products/${it.slug}`} className="font-medium hover:underline">
                {it.name}
              </Link>
              <div className="mt-1 text-sm text-gray-600">{formatPrice(it.price)}</div>
            </div>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity - 1)}
                className="h-8 w-8 rounded border"
                aria-label="수량 줄이기"
              >
                
              </button>
              <span className="w-8 text-center">{it.quantity}</span>
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity + 1)}
                className="h-8 w-8 rounded border"
                aria-label="수량 늘리기"
              >
                +
              </button>
            </div>
            <div className="w-24 text-right font-semibold">
              {formatPrice(it.price * it.quantity)}
            </div>
            <button
              type="button"
              onClick={() => removeItem(it.slug)}
              className="text-sm text-gray-500 hover:text-red-500"
            >
              삭제
            </button>
          </li>
        ))}
      </ul>

      <div className="mt-6 flex items-center justify-between">
        <div className="text-lg">합계</div>
        <div className="text-2xl font-bold">{formatPrice(totalPrice)}</div>
      </div>

      <Link
        href="/checkout"
        className="mt-6 inline-block w-full rounded-md bg-black px-4 py-3 text-center text-white"
      >
        결제 진행
      </Link>
    </main>
  );
}

몇 가지 디테일:

  • hydration 동안 “불러오는 중”hydrated 가 false 인 첫 그림에서 빈 카트로 보이는 깜빡임을 막습니다.
  • 수량 0 으로 줄어들면 자동 삭제updateQuantity 함수 안에서 quantity <= 0 이면 removeItem 으로 위임. 사용자가 “−” 를 끝까지 눌렀을 때 자연스럽게 동작합니다.
  • aria-label — “+” / “−” 버튼은 시각적으로는 분명하지만 스크린리더에는 모호합니다.
  • 결제 진행 링크 — 다음 글로 이어지는 연결점입니다.

동작 확인 #

  • http://localhost:3000/products/minimal-tee-black → “장바구니에 담기” 클릭 → 헤더에 빨간 뱃지 1
  • 같은 상품을 두 번 더 담기 → 뱃지 3, /cart 에서 수량 3 으로 합쳐짐
  • 다른 상품을 추가 → /cart 에 두 줄
  • 새로고침 → 카트 그대로 유지
  • “+” / “−” 로 수량 변경 → 합계가 즉시 갱신, localStorage 도 갱신
  • 수량을 0 까지 줄이거나 “삭제” 클릭 → 항목 제거
  • 마지막 항목 삭제 → 빈 카트 안내 + 둘러보기 링크

브라우저 DevTools 의 Application 탭 → Local Storage 에서 cart-v1 키를 열어보면 카트 상태가 JSON 으로 저장된 게 보입니다.

마무리 #

이번 글에서는 클라이언트 상태와 영속성을 처음으로 다뤘습니다.

  • Server / Client 경계를 명확히 그렸다 (Provider 한 점에서만 클라이언트로 전환)
  • CartProvider 로 Context API 와 localStorage 영속화를 묶었다
  • hydration 안전 패턴을 적용했다 (hydrated 플래그)
  • 수량 뱃지,담기 버튼,장바구니 페이지가 모두 같은 상태를 공유한다
  • 새로고침해도 카트가 살아남는다

지금까지 만든 화면들은 모두 읽기 중심 이었습니다. 카트도 클라이언트 상태일 뿐 서버는 아직 카트의 존재를 모릅니다. 다음 글에서 처음으로 서버에 무언가를 만들어 보냅니다 — 주문입니다. “Next.js로 쇼핑몰 만들기 #4 체크아웃과 결제 시뮬레이션” 에서는 주문 폼을 만들고, Server Actions 로 주문을 생성하고, 가짜 결제 게이트웨이를 만들어 성공/실패 분기를 다루는 곳까지 갑니다.

X