Bitroot블로그
공식 사이트로 돌아가기 ↗
© 2026 Bitroot · 본 사이트의 내용은 일반 정보이며 금융, 투자, 법률 또는 세무 조언이 아닙니다.
편집 기준공식 사이트로 돌아가기
← 전체 글
EVM 기초·2026/10/02·약 2분

ABI 정독: selector, 정적 매개변수와 동적 타입

가공되지 않은 calldata에서 출발해 ABI를 해부합니다. 4바이트 selector는 정규 시그니처를 잘라낸 것이고, 정적 타입은 한 워드를 그대로 차지하며, 동적 타입은 오프셋과 길이라는 2단계 주소 지정에 기댑니다. 이벤트는 indexed 매개변수를 topics에 기록해 검색 가능성을 얻습니다. ABI는 자기 서술적이지 않으므로 디코딩과 업그레이드 모두 제약을 받습니다.

ERC-20 전송의 calldata는 보통 0xa9059cbb로 시작하고, 뒤에 32바이트 워드 두 개가 붙습니다. 첫 번째는 수신 주소, 두 번째는 금액입니다. 온체인에는 이 바이트 열이 transfer(address,uint256)에 대응한다고 알려 주는 필드가 없고, EVM(Ethereum Virtual Machine, 이더리움 가상 머신)도 함수 이름을 알아보지 못합니다. EVM은 배포 시 바이트코드에 컴파일된 디스패치 로직에 따라 calldata의 앞 4바이트를 비교해 해당 코드 구간으로 점프할 뿐입니다.

이 4바이트가 어디서 오는지, 매개변수가 어떤 것은 처음에 바로 놓이고 어떤 것은 뒤로 돌아가 찾아야 하는지, 그리고 calldata를 손에 넣고도 인터페이스 정의가 없으면 풀 수 없는 이유는 컨트랙트 상호작용을 이해하는 세 가지 문제입니다. 이 글은 먼저 ABI(Application Binary Interface, 애플리케이션 바이너리 인터페이스)의 인코딩 규칙을 설명하고, 명세 문서가 제시한 예제 calldata를 따라 디코딩을 손으로 한 번 수행한 뒤, 이벤트 로그가 매개변수를 topics와 data 두 위치로 나누는 이유를 마지막에 밝힙니다.

selector는 정규 시그니처 해시를 잘라낸 것이지 함수 이름 자체가 아니다

Solidity ABI 명세는 함수 셀렉터(function selector)를 아주 짧게 정의합니다. calldata의 앞 4바이트이며, 함수 시그니처의 Keccak-256 해시에서 가장 높은 4바이트를 가져옵니다. 여기서 시그니처는 정규 형식을 취하는데, 소스 코드에 적는 표기와는 같지 않습니다. 함수 이름에 괄호로 감싼 매개변수 타입 목록을 붙이고, 타입 사이는 쉼표 하나로 구분하며, 공백도 매개변수 이름도 memory, calldata, storage 같은 데이터 위치 수정자도 넣지 않습니다.

정규 형식은 타입 정규화도 수행합니다. uint와 int는 반드시 uint256과 int256으로 써야 하고, address payable과 컨트랙트 타입은 모두 address로, 열거형(enum)은 uint8로, 사용자 정의 값 타입은 그 기반 타입으로 씁니다. 구조체(struct)는 괄호로 감싼 컴포넌트 타입, 즉 튜플(tuple)로 씁니다. S에 uint256 필드가 두 개라면 function f(S memory s)의 시그니처는 f((uint256,uint256))이고, 셀렉터는 keccak256("f((uint256,uint256))")의 앞 4바이트에서 가져옵니다. 반환값은 시그니처에 참여하지 않으며, 이는 Solidity의 오버로드 해석과 일치합니다. 호출자는 매개변수로만 대상 함수를 구분하고, 반환값 차이로는 모호함을 풀 수 없습니다. 시그니처에서는 문자 하나하나가 결과에 영향을 주므로, 공백이 하나 더 있거나 uint를 uint256으로 쓰면 셀렉터가 완전히 달라집니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SelectorDemo {
    function selectorOfTransfer() external pure returns (bytes4) {
        // 따옴표 안은 정규 시그니처: 공백 없음, 매개변수 이름 없음, uint는 uint256으로 정규화
        return bytes4(keccak256("transfer(address,uint256)"));
    }
}

32비트 공간이 낳은 직접적 결과는 충돌이 이론적 가정이 아니라는 점입니다. 2026년 9월 기준으로 4byte.directory에서 0xa9059cbb라는 셀렉터 하나에 후보 시그니처 6개가 올라와 있습니다. transfer(address,uint256) 외에도 many_msg_babbage(bytes1), workMyDirefulOwner(uint256,uint256), func_2093253501(bytes) 같은 자리표시자 이름이 있습니다. 이 데이터베이스의 항목은 누구나 제출할 수 있어 개수와 내용은 시간이 지나면 바뀝니다. 서로 다른 두 함수가 셀렉터를 공유하면 calldata 접두사만으로는 호출 의도를 구분할 수 없고, 대상 컨트랙트의 코드나 인터페이스 정의에 기대어 판단해야 합니다.

Solidity 공식 문서는 이 제약을 따로 명시하지 않지만, solc는 구현 수준에서 같은 컨트랙트(상속 체인 포함) 안의 셀렉터 중복을 검사합니다. 두 함수의 셀렉터가 같으면 컴파일이 Function signature hash collision으로 실패합니다(최소한의 컨트랙트로 재현할 수 있습니다). 컨트랙트를 넘나들거나 컴파일러 버전이 다르면 이 검사는 없습니다. 이는 4byte 계열 데이터베이스만으로 함수 이름을 역조회하는 것이 신뢰할 만하지 않다는 뜻이기도 합니다. 데이터베이스가 주는 것은 후보 집합이고, 충돌 시에는 유일한 답을 확정할 수 없습니다.

정적 타입은 한 워드를 그대로 차지한다

인코딩은 5번째 바이트에서 시작합니다. 규칙은 먼저 매개변수를 정적과 동적 두 종류로 나눕니다. 정적 타입은 그 자리에서 인코딩되어 매개변수 바이트 스트림에 바로 기록되고, 동적 타입은 실제 내용을 뒤에 두고 원래 자리에는 오프셋만 남깁니다.

EVM의 워드 길이는 32바이트이고, 정적 타입은 예외 없이 한 워드를 가득 채웁니다. uint<M>과 int<M>은 빅엔디언으로 기록하며 상위 비트를 채웁니다. 부호 없는 수는 0으로, 부호 있는 수는 부호 확장해 0xff 또는 0x00으로 채웁니다. bool은 uint8과 같으며 true는 1, false는 0으로 인코딩됩니다. address는 uint160과 같으므로 20바이트 주소는 32바이트 워드의 하위 바이트 자리에 놓이고 앞쪽 12바이트는 0으로 채워집니다. 고정 길이 바이트 타입 bytes<M>은 채우는 방향이 반대입니다. 값이 왼쪽으로 정렬되고 오른쪽이 32바이트가 되도록 0으로 채워집니다. 둘 다 정적 타입이지만 uint32(1)과 bytes4(0x00000001)은 같은 워드 안에서 서로 다른 위치에 놓입니다. 전자는 마지막 4바이트, 후자는 처음 4바이트에 있습니다. 16진수 덤프를 읽을 때 가장 헷갈리기 쉬운 지점입니다.

고정 길이 배열 <type>[M]은 원소 타입도 정적일 때 마찬가지로 정적으로 분류되며, 인코딩할 때 튜플처럼 펼쳐 M개 원소를 차례로 나열하고 길이는 쓰지 않습니다. 구조체와 튜플도 같은 방식으로 재귀적으로 펼쳐집니다. 명세는 정적 타입을 "bytes, string, T[], 원소가 동적 타입인 T[k], 그리고 동적 멤버를 포함한 튜플을 제외한 모든 타입"으로 정의하며, 이 정의가 "값을 바로 읽을지 포인터를 따라갈지"를 판단하는 유일한 근거입니다.

calldata 자체에도 gas 비용이 있고, 그것도 ABI의 32바이트 정렬과 일치하지 않습니다. EIP-2028은 트랜잭션 데이터에서 0이 아닌 바이트의 비용을 68 gas에서 16 gas로 낮췄고, 0 바이트는 여전히 4 gas입니다. 이 비용은 트랜잭션 고유 gas에 속합니다. uint256에 아주 작은 값을 넘겨도 여전히 32바이트를 가득 채우며 그 대부분은 0 바이트라서 비용이 낮을 뿐 결코 0은 아닙니다. 반대로 여러 작은 정수를 bytes32로 촘촘히 묶으면 gas를 아낄 수 있지만, 그 대가로 온체인에서 읽을 수 있는 매개변수 경계를 잃고 디코딩 쪽이 이 사용자 정의 레이아웃을 알아야 합니다.

동적 타입은 오프셋과 길이 접두사로 2단계 주소 지정을 한다

bytes, string, T[], 그리고 원소가 동적 타입인 고정 길이 배열과 튜플은 모두 동적 타입에 속합니다. 이들의 길이는 런타임 값에 달려 있어 컴파일 시점에 고정할 수 없으므로, 매개변수 바이트 스트림에서 고정 폭을 차지할 수 없습니다.

ABI는 헤드/테일(head/tail) 레이아웃을 씁니다. 모든 매개변수의 "헤드"가 먼저 차례로 나열됩니다. 정적 타입의 헤드는 그 자체의 인코딩이고, 동적 타입의 헤드는 32바이트 오프셋입니다. 오프셋의 기준은 매개변수 블록의 시작, 즉 셀렉터 다음의 첫 바이트이며, calldata 전체의 시작이 아닙니다. 손으로 디코딩할 때 가장 흔한 어긋남의 원인입니다. 헤드의 길이는 매개변수 타입에만 달려 있고 매개변수 값과는 무관합니다. 헤드 뒤에는 각 동적 매개변수의 "테일"이 차례로 놓입니다. 테일의 첫 항목은 길이이고, 단위는 타입에 따라 다릅니다. bytes와 string은 바이트 수, 배열은 원소 개수이며, 길이 다음에 내용이 옵니다.

string의 길이는 문자 수가 아니라 UTF-8로 인코딩한 뒤의 바이트 수를 적으므로, 중국어 문자와 이모지는 여러 바이트를 차지합니다. 내용 뒤에는 32바이트의 정수 배가 되도록 0을 채웁니다. 배열 원소에는 같은 규칙이 재귀적으로 적용되고, 중첩 배열(예: uint256[][])에서는 각 층이 나름의 길이와 오프셋을 가지며, 오프셋은 언제나 그것이 속한 층의 인코딩 블록 시작을 기준으로 합니다. 명세는 이 설계의 목표를 아주 분명히 적어 두었습니다. 최악의 경우 어떤 값에 접근하는 읽기 횟수가 매개변수 구조에서의 중첩 깊이를 넘지 않고, 어느 원소의 데이터든 통째로 옮겨도 무효화되지 않습니다. 상대 주소에만 의존하기 때문입니다.

놓치기 쉬운 세부 사항이 하나 있습니다. 오프셋은 최소일 필요도, 데이터 영역이 서로 겹치지 않을 필요도 없습니다. 명세가 정의한 엄격 인코딩 모드(strict encoding mode)는 헤드가 촘촘하고 빈틈이 없을 것을 요구하며, Solidity 인코더는 언제나 엄격 모드를 출력하지만 디코더는 이를 강제로 검사하지 않습니다. 다시 말해 의미가 같은 호출 하나에도 여러 바이트 레이아웃이 존재할 수 있고, 디코딩 구현은 빈틈이 있는 오프셋을 받아들이거나 명시적으로 거부해야 합니다. 두 선택은 클라이언트를 교차 비교할 때 차이를 낳습니다.

calldata 한 조각을 손으로 디코딩하기

디코딩 예제는 "Contract ABI Specification"의 Examples 절에서 그대로 가져왔습니다. sam(bytes,bool,uint256[])를 호출하며 매개변수는 "dave", true, [1,2,3]이고, 총 292바이트로 32바이트마다 줄을 바꾸면 다음과 같습니다.

0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

디코딩 단계는 명세 정의대로 엄격히 재현할 수 있습니다.

  1. 앞 4바이트 0xa5643bf2는 셀렉터이고 sam(bytes,bool,uint256[]) 시그니처에 대응합니다. 시그니처의 uint는 반드시 uint256으로 써야 합니다.
  2. 매개변수 블록에는 헤드가 3개 있고 각각 32바이트입니다. 1번째 워드는 0x60, 즉 96으로 1번째 매개변수 bytes의 오프셋입니다. 2번째 워드는 0x01이고 bool은 true입니다. 3번째 워드는 0xa0, 즉 160으로 3번째 매개변수 uint256[]를 가리킵니다.
  3. 헤드는 모두 96바이트이므로 1번째 매개변수의 테일은 매개변수 블록의 96번째 바이트에서 시작하고 0x60과 자기 정합적입니다. 테일의 1번째 워드는 0x04로 bytes가 4바이트임을 나타냅니다. 이어지는 4바이트는 64617665이고 ASCII로 읽으면 dave입니다. 그다음 32바이트가 되도록 0을 채웁니다.
  4. 1번째 매개변수의 테일은 두 워드 64바이트를 차지하며 96에서 160까지입니다. 따라서 2번째 동적 매개변수의 오프셋은 0xa0입니다. 그 위치의 1번째 워드는 0x03으로 배열에 원소가 3개임을 뜻하고, 이어지는 3개 워드는 각각 1, 2, 3입니다.

같은 과정을 최소한의 디코더로 적을 수 있습니다. 아래 Python 코드는 위의 시그니처 하나만 처리하며, 오프셋의 단위가 바이트라는 점과 길이 접두사의 위치를 설명합니다.

# 선택자 이후의 매개변수 블록을 입력으로 받아 32바이트 단위로 워드를 자름
def word(data, i):
    return int.from_bytes(data[i * 32:(i + 1) * 32], "big")

def decode_sam(args: bytes):
    bytes_off = word(args, 0)          # 1번째 워드: bytes 매개변수의 매개변수 블록 시작 기준 바이트 오프셋
    flag = word(args, 1) != 0          # 2번째 워드: bool
    arr_off = word(args, 2)            # 3번째 워드: uint256[]의 오프셋

    n = word(args, bytes_off // 32)    # 오프셋을 워드 인덱스로 환산한 뒤 길이 접두사를 읽음
    s = args[bytes_off + 32: bytes_off + 32 + n].decode()

    k = word(args, arr_off // 32)      # 배열도 마찬가지로 원소 개수를 먼저 읽음
    items = [word(args, arr_off // 32 + 1 + j) for j in range(k)]
    return s, flag, items              # ("dave", True, [1, 2, 3])

ABI는 자기 서술적이지 않고, schema는 오프체인 필수 요소다

명세는 첫머리에서 이 인코딩이 자기 서술적이지 않으며 디코딩에는 schema가 반드시 필요하다고 밝힙니다. 이 문장의 공학적 함의는 읽었을 때보다 무겁습니다.

EVM이 받는 것은 바이트뿐이고, 함수 이름과 매개변수 이름, 타입 정보는 컴파일 후 모두 사라집니다. 디스패치는 4바이트 셀렉터를 비교하는 방식이고 점프 대상은 컴파일러가 만든 switch입니다. 반환 데이터 역시 바이트 스트림일 뿐이라, 호출자가 동적 길이 반환값을 읽으려면 RETURNDATASIZE와 RETURNDATACOPY 같은 연산이 필요합니다(EIP-211에서 제안되어 비잔티움 업그레이드에서 활성화). 온체인에는 ABI JSON을 둘 자리가 없어, 그것은 컴파일 산출물로서 소스와 함께 배포될 수밖에 없습니다. 여기서 몇 가지 구체적 제약이 나옵니다.

블록 탐색기가 "어떤 함수가 호출되었고 매개변수가 무엇인지"를 표시하려면 먼저 검증된 소스와 ABI를 확보해야 합니다. 검증되지 않은 컨트랙트는 원시 calldata만 표시하거나, 4byte 계열 데이터베이스로 역조회하는 쪽으로 물러설 수밖에 없습니다. 그런데 역조회로 얻는 것은 후보 시그니처 집합이라 충돌 시 확정할 수 없습니다. 인덱서와 데이터 파이프라인은 배포 시점에 ABI를 알고 있어야 하며, 그렇지 않으면 이벤트와 호출에 인덱스를 만들 수 없습니다. 인터페이스 진화에도 필드 번호 같은 호환 기제가 없습니다. 함수에 매개변수를 추가하거나 삭제하거나 순서를 바꾸면 셀렉터가 달라지고, 호출자 입장에서는 완전한 인터페이스 파괴입니다. 그래서 업그레이드는 보통 기존 함수를 수정하는 대신 새 함수를 추가하는 방식만 가능합니다. 프록시 컨트랙트는 구현 계층에서 함수를 추가할 수 있지만 셀렉터는 안정적으로 유지해야 하며, 시그니처를 바꾸는 어떤 "리팩터링"이든 기존 호출을 fallback 분기로 떨어뜨립니다. fallback은 원시 calldata만 받으므로 호출자의 의도를 복원할 수 없습니다.

흔히 오해하는 지점이 두 가지 더 있습니다. 셀렉터가 같다고 인코딩이 서로 바꿔 쓸 수 있는 것은 아닙니다. transfer(address,uint256)와 many_msg_babbage(bytes1)는 0xa9059cbb를 공유하지만 매개변수 길이와 의미가 전혀 다르므로, 도구는 충돌 시 컨트랙트 코드나 calldata 길이를 함께 보고 판단해야 합니다. 사용자 정의 오류(custom error)의 셀렉터도 오류 시그니처의 앞 4바이트에서 가져오므로, 어떤 컨트랙트든 특정 오류 시그니처에 맞는 데이터를 반환할 수 있습니다. 그래서 명세는 호출자에게 오류 데이터를 출처가 신뢰할 수 있는 정보로 취급하지 말라고 분명히 경고하며, 그것은 힌트로만 적합합니다.

이벤트는 검색 가능성을 topics에, 가독성을 data에 남긴다

이벤트의 인코딩 규칙은 함수 호출과 뿌리가 같지만 적용 지점이 다릅니다. 로그 하나는 컨트랙트 주소, 최대 4개의 토픽(topic), 임의 길이 데이터 한 조각으로 구성됩니다. 익명이 아닌 이벤트의 topics[0]은 언제나 이벤트 시그니처의 Keccak-256 해시이며 4바이트로 자르지 않습니다. eth_getLogs로 이벤트를 필터링할 때 조건에 이벤트 이름이 아니라 시그니처 해시를 쓰는 이유도 여기에 있습니다. 이벤트 시그니처도 정규 형식을 사용하므로 uint는 uint256으로 정규화됩니다.

나머지 매개변수는 indexed 여부에 따라 두 갈래로 나뉩니다. indexed가 붙은 값 타입 매개변수는 topics[1]부터 topics[3]까지에 바로 인코딩됩니다. 정수는 상위 비트를 채우고, 고정 길이 바이트는 오른쪽을 채우며, 주소는 하위 20바이트를 취합니다. 익명이 아닌 이벤트의 indexed 매개변수는 최대 3개이고 시그니처 토픽까지 더하면 정확히 4개를 다 씁니다. anonymous로 선언한 이벤트는 시그니처 토픽을 쓰지 않아 indexed 매개변수가 최대 4개인 대신 시그니처로 필터링하는 능력을 잃습니다. indexed가 없는 매개변수는 ABI 규칙대로 data에 인코딩되며, 이 데이터 내부에도 헤드/테일 구조가 있어 동적 매개변수는 그 안에서도 오프셋으로 주소를 찾습니다.

차이는 indexed가 붙은 복잡한 타입에서 집중적으로 드러납니다. 배열, string, bytes, 구조체가 indexed로 표시되면 topic에 기록되는 것은 그 인코딩의 Keccak-256 해시입니다. 해시는 되돌릴 수 없으므로 이런 매개변수는 정확히 검색할 수 있습니다. 목표 값의 해시를 미리 계산해 두면 그것을 필터 조건으로 삼아 로그를 찾아낼 수 있습니다. 그러나 로그에서 원래 값을 복원할 수는 없고 일치 여부만 확인할 수 있습니다. 명세가 제시하는 대책은 같은 값을 담는 매개변수를 두 개 선언하는 것입니다. 하나는 indexed로 검색에, 하나는 indexed 없이 읽기에 쓰며, 대가는 로그 크기와 gas 증가입니다. 해시 인코딩 규칙 자체에도 모호함이 남아 있습니다. 구조체가 동적 배열을 여러 개 포함하면 이어 붙인 바이트 열이 유일하지 않을 수 있으므로, 명세는 indexed 매개변수의 검색 결과만으로 이벤트의 의미를 단정하지 말라고 경고합니다.

마지막으로 "검색 가능"과 "디코딩 가능"을 구분해야 합니다. 값 타입 indexed 매개변수는 바로 읽어낼 수 있고, 동적 타입 indexed 매개변수는 적중만 될 뿐 읽어낼 수 없습니다. indexed가 아닌 매개변수는 읽을 수 있지만 값으로 검색할 수는 없습니다. 로그가 topics와 data를 나누는 것은 로그 크기, 검색 능력, 디코딩 능력 사이의 절충이며, 어느 한 종류의 매개변수도 세 가지 속성을 동시에 가질 수 없습니다.

출처

  • Solidity 문서 "Contract ABI Specification", 함수 셀렉터, 헤드/테일 레이아웃, 엄격 인코딩 모드, 이벤트와 indexed 매개변수 인코딩 명세, sam 디코딩 예제: https://docs.soliditylang.org/en/latest/abi-spec.html
  • 4byte.directory의 0xa9059cbb 후보 시그니처 목록(2026년 9월 조회, 항목은 누구나 제출 가능): https://www.4byte.directory/signatures/?bytes4_signature=0xa9059cbb
  • EIP-609 "Hardfork Meta: Byzantium", EIP-211과 EIP-214를 비잔티움 포함 목록에 넣은 메타 제안: https://eips.ethereum.org/EIPS/eip-609
  • EIP-2028 "Transaction data gas cost reduction", 0이 아닌 calldata 바이트를 68 gas에서 16 gas로 인하: https://eips.ethereum.org/EIPS/eip-2028
  • EIP-211 "New opcodes: RETURNDATASIZE and RETURNDATACOPY", 동적 반환 데이터 읽기와 BYZANTIUM_FORK_BLKNUM: https://eips.ethereum.org/EIPS/eip-211
  • Ethereum Yellow Paper, 부록 G 수수료 표의 G_txdatazero 4 gas와 G_txdatanonzero 16 gas: https://ethereum.github.io/yellowpaper/paper.pdf
  • ethereum.org EVM 오프코드 참고와 wolflo/evm-opcodes 동적 gas 표, 트랜잭션 고유 gas의 0/0이 아닌 바이트 가격: https://ethereum.org/en/developers/docs/evm/opcodes/ , https://github.com/wolflo/evm-opcodes/blob/main/gas.md

더 읽기

EVM 호환성의 실제 의미: 바이트코드, 프리컴파일, JSON-RPC, 툴링Bitroot 낙관적 병렬화: 감지, 재실행, 결정성

호출 명령어와 컨텍스트의 관계는 0.17 "CALL 계열 대조: CALL, CALLCODE, DELEGATECALL, STATICCALL"을 참고하십시오.

← 이전단일 스레드 전역 상태: EVM 성능 병목의 설계 원점
목차
selector는 정규 시그니처 해시를 잘라낸 것이지 함수 이름 자체가 아니다정적 타입은 한 워드를 그대로 차지한다동적 타입은 오프셋과 길이 접두사로 2단계 주소 지정을 한다calldata 한 조각을 손으로 디코딩하기ABI는 자기 서술적이지 않고, schema는 오프체인 필수 요소다이벤트는 검색 가능성을 topics에, 가독성을 data에 남긴다출처더 읽기
읽기 설정