devseop08 님의 블로그

[API] 람다와 스트림: Mordern Java - 8. 컬렉션 API 개선 본문

Language/Java

[API] 람다와 스트림: Mordern Java - 8. 컬렉션 API 개선

devseop08 2025. 7. 6. 23:03

8.1 컬렉션 팩토리

  • 자바 9에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공한다.
  • 왜 이와 같은 기능이 필요한 지 살펴보고 새 팩토리 메서드를 사용하는 방법을 알아본다.
  • 자바에서 적은 요소의 리스트를 만드는 일반적인 방법
List<String> friends = new ArrayList<>();
friends.add("Raphael");
friends.add("Olivia");
friends.add("Thibaut");
  • Arrays.asList() 팩토리 메서드 이용
List<String> friends = Arrays.asList("Raphael", "Olivia", "Thibaut");
  • 고정 크기의 리스트를 만들었으므로 요소를 갱신할 수는 있지만 새 요소를 추가하거나 요소를 삭제할 순 없다.
  • 요소를 추가하거나 삭제하려 하면 UnsupportedOperationException이 발생
List<String> friends = Arrays.asList("Raphael", "Olivia");
friends.set(0, "Richard"); // OK
friends.add("Thibaut"); // UnsupportedOperationException이

UnsupportedOperationException 예외 발생

  • 내부적으로 고정된 크기를 가지면서 요소를 변환할 수 있는 배열로 구현되었기 때문에 리스트 요소 갱신은 가능하고 요소 추가나 삭제는 UnsupportedOperationException이 발생하는 것이다.
  • 집합의 경우에는 Arrays.asSet()이라는 팩토리 메서드는 없으므로 다른 방법이 필요하다.
Set<String> friends 
    = new HashSet<>(Arrays.asList("Raphael", "Olivia", "Thibaut"));
  • 스트림 API를 사용할 수도 있다.
Set<String> friends 
    = Stream.of("Raphael", "Olivia", "Thibaut")
            .collect(Collectors.toSet())
  • 두 방법 모두 매끄럽지 못하며 내부적으로 불필요한 객체 할당 필요
  • 맵의 경우에는 작은 맵을 만들 수 있는 방법이 따로 없다.
  • 자바 9에서 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 팩토리 메서드를 제공하게 되었다.

8.1.1 리스트 팩토리

  • List.of 팩토리 메서드를 이용해서 간단하게 리스트를 만들 수 있다.
List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
  • List.of 팩토리 메서드로 만든 리스트는 add 메서드와 set 메서드 모두 UnsupportedOperationException을 발생시킨다.
  • List.of 팩토리 메서드로 만든 리스트는 변경할 수 없는 리스트이기 때문이다.
  • 이런 제약이 나쁜 것만은 아니다. 컬렉션이 의도치 않게 변하는 것을 막을 수 있다.
  • 리스트를 바꿔야 하는 상황이라면 직접 리스트를 만들면 된다.
  • 새로운 컬렉션 팩토리 메서드 대신 스트림 API를 사용해 리스트를 만들어야 하는 경우는 데이터 처리 형식을 따로 설정하거나 데이터를 변환할 필요가 있는 상황이다.
  • 그런 경우가 아니면 간편한 팩토리 메서드를 이용할 것을 권장한다.

오버로딩 vs 가변인수

  • List 인터페이스 List.of의 다양한 오버로드 버전이 있다.
...

static <E> List<E> of(E e1, E e2, E e3, E e4);

static <E> List<E> of(E e1, E e2, E e3, E e4, E e5);

static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6);

...
  • static <E> List<E> of(E... elements)와 같은 가변 인수 버전은 없다.
  • 내부적으로 가변 인수 버전은 추가 배열을 할당해서 리스트로 감싼다. => 가비지 컬렉션 비용 지불 필요
  • 고정된 숫자의 요소(최대 10개까지)를 API로 정의하므로 이런 비용을 제거 가능
  • List.of로 열 개 이상의 요소를 가진 리스트를 만들 수도 있지만 이때는 가변 인수를 이용하는 메서드가 사용
  • Set.of와 Map.of에서도 이와 같은 패턴이 등장

8.1.2 집합 팩토리

  • List.of와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있다.
Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");
  • 중복된 요소를 제공해 집합을 만들려고 하면 IllegalArgumentException 발생
Set<String> friends = Set.of("Raphael", "Olivia", "Olivia");

8.1.3 맵 팩토리

Map<String, Integer> ageOfFriends
     = Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);
  • 열 개가 넘는 키와 값 쌍을 갖는 맵을 만들 때는 Map.Entry<K,V> 객체를 인수로 받으며 가변 인수로 구현된 Map.ofEtries 팩토리 메서드를 이용하는 것이 좋다
import static java.util.Map.entry;

Map<String, Integer> ageOfFriends
    = Map.ofEntries(entry("Raphael", 30),
                    entry("Olivia", 25),
                    entry("Thibaut", 26));
  • Map.entry는 Map.Entry 객체를 만드는 새로운 팩토리 메서드

8.2 리스트와 집합 처리

  • 자바 8에서는 List, Set 인터페이스에 다음과 같은 메서드를 추가했다.
    • removeIf: 프레디케이트를 만족하는 요소를 제거, List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용 가능
    • replaceAll: 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소 변경
    • sort: List 인터페이스에서 제공하는 기능으로 리스트를 정렬
  • 위 메서드들은 호출한 컬렉션 자체를 바꾼다.
  • 새로운 결과를 만들어내는 스트림 동작과 달리 기존 컬레션 자체를 바꾸는 것이다.
  • 컬렉션 자체를 바꾸는 동작은 에러를 유발하며 복잡함을 더하는데 이 때문에 자바 8에서 removeIf와 replaceAll 메서드를 추가한 것이다.

8.2.1 removeIf 메서드

for(Transactin 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 객체와 Collectiono 객체 자체)가 컬렉션을 관리
  • 결과적으로 반복자의 상태가 컬렉션의 상태와 동기화되지 않는다.
  • for-each 루프를 사용하지 않고 Iterator 객체를 명시적으로 사용, 그 객체의 remove 메서드를 호출하는 방식으로 해결 가능
for(Iterator<Transaction> iterator = transactions.iterator();
        iterator.hasNext();){
    Transaction transaction = iterator.next();
    if(Character.isDigit(transaction.getReferenceCode().charAt(0))){
        iterator.remove();
    }
}
  • 이 코드 패턴을 자바 8에서는 removeIf 메서드로 바꿀 수 있다.
transactions.removeIf(transaction -> 
                    Character.isDigit(transaction.getReferenceCode().charAt(0));

8.2.2 replaceAll 메서드

  • 요소를 바꾸는 set 메서드를 지원하는 ListIterator 객체를 이용하여 기존 컬렉션을 변경할 수 있다.
for(ListIterator<String> iterator = referenceCodes.listIterator();
    iterator.hasNext();){
    String code = iterator.next();
    iterator.set(Character.toUpper(code.charAt(0)) + code.substring(1));    
}
  • 자바 8의 replaceAll 메서드를 이용해서 간단화할 수 있다.
referenceCodes.replaceAll(code -> 
    Character.toUpper(code.charAt(0)) + code.substring(1));

8.3 맵 처리

8.3.1 forEach 메서드

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

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

8.3.2 정렬 메서드

  • Map 인터페이스의 내부 인터페이스 Entry의 디폴트 메서드 comparingByKey()와 comparingByValue()를 이용해서 맵의 항목을 키 또는 값을 기준으로 정렬 가능

Map<String, Integer> favoriteMovies
    = Map.ofEntries(entry("Raphael", "Star Wars"),
                    entry("Cristina", "Matrix"),
                    entry("Olivia", "James Bond"));

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

Cristina=Matrix
Olivia=James Bond
Raphael=Star Wars

8.3.3 getOrDefault 메서드

  • 기존에는 찾으려는 키가 존재하지 않으면 널이 반환되므로 NullPointerException을 방지하려면 요청 결과가 널인지 확인이 필요
  • getOrDefault 메서드를 이용하면 기본값을 반환하여 문제 해결 가능
  • getOrDefault 메서드는 첫 번째 인수로 키를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 두 번째 인수로 받은 기본값을 반환
Map<String, Integer> favoriteMovies
    = Map.ofEntries(entry("Raphael", "Star Wars"),
                    entry("Cristina", "Matrix"),
                    entry("Olivia", "James Bond"));

favoriteMovies.getOrDefault("Olivia", "Matrix"); // James Bond
favoriteMovies.getOrDefault("Thibaut", "Matrix");// Matrix

8.3.4 계산 패턴

  • 맵에 키가 존재하는지, 존재하지 않는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요할 때가 있다.
  • computeIfAbsent: 제공된 키에 해당하는 값이 없으면(값이 없거나 널), 키를 이용해 새 값을 계산하고 맵에 키, 값을 추가 혹은 수정한 다음 계산된 값을 반환

  • computeIfPresent: 제공된 키가 존재하면 새 값을 계산하고 맵에 추가한 다음, 계산될 결과 반환

  • compute: 제공된 키로 새 값을 계산하고 맵에 저장한 다음 계산된 값을 반환

Map<String, List<String>> friendToMovies = Map.of();

friendToMovies.computeIfAbsent("Raphael", 
                                    name -> new ArrayList<>()) // ArrayList 반환
              .add("Star Wars");  // 반환된 ArrayList에 값 추가

// {Raphel:[Star Wars]}

8.3.5 삭제 패턴

  • 제공된 키에 해당하는 맵 항목을 제거하는 remove 메서드는 이미 알고 있다.
String key = "Raphael";
String value = "Jack Reacher 2";
if (favoriteMovies.containsKey(key) &&
        Objects.equals(favoriteMovies.get(key), value)){
    favoriteMovies.remove(key);
    return true;        
}else {
    return false;
}
  • 자바 8에서는 키가 특정한 값과 연관되었을 때만 항목을 제거하는 오버로드 버전 메서드를 제공
favoriteMovies.remove(key, value);

8.3.6 교체 패턴

  • 맵의 항목을 바꾸는 데 사용할 수 있는 두 개의 메서드가 맵에 추가되었다.
  • replaceAll: BiFunction을 적용한 결과로 맵 각 항목의 값을 교체한다.

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

Map<String, String> favoriteMovies = new HashMap<>();
favoriteMovies.put("Raphael", "Star Wars");
favoriteMovies.put("Olivia", "james bond");
favoriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

8.3.7 합침

  • merge는 첫 번째 인수로 주어지는 key와 연결된 값(oldValue)이 널이면 두 번째 인수로 주어지는 value를 키와 연결되는 새로운 값(newValue)으로 put하고 키와 연결된 새로운 값(newValue) 반환(단, 두 번째 인수로 주어지는 value가 널이면 첫 번째 인수 key에 해당하는 항목 remove )
  • 만약 첫 번째 인수로 주어지는 key와 연결된 값(oldValue)이 널이 아니면 세 번째 인수로 주어지는 람다의 실행 결과값을 키와 연결되는 새로운 값(newValue)으로 put하고 키와 연결된 새로운 값(newValue) 반환(단, 두 번째 인수로 주어지는 value가 널이면 첫 번째 인수 key에 해당하는 항목 remove )
  • 세 번째 인수로 주어지는 람다에 주어지는 파라미터는 merge 메서드의 첫 번째 인수로 주어지는 key와 연결된 값(oldValue)과 merge 메서드의 두 번째 인수로 주어지는 value이고 람다의 바디에서는 이 두 값을 병합(merge, 합침)하는 연산을 해줘야 한다.
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);
}
moviesToCount.merge(movieName, 1L, (count, increment) -> count + increment);

8.4 개선된 ConcurrentHashMap

  • ConcurrentHashMap 클래스는 동시성 친화적이며 최신 기술을 반영한 HashMap 버전이다.
  • ConcurrentHashMap은 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용
  • 모든 연산 작업이 동기화되는 Hashtable 버전에 비해 읽기 쓰기 연산 성능이 월등하다.
  • 참고로 표준 HashMap은 비동기로 동작한다.

8.4.1 리듀스와 검색

  • ConcurrentHashMap은 스트림과 비슷한 종류의 세 가지 새로운 연산을 지원한다.
    • forEach: 각 (키, 값) 쌍에 주어진 액션을 수행
      • forEach(키, 값으로 연산)
      • forEachKey(키로 연산)
      • forEachValue(값으로 연산)
      • forEachEntry(Map.Entry 객체로 연산)
    • reduce
      • reduce(키, 값으로 연산)
      • reduceKeys(키로 연산)
      • reduceValues(값으로 연산)
      • reduceEnties(Map.Entry 객체로 연산)
    • search
      • search(키, 값으로 연산)
      • searchKeys(키로 연산)
      • searchValues(값으로 연산)
      • searchEnties(Map.Entry 객체로 연산)
  • 이들 연산은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행하기 때문에 연산에 제공하는 함수(람다)는 계산이 진행되는 동안 상태가 변경될 수 있는 객체, 값, 순서 등에 의존해선 안 된다.
  • 또한 이들 연산에는 병렬성 기준값을 지정해야 하는데 맵의 크기가 주어진 병렬성 기준값보다 작으면 병렬 연산이 아닌 순차 연산이 실행된다.
  • 병렬성 기준값을 1로 하면 공통 스레드 풀을 이용해 병렬성을 극대화할 수 있다.
ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
long parallelismThreshold = 1;
Optional<Integer> maxValue = 
    Optional.ofNullable(map.reduceValues(parallelismThreshold, Long::max));
  • int, long, double 등의 기본값에는 전용 each reduce 연산이 제공되므로 reduceValuesToInt, reduceKeysToLong 등을 이용하면 박싱 작업을 할 필요가 없고 효율적으로 작업을 처리할 수 있다.

8.4.2 계수

  • ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드를 제공
  • 기존의 size 메서드 대신 새 코드에서는 long을 반환하는 mappingCount 메서드를 사용하는 것이 좋다.
  • 매핑 개수가 int를 넘어서는 이후의 상황을 대처할 수 있도록 한다.

8.4.3 집합뷰

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