본문 바로가기

IT Book Summary/ModernJavaInAction

Part 3 스트림과 람다를 이용한 효과적 프로그래밍 - Chapter 8 컬렉션API 개선

이번장에서는 자바8, 9 에서 추가된 새로운 컬렉션 API 기능을 소개한다

 

8.1 컬렉션 팩토리

 

Arrays.asList() 팩토리 메서드를 이용하면 코드를 간단하게 줄일 수 있다.

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

 

고정된 리스트를 만들었으므로 요소를 갱신할 수 있지만 새 요소를 추가하거나 삭제는 불가능하다.

UnsupportedOperationException 예외발생.

 

다른 방법으로 갱신할 수있는 배열을 만들순 없을까?

- 리스트를 인수로 받는 HashSet 생성자를 사용할 수 있다.

 

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

또는 스트림 API 사용

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

                                              .collect(Collectors.toSet());

 

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

 

자바9 에서 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 팩토리 메서드를 제공함.

 

1 - 리스트 팩토리

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

 

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

System.out.println(friends); -> [Raphael, Olivia, Thibaut]

 

하지만 요소자체가 변하는 것을 막을 수 있는 방법은 없다.

 

데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면,

사용하기 간편한 팩토리 메서드를 이용할 것을 권장한다.

- 팩토리 메서드 구현이 더 단순하고 목적을 달성하는데 충분하기 때문

 

2 - 집합 팩토리

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

 

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

 

중복된 요소를 제공해 집합을 만들려고 하면 요소가 중복되어 있다는 설명과 함께 IlligalArgumentException 발생.

집합은 오직 고유의 요소만 포함할 수 있다는 원칙.

 

3 - 맵 팩토리

자바9 에서는 두가지 방법으로 바꿀수 없는 맵을 초기화

 

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.entry 는 Map.entry 객체를 만드는 새로운 팩토리 메서드
Map<String, Integer> ageOfFriends = Map.ofEntries(entry("Rachael", 30),
                                                  entry("Olivia", 25),
                                                  entry("Thibaut", 26));

 

 


8.2 리스트와 집합 처리

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

이 메서드들은 호출한 컬렉션 자체를 바꿈.

  • removeIf : 프레디케이트를 만족하는 요소를 제거. List나 Set 구현한 모든 클래스에서 이용 가능.
  • replaceAll : 리스트 기능. UnaryOperator 함수를 이용해 요소를 바꿈.
  • sort : List인터페이스에서 제공하는 기능. 리스트 정렬

1 - removeIf 메서드

ex) 숫자로 시작되는 참조 코드를 가진 트랜젝션을 삭제하는 코드

for(Transaction transaction : transactions) {
    if(Character.isDigit(transaction.getReferenceCode().charAt(0))){
        transactions.remove(transaction);
    }
}
//ConcurrentModificationException 일으킴.
// for-each 루프는 내부적으로 Iterator객체를 사용하므로 다음과 같이 해석됨.
for(Iterator<Transaction> iterator = transactions.iterator();
     iterator.hasNext(); ) {
    Transaction transaction = iterator.next();
    if(Character.isDigit(transaction.getReferenceCode().charAt(0))){
        transactions.remove(transaction);
        //반복하면서 별도의 두 객체를 통해 컬렉션을 바꾸는 문제.
    }
}

-> 두개의 개별 객체가 컬렉션을 관리함.

- Iterator 객체, next(), hasNext()를 이용해 소스를 질의

- Collection 객체 자체, remove()를 호출해 요소를 삭제.

 

-> 결과적으로 반복자의 상태는 컬렉션의 상태와 서로 동기화 되지않음.

 

//Iterator 객체를 명시적으로 사용하고 그 객체의 remove() 메서드를 호출해서 해결
for(Iterator<Transaction> iterator = transactions.iterator();
     iterator.hasNext(); ) {
    Transaction transaction = iterator.next();
    if(Character.isDigit(transaction.getReferenceCode().charAt(0))){
        iterator.remove();
    }
}

//삭제할 요소를 가리키는 프레디케이트를 인수로 받는 removeIf 사용
transactions.removeIf(transaction -> 
              Character.isDigit(transaction.getReferenceCode().charAt(0)));

 

2 - replaceAll 메서드

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

 

//스트림 API 사용
// 이 코드는 새로운 문자열을 만드는 것.
referenceCode.stream()
             .map(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1))
             .collect(Collectors.toList())
             .forEach(System.out::println);
             
// ListIterator객체를 이용 (요소를 바꾸는 set() 메서드 지원)
// 기존 컬렉션을 바꿈.
for(ListIterator<String> iterator = referenceCodes.listIterator();
    iterator.hasNext(); ) {
    String code = iterator.next();
    iterator.set(Character.toUpperCase(code.charAt(0)) + code.substring(1));
}

// 컬렉션 객에를 Iterator 객체와 혼용하면 반목과 컬렉션 변경이 동시에 이루어지면서 문제.
//다음 자바8 기능을 이용하면 간단하게 구현.
referenceCodes.replaceAll(code -> 
                   Character.toUpperCase(code.charAt(0)) + code.substring(1));

 


8.3  맵 처리

 

자바8 에서 추가된 Map 인터페이스의 몇가지 디폴트 메서드.

자주 사용하는 패턴을 개발자가 직접 구현할 필요없도록 추가된 메서드들.

 

1 - forEach 메서드 

 

// Map.Entry<K,V>의 반복자를 이용해 맵의 항목 집합을 반복할 수 있음.
for(Map.Entry<String, Integer> entry: ageOfFriends.entrySet()) {
    String friend = entry.getKey();
    Integer age = entry.getValue();
    System.out.println(friend + " is " +age+ " years old");
}

// 자바8에서부터는 Map 인터페이스는 Biconsumer를 인수로 받는 forEach메서드를 지원
ageOfFriends.forEach((friend, age)-> 
                     System.out.println(friend + " is " +age+ " years old"));

 

2 - 정렬 메서드

새로운 유틸리티를 이용하면 맵의 항목을 값 또는 키를 기준으로 정렬 가능.

- Entry.comparingByValue

- Entry.comparingByKey

 

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
**/

 

 

3 - getOrDefault 메서드

찾으려는 키가 존재하지 않으면 발생하는 NullPointerException 을 방지하기 위해 

기본값을 반환하는 getOrDefault 메서드를 사용할 수 있음.

첫번째 인수로 키를, 두번째 인수로 기본값을 받으며,

맵에 키가 존재하지 않으면 두번째 인수로 받은 기본갑을 반환.

 

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

 

4 - 계산 패턴

키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야하는 상황

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

ex) 키를 이용해 값비싼 동작을 실행해서 얻은 결과를 캐시하기. 키가 존재하면 결과를 다시 계산할 필요 없음.

- 정보를 캐시할때 computeIfAbsent를 활용. 파일집합의 각 행을 파싱해 SHA-256을 계산.

- 다음으로 MessageDigest 인스턴스로 SHA-256해시를 계산

- 이제 데이터를 반복하면서 결과를 캐시

 

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

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

 

여러 값을 저장하는 맵을 처리할 때도 이 패턴을 유용하게 활용 가능하다

ex) Raphael에게 줄 영화 목록을 만드는 예제

 

//Map<K, List<V>> 에 요소를 추가하려면 항목이 초기화되어있는지 확인해야 함.

String friends = "Raphael";
List<String> movies = friendsToMovies.get(friend);
if(movies == null) { //리스트가 초기화 되었는지 확인
    movies = new ArrayList<>();
    friendsToMovie.put(friend, movies);
}
movies.add("Star Wars");

//computeIfAbsent 활용
friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>())
               .add("Star Wars");

 

5 - 삭제 패턴

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

 

String key = "Raphael";
String value = "Jack Reacher 2";
if(favouriteMovies.containsKey(key) && 
    Objects.equals(favouriteMovies.get(key), value)) {
    favouriteMovies.remove(key);
    return true;
} else {
    return false;
}

// 다음과 같이 간결하게 구현
favouriteMovies.remove(key, value);

 

6 - 교체 패턴

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

  • replaceAll : BiFunction을 적용한 결과로 각 항목의 값을 교체. 이 메서드는 이전에 살펴본 List의 replaceAll과 비슷한 동작을 수행.
  • Replace : 키가 존재하면 맵의 값을 바꿈. 키가 특정 값으로 매핑되었을때만 값을 교체하는 오버로드 버전도 존재.
//맵의 모든 값의 형식을 바꿀수 있음.
Map<String, String> favouriteMovies = new HashMap<>(); //바꿀수 있는 맵 사용.
favouriteMovies.put("Raphael", "Star Wars");
favouriteMovies.put("Olivia", "james bond");
favouriteMovies.replaceAll((friend, movie)-> movie.toUpperCase());

System.out.println(favouriteMovies);
// {Olivia=JAMES BOND, Raphael=STAR WARS}

replace 패턴은 한개의 맵에만 적용 가능.

 

두개의 맵에서 값을 합치거나 바꿔야 한다면?

 

7 - 합침

 

ex) 두 그룹의 연락처를 포함하는 두 개의 맵을 합치는 예제

 

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); //friends의 모든 항목을 everyone으로 복사
System.out.println(everyone);

중복된 키가 없다면 위의 코드는 잘 동작.

값을 좀 더 유연하게 합쳐야 한다면 새로운 merge메서드를 이용가능.

 

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"));
        
//중복된 키값이 있으면 forEach와 merge메서드를 이용해 충돌을 해결.
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v)-> 
        everyone.merge(k, v, (movie1, movie2)-> movie1 + " & " + movie2));
        //중복된 키값이 있으면 두 값을 연결.

System.out.println(everyone);
// {Raphael=Star Wars, Cristina=James Bond & Matrix, Teo=Star Wars}

 

merge 메서드는 널값과 관련된 복잡한 상황도 처리.

merge를 이용해 초기화 검사를 구현할 수 있다.

ex) 영화를 몇회 시청했는지 기록하는 맵

 

//맵에서, 해당 값을 증가시키기전에 관련영화가 맵에 존재하는지 확인

Map<String, Long> moviesToCount = new HashMap<>();
String movieName = "JamesBond";
long count = moviesToCount.get(movieName);
if(count == null) {
    moviesToCount.put(movieName, 1);
} else {
    moviesToCount.put(moviename, count + 1);
}

// merge이용해 다음처럼 구현

moviesToCount.merge(movieName, 1L, (key, count)-> count+1L);
// 키의 반환값이 널이므로 처음에는 1이 사용.
// 다음부터는 값이 1로 초기화 되어있으므로 BiFunction 적용해 값이 증가

 


8.4 개션된 ConcurrentHashMap

 

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

내부 자료구조의 특정 부분만 잠궈 동시 추가, 개인 작업을 허용.

따라서 동기화된 Hashtable 버전에 비해 읽기 쓰기 연산 성능히 월등함. (표준 HashMap은 비동기로 동작)

 

1 - 리듀스와 검색 

 

세가지 새로운 연산을 지원

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

다음과 같이 키에 함수받기, 값, Map, Entry, (키, 값) 인수를 이용한 네가지 연산 형태를 지원

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

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

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

 

또한 이들 연산에 병렬성 기준값 threshold을 지정해야 함.

맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행

- 기준값을 1로 지정하면 공통 스레드풀을 이용해 병렬성을 극대화.

- 기준값을 Long.MAX_VALUE를 기준값으로 설정하면 한개의 스레드로 연산을 실행.

- 아키텍처가 고급 수준의 자원활용 최적화가 되어있지 않으면 기준값 규칙을 따르는 것이 좋음.

 

// reduceValues 메서드를 이용해 맵의 최댓값을 찾는 예제
// 여러 키와 값을 포함하도록 갱신될 맵
ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
long parallelismThreshold = 1;
Oprional<Integer> maxValue = 
        Optional.ofNullable(map.reduceValues(parallelismThreshold, Long::max)));

 

int,long 등 기본값 전용 each reduce 연산이 제공되므로 reduceValuesToInt, reduceKeysToLong 등을 이용하면

박싱작업을 할 필요가 없어 효율적으로 작업 처리 가능.

 

2 - 계수 

 

ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드를 제공

기존의 size 메서드 대신 int 를 반환하는 mappingCount 메서드를 사용하는것이 좋음.

- 매핑 개수가 int 의 범위를 넘어서는 이후의 상황을 대처할 수 있기 때문.

 

3 - 집합뷰 

 

ConcurrentHashMap을 집합 뷰로 반환하는 keySet이라는 새 메서드 제공.

맵을 바꾸면 집합도 바뀌고 반대로 집합을 바꾸면 맵도 영향 받음.

newKeySet이라는 메서드를 이용해 ConcurrentHashMap으로 유지되는 집합을 만들수 있음.

 


  • 자바9는 적의 원소를 포함하며 바꿀 수 없는 리스트, 집합, 맵을 쉽게 만들 수 있도록 List.of, Set.of, Map.of, Map.ofEntries 등의 컬렉션 팩토리를 지원함.
  • 이들 컬렉션 팩토리가 반환한 객체는 만들어진 다음 바꿀수 없음.
  • List 인터페이스는 removeIf, replaceAll, sort 세가지 디폴트 메서드를 지원
  • Set 인터페이스는 removeIf 디폴트 메서드를 지원
  • Map 인터페이스는 자주 사용하는 패턴과 버그를 방지할 수 있도록 다양한 디폴트 메서드를 지원
  • ConcurrentHashMap은 Map에서 상속받은 새 디폴트 메서드를 지원함과 동시에 스레드 안정성도 제공.