스토리지와 트랜지언트 스토리지의 상태 변수 배치

스토리지와 트랜지언트 스토리지의 상태 변수 배치 (Layout of State Variables in Storage and Transient Storage)

Solidity 컨트랙트의 상태 변수는 스토리지에 압축된 방식으로 저장돼서, 여러 값이 때로는 같은 스토리지 슬롯을 공유해요. 이 절의 규칙은 스토리지와 트랜지언트 스토리지 두 데이터 위치 모두에 적용돼요. 솔리디티가 변수를 어디에 어떻게 배치하는지 이해하면 가스 사용량과 스토리지 레이아웃 예측이 훨씬 정확해져요.

출처: 문서

본문

참고 (Note)

이 절에서 설명하는 규칙은 스토리지와 트랜지언트 스토리지 두 데이터 위치 모두에 적용돼요. 두 레이아웃은 완전히 독립적이며 서로의 변수 위치를 간섭하지 않아요. 따라서 스토리지 변수와 트랜지언트 스토리지 변수는 부작용 없이 안전하게 섞어 쓸 수 있어요. 트랜지언트 스토리지에는 값 타입만 지원돼요.

컨트랙트의 상태 변수는 압축된 방식으로 스토리지에 저장되며, 여러 값이 때로 같은 스토리지 슬롯을 사용해요. 동적 크기 배열과 매핑을 제외하면(아래 참고), 데이터는 첫 번째 상태 변수부터 항목별로 연속해서 저장돼요. 첫 번째 상태 변수는 슬롯 0에 저장돼요. 각 변수에 대해 그 타입에 따라 바이트 단위의 크기가 결정돼요.

32바이트보다 적게 필요한 연속 항목 여러 개는 가능하면 다음 규칙에 따라 하나의 스토리지 슬롯에 패킹돼요:

  • 스토리지 슬롯의 첫 번째 항목은 낮은 순서 정렬(lower-order aligned)로 저장돼요.
  • 값 타입은 저장하는 데 필요한 만큼의 바이트만 사용해요.
  • 값 타입이 스토리지 슬롯의 남은 부분에 맞지 않으면 다음 스토리지 슬롯에 저장돼요.
  • 구조체와 배열 데이터는 항상 새 슬롯에서 시작하며, 그 항목들은 이 규칙에 따라 조밀하게 패킹돼요.
  • 구조체나 배열 데이터 뒤에 오는 항목은 항상 새 스토리지 슬롯에서 시작해요.

상속을 사용하는 컨트랙트의 경우 상태 변수의 순서는 가장 기본(base)이 되는 컨트랙트부터 시작하는 C3-선형화(C3-linearized) 순서에 따라 결정돼요. 위 규칙이 허용하면 서로 다른 컨트랙트의 상태 변수도 같은 스토리지 슬롯을 공유해요.

구조체와 배열의 요소는 개별 값으로 주어진 것처럼 서로 뒤이어 저장돼요.

컨트랙트가 커스텀 스토리지 레이아웃(custom storage layout)을 지정하면, 정적 스토리지 변수에 할당되는 슬롯은 레이아웃 베이스로 정의된 값만큼 이동돼요. 동적 배열과 매핑의 위치도 그들이 기반하는 정적 슬롯의 이동 때문에 간접적으로 영향을 받아요. 커스텀 레이아웃은 가장 파생된(most derived) 컨트랙트에 지정되며, 위에서 설명한 순서를 따라 가장 기본이 되는 컨트랙트의 변수부터 모든 스토리지 슬롯이 조정돼요.

다음 예시에서 컨트랙트 C는 컨트랙트 A와 B로부터 상속받고 커스텀 스토리지 베이스 슬롯도 지정해요. 결과적으로 상속 트리의 모든 스토리지 변수 슬롯이 C가 지정한 값에 따라 조정돼요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.29;

struct S {
    int32 x;
    bool y;
}

contract A {
    uint a;
    uint128 transient b;
    uint constant c = 10;
    uint immutable d = 12;
}

contract B {
    uint8[] e;
    mapping(uint => S) f;
    uint16 g;
    uint16 h;
    bytes16 transient i;
    S s;
    int8 k;
}

contract C is A, B layout at 42 {
    bytes21 l;
    uint8[10] m;
    bytes5[8] n;
    bytes5 o;
}

이 예시에서 스토리지 레이아웃은 상속받은 상태 변수 a가 기본 슬롯(슬롯 42) 안에 직접 저장되면서 시작해요. 트랜지언트, 상수(constant), 불변(immutable) 변수는 별도 위치에 저장되므로 b, i, c, d는 스토리지 레이아웃에 영향을 주지 않아요.

다음은 동적 배열 e와 매핑 f예요. 둘 다 전체 슬롯 하나를 예약하며, 그 슬롯의 주소는 실제 데이터가 저장되는 위치를 계산하는 데 사용돼요. 결과 주소가 고유해야 하므로 이 슬롯은 다른 변수와 공유할 수 없어요.

다음 두 변수 g와 h는 각각 2바이트가 필요하며, 슬롯 45의 오프셋 0과 2에 패킹될 수 있어요. s는 구조체이므로 두 멤버가 연속해서 패킹되며 각각 5바이트를 차지해요. 둘 다 여전히 슬롯 45에 들어갈 수 있지만, 구조체와 배열은 항상 새 슬롯에서 시작해요. 따라서 s는 슬롯 46에, 다음 변수 k는 슬롯 47에 배치돼요.

반면 기본 컨트랙트는 파생 컨트랙트와 슬롯을 공유할 수 있으므로 l은 새 슬롯이 필요 없어요. 그리고 10개 항목의 배열인 변수 m은 슬롯 48에 들어가 10바이트를 차지해요. n도 배열이지만 항목 크기 때문에 첫 번째 슬롯을 완벽히 채우지 못하고 다음 슬롯으로 넘쳐 흘러요. 마지막으로 변수 o는 n의 항목과 같은 타입인데도 슬롯 51에 배치돼요. 앞서 설명했듯 구조체와 배열 뒤의 변수는 항상 새 슬롯에서 시작하기 때문이에요.

종합하면, 컨트랙트 C의 스토리지와 트랜지언트 스토리지 레이아웃은 다음과 같이 나타낼 수 있어요:

  • 스토리지: open in Remix 42 [ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ] 43 [ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee ] 44 [ ffffffffffffffffffffffffffffffff ] 45 [ hhgg ] 46 [ yxxxx ] 47 [ lllllllllllllllllllllk ] 48 [ mmmmmmmmmm ] 49 [ nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn ] 50 [ nnnnnnnnnn ] 51 [ ooooo ]
  • 트랜지언트 스토리지: open in Remix 00 [ iiiiiiiiiiiiiiiibbbbbbbbbbbbbbbb ]

스토리지 지정자는 C의 상속 계층의 일부로서만 A와 B에 영향을 준다는 점에 주의해요. 독립적으로 배포될 때 그들의 스토리지는 0에서 시작해요:

  • A의 스토리지 레이아웃: open in Remix 00 [ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ]
  • B의 스토리지 레이아웃: open in Remix 00 [ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee ] 01 [ ffffffffffffffffffffffffffffffff ] 02 [ hhgg ] 03 [ yxxxx ] 04 [ k ]

경고 (Warning)

32바이트보다 작은 요소를 사용하면 컨트랙트의 가스 사용량이 더 높아질 수 있어요. EVM은 한 번에 32바이트 단위로 동작하기 때문이에요. 따라서 요소가 32바이트보다 작으면 EVM은 요소의 크기를 32바이트에서 원하는 크기로 줄이기 위해 더 많은 연산을 사용해야 해요.

스토리지 값을 다룰 때는 축소된 크기의 타입을 쓰는 게 이로울 수 있어요. 컴파일러가 여러 요소를 하나의 스토리지 슬롯에 패킹해서 여러 읽기나 쓰기를 단일 연산으로 결합하기 때문이에요. 다만 슬롯 안의 모든 값을 동시에 읽거나 쓰지 않으면 반대 효과가 생길 수 있어요. 다중 값 스토리지 슬롯에 하나의 값을 쓸 때는 같은 슬롯의 다른 데이터가 파괴되지 않도록 먼저 슬롯을 읽고 새 값과 결합해야 하기 때문이에요. 함수 인자나 메모리 값을 다룰 때는 컴파일러가 그 값들을 패킹하지 않으므로 내재적 이점이 없어요.

마지막으로 EVM이 이를 최적화할 수 있게, 스토리지 변수와 구조체 멤버를 조밀하게 패킹될 수 있는 순서로 배열하는 걸 권장해요. 예를 들어 uint128, uint128, uint256 순서로 선언하면 스토리지 슬롯 2개만 차지하지만 uint128, uint256, uint128 순서는 슬롯 3개를 차지하므로 전자처럼 선언하는 게 좋아요.

참고 (Note)

스토리지의 상태 변수 레이아웃은 스토리지 포인터가 라이브러리에 전달될 수 있기 때문에 Solidity의 외부 인터페이스의 일부로 간주돼요. 이는 이 절에 설명된 규칙에 대한 어떤 변경도 언어의 breaking change로 여겨진다는 뜻이에요. 매우 중요한 성질이므로 실행되기 전에 신중히 고려해야 해요. 그런 breaking change가 발생하면, 컴파일러가 옛 레이아웃을 지원하는 바이트코드를 생성하는 호환 모드를 출시하고 싶어 해요.

매핑과 동적 배열 (Mappings and Dynamic Arrays)

매핑과 동적 크기 배열 타입은 크기를 예측할 수 없기 때문에 앞뒤의 상태 변수 "사이"에 저장될 수 없어요. 대신 위 규칙 관점에서는 32바이트만 차지하는 것으로 간주되고, 그 요소들은 Keccak-256 해시로 계산된 다른 스토리지 슬롯에서 시작해 저장돼요.

스토리지 레이아웃 규칙을 적용한 뒤 매핑이나 배열의 스토리지 위치가 슬롯 p에 놓인다고 가정해 보죠. 동적 배열의 경우 이 슬롯이 배열의 요소 개수를 저장해요(바이트 배열과 문자열은 예외, 아래 참고). 매핑의 경우 슬롯은 비어 있지만, 두 매핑이 나란히 있어도 그 내용이 서로 다른 스토리지 위치에 놓이도록 보장하려면 여전히 필요해요.

배열 데이터는 keccak256(p)에서 시작하며 정적 크기 배열 데이터와 같은 방식으로 배치돼요. 요소가 16바이트보다 길지 않으면 서로 스토리지 슬롯을 공유할 수 있으면서 한 요소씩 이어져요. 동적 배열의 동적 배열은 이 규칙을 재귀적으로 적용해요. 타입이 uint24[][]인 x의 요소 x[i][j]의 위치는 다음과 같이 계산돼요(역시 x 자체가 슬롯 p에 저장된다고 가정):

슬롯은 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))이고, 요소는 슬롯 데이터 v에서 (v >> ((j % floor(256 / 24)) * 24)) & type(uint24).max로 얻을 수 있어요.

매핑 키 k에 대응하는 값은 keccak256(h(k) . p)에 위치해요. 여기서 .는 연결이고 h는 키의 타입에 따라 키에 적용되는 함수예요:

  • 값 타입의 경우, h는 값을 메모리에 저장할 때와 같은 방식으로 32바이트로 패딩해요.
  • 문자열과 바이트 배열의 경우, h(k)는 패딩되지 않은 데이터 그대로예요.

매핑 값이 값 타입이 아니면, 계산된 슬롯이 데이터의 시작을 표시해요. 예를 들어 값이 구조체 타입이면 그 멤버에 도달하려면 구조체 멤버에 해당하는 오프셋을 더해야 해요.

예시로 다음 컨트랙트를 살펴볼게요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;

contract C {
    struct S { uint16 a; uint16 b; uint256 c; }
    uint x;
    mapping(uint => mapping(uint => S)) data;
}

data[4][9].c의 스토리지 위치를 계산해 볼게요. 매핑 자체의 위치는 1이에요(32바이트인 변수 x가 앞에 있기 때문). 즉 data[4]는 keccak256(uint256(4) . uint256(1))에 저장돼요. data[4]의 타입은 다시 매핑이고, data[4][9]의 데이터는 슬롯 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)))에서 시작해요. 구조체 S 안에서 멤버 c의 슬롯 오프셋은 1이에요. a와 b가 한 슬롯에 패킹되기 때문이죠. 즉 data[4][9].c의 슬롯은 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1이에요. 값의 타입은 uint256이므로 단일 슬롯을 사용해요.

bytes와 string

bytes와 string은 동일하게 인코딩돼요. 일반적으로 인코딩은 bytes1[]와 비슷해요. 배열 자체를 위한 슬롯이 있고, 그 슬롯 위치의 keccak256 해시로 계산되는 데이터 영역이 있기 때문이에요. 하지만 짧은 값(32바이트보다 짧은)의 경우 배열 요소가 길이와 같은 슬롯에 저장돼요.

구체적으로: 데이터가 최대 31바이트라면 요소는 높은 순서 바이트(왼쪽 정렬)에 저장되고, 가장 낮은 순서 바이트는 값 length * 2를 저장해요. 32바이트 이상의 데이터를 저장하는 바이트 배열의 경우, 메인 슬롯 p는 length * 2 + 1을 저장하고 데이터는 평소처럼 keccak256(p)에 저장돼요. 즉 가장 낮은 비트가 설정되어 있는지 확인해 짧은 배열과 긴 배열을 구분할 수 있어요. 짧으면(설정 안 됨), 길면(설정됨).

참고 (Note)

잘못 인코딩된 슬롯의 처리는 현재 지원되지 않지만 미래에 추가될 수 있어요. IR로 컴파일하면 잘못 인코딩된 슬롯을 읽으면 Panic(0x22) 에러가 발생해요.

JSON 출력 (JSON Output)

컨트랙트의 스토리지(또는 트랜지언트 스토리지) 레이아웃은 표준 JSON 인터페이스를 통해 요청할 수 있어요. 출력은 storage와 types 두 키를 담은 JSON 객체예요. storage 객체는 배열이며, 각 요소는 다음과 같은 형태를 가져요:

{
    "astId": 2,
    "contract": "fileA:A",
    "label": "x",
    "offset": 0,
    "slot": "0",
    "type": "t_uint256"
}

위 예시는 소스 유닛 fileA의 컨트랙트 A { uint x; }의 스토리지 레이아웃이며, 각 필드는 다음과 같아요:

  • astId: 상태 변수 선언의 AST 노드 id.
  • contract: 경로를 접두사로 포함한 컨트랙트 이름.
  • label: 상태 변수의 이름.
  • offset: 인코딩에 따른 스토리지 슬롯 안의 바이트 오프셋.
  • slot: 상태 변수가 위치하거나 시작하는 스토리지 슬롯. 이 숫자는 매우 클 수 있으므로 JSON 값은 문자열로 표현돼요.
  • type: 변수의 타입 정보(다음에 설명)에 대한 키로 쓰이는 식별자.

주어진 타입, 이 경우 t_uint256은 types의 요소를 나타내며, 다음 형태를 가져요:

{
    "encoding": "inplace",
    "label": "uint256",
    "numberOfBytes": "32",
}

여기서:

  • encoding: 데이터가 스토리지에 인코딩되는 방식. 가능한 값은 inplace(데이터가 스토리지에 연속 배치, 위 참고), mapping(Keccak-256 해시 기반, 위 참고), dynamic_array(Keccak-256 해시 기반, 위 참고), bytes(데이터 크기에 따라 단일 슬롯 또는 Keccak-256 해시 기반, 위 참고).
  • label: 표준 타입 이름.
  • numberOfBytes: 사용되는 바이트 수(십진수 문자열).

numberOfBytes > 32이면 슬롯이 하나 이상 사용된다는 뜻이에요. 어떤 타입은 위 네 가지 외의 추가 정보가 있어요. 매핑은 키와 값 타입을 담고(역시 이 types 매핑의 항목을 참조), 배열은 기본(base) 타입을 가지며, 구조체는 최상위 스토리지와 같은 형식으로 멤버를 나열해요(위 참고).

참고 (Note)

컨트랙트 스토리지 레이아웃의 JSON 출력 형식은 여전히 실험적이며 Solidity의 비-브레이킹 릴리스에서 바뀔 수 있어요.

다음 예시는 값·참조 타입, 패킹 인코딩되는 타입, 중첩 타입을 담은 컨트랙트와 그 스토리지·트랜지언트 스토리지 레이아웃 둘 다를 보여줘요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.28;
contract A {
    struct S {
        uint128 a;
        uint128 b;
        uint[2] staticArray;
        uint[] dynArray;
    }

    uint x;
    uint transient y;
    uint w;
    uint transient z;

    S s;
    address addr;
    address transient taddr;
    mapping(uint => mapping(address => bool)) map;
    uint[] array;
    string s1;
    bytes b1;
}

스토리지 레이아웃 (Storage Layout)

{
  "storage": [
    {
      "astId": 15,
      "contract": "fileA:A",
      "label": "x",
      "offset": 0,
      "slot": "0",
      "type": "t_uint256"
    },
    {
      "astId": 19,
      "contract": "fileA:A",
      "label": "w",
      "offset": 0,
      "slot": "1",
      "type": "t_uint256"
    },
    {
      "astId": 24,
      "contract": "fileA:A",
      "label": "s",
      "offset": 0,
      "slot": "2",
      "type": "t_struct(S)13_storage"
    },
    {
      "astId": 26,
      "contract": "fileA:A",
      "label": "addr",
      "offset": 0,
      "slot": "6",
      "type": "t_address"
    },
    {
      "astId": 34,
      "contract": "fileA:A",
      "label": "map",
      "offset": 0,
      "slot": "7",
      "type": "t_mapping(t_uint256,t_mapping(t_address,t_bool))"
    },
    {
      "astId": 37,
      "contract": "fileA:A",
      "label": "array",
      "offset": 0,
      "slot": "8",
      "type": "t_array(t_uint256)dyn_storage"
    },
    {
      "astId": 39,
      "contract": "fileA:A",
      "label": "s1",
      "offset": 0,
      "slot": "9",
      "type": "t_string_storage"
    },
    {
      "astId": 41,
      "contract": "fileA:A",
      "label": "b1",
      "offset": 0,
      "slot": "10",
      "type": "t_bytes_storage"
    }
  ],
  "types": {
    "t_address": {
      "encoding": "inplace",
      "label": "address",
      "numberOfBytes": "20"
    },
    "t_array(t_uint256)2_storage": {
      "base": "t_uint256",
      "encoding": "inplace",
      "label": "uint256[2]",
      "numberOfBytes": "64"
    },
    "t_array(t_uint256)dyn_storage": {
      "base": "t_uint256",
      "encoding": "dynamic_array",
      "label": "uint256[]",
      "numberOfBytes": "32"
    },
    "t_bool": {
      "encoding": "inplace",
      "label": "bool",
      "numberOfBytes": "1"
    },
    "t_bytes_storage": {
      "encoding": "bytes",
      "label": "bytes",
      "numberOfBytes": "32"
    },
    "t_mapping(t_address,t_bool)": {
      "encoding": "mapping",
      "key": "t_address",
      "label": "mapping(address => bool)",
      "numberOfBytes": "32",
      "value": "t_bool"
    },
    "t_mapping(t_uint256,t_mapping(t_address,t_bool))": {
      "encoding": "mapping",
      "key": "t_uint256",
      "label": "mapping(uint256 => mapping(address => bool))",
      "numberOfBytes": "32",
      "value": "t_mapping(t_address,t_bool)"
    },
    "t_string_storage": {
      "encoding": "bytes",
      "label": "string",
      "numberOfBytes": "32"
    },
    "t_struct(S)13_storage": {
      "encoding": "inplace",
      "label": "struct A.S",
      "members": [
        {
          "astId": 3,
          "contract": "fileA:A",
          "label": "a",
          "offset": 0,
          "slot": "0",
          "type": "t_uint128"
        },
        {
          "astId": 5,
          "contract": "fileA:A",
          "label": "b",
          "offset": 16,
          "slot": "0",
          "type": "t_uint128"
        },
        {
          "astId": 9,
          "contract": "fileA:A",
          "label": "staticArray",
          "offset": 0,
          "slot": "1",
          "type": "t_array(t_uint256)2_storage"
        },
        {
          "astId": 12,
          "contract": "fileA:A",
          "label": "dynArray",
          "offset": 0,
          "slot": "3",
          "type": "t_array(t_uint256)dyn_storage"
        }
      ],
      "numberOfBytes": "128"
    },
    "t_uint128": {
      "encoding": "inplace",
      "label": "uint128",
      "numberOfBytes": "16"
    },
    "t_uint256": {
      "encoding": "inplace",
      "label": "uint256",
      "numberOfBytes": "32"
    }
  }
}

트랜지언트 스토리지 레이아웃 (Transient Storage Layout)

{
  "storage": [
    {
      "astId": 17,
      "contract": "fileA:A",
      "label": "y",
      "offset": 0,
      "slot": "0",
      "type": "t_uint256"
    },
    {
      "astId": 21,
      "contract": "fileA:A",
      "label": "z",
      "offset": 0,
      "slot": "1",
      "type": "t_uint256"
    },
    {
      "astId": 28,
      "contract": "fileA:A",
      "label": "taddr",
      "offset": 0,
      "slot": "2",
      "type": "t_address"
    }
  ],
  "types": {
    "t_address": {
      "encoding": "inplace",
      "label": "address",
      "numberOfBytes": "20"
    },
    "t_uint256": {
      "encoding": "inplace",
      "label": "uint256",
      "numberOfBytes": "32"
    }
  }
}

더 알아보기 (Learn more)