Chap08. 컬렉션 API 개선

728x90

컬렉션 팩토리

기존에는 Arrays.asList() 팩토리 메서드르 이용하여 간단한 코드로 쉽게 List 요소를 생성했다. 이렇게 생성된 리스트는 크기가 고정된다. 즉 요소를 갱신할 수는 있지만 새 요소를 추가하거나 요소를 삭제할 순 없다. 만약 요소를 추가 및 삭제하는 작업을 수행하게 되면 UnsupportedOperationException 예외가 발생한다(필자의 경우에도 Arrays.asList의 크기가 고정된다는 사실을 까먹어서 테스트 코드가 깨지면 왜 깨지는지 오류를 못 찾고는 했다).

아쉽게도 Set 혹은 Map을 바로 생성할 수 있는 방법은 없다. 리스트를 인수로 받는 HashSet 생성자를 통해 그.나.마 집합을 바로 생성할 수도 있긴 하다. 이는 스트림 API를 통해 아래와 같이도 만들 수 있다.

Set<String> friends = Stream.of("Raphael", "Olivia", "Thibaut")
                            .collect(Collectors.toSet());

하지만 두 방법 모두 매끄럽지 못하고 불필요한 객체 할당을 필요로 한다. 그리고 결과는 변환할 수 있는 집합이라는 것 또한 문제이다.

하지만 자바 9에서는 이러한 문제를 해결해줄 수 있는 팩토리 메서드를 제공한다.

리스트 팩토리

List.of 팩토리 메서드를 이용해서 간단하게 리스트를 만들 수 있다. 팩토리 메서드로 생성한 List는 add로 요소를 추가하거나 set과같이 기존의 요소를 변경하는 작업을 시도하게 되면, UnsupportedOperationException 예외가 발생한다.

컬렉션이 의도치 않게 변하는 것을 막을 수 있기 때문에 이는 꼭 나쁜 제약이라고 볼 수 없다. 하지만 요소 자체가 변하는 것을 막을 수 있는 방법은 없다.

이전에 스트림 API를 통해 리스트를 만드는 방법을 배웠었는데, 그렇다면 언제 이 방식과 새로운 방식을 적재적소에 사용해야하는지 기준이 궁금할 것이다.

간단하다 데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 간편한 팩토리 메서드를 이용할 것을 권장한다. 그 반대의 상황에서는 스트림 API를 활용하면 된다.

집합 팩토리

Set.of 팩토리 메서드를 통해 역시 간단하게 집합을 만들 수 있다. 만약 중복된 요소를 제공해 집합을 만드려고 하면 중복되는 특정 요소가 중복되어 있다는 설명과 함께 IllegalArgumentException 이 발생한다.

맵 팩토리

맵을 만드는 팩토리 메서드는 키와 값이 있어야 하기 때문에 집합과 리스트를 만드는 것에 비해서는 조금 복잡한 팩토리 메서드 구조를 가진다.

Map.of 팩토리 메서드에 을 번갈아 제공하는 방법으로 맵을 만들 수 있다.

java Map<String, Integer> ageOfFriends = Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);

열 개 이하의 키와 값 쌍을 가진 작은 맵을 만들 때는 이 메서드가 유용하다. 하지만 그 이상의 맵에서는 Map.Entry<K, V> 객체를 인수로 받으며 가변 인수로 구현된 Map.ofEntries 팩토리 메서드를 이용하는 것이 좋다. 이 메서드는 키와 값을 감쌀 추가 객체 할당을 필요로한다.

import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(entry("Raphael", 30),
                                            entry("Olivia", 25),
                                            entry("Thibaut", 26));

Map.entry는 Map.Entry 객체를 만드는 새로운 팩토리 메서드다.

리스트와 집합 처리

자바 8에서는 List, Set 인터페이스에 다음과 같은 메서드를 추가했다.

  • removeIf: 프레디케이트를 만족하는 요소를 제거한다. List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용할 수 있다.

  • replaceAll: 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꾼다.

  • sort: List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

위의 메서드들은 호출한 컬렉션 자체를 바꾼다. 이런 메서드가 추가된 이유는 컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더한다. 자바 8에서 removeIfreplaceAll를 추가한 이유가 바로 이 때문이다.

맵 처리

자바 8에서는 Map 인터페이스에 몇 가지 디폴트 메서드를 추가했다.

forEach 메서드

자바 8에서부터는 Map 인터페이스는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는 forEach 메서드를 지원하므로 코드를 조금 더 간단하게 구현할 수 있다.

ageOfFriends.forEach((friend, age) -> System.out.println(firend + " is " + age + " years old"));

정렬 메서드

다음 두 개의 새로운 유틸리티를 이용하면 맵의 항목을 값 또는 키를 기준으로 정렬할 수 있다.

  • Entry.comparingByValue
  • Entry.comparingByKey

favouriteMovies는 Map<String, String> 타입의 값이다.

favouriteMoves
    .entrySet()
    .stream()
    .sorted(Entry.comparingByKey())
    .forEachOrdered(System.out::println);

요청한 키가 맵에 존재하지 않을 때는 getOrDefault 메서드를 이용하여 해결할 수 있다.

getOrDefault 메서드

기존에는 찾으려는 키가 존재하지 않으면 null이 반환되므로 NullPointerException을 방지하려면 null check가 필수적이었다. 하지만 기본값을 반환하는 방식으로 이 문제를 해결할 수 있다. getOrDefault 메서드를 이용하면 쉽게 이 문제를 해결할 수 있다. 이 메서드는 첫 번째 인수로 키를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 두 번째 인수로 받은 기본값을 반환한다.

키가 존재하더라도 값이 null인 상황에서는 getOrDefault가 널을 반환할 수 있다는 사실을 알고있어야 한다. 오직 키의 존재 여부만 가지고 두 번째 인수가 반환될지 아닐지가 결정된다.

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다. 다음의 세 가지 연산이 이런 상황에서 도움을 준다.

  • computeIfAbsent: 제공된 키에 해당하는 값이 없으면 (값이 없거나 null), 키를 이용해 새 값을 계산하고 맵에 추가한다.
  • computeIfPresent: 제공된 키가 존재하면 새 값을 계산하고 맵에 추가한다.
  • compute: 제공된 키로 새 값을 계산하고 맵에 저장한다.

| computeIfAbset에 값을 만드는 함수로 null을 반환하면 현재 매핑을 맵에서 제거한다. 하지만 삭제는 remove 메서드를 오버라이드하는 것이 더 적합하다.

삭제 패턴

제공된 키에 해당하는 맵 항목을 제거하는 remove 메서드는 이미 알고 있다. 자바 8에서는 키가 특정한 값과 연관되었을 때만 항목을 제거하는 오버로드 된 메서드를 제공한다.

만약 특정 맵에서 삭제할 키, 밸류를 모두 알고있다면, 그 요소를 제거할 수 있다.

favouriteMovies.remove(key, value);

교체 패턴

맵의 항목을 바꾸는 데 사용할 수 있는 두 개의 메서드가 맵에 추가되었다.

  • replaceAll: BiFunction을 적용한 결과로 각 항목의 값을 교체한다. 이 메서드는 이전에 살펴본 List의 replaceAll과 비슷한 동작을 수행한다.
  • replace: 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.

favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

지금까지 배운 replace 패턴은 한 개의 맵에만 적용할 수 있다. 만약 두 개의 맵에서 값을 합쳐서 바꿔야 한다면 어떻게 해야할까? 새로운 merge 메서드를 이용하면 이 문제를 해결할 수 있다.

합침

두 그룹의 연락처를 포함하는 두 개의 맵을 합친다고 가정했을 때, 다음처럼 putAll을 사용할 수 있다.

Map<String, String> family = Map.ofEntries(
    entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    entry("Raphael", "Star Wars"));
Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends);

중복된 키가 없다면 위 코드는 잘 동작한다. 값을 좀 더 유연하게 합쳐야 한다면 새로운 merge 메서드를 이용할 수 있다. 이 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다. family와 friends 두 맵 모두에 Cristina가 다른 영화 값으로 존재한다고 하자.

Map<String, String> family = Map.ofEntries(
    entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));

Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> 
    everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)); // 중복된 키가 있으면 두 값을 연결 

자바독에서 설명하는 것처럼 merge 메서드는 null값과 관련된 복잡한 상황도 처리한다.

지정된 키와 연관된 값이 없거나 값이 널이면 merge는 키를 null이 아닌 값과 연결한다. 아니면 merge는 연결된 값을 주어진 매핑 함수의 결과 값으로 대치하거나 항목을 제거한다.

moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);

위 코드에서 merge의 두 번째 인수는 1L이다. 이 인수는 "키와 연관된 기존 값에 합쳐질 널이 아닌 값 또는 값이 없거나 키에 널 값이 연관되어 있다면 이 값을 키와 연결"하는데 사용된다. 처음에는 키의 반환값이 널이므로 1이 사용된다. 그 다음부터는 값이 1로 초기화되어 있으므로 BiFunction을 적용해 값이 증가된다.

개선된 ConcurrentHashMap

ConcurrentHashMap 클래스는 동시성 친화적이며 최신 기술을 반영한 HashMap 버전이다. CocurrnetHashMap은 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용한다. 따라서 동기화된 HashTable 버전에 비해 읽기 쓰기 연산 성능이 월등하다.

리듀스와 검색

ConcurrentHashMap은 스트림에서 봤던 것과 비슷한 종류의 세 가지 새로운 연산을 지원한다.

  • forEach: 각 (키, 값) 쌍에 주어진 액션을 실행
  • reduce: 모든 (키, 값) 쌍을 제공된 리듀스 함수를 이용해 결과로 합침
  • search: 널이 아닌 값을 반환할 때까지 각 (키, 값) 쌍에 함수를 적용

위의 연산들과 키, 값, entry, (키,값) 인수를 이용하여 네가지 연산 형태를 지원한다.

이들 연산은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행한다는 점을 주목하자. 따라서 이들 연산에 제공된 함수는 계산이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야 한다.

또한 이들 연산에 병렬성 기준값을 지정해야 한다. 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다. 기준값을 1로 지정하면 공통 스레드 풀을 이용해 병렬성을 극대화한다. Long.MAX_VALUE를 기준값으로 설정하면 한 개의 스레드로 연산을 실행한다. 우리의 소프트웨어 아키텍처가 고급 수준의 자원 활용 최적화를 사용하고 있지 않다면 기준값 규칙을 따르는 것이 좋다.

계수

ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 maapingCount 메서드를 제공한다. 기존의 size 메서드 대신 새 코드에서는 int를 반환하는 mappingCount 메서드를 사용하는 것이 좋다. 그래야 매핑의 개수가 int의 범위를 넘어서는 이후의 상황을 대처할 수 있기 때문이다.

집합뷰

ConcurrentHashMap 클래스는 ConcurrentHashMap을 집합 뷰로 반환하는 keySet이라는 새 메서드를 제공한다. 맵을 바꾸면 집합도 바뀌고 반대로 집합을 바꾸면 맵도 영향을 받는다. newKeySet이라는 새 메서드를 이용해 ConcurrentHashMap으로 유지되는 집합을 만들 수도 있다.

728x90