맵
맵 (Maps)
맵을 효율적으로 쓰는 방법은 레코드(record) 대신 맵을 쓸지의 선택에서 시작해요. 이 가이드는 먼저 레코드와 맵 중 무엇을 고를지에 대한 짧은 설명으로 시작하고, 이어서 맵을 레코드의 대안으로, 사전(dictionary)으로, 집합(set)으로 쓰는 구체적이고 간결한 조언을 3절에 걸쳐 다룹니다. 나머지 절에서는 맵이 어떻게 구현되는지, 맵 문법, 마지막으로 m:maps 모듈의 함수들에 대해 더 깊게 살펴볼게요.
이번 장에서 쓰는 용어는 다음과 같아요:
- 요소가 최대 32개인 맵을 비공식적으로 작은 맵(small map) 이라고 불러요.
- 요소가 32개를 넘는 맵을 비공식적으로 큰 맵(large map) 이라고 불러요.
맵 또는 레코드? (Maps or Records?)
이 장의 조언을 따른다면, 레코드의 성능과 레코드 대신 작은 맵을 쓰는 경우의 성능은 비슷할 것으로 기대돼요. 따라서 레코드와 맵 사이의 선택은 성능이 아니라 데이터 구조가 가진 원하는 특성에 근거해야 합니다.
레코드가 맵보다 나은 점은:
- 레코드 필드 이름을 잘못 입력하면 컴파일 오류가 나요. 반면 맵 키를 잘못 입력하면 컴파일러가 경고도 하지 않고, 프로그램이 실행될 때 어떤 식으로든 실패합니다.
- 레코드는 맵보다 메모리를 약간 덜 쓰고, 대부분의 상황에서 성능도 약간 더 좋을 것으로 기대돼요.
레코드가 맵보다 불리한 점은, 레코드에 새 필드를 추가하면 그 레코드를 쓰는 모든 코드를 다시 컴파일해야 한다는 거예요. 그 때문에 레코드는 한 번에 쉽게 다시 컴파일할 수 있는 코드 단위 안에서만 쓰는 것을 권장합니다. 예를 들어 단일 애플리케이션 안이나 단일 모듈 안처럼요.
맵을 레코드의 대안으로 쓰기
-
m:maps모듈의 함수 대신 맵 문법을 사용하세요. -
맵에 32개보다 많은 요소를 두지 마세요. 맵에 요소가 32개를 넘어가면 메모리를 더 필요로 하고, 키를 맵의 다른 인스턴스와 공유할 수 없게 돼요.
-
새 맵을 만들 때는 항상 앞으로 쓰게 될 모든 키를 넣어서 만들어요. 키 공유를 최대화(그래서 메모리 사용을 최소화)하려면, 맵 문법으로 맵을 만드는 단일 함수를 하나 만들고 항상 그 함수를 쓰세요.
-
맵을 갱신할 때는 항상
:=연산자를 사용하세요(즉, 그 키를 가진 요소가 이미 존재해야 한다고 요구하는 방식).:=연산자가 약간 더 효율적이고, 키를 잘못 입력하는 것도 잡아주죠. -
가능하면 한 번에 여러 맵 요소를 매칭하세요.
-
가능하면 한 번에 여러 맵 요소를 갱신하세요.
-
기본값과
maps:get/3함수는 피하세요. 기본값이 있으면 맵의 서로 다른 인스턴스 사이에서 키 공유가 덜 효율적이 되고, 기본값을 가진 여러 요소를 한 번에 매칭할 수 없어요. -
일부 키가 없을 수도 있는 맵을 다뤄야 하는 상황을 피하려면,
maps:merge/2가 여러 기본값을 효율적으로 추가해 줄 수 있어요. 예를 들면:DefaultMap = #{shoe_size => 42, editor => emacs}, MapWithDefaultsApplied = maps:merge(DefaultMap, OtherMap)
맵을 사전(Dictionary)으로 쓰기
맵을 사전으로 쓰는 것은 다음과 같은 사용 패턴을 의미해요:
- 키는 보통 컴파일 타임에 알 수 없는 변수예요.
- 맵에는 어떤 개수의 요소든 있을 수 있어요.
- 보통은 한 번에 하나 이상의 요소를 조회하거나 갱신하지 않습니다.
이런 사용 패턴이라면, 맵 문법을 쓰는 것과 maps 모듈을 쓰는 것 사이의 성능 차이는 보통 작아요. 그래서 무엇을 쓰느냐는 대부분 취향의 문제입니다.
맵은 보통 가장 효율적인 사전 데이터 구조인데, 몇 가지 예외가 있어요:
- 사전을 정렬된 목록으로 자주 변환하거나, 정렬된 목록에서 사전으로 자주 변환해야 한다면,
m:gb_trees를 쓰는 게 더 나은 선택일 수 있어요. - 모든 키가 음이 아닌 정수라면
m:array모듈이 더 나은 선택일 수 있습니다.
맵을 집합(Set)으로 쓰기
OTP 24부터 m:sets 모듈에는 집합을 맵으로 표현하는 옵션이 있어요. 예시:
1> sets:new([{version,2}]).
#{}
2> sets:from_list([x,y,z], [{version,2}]).
#{x => [],y => [],z => []}
맵으로 뒷받침되는 sets는 일반적으로 가장 효율적인 집합 표현인데, 몇 가지 가능한 예외가 있어요:
ordsets:intersection/2가sets:intersection/2보다 더 효율적일 수 있어요. 교집합 연산을 자주 쓰고 집합의 단일 요소를 다루는 연산(예:is_element/2)을 피한다면m:gb_sets보다m:ordsets가 더 나은 선택일 수 있습니다.- 교집합 연산을 자주 쓰는데 집합의 단일 요소를 다루는 연산(예:
is_element/2)도 효율적이어야 한다면,m:sets보다m:gb_sets가 더 나은 선택일 수 있어요. - 집합의 요소들이 꽤 빽빽한 범위의 정수라면, 집합을 정수로 표현할 수 있는데, 각 비트가 집합의 한 요소를 나타내요. 합집합(union)은
bor로, 교집합은band로 수행됩니다.
맵이 어떻게 구현되는가
내부적으로 맵은 맵의 요소 수에 따라 서로 다른 두 가지 표현을 가져요. 맵이 32개 요소를 넘어서거나 32개 이하로 줄어들 때 표현이 바뀝니다.
- 요소가 최대 32개인 맵은 컴팩트한 표현을 가지며, 레코드의 대안으로 쓰기에 적합해요.
- 요소가 32개를 넘는 맵은 요소가 몇 개든 효율적으로 탐색하고 갱신할 수 있는 트리로 표현됩니다.
작은 맵이 어떻게 구현되는가
작은 맵은 런타임 시스템 안에서 이렇게 생겼어요:
| 0 | 1 | 2 | 3 | N | |
|---|---|---|---|---|---|
| FLATMAP | N | Keys | Value1 | ... | ValueN |
표: 작은 맵의 표현
-
FLATMAP- 작은 맵의 태그예요(런타임 시스템 소스 코드에서는 flat map 이라고 불러요). -
N - 맵의 요소 수.
-
Keys - 맵의 키 튜플:
{Key1,...,KeyN}. 키는 정렬되어 있어요. -
Value1 - 키 튜플의 첫 번째 키에 대응하는 값.
-
ValueN - 키 튜플의 마지막 키에 대응하는 값.
예를 들어 맵 #{a => foo, z => bar}가 어떻게 표현되는지 살펴볼게요:
| 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| FLATMAP | 2 | {a,z} |
foo |
bar |
표: #{a => foo, z => bar}
이제 맵을 갱신해 보겠습니다: M#{q => baz}. 맵은 이제 이렇게 생겼어요:
| 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| FLATMAP | 3 | {a,q,z} |
foo |
baz |
bar |
표: #{a => foo, q => baz, z => bar}
마지막으로 한 요소의 값을 바꿔 볼까요: M#{z := bird}. 맵은 이제 이렇게 생겼어요:
| 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| FLATMAP | 3 | {a,q,z} |
foo |
baz |
bird |
표: #{a => foo, q => baz, z => bird}
기존 키의 값을 갱신할 때는 키 튜플이 갱신되지 않아요. 그래서 키 튜플을 같은 키를 가진 맵의 다른 인스턴스와 공유할 수 있죠. 실제로 키 튜플은 조금만 신경 쓰면 같은 키를 가진 모든 맵 사이에서 공유될 수 있어요. 그러려면 맵을 반환하는 함수를 정의하세요. 예를 들면:
new() ->
#{a => default, b => default, c => default}.
이렇게 정의하면 키 튜플 {a,b,c}는 전역 리터럴이 돼요. 맵의 인스턴스를 만들 때 키 튜플이 공유되도록 하려면, 항상 new()를 호출하고 그 반환된 맵을 수정하세요:
(SOME_MODULE:new())#{a := 42}.
작은 맵에서 맵 문법을 쓰는 것은 특히 효율적이에요. 키가 컴파일 타임에 알려져 있는 한, 맵은 한 번에 갱신되어, 갱신되는 키의 수와 무관하게 맵을 갱신하는 시간은 본질적으로 일정해요. 매칭도 마찬가지입니다. (키가 변수일 때는 키 중 하나 이상이 같을 수 있으므로, 연산은 왼쪽에서 오른쪽으로 순차적으로 수행되어야 해요.)
작은 맵의 메모리 크기는 모든 키와 값의 크기에 5워드를 더한 값이에요. 메모리 크기에 대한 자세한 내용은 Memory를 참고하세요.
큰 맵이 어떻게 구현되는가
요소가 32개를 넘는 맵은 Hash array mapped trie (HAMT)로 구현됩니다. 큰 맵은 맵의 요소 수와 무관하게 효율적으로 탐색하고 갱신할 수 있어요.
큰 맵에서 맵 문법으로 여러 요소를 매칭하거나 갱신해 얻는 성능 이득은 작은 맵에 비해 적어요. 실행 시간은 매칭되거나 갱신되는 요소 수에 거의 비례합니다.
큰 맵의 저장 오버헤드는 작은 맵보다 커요. 큰 맵의 경우 키와 값 외의 추가 워드 수는 요소 수에 거의 비례합니다. Memory의 공식에 따르면 33개 요소를 가진 맵의 오버헤드는 최소 53 힙 워드예요(요소 수와 무관하게 5워드 추가인 작은 맵과 비교하면요).
큰 맵이 갱신될 때, 갱신된 맵과 원본 맵은 HAMT의 공통 부분을 공유하지만, 공유가 작은 맵의 키 튜플이 가질 수 있는 최고 수준의 공유만큼 효율적이지는 않을 거예요.
따라서 맵을 레코드 대신 쓰는데 맵의 인스턴스가 많이 만들어질 것으로 예상된다면, 메모리 관점에서 큰 맵을 피하는 게 더 효율적입니다(예를 들어 관련 맵 요소를 하위 맵으로 묶어 요소 수를 줄이는 방식으로요).
맵 문법 사용하기
맵 문법을 쓰는 게 보통 m:maps 모듈의 대응 함수를 쓰는 것보다 약간 더 효율적이에요.
맵 문법의 효율 이득은, 맵 문법으로만 얻을 수 있는 다음 연산들에서 더 두드러져요:
- 여러 리터럴 키 매칭하기
- 여러 리터럴 키 갱신하기
- 맵에 여러 리터럴 키 추가하기
예를 들면:
이렇게(D0)
Map = Map1#{x := X, y := Y, z := Z}
하지 마세요(DON'T)
Map2 = maps:update(x, X, Map1),
Map3 = maps:update(y, Y, Map2),
Map = maps:update(z, Z, Map3)
맵이 작은 맵이라면 첫 번째 예시가 대략 세 배 정도 빠르게 실행돼요.
변수 키의 경우 요소가 왼쪽에서 오른쪽으로 순차적으로 갱신된다는 점을 기억해 두세요. 예를 들어 변수 키로 다음과 같이 갱신한다면:
Map = Map1#{Key1 := X, Key2 := Y, Key3 := Z}
컴파일러는 갱신이 왼쪽에서 오른쪽으로 적용되도록 이렇게 다시 씁니다:
Map2 = Map1#{Key1 := X},
Map3 = Map2#{Key2 := Y},
Map = Map3#{Key3 := Z}
키가 맵에 존재한다는 것이 알려져 있다면, 작은 맵에서 := 연산자를 쓰는 것이 => 연산자를 쓰는 것보다 약간 더 효율적이에요.
maps 모듈의 함수 사용하기
maps 모듈의 대부분의 함수에 대한 몇 가지 참고 사항이 이어집니다. 각 함수에 대해 구현 언어(C 또는 Erlang)를 밝힐게요. 언어를 언급하는 이유는 함수가 얼마나 효율적인지에 대한 힌트를 주기 때문이에요:
-
C로 구현된 함수라면, 같은 기능을 Erlang으로 더 효율적으로 구현하는 것은 거의 불가능해요.
-
그러나 Erlang으로 구현된
maps모듈의 함수를 이기는 것이 가능할 수 있어요. 그 함수들은 일반적으로 모든 가능한 입력에 대해 합리적인 성능을 내려는 방식으로 구현되기 때문이에요.예를 들어
maps:map/2는 맵의 모든 요소를 순회하면서 매핑 fun을 호출하고, 갱신된 맵 요소를 목록에 모은 다음, 마지막으로maps:from_list/1로 목록을 다시 맵으로 변환해요. 맵 안의 값 중 최대 1퍼센트만 바뀔 것이라고 알려져 있다면, 바뀐 값만 갱신하는 것이 더 효율적일 수 있어요.
참고 {: .info }
이 절에 주어진 구현 세부 사항은 미래에 바뀔 수 있어요.
maps:filter/2
maps:filter/2는 Erlang으로 구현되어 있어요. maps:from_list/1로 새 맵을 만들어요. 값 중 소수만 제거될 것이라고 알려져 있다면, maps:filter/2를 피하고 maps:remove/2로 원하지 않는 값을 제거하는 함수를 작성하는 것이 더 효율적일 수 있어요.
maps:filtermap/2
maps:filtermap/2는 Erlang으로 구현되어 있어요. maps:from_list/1로 새 맵을 만들어요. 더 효율적인 버전을 구현하는 방법의 힌트는 maps:map/2와 maps:filter/2에 대한 참고 사항을 보세요.
maps:find/2
maps:find/2는 C로 구현되어 있어요.
maps:find/2 대신 맵 매칭 문법을 쓰는 것이 약간 더 효율적인데, {ok,Value} 튜플을 만드는 것을 피할 수 있기 때문이에요.
maps:get/2
최적화로, 컴파일러는 maps:get/2 호출을 가드 BIF map_get/2 호출로 다시 써요. 가드 BIF 호출은 다른 BIF 호출보다 효율적이라서, 맵 매칭 문법을 쓰는 것과 비슷한 성능을 내요.
맵이 작고 키가 컴파일 타임에 알려진 상수라면, 맵 매칭 문법을 쓰는 것이 maps:get/2를 여러 번 호출하는 것보다 더 효율적이에요.
maps:get/3
최적화로, 컴파일러는 maps:get/3 호출을 다음과 비슷한 Erlang 코드로 다시 써요:
Result = case Map of
#{Key := Value} -> Value;
#{} -> Default
end
이것은 꽤 효율적이지만, 작은 맵을 레코드를 쓰는 대신 사용한다면 기본값을 신뢰하지 않는 것이 더 나을 때가 많아요. 기본값은 키 공유를 막아서, 결국 맵에 기본값을 저장하지 않음으로써 아끼는 것보다 더 많은 메모리를 쓸 수 있기 때문이에요.
그래도 기본값이 필요하다면, maps:get/3을 여러 번 호출하는 대신 기본값을 맵에 넣고 그 맵을 다른 맵과 병합하는 것을 고려해 보세요:
DefaultMap = #{Key1 => Value2, Key2 => Value2, ..., KeyN => ValueN},
MapWithDefaultsApplied = maps:merge(DefaultMap, OtherMap)
이렇게 하면 기본 맵이 앞으로 쓰게 될 모든 키를(기본값이 있는 키뿐 아니라) 포함하는 한, 기본 맵과 기본값을 적용한 맵 사이의 키 공유가 도움이 돼요. 이것이 maps:get/3을 여러 번 호출하는 것보다 빠른지는 맵의 크기와 기본값의 수에 달려 있어요.
변경 {: .info }
OTP 26.0 이전에는
maps:get/3이 Erlang 표현식으로 다시 쓰는 대신 함수를 호출하는 방식으로 구현됐어요. 이제는 약간 더 빠르지만 더 이상 추적(trace)할 수 없어요.
maps:intersect/2, maps:intersect_with/3
maps:intersect/2와 maps:intersect_with/3은 Erlang으로 구현되어 있어요. 둘 다 maps:from_list/1로 새 맵을 만들어요.
참고 {: .info }
맵은 보통 집합을 구현하는 가장 효율적인 방법이지만, 예외가 교집합 연산이에요.
m:ordsets에ordsets:intersection/2를 쓰는 것이 맵으로 구현된 집합에maps:intersect/2를 쓰는 것보다 더 효율적일 수 있어요.
maps:from_list/1
maps:from_list/1은 C로 구현되어 있어요.
maps:from_keys/2
maps:from_keys/2는 C로 구현되어 있어요.
maps:is_key/2
최적화로, 컴파일러는 maps:is_key/2 호출을 가드 BIF is_map_key/2 호출로 다시 써요. 가드 BIF 호출은 다른 BIF 호출보다 효율적이라서, 맵 매칭 문법을 쓰는 것과 비슷한 성능을 내요.
maps:iterator/1
maps:iterator/1은 C와 Erlang으로 효율적으로 구현되어 있어요.
maps:keys/1
maps:keys/1은 C로 구현되어 있어요. 결과 목록이 정렬되어야 한다면 lists:sort/1로 결과를 정렬하세요.
maps:map/2
maps:map/2는 Erlang으로 구현되어 있어요. maps:from_list/1로 새 맵을 만들어요. 값 중 소수만 갱신될 것이라고 알려져 있다면, maps:map/2를 피하고 maps:update/3를 호출해서 바뀐 값만 갱신하는 함수를 작성하는 것이 더 효율적일 수 있어요.
maps:merge/2
maps:merge/2는 C로 구현되어 있어요. 작은 맵의 경우, 인자 맵 중 하나가 모든 키를 포함한다면 키 튜플이 그 인자 맵과 공유될 수 있어요. 가능하면 리터럴 키 튜플이 선호됩니다.
변경 {: .info }
maps:merge/2의 키 튜플 공유는 OTP 26.0에서 도입됐어요. 이전 버전은 항상 호출자의 힙에 새 키 튜플을 만들었습니다.
maps:merge_with/3
maps:merge_with/3은 Erlang으로 구현되어 있어요. 두 맵 중 더 큰 맵을 갱신하고 반환합니다.
maps:new/0
컴파일러는 maps:new/0 호출을 빈 맵을 만드는 문법 #{}을 사용하도록 다시 써요.
maps:next/1
maps:next/1은 C와 Erlang으로 효율적으로 구현되어 있어요.
maps:put/3
maps:put/3은 C로 구현되어 있어요.
키가 맵에 이미 존재하는 것으로 알려져 있다면, maps:update/3가 maps:put/3보다 약간 더 효율적이에요.
컴파일러가 세 번째 인자가 항상 맵이라고 판단할 수 있다면, maps:put/3 호출을 맵을 갱신하는 맵 문법을 사용하도록 다시 써요.
예를 들어 다음 함수를 고려해 보세요:
add_to_known_map(Map0, A, B, C) when is_map(Map0) ->
Map1 = maps:put(a, A, Map0),
Map2 = maps:put(b, B, Map1),
maps:put(c, C, Map2).
컴파일러는 먼저 각 maps:put/3 호출을 맵 문법을 사용하도록 다시 쓰고, 이어서 세 갱신 연산을 단일 갱신 연산으로 결합해요:
add_to_known_map(Map0, A, B, C) when is_map(Map0) ->
Map0#{a => A, b => B, c => C}.
컴파일러가 세 번째 인자가 항상 맵이라고 판단할 수 없다면 maps:put/3 호출을 유지해요. 예를 들어 다음 함수가 주어지면:
add_to_map(Map0, A, B, C) ->
Map1 = maps:put(a, A, Map0),
Map2 = maps:put(b, B, Map1),
maps:put(c, C, Map2).
컴파일러는 첫 번째 maps:put/3 호출은 유지하되, 나머지 두 호출을 다시 쓰고 결합합니다:
add_to_map(Map0, A, B, C) ->
Map1 = maps:put(a, A, Map0),
Map1#{b => B, c => C}.
변경 {: .info }
maps:put/3을 맵 문법으로 다시 쓰는 것은 Erlang/OTP 28에서 도입됐어요.
maps:remove/2
maps:remove/2는 C로 구현되어 있어요.
maps:size/1
최적화로, 컴파일러는 maps:size/1 호출을 가드 BIF map_size/1 호출로 다시 써요. 가드 BIF 호출은 다른 BIF 호출보다 효율적이에요.
maps:take/2
maps:take/2는 C로 구현되어 있어요.
maps:to_list/1
maps:to_list/1은 C와 Erlang으로 효율적으로 구현되어 있어요. 결과 목록이 정렬되어야 한다면 lists:sort/1로 결과를 정렬하세요.
참고 {: .info }
맵은 보통
m:gb_trees보다 성능이 좋지만, 정렬된 목록으로의 변환을 자주 해야 한다면gb_trees가 더 나은 선택일 수 있어요.
maps:update/3
maps:update/3은 C로 구현되어 있어요.
키가 컴파일 타임에 알려진 상수라면, 특히 작은 맵의 경우 := 연산자로 맵 갱신 문법을 쓰는 것이 maps:update/3을 여러 번 호출하는 것보다 더 효율적이에요.
maps:values/1
maps:values/1은 C로 구현되어 있어요.
maps:with/2
maps:with/2는 Erlang으로 구현되어 있어요. maps:from_list/1로 새 맵을 만들어요.
maps:without/2
maps:without/2는 Erlang으로 구현되어 있어요. 입력 맵의 수정된 복사본을 반환합니다.