Skip to main content

Solidity Storage Layout

EVM의 스토리지는 스마트 컨트랙트의 상태를 영구적으로 저장하는 공간이다. 컨트랙트의 상태 변수들이 어떤 규칙에 따라 스토리지에 배치되는지 이해하는 것은 가스 최적화, 프록시 패턴 구현, 그리고 보안 취약점 방지를 위해 중요하다.

이전 글에서 delegatecall을 사용할 때 스토리지 충돌 문제를 간략히 다뤘는데, 이번 글에서는 스토리지 레이아웃 자체에 대해 깊이 있게 살펴보려고 한다.

EVM 스토리지 구조

EVM의 스토리지는 2^256개의 슬롯으로 구성된 key-value 저장소이다. 각 슬롯은 32바이트(256비트) 크기를 가지며, 슬롯 번호(0부터 시작)를 키로 사용하여 값에 접근한다.

┌─────────────────────────────────────────────────────────────┐
│ Slot 0 │ 32 bytes (256 bits) │
├─────────────────────────────────────────────────────────────┤
│ Slot 1 │ 32 bytes (256 bits) │
├─────────────────────────────────────────────────────────────┤
│ Slot 2 │ 32 bytes (256 bits) │
├─────────────────────────────────────────────────────────────┤
│ ... │ ... │
├─────────────────────────────────────────────────────────────┤
│ Slot 2^256-1 │ 32 bytes (256 bits) │
└─────────────────────────────────────────────────────────────┘

스토리지 읽기(SLOAD)와 쓰기(SSTORE)는 EVM에서 가장 비용이 많이 드는 연산 중 하나이다. 특히 0이 아닌 값을 처음 쓸 때는 20,000 가스가 소모되며, 이미 값이 있는 슬롯을 수정할 때도 5,000 가스가 든다. 이러한 비용 구조 때문에 스토리지 레이아웃을 효율적으로 설계하는 것이 중요하다.

기본 할당 규칙

Solidity는 상태 변수를 선언된 순서대로 슬롯에 할당한다. 첫 번째 변수는 슬롯 0부터 시작하며, 이후 변수들은 순차적으로 다음 슬롯에 배치된다.

contracts/storage-layout/BasicSlot.sol
contract BasicSlot {
// slot 0
uint256 public a = 1;
// slot 1
uint256 public b = 2;
// slot 2: addr(20 bytes) + flag(1 byte) = 21 bytes packed
address public addr = 0x1234567890123456789012345678901234567890;
bool public flag = true;
}

위 코드에서 uint256 타입인 ab는 각각 32바이트 전체를 사용하므로 슬롯 0과 1에 개별적으로 저장된다. 반면 address(20바이트)와 bool(1바이트)은 합쳐서 21바이트이므로 슬롯 2에 함께 패킹된다.

실제로 슬롯 값을 확인해보면:

test/storage-layout/StorageLayout.test.ts
it("Variables are allocated to slots in declaration order", async function () {
const BasicSlot = await ethers.getContractFactory("BasicSlot");
const contract = await BasicSlot.deploy();

// slot 0: a = 1
const slot0 = await contract.getSlot(0);
expect(BigInt(slot0)).to.equal(1n);

// slot 1: b = 2
const slot1 = await contract.getSlot(1);
expect(BigInt(slot1)).to.equal(2n);

// slot 2: addr(20 bytes) + flag(1 byte) packed
// lower-order aligned: addr on right, flag on left
const slot2 = await contract.getSlot(2);

// address: rightmost 20 bytes (40 hex chars)
const addr = "0x" + slot2.slice(-40).toLowerCase();
expect(addr).to.equal("0x1234567890123456789012345678901234567890");

// flag: 1 byte to the left of address (21st byte)
// slot2 is 0x + 64 chars, rightmost 40 chars are addr, next 2 chars are flag
const flagHex = slot2.slice(-42, -40);
expect(parseInt(flagHex, 16)).to.equal(1); // true
});

여기서 주목할 점은 lower-order aligned 방식이다. 값은 슬롯의 오른쪽(낮은 주소)부터 채워진다. 위 예시에서 address가 먼저 오른쪽 20바이트에 저장되고, bool은 그 왼쪽 1바이트에 저장된다.

Storage Packing

32바이트 미만의 변수들이 연속으로 선언되면 Solidity는 이들을 하나의 슬롯에 패킹한다. 이를 통해 스토리지 사용량과 가스 비용을 절약할 수 있다.

패킹 규칙

  1. 연속된 작은 변수들은 32바이트 이내라면 같은 슬롯에 저장된다.
  2. 새 변수가 남은 공간에 들어가지 않으면 다음 슬롯으로 넘어간다.
  3. Struct배열은 항상 새 슬롯에서 시작한다.
  4. Struct나 배열 다음 변수도 새 슬롯에서 시작한다.

패킹 순서의 중요성

변수 선언 순서에 따라 슬롯 사용량이 달라질 수 있다.

contract StoragePacking {
// slot 0: a (16 bytes)
uint128 public a = 1;
// slot 1: b (32 bytes) - 새 슬롯 시작
uint256 public b = 2;
// slot 2: c (16 bytes) - 새 슬롯 시작
uint128 public c = 3;
// 총 3개 슬롯 사용
}

uint128, uint256, uint128 순서로 선언하면 3개의 슬롯을 사용하지만, uint128, uint128, uint256 순서로 변경하면 2개의 슬롯만 사용한다. 첫 번째 경우 b(32바이트)가 a(16바이트) 다음에 오면서 남은 16바이트에 들어가지 못해 새 슬롯으로 넘어가고, c도 마찬가지로 새 슬롯이 필요하다.

여러 작은 타입의 패킹

contracts/storage-layout/StoragePacking.sol
contract MultiPacking {
// slot 0: flag1(1) + flag2(1) + num(1) + addr(20) = 23 bytes 패킹
bool public flag1 = true; // 1 byte
bool public flag2 = false; // 1 byte
uint8 public num = 255; // 1 byte
address public addr = 0x1234567890123456789012345678901234567890; // 20 bytes

// slot 1: value (32 bytes) - 새 슬롯 시작
uint256 public value = 100;
}

bool(1바이트), uint8(1바이트), address(20바이트)가 연속으로 선언되면 총 23바이트로 하나의 슬롯에 패킹된다. 다음에 오는 uint256은 32바이트 전체가 필요하므로 새 슬롯에서 시작한다.

동적 타입의 저장 방식

고정 크기 타입과 달리 매핑(mapping)동적 배열(dynamic array)은 크기를 미리 알 수 없다. Solidity는 이들의 실제 데이터를 keccak256 해시 기반의 위치에 분산 저장한다.

Mapping

매핑은 선언된 위치의 슬롯 자체에는 아무것도 저장하지 않는다. 대신 각 키에 해당하는 값은 다음 공식으로 계산된 슬롯에 저장된다.

slot = keccak256(key . p)

여기서 .는 연결(concatenation)을 의미하고, p는 매핑이 선언된 슬롯 번호이다.

contracts/storage-layout/MappingStorage.sol
contract MappingStorage {
// slot 0
uint256 public baseValue = 100;

// slot 1 (슬롯 자체는 비어있음)
mapping(address => uint256) public balances;

constructor() {
balances[0x1234567890123456789012345678901234567890] = 1000;
}

function computeMappingSlot(address key, uint256 slot) external pure returns (bytes32) {
return keccak256(abi.encode(key, slot));
}
}
test/storage-layout/StorageLayout.test.ts
it("Mapping value location is computed as keccak256(key, slot)", async function () {
const MappingStorage = await ethers.getContractFactory("MappingStorage");
const contract = await MappingStorage.deploy();

const key = "0x1234567890123456789012345678901234567890";
const mappingSlot = 1n; // balances is at slot 1

// Calculate expected slot
const abiCoder = new AbiCoder();
const encoded = abiCoder.encode(["address", "uint256"], [key, mappingSlot]);
const expectedSlot = keccak256(encoded);

// Compute via contract function
const computedSlot = await contract.computeMappingSlot(key, mappingSlot);
expect(computedSlot).to.equal(expectedSlot);

// Verify actual value
const value = await contract.getSlot(computedSlot);
expect(BigInt(value)).to.equal(1000n);
});

중첩 매핑

중첩 매핑의 경우 해시를 재귀적으로 적용한다.

mapping(uint256 => mapping(address => uint256)) public nestedMap;
// nestedMap[key1][key2]의 위치:
// 1. intermediateSlot = keccak256(key1 . baseSlot)
// 2. finalSlot = keccak256(key2 . intermediateSlot)

Dynamic Array

동적 배열은 선언된 슬롯에 배열의 길이를 저장하고, 실제 요소들은 다음 공식으로 계산된 위치부터 순차적으로 저장된다.

elementSlot = keccak256(p) + index

여기서 p는 배열이 선언된 슬롯 번호이다.

contracts/storage-layout/DynamicArrayStorage.sol
contract DynamicArrayStorage {
// slot 0
uint256 public baseValue = 100;

// slot 1: 배열 길이 저장, 실제 데이터는 keccak256(1)부터
uint256[] public numbers;

constructor() {
numbers.push(111);
numbers.push(222);
numbers.push(333);
}

function computeArrayElementSlot(uint256 slot, uint256 index) external pure returns (bytes32) {
return bytes32(uint256(keccak256(abi.encode(slot))) + index);
}
}
test/storage-layout/StorageLayout.test.ts
it("Array elements are stored at keccak256(slot) + index", async function () {
const DynamicArrayStorage = await ethers.getContractFactory("DynamicArrayStorage");
const contract = await DynamicArrayStorage.deploy();

// Calculate numbers[0] location
const element0Slot = await contract.computeArrayElementSlot(1, 0);
const value0 = await contract.getSlot(element0Slot);
expect(BigInt(value0)).to.equal(111n);

// Calculate numbers[1] location
const element1Slot = await contract.computeArrayElementSlot(1, 1);
const value1 = await contract.getSlot(element1Slot);
expect(BigInt(value1)).to.equal(222n);

// Calculate numbers[2] location
const element2Slot = await contract.computeArrayElementSlot(1, 2);
const value2 = await contract.getSlot(element2Slot);
expect(BigInt(value2)).to.equal(333n);
});

Bytes와 Strings

bytesstring 타입은 저장 방식이 데이터 길이에 따라 달라진다.

짧은 데이터 (31바이트 이하)

데이터와 길이 정보가 같은 슬롯에 저장된다:

  • 데이터: 슬롯의 상위 바이트들
  • 길이 정보: 최하위 바이트에 length * 2 값 저장
┌────────────────────────────────────────────────────────────┐
│ [data: 31 bytes max] │ [length*2: 1 byte] │
└────────────────────────────────────────────────────────────┘

긴 데이터 (32바이트 이상)

슬롯에는 length * 2 + 1 값만 저장되고, 실제 데이터는 keccak256(slot) 위치부터 저장된다. 최하위 비트가 1이면 긴 데이터임을 나타낸다.

contracts/storage-layout/BytesStringStorage.sol
contract BytesStringStorage {
// slot 0: 짧은 문자열 - 데이터와 길이가 같은 슬롯에
string public shortString = "hello"; // 5 bytes

// slot 1: 긴 문자열 - 슬롯에는 길이*2+1, 데이터는 keccak256(1)부터
string public longString = "This is a very long string that exceeds 31 bytes in length";

function isShortData(bytes32 slotValue) external pure returns (bool) {
// 최하위 비트가 0이면 짧은 데이터
return uint256(slotValue) & 1 == 0;
}

function getShortDataLength(bytes32 slotValue) external pure returns (uint256) {
// 최하위 바이트 / 2 = 실제 길이
return (uint256(slotValue) & 0xFF) / 2;
}
}

Structs와 상속

Struct 저장

Struct는 항상 새 슬롯에서 시작하며, 내부 멤버들은 일반 변수와 동일한 패킹 규칙을 따른다.

contracts/storage-layout/StructStorage.sol
contract StructStorage {
struct User {
uint128 balance; // 16 bytes
uint64 lastUpdate; // 8 bytes
bool isActive; // 1 byte
// 위 3개가 하나의 슬롯에 패킹 (25 bytes)
address wallet; // 20 bytes -> 다음 슬롯
uint256 totalTx; // 32 bytes -> 다음 슬롯
}

// slot 0, 1, 2: user1 구조체 (3개 슬롯)
User public user1;

// slot 3, 4, 5: user2 구조체 (3개 슬롯)
User public user2;
}

User 구조체는 3개의 슬롯을 사용한다:

  • 슬롯 0: balance(16) + lastUpdate(8) + isActive(1) = 25바이트 패킹
  • 슬롯 1: wallet(20바이트)
  • 슬롯 2: totalTx(32바이트)

상속과 C3 Linearization

상속 관계에서 스토리지 레이아웃은 C3 linearization 규칙을 따른다. 가장 기본(base) 컨트랙트의 변수부터 슬롯이 할당된다.

contracts/storage-layout/InheritanceStorage.sol
contract Base {
// slot 0
uint256 public baseValue = 100;
// slot 1
address public baseOwner;
}

contract Middle is Base {
// slot 2 (Base의 슬롯 이후)
uint256 public middleValue = 200;
}

contract Child is Middle {
// slot 3 (Middle의 슬롯 이후)
uint256 public childValue = 300;
}

다중 상속의 경우:

contract A {
uint256 public aValue = 1; // slot 0
}

contract B {
uint256 public bValue = 2; // slot 0 (독립적)
}

// C3 linearization: MultipleInheritance -> B -> A
// 실제 레이아웃: A.aValue(slot 0) -> B.bValue(slot 1) -> cValue(slot 2)
contract MultipleInheritance is A, B {
uint256 public cValue = 3; // slot 2
}

상속 순서가 is A, B이면 linearization 결과 A가 먼저, B가 그 다음이므로 aValue가 슬롯 0, bValue가 슬롯 1에 배치된다.

Assembly로 스토리지 접근

Solidity의 인라인 어셈블리를 사용하면 스토리지를 직접 읽고 쓸 수 있다. 이를 통해 private 변수에도 접근할 수 있으며, 패킹된 슬롯의 특정 부분만 조작할 수도 있다.

sload와 sstore

contracts/storage-layout/AssemblyAccess.sol
contract AssemblyAccess {
// slot 0: private여도 온체인에 저장됨
uint256 private secretNumber = 12345;

// slot 1: 패킹된 변수들
uint128 private value1 = 100; // lower 128 bits
uint128 private value2 = 200; // upper 128 bits

function readSlot(uint256 slot) external view returns (bytes32) {
bytes32 value;
assembly {
value := sload(slot)
}
return value;
}

function writeSlot(uint256 slot, bytes32 value) external {
assembly {
sstore(slot, value)
}
}
}

private 키워드는 Solidity 레벨에서의 접근 제어일 뿐, 온체인 데이터는 누구나 읽을 수 있다.

it("Private variables can be read through slots", async function () {
const AssemblyAccess = await ethers.getContractFactory("AssemblyAccess");
const contract = await AssemblyAccess.deploy();

// slot 0: secretNumber
const slot0 = await contract.readSlot(0);
expect(BigInt(slot0)).to.equal(12345n);
});

패킹된 슬롯의 개별 값 접근

패킹된 슬롯에서 특정 값만 읽거나 쓰려면 비트 마스킹과 시프트 연산이 필요하다.

contracts/storage-layout/AssemblyAccess.sol
// value1 읽기 (lower 128 bits)
function readPackedValue1() external view returns (uint128) {
uint128 result;
assembly {
let slotValue := sload(1)
// lower 128 bits 마스킹
result := and(slotValue, 0xffffffffffffffffffffffffffffffff)
}
return result;
}

// value2 읽기 (upper 128 bits)
function readPackedValue2() external view returns (uint128) {
uint128 result;
assembly {
let slotValue := sload(1)
// 128비트 오른쪽 시프트
result := shr(128, slotValue)
}
return result;
}

// value1만 수정 (value2 유지)
function writePackedValue1(uint128 newValue) external {
assembly {
let slotValue := sload(1)
// upper 128 bits 유지, lower 128 bits 클리어
let cleared := and(slotValue, not(0xffffffffffffffffffffffffffffffff))
// 새 값 설정
sstore(1, or(cleared, newValue))
}
}
it("Modifying individual values in packed slot", async function () {
const AssemblyAccess = await ethers.getContractFactory("AssemblyAccess");
const contract = await AssemblyAccess.deploy();

// Change only value1
await contract.writePackedValue1(999);

// value1 is changed
expect(await contract.getValue1()).to.equal(999n);
// value2 is unchanged
expect(await contract.getValue2()).to.equal(200n);
});

정리

  1. 순차적 할당: 변수는 선언 순서대로 슬롯 0부터 할당된다.
  2. Lower-order aligned: 값은 슬롯의 오른쪽(낮은 바이트)부터 채워진다.
  3. 패킹: 32바이트 미만 변수들은 가능한 한 같은 슬롯에 묶인다.
  4. 동적 타입: 매핑과 동적 배열은 keccak256 기반으로 분산 저장된다.
  5. 상속: C3 linearization에 따라 부모 컨트랙트부터 슬롯이 할당된다.

프록시 패턴과의 연관성

이전 글에서 다뤘듯이 delegatecall을 사용하는 프록시 패턴에서는 구현 컨트랙트와 프록시 컨트랙트의 스토리지 레이아웃이 반드시 일치해야 한다. 그렇지 않으면 잘못된 슬롯에 값이 저장되어 데이터 손상이 발생하여 구현 컨트랙트 주소와 같은 중요한 값이 덮어씌워질 위험이 있다.

이러한 문제를 방지하기 위해 ERC-1967은 충돌 가능성이 극히 낮은 특정 슬롯(예: keccak256("eip1967.proxy.implementation") - 1)에 프록시 관련 데이터를 저장하도록 표준화했다.

가스 최적화 팁

  • 같은 슬롯에 패킹될 수 있도록 작은 타입 변수들을 연속으로 선언한다.
  • 자주 함께 읽히는 변수들을 같은 슬롯에 배치한다.
  • 불필요한 스토리지 쓰기를 줄인다 (memory 변수 활용).

글에서 사용된 예시코드는 crypto-examples/solidity-examples에서 확인할 수 있다.

참조