스트림이란?
스트림은 Java 8 API에 추가된 기능으로써, 컬렉션 및 배열의 요소를 반복 처리하기 위해 사용할 수 있다.
예를 들어 List 컬렉션에서 요소를 반복 처리하기 위해 스트림을 사용하게 된다면 아래와 같이 사용할 수 있다.
Stream<String> stream = list.stream();
stream.forEach(item -> //아이템 처리);
List 컬렉션의 stream() 메소드로 Stream 객체를 얻은 뒤에, forEach() 메소드로 요소를 어떻게 처리할지를 람다식으로 정의하는 것을 볼 수 있다.
한가지 예시 코드를 통해 스트림을 사용하기 전과 후를 비교해보자.
Student 클래스에는 이름과 성적 멤버 변수가 있고, main 메소드 내에서 classMembers 리스트에 학급내의 학생들 정보를 넣은 뒤, 80점이 넘는 학생들의 이름 목록을 구하려고 한다.
// 80점이 넘는 고득점 학생 리스트 생성
List<Student> studentList = new ArrayList<>();
for (Student student : classMembers) {
if (student.getScore() > 80) {
studentList.add(student);
}
}
// 고득점 학생 이름 리스트 생성
List<String> highScoreStudentsName = new ArrayList<>();
for (Student student : studentList) {
highScoreStudentsName.add(student.getName());
}
일반적인 for-each문을 사용하게 된다면 위와 같이 구현할 수 있을 것이다.
이 때, 80점이 넘는 학생들을 구하기 위해 studentList라는 리스트를 만들게 되고, 여러 for문과 조건문으로 인해 코드가 길어지고 가독성이 떨어지게 된다.
반면, 위 코드를 스트림을 사용하면 아래와 같이 구현할 수 있다.
List<String> highScoreStudentsName1 = classMembers.stream()
.filter(s -> s.getScore() > 80)
.map(s -> s.getName())
.collect(Collectors.toList());
앞에서 사용했던 for문과 비교했을 때, 코드의 길이가 짧아질 뿐만 아니라 가독성도 확실히 좋아지는 것을 알 수 있다.
스트림은 Iterator와 비슷한 반복자이지만, 아래와 같은 차이점을 가지고 있다.
- 내부 반복자이므로 처리 속도가 빠르고 병렬 처리에 효율적이다.
- 중간 처리와 최종 처리를 수행하도록 파이프 라인을 형성할 수 있다.
- 람다식으로 다양한 요소 처리를 정의할 수 있다.
이제 차이점이라고 말한 각각의 내용에 대해서 알아보도록 하자.
내부 반복자
for문과 Iterator는 컬렉션의 요소를 컬렉션 바깥쪽으로 반복해서 가져와 처리하는데, 이것을 외부 반복자라고 한다.
반면 스트림은 요소 처리 방법을 컬렉션 내부로 주입시켜서 요소를 반복해서 처리하는데, 이것을 내부 반복자라고 한다.
외부 반복자일 경우에는 컬렉션의 요소를 외부로 가져오는 코드와 처리하는 코드를 모두 개발자 코드가 가지고 있어야 한다.
반면, 내부 반복자일 경우에는 개발자 코드에서 제공한 데이터 처리 코드(람다식)를 가지고 컬렉션 내부에서 요소를 반복 처리한다.
이에 대한 내용은 앞에서 학생에 대한 예시 코드를 통해 확인할 수 있었다.
그리고 내부 반복자는 멀티 코어 CPU를 최대한 활용하기 위해서 요소들을 분배시켜 병렬 작업을 할 수 있다.
따라서 하나씩 처리하는 순차적 외부 반복자보다는 효율적으로 요소를 반복시킬 수 있다는 장점이 있다.
중간 처리와 최종 처리
스트림은 여러 개가 연결될 수 있다.
아래 그림을 보면 컬렉션의 오리지널 스트림 뒤에 필터링 중간 스트림이 연결될 수 있고, 그 뒤에 매핑 중간 스트림이 연결될 수 있다.
이와 같이 스트림이 연결되어 있는 것을 스트림 파이프라인(stream pipelines)이라고 한다.
오리지널 스트림과 집계 처리 사이의 중간 스트림들은 최종 처리를 위해 요소를 걸러내거나(필터링), 요소를 변환시키거나(매핑), 정렬하는 작업을 수행한다.
최종 처리는 중간 처리에서 정제된 요소들을 반복하거나, 집계(카운팅, 총합, 평균)하는 작업을 수행한다.
스트림 활용
java.util.stream 패키지에는 스트림 인터페이스들이 있다.
BaseStream 인터페이스를 부모로 한 자식 인터페이스로는 Stream, IntStream, LongStream, DoubleStream이 있다.
그리고 BaseStream에는 모든 스트림에서 사용할 수 있는 공통 메소드들이 정의되어 있다.
Stream은 객체 요소를 처리하는 스트림이고, IntStream, LongStream, DoubleStream은 각각 기본 타입인 int, long, double 요소를 처리하는 스트림이다.
스트림 얻기
// 1. 컬렉션으로부터 스트림 얻기
List<Student> list = new ArrayList<>();
Stream<Student> stream = list.stream();
// 2. 배열로부터 스트림 얻기
String[] strArray = {"a", "b"};
Stream<String> strStream = Arrays.stream(strArray);
// 3. 숫자 범위로부터 스트림 얻기
IntStream intStream = IntStream.range(1, 10);
// 4. 파일로부터 스트림 얻기
Path path = Paths.get(MainClass.class.getResources("file.txt").toURI());
Stream<String> fileStream = Files.linses(path, Charset.defaultCharset());
스트림은 컬렉션, 배열, 숫자 범위, 파일로부터 얻을 수 있다.
java.util.Collection 인터페이스는 stream()과 parallelStream() 메소드를 가지고 있기 때문에 자식 인터페이스인 List와 Set 인터페이스를 구현한 모든 컬렉션에서 스트림 객체를 얻을 수 있다.
java.util.Arrays 클래스를 이용하면 다양한 종류의 배열로부터 스트림을 얻을 수 있다.
IntStream 또는 LongStream의 정적 메소드인 range()와 rangeClosed() 메소드를 이용하면 특정 범위의 정수 스트림을 얻을 수 있다.
첫 번째 매개변수 값은 시작 수이고 두번째 매개변수 값은 끝 수이다. 끝 수를 포함한다면 rangeClosed()를, 포함하지 않는다면 range() 메소드를 사용한다.
java.nio.file.Files의 lines() 메소드를 이용하면 텍스트 파일의 행 단위 스트림을 얻을 수 있다.
텍스트 파일에서 한 행씩 읽고 처리할 때 유용하게 사용할 수 있다.
중간 처리 기능
중간 처리에는 여러 기능이 있다.
- 요소 걸러내기(필터링)
- 요소 변환하기(매핑)
- 요소 정렬하기
List<String> list = new ArrayList<>();
list.add("AAA");
list.add("BBB");
list.add("CCC");
list.add("AAA");
// 중복 요소 제거
list.stream()
.distinct()
.forEach(s -> System.out.println(s));
System.out.println();
// A로 시작하는 요소만 필터링
list.stream()
.filter(s -> s.startsWith("A"))
.forEach(s -> System.out.println(s));
필터링 기능을 하는 메소드에는 distinct(), filter()가 있다.
distinct() 메소드는 요소의 중복을 제거한다.
만약 객체 스트림일 경우에는 equals() 메소드의 리턴값이 true이면 동일한 요소로 판단하고, Int/Long/DoubleStream은 같은 값일 경우 중복을 제거한다.
filter() 메소드는 매개값으로 주어진 Predicate가 true를 리턴하는 요소만 필터링한다.
// List 컬렉션 생성
List<Student> list = new ArrayList<>();
list.add(new Student("A", 50));
list.add(new Student("B", 30));
list.add(new Student("C", 90));
// Student 스트림을 score 스트림으로 변환
list.stream()
.mapToInt(s -> s.getScore())
.forEach(score -> System.out.println(score));
System.out.println();
// 배열 생성
int[] intArray = {1, 2, 3};
// 정수 스트림을 실수 스트림으로 변환
IntStream intStream = Arrays.stream(intArray);
intStream
.asDoubleStream()
.forEach(d -> System.out.println(d));
System.out.println();
// 기본 타입 int 스트림을 래퍼 타입 Integer로 변환
intStream = Arrays.stream(intArray);
intStream
.boxed()
.forEach(obj -> System.out.println(obj.intValue()));
System.out.println();
// 문장 스트림을 단어 스트림으로 변환
List<String> list1 = new ArrayList<>();
list1.add("java stream test");
list1.stream()
.flatMap(data -> Arrays.stream(data.split(" ")))
.forEach(word -> System.out.println(word));
요소 변환을 하는 매핑(mapping)은 스트림의 요소를 다른 요소로 변화시킨다.
매핑 메소드는 map(), mapToInt(), asDoubleStream(), boxed(), flatMap()등이 있다.
map(), mapToInt/Long/Double/Obj() 메소드는 요소를 다른 요소로 변환한 새로운 스트림을 리턴한다.
위 코드에서는 Student 스트림을 mapToInt() 메소드를 사용해서 score 스트림으로 변환했다.
기본 타입 간의 변환이거나 기본 타입 요소를 래퍼 객체 요소로 변환할 때는 asLong/DoubleStream() 메소드, boxed() 메소드를 사용한다.
위 코드에서는 정수 스트림을 실수 스트림으로 변환하고, int 스트림을 Integer 스트림으로 변환했다.
flatMap(), flatToInt/Long/Double() 메소드는 하나의 요소를 복수 개의 요소들로 변환한 새로운 스트림을 리턴한다.
위 코드에서는 문장 스트림을 단어 스트림으로 변환했다.
// List 컬렉션 생성
List<Student> list = new ArrayList<>();
list.add(new Student("A", 50));
list.add(new Student("B", 30));
list.add(new Student("C", 90));
// 점수를 기준으로 오름차순 정렬한 스트림
list.stream()
.sorted()
.forEach(s -> System.out.println(s.getName() + ": " + s.getScore()));
System.out.println();
// 점수를 기준으로 내림차순 정렬한 스트림
list.stream()
.sorted(Comparator.reverseOrder())
.forEach(s -> System.out.println(s.getName() + ": " + s.getScore()));
System.out.println();
// 점수를 기준으로 내림차순 정렬한 스트림
list.stream()
.sorted((s1, s2) -> Integer.compare(s2.getScore(), s1.getScore()))
.forEach(s -> System.out.println(s.getName() + ": " + s.getScore()));
요소를 정렬하는 메소드는 sorted()이다.
스트림의 요소가 객체일 경우 객체가 Comparable을 구현하고 있거나 sorted() 메소드 내에 비교자를 제공해서 비교에 대한 기준을 줘야 한다.
위 코드에서 1, 2번째 경우에는 Student 클래스 내에서 score를 기준으로 오름차순 정렬하는 compareTo() 메소드를 오버라이딩했다.
3번째 경우에는 sorted() 메소드 내에서 비교자를 제공해 내림차순으로 정렬한 모습을 볼 수 있다.
최종 처리 기능
최종 처리에는 여러 기능이 있다.
- 요소 조건 만족 여부 확인(매칭)
- 요소 기본 집계
- 요소 커스텀 집계
- 요소 수집
int[] intArray = {1, 5, 10};
boolean result = Arrays.stream(intArray)
.allMatch(i -> i % 5 == 0);
System.out.println("모두 5의 배수이다.: " + result);
result = Arrays.stream(intArray)
.anyMatch(i -> i % 5 == 0);
System.out.println("하나라도 5의 배수가 있다.: " + result);
result = Arrays.stream(intArray)
.noneMatch(i -> i % 5 == 0);
System.out.println("5의 배수가 없다.: " + result);
요소들이 특정 조건에 만족하는지 여부를 조사하는 매칭과 관련된 메소드로는 allMatch(), anyMatch(), noneMatch()가 있다.
allMatch()는 모든 요소의 Predicate가 true를 리턴해야만 true를 리턴한다
anyMatch()는 최소한 하나의 요소의 Predicate가 true를 리턴하면 true를 리턴한다.
noneMatch()는 모든 요소의 Predicate가 false를 리턴하면 true를 리턴한다.
요소 기본 집계를 하는 메소드로는 count(), findFirst(), max(), min(), average(), sum()이 있다.
메소드 명을 보면 알 수 있듯이, count()는 요소 개수를, findFirst()는 첫번째 요소를, max()/min() 메소드는 최대/최소값을, average() 메소드는 평균값을, sum() 메소드는 합계를 리턴한다.
위 메소드들은 대량의 데이터를 가공해서 하나의 값으로 축소하는 리덕션(Reduction)이라고도 볼 수 있다.
위 메소드 중 findFirst(), max(), min(), average()는 Optional 클래스 타입을 리턴하는데, 이것은 최종값을 저장하는 객체로써 집계 후에 get(), getAsDouble() 등을 호출하면 최종값을 얻을 수 있다.
요소 기본 집계 메소드외에도 직접 커스텀해서 다양한 집계 결과를 만들 수 있도록 하는 reduce() 메소드가 있다.
reduce() 는 스트림에 요소가 없을 경우 예외가 발생하지만, identity 매개변수 값이 주어지면 이 값을 디폴트 값으로 리턴한다.
int sum = stream
.reduce((a, b) -> a + b)
.getAsInt();
int sum = stream
.reduce(0, (a, b) -> a + b);
위의 코드에서 스트림에 요소가 없을 경우, 첫번째 경우에는 NoSuchElementException이 발생하지만, 두번째 경우에는 디폴트 값인 0을 리턴한다.
요소들을 필터링 또는 매핑한 후에 요소들을 수집하는 메소드로는 collect() 메소드가 있다.
이 메소드를 사용하면 필요한 요소만 컬렉션에 담을 수 있고, 요소들을 그룹핑한 후에 집계도 할 수 있다.
List<String> highScoreStudentsName1 = classMembers.stream()
.filter(s -> s.getScore() > 80)
.map(s -> s.getName())
.collect(Collectors.toList());
collect() 메소드의 매개변수에는 Collector 클래스 타입이 들어갈 수 있는데, 이는 어떤 요소를 어떤 컬렉션에 수집할 것인지를 결정한다.
Collector 클래스의 정적 메소드로 구현돼있는 toList(), toSet(), toMap()를 통해 Collector 구현 객체를 얻을 수 있다.
collect() 메소드는 단순히 요소를 수집하는 기능 이외에 컬렉션의 요소들을 그룹핑해서 Map 객체를 생성하는 기능도 제공한다.
Map<String, List<Student>> map = list.stream()
.collect(Collectors.groupingBy(s -> s.getClass()));
groupingBy() 메소드는 제네릭 메소드이고, Function을 이용해서 T를 K로 매핑하고, K를 키로 가지면서 List<T>를 값으로 갖는 Map 컬렉션을 생성한다.
예시 코드에서는 학생의 클래스를 키로 가지고 학생을 값으로 갖도록 하도록 했다.
groupingBy() 메소드는 그룹핑 후에 매핑 및 집계를 수행할 수 있도록 두번째 매개변수 값인 Collector를 가질 수 있다.
이 때 Collector를 얻기 위해서 Collector의 정적 메소드인 mapping(), averagingDouble(), counting(), max/minBy(), reducing()를 사용할 수 있다.
Reference
- 이것이 자바다 | 신용권, 임경균 지음
댓글