먼저 내부 클래스와 람다식에 대한 이해가 필요한 내용입니다.
목차
<스트림이란?>
자료가 모여있는 배열이나 컬렉션 또는 특정 범위 안에 있는 일련의 숫자를 처리하는 기능이 미리 구현되어 있다면 프로그램의 코드가 훨씬 간결해지고, 일관성 있게 다룰 수 있을 것이다.
배열 요소를 특정 기준에 따라 정렬(sorting)하거나, 요소 중 특정 값은 제외하고 출력하는(filter) 기능처럼 여러 자료의 처리에 대한 기능을 구현해 놓은 클래스가 '스트림(stream)'이다.
※ 입출력의 I/O 스트림과는 다른 개념이다.
스트림을 활용하면 배열, 컬렉션 등의 자료를 일관성있게 처리할 수 있다.
자료에 따라 기능을 각각 새로 구현하는 것이 아닌, 처리해야 하는 자료가 무엇인지와 상관 없이 같은 방식으로 메서드를 호출할 수 있기 때문이다.
다른 말로는 '자료를 추상화했다'고 표현한다.
배열을 예로 보면 아래 코드는 정수 5개를 요소로 가진 배열이고, 이를 모두 출력하는 출력문이다.
int[] arr = {1, 2, 3, 4, 5};
for(int i = 0; i < arr.length; i++){
System.out.println(arr[i]);
}
이 배열에 대한 스트림을 생성하여 출력하면 다음과 같다.
int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr).forEach(n->System.out.println(n));
스트림 생성부분 / 요소를 하나씩 꺼내어 출력하는 기능
스트림을 생성하고 미리 구현되어 있는 forEach() 메서드(최종 연산)를 사용해 배열의 요소를 하나씩 꺼내어 추력할 수 있다. 그러면 스트림에 미리 구현되어 있는 연산 기능은 무엇이 있는지 알아보자.
<스트림 연산>
스트림 연산의 종류에는 크게 '중간 연산'과 '최종 연산' 두 가지.
- '중간 연산'은 자료를 거르거나 변경하여 또 다른 자료를 내부적으로 생성한다.
- '최종 연산'은 생성된 내부 자료를 소모하여 연산을 수행한다. 따라서 '최종 연산'은 마지막에 한 번만 호출된다.
- 최종 연산이 호출되어야 중간 연산의 결과가 만들어진다.
중간 연산과 최종 연산에는 여러 종류가 있지만, 그중에서 많이 사용하는 연산 위주로 설명하겠다.
중간 연산
filter()
: 'filter()'는 조건을 넣고 그 조건에 참인 경우만 추출하는 경우에 쓴다.
문자열 배열이 있을 때 문자열의 길이가 5 이상인 경우만 출력하는 코드는 다음과 같다.
sList.stream().filter(s -> s.length() >= 5).forEach(s -> System.out.println(s));
스트림 생성 / 중간 연산 / 최종 연산
map()
: 'map()'은 클래스가 가진 자료 중 이름만 출력하는 경우에 사용.
예를 들어 고객 클래스가 있다면 고객 이름만 가져와서 출력할 수 있다. map()은 요소들을 순회하여 다른 형식으로 변환하기도 한다.
map()을 사용하는 예는 다음과 같다.
customerList.stream().map(c -> c.getName()).forEach(s -> System.out.println(s));
스트림 생성 / 중간 연산 / 최종 연산
filter()와 map() 둘 다 함수를 수행하면서 해당 조건이나 함수에 맞는 결과를 추출해 내는 중간 역할을 한다.
그리고 최종 연산으로 중간 연산결과를 출력한다.
최종 연산
최종 연산은 스트림의 자료를 소모하면서 연산을 수행하기 때문에 최종 연산이 수행되고 나면 해당 스트림은 더 이상 사용할 수 없다. 최종 연산은 결과를 만드는 데 주로 사용한다.
forEach()
: forEach()는 앞서 확인했듯 요소를 하나씩 꺼내는 기능을 한다.
count()
: count()는 통계용으로 사용되며, 배열 요소의 개수를 출력하는 연산을 수행한다.
sum()
: sum()은 배열 요소의 합계를 구하는 연산을 수행한다.
reduce()
: reduce() 연산은 내부적으로 스트림의 요소를 하나씩 소모하며 작성자가 직접 지정한 기능을 수행한다.
또는, reduce()가 호출하는 BinaryOperator 인터페이스의 apply() 메서드를 구현하여 대입한다.
<스트림 사용하기>
정수 배열에 스트림 사용
스트림을 활용해 정수 배열에 대한 개수와 합을 출력해보자.
package stream;
import java.util.Arrays;
public class IntArrayTest {
public static void main(String[] args) {
int[] arr = {1,2,3,4,5};
//sum()연산으로 arr 배열에 저장된 값을 모두 더함
int sumVal = Arrays.stream(arr).sum();
//count()연산으로 arr 배열의 요소 개수를 반환
//count() 메서드의 반환 값이 long이므로 int형으로 반환
int count = (int)Arrays.stream(arr).count();
System.out.println(sumVal);
System.out.println(count);
}
}
출력 결과를 보면 배열의 합과 개수가 계산되는 것을 알 수 있다.
count()메서드의 반환 값이 long형이므로 int형으로 형 변환했다.
count(), sum() 이외에 max(), min(), average()등 통계 연산을 위한 메서드도 제공한다.
Collection에 스트림 사용
Collection 인터페이스를 구현한 클래스 중 가장 많이 사용하는 ArrayList에 스트림을 생성하고 활용해보자.
아래와 같이 문자열을 요소로 가지는 ArrayList가 있다.
List<String> sList = new ArrayList<String>();
sList.add("Tomas");
sList.add("James");
sList.add("Edward");
이 ArrayList의 스트림을 생성하여 출력하고, 정렬하는 예를 살펴보자.
Collection 인터페이스의 메서드를 살펴보면 다음과 같은 메서드가 있다.
메서드 | 설명 |
Stream<E> stream() | 스트림 클래스를 반환한다. |
Collection에서 stream()메서드를 사용하면 이 클래스는 제네릭형을 사용해 다음과 같이 자료형을 명시할 수 있다.
Stream<String> stream = sList.stream();
이렇게 생성된 스트림은 내부적으로 ArrayList의 모든 요소를 가지고 있다.
각 요소를 하나씩 출력하는 기능을 구현해보자. 모든 요소를 하나씩 가져와서 처리할 때 스트림의 forEach() 메서드를 활용한다.
Stream<String> stream = sList.stream();
stream.forEach(s -> System.out.println(s));
forEach() 메서드는 내부적으로 반복문이 수행된다.
forEach() 괄호 안에 구현되는 람다식의 의미는 forEach() 메서드가 수행되면 요소가 하나씩 차례로 변수s에 대입되고 이를 매개변수로 받아 출력문이 호출된다.
이번에는 ArrayList에 젖아된 이름을 정렬하여 그 결과를 출력해 보자.
앞에서 stream변수에 스트림을 생성했지만 forEach()메서드가 수행되면서 자료가 소모되었다.
따라서 스트림을 새로 생성해야한다.
Stream<String> stream2 = sList.stream();
stream2.sorted().forEach(s -> System.out.println(s));
여기서는 중간 연산으로 정렬을 위한 sorted() 메서드를 호출하고, 최종 연산으로 출력을 위해 forEach()메서드를 사용했다.
sorted() 메서드를 사용하려면 정렬 방식에 대한 정의가 필요하다. 따라서 사용하는 자료 클래스가 Comprable 인터페이스를 구현해야 한다. 만약 구현되어 있지 않다면 sorted() 메서드의 매개 변수로 Comparator 인터페이스를 구현한 클래스를 지정할 수 있다.
ArrayList 이외에 다른 Collection의 자료도 같은 방식으로 정렬하고 출력할 수 있다.
이것이 스트림을 사용할 때의 장점이라고 할 수 있다.
정리한 코드는 아래와 같다.
package stream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class ArrayListStreamTest {
public static void main(String[] args) {
List<String> sList = new ArrayList<>();
sList.add("Tomas");
sList.add("James");
sList.add("Edward");
sList.add("Jack");
sList.add("Leonard");
sList.add("Amanda");
sList.add("Aaron");
sList.add("Andy");
//스트림 생성
Stream<String> stream = sList.stream();
//배열의 요소를 하나씩 출력
stream.forEach(s -> System.out.print(s+" "));
System.out.println();
/*스트림 생성
Stream<String> stream2 = sList.stream(); 도 있지만, */
// 스트림 생성, 정렬, 요소를 하나씩 꺼내 출력
sList.stream().sorted().forEach(s -> System.out.print(s+" "));
}
}
<스트림의 특징>
스트림의 특징을 정리하면,
자료의 대상과 관계없이 동일한 연산을 수행.
배열이나 컬렉션에 저장된 자료를 가지고 수행할 수 있는 연산은 여러가지가 있다.
배열에 저장된 요소 값을 출력한다든지(forEach()), 조건에 따라 자료를 추출하거나(filter()), 자료가 숫자일 때 합계(sum(), 평균(average()) 등을 구할 수도 있다.
스트림은 컬렉션의 여러 자료 구조에 대해 이러한 작업을 일관성 있게 처리할 수 있는 메서드를 제공한다.
한 번 생성하고 사용한 스트림은 재사용 불가.
어떤 자료에 대한 스트림을 생성하고 이 스트림에 메서드를 호출하여 연산을 수행했다면 해당 스트림을 다시 다른 연산에 사용할 수 없다.
예를 들어 스트림을 생성하여 배열에 있는 요소를 출력하기 위해 각 요소들을 하나씩 순회하면서 출력에 사용하는데, 이때 요소들이 '소모된다'고 말한다. 소모된 요소는 재사용할 수 없다.
만약 다른 기능을 호출하려면 스트림을 새로 생성해야 한다.
스트림의 연산은 기존 자료를 변경하지 않는다.
스트림을 생성하여 정렬하거나 합을 구하는 등 여러 연산을 수행한다고 해서 기존 배열이나 컬렉션이 변경되지 않는다.
스트림 연산을 위해 사용하는 메모리 공간이 별도로 존재하기 때문에 스트림의 여러 메서드를 호출하더라도 기존 자료에는 영향이 없다.
스트림의 연산은 중간 연산과 최종 연산이 있다.
스트림에서 사용하는 메서드는 크게 '중간 연산'과 '최종 연산' 두 가지로 나뉜다.
스트림에 중간 연산은 여러 개가 적용될 수 있고, 최종 연산은 맨 마지막에 한 번 적용된다. 만약 중간 연산이 여러개 호출되었더라도 최종 연산이 호출되어야 스트림의 중간 연산이 모두 적용된다.
예를 들어 자료를 정렬하거나 검색하는 중간 연산이 호출되어도 최종 연산이 호출되지 않으면 정렬이나 검색한 결과를 가져올 수 없다.
이를 '지연 연산(lazy evaluation)'이라고 한다.
<reduce() 연산>
지금까지의 스트림 연사은 기능이 미리 정해져 있었다.
'reduce()연산'은 내부적으로 스트림의 요소를 하나씩 소모하며 프로그래머가 직접 지정한 기능을 수행하는 연산이다.
JDK에서 제공하는 reduce() 메서드의 정의는 다음과 같다.
T reduce(T identity, BinaryOperator<T> accumulator);
- 첫 번째 매개변수 T identity는 초깃값을 의미.
- 두 번째 매개변수 BinaryOperator<T> accumulator는 수행해야 할 기능입니다.
- BinaryOperator 인터페이스는 두 매개변수로 각 요소가 수행해야 할 기능을 람다식으로 구현한다.
이때 BinaryOperator 인터페이스를 구현한 람다식을 직접 써도 되고, 람다식이 길면 인터페이스를 구현한 클래스를 생성해 대입해도 된다. - 또한 BinaryOperator는 함수형 인터페이스로 apply() 메서드를 반드시 구현해야 한다.
apply() 메서드는 두 개의 매개변수와 한 개의 반환 값을 가지는데, 세 개 모두 같은 자료형이다. - reduce() 메서드가 호출될 때 BinaryOperator의 apply()메서드가 호출된다.
reduce() 메서드를 사용해 모든 요소의 합을 구할 때, 두 번째 매개변수에 람다식을 직접 쓰는 경우는 다음과 같다.
Arrays.stream(arr).reduce(0, (a, b) -> a + b);
- 초깃값은 0 이다.
- 스트림 요소가 매개변수(a와 b)로 전달되면서 합을 구한다.
내부적으로는 반복문이 호출되면서 람다식에 해당하는 부분이 리스트 요소만큼 호출되는 것이다. 따라서 reduce()메서드에 어떤 람다식이 전달되느냐에 따라 다양한 연산을 수행할 수 있다.
reduce()는 처음부터 마지막까지 모든 요소를 소모하면서 람다식을 반복해서 수행하므로 최종 연산이다.
배열에 여러 문자열이 있을 때 그중 길이가 가장 긴 문자열을 찾는 예제를 따라하면서 reduce()메서드 사용법을 살펴본다.
두 번째 매개변수에 람다식을 직접 쓰는 경우와
BinaryOperator인터페이스를 구현한 클래스를 사용하는 경우 두 가지를 살펴보자.
package stream;
import java.util.Arrays;
import java.util.function.BinaryOperator;
//BinaryOperator를 구현한 클래스 정의
class CompareString implements BinaryOperator<String>{
//reduce() 메서드가 호출될 때 불리는 메서드, 두 문자열 길이를 비교하자.
@Override
public String apply(String s1, String s2) {
if(s1.getBytes().length >= s2.getBytes().length)
return s1;
else return s2;
}
}
public class ReduceTest {
public static void main(String[] args) {
String[] greeting = {"안녕하세요", "Hello", "Good Morning", "반갑습니다~!"};
//람다식을 직접 구현하는 방법
System.out.println(Arrays.stream(greeting).reduce("", (s1, s2)->{
if(s1.getBytes().length >= s2.getBytes().length)
return s1;
else return s2; }));
//BinaryOperator를 구현한 클래스 사용
String str = Arrays.stream(greeting).reduce(new CompareString()).get();
System.out.println(str);
}
}
- reduce()메서드 내에 직접 람다식을 구현했다.
이 부분의 람다식을 보면 문자열을 비교하여 바이트 수가 더 긴 문자열을 반환한다.
내부적으로 람다식 부분이 요소 개수만큼 반복 호출되고 결과적으로 가장 긴 문자열을 반환한다. - 구현하는 람다식이 너무 긴 경우에는 클래스에 BinaryOperator 인터페이스를 구현하고
reduce()메서드에 해당 클래스로 생성한 인스턴스를 매개변수로 전달하면 여기에 구현된 apply() 메서드가 자동으로 호출된다.
람다식으로 구현된 부분도 익명 클래스의 인스턴스가 생성되는 것이므로 내부적으로는 동일한 구조라 할 수 있다.
예제는 간단하지만 그 내부에 많은 코드가 숨겨져 있어서 이해하는데 쉽지 않을수 있으니 반복적인 학습이 필요하다.
<스트림을 활용한 프로그램 구현>
패키지 여행을 떠나는 고객들이 있다.
여행 비용은 15세 이상 100만원, 그 미만은 50만원이다.
고객 3명이 패키지 여행을 간다고 했을 때 비용 계산과 고객 명단 검색등을 스트림을 활용하여 구현해 보겠다.
우선 고객 클래스를 정의한다. 고객 클래스는 이름, 나이, 비용을 멤버 변수로 가지며, 멤버 변수에 대한 get() 메서드만 제공한다.
package stream;
public class TrevelCustomer {
private String name;//고객 이름
private int age; //나이
private int price; //가격
public TrevelCustomer(String name, int age, int price) {
this.name = name;
this.age = age;
this.price = price;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public int getPrice() {
return price;
}
public String toString() {
return "name: " + name + "age: "+ age +"price: "+ price ;
}
}
세 명의 고객을 ArrayList에 추가하고 이에 대한 스트림을 생성하여 다음 연산을 수행해보자.
[예제 시나리오] |
1. 고객의 명단을 출력한다. 2. 여행의 총 비용을 계산한다. 3. 고객 중 20세 이상인 사람의 이름을 정렬하여 출력한다. |
스트림을 사용하지 않고 위 내용을 구현한다면 코드를 여러 번 반복해서 사용해야 할 것이다.
하지만 미리 구현되어 있는 스트림의 메서드로 코드를 간결하게 작성할 수 있다.
package stream;
import java.util.ArrayList;
import java.util.List;
public class TrevelTest {
public static void main(String[] args) {
//고객 생성
TrevelCustomer customerLee = new TrevelCustomer("이순신", 40, 100);
TrevelCustomer customerKim = new TrevelCustomer("김유신", 20, 100);
TrevelCustomer customerHong = new TrevelCustomer("홍길동", 13, 50);
List<TrevelCustomer> customerList = new ArrayList<>();
//ArrayList에 고객 추가
customerList.add(customerLee);
customerList.add(customerKim);
customerList.add(customerHong);
System.out.println("==고객 명단에 추가된 순서로 출력");
customerList.stream().map(c -> c.getName()).forEach(s -> System.out.println(s));
int total = customerList.stream().mapToInt(c -> c.getPrice()).sum();
System.out.println("총 여행 비용은 : "+ total + "입니다.");
System.out.println("==20세 이상 고객 명단 정렬하여 출력==");
customerList.stream().filter(c -> c.getAge() >= 20).map(c -> c.getName()).sorted().forEach(s -> System.out.println(s));
}
}
고객 명단을 출력하는 코드를 살펴보면
- map() 메서드를 사용하여 고객의 이름을 가져오고 forEach()메서드로 이름을 출력.
- 각 고객이 지불한 비용을 가져와 mapToInt()메서드로 그 값을 정수로 변환한 후 sum()으로 합을 구한다.
그리고 최종 연산 sum()의 반환값이 int형이므로 int형 total변수에 결과를 대입했다. - 20세 이상 고객을 가져와 이름을 정렬하는 부분은 3개의 중간 연산을 사용했다.
우선 filter()를 사용해 20세 이상만 추출한 후
map()으로 이들의 이름을 가져오고,
sorted()를 사용해 이름을 정렬. - 여기까지가 중간 연산
forEach()를 활용해 출력 - 최종 연산
데이터베이스를 사용해 본적이 있다면 스트림이 쿼리문과 비슷하다는 느낌을 받을 것이다.
이처럼 스트림은 많은 데이터 속에서 우리가 원하는 데이터를 추출하고 적용하고 계산하고 출력하는 기능을 제공한다.