Java 8 은 언어의 현대화와 프로그래밍 생산성을 향상시키기 위해 람다식과 스트림을 도입했다. 람다식은 Java 에 함수형 프로그래밍의 개념을 도입하여 코드를 간결하게 만들고, 스트림은 대량의 데이터를 효율적으로 처리할 수 있는 강력한 도구를 제공한다. 이번 글에서는 람다식과 스트림의 개념과 필요성, 기본 사용법, 그리고 이 둘의 결합 활용 방법에 대해 자세히 알아보려고 한다.
람다식은 익명 함수(Anonymous Function)라고도 불리며, 코드 내에서 이름 없는 함수를 정의하고 사용할 수 있는 표현식이다. Java 에서는 주로 간결하고 직관적인 방식으로 코드를 작성할 수 있도록 하기 위해 람다식을 도입했다.
람다식이 도입되기 전에는 Java 에서 함수형 프로그래밍을 구현하는 것이 복잡하고 번거로웠다. 특히, 익명 내부 클래스를 사용할 때 많은 반복되는 코드가 필요했다. 람다식을 사용하면 이러한 복잡성을 줄이고, 코드를 더욱 간결하고 가독성 있게 작성할 수 있다. 람다식의 주요 필요성은 다음과 같다.
람다식의 문법은 간단하고 직관적이다. 다음은 람다식의 기본 문법이다.
(parameters) -> expression
또는 블록을 사용하는 경우
(parameters) -> { statements; }
예를 들어, 두 수를 더하는 람다식은 다음과 같다.
(int x, int y) -> x + y
람다식을 사용하기 위해서는 함수형 인터페이스(Functional Interface)가 필요하다. 함수형 인터페이스는 단 하나의 추상 메서드만을 가지는 인터페이스를 의미한다. Java 8 에서는 @FunctionalInterface 어노테이션을 제공하여 함수형 인터페이스임을 명시할 수 있다. 다음은 함수형 인터페이스의 예이다.
@FunctionalInterface
public interface Calculator {
int calculate(int x, int y);
}
람다식을 사용하여 이 인터페이스를 구현할 수 있다.
Calculator adder = (int x, int y) -> x + y;
System.out.println(adder.calculate(10, 20)); // 출력: 30
Java 8 은 몇 가지 자주 사용되는 함수형 인터페이스를 제공한다. 대표적인 예는 다음과 같다.
Predicate<T> : 입력을 받아 boolean 값을 반환한다.Function<T, R> : 입력을 받아 변환된 값을 반환한다.Supplier<T> : 입력 없이 값을 반환한다.Consumer<T> : 입력을 받아 소비하고 반환값이 없다.BiFunction<T, U, R> : 두 입력을 받아 변환된 값을 반환한다.다음은 이러한 인터페이스를 사용하는 예이다.
Predicate<String> isEmpty = s -> s.isEmpty();
Function<String, Integer> length = s -> s.length();
Supplier<String> supplier = () -> "Hello, World!";
Consumer<String> printer = s -> System.out.println(s);
System.out.println(isEmpty.test("")); // 출력: true
System.out.println(length.apply("Hello")); // 출력: 5
System.out.println(supplier.get()); // 출력: Hello, World!
printer.accept("Hello, Lambda!"); // 출력: Hello, Lambda!
스트림(Stream)은 컬렉션, 배열 등의 데이터 소스를 처리하기 위한 추상화된 도구이다. 스트림을 사용하면 대량의 데이터를 효율적으로 처리할 수 있으며, 함수형 프로그래밍의 개념을 도입하여 복잡한 데이터 처리 작업을 간단하게 표현할 수 있다. 스트림의 주요 특징은 다음과 같다.
스트림이 도입되기 전에는 Java 에서 대량의 데이터를 처리하는 것이 복잡하고 번거로웠다. 스트림을 사용하면 이러한 복잡성을 줄이고, 데이터를 효율적으로 처리할 수 있다. 스트림의 주요 필요성은 다음과 같다.
스트림은 다양한 방법으로 생성할 수 있다. 컬렉션, 배열, 파일, 특정 값 등을 소스로 스트림을 생성할 수 있다. 다음은 스트림을 생성하는 예이다.
// 컬렉션에서 스트림 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
// 배열에서 스트림 생성
String[] array = {"a", "b", "c"};
Stream<String> arrayStream = Arrays.stream(array);
// 특정 값에서 스트림 생성
Stream<String> valueStream = Stream.of("a", "b", "c");
// 빈 스트림 생성
Stream<String> emptyStream = Stream.empty();
스트림은 중간 연산과 최종 연산으로 구성된다. 중간 연산은 스트림을 변환하고, 최종 연산은 결과를 생성한다. 다음은 중간 연산과 최종 연산의 예이다.
List<String> list = Arrays.asList("a", "b", "c", "d", "e");
// 중간 연산: 필터링, 매핑
Stream<String> filteredStream = list.stream().filter(s -> s.compareTo("c") > 0);
Stream<String> mappedStream = filteredStream.map(String::toUpperCase);
// 최종 연산: forEach, collect
mappedStream.forEach(System.out::println);
List<String> collectedList = list.stream()
.filter(s -> s.compareTo("c") > 0)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(collectedList); // 출력: [D, E]
스트림의 중간 연산은 스트림을 변환하는 연산으로, 여러 번 적용할 수 있다. 대표적인 중간 연산은 다음과 같다.
filter(Predicate<T>) : 조건을 만족하는 요소만 남긴다.map(Function<T, R>) : 요소를 다른 형태로 변환한다.flatMap(Function<T, Stream<R>>) : 요소를 스트림으로 변환하고 평탄화한다.sorted() : 요소를 정렬한다.distinct(): 중복 요소를 제거한다.limit(long n) : 처음 n개의 요소를 반환한다.skip(long n) : 처음 n개의 요소를 건너뛴다.스트림의 최종 연산은 스트림을 종료하고 결과를 생성하는 연산이다. 대표적인 최종 연산은 다음과 같다.
forEach(Consumer<T>) : 각 요소에 대해 동작을 수행한다.collect(Collector<T, A, R>) : 스트림의 요소를 컬렉션으로 변환한다.reduce(BinaryOperator<T>) : 스트림의 요소를 하나로 합친다.toArray() : 스트림의 요소를 배열로 변환한다.count() : 스트림의 요소 수를 반환한다.anyMatch(Predicate<T>) : 스트림의 요소 중 하나라도 조건을 만족하면 true 를 반환한다.allMatch(Predicate<T>) : 스트림의 모든 요소가 조건을 만족하면 true 를 반환한다.noneMatch(Predicate<T>) : 스트림의 모든 요소가 조건을 만족하지 않으면 true 를 반환한다.findFirst() : 스트림의 첫 번째 요소를 반환한다.findAny() : 스트림의 임의의 요소를 반환한다.람다식과 스트림을 결합하면 더욱 강력하고 유연한 데이터 처리를 구현할 수 있다. 람다식은 스트림의 각 요소에 대한 동작을 간결하게 표현할 수 있으며, 스트림은 대량의 데이터를 효율적으로 처리할 수 있다.
다음은 숫자 리스트에서 짝수만 필터링하고 제곱하여 출력하는 예제이다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.forEach(System.out::println);
다음은 문자열 리스트에서 길이가 3보다 큰 문자열만 필터링하고 대문자로 변환하여 리스트로 수집하는 예제이다.
List<String> words = Arrays.asList("apple", "banana", "cherry", "date", "fig", "grape");
List<String> result = words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result); // 출력: [APPLE, BANANA, CHERRY, GRAPE]
다음은 객체 리스트에서 특정 조건을 만족하는 객체만 필터링하고, 그 객체의 속성을 추출하여 리스트로 수집하는 예제이다.
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
List<Person> people = Arrays.asList(
new Person("Alice", 30),
new Person("Bob", 20),
new Person("Charlie", 40)
);
List<String> names = people.stream()
.filter(p -> p.getAge() > 25)
.map(Person::getName)
.collect(Collectors.toList());
System.out.println(names); // 출력: [Alice, Charlie]
람다식과 스트림은 Java 8에서 도입된 중요한 기능으로, Java 프로그래밍의 생산성과 효율성을 크게 향상시킨다. 람다식을 사용하면 코드를 간결하게 작성할 수 있으며 스트림을 사용하면 대량의 데이터를 효율적으로 처리할 수 있기 때문에, 적극적으로 활용해야겠다는 생각이 들었다.