Use Cases (업데이트: 2026. 6. 1.)

Claude Code로 이커머스 스토어 구축하기: Next.js, Stripe Checkout, 재고 관리

Claude Code로 상품, 장바구니, 재고, Stripe Checkout, Webhook, 관리자 화면, SEO와 반품 운영까지 구현하는 실전 가이드.

Claude Code로 이커머스 스토어 구축하기: Next.js, Stripe Checkout, 재고 관리

이커머스 구축에서 Claude Code에 맡길 범위

이커머스 스토어는 상품 카드와 결제 버튼만으로 완성되지 않습니다. 상품 목록, 상세 페이지, 장바구니, 재고 예약, 주문 생성, Stripe Checkout, Webhook 기반 주문 확정, 관리자 화면, SEO, 분석 이벤트, 반품과 취소 운영까지 하나의 흐름으로 연결되어야 실제 매출을 받을 수 있습니다.

Claude Code의 강점은 단순히 컴포넌트를 빨리 만드는 것이 아니라, 이런 업무 경계를 유지한 채 구현 단위로 나누는 데 있습니다. Masa가 작은 실물 상품 데모를 만들 때 처음에는 성공 페이지 도착을 주문 확정으로 처리했습니다. 하지만 결제 후 브라우저를 닫는 고객, Webhook 재전송, Checkout 만료 시 재고 복구를 고려하자 바로 문제가 드러났습니다. 그래서 프롬프트에는 “결제 확인은 Webhook을 기준으로 한다”와 “가격과 재고는 서버에서 다시 계산한다”를 명시해야 합니다.

이 글은 Next.js App Router와 TypeScript 기준입니다. 예제는 로컬에서 복사해 테스트할 수 있도록 메모리 기반 저장소를 사용합니다. 운영 환경에서는 PostgreSQL이나 Prisma 같은 영속 저장소로 바꾸세요. 결제 구현은 Stripe 공식 Checkout Sessions APICheckout fulfillment 가이드를 확인하고, Next.js는 Route HandlersMetadata API를 참고하면 됩니다.

함께 보면 좋은 글은 Claude Code로 Stripe Checkout 구현, Claude Code로 SEO 최적화, Claude Code로 대시보드 개발입니다.

전체 구조를 먼저 분리하기

주문 생성, 재고 예약, 결제 확인, 결제 후 운영을 분리하면 Claude Code에게 맡기는 작업도 명확해지고 리뷰도 쉬워집니다.

flowchart LR
  A["상품 목록과 상세"] --> B["장바구니"]
  B --> C["주문 생성 API"]
  C --> D["재고 예약"]
  D --> E["Stripe Checkout Session"]
  E --> F["Webhook"]
  F --> G["주문 확정과 출고 대기"]
  G --> H["관리자 화면"]
  H --> I["배송, 반품, 취소"]
  A --> J["SEO와 구조화 데이터"]
  B --> K["분석 이벤트"]

첫 프롬프트는 다음처럼 업무 규칙을 포함해야 합니다.

Next.js App Router와 TypeScript로 작은 이커머스 스토어를 구현하세요.
상품 목록, 장바구니, 재고 예약, Stripe Checkout Session 생성,
Webhook 기반 주문 확정, 관리자 배송/취소 작업을 분리하세요.
클라이언트가 보낸 가격과 재고를 신뢰하지 마세요.
Stripe Webhook 서명을 검증하고 checkout.session.completed와 async_payment_succeeded를 처리하세요.
success_url 방문만으로 주문을 결제 완료로 만들지 마세요.

상품, 재고, 주문 모델

아래 코드는 로컬 테스트용으로 바로 붙여 넣을 수 있습니다. 메모리 저장소이므로 운영에서는 데이터베이스로 바꿔야 합니다. 핵심은 가격, 재고, 주문 상태를 서버가 소유한다는 점입니다.

// src/lib/store.ts
export type Product = {
  id: string;
  slug: string;
  name: string;
  description: string;
  priceJPY: number;
  stock: number;
  active: boolean;
  image: string;
};

export type CartLine = {
  productId: string;
  quantity: number;
};

export type OrderLine = CartLine & {
  name: string;
  unitAmount: number;
};

export type OrderStatus = "pending" | "paid" | "shipped" | "canceled" | "refunded";

export type Order = {
  id: string;
  lines: OrderLine[];
  amountTotal: number;
  status: OrderStatus;
  reserved: boolean;
  stripeSessionId?: string;
  customerEmail?: string;
  createdAt: string;
  updatedAt: string;
};

const products: Product[] = [
  {
    id: "tea-001",
    slug: "roasted-green-tea",
    name: "호지차 선물 세트",
    description: "첫 구매자를 위한 선물 박스 세트입니다.",
    priceJPY: 3200,
    stock: 12,
    active: true,
    image: "/images/products/tea.jpg",
  },
  {
    id: "mug-001",
    slug: "ceramic-mug",
    name: "수제 세라믹 머그",
    description: "소량 생산 머그입니다. 반품 입고 전 파손 여부를 확인해야 합니다.",
    priceJPY: 4800,
    stock: 6,
    active: true,
    image: "/images/products/mug.jpg",
  },
];

const stock = new Map<string, number>(products.map((product) => [product.id, product.stock]));
const orders = new Map<string, Order>();

export function listProducts(): Product[] {
  return products
    .filter((product) => product.active)
    .map((product) => ({ ...product, stock: stock.get(product.id) ?? 0 }));
}

export function getProduct(productIdOrSlug: string): Product | undefined {
  return listProducts().find(
    (product) => product.id === productIdOrSlug || product.slug === productIdOrSlug,
  );
}

function normalizeLines(lines: CartLine[]): CartLine[] {
  const merged = new Map<string, number>();

  for (const line of lines) {
    if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 20) {
      throw new Error("수량은 1에서 20 사이여야 합니다.");
    }
    merged.set(line.productId, (merged.get(line.productId) ?? 0) + line.quantity);
  }

  return Array.from(merged, ([productId, quantity]) => ({ productId, quantity }));
}

function requireOrder(orderId: string): Order {
  const order = orders.get(orderId);
  if (!order) throw new Error("주문을 찾을 수 없습니다.");
  return order;
}

export function createPendingOrder(lines: CartLine[]): Order {
  const normalized = normalizeLines(lines);
  const orderLines = normalized.map((line) => {
    const product = getProduct(line.productId);
    if (!product) throw new Error(`상품을 찾을 수 없습니다: ${line.productId}`);

    const availableStock = stock.get(product.id) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${product.name}의 재고가 부족합니다.`);
    }

    return {
      productId: product.id,
      quantity: line.quantity,
      name: product.name,
      unitAmount: product.priceJPY,
    };
  });

  const now = new Date().toISOString();
  const order: Order = {
    id: crypto.randomUUID(),
    lines: orderLines,
    amountTotal: orderLines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0),
    status: "pending",
    reserved: false,
    createdAt: now,
    updatedAt: now,
  };

  orders.set(order.id, order);
  return order;
}

export function reserveOrderStock(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.reserved) return order;

  for (const line of order.lines) {
    const availableStock = stock.get(line.productId) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${line.name}의 재고가 부족합니다.`);
    }
  }

  for (const line of order.lines) {
    stock.set(line.productId, (stock.get(line.productId) ?? 0) - line.quantity);
  }

  order.reserved = true;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function attachStripeSession(orderId: string, stripeSessionId: string): Order {
  const order = requireOrder(orderId);
  order.stripeSessionId = stripeSessionId;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function fulfillPaidOrder(input: {
  orderId: string;
  stripeSessionId: string;
  customerEmail?: string;
}): Order {
  const order = requireOrder(input.orderId);
  if (order.status === "paid" || order.status === "shipped") return order;

  if (!order.reserved) reserveOrderStock(order.id);

  order.status = "paid";
  order.stripeSessionId = input.stripeSessionId;
  order.customerEmail = input.customerEmail;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function markOrderShipped(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid") throw new Error("결제 완료 주문만 배송 처리할 수 있습니다.");
  order.status = "shipped";
  order.updatedAt = new Date().toISOString();
  return order;
}

export function cancelOrder(orderId: string, reason = "customer_canceled"): Order {
  const order = requireOrder(orderId);
  if (order.status === "canceled" || order.status === "refunded") return order;

  if (order.status === "pending" && order.reserved) {
    for (const line of order.lines) {
      stock.set(line.productId, (stock.get(line.productId) ?? 0) + line.quantity);
    }
    order.reserved = false;
  }

  order.status = "canceled";
  order.updatedAt = new Date().toISOString();
  console.info(`Order ${order.id} canceled: ${reason}`);
  return order;
}

export function markOrderRefunded(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid" && order.status !== "shipped") {
    throw new Error("결제 완료 또는 배송 완료 주문만 환불 처리할 수 있습니다.");
  }
  order.status = "refunded";
  order.updatedAt = new Date().toISOString();
  return order;
}

export function listOrders(): Order[] {
  return Array.from(orders.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

Claude Code에게 이 모듈을 리뷰시킬 때는 수량 검증, 재고 부족, 주문 확정의 중복 실행 방지, 미결제 주문 취소 시 재고 복구를 집중적으로 보게 합니다.

상품 목록과 장바구니

클라이언트 장바구니는 사용자 경험을 위해 필요하지만, 최종 금액을 결정하면 안 됩니다. 서버에는 상품ID와 수량만 보냅니다.

// src/components/product-grid-with-cart.tsx
"use client";

import { useMemo, useState } from "react";
import type { CartLine, Product } from "@/lib/store";

type CheckoutResponse = {
  url?: string;
  error?: string;
};

export function ProductGridWithCart({ products }: { products: Product[] }) {
  const [cart, setCart] = useState<CartLine[]>([]);
  const [loading, setLoading] = useState(false);

  const subtotal = useMemo(() => {
    return cart.reduce((sum, line) => {
      const product = products.find((item) => item.id === line.productId);
      return sum + (product?.priceJPY ?? 0) * line.quantity;
    }, 0);
  }, [cart, products]);

  function addToCart(productId: string) {
    setCart((current) => {
      const existing = current.find((line) => line.productId === productId);
      if (existing) {
        return current.map((line) =>
          line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line,
        );
      }
      return [...current, { productId, quantity: 1 }];
    });
  }

  async function checkout() {
    try {
      setLoading(true);
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ lines: cart }),
      });
      const data = (await response.json()) as CheckoutResponse;

      if (!response.ok || !data.url) {
        throw new Error(data.error ?? "Checkout을 시작할 수 없습니다.");
      }

      window.location.href = data.url;
    } catch (error) {
      alert(error instanceof Error ? error.message : "Checkout에 실패했습니다.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="grid gap-8 lg:grid-cols-[1fr_320px]">
      <div className="grid gap-6 sm:grid-cols-2">
        {products.map((product) => (
          <article key={product.id} className="rounded-lg border p-4">
            <img src={product.image} alt={product.name} className="aspect-square w-full object-cover" />
            <h2 className="mt-3 text-lg font-semibold">{product.name}</h2>
            <p className="mt-1 text-sm text-gray-600">{product.description}</p>
            <p className="mt-3 font-bold">JPY {product.priceJPY.toLocaleString()}</p>
            <p className="text-sm text-gray-500">재고: {product.stock}</p>
            <button type="button" disabled={product.stock < 1} onClick={() => addToCart(product.id)} className="mt-4 w-full rounded bg-black px-4 py-2 text-white disabled:bg-gray-300">
              장바구니에 담기
            </button>
          </article>
        ))}
      </div>

      <aside className="h-fit rounded-lg border p-4">
        <h2 className="text-lg font-semibold">장바구니</h2>
        {cart.length === 0 ? (
          <p className="mt-3 text-sm text-gray-500">장바구니가 비어 있습니다.</p>
        ) : (
          <ul className="mt-3 space-y-2">
            {cart.map((line) => {
              const product = products.find((item) => item.id === line.productId);
              return (
                <li key={line.productId} className="flex justify-between text-sm">
                  <span>{product?.name}</span>
                  <span>{line.quantity}개</span>
                </li>
              );
            })}
          </ul>
        )}
        <p className="mt-4 font-bold">소계: JPY {subtotal.toLocaleString()}</p>
        <button type="button" disabled={cart.length === 0 || loading} onClick={checkout} className="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300">
          {loading ? "이동 중..." : "Stripe Checkout으로 이동"}
        </button>
      </aside>
    </div>
  );
}

Stripe Checkout Session 생성

Checkout으로 보내기 전에 주문을 만들고 재고를 예약합니다. Checkout Session에는 서버에서 계산한 금액만 전달하고, 내부 주문ID는metadata에 넣습니다. 주소나 카드 정보 같은 민감한 개인정보는 metadata에 넣지 않습니다.

// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import {
  attachStripeSession,
  createPendingOrder,
  getProduct,
  reserveOrderStock,
} from "@/lib/store";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  try {
    const { lines } = (await request.json()) as {
      lines: { productId: string; quantity: number }[];
    };

    const order = createPendingOrder(lines);
    reserveOrderStock(order.id);

    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: order.lines.map((line) => {
        const product = getProduct(line.productId);
        if (!product) throw new Error(`상품을 찾을 수 없습니다: ${line.productId}`);

        return {
          price_data: {
            currency: "jpy",
            product_data: {
              name: product.name,
              images: [`${process.env.NEXT_PUBLIC_APP_URL}${product.image}`],
              metadata: { productId: product.id },
            },
            unit_amount: product.priceJPY,
          },
          quantity: line.quantity,
        };
      }),
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?canceled=1`,
      shipping_address_collection: {
        allowed_countries: ["JP"],
      },
      metadata: {
        orderId: order.id,
      },
    });

    attachStripeSession(order.id, session.id);
    return NextResponse.json({ url: session.url }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Checkout에 실패했습니다." },
      { status: 400 },
    );
  }
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

가장 흔한 실수는 브라우저의 소계로unit_amount를 만드는 것입니다. 금액은 반드시 서버 상품 데이터에서 다시 계산해야 합니다. 또한 Checkout을 시작했지만 결제하지 않는 고객이 있으므로 만료된 세션의 재고를 복구해야 합니다.

Webhook으로 주문 확정하기

성공 페이지는 주문 확정 근거가 아닙니다. 고객이 결제 후 페이지로 돌아오지 않을 수 있기 때문에 Webhook이 기준이어야 합니다. 같은 이벤트가 여러 번 와도 중복 배송이나 중복 메일이 발생하지 않게 만듭니다.

// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { cancelOrder, fulfillPaidOrder } from "@/lib/store";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Invalid webhook" },
      { status: 400 },
    );
  }

  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;

    if (orderId && session.payment_status === "paid") {
      fulfillPaidOrder({
        orderId,
        stripeSessionId: session.id,
        customerEmail: session.customer_details?.email ?? undefined,
      });
    }
  }

  if (
    event.type === "checkout.session.expired" ||
    event.type === "checkout.session.async_payment_failed"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;
    if (orderId) cancelOrder(orderId, event.type);
  }

  return NextResponse.json({ received: true });
}
stripe listen --forward-to localhost:3000/api/stripe/webhook

리뷰할 때는 서명 검증,payment_status, 지연 결제의 성공과 실패, 재고 복구, 멱등성을 확인합니다. 멱등성은 같은 작업이 여러 번 실행되어도 결과가 망가지지 않는 성질입니다.

관리자 화면, 반품, 취소 운영

관리자 화면은 단순 조회가 아니라 운영 도구입니다. 결제 완료 주문을 배송 처리하고, 미결제 주문을 취소하고, 환불 주문을 구분해야 합니다. 운영 전에는 인증과 권한도 반드시 추가하세요.

// src/app/admin/orders/page.tsx
import { cancelOrder, listOrders, markOrderShipped } from "@/lib/store";

async function shipOrder(formData: FormData) {
  "use server";
  markOrderShipped(String(formData.get("orderId")));
}

async function cancelPendingOrder(formData: FormData) {
  "use server";
  cancelOrder(String(formData.get("orderId")), "admin_canceled");
}

export default function AdminOrdersPage() {
  const orders = listOrders();

  return (
    <main className="mx-auto max-w-5xl p-6">
      <h1 className="text-2xl font-bold">주문 관리</h1>
      <div className="mt-6 overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <thead>
            <tr className="border-b text-left">
              <th className="py-2">주문ID</th>
              <th className="py-2">상태</th>
              <th className="py-2">금액</th>
              <th className="py-2">이메일</th>
              <th className="py-2">작업</th>
            </tr>
          </thead>
          <tbody>
            {orders.map((order) => (
              <tr key={order.id} className="border-b">
                <td className="py-3 font-mono text-xs">{order.id}</td>
                <td className="py-3">{order.status}</td>
                <td className="py-3">JPY {order.amountTotal.toLocaleString()}</td>
                <td className="py-3">{order.customerEmail ?? "-"}</td>
                <td className="flex gap-2 py-3">
                  <form action={shipOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    <button type="submit" disabled={order.status !== "paid"} className="rounded bg-black px-3 py-1 text-white disabled:bg-gray-300">
                      배송 처리
                    </button>
                  </form>
                  <form action={cancelPendingOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    <button type="submit" disabled={order.status !== "pending"} className="rounded border px-3 py-1 disabled:text-gray-300">
                      취소
                    </button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

반품 정책은 자동화보다 먼저 정해야 합니다. 배송 전 취소는 재고를 되돌릴 수 있지만, 배송 후 반품은 상품 상태를 확인한 뒤 재판매, 할인 판매, 폐기 중 하나로 나눠야 합니다. 환불을 Stripe Dashboard에서 수동으로 처리하더라도 주문 상태에는 반드시 반영하세요.

SEO와 분석 이벤트

상품 페이지는 검색 유입의入口입니다. 상품명, 용도, 소재, 배송 조건, 반품 조건, OGP 이미지, canonical을 넣습니다. 분석 이벤트는 상품 조회, 장바구니 추가, Checkout 시작, 구매 완료를 같은 명명 규칙으로 보냅니다.

// src/app/products/[slug]/metadata.ts
import type { Metadata } from "next";
import { getProduct } from "@/lib/store";

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const product = getProduct(params.slug);

  if (!product) {
    return {
      title: "상품을 찾을 수 없습니다",
      robots: { index: false, follow: false },
    };
  }

  const title = `${product.name} | ClaudeCodeLab Store`;
  const description = `${product.description} 가격은 JPY ${product.priceJPY.toLocaleString()}입니다. 구매 전 재고, 배송, 반품 조건을 확인하세요.`;

  return {
    title,
    description,
    alternates: {
      canonical: `/products/${product.slug}`,
    },
    openGraph: {
      title,
      description,
      images: [product.image],
      type: "website",
    },
  };
}
// src/lib/analytics.ts
type CommerceEventName = "view_item" | "add_to_cart" | "begin_checkout" | "purchase";

type CommercePayload = {
  currency: "JPY";
  value: number;
  items: Array<{
    item_id: string;
    item_name: string;
    price: number;
    quantity: number;
  }>;
};

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

export function trackCommerceEvent(name: CommerceEventName, payload: CommercePayload) {
  if (typeof window === "undefined") return;
  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push({
    event: name,
    ecommerce: payload,
  });
}

세 가지 실제 사용 사례

D2C 브랜드의 첫 판매에서는 고급 검색보다 재고 예약, Checkout, Webhook, 배송 관리가 우선입니다. 캠페인 유입으로 동시에 구매가 발생할 수 있으므로 서버 재고 제어가 중요합니다.

디지털 강의나 PDF 판매는 배송이 없지만 권한 부여가 필요합니다. 성공 페이지만으로 다운로드 링크를 노출하지 말고, Webhook으로 결제 확인 후 다운로드 권한을 줍니다.

B2B 소량 주문 사이트는 견적과 승인 흐름이 필요할 수 있습니다. Stripe Checkout은 카드 결제용으로 남겨두고, 관리자 화면에 견적 중, 청구서 발송, 입금 확인 같은 상태를 추가합니다.

사용 사례우선 구현주의점
D2C 첫 판매재고 예약, Checkout, 배송 관리캠페인 중 동시 구매
디지털 상품Webhook 후 권한 부여성공 페이지만 믿지 않기
B2B 주문관리자, 견적, 상태 관리사람의 확인이 남을 수 있음

실패 사례와 함정

가장 흔한 실패는success_url에 돌아오면 결제 완료로 처리하는 것입니다. 고객이 결제 후 사이트로 돌아오지 않을 수 있으므로 Webhook을 기준으로 삼아야 합니다.

두 번째는 장바구니 금액을 신뢰하는 것입니다. 브라우저의 JavaScript는 수정될 수 있습니다. API는 가격, 할인, 배송비, 재고를 서버에서 다시 계산해야 합니다.

재고도 함정입니다. Checkout 시작 시 재고를 잡으면 미결제 주문이 재고를 막고, 결제 후에만 차감하면 동시에 팔려 초과 판매가 날 수 있습니다. 짧은 예약, 만료 시 복구, 관리자 수동 조정을 조합해야 합니다.

보안에서는 Stripe secret 노출, Webhook 서명 미검증, 인증 없는 관리자 화면, metadata에 개인정보 저장이 위험합니다. Claude Code에 맡기기 전에 권한과 비밀정보 경계를 프롬프트에 적으세요.

Claude Code 리뷰 프롬프트

이 이커머스 구현을 리뷰하세요.
가격 변조, 재고 중복 예약, Webhook 재전송 시 중복履行,
미결제 주문 배송, 취소 시 재고 복구, 관리자 권한,
Stripe secret과 webhook secret 노출, metadata의 개인정보 포함 여부를 확인하세요.
문제마다 파일명, 함수명, 재현 절차, 수정안을 함께 제시하세요.

사람이 직접 확인할 항목은 테스트 카드 결제 성공, 인증 실패, Checkout 만료, Webhook 재생, 배송 버튼 두 번 클릭, 환불 상태 표시입니다.

정리

Claude Code로 이커머스 스토어를 만들 때는 상품 목록과 UI만이 아니라 주문, 재고, 결제, Webhook, 관리자 화면, SEO, 분석, 반품과 취소 운영을 하나의 흐름으로 설계해야 합니다. 운영 품질을 가르는 기준은 Webhook으로 결제 확정하기, 서버에서 가격과 재고 다시 계산하기, 반복 이벤트에도 안전하기입니다.

ClaudeCodeLab은 Claude Code 기반 이커머스 프로토타입, Stripe Checkout 연동, 관리자 화면, SEO 개선, 팀 교육을 지원합니다. 현재 앱, 상품 규칙, 운영 제약을 정리해 오면 구현 범위와 우선순위를 구체화할 수 있습니다.

이 글의 코드를 실제로 시험할 때는stripe listen이 이벤트를 받는지, 테스트 카드 결제 후checkout.session.completed가 오는지, 성공 페이지를 닫아도 주문이 결제 완료가 되는지, 재고 부족이면 Checkout Session이 만들어지지 않는지, 관리자 배송/취소 버튼이 올바른 상태에서만 활성화되는지 확인하세요.

#Claude Code #이커머스 #Stripe #Next.js #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.