Free

Claude로 직접 만든 x402 구현을 커뮤니티 gem으로 이관하기

자작 → gem 이관: 순 -622/+317 줄. 컨트롤러가 프로토콜 배관 30줄에서 4줄로. 실제 함정: importmap이 pin 조용히 누락, YAML이 0x...를 정수로 해석.


한 커밋의 diff:

19 files changed, 317 insertions(+), 622 deletions(-)

삭제:

app/services/x402/facilitator_client.rb        53줄
app/services/x402/payment_handler.rb           86줄
test/services/x402/facilitator_client_test.rb  112줄
test/services/x402/payment_handler_test.rb     108줄

추가: Gemfile에 한 줄, config/initializers/x402.rb 29줄, Purchase/Subscriptionrecord_x402! 메서드 두 개 + 해당 model 테스트.

리팩터가 아니다. 내가 쓴 부분을 다른 사람이 쓴 부분으로 바꾸는 작업이다. 자작 버전은 2주간 돌아가고 있었다. 일회성 결제, 구독, tx_hash 기록 모두 정상. 그런데 왜 이관했나?

이 글은 이런 이관을 Claude에게 시키는 방법과, 언제 이관할 가치가 있는지에 대한 것이다.


배경: 당시 자작 구현

x402는 HTTP 402 Payment Required 프로토콜이다. 클라이언트가 EIP-3009 authorization에 서명하고, 서버는 facilitator를 통해 온체인 거래를 검증 + 정산한다.

자작 PaymentHandler는 대략 이랬다:

handler = X402::PaymentHandler.new
payment_payload = handler.decode_payment_signature(params[:payment_signature])
requirements = {
  scheme: "exact",
  network: X402::PaymentHandler::NETWORK.call,
  maxAmountRequired: (plan.price_cents * 10_000).to_s,
  payTo: X402::PaymentHandler::WALLET_ADDRESS.call,
  token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  description: "#{plan.key} subscription"
}

verify_result = handler.facilitator.verify(payment_payload, requirements)
unless verify_result["isValid"]
  render json: { error: verify_result["invalidReason"] || "Verification failed" }, status: :unprocessable_entity and return
end

settle_result = handler.facilitator.settle(payment_payload, requirements)
unless settle_result["success"]
  render json: { error: settle_result["errorReason"] || "Settlement failed" }, status: :unprocessable_entity and return
end

약 30줄, controller 안에서 프로토콜 배관 작업: 서명 디코드, requirements 구성, verify, settle, 에러 처리. USDC 컨트랙트 주소는 코드에 하드코딩. 프론트도 마찬가지 — 직접 쓴 window.ethereum.request, 수동 체인 전환, 수동 X-PAYMENT 헤더 조립.

트리거: 라이브러리가 성숙했다

의존하는 프로토콜의 생태계를 매주 Claude로 스캔시키는 건 좋은 습관이다 — 특히 x402처럼 "나온 지 얼마 안 된 프로토콜"이라면. Claude는 x402-rails gem(Ruby 쪽)과 x402-fetch(JS 쪽)의 변화를 지켜보고 커뮤니티가 형태를 잡는 걸 본다.

그러다 어느 날:

너: "x402-railsx402-fetch 지금 성숙해졌어? 그렇다면 이관해줘."

Claude가 README와 changelog를 읽고 보고한다: v1 프로토콜 안정, non-optimistic 모드로 settlement 결과를 받을 수 있음, facilitator 기본값 payai.network. 이관 가능.

이관 후: controller가 4줄이 된다

이관 후 같은 subscribe 액션:

def subscribe
  plan = Plan.find(params[:plan])

  if Current.user.subscriptions.active.exists?(plan: plan.key)
    render json: { success: true, plan: plan.key, already_active: true }
    return
  end

  x402_paywall(amount: plan.price_dollars)
  return if performed? # gem이 402 또는 에러를 render하고 halt

  settlement = request.env["x402.settlement_result"]
  payment    = request.env["x402.payment"]
  return render_failure("settlement failed") unless settlement&.success?

  Subscription.record_x402!(user: Current.user, plan: plan, payment: payment, settlement: settlement)
end

프로토콜 부분은 전부 gem 안으로. x402_paywall(amount:) 한 줄로 처리:

  • 첫 요청은 X-PAYMENT 헤더 없음 → gem이 402 + PaymentRequirements 렌더
  • 클라이언트 x402-fetch가 EIP-3009 authorization에 서명하고 X-PAYMENT를 달아 재시도
  • Gem이 facilitator의 /verify/settle 호출(non-optimistic, 즉 settle 완료를 기다려 반환)
  • performed?로 gem이 이미 render했음을 감지하면 return; 아니면 request.env["x402.settlement_result"]request.env["x402.payment"]에 이번 거래 결과

초기화 설정은 config/initializers/x402.rb(29줄):

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 메인넷(진짜 USDC). dev/test → Base Sepolia(무료 테스트넷 USDC)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # facilitator settle 완료를 기다린 뒤 반환. 그래야 tx_hash를 동기로 기록 가능
end

이게 "자작 → 라이브러리" 이관의 핵심 동작이다: 자작 139줄 services + 220줄 services 테스트29줄 initializer + 4줄 controller 호출로 교체.

프런트엔드: viem + x402-fetch, 단 vendor 금지

JS 쪽에서는 자작 버전이 직접 서명을 조립하고 window.ethereum.request를 직접 호출했다. 라이브러리로 바꿔 viemx402-fetch 사용.

다만 이 두 패키지는 번들하면 수백 KB. vendor하면(npm의 dist/vendor/javascript/에 복사) 레포 크기가 폭발. 해법: importmap + jsdelivr CDN + 지연 로드:

# config/importmap.rb
pin "viem",        to: "https://cdn.jsdelivr.net/npm/viem/+esm",        preload: false
pin "viem/chains", to: "https://cdn.jsdelivr.net/npm/viem/chains/+esm", preload: false
pin "x402-fetch",  to: "https://cdn.jsdelivr.net/npm/x402-fetch/+esm",  preload: false

preload: false가 핵심: 첫 페인트 <link rel="modulepreload">에 안 들어가므로 대부분의 페이지는 아예 다운로드하지 않는다.

Stimulus controller에서 첫 pay 클릭 때만 로드:

async loadDeps() {
  if (this._deps) return this._deps
  const [{ wrapFetchWithPayment }, { createWalletClient, custom }, { base, baseSepolia }] =
    await Promise.all([
      import("x402-fetch"),
      import("viem"),
      import("viem/chains")
    ])
  this._deps = { wrapFetchWithPayment, createWalletClient, custom, base, baseSepolia }
  return this._deps
}

지갑 없는 사용자는 영원히 이 300+ KB를 로드하지 않는다. MetaMask를 깔고 "결제"를 누른 사용자는 jsdelivr에서 한 번만 기다리고(CDN 캐시), 다음 번은 즉시.

구현상 3가지 잔존 문제를 겸사겸사 고치기

자작 버전은 다른 프로젝트의 참조 구현에서 복사해 온 것이었다. 이관하는 김에 Claude에게 쌓인 냄새를 훑어보라고 시켰더니 3개 나왔다:

1. selectedAddress 쓰지 말기

구코드:
js
const address = window.ethereum.selectedAddress

selectedAddress는 최신 MetaMask에서 deprecated. 올바른 방법:

const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]

eth_requestAccounts는 연결 팝업도 띄운다 — 사용자가 아직 이 사이트에 지갑을 연결한 적 없다면 여기가 인가 진입점이다.

2. 에러를 문자열 매칭하지 않기

구코드:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }

문자열 매칭은 다음 지갑 구현의 문구 변경에서 반드시 깨진다. typed code로 전환:

// EIP-1193 표준: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// flow를 관통하는 커스텀 코드
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

직접 에러를 throw할 때도 code 부착:

throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })

3. UI 문자열은 i18n으로, 영어 하드코딩 금지

구코드에선 "Connecting wallet..." 같은 모든 문자열이 JS에 박혀 있었다. ERB에서 주입하는 data-value 속성으로 이동:

<button data-controller="x402-payment"
        data-x402-payment-label-connecting-value="<%= t('paywall.x402.connecting') %>"
        data-x402-payment-label-signing-value="<%= t('paywall.x402.signing') %>"
        data-x402-payment-error-rejected-value="<%= t('paywall.x402.error.rejected') %>"
        ...>
  <%= t('paywall.x402.pay_button') %>
</button>

JS는 this.labelConnectingValue를 읽는다. 19개 언어 독립 번역 가능. JS는 한 글자도 안 고친다.

실전에서 밟은 두 가지 함정

x402 프로토콜 자체와는 무관하지만 gem README에도 없는 함정이 두 개 있었다.

함정 1: importmap은 vendor 파일 없는 pin을 조용히 버린다

x402-rails gem은 자체 Stimulus controller를 몇 개 포함한다. gem 설치 후 결제 버튼을 누르자 브라우저가 토했다:

Uncaught Error: no Stimulus controller registered for "x402-pay"

파보니. importmap.rb에는 분명히:

pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2

그런데 vendor/javascript/@hotwired--stimulus.js가 없다. importmap은 이 상황에서 에러를 내지 않는다 — 그냥 해당 pin을 조용히 버린다. 결과적으로 gem의 controller가 Stimulus를 못 찾아 등록 실패, 이후 모든 controller가 죽는다.

수정: vendor 파일 채우기:

./bin/importmap pin @hotwired/stimulus

이 명령이 npm 패키지를 vendor/javascript/에 다운로드한다. 이런 종류의 조용한 실패는 Claude가 놓치기 쉽다 — importmap에 pin이 있으니 OK라고 생각하고, vendor/javascript/ 안에 해당 파일이 실제 있는지 자발적으로 확인하지 않는다. 다음에 이런 진단을 할 때는 Claude에게 양쪽 모두 확인시켜라.

함정 2: credentials.yml이 0x...를 정수로 해석한다

본번 credentials, 평범하게 쓰면:

x402:
  wallet_address: 0xAbCd...

배포 후 x402 클릭마다 422, wallet_address가 EVM 주소 정규식과 일치하지 않는다는 에러.

YAML이 0xAbCd...16진수 정수로 파싱했다. Ruby 쪽 Rails.application.credentials.dig(:x402, :wallet_address)가 반환하는 건 Integer이지 String이 아니다. 이후 PaymentRequirements에 넣기 전 .to_s하면 10진수 숫자 문자열이 된다 — 유효한 주소가 아니다.

수정은 한 글자 — 인용부호 추가:

x402:
  wallet_address: "0xAbCd..."

이 함정은 Claude가 처음엔 못 잡는다. 에러 메시지에서 거꾸로 짚어 YAML 파싱 층까지 내려가야 위치가 잡힌다. 한 번 배운 뒤엔 0x로 시작하는 값을 YAML에 넣을 때 반사적으로 인용부호를 붙인다.

테스트 모양이 변한다 (이게 가장 중요한 신호)

이관 후 테스트 파일 수는 줄지 않았지만 위치가 바뀌었다:

삭제:
- test/services/x402/facilitator_client_test.rb(112줄)
- test/services/x402/payment_handler_test.rb(108줄)

추가:
- test/models/purchase_test.rbrecord_x402! 테스트 40줄
- test/models/subscription_test.rbrecord_x402! 테스트 69줄

서비스 층(프로토콜이 어떻게 돌아가는가) 테스트가 전부 사라졌다. 대신 model 층(결제 성공 후 데이터가 어떻게 기록되는가) 테스트가 생겼다.

타당하다 — 프로토콜 동작은 gem 책임이고 gem 스스로 테스트한다. 너는 네가 쓴 부분만 테스트하면 된다: settlement 결과를 받은 뒤 Purchase / Subscription 행을 어떻게 insert할지, tx_hash를 어떻게 저장할지.

이건 "이관해야 하나"의 강한 신호이기도 하다: 테스트에 "내가 보내는 payload 형식이 맞는가", "facilitator가 isValid=false를 반환할 때 어떻게 처리하는가" 같은 덩어리가 많다면 — 그건 프로토콜 동작이고, 본래 라이브러리에 속한다. test/services/ 아래 100줄을 넘는 service 테스트 파일이 있다면 그 service가 라이브러리화해야 할 프로토콜 / 외부 인터페이스를 테스트하고 있을 가능성이 크다.

Claude에게 이런 이관을 시키는 기준

"커뮤니티가 gem을 냈다"고 해서 모두 이관해야 하는 건 아니다. Claude에게 먼저 이 질문들을 하게 하라:

  1. 라이브러리 버전 번호. 0.x는 API가 아직 움직인다; 1.x에서 고정할 가치.
  2. 코드 감소 ≥ 200줄. 이번엔 순 -305줄. 순 감소 < 100줄이면 switching cost가 안 맞다.
  3. 테스트가 실제로 합쳐진다. 이관 후 테스트가 90% 같은 걸 새 stub으로 주장하고 있다면 — 동작이 라이브러리로 안 옮겨진 것, API 이름만 바뀐 것. 이관하지 마라.
  4. 설정이 한 곳으로 모인다. 자작 버전에서 USDC 컨트랙트 주소, 네트워크 이름, facilitator URL이 3곳에 흩어져 있었다. 이관 후 전부 29줄 initializer에 모였다. 이게 가치.
  5. 업그레이드 경로가 명확. 라이브러리는 앞으로 어떻게 올리나? breaking change changelog 규칙이 있나? 없다면 자체 adapter로 한 겹 감싸 gem이 50개 호출 지점으로 번지지 않게 하라.

이 5개를 통과하면 이관 프롬프트는 한 문장으로 족하다:

"x402-rails gem v1이 안정됐다. 현재 PaymentHandler + FacilitatorClient를 교체해. 컨트롤러의 엔드포인트와 응답 형식은 그대로 — 프로토콜 일만 gem으로 들어가면 돼. 테스트는 그에 맞게 model 층으로 이동."

Claude가 할 일: gem 문서 읽기 → initializer 작성 → controller 재작성 → 구 service 삭제 → 테스트 재구축. 중간에 두세 번 확인을 요청할 수 있다(예: "이 동작은 유지할까?"). 끝나면 bin/rails test 돌리고 전부 초록이면 커밋.

얻은 것

진짜 통찰은 "라이브러리가 자작보다 낫다"가 아니다. 자작이 맞을 때도 있다 — 프로토콜 커스터마이징, 지연 민감, 규정 준수.

진짜 판단점은:

너의 services/ 폴더에 있는 그 파일, 프로토콜이 업데이트될 때마다 바꿔야 하는 그 파일 — 그 일을 전담으로 유지보수하는 gem이 이미 존재하는가?

그렇다면 그건 너의 비즈니스 로직이 아니다. 너의 프로젝트에 들여다 키운 "프로토콜 길들이기" 길고양이다. 2주 먹이고 잘 돌아가고 있지만, 너의 것이 아니다. Claude에게 시켜 커뮤니티로 돌려보내라. 너에게 남는 건 프로토콜 결과를 너의 model에 써넣는 부분 — 그게 너의 프로젝트 고유 로직이다.

이관 후 내 x402 디렉터리에 남은 건: 29줄 initializer + 4줄 controller 호출 + record_x402! 메서드 두 개. 자작 버전의 139줄 서비스 층과 그에 딸린 220줄 서비스 층 테스트 — 전부 사라졌다. 코드는 줄고 동작은 같고 테스트는 더 정확하다. 이게 성공한 이관이다.