저번 포스팅까지는 자바 스트림에 대한 사용, 활용, 병렬 스트림에 대해 알아보았다. 항상 느끼는 점이지만 프로그래밍 언어는 하나를 완벽히 안다고 자부하여도 모르는 것이 태반인 경우가 많은 것 같다. 계속 공부하고 찾아보면 새로운 것들이 많다..! 그래서 개발자는 끊임없이 공부를 해야하는 것 같다.
다른 이야기는 뒤로하고 이번에는 자바 스트림의 마지막 글이 될 것 같다. 저번에 이야기 하였듯이 커스텀 컬렉터, 예외처리에 대해서 알아보려고 한다.
Java 스트림 API의 기존 컬렉터는 다양한 데이터 집계와 변환 작업을 수행하기 위한 도구로는 매우 유용하다. 그러나 특정 시나리오에서는 이러한 기본 제공 컬렉터들로는 충분하지 않거나 비효율적일 수 있다. 따라서 이러한 한계점을 극복하기 위해 커스텀 컬렉터를 사용하는 것이 좋다. 다음은 기존 컬렉터의 몇 가지 한계점과 커스텀 컬렉터가 필요한 상황에 대해 알아보자.
복잡한 데이터 구조에 대한 수집 작업
기본 제공 컬렉터는 리스트, 맵, 세트 등 일반적인 데이터 구조로의 수집 작업을 지원한다. 그러나 더 복잡한 데이터 구조로 데이터를 수집하고자 할 때는 기본 컬렉터만으로는 충분하지 않을 수 있다. 예를 들어, 트리 구조나 그래프 구조로 데이터를 수집하려는 경우, 커스텀 컬렉터를 통해 이러한 복잡한 데이터 구조를 생성하고 관리하는 로직을 구현할 수 있다.
성능 최적화
기존 컬렉터는 일반적인 사용 사례를 대상으로 설계되어 있어 특정 상황에서 최적의 성능을 보장하지 않을 수 있다. 특히, 대규모 데이터 집합을 처리할 때 불필요한 중간 데이터 구조 생성이나 알고리즘의 비효율성으로 인해 성능 저하가 발생할 수 있다. 그러므로, 커스텀 컬렉터를 사용하면 특정 작업에 최적화된 데이터 수집 로직을 구현하여 성능을 개선할 수 있다.
특정 비즈니스 로직의 적용
비즈니스 요구 사항에 따라 데이터를 수집하는 과정에 특정 로직을 적용해야 할 수 있다. 예를 들어, 데이터 수집 과정에서 특정 조건에 따라 데이터를 필터링하거나 변환해야 하는 경우, 이러한 로직을 커스텀 컬렉터 내에 통합할 수 있다. 이를 통해 스트림 파이프라인의 가독성을 유지하면서도 복잡한 처리를 수행할 수 있다.
병렬 처리의 최적화
Java 스트림은 저번에 알아봤듯이 병렬 처리를 쉽게 구현할 수 있도록 지원하지만, 모든 상황에서 자동으로 최적의 병렬 처리 성능을 제공하는 것은 아니다. 커스텀 컬렉터를 사용하면 데이터 분할, 병합 과정을 더 세밀하게 제어하며 특정 사용 사례에 맞는 병렬 처리 전략을 구현할 수 있다.
기존 컬렉터의 이러한 한계점들을 고려할 때, 커스텀 컬렉터는 더 세밀한 데이터 처리 제어, 성능 최적화, 특정 비즈니스 로직의 적용 및 병렬 처리의 최적화를 위해 매우 유용하다. 따라서 복잡하고 특수한 데이터 처리 요구 사항을 가진 경우, 커스텀 컬렉터의 구현을 고려해야 한다.
커스텀 컬렉터를 생성하려면 Collector 인터페이스를 구현해야 한다. Collector 인터페이스에는 다음과 같은 메서드가 있다.
Supplier<A> supplier() : 새로운 빈 컬렉션을 생성하는 함수이다.BiConsumer<A, T> accumulator() : 요소를 컬렉션에 추가하는 함수이다.BinaryOperator<A> combiner() : 두 컬렉션을 결합하는 함수이다. 병렬 스트림에서 사용된다.Function<A, R> finisher() : 최종 결과를 반환하는 함수이다.Set<Characteristics> characteristics() : 컬렉터의 특성을 반환하는 함수이다.커스텀 컬렉터를 작성할 때는 위의 메서드들을 적절하게 구현해야 한다. 예를 들어, 요소를 어떻게 컬렉션에 추가할 것인지, 병렬 처리 시 어떻게 컬렉션을 결합할 것인지 등을 고려하여 메서드를 사용해야 한다.
커스텀 컬렉터를 사용하는 예시는 아래를 확인하자.
import java.util.*;
import java.util.stream.Collector;
import java.util.stream.Collectors;
public class CustomCollectorExample {
public static void main(String[] args) {
List<String> strings = Arrays.asList("apple", "banana", "orange", "grape", "peach");
// 커스텀 컬렉터를 사용하여 문자열의 길이로 맵을 생성하는 예시
Map<Integer, List<String>> lengthMap = strings.stream()
.collect(new StringLengthCollector());
System.out.println(lengthMap);
}
// 커스텀 컬렉터 구현
static class StringLengthCollector implements Collector<String, Map<Integer, List<String>>, Map<Integer, List<String>>> {
@Override
public Supplier<Map<Integer, List<String>>> supplier() {
return HashMap::new;
}
@Override
public BiConsumer<Map<Integer, List<String>>, String> accumulator() {
return (map, str) -> map.computeIfAbsent(str.length(), key -> new ArrayList<>()).add(str);
}
@Override
public BinaryOperator<Map<Integer, List<String>>> combiner() {
return (map1, map2) -> {
map2.forEach((key, value) -> map1.merge(key, value, (list1, list2) -> {
list1.addAll(list2);
return list1;
}));
return map1;
};
}
@Override
public Function<Map<Integer, List<String>>, Map<Integer, List<String>>> finisher() {
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
return Collections.emptySet();
}
}
}
위의 예시는 커스텀 컬렉터를 사용하여 문자열의 길이에 따라 그룹화된 맵을 생성하는 방법을 보여준다. 그렇다면 코드를 하나하나 살펴보자.
스트림 생성: List<String> 타입의 문자열 리스트를 생성한다.
List<String> strings = Arrays.asList("apple", "banana", "orange", "grape", "peach");
커스텀 컬렉터 구현: Collector 인터페이스를 구현하여 커스텀 컬렉터를 정의한다. 이 예시에서는 문자열의 길이를 키로 갖고 해당 길이에 속하는 문자열을 값으로 갖는 Map<Integer, List<String>>을 생성한다.
static class StringLengthCollector implements Collector<String, Map<Integer, List<String>>, Map<Integer, List<String>>> {
// 메서드 구현
}
supplier() 메서드: 빈 맵을 생성하는 함수를 정의한다.
@Override
public Supplier<Map<Integer, List<String>>> supplier() {
return HashMap::new;
}
accumulator() 메서드: 요소를 맵에 추가하는 함수를 정의한다. 이 예시에서는 문자열의 길이를 키로 하여 해당 길이에 속하는 리스트에 문자열을 추가한다.
@Override
public BiConsumer<Map<Integer, List<String>>, String> accumulator() {
return (map, str) -> map.computeIfAbsent(str.length(), key -> new ArrayList<>()).add(str);
}
combiner() 메서드: 병렬 처리 시에 사용될 두 맵을 결합하는 함수를 정의한다. 여기서는 두 맵의 키-값 쌍을 결합하여 하나의 맵으로 합친다.
@Override
public BinaryOperator<Map<Integer, List<String>>> combiner() {
return (map1, map2) -> {
map2.forEach((key, value) -> map1.merge(key, value, (list1, list2) -> {
list1.addAll(list2);
return list1;
}));
return map1;
};
}
finisher() 메서드: 최종 결과를 반환하는 함수를 정의한다. 이 예시에서는 추가적인 처리가 필요하지 않으므로 항등 함수를 반환한다.
@Override
public Function<Map<Integer, List<String>>, Map<Integer, List<String>>> finisher() {
return Function.identity();
}
characteristics() 메서드: 컬렉터의 특성을 정의한다. 이 예시에서는 병렬 처리에 관련된 특성을 가지고 있지 않으므로 빈 집합을 반환힌다.
@Override
public Set<Characteristics> characteristics() {
return Collections.emptySet();
}
커스텀 컬렉터 사용: collect() 메서드를 사용하여 커스텀 컬렉터를 적용하여 문자열 리스트를 맵으로 변환한다.
Map<Integer, List<String>> lengthMap = strings.stream().collect(new StringLengthCollector());
이러한 과정을 통해 문자열의 길이에 따라 그룹화된 맵을 생성할 수 있다. 이 예시는 자바 스트림에서 커스텀 컬렉터를 작성하고 사용하는 방법을 보여준다.
자바 스트림에서 예외 처리는 일반적으로 스트림 파이프라인 내에서 발생하는 예외를 처리하는 방법을 의미한다. 스트림은 함수형 프로그래밍의 특성을 갖고 있기 때문에 예외 처리도 함수형 스타일로 다루어진다.
try-catch 블록 사용: 스트림 파이프라인 내에서 예외를 처리하기 위해 각 단계에서 기존의 예외 처리 방법과 같은 try-catch 블록을 사용할 수 있다.
List<String> list = Arrays.asList("1", "2", "three", "4", "5");
List<Integer> result = list.stream()
.map(s -> {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return null; // 또는 다른 기본값이나 예외 처리 로직
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
Optional 사용: 스트림의 map() 메서드와 flatMap() 메서드를 이용하여 각 요소를 Optional로 래핑하고, 필요한 경우 예외 처리를 수행할 수 있다.
List<String> list = Arrays.asList("1", "2", "three", "4", "5");
List<Integer> result = list.stream()
.map(s -> {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
})
.flatMap(Optional::stream)
.collect(Collectors.toList());
예외를 던지고 처리하는 함수 사용: 예외가 발생할 수 있는 메서드를 포함하는 함수를 정의하고, 해당 함수를 스트림 파이프라인에서 사용할 수 있다. 이 경우 메서드를 정의할 때 throws 절을 사용하여 예외를 던질 수 있다. 자세한 내용은 자바 예외 처리에 대한 포스팅 글을 살펴보자.
// 예외를 던질 수 있는 함수 정의
public static Integer parseInteger(String s) throws NumberFormatException {
return Integer.parseInt(s);
}
List<String> list = Arrays.asList("1", "2", "three", "4", "5");
List<Integer> result = list.stream()
.map(s -> {
try {
return parseInteger(s);
} catch (NumberFormatException e) {
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
스트림에서 예외 처리를 하는 방법은 위의 세 가지 방법 외에도 다양할 수 있다. 방법은 다양하지만 이러한 방법을 어떤 예외를 처리 할 것인지가 더욱 중요하다는 것을 잊지말자.
이렇게 자바 스트림에 대한 포스팅이 완료 되었다. 기본적인 내용부터 고급(?)의 내용까지 다루려 했는데 빠진 내용이 분명 있을 것이라 생각된다. 그 부분에 대해서는 많이 사용하지 않은 것들이지만 가끔씩 알아야하는 내용이 있을 수 있기 때문에 그것은 구글링..을 통해 알아보자! 스트림에 대해 평소에 중요하게 생각하지 않았지만 사용하면 굉장히 편리해서 언젠가 한번 정리해봐야지 하는 생각을 하곤 했다. 이렇게 정리하고 공부하니 스트림을 앞으로 더욱더 많이 사용하지 않을까 싶다. 다음 포스팅은 아마 자바의 리플렉션과 어노테이션에 대해 설명하지 않을까 싶다. 궁금한 분들은 꼭 확인하시길!