Java의 람다(Lambda)와 스트림(Stream)이 뭘까? 왜 사용할까?
Java는 객체지향 프로그래밍이 주류였던 1990년대에 설계되었다. 당시 객체지향 프로그래밍은 코드의 재사용성과 설계의 유연성을 제공하며 소프트웨어 개발의 주요 패러다임이었지만, 병렬 처리나 이벤트 기반 프로그래밍의 복잡성을 해결하기엔 한계가 있었다.
한편, 함수형 프로그래밍 언어인 Lisp이나 Scheme 같은 언어들은 오래전부터 존재했지만, 주목받지 못했다. 그러나 최근에 병렬 처리와 이벤트 지향 프로그래밍의 중요성이 대두되면서, 함수형 프로그래밍이 재조명되었다.
Java는 이러한 변화에 발맞추어, 객체지향 프로그래밍과 함수형 프로그래밍을 혼합하여 더 유연하고 강력한 언어가 되고자 했다. 그 결과, Java 8에서는 람다식과 스트림 API가 도입되었다. 이 두 기능은 코드의 간결성, 데이터 처리의 효율성, 병렬 처리를 통한 성능 향상 등 다양한 장점을 제공하여 Java의 기능을 확장시키고 현대적인 프로그래밍 스타일을 지원하게 되었다.
람다식(Lambda Expression)
람다식이란?
람다식은 메서드를 하나의 식(expression)으로 간단하게 표현한 구문이다.
기존의 메소드는 이름과 반환 타입을 가지지만, 람다식은 메서드의 이름과 반환 타입을 생략하고 매개변수와 본문만을 사용하여 익명 함수(anonymous function)로 불리기도 한다. 이를 통해 코드가 간결해지고 가독성이 높아지며, 복잡한 익명 클래스를 간단한 식으로 대체할 수 있게 되었다.
람다식 작성법
람다식은 기존 메소드의 이름과 반환 타입을 생략하고, 매개변수와 본문 사이에 ->
연산자를 사용하여 정의한다. 다음은 일반 메서드와 람다식의 예시이다.
// 일반 메서드
int max(int a, int b) {
return a > b ? a : b;
}
// 람다식으로 변경
(int a, int b) -> {
return a > b ? a : b;
}
// return을 '식'으로 변경
(int a, int b) -> a > b ? a : b
// 매개변수 타입 생략
(a, b) -> a > b ? a : b
- 반환 값이 있는 경우, return 문 대신 ‘식(expression)’ 으로 대신 할 수 있고, 식의 연산 결과가 반환 값이 된다.
- 단일 표현식일 경우, 몸통{ }을 없애고 끝에 ‘;’를 붙이지 않는다.
- 람다식에 선언된 매개변수의 타입은 추론이 가능한 경우 대부분 생략할 수 있다. 하지만 여러 개의 매개변수가 있는 경우 일부만 생략할 수는 없다.
함수형 인터페이스(Functional Interface)란?
람다식의 형태는 매개 변수를 가진 코드 블록이기 때문에 마치 ‘메서드’를 선언하는 것처럼 보인다. 하지만 Java는 메서드를 단독으로 선언할 수 없고, 항상 클래스의 구성 멤버로 선언하기 때문에 람다식은 이 메서드를 가지고 있는 ‘객체’를 생성해 내는 것으로 볼 수 있다.
그렇다면 어떤 ‘타입’의 객체를 생성하는 것일까?
참조타입(인터페이스) 변수명 = 람다식; // 일반적인 참조타입 변수 선언
Java8 이전에는 익명 클래스와 같은 방법으로 일회성으로 특정 메서드를 정의할 수 있었다. 하지만 코드 가독성과 불필요한 객체 생성 등의 문제로 함수형 프로그래밍 스타일을 도입하면서, 함수형 인터페이스를 통해 람다식이 구현체로 사용될 수 있는 표준화된 참조 타입을 제공한다.
즉, 람다식은 익명 클래스를 대체하는 것과 같고, 그렇기 때문에 참조 타입은 인터페이스가 된다.
// 1. 익명 클래스로 구현된 함수형 인터페이스 Runnable
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("익명 클래스로 구현된 스레드 실행");
}
};
// 2. 람다식으로 구현된 함수형 인터페이스 Runnable
Runnable task = () -> System.out.println("람다식으로 구현된 스레드 실행");
task.run();
람다식은 대입될 인터페이스의 종류에 따라 작성 방법이 달라지기 때문에 람다식이 대입될 인터페이스를 람다식의 타겟 타입(target type)이라고 한다.
하지만 모든 인터페이스를 람다식의 타겟 타입으로 사용할 수는 없다. 람다식은 단 ‘하나’의 추상 메서드를 정의하기 때문에 두 개 이상의 추상 메서드가 선언된 인터페이스는 람다식을 이용해서 구현 객체를 생성할 수 없다.
즉 하나의 추상 메서드가 선언된 인터페이스만이 람다식의 타겟 타입이 될 수 있는데, 이러한 인터페이스를 함수형 인터페이스(functional interface)라고 한다. 두 개 이상의 추상 메서드가 선언되지 않도록 컴파일러가 체킹 해주기 위해 인터페이스 작성 시 @FunctionalInterface
어노테이션을 붙여줄 수 있다.
Java의 java.util.function
패키지에는 다양한 함수형 인터페이스가 정의되어 있으며, 이를 통해 람다식을 활용한 함수형 프로그래밍을 지원한다.
자주 사용되는 함수형 인터페이스
인터페이스 | 추상 메서드 시그니처 | 설명 및 역할 | 사용 예 |
Predicate<T> | boolean test(T t) | 주어진 조건을 검사하여 true 또는 false를 반환 | filter, allMatch, anyMatch 등 |
Function<T, R> | R apply(T t) | 입력값 T를 받아 결과값 R을 반환 | map, flatMap 등 |
Consumer<T> | void accept(T t) | 입력값 T를 받아 소비(사용)하고 반환하지 않음 | forEach, peek 등 |
Supplier<T> | T get() | 입력값 없이 특정 타입의 결과값을 반환 | 객체 생성, 값 제공, 초기화 등 |
BiFunction<T, U, R> | R apply(T t, U u) | 두 개의 입력값 T, U를 받아 R을 반환 | 두 값의 조합 및 변환 |
Comparator<T> | int compare(T o1, T o2) | 두 객체를 비교하여 정렬 기준을 결정 | 컬렉션 정렬, 커스텀 정렬 기준 제공 |
정리, 람다식을 사용하는 이유
- 코드의 간결성
- 클래스 생성, 메소드 선언, 객체 생성 과정을 생략하고, 간단하게 함수를 표현할 수 있다.
- 함수형 프로그래밍 지원
- 메서드를 매개변수로 전달하거나, 메서드의 결과로 함수를 반환하는 함수형 프로그래밍 패턴을 지원한다.
- 병렬 처리 및 스트림 API와의 연계
- 람다식은 스트림 API와 함께 사용될 때 가장 큰 효과를 발휘한다. 스트림 API의
filter
,map
,reduce
등의 메소드를 람다식으로 구현하여 데이터 변환과 필터링 작업을 간단하게 표현할 수 있다.
- 람다식은 스트림 API와 함께 사용될 때 가장 큰 효과를 발휘한다. 스트림 API의
스트림(Stream)
스트림이란?
스트림은 데이터 소스를 추상화하여, 일관된 방식으로 데이터를 처리할 수 있도록 설계된 API이다. 기존의 컬렉션 반복 처리 방식에서는 for
문이나 Iterator
를 사용하여 데이터를 일일이 처리해야 했으며, 조건문과 반복문이 얽혀 코드의 복잡도가 높아지고 가독성이 떨어졌다. 또한, 각 데이터 소스마다 같은 기능을 다르게 다루어야 하는 문제가 있었다(Collections.sort()
, Arrays.sort()
).
List<String> list = Arrays.asList("홍길동", "신용권", "감자바");
// Java 8 이전
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()) {
String name = iterator.next();
System.out.println(name);
}
// Stream 사용
Stream<String> stream = list.stream();
stream.forEach(name -> System.out.println(name));
이러한 문제점을 해결하기 위해 만든 것이 ‘스트림(Stream)’으로, 데이터 소스를 추상화하여 동일한 방식으로 데이터를 필터링, 매핑, 정렬, 집계할 수 있도록 지원한다. 이를 통해 데이터 소스의 종류에 관계없이 같은 방식으로 데이터 처리를 수행할 수 있게 되었다.
스트림의 특징
- 데이터 소스 추상화
- 컬렉션, 배열, 파일 등 다양한 데이터 소스를 같은 방식으로 처리할 수 있다.
- 중간 연산과 최종 연산의 분리
- 스트림은 중간 연산(
filter
,map
등)과 최종 연산(forEach
,collect
등)을 구분하여 단계별로 데이터 처리를 수행한다.
- 스트림은 중간 연산(
- 지연 연산(Lazy Evaluation)
- 중간 연산은 최종 연산이 호출되기 전까지 실제로 수행되지 않는다. 이로 인해 매 연산마다 요소를 반복하고 새로운 Stream을 생성하는 작업이 생략되어 성능이 최적화된다.
- 병렬 처리 지원
- 스트림은
parallelStream()
메소드를 사용하여 데이터를 병렬로 처리할 수 있다. 이를 통해 대량의 데이터도 빠르게 처리할 수 있다.
- 스트림은
스트림 사용 예시
List<String> names = Arrays.asList("John", "Jane", "Jack", "Doe");
// 스트림을 사용하여 'J'로 시작하는 이름을 필터링하고, 대문자로 변환하여 출력
names.stream()
.filter(name -> name.startsWith("J"))
.map(String::toUpperCase)
.forEach(System.out::println); // JOHN, JANE, JACK 출력
스트림을 사용하면 데이터를 선언적인 방식으로 다룰 수 있어, 코드의 가독성이 높아지고 유지보수가 쉬워진다.
자주 사용되는 중간 연산 (Intermediate Operations)
메서드 | 시그니처 | 설명 및 용도 | 예시 |
filter | Stream<T> filter(Predicate<? super T> p) | - 조건에 맞는 요소만을 필터링하여 새로운 스트림을 생성 | stream.filter(x -> x > 5) |
map | <R> Stream<R> map(Function<? super T, ? extends R> mapper) | - 각 요소를 변환하여 새로운 타입의 요소로 구성된 스트림을 반환 | stream.map(String::length) |
distinct | Stream<T> distinct() | - 중복된 요소를 제거한 새로운 스트림을 반환 | stream.distinct() |
limit | Stream<T> limit(long maxSize) | - 스트림의 앞에서부터 최대 maxSize 개의 요소만 남기고 새로운 스트림을 생성 | stream.limit(3) |
자주 사용되는 최종 연산 (Terminal Operations)
메서드 | 시그니처 | 설명 및 용도 | 예시 |
forEach | void forEach(Consumer<? super T> action) | 각 요소에 대해 지정된 동작(Consumer)을 수행 | stream.forEach(System.out::println) |
toArray | Object[] toArray() | 스트림의 모든 요소를 배열로 반환 | stream.toArray() |
collect | <R, A> R collect(Collector<? super T, A, R> collector) | 스트림의 모든 요소를 수집하여 리스트, 세트, 맵 등으로 변환 | stream.collect(Collectors.toList()) |
findFirst | Optional<T> findFirst() | 스트림의 첫 번째 요소를 반환 | stream.findFirst() |
정리, 스트림을 사용하는 이유
- 데이터 소스의 추상화 및 일관된 데이터 처리
- 스트림은 컬렉션, 배열, 파일 등의 다양한 데이터 소스를 일관된 방식으로 처리할 수 있도록 추상화한다.
- 코드의 간결성과 가독성 향상
- 스트림을 사용하면, 기존의 반복문이나 조건문을 대체하여 간단한 메소드 체이닝으로 복잡한 데이터 처리를 수행할 수 있다.
- 병렬 처리 지원
- 스트림은
parallelStream()
메소드를 통해 손쉽게 병렬 스트림으로 변환할 수 있다. 병렬 스트림은 멀티 스레드를 활용하여 데이터 처리 작업을 병렬로 수행하므로, 대규모 데이터의 경우 처리 성능을 크게 향상할 수 있다.
- 스트림은
Reference
도서
- Java의 정석(남궁성 저)
- 이것이 자바다(신용권 저)