자작 → 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/Subscription의 record_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-rails랑x402-fetch지금 성숙해졌어? 그렇다면 이관해줘."
Claude가 README와 changelog를 읽고 보고한다: v1 프로토콜 안정, non-optimistic 모드로 settlement 결과를 받을 수 있음, facilitator 기본값 payai.network. 이관 가능.
이관 후 같은 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를 달아 재시도/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 호출로 교체.
JS 쪽에서는 자작 버전이 직접 서명을 조립하고 window.ethereum.request를 직접 호출했다. 라이브러리로 바꿔 viem과 x402-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 캐시), 다음 번은 즉시.
자작 버전은 다른 프로젝트의 참조 구현에서 복사해 온 것이었다. 이관하는 김에 Claude에게 쌓인 냄새를 훑어보라고 시켰더니 3개 나왔다:
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는 연결 팝업도 띄운다 — 사용자가 아직 이 사이트에 지갑을 연결한 적 없다면 여기가 인가 진입점이다.
구코드:
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" })
구코드에선 "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에도 없는 함정이 두 개 있었다.
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에게 양쪽 모두 확인시켜라.
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.rb에 record_x402! 테스트 40줄
- test/models/subscription_test.rb에 record_x402! 테스트 69줄
서비스 층(프로토콜이 어떻게 돌아가는가) 테스트가 전부 사라졌다. 대신 model 층(결제 성공 후 데이터가 어떻게 기록되는가) 테스트가 생겼다.
타당하다 — 프로토콜 동작은 gem 책임이고 gem 스스로 테스트한다. 너는 네가 쓴 부분만 테스트하면 된다: settlement 결과를 받은 뒤 Purchase / Subscription 행을 어떻게 insert할지, tx_hash를 어떻게 저장할지.
이건 "이관해야 하나"의 강한 신호이기도 하다: 테스트에 "내가 보내는 payload 형식이 맞는가", "facilitator가 isValid=false를 반환할 때 어떻게 처리하는가" 같은 덩어리가 많다면 — 그건 프로토콜 동작이고, 본래 라이브러리에 속한다. test/services/ 아래 100줄을 넘는 service 테스트 파일이 있다면 그 service가 라이브러리화해야 할 프로토콜 / 외부 인터페이스를 테스트하고 있을 가능성이 크다.
"커뮤니티가 gem을 냈다"고 해서 모두 이관해야 하는 건 아니다. Claude에게 먼저 이 질문들을 하게 하라:
0.x는 API가 아직 움직인다; 1.x에서 고정할 가치.이 5개를 통과하면 이관 프롬프트는 한 문장으로 족하다:
"
x402-railsgem 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줄 서비스 층 테스트 — 전부 사라졌다. 코드는 줄고 동작은 같고 테스트는 더 정확하다. 이게 성공한 이관이다.