8장 컬렉션 API 개선

Posted by yunki kim on March 15, 2022

컬렉션 팩토리

  여러 소수의 문자열을 한번에 저장하려면 add메서드를 사용하는 것보단 Arrays.asList 팩토리 메서드를 사용하는 것이 더 간결하다. 하지만 이 방식으로 만들어진 리스트는 고정 크기라 요소를 갱신할 수는 있지만 새 요소를 추가하거나 삭제할 수 없다. 요소를 추가하거나 삭제한다면 UnsupportedOperationException이 발생한다. 이는 asList 메서드가 내부적으로 고정된 크기의 변환할 수 있는 배열로 구현되었기 때문이다.

  Set의 경우 다음과 같이 깔끔하지 못하고 내부적으로 불필요한 객체 할당을 하는 연산을 해야 생성할 수 있다.

1
2
3
4
5
Set<String> friends = new HashSet<>(Arrays.asList("a""b""c"));
 
Set<String> friends = Stream.of("a""b""c")
    .collect(Collectors.toSet());
 


cs

  자바 9에서는 위 예시와는 다르게 좀 더 편한 방법으로 컬랙션을 만드는 방법을 제공한다.

리스트 팩토리

  List.of 팩토리 메서드를 이용해 리스트를 생성할 수 있다. 하지만 이를 통해 리스트를 만들고 해당 리스트에 요소를 추가한다면 UnsupportedOperationException이 발생한다. 이는 변경할 수 없는 리스트가 생성됬기 때문이다. 이런 제약은 컬렉션이 의도치 않게 변하는 것을 막을 수 있다. 또 한 이 방식은 nulll 요소를 금지하기에 의도치 않은 버그를 방지한다.

  List.of 메서드를 보면 0~10 개의 파라미터 까지는 오버로드 되있고 11개 이상은 가변 인수로 받는다. 이런 구현을 한 이유는 최적화를 위해서다. 가변 인수를 사용하면 추가 배열을 할당해 리스트로 감싸기 떄문에 배열을 할당하고 초기화하며 나중에 GC 비용을 지불해야 한다. Set.of 와 Map.of 메서드 역시 같은 방식을 사용한다.

1
2
List<Integer> numbers = List.of(123);
 
cs

집합 팩토리

  Set.of를 사용하면 집합을 만들 수 있다. 만약 메서드 인자로 넘긴 값 중 중복된 값이 있다면 IllegalArgumentException이 발생한다.

1
2
Set<String> firends = Set.of("a""b""c");
 
cs

맵 팩토리

  자바 9에서는 Map.of와 Map.Entry<K, V> 두 방식을 통해 바꿀 수 없는 맵을 초기화할 수 있다.

  Map.of의 경우 10개 이하의 키와 값 쌍을 가진 맵을 만들 때 유용하다.

1
2
3
4
// 인자엔 키와 값이 번갈아 등장한다
Map<String, Integer> ageOfFriends = Map.of("a"30"b"26);
 
 
cs

  11개 이상의 쌍을 가진다면 Map.ofEntries 팩토리 메서드를 이용하는 것이 좋다. 이 메서드는 키, 값을 감쌀 추가 객체를 필요로 한다. Map.entry는 Map.Entry 객체를 만드는 새로운 팩토리 메서드다.

1
2
3
4
5
import static java.util.Map.entry;
Map<String, Integer> ageOfFriends = Map.ofEntries(entry("a"30),
        entry("b"34),
        entry("c"35));
 
cs

리스트와 집합 처리

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

    removeIf: 프리디케이트를 만족하는 요소를 제거한다.

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

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

  이런 컬렉션 자체를 바꾸는 메서드가 추가된 이유는 컬렉션 자체를 바꾸는 동작이 에러를 유발하고 복잡하기 때문이다.

removeIf 메서드

  다음과 같은 코드가 있다 해보자.

1
2
3
4
5
6
for (Transaction transaction : transactions) {
    if (Character.isDigit(transaction.getReferenceCode().charAt(0))) {
        transactions.remove(transaction);
    }
}
 
cs

  for-each 루프는 내부적으로 Iterator 객체를 사용하므로 다음과 같이 해석할 수 있다.

1
2
3
4
5
6
7
8
// Iterator 객체와
for (Iteratro<Transaction> iterator = transactions.iterator(); iteratro.hasNext();) {
    // Transaction 객체를 통해 컬렉션을 관리한다.
    Transaction transaction = iterator.next();
    if (Character.isDigit(transaction.getReferenceCode().charAt(0))) {
        transactions.remove(transaction);
    }
}
cs

  위 코드에서 볼 수 있듯이. for-each는 내부적으로 두 개의 개별 객체가 컬랙션을 관리한다. 따라서 반복자의 상태는 컬랙션의 상태와 서로 동기화 되지 않는다. 따라서 ConcurrentModificationException이 발생한다. 이 문제를 해결하려면 Iterator 객체를 명시적으로 사용하고 그 객체의 remove 메서드를 호출해야 한다.

1
2
3
4
5
6
for (Iterator<Transaction> iterator = transactions.iterator(); iterator.hasNext()) {
    Transaction transaction = iterator.next();
    if (Character.isDigit(transaction.getReferenceCode().charAt(0))) {
        transactions.remove(transaction);
    }
}
cs

  하지만 코드가 복잡하다. 자바 8의 removeIf 메서드를 사용하면 다음과 같이 간결화 할 수 있다.

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

replaceAll 메서드

  replaceAll 메서드를 사용하면 리스트의 각 요소를 바꿀 수 있다. 

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

맵 처리

forEach 메서드

  자바 8부터 Map 인터페이스는 key, value를 인자로 받는 BiConsumer를 인수로 받는 forEach 메서드를 지원한다. 따라서 Map을 다음과 같이 순회할 수 있다.

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

정렬 메서드

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

    - Entry.comparingByValue

    - Entry.comparingByKey

1
2
3
4
5
favouriteMovies.entrySet()
    .stream()
    .sorted(Entry.comparingByKey())
    .forEachOrdered(System.out::println);
 
cs

HashMap 성능

  자바 8에서 HashMap의 내부 구조를 바꿔 성능을 개선했다. 기존에 맵의 항목은 키로 생성한 해시코드로 접근할 수 있는 버킷에 저장했다. 따라서 많은 키가 같은 해시코드를 반환하게 되면 O(n)의 시간이 걸리는 LinkedList로 버킷을 반환해야 하므로 성능이 저사된다. 자바 8부터는 버킷이 커지면 O(logn)의 시간이 소요되는 정렬된 트리를 이용해 종적으로 치환해 충돌이 일어나는 요소 반환 성능을 개선했다. 하지만 String, Integer 처럼 Comparable의 형태여야만 정렬된 트리가 지원된다.

getOrDefault 메서드

  기존 방식으로는 키가 존재하지 않으면 null이 반환되 null 체크를 해야했다. 이는 getOrDefault 메서드로 해결할 수 있다. getOrDefault 메서드는 첫 번째 인자로 키를, 두 번째 인자로 기본값을 받는다. 만약 첫 번째 인자로 넘긴 키가 없다면 두 번째 인자로 넘긴 기본값을 반환한다. 하지만 키가 존재해도 값이 널이라면 기본값을 반환하지 않는다. 

1
2
3
Map<StringString> favouriteMovies = Map.ofEntries(entry("a""b"));
favouriteMovies.getOrDefautl("a""c");
 
cs

계산 패턴

  맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황에는 다음과 같은 연산을 사용할 수 있다.

    - comuteIfAbsent: 제공된 키에 해당하는 값이 없거나 null이면, 키를 이용해 새 값을 계산하고 맵에 추가한다.

    - computeIfPresent: 제공된 키가 존재하면 새 값을 계산한고 맵에 추가한다.

    - compute: 제공된 키로 새 값을 계산하고 맵에 저장한다.

1
2
3
4
5
6
7
8
9
10
Map<Stringbyte[]> dataToHash = new HashMap<>();
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
lines.forEach(lien -> dataToHash.computeIfAbsent(line, // line은 맵에서 찾을 키
    this::calculateDigest)); // 키가 존재하지 않으면 동작을 실행
 
private byte[] calculateDigest(String key) {
    return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
}
 
 
cs

삭제 패턴

  자바 8에서는 키가 특정한 값과 연관되었을 때만 항목을 제거하는 오버로드 버전 메서드를 제공한다.

1
2
favouriteMovies.remove(key, value);
 
cs

교체 패턴

  맵의 항목을 바꾸는 데 사용되는 메서드도 추가되었다.

    - replaceAll: BiFunction을 적용한 결과로 각 항목의 값을 교체한다.

    - replace: 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.

1
2
movies.replaceAll((friend, movie) -> movie.toUpperCase());
 
cs

합침

  위에서 소개한 replace는 오직 하나의 맵에서만 동작한다. 만약 두 개의 맵을 합치거나 값을 바꿔야 한다면 merge를 사용하면 된다. 우선 두 개의 맵의 키가 중복이 없다면 putAll 메서드를 사용하면 된다. putAll은 중복이 생기면 기존 value를 새로운 vlaue로 대체한다.

1
2
3
4
5
6
Map<StringString> movies1 = Map.ofEntries(entry("a""b"), entry("c""d"));
Map<StringString> movies2 = Map.ofEntries(entry("a""aa"), entry("c""cc"));
movies1.putAll(movies2);
movies1.forEach((key, value) -> System.out.println(key + " " + value));
// 출력: a aa
//        c cc
cs

  merge 메서드는 중복된 키를 어떻게 합칠지를 결정하는 BiFunction을 인수로 받기에 중복에 대해 적절히 처리할 수 있다.

1
2
3
4
5
6
7
Map<StringString> movies1 = Map.ofEntries(entry("a""b"), entry("c""d"));
Map<StringString> movies2 = Map.ofEntries(entry("a""aa"), entry("c""cc"));
Map<StringString> m = new HashMap<>(movies2);
movies1.forEach((key, value) -> m.merge(key, value, (m1, m2) -> m1 + " & " + m2));
System.out.println(m);
// 출력: {a=aa & b, c=cc & d}
 
cs

  merge 메서드는 널값과 관련된 상황도 처리해준다.

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

개선된 ConcurrentHashMap

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

리듀스와 검색

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

    - forEach: 각 쌍에 주어진 액션을 실행

    - reduce: 모든 쌍을 제공된 리듀스 함수를 이용해 결과로 합침

    - search: null이 아닌 값을 반환할 때까지 각 쌍에 함수를 적용

  이 연산들은 세부적으론 다음과 같은 연산 형태를 지원한다.

    - 키, 값으로 연산(forEach, reduce, search)

    - 키로 연산(forEachKey, reduceKeys, searchKeys)

    - 값으로 연산(forEachValue, reduceValues, serachValues)

    - Map.Entry 객체로 연산(forEachEntry, reduceEntries, searchEntries)

  이 연산들은 상태를 잠그지 않고 연산을 수행하기 떄문에 연산 중 바뀔 수 있는 객체, 값 등에 의존하지 말아야 한다.

  또 한 이 연산들은 병렬성 기준값(threshold)을 지정해야 한다. 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다. 기준값을 1로 지정하면 공통 스레드 풀을 이용해 병렬성을 극대화하고 Long.MAX_VALUE를 기준값으로 설정하면 한 개의 스레드로 연산을 실행한다.

계수

  ConcurrentHashMap 클래스가 제공하는 매핑 개수를 반환하는 mappingCount 메서드는 long 타입을 반환한다. CouncurrentHashMap은 int 보다 많은 범위의 요소를 가질 수 있으므로 size 메서드 대신 mappingCount 메서드를 사용해야 한다.

집합 뷰

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

 

 

출처 - 모던 자바 인 액션