맵 (Maps)

맵을 효율적으로 쓰는 방법은 레코드(record) 대신 맵을 쓸지의 선택에서 시작해요. 이 가이드는 먼저 레코드와 맵 중 무엇을 고를지에 대한 짧은 설명으로 시작하고, 이어서 맵을 레코드의 대안으로, 사전(dictionary)으로, 집합(set)으로 쓰는 구체적이고 간결한 조언을 3절에 걸쳐 다룹니다. 나머지 절에서는 맵이 어떻게 구현되는지, 맵 문법, 마지막으로 m:maps 모듈의 함수들에 대해 더 깊게 살펴볼게요.

출처: Erlang 공식 문서 - 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/2sets: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/2maps: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/2maps:intersect_with/3은 Erlang으로 구현되어 있어요. 둘 다 maps:from_list/1로 새 맵을 만들어요.

참고 {: .info }

맵은 보통 집합을 구현하는 가장 효율적인 방법이지만, 예외가 교집합 연산이에요. m:ordsetsordsets: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/3maps: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으로 구현되어 있어요. 입력 맵의 수정된 복사본을 반환합니다.

더 알아보기 (Learn more)