자신의 owner 필드만 바꾼 구현 컨트랙트가 프록시 컨트랙트의 관리자 주소를 쓰레기 값으로 덮어쓸 수 있습니다. 이런 사고의 뿌리는 대개 Solidity가 상태 변수를 보관하는 방식입니다. 컨트랙트에는 "변수 이름에서 스토리지 위치로" 가는 런타임 색인이 없고, 컴파일러는 선언 순서대로 변수를 32바이트(32 bytes) 슬롯(slot)에 차례로 넣습니다. delegatecall은 구현 컨트랙트의 코드가 프록시의 스토리지 위에서 실행되게 하는데, 양쪽이 슬롯 배치를 다르게 이해하면 쓰기 연산이 상대방의 데이터에 떨어집니다.
슬롯은 스토리지의 주소 지정 단위이자, 실행 계층이 상태 접근 한 번을 기록할 때의 최소 입자입니다. 변수가 어느 슬롯에 놓이는지, 패킹 후 슬롯을 공유하는지, 배열과 매핑의 원소가 키로부터 위치를 어떻게 파생하는지가 두 가지 아주 실제적인 문제를 결정합니다. 한 번의 호출이 프록시의 어느 필드를 망가뜨리는지, 그리고 한 트랜잭션의 상태 읽기/쓰기 집합이 도대체 얼마나 큰지입니다.
변수는 선언 순서대로 32바이트 슬롯을 선형으로 차지한다
Solidity 공식 문서 "Layout of State Variables in Storage and Transient Storage"에 따르면, 동적 배열과 매핑을 제외한 상태 변수는 첫 선언부터 연속으로 저장되며 첫 변수가 slot 0에 놓입니다. 각 변수는 타입에 따라 바이트 수가 정해지고, 연속하면서 합계가 32바이트에 못 미치는 변수들은 같은 슬롯에 담깁니다. 규칙은 다섯 가지입니다. 슬롯 안의 첫 항목은 하위 바이트(lower-order aligned)에 놓입니다. 값 타입은 실제로 필요한 바이트만 차지합니다. 현재 슬롯의 남은 공간에 들어가지 않으면 다음 슬롯으로 옮깁니다. 구조체와 배열 데이터는 항상 새 슬롯에서 시작합니다. 구조체나 배열 뒤에 오는 변수도 새 슬롯에서 시작합니다.
간과하기 쉬운 두 가지 예외가 슬롯 번호 계산에 영향을 줍니다. constant 변수는 스토리지 슬롯을 차지하지 않고 그 값이 사용 지점에 인라인됩니다. immutable 변수는 배포 바이트코드에 인코딩되어 런타임에 스토리지를 읽지 않습니다. 공식 문서의 예제 컨트랙트 C를 보면, 상수 c와 불변 변수 d는 레이아웃에 참여하지 않으므로 이들을 선언 순번에 포함하면 이후 모든 변수의 슬롯 번호가 밀립니다. 임시 스토리지(transient storage)는 별도의 독립적인 레이아웃으로, 규칙은 같지만 공간이 완전히 분리되어 있어 한 컨트랙트 안에서 일반 상태 변수와 임시 변수가 마음대로 뒤섞여도 서로 영향을 주지 않습니다.
패킹이 아끼는 것은 슬롯이지 가스가 아니다
작은 타입이 같은 슬롯을 공유하는 것을 패킹(packing)이라고 합니다. 아래 두 선언은 변수 순서만 다를 뿐인데 차지하는 슬롯 수가 하나 차이 납니다.
// 3개의 슬롯을 차지한다
uint128 a; // slot 0, offset 0
uint256 b; // 32바이트는 slot 0의 남은 공간에 들어가지 않아 slot 1로 간다
uint128 c; // slot 1이 가득 차서 slot 2로 간다
// 2개의 슬롯을 차지한다
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1
공식 문서는 32바이트보다 작은 원소를 쓰면 가스 소비가 늘 수 있다고 분명히 밝힙니다. EVM은 32바이트 단위로 연산하므로 작은 타입을 다루려면 추가적인 절단이나 시프트 연산이 필요합니다. 패킹의 실제 이득은 "슬롯 하나에 한 번 접근해 여러 값을 얻는다"는 데 있고, 읽기와 쓰기 횟수는 슬롯 단위로 값을 매깁니다. 그 반대도 성립합니다. 어떤 로직이 그중 한 변수만 쓴다면 EVM은 먼저 슬롯 전체를 읽고, 해당 바이트를 고친 뒤, 다시 통째로 써야 합니다. 그렇지 않으면 같은 슬롯의 다른 변수를 덮어씁니다. 자주 함께 접근하지 않는 변수를 같은 슬롯에 밀어 넣으면 쓰기 한 번이 읽기 한 번 더하기 쓰기 한 번으로 바뀝니다.
여기에 동시성과 관련된 관찰 지점이 하나 숨어 있습니다. 슬롯을 단위로 보면, 패킹은 논리적으로 무관한 여러 변수를 같은 쓰기 가능 객체로 묶습니다.
고정 길이 배열은 인라인되고, 동적 배열과 매핑은 해시로 위치를 파생한다
고정 길이 배열(예: uint256[3])의 원소는 순서대로 슬롯에 인라인 저장되며, 하나씩 선언한 변수와 다르지 않습니다. 총 바이트 수가 32를 넘으면 자연히 여러 슬롯에 걸칩니다. 구조체도 마찬가지지만, 구조체와 그 뒤에 오는 변수는 반드시 새 슬롯에서 시작합니다.
동적 배열과 매핑은 크기를 미리 알 수 없어 인라인할 수 없습니다. 이들이 택한 방식은 레이아웃에서 슬롯 p 하나만 차지하고, 슬롯 자체에는 데이터를 두지 않으며 데이터 위치를 keccak256으로 계산하는 것입니다.
동적 배열의 슬롯 p에는 배열 길이가 저장되고, 원소는 keccak256(p)에서 시작해 고정 길이 배열의 규칙대로 연속 배치되며 원소가 16바이트를 넘지 않으면 여전히 슬롯을 공유할 수 있습니다. 중첩 동적 배열에는 같은 규칙이 재귀적으로 적용됩니다. 타입이 uint24[][]이고 슬롯 p에 선언된 x에서 원소 x[i][j]가 있는 슬롯은 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))입니다.
매핑의 슬롯 p는 비어 있지만 반드시 남겨 둡니다. 바로 이 예약 슬롯이 인접한 두 매핑의 데이터가 겹치지 않게 보장합니다. 키 k에 대응하는 값은 keccak256(h(k) . p)에 있고, 여기서 .은 연결(concatenation)을 뜻하며 h는 키에 타입별 처리를 합니다. 값 타입은 메모리 저장과 같은 방식으로 32바이트까지 채우고, string과 bytes 타입 키는 채우지 않습니다. 문서가 제시한 중첩 예제로 이것을 바로 확인할 수 있습니다. uint x; mapping(uint => mapping(uint => S)) data;에 대해 data[4][9].c의 슬롯은 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1이고, 끝의 +1은 구조체 멤버 c가 S 안에서 갖는 슬롯 오프셋에서 나옵니다. 그리고 a, b 두 uint16은 이미 같은 슬롯에 패킹되어 있습니다.
bytes와 string의 인코딩은 따로 설명해야 합니다. 이들은 bytes1[]의 단순한 래퍼가 아닙니다. 데이터가 31바이트를 넘지 않으면 데이터 자체와 길이가 같은 슬롯에 저장됩니다. 데이터는 상위 바이트에 왼쪽 정렬로 놓이고, 최하위 바이트에 length * 2가 저장됩니다. 데이터가 32바이트 이상이면 슬롯 p에 length * 2 + 1이 저장되고 실제 데이터는 keccak256(p)에서 시작하는 영역에 놓입니다. 두 경우는 최하위 비트로 구분합니다. 짧은 데이터는 이 비트가 0이고 긴 데이터는 1입니다.
조합 타입의 위치도 같은 재귀를 따릅니다. 위 규칙대로 유도하면 uint8[4]가 동적 배열의 원소일 때 1바이트 값 4개가 정확히 슬롯 하나에 패킹됩니다. uint[3][]의 각 원소는 슬롯 3개를 차지하는 고정 길이 배열이고, 원소들은 3슬롯 간격으로 차례로 놓이며 x[i]의 시작점은 keccak256(p) + 3 * i입니다. 이 두 예는 규칙의 특수한 경우를 끌어낸 것이고, 공식 문서는 uint24[][] 중첩 예제만 제시했을 뿐 이 둘을 그대로 적어 두지는 않았습니다.
이 모든 것에는 직접적인 귀결이 하나 있습니다. 배열 원소와 매핑 값의 슬롯 위치는 런타임의 키나 인덱스에 의존하므로 컴파일 시점에 열거할 수 없고, 소스만 읽는 정적 분석으로도 전체 집합을 끌어낼 수 없습니다.
레이아웃은 컴파일러의 출력이며 내보내고 대조할 수 있다
슬롯 번호를 추론으로 짐작할 필요는 없습니다. Solidity의 표준 JSON 인터페이스는 컨트랙트의 스토리지 레이아웃을 내보낼 수 있고, 출력에는 storage와 types 두 키가 들어 있습니다. storage 배열의 각 항목은 astId, contract, label, offset, slot, type을 주고, types는 각 타입의 인코딩 방식을 설명합니다. 값 inplace는 인라인 배치, mapping과 dynamic_array는 keccak256 기반 파생, bytes는 길이에 따라 단일 슬롯과 해시 영역 중 하나를 고르는 방식을 뜻합니다. slot의 값은 매우 클 수 있어 JSON에서는 문자열로 표현됩니다. 문서는 이 출력 형식이 여전히 실험적으로 간주되어 Solidity의 비파괴적 버전에서 바뀔 수 있다고 함께 경고하므로, 한 번씩 대조하는 도구로는 적합하지만 장기적으로 의존할 인터페이스로는 적절하지 않습니다.
상속 순서와 슬롯 경계가 업그레이드 가능성을 결정한다
상속을 쓰는 컨트랙트에서 상태 변수 순서는 컨트랙트의 C3 선형화(C3-linearized) 순서로 결정되며, 상속 체인의 가장 기반이 되는 컨트랙트부터 배치됩니다. 패킹이 허용되면 서로 다른 컨트랙트에서 온 변수도 같은 슬롯을 공유하며, 기반 클래스와 파생 클래스의 변수가 한 슬롯에 함께 있을 수 있습니다.
이 규칙은 두 종류의 업그레이드 사고를 설명합니다. 하나는 기반 클래스에 변수를 추가하는 경우입니다. 자식 클래스가 이미 자기 변수를 선언했다면, 기반 클래스에 추가된 변수가 자식 클래스 기존 변수의 슬롯 자리를 밀어내고 옛 데이터를 새 변수로 읽어 들입니다. 다른 하나는 기존 변수 앞에 변수를 끼워 넣거나 변수 타입을 바꾸는 경우로, 결과는 같습니다. OpenZeppelin의 업그레이드 문서는 이 제약을 아주 노골적으로 적어 둡니다. 새 변수는 끝에만 추가할 수 있고, 끝에서 변수를 삭제해도 스토리지는 비워지지 않아 이후 같은 위치에 추가한 변수가 역사에 남은 잔여 값을 읽게 됩니다.
레이아웃을 능동적으로 통제하려는 상황을 위해 Solidity는 컨트랙트에 사용자 지정 레이아웃 시작점을 선언할 수 있게 합니다. 공식 문서의 예제는 pragma solidity ^0.8.29;와 contract C is A, B layout at 42로 적혀 있고, 상속 트리에 있는 모든 정적 변수의 슬롯 번호가 통째로 평행 이동합니다. 문서는 이 기능이 어느 컴파일러 버전에서 도입되었는지 밝히지 않았고, 여기서도 버전을 단정하지 않습니다. 이 선언은 해당 상속 트리에만 작용하므로 A, B를 따로 배포하면 레이아웃은 여전히 slot 0에서 시작합니다. 다만 이것은 시작점을 옮기는 것일 뿐이라, 동적 배열과 매핑의 데이터 위치는 기준 슬롯이 바뀐 만큼 함께 바뀝니다.
프록시 업그레이드: storage collision은 왜 필연이며, 예약 슬롯은 어떻게 피하는가
업그레이드 프록시(proxy)의 메커니즘은 이렇습니다. 프록시가 스토리지와 잔액을 보유하고, delegatecall로 구현 컨트랙트의 코드를 실행합니다. delegatecall은 호출자의 스토리지 컨텍스트를 유지하므로 구현 컨트랙트가 보는 slot 0, slot 1은 프록시의 slot 0, slot 1입니다. 프록시가 스스로 address public admin;을 선언하면 그것이 slot 0을 차지하고, 구현 컨트랙트의 첫 상태 변수도 slot 0을 차지하므로 어느 쪽이 쓰든 다른 쪽을 덮어씁니다. 이것이 storage collision(스토리지 충돌)입니다. 문제는 어느 한쪽이 코드를 잘못 쓴 데서 생기지 않습니다. 스토리지 공유에 양쪽이 각자 독립적으로 컴파일된다는 조건이 더해진 조합 자체가 충돌을 만들어 냅니다.
EIP-1967의 해법은 컴파일러가 할당하는 슬롯을 쓰지 않고, 합의된 슬롯 한 벌을 고정하는 것입니다.
| 용도 | 슬롯 위치 | 파생 방식 |
|---|---|---|
| 구현 컨트랙트 주소 | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) |
| 비컨 컨트랙트 주소 | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) |
| 관리자 주소 | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) |
이 숫자들의 매개변수 선택에는 분명한 이유가 있습니다. 슬롯 위치는 어떤 문자열의 keccak256에서 가져오는데, 그 문자열이 스토리지 인덱스로 시작하지 않으므로 컴파일러가 0부터 증가시키며 할당하는 슬롯과 겹치지 않습니다. 값 자체도 매우 커서 일반 변수 구간에서 한층 멀어집니다. 끝에서 1을 빼면 해시의 원상(preimage)을 알 수 없게 되어, 누군가 매핑 키를 만들어 keccak256(h(k) . p)를 통해 정확히 그 슬롯에 쓰는 일을 피할 수 있습니다. EIP-1967은 이 슬롯들을 고쳐 쓰는 함수는 모두 대응하는 이벤트를 발생시키라고 권고합니다. 온체인 모니터링으로는 임의 슬롯의 변화를 직접 추적하기 어렵기 때문입니다.
EIP-1967 외에도 흔한 방식이 두 가지 있습니다. 비교적 이른 방식은 스토리지 갭(storage gap)으로, 기반 클래스 끝에 uint256[49] __gap; 같은 고정 길이 배열을 선언해 미래의 변수용 슬롯을 예약하고, 기반 클래스에 변수를 추가할 때 __gap을 같은 만큼 줄입니다. OpenZeppelin 문서는 이것이 가스 소비를 늘리지는 않는다고 지적하지만, 실패하는 방식도 아주 구체적입니다. 갭을 줄이는 것을 잊거나, 자식 클래스에 이미 변수가 있는데 기반 클래스에 변수를 추가하면 충돌이 다시 들어옵니다. 더 새로운 방식은 ERC-7201의 네임스페이스 스토리지 레이아웃으로, 변수 묶음을 구조체에 넣고 @custom:storage-location erc7201:<NAMESPACE_ID>로 표시하며 위치는 keccak256(keccak256(id) - 1) & ~0xff로 계산합니다. 끝의 & ~0xff는 네임스페이스를 256개 슬롯 단위로 정렬하는데, 문서가 제시한 이유는 이것이 미래의 최적화로 쓰일 수 있다는 것입니다. Verkle 상태 트리로 이전한 뒤에는 256개 슬롯이 한꺼번에 핫해지는 가스 규칙이 나타날 수 있습니다. OpenZeppelin Contracts 5.0부터의 업그레이드 가능 버전이 이 규약을 채택했습니다.
마지막으로 전제를 하나 덧붙이겠습니다. Solidity 문서는 스토리지 레이아웃을 언어의 외부 인터페이스 일부로 봅니다. 스토리지 포인터를 라이브러리 함수에 넘길 수 있어서, 레이아웃 규칙의 어떤 변경도 파괴적 변경으로 취급되기 때문입니다. 슬롯에 주소를 하드코딩하는 프록시 방식이 성립하는 것은 레이아웃 규칙이 장기적으로 안정적이라는 약속 위에서입니다.
읽기/쓰기 집합의 최소 단위는 슬롯이다
위 규칙을 모두 합쳐 보면, 접근 값 매기기 관점에서 실행 계층이 관찰할 수 있는 최소 상태 접근 단위는 주소 더하기 슬롯입니다. EIP-2929가 가장 직접적인 증거입니다. 이 제안은 트랜잭션마다 accessed_addresses와 accessed_storage_keys 두 집합을 유지하고, 후자의 원소 타입은 Set[Tuple[Address, Bytes32]]이며 이 입자로 값을 매깁니다. 어떤 스토리지 슬롯을 처음 접근하면 COLD_SLOAD_COST, 즉 2100 gas가 부과되고, 이미 접근한 슬롯에는 WARM_STORAGE_READ_COST, 즉 100 gas가 부과되며, 어떤 계정 주소를 처음 접근하면 COLD_ACCOUNT_ACCESS_COST, 즉 2600 gas가 부과됩니다. 콜드/핫 값 매기기의 단위가 슬롯이라는 것은 실행 계층 자체가 슬롯을 접근 대상으로 삼는다는 뜻입니다.
여기서 동시성과 관련된 엔지니어링 추론 세 가지를 끌어낼 수 있습니다.
EIP-2929가 규정하는 것은 접근 값 매기기의 최소 단위이고, 명세는 병렬 실행에서 충돌 판정에 어느 입자를 써야 하는지 정의하지 않습니다. 다음은 값 매기기 입자에서 출발한 추론입니다. 패킹이 작은 변수들을 같은 슬롯에 묶는다는 것은, 슬롯 입자로 읽기/쓰기 집합을 기록할 때 같은 슬롯 안의 서로 다른 변수를 쓰는 두 트랜잭션이 여전히 충돌한다는 뜻입니다. 이런 거짓 충돌을 없애려면 검출을 슬롯 내부 오프셋과 바이트 차이까지 세분화해야 하고, 그 대가는 비교마다 드는 비용의 상승입니다.
동적 배열과 매핑의 슬롯 위치는 키 값에 의존해 읽기/쓰기 집합을 컴파일 시점에 알 수 없고, 실행 과정에서 기록하고 실행 후에 검증할 수밖에 없습니다. 낙관적 동시성 제어(optimistic concurrency control, OCC)가 읽기 집합과 쓰기 집합을 필요로 하고, 데이터베이스처럼 개발자가 미리 선언하는 방식을 그대로 가져올 수 없는 이유도 여기에 있습니다(이 사이트의 "낙관적 동시성 제어(OCC) 입문: 데이터베이스에서 온체인 실행까지"와 "충돌 핫스팟과 워크로드: 병렬 EVM이 실제로 도움이 될 때" 참고).
슬롯 수준 읽기/쓰기 집합은 상한이지 정확한 집합이 아닙니다. 한 트랜잭션이 어떤 슬롯의 한 바이트만 고쳤는데도 슬롯 전체를 쓴 것으로 기록될 수 있고, 어떤 슬롯에 접근했지만 이후 경로가 리버트되어 실제로는 효력이 없었을 수도 있습니다. 읽기/쓰기 집합을 그대로 충돌 판정 기준으로 삼으면 충돌률이 실제보다 높게 나오고, 이를 롤백과 재실행으로 소화해야 합니다.
경계도 분명히 해 두어야 합니다. 구현 컨트랙트가 ERC-7201 네임스페이스 레이아웃을 완전히 채택하면 원칙적으로 프록시의 슬롯과 더 이상 충돌하지 않지만, 그 대가는 임의의 슬롯 번호를 더 이상 소스 순서로 읽어낼 수 없다는 것이고, 소스 순서만 인정하는 도구(일부 정적 분석기, 블록 익스플로러)는 무력해집니다. 또한 인라인 어셈블리의 sstore는 컴파일러 레이아웃 밖의 임의 슬롯에 쓸 수 있어, AST만 파싱하는 레이아웃 도구는 이런 쓰기를 포괄할 수 없습니다. 이것도 컨트랙트 스토리지 동작을 분석할 때 거듭 나타나는 불확실성의 원천입니다.
출처
- Solidity 문서, Layout of State Variables in Storage and Transient Storage: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- EIP-1967, Proxy Storage Slots: https://eips.ethereum.org/EIPS/eip-1967
- ERC-7201, Namespaced Storage Layout: https://eips.ethereum.org/EIPS/eip-7201
- EIP-2929, Gas cost increases for state access opcodes: https://eips.ethereum.org/EIPS/eip-2929
- OpenZeppelin 문서, Writing Upgradeable Contracts: https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable