Upgradeable Proxy와 운영 보안
도입
Upgradeable proxy는 "나중에 고칠 수 있는 편리한 배포 방식"이 아니다. 사용자가 계속 같은 주소를 호출하게 두고, 그 주소가 바라보는 코드 해석기를 바꾸는 운영 권한이다. 스테이블코인에서는 이 권한이 mint, freeze, pause, fee, permit, cross-chain minting 정책을 한 번에 바꿀 수 있기 때문에 단순 개발 편의가 아니라 최고 위험 권한으로 다뤄야 한다.
이번 강의의 목표는 proxy 패턴 이름을 외우는 것이 아니다. 업그레이드가 실제 서비스에서 어떤 evidence를 요구하는지, 어떤 검증이 빠지면 잔액과 권한이 망가지는지, 운영팀이 어떤 순서로 승인하고 감시해야 하는지를 문서로 만들 수 있어야 한다.
학습 목표
- proxy, implementation, admin, initializer, monitoring을 분리해 설명한다.
- storage layout 변경이 잔액, allowance, nonce에 미치는 영향을 추적한다.
- Transparent Proxy, UUPS, beacon의 운영 책임 차이를 비교한다.
- 업그레이드 제안서와 사후 smoke test를 release gate로 작성한다.
개념 설명
Adopt
Trial
Assess
Hold
1. Proxy를 세 개의 객체로 나누어 본다
Upgradeable contract를 볼 때 "컨트랙트 하나"라고 생각하면 사고가 난다. 사용자가 호출하는 주소, 실제 로직 코드, 업그레이드 권한자가 분리되어 있기 때문이다.
| 구성 | 무엇을 담당하는가 | 실무에서 확인할 것 |
|---|---|---|
| Proxy | 사용자가 호출하는 고정 주소와 영구 storage | chain ID, proxy address, implementation slot, admin slot |
| Implementation | proxy storage를 해석하는 로직 코드 | compiler version, bytecode, selector diff, storage layout |
| Proxy admin 또는 upgrade 권한 | implementation 주소를 바꾸는 권한 | multisig, timelock, guard, role owner, emergency path |
| Initializer | constructor 대신 proxy storage를 초기화하는 함수 | 한 번만 호출되는지, parent initializer가 빠지지 않았는지 |
| Monitoring | 업그레이드 후 실제 상태 변화를 감시하는 체계 | Upgraded, AdminChanged, role event, smoke test 결과 |
OpenZeppelin Upgrades Plugins는 deployProxy와 upgradeProxy 과정에서 구현체 배포, proxy 생성, initializer 실행, upgrade safety 검증을 지원한다. 하지만 플러그인이 있다고 해서 운영 책임이 사라지는 것은 아니다. production에서는 "도구가 통과했다"와 "서비스가 안전하다"를 분리해서 증거를 남겨야 한다.
2. 주소는 같아도 코드 해석은 바뀐다
proxy의 핵심은 delegatecall이다. 사용자는 proxy 주소를 호출하고, proxy는 implementation 코드를 실행하지만 상태는 proxy storage에 남긴다. 그래서 V2가 같은 storage slot을 다르게 해석하면 기존 잔액, allowance, nonce, role 값이 다른 의미로 읽힌다.
이 구조에서 업그레이드 리뷰는 새 implementation 코드 리뷰만으로 끝나지 않는다. proxy 주소 기준으로 기존 상태가 유지되는지, signer nonce가 유지되는지, 이미 발급된 permit/authorization이 예상대로 처리되는지, indexer가 같은 contract address의 ABI 변경을 따라갈 수 있는지까지 확인해야 한다.
3. Storage layout은 잔액표의 좌표계다
다음처럼 V1이 배포되어 있다고 하자.
| Slot | V1 의미 |
|---|---|
| 0 | _balances mapping root |
| 1 | _allowances mapping root |
| 2 | _totalSupply |
| 3 | _nonces mapping root |
| 4 | _roles |
V2에서 bool paused를 맨 앞에 추가하면 "변수 하나를 추가한 것"처럼 보이지만 proxy storage 해석은 달라진다.
| Slot | V2가 기대하는 의미 | 실제 proxy에 들어 있던 값 |
|---|---|---|
| 0 | paused | _balances mapping root |
| 1 | _balances mapping root | _allowances mapping root |
| 2 | _allowances mapping root | _totalSupply |
| 3 | _totalSupply | _nonces mapping root |
| 4 | _nonces mapping root | _roles |
이런 변경은 테스트넷에서 "transfer 한 번 성공"으로 발견되지 않을 수 있다. 그래서 upgrade review에는 반드시 storage layout diff가 들어가야 한다. 새 변수는 기존 storage 뒤에 붙이고, upgradeable base contract의 storage gap이나 namespace 규칙을 사용하는 이유도 이 때문이다.
4. Initializer는 배포 스크립트가 아니라 보안 경계다
upgradeable contract는 constructor가 proxy storage에 적용되지 않는다. 초기 상태는 initializer로 설정해야 하며, initializer는 한 번만 실행되어야 한다. parent contract가 있다면 parent initializer도 빠짐없이 호출해야 한다.
| 점검 항목 | 실패하면 생기는 일 |
|---|---|
| initializer가 한 번만 호출되는가 | admin, minter, pauser가 재설정될 수 있다 |
| parent initializer가 호출되는가 | ERC20 name/symbol, access control, permit domain이 비어 있을 수 있다 |
| implementation 자체가 잠겨 있는가 | 공격자가 implementation을 직접 initialize해 권한을 잡을 수 있다 |
| reinitializer가 의도적으로 설계되었는가 | V2 기능 초기화가 빠지거나 중복 실행될 수 있다 |
| deploy script가 initializer calldata를 기록하는가 | 사후 감사에서 초기 권한의 출처를 설명할 수 없다 |
OpenZeppelin 문서는 upgradeable contract에서 initializer 패턴과 _disableInitializers() 사용을 강조한다. 강의에서는 이 내용을 "코딩 문법"이 아니라 "운영자가 초기 권한을 증명하는 방식"으로 받아들여야 한다.
5. Transparent, UUPS, beacon은 권한 위치가 다르다
세 패턴의 차이는 이름보다 "누가 upgrade logic을 가지고 있는가"로 이해하면 된다.
| 패턴 | upgrade logic 위치 | 장점 | 주의할 점 |
|---|---|---|---|
| Transparent Proxy | proxy/admin 쪽 | user call과 admin call을 분리하기 쉽다 | ProxyAdmin owner 관리가 핵심 위험이다 |
| UUPS | implementation 쪽 | proxy가 가볍고 확장성이 좋다 | _authorizeUpgrade가 잘못되면 누구나 upgrade할 수 있다 |
| Beacon | 별도 beacon contract | 여러 proxy를 한 번에 같은 implementation으로 바꿀 수 있다 | 한 beacon 사고가 여러 instance로 확산된다 |
스테이블코인 checkout 시스템에서 단일 payment escrow만 업그레이드한다면 Transparent 또는 UUPS가 후보가 된다. 여러 merchant별 proxy가 같은 로직을 공유한다면 beacon도 검토할 수 있지만, beacon owner와 영향 범위를 더 강하게 제한해야 한다.
6. Stablecoin에서 위험한 변경은 기능보다 상태와 권한이다
아래 변경은 "기능 추가"처럼 보이지만 실제로는 결제, 정산, compliance, indexer를 동시에 흔든다.
| 변경 | 왜 위험한가 | release 전에 필요한 증거 |
|---|---|---|
| transfer hook 추가 | 기존 transfer, refund, settlement가 새 조건에서 revert할 수 있다 | 대표 merchant route smoke test, pause fallback |
| freeze 정책 변경 | compliant user가 막히거나 blocked user가 통과할 수 있다 | allow/deny 시나리오, support runbook |
| decimals/name/symbol 변경 | 회계, display, indexer가 잘못 계산할 수 있다 | 변경 금지 원칙 또는 migration plan |
| permit domain 변경 | 기존 서명 UX와 relayer flow가 깨진다 | domain separator diff, old signature 처리 정책 |
| role admin 변경 | mint/freeze/upgrade 권한이 예상 밖 주소로 이동할 수 있다 | role graph diff, multisig signer 확인 |
| storage 변수 삽입 | balances, allowances, nonces가 손상될 수 있다 | storage layout report, fork migration test |
| cross-chain mint 로직 변경 | source burn과 destination mint reconciliation이 틀어질 수 있다 | CCTP/bridge state replay test |
강의 과제에서는 이 표를 release checklist로 바꾼다. 각 행마다 "테스트 이름", "증거 링크", "승인자", "실패 시 rollback 또는 pause 조건"을 붙이면 운영 문서가 된다.
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
컨트랙트UUPSUpgradeable + _authorizeUpgrade 가드
OZ UUPS 패턴에서는 implementation 자신이 upgrade 권한을 검사한다.
_authorizeUpgrade에 timelock·multisig를 그대로 박는다.
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";contract StablecoinV2 is UUPSUpgradeable, AccessControlUpgradeable { bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); function initialize(address admin) public initializer { __AccessControl_init(); __UUPSUpgradeable_init(); _grantRole(DEFAULT_ADMIN_ROLE, admin); } function _authorizeUpgrade(address newImpl) internal override onlyRole(UPGRADER_ROLE) { // UPGRADER_ROLE 은 production 에서 timelock + multisig 주소만 가져야 함 // 추가 가드: newImpl 이 EOA 가 아닌지, 등록된 implementation registry 에 있는지 검증 가능 }}컨트랙트Storage gap 으로 미래 변수 추가 자리 비워두기
upgrade 시 새 state 변수를 추가하려면 layout이 호환되어야 한다. 부모 컨트랙트마다
__gap을 두면 storage 충돌을 피한다.
abstract contract StablecoinStorageV1 { mapping(address => uint256) internal _balances; mapping(address => mapping(address => uint256)) internal _allowances; uint256 internal _totalSupply; mapping(address => uint256) public nonces; // V2 이상에서 새 변수 추가 시 이 gap 을 사용한다 — 절대 변수 순서 변경 금지 uint256[46] private __gap;}contract StablecoinV2 is StablecoinStorageV1 { mapping(address => bool) public isFrozen; // V2 에서 추가, __gap[45] 자리 사용 bool public paused;}운영Storage layout diff 검증 (CI)
forge inspect storageLayout을 V1·V2에서 비교해 변수 순서·타입 변경을 차단한다. 또는 OZ Upgrades plugin의validateUpgrade로 자동 검증.
forge inspect src/StablecoinV1.sol:StablecoinV1 storageLayout > layout/v1.jsonforge inspect src/StablecoinV2.sol:StablecoinV2 storageLayout > layout/v2.json# OZ Upgrades plugin (Hardhat) — V1→V2 호환성 자동 검증npx hardhat upgrades:validate --reference StablecoinV1 --new StablecoinV2# 또는 storage-layout-diff 라이브러리 사용npx storage-layout-diff layout/v1.json layout/v2.json --fail-on conflict운영업그레이드 절차 — timelock + 사후 smoke test
정상 업그레이드는 propose → wait → execute 3단계. 긴급 패치는 별도 multisig 경로 + 강제 사후 공지.
# 1) implementation 배포만 (proxy 변경 X)forge create src/StablecoinV2.sol:StablecoinV2 --rpc-url $RPC --private-key $DEPLOY_KEY# 2) timelock 에 upgrade 제안 (24h delay)cast send $TIMELOCK \ "schedule(address,uint256,bytes,bytes32,bytes32,uint256)" \ $PROXY 0 $(cast calldata "upgradeTo(address)" $NEW_IMPL) \ 0x0 0x0 86400 \ --private-key $PROPOSER_KEY --rpc-url $RPC# 3) 24h 후 execute (multisig)cast send $TIMELOCK \ "execute(address,uint256,bytes,bytes32,bytes32)" \ $PROXY 0 $(cast calldata "upgradeTo(address)" $NEW_IMPL) \ 0x0 0x0 --private-key $EXECUTOR_KEY --rpc-url $RPC# 4) 사후 smoke test — proxy 주소에서 V2 함수 호출 확인cast call $PROXY "version()(string)" --rpc-url $RPCcast call $PROXY "totalSupply()(uint256)" --rpc-url $RPC# 5) Upgraded 이벤트 확인 + 모니터링 알림 발송강의 포인트
| 관점 | 수업 중 확인할 질문 | 산출물 |
|---|---|---|
| 상태 | V2가 proxy storage를 같은 의미로 읽는가 | storage layout diff와 fork test 결과 |
| 권한 | upgrade 권한이 어떤 multisig, timelock, guard를 거치는가 | role graph와 execution owner 표 |
| 사용자 영향 | 기존 결제, refund, permit, indexer가 깨지지 않는가 | proxy 주소 기준 smoke test |
| 운영 절차 | 정상 업그레이드와 긴급 패치가 어떻게 다른가 | normal/emergency runbook |
| 복구 | rollback만으로 회복되지 않는 데이터는 무엇인가 | 수동 보정, 공지, postmortem 체크리스트 |
실무 예시
운영7. 업그레이드 제안서는 코드 diff보다 넓어야 한다
업그레이드 제안서에 들어갈 최소 항목은 다음과 같다.
| 섹션 | 포함할 내용 |
|---|---|
| 목적 | 버그 수정, 규제 대응, chain 지원, 수수료 정책 등 변경 이유 |
| 영향 범위 | contract address, function selector, role, storage, event, ABI, indexer |
| 검증 증거 | unit/fuzz/invariant, fork test, storage layout diff, Slither/Echidna 결과 |
| 권한 경로 | proposer, reviewer, timelock, multisig signer, Safe guard |
| 실행 절차 | prepareUpgrade, proposal 생성, timelock queue, execution tx, smoke test |
| 중단 조건 | 테스트 실패, signer mismatch, unexpected diff, monitoring alert |
| 복구 절차 | pause, route disable, rollback implementation, refund/backfill |
| 커뮤니케이션 | merchant notice, status page, support macro, postmortem owner |
정상 업그레이드는 timelock과 사전 공지를 둔다. 긴급 패치는 시간을 줄일 수 있지만, 그 대신 더 강한 사후 공시, 더 좁은 변경 범위, 더 명확한 pause/rollback 조건이 있어야 한다. 긴급이라는 말은 검증 생략권이 아니라 영향 범위를 좁혀야 한다는 뜻이다.
8. Upgrade 직후 확인할 smoke test
실행이 끝났다면 "transaction 성공"만 보지 않는다. proxy 주소 기준으로 서비스가 계속 맞게 동작하는지 확인한다.
| 영역 | 확인 예시 |
|---|---|
| 주소와 slot | proxy address, implementation slot, admin slot이 proposal과 일치한다 |
| 권한 | minter, pauser, freezer, upgrader role owner가 예상 주소다 |
| 토큰 상태 | totalSupply, sample balances, allowance, nonce가 upgrade 전후 일치한다 |
| 결제 | permit/ERC-3009 payment, refund, settlement path가 동작한다 |
| compliance | frozen address transfer가 실패하고 정상 address transfer가 성공한다 |
| pause | pause 상태에서 막혀야 할 함수가 막히고 view 함수는 유지된다 |
| event | Upgraded, AdminChanged, role event가 indexer에 반영된다 |
| 운영 | dashboard alert, status page, rollback decision owner가 준비되어 있다 |
이 smoke test는 수동 체크리스트로만 남기지 말고 가능한 범위에서 스크립트로 만들어야 한다. 그래야 새벽 incident에서도 사람이 기억에 의존하지 않는다.
흔한 오해와 실패 시나리오
| 오해 | 실제 기준 |
|---|---|
| upgrade plugin이 통과하면 안전하다 | plugin 검증은 출발점이다. business invariant와 운영 runbook은 별도로 필요하다 |
| admin이 multisig면 충분하다 | signer 구성, timelock, guard, proposal 내용, 실행 로그까지 봐야 한다 |
| implementation만 테스트하면 된다 | 사용자는 proxy를 호출하므로 proxy 주소 기준 smoke test가 필요하다 |
| storage gap이 있으면 자동으로 안전하다 | gap을 어떻게 줄였는지, inherited layout이 어떻게 바뀌었는지 diff로 확인해야 한다 |
| rollback은 이전 implementation으로 돌리면 끝이다 | 이미 잘못 처리된 payment, event, indexer state, user notice까지 복구해야 한다 |
실습 과제
- 컨트랙트업그레이드 제안서 검수표 작성: stablecoin proxy 업그레이드 제안서에 포함해야 할 storage layout, selector, role, initializer, event, rollback 항목을 표로 정리한다.
- 운영Proxy 업그레이드 runbook 작성: normal upgrade와 emergency patch를 분리해 승인자, timelock, 실행 transaction, 사후 smoke test, 사용자 공지 조건을 작성한다.
- 컨트랙트[OPS] V2 storage 사고 시나리오 분석: V1의 balances/allowances/nonces 앞에 새 변수를 추가한 V2가 배포되었다고 가정하고, 어떤 상태가 손상되는지와 사후 조치 절차를 설명한다.
완료 기준
- proxy, implementation, admin, initializer, monitoring의 책임을 분리해 설명했다.
- storage layout diff와 initializer 검증 절차를 작성했다.
- Transparent Proxy, UUPS, beacon의 운영 책임 차이를 비교했다.
- 정상 업그레이드와 긴급 패치의 승인/실행/복구 절차를 분리했다.
- 업그레이드 직후 proxy 주소 기준 smoke test 목록을 만들었다.
근거 자료
- Upgradeable Proxy 운영보안: 02-보안-테스트/08-Upgradeable-Proxy-운영보안.md
- OpenZeppelin Upgrades Plugins: https://docs.openzeppelin.com/upgrades-plugins
- OpenZeppelin: Upgrading smart contracts: https://docs.openzeppelin.com/contracts/5.x/learn/upgrading-smart-contracts
- OpenZeppelin: Proxy API: https://docs.openzeppelin.com/contracts/5.x/api/proxy
- OpenZeppelin: Writing Upgradeable Contracts: https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable
- Safe Guards: https://docs.safe.global/advanced/smart-account-guards