Free

Claude에게 두 결제 통합시키기: Stripe + x402

완전히 다른 두 프로토콜(Stripe 호스티드 + x402 온체인 지갑) 결제 통합을 Claude에게 시키며 세 가지 조용한 실패를 밟고, 두 결제 레인을 한 앱에서 굴리는 아키텍처 완성.


최근 how2claude의 Pro 티어에 Stripe(카드/법정화폐)와 x402(EVM 온체인 USDC) 둘 다 연결했다. Claude에게 완전히 다른 두 프로토콜의 결제 통합을 시키니—한쪽은 호스티드 Checkout + webhook, 다른 쪽은 HTTP 402 + 브라우저 지갑—한 저녁 session이 통째로 들어갔다. 조용한 실패 셋을 밟고, 두 결제 레인을 한 앱에서 같이 굴리는 아키텍처를 완성했다.

이 글은 "Stripe 어떻게 붙이나" 튜토리얼이 아니다—그건 어디에나 있다. 흥미로운 부분: 두 프로토콜이 어떻게 나란히 자리 잡는가, Claude가 어디서 가장 잘 자빠지는가, 어느 순간엔 네가 직접 앉아서 봐야 하는가.


두 결제 패러다임

차원 Stripe x402
트리거 button_to → checkout.stripe.com 으로 리다이렉트 POST /x402/subscribe → HTTP 402 반환
사용자 동작 Stripe 호스티드 페이지에서 카드 입력 브라우저 지갑에서 서명
결과 전달 webhook (checkout.session.completed) X-PAYMENT 헤더 달고 요청 재시도, gem이 동기 정산
영속화할 데이터 payment_intent_id + amount_total tx_hash + payer + amount
프로토콜 복잡도 SDK가 다 함 viem + x402-fetch 프로토콜 핸드셰이크 필요

본질적으로 다름: Stripe는 사용자를 자기 페이지로 밀어내고 너는 사용자가 돌아왔을 때 webhook만 확인하면 된다. x402는 처음부터 끝까지 네 도메인 위에서 돌면서 HTTP 레이어로 프로토콜 핸드셰이크를 끝낸다.

이 차이가 아래 모든 아키텍처 결정을 좌우한다.

컨트롤러 얇게: record 메서드를 모델층으로 밀어내기

처음엔 컨트롤러가 필드 매핑으로 가득했다:

# ❌ 초기 버전
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... 십수 줄짜리 필드 매핑
  )
end

두 결제 모두 Purchase + Subscription을 영속화하는데, 필드는 완전히 다르다. 컨트롤러에 매핑을 두면 두 결제가 같은 로직을 베껴 쓰게 된다.

이 마이그레이션(9f3e239)이 모델로 밀어냈다:

class Purchase < ApplicationRecord
  validates :provider, presence: true, inclusion: { in: %w[stripe x402] }

  def self.record_x402!(article:, user:, payment:, settlement:)
    create!(
      article: article,
      user: user,
      provider: "x402",
      wallet_address: payment[:payer],
      amount_cents: article.price_cents,
      tx_hash: settlement.transaction,
      purchased_at: Time.current
    )
  end

  def self.record_stripe!(session:, user:)
    create!(
      article_id: session.metadata.article_id,
      user: user,
      provider: "stripe",
      amount_cents: session.amount_total,
      stripe_payment_intent_id: session.payment_intent,
      purchased_at: Time.current
    )
  end
end

총 4개 메서드: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. 컨트롤러는 한 줄이 된다:

Purchase.record_x402!(article:, user:, payment:, settlement:)

Claude가 이 작업에 딱 맞다: 묵묵히 필드를 하나씩 매핑하고 테스트도 쓰고 validates :provider, inclusion: { in: %w[stripe x402] }도 넣는다. 사람은 "일단 돌아가게부터" 하다가 필드 매핑이 컨트롤러 사이에 흩어진 채 영영 안 빠져나온다.

페이스: 손으로 한 번 쓰고 그다음 gem으로

b2f0333에서 처음 Claude에게 x402 통합을 시켰을 때, 클래스 셋을 손으로 만들었다:

  • X402::PaymentHandler — 402 requirements 빌드, PAYMENT-SIGNATURE 헤더 디코드
  • X402::FacilitatorClientx402.org/facilitator/verify + /settle 래핑
  • app/controllers/concerns/content_gate.rb — 402 헤더 감지, PAYMENT-REQUIRED 반환

449줄, 동작, 테스트도 통과.

6시간 뒤(9f3e239) 통째로 x402-rails gem(v1 프로토콜, 비-optimistic 모드)으로 갈아끼웠다. 그 세 클래스 삭제, 컨트롤러는 x402_paywall(amount:) DSL을 쓰고 request.env["x402.payment"]request.env["x402.settlement_result"]에서 읽는다.

페이스가 중요하다: 손으로 한 번 쓰면 프로토콜을 이해하게 되고, 그다음 gem이 너를 풀어준다. 처음부터 gem을 깔면 Claude는 gem 문서대로 쓰는데 너는 402 헤더 안에 뭐가 들었는지 /settle이 뭘 하는지 모른다. 뭔가 깨졌을 때(반드시 뭔가는 깨진다) 디버깅 발판이 없다.

이 패턴은 새 프로토콜/새 서비스 전반에 통한다: Claude에게 한 번 손으로 쓰게 하고, 테스트 그린 만든 다음 gem으로 갈아끼우게 한다. 두 디프가 네 학습 자료다.

체인은 Rails.env로 런타임에 전환, 배포 시 수동 전환 금지

x402 이니셜라이저(config/initializers/x402.rb)는 규칙을 박아놓는다:

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                       "https://facilitator.payai.network"
  # Production → Base mainnet (real USDC). Dev/test → Base Sepolia (free testnet USDC).
  config.chain = Rails.env.production? ? "base" : "base-sepolia"
  config.currency = "USDC"
  config.version = 1
  config.optimistic = false  # facilitator settle 반환 후 진행, 동기로 tx_hash 잡으려고
end

같은 코드, dev는 base-sepolia(공짜 테스트 토큰), prod는 base mainnet. 배포 시 아무것도 안 바꾼다. (이 원칙은 앞 글 Claude에게 프로덕션 배포 맡기기에서 다룸—dev와 prod가 다른 모든 설정은 Rails.env로 뒤집기.)

optimistic = false 줄이 핵심: gem 기본 optimistic 모드는 요청 통과시키고 나중에 정산하지만, 우리는 끈다—action 반환 전 settlement_result.transaction(tx_hash)을 잡아 동기로 Purchase 행에 쓰고 싶다. tx_hash 없는 Purchase 레코드는 사용자에게 가치 없음—BaseScan 열어 트랜잭션 보고 싶을 테니.

프론트엔드: 한쪽은 호스트, 다른 쪽은 직접 만들기

Stripe 쪽 "프론트엔드"는 한 줄:

<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
      class: "...",
      form: { class: "w-full", data: { turbo: false } } do %>
  <%= t("pricing.subscribe") %>
<% end %>

사용자가 누르면 브라우저는 checkout.stripe.com로 점프. 네 쪽 프론트엔드 코드 0.

x402 쪽(93746d8)은 Stimulus 컨트롤러를 쓰게 했다:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // 지연 로드, vendor 부풀리지 마
  const viem = await import("https://esm.run/viem@2")
  const { wrapFetchWithPayment } = await import("https://esm.run/[email protected]")

  const [account] = await window.ethereum.request({ method: "eth_requestAccounts" })
  const walletClient = viem.createWalletClient({ account, transport: viem.custom(window.ethereum) })
  const fetchWithPayment = wrapFetchWithPayment(fetch, walletClient)

  const res = await fetchWithPayment(this.endpointValue, {
    method: "POST",
    headers: { "Accept": "application/json" },
    body: new URLSearchParams(this.paramsValue)
  })
  // ...
}

주목할 두 가지:

  1. viem + x402-fetch 지연 로드(첫 클릭 때만 jsdelivr에서 받음). 이 둘 합치면 크다, vendor 번들에 박으면 결제 안 하는 사용자도 다 다운받는다. 지연 로드로 "결제할 때만 받기"로 바뀜.
  2. eth_requestAccounts 결과 사용, selectedAddress 쓰지 마. selectedAddress는 deprecated, 대부분 지갑이 낡은 값을 반환. Claude 첫 버전이 selectedAddress를 썼다(MDN 문서대로). 내가 전자로 바꿈.

또 하나: 에러 코드 열거화. 지갑 서명 거부는 4001, 체인 다른 경우 CHAIN_SWITCH, 결제 필요는 PAYMENT_REQUIRED. error.message로 문자열 매칭하지 마—지갑마다 문구가 다르고 테스트 못 쓴다.

함정 #1: button_to + Turbo가 Stripe 302를 조용히 삼킨다

527f700 이 커밋은 브라우저 30분 째려보고서야 찾았다.

증상: /pricing의 Subscribe 버튼 누르니 페이지 아무 일도 없음. 콘솔 에러 없음, 네트워크 에러 없음, Rails 로그는 200으로 302 → checkout.stripe.com/c/pay/cs_xxx 반환. 그런데 브라우저는 꼼짝도 안 함.

원인: button_to<form method="post">를 생성하고, Turbo가 그 폼 제출을 가로채서 응답을 TURBO_STREAM으로 처리. TURBO_STREAM은 cross-origin 302를 안 따른다. 응답은 Turbo한테 조용히 먹히고, 페이지는 가만히 있음.

수정:

 <%= button_to stripe_checkouts_subscription_path(plan: plan.key),
       class: "...",
-      form: { class: "w-full" } do %>
+      form: { class: "w-full", data: { turbo: false } } do %>

해당 버튼 셋: /pricing의 Subscribe, /pricing의 "현재 플랜" 카드의 Manage(billing.stripe.com로 점프), /accounts의 Manage Subscription. 각각 data-turbo=false 추가, 각각 회귀 테스트 추가.

처음에 Claude한테 디버깅시키니 세 잘못된 방향을 의심: Stripe 설정 잘못(아님), redirect_uri 화이트리스트(아님), CORS(틀린 방향). Turbo와 Stripe 충돌은 Stripe 문서에도 Turbo 문서에도 없다—Claude 훈련 데이터에도 거의 없다. 이런 함정은 network 탭에서 302 응답이 돌아오는 걸 보고 "그럼 왜 안 따라가지?"라고 자문하는 수밖에 없다.

함정 #2: Failed to resolve module specifier 'x402-fetch'

x402-rails gem 깔고 나니 브라우저 콘솔:

Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.

근데 나는 분명 await import("https://esm.run/[email protected]")로 지연 로드 중—풀 URL인데 왜 "resolve module specifier"가 실패?

근본 원인: x402-rails gem 자체가 @hotwired/stimulus에 의존하는 Stimulus 컨트롤러를 동봉. config/importmap.rb에 그 패키지를 pin했지만 대응 vendor 파일 vendor/javascript/@hotwired--stimulus.js다운로드 안 됨. importmap이 파일 없는 걸 알아채고 생성된 importmap에서 그 pin을 조용히 버린다. 실패는 내 x402-fetch가 아니라 gem의 Stimulus 컨트롤러. 에러는 가장 가까운 import로 버블업.

진단: bin/importmap json이 실제 생성된 importmap을 출력—config/importmap.rb와 비교, json에 없는 pin이 있으면 대응 vendor 파일이 없는 것.

수정: bin/importmap pin @hotwired/stimulus로 파일을 실제로 다시 가져오기.

Claude는 gem 통합 코드 쓸 때 bin/importmap json을 반사적으로 새너티 체크로 안 돌린다. 이건 사람 몫. importmap 쓴다면, Stimulus 컨트롤러 동봉 gem 깐 다음에 bin/importmap json 한 번 돌려서 pin이 조용히 안 빠졌는지 확인하라.

함정 #3: YAML이 0x... 지갑 주소를 정수로 해석

credentials에:

x402:
  wallet_address: 0x1234abcd...

Rails 로딩 시 YAML이 0x1234abcd...정수(hex literal)로 해석. X402.configure가 그 값 받았을 땐 타입이 깨져 있고, gem 내부에서 paywall requirement 만들 때 이상한 구조 생성.

한 글자 수정: 따옴표 추가.

x402:
  wallet_address: "0x1234abcd..."

Claude는 credentials 템플릿 쓸 때 따옴표 안 넣었다—훈련 데이터의 YAML 예시는 거의 베어 문자열. 접두사가 우연히 0x / true / false / 숫자일 때만 문제 발생. 이런 "YAML 특수 해석" 함정은 진짜 값 채워야 발화한다.

왜 한 앱에 결제 레인이 둘 필요한가

Stripe는 사용자 99% 커버—신용카드/Apple Pay/Google Pay. $9.99/월 플로우에선 경험이 압도적으로 좋다.

x402는 나머지 1%지만 중요한 사람들 커버: 크립토 네이티브 사용자, 스테이블코인 쓰고 싶은 해외 사용자, 자동화 에이전트 짜는 개발자(에이전트가 유료 API 액세스에 직접 결제할 수 있어야 함—402가 그래서 설계됨).

핵심 제품 결정: 월 플랜에는 x402 안 줌. $9.99/월에 매월 지갑 서명은 UX 끔찍. 연 $99에만 x402 활성화—마찰 비용을 연 1회로 분산.

<% if plan.interval == "year" %>
  <%= render "shared/x402_pay_button", ... %>
<% end %>

_plan_card.html.erb의 한 줄 if가 어떤 카드가 USDC 결제 버튼을 보여줄지 결정. 그게 다.


Claude에게 결제 통합시키기 전체 체크리스트:

  1. 두 프로토콜을 따로 이해한 다음 Claude한테 쓰게 하라. Stripe는 hosted Checkout + webhook, x402는 HTTP 402 + 브라우저 지갑—Claude가 알아서 구분할 거라 기대하지 마.
  2. record 메서드는 모델층에. 컨트롤러는 한 줄만 호출, 필드 매핑은 전부 모델 안. inclusion: { in: %w[stripe x402] }를 타입 게이트로.
  3. 새 프로토콜은 손으로 쓰고 그다음 gem으로. 두 디프가 네 학습 자료.
  4. 체인/모드 전환은 런타임에 Rails.env로. Stripe test/live, x402 base-sepolia/base, 전부 Rails.env.production?로 뒤집기.
  5. 모든 Stripe button_to에 data-turbo=false. 안 하면 Turbo가 cross-origin 302를 조용히 먹는다.
  6. Stimulus 컨트롤러 동봉 gem 깔고 나면 bin/importmap json 돌려라. importmap이 vendor 파일 없는 pin을 조용히 버린다.
  7. 숫자 접두사처럼 보이는 credentials에 전부 따옴표. 0x... / true / 07은 YAML이 특수 해석.

Claude한테 결제 쓰게 하는 진짜 어려움은 프로토콜 자체가 아니라 프로토콜 경계의 통합(Turbo와 Stripe, importmap과 gem, YAML과 지갑 주소). 거기가 네가 직접 앉아서 봐야 하는 순간이다.