-
자바의 컬렉션 - 모던 자바 인 액션BackEnd/자바 2022. 3. 21. 23:32
Java에서는 리스트, 집합(Set), 맵(Map)을 초기화하는 다양한 방법이 있습니다.
특히 Java 9부터 도입된 컬렉션 팩토리 메서드는 코드의 간결성과 성능 면에서 유리합니다.
기존 Arrays.asList()의 특징과 Java 9 이후의 List.of 의 차이
Arrays.asList() – 고정 크기 리스트
- 내부 구현: Arrays 클래스 내부의 정적 클래스 ArrayList (java.util과 다름)
- 요소 수정 가능 (list.set(0, "z"))
- 요소 추가/삭제 불가능 - 고정 크기 리스트
- UnsupportedOperationException 발생
- 배열 기반 리스트이기 때문에 크기 변경이 불가능합니다.
public static <T> List<T> asList(T... a) { return new ArrayList<>(a); }
Arrays 클래스 내부에 선언된 ArrayList.
private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable { private static final long serialVersionUID = -2764017481108945198L; private final E[] a; ArrayList(E[] array) { a = Objects.requireNonNull(array); }
요소에 추가, 삭제를 가능하게 하려면 Set으로 감싸줘야합니다.
HashSet<String> strings1 = new HashSet<>(Arrays.asList("a", "v", "c"));
자바 9부터는 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 팩토리 메서드를 제공한다.
자바 9의 새로운 컬렉션 팩토리 메서드
1. List.of
- 완전 불변 리스트
- 요소 추가, 삭제, 변경 모두 불가능
- 내부 클래스는 ImmutableCollections.ListN, List12 등 최적화된 불변 구현체
- 성능을 고려해 0~10개까지는 오버로드 메서드, 11개 이상은 varargs 방식으로 처리
List<String> list = List.of("a", "b", "c");
List of 는 인자를 1~10개까지 받는 다양한 오버로드 버전이 있다.
List of(E..elements) 이런식으로 다중 요소를 받을 수 있도록 되어있지 않은 이유는
내부적으로 가변 인수 버전은 추가 배열을 할당해서 리스트로 감싸는데, 이 때 배열을 할당,초기화, 가비지 컬렉션 비용이 발생한다. 그래서 이런 비용을 피하기 위해 고정된 숫자요소를 API 로 정의하고 10개 이상의 요소를 가진 리스트를 만들 때는 가변 인수를 이용하는 메서드를 사용한다.
이 아이는 불변 리스트로 값의 추가, 삭제뿐만 아니라 요소 변경도 불가하다.
static <E> List<E> of() { return ImmutableCollections.emptyList(); } 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 new ImmutableCollections.ListN<>(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10); } @SafeVarargs @SuppressWarnings("varargs") static <E> List<E> of(E... elements) { switch (elements.length) { // implicit null check of elements case 0: return ImmutableCollections.emptyList(); case 1: return new ImmutableCollections.List12<>(elements[0]); case 2: return new ImmutableCollections.List12<>(elements[0], elements[1]); default: return new ImmutableCollections.ListN<>(elements); } }
2. Set.of
- List.of()와 동일하게 불변 Set을 생성
- 중복 요소를 허용하지 않으며, 중복 시 IllegalArgumentException 발생
Set<String> set = Set.of("a", "b", "c");
3. Map 팩토리
- Map.of() → 최대 10쌍의 키-값 쌍
- Map.ofEntries() → 10쌍 이상 가능
- 불변 Map이며, 키 중복 시 IllegalArgumentException 발생
맵은 두가지 방법으로 초기화 할 수 있다.
열 개 이하의 키와 값 쌍을 가진 작은 맵을 만들 때는 Map.of 를,
이상의 큰 맵에는 가변인수로 구현된 Map.ofEntries 를 이용하는 것이 좋다. 키와 값을 감쌀 추가 객체 할당을 필요로 한다.
Map<String, Integer> rr = Map.of("R", 30, "A", 20); Map<String, Integer> map = Map.ofEntries(Map.entry("A", 30), Map.entry("B", 10));
자바 8에서 추가된 컬렉션(List, Set, Map) 처리 기능 정리
자바 8에서는 List, Set, Map 컬렉션에 유용한 디폴트 메서드들이 추가되었습니다. 이를 통해 컬렉션 요소를 더 안전하고 간결하게 다룰 수 있게 되었으며, 특히 람다와 함께 사용하면 코드 가독성과 유지보수성이 향상됩니다.
List, Set 인터페이스의 추가 메서드
List,Set 인터페이스에 아래와 같은 메서드가 추가되었다.
1. removeIf
- 조건(predicate)을 만족하는 요소를 제거합니다.
- List, Set을 구현한 모든 컬렉션에서 사용할 수 있습니다.
transactions.removeIf(transaction -> "2000".equals(transaction.getYear()));
2. replaceAll
- 리스트의 모든 요소를 주어진 함수(UnaryOperator)로 교체합니다.
- List에서만 사용 가능합니다.
transactions.replaceAll(transaction -> { transaction.setValue(2000); return transaction; });
3. sort(Comparator)
- 리스트를 주어진 정렬 기준에 따라 정렬합니다.
transactions.sort(Comparator.comparing(Transaction::getYear));
주의: 반복 중 컬렉션 수정 시 예외 발생
이들 메서드는 호출한 컬렉션 자체를 바꾼다. 컬렉션을 바꾸는 동작은 에러 유발과 복잡함을 더하는데 왜 추가되었을까?
아래와 같은 코드를 짜면 ConcurrentModificationException 을 일으키다.
- for-each 문은 내부적으로 Iterator를 사용하지만,
- 위 코드에서는 컬렉션 객체의 remove()를 직접 호출하므로,
- Iterator와 컬렉션 상태가 불일치하게 되어 예외가 발생합니다.
List<Transaction> transactions = new ArrayList<>(); for (Transaction transaction : transactions) { if(Character.isDigit(transaction.getReferenceCode().charAt(0))){ transactions.remove(transaction); } }
해결 방법: 명시적으로 Iterator 사용
반복자의 상태는 컬렉션의 상태와 서로 동기화되지 않는다. Iterator 객체를 명시적으로 사용하고,
그 객체의 remove 메서드를 호출함으로 이 문제를 해결할 수 있다.
for (Iterator<Transaction> iterator = transactions.iterator(); iterator.hasNext(); ) { Transaction transaction = iterator.next(); System.out.println("getval = "+transaction.getValue()); if("2000".equals(transaction.getYear())){ iterator.remove(); } }
ListIterator를 사용하여 요소를 변경할 수도 있지만, 자바 8에서는 replaceAll로 훨씬 간단하게 처리할 수 있습니다.
transactions.removeIf(transaction -> "2000".equals(transaction.getYear()));
replaceAll 은 삭제가 아닌 요소 변경을 하고자 할 때 사용할 수 있다.
//기존 for (ListIterator<Transaction> iterator = transactions.listIterator(); iterator.hasNext(); ) { Transaction transaction = iterator.next(); System.out.println("getval = "+transaction.getValue()); if("2000".equals(transaction.getYear())){ transaction.setValue(2000); iterator.set(transaction); } }
//자바8 이후 transactions.replaceAll(transaction-> { transaction.setValue(2000); return transaction; });
Map 인터페이스의 기능 강화
자바 8에서는 Map 인터페이스에 몇 가지 디폴트 메서드를 추가했다.
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"); }
ageOfFriends.forEach((friend,age)->{ System.out.println(friend + "is " + age + " years old"); });
정렬은 반복과 관련된 오래된 고민거리다. 자바 8에서는 맵의 항목을 쉽게 비교할 수 있는 몇 가지 방법을 제공한다.
Entry.comparingByValue
Entry.comparingByKey
Map<String, String> map = Map.ofEntries(Map.entry("c", "3"), Map.entry("a", "1"), Map.entry("b", "2")); map.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .forEach(System.out::println);
HashMap의 성능 개선
기존에는 HashMap의 버킷(bucket)에 LinkedList를 사용하여 키 충돌 시 O(n) 시간 복잡도가 발생했습니다.
자바 8부터는 충돌이 많은 경우 TreeNode 구조로 전환하여 O(log n)으로 충돌이 일어나는 요소 반환 성능을 개선했습니다.- 단, 키가 Comparable을 구현한 타입이어야 트리로 전환됩니다.
- 예: String, Integer 등
키가 존재하지 않을 때: getOrDefault
기존에는 없는 키를 조회하면 null이 반환되어 NPE 위험이 있었습니다.
getOrDefault는 기본값을 지정할 수 있어 이를 방지할 수 있습니다.String value = map.getOrDefault("key", "defaultValue");
단, 키는 존재하지만 값이 null이면 null이 반환됩니다.
자바 8 Map 계산 및 병합 메서드 정리 (compute, merge 등)
컬렉션을 다루다 보면 맵(Map)의 키 존재 여부에 따라 특정 로직을 수행하거나, 값을 병합하거나, 조건에 따라 값을 수정하는 일이 자주 발생합니다.
자바 8에서는 이러한 계산 및 병합 패턴을 간결하게 처리할 수 있도록 compute, computeIfAbsent, merge 등의 메서드를 추가했습니다.
이 글에서는 이들 메서드의 사용법과 주의사항을 예제와 함께 정리합니다.computeIfAbsent – 값이 없을 때 계산해서 추가
computeIfAbsent는 지정된 키가 맵에 없거나 null로 연결되어 있을 때, 새로운 값을 계산하여 맵에 추가합니다.
캐시(Cache) 패턴에서 매우 자주 사용됩니다.dataToHash.computeIfAbsent(line, this::calculateDigest);
파일 집합의 각 행을 파싱해 SHA-256을 계산한다고 가정하다. 기존에 이미 데이터를 처리했다면 이 값을 다시 계산할 필요가 없다.
public byte[] calculateDigest(String key) { try { MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }
주의할 점
- 계산 함수가 null을 반환하면 해당 키는 맵에서 제거됩니다.
- null 처리는 명확하게 하시는 것이 좋고, 제거 목적이라면 remove()를 명시적으로 사용하는 것이 더 적절합니다.
String key="Raphael"; String value="Jack Reacher 2"; HashMap<String, List<String>> favoriteMovies = new HashMap<>(); if(favoriteMovies.containsKey(key) && Objects.equals(favoriteMovies.get(key),value)){ favoriteMovies.remove(key); }
favoriteMovies.remove(key,value);
기존 방식 vs computeIfAbsent
//기존 방식 String friend = "Rachel"; HashMap<String, List<String>> friendsToMovies = new HashMap<>(); List<String> movies = friendsToMovies.get(friend); if(movies==null){ movies = new ArrayList<>(); friendsToMovies.put(friend,movies); } movies.add("Stars"); System.out.println(movies); //자바8방식 friendsToMovies.computeIfAbsent(friend,name->new ArrayList<>()).add("Stars");
computeIfPresent, compute
- computeIfPresent: 키가 존재할 경우에만 새 값을 계산합니다.
- compute: 존재 여부에 관계없이 항상 새 값을 계산하고 대체합니다.
map.computeIfPresent(key, (k, v) -> v + 1); map.compute(key, (k, v) -> v == null ? 1 : v + 1);
replace, replaceAll – 값 교체
replace(key, value), replace(key, oldValue, newValue)
- 키가 존재할 때만 값을 바꿉니다.
- 조건부로 특정 값일 때만 교체도 가능합니다.
replaceAll : BiFunction 을 적용한 결과로 각 항목의 값을 교체한다. 이 메서드는 이전에 List 의 replaceAll 과 비슷한 동작을 수행한다.
replace : 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.
HashMap<String, String> favoriteMovies2 = new HashMap<>(); favoriteMovies2.replaceAll((k,v)->v.toUpperCase());
merge – 키가 중복될 때 값 병합 처리
맵을 병합할 때 putAll을 사용하면 덮어쓰기만 됩니다.
반면 merge는 중복 키에 대해 어떻게 처리할지 지정할 수 있는 BiFunction을 제공합니다.Map<String, String> family = Map.ofEntries( Map.entry("teo", "star wars"), Map.entry("christina", "james bond") //Map.entry("c", "") ); Map<String, String> friends = Map.ofEntries( Map.entry("rachel", "star wars"), Map.entry("christina", "matrix") ); HashMap<String, String> everyone = new HashMap<>(family); friends.forEach((k,v)->everyone.merge(k,v,(m1,m2)->{ System.out.println(m1+"&" + m2); return m1+ "&" + m2; }));
merge 동작 방식
- 키가 없으면 → 새 값을 그대로 추가
- 키가 있고 기존 값이 있으면 → BiFunction을 적용
- 함수가 null을 반환하면 해당 항목은 제거됨
카운팅 로직에 merge 활용할 수도 있다.
Map<String,Long> moviesToCount = new HashMap<>(); String movieName = "JamesBond"; Long count = moviesToCount.get(movieName); if(count==null){ moviesToCount.put(movieName,1L); } else { moviesToCount.put(movieName,count+1L); }
moviesToCount.merge(movieName,1L,(key,c) -> c+1L);
반응형'BackEnd > 자바' 카테고리의 다른 글
Optional 에서 flatmap 과 map (0) 2022.04.07 Optional - 모던 자바 인 액션 (0) 2022.04.04 스트림의 병렬 처리 - 모던 자바 인 액션 (0) 2022.03.20 Stream Collector 커스터마이징 - 모던 자바 인 액션 (0) 2022.03.10 스트림의 사용 - 모던 자바 인 액션 (0) 2022.03.04