온체인 데이터에 "23s ago" 라벨을 제안했더니, Claude가 그게 제품의 거짓말이라고 짚어줬다.
smarts.md는 스마트 컨트랙트의 live docs 사이트입니다. 각 컨트랙트 페이지에는 온체인에서 직접 읽어온 값들이 나열돼 있습니다 — Uniswap pool의 liquidity, ERC-20의 totalSupply, 커스텀 컨트랙트의 owner. 이 값들은 최대 60초 전 스냅샷일 수 있습니다 (Solid Cache TTL = 60s).
그런데 페이지에는 "언제 읽었는지"에 대한 표시가 전혀 없었습니다. Mintlify처럼 보였고, 정적 문서와 구분되지 않았죠.
Claude와 함께 데이터 갱신 전략 감사를 하면서 6개 갭을 정리했습니다. 갭 4는 한 줄짜리:
live 데이터에 신선도 표시가 없음 — "as of block X / Y seconds ago" 라벨 추가.
작성을 마치고 점심을 먹으러 갔습니다. 돌아와서 다시 보다가 그 한 줄에서 한 시간을 멈췄습니다. 결국 출시한 방향은 처음 직감과 완전히 달랐고 — Claude 덕분에 깨달았습니다, 원래 직관 안은 그 자체로 제품의 거짓말이었다는 걸.
처음 떠올린 건 이런 모양이었습니다:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
단순, 직접적, 사용자가 즉시 이해.
점심 후에 다시 보면서 Claude에 몇 가지 질문을 던졌습니다:
이 "23s"는 정확히 무엇에서 무엇을 뺀 값이야?
Claude가 명확히 답했습니다: now - cache.fetched_at. 즉, 캐시에 쓴 이후 흐른 시간.
그럼 체인 헤드 기준 실제 지연은 얼마야?
Claude가 잠시 생각하고 말했습니다: 당신의 Multicall은 모든 view 함수를 한 번에 읽고 캐시에 쓰며, 캐시는 60초 동안 유효합니다. 그런데 캐시 쓰기 시점에 읽은 block 자체가 이미 과거의 것 — Ethereum 블록 타임 하한 0–12s.
실제 인과 사슬은 이렇습니다:
체인 헤드에서 새 block 생성
└→ RPC가 block N을 읽음 (헤드보다 0–12s 뒤)
└→ Solid Cache에 기록
└→ 사용자 화면 렌더링 (캐시 최대 60s)
그러면 "23s ago" 라벨의 23s는 어느 구간에 해당하지?
마지막 구간만: now - fetched_at, 캐시 쓰기에서 지금까지의 시간.
앞쪽의 체인 측 지연은 전혀 반영되지 않습니다.
구체적인 예: 사용자가 "fetched 5s ago"를 보지만, 그 block 자체가 이미 12초 전 것이고, 지금 체인 헤드는 또 2 블록 더 나아갔을 수 있다 — 실제 체인 지연은 약 24초, 라벨은 5초.
그 순간 깨달았습니다: "신선도"를 하나의 숫자로 압축하면, 그 숫자 자체가 물리적으로 틀렸다.
Claude에게 정리를 부탁한 결과 — 세 개의 독립된 차원이 있습니다:
| 차원 | 의미 | 물리적 하한 |
|---|---|---|
| 블록 신선도 | 체인 헤드 → 우리가 읽은 block | Ethereum 블록 타임 0–12s |
| 읽기 신선도 | RPC 읽기 → cache 쓰기 | 0 (동기) |
| 표시 신선도 | cache 쓰기 → 사용자 화면 | 0–60s (cache TTL) |
세 개를 합쳐 "freshness: 35s" 하나로 표시하는 건 물리적으로 존재하지 않습니다. chain → cache → screen의 인과 사슬은 동기적이지 않습니다: cache는 쓰자마자 얼어붙지만, 체인은 계속 블록을 만들어냅니다. 정밀도 문제가 아니라 카테고리 오류입니다.
더 중요한 건: 세 구간 중 어느 게 중요한지는 필드 자체에 따라 다르다.
decimals: 생성자에서 정의되고 다시는 변하지 않음. 신선도 표시는 전혀 필요 없음.owner: 거버넌스로만 변하고, 몇 년간 그대로일 수 있음. 어느 block에서 읽었는지는 알아야 함 ("다섯 번 업그레이드 전" 값을 보고 있지 않은지 확인). 하지만 "23초 전"은 필요 없음.liquidity / slot0: 매 블록마다 변할 수 있음. block과 시간 둘 다 중요.block number는 유일한 객관적 앵커 — 체인 위의 진실이고, 우리의 캐싱 전략으로 왜곡되지 않습니다.
최종 표시:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
block은 아는 사람용, "23s ago"는 모르는 사람용. 어느 것도 거짓말하지 않습니다: block은 물리적 사실이고, "23s ago"는 명확히 캐시 쓰기에서 지금까지의 시간이지 "체인 헤드 대비 지연"이 아닙니다.
필드 속성으로 표시를 분리:
# app/services/chain_reader/field_mutability.rb
module ChainReader
module FieldMutability
IMMUTABLE = %w[
name symbol decimals
DOMAIN_SEPARATOR PERMIT_TYPEHASH
factory token0 token1 fee tickSpacing
asset underlying
].freeze
SLOW = %w[
owner
paused deprecated
pauser blacklister masterMinter rescuer
upgradedAddress implementation
maxTotalSupply
].freeze
def self.classify(function_name)
return :immutable if IMMUTABLE.include?(function_name)
return :slow if SLOW.include?(function_name)
:fast
end
end
end
렌더링 규칙:
| 분류 | 예 | 표시 |
|---|---|---|
| immutable | decimals, name, symbol |
신선도 표시 없음 (영원히 안 변함, 노이즈) |
| slow | owner, paused |
block만, 초 단위 없음 |
| fast | slot0, liquidity, totalSupply |
block + 초 |
이 화이트리스트는 의도적으로 보수적입니다 — 리스트 밖은 모두 fast로 간주. "변할 수 있음"으로 과하게 표시하는 게, 가변 값을 조용히 정적으로 보여주는 것보다 낫습니다.
Multicall3 자체에 getBlockNumber() 함수가 있습니다. 매 batch 끝에 붙이기만 하면 됩니다 — RPC 한 번, 값 하나 추가, 지연은 거의 없음:
# app/services/chain_reader/multicall3_client.rb
BLOCK_NUMBER_FN = {
"name" => "getBlockNumber",
"inputs" => [],
"outputs" => [{ "type" => "uint256" }]
}.freeze
def call(calls)
block_call = Call.new(target: ADDRESS, function: BLOCK_NUMBER_FN)
augmented = calls + [block_call]
# ...aggregate3로 한 번에 실행
Batch.new(block_number: block_number, results: results)
end
ViewCaller의 캐시 payload도 버전 업 (CACHE_VERSION = "v2"). {fn_sig => result}에서 Snapshot(results:, block_number:, fetched_at:)로. Snapshot은 def [](key) = @results[key] 같은 위임 메서드로 기존 hash 인터페이스와 호환되므로, 기존 호출 측은 수정할 필요가 없습니다.
UI 헬퍼는 이런 모양:
def freshness_tag_for(fn)
return nil unless @live_snapshot&.block_number
mutability = ChainReader::FieldMutability.classify(fn["name"])
return nil if mutability == :immutable
block_text = "Block ##{number_with_delimiter(@live_snapshot.block_number)}"
label =
if mutability == :fast && @live_snapshot.fetched_at
"as of #{block_text} · #{freshness_phrase(@live_snapshot.fetched_at)}"
else
"as of #{block_text}"
end
# ...
end
전체 PR: 24개 파일, +676 / -55.
배포 후, 차별화 포인트를 다시 썼습니다:
Smarts는 필드별로, 그 상태가 어느 block에서 읽혔는지 명시적으로 알려주는 최초의 DeFi 문서입니다.
"우리는 Mintlify보다 신선합니다"보다 더 구체적이고, 신뢰의 경계가 분명합니다. 실제 지연을 드러내고 숨기지 않는다 — CLAUDE.md에 적은 두 원칙과 일치합니다: "AI는 도구지 전시품이 아니다" + "Build in Public".
Claude와 1년 넘게 일한 가장 큰 깨달음은 코드 작성 속도가 아니라, 멈춰서 물어보기만 하면 내 안을 진지하게 반박해준다는 것입니다.
내가 "23s ago 라벨 추가"를 제안했을 때, 스스로는 잘못된 점을 보지 못했습니다. 내 PR을 내가 리뷰했더라도 아마 통과시켰을 겁니다 — 너무 "당연해 보이는" 안이니까.
그런데 어조를 바꿔 Claude에 "이 23s는 정확히 뭘 빼는 거고, 어느 지연 구간에 해당해?"라고 물었더니, 인과 사슬을 펼쳐서 그 숫자의 물리적 의미가 틀렸다는 걸 보여줬습니다.
이 비용은 "Claude가 코드를 더 써줬다"가 아니라, "제품의 거짓말 하나를 프로덕션에 내보내지 않게 해줬다"입니다.
23s ago가 정말 출시됐다면 아무도 항의하지 않았을 겁니다 — 그럴듯해 보이고, 알아챌 사람은 굳이 지적하지 않으니까. 하지만 분야를 아는 사람의 눈에는 당신의 DeFi 문서 사이트에 구멍이 있는 셈입니다: 이 사이트는 자기가 뭘 보여주는지 모른다.
다음에 Claude와 제품 결정을 할 때, 이 단계에 5분만 써보세요:
"방금 내가 낸 안에 대해, 잘못될 수 있는 방식 3가지를 들어줘. 각각 구체적인 시나리오로."
Claude가 당신이 생각 못 한 한 가지를 찾아낸다면, 그 5분은 본전을 뽑은 셈입니다.