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

이주오·2021년 8월 30일
0

도서

목록 보기
8/15

이번 주제 키워드

  • 컬렉션 팩토리 메서드 사용하기
  • 리스트 및 집합과 사용할 새로운 관용 패턴
  • 맵과 사용할 새로운 관용 패턴

컬렉션 팩토리

자바 9 에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공한다.

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

List<String> friends = Arrays.asList("Raphael", "Olivia", "Thibaut");
  • 고정 크기의 리스트를 만들었으므로 요소를 갱신할 순 있지만 새 요소를 추가하거나 삭제할 수는 없다.
friends.set(0, "Richard"); // 문제 없음
friends.add("Tom");        //UnsupportedOperationException 발생
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
    implements RandomAccess, java.io.Serializable
{
    private final E[] a;

    ArrayList(E[] array) {
        a = Objects.requireNonNull(array);
    }
 ...
}
  • Arrays.asList()는 Arrays의 private 정적 클래스인 ArrayList를 리턴한다.
    • java.util.ArrayList 클래스와는 다른 클래스이다.
  • Arrays.asList는 내부적으로 고정된 크기의 배열로 구현되었기 때문에 이와 같은 일이 발생

그렇다면 set은??

Set<String> elems1 = new HashSet<>(Arrays.asList("e1","e2","e3"));
Set<String> elems2 = Stream.of("e1","e2","e3").collect(toSet());
  • 집합의 경우 리스트를 인수로 받는 HashSet 생성자를 사용하거나 스트림 API를 사용하는 방법이 존재했다.
  • 두 방법 모두 매끄럽지 못하며 내부적으로 불필요한 객체 할당을 필요로 한다.
  • 그리고 결과는 변환할 수 있는 집합이다.

자바 9에서 제공되는 팩토리 메서드

List.of

  • 변경할 수 없는 불변 리스트를 만든다.

Set.of

  • 변경할 수 없는 불변 집합을 만든다.
  • 중복된 요소를 제공해 집합 생성 시 IllegalArgumentException이 발생한다.

Map.of

  • 키와 값을 번갈아 제공하는 방법으로 맵을 만들 수 있다.

Map.ofEntries

  • Map.Entry<K, V> 객체를 인수로 받아 맵을 만들 수 있다.
  • 엔트리 생성은 Map.entry 팩터리 메서드를 이용해서 전달하자.

리스트 팩토리

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

List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
  • Arrays.asList 방법과 다르게 List.of 는 추가, 삭제뿐만 아니라 변경(set)도 할 수 없고 null 추가가 불가능한
    리스트로 만들어진다.

스트림 API vs 리스트 팩토리

데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 사용하기 간편한 팩토리 메서드를 사용하면 된다 !

  • 구현이 더 단순하고 목적을 달성하는데 충분하기 때문

집합 팩토리

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

// 요소가 중복되어 있다는 IllegalArgumentException 발생
Set<String> friends = Set.of("Raphael", "Olivia", "Olivia");
  • List.of 와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있다.

맵 팩토리

자바 9 에서는 두 가지 방법으로 바꿀 수 없는 맵을 만들 수 있다.

  1. Map.of 팩토리 메서드에 키와 값을 번갈아 제공하는 방법
Map<String, Integer> ageOfFriends = 
				Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);
  • 열개 이하의 키와 값 쌍을 가진 작은 맵을 만들 경우 (오버로딩으로 10개까지 지원해둔 것)
  1. Map.Entry<K,V> 객체를 인자로 받으며 가변 인수로 구현된 Map.ofEntries 이용
import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(
		entry("Raphael", 30), 
		entry("Olivia", 25),
		entry("Thibaut", 26));
  • 10개 이상의 경우 사용하면 좋다.
  • Map.entry는 Map.Entry 객체를 만드는 팩토리 메서드

리스트와 집합 처리

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

  • removeIf
    • 프레디케이트를 만족하는 요소를 제거한다.
  • replaceAll
    • UnaryOperator 함수를 이용해 요소를 바꾼다.
    • UnaryOperator: Function(T, T), T → T
  • sort
    • List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

그런데 이들 메서드는 호출한 컬렉션 자체를 바꾼다.

  • 새로운 결과를 만드는 스트림 동작과 달리 이들 메서드는 기존 컬렉션을 바꾼다.
  • 왜 이런 메서드가 추가 되었을까?

컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더하기 때문이다!!

  • 삭제 시에는 IteratorCollection 의 상태를 동기화 시켜주어야 하기 때문이다.

removeIf 메서드

// ConcurrentModificationException 발생
for (Transaction transaction : transactions){
	if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
		transactions.remove(transaction);
	}
}

// for-each 내부적으로 Iterator 객체를 사용하므로 아래와 동일
for(Iterator<Transaction> iterator = transactions.iterator();
			iterator.hasNext(); ){
	Transaction transaction = iterator.next();
	if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
			// 반복하면서 별도의 두 객체를 통해 컬렉션을 바꾸고 있음
			transactions.remove(transaction);
	}
}
  • 다음은 숫자로 시작되는 참조 코드를 가진 트랜잭션을 삭제하는 코드
  • Iterator 객체 : next(), hastNext()를 이용해 소스를 질의한다.
  • Collection 객체 자체 : remove()를 호출해 요소를 삭제한다.
  • 반복자의 상태는 컬렉션의 상태와 서로 동기화 되지 않기 때문에 에러 발생
    • 즉 반복자에서도 요소를 조작하고 컬렉션에서도 요소를 조작하기 때문에 ConcurrentModificationException 발생
    • transactions.remove(transaction) 대신iterator.remove() 사용
    • 하지만 코드가 복잡해졌다.
  • 이유를 코드로 자세히 살펴보자

java.util.ArrayList의 remove()

    protected transient int modCount = 0;

    public boolean remove(Object o) {
        final Object[] es = elementData;
        final int size = this.size;
        int i = 0;
        found: {
            if (o == null) {
                for (; i < size; i++)
                    if (es[i] == null)
                        break found;
            } else {
                for (; i < size; i++)
                    if (o.equals(es[i]))
                        break found;
            }
            return false;
        }
        fastRemove(es, i);
        return true;
    }

    private void fastRemove(Object[] es, int i) {
        modCount++;
        final int newSize;
        if ((newSize = size - 1) > i)
            System.arraycopy(es, i + 1, es, i, newSize - i);
        es[size = newSize] = null;
    }
  • 살펴보면 remove시에 modCount를 증가를 시키고, System.arraycopy를 통해 remove할 데이터가 위치한 곳에 index+1부터 마지막까지 남은 데이터를 copy하고 해당 List의 맨 끝부분의 데이터를 null로 바꾸게 된다.
  • 결국 여기서 데이터의 조작은 이미 발생한 것이다. 그리고 생각해야 되는 부분이 클래스 변수인 modCount이다.
  • 이 변수는 처음에 iterator가 생성될 때 다른 클래스 변수인 expectedModCount 와 같은 값으로 동기를 하게 되어 있다

java.util.ArrayList의 이너 클래스 Itr


    public Iterator<E> iterator() {
            return new Itr();
        }

    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        // prevent creating a synthetic constructor
        Itr() {}

        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();
            ...
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }
  • 이 2개의 클래스 변수로 리스트의 데이터 변경 여부를 체크하게 되는 것이다
  • modCount 를 증가시키고 element가 제거되고 난 뒤 iterator에서 next() 메소드로 다음 element를 가져오려고 시도하는 순간 ConcurrentModificationException이 발생하는 것을 알 수 있다.

위의 단점을 removeIf 로 해결 가능하다.

transactions.removeIf(
	transaction -> Charater.isDigit(transaction.getReferenceCode().charAt(0))
);
  • 단순해 질 뿐 아니라 버그도 예방 가능!!
  • 삭제할 요소를 가리키는 프레디케이트를 인수로 받는다.

replaceAll 메서드

때로는 요소를 제거하는 것이 아닌 변경해야 할 상황이 있다.

스트림 API 를 사용하면 되지만 새 컬렉션을 만들기에 기존 컬렉션을를 바꾸고 싶은 경우 부적합

이때는 replaceAll 을 사용하여 데이터를 변경 가능 !

// 첫 단어만 대문자로 바꾸는 코드
referenceCodes.replaceAll(
	code -> Charater.toUpperCase(code.charAt(0)) + code.subString(1)
);

맵 처리

forEach 메서드

맵을 조회하기 위한 기존의 반복 코드

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

forEach 를 사용한 코드

ageOfFriends.forEach(
	(friend, age) -> System.out.println(friend + " is " + age + " years old")
);
  • forEach 메서드는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는다.

정렬 메서드

다음 두 개의 새로운 메서드를 이용하면 맵을 키 또는 값을 기준으로 정렬 가능

  • Entry.comparingByValue
  • Entry.comparingByKey
Map<String, String> favoriteMovies = Map.ofEntries(
        Map.entry("ljo", "Star Wars"),
        Map.entry("hsy", "Matrix"),
        Map.entry("yhh", "James Bond")
);

favoriteMovies.entrySet().stream()
		.sorted(Entry.comparingByKey())
		.forEachOrdered(System.out::println); // 키 값 순서대로
hsy=Matrix
ljo=Star Wars
yhh=James Bond

getOrDefault 메서드

기존에 찾으려는 키가 존재하지 않을 경우 NPE을 방지하기 위해 널 체크를 해야 했지만getOrDefault 를 이용하면 이를 해결 할 수 있다.

  • 첫 번째 인수로 받은 가 맵에 없으면
  • 두 번째 인수로 받은 기본값 을 반환한다.
  • 키가 존재하더라도 값이 널인 상황에서는 널을 반환할 수 있으므로 주의

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다.

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

Ex) 허승연님에게 줄 영화 목록을 만든다고 가정

  • 기존 코드
    String friend = "hsy";
    List<String> movies = friendsToMovies.get(friend);
    if (movies == null){     // 초기화 확인
    	movies = new ArrayList<>();
    	friendsToMovies.put(friend, movies);
    }
    movies.add("Iron man"); // 영화 추가
  • 컬렉션 API 사용
    friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>)).add("Star Wars");
</br>

삭제 패턴

  • 제공된 키에 해당하는 맵 요소를 제거하는 remove 메서드는 이미 알고 있다
    • 삭제할 경우 키가 존재하는지 확인하고 값을 삭제하지만
  • 자바 8 에서는 키가 특정한 값과 연관되어 있을 때만 항목을 제거하는 오버로드 버전 메서드를 제공한다.
map.remove(key, value); 

교체 패턴

맵의 항목을 바꾸는데 사용할 수 있는 메서드들

  • replaceAll
    • Bifunction 을 적용한 결과로 각 항목의 값을 교체한다.
    • 이 메서드는 ListreplaceAll 과 비슷한 동작을 수행
  • Replace
    • 키가 존재하면 맵의 값을 바꾼다.
    • 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전 도 있다.

합침

두 개의 맵에서 값을 합칠 때 조건을 걸고 합치려면 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")
);

// merge 메서드 사용 - 조건에 따라 맵을 합치는 코드
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> 
	everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)
);

{Raphael=Star Wars, Cristina=James Bond & Matrix, Teo=Star Wars}
  • merge 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다.

개선된 ConcurrentHashMap

ConcurrentHashMap 는 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용


리듀스와 검색

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

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

또한, 다음 처럼 4가지 연산 형태를 지원

  1. 키, 값으로 연산 (forEach , reduce , search)
  2. 키로 연산 ( forEachKey, reduceKey, searchKey )
  3. 값으로 연산 ( forEachValue, reduceValue, searchValue )
  4. Map.Entry 객체로 연산 ( forEachEntry, reduceEntry, searchEntry )

위의 연산들은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행한다.

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

그리고 이들 연산에 병렬성 기준값(threshold) 를 지정해야한다.

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

계수

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

집합뷰

  • ConcurrentHashMap 클래스는 집합 뷰로 반환하는 keySet 이라는 새 메서드 제공
  • 맵을 바꾸면 집합도 바뀌고 반대로 집합을 바꾸면 맵도 영향을 받는 구조
  • newKeySet 이라는 새 메서드를 이용해 ConcurrentHashMap 으로 유지되는 집합을 만들 수도 있다.

참고출처

profile
동료들이 같이 일하고 싶어하는 백엔드 개발자가 되고자 합니다!

0개의 댓글