paint-brush
복합 키: 키 처리 방법에 대한 가이드~에 의해@kevinmasur
963 판독값
963 판독값

복합 키: 키 처리 방법에 대한 가이드

~에 의해 Kevin Masur9m2024/01/14
Read on Terminal Reader

너무 오래; 읽다

대부분의 경우 단순하게 유지하세요. 가장 쉬운 옵션이고 성능이 주요 관심사가 아닌 경우 복합 키를 맵이나 캐시에 저장하기 위한 문자열 키로 결합합니다. 성능이 중요한 시나리오에서는 자체 테스트를 수행해야 합니다. 그러나 대부분의 경우 중첩된 맵을 사용하는 것이 가장 효과적입니다. 또한 스토리지 요구 사항이 가장 작을 수도 있습니다. 그리고 중첩 매핑이 실용적이지 않을 때 복합 키는 여전히 성능 좋은 대안입니다.
featured image - 복합 키: 키 처리 방법에 대한 가이드
Kevin Masur HackerNoon profile picture

복합 키는 지도 또는 캐시 조회를 위한 "키"를 정의하기 위해 데이터 조합이 필요한 경우입니다. 이에 대한 예로 고객 이름과 사용자 역할을 기반으로 값을 캐시해야 하는 경우를 들 수 있습니다. 이와 같은 경우 캐시는 이러한 두 가지(또는 그 이상) 기준 각각에 따라 고유한 값을 저장할 수 있어야 합니다.


코드에서 복합 키를 처리할 수 있는 몇 가지 방법이 있습니다.

기준을 문자열로 결합

가장 많이 사용되는 첫 번째 대답은 기준을 문자열로 결합하여 키로 사용하는 것입니다. 간단하고 많은 노력이 필요하지 않습니다.


 private String getMapKey(Long userId, String userLocale) { return userId + "." userLocale; }


이것은 문제를 처리하는 매우 기본적인 방법입니다. 문자열 키를 사용하면 캐시 키가 사람이 읽을 수 있는 형식이므로 디버깅 및 조사가 더 쉬워집니다. 그러나 이 접근 방식에는 알아야 할 몇 가지 문제가 있습니다.


  1. 지도와 상호작용할 때마다 새 문자열을 생성해야 합니다. 이 문자열 할당은 일반적으로 작지만 맵에 자주 액세스하면 시간이 걸리고 가비지 수집이 필요한 할당 수가 많아질 수 있습니다. 문자열 할당의 크기는 키 구성 요소의 크기 또는 보유한 수에 따라 더 커질 수도 있습니다.


  2. 생성한 복합 키가 다른 키 값으로 스푸핑되지 않도록 해야 합니다.

 public String getMapKey(Integer groupId, Integer accessType) { return groupId.toString() + accessType.toString(); }


위에서 groupId = 1이고 accessType = 23인 경우 이는 groupId = 12 및 accessType = 3과 동일한 캐시 키입니다. 문자열 사이에 구분 문자를 추가하면 이러한 종류의 겹침을 방지할 수 있습니다. 그러나 키의 선택적 부분에 주의하세요.


 public String getMapKey(String userProvidedString, String extensionName) { return userProvidedString + (extensionName == null ? "" : ("." + extensionName)); }


위의 예에서 ExtensionName은 키의 선택적 부분입니다. ExtensionName이 선택 사항인 경우 userProvidedString은 구분 기호와 유효한 ExtensionName을 포함할 수 있으며 액세스해서는 안 되는 캐시 데이터에 액세스할 수 있습니다.


문자열을 사용할 때 키 충돌을 피하기 위해 데이터를 결합하는 방법에 대해 생각하고 싶을 것입니다. 특히 키에 대한 사용자 생성 입력 주변에서는 더욱 그렇습니다.

중첩된 맵/캐시 사용

또 다른 옵션은 키를 전혀 결합하지 않고 대신 데이터 구조(지도의 지도)를 중첩하는 것입니다.


 Map<Integer, Map<String, String>> groupAndLocaleMap = new HashMap<>(); groupAndLocaleMap.computeIfAbsent(userId, k -> new HashMap()).put(userLocale, mapValue);


이는 전달된 키 값이 이미 할당되어 있으므로 맵과 상호 작용할 때 새 메모리를 할당할 필요가 없다는 장점이 있습니다. 최종 값을 얻으려면 여러 번 조회해야 하지만 맵은 더 작아집니다.


그러나 이 접근 방식의 단점은 중첩이 깊어질수록 더 복잡해진다는 것입니다. 레벨이 두 개뿐인 경우에도 맵 초기화가 혼란스러워 보일 수 있습니다. 3개 이상의 데이터를 처리하기 시작하면 코드가 매우 장황해질 수 있습니다. 게다가 각 레벨에서는 널 포인터를 피하기 위해 널 검사가 필요합니다.


일부 "핵심 부분"도 지도 키로 제대로 작동하지 않을 수 있습니다. 배열이나 컬렉션에는 내용을 비교하는 기본 equals 메서드가 없습니다. 따라서 이를 구현하거나 다른 대안을 사용해야 합니다.


중첩된 맵을 사용하면 각 키 수준의 고유성에 따라 공간 효율성이 떨어질 수도 있습니다.

복합 키 개체 생성

마지막 옵션은 키 값을 문자열로 결합하는 대신 키에 대한 사용자 정의 개체를 만드는 것입니다.

 private class MapKey { private final int userId; private final String userLocale; public MapKey(int userId, String userLocale) { this.userId = userId; this.userLocale = userLocale; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MapKey mapKey = (MapKey) o; return userId == mapKey.userId && Objects.equals(userLocale, mapKey.userLocale); } @Override public int hashCode() { return Objects.hash(userId, userLocale); } }


모든 상호 작용에는 여전히 새 객체에 대한 새로운 메모리 할당이 필요합니다. 개체 키 할당은 복합 문자열에 필요한 것보다 훨씬 작습니다. 그 이유는 키를 구성하는 부분을 문자열로 다시 할당할 필요가 없기 때문입니다. 대신 래핑 객체 키에만 새 메모리가 필요합니다.


복합 키 개체를 사용하면 키 동일성 및 해시 코드 구현을 사용자 지정할 수도 있습니다. 문자열에서 대문자를 무시하거나 배열이나 컬렉션을 키의 일부로 사용하는 등.


하지만 여기서의 단점은 복합 문자열보다 훨씬 더 많은 코드가 필요하다는 것입니다. 그리고 지도의 키 클래스에 유효한 같음 및 해시 코드 계약이 있는지 확인해야 합니다.


그럼 어느 것을 선택해야 할까요?


일반적으로 복합 문자열 키를 사용하는 것이 좋습니다. 간단하고 이해하기 쉽고 최소한의 코드가 필요하며 나중에 디버깅하기가 가장 쉽습니다. 성능이 가장 느릴 수 있지만 간단하고 읽기 쉬운 코드를 작성하는 것이 일반적으로 다른 두 옵션 중 하나를 사용하여 얻을 수 있는 이점보다 더 중요합니다. 기억하다:


“성급한 최적화는 모든 악의 근원입니다” Donald Knuth


맵/캐시 조회가 성능 병목 현상을 일으킬 것이라는 증거나 이유가 없다면 가독성을 고려하세요.


그러나 맵이나 캐시에 대한 처리량이 매우 높은 시나리오에 있다면 다른 두 옵션 중 하나로 전환하는 것이 좋을 수 있습니다. 3개 모두 성능 측면과 메모리 할당 크기를 서로 비교하는 방법을 살펴보겠습니다.


위의 3가지 시나리오를 테스트하기 위해 복합 키에 대한 3가지 시나리오 모두의 동일한 구현을 복제하는 코드를 작성했습니다. 키 자체는 정수 값, 문자열 값 및 긴 값으로 구성됩니다. 세 가지 구현 모두 각 실행에서 동일한 테스트 데이터를 사용하여 키를 구축했습니다.


모든 실행은 맵에 있는 100만 개의 레코드로 실행되었습니다(Java의 해시맵이 사용되었습니다). 다양한 키 크기 조합으로 키를 구축하는 작업이 3번 수행되었습니다.


  • 정수 100개, 문자열 100개, 길이 100개 — 고유 키 1백만 개

  • 정수 1개, 문자열 1개, 길이 1,000,000개 - 고유 키 1백만 개

  • 정수 1,000,000개, 문자열 1개, 길이 1개 — 고유 키 1백만 개


먼저, 각 맵이 힙에서 차지하는 공간을 살펴보겠습니다. 이는 애플리케이션을 실행하는 데 필요한 메모리 양에 영향을 미치기 때문에 중요합니다.


유지된 맵 크기(MB)(맵 생성 후 힙 덤프로 캡처)


여기에는 한 가지 흥미롭고 분명한 참고 사항이 있습니다. 마지막 시나리오(1,000,000 정수)에서는 중첩된 맵 크기가 다른 맵보다 훨씬 큽니다. 이는 이 시나리오에서 중첩된 맵이 100만 개의 항목이 포함된 1차 수준 맵 1개를 생성하기 때문입니다. 그런 다음 두 번째 및 세 번째 수준에서는 단 하나의 항목으로 100만 개의 지도를 생성합니다.


모든 중첩된 맵은 추가 오버헤드를 저장하며 대부분 비어 있습니다. 이것은 분명히 극단적인 경우이지만 요점을 강조하기 위해 보여주고 싶었습니다. 중첩 맵 구현을 사용할 때 고유성(및 해당 고유성의 순서)이 매우 중요합니다.


순서를 1, 1, 100만으로 바꾸면 실제로 가장 낮은 저장 공간 요구 사항을 얻게 됩니다.


다른 두 시나리오에서는 중첩 매핑이 가장 효율적입니다. 사용자 지정 키 개체가 두 번째로 나오고 문자열 키가 마지막으로 나옵니다.


다음으로, 이러한 각 지도를 처음부터 만드는 데 걸리는 시간을 살펴보겠습니다.


Intellij 프로파일러를 사용하고 맵 생성 방법의 CPU 타이밍을 확인하여 측정항목을 수집했습니다.

Intellij 프로파일러를 사용하여 맵 생성 방법의 메모리 할당을 살펴보고 측정항목을 수집했습니다.


다시 한번, 중첩된 맵이 메모리 할당에 대한 100만-1-1 시나리오에서 최악의 성능을 발휘하는 것을 볼 수 있지만, 그럼에도 불구하고 CPU 시간에서는 다른 맵보다 성능이 뛰어납니다. 위의 내용에서는 문자열 키가 모든 경우에 최악의 성능을 발휘하는 반면 사용자 정의 키 객체를 사용하는 것은 중첩된 키보다 약간 느리고 더 많은 메모리 할당이 필요하다는 것을 확인할 수 있습니다.


마지막으로 처리량이 가장 높은 시나리오와 읽기가 얼마나 효과적인지 살펴보겠습니다. 우리는 100만 번의 읽기 작업을 실행했습니다(생성된 각 키에 대해 1회). 존재하지 않는 키를 포함하지 않았습니다.


Intellij 프로파일러를 사용하고 맵 조회 방법의 CPU 타이밍을 확인하여 측정한 측정항목(읽기 100만 개)

Intellij 프로파일러를 사용하고 맵 조회 방법의 메모리 할당을 살펴보는 측정항목(읽기 100만 개)


여기서는 문자열 기반 키 조회가 얼마나 느린지 실제로 확인할 수 있습니다. 이는 3가지 옵션 중 가장 느리고 가장 많은 메모리를 할당합니다. 사용자 정의 키 객체는 중첩된 지도 구현에 "가까이" 수행하지만 여전히 작은 차이로 인해 지속적으로 속도가 느려집니다.


그러나 조회 메모리 할당에서는 중첩된 맵이 얼마나 잘 빛나는지 확인하십시오. 아니요, 이는 그래프의 결함이 아닙니다. 중첩된 맵에서 값을 찾는 데에는 조회를 수행하기 위한 추가 메모리 할당이 필요하지 않습니다. 그게 어떻게 가능합니까?


복합 개체를 문자열 키로 결합할 때마다 매번 새 문자열 개체에 메모리를 할당해야 합니다.


 private String lookup(int key1, String key2, long key3) { return map.get(key1 + "." + key2 + "." + key3); }


복합 키를 사용하는 경우에도 새 키 개체에 메모리를 할당해야 합니다. 그러나 해당 개체의 멤버가 이미 생성 및 참조되었기 때문에 여전히 새 문자열보다 훨씬 적은 양을 할당합니다.


 private String lookup(int key1, String key2, long key3) { return map.get(new MapKey(key1, key2, key3)); }


그러나 중첩된 맵 구현에는 조회 시 새로운 메모리 할당이 필요하지 않습니다. 주어진 부분을 각 중첩 맵의 키로 재사용하고 있습니다.


 private String lookup(int key1, String key2, long key3) { return map.get(key1).get(key2).get(key3); }


그렇다면 위의 내용을 바탕으로 가장 성능이 좋은 것은 무엇입니까?


거의 모든 시나리오에서 중첩된 지도가 맨 위에 나타나는 것을 쉽게 확인할 수 있습니다. 대부분의 사용 사례에서 원시 성능을 찾고 있다면 이것이 최선의 선택일 것입니다. 하지만 사용 사례를 확인하려면 자체 테스트를 수행해야 합니다.


키 객체는 중첩된 맵이 구현에 사용하기 불가능하거나 불가능할 때 매우 유용한 범용 옵션을 만듭니다. 그리고 복합 문자열 키는 구현하기 가장 쉽지만 거의 항상 가장 느립니다.


복합 키 구현을 고려할 때 고려해야 할 마지막 사항은 위의 키를 결합할 수 있다는 것입니다. 예를 들어 첫 번째 또는 두 수준에 중첩된 맵을 사용한 다음 더 깊은 수준을 단순화하기 위해 복합 키 개체를 사용할 수 있습니다.


이렇게 하면 스토리지 및 조회 성능을 최적화하는 동시에 빠른 조회를 위해 데이터를 분할된 상태로 유지할 수 있습니다. 그리고 코드도 읽기 쉽게 유지하세요.

TLDR;

대부분의 경우 단순하게 유지하세요. 가장 쉬운 옵션이고 성능이 주요 관심사가 아닌 경우 복합 키를 맵이나 캐시에 저장하기 위한 문자열 키로 결합합니다.


성능이 중요한 시나리오에서는 자체 테스트를 수행해야 합니다. 그러나 대부분의 경우에는 중첩된 맵을 사용하는 것이 가장 효과적입니다. 또한 스토리지 요구 사항이 가장 작을 수도 있습니다. 그리고 중첩 매핑이 실용적이지 않을 때 복합 키는 여전히 성능 좋은 대안입니다.


여기에도 게시됨