[Ch8] 컬렉션 API 개선

이번 챕터에서는 자바8, 자바9에 추가되어 편리해진 새로운 컬렉션 API기능에 대해 알아본다.

컬렉션 팩토리

자바9에서는 리스트, 집합, 맵과 같은 컬렉션 객체를 쉽게 만드는 팩토리 메서드를 제공한다.

리스트 팩토리

List.of 팩토리 메서드를 이용해서 간단하게 리스트를 만들 수 있다.

List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
System.out.println(friends);

Arrays.asList() 와 같은 기능을 한다. 고정 크기의 리스트를 만들었기 때문에, 요소를 추가하려 하면 UnsupportedOperationException이 발생한다.

스트림 API를 이용해 리스트를 만들 수도 있다. Collectors.toList() 컬렉터로 스트림을 리스트로 변환할 수 있다. 다만, 데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 사용하기 간편한 팩토리 메서드를 이용할 것을 권장한다.

집합 팩토리

Set.of 팩토리 메서드를 이용해서 바꿀 수 없는 집합을 만들 수 있다.

Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");
System.out.prinln(friends);

Set은 고유의 요소만 포함할 수 있기 때문에, 중복된 요소를 추가하고자 하면 IllegalArgumentException 이 발생한다.

맵 팩토리

Map.of 를 사용해서 맵을 만들 수 있다. 맵을 만들려면 key 와 value 가 필요한데, 두 가지 방법으로 이를 지정할 수 있다.

  • 키와 값을 번갈아 제공한다.
Map<String, Integer> ageOfFriends = Map.of("Sunmin", 28, "Hyerin", 30, "SeJeong", 29, "KwangWoon", 31);
System.out.println(ageOfFriends);

열 개 이하의 키와 값 쌍을 가진 맵을 만들때는 이 메서드가 유용하다.

  • Map.Entry<K, V> 객체 생성
import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(Map.entry("Sunmin", 28),
                                                  Map.entry("Hyerin", 30),
                                                  Map.entry("Kwangwoon", 31),
                                                  Map.entry("SeJeong", 29));

리스트와 집합 처리

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

  • removeIf : predicate 를 만족하는 요소를 제거한다. List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용할 수 있다.
  • replaceAll : 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꾼다.
  • sort : List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

이들 메서드는 호출한 컬렉션 자체를 바꾼다. 기존에는 컬렉션을 바꾸려면 복잡한 코드가 필요했는데, 이를 간소화하기 위해서 이러한 메서드가 추가된 것이다.

removeIf 메서드

for(Transaction transaction : transactions) {
  if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
    transactions.remove(transaction);
  }
}

위 코드는 transactions 리스트를 for-each 로 돌면서, 특정 0 번 트랜잭션을 리스트에서 삭제하는 코드이다. 하지만 ConcurrentModificationException 에러를 일으킨다. 왜냐하면 Iterator 객체와 Collection 객체가 리스트 컬렉션에 대한 참조를 하고 있기 때문이다. 에러를 없애려면 Iterator 객체를 명시적으로 사용해야 한다.

for(Iterator<Transaction> iterator = transactions.iterator(); iterator.hasNext();) {
  Transaction transaction = iterator.next();
  if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
    iterator.remove(transaction);
  }
}

코드가 많이 복잡해졌다. 위의 코드를 자바8의 removeIf 로 수정하면 간결할뿐만 아니라 버그도 예방할 수 있다.

transactions.removeIf(transaction -> Character.isDigit(transaction.getReferenceCode().charAt(0)));

replaceAll 메서드

List 인터페이스의 replaceAll 메서드를 이용해 리스트의 각 요소를 새로운 요소로 바꿀 수 있다.

referenceCodes.stream() // [a12, C14, b13]
              .map(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1))
              .collect(Collectors.toList())
              .forEach(System.out::println); // [A12, C14, B13]

하지만 이 코드는 새 문자열 컬렉션을 만든다 (Collectors.toList()) replaceAll 메서드를 쓰면 기존의 컬렉션을 바꿀수 있다.

referenceCodes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1)));

맵 처리

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

forEach 메서드

맵에서 키와 값을 반복하면서 확인하는 작업은 정말 귀찮은 일이었다. 자바 8서부터 BiConsumer를 인수로 받는 forEach 메서드를 지원해서 <키, 값> 엔트리를 쉽게 반복할 수 있다.

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

정렬 메서드

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

  • Entry.comparingByValue
  • Entry.comparingByKey
ageOfFriends.entrySet().stream()
                       .sorted(Map.Entry.comparingByKey())
                       .forEachOrdered(System.out::println);

// Hyerin=30
// Kwangwoon=31
// SeJeong=29
// Sunmin=28

getOrDefault 메서드

기존에는 찾으려는 키가 존재하지 않으면 null 을 반환하므로 NPE를 방지하려면 요청 결과가 널인지 확인해야 한다. getOrDefault 메서드를 사용하면 쉽게 null 을 처리할 수 있다.

System.out.println(ageOfFriends.getOrDefault("JongSun", -1));

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 저장해야 하는 상황이 필요할 때가 있다. 보통 if 문을 써서 키의 존재를 확인한 뒤, 있으면 냅두고 없으면 맵에 값을 넣는 형식으로 구현했다. 자마8에서는 다음 세 가지 연산을 제공했고, 이런 상황에서 도움을 준다.

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

위와 같은 상황인 정보를 캐시할 때 computeIfAbsent를 활용할 수 있다.

Map<String, byte[]> dataToHash = new HashMap<>();

lines.forEach(line -> dataToHash.computeIfAbsent(line, this::calculateDigest)); // line을 반복하면서, dataToHash에 Key로 존재하지 않으면 동작을 실행한다.

private byte[] calculateDigest(String key){
  // SHA256 으로 암호화한 바이트
}

calculateDigest 로 계산된 값을 맵에 키(line)로 추가한다.

삭제 패턴

키가 특정한 값과 연관되었을 때만 항목을 제거하는 메서드를 제공한다.

favouriteMovies.remove(key, value);

교체 패턴

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

  • replaceAll : BiFunction을 적용한 결과로 각 항목의 값을 교체한다.
    • BiFunction<? super K, ? super V, ? extends V> function
  • Replace : 키가 존재하면 맵의 값을 바꾼다.
Map<String, String> favouriteMovies = new HashMap<>();

favouriteMovies.put("Raphael", "Star Wars");
favouriteMovies.put("Olivia", "james bond");
favouriteMovies.replaceAll((freind, movie) -> movie.toUpperCase());
System.out.println(favouriteMovies);

Merge

두 개의 맵을 합친다고 가정할 때, putAll 을 사용하면 (중복된 키가 없다면) 정상적으로 작동한다. 값을 좀더 유연하게 합쳐야 한다면 merge 메서드를 이용할 수 있다.

중복된 키를 어떻게 합칠지 결정하는 Bifunction을 인수로 받는다.

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"));

위 두개의 맵에는 Cristina 가 중복으로 있다. 충동을 해결하기 위해서는 아래와 같이 합칠 수 있다.

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

merge 메서드

default V merge(K key, V value,
        BiFunction<? super V, ? super V, ? extends V> remappingFunction)

자바독에 따르면, 두 번째 인수로 주어지는 value 는 “키와 연과된 기존 값에 합쳐질 널이 아닌 값 또는 값이 없거나 키에 널 값이 연관되어 있다면 이 값을 키와 연결” 이라고 적혀있다.

즉, 카의 반환값이 null 이면 (디폴트로) value 값을 넣어준다는 뜻이다.

개선된 ConcurrentHashMap

ConcurrentHashMap 클래스는 동시성 친화적이며 최신 기술을 반영한 HashMap 이다.

HashMap 과 ConcurrentHashMap 의 기본적인 동작원리와 차이점에 대한 보충 자료를 기록해둔다.

https://pplenty.tistory.com/17

https://happy-coding-day.tistory.com/151


리듀스와 검색

ConcurrentHashMap 은 세가지 새로운 연산을 지원한다.

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

이들 연산은 ConcurrentHashMap 의 상태를 잠그지 않고 연산을 수행한다.

따라서 이들 연산에 제공한 함수는 계싼이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야 한다.

또한, 병렬성 threshold를 지정해야 한다. 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다. (기준값 이상이면 그 기준으로 병렬화 한다는 뜻)

ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();

long parallelismThreshold = 1;
Optional<Integer> maxValue = Optional.ofNullable(map.reduceValues(parallelismThreshold, Long::max));

맵의 값을 모두 순회하여 최대값을 뽑아 내는 예제이다.

기본값에는 전용 each, reduce 연산이 제공되므로 박싱작업을 할 필요 없이 효율적으로 작업을 처리할 수 있다.

  • reduceValuesToInt, reduceKeysToLong

계수

mappingCount 맵의 매핑 개수를 반환하는 메서드를 제공한다.

public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}

자바독에 따르면 ConcurrentHashMap의 매핑 개수는 size 메서드 대신 mappingCount 를 사용하는 것을 권장한다.

Returns the number of mappings. This method should be used instead of size because a ConcurrentHashMap may contain more mappings than can be represented as an int.

집합뷰

keySet : ConcurrentHashMap을 집합 뷰(Set)로 반환한다.

댓글남기기