Free

Claude가 내가 직접 낸 "23s ago" 안을 무너뜨린 이야기

온체인 데이터에 "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 덕분에 깨달았습니다, 원래 직관 안은 그 자체로 제품의 거짓말이었다는 걸.


첫 직감: "23s ago" 그냥 붙이기

처음 떠올린 건 이런 모양이었습니다:

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 + age 병렬, 필드별로 분류

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로 간주. "변할 수 있음"으로 과하게 표시하는 게, 가변 값을 조용히 정적으로 보여주는 것보다 낫습니다.

구현 세부: block number는 사실상 무료

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에게 반대 입장으로 토론하게 하라

Claude와 1년 넘게 일한 가장 큰 깨달음은 코드 작성 속도가 아니라, 멈춰서 물어보기만 하면 내 안을 진지하게 반박해준다는 것입니다.

내가 "23s ago 라벨 추가"를 제안했을 때, 스스로는 잘못된 점을 보지 못했습니다. 내 PR을 내가 리뷰했더라도 아마 통과시켰을 겁니다 — 너무 "당연해 보이는" 안이니까.

그런데 어조를 바꿔 Claude에 "이 23s는 정확히 뭘 빼는 거고, 어느 지연 구간에 해당해?"라고 물었더니, 인과 사슬을 펼쳐서 그 숫자의 물리적 의미가 틀렸다는 걸 보여줬습니다.

이 비용은 "Claude가 코드를 더 써줬다"가 아니라, "제품의 거짓말 하나를 프로덕션에 내보내지 않게 해줬다"입니다.

23s ago가 정말 출시됐다면 아무도 항의하지 않았을 겁니다 — 그럴듯해 보이고, 알아챌 사람은 굳이 지적하지 않으니까. 하지만 분야를 아는 사람의 눈에는 당신의 DeFi 문서 사이트에 구멍이 있는 셈입니다: 이 사이트는 자기가 뭘 보여주는지 모른다.

다음에 Claude와 제품 결정을 할 때, 이 단계에 5분만 써보세요:

"방금 내가 낸 안에 대해, 잘못될 수 있는 방식 3가지를 들어줘. 각각 구체적인 시나리오로."

Claude가 당신이 생각 못 한 한 가지를 찾아낸다면, 그 5분은 본전을 뽑은 셈입니다.