자바의 정석 14장 - 람다와 스트림(Lambda & stream)

청포도봉봉이·2024년 2월 25일
2

자바의 정석

목록 보기
14/16
post-thumbnail

람다식(Lambda expression)

자바가 1996년에 처음 등장한 이후로 두 번의 큰 변화가 있었는데, 한번은 JDK1.5부터 추가된 지네릭스(generics)의 등장이고, 또 한 번은 JDK1.8부터 추가된 람다식(lambda expression)의 등장이다. 이 두 가지 새로운 변화에 의해 자바는 더 이상 예전의 자바가 아니게 되었다.

특히 람다식의 도입으로 인해, 이제 자바는 객체지향언어인 동시에 함수형 언어가 되었다. 객체지향언어가 함수형 언어의 기능까지 갖추게 하는 일은 결코 쉬운 일이 아니었을텐데도 기존의 자바를 거의 변경하지 않고도 함수형 언어의 장점을 잘 접목시키는데 성공했다. 앞으로 람다식을 배워가면서. 자바8(JDK1.8)을 만든 사람들이 얼마나 많은 노력을 기울였는지 알게 될 것이다.

람다식이란?

람다식(Lambda expression)은 간단히 말해서 메서드를 하나의 식(expression)으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.

메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 익명 함수(anonymous function)이라고도 한다.

int[] arr = new int[5];
Arrays.setAll(arr, (i) -> (int)(Math.random() * 5) + 1);

앞서 11장에서 처음으로 람다식이 등장했는데, 위의 문장에서 () -> (int)(Math.random() * 5) + 1이 바로 람다식이다. 이 람다식이 하는 일을 메서드로 표현하면 다음과 같다.

int method() {
	return (int) (Math.random() * 5) + 1;
}

위의 메서드보다 람다식이 간결하면서도 이해하기 쉽다는 것에 이견이 없을 것이다. 게다가 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야만 비로소 이 메서드를 호출할 수 있다. 그러나 람다식은 이 모든 과정없이 오직 람다식 자체만으로도 이 메서드의 역할을 대신할 수 있다.

게다가 람다식은 메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수도 있다. 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해진 것이다.

Q. 메서드와 함수의 차이가 뭐죠?

A.
1. 사용 방법: 메서드는 객체(Object)에 속해있는 함수로, 해당 객체의 속성과 동작을 나타내는 코드입니다. 메서드는 객체의 상태를 변경하거나 객체의 특정 동작을 수행할 수 있습니다. 반면에 함수는 독립적으로 존재하며, 특정한 객체에 속하지 않고 독립적으로 실행됩니다.
2. 객체 지향 프로그래밍: 메서드는 객체 지향 프로그래밍에서 주로 사용되며, 객체의 특정 동작을 수행하기 위해 호출됩니다. 객체의 상태를 변경하거나 객체 간의 상호작용을 위해 메서드를 사용합니다. 함수는 객체 지향 프로그래밍에 국한되지 않고, 다양한 프로그래밍 패러다임에서 사용될 수 있습니다.
3. 호출 방식: 메서드는 해당 객체를 통해 호출되며, 객체의 속성과 메서드에 접근할 수 있습니다. 예를 들어, 객체의 이름을 통해 메서드를 호출할 수 있습니다. 함수는 일반적으로 함수 이름을 통해 호출됩니다.
4. 반환 값: 메서드는 호출된 객체의 상태를 변경하거나, 특정한 결과 값을 반환할 수 있습니다. 함수는 호출된 결과 값을 반환합니다.

람다식 작성하기

람다식은 익명 함수답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통 {} 사이에 ->를 추가한다.

int max(int a, int b) {
	return a > b ? a : b;
}
(int a, int b) -> {
	return a > b ? a : b;
}

반환값이 있는 메서드의 경우, return문 대식 식(expression)으로 대신 할 수 있다. 식의 연산결과가 자동적으로 반환값이 된다. 이 때는 문장(statement)이 아닌 이므로 끝에 ;을 붙이지 않는다.

(int a, int b) -> { return a > b ? a : b; }

(int a, int b) -> a > b ? a : b

람다식의 선언된 매개변수의 타입은 추록이 가능한 경우에 생략할 수 있는데, 대부분의 경우에 생략가능하다. 람다식에 반환타입이 없는 이유도 항상 추론이 가능하기 때문이다.

(int a, int b) -> a > b ? a : b

(a, b) -> a > b ? a : b

아래와 같이 선언된 매개변수가 하나뿐인 경우에는 괄호()를 생략할 수 있다. 단, 매개변수의 타입이 있으면 괄호()를 생략할 수 없다.

a -> a * a // OK

int a -> a * a // 에러

마찬가지로 괄호 {} 안의 문장이 하나일 때는 괄호{}를 생략할 수 있다. 이 때 문자의 끝에 ;을 붙이지 않아야 한다는 것에 주의하자.

(String name, int i) ->
	System.out.println(name + "=" + i)

그러나 괄호{} 안의 문장이 return문일 경우 괄호{}를 생략할 수 없다.

{ return a > b ? a : b; } // OK
return a > b ? a : b // 에러

아래의 표는 메서드를 람다식으로 변환하여 보여준다. 람다식을 가리고 왼쪽의 메서드만 보면서 람다식을 직접 작성한 다음 바르게 반환하였는지 확인해 보자.

함수형 인터페이스(Functional Interface)

자바에서 모든 메서드는 클래스 내에 포함되어야 하는데, 람다식은 어떤 클래스에 포함되는 것일까? 지금까지 람다식이 메서드와 동등한 것처럼 설명해왔지만, 사실 람다식은 익명 클래스의 객체와 동등하다.

(int a, int b) -> a > b ? a : b
new Object() {
	int max(int a, int b) {
    	return a > b ? a : b;
    }
}

위의 2번째 코드에서 메서드 이름 max는 임의로 붙인 것일 뿐 의미는 없다. 어쨌든 람다식으로 정의된 익명 객체의 메서드를 어떻게 호출할 수 있을 것인가? 이미 알고 있는 것처럼 참조변수가 있어야 객체의 메서드를 호출 할 수 있으니까 일단 이 익명 객체의 주소를 f라는 참조변수에 저장해보자

타입 f = (int a, int b) -> a > b ? a : b; // 참조변수의 타입을 뭘로 해야 할까?

그러면, 참조변수 f의 타입은 어떤 것이어야 할까? 참조형이니까 클래스 또는 인터페이스가 가능하다. 그리고 람다식과 동등한 메서드가 정의되어 있는 것이어야 한다. 그래야만 참조변수로 익명 객체(람다식)의 메서드를 호출할 수 있기 때문이다.

예를 들어 아래와 같이 max()라는 메서드가 정의된 MyFunction 인터페이스가 정의되어 있다고 가정하자.

interface Myfunction {
	public abstract int max(int a, int b);
}

그러면 이 인터페이스를 구현한 익명 클래스의 객체는 다음과 같이 생성할 수 있다.

MyFunction f = new Function() {
	public int max(int a, int b) {
    	return a > b ? a : b;
    }
};

int big = f.max(5, 3); // 익명 객체의 메서드를 호출

MyFunction 인터페이스에 정의된 메서드 max()는 람다식 (int a, int b) -> a > b ? a : b과 메서드의 선언부가 일치한다. 그래서 위 코드의 익명 객체를 람다식으로 아래와 같이 대체할 수 있다.

MyFunction f = (int a, int b) -> a > b ? a : b; // 익명 객체를 람다식으로 대체
int big = f.max(5, 3); // 익명 객체의 메서드를 호출

이처럼 MyFunction 인터페이스를 구현한 익명 객체를 람다식으로 대체가 가능한 이유는, 람다식도 실제로 익명 객체이고, MyFunction 인터페이스를 구현한 익명 객체의 메서드 max()와 람다식의 매개변수의 타입과 개수 그리고 반환값이 일치하기 때문이다.

지금까지 살펴본 것처럼, 하나의 메서드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바의 규칙들을 어기지 않으면서도 자연스럽다.

그래서 인터페이스를 통해 람다식을 다루기로 결정되었으며, 람다식을 다루기 위한 인터페이스를 함수형 인터페이스(functional interface)라고 부르기로 했다.

@FuncationalInterface
interface MyFunction { // 함수형 인터페이스 MyFunction을 정의
	public abstract int max(int a, int b);
}

단, 함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야 한다는 제어약이 있다. 그래야 람다식과 인터페이스의 메서드가 1:1로 연결될 수 있기 때문이다. 반면에 static메서드와 default메서드의 개수에는 제약이 없다.

기존에는 아래와 같이 인터페이스의 메서드 하나를 구현하는데도 복잡하게 해야 했는데,

List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd", "aaa");

Collections.sort(list, new Comparator<String() {
	public int compare(Stirng s1, String s2) {
    	return s2.compareTo(s1);
    }
});

이제 람다식으로 아래와 같이 간단히 처리할 수 있게 되었다.

List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd", "aaa");
Collections.sort(list, (s1, s2) -> s2.compareTo(s1));

함수형 인터페이스 타입의 매개변수와 반환타입

함수형 인터페이스 MyFunction이 아래와 같이 정의되어 있을 때,

@FunctionalInterface
interface MyFunction {
	void myMetethod(); // 추상메서드
}

메서드의 매개변수가 MyFunction 타입이면, 이 메서드를 호출할 때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 뜻이다.

void aMethod(MyFunction f) {
	f.myMethod();
}

	...
    
MyFunction f = () -> System.out.println("myMethod()");
aMethod(f);

또는 참조변수 없이 아래와 같이 직접 람다식을 매개변수로 지정하는 것도 가능하다.

aMethod(() -> System.out.println("MyMethod()")); // 람다식을 매개변수로 지정

그리고 메서드의 반환타입이 함수형 인터페이스타입이라면, 이 함수형 인터페이스의 추상메서드와 동등한 람다식을 가리키는 참조변수를 반환하거나 람다식을 직접 반환할 수 있다.

MyFunction myMethod() {
	MyFunction f = () -> {};
    return f; // 이 줄과 윗 줄을 한 줄로 줄이면, return () -> {};
}

람다식을 참조변수로 다룰 수 있다는 것은 메서드를 통해 람다식을 주고받을 수 있다는 것을 의미한다. 즉, 변수처럼 메서드를 주고받는 것이 가능해진 것이다.

사실상 메서드가 아니라 객체를 주거받는 것이라 근본적으로 달라진 것은 아무것도 없지만, 람다식 덕분에 예전보다 코드가 더 간결하고 이해하기 쉬워졌다.

람다식 타입과 형변환

함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 람다식은 익명 객체이고 익명 객체는 타입이 없다. 정확히는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없는 것이다. 그래서 대입 연산자의 양변의 타입을 일치시키기 위해 아래와 같이 형변환이 필요하다.

MyFunction f = (MyFunction) (() -> {}); // 양변의 타입이 다르므로 형변환 필요

람다식은 MyFunction 인터페이스를 직접 구현하지 않았지만, 이 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 위와 같은 형변환을 허용한다. 그리고 이 형변환은 생략가능하다.

람다식은 이름이 없을 뿐 분명히 객체인데도, 아래와 같이 Object 타입으로 형변환 할 수 없다. 람다식은 오직 함수형 인터페이스로만 형변환이 가능하다.

Object obj = (Object) (() -> {}); // 에러. 함수형 인터페이스로만 형변환 가능

굳이 Object 타입으로 형변환하려면, 먼저 함수형 인터페이스로 변환해야 한다.

Object obj = (Object) (MyFunction) (() -> {});
String str = ((Object)(MyFunction)(() -> {})).toString();

java.util.function 패키지

대부분의 메서드는 타입이 비슷하다. 매개변수가 없거나 한 개 또는 두 개, 반환 값은 업거나 한 개. 게다가 지네릭 메서드로 정의하면 매개변수나 반환 타입이 달라도 문제가 되지 않는다. 그래서 java.util.function 패키지에 일반적으로 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해 놓았다. 매번 새로운 함수형 인터페이스를 정의하지 말고, 가능하면 이 패키지의 인터페이스를 활용하는 것이 좋다.

그래야 함수형 인터페이스에 정의된 메스드 이름도 통일되고, 재사용성이나 유지보수 측면에서도 좋다. 자주 쓰이는 가장 기본적인 함수형 인터페이스는 다음과 같다.

매개변수와 반환값의 유무에 따라 4개의 함수형 인터페이스가 정의되어 있고, Function의 변형으로 Predicate가 있는데, 반환값이 boolean이라는 것만 제외하면 Function과 동일하다. Predicate는 조건식을 함수로 표현하는데 사용된다.

조건식의 표현에 사용되는 Predicate

Predicate는 Function의 변형으로, 반환타입이 boolean이라는 것ㅁ나 다르다. Predicate는 조건식을 람다식으로 표현하는 데사용된다.

Predicate<String> isEmptyStr = s -> s.length() == 0;
String s = "";

if(isEmptyStr.test(s)) // if(s.length() == 0)
	System.out.println("This is an empty String.");

매개변수가 두 개인 함수형 인터페이스

매개변수의 개수가 2개인 함수형 인터페이스는 이름 앞에 접두사 Bi가 붙는다.

두 개 이상의 매개변수를 갖는 함수형 인터페이스가 필요하다면 직접 만들어서 써야한다.
만일 3개의 매개변수를 갖는 함수형 인터페이스를 선언한다면 다음과 같을 것이다.

@FUnctionalInterface
interface TriFunction<T, U, V, R> {
	R apply(T t, U u, V v);
}

UnaryOperator와 BinaryOperator

Function의 또 다른 변형으로 UnaryOperator와 BinaryOperator가 있는데, 매개변수의 타입과 반환타입의 타입이 모두 일치한다는 점만 제외하고는 Function과 같다.

컬렉션 프레임웍과 함수형 인터페이스

컬렉션 프레임웍의 인터페이스에 다수의 디폴트 메서드가 추가되었는데, 그 중의 일부는 함수형 인터페이스를 사용한다. 다음은 그 메서드들의 목록이다.

기본형을 사용하는 함수형 인터페이스

지금까지 소개한 함수형 인터페이스는 매개변수와 반환값이 타입이 모두 지네릭 타입이었는데, 기본형 타입의 값을 처리할 때도 래퍼(wrapper)클래스를 사용해왔다. 그러나 기본형 대신 래퍼클래스를 사용하는 것은 당연히 비효율적이다. 그래서 보다 효율적으로 처리할 수 있도록 기본형을 사용하는 함수형 인터페이스들이 제공된다.

Function의 합성과 Predicate의 결합

앞서 소개한 java.util.function 패키지의 함수형 인터페이스에는 추상메서드 외에도 디폴트 메서드와 static 메서드가 정의되어 있다. 우리는 Function과 Predicate에 정의된 메서드에 대해서만 살펴볼 것인데, 그 이유는 다른 함수형 인터페이스의 메서드도 유사하기 때문이다. 이 두 함수형 인터페이스에 대한 설명만으로도 충분히 응용이 가능할 것이다.

// Function
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)
static <T> Function<T, T> identity()

// Predicate
default Predicate<T> and(Predicate<? super T> other)
default Predicate<T> or(Predicate<? super T> other)
default Predicate<T> negate()
static <T> Predicate<T> isEqual(Object targetRef)

Function의 합성

수학에서 두 함수를 합성해서 하나의 새로운 함수를 만들어 낼 수 있다는 것처럼, 두 람다식을 합성해서 새로운 람다식을 만들 수 있다. 이미 알고 있는 것처럼, 두 함수의 합성은 어느 함수를 먼저 적용하느냐에 따라 달라진다. 함수 f, g가 있을 때, f.andThen(g)는 함수 f를 먼저 적용하고, 그 다음에 함수 g를 적용한다. 그리고 f.compose(g)는 반대로 g를 먼저 적용하고 f를 적용한다.

예를 들어, 문자열을 숫자로 변환하는 함수 f와 숫자를 2진 문자열로 변환하는 함수 g를 andThen(0으로 합성하여 새로운 함수 h를 만들어낼 수 있다.

Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, String> g = (i) -> Integer.toBinaryString(i);
Function<String, String> h = f.andThen(g);

함수 h의 지네릭 타입이 <String, String>이다. 즉, String을 입력받아 String을 결과로 반환한다. 예를 들어 함수 h에 문자열 FF를 입력하면, 결과로 11111111을 얻는다.

이번엔 compose()를 이용해서 두 함수를 반대 순서로 합성해보자.

Function<Integer, String> g = (i) -> Integer.toBinaryString(i);
Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, Integer> h = f.compose(g);

이전과 달리 함수 h의 지네릭 타입이 <Integer, Integer>이다. 함수 h에 숫자 2를 입력하면, 결과로 16을 얻는다.

그리고 identity()는 함수를 적용하기 이전과 이후가 동일한 항등 함수가 필요할 때 사용한다. 이 함수를 람다식으로 표현하면 x -> x이다. 아래의 두 문장은 동등하다.

Function<String, String> f = x -> x;
// Function<String, String> f = Function.identify(); // 위의 문장과 동일

System.out.println(f.apply("AAA")); // AAA가 그대로 출력됨

항등 함수는 잘 사용되지 않는 편이며, 나중에 배울 map()으로 변환작업할 때, 변환없이 그대로 처리하고자할 때 사용된다.

Predicate의 결합

여러 조건식을 논리 연산자인 &&(and), ||(or), !(not)으로 연결해서 하나의 식을 구성할 수 있는 것처럼, 여러 Predicate를 and(), or(), negate()로 연결해서 하나의 새로운 Predicate로 결합할 수 있다.

Predicate<Integer> p = i -> i < 100;
Predicate<Integer> q = i -> i < 200;
Predicate<Integer> r = i -> i % 2 == 0;
Predicate<Integer> notP = p.negate(); // i >= 100

Predicate<Integer> all = notP.and(q.or(r));
System.out.println(all.test(150)); // true

이처럼 and(), or(), negate()로 여러 조건식을 하나로 합칠 수 있다. 물론 아래와 같이 람다식을 직접 넣어도 된다.

Predicate<Integer> all = notP.and(i -> i < 200).or(i -> i % 2 == 0);

그리고 static메서드인 isEquals()은 두 대상을 비교하는 Predicate를 만들 때 사용한다. 먼저, isEquals()의 매개변수를 비교대상으로 하나 지정하고, 또 다른 비교대상은 test()의 매개변수로 지정한다.

Predicate<String> p = Predicate.isEquals(str1));
boolean result = p.test(str2);

위의 두 문장을 합치면 아래와 같다.

boolean result = Predicate.isEquals(str1).test(str2);

메서드 참조

람다식으로 메서드를 이처럼 간결하게 표현할 수 있다는 것에 충분히 감탄했을 텐데, 놀랍게도 람다식을 더욱 간결하게 표현할 수 있는 방법이 있다. 항상 그런 것은 아니고, 람다식이 하나의 메서드만 호출하는 경우에는 메서드 참조(methods reference)라는 방법으로 람다식을 간략히 할 수 있다. 예를 들어 문자열을 정수로 변환하는 람다식은 아래와 같이 작성할 수 있다.

Function<String, Integer> f = (String s) -> Integer.parseInt(s);

보통은 이렇게 람다식을 작성하는데, 이 람다식을 메서드로 표현하면 아래와 같다.

Integer wrapper(String s) { // 이 메서드의 이름은 의미없다.
	return Interger.parseInt(s);
}

위 코드보단 Integer.parseInt()를 직접 호출하는게 낫지 않을까?

Function<String, Integer> f = (String s) -> Integer.parseInt(s);

Function<String, Integer> f = Interger::parseInt; // 메서드 참조

위 메서드 참조에서 람다식의 일부가 생략되었지만, 컴파일러는 생략된 부분을 우변의 parseInt메서드의 선언부로부터, 또는 좌변의 Function 인터페이스에 지정된 지네릭 타입으로부터 쉽게 알아낼 수 있다.

한 가지 예를 더 보자. 아래의 람다식을 메서드 참조로 변경한다면 어떻게 되겠는가?

BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);

참조변수 f의 타입만 봐도 람다식이 두 개의 String타입의 매개변수를 받는 다는 것을 알 수 있으므로, 람다식의 매개변수들은 없어도 된다. 위의 람다식에서 매개변수들을 제거해서 메서드 참조로 변경하면 아래와 같다.

BiFunction<String, String, Boolean> f = String::equals; // 메서드 참조

매개변수 s1과 s2를 생략해버리고 나면 equals만 남는데, 두 개의 String을 받아서 Boolean을 반환하는 equals라는 이름의 메서드는 다른 클래스에도 존재할 수 있기 때문에 equals앞에 클래스 이름은 반드시 필요하다.

메서드 참조를 사용할 수 있는 경우가 한 가지 더 있는데, 이미 생성된 객체의 메서드를 람다식에서 사용한 경우에는 클래스 이름 대신 그 객체의 참조변수를 적어줘야 한다.

MyClass obj = new MyClass();
Function<String, Boolean> f = (x) -> obj.equals(x); // 람다식
Function<String, Boolean> f = obj::equals; // 메서드 참조

지금까지 3가지 경우의 메서드 참조에 대해서 알아봤는데, 정리하면 다음과 같다.

종류람다메서드 참조
static 메서드 참조(x) -> ClassName.method(x)ClassName::method
인스턴스 메서드 참조(obj, x) -> obj.method(x)ClassName::method
특정 객체 인스턴스 메서드 참조(x) -> obj.method(x)obj::method

하나의 메서드만 호출하는 람다식은 클래스이름::메서드이름 또는 참조변수::메서드이름으로 바꿀 수 있다.

생성자의 메서드 참조

생성자를 호출하는 람다식도 메서드 참조로 변환할 수 있다.

Supplier<MyClass> s = () -> new MyClass(); // 람다식
Supplier<MyClass> s = () -> MyClass::new; // 메서드 참조

매개변수가 이는 생성자라면 매개변수의 개수에 따라 알맞은 함수형 인터페이스를 사용하면 된다. 필요하다면 함수형 인터페이스를 새로 정의해야 한다.

Function<Integer, MyClass> f = (i) -> new MyClass(i); // 람다식
Function<Integer, MyClass> f2 = MyClass::new; // 메서드 참조

BiFunction<Integer, String, MyClass> bf = (i, s) -> new MyClass(i, s);
BiFunction<Integer, String, MyClass> bf2 = MyClass::new; // 메서드 참조

그리고 배열을 생성할 때는 아래와 같이 하면 된다.

Function<Integer, int[]> f = x -> new int[x]; // 람다식
Function<Integer, int[]> f = int[]::new; // 메서드 참조

메서드 참조는 람다식을 마치 static 변수처럼 다룰 수 있게 해준다. 메서드 참조는 코드를 간략히 하는데 유용해서 많이 사용된다. 람다식을 메서드 참조로 변환하는 연습을 많이해서 빨리 익숙해지기 바란다.

스트림(stream)

스트림이란?

지금까지 우리는 많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 Iterator를 이용해서 코드를 작성해왔다. 그러나 이러한 방식으로 작성된 코드는 너무 길고 알아보기 어렵다. 그리고 재사용성도 떨어진다.

또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 것이다. Collection이나 Iterator와 같은 인터페이스를 이용해서 컬렉션을 다루는 방식을 표준화하기는 했지만, 각 컬렉션 클래스에는 같은 기능의 메서드들이 중복해서 정의되어 있다. 예를 들어 List를 정렬할 때는 Collections.sort()를 사용해야하고, 배열을 정렬할 때는 Arrays.sort()를 사용해야 한다.

이러한 문제점들을 해결하기 위해서 만든 것이 스트림(stream)이다. 스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데이터 소스를 추상화하였다는 것은, 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다.

스트림을 이용하면, 배열이나 컬렉션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.

예를 들어, 문자열 배열과 같은 내용의 문자열을 저장하는 List가 있을 때,

String[] strArr = {"aaa", "ddd", "ccc"};
List<String> strList = Arrays.asList(strArr);

이 두 데이터 소스를 기반으로 하는 스트림은 다음과 같이 생성한다.

Stream<String> strStream1 = strList.stream(); // 스트림을 생성
Stream<String> strStream2 = Arrays.asList(strArr); // 스트림을 생성

이 두 스트림으로 데이터 소스의 데이터를 읽어서 정렬하고 화면에 출력하는 방법은 다음과 같다. 데이터 소스가 정렬되는 것은 아니라는 것에 유의하자.

strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);

두 스트림의 데이터 소스는 서로 다르지만, 정렬하고 출력하는 방법은 완전히 동일하다 예전에는 아래와 같이 코드를 작성해야 했을 것이다.

Arrays.sort(strArr);
Collection.sort(strList);

for (String str : strArr)
	System.out.println(str);
    
for (String str : strList)
	System.out.println(str);

스트림을 사용한 코드가 간결하고 이해하기 쉬우며 재사용성도 높다는 것을 알 수 있다.

스트림은 데이터 소스를 변경하지 않는다.

그리고 스트림은 데이터 소스로 부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있다. 필요하다면, 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.

// 정렬된 결과를 새로운 List에 담아서 반환한다.
List<String> sortedList = strStream2.sorted().collect(Collection.toList());

스트림은 일회용이다.

스트림은 Iterator처럼 일회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야한다.

strStream1.sorted().forEach(System.out::println);
int numOfStr = strStream1.count(); // 에러. 스트림이 이미 닫혔음.

스트림은 작업을 내부 반복으로 처리한다.

스트림을 이용한 작업이 간결할 수 있는 비결 중의 하나가 바로 내부 반복이다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용된다.

stream.forEach(System.out::println);

즉, forEach()는 메서드 안으로 for문을 넣은 것이다. 수행할 작업은 매개변수로 받는다.

void forEach(Consumer<? super T> action) {
	Objects.requireNonNull(action); // 매겨변수의 널 체크
    
    for (T t : src) { // 내부 반복
    	action.accept(T);
    }
}

스트림의 연산

스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다. 마치 데이터베이스에 SELECT문으로 질의(쿼리, query)하는 것과 같은 느낌이다.

스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있는데, 중간 연산은 연산결괄르 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면에 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.

중간 연산 : 연산 결과가 스트림인 연산. 스트림에 연속해서 중간 연산할 수 있음
최종 연산 : 연산 결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 단 한번만 가능

stream.distinct().limit(5).sorted().forEach(System.out::println)

모든 중간 연산의 결과는 스트림이지만, 연산 전의 스트림과 같은 것은 아니다. 위의 문장과 달리 모든 스트림 연산을 나누어 쓰면 아래와 같다. 각 연산의 반환타입을 눈여겨보자.

String[] strArr = { "dd", "aaa", "CC", "cc", "b" };
Stream<String> stream = Stream.of(strArr); // 문자열 배열이 소스인 스트림
Stream<String> filteredStream = stream.filter(); // 걸러내기 (중간 연산)
Stream<String> distinctedStream = stream.distinct(); // 중복제거 (중간 연산)
Stream<String> sortedStream = stream.sort(); // 정렬 (중간 연산)
Stream<String> limitedStream = stream.limit(5); // 스트림 자르기(중간 연산)
int total = stream.count(); // 요소 개수 세기 (최종연산)

Stream에 정의된 연산을 정리하면 다음과 같다. 아픙로 하나씩 자세히 설명할 것이므로 지금은 어떤 것들이 있다는 정도만 가볍게 봐주다.

중간 연산은 map()과 flatMap(), 최종 연산은 reduce()와 collect()가 핵심이다. 나머지는 이해하기 쉽고 사용법도 간단하다.

지연된 연산

스트림 연산에서 한 가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것이다. 스트림에 대해 distinct()나 sort() 같은 중간 연산을 호출해도 즉각적인 연산이 수행되는 것은 아니라는 것이다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야하는지를 지정해주는 것일 뿐이다. 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.

Stream<Integer>와 IntStream

요소의 타입이 T인 스트림은 기본적으로 Stream<T> 이지만, 오토박싱 & 언박싱으로 인한 비효율을 줄이기 위해 데이터 소스의 요소를 기본형으로 다루는 스트림, IntStream, LongStream, DoubleStream이 제공된다. 일반적으로 Stream<Integer>대신 IntStream을 사용하는 것이 더 효율적이고, IntStream에는 int타입의 값으로 작업하는데 유용한 메서드들이 포함되어 있다. 보다 자세한 것은 곧 설명할 것이다.

병렬 스트림

스트림으로 데이터를 다룰 때의 장점 중 하나가 바로 병렬 처리가 쉽다는 것이다. 앞서 13장에서 fork & join 프레임웍으로 작업을 병렬처리하는 것을 배웠는데, 병렬 스트림은 내부적으로 이 프레임웍을 이용해서 자동적으로 연산을 병렬으로 수행한다. 우리가 할 일이라고는 그저 스트림에 parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 될 뿐이다. 반대로 병렬로 처리되지 않게 하려면 sequential()을 호출하면 된다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 sequential()을 호출할 필요가 없다. 이 메서드는 parallel()을 호출한 것을 취소할 때만 사용한다.

int sum = strStream.parallel() // strStream을 병렬 스트림으로 전환
				   .mapToInt(s -> s.length())
                   .sum();

앞서 13장에서 설명한 것과 같이 병렬처리가 항상 더 빠른 결과를 얻게 해주는 것이 아니라는 것을 명심하자.

스트림 만들기

스트림으로 작업을 하려면, 스트림이 필요하니까 일단 스트림을 생성하는 방법부터 먼저 시작하자. 스트림의 소스가 될 수 있는 대상은 배열, 컬렉선은, 임의의 수 등 다양하며, 이 다양한 소스들로부터 스트림을 생성하는 방법에 대해서 배우게 될 것이다.

컬렉션

컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다. stream()은 해당 컬렉션을 소스(source)로 하는 스트림을 반환하다.

Sream<T> Collection.stream()

예를 들어 List로부터 스트림을 생성하는 코드는 다음과 같다.

List<Integer> list = Arrays.asList(1,2,3,4,5); // 가변인자
Stream<Integer> intStream = list.stream(); // list를 소스로 하는 컬렉션 생성

forEach()는 지정된 작업을 스트림의 모든 요소에 대해 수행한다. 아래의 문장은 스트림의 모든 요소를 화면에 출력한다.

intStream.forEach(System.out::println); // 스트림의 모든 요소를 출력한다.
intStream.forEach(System.out::println); // 에러. 스트림이 이미 닫혔다.

한 가지 주의할 점은 forEach()가 스트림의 요소를 소모하면서 작업을 수행하므로 같은 스트림에 forEach()를 두 번 호출할 수 없다는 것이다. 그래서 스트림의 요소를 한번 더 출력하려면 스트림을 새로 생성해야 한다. forEach()에 의해 스트림의 요소가 소모되는 것이지, 소스의 요소가 소모되는 것은 아니기 때뭄ㄴ에 같은 소스로부터 다시 스트림을 생성할 수 잇다.

forEach()에 대한 것은 나중에 더 자세히 배우기로 하고, 지금은 forEach()로 스트림의 모든 요소를 화면에 출력하는 방법만 알아두자.

배열

배열을 소스로 하는 스트림을 생성하는 메서드는 다음과 같이 Stream과 Arrays에 static 메서드로 정의되어 있다.

Stream<T> Stream.of(T... values) // rㅏ변인자
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)

예를 들어 문자열 스트림은 다음과 같이 생성한다.

Stream<String> strStream = Stream.of("a", "b", "c"); // 가변 인자
Stream<String> strStream = stream.of(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"}, 0, 3};

그리고 int, long, double과 같은 기본형 배열을 소스로 하는 스트림을 생성하는 메서드도 있다.

IntStream IntStrea.of(int... values) // Stream이 아니라 IntStream
IntStream IntStream.of(int[])
IntStream Arrays.stream(int[])
IntStream Arrays.stream(int[], array, int startInclusive, int endExclusive)

이 외에도 long과 double 타입의 배열로부터 LongStream과 DoubleStream을 반환하는 메서드들이 있지만 일일이 나열하지 않아도 쉽게 유추해낼 수 있을 것이므로 생략한다.

특정 범위의 정수

IntStream과 LongStream은 다음과 같이 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다.

IntStream IntStream.range(int begin, int end) // 1,2,3,4
IntStream IntStream.rangeClosed(int begin, int end) // 1,2,3,4,5

int보다 큰 범위의 스트림을 생성하려면 LongStream에 있는 동일한 이름의 메서드를 사용하면 된다.

임의의 수

난수를 생성하는데 사용하는 Random 클래스에는 아래와 같은 인스턴스 메서드들이 포함되어 있다. 이 메서드들은 해당 타입의 난수들로 이루어진 스트림을 반환한다.

IntStream ints()
LongStream longs()
DoubleStreeam doubles()

이 메서드들이 반환하는 스트림은 크기가 정해지지 않은 무한 스트림(infinite stream)이므로 limit()도 같이 사용해서 스트림의 크기를 제한해 주어야 한다. limit()은 스트림의 개수를 지정하는데 사용되며, 무한 스트림을 유한 스트림으로 만둘어 준다.

IntStream intStream = new Random().ints(); // 무한 스트림
intStream.limit(5).forEach(System.out::println); // 5개의 요소만 출력한다.

아래의 메서드들은 매개변수로 스트림의 크기를 지정해서 유한 스트림을 생성해서 반환하므로 limit()을 사용하지 않아도 된다.

IntStream ints(long streamSize)
LongStream longs(long streamSize)
DoubleStreeam doubles(long streamSize)

IntStream intStream = new Random().ints(5); // 크기가 5인 난수 스트림을 반환

람다식 - iterate(), generate()

Stream 클래스의 iterate()와 generate()는 람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.

iterate()는 씨앗값(seed)으로 지정된 값부터 시작해서, 람다식 f에 의해 계산된 결과를 다시 seed값으로 해서 계산을 반복한다. 아래의 evenStream은 0부터 시작해서 값이 2씩 계속 증가한다.

Stream<Integer> evenStream = Stream.iterate(0, n -> n + 2); // 0, 2, 4, 6, ...

generate()도 iterate()처럼, 람다식에 의해 계산되는 값을 요소로 하는 무한 스트림을 생성해서 반환하지만, iterate()와 달리, 이전 결과를 이용해서 다음 요소를 계산하지 않는다.

Stream<Double> randomStream = Stream.generate(Math::random);
Stream<Integer> oneStream = Stream.generate(() -> 1);

그리고 generate()에 정의된 매개변수의 타입은 Supplier<T>이므로 매개변수가 없는 람다식만 허용된다. 한 가지 주의할 점은 iterate()와 generate()에 의해 생성된 스트림을 아래와 같이 기본형 스트림 타입의 참조변수로 다룰 수 없다는 뜻이다.

IntStream evenStream = Stream.iterate(0, n -> n+2); // 에러
DoubleStream randomStream = Stream.generate(Math::random); // 에러

굳이 필요하다면, 아래와 같이 mapToInt()와 같은 메서드로 변환을 해야 한다.

IntStream evenStream = Stream.iterate(0, n -> n+2).mapToInt(Integer::valueOf);
Stream<Integer> stream = evenStream.boxed(); // IntStream -> Stream<Integer>

반대로 IntStream 타입의 스트림을 Stream<Integer> 탕비으로 변환하려면, boxed()를 사용하면 된다. 스트림간의 변환에 대해서는 나중에 모아서 같이 다룰 것이므로, 지금은 참고만 하자.

파일

java.nio.file.Files는 파일을 다루는데 필요한 유용한 메서드들을 제공하는데, list()는 지정된 디렉토리(dir)에 있는 파일의 목록을 소스로 하는 스트림을 생성해서 반환한다.

Stream<Path> Files.lists(Path dir)

이 외에도 Files 클래스에는 Path를 요소로 하는 스트림을 생성하는 메서드가 더 있지만, 이 장의 주제를 벗어나므로 설명을 생략한다.

그리고, 파일의 한 행(line)을 요소로 하는 스트림을 생성하는 메서드도 있다 아래의 세 번째 메서드는 BufferedReader 클래스에 속한 것인데, 파일 뿐만 아니라 다른 입력대상으로부터도 데이터를 행단위로 읽어올 수 있다.

Stream<String> Files.lines(Path path)
Stream<String> Files.lines(Path path, Charset cs)
Stream<String> lines() // BufferedReader 클래스의 메서드

빈 스트림

요소가 하나도 없는 비어있는 스트림을 생성할 수도 있다. 스트림에 연산을 수행한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는 것이 낫다.

Stream emptyStream = Stream.empty(); // empty()는 빈 스트림을 생성해서 반환한다.
long count = emptyStream.count(); // count의 값은 0

count()는 스트림 요소의 개수를 반환하며, 위의 문장에서 변수 count의 값은 0이 된다.

두 스트림의 연결

Stream의 static메서드인 concat()을 사용하면, 두 스트림을 하나로 연결할 수 있다. 물론 연결하려는 두 스트림의 요소는 같은 타입이어야 한다.

String[] str1 = {"123", "456", "789"};
String[] str2 = {"ABC", "abc", "DEF"};

Stream<String> strs1 = Stream.of(str1);
Stream<String> strs2 = Stream.of(str2);
Stream<String> strs3 = Stream.concat(strs1, strs2); // 두 스트림을 하나로 연결

스트림의 중간연산

스트림 자르기 - skip(), limit()

skip()과 limit()은 스트림의 일부를 잘라낼 때 사용하며, 사용법은 아주 간단하다. skip(3)은 처음 3개의 요소를 건너뛰고, limit(5)는 스트림의 요소를 5개로 제한한다.

Stream<T> skil(long n)
Stream<T> limit(long maxSize)

스트림의 요소 걸러내기 - filter(), distinct()

distinct()는 스트림에서 중복된 요소들을 제거하고, filter()는 주어진(Predicate)에 맞지 않는 요소를 걸러낸다.

Stream<T> filter(Predicate<? super T> predicate)
Stream<T> distinct()

distinct()의 사용 방법은 간단하다.

IntStream intStream = IntStream.of(1, 2, 2, 3, 3, 3, 4, 5, 5, 6);
intStream.distinct().forEach(System.out::print);

filter()는 매개변수로 Predicate를 필요로 하는데, 아래와 같이 연산결과가 boolean인 람다식을 사용해도 된다.

IntStream intStream = IntStream.rangeClosed(1, 10) // 1 ~ 10
intStream.filter(i -> i % 2 == 0).forEach(System.out::print); // 246810

필요하다면 filter()를 다른 조건으로 여러 번 사용할 수도 있다.

정렬 - sorted()

스트림을 정렬할 때는 sorted()를 사용하면 된다.

Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)

sorted()는 지정된 COmparator로 스트림을 정렬하는데, Comparator 대신 int값을 반환하는 람다식을 사용하는 것도 가능하다. Comparator를 지정하지 않으면 스트림의 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 단 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.

Stream<String> strStream = Stream.of("dd", "aaa", "CC", "cc", "b");
strStream.sorted().forEach(System.out::print); // CCaaabccdd

우의 코드는 문자열 스트림을 String에 정의된 기본 정렬(사전순 정렬)로 정렬해서 출력한다. 아래의 표는 문자열 스트림(strStream)을 다양한 방법으로 정렬한 후에 forEach(System.out::print)로 출력한 결과를 보여준다.

변환 - map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정형태로 변환해야 할 때가 있다. 이 때 사용하는 것이 바로 map()이다. 이 메서드의 선언부는 아래와 같으며, 매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야 한다.

Stream<R> map(Function<? super T, ? extends R> mapper)

예를 들어 File의 스트림에서 파일의 이름만 뽑아서 출력하고 싶을 때, 아래와 같이 map()을 이용하면 File객체에서 파일의 이름(String)만 간단히 뽑아낼 수 있다.

Stream<File> fileStream = Stream.of(new File("Ex1.java"), new File("Ex1")
, new File("Ex1.bak"), new File("Ex2.java"), new File("Ex1.txt"));

map() 역시 중간 연산이므로, 연산결과는 String을 요소로 하는 스트림이다. map()으로 Stream<File>Stream<String>으로 변환했다고 볼 수 있다.

그리고, map()도 filter()처럼 하나의 스트림에 여러 번 적용할 수 있다. 다음의 문장은 File의 스트림에서 파일의 확장자만을 뽑은 다음 중복을 제거해서 출력한다.

fileStream.map(File::getName) // Stream<File> -> Stream<String>
	.filter(s -> s.indexOf('.') != -1) // 확장자가 없는 것은 제외
    .map(s -> s.substring(s.indexOf('.') + 1)
    .map(String::toUpperCase)
    .distinct()
    .forEach(System.out::print);

조회 - peek()

연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면, peek()를 사용하자. forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러 번 끼워 넣어도 문자게 되지 않는다.

fileStream.map(File::getName) // Stream<File> -> Stream<String>
	.filter(s -> s.indexOf('.') != -1)
    .peek(s -> System.out.printf("filename = %s%n", s))
    .map(s -> s.substring(s.indexOf('.') + 1))
    .peek(s -> System.out.printf("Extension = %s%n", s))
    .forEach(System.out::print);

filter()나 map()의 결과를 확인할 때 유용하게 사용될 수 있다.

mapToInt(), mapToLong(), mapToDouble()

map()은 연산의 결과로 Stream<T> 타입의 스트림을 반환하는데, 스트림의 요소를 숫자로 변환하는 경우 IntStream과 같은 기본형 스트림으로 변환하는 것이 더 유용할 수 있다. Stream<T> 타입의 스트림을 기본형 스트림으로 변환할 때 사용하는 것이 아래의 메서드들이다.

DoubleStream	mapToDouble(ToDoubleFunction<? super T> mapper)
IntStream		mapToInt(ToIntFunction<? super T> mapper)
LongStream		mapToLong(ToLongFunction<? super T> mapper)

count()만 지원하는 Stream<T>와 달리 기본형 스트림은 아래와 같이 숫자를 다루는데 편리한 메서드를 제공한다.

int sum()
OptionalDouble average()
OptionalInt max()
OptionalInt min()

이 메서드들은 최종연산이기 때문에 호출 후에 스트림이 닫힌다는 점을 주의해야 한다.

flatMap() - Stream<T[]>를 Stream<T>로 변환

스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우, 즉 스트림의 타입이 Stream<T[]>인 경우, Stream<T>로 다루는 것이 더 편리할 때가 있다. 그럴 때는 map()대신 flatMap()을 사용하면 된다.

예를 들어 아래와 같이 요소가 문자열 배열(String[])인 스트림이 있을 때,

Stream<String[]> strArrStream = Stream.of(
		new String[]{"abc", "def", "ghi"},
        new String[]{"ABC", "GHI", "JKLMN"}
);

각 요소의 문자열들을 합쳐서 문자열이 요소인 스트림, 즉 Stream<String>으로 만드려면 어떻게 해야할까?

먼저 스트림의 요소를 변환해야하니까 일단 map()을 써야할 것이고 여기에 배열을 스트림으로 만들어주는 Arrays.stream(T[])를 함께 사용해보자.

Stream<Stream<String>> strStrStrm = strArrStrm.map(Arrays::stream);

예상한 것과 달리, Stream<String[]>map(Arrays::stream)으로 변환한 결과는 Stream<String>이 아닌, Stream<Stream<String>>이다. 즉, 스트림의 스트림인 것이다. 이 상황을 그림으로 그려보면 다음과 같다.

각 요소의 문자열들이 합쳐지지 않고, 스트림의 스트림의 형태가 되어버렸다. 이 때, 간단히 map()을 아래와 같이 flatMap()으로 바꾸기만 하면 우리가 원하는 결과를 얻을 수 있다.

Stream<String> strStrm = strArrStrm.flatMap(Arrays::stream);

위의 코드를 그ㄹ미으로 표현하면 아래와 같다.

flatMap()은 map()과 달리 스트림의 스트림이 아닌 스트림으로 만들어 준다.

Optional<T>와 OptionalInt

앞서 잠시 언급된 것과 같이 최종 연산의 결과 타입이 Optional인 경우가 있다. 최종 연산에 대해 배우기 전에 Optional에 대해서 알아보자.

Optional<T>은 지네릭 클리스로 T타입의 객체를 감싸는 래퍼 클래스이다. 그래서 Optional 타입의 객체에는 모든 타입의 참조변수를 담을 수 있다.

public final class Optional<T> {
	private final T value; // T타입의 참조변수
    	...
}

최종 연산의 결과를 그냥 반환하는 게 아니라 Optional 객체에 담아서 반환하는 것이다. 이처럼 객체에 담아서 반환을 하면, 반환한 결과가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단히 처리할 수 있다.

Optional 객체 생성하기

Optional 객체를 생성할 때는 of() 또는 ofNullable()을 사용한다.

String str = "abc";
Optional<String> optVal = Optional.of(str);
Optional<String> optVal = Optional.of("abc");
Optional<String> optVal = Optional.of(new String("abc"));

만일 참조변수의 값이 null일 가능성이 있다면, of()대신 ofNullable()을 사용해야한다. of()는 매개변수의 값이 null이면 NullPointException이 발생하기 때문이다.

Optional<T> 타입의 참조변수를 기본값으로 초기화할 때는 empty()를 사용한다. null로 초기화하는 것이 가능하지만, empty()로 초기하는 것이 바람직하다.

Optional<String> optVal = null; // 널로 초기화
Optional<String> optVal = Optional<String>.empty(); // 빈 객체로 초기화

Optional 객체의 값 가져오기

Optional 객체에 저장된 값을 가져올 때는 get()을 사용한다. 값이 null일 때는 NoSuchElementException이 발생하며, 이를 대비해서 orElse()로 대체할 값을 지정할 수 있다.

Optional<String> optVal = Optional.of("abc");
String str1 = optVal.get(); // optVal에 저장된 값을 반환. null이면 예외 발생
String str2 = optVal.orElse(""); // optVal에 저장된 값이 null일때는, ""반환

orElse()의 변형으로는 null을 대체할 값을 반환하는 람다식을 지정할 수 있는 orElseGet()과 null일 때 지정된 예외를 발생시키는 orElseThrow()가 있다.

String str3 = optVal2.orEsleGet(String::new);
String str4 = optVal2.orElseThrow(NullPointException::new);

Stream처럼 Optional 객체에서도 filter(), map(), flatMap()을 사용할 수 있다.

Optional<Option<T>>일 때, flatMap()을 사용하면 Optional<T>를 결과로 얻는다. 만일 Optional 객체의 값이 null이면 이 메서드들은 아무 일도 하지 않는다.

isPresent()는 Optional 객체의 값이 null이면 false를, 아니면 true를 반환한다.

if(Optional.ofNullable(str).isPresent()) {
	System.out.println(str);
}

이 코드를 ifPresent()를 이용해서 바꾸면 더 간단히 할 수 있다. 아래의 문장은 참조변수 str이 null이 아닐 때만 값을 출력한다.

Optional.ofNullable(str).ifPresent(System.out::println);

ifPresent()는 Optional<T>를 반환하는 findAny()나 findFirst()와 같은 최종 연산과 잘 어울린다. Stream 클래스에 정의된 메서드 중에서 Optional<T>를 반환하는 것들은 다음과 같다.

Optional<T> findAny()
Optional<T> findFirst()
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)
Optional<T> reduce(BinaryOperator<T> accumulator)

이처럼 Optional<T>를 결과로 반환하는 최종 연산 메서드들은 몇 개 없다. 심지어 max()와 min()같은 메서드들은 reduce()를 이용해서 작성된 것이다.

OptionalInt, OptionalLong, OptionalDouble

IntStream과 같은 기본형 스트림에는 Optional도 기본형을 값으로 하는 OptionalInt, OptionalLong, OptionalDouble을 반환한다.

스트림의 최종 연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산 후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다. 최종 연산의 결과는 스트림 요소의 합과 같은 단일 값이거나, 스트림의 요소가 담긴 배열 또는 컬렉션일 수 있다.

forEach()나 count() 같은 최종 연산의 일부는 이미 배웠으며, 나머지도 별로 어렵지 않다. 다만 collect()는 별도로 다뤄야 할 정도로 복잡하므로 별도의 단원으로 따로 설명할 것이다.

forEach()

forEach()는 peek()와 달리 스트림의 요소를 소모하는 최종연산이다. 반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용된다.

void forEach(Consumer<? super T> action)

조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는지 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들이다. 이 메서드들은 모두 매개변수로 Predicate를 요구하며, 연산결과로 boolean을 반환한다.

boolean allMatch (Predicate<? super T> predicate)
boolean anyMatch (Predicate<? super T> predicate)
boolean noneMatch (Predicate<? super T> predicate)

예를 들어 학생들의 성적 정보 스트림 stuStream에서 총점이 낙제점(총점 100이하)인 학생이 있는지 확인하는 방법은 다음과 같다.

boolean noFailed = stuStream.anyMatch(s -> s.getTotalScore() <= 100)

이외에도 스트림의 요소 중에서 조건이 일치하는 것을 반환하는 findFirst()가 있는데, 주로 filter()와 함께 사용되어 조건에 맞는 스트림의 요소가 있는지 확인하는데 사용된다. 병렬 스트림인 경우에는 findFirst() 대신 findAny()를 사용해야 한다.

Optional<Student> stu = stuStream.filter(s -> s.getTotalScore() <= 100).findFirst();
Optional<Student> stu = parallelStream.filter(s -> s.getTotalScore() <= 100).findAny();

통계 - count(), sum(), average(), max(), min()

앞서 살펴본 것처럼 IntStream과 같은 기본형 스트림에는 스트림의 요소들에 대한 통계 정보를 얻을 수 있는 메서드들이 있다. 그러나 기본형 스트림이 아닌 경우에는 통계와 관련된 메서드들이 아래의 3개뿐이다.

long 		count()
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)

리듀싱 - reduce()

reduce()는 이름에서 짐작할 수 있듯이, 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환한다. 그래서 매개변수의 타입이 BinaryOperator<T>인 것이다. 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다.

이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트림의 모든 요소를 소모하게 되면 그 결과를 반환한다.

Optional<T> reduce(BinaryOperator<T> accumlator)

이 외에도 연산결과의 초기값(indentity)를 갖는 reduce()도 있는데, 이 메서드들은 초기값과 스트림의 첫 번째 요소로 연산을 시작한다. 스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로, 반환 타입이 Optional<T>가 아니라 T이다.

collect()

스트림의 최종 연산 중에서 가장 복잡하면서도 유용하게 활용될 수 있는 것이 collect()이다. 그만큼 다뤄야할 내용이 많아서 별도의 단원으로 분리하였다.

collect()는 스트림의 요소를 수집하는 최종 연산으로 배운 리듀싱(reducing)과 유사하다. collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어 있어야 하는데, 이 방법을 정의한 것이 바로 컬렉터(collector)이다.

컬렉터는 Collector 인터페이스를 구현한 것으로 직접 구현할 수도 있고 미리 작성된 것을 사용할 수도 있다. Collectors 클래스는 미리 작성된 다양한 종류의 컬렉터를 반환하는 static 메서드를 가지고 있으며, 이 클래스를 통해 제공되는 컬렉터만으로도 많은 일들을 할 수 있다.

collect() : 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
Collector : 인터페이스, 컬렉터는 이 인터페이스로 구현해야 한다.
Collectors : 클래스, static 메서드로 미리 작성된 컬렉터를 제공한다.

Collector 인터페이스를 직접 구현해서 컬렉터를 만드는 방법은 다음 단원에서 배울 것이고, 이 단원에서는 Collectors 클래스가 제공하는 컬렉터들을 사용하는 방법에 대해서 배운다.

스트림을 컬렉션과 배열로 변환 - toList(), toSet(), toMap(), toCollection(), toArray()

스트림의 모든 요소를 컬렉션에 수집하려면, Collectors 클래스의 toList()와 같은 메서드를 사용하면 된다. List나 Set이 아닌 특정 컬렉션을 지정하려면 toCollection()에 해당컬렉션의 생성자 참조를 매개변수로 넣어주면 된다.

List<String> names = stuStream.map(Student::getName)
								.collect(Collectors.toList());
ArrayList<String> list = names.toStream()
							.collect(Collectors.toCollection(ArrayList::new));

Map은 키와 값의 쌍으로 저장해야하므로 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 지정해줘야 한다.

Map<String, Person> map = personStream
							.collect(Collectors.toMap(p -> p.getRegId(), p -> p));

위의 문장은 요소의 타입이 Person인 스트림에서 사람의 주민번호(regId)를 키로 하고, 값으로 Person 객체를 그대로 저장한다.

스트림에 저장된 요소들을 T[] 타입의 배열로 변환하려면 toArray()를 사용해주면 된다. 단, 해당 타입의 생정자 참조를 매개변수로 지정해줘야 한다. 만일 매개변수를 지정하지 않으면 반환되는 배열의 타입은 Object[]이다.

Student[] stuNames = studentStream.toArray(Student[]::new); // OK

Student[] stuNames = studentStream.toArray(); // 에러
Object[] stuNames = studentStream.toArray; // OK

통계 - counting(), summingInt(), averagingInt(), maxBy(), minBy()

각 메서드가 구체적으로 어떤 일을 하는지는 앞에서 이미 설명을 했으므로 생략한다.

리듀싱 - reducing()

Collector reducing(BinaryOperator<T> op)
Collector reducing(T identity, BinaryOperator<T> op) // T identity 는 초기화, BinaryOperator<T> op 은 누적작업을 의미
Collector reducing(U identity, Function<T, U> mapper, BinaryOperator<U> op) // map + reduce. 리듀싱하기 전에 변환(ex. map)이 필요할 경우 사용

문자열 결합 - joining()

문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 반환한다. 구분자를 지정해줄 수 있고, 접두사와 접미사를 지정가능하다. 스트림의 요소가 String이나 StringBuffer처럼 CharSequence의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 경우에는 먼저 map()을 이용해서 스트림의 요소를 문자열로 변환해야 한다.

String studentNames = stuStream.map(Student::getName).collect(joining()); //  Stream<Studnet> -> Stream<String>.
String studentNames = stuStream.map(Student::getName).collect(joining(",")); // 구분자임. 김자바, 이자바, 박자바, ...
String studentNames = stuStream.map(Student::getName).collect(joining(",", "[", "]")); // ,는 구분자, [와 ]는 앞뒤로. 그래서 [김자바, 이자바, 박자바, ...]

만일 map()없이 스트림에 바로 joining()하면, 스트림의 요소에 toString()을 호출한 결과를 결합한다.

String studentInfo = stuStream.collect(joining(",")); // Student의 toString()으로 결합

그룹화와 분할 - groupingBy(), partitioningBy()

그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고, 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치한지 않는 그룹으로의 분할을 의미한다. 아래의 메서드 정의에서 알 수 있듯이, groupingBy()는 스트림의 요소를 Function으로, partitioningBy()는 Predicate로 분류한다.

Collector groupingBy(Function classifier)
Collector groupingBy(Function classifier, Collector downstream)
Collector groupingBy(Function classifier, Supplier mapFactory, Collector downstream)

Collector partitioningBy(Predicate predicate)
Colelctor partitioningBy(Predicate predicate, Collector downstream)

메서드의 정의를 보면 groupingBy()와 paartitioningBy()가 분류를 Function으로 하느냐 Predicate로 하느냐의 차이만 있을 뿐 동일하다는 것을 알 수 있다. 스트림을 두 개의 그룹으로 나눠야 한다면, 당연히 partitioningBy()로 분할하는 것이 더 빠르다. 그 외에는 groupingBy()를 쓰면 된다. 그리고 그룹화와 분할의 결과는 Map에 담겨 반환된다.

partitioningBy()에 의한 분류

// 1. 기본 분할
Map<Boolean, List<Student>> stuBySex 
              = stuStream.collect(partitioningBy(Student::isMale));  // 학생들을 성별로 분할
List<Student> maleStudent = stuBySex.get(true);   // Map에서 남학생 목록을 얻는다.
List<Student> femaleStudent = stuBySex.get(false);  // 여학생 목록

// 2. 기본 분할 + 통계 정보
Map<Boolean, Long> stuNumBySex = stuStream.collect(partitioningBy(Student::isMale, counting()));
System.out.println(stuNumBySex.get(true));  // 8 (남학생수)
System.out.println(stuNumBySex.get(false));  // 10 (여학생수)


// 남학생 1등 구하기, mapBy()의 반환타입은 Optional<Student>
Map<Boolean, Optional<Student>> topScoreBySex
                = stuStream.collect(partitioningBy(Student::isMale, maxBy(comparingInt(Student::getScore))));
System.out.println(topScoreBySex.get(true));  // Optional{[남일등, 남, 1, 1, 300]}


// mapBy()의 반환타입이 Optional<Student>가 아닌 Student를 반환 결과로 얻으려면,  
// collectiongAndThen()과 Optional::get 함께 사용
Map<Boolean, student> topScoreBySex 
            = stuStream.collect(
                        partitioningBy(
                            Student::isMale, collectingAndThen(
                                              maxBy(comparingInt(Student::getScore))
                                              , Optional::get)));
                                              
// 3. 이중 분할
Map<Boolean, Map<Boolean, List<Student>>> failedStuBySex 
                         = stuStream.collect(
                                      partitioningBy(Student::isMale, partitioningBy(s->s.getScore()<150)));
List<Student> failedMaleStu = failedStuBySex.get(true).get(true);

groupingBy()에 의한 분류

일단 가장 간단한 그룹화를 해보자. stuStream을 반 별로 그룹지어 Map에 저장하는 방법은 다음과 같다.

Map<Integer, List<Student>> stuByBan = stuStream
						.collect(groupingBy(Student::getBan)); // toList()가 생략

groupingBy()로 그룹화를 하면 기본적으로 List<T>에 담는다. 그래서 위의 문장은 아래 문장의 생략된 형태이다. 만일 원한다면 toList() 대신 toSet()dㅣ나 toCollection(HashSet::new)을 사용할 수도 있다.

Map<Integer, List<Student>> stuByBan = stuStream
					.collect(groupingBy(Student::getBan, toList())); // toList 생략 가능							

모든 학생을 학년으로 나눈 후 반으로 나눈 학생들 후 성적을 map을 이용하여 등급별로 나눈다.

Map<Integer, Map<Integer, Set<Student.Level>>> stuByHakAndBan = stuStream
    .collect(
      groupingBy(Student::getHak, groupingBy(Student::getBan,     // 다중 그룹화(학년별, 반별)
            mapping(s -> {    // 성적등급(Level)으로 변환. List<Student> -> Set<Student.Level>
              if (s.getScore() >= 200) return Student.Level HIGH,
              else if (s.getScore() >= 100) return Student.Level MID,
              else                          return Student.Level LOW;
            }, toSet()) // mapping()              // enum Level {HIGH, MID, LOW}
      )) // groupingBy()
  ); // collect

Collector 구현하기

지금까지 Collectors 클래스가 제공하는 컬렉터를 사용하는 방법에 대해서 배웠으니, 이제 우리가 직접 컬렉터를 작성해 보자. 컬렉터를 작성한다는 것은 Collector 인터페이스를 구현한다는 것을 의미하는데, Collector 인터페이스는 다음과 같이 정의되어 있다.

public interface Collector<T, A, R> {
	Supplier<A>	supplier();
    BiConsumer<A, T>	accumulator();
    BinaryOperator<A>	combiner();
    Function<A, R>	finisher();
    
    Set<Charcteristics> characteristics(); // 컬렉터의 특성이 담긴 Set을 반환
    	...
}

supplier() : 작업 결과를 저장할 공간을 제공
accumulator() : 스트림의 요소를 수집(collect)할 방법을 제공
combiner() : 두 저장공간을 병합할 방법을 제공(병렬 스트림)
finisher() : 결과를 최종적으로 변환할 방법을 제공

Characteristics.CONCURRENT : 병렬로 처리할 수 있는 작업
Characteristics.UNORDERED : 스트림의 요소의 순서가 유지될 필요가 없는 작업
Characteristics.IDENTITY_FINISH : finisher()가 항등 함수인 작업

스트림의 변환

profile
서버 백엔드 개발자

0개의 댓글