on
Modern Java in Action - Ch.8
참고: 책 - Modern Java in Action
책 Modern Java in Action을 읽고 정리합니다. 이번 포스트에서는 Ch 8.1 ~ Ch 8.3의 내용을 읽고 정리합니다.
Ch 8. 컬렉션 API 개선
8.1 컬렉션 팩토리
- 8.1.1 리스트 팩토리
- 8.1.2 집합 팩토리
- 8.1.3 맵 팩토리
8.2 리스트와 집합 처리
- 8.2.1 removeIf 메서드
- 8.2.2 replaceAll 메서드
8.3 맵 처리
- 8.3.1 forEach 메서드
- 8.3.2 정렬 메서드
- 8.3.3 getOrDefault 메서드
- 8.3.4 계산 패턴
- 8.3.5 삭제 패턴
- 8.3.6 교체 패턴
- 8.3.7 합침
8.1 컬렉션 팩토리
Java 9에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공합니다.
적은 요소를 포함하는 리스트
ex. 휴가를 함께 보내려는 친구 이름을 포함하는 그룹을 만드는 예제입니다.
//Before
List<String> friends = new ArrayList<>();
friends.add("Rachel");
friends.add("Poogle");
friends.add("Suhyun");
- 위 코드를
Arrays.asList()
팩토리 메서드를 이용하면 간단하게 작성할 수 있습니다.
//After
List<String> friends = Arrays.asList("Rachel", "Poogle", "Suhyun");
//갱신 - 가능
friends.set(0, "Richard");
//UnsupportedOperationException 발생
friends.add("Solar");
- 고정 크기의 리스트를 만들었으므로 요소를 갱신할 순 있지만 새 요소를 추가하거나 요소를 삭제할 순 없습니다.
- 내부적으로 고정된 크기의 변환할 수 있는 배열로 구현되었기 때문에 요소를 추가하려 하면
UnsupportedOperationException
이 발생합니다.
8.1.1 리스트 팩토리 - List.of
List.of
팩토리 메소드를 이용해서 간단하게 리스트를 만들 수 있습니다.- 변경할 수 없는 리스트라는 제약은 컬렉션이 의도치 않게 변하는 것을 막을 수 있다는 장점이 있는데 요소 자체가 변하는 것을 막을 수 있는 방법은 없습니다.
- 리스트를 바꿔야하는 상황이라면 직접 리스트를 만들면 됩니다.
- null 요소는 금지하므로 의도치 않은 버그를 방지하고 좀 더 간결한 내부 구현을 달성했습니다.
- 어떤 상황에서 컬렉션 팩토리 메서드(
List.of
)를 스트림 API(Collectors.toList
) 대신 사용해 리스트를 만들어야 하는지 살펴보면- 데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 사용하기 간편한 팩토리 메서드를 이용할 것을 권장합니다.
- 팩토리 메서드 구현이 더 단순하고 목적을 달성하는데 충분하기 때문입니다.
오버로딩 vs 가변 인수
List 인터페이스를 살펴보면 List.of
의 다양한 오버로드 버전이 있다는 사실을 알 수 있습니다.
static <E> List<E> of(E... elements)
다중요소를 받을 수 있도록 가변인수를 사용할 수도 있는데 내부적으로 가변 인수 버전은 추가 배열을 할당해서 리스트로 감싸게 됩니다. 따라서 배열을 할당하고 초기화하며 나중에 가비지 컬렉션을 하는 비용을 지불해야 합니다. 이 때 고정된 숫자의 요소(최대 10개까지)를 API로 정의하므로 이런 비용을 제거할 수 있습니다.
List.of()
로 10개 이상의 요소를 가진 리스트를 만들게 될 때는 위와 같이 가변 인수를 이용하는 메소드가 사용됨을 알 수 있습니다. Set.of()
와 Map.of()
에서도 이와 같은 패턴이 등장함을 확인할 수 있습니다.
8.1.2 집합 팩토리
Set<String> friends = Set.of("Rachel", "Poogle", "Suhyun");
List.of
와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있습니다.- 중복된 요소를 제공해 집합을 만들려고 하면
IllegalArgumentException
이 발생합니다. 집합은 오직 고유의 요소만 포함할 수 있다는 원칙을 상기시킵니다.
8.1.3 맵 팩토리
Map<String, Integer> ageOfFriends = Map.of("Rachel", 20, "Poogle", 26, "Suhyun", 28);
- Java 9에서는 두 가지 방법으로 바꿀 수 없는 맵을 초기화할 수 있습니다.
Map.of
팩토리 메서드에 키와 값을 번갈아 제공하는 방법으로 맵을 만들 수 있습니다. - 10개 이하의 키와 값 쌍을 가진 작은 맵을 만들 때는 이 메서드가 유용합니다.
import static java.util.Map.entry;
Map<String, Integer> ageOfFriends = Map.ofEntries(entry("Rachel", 20), entry("Poogle", 26), entry("Suhyun", 28));
- 그 이상의 맵에서는
Map.Entry<K, V>
객체를 인수로 받으며 가변 인수로 구현된Map.ofEntries
팩토리 메서드를 이용하는 것이 좋습니다. - 이 메서드는 키와 값을 감쌀 추가 객체 할당을 필요로 합니다.
8.2 리스트와 집합 처리
Java 8에서는 List, Set 인터페이스에 다음과 같은 메서드를 추가했습니다.
removeIf
: 프레디케이트를 만족하는 요소를 제거합니다. List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용할 수 있습니다.replaceAll
: 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꿉니다.sort
: List 인터페이스에서 제공하는 기능으로 리스트를 정렬합니다.
이들 메서드는 호출한 컬렉션 자체를 바꿉니다. 새로운 결과를 만드는 스트림 동작과 달리 이들 메서드는 기존 컬렉션을 바꿉니다.
8.2.1 removeIf 메서드
ex. 숫자로 시작되는 참조 코드를 가진 트랜젝션을 삭제하는 예제입니다.
for (Transaction 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 객체:
next()
,hasNext()
를 이용해 소스를 질의합니다. - Collection 객체: 객체 자체가
remove()
를 호출해 요소를 삭제합니다.
- Iterator 객체:
- 결과적으로 반복자의 상태는 컬렉션의 상태와 서로 동기화 되지 않습니다. Iterator 객체를 명시적으로 사용하고 그 객체의
remove()
를 호출해야 합니다.
for (Iterator<Transaction> iterator = transactions.iterator(); iterator.hasNext(); ) {
Transaction transaction = iterator.next();
if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
iterator.remove(transaction); // transactions -> iterator
}
}
- 코드가 조금 복잡해졌는데 이 코드 패턴은 Java 8의
removeIf()
메서드로 바꿀 수 있습니다.removeIf()
는 삭제할 요소를 가리키는 프레디케이트를 인수로 받습니다.
transactions.removeIf(transaction -> Character.isDigit(transaction.getReferenceCode().charAt(0)));
8.2.2 replaceAll 메서드
referenceCodes.stream()
.map(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1))
.collect(Collectors.toList())
.forEach(System.out::println);
- List 인터페이스의 replaceAll 메서드를 이용해 리스트의 각 요소를 새로운 요소로 바꿀 수 있습니다.
- 하지만 이 코드는 새 문자열 컬렉션을 만듭니다.
for(ListIterator<String> iterator = referenceCodes.listIterator; iterator.hasNext(); ) {
String code = iterator.next();
iterator.set(Character.toUpperCase(code.charAt(0)) + code.substring(1));
}
- 다음처럼 ListIterator 객체를(요소를 바꾸는
set()
메서드를 지원) 이용하면 기존 컬렉션을 바꿀 수 있습니다. - 컬렉션 객체를 Iterator 객체와 혼용하면 반복과 컬렉션 변경이 동시에 이루어지면서 쉽게 문제를 일으킵니다.
referenceCodes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1));
- replaceAll을 이용해 간단하게 구현할 수 있습니다.
8.3 맵 처리
8.3.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");
}
Map.Entry<K, V>
반복자를 이용해 맵의 항목 집합을 반복할 수 있습니다.
ageOfFriends.forEach((friend, age) -> System.out.println(friend + " is " + age + " years old"));
- Java 8에서부터 Map 인터페이스는 BiConsumer(key 값을 인수로 받음)를 인수로 받는 forEach 메서드를 지원하므로 코드를 조금 더 간단하게 구현할 수 있습니다.
8.3.2 정렬 메서드
Entry.comparingByValue
Entry.comparingByKey
- 두 개의 새로운 유틸리티를 이용하면 맵의 항목을 값 또는 키를 기준으로 정렬할 수 있습니다.
Map<String, String> favoriteMovies = Map.ofEntries(entry("Rachel", "About Time"),
entry("Poogle", "Decision to Leave"),
entry("Suhyun", "Top Gun"));
favoriteMovies.entrySet()
.stream()
.sorted(Entry.comparingByKey())
.forEachOrdered(System.out::println);
결과
Poogle=Decision to Leave
Rachel=About Time
Suhyun=Top Gun
8.3.3 getOrDefault 메서드
기존에는 찾으려는 키가 존재하지 않으면 null이 반환되므로 NullPointerException을 방지하려면 요청 결과가 null인지 확인해야 합니다. 이 문제는 기본값을 반환하는 방식으로 해결할 수 있습니다.
getOrDefault
메서드는 첫 번째 인수로 Key를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 (key가 존재하더라도 값이 null인 상황에서는 null을 반환할 수 있음) 두 번째 인수로 받은 기본값을 반환합니다.
Map<String, String> favoriteMovies = Map.ofEntries(entry("Rachel", "About Time"),
entry("Poogle", "Decision to Leave"));
System.out.println(favoriteMovies.getOrDefault("Rachel", "Get Out")); //About Time 출력
System.out.println(favoriteMovies.getOrDefault("Suhyun", "Get Out")); //Get Out 출력
8.3.4 계산 패턴
맵에 키가 존재하는지 여부에 따라 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있습니다. (ex. 키를 이용해 값비싼 동작을 실행해서 얻은 결과를 캐시, 키가 존재하면 결과를 다시 계산할 필요가 없음) 다음의 세 가지 연산이 이런 상황에서 도움을 줍니다.
computeIfAbsent
: 제공된 키에 해당하는 값이 없으면(값이 없거나 null), 키를 이용해 새 값을 계산하고 맵에 추가computeIfPresent
: 제공된 키가 존재하면 새 값을 계산하고 맵에 추가compute
: 제공된 키로 새 값을 계산하고 맵에 저장
ex. 파일 집합의 각 행을 파싱해 SHA-256을 계산하는 예제입니다.
- 기존에 이미 데이터를 처리했다면 이 값을 다시 계산할 필요가 없습니다.
- 맵을 이용해 캐시를 구현했다고 가정하면 다음처럼 MessageDigest 인스턴스로 SHA-256 해시를 계산할 수 있습니다.
Map<String, byte[]> dataToHash = new HashMap<>();
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
//데이터 반복하면서 결과를 캐시, line: 키 & 키가 존재하지 않으면 동작 실행
lines.forEach(line -> dataToHash.computeIfAbsent(line, this::calculateDigest));
//헬퍼가 제공된 키의 해시를 계산
private byte[] calculateDigest(String key) {
return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
}
ex. Rachel에게 줄 영화 목록을 만드는 예제
String friend = "Rachel";
List<String> movies = friendsToMovies.get(friend);
if(movies == null) {
movies = new ArrayList<>();
friendsToMovies.put(friend, movies);
}
movies.add("Star Wars");
System.out.println(friendsToMovies);
computeIfAbsent
를 활용 - 키가 존재하지 않으면 값을 계산해 맵에 추가, 키가 존재하면 기존 값을 반환
friendsToMovies.computeIfAbsent("Rachel", name -> new ArrayList<>())
.add("Star Wars");
8.3.5 삭제 패턴
Java 8에서는 키가 특정한 값과 연관되었을 때만 항목을 제거하는 오버로드 버전 메서드를 제공합니다.
String key = "Rachel";
String value = "Jack Reacher 2";
if (favoriteMovies.containsKey(key) && Object.equals(favoriteMovies.get(key), value)) {
favoirteMovies.remove(key);
} else {
return false;
}
다음처럼 코드를 간결하게 구현할 수 있습니다.
favoriteMovies.remove(key, value);
8.3.6 교체 패턴
replaceAll
: BiFunction을 적용한 결과로 각 항목의 값을 교체합니다. 이 메서드는 이전에 살펴본 List의 replaceAll과 비슷한 동작을 수행합니다.Replace
: 키가 존재하면 맵의 값을 바꿉니다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있습니다.
8.3.7 합침
ex. 두 그룹의 연락처를 포함하는 두 개의 맵을 합치는 예제입니다.
Map<String, String> family = Map.ofEntries(
entry("Teo", "Star Wars"),
entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(entry("Raphael", "Star Wars"));
Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends); //friends의 모든 항목을 everyone으로 복사
System.out.println(everyone);
- 중복된 키가 없다면 위 코드는 잘 동작합니다.
- 값을 더 유연하게 합쳐야 한다면 새로운 merge 메서드를 이용할 수 있습니다. 이 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받습니다.
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"));
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)); //중복된 키가 있으면 두 값을 연결
System.out.println(everyone);
- family와 friends 두 맵 모두에 Cristina가 다른 영화 값으로 존재한다고 가정했을 때
forEach
와merge
메서드를 이용해 충돌을 해결할 수 있습니다. - JavaDoc에서 설명하는 것처럼 merge메서드는 null값과 관련된 복잡한 상황도 처리합니다.
- 지정된 키와 연관된 값이 없거나 값이 null이면 merge는 키를 null이 아닌 값과 연결합니다.
- 아니면 merge는 연결된 값을 주어진 매핑 함수의 결과 값으로 대치하거나 결과가 null이면 항목을 제거합니다.
ex. 영화를 몇 회 시청했는지 기록하는 맵 예제
Map<String, Long> moviesToCount = new HashMap<>();
String movieName = "AboutTime";
long count = moviesToCount.get(movieName);
if(count == null) {
moviesToCount.put(movieName, 1);
} else {
moviesToCount.put(movieName, count + 1);
}
- merge를 이용해 초기화 검사를 구현할 수도 있습니다. 해당 값을 증가시키기 전에 관련 영화가 이미 맵에 존재하는지 확인해야 합니다.
moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);
- merge의 두 번째 인수는 키와 연관된 기존 값에 합쳐진 null이 아닌 값 또는 값이 없거나 키에 null값이 연관되어 있다면 이 값을 키와 연결하는데 사용됩니다.
- 키의 반환값이 null이므로 처음에는 1이 사용되고 그 다음부터는 값이 1로 초기화되어 있으므로 BiFunction에 적용해 값이 증가됩니다.