LLM/VibeCoding

토이 프로젝트에 적용하는 Lemon Squeezy API 활용 가이드

Joonfluence 2025. 6. 30.

토이 프로젝트에 적용하는 Lemon Squeezy API 활용 가이드

개요

저는 현재 토이 프로젝트로 "핏픽(FitPick)"이라는 프로젝트를 진행 중입니다. 해당 프로젝트는 Next.js를 활용한 풀스택 프로젝트이며, Lemon Squeezy API를 활용하여 상품 관리, 장바구니, 결제 기능을 제공합니다. 오늘은 Lemon Squeezy를 활용하여, 어떻게 실제 적용 가능한지 살펴보겠습니다.

먼저, Lemon Squeezy는 디지털 제품 판매에 특화된 플랫폼으로, 백엔드 서버 없이도 완전한 이커머스 기능을 구현할 수 있습니다.

1. 프로젝트 설정

1.1 환경 변수 설정

# .env.local
LEMON_SQUEEZY_API_KEY=your_api_key_here
LEMON_SQUEEZY_STORE_ID=your_store_id_here
LEMON_SQUEEZY_WEBHOOK_SECRET=your_webhook_secret_here
NEXT_PUBLIC_APP_URL=http://localhost:3000

1.2 패키지 설치

npm install @lemonsqueezy/lemonsqueezy.js
# 또는
pnpm add @lemonsqueezy/lemonsqueezy.js

2. Lemon Squeezy SDK 설정

2.1 설정 파일 생성

// lib/lemonsqueezy.ts
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';

export function configureLemonSqueezy() {
  const requiredVars = [
    'LEMON_SQUEEZY_API_KEY',
    'LEMON_SQUEEZY_STORE_ID',
    'LEMON_SQUEEZY_WEBHOOK_SECRET',
  ];

  const missingVars = requiredVars.filter((varName) => !process.env[varName]);

  if (missingVars.length > 0) {
    throw new Error(
      `Missing required LEMON_SQUEEZY env variables: ${missingVars.join(
        ', '
      )}. Please set them in your .env file.`
    );
  }

  lemonSqueezySetup({ 
    apiKey: process.env.LEMON_SQUEEZY_API_KEY!,
    onError: (error) => console.error('Lemon Squeezy Error:', error),
  });
}

3. 상품 관리 (Products & Variants)

3.1 상품 목록 조회

// lib/products.ts
import { 
  listProducts, 
  listVariants, 
  getProduct, 
  getVariant 
} from '@lemonsqueezy/lemonsqueezy.js';
import { configureLemonSqueezy } from './lemonsqueezy';

export interface FitPickProduct {
  id: string;
  name: string;
  description: string;
  price: string;
  category: 'men' | 'women' | 'unisex';
  images: string[];
  variants: FitPickVariant[];
}

export interface FitPickVariant {
  id: string;
  name: string;
  price: string;
  size?: string;
  color?: string;
}

export async function getAllProducts(): Promise<FitPickProduct[]> {
  configureLemonSqueezy();

  try {
    const products = await listProducts({
      filter: { storeId: process.env.LEMON_SQUEEZY_STORE_ID },
      include: ['variants']
    });

    if (!products.data) {
      throw new Error('Failed to fetch products');
    }

    const fitPickProducts: FitPickProduct[] = [];

    for (const product of products.data.data) {
      const variants = await listVariants({
        filter: { productId: product.id }
      });

      const fitPickVariants: FitPickVariant[] = variants.data?.data.map(variant => ({
        id: variant.id,
        name: variant.attributes.name,
        price: variant.attributes.price?.toString() || '0',
        size: variant.attributes.name.includes('Size') ? 
          variant.attributes.name.split(' ')[1] : undefined,
        color: variant.attributes.name.includes('Color') ? 
          variant.attributes.name.split(' ')[1] : undefined,
      })) || [];

      fitPickProducts.push({
        id: product.id,
        name: product.attributes.name,
        description: product.attributes.description || '',
        price: fitPickVariants[0]?.price || '0',
        category: getCategoryFromName(product.attributes.name),
        images: [], // Lemon Squeezy에서 이미지 URL 가져오기
        variants: fitPickVariants
      });
    }

    return fitPickProducts;
  } catch (error) {
    console.error('Error fetching products:', error);
    throw error;
  }
}

function getCategoryFromName(name: string): 'men' | 'women' | 'unisex' {
  const lowerName = name.toLowerCase();
  if (lowerName.includes('men') || lowerName.includes('남성')) return 'men';
  if (lowerName.includes('women') || lowerName.includes('여성')) return 'women';
  return 'unisex';
}

3.2 카테고리별 상품 조회

// lib/products.ts (계속)
export async function getProductsByCategory(category: 'men' | 'women' | 'unisex'): Promise<FitPickProduct[]> {
  const allProducts = await getAllProducts();
  return allProducts.filter(product => product.category === category);
}

export async function getProductById(productId: string): Promise<FitPickProduct | null> {
  configureLemonSqueezy();

  try {
    const product = await getProduct(productId);

    if (!product.data) {
      return null;
    }

    const variants = await listVariants({
      filter: { productId: productId }
    });

    const fitPickVariants: FitPickVariant[] = variants.data?.data.map(variant => ({
      id: variant.id,
      name: variant.attributes.name,
      price: variant.attributes.price?.toString() || '0',
    })) || [];

    return {
      id: product.data.data.id,
      name: product.data.data.attributes.name,
      description: product.data.data.attributes.description || '',
      price: fitPickVariants[0]?.price || '0',
      category: getCategoryFromName(product.data.data.attributes.name),
      images: [],
      variants: fitPickVariants
    };
  } catch (error) {
    console.error('Error fetching product:', error);
    return null;
  }
}

4. 결제 시스템

4.1 체크아웃 생성의 의미와 목적

Lemon Squeezy에서 체크아웃을 생성하는 것은 "고유한 결제 세션을 만드는 것"입니다. 이는 단순한 URL 생성이 아닌, 완전한 결제 플로우를 위한 독립적인 환경을 구축하는 것입니다.

체크아웃 생성이 필요한 이유

  1. 보안과 격리: 각 체크아웃은 독립적인 세션으로, 다른 주문과 완전히 분리됩니다.
  2. 상태 관리: 결제 진행 상태, 장바구니 정보, 사용자 데이터를 안전하게 관리합니다.
  3. 재시도 방지: 동일한 주문에 대한 중복 결제를 방지합니다.
  4. 트랜잭션 추적: 각 결제에 고유한 ID를 부여하여 추적 가능합니다.

업계 표준과의 비교

// Stripe의 체크아웃 생성
const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  line_items: [{ price: 'price_H5ggYwtDq4fbrJ', quantity: 2 }],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
});

// PayPal의 주문 생성
const order = await paypal.orders.create({
  intent: 'CAPTURE',
  purchase_units: [{ amount: { currency_code: 'USD', value: '100.00' } }]
});

// Lemon Squeezy의 체크아웃 생성
const checkout = await createCheckout(storeId, variantId, options);

4.2 체크아웃 생성 구현

장바구니 컴포넌트 및 기능은 개발되었다는 전제로 진행하였습니다.

// lib/checkout.ts
import { createCheckout as createLemonCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import { configureLemonSqueezy } from './lemonsqueezy';
import { CartItem } from '@/store/cartStore';

export async function createCheckout(cartItems: CartItem[]): Promise<string> {
  configureLemonSqueezy();

  if (cartItems.length === 0) {
    throw new Error('장바구니가 비어있습니다.');
  }

  // 단일 상품 체크아웃 (Lemon Squeezy는 한 번에 하나의 variant만 지원)
  // 여러 상품의 경우 별도의 로직 필요
  const firstItem = cartItems[0];

  try {
    const checkout = await createLemonCheckout(
      process.env.LEMON_SQUEEZY_STORE_ID!,
      parseInt(firstItem.variantId),
      {
        checkoutOptions: {
          embed: false,
          media: true,
          logo: true,
        },
        checkoutData: {
          email: '', // 사용자 이메일이 있다면 여기에
          custom: {
            cart_items: JSON.stringify(cartItems),
          },
        },
        productOptions: {
          redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/payment/success`,
          receiptButtonText: '쇼핑 계속하기',
          receiptThankYouNote: '핏픽을 이용해 주셔서 감사합니다!',
        },
      }
    );

    if (!checkout.data?.data.attributes.url) {
      throw new Error('체크아웃 URL을 생성할 수 없습니다.');
    }

    return checkout.data.data.attributes.url;
  } catch (error) {
    console.error('Checkout creation error:', error);
    throw new Error('결제 페이지 생성 중 오류가 발생했습니다.');
  }
}

// 다중 상품 체크아웃을 위한 커스텀 가격 설정
export async function createCustomCheckout(
  variantId: number, 
  customPrice: number, 
  cartItems: CartItem[]
): Promise<string> {
  configureLemonSqueezy();

  try {
    const checkout = await createLemonCheckout(
      process.env.LEMON_SQUEEZY_STORE_ID!,
      variantId,
      {
        customPrice: customPrice * 100, // cents 단위로 변환
        checkoutOptions: {
          embed: false,
          media: true,
          logo: true,
        },
        checkoutData: {
          custom: {
            cart_items: JSON.stringify(cartItems),
            total_amount: customPrice,
          },
        },
        productOptions: {
          name: `핏픽 주문 (${cartItems.length}개 상품)`,
          description: cartItems.map(item => `${item.name} x${item.quantity}`).join(', '),
          redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/payment/success`,
          receiptButtonText: '쇼핑 계속하기',
          receiptThankYouNote: '핏픽을 이용해 주셔서 감사합니다!',
        },
      }
    );

    if (!checkout.data?.data.attributes.url) {
      throw new Error('체크아웃 URL을 생성할 수 없습니다.');
    }

    return checkout.data.data.attributes.url;
  } catch (error) {
    console.error('Custom checkout creation error:', error);
    throw new Error('결제 페이지 생성 중 오류가 발생했습니다.');
  }
}

4.3 Lemon.js를 이용한 오버레이 체크아웃

// components/Checkout/CheckoutButton.tsx
'use client';

import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { createCheckout } from '@/lib/checkout';
import { CartItem } from '@/store/cartStore';

declare global {
  interface Window {
    LemonSqueezy: {
      Url: {
        Open: (url: string) => void;
      };
    };
  }
}

interface CheckoutButtonProps {
  cartItems: CartItem[];
  onSuccess?: () => void;
}

export function CheckoutButton({ cartItems, onSuccess }: CheckoutButtonProps) {
  useEffect(() => {
    // Lemon.js 스크립트 로드
    const script = document.createElement('script');
    script.src = 'https://app.lemonsqueezy.com/js/lemon.js';
    script.defer = true;
    document.head.appendChild(script);

    return () => {
      document.head.removeChild(script);
    };
  }, []);

  const handleCheckout = async () => {
    try {
      const checkoutUrl = await createCheckout(cartItems);

      // 오버레이로 체크아웃 열기
      if (window.LemonSqueezy) {
        window.LemonSqueezy.Url.Open(checkoutUrl);
      } else {
        // Lemon.js가 로드되지 않은 경우 새 창에서 열기
        window.open(checkoutUrl, '_blank');
      }
    } catch (error) {
      console.error('Checkout error:', error);
      alert('결제 페이지로 이동하는 중 오류가 발생했습니다.');
    }
  };

  return (
    <Button 
      onClick={handleCheckout}
      className="w-full bg-black text-white hover:bg-gray-800"
      disabled={cartItems.length === 0}
    >
      {cartItems.length === 0 ? '상품을 선택해주세요' : '결제하기'}
    </Button>
  );
}

5. 웹훅 처리

5.1 웹훅 처리 방식: 동기 vs 비동기

웹훅 처리에서는 비동기 처리가 표준입니다. 거의 모든 프로덕션 시스템에서 비동기 방식을 사용합니다.

왜 비동기 처리가 일반적인가?

  1. 웹훅 제공자의 제약사항

    • 대부분 30초 타임아웃
    • 재시도 정책 (보통 3회)
    • 페이로드 크기 제한
  2. 실제 처리 시간 분석

    // 동기 처리 시 예상 시간
    const syncProcessingTime = {
      서명검증: '0.1초',
      DB저장: '1-3초',
      재고업데이트: '1-2초',
      이메일발송: '5-10초',
      관리자알림: '2-3초',
      총합: '9-18초' // 타임아웃 위험!
    };
  3. 비동기 처리의 장점

    • 즉시 응답으로 타임아웃 방지
    • 실패 시 재시도 가능
    • 시스템 부하 분산
    • 확장성 확보

5.2 비동기 웹훅 처리 구현

// app/api/webhooks/lemonsqueezy/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  try {
    const body = await request.text();
    const signature = request.headers.get('X-Signature');

    if (!process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
      return NextResponse.json(
        { error: 'Webhook secret not configured' },
        { status: 500 }
      );
    }

    // 서명 검증
    const hmac = crypto.createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET);
    const digest = Buffer.from(hmac.update(body).digest('hex'), 'utf8');
    const expectedSignature = Buffer.from(signature || '', 'utf8');

    if (!crypto.timingSafeEqual(digest, expectedSignature)) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      );
    }

    const event = JSON.parse(body);

    // 즉시 응답 (비동기 처리)
    processWebhookAsync(event);

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }
}

// 비동기 웹훅 처리
async function processWebhookAsync(event: any) {
  try {
    // 백그라운드에서 처리
    setImmediate(async () => {
      await handleWebhookEvent(event);
    });
  } catch (error) {
    console.error('Async webhook processing error:', error);
    // 에러 로깅 및 모니터링
  }
}

async function handleWebhookEvent(event: any) {
  const eventName = event.meta.event_name;

  switch (eventName) {
    case 'order_created':
      await handleOrderCreated(event);
      break;
    case 'subscription_created':
      await handleSubscriptionCreated(event);
      break;
    case 'subscription_updated':
      await handleSubscriptionUpdated(event);
      break;
    default:
      console.log(`Unhandled event: ${eventName}`);
  }
}

async function handleOrderCreated(event: any) {
  const order = event.data;
  const customData = event.meta.custom_data;

  try {
    // 1. 주문 정보를 데이터베이스에 저장
    await db.orders.create({
      lemonOrderId: order.id,
      customerEmail: order.attributes.user_email,
      total: order.attributes.total,
      status: order.attributes.status,
      currency: order.attributes.currency,
      cartItems: customData?.cart_items ? JSON.parse(customData.cart_items) : [],
      createdAt: new Date(),
    });

    // 2. 재고 차감 (필요한 경우)
    const cartItems = JSON.parse(customData?.cart_items || '[]');
    for (const item of cartItems) {
      await updateInventory(item.productId, item.quantity);
    }

    // 3. 이메일 발송 (비동기)
    sendOrderConfirmationEmail(order.attributes.user_email, order);

    // 4. 관리자 알림 (비동기)
    notifyAdminOfNewOrder(order);

  } catch (error) {
    console.error('Order processing error:', error);
    // 에러 처리 및 재시도 로직
  }
}

async function handleSubscriptionCreated(event: any) {
  // 구독 생성 처리 (필요한 경우)
  console.log('Subscription created:', event.data);
}

async function handleSubscriptionUpdated(event: any) {
  // 구독 업데이트 처리 (필요한 경우)
  console.log('Subscription updated:', event.data);
}

// 비동기 이메일 발송
async function sendOrderConfirmationEmail(email: string, order: any) {
  try {
    // 이메일 발송 로직
    console.log(`Sending confirmation email to ${email}`);
  } catch (error) {
    console.error('Email sending error:', error);
  }
}

// 비동기 관리자 알림
async function notifyAdminOfNewOrder(order: any) {
  try {
    // 관리자 알림 로직
    console.log(`Notifying admin of new order: ${order.id}`);
  } catch (error) {
    console.error('Admin notification error:', error);
  }
}

6. API 라우트

6.1 상품 API

// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAllProducts, getProductsByCategory } from '@/lib/products';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const category = searchParams.get('category') as 'men' | 'women' | 'unisex' | null;

    const products = category 
      ? await getProductsByCategory(category)
      : await getAllProducts();

    return NextResponse.json(products);
  } catch (error) {
    console.error('Products API error:', error);
    return NextResponse.json(
      { error: 'Failed to fetch products' },
      { status: 500 }
    );
  }
}

6.2 체크아웃 API

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createCheckout, createCustomCheckout } from '@/lib/checkout';

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { cartItems, useCustomPrice } = body;

    if (!cartItems || cartItems.length === 0) {
      return NextResponse.json(
        { error: 'Cart items are required' },
        { status: 400 }
      );
    }

    let checkoutUrl: string;

    if (useCustomPrice && cartItems.length > 1) {
      // 다중 상품의 경우 커스텀 가격으로 체크아웃
      const totalPrice = cartItems.reduce((sum: number, item: any) => 
        sum + (parseFloat(item.price) * item.quantity), 0
      );

      checkoutUrl = await createCustomCheckout(
        parseInt(cartItems[0].variantId), 
        totalPrice, 
        cartItems
      );
    } else {
      // 단일 상품 체크아웃
      checkoutUrl = await createCheckout(cartItems);
    }

    return NextResponse.json({ checkoutUrl });
  } catch (error) {
    console.error('Checkout API error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout' },
      { status: 500 }
    );
  }
}

7. 레퍼런스 및 참고 자료

7.1 공식 문서

7.2 주요 API 엔드포인트

  • Products: GET /v1/products - 상품 목록 조회
  • Variants: GET /v1/variants - 상품 변형 조회
  • Checkouts: POST /v1/checkouts - 체크아웃 생성
  • Orders: GET /v1/orders - 주문 조회
  • Customers: GET /v1/customers - 고객 정보 조회

7.3 웹훅 이벤트

  • order_created - 주문 생성
  • order_refunded - 주문 환불
  • subscription_created - 구독 생성
  • subscription_updated - 구독 업데이트
  • subscription_cancelled - 구독 취소

7.4 제한사항 및 고려사항

  • Rate Limiting: 분당 300회 API 호출 제한
  • 단일 상품 체크아웃: 한 번에 하나의 variant만 체크아웃 가능
  • 테스트 모드: 개발 시 테스트 모드 API 키 사용 필수
  • 웹훅 보안: 서명 검증을 통한 웹훅 보안 구현 필수
  • 웹훅 타임아웃: 30초 내 응답 필요, 비동기 처리 권장
  • 체크아웃 세션: 각 체크아웃은 독립적인 결제 세션으로 관리

7.5 업계 표준 비교

  • Stripe: Checkout Sessions 방식 사용
  • PayPal: Orders API 방식 사용
  • Shopify: Checkout API 방식 사용
  • Lemon Squeezy: Checkout 생성 방식 사용

이렇게 핏픽 프로젝트에서 Lemon Squeezy API를 활용한 완전한 이커머스 기능을 구현할 수 있습니다.

반응형

댓글