~/blockchain-dev.md
BLOCKCHAIN_DEV

업그레이드 가능한 컨트랙트에서 storage slot은 왜 API 계약일까: ERC-7201로 보는 namespaced storage

10분 읽기·2026.07.29·출처 5·00
오늘의 질문

프록시 컨트랙트를 업그레이드할 때, 새 변수를 아래에 하나 추가하는 일이 왜 사용자 자산을 바꾸는 상태 전이 리스크가 될까?

blockchain-dev.md
ERC-7201 Storage

업그레이드 가능한 컨트랙트에서 storage slot은 왜 API 계약일까: ERC-7201로 보는 namespaced storage

1. 왜 지금 봐야 하나

스마트컨트랙트 업그레이드를 처음 배우면 보통 프록시 패턴을 “주소를 유지하고 로직만 갈아끼우는 방법”으로 이해한다. 하지만 실제 위험은 로직 교체보다 더 낮은 층에 있다. 프록시는 delegatecall로 새 구현 컨트랙트의 코드를 실행하지만, 상태는 프록시의 storage에 남는다. 즉 사용자가 맡긴 잔액, role, nonce, pause 상태, oracle 설정은 모두 같은 storage slot에 계속 있어야 한다.

그래서 업그레이드 가능한 컨트랙트의 storage layout은 내부 구현 디테일이 아니라 API 계약에 가깝다. 함수 시그니처를 함부로 바꾸면 클라이언트가 깨지듯, storage slot 의미를 함부로 바꾸면 기존 상태가 다른 변수로 해석된다. 예를 들어 예전 구현에서 slot 0이 owner였는데 새 구현에서 slot 0을 totalSupply로 읽으면, 컴파일은 되더라도 체인 위 상태 의미는 무너진다.

ERC-7201은 이 문제를 “조심해서 변수 순서를 유지하자” 수준에서 끝내지 않는다. @custom:storage-location erc7201:<namespace>라는 NatSpec annotation과 표준화된 slot 계산식을 정의해, 모듈별 storage 영역을 독립 namespace로 나누는 방법을 제안한다. OpenZeppelin Contracts 5.0도 upgrade 보안을 위해 이 namespaced storage 패턴을 사용한다고 설명한다.

오늘의 핵심은 이것이다.

업그레이드 가능한 컨트랙트에서 storage slot은 데이터베이스 컬럼이 아니라, 이전 구현과 다음 구현이 공동 서명한 상태 전이 계약이다.

2. 핵심 개념

Solidity의 기본 storage layout은 단순해 보인다. 상태 변수는 slot 0부터 순서대로 배치되고, 작은 값들은 한 slot에 packing될 수 있다. mapping과 dynamic array는 keccak 기반 위치를 사용한다. 상속이 들어가면 C3 linearization 순서에 따라 base contract부터 배치된다. Solidity 문서는 이 layout 규칙을 명시한다.

문제는 upgradeable proxy에서 생긴다.

  1. 프록시 주소가 사용자와 자산의 진짜 주소가 된다.
  2. 구현 컨트랙트는 코드만 제공한다.
  3. delegatecall 때문에 구현 코드가 읽고 쓰는 storage는 프록시 storage다.
  4. 다음 업그레이드 구현도 같은 slot 의미를 유지해야 한다.

전통적인 해결책은 __gap이었다. base contract마다 미래 변수를 위해 빈 배열 slot을 예약해두고, 다음 버전에서 그 공간을 일부 소비하는 방식이다. 하지만 상속 구조가 복잡해지고 여러 모듈이 동시에 진화하면 “어느 gap을 얼마나 줄였는지”를 사람이 계속 맞춰야 한다. 실수하면 storage collision이 난다.

ERC-7201의 namespaced storage는 관점을 바꾼다.

  • 각 모듈의 상태를 struct로 묶는다.
  • struct에 @custom:storage-location erc7201:<namespace_id>를 붙인다.
  • root slot은 keccak256(keccak256(id) - 1) & ~0xff 공식으로 계산한다.
  • 그 namespace 안에서는 Solidity의 일반 storage layout처럼 변수를 배치한다.

TokenStorage, AccessStorage, VaultStorage가 모두 slot 0부터 경쟁하는 것이 아니라, 각자 멀리 떨어진 root에서 독립 storage tree를 갖는 구조다.

3. 최신 이슈와 연결

OpenZeppelin은 Contracts 5.0 소개 글에서 namespaced storage를 “better upgrades security”로 설명했다. 특히 이전 upgradeable contracts가 __gap을 사용해 미래 변수를 위한 공간을 비워두던 것과 달리, 5.0에서는 ERC-7201 기반 namespace를 사용해 이전 변수 훼손 없이 변수를 추가할 수 있다고 정리했다.

OpenZeppelin 문서도 upgradeable variant를 사용할 때 생성자 대신 initializer를 쓰고, minor version 간 storage incompatibility를 검사한다고 설명한다. 이 말은 “라이브러리가 알아서 안전하다”가 아니다. 더 정확히는 다음 두 가지 책임을 분리한다는 뜻이다.

  • 컴파일러와 라이브러리의 책임: storage layout 규칙, initializer 패턴, upgradeable variant 제공
  • 프로젝트 개발자의 책임: namespace id 안정성, 변수 삭제/재정렬 금지, migration 함수 검토, upgrade 권한 통제

ERC-7201이 표준화한 것은 storage namespace의 문서화와 root 계산식이다. 이 annotation이 Solidity AST에 포함되기 때문에 static analyzer, explorer, upgrade plugin이 “이 struct는 숨은 assembly storage가 아니라 표준 namespace storage다”라고 해석할 수 있다.

4. 개발자 관점 해석

1) Storage layout은 ABI보다 더 오래 간다

ABI가 바뀌면 프론트엔드 호출이 실패한다. Storage layout이 바뀌면 호출은 성공하지만 잘못된 상태를 읽을 수 있다. 이게 더 위험하다. 실패가 아니라 “정상 실행처럼 보이는 오염”이기 때문이다.

업그레이드 리뷰에서 다음 질문을 먼저 던져야 한다.

  • 새 구현이 기존 slot을 같은 타입과 같은 의미로 읽는가?
  • base contract 상속 순서가 바뀌지 않았는가?
  • enum, struct packing, mapping root가 의도치 않게 변하지 않았는가?
  • initializer/reinitializer가 한 번만 실행되는가?

2) Namespace는 마법 방패가 아니다

ERC-7201은 namespace끼리 충돌하지 않게 돕는다. 하지만 namespace 안의 변수 순서를 바꾸면 여전히 깨진다. VaultStorage 안에서 uint256 totalAssetsaddress manager의 순서를 바꾸면 같은 namespace 안에서 의미가 바뀐다.

따라서 namespace를 쓰더라도 규칙은 필요하다.

  • 기존 변수는 삭제하지 않는다.
  • 기존 변수 타입을 바꾸지 않는다.
  • 기존 변수 순서를 바꾸지 않는다.
  • 새 변수는 해당 namespace struct의 뒤에 추가한다.
  • namespace id 문자열은 public API처럼 취급한다.

3) 모듈 설계가 보안 경계가 된다

Namespaced storage는 모듈별 상태 격리를 쉽게 만든다. 하지만 이것은 곧 “어떤 상태를 어느 namespace에 둘 것인가”가 설계 문제가 된다는 뜻이다.

예를 들어 vault에서 회계 상태와 권한 상태를 같은 namespace에 섞으면, migration 검토 때 책임 경계가 흐려진다. 반대로 VaultAccountingStorage, VaultRiskStorage, VaultAccessStorage처럼 도메인을 나누면 reviewer가 “이번 upgrade는 risk parameter만 건드린다”는 사실을 더 쉽게 검증할 수 있다.

4) Upgrade authority와 storage migration은 한 쌍이다

Storage layout만 안전해도 upgrade 권한이 허술하면 의미가 없다. 새 구현이 악의적으로 같은 namespace를 읽어 자금을 전송할 수 있기 때문이다. 반대로 권한이 안전해도 migration 함수가 잘못되면 상태가 깨진다.

그래서 upgrade 계획에는 코드 diff뿐 아니라 다음이 같이 들어가야 한다.

  • storage layout diff
  • namespace id 목록
  • initializer/reinitializer 호출 계획
  • migration 전후 invariant
  • rollback 또는 pause 조건
  • timelock와 multisig 승인 경로

5. 내 프로젝트에 적용할 체크포인트

  • 프록시를 쓰는 컨트랙트인지 먼저 표시한다. Transparent, UUPS, Beacon, Diamond 여부를 문서 첫 줄에 적는다.
  • 각 implementation version의 storage layout artifact를 저장한다.
  • @custom:storage-location erc7201:<namespace>를 쓰는 경우 namespace id를 변경 불가 계약으로 취급한다.
  • namespace struct 내부에서는 기존 필드 삭제, 타입 변경, 순서 변경을 금지한다.
  • 새 변수 추가는 namespace struct의 끝에만 한다.
  • upgrade PR에는 “storage 변경 없음 / 새 필드 추가 / migration 필요” 중 하나를 반드시 적는다.
  • initializer와 reinitializer가 중복 실행될 수 없는지 테스트한다.
  • upgrade 후 owner, roles, paused, totalSupply, balances, oracle, feeRecipient 같은 핵심 상태를 샘플링해 이전 값과 비교한다.
  • 권한 변경과 storage migration을 같은 트랜잭션에 묶을지, timelock으로 나눌지 명시한다.

6. 오늘 10분 액션

  1. 내 프로젝트 또는 자주 쓰는 오픈소스 upgradeable contract 하나를 고른다.
  2. storage, __gap, StorageLocation, @custom:storage-location을 검색한다.
  3. 상태 변수를 회계, 권한, 설정, 사용자별 기록, 외부 oracle/cache로 분류한다.
  4. 다음 upgrade에서 새 변수를 추가한다면 어느 namespace/slot 뒤에 붙어야 하는지 적어본다.
  5. upgrade PR 템플릿에 아래 한 줄을 추가한다.
zsh — 생존확인.sh
Storage layout impact: none / append-only in namespace ___ / migration required with invariant ___

7. 더 볼 자료

  • ERC-7201의 공식 계산식과 rationale을 읽어보면, 왜 단순히 keccak256(id)를 root로 쓰지 않고 keccak256(keccak256(id) - 1) & ~0xff를 사용하는지 이해할 수 있다.
  • Solidity storage layout 문서는 packing, inheritance, mapping/dynamic array root 규칙을 확인하는 기준 문서다.
  • OpenZeppelin Upgrades 문서는 constructor 대신 initializer를 써야 하는 이유와 upgradeable variant 사용법을 정리한다.

중복 회피 메모

로컬 content/generated와 Supabase의 최근 blockchain_dev 글을 확인했다. 최근 글은 L2BEAT Stage 2 upgrade authority, AFX hot-validator bridge key 운영, OP Stack interop fork choice, Chainlink SVR/OEV, Arbitrum Timeboost sequencing, EIP-4444 history expiry, EIP-7917 proposer lookahead, EIP-7907 code size gas, EIP-7939 CLZ opcode, EIP-7825/EIP-7934 gas·block size 경계, EIP-7702 지갑 delegation, ProxyAdmin 멀티시그 운영을 다뤘다. 이번 글은 특정 프록시 관리자 사고나 EVM gas repricing이 아니라, ERC-7201 namespaced storage와 OpenZeppelin Contracts 5.x upgradeable 패턴을 바탕으로 storage slot 자체를 upgrade API 계약으로 보는 관점에 집중해 중복을 피했다.

오늘 10분 액션+5%

댓글 0

최신순 ▾
한 줄 남기기

혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)