완전히 다른 두 프로토콜(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 레이어로 프로토콜 핸드셰이크를 끝낸다.
이 차이가 아래 모든 아키텍처 결정을 좌우한다.
처음엔 컨트롤러가 필드 매핑으로 가득했다:
# ❌ 초기 버전
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] }도 넣는다. 사람은 "일단 돌아가게부터" 하다가 필드 매핑이 컨트롤러 사이에 흩어진 채 영영 안 빠져나온다.
b2f0333에서 처음 Claude에게 x402 통합을 시켰을 때, 클래스 셋을 손으로 만들었다:
X402::PaymentHandler — 402 requirements 빌드, PAYMENT-SIGNATURE 헤더 디코드X402::FacilitatorClient — x402.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으로 갈아끼우게 한다. 두 디프가 네 학습 자료다.
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)
})
// ...
}
주목할 두 가지:
eth_requestAccounts 결과 사용, selectedAddress 쓰지 마. selectedAddress는 deprecated, 대부분 지갑이 낡은 값을 반환. Claude 첫 버전이 selectedAddress를 썼다(MDN 문서대로). 내가 전자로 바꿈.또 하나: 에러 코드 열거화. 지갑 서명 거부는 4001, 체인 다른 경우 CHAIN_SWITCH, 결제 필요는 PAYMENT_REQUIRED. error.message로 문자열 매칭하지 마—지갑마다 문구가 다르고 테스트 못 쓴다.
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 응답이 돌아오는 걸 보고 "그럼 왜 안 따라가지?"라고 자문하는 수밖에 없다.
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이 조용히 안 빠졌는지 확인하라.
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에게 결제 통합시키기 전체 체크리스트:
inclusion: { in: %w[stripe x402] }를 타입 게이트로.Rails.env.production?로 뒤집기.data-turbo=false. 안 하면 Turbo가 cross-origin 302를 조용히 먹는다.bin/importmap json 돌려라. importmap이 vendor 파일 없는 pin을 조용히 버린다.0x... / true / 07은 YAML이 특수 해석.Claude한테 결제 쓰게 하는 진짜 어려움은 프로토콜 자체가 아니라 프로토콜 경계의 통합(Turbo와 Stripe, importmap과 gem, YAML과 지갑 주소). 거기가 네가 직접 앉아서 봐야 하는 순간이다.