JAVA (23) - 람다와 스트림
1. 개요
Java 8에서 도입된 가장 큰 변화 중 하나는 함수형 프로그래밍 스타일의 도입이다. 그 중심에는 람다식(Lambda Expression)과 스트림 API(Stream API)가 존재한다. 람다식을 통해 코드를 더욱 간결하고 직관적으로 작성할 수 있게 되었으며, 스트림을 사용하면 컬렉션이나 배열 데이터를 효율적이고 통일된 방식으로 처리할 수 있다.
여기서 다루는 java.util.stream.Stream은 데이터를 변환하고 집계하는 API다. 데이터를 읽고 쓰는 InputStream, OutputStream 등의 I/O 스트림과는 이름만 같을 뿐 목적과 동작 방식이 다르다.
2. 람다식(Lambda Expression)의 개념과 특징
람다식은 익명 함수(Anonymous Function)를 생성하기 위한 식이다. 메서드를 하나의 간결한 ’식(Expression)’으로 표현하여 코드를 줄이고 가독성을 높여준다.
- 기본 문법:
(매개변수) -> { 실행문; } - 주요 특징:
- 메서드의 이름과 반환 타입을 생략할 수 있어 코드가 매우 간결해진다.
- 람다식은 함수형 인터페이스(단 하나의 추상 메서드만 가지는 인터페이스)의 구현체로 취급된다.
- 대표적인 함수형 인터페이스로는
Runnable,Supplier,Consumer,Function,Predicate등이 있다.
// 기존 익명 클래스 방식
Runnable runnable1 = new Runnable() {
@Override
public void run() {
System.out.println("Hello World!");
}
};
// 람다식 적용
Runnable runnable2 = () -> System.out.println("Hello World!");
3. 스트림 API(Stream API)의 개념과 특징
스트림은 컬렉션, 배열 등의 데이터 소스에서 요소를 전달받아 필터링, 변환, 집계하는 데이터 처리 파이프라인이다. 스트림 자체는 요소를 저장하는 자료구조가 아니며, 원본 데이터와 처리 방법을 연결해 결과를 만든다.
- 저장 공간 없음: 컬렉션처럼 요소를 보관하지 않고 데이터가 흐르는 처리 경로를 제공한다.
- 원본 데이터 비변경:
filter(),map()등의 연산은 원본 컬렉션을 직접 변경하지 않고 새로운 결과를 만든다. - 일회용: 최종 연산이 끝난 스트림은 이미 소비된 상태이므로 다시 사용할 수 없다.
- 지연 연산(Lazy Evaluation): 중간 연산은 파이프라인만 구성하고, 최종 연산이 호출될 때 실제 처리를 시작한다.
- 병렬 처리 선택 가능: 기본적으로 순차 실행되며, 필요한 경우
parallelStream()이나parallel()을 통해 병렬 실행을 요청할 수 있다.
4. Collection과 Stream의 차이
컬렉션은 데이터를 저장하고 관리하는 자료구조이고, 스트림은 저장된 데이터를 어떻게 처리할지 표현하는 도구다.
| 구분 | Collection | Stream |
|---|---|---|
| 목적 | 데이터를 저장하고 관리 | 데이터를 변환하고 집계 |
| 데이터 보유 | 요소를 직접 저장 | 요소를 저장하지 않음 |
| 반복 방식 | 외부 반복 | 내부 반복 |
| 처리 시점 | 컬렉션에 값이 준비되어 있음 | 최종 연산이 호출될 때 처리 시작 |
| 재사용 | 여러 번 순회 가능 | 한 번 소비하면 재사용 불가 |
| 원본 변경 | 요소 추가·삭제 가능 | 연산이 원본을 직접 변경하지 않음 |
외부 반복과 내부 반복
컬렉션을 for-each 문으로 순회하면 개발자가 요소를 꺼내는 과정과 처리 순서를 직접 제어한다. 이를 **외부 반복(External Iteration)**이라고 한다.
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.startsWith("K")) {
result.add(name.toUpperCase());
}
}
스트림은 원하는 작업만 선언하면 라이브러리가 요소의 반복을 담당한다. 이를 내부 반복(Internal Iteration) 이라고 한다.
List<String> result = names.stream()
.filter(name -> name.startsWith("K"))
.map(String::toUpperCase)
.collect(Collectors.toList());
내부 반복은 파이프라인 최적화와 병렬 실행의 여지를 제공하지만 항상 외부 반복보다 빠른 것은 아니다. 데이터 크기, 연산 비용, 실행 환경에 따라 반복문이 더 단순하고 빠를 수도 있다.
5. 스트림의 연산 구조 및 주요 연산
스트림을 이용한 데이터 처리는 생성 -> 중간 연산 -> 최종 연산의 흐름(파이프라인)을 거친다.
(1) 스트림 생성
컬렉션이나 배열 등을 스트림 객체로 변환하는 단계다.
list.stream()/Arrays.stream(array)/Stream.of(...)
(2) 중간 연산 (Intermediate Operations)
데이터를 가공·필터링하는 단계로, 연산 결과로 새로운 스트림을 반환하므로 체이닝(Chaining)이 가능하다. 최종 연산이 호출되기 전까지는 실행되지 않는 지연 연산(Lazy Evaluation)의 특징을 갖는다.
- 필터링 및 변환
filter(Predicate): 조건에 맞는 요소만 추출map(Function): 요소를 원하는 형태나 값으로 변환flatMap(Function): 중첩된 구조(리스트 안의 리스트 등)를 단일 스트림으로 평평하게 변환
- 정렬 및 중복 제거
distinct(): 중복 요소 제거 (equals,hashCode기준)sorted()/sorted(Comparator): 요소 오름차순 또는 지정 기준 정렬
- 자르기 및 조회
limit(long maxSize): 지정한 개수만큼 요소 잘라내기skip(long n): 지정한 개수만큼 요소 건너뛰기peek(Consumer): 중간 결과 확인 (디버깅 용도)
(3) 최종 연산 (Terminal Operations)
가공된 스트림의 요소를 소모하여 최종 결과를 도출한다. 최종 연산이 실행되면 스트림 파이프라인은 소비된 상태가 되어 재사용할 수 없다.
- 요소 수집 및 순회
collect(Collector): List, Set, Map 등 원하는 컬렉션 타입으로 결과 수집forEach(Consumer): 각 요소를 순회하며 지정된 작업 수행toArray(): 스트림 요소를 배열로 변환
- 통계 및 누적 계산
count(): 전체 요소 개수 반환sum()/average():IntStream,LongStream,DoubleStream과 같은 기본형 특화 스트림의 합계 및 평균 계산max(Comparator)/min(Comparator): 최댓값 및 최솟값 반환 (Optional)reduce(BinaryOperator): 모든 요소를 누적하여 하나의 결과로 축소
- 조건 검사 및 검색 (Short-circuit)
anyMatch(Predicate): 조건에 맞는 요소가 하나라도 있는지 확인 (boolean)allMatch(Predicate): 모든 요소가 조건을 만족하는지 확인 (boolean)noneMatch(Predicate): 모든 요소가 조건을 만족하지 않는지 확인 (boolean)findFirst()/findAny(): 조건에 맞는 첫 번째 또는 임의의 요소 반환 (Optional)
List<String> names = Arrays.asList("Kim", "Lee", "Park", "Kang");
// 이름이 'K'로 시작하는 요소를 필터링하여 대문자로 변환 후 리스트로 수집
List<String> kNames = names.stream()
.filter(name -> name.startsWith("K")) // 중간 연산
.map(String::toUpperCase) // 중간 연산
.collect(Collectors.toList()); // 최종 연산
지연 연산과 쇼트서킷
중간 연산은 호출 즉시 전체 데이터를 처리하지 않는다. 다음 예제에서는 최종 연산인 collect()가 호출되어야 처리가 시작되며, limit(5)에 필요한 요소를 찾으면 나머지 요소를 모두 확인하지 않고 작업을 끝낼 수 있다.
List<String> selectedNames = items.stream()
.filter(item -> item.getPrice() >= 1000)
.map(Item::getName)
.limit(5)
.collect(Collectors.toList());
filter()가 전체 결과를 별도의 목록으로 만든 뒤 map()을 실행하는 것이 아니라, 각 요소가 파이프라인을 따라 처리된다. 이러한 연산 결합과 쇼트서킷은 불필요한 처리와 중간 자료구조 생성을 줄일 수 있다.
6. 스트림 사용 시 주의점
스트림은 재사용할 수 없다
Stream<String> stream = names.stream();
long count = stream.count();
// 이미 소비된 스트림이므로 IllegalStateException이 발생할 수 있다.
stream.forEach(System.out::println);
같은 데이터를 다시 처리하려면 원본 컬렉션에서 새로운 스트림을 생성해야 한다.
원본과 외부 상태를 변경하지 않는다
스트림이 실행되는 동안 원본 컬렉션을 수정하거나 람다식에서 외부의 가변 상태를 변경하면 예외나 예측하기 어려운 결과가 발생할 수 있다. 특히 병렬 스트림에서는 스레드 안전성 문제로 이어질 수 있으므로, 결과를 모을 때는 외부 목록을 직접 변경하기보다 collect(), reduce()를 사용한다.
병렬 스트림이 항상 빠른 것은 아니다
병렬 처리에는 작업 분할과 결과 병합 비용이 든다. 데이터가 적거나 연산이 단순한 경우에는 순차 스트림이 더 효율적일 수 있으므로 실제 성능을 측정한 뒤 선택해야 한다.
컬렉션이나 배열에서 만든 스트림은 일반적으로 직접 닫을 필요가 없다. 하지만 Files.lines()처럼 I/O 자원을 기반으로 만든 스트림은 try-with-resources를 사용해 닫아야 한다.
7. 람다와 스트림을 함께 사용할 때의 장점
- 가독성과 생산성 향상: 반복문(
for,while)과 조건문(if)이 복잡하게 얽힌 코드를 단 몇 줄의 직관적인 체이닝 코드로 대체할 수 있다. - 선언형 코드 작성: ’어떻게 처리할 것인가(How)’에 집중하는 대신 ’무엇을 할 것인가(What)’를 명확히 표현할 수 있다.
- 병렬 처리 지원: 순차 파이프라인을 크게 변경하지 않고 병렬 실행을 선택할 수 있다.
8. 정리
자바의 람다와 스트림은 코드를 훨씬 간결하고 가독성 있게 만들어 주는 강력한 도구다. 람다식은 함수형 프로그래밍의 기반을 제공하며, 스트림은 데이터 처리 파이프라인(생성-중간연산-최종연산)을 통해 데이터의 가공 및 집계를 손쉽게 처리하도록 돕는다.
컬렉션이 데이터를 저장하는 자료구조라면 스트림은 데이터를 처리하는 통로다. 중간 연산의 지연 실행과 내부 반복을 활용하면 처리 의도를 선언적으로 표현할 수 있지만, 스트림 재사용과 부수 효과, 무분별한 병렬 실행에는 주의해야 한다.