개요
OpenZeppelin의 contracts-upgradeable은 업그레이드 가능한 스마트 컨트랙트를 안전하게 구현하기 위한 베이스 라이브러리 프록시 Proxy 패턴으로 사용자 호출 주소와 로직 컨트랙트를 분리하고 delegatecall로 로직을 실행 Transparent Proxy와 UUPS 두 방식을 공식 지원
이 글은 Hardhat 환경에서 @openzeppelin/hardhat-upgrades 플러그인과 함께 UUPS를 중심으로 배포와 업그레이드 흐름을 정리하고, Transparent와의 차이점을 요약
핵심 개념
- Proxy 컨트랙트와 Implementation 컨트랙트를 분리하여 배포
- 사용자는 Proxy 주소로 상호작용, Proxy는 delegatecall로 Implementation의 로직 실행
- 업그레이드는 Proxy가 가리키는 Implementation 주소를 교체하는 방식
- EIP-1967 표준 슬롯을 사용해 Implementation 주소 보관
방식 요약
- Transparent Proxy 방식
- Proxy와 ProxyAdmin이 분리되어 권한 관리
- 관리자 주소로 호출 시 업그레이드 관리 인터페이스 노출, 일반 사용자는 로직만 사용
- UUPS 방식
- 로직 컨트랙트가 업그레이드 함수를 직접 구현, Proxy는 그 함수를 delegatecall로 실행
- 구조 단순, 불필요한 관리 레이어 감소
프로젝트 초기 세팅
- Node.js 프로젝트 생성 후 기본 설정 진행
- 필수 패키지 설치
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install --save-dev @openzeppelin/hardhat-upgrades @openzeppelin/contracts-upgradeable
npx hardhat- hardhat.config에 네트워크 설정 추가 권장
업그레이드 가능한 컨트랙트 작성
UUPS 기준 최소 골격 예시. 생성자 대신 initializer 사용, 접근 제어로 업그레이드 권한 제한 권장
V1
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
function initialize() public initializer {
__Ownable_init();
value = 0;
}
function setValue(uint256 _value) external {
value = _value;
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}포인트
- Initializable 상속으로 생성자 대체, 재초기화 방지
- UUPSUpgradeable 상속으로 UUPS 업그레이드 진입점 제공
- _authorizeUpgrade에 onlyOwner 부여로 업그레이드 권한 제한
V2 증분 변경 예시
contract MyContractV2 is MyContractV1 {
function increment() external {
value += 1;
}
// 상태 변수 추가 시 기존 순서 유지 후 맨 아래에 추가
}주의
- Storage Layout 보존 필수. 기존 변수 타입·순서 변경 금지, 삭제 금지, 새 변수는 맨 아래 추가
- 구현 컨트랙트에 constructor 사용 금지. 초기화는 initializer로 일원화
배포와 업그레이드 흐름
처음 배포 UUPS
const MyV1 = await ethers.getContractFactory("MyContractV1")
const proxy = await upgrades.deployProxy(MyV1, [], { kind: "uups", initializer: "initialize" })
await proxy.deployed()업그레이드 V2
const MyV2 = await ethers.getContractFactory("MyContractV2")
const upgraded = await upgrades.upgradeProxy(proxy.address, MyV2)
await upgraded.deployed()구현 주소 확인 EIP-1967
const impl = await upgrades.erc1967.getImplementationAddress(proxy.address)특징
- 사용자 호출 주소는 Proxy 주소로 고정 유지
- 업그레이드 시 새 Implementation 배포 후 Proxy의 구현 포인터만 교체
Transparent Proxy와의 비교
배포 구분은 kind 옵션으로 결정
await upgrades.deployProxy(MyV1, [], { kind: "transparent" })차이점 요약
- Transparent는 ProxyAdmin으로 업그레이드 권한 분리, 관리 계정과 일반 사용자의 인터페이스 충돌 방지
- UUPS는 로직 컨트랙트가 업그레이드 엔트리포인트를 제공해 구성 간결, 배포·운영 비용 절감 가능
- 두 방식 모두 EIP-1967 슬롯 사용, Hardhat 업그레이드 플러그인으로 배포·업그레이드 절차 유사
업그레이드 시 체크리스트
- Storage Layout 안전성
- 변수 삭제·재배치 금지
- 새 변수는 항상 맨 아래 추가
- 업그레이드 전후 슬럿 변화 정적 분석 권장
- 업그레이드 권한 모델
- UUPS: _authorizeUpgrade로 접근 제어 구성
- Transparent: ProxyAdmin 보유자 또는 멀티시그로 관리
- 멀티시그·타임락·거버넌스 연계로 안전성 강화
- ABI·인터페이스 호환성
- 기존 인터페이스 변경 최소화
- 이벤트 시그니처, 외부 호출 경로 변화 시 연동 주체 공지 및 마이그레이션 플랜 준비
- 초기화와 재초기화 관리
- constructor 사용 금지, initializer 단일 진입점 유지
- 필요 시 reinitializer 버전 관리로 점진 초기화 설계
- 테스트와 검증
- 스토리지 레이아웃 회귀 테스트, 업그레이드 시나리오 테스트 포함
- 로컬·테스트넷에서 배포→업그레이드→롤백 흐름 리허설
- 코드 리뷰 및 외부 감사 고려
- 운영 관점 베스트 프랙티스
- 구현 주소와 Proxy 주소, 관리자 키 체계적 보관
- 변경 이력 온체인·오프체인 기록, 이벤트 기반 모니터링 구성
- 사고 대응을 위한 업그레이드 차단 스위치 또는 긴급 멈춤 흐름 검토
간단 예시 워크플로
- V1 배포 후 기능 검증
- V2 빌드 및 저장소 레이아웃 확인
- 테스트넷에서 upgradeProxy 리허설, 구현 주소 확인
- 메인넷에서 멀티시그 승인 후 upgradeProxy 실행
- 구현 주소 변경 이벤트와 릴리스 노트 공지
마무리
OpenZeppelin contracts-upgradeable은 Proxy + Implementation 패턴을 표준 슬롯과 베이스 컨트랙트로 일관되게 제공 UUPS와 Transparent 중 운영 요구에 맞춰 선택, Hardhat 업그레이드 플러그인으로 배포와 업그레이드 절차 단순화 Proxy 주소를 유지하면서 내부 로직을 교체해 실서비스 중 기능 추가와 버그 수정이 가능한 운영 체계 수립 가능 핵심은 스토리지 레이아웃 보존, 권한 모델 안전화, 업그레이드 전 검증과 회귀 방지 체계 구축임