람다식 (Lambda expression)
자바가 1996년에 처음 등장한 이후로 세 번의 큰 변화가 있었는데, 한 번은 JDK 5부터 추가된 지네릭스(generics)의 등장이고, 또 한 번은 JDK 8부터 추가된 람다식(lambda expression)의 등장, 그리고 마지막으로 JDK 9의 모듈화(modularity)입니다. 이 세가지 새로운 변화에 의해 자바는 더 이상 예전의 자바가 아니게 되었습니다.
특히 람다식의 도입으로 인해, 이제 자바는 객체지향언어인 동시에 함수형 언어가 되었습니다.
람다식이란?
람다식(Lambda expression)은 간단히 말해서 메서드를 하나의 '식(expression)'으로 표현한 것입니다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해줍니다. 메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 '익명 함수(anonymous function)'이라고도 합니다.
Arrays.setAll(arr, (i) -> (int)(Math.random() * 5) + 1);
예를 들어 위 람다식 문장을 메서드로 표현하면 아래와 같습니다.
int method(int i) {
return (int)(Math.random() * 5) + 1;
}
위 메서드보다 람다식이 훨씬 간결하면서 이해하기도 쉽습니다. 게다가 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야만 비로소 이 메서드를 호출할 수 있습니다. 그러나 람다식은 이 모든 과정없이 오직 람다식 자체만으로 이 메서드의 역할을 대신할 수 있습니다.
게다가 람다식은 메서드의 매개변수로 전달이 가능하고, 메서드의 결과로 반환될 수도 있어서 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해졌습니다.
객체지향개념에서는 함수(function)대신 객체의 행위나 동작을 의미하는 메서드(method)라는 용어를 사용합니다. 메서드는 함수와 근본적으로 같지만, 특정 클래스에 반드시 속해야 한다는 제약이 있습니다. 그러나 이제 람다식을 통해 메서드가 함수처럼 독립적으로 쓰일 수 있게 되었습니다.
람다식 작성
람다식을 작성하는 방법은 '익명 함수'답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통{ } 사이에 '->'를 추가하기만 하면 끝입니다.

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

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

JDK 11부터 람다식의 지역변수에도 var을 사용할 수 있게 되었습니다.
아래와 같이 선언된 매개변수가 하나뿐인 경우에는 괄호( )도 생략할 수 있습니다. 단, 매개변수의 타입이 있으면 생략할 수 없습니다.

마찬가지로 괄호{ } 안에 문장이 하나일 때는 괄호{ }를 생략할 수 있습니다. 이 때 문장의 끝에 세미콜론(;)을 붙이지 않아야 한다는 것에 주의해야 합니다.

함수형 인터페이스 (functional interface)
사실 람다식은 메서드와 동등한 것이 아니라, 익명 클래스의 객체 즉 익명 객체와 동등합니다.

메서드 이름 multiply는 임의로 붙인 것일 뿐 의미는 없습니다.
일반적인 객체의 메서드 호출과 같이, 익명 객체의 메서드를 호출하기 위해서는 참조 변수에 저장하고 호출해야 합니다. 그다음으로 참조 변수의 타입은 클래스나 인터페이스가 가능합니다. 그리고 람다식과 동등한 메서드가 정의되어 있어야 합니다.
예를 들어 아래와 같이 multiply()라는 메서드가 정의된 MyFunction인터페이스가 있다고 가정해보겠습니다.
interface MyFunction {
public abstract int multiply(int a, int b);
}
그러면 이 인터페이스를 구현한 익명 객체는 다음과 같이 생성할 수 있습니다.
MyFunction f = new MyFunction() {
public int multiply(int a, int b) {
return a * b;
}
};
int answer = f.multiply(3, 5);
MyFunction인터페이스에 정의된 메서드 multiply()를 람다식으로 바꾸면 아래와 같이 대체할 수 있습니다.
MyFunction f = (int a, int b) -> a * b; // 익명 객체를 람다식으로 대체
int answer = f.myltiply(3, 5);
이처럼 MyFunction인터페이스를 구현한 익명 객체를 람다식으로 대체 가능한 이유는, 람다식이 실제로는 익명 객체이고, MyFunction인터페이스를 구현한 익명 객체의 메서드와 람다식의 매개변수의 타입과 개수 그리고 반환값이 일치하기 때문입니다.
지금 살펴본 것처럼 하나의 메서드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바의 규칙들을 어기지 않고 자연스럽습니다. 그래서 인터페이스를 통해서 람다식을 다루기로 결정되었으며, 람다식을 다루기 위한 인터페이스를 '함수형 인터페이스(functional interface)'라고 부르기로 했습니다.
@FunctionalInterface
interface MyFunction {
public abstract int multiply(int a, int b);
}
단, 함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야 한다는 제약이 있습니다. 그래야 람다식과 인터페이스의 메서드가 1:1로 연결될 수 있기 때문입니다. 반면에 static메서드와 디폴트 메서드의 개수에는 제약이 없습니다.
@FunctionalInterface를 붙이면, 컴파일러가 함수형 인터페이스를 올바르게 정의하였는지 확인해주므로, 꼭 붙이는 것이 좋습니다.
람다식을 이용하면 기존의 복잡했던 인터페이스의 메서드 구현도 간단하게 처리할 수 있습니다.

함수형 인터페이스의 타입의 매개변수와 반환타입
함수형 인터페이스 MyFunction이 아래와 같이 정의되어 있을 때,
@FunctionalInterface
interface MyFunction {
void myMethod();
}
메서드의 매개변수가 MyFunction타입이면, 이 메서드를 호출할 때 람다식을 가리키는 참조 변수를 매개변수로 지정해야 한다는 뜻입니다.
void func(MyFunction f) { // 매개변수
f.myMethod();
}
MyFunction f = () -> System.out.println("myMethod");
func(f);
또는 참조변수 없이 아래와 같이 직접 람다식을 매개변수로 지정하는 것도 가능합니다.
func(() -> System.out.println("myMethod"));
그리고 메서드의 반환타입이 함수형 인터페이스타입이면, 이 함수형 인터페이스의 추상메서드와 동등한 람다식을 가리키는 참조변수를 반환하거나 람다식을 직접 반환할 수 있습니다.
MyFunction func() { // 반환타입
MyFunction f = () -> System.out.println("myMethod");
return f;
}
람다식을 참조변수로 다룰 수 있다는 것은 메서드를 통해 람다식을 주고받을 수 있다는 것을 의미합니다. 즉, 변수처럼 메서드를 주고받는 것이 가능해진 것입니다. 사실상 메서드가 아니라 객체를 주고받는 것이라 근본적으로 달라진 것은 아무것도 없지만, 람다식 덕분에 예전보다 코드가 더 간결하고 이해하기 쉬워졌습니다.
람다식의 타입과 형변환
함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스 타입과 일치하는 것은 아닙니다. 람다식은 익명 객체이고 익명 객체는 타입이 없습니다. 정확히는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없다는 것입니다. 그래서 대입 연산자의 양변의 타입을 일치시키기 위해서 아래와 같이 형변환이 필요합니다.
MyFunction f = (MyFunction)(() -> {});
람다식은 MyFunction인터페이스를 직접 구현하지 않았지만, 이 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 위와 같은 형변환을 허용합니다. 그리고 이 형변환은 생략도 가능합니다.
람다식은 이름이 없을 뿐 분명히 객체인데도, 아래와 같이 Object타입으로 형변환할 수 없습니다.
Object obj = (Object)(() -> {}); // Error
람다식은 오직 함수형 인터페이스로만 형변환이 가능합니다.
굳이 Object타입으로 형변환하려면, 먼저 함수형 인터페이스로 형변을 해야합니다.
Object obj = (Object)(MyFunction)(() -> {});
람다식의 접근성
람다식도 익명 객체, 즉 익명 클래스의 인스턴스이므로 람다식에서 외부에 선언된 변수에 접근하는 규칙은 익명 클래스에서와 동일합니다.
즉, 람다식 내에서 참조하는 지역변수는 final 또는 effectively final로 취급합니다.
java.util.function 패키지
대부분의 메서드는 형태가 비슷합니다. 매개변수가 없거나 한 개 또는 두 개, 반환 값은 없거나 한 개, 게다가 지네릭 메서드로 정의하면 매개변수나 반환타입이 달라도 문제가 되지 않습니다. 그래서 java.util.function패키지에 일반적으로 자주 쓰이는 형식이 메서드를 함수형 인터페이스로 미리 정의해 놓았습니다. 매번 새로운 함수형 인터페이스를 정의하지 말고, 가능하면 이 패키지의 인터페이스를 활용하는 것이 좋습니다.
java.util.function 패키지에 정의된 표준 함수형 인터페이스들은 모두 checked exception을 던지지 않도록 정의되어 있습니다. 따라서 이러한 인터페이스를 구현하는 람다식이나 메서드 참조 내부에서 checked exception을 발생시킬 수 있는 메서드를 사용할 경우, 반드시 람다 내부에서 try-catch로 직접 예외를 처리해야 합니다.
그래야 함수형 인터페이스에 정의된 메서드의 이름도 통일되고, 재사용성이나 유지보수 측면에서서 좋습니다. 자주 쓰이는 기본적인 함수형 인터페이스는 다음과 같습니다.
| 함수형 인터페이스 | 메서드 | 설명 |
| Runnable | void run() | 매개변수도 없고, 반환값도 없습니다. |
| Supplier<T> | T get() | 매개변수는 없고, 반환값만 있습니다. |
| Consumer<T> | void accept(T t) | 매개변수만 있고, 반환값은 없습니다. |
| Function<T, R> | R apply(T t) | 일반적인 함수처럼 하나의 매개변수를 받아서 결과를 반환합니다. |
| Predicate<T> | boolean test(T t) | 조건식을 표현하는데 사용됩니다. 매개변수가 있고, 반환타입은 boolean입니다. |
타입 문자 T 는 'Type'을, R 은 'Return Type'을 의미합니다.
조건식 표현에 사용되는 Predicate
Predicate는 Function의 변형으로, 반환타입이 boolean이라는 것만 다릅니다. Predicate는 조건식을 람다식으로 표현하는데 사용됩니다.
Predicate<String> isEmptyStr = s -> s.length() == 0;
String s = "";
if(isEmptyStr.test(s))
System.out.println("Empty");
매개변수가 2개인 함수형 인터페이스
매개변수의 개수가 2개인 함수형 인터페이스는 이름 앞에 접두사 'Bi'가 붙습니다.
| 함수형 인터페이스 | 메서드 | 설명 |
| BiConsumer<T, U> | void accept(T t, U u) | 두 개의 매개변수만 있고, 반환값이 없습니다. |
| BiPredicate<T, U> | boolean test(T t, U u) | 조건식을 표현하는데 사용됩니다. 매개변수가 두 개 있고, 반환값은 boolean입니다. |
| BiFunction<T, U, R> | R apply(T t, U u) | 두 개의 매개변수를 받아서 하나의 결과를 반환합니다. |
타입 문자 U 는 일반적으로 사용되는 타입문자인 T 의 다음 알파벳문자입니다.
자바의 메서드는 두 개 이상의 값을 반환할 수 없기 때문에 BiSupplier는 없습니다.
만일 3개 이상의 매개변수를 갖는 함수형 인터페이스가 필요하다면 다음처럼 직접 정의해서 사용해야 합니다.
@FunctionalInterface
interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
UnaryOperator와 BinaryOperator
Function의 또 다른 변형으로 UnaryOperator와 BinaryOperator가 있는데, 매개변수의 타입과 반환타입의 타입이 모두 일치한다는 점만 제외하고는 Function과 같습니다.
| 함수형 인터페이스 | 메서드 | 설명 |
| UnaryOperator<T> | T apply(T t) | Function의 자손. 매개변수의 타입과 반환타입이 같습니다. |
| BinaryOperator<T> | T apply(T t, T t) | BiFuntion의 자손. 매개변수의 타입과 반환타입이 같습니다. |
컬렉션 프레임워크와 함수형 인터페이스
컬렉션 프레임워크의 인터페이스에 다수의 디폴트 메서드가추가되었는데, 그 중의 일부는 함수형 인터페이스를 사용합니다. 다음은 그 메서드들의 목록입니다.
| 인터페이스 | 메서드 | 설명 |
| Iterator | void forEach(Consumer<T> action) | 모든 요소에 작업 action을 수행합니다. |
| Collection | boolean removeIf(Predicate<R> filter) | 조건에 맞는 요소를 삭제합니다. |
| List | void replaceAll(UnaryOperator<E> operator) | 모든 요소를 변환하여 대체합니다. |
| Map | V compute(K key, BiFunction<K, V, V> f) | 지정된 키의 값에 작업 f를 수행합니다. |
| V computeIfAbsect(K key, Function<K, V> f) | 키가 없으면, 작업 f를 수행합니다. | |
| V computeIfPresent(K key, BiFunction<K, V, V> f) | 지정된 키가 있으면, 작업 f를 수행합니다. | |
| V merge(K key, V avlue, BiFunction<V, V, V> f) | 모든 요소에 병합작업 f를 수행합니다. | |
| void forEach(BiConsumer<K, V> action) | 모든 요소에 작업 action을 수행합니다. | |
| void replaceAll(BiFunction<K, V, V> f) | 모든 요소에 치환작업 f를 수행합니다. |
Map인터페이스에 있는 'compute'로 시작하는 메서드들은 맵의 value를 변환하는 일을 하고, merge()는 Map을 병합하는 일을 합니다.
기본형을 사용하는 함수형 인터페이스
위에서 살펴본 함수형 인터페이스는 매개변수와 반환값의 타입이 모두 지네릭 타입이었는데, 기본형 타입을 사용하는 함수형 인터페이스들도 제공됩니다.
| 함수형 인터페이스 | 메서드 | 설명 |
| DoubleToIntFunction | int applyAsInt(double d) | A To B Function (입력은 A 타입, 출력은 B 타입) |
| ToIntFunction<T> | int applyAsInt(T value) | To B Function (입력은 지네릭 타입, 출력은 B 타입) |
| IntFunction<R> | R apply(T t, U u) | A Function (입력은 A 타입, 출력은 지네릭 타입) |
| ObjIntConsumer<T> | void accept(T t, U u) | Obj A Function (입력은 T, A 타입, 출력은 void) |
IntToIntFunction은 없는데, 바로 IntUnaryOperator가 그 역할을 합니다. 매개변수의 타입과 반환타입이 일치할 때는 앞서 배운 것처럼 Function 대신에 UnaryOperator를 사용합니다.
Function의 합성과 Predicate의 결합
앞서 소개한 java.util.function패키지의 함수형 인터페이스에는 추상메서드 외에도 디폴트 메서드와 static메서드가 정의되어 있습니다. 여기서 Function과 Predicate에 정의된 메서드에 대해서만 살펴볼 것인데, 그 이유는 다른 함수형 인터페이스의 메서드들도 유사하기 때문입니다.
// Function
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
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의 합성
두 람다식을 합성해서 새로운 람다식을 만들 수 있는데, 합성이라는 것은 단지 두 개의 람다식을 붙여서 하나로 만드는 것입니다. 두 람다식을 붙이는 방법은 아래와 같이 2가지 방법밖에 없으며, 연결 부분의 타입이 일치해야 합니다.
함수 f, g가 있을 때, `f.andThen(g)` 는 함수 f를 먼저 적용하고, 그 다음에 함수 g를 적용합니다.

그리고 `f.compose(g)` 는 반대로 g를 먼저 적용하고, 그 다음에 f를 적용합니다.

예를 들어, 문자열을 숫자로 변환하는 함수 f와 숫자를 2진 문자열로 변환하는 함수 g를 andThen()으로 합성하여 새로운 함수 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); // String -> String
반대로 compose()를 이용해서 순서를 거꾸로 합성할 수 있습니다.
Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, String> g = (i) -> Integer.toBinaryString(i);
Function<Integer, Integer> h = f.compose(g); // Integer -> Integer
그리고 identify()는 함수를 적용하기 이전과 이후가 동일한 '항등 함수'가 필요할 때 사용합니다. 이 함수를 람다식으로 표현하면 `x -> x`입니다. 다음 두 문장은 동등합니다.
항등 함수는 함수에 x를 대입하면 결과가 x인 함수를 말합니다. f(x) = x
Function<String, String> f = x -> x;
Function<String, String> f = Function.identiry();
항등 함수는 잘 사용되지 않는 편이며, 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();
Predicate<Integer> all = notP.and(q.or(r));
System.out.println(all.test(150));
이처럼 and(), or(), negate()로 여러 조건식을 하나로 합칠 수 있습니다. 물론 람다식을 직접 넣어도 됩니다.
그리고 static메서드인 isEqual()은 두 대상을 비교하는 Predicate를 만들 때 사용합니다. 먼저, isEqual()의 매개변수로 비교대상을 하나 지정하고, 또 다른 비교대상은 test()의 매개변수로 지정합니다.
Predicate<String> p = Predicate.isEqual(str1);
boolean result = p.test(str2);
메서드 참조
위에서 배운 람다식을 더욱 간결하게 표현할 수 있는 방법이 있습니다. 항상 가능한 것은 아니고, 람다식이 하나의 메서드만 호출하고 매개변수의 수정이 없는 경우에는 '메서드 참조(method reference)'라는 방법으로 람다식을 간략히 할 수 있습니다. 예를 들어 문자열을 정수로 변환하는 람다식은 아래와 같이 작성할 수 있습니다.
Function<String, String> f = (String s) -> Integer.parseInt(s);
이 람다식을 메서드로 표현하면 아래와 같습니다.
Integer parse(String s) {
return Integer.parseInt(s);
}
이 parse메서드는 그저 값을 받아서 Integer.parseInt()에게 넘겨주는 일만 할 뿐입니다. 차라리 이 거추장스러운 메서드를 벗겨내고 Integer.parseInt()를 직접호출하는 것이 나을 것 같습니다.

위 메서드 참조에서 람다식의 일부가 생략되었지만, 컴파일러는 생략된 부분을 우변의 parseInt메서드의 선언부로부터, 또는 좌변의 Function인터페이스에 지정된 지네릭 타입으로부터 쉽게 알아낼 수 있습니다.
다른 예시를 보겠습니다.
BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);
참조변수 f의 타입만 봐도 람다식이 두 개의 String타입의 매개변수를 받는 다는 것을 알 수 있으므로, 람다식의 매개변수들은 없어도 됩니다. 위의 람다식에서 매개변수들을 제거해서 메서드 참조로 변경하면 아래와 같습니다.

매개변수 s1과 s2를 생략해버리고 나면 equals만 남는데, 두 개의 String을 받아서 Boolean을 반환하는 equals라는 이름의 메서드는 다른 클래스에도 존재할 수 있기 때문에 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; // 메서드 참조
매개변수가 있는 생성자라면, 매개변수의 개수에 따라 알맞은 함수형 인터페이스를 사용하면 됩니다. 필요하다면 함수형 인터페이스를 새로 정의할 수도 있습니다.
BiFunction<Integer, String, MyClass> bf = (i, s) -> new MyClass(i, s); // 람다식
BiFunction<Integer, String, MyClass> bf = MyClass::new; // 메서드 참조
그리고 배열을 생성할 때는 아래와 같이 하면 됩니다.
Function<Integer, int[]> f = x -> new int[x]; // 람다식
Function<Integer, int[]> f = int[]::new; // 메서드 참조
메서드 참조는 람다식을 마치 static변수처럼 다룰 수 있게 해주고, 코드를 간략히 하는데 유용해서 많이 사용됩니다.
SAM Conversion
람다식(및 메서드 참조)은 익명 객체를 간결하게 표현하는 문법적 축약 형태이며, 자체적인 타입을 갖지 않습니다. 익명 객체는 특정 인터페이스를 구현해야 사용할 수 있으므로, 람다식 역시 오직 함수형 인터페이스로만 대입·형변환이 가능합니다.
따라서 람다식 또는 메서드 참조의 시그니처가 함수형 인터페이스의 추상 메서드와 호환된다면, 컴파일러가 이를 해당 인터페이스 구현체로 자동 변환해주는데, 이 과정을 SAM Conversion(Single Abstract Method Conversion) 이라고 부릅니다.
이러한 특성을 활용하면, 함수형 인터페이스를 인자로 받는 함수에 동일한 시그니처를 가진 일반 메서드를 그대로 전달할 수 있습니다.
다음 예제를 통해 살펴보겠습니다.
@FunctionalInterface
interface Operation {
int apply(int a, int b);
}
class Calculator {
static int execute(Operation op, int x, int y) {
return op.apply(x, y);
}
}
public class Main {
static int addNumber(int a, int b) { return a + b; }
public static void main(String[] args) {
// 1) 익명 객체 전달
Calculator.execute(new Operation() {
@Override
public int apply(int a, int b) {
return a + b;
}
}, 10, 3)
// 2) 람다 전달
Calculator.execute((a, b) -> a + b, 10, 3);
// 3) 메서드 참조 전달 (SAM Conversion)
Calculator.execute(this::addNumber, 10, 3);
}
}
코드를 통해, 시그니처만 맞으면 일반 메서드 역시 메서드 참조 형태로 함수형 인터페이스로 자동 변환(SAM Conversion)되어 사용될 수 있음을 확인할 수 있습니다.
스트림 (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.stream(strArr);
이 두 스트림으로 데이터 소스의 데이터를 읽어서 정렬하고 화면에 출력하는 방법은 다음과 같습니다.
이때 원본 데이터 소스가 정렬되는 것은 아니라는 것에 유의해야합니다.
strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);
두 스트림 데이터 소스는 서로 다르지만 정렬하고 출력하는 방법은 완전히 동일합니다.
Arrays.sort(strArr);
Collections.sort(strList);
for(String str : strArr)
System.out.println(str);
for(String str : strList)
System.out.println(str);
기존의 방식과 비교하면 스트림을 사용한 코드가 간결하고 이해하기 쉬우며 재사용성도 높다는 것을 알 수 있습니다.
1. 스트림은 데이터 소스를 변경하지 않습니다.
스트림은 데이터 소스로부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있습니다. 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있습니다.
2. 스트림은 일회용입니다.
스트림은 Iterator처럼 일회용입니다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한 번 사용하면 닫혀서 다시 사용할 수 없습니다. 필요하다면 스트림을 다시 생성해야합니다.
3. 스트림은 작업을 내부 반복으로 처리합니다.
스트림을 이용한 작업이 간결할 수 있는 비결중의 하나가 바로 '내부 반복'입니다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미합니다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스이 모든 요소에 적용합니다.
다음은 forEach()의 내부 동작을 요약한 코드로, 내부적으로 반복문을 사용하는 것을 알 수 있습니다.
void forEach(Consumer<? super T> action) {
do { } while (action.accept(element));
}
스트림의 연산
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있습니다. 마치 데이터베이스에 SELECT문으로 질의하는 것과 같은 느낌입니다.
스트림에 정의된 메서드 중에서 데이터 소스를 다루는 작업을 수행하는 것을 연산(operation)이라고 합니다.
스트림이 제공하는 연산은 중간 연산과 최종 연산으로 나눌 수 있는데, 중간 연산은 연산 결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있습니다. 반면에 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 마지막에 한 번만 가능합니다.

지연된 연산
스트림 연산에서 한 가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 점입니다. 스트림에 대해 distinct()나 sort() 같은 중간 연산을 호출해도 즉각적인 연산이 수행되는 것은 아니라는 것입니다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야 하는지를 지정해주는 것일 뿐, 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모됩니다.
Stream<Integer>와 IntStream
요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱 & 언박싱으로 인한 비효율을 줄이기 위해 데이터 소스의 요소를 기본형으로 다루는 스트림, IntStream, LongStream, DoubleStream이 제공됩니다. 일반적으로 Stream<Integer>대신 IntStream을 사용하는 것이 더 효율적이고, IntStream에는 int타입의 값으로 작업하는데 유용한 메서드들이 포함되어 있습니다.
병렬 스트림
스트림으로 데이터를 다룰 때의 장점 중 하나가 바로 병렬 처리가 쉽다는 것입니다. 앞서 thread에서 Fork&Join 프레임워크로 작업을 병렬처리하는 것을 배웠는데, 병렬 스트림은 내부적으로 Fork&Join 프레임워크를 이용해서 자동적으로 연산을 병렬로 수행합니다. 기본값은 직렬로 처리하는 sequential()이고, 병렬로 처리되게 하려면 parallel()을 호출하면 됩니다.
int sum = strStream.pararell()
.mapToInt(s -> s.length())
.sum();
parallel()과 sequential()은 새로운 스트림을 생성하는 것이 아니라, 그저 스트림의 속성을 변경할 뿐입니다.
스트림 만들기
스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하며, 이 다양한 소스들로부터 스트림을 생성하는 방법에 대해서 알아보겠습니다.
컬렉션
컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있습니다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있습니다.
Stream<T> Collection.stream()
배열
배열을 소스로 하는 스트림을 생성하는 메서드는 다음과 같이 Stream과 Arrays에 static메서드로 정의되어 있습니다.
Stream<T> Stream.of(T... values)
Stream<T> Stream.of(T[] array)
Stream<T> Arrays.stream(T[] array)
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)
그리고 int, long, double과 같은 기본형 배열을 소스로 하는 스트림을 생성하는 메서드도 있습니다.
IntStream IntStream.of(int... values)
IntStream IntStream.of(int[] array)
IntStream Arrays.stream(int[] array)
IntStream Arrays.stream(int[] array, int startInclusive, int endExclusive)
특정 범위의 정수
IntStream과 LongStream은 다음과 같이 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있습니다.
IntStream IntStream.range(int begin, int end)
IntStream IntStream.rangeClosed(int begin, int end)
range()는 경계의 끝인 end가 범위에 포함되지 않고, rangeClosed()는 포함됩니다.
임의의 수
난수를 생성하는데 사용하는 Random클래스는 아래와 같은 인스턴스 메서드들이 포함되어 있습니다. 이 메서드들은 해당 타입의 난수들로 이루어진 스트림을 반환합니다.
IntStream ints()
LongStream longs()
DoubleStream doubles()
이 메서드들이 반환하는 스트림은 크기가 정해지지 않은 '무한 스트림(infinite stream)'이므로 limit()도 같이 사용해서 스트림의 크기를 제한해 주어야 합니다. limit()은 스트림의 개수를 지정하는데 사용되며, 무한 스트림을 유한 스트림으로 만들어줍니다.
아래의 메서드들은 매개변수로 스트림의 크기를 지정해서 '유한 스트림'을 생성해서 반환하므로 limit()을 사용하지 않아도 됩니다.
IntStream ints(long streamSize)
LongStream longs(long streamSize)
DoubleStream doubles(long streamSize)
위 메서드들에 의해 생성된 스트림의 난수는 아래의 범위를 갖습니다.

지정된 범위의 난수를 발생시키는 스트림을 얻는 메서드들도 있습니다. 단, end는 범위에 포함되지 않습니다.
IntStream ints(int begin, int end) // 무한 스트림
IntStream ints(long streamSize, int begin, int end) // 유한 스트림
...
람다식 - iterate(), generate()
Stream의 iterate()와 generate()는 람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성합니다.
public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f)
public static<T> Stream<T> generate(Supplier<? extends T> s)
iterate()는 seed로 지정된 값부터 시작해서, 람다식 f에 의해 계산된 결과를 다시 seed값으로 해서 계산을 반복합니다.
JDK 9부터 `Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next)`가 추가되었습니다. hasNext에 조건을(Predicate)을 주면, 조건이 참인 동안만 요소가 만들어지는 유한 스트림을 생성합니다.
Stream<Integer> eventStream = Stream.iterate(0, n -> n < 10, n -> n + 2); // 0, 2, 4, 6, 8
generate()도 iterate()처럼, 람다식에 의해 계산되는 값을 요소로 하는 무한 스트림을 생성해서 반환한거지만, iterate()와 달리, 이전 결과를 이용해서 다음 요소를 계산하지 않습니다. 그리고 generate()에 정의된 매개변수 타입은 Supplier<T>이므로 매개변수가 없는 람다식만 허용됩니다.
Stream<Double> randomStream = Stream.generate(Math::random);
한 가지 주의할 점은 iterate()와 generate()에 의해 생성된 스트림을 아래와 같이 기본형 스트림 타입의 참조변수로 다룰 수 없다는 것입니다. 굳이 필요하다면 mapToInt()와 같은 메서드로 변환을 해야합니다.
IntStream eventStream = Stream.iterate(0, n -> n + 2); // Error
IntStream eventStream = Stream.iterate(0, n -> n + 2).mapToInt(Integer::valueOf);
파일
java.nio.file.Files는 파일을 다루는데 필요한 유용한 메서드들을 제공하는데, list()는 지정된 디렉터리(dir)에 있는 파일의 목록을 소스로 하는 스트림을 생성해서 반환합니다.
Stream<Path> Files.list(Path dir)
그리고 파일의 한 행(line)을 요소로 하는 스트림을 생성하는 메서드도 있습니다.
Stream<String> Files.lines(Path path)
Stream<String> Files.lines(Path path, Charset cs)
Stream<String> lines() // BufferedReader클래스
Stream<String> lines() // String클래스 (JDK 11)
이 메서드들 중 세번째 메서드는 BufferedReader클래스에 속한 것인데, 파일 뿐만 아니라 다른 입력대상으로부터도 데이터를 행단위로 읽어올 수 있습니다.
두 스트림의 연결
Stream의 static메서드인 concat()을 사용하면, 두 스트림을 하나로 연결할 수 있습니다. 물론 연결하려는 두 스트림의 요소는 같은 타입이어야 합니다.
Stream<String> st3 = Stream.concat(st1, st2);
빈 스트림
요소가 하나도 없는 비어있는 스트림을 생성할 수도 있습니다. 스트림에 연산을 수행한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는 것이 낫습니다.
Stream emptyStream = Stream.empty();
long count = emptyStream.count(); // 0
count()는 스트림의 요소의 개수를 반환합니다.
요소가 하나인 스트림
JDK 9부터 추가된 ofNullable()로 요소가 단 하나뿐인 스트림을 생성할 수도 있습니다. 매개변수로 null을 지정하면, 요소의 개수가 0인 '빈 스트림'을 반환합니다.
String e = "aaa";
Stream<String> oneStream = Stream.ofNullbale(e);
long count = oneStream.count(); // 1
이런 특징을 이용하면, 여러 스트림을 연결할 때 if문을 사용하지 않고도 값이 null인 경우에 값이 스트림에 포함되지 않게 할 수 있습니다.
String[] arr = { "aaa", null };
Stream<String> st1 = Stream.ofNullable(arr[0]);
Stream<String> st2 = Stream.ofNullable(arr[1]);
Stream<String> st3 = Stream.concat(st1, st2);
long count = st3.count(); // 1
스트림의 중간연산
스트림 자르기 - skip(), limit(), dropWhile(), takeWhile()
skip()과 limit()은 스트림의 일부를 잘라낼 때 사용하는데, skip()은 처음부터 지정된 개수만큼 요소를 건너뛰고, limit()은 스트림의 요소의 개수를 제한합니다. 기본형 스트림에도 반환 타입이 기본형 스트림은 함수가 정의되어 있습니다.
Stream<T> skip(long n)
Stream<T> limit(long maxSize)
JDK 9부터 추가된 dropWhile()과 takeWhile()은 건너뛰거나 잘라낸다는 점에서 skip(), limit()과 같은데, 요소의 개수 대신 조건식을 사용한다는 점이 다릅니다.
IntStream stream = IntStream.range(1, 10);
stream.dropWhile(n -> n < 5); // 1, 2, 3, 4
stream.takeWhile(n -> n < 5); // 5, 6, 7, 8, 9
dropWhile()은 조건식을 만족할 때까지 건너뛰고, takeWhile()은 조건식을 만족하지 않는 요소를 만나면 잘라냅니다.
스트림의 요소 걸러내기
distinct()는 스트림에서 중복된 요소들을 제거하고, filter()는 주어진 조건(Predicate)에 맞지 않는 요소를 걸러냅니다.
Stream<T> filter(Predicate<? super T> predicate)
Stream<T> distinct()
필요하다면 filter()를 다른 조건으로 여러 번 사용할 수도 있습니다.
정렬 - sort()
스트림을 정렬할 때는 sorted()를 사용하면 됩니다.
Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)
sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator 대신 int값을 반환하는 람다식을 사용하는 것도 가능합니다. Comparator를 지정하지 않으면 스트림의 요소의 기본 정렬 기준(Comparable)으로 정렬합니다. 단, 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생합니다.
JDK 8부터 Comparator인터페이스에 static메서드와 디폴트 메서드가 많이 추가되었는데, 이 메서드들을 이용하면 정렬이 쉬워집니다. 이 메서드들은 모두 Comparator<T>를 반환하며, 아래의 메서드 목록은 지네릭은 간단하게 표현 것입니다.
// Comparator의 디폴트 메서드
reversed()
thenComparing(Comparator<T> other)
thenComparing(Function<T, U> keyExtractor)
thenComparing(Function<T, U> keyExtractor, Comparator<U> keyComparator)
thenComparingInt(ToIntFunction<T> keyExtractor)
thenComparingLong(ToLongFunction<T> keyExtractor)
thenComparingDouble(ToDoubleFunction<T> keyExtractor)
// Comparator의 static메서드
naturalOrder()
reverseOrder()
comparing(Function<T, U> keyExtractor)
comparing(Function<T, U> keyExtractor, Comparator<U> keyExtractor)
comparingInt(ToIntFunction<T, U> keyExtractor)
comparingLong(ToLongFunction<T, U> keyExtractor)
comparingDouble(ToDoubleFunction<T, U> keyExtractor)
nullsFirst(Comparator<T> comparator)
nullsLast(Comparator<T> comparator)
정렬에 사용되는 메서드의 개수가 많지만, 가장 기본적인 메서드는 comparing()입니다. 스트림의 요소가 Comparable을 구현한 경우, 매개변수가 하나짜리를 사용하면 되고, 그렇지 않은 경우는 추가적으로 정렬기준(Comparator)을 따로 지정해 줘야합니다.
비교대상이 기본형인 경우, comparing()대신 각 자료형에 해당하는 comparing메서드를 사용하면 오토박싱과 언박싱과정이 없어서 더 효율적입니다. 그리고 정렬 조건을 추가할 때는 thenComparing()을 사용합니다.
예를 들어 학생 스트림을 반별, 성적순, 그리고 이름순으로 정렬하여 출력하면 다음과 같습니다.
studentStream.sorted(Comparator.comparing(Student::getBan)
.thenComparing(Student::getTotalScore)
.thenComparing(Student::getName)
.forEach(System.out::println);
변환 - map()
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있습니다. 이때 사용하는 것이 바로 map()입니다. 이 메서드의 선언부는 아래와 같이며, 매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야합니다.
Stream<R> map(Function<? super T, ? extends R> mapper)
map() 역시 중간 연산이므로, 연산결과는 String을 요소로 하는 스트림입니다. 그리고 map()도 filter()처럼 하나의 스트림에 여러 번 적용할 수 있습니다.
조회 - peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면, peek()를 사용하면 됩니다. forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러 번 끼워 넣어도 문제가 되지 않습니다.
fileStream.map(File::getName)
.peek(System.out::println)
.filter(s -> s.indexOf('.') != -1)
.forEach(System.out::println);
filter()나 map()의 결과를 확인할 때 유용하게 사용될 수 있습니다.
mapToInt(), mapToLong(), mapToDouble()
map()은 연산의 결과로 Stream<T>타입의 스트림을 반환하는데, 스트림의 요소를 숫자로 변환하는 경우 IntStream과 같은 기본형 스트림으로 변환하는 것이 더 유용할 수 있습니다. Stream<T>타입의 스트림을 기본형 스트림으로 변환할 때 사용하는 것이 아래의 메서드입니다.
IntStream mapToInt(ToIntFunction<? super T> mapper)
LongStream mapToLong(ToLongFunction<? super T> mapper)
DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper)
mapToInt()와 함께 자주 사용되는 메서드로는 parseInt()나 valueOf()가 있습니다.
stream.mapToInt(Integer::parseInt); // Stream<String> -> IntStream
stream.mapToInt(Integer::valueOf); // Stream<Integer> -> IntStream
count()만 지원하는 Stream<T>와 달리 기본형 스트림은 아래와 같이 숫자를 다루는데 편리한 메서드들을 제공합니다.
int sum()
OptionalDouble average()
OptionalInt max()
OptionalInt min()
max()와 min()은 Stream에도 정의되어 있지만, 매개변수로 Comparator를 지정해야 한다는 차이가 있습니다.
위 메서드들은 최종연산이기 때문에 호출 후 스트림이 닫힌다는 점을 주의해야 합니다. 따라서 하나의 스트림의 sum()과 average()를 연속해서 호출할 수 없습니다. 만약 sum()과 average()를 모두 호출하고 싶다면, 스트림을 또 생성해야하므로 불편합니다.
그래서 summaryStatistics()라는 메서드가 따로 제공됩니다.
IntSummaryStatistics stat = scoreStream.summaryStatistics();
long totalCount = stat.getCount();
long totalScore = stat.getSum();
double avgScore = stat.getAverage();
int minScore = stat.getMin();
int maxScore = stat.getMax();
mapToObj()
위에서 변환한 것과 반대로 기본형 스트림을 Stream<T>로 변환할 때는 mapToObj()를 사용하면 됩니다. 또는 boxed()를 사용하면 됩니다.
Stream<U> mapToObj(IntFunction<? extends mapper)
Stream<Integer> boxed()
참고로 CharSequence에 정의된 chars()는 String이나 StringBuffer에 저장된 문자들을 IntStream으로 다룰 수 있게 해줍니다.
flatMap() - Stream(T[])를 Stream<T>로 변환
스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우, 즉 스트림의 타입이 Stream(T[])인 경우, Stream<T>로 다루는 것이 더 편리할 때가 있습니다. 그럴 때는 map() 대신 flatMap()을 사용하면 됩니다.
예를 들어 아래와 같은 요소가 문자열 배열(String[])인 스트림이 있을 때,
Stream<String[]> st1 = Stream.of(
new String[]{ "abc", "def", "ghi" },
new String[]{ "ABC", "DEF", "GHI" }
);
map()을 사용하게 되면 다음처럼 됩니다.
Stream<Stream<String>> strStream = st1.map(Arrays::stream);
각 요소의 문자열들이 합쳐지지 않고, 스트림의 스트림 형태로 되어버렸습니다. 이때, 간단히 map()을 flatMap()으로 바꾸기만 하면 원하는 결과를 얻을 수 있습니다.
Stream<String> strStream = st1.flatMap(Arrays::stream);
mapMutil() - 하나의 요소를 여러 요소로 반환
이 메서드는 Java 16부터 새로 추가되었는데, 이름에서 짐작할 수 있듯이 하나의 요소를 변환해서 여러 요소를 만들 수 있습니다.
Stream<R> mapMulti(BiConsumer<T, Consumer<R>> mapper)
스트림의 요소 T를 R로 변환하여 Consumer<R>에게 제공하면 Stream<R>을 결과로 반환합니다.
mapMutli()가 있기 전에는 아래와 같이 했어야 했습니다.
Stream.of(1, 2, 3)
.flatMap(n -> Stream.of(n, -n))
.forEach(System.out::println);
// 1, -1, 2, -2, 3, -3
그런데 이렇게 하면 매번 내부적으로 Stream.of()를 반복생성해야 했습니다.
하지만 다음과 같이 mapMulti()를 사용하게 되면 중간에 새로운 스트림 객체를 만들지 않고 처리하기 때문에, 오버헤드를 없앨 수 있습니다.
Stream.of(1, 2, 3)
.mapMulti((n, consumer) -> {
consumer.accept(n);
consumer.accept(-n);
})
.forEach(System.out::println);
// 1, -1, 2, -2, 3, -3
Optional<T>
앞서 잠시 언급된 것과 같이 최종 연산의 결과 타입이 Optional인 경우가 있습니다. 최종 연산에 대해 살펴보기 전에 Opational에 대해서 알아보겠습니다.
Optional<T>는 지네릭 클래스로 "T타입의 객체"를 감싸는 래퍼 클래스입니다. 그래서 Optional타입의 객체에는 모든 타입의 참조변수를 담을 수 있습니다.
public final class Optional<T> {
private final T value;
...
}
최종 연산의 결과를 그냥 반환하는게 아니라 Optional객체에 담아서 반환하는 것입니다. 이처럼 결과를 객체에 담아서 반환을 하면, 반환된 결과가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단하게 처리할 수 있습니다. 이제 널 체크를 위한 if문 없이도 NullPointerException이 발생하지 않는 보다 간결하고 안전한 코드를 작성하는 것이 가능해진 것입니다.
Optional객체 생성하기
Optional객체를 생성할 때는 of() 또는 ofNullable()을 사용합니다.
Optional<String> optVal = Optional.of("abc");
Optional<String> optVal = Optional.ofNullable(null);
만일 변수의 값이 null일 가능성이 있으면, of()대신 ofNullable()을 사용해야 합니다.
Optional클래스는 다음과 같이 정의되어 있습니다.
public final class Optional<T> {
private static final Optional<?> EMPTY = new Optional<>(null);
private final T value;
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
...
}
Optional<T>타입의 참조 변수를 기본값으로 초기화할 때는 empty()를 사용합니다. 내부적으로 empty()는 Optional객체에 null을 넣어서 초기화합니다. null로 직접 초기화하는 것도 가능하지만, empty()로 초기화하는 것이 바람직합니다.
Optional<String> optVal = Optional.<String>empty();
empty()는 지네릭 메서드라서 앞에 <T>를 붙였는데, 대부분의 경우 추정 가능하므로 생략할 수 있습니다.
Optional객체의 값 가져오기
Optional객체에 저장된 값을 가져올 때는 get()을 사용합니다. 값이 null일 때는 NoSuchElementException이 발생하며, 이를 대비해서 orElse()로 대체할 값을 지정할 수 있습니다.
orElse()의 변형으로는 null을 대체할 값을 반환하는 람다식을 지정할 수 있는 orElseGet()과 null일 때 지정된 예외를 발생시키는 orElseThrow()가 있습니다.
T get()
T orElse(T other)
T orElseGet(Supplier<? extends T> supplier)
T orElseThrow(Supplier<? extends X> exceptionSupplier)
사용하는 방법은 다음과 같습니다.
String str = optVal.get();
String str = optVal.orElse("");
String str = optVal.orElseGet(String::new);
String str = optVal.orElseThrow(NullPointerException::new);
Stream처럼 Optional객체에도 filter(), map(), flatMap()을 사용할 수 있습니다. map()의 연산결과가 Optional<Optional<T>>일 때, flatMap()을 사용하면 Optional<T>를 결과로 얻습니다. 만일 Optional객체의 값이 null이면, 이 메서드들은 아무 일도 하지 않습니다.
Optional로 널 체크하기
isPresent()는 Optional객체의 값이 null이면 false를, 아니면 true를 반환합니다.
ifPresent(Consumer<T> block)은 값이 있으면 주어진 람다식을 실행하고, 없으면 아무일도 하지 않습니다.
if(str != null)
System.out.println(str);
만일 위와 같은 조건문이 있다면, isPresent()를 이용해서 다음과 같이 쓸 수 있습니다.
if(Optional.ofNullable(str).isPresent())
System.out.println(str);
이 코드를 ifPresent()를 이용해서 바꾸면 더 간단히 할 수 있습니다.
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)
max()와 min()도 내부적으로는 reduce()를 이용해서 작성된 것입니다.
OptionalInt, OptionalLong, OptionalDouble
IntStream과 같은 기본형 스트림에는 Optional도 기본형을 값으로 하는 OptionalInt, OptionalLong, OptionalDouble을 반환합니다.
아래는 IntStream에 정의된 메서드들입니다.
OptionalInt findAny()
OptionalInt findFirst()
OptionalInt max()
OptionalInt min()
OptionalInt reduce(IntBinaryOperator o)
OptionalDouble average()
반환 타입이 Optional<T>가 아니라는 것을 제외하면 Stream에 정의된 것과 비슷합니다.
그리고 기본형 Optional에서 값을 꺼낼 때 사용하는 메서드의 이름이 조금씩 다릅니다.
T get() // Optional<T>
int getAsInt() // OptionalInt
long getAdLong() // OptionalLong
double getAsDouble() // OptionalDouble
OptionalInt는 다음과 같이 정의되어 있습니다.
public final class OptionalInt {
private static final OptionalInt EMPTY = new OptionalInt();
private final boolean isPresent;
private final int value;
private OptionalInt() {
this.isPresent = false;
this.value = 0;
}
...
}
처음 생성될 떄 기본형 int의 기본값인 0이 저장되지만, isPresent가 false이기 때문에 직접 저장한 0과 구분이 가능합니다.
스트림의 최종연산
최종 연산은 스트림의 요소를 소모해서 결과를 만들어냅니다. 그래서 최종 연산후에 스트림이 닫히게 되고 더 이상 사용할 수 없습니다. 최종 연산의 결과는 스트림 요소의 합과 같은 단일 값이거나, 스트림의 요소가 담긴 배열 또는 컬렉션일 수 있습니다.
forEach()
forEach()는 peek()과 달리 스트림의 요소를 소모하는 최종연산입니다. 반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용됩니다.
void forEach(Consumer<? super T> action)
조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()
스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는 지, 일부가 일치하는지 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들입니다. 이 메서드들은 모두 매개변수로 Predicate를 요구하며, 연산결과로 boolean을 반환합니다.
boolean anyMatch(Predicate<? super T> predicate)
boolean allMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)
Optional<T> findFirst()
Optional<T> findAny()
이외에도 스트림의 요소 중에서 조건에 일차하는 첫 번째 것을 반환하는 findFirst()가 있는데, 주로 filter()와 함께 사용되며 조건에 맞는 스트림의 요소가 있는지 확인하는데 사용됩니다. 병렬 스트림인 경우에는 findFirst() 대신에 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()는 이름에서 짐작할 수 있듯이, 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환합니다. 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산을 합니다. 그래서 매개변수의 타입이 BinaryOperater<T>인 것입니다.
이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트림의 모든 요소를 소모하면 그 결과를 반환합니다.
Optional<T> reduce(BinaryOperator<T> accumulator)
이 외에도 연산 결과의 초기값(identity)을 갖는 reduce()도 있는데, 이 메서드들은 초기값과 스트림의 첫 번째 요소로 연산을 시작합니다. 스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로 반환타입은 Optional<T>가 아니라 T 입니다.
T reduce(T identity, BinaryOperator<T> accumulator)
U reduce(U identity, BiFunction<U, T, U> accumulator, BinaryOperator<U> combiner)
위 두 번째 메서드의 마지막 매개변수인 combiner는 병렬 스트림에 의해 처리된 결과를 합칠 때 사용하기 위한 것입니다.
max()와 min()의 경우, 내부적으로 모두 reduce()를 이용해서 작성되었습니다.
collect()
스트림의 최종 연산중에서 가장 복잡하면서도 유용하게 활용될 수 있는 것이 collect()입니다. collect()는 스트림의 요소를 수집하는 최종연산으로 앞서 배운 리듀싱(reducing)과 유사합니다. collect()가 스트림의 요소를 수집할 때, 어떻게 수집할 것인가에 대한 방법을 정의한 것이 바로 컬렉터(collector)입니다. 컬렉터는 Collector인터페이스를 구현한 것으로, 직접 구현할 수도 있고, 미리 작성된 것을 사용할 수도 있습니다. Collectors클래스는 미리 작성된 다양한 종류의 컬렉터를 반환하는 static메서드를 가지고 있으며, 이 클래스를 통해 제공되는 컬렉터만으로도 많은 일들을 할 수 있습니다.
collect()는 매개변수의 타입이 Collector인데, 매개변수가 Collector를 구현한 클래스의 객체이어야 한다는 뜻입니다.
R collect(Collector<? super T, A, R> collector)
R collect(Supplier<R> supplier, BiConsumer<R, T> accumulator, BiConsumer<R, R> combiner)
기본형 스트림에는 매개변수 3개짜리 collect()만 정의되어 있기 때문에, 매개변수 1개짜리 collect()를 사용하기 위해서는 boxed()를 통해 Stream<T>로 변환해야 합니다. 매개변수가 3개짜리 collect()는 잘 사용되지는 않지만, Collector인터페이스를 구현하지 않고 간단히 람다식으로 수집할 때 사용하면 편리합니다.
컬렉션과 배열로 변환
스트림의 모든 요소를 컬렉션에 수집하려면, Collectors클래스의 toList()와 같은 메서드를 사용하면 됩니다. List나 Set이 아닌 특정 컬렉션을 지정하려면, toCollection()에 해당 컬렉션의 생성자 참조를 매개변수로 넣어주면 됩니다.
List<String> list = stuStream.map(Student::getName)
.collect(Collectors.toList());
ArrayList<String> list = stuStream.map(Student::getName)
.collect(Collectors.toCollection(ArrayList::new));
Map은 키와 값의 쌍으로 저장해야하므로 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 지정해줘야 합니다.
Map<String, Person> map = personStream
.collect(Collectors.toMap(p::getId, p -> p));
항등 함수는 입력을 그대로 반환하는 함수를 말하며, 람다식 `p -> p` 대신에 Function.indenty()를 쓸 수도 있습니다.
스트림에 저장된 요소들을 'T[]' 타입의 배열로 변환하려면, toArray()를 사용하면 됩니다. 단, 해당 타입의 생성자 참조를 매개변수로 지정해줘야 합니다. 만일 매개변수를 지정하지 않으면 반환되는 배열의 타입은 'Object[]'입니다.
Student[] stuNames = studentStream.toArray(Student[]::new);
Object[] stunames = studentStream.toArray();
toArray()는 매개변수로 IntFunction<A[]>를 받는데, 배열은 int값을 받아서 객체를 생성하는 람다식으로 표현할 수 있기 때문입니다.
ex. (int size) -> new Student[size];
통계 - counting(), summingInt(), averagingInt(), maxBy(), minBy()
앞서 살펴보았던 최종 연산들이 제공하는 통계 정보를 collect()로 동일하게 얻을 수 있습니다. 통계 메서드를 제공해주는데 굳이 collect()를 사용하는 방법이 따로 있는 것은 groupingBy()와 함께 사용할 때를 위해서입니다.
다음은 counting()과 summingInt()의 예시입니다.
long count = stuStream.count();
long count = stuStream.collect(Collectors.counting());
long totalScore = stuStream.mapToInt(Student::getTotalScore).sum();
long totalScore = stuStream.collect(Collectors.summingInt(Student::getTotalScore));
리듀싱 - reducing()
리듀싱 역시 collect()로 가능합니다.
int sum = Stream.of(1, 2, 3, 4)
.reduce(0, (a, b) -> a + b);
int sum = Stream.of(1, 2, 3, 4)
.collect(Collectors.reducing(0, (a, b) -> a + b));
Collectors.reducing()에는 reduce()에는 없는 map()과 reduce()를 하나로 합쳐 놓은 메서드가 하나가 더 있습니다.
Collector reducing(U identity, Function<? super T, ? extends U> mapper, BinaryOperator<U> op)
문자열 결합 - joining()
문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 반환합니다. 구분자를 지정해줄 수도 있고, 접두사와 접미사도 지정가능합니다. 스트림의 요소가 String이나 StringBuffer처럼 CharSequence의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 때는 먼저 map()을 이용해서 스트림의 요소를 문자열로 변환해야 합니다.
String studentNames = stuStream.map(Student::getName).collect(joining());
String studentNames = stuStream.map(Student::getName).collect(joining(","));
그룹화와 분할 - groupingBy(), partitioningBy()
그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고, 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치하는 그룹과 일치하지 않는 그룹으로의 분할을 의미합니다. groupingBy()는 스트림의 요소를 Function으로, partitioningBy()는 Predicate로 분류합니다.
Collector groupingBy(Function<T, K> classifier)
Collector groupingBy(Function<T, K> classifier, Collector downstream)
Collector groupingBy(Function<T, K> classifier, Supplier<M> mapFactory, Collector downstream)
Collector partitioningBy(Predicate<T> predicate)
Collector partitioningBy(Predicate<T> predicate, Collector downstream)
메서드의 정의를 보면 groupingBy()와 partitioningBy()가 분류를 Function으로 하느냐 Predicate로 하느냐의 차이만 있을 뿐 동일하다는 것을 알 수 있습니다. 스트림을 두 그룹으로 나눠야 하는 경우가 아니라면 groupingBy()를 사용하면 됩니다. 그룹화와 분할의 결과는 기본적으로 List로 값을 모아서 Map에 담겨 반환됩니다.
다음은 partitioningBy()의 사용 방법입니다.
// partitioningBy()
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Map<Boolean, List<Integer>> result =
numbers.stream().collect(Collectors.partitioningBy(n -> n % 2 == 0));
Map<Boolean, List<Integer>> result =
numbers.stream().collect(Collectors.partitioningBy(n -> n % 2 == 0), Collectors.counting());
result.get(true);
result.get(false);
key 값의 타입이 Boolean이기 때문에 'get(true)', 'get(false)'를 통해 분할된 두 그룹에 접근할 수 있습니다.
maxBy()는 반환타입이 Optional<T>인데, collectingAndThen()과 Optional::get을 함께 사용하면 Optional 없이 바로 객체로 결과를 받을 수 있습니다.
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Map<Boolean, Optional<Integer>> maxByEven =
numbers.stream()
.collect(Collectors.partitioningBy(
n -> n % 2 == 0,
Collectors.maxBy(Comparator.naturalOrder())
));
Map<Boolean, Integer> maxByEvenClean =
numbers.stream()
.collect(Collectors.partitioningBy(
n -> n % 2 == 0,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.naturalOrder()),
Optional::get
)
));
또한 partitioningBy()를 한 번 더 사용해서 이중 분할도 가능합니다.
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Map<Boolean, Map<Boolean, List<Integer>>> result =
numbers.stream()
.collect(Collectors.partitioningBy(
n -> n % 2 == 0,
Collectors.partitioningBy(
n -> n > 5
)
));
groupingBy()의 결과 맵의 키 타입이 Boolean이 아닌 Function<T, K>의 K라는 점이 다르고, 사용 방법은 partitioningBy()와 동일합니다.
'Lang > Java' 카테고리의 다른 글
| [Java 21] (17) - I/O 2 (0) | 2025.11.21 |
|---|---|
| [Java 21] (16) - I/O 1 (0) | 2025.11.19 |
| [Java 21] (14) - thread 2 (0) | 2025.11.13 |
| [Java 21] (13) - thread 1 (0) | 2025.11.11 |
| [Java 21] (12) - modern Java features (0) | 2025.11.05 |