자바

[모던 자바 인 액션] 컬렉션 API 개선

__Minnie_ 2025. 5. 2. 13:38

 

앞에서 여러 컬렉션 API에 대해서 배웠지만, 더 쉽고 간단하게 사용할 수 있는 새로운 컬렉션 API에 대해서 소개한다.

 

리스트 컬렉션 팩토리
List<String> friends = new ArrayList();
friends.add("Raphael");
friends.add("Olivia");
friends.add("Thibaut");

우리가 자바에서 작은 요소를 포함하는 리스트를 만들고 싶을 때, 위처럼 작성하게 되면 간단한 동작임에도 불구하고 굉장히 많은 코드를 필요로 한다.

 

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

그러면 Arrays.asList를 사용해보자. 그러면 코드가 굉장히 간단해지는 것을 볼 수 있다. 하지만 Arrays.asList를 사용하게 되면 고정된 크기의 가변 리스트를 만드는 것이기 때문에 요소를 삭제 혹은 추가를 하게 되면 UnSupportedOpreationException에러가 발생한다. (참고! 파이썬에서는 [1, 2, 3]과 같은 컬렉션 리터럴을 제공하지만, 자바에서는 언어적인 한계로 이런 형태를 지원할 수가 없다.)

 

List.of를 활용해서도 간단한 리스트를 만들 수 있다.

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

 

그렇다면 Arrays.asList, List.of의 차이점은 뭘까?

항목 List.of Arrays.asList
지원 자바 버전 9 1.2
변경 여부 완전 불변 변경은 가능하나 추가, 삭제 불가
Null 허용 여부 불가 허용
내부 구조 불변 리스트 고정 크기 리스트

* List.of는 불변리스트인데, 책에서는 요소 자체가 변하는 것을 막을수는 없다고 하는데, 이는 요소 자체가 뮤터블 객체인 경우를 의미하는 것으로 보임.

 

List의 구조를 자세히 살펴보면 아래처럼 오버로딩을 통한 생성자와 가변인수를 받는 생성자가 있다.

public interface List<E> extends SequencedCollection<E> {
	...
    static <E> List<E> of() {
        return (List<E>) ImmutableCollections.EMPTY_LIST;
    }
    
    static <E> List<E> of(E e1) {
        return new ImmutableCollections.List12<>(e1);
    }
    
    static <E> List<E> of(E e1, E e2) {
        return new ImmutableCollections.List12<>(e1, e2);
    }

    static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10) {
        return ImmutableCollections.listFromTrustedArray(e1, e2, e3, e4, e5,
                                                         e6, e7, e8, e9, e10);
    }

    static <E> List<E> of(E... elements) {
        switch (elements.length) { // implicit null check of elements
            case 0:
                @SuppressWarnings("unchecked")
                var list = (List<E>) ImmutableCollections.EMPTY_LIST;
                return list;
            case 1:
                return new ImmutableCollections.List12<>(elements[0]);
            case 2:
                return new ImmutableCollections.List12<>(elements[0], elements[1]);
            default:
                return ImmutableCollections.listFromArray(elements);
        }
    }
    ...
}

먼저 오버로딩을 통해서 인수 0~10개까지 생성자가 선언되어 있는 이유는 최적화를 위해서이다. 가변 인수 버전은 추가 배열을 생성해야 하기 때문에 오버헤드가 발생할 수 있다. 따라서 이런 비용을 줄이기 위해서 요소 개수가 0~10인 경우는 오버로딩을 통해서 생성자를 따로 만들어두는 것이고, 이외의 경우에는 10개 이상의 경우를 처리하기 위해서 제공된다.

 

위 생성자를 보면 내부적으로  ImmutableCollections을 사용하고 있는 것을 볼 수 있는데, ImmutableCollections은 자바9에서 추가된 기능으로 불변 컬렉션 객체들을 효율적으로 관리하기 위한 클래스이다. ImmutableCollections는 내부적으로 2개의 인수를 가지는 리스트에 대해서는 배열을 생성하지 않고 필드를 활용해서 효율성을 극대화한다.

class ImmutableCollections {
	...

	static <E> List<E> listFromTrustedArray(Object... input) {
        assert input.getClass() == Object[].class;
        for (Object o : input) { // implicit null check of 'input' array
            Objects.requireNonNull(o);
        }

        return switch (input.length) {
            case 0  -> (List<E>) ImmutableCollections.EMPTY_LIST;
            case 1  -> (List<E>) new List12<>(input[0]);
            case 2  -> (List<E>) new List12<>(input[0], input[1]);
            default -> (List<E>) new ListN<>(input, false);
        };
    }

	static <E> List<E> listFromArray(E... input) {
        // copy and check manually to avoid TOCTOU
        @SuppressWarnings("unchecked")
        E[] tmp = (E[])new Object[input.length]; // implicit nullcheck of input
        for (int i = 0; i < input.length; i++) {
            tmp[i] = Objects.requireNonNull(input[i]);
        }
        return new ListN<>(tmp, false);
    }	
	...
	
    static final class List12<E> extends AbstractImmutableList<E> implements Serializable {
        ...

        @Stable
        private final E e0;

        @Stable
        private final Object e1;

        List12(E e0) {
            this.e0 = Objects.requireNonNull(e0);
            // Use EMPTY as a sentinel for an unused element: not using null
            // enables constant folding optimizations over single-element lists
            this.e1 = EMPTY;
        }

        List12(E e0, E e1) {
            this.e0 = Objects.requireNonNull(e0);
            this.e1 = Objects.requireNonNull(e1);
        }
        ...
    }

    static final class ListN<E> extends AbstractImmutableList<E> implements Serializable {
		...
        
        @Stable
        private final E[] elements;

        @Stable
        private final boolean allowNulls;

        // caller must ensure that elements has no nulls if allowNulls is false
        private ListN(E[] elements, boolean allowNulls) {
            this.elements = elements;
            this.allowNulls = allowNulls;
        }
        ...
    }
    
    ...
}

 

 

집합 컬렉션 팩토리
Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");

 

 

맵 컬렉션 팩토리

 

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

Map<String, Integer> ageOfFriends2 = Map.ofEntries(Map.entry("Raphael", 30), Map.entry("Olivia", 25), Map.entry("Thibaut", 26));

맵 생성시에는 기본적으로 key, value를 번갈아가면서 입력하는 Map.of가 있다. 10개 이하의 쌍까지는 Map.of를 사용하는 것이 유리하지만 Map.ofEntries를 사용하는 것이 좋다. Map.ofEntries는 인자로 키와 값을 받을 때 이를 감쌀 추가적인 객체를 필요로 한다.

 

Map.Entry<K, V> 타입은?? Map인터페이스와 함께 사용되는 중첩 인터페이스로 Map하나의 키와 값의 쌍을 표현하는 객체이다. 주로 Map의 entrySet메소드를 이용해서 Map의 모든 값들을 탐색할 때 사용된다.  

public interface Map<K,V> {
   
    Set<Map.Entry<K, V>> entrySet();
    
    ...
    
    interface Entry<K,V> {
        ...
        K getKey();
        V getValue();
        V setValue(V value);  // optional operation
        ...
    }
}

 

 

RemoveIf, ReplaceAll (리스트, 집합 처리)

 

List, Set 인터페이스에서는 removeIf, ReplaceAll이라는 메서드를 제공한다. removeIf는 주어진 프레디케이트를 만족하는 요소를 제거하며, replaceAll은 UnaryOperator를 통해서 요소를 변환한다. 이 기능은 앞의 스트림에서도 배운 것 같지만 다른 점은 호출한 컬렉션 자체를 변경한다는 점이다. (스트림의 경우 기존 스트림의 값은 변경되지 않고 새로운 결과를 생성)

 

우리가 일반적으로 많이 사용하는 for-each 구분을 사용하는 경우, ConcurrentModificationExceptiondmf 발생할 가능성이 높다. 반복을 하면서 컬렉션을 변경하고 있기 때문이다. 이를 해결하려면 Iterator를 직접 사용하여 반복문을 돌려야 하는데, 이를 사용하면 코드가 굉장히 복잡해진다.

trasactions.removeIf(t -> Character.isDigit(t.getReferencecode.charAt(0)));

여기서 removeIf를 사용하면 특정 조건을 만족하는 요소를 삭제하는 것을 간단하고 쉽게 구성할 수 있다.

 

referenceCodes.replaceAll(code -> Character.tpUpperCase(code.charAt(0)) + code.substrin(1));

이렇게 replaceAll도 간단하게 사용할 수 있다. 여기서 우리는 이 기능을 스트림으로도 구성할 수 있는데, 앞에서 언급했듯이 다른 점은 removeIf, replaceAll은 기존 컬렉션 자체를 변경한다는 점이다.

 

 

맵 처리

 

1. forEach

ageOfFriends.forEach((friendx, agex) -> {
    System.out.println(friendx + " is " + agex + " years old");
});

 

2. Entry.comparingByValue,  Entry.comparingByKey

Stream var10000 = favouriteMovies.entrySet().stream().sorted(Entry.comparingByKey());

엔트리를 key 혹은 value 기준으로 정렬

 

3. getOrDefault

System.out.println((String)favouriteMovies.getOrDefault("Olivia", "Matrix"));

map에서 key에 해당하는 value값을 반환하되, 값이 없는 경우 default값을 반환

 

4. computeIfAbsent, computeIfPresent, compute

computeIfAbsent는 입력된 키에 해당하는 값이 없는 경우, 계산한 후 맵에 추가, computeIfPresent는 제공된 키가 존재하면 새값을 계산해서 추가, compute는 제공된 키로 계산 후 추가

friendsToMovies.computeIfAbsent("Raphael", (name) -> {return new ArrayList();}).add("Star Wars");

 

5. remove(k, v)

remove(k)는 해당 키 값이 존재하면 삭제하는 메소드이지만, remove(k, v)는 k에 해당하는 value가 존재하고 해당 value가 입력된 value와 동일한 경우에면 삭제하는 메소드이다.

 

6. replaceAll, replace

favouriteMovies.replaceAll((friend, movie) -> {
    return movie.toUpperCase();
});

replaceAll는 BiFunction을 적용한 결과 값으로 값을 변경한다. replace는 키가 존재하면 값을 변경한다. 

 

7. merge

우리가 두개의 맵을 합칠 때, 기본적으로 맵에서 제공하는 putAll이라는 메소드를 사용할 수 있다. 그러나, 이 메서드는 겹치는 값이 있을 때, 어떻게 처리할지 설정할 수 없다. 따라서, 우리는 두개의 맵을 합칠 때, 조금 더 유연하게 합치는 것이 필요한 경우 merge를 사용한다. 

friends2.forEach((k, v) -> {
    String var10000 = (String)everyone2.merge(k, v, (movie1, movie2) -> {
        return movie1 + " & " + movie2;
    });
});

merge에 세번째 인자로 겹치는 키가 있는 경우 어떻게 처리할 것인지를 설정하는 BiFunction을 전달한다. 

 

이를 활용하면 특정 엘리먼트가 몇개인지 카운트하는 기능도 간단하게 구현할 수 있다.

moviesToCount.merge(movieName, 1L, (count, increment) -> count + 1L);

키가 없는 경우에는 입력된 1L이 그대로 저장되고, 이미 키에 대한 값이 있는 경우에는 입력된 BiFunction에 따라서 두 값을 더해준다.

 

ConcurrentMap 처리

 

1. forEach: 모든 (키, 값) 요소에 주어진 액션 실행

2. reduce: 모든 (키, 값) 요소에 제공된 리듀스 함수를 이용해 결과를 합침

3. search: 널이 아닌 값을 반환할 때까지 모든 (키, 값) 요소에 함수 적용

위 모든 메서드는 키, 값으로 연산, 키로 연산, 값으로 연산, Entry객체로 연산하는 특화 버전을 제공한다.  위 메서드들은 모두 락을 걸지 않는데, 모두 데이터를 읽기 연산용 메서드이기 때문이다. 또한 메서드 사용시 병렬성 기준값을 설정해주어야 한다. 맵의 사이즈가 기준값보다 크다면 병렬로 처리를 한다(맵 사이즈 > 병렬성 기준값). 예를 들어서 데이터가 100인데 기준값을 50으로 설정하면 병렬처리가 된다.

// forEach 
map.forEach(1, (key, value) -> System.out.println(key + " => " + value));

// search
String result = map.search(1, (key, value) -> {
    if (value > 2) return key;
    return null;
});

// reduce
int sum = map.reduce(1, (key, value) -> value, Integer::sum);

 

4. mappingCount: 맵의 요소 개수 반환. long타입값으로 반환

5. keySet, newKeySet: concurrentHashMap을 집합뷰로 변환

항목 keySet newKeyset
리턴 타입 Set<K> (기존 맵의 키 뷰) ConcurrentHashMap.KeySetView<K, Boolean>
기존 맵과 연결 O X
목적 Map의 키를 읽거나 조작할 때 사용 쓰레드에 안전한 Set이 필요한 경우