스트림은 람다에 대한 기본 지식이 없으면 이해가 힘들다! 스트림 메소드의 인자로 들어가는 함수형 인터페이스에 대한 기본 배경지식이 깔려있어야 한다.
1. Stream?
stream이란, iterable한 객체의 각 element에 parallel한 연산을 수행할 수 있도록 도와주는 API를 말한다. Java 8에서 람다식과 함께 추가된 기능으로, 람다식을 이용해서 array나 collection 자료구조의 데이터 '흐름'을 가독성 높고 간결하게 작성할 수 있도록 도와준다.
stream의 구조는 일반적으로 '스트림 생성 -> 중개 연산(intermediate operation) -> 종단 연산(terminate operation)'으로 이루어진다. 스트림을 생성하고, 일정 로직을 수행하고, 그 결과값을 받는 각각의 과정이라고 이해하면 좋다. 학생 3명과 선생님 1명, 책이 2권 있는 교실을 classRoom List에 담는 예시를 살펴보자.
public class StreamExample {
public static void main(String[] args) {
List<String> classRoom = new ArrayList<>(
Arrays.asList("학생1","학생2","학생3","선생님1","책1","책2"));
System.out.println("학생 수는 " +
classRoom.stream().filter(x -> x.contains("학생")).count() +
"명 입니다.");
}
}
- Arrays.asList() : 인자로 받은 객체들을 Collection의 List 객체로 반환해주는 스태틱 메소드이다. ArrayList의 생성자로 전달해주어, 해당 인자들을 요소로 갖는 ArrayList 객체를 생성했다.
- classRoom.stream() : 스트림 생성 연산. 단지 자료의 나열이었던 ArrayList를 스트림화시켜 원하는 평행 연산을 수행할 수 있도록 했다.
- filter() : 중개 연산. 인자로 Predicate 구현체를 받는다. Predicate는 주어진 인자(위의 예시에선 classRoom 리스트의 각 요소들)에 대해 true/false 값을 반환하는 test() 메소드를 가진 함수형 인터페이스이다. filter의 인자로 주어진 classRoom 요소들에 대해 true를 반환하는 요소들만 다음 스트림으로 넘기게 된다. 예시의 경우, "학생" 문자열을 포함하는 학생1, 학생2, 학생3 만이 걸러져 다음 스트림으로 넘어간다.
- count() : 종단 연산. 메소드를 호출한 스트림에 존재하는 요소들의 개수를 카운트하여 long 타입으로 리턴한다.
main 메소드의 결과값은 예상 되겠지만, 아래와 같다.
학생 수는 3명 입니다.
예시만 봐도 스트림을 어떻게 사용해야할지 대충 감이 잡힌다. 만약 스트림을 사용하지 않고, 요소를 하나하나 검사하는 for 루프를 돌 경우 같은 동작을 하는 코드는 아래와 같다.
public class ForExample {
public static void main(String[] args) {
List<String> classRoom = new ArrayList<>(
Arrays.asList("학생1","학생2","학생3","선생님1","책1","책2"));
int studentNum = 0;
for (String element : classRoom) {
if (element.contains("학생")) {
studentNum++;
}
}
System.out.println("학생 수는 " +
studentNum +
"명 입니다.");
}
}
예시 자체가 간단한데도 스트림을 이용한 것보다 훨씬 코드가 길어졌다. 스트림을 사용하면 간결하고, 가독성 높은 코드를 작성할 수 있다. 위 예시와 같이 필터링 기능도 가능하고, 요소 각각에 대해 연산을 수행할 수 있는 map,
2. 스트림 생성하기
스트림을 생성하는 방법은 정말 많다. 배열로 생성하기, 컬렉션으로 생성하기, Stream 자체의 스태틱 메소드 혹은 builder()를 사용하기, IntStream/LongStream 등 기본 자료형 스트림을 이용하기 등이 있다. 해당 내용은 java의 스트림 공식 문서에 잘 나와있으니, 여기선 가장 많이 사용하는 배열을 이용한 방법과 Collection 프레임워크의 stream() 메소드를 사용하는 방법만 간단히 알아보자.
1. 배열 이용
public class CreateArray {
public static void main(String[] args) {
String [] classRoom = {
"학생1","학생2","학생3","선생님1","책1","책2"};
// Arrays.stream() 스태틱 메소드 사용
Stream<String> classRoomStream = Arrays.stream(classRoom);
classRoomStream.forEach(System.out::println);
}
}
Arrays는 java.util 패키지가 제공하는 클래스 중 하나인데, 자바에서 사용하는 배열과 관련한 여러 기능들을 제공한다. stream() 메소드는 인자로 들어온 배열의 stream을 반환한다.
forEach()는 스트림 혹은 collection의 각 요소에 대해 Consumer의 action() 메소드를 수행하도록 하는 종단 연산인데, 자세한 것은 조금 이따 설명할 것이니 그냥 이런게 있구나~ 하고 넘기자.
2. stream() 메소드 이용
다음으로 Collection에서 제공하는 stream() 기능이다. 간단하게 collection 객체의 stream() 메소드를 호출하는 것만으로 새로운 스트림 객체가 만들어진다.
public class CreateStream {
public static void main(String[] args) {
List<String> classRoom = new ArrayList<>(Arrays.asList(
"학생1","학생2","학생3","선생님1","책1","책2"));
classRoom.stream().forEach(System.out::println);
// 사실 collection 자체에서 제공하는 forEach() 메소드를 사용해도 됨
// classRoom.forEach(System.out::println)
}
}
그런데 collection 자체가 내부 요소들을 가지고 이런저런 일을 하는 경우가 많다보니, collection에선 자체적으로 스트림 비슷한 기능을 하는 메소드를 몇 가지 제공하고 있다. forEach()가 그중 하나이다. collection과 스트림의 forEach()는 모두 인자로 Consumer 구현체를 받고 각 요소에 대해 Consumer 내의 action() 메소드를 수행한다.
3. 중개 연산하기
스트림을 생성하는 가장 큰 이유이다. 앞서 설명했듯 stream은 평행 연산을 가독성있고 짧게 작성하는데 큰 의의가 있다. 평행 연산에서 '연산'에 해당하는 부분이 intermediate operation, 즉 중개 연산이다.
스트림에서 중개 연산은 0개 이상이다. 중개 연산 없이 종단 연산만으로 이루어진 스트림도 존재한다. 중개 연산이 꼭 존재할 필요는 없단 뜻이다. 그리고 꼭 중개 연산이 1개만 있을 필요도 없다. 중개 연산이 여러개인 스트림 역시 존재한다. 스트림의 중개 연산의 결과는 스트림이기 때문에, 일정 로직을 수행한 결과 스트림을 또다시 중개 연산의 대상 스트림으로 사용할 수 있다. 계산하고자 하는 상황과 목적에 맞게 스트림을 사용하면 좋은데, 예시를 보면 더 와닿을 것이다.
중개 연산 역시 다양한 종류가 존재하는데, 가장 많이 쓰이는 몇 가지 예시를 살펴보자.
1) Filtering : 일정 조건 하에 필터링한 스트림 반환
앞선 예시에서 살펴봤던 filter(Predicate<T> predicate) 메소드를 통해 구현한다.
public class Filtering {
public static void main(String[] args) {
List<String> classRoom = new ArrayList<>(
Arrays.asList("학생1","학생2","학생3","선생님1","책1","책2"));
System.out.println("교실에 존재하는 책 목록");
classRoom.stream().filter(x -> x.contains("책")).forEach(System.out::println);
}
}
filter()을 통해 반환된 스트림은 predicate()에서 true를 반환된 요소들로만 이뤄진 스트림이다. 위의 예시에선, "책"문자를 포함한 "책1", "책2" 요소들만 filter()의 결과 스트림에 포함되어있을 것이다. 따라서 forEach()에서 println의 인자값으로 들어간 요소는 방금 "책1", "책2" 2개 뿐이다. 따라서 결과 출력물은 아래와 같다.
교실에 존재하는 책 목록
책1
책2
2) Mapping : 각 요소에 일정 로직을 수행한 스트림 반환
map()은 인자로 Function의 람다식을 받는다. Function은 전달받은 인자를 바탕으로 일정 로직을 수행하여 결과물을 내놓는 mapper() 메소드를 가진다. map()을 통해 새로 반환된 요소는 다음 스트림의 요소가 된다.
classRoom 예시에서, 교실에 고양이가 침범했다 생각해보자. 교실의 각 문자열 앞에 "고양이"를 붙이고 싶다. 이때 mapper를 사용하여 스트림의 각 요소 앞에 "고양이"를 붙일 수 있도록 한다.
public class Mapping {
public static void main(String[] args) {
List<String> classRoom = new ArrayList<>(
Arrays.asList("학생1","학생2","학생3","선생님1","책1","책2"));
classRoom.stream().map(x -> "고양이 " + x).forEach(System.out::println);
}
}
결과 출력물은 예상이 갈 것이다.
고양이 학생1
고양이 학생2
고양이 학생3
고양이 선생님1
고양이 책1
고양이 책2
3) Sorting : 스트림 요소 정렬
스트림의 요소들을 정렬하는 sorted() 메소드도 존재한다. sorted는 인자 없이 그냥 사용했을 경우 해당 클래스에 기본으로 구현된 compare() 메소드를 통해 정렬한 결과물을 반환한다(일반적으로 오름차순 정렬). 그렇지 않을 경우 Comparator 객체를 인자로 받는데, Comparator<T>에 존재하는 comparator() 메소드를 직접 구현하여 넘겨줄 수도 있다.
예시는 순서 없이 늘어선 정수 리스트를 배열한 스트림 결과값 요소들을 출력하도록 했다.
public class Sorting {
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>(
Arrays.asList(2,8231,44,283,-2,947,71));
integers.stream().sorted().forEach(System.out::println);
}
}
오름차순으로 정렬된 정수들이 결과로 출력된다.
-2
2
44
71
283
947
8231
4. 결과 만들기
스트림은 스트림 자체로 일정 로직을 수행하고, 그 결과로 반환된 스트림을 마지막으로 처리하여 결과값을 내는 terminate operation 단계이다.
terminate operation을 끝낸 스트림은 '닫혔다'라고 표현한다. 앞선 중개 연산에서 스트림 중개 연산의 결과는 스트림이라고 언급한 바 있따. 그렇기 때문에 연쇄적으로 스트림 중개 연산이 가능했는데, 종단 연산 후의 스트림은 더이상 스트림 계산을 할 수 없다. 뒤에 스트림의 재사용과 관련된 설명에 추가하겠지만, 한 번 닫힌 스트림은 다시 사용할 수 없고 새로이 스트림을 생성해야한다.
스트림 종단 연산에도 여러가지 종류가 있다. 역시 많이 쓰이는 것들 위주로 간략하게 살펴보자.
1) Caculating : 결과 스트림의 계산 결과 반환
스트림 결과 요소들을 취합하여 계산을 하는 메소드들이 존재한다. primitive type의 스트림에서 자주 사용되는 기능이므로 IntStream 스트림 객체를 만들어 예제를 살펴보자.
public class Calculating {
public static void main(String[] args) {
List<Integer> integerList = new ArrayList<>(List.of(
2,8231,44,283,-2,947,71));
System.out.println("예제에서 사용하는 stream 예제 : " + integerList);
// count
System.out.println("count() : " + IntStream.of(2,8231,44,283,-2,947,71).count());
// sum
System.out.println("sum() : " + IntStream.of(2,8231,44,283,-2,947,71).sum());
// optional min, max, average
IntStream.of(2,8231,44,283,-2,947,71)
.min().ifPresent(
m -> System.out.println("min() : " + m));
IntStream.of(2,8231,44,283,-2,947,71)
.max().ifPresent(
M -> System.out.println("max() : " + M));
IntStream.of(2,8231,44,283,-2,947,71)
.average().ifPresent(
avg -> System.out.println("average() : " + avg));
}
}
- count() : 스트림 요소의 개수를 return
- sum() : 스트림 요소의 총합 return
- empty stream일 경우를 대비해 Optional 객체를 return하는 종단 연산들. 비어있을 경우 null return
- min(), max() : 스트림 요소의 최소, 최대 return
- average() : 스트림 요소의 평균 return
2) Collecting : Collectors 객체 이용
종단 연산으로 collect() 메소드를 이용해 Collectors에서 제공하는 기능을 사용할 수 있다. 스트림 결과값을 또다른 collection으로 취합할 수도 있고, 객체의 특정 상태값을 뽑아 grouping 혹은 joining을 시킬 수도 있다. collect() 메소드의 인자로는 Collectors 객체가 들어가는데, 종단 연산 수행을 위한 다양한 기능을 제공한다.
상태 필드 값으로 int price, String name을 갖는 Product 객체를 사용한 스트림 예제 코드를 보자.
public class Collecting {
public static void main(String[] args) {
List<Product> products = new ArrayList<>(
Arrays.asList(new Product("밥",200),
new Product("국",100),
new Product("반찬",50)));
// name 필드만 뽑기
// collect(Collectors.toList()) -> toList() 축약 가능
List<String> names = products.stream()
.map(Product::getName)
.collect(Collectors.toList());
// price 평균 구하기
// IntStream 의 Calculating 기능과 동일 (조금 더 일반적인 상황에서 쓰임)
double averagePrice = products.stream()
.collect(Collectors.averagingInt(Product::getPrice));
}
}
- names : products 리스트에 존재하는 Product 인스턴스들의 "name" 필드만 들어있는 리스트이다. 결과 스트림을 리스트로 반환하는 Collectors.toList()를 사용했다.
- averagePrice : products 리스트의 price 필드값들을 모아 평균을 낸 결과값이다. Collectors.averagingInt() 메소드를 사용하며 메소드 인자값으로 int를 반환하는 Function 함수형 인터페이스를 받는다. 해당 람다식으로 반환된 정수값에 대해 평균을 구한 뒤 반환해주는 과정을 거친다.
참고로 주석에도 달아놨지만, java 16부턴 Collectors.toList()를 toList()로 축약해 사용할 수 있다.
3) forEach() : 종단 연산의 한 종류
예시에서 자주 등장한 forEach() 메소드 역시 종단 연산중 하나이다. forEach()를 호출한 스트림을 닫고, 스트림의 각 요소에 대해 Consumer 람다식 로직을 수행한다.
5. 스트림 이모저모
스트림은 저장이 아닌 계산을 위한 자료구조이다. 이와 관련하여 가지는 특징들이 몇 가지 있는데, 역시 중요한 것 위주로 살펴보자.
1) Stream은 Lazy 연산이다
종단 연산이 일어나기 전까지 중개 연산은 일어나지 않는다. 스트림이 종단연산을 통해 닫히는 하나의 생성~종료 과정이 존재해야만 일련의 연산 수행이 일어난다. 메소드 호출이 일어났을 때 바로 계산하지 않고, 종단 연산 과정에서 값이 필요할 때 연산이 '뒤늦게' 일어난다고 하여 이를 Lazy invocation이라고 부른다.
아래 예시를 보자. map() 중개연산을 통해 각 요소를 출력하는 람다식을 작성했다. 두 개의 스트림을 생성했는데, 하나는 map()연산만을, 나머지 하나는 collect()를 통해 종단 연산까지 확실하게 진행했다.
public class LazyStream {
public static void main(String[] args) {
List<Product> products = new ArrayList<>(
Arrays.asList(new Product("밥",200),
new Product("국",100),
new Product("반찬",50)));
products.stream().map(p -> {
System.out.println("no terminate operation " + p.getName());
return p;
});
products.stream().map(p -> {
System.out.println("with terminate operation " + p.getName());
return p;
}).collect(Collectors.toList());
}
}
결과는?
with terminate operation 밥
with terminate operation 국
with terminate operation 반찬
"with termibate operation", 즉 collect()를 통해 확실히 terminate operation을 진행한 중개 연산만이 수행됐다.
2) 스트림은 원본 데이터를 수정하지 않아야 한다.
어떤 collection 혹은 배열 객체에 대해 스트림을 만들고, intermediate / terminal 연산을 수행했다고 해서 원본 객체는 변화하지 않는다. 앞선 예시들에서도 스트림 계산 후 '계산의 결과값'을 가지고 forEach() 혹은 collect() 메소드를 이용하여 결과를 구경했지, 스트림을 생성한 원본 객체 자체가 변화한 부분은 찾아볼 수 없다. 종단 연산을 통해 객체나 값을 얻어도 원본 객체가 수정되는 것이 아니라 새로운 객체나 값이 생길 뿐이다.
이와 관련해 재밌는 사실이 있다. collection이나 배열에 대해 스트림 연산을 하는 것이 for 루프에 비해 성능 면에서 좋다고 보기 어렵다. 스트림에서 사용하는 연산들(map, filter 등)은 원본 데이터가 아닌 메소드 인자로 넘어오는 값에만 메소드 기능을 의존한다(이를 '순수 함수'라고 한다). call by value를 지원하는 자바의 특성상, 함수에 인자가 전달될 때 값의 복사가 일어난다. 스트림의 모든 요소에 대해 복사 과정을 거치기 때문에 직접 배열의 요소를 사용하는 for문에 비해 스트림 연산은 느린 것이다.
객체의 불변성을 유지하는 것엔 일반적으로 몇 가지의 장점이 있다. 멀티 스레드 환경에서 동기화를 고려하지 않아도 된다든가, 불변 확신에 따른 생산성 향상이라든가, 혹시 모를 side effect를 생각하지 않아도 된다든가 하는 점이다.
원본에 immutable을 보장하지 않았을 때 일어날 참사를 생각해보자. 다음 예시는 0부터 9999까지의 원소를 integers 리스트에 집어넣고, integers의 parallelStream을 이용해 각 원소를 matched 리스트에 집어넣는 과정을 수행한다. 그 뒤 결과로 matched 리스트의 크기, 그리고 index 0~10의 요소를 출력하도록 했다.
public class MultiThread {
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>();
List<Integer> matched = new ArrayList<>();
for (int i=0; i<10000; i++) {
integers.add(i);
}
// race condition!
integers.parallelStream().forEach(x -> {
matched.add(x);
});
System.out.println(matched.size());
System.out.println(matched.subList(0,10));
}
}
결과를 예상해보자. 첫번째 줄로 10000, 두번째 줄로 [0,1,2,3,4,5,6,7,8,9]이 출력될 것 같다.
그러나?
6626
[6562, 6563, 6564, 6565, 6566, 6567, 6568, 6569, 6570, 6571]
예상과 다르다. 심지어 프로그램을 실행할 때마다 다른 결과가 나온다. race condition이 발생한 것이다.
stream은 중개~종단 연산들을 하나의 파이프라인으로 수행한다. 스트림의 모든 원소들에 대해 한꺼번에 중개연산1을 수행하고, 중개연산2를 수행하고, 종단연산을 수행하는 방식이 아니라 스트림 요소 하나에 대해 중개연산1 -> 중개연산2 -> 종단연산을 수행한 뒤 그 다음 스트림 요소에 똑같은 과정을 거친다. parallelStream()은 이러한 스트림 연산을 멀티 스레드 방식으로 수행한다. 여러 스레드가 스트림 각각의 요소를 가지고 중개연산~종단연산을 병렬적으로 수행하는데, 이 계산 과정에서 어떤 요소가 먼저 실행될지는 알 수 없다. 그렇기 때문에 matched.subList(0,10)의 결과값이 얌전히 0~9로 나오지 않는다.
또한 matched는 ArrayList 객체인데, ArrayList는 기본적으로 thread-safe하지 않다. 앞선 스레드가 리스트에 데이터를 쓰기도 전에 뒤의 스레드가 리스트를 읽는 상황이 발생하기 때문에 matched 리스트의 길이가 얌전히 10000으로 나오지 않는다.
이런 혹시 모를 참사가 있는 상황에 스트림 원본 객체의 값을 변경시키는 연산이 수행되는 것을 자바에서는 용납하지 않는다.
public class NoModification {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5,6,7,8,9,10));
try {
list.stream().forEach(x -> list.add(x));
} catch (ConcurrentModificationException e) {
e.printStackTrace();
}
}
}
따라서, 스트림 연산 과정중 원본 객체를 수정하려는 접근이 있으면 위 예시와 같이 ConcurrentModificationException이 발생한다.
하지만 중개 연산의 계산 순서나 병렬성과는 별개로 종단 연산을 통해 반환된 결과값의 순서는 대부분 보장된다.
3) 스트림은 재사용 불가하다!
한 번 닫힌 스트림은 다시 열리지 않는다.
public class NoReuse {
public static void main(String[] args) {
IntStream intStream = IntStream.range(0,10);
intStream.forEach(System.out::println);
try {
intStream.average().ifPresent(System.out::println);
} catch (Exception e) {
// IllegalStateException !!
e.printStackTrace();
}
}
}
위의 예시에서, intStream.forEach()를 통해 생성한 intStream 객체의 종단 연산을 완료하여 스트림이 닫혔다고 볼 수 있다.
이때 같은 스트림 객체에 대해 또다시 연산을 시도하면(예시의 경우 average() 메소드) IllegalStateException이 발생한다.
같은 객체에 대해 다른 스트림 연산을 수행하고 싶다면 새로운 스트림을 새로 만들어야 한다. 이는 스트림이 '저장이 아닌 계산을 위한 구조이다'임을 다시 한 번 상기하면 좋을 것 같다.
REFERENCE
https://futurecreator.github.io/2018/08/26/java-8-streams/
https://wjdtn7823.tistory.com/89
https://futurecreator.github.io/2018/08/26/java-8-streams-advanced/
https://hamait.tistory.com/547
https://stackoverflow.com/questions/47041144/what-is-the-danger-of-side-effects-in-java-8-streams