배포하면 수정할 수 없다 — 그럼 버그가 생기면?
스마트컨트랙트의 황금 법칙은 “코드는 불변(immutable)이다”다. 배포 후 코드를 직접 수정하는 것은 불가능하다. 하지만 현실에서 버그는 언제나 발견된다. 기능 추가도 필요하다. 이 딜레마를 해결하는 것이 업그레이드 가능한 컨트랙트(Upgradeable Contract) 패턴이다.
Proxy 패턴의 원리 — delegatecall
업그레이드를 가능하게 하는 핵심은 EVM의 delegatecall opcode다.
// call vs delegatecall의 차이
// 일반 call:
A가 B를 call하면
→ B의 코드가 B의 스토리지, B의 msg.sender로 실행됨
// delegatecall:
A가 B를 delegatecall하면
→ B의 코드가 A의 스토리지, A의 msg.sender로 실행됨
→ 코드는 B에서, 상태는 A에서!
이것을 이용한 것이 Proxy 패턴이다.
구조:
┌─────────────────┐ delegatecall ┌─────────────────────┐
│ Proxy 컨트랙트 │ ──────────────────▶ │ Implementation 컨트랙트 │
│ (주소 고정) │ │ (로직 코드) │
│ (스토리지 보유) │ │ (업그레이드 가능) │
└─────────────────┘ └─────────────────────┘
사용자는 항상 Proxy 주소와 상호작용.
구현체(로직)만 교체하면 업그레이드 완료.
Transparent Proxy vs UUPS — 두 가지 방식
Transparent Proxy (OpenZeppelin 기본)
// Transparent Proxy의 특징:
// - 업그레이드 함수는 Admin만 호출 가능 (일반 사용자는 로직으로 delegatecall)
// - 모든 호출에서 admin 체크 → 약간의 가스 오버헤드
// - ProxyAdmin 컨트랙트가 별도로 필요
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
// 배포 (Hardhat 스크립트)
const ProxyAdmin = await ethers.getContractFactory("ProxyAdmin");
const admin = await ProxyAdmin.deploy(owner.address);
const Implementation = await ethers.getContractFactory("MyContractV1");
const impl = await Implementation.deploy();
const Proxy = await ethers.getContractFactory("TransparentUpgradeableProxy");
const proxy = await Proxy.deploy(
impl.address,
admin.address,
"0x" // 초기화 데이터 (빈 값)
);
UUPS (Universal Upgradeable Proxy Standard — 권장)
// UUPS의 특징:
// - 업그레이드 함수가 Implementation 컨트랙트 안에 있음
// - 가스 효율적 (admin 체크 오버헤드 없음)
// - 구현체가 upgradeTo 함수를 포함해야 함 (잊으면 영구 잠금!)
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContractV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
// constructor 대신 initialize 사용 (Proxy 패턴에서 constructor는 Proxy에서 실행됨)
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function setValue(uint256 _value) external {
value = _value;
}
// 업그레이드 권한 제어 — 반드시 구현해야 함!
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{} // onlyOwner만 업그레이드 가능
}
업그레이드 실행
// V2 구현체 배포 및 업그레이드
import { ethers, upgrades } from "hardhat";
// OpenZeppelin Hardhat Upgrades 플러그인 사용
// npm install @openzeppelin/hardhat-upgrades
// V1 최초 배포
const MyContractV1 = await ethers.getContractFactory("MyContractV1");
const proxy = await upgrades.deployProxy(MyContractV1, [owner.address], {
kind: 'uups',
});
console.log("Proxy 주소:", proxy.address); // 이 주소가 영구 주소
// V2로 업그레이드
const MyContractV2 = await ethers.getContractFactory("MyContractV2");
const upgraded = await upgrades.upgradeProxy(proxy.address, MyContractV2);
console.log("업그레이드 완료! 주소는 동일:", upgraded.address);
스토리지 충돌 — 업그레이드의 가장 큰 함정
업그레이드할 때 가장 위험한 것은 스토리지 슬롯 충돌이다.
// V1
contract MyContractV1 {
uint256 public value; // 슬롯 0
address public owner; // 슬롯 1
}
// V2 - 잘못된 업그레이드! 슬롯 순서 변경
contract MyContractV2 {
address public owner; // 슬롯 0 ← V1의 value가 있던 자리!
uint256 public value; // 슬롯 1 ← V1의 owner가 있던 자리!
uint256 public newVar; // 슬롯 2 (새 변수)
}
// V2 - 올바른 업그레이드 (기존 슬롯 순서 유지)
contract MyContractV2 {
uint256 public value; // 슬롯 0 (유지)
address public owner; // 슬롯 1 (유지)
uint256 public newVar; // 슬롯 2 (새로 추가 — 끝에만)
}
핵심 규칙:
- 기존 변수의 순서와 타입을 절대 변경하지 않는다
- 새 변수는 항상 끝에 추가한다
- 기존 변수를 삭제하지 않는다 (빈 슬롯으로 남겨둔다)
OpenZeppelin Upgrades 플러그인 — 자동 검증
// 업그레이드 전 스토리지 호환성 자동 검사
const upgraded = await upgrades.upgradeProxy(
proxy.address,
MyContractV2,
{
kind: 'uups',
// 스토리지 레이아웃 변경 시 자동으로 오류 발생
}
);
// 수동으로 스토리지 호환성 검사
await upgrades.validateUpgrade(proxy.address, MyContractV2, { kind: 'uups' });
// 호환되지 않으면 오류: "New storage layout is incompatible"
실제 사례 — 업그레이드로 버그 수정
2022년 Compound Finance에서 토큰 분배 버그가 발견됐다. Proxy 패턴 덕분에 이틀 만에 새 구현체를 배포하고 업그레이드를 완료했다. Proxy 없이 배포됐다면 모든 사용자가 새 컨트랙트 주소로 이전해야 하는 대규모 마이그레이션이 필요했을 것이다.
핵심 정리
- 스마트컨트랙트는 배포 후 코드 수정 불가 → Proxy 패턴으로 업그레이드 구현
- delegatecall: B의 코드를 A의 스토리지에서 실행 → Proxy의 핵심 메커니즘
- UUPS가 Transparent Proxy보다 가스 효율적 (현재 권장 방식)
- constructor 대신 initializer 함수 사용 (한 번만 실행 보장)
- 스토리지 슬롯 순서 변경 절대 금지. 새 변수는 끝에만 추가
- OpenZeppelin Upgrades 플러그인으로 배포 전 자동 검증 필수