Lambda and Stream (람다와 스트림)
람다란?
익명클래스
익명클래스는 자바의 지역클래스와 비슷한 개념이라고 한다. 익명클래스는 이름이 없는 클래스를 의미한다. 클래스 선언과 인스턴스화를 동시에 진행할 수 있다.
public class AppleMachine {
public boolean isGreen(Apple apple){
return GREEN.equals(apple);
}
}
위와 같은 클래스가 있다면 이 클래스를 사용하기 위해서 new 예약어를 통해 생성하고 이름을 부여한다. (아래의 예시는 AppleMachine 인스턴스에 goldAppleMachine이라는 이름을 부여한 것이다.)
AppleMachine goldAppleMachine = new AppleMachine();
하지만 익명클래스로 만들면 선언과 동시에 인스턴스화가 가능하다. 익명클래스는 이름이 없어서 다시 찾을 수 없기 때문에 인자로 들어가는 경우 인자에서 바로 생성하여 할당해주는 듯 하다.
new AppleMachine(){
public boolean isGreen(Apple apple){
return GREEN.equals(apple);
}
}
익명함수와 람다표현식
익명함수도 말 그대로 이름이 없는 함수이다. 익명함수를 단순화하여 표현한 것이 람다표현식이다. 함수형 인터페이스를 받는 메서드에만 람다 표현식이 가능하다. 추상메서드 1개짜리 인터페이스는 익명클래스를 사용할 필요가 없이 람다식으로 사용이 가능하다. 상황에 따라 컴파일러가 타입을 결정하지 못한다면 해당 인터페이스를 명시적으로 타입을 지정해주어야 한다. 타입을 명시해야 할 경우를 제외하고는 람다의 매개변수 타입은 생략하는 것이 좋다.
(람다 파라미터) -> 람다바디
(람다 파라미터) -> { 람다바디; }
위와 같은 방식으로 표현식 스타일과 블록 스타일로 표현가능하다. 일반 메소드와 다르게 람다에서 참조할 수 있는 지역변수는 final 선언되거나 실질적으로 final처럼 취급되는 가변되지 않는 변수이다.
함수형 인터페이스와 동작파라미터화
동작 파리미터는 무엇일까? 동작 파라미터는 메서드가 동작(전략)을 받아서 내부적으로 수행할 수 있게 동작을 매개변수로 수행할 수 있게 한다. 어떤 동작을 할 것인지를 매개변수 값으로 받아서 수행할 수 있게하는 것이다. 아직은 어떻게 실행할 것인지 결정하지 않은 코드블럭을 파라미터화하여 유연한 메서드를 만들어 주는 것이다. 동작파라미터화를 위해서 함수형 인터페이스가 사용된다.
함수형 인터페이스 | 인수 | 반환값 | 설명 |
Predicate<T> | T | boolean | Predicate는 1개의 인자를 받아 boolean을 리턴한다. |
BiPredicate<T,U> | (T,U) | boolean | 인자를 2개 받아 boolean을 리턴한다. |
Consumer<T> | T | void | Consumer는 1개의 인자를 받아 소진한다. |
BiConsumer<T,U> | (T,U) | void | Consumer는 2개의 인자를 받아 소진한다. |
Supplier<T> | () | T | Supplier는 인자를 받지 않고 T타입을 반환한다. |
Function<T,R> | T | R | Function은 1개의 인자 T를 받아 R타입을 리턴한다. |
BiFunction<T,U,R> | (T,U) | R | 2개의 인자 (T,U)을 받아 R타입을 리턴한다. |
UnaryOperator<T> | T | T | UnaryOperator는 T타입의 1개의 인자를 받아 T타입을 리턴한다. |
BiUnaryOperator<T> | (T,T) | T | T타입의 2개의 인자를 받아 T타입을 리턴한다. |
인자가 2개짜리까지만 기본적으로 제공하는데 3개 이상의 인자가 필요한 함수형 인터페이스는 직접 만들어 사용한다고 한다. 자주 사용되는 기본형, 1개 또는 2개짜리 함수형 인터페이스는 기본 인터페이스로 제공된다. 예를들어, Int형 Predicate라면 IntPredicate로 다이아몬드 안에 타입을 넣을 필요 없이 사용가능하다.
스트림이란?
스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다. 중간연산과 최종연산으로 나뉜다. 중간연산 스트림은 연산하여 다시 스트림을 반환한다. 이것은 스트림을 여러개 체이닝하는 것을 가능하게 한다. 최종연산 스트림은 스트림을 소진한다.
스트림을 사용하면 투명하게 병렬성을 사용할 수 있다고 한다. 이 문구 때문에 스트림은 내부적으로 병렬적으로 처리된다고 오해하고 있었다. 스트림을 사용한다고 병렬적으로 처리되는 것은 아니다. stream()은 기본적으로 순차적으로 처리된다. 다만 메서드들이 체이닝되어 파이프라인화 되어 처리되는 것 뿐이다. 병렬적으로 처리하기 위해서는 parallelStream()으로 호출해주어야 한다.
스트림의 특징
- 선언형 : 더 간결하고 가독성이 좋아진다.
- 조립가능 : 여러개를 조립하여 사용할 수 있기 때문에 유연성이 좋아진다.
- 병렬화 : 성능이 좋아진다.
- 파이프라이닝 : 스트림끼리 연산하여 커다란 파이프라인을 구성하도록 스트림을 반환한다. 이 덕분에 최적화(게으름, 쇼트서킷)의 효과를 얻을 수 있다.
- 내부반복 : 내부 반복은 원하는 결과만 받을 수 있게 해준다. (데이터를 꺼내서 외부에서 연산을 진행하는 것이 아니라 데이터를 내부적으로 연산하고 원하는 결과만 받게 된다.)
참고사항
- 람다 표현식은 메서드참조보다 참조연산이 더 일어난다고 한다. 따라서 가능하면 메서드 참조로 변경할 수 있다면 변경하는 것이 더 효율적이다.
- 기본형은 특화된 스트림이 존재하니 기본특화형 스트림을 사용하는 것이 좋다.
스트림과 컬렉션의 차이
데이터를 언제 계산하는냐가 가장 큰 차이이다.
컬렉션
컬렉션은 자료구조가 포함하는 모든 값을 메모리에 저장한다. 자료구조에 저장되기 전에 모든 요소는 계산된다. 생산자 중심(창고에 물건을 팔기도 전에 제조해서 채워놓음)으로 비유하면 이해가 조금 더 쉽다. 연산은 외부반복하며, 명시적으로 항목을 가져와서 처리한다. 병렬성을 구현할 때 직접처리해야한다.
스트림
스트림은 게으르게 만들어지는 컬렉션과 같다. Stream은 사용자가 요청하는 값만 스트림에서 추출하여 계산한다(Lazy Evaluation). 종단 연산이 호출될 때, 연산이 실행된다. 즉, 데이터를 요청할 때만 값을 계산한다. 비유하자면, 소비자 중심적이며, 물건 요청이 들어올 때만 물건을 제조한다. 스트림의 특징은 내부반복한다는 것이다. 스트림을 사용하면 투명하게 병렬성을 구현가능하다.
map, flatmap
- map은 새로운 버전을 만든다는 개념에 가깝기 때문에
mapping
이라는 단어를 사용한다. - flatmap 메서드는 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 한다. 만약 배열이라면, 각 덩어리의 스트림의 각 요소를 가지고 다른 스트림들을 만들고 각각의 스트림을 가지고 하나의 스트림으로 반환한다. 각 배열을 스트림이 아니라
스트림의 콘텐츠로 매핑
한다.
스트림의 병렬성
기본적으로 스트림이 병렬적으로 계산되는 것은 아니다. 기본적으로 스트림 파이프라인은 순차적이다. 스트림을 병렬로 만드려면 stream() 대신 parallelStream()으로 호출하면 된다.
병렬스트림이란 스트림 요소를 여러 청크로 분할할 스트림이다. parallel을 호출하면 이후 연산이 병렬로 수행해야 함을 의미하는 불리언 블래그가 설정된다. sequential로 병렬 스트림을 순차 스트림으로 바꿀 수 있다.
병렬화를 사용할 때 주의점
병렬화를 이용하려면 스트림을 재귀적으로 분할하고, 각 서브스트림을 서로 다른 스레드의 리듀싱 연산으로 할당하고, 이를 하나로 합쳐야 한다. 멀티 코어간의 데이터 이동은 비싸기 때문에 코어간 데이터 전송이 더 오래걸리는 작업만 병렬로 수행하는 것이 좋다.
공유되는 가변 데이터를 사용하면 제대로된 기댓값이 나오지 않을 가능성이 높다. 또한, 데이터소스가 Stream.iterate나 중간연산으로 limit가 들어가면 병렬화로 인한 성능개선을 기대하기 어렵다고 한다.
병렬화를 잘못하면 응답불가 상황이 되거나 성능이 더 나빠질 수도 있기 때문에 병렬화는 주위하여 사용해야한다. 스트림 병렬화는 오직 성능 최적화를 위한 수단
이므로 변경 전후 성능테스트를 거쳐 필요한 경우에만 사용해야 한다.
병렬화를 사용하면 좋은 상황
스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스나 배열, int 범위, long 범위 일 때, 병렬화의 효과가 가장 좋다고 한다(이팩티브자바 292pg). 이 자료구조의 공통점은 참조 지역성이 뛰어나다는 것이다. 참조 지역성은 벌크 연산을 병렬화할 때 중요한 요소로 작용한다고 한다.
종단 연산 중 reduce 같이 모든 원소를 하나로 합치는 축소시키는 연산도 병렬화에 적합하다. 하지만, collect와 같이 가변 축소 수행은 병렬화에 적합하지 않다. 조건이 맞으면 바로 반환하는 anyMatch, allMatch, noneMatch 등도 병렬화에 적합하다.
정리
스트림과 람다을 잘 모르고 사용만 하고 있었다. 여전히 깊게 들어가면 어려운 내용이 많은 것 같지만, 스트림과 람다에 대해 정리해보는 기회가 되었다. 일단, 스트림의 내부동작이 병렬적으로 수행한다고 오해하고 있었다. 명시적으로 병렬화를 시켜주었을 때만 병렬화가 되고, 이것은 성능최적화 여부를 따져보고 수행해야한다. 람다는 익명함수를 표현하는데 사용되며, 함수형 인터페이스가 사용되면서 더 유용해졌다. 추상메서드 1개인 인터페이스는 익명클래스도 필요없이 람다로만 표현이 가능하다. 스트림은 데이터를 처리하는 연산에 적합하며 게으른 평가를 하고 내부연산을 하기 때문에 데이터 연산을 안전하고 효율적으로 실행할 수 있다. 람다, 스트림, 함수형 인터페이스는 구조를 유연하고 간결하게 만들어주는데 큰 역할을 하는 듯 하다.
참고
- 모던 자바 인 액션 (책)
- 우아한테크코스 수업
- 이펙티브 자바