CHAPTER8 - 컬렉션 API 개선

2025. 3. 15. 12:46카테고리 없음

목차.

1. 컬렉션 팩토리 사용하기

2. 리스트 및 집합과 사용할 새로운 관용 패턴 배우기

3. 맵과 사용할 새로운 관용 패턴 배우기

 

 

1. 컬렉션 팩토리

 

자바 9에서는 작은 컬렉션 객체를 쉽게 만들 수 있다.

 

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

List<String> friends = new ArrayList<>();
friends.add("James");
friends.add("Jane");
friends.add("John");

 

세 문자열을 저장하는데 많은 코드가 필요하다. 다음처럼 Arrays.asList() 팩토리 메서드를 이용하면 코드를 줄일 수 있다.

List<String> friends = Arrays.asList("James", "Jane", "John");

 

고정 크기의 리스트이기 때문에 요소를 갱신할 순 있지만 새 요소를 추가하거나 요소를 삭제할 순 없다. 요소를 추가하려 하면 UnsupportedOperationException이 발생한다.

List<String> friends = Arrays.asList("James", "Jane", "John");
friends.set(0, "Jeff");
friends.add("Bardy");

=>

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.AbstractList.add(AbstractList.java:153)
	at java.base/java.util.AbstractList.add(AbstractList.java:111)

 

 

UnsupportedOperationException

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

 

그럼 집합은 어떨까? Arrays.asSet() 이라는 팩토리 메서드는 없으므로 다른 방법을 사용해야 한다. 리스트를 인수로 받아 HashSet 생성자를 사용할 수 있다.

Set<String> friendsSet = new HashSet<>(Arrays.asList("James", "Jane", "John"));

Set<String> friendsSet = Stream.of("James", "Jane", "John")
                .collect(Collectors.toSet());

 

두 방법 모두 내부적으로 불필요한 객체 할당을 필요로 한다. 그리고 결과는 변환할 수 있는 집합이다.

 

위의 매끄럽지 못한 코드를 사용할 필요는 없다. 자바 9에서 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 팩토리 메서드를 제공하기 때문이다.

 

 

1.1 리스트 팩토리

 

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

List<String> friends = List.of("John", "Jane", "Mary");

 

하지만 위 코드 또한 변경할 수 없는 리스트이기 때문에 요소를 추가한다면 UnsupportedOperationException이 발생한다. set() 메서드로 아이템을 바꾸려해도 비슷한 예외가 발생한다.

//UnsupportedOperationException 발생
friends.set(1, "Jack");

 

하지만 위 제약은 꼭 나쁜 것은 아니다. 컬렉션이 의도치 않게 변하는 것을 막을 수 있기 때문이다. 리스트를 바꿔야 하는 상황이라면 직접 리스트를 만들면 된다. null 요소도 금지하므로 의도치 않은 버그도 방지할 수 있다.

 

 

1.2 집합 팩토리

 

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

Set<String> friendsSet = Set.of("John", "Jane", "Mary");

 

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

 

 

1.3 맵 팩토리

 

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

Map<String, Integer> ageOfFriends =
                Map.of("Jane", 30, "John", 25, "Mary", 20);

 

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

Map<String, Integer> ageOfFriends2 =
        Map.ofEntries(
                Map.entry("Jane", 30),
                Map.entry("John", 25),
                Map.entry("Mary", 20)
        );

 

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

 

 

** 내부적으로 인수 수가 10개를 넘어가면 인수를 가변 인수로 받는 오버로딩된 정적 팩토리 메서드가 사용된다. 내부적으로 추가 배열을 할당해서 리스트로 감싸는데 배열을 할당하고 초기화하며 나중에 가비지 컬렉션을 하는 비용을 지불해야 한다.(List, Set, Map 모두 적용)**

 

 

 

2. 리스트와 집합 처리

 

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

- removeIf

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

 

- replaceAll

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

 

- sort

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

 

이들 메서드는 호출한 컬렉션 자체를 바꾼다. 새로운 결과를 만드는 스트림 동작과 달리 이들 메서드는 기존 컬렉션을 바꾼다. 컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더하기 때문에 위와 같은 메서드가 추가됐다.

 

 

2.1 removeIf 메서드

 

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

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

=>

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)

 

왜 에러를 일으켰을까? 내부적으로 for-each 루프는 Iterator 객체를 사용하므로 위 코드는 아래와 같이 해석된다.

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

**iterator가 순회중에 컬렉션의 크기 변경을 감지하고 에러를 던짐**

 

두 개의 개별 객체가 컬렉션을 관리하고 있다.

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

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

 

결과적으로 반복자의 상태는 컬렉션의 상태와 서로 동기화되지 않는다. Iterator 객체를 명시적으로 사용하고 그 객체의 remove() 메서드를 호출함으로 이 문제를 해결할 수 있다.

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

 

이 코드 패턴은 자바 8의 removeIf 메서드로 바꿀 수 있다.

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

 

 

2.1 replaceAll 메서드

 

List 인터페이스의 replaceAll 메서드를 이용해 리스트의 각 요소를 새로운 요소로 바꿀 수 있다. 먼저 스트림 API를 사용해 문제를 해결해보자

referenceCodes.stream()
        .map(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1))
        .toList()
        .forEach(System.out::println);

 

위 코드는 새 문자열 컬렉션을 만든다. 기존 컬렉션을 바꿔서 해결해보자.

for (ListIterator<String> iterator = referenceCodes.listIterator();
     iterator.hasNext(); ) {

    String code = iterator.next();
    iterator.set(Character.toUpperCase(code.charAt(0)) + code.substring(1));
}

**for-each 를 사용해서 바꿔도 리스트 자체의 구조를 변경하는 것이 아니기 때문에 에러는 나지 않는다.**

 

코드의 복잡성이 증가했고 가독성도 좋지 않다. replaceAll을 이용해 간단하게 구현해보자

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

 

 

 

3. 맵 처리

 

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

 

 

3.1 forEach 메서드

 

Map.entity<K, V> 의 반복자를 이용해 맵에서 키와 값을 반복하면서 확인하는 작업을 할 수 있다.

Map<String, Integer> ageOfFriends =
        Map.of("Jane", 30, "John", 25, "Mary", 20);

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

 

 

3.2 정렬 메서드

 

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

- Entry.comparingByValue

- Entry.comparingByKey

 

코드를 살펴보자

// Map 정렬
Map<String, String> favoriteMovies
        = Map.ofEntries(Map.entry("Raphael", "Star wars"),
                        Map.entry("Cristina", "Matrix"),
                        Map.entry("Jane", "The Godfather"),
                        Map.entry("John", "The Godfather II"));

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

 

 

3.3 getOrDefault 메서드

 

기존에는 찾으려는 키가 존재하지 않으면 NPE가 발생했다. 기본값을 반환하는 방식으로 이 문제를 해결할 수 있다. 이 메서드는 첫 번째 인수로 키를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 두 번재 인수로 받은 기본값을 반환한다.

// getOrDefault 메서드를 통한 키가 없을 경우 기본값 반환
System.out.println(favoriteMovies.getOrDefault("Tom", "No movie"));

 

하지만 키가 존재하더라도 값이 널인 상황에서는 NPE가 발생한다.

 

 

3.4 계산 패턴

 

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다. 예를 들어 키를 이용해 값비싼 동작을 실행해서 얻은 결과를 캐시하려 한다. 키가 존재하면 결과를 다시 계산할 필요가 없다. 다음의 세 가지 연산이 이런 상황에서 도움을 준다.

 

- computeIfAbsent

   제공된 키에 해당하는 값이 없으면, 키를 이용해 새 값을 계산하고 맵에 추가한다.

- computeIfPresent

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

- compte

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

 

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

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

 

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

public static void main(String[] args) {
    List<String> lines = List.of("Jane", "John", "Mary");

    lines.forEach(line ->
            dataToHash.computeIfAbsent(line, CalculatePattern_example::calculateDigest));
}
private static byte[] calculateDigest(String key) {
    return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
}

 

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

Map<String, List<String>> friendsToMovies = new HashMap<>();

String friend = "Jane";

List<String> movies = friendsToMovies.get(friend);
if (movies == null) {
    movies = new ArrayList<>();
    friendsToMovies.put(friend, movies);
}

movies.add("The Godfather");

System.out.println(friendsToMovies);

 

computeIfAbsent를 활용해보자 키가 Map에 존재하지 않으면 값을 계산해 맵에 추가하고 키가 존재하면 기존 값을 반환한다.

friendsToMovies.computeIfAbsent(friend, k -> new ArrayList<>())
               .add("The GodFather");

 

 

3.5 삭제 패턴

 

제공된 키에 해당하는 맵 항목을 제거하는 remove 메서드이다.

Map<String, String> favoriteMovies = FriendsFactory.favoriteMovies;

String key = "Tom";
String value = "Lion King";

favoriteMovies.remove(key, value);

 

 

3.6 교체 패턴

 

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

 

- replaceAll

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

- Replace

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

 

Map<String, String> favoriteMovies = new HashMap<>();
favoriteMovies.put("Raphael", "Star wars");
favoriteMovies.put("James", "Lion king");
favoriteMovies.replace("James", "James bond");
favoriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());
favoriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());
System.out.println(favoriteMovies);

=>
{Raphael=STAR WARS, James=JAMES BOND}

 

 

3.7 합침

 

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

Map<String, String> family = Map.ofEntries(entry("Teo", "Star Wars"),
                                           entry("Tom", "The Matrix"));

Map<String, String> friends = Map.ofEntries(entry("Raphael", "Star Wars"),
                                            entry("James", "Lion king"));

Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends);
System.out.println(everyone);

 

중복된 키가 없다면 위 코드는 잘 동작한다.(만약 중복됐다면 나중에 합쳐지는 friends 의 값이 family 맵의 중복된 키에 덮어씌어진다.)

값을 좀 더 유연하게 합쳐야 한다면 merge 메서드를 이용할 수 있다. 이 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다.

Map<String, String> boys = Map.ofEntries(entry("James", "Star Wars"),
                                         entry("Tom", "The Matrix"),
                                         entry("Kim", "D War"));

Map<String, String> girls = Map.ofEntries(entry("Jane", "Star Wars"),
                                          entry("Christina", "Lion king"),
                                          entry("Kim", "God Father"));

 

forEach와 merge 메서드를 이용해 충돌을 해결할 수 있다.

Map<String, String> boys = Map.ofEntries(entry("James", "Star Wars"),
                                         entry("Tom", "The Matrix"),
                                         entry("Kim", "D War"));

Map<String, String> girls = Map.ofEntries(entry("Jane", "Star Wars"),
                                          entry("Christina", "Lion king"),
                                          entry("Kim", "God Father"));

Map<String, String> students = new HashMap<>(boys);
girls.forEach((friend, movie) -> {
    students.merge(friend, movie, (boysMovie, girlsMovie) -> boysMovie + " & " + girlsMovie);
});

System.out.println(students);

=>
{Tom=The Matrix, James=Star Wars, Jane=Star Wars, Christina=Lion king, Kim=D War & God Father}

 

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

지정된 키와 연관된 값이 없거나 값이 널이면 [merge]는 키를 널이 아닌 값과 연결한다. 아니면

[merge]는 연결된 값을 주어진 매핑 함수의 [결과] 값으로 대치하거나 결과가 널이면 [항목]을 제거한다.

 

merge를 이용해 초기화 검사를 구현할 수 있다. 영화를 몇 회 시청했는지 기록하는 맵이 있다고 가정해보자

Map<String, Long> moviesToCount = new HashMap<>();
String movie = "The Matrix";
Long count = moviesToCount.get(movie);
if (count == null) {
    moviesToCount.put(movie, 1L);
}
else {
    moviesToCount.put(movie, count + 1);
}

 

위 코드는 아래와 같이 구현할 수 있다.

moviesToCount.merge(movie, 1L, (cnt, increment) -> cnt + 1L);

 

위 코드에서 merge의 두 번째 인수는 1L이다. 이 인수는

"키와 연관된 기존 값에 합쳐질 널이 아닌 값"

or

"값이 없거나 키에 널 값이 연관되어 있다면 이 값을 키와 연결"

하는데 사용된다. 키의 반환값이 널이므로 처음에는 1이 사용된다. 그 다음부터는 값이 1로 초기화되어 있으므로 BiFunction을 적용해 값이 증가된다.

 

String movie = "The Matrix";

moviesToCount.merge(movie, 1L, (cnt, increment) -> cnt + 1L);
System.out.println(moviesToCount)

=>
if movie 키와 연관된 값이 없으면
	두 번째 인수 1L로 초기화
if movie 키와 연관된 값이 있으면
	세 번째 BiFunction (기존 키 값-cnt , 두 번째 인수 1L-increment) -> cnt + increment 값 할당

 

 

4. 개선된 ConcurrentHashMap

 

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

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

 

 

4.1 리듀스와 검색

 

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

 

- 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를 기준값으로 설정하면 한 개의 스레드로 연산을 실행한다.

// 맵 요소의 개수가 1 기준값보다 많다면 최적화된 방향으로 병령 연산
long parallelismThreshold = 1;
Optional<Long> maxValue =
        Optional.ofNullable(
                map.reduceValues(parallelismThreshold, (e1, e2) -> {
                    System.out.println(Thread.currentThread().getName() + " : " + e1 + " , " + e2);
                    return Math.max(e1, e2);
                })
        );

System.out.println(maxValue);

=>

ForkJoinPool.commonPool-worker-4 : 20 , 70
ForkJoinPool.commonPool-worker-2 : 30 , 50
ForkJoinPool.commonPool-worker-4 : 70 , 80
ForkJoinPool.commonPool-worker-4 : 80 , 90
ForkJoinPool.commonPool-worker-4 : 110 , 90
ForkJoinPool.commonPool-worker-8 : 40 , 10
ForkJoinPool.commonPool-worker-8 : 40 , 110
ForkJoinPool.commonPool-worker-8 : 60 , 120
ForkJoinPool.commonPool-worker-8 : 120 , 100
ForkJoinPool.commonPool-worker-8 : 120 , 50
ForkJoinPool.commonPool-worker-8 : 120 , 110
Optional[120]

 

 

4.2 계수

 

ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드를 제공한다. 기존의 size 메서드 대신 mappingCount 메서드를 사용하는 것이 좋다.

 

- size -> int 반환

- mappingCount -> long 반환