Go 1.24는 언어의 가장 기본적인 자료구조인 내장 맵(map)의 런타임 구현을 통째로 교체했다. 오랫동안 쓰이던 버킷(bucket) 기반 구조가 스위스 테이블(Swiss Tables) 설계로 대체된 것이다. 코드에서 map[string]int 같은 타입은 그대로지만, 그 아래에서 키를 저장하고 찾는 방식이 근본적으로 달라졌다. 실무자가 맵을 쓰는 방식이 바뀌는 것은 아니지만, 왜 이 변화가 조회 성능과 메모리 지역성에 유리한지 이해해두면 대규모 데이터를 다루는 코드에서 판단의 근거가 된다.
맵의 껍데기와 씨앗
런타임에서 맵 변수는 internal/runtime/maps.Map 구조체를 가리키는 포인터다. 그래서 맵을 다른 변수에 복사하면 포인터만 복사되어 두 변수가 같은 실체를 공유한다. 이 구조체 맨 위에는 두 필드가 있는데, used는 현재 저장된 엔트리 수를 센다. len(m)이 O(1)인 이유가 여기 있다. 컴파일러가 이 호출을 첫 번째 필드 접근으로 치환하기 때문에 전체를 훑지 않는다. 또 다른 필드 seed는 맵마다 무작위로 초기화되는 값으로, 같은 키라도 맵이 다르면 해시 결과가 달라진다. 키의 저장 위치가 맵마다 흩어지도록 하는 장치다.
그룹과 컨트롤 바이트
스위스 테이블이 한 번에 들여다보는 최소 저장 단위는 '그룹(group)'이며, 하나의 그룹은 최대 8개의 키-값 쌍을 담는다. 그룹의 맨 윗줄에는 8개의 컨트롤 바이트가 있는데, 각 바이트는 바로 아래 슬롯 하나를 설명한다. Go는 키를 해시한 뒤 상위 57비트를 H1, 하위 7비트를 H2로 나눈다. H2는 컨트롤 바이트에 저장되어 슬롯을 빠르게 걸러내는 지문 역할을 한다. 컨트롤 바이트는 8비트지만 H2는 7비트만 쓰므로 남는 최상위 비트가 슬롯 상태를 표시한다. 이 비트가 0이면 살아 있는 엔트리이고 나머지 7비트가 H2다. 1이면 특수 상태로, 10000000은 빈 슬롯, 11111110은 삭제된 슬롯(툼스톤)을 뜻한다. 이 구분이 조회 시 빈 슬롯에서는 멈추지만 삭제된 슬롯은 지나쳐 계속 탐색해야 하는 이유가 된다.
조회의 핵심 비결은 SIMD다. Go는 8개 슬롯을 하나씩 비교하지 않는다. AMD64에서는 SIMD 명령으로 H2 값 하나를 8개 컨트롤 바이트와 동시에 비교해, 일치 후보를 비트맵으로 얻는다. 예컨대 H2가 42인 후보가 슬롯 0과 2에 있으면 두 비트만 켜지고, 나머지 6개 슬롯은 완전한 키를 읽지 않고 곧바로 배제된다. 후보로 걸러진 슬롯만 실제 키 동등성 검사를 거친다. 다른 아키텍처는 64비트 컨트롤 워드에 산술·비트 연산을 적용해 같은 후보 마스크를 만든다.
테이블로의 성장과 삼각 탐사
그룹 하나는 슬롯이 8개뿐이므로 그 이상 담으려면 구조가 커져야 한다. Go는 그룹을 1개에서 2개로 늘리고 이들을 관리하는 '테이블(table)'을 도입한다. 이때 H1이 등장한다. H1을 그룹 개수로 나눈 나머지가 각 키의 시작 그룹을 정한다. 그룹이 2개면 H1의 최하위 1비트만 쓰면 된다. 다만 이는 시작 지점일 뿐, 해당 그룹이 꽉 찼다면 다른 그룹을 봐야 한다. 이때 Go는 인접 그룹을 순서대로 훑지 않고 +1, +2, +3처럼 삼각수 간격으로 건너뛰며 끝에서 되돌아온다. 그룹 개수가 2의 거듭제곱이라 이 삼각 탐사 순서는 모든 그룹을 정확히 한 번씩 방문한 뒤에야 반복된다.
맵이 작을 때는 dirPtr이 그룹을 직접 가리키지만, 테이블 기반이 되면 이 포인터는 테이블을 가리키는 배열, 즉 디렉터리(directory)를 가리킨다. 테이블은 그룹을 계속 두 배로 늘려 최대 128개 그룹, 1024개 슬롯까지 커진다. 여기서 더 필요하면 테이블 자체를 둘로 쪼갠다. 흥미로운 점은 그룹을 나눌 때는 H1의 오른쪽 낮은 비트를 하나 더 쓰지만, 테이블을 쪼갤 때는 H1의 왼쪽 높은 비트를 써서 두 새 테이블 중 하나를 고른다는 것이다. 같은 해시의 서로 다른 끝을 용도별로 나눠 쓰는 셈이다.
로드 팩터라는 안전선
일반 테이블은 모든 슬롯을 채우기 전에 삽입 한계에 도달한다. 이 한계를 정하는 것이 로드 팩터이며, Go는 최대 7/8, 즉 87.5%로 제한한다. 중요한 것은 이 계산에 살아 있는 엔트리뿐 아니라 삭제된 슬롯도 포함된다는 점이다. growthLeft 필드가 한계까지 남은 빈 슬롯 수를 추적한다. 16슬롯 테이블이라면 16×7/8=14가 삽입 한계이므로, 15번째 키가 들어올 때 Go는 그룹 수를 두 배로 늘리고 모든 살아 있는 키를 다시 해시해 재분배한다. 삭제가 즉시 자리를 비우지 않고 툼스톤으로 남는 설계는 탐사 연쇄를 끊지 않기 위한 것이며, 그만큼 삭제가 잦은 맵은 실제 사용량보다 일찍 성장 임계에 닿을 수 있다는 점을 기억해둘 만하다.
결국 이번 변화는 API가 아니라 캐시 친화성과 병렬 비교에 초점을 맞춘 내부 최적화다. Go는 여기에 더해 키와 값을 별도 배열로 분리해 조회 지역성을 높이고 정렬 패딩을 줄이는 실험적 레이아웃도 시험 중이다. 코드를 고칠 필요는 없지만, 맵이 왜 특정 시점에 재해시로 잠시 무거워지는지, 삭제가 성능에 어떻게 영향을 주는지를 아는 것은 성능 민감한 서비스를 운영하는 개발자에게 여전히 쓸모 있는 지식이다.