본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 8 컬렉션 API 개선

by hanyugyeong 2023. 7. 26.
반응형
SMALL

8.1 컬렉션 팩토리

자바에서 적은 요소를 포함하는 리스트를 어떻게 만들까?

다음 방법은 세 문자열을 저장하는데도 많은 코드가 필요하다.

List<String> friends = new ArrayList<>();
friends.add("Raphael");
friends.add("Olivia");
friends.add("Thibaut");

다음처럼 Arrays.asList() 팩토리 메서드를 이용해 코드를 줄일 수 있지만, 고정 크기의 리스트이기 때문에 요소를 추가하려하면 Unsupported OperationException이 발생한다.

List<String> friends = Arrays.asList("Raphael", "Olivia");
friends.set(0, "Rechard");
friends.add("Thibaut");

UnsupportedOperationException 예외 발생

내부적으로 고정된 크기의 변환할 수 있는 배열로 구현되었기 때문에 이와같은 일이 일어난다.

리스트를 인수로 받는 HashSet 생성자를 사용하거나, 스트림 API로 해결할 수 있다.

Set<String> friends = new HashSet<>(Arrays.asList("Raphael", "Olivia","Thibaut"));

또는 다음처럼 스트림 API를 사용할 수 있다. 

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

하지만 두 방법 모두 매끄럽지 못하며 불필요한 객체 할당을 필요로 한다.

8.1.1. 리스트 팩토리

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

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

하지만 마찬가지로 변경할 수 없는 리스트이기 때문에 add()나 set() 메서드를 사용할 수 없다.

8.1.2 집합 팩토리

List.of와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있다.

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

중복된 요소를 제공해 집합을 만들려고 하면 Olivia라는 요소가 중복되어 있다는 설명과 함께 IllegalArgumentException이 발생한다. 집합은 오직 고유의 요소만 포함할 수 있다는 원칙을 상기시킨다.

8.1.3 맵 팩토리

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

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

10개 이상의 키와 값 쌍을 가진 맵을 만들때는 Map.Entry<K, V> 객체를 인수로 받는 Map.ofEntries 팩토리 메서드를 이용하는 것이 좋다.

import static java.util.Map.entity;
Map<String, Integer> ageOfFrieds = Map.ofEntries(
  entry("Raphael", 30),
  entry("Olivia", 25),
  entry("Thibaut", 26));

8.2 리스트와 집합 처리

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

  • removeIf : 프레디케이트를 만족하는 요소를 제거한다.
  • replaceAll : UnaryOperator 함수를 이용해 요소를 바꾼다. 리스트에서 사용할 수 있다.
  • sort : 리스트를 정렬한다.

이 메서드는 새로운 결과를 만드는 것이 아니라 호출한 기존 컬렉션 자체를 바꾼다.

8.2.1 removeIf 메서드

다음은 숫자로 시작되는 참조 코드를 가진 트랜잭션을 삭제하는 코드다.

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

코드를 실행해보면 ConcurrentModificationException을 일으킨다.

forEach 루프는 Iterator 객체를 사용하므로 위 코드는 다음과 같이 해석된다.

for(Iterator<Transaction> iterator = transactions.iterator(); iterator.hasNext(); ) {
  Transaction transaction = iterator.next();
  if(Character.isDigit(transaction.getReferanceCode().charAt(0))) {
    transactions.remove(transaction); //반복하면서 별도의 두 객체를 통해 컬렉션을 바꾸고 있는 문제
  }
}

두 개의 개별 객체가 컬렉션을 관리한다는 사실을 주목하자 

  • Iterator 객체, next(), hasNext()를 이용해 소스를 질의한다.
  • Collection 객체 자체, remove()를 호출해 요소를 삭제한다.

Iterator 객체를 통해 소스를 질의하고, Collection 객체 자체에 remove()를 호출해 요소를 삭제하고있다. 따라서 반복자의 상태와 컬렉션의 상태가 동기화되지 않는다.

Iterator 객체를 명시적으로 사용하고 그 객체의 remove() 메서드를 호출해줘야 정상적으로 동작한다.

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

위 코드 패턴은 자바8의 removeIf 메서드로 바꿀 수 있다. removeIf는 삭제할 요소를 가리키는 프레디케이트를 인수로 받는다.

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

8.2.2 replaceAll 메서드

리스트의 각 요소를 새로운 요소로 바꾸고자할때 스트림 API를 사용하면 다음과 같다.

referenceCode.stream().map(code -> Character.toUpperCase(code.charAt(0)) + code.subString(1))
    .collect(Collectors.toList())
    .forEach(System.out::println);

outputs: A12,C14,B13

 

하지만 이 코드는 새 문자열 컬렉션을 만든다. 기존 컬렉션을 바꾸려면 ListIterator 객체를 이용해야한다.

for(ListIterator<String> iterator = referenceCodes.listIterator(); iterator.hasNext(); ) {
  String code = iterator.next();
  iterator.set(Character.toUpperCase(code.charAt(0)) + code.subString(1));
}

 

A12, C14, B13

코드가 복잡해졌다. 그리고 이처럼 컬렉션 객체와 Iterator 객체를 혼용하면 반복과 변경이 동시에 이루어져 쉽게 문제를 일으킨다.

자바 8의 replaceAll 메서드를 사용하면 간단하게 구현할 수 있다.

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

outputs: A12,C14,B13

 

8.3 맵 처리 

8.3.1 forEach 매서드

맵에서 키와 값을 반복하면서 확인하는 작업은 번거롭다.

for(Map.Entity<String, Integer> entry : ageOfFriends.entitySet()) {
  String friends = entry.getKey();
  Integer age = entry.getValue();
  System.out.println(friend + " is " + age + " years old");
}

자바8의 Map 인터페이스는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는 forEach 메서드를 지원한다.

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

8.3.2 정렬 메서드

새로 추가된 유틸리티를 이용해 맵의 항목을 값 또는 키를 기준으로 정렬할 수 있다.

  • Entry.comparingByKey
  • Entry.comparingByValue
Map<String, String> favouriteMovies = Map.ofEntries(
  entry("Raphael", "Star Wars"),
  entry("Cristina", "Matrix"),
  entry("Olivia", "James Bond"));
  
favouriteMovies.entrySet().stream()
  .sorted(Entry.ComparingByKey())
  .forEachOrdered(System.out::println); //사람의 이름을 알파벳 순으로 스트림 요소를 처리한다.
  
/*
출력
cristina=matrix
olivia=james Bond
Raphael=Star Wars
*/

Cristina=Matrix
Olivia=James Bond
Raphael=Star Wars

 

8.3.3 getOutDefault 메서드

이 메서드는 첫 번째 인수로 키를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 두 번째 인수로 받은 기본값을 반환한다. 요청결과를 확인하지 않아도 NullPointerException 문제를 해결할 수 있다.

Map<String, String> favouriteMovies = Map.ofEntries(
  entry("Raphael", "Star Wars"),
  entry("Olivia", "James Bond"));
  
System.out.println(favorieMovies.getOrDefault("Olivia", "Matrix")); //James Bond 출력
System.out.println(favorieMovies.getOrDefault("Thibaut", "Matrix")); //Matrix 출력

8.3.4 계산 패턴 

맵에 키가 존재하는지 여부에 따라 동작을 수행하고자 할 때에 사용할 수 있는 연산이 있다.

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

맵을 이용해 캐시를 구현했다고 가정하면 다음처럼 MessageDigest 인스턴스로 SHA-256 해시를 계산할 수 있다. 

 Map<String, byte[]> dataToHash = new HashMap<>();
 MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");

이제 데이터를 반복하면서 결과를 캐시한다. 

 lines.forEach(line ->
        dataToHash.computeIfAbsent(line, this::calculateDigest)); //line은 맵에서 찾을 키, 키가 존재하지 않으면 동작을 실행
        
   private byte[] calculateDigest(String key) { //헬퍼가 제공된 키의 해시를 계산할 것이다.
    return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
  }

여러 값을 저장하는 맵을 처리할 때도 이 패턴을 유용하게 활용할 수 있다. Map<K,List<V>>에 요소를 추가하려면 항목이 초기화되어 있는지 확인해야 한다. Raphel에게 줄 영화 목록을 만든다고 가정하자.

String friend = "Raphael";
List<String> movies = friendsToMovies.get(friend);
if (movies == null) {
    movies = new ArrayList<>();
    friendsToMovies.put(friend, movies);
}
movies.add("Star Wars");
System.out.println(friendsToMovies);

{Raphael=[Star Wars]}

 

어떻게 computeIfAbsent를 여기 활용할 수 있을까? computeIfAbsent는 키가 존재하지 않으면 값을 계산해 맵에 추가하고 키가 존재하면 기존 값을 반환한다. 이를 이용해 다음처럼 코드를 구현할 수 있다. 

friendsToMovies.clear();
    friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>())
        .add("Star Wars");
    System.out.println(friendsToMovies);

computeIfAbsent 매서드는 현재 키와 관련된 값이 맵에 존재하며 널이 아닐 때만 새 값을 계산한다.

 

8.3.5 삭제 패턴 

키가 특정한 값일 경우 항목을 제거하도록 remove 메서드를 사용할 수 있다.

String key = "Raphael";
String value = "Jack Reacher 2";
favoriteMovies.remove(key, value);

8.3.6 교체 패턴

  • replaceAll : BiFunction을 적용한 결과로 각 항목의 값을 교체한다.
  • Replce : 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.
//repaceAll을 적용할 것이므로 바꿀 수 있는 맵을 사용해야 한다.
Map<String, String> favouriteMovies = new HashMap<>();
favouriteMovies.put("Raphael", "Star Wars"),
favouriteMovies.put("Olivia", "James Bond");

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

8.3.7 합침

맵을 합칠때는 putAll을 사용할 수 있다.

Map<String, String> family = Map.ofEntries(
  entry("Teo", "Star Wars"),
  entry("Cristina", "James Bond"));
Map<String, String> friens = Map.ofEntries(
  entry("Raphael", "Star Wars"));
  
Map<String, String> everyone = newHashMap<>(family); 
everyone.putAll(friends);//friends의 모든 항목을 everyone으로 복사

중복된 키가 없다면 위 코드는 잘 동작한다. 값을 좀 더 유연하게 합쳐야 한다면 새로운 merge 메서드를 이용할 수 있다. 

Map<String, String> everyone = newHashMap<>(family);
friends.forEach((k, v) -> 
  everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2));

{Raphael=Star Wars, Cristina=James Bond & Matrix, Teo=Star Wars}

8.4 개선된 ConcurrentHashMap

ConcurrentHashMap는 병렬 친화적인 HashMap이다. 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용한다.

8.4.1 리듀스와 검색

ConcurrentHashMap은 스트림과 비슷한 세 가지 새로운 연산을 지원한다.

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

다음처럼 키, 값, Map.entry 인수를 이용해 네 가지 연산 형태를 지원한다.

  • 키, 값으로 연산 (forEach, reduce, search)
  • 키로 연산 (forEachKey, reduceKeys, searchKeys)
  • 값으로 연산 (forEachValue, reduceValues, searchValues)
  • Map.Entry 객체로 연산(forEachEntry, reduceEntries, searchEntries)

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

 

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

이 예제에서는 reduceValues 메서드를 이용해 맵의 최댓값을 찾는다.

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

8.4.2 계수

ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드를 제공한다. size 대신 mappingCount 를 사용해야 매핑의 개수가 int의 범위를 넘어서는 이후의 상황을 대처할 수 있다.

8.4.3 집합뷰

ConcurrentHashMap을 집합 뷰로 반환하는 keySet 메서드를 제공한다. 맵을 바꾸면 집합도 바뀌고 집합을 바꾸면 맵도 영향을 받는다.

newkeySet 메서드를 이용하면 ConcurrentHashMap으로 유지되는 집합을 만들 수도 있다.

반응형
LIST