📖 11주차 04강: 라라벨 캐셔(Cashier Stripe & Paddle)를 이용한 정기 구독 및 글로벌 결제
실습 프로젝트: 지니샵(JinyShop) — 온라인 도서 쇼핑몰
학습 목표: ‘이달의 책 정기 배송 북클럽’ 멤버십 구독 비즈니스 모델을 구현하기 위해 라라벨 공식 결제 패키지인 Cashier(Stripe & Paddle)를 연동하고, 구독 생성/취소/유예기간(Grace Period) 및 웹훅(Webhook) 처리 방식을 마스터합니다.
등장인물: 🐱 지니(시니어 멘토), 👧 도로시(주니어 개발자), 🐶 토토(개발 보조견)
💬 지니와 도로시의 티키타카 회의
👧 도로시: “지니! 우리 지니샵에서 ‘북클럽 멤버십’ 서비스를 런칭하기로 했어! 매월 19,900원씩 정기 결제하면 매달 베스트셀러 1권 무료 배송에 전자책 무제한 대여 혜택을 주는 거야. 그런데 정기 결제 날짜 계산, 카드 변경, 결제 실패 시 재시도, 환불, 영수증 발행까지 직접 다 구현하려니까 머리가 터질 것 같아!”
🐱 지니: “도로시, 바로 그럴 때 전 세계 수많은 SaaS와 이커머스가 사용하는 라라벨 캐셔(Laravel Cashier)를 쓰는 거란다! Stripe와 Paddle을 위한 라라벨 공식 패키지로, 복잡한 구독 라이프사이클을 Eloquent 메소드 몇 개로 완전히 정복할 수 있지!”
🐶 토토: “멍멍! 게다가 해외 독자들을 위한 글로벌 결제나 국가별 부가세(VAT) 자동 계산까지 필요하다면 Cashier Paddle이 환상적인 대안이 된다멍!”
🎯 실습 목표 및 서점 시나리오
- 라라벨 캐셔(Cashier Stripe) 패키지 설치 및 마이그레이션:
Billable트레이트 연동 - 단건 결제 및 정기 구독(Subscription) 생성: 북클럽 정기 구독 결제 폼 연동
- 구독 상태 조회 및 권한 제어:
subscribed(),cancelled(),onGracePeriod() - 글로벌 판매를 위한 Cashier Paddle의 개념과 특징 이해
- 웹훅(Webhook) 보안 처리: 결제 성공/실패 시 비동기 DB 상태 동기화
🛠️ 단계별 실습 절차
1단계: Cashier 패키지 설치 및 환경 설정
터미널에서 Cashier Stripe 패키지를 설치합니다:
composer require laravel/cashier
php artisan migrate
💡 Cashier 마이그레이션 내용:
users테이블에stripe_id,pm_type,pm_last_four,trial_ends_at컬럼이 추가되고,subscriptions및subscription_items테이블이 생성되어 구독 내역을 자동으로 추적합니다!
.env 파일에 Stripe API 키를 입력합니다 (Stripe 대시보드 테스트 키 발급):
STRIPE_KEY=pk_test_51...
STRIPE_SECRET=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=krw
2단계: User 모델에 Billable 트레이트 추가
app/Models/User.php에 Laravel\Cashier\Billable 트레이트를 추가합니다:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use HasFactory, Notifiable, Billable;
// ... 기존 코드 유지
}
3단계: 북클럽 구독 결제 페이지 및 SetupIntent 발급
신용카드 정보를 안전하게 Stripe 서버로 직접 전송(PCI-DSS 준수)하기 위해 클라이언트 비밀키(SetupIntent)를 발급받는 컨트롤러를 작성합니다.
app/Http/Controllers/SubscriptionController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
/**
* 북클럽 멤버십 안내 및 카드 등록 폼
*/
public function show()
{
$user = auth()->user();
return view('subscriptions.checkout', [
'intent' => $user->createSetupIntent(),
'monthlyPrice' => 19900,
]);
}
/**
* 북클럽 정기 구독 생성 처리
*/
public function store(Request $request)
{
$user = $request->user();
$paymentMethod = $request->payment_method;
// Stripe 가격 플랜 ID (Stripe 대시보드에서 생성한 Product Price ID)
$stripePriceId = config('services.stripe.book_club_price_id', 'price_1Nxxxxxxxxxxx');
try {
// 새 구독 생성 (신용카드 등록 + 즉시 첫 결제 진행)
$user->newSubscription('book-club', $stripePriceId)
->create($paymentMethod);
return redirect()->route('subscriptions.index')
->with('status', '지니샵 북클럽 정기 회원이 되신 것을 축하합니다! 📚');
} catch (\Exception $e) {
return back()->withErrors(['error' => '결제 승인 중 오류가 발생했습니다: ' . $e->getMessage()]);
}
}
}
4단계: 북클럽 회원 전용 혜택 페이지 및 상태 체크
독자가 북클럽 멤버십을 유지하고 있는지 검사하여 전자책 무제한 열람 권한을 부여합니다:
// 컨트롤러 또는 미들웨어에서의 검증
if ($user->subscribed('book-club')) {
// 북클럽 정회원 전용 혜택 제공 (신간 도서 무료 다운로드 등)
}
// 구독 취소 후 잔여 기간(유예 기간) 이용 중인지 확인
if ($user->subscription('book-club')->onGracePeriod()) {
// "이번 결제 주기(2026-10-25)까지는 혜택이 유지됩니다" 배너 노출
}
블레이드 뷰(resources/views/subscriptions/index.blade.php):
<div class="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow">
<h2 class="text-2xl font-bold mb-4">내 멤버십 관리</h2>
@if(auth()->user()->subscribed('book-club'))
<div class="p-4 bg-green-50 border border-green-200 rounded">
<h3 class="text-lg font-bold text-green-800">✅ 지니샵 북클럽 정기 회원 이용 중</h3>
<p class="text-green-700">다음 결제 예정일: {{ auth()->user()->subscription('book-club')->asStripeSubscription()->current_period_end ? date('Y-m-d', auth()->user()->subscription('book-club')->asStripeSubscription()->current_period_end) : '확인 중' }}</p>
</div>
@if(auth()->user()->subscription('book-club')->onGracePeriod())
<div class="mt-4 p-3 bg-yellow-50 text-yellow-700 rounded">
⚠️ 멤버십 취소 신청이 완료되었습니다. 서비스 이용 종료일까지는 정상 이용 가능합니다.
</div>
<!-- 멤버십 다시 유지하기 -->
<form action="{{ route('subscriptions.resume') }}" method="POST" class="mt-2">
@csrf
<button type="submit" class="text-blue-600 underline">멤버십 구독 다시 이어가기</button>
</form>
@else
<!-- 멤버십 취소 버튼 -->
<form action="{{ route('subscriptions.cancel') }}" method="POST" class="mt-4">
@csrf
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
멤버십 구독 해지
</button>
</form>
@endif
@else
<p class="text-gray-600 mb-4">현재 가입된 멤버십이 없습니다. 매월 엄선된 도서와 함께해 보세요!</p>
<a href="{{ route('subscriptions.create') }}" class="px-5 py-2 bg-indigo-600 text-white rounded">
북클럽 시작하기 (월 19,900원)
</a>
@endif
</div>
5단계: 구독 취소 및 재개(Resume) 처리
// 구독 취소 (즉시 끊지 않고, 이번 결제 주기가 끝날 때까지 혜택 유지)
$user->subscription('book-club')->cancel();
// 즉시 강제 취소
$user->subscription('book-club')->cancelNow();
// 유예 기간(Grace Period) 중 마음이 바뀌어 취소 철회(재개)
$user->subscription('book-club')->resume();
6단계: 웹훅(Webhook) 처리 및 Cashier Paddle 비교
사용자의 카드가 만료되어 2회차 정기 결제가 실패했거나, Stripe 대시보드에서 직접 구독을 중지했을 때 우리 서버 DB와 동기화하려면 Stripe 웹훅을 수신해야 합니다.
Cashier는 웹훅 컨트롤러를 내장하고 있으므로 routes/web.php에 라우트만 등록해 주면 끝납니다:
use Laravel\Cashier\Http\Controllers\WebhookController;
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook']);
⚠️ 주의 (CSRF 예외 처리):
외부 Stripe 서버에서 전송하는 POST 요청이므로bootstrap/app.php에서 CSRF 검증을 면제해야 합니다:->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'stripe/*', ]); })
🔍 라라벨 공식 문서 원리 심층 분석
Cashier Stripe vs Cashier Paddle 비교
| 비교 항목 | Laravel Cashier (Stripe) | Laravel Cashier (Paddle) |
|---|---|---|
| 판매자 지위 | 가맹점(Merchant of Record)이 우리 회사 | Paddle이 MoR(대행 판매자) 지위 가짐 |
| 해외 세금(VAT/GST) | 국가별 부가세 신고/납부를 직접 해야 함 | Paddle이 전 세계 세금을 자동 징수/납부 |
| 커스터마이징 | UI/UX 및 카드 결제창을 100% 완전 커스텀 가능 | Paddle의 호스팅 결제 오버레이 팝업 활용 |
| 적합한 비즈니스 | 국내 중심의 정밀한 결제/구독 비즈니스 | 전 세계 고객을 대상으로 하는 디지털 콘텐츠/전자책 |
🐶 토토의 트러블슈팅 & 주의사항
- Stripe CLI를 이용한 로컬 웹훅 테스트:
로컬 개발 환경(localhost)에서는 Stripe 웹훅을 직접 수신할 수 없습니다. 터미널에서 Stripe CLI를 설치하고 다음 명령어를 실행하면 로컬 포트로 웹훅을 터널링해 줍니다:stripe listen --forward-to localhost:8000/stripe/webhook - 단건 도서 구매 결제(
charge):
정기 구독이 아니라 도서 1권 단건 결제에도 Cashier를 쓸 수 있습니다:// 25,000원 단건 결제 $user->charge(25000, $paymentMethodId, [ 'description' => '도서 [라라벨 마스터 가이드] 1권 구매', ]);
💡 자가진단 퀴즈 & 실습 과제
- 퀴즈: 사용자가 정기 구독을 취소했으나, 이번 결제 주기 마감일까지 잔여 이용 권한을 유지하고 있는 상태를 검사하는 Cashier 메소드는 무엇일까요?
- 정답:
$user->subscription('플랜명')->onGracePeriod()
- 정답:
- 과제: 지니샵 북클럽 회원이 플랜을 월간 결제(19,900원)에서 연간 결제(199,000원, 2개월 무료 할인)로 즉시 변경하는
swap('price_annual')로직을 구현해 보세요!