자바에서 스트림(Stream)은 함수형 프로그래밍(Functional Programming)을 가능하게 해주는 도구이다.
스트림을 사용하면 코드의 흐름을 함수처럼 연결해서 처리할 수 있으며, 간결하고 오류가 적은 코드를 작성할 수 있다.
기존에는 조건문이나 반복문처럼 명령을 순서대로 나열하는 방식, 즉 절차적 프로그래밍(Procedural Programming)이 일반적이었다. 이후 객체지향 프로그래밍(Object-Oriented Programming) 개념이 도입되며, 객체 단위로 코드 구조를 설계하는 방식이 확산되었다.
함수형 프로그래밍은 그 다음 개념으로, 데이터를 흐름처럼 처리하는 방식이다. 복잡한 상태 관리나 반복문 없이도 원하는 결과를 얻을 수 있으며, 코드의 가독성과 안정성이 높아지는 장점이 있다.
자바는 본래 함수형 언어는 아니지만, 자바 8부터 Stream API를 도입하여 함수형 프로그래밍 스타일을 일부 지원할 수 있게 되었다.
따라서 스트림을 잘 활용하면 더 선언적이고 명확한 코드를 작성할 수 있다고 할 수 있다.
코딩을 하다 보면 대부분의 작업은 데이터를 전달하고, 변환한 뒤, 다시 다른 곳에 전달하는 과정으로 이루어진다.
이러한 작업 흐름이 마치 데이터가 흐르는 것처럼 보이기 때문에, Stream이라는 이름이 붙었다고 볼 수 있다.
자바에서는 주로 컬렉션(Collection)을 처리할 때 스트림을 많이 사용한다.
그 이유는 자바 컬렉션에 대해 스트림을 바로 생성할 수 있는 메서드(stream())가 제공되기 때문이다.
즉, 데이터를 담고 있는 컬렉션으로부터 스트림을 생성하고,
그 스트림에 여러 개의 중간 연산(map, filter 등)과 최종 연산(forEach, collect 등)을 연결하여 처리하는 방식이 일반적이다.
이러한 구조 덕분에 스트림은 컬렉션 데이터를 간결하고 효과적으로 처리할 수 있는 도구로 널리 활용되고 있다.
스트림은 원본 데이터를 변경하지 않는다.
스트림에서 map, filter 등으로 데이터를 변환하더라도, 원래의 데이터 소스(예: ArrayList)는 변하지 않는다.
예를 들어, ["korea", "japan"]
이라는 리스트를 스트림으로 만들어 toUpperCase()를 적용해도, 원본 리스트는 그대로 소문자를 유지한다.
스트림의 각 단계는 이전 데이터를 기반으로 새로운 데이터를 생성하는 방식이며, 자바 관점에서는 서로 다른 객체로 간주된다.
스트림은 내부적으로 반복 처리된다.
전통적인 for나
for-each
문처럼 개발자가 반복 처리를 직접 명시하지 않아도, 스트림 내부에서 자동으로 요소를 순회하며 처리한다.
즉, stream.map(...).filter(...).forEach(...)
처럼 한 줄로 작성해도, 내부적으로는 데이터의 크기만큼 반복적으로 연산이 수행된다.
스트림은 일회성이다.
스트림은 한 번 소비되면 닫히기 때문에 재사용이 불가능하다.
예를 들어, 하나의 스트림을 forEach
로 출력한 다음, 다시 collect
하려고 하면 예외가 발생한다.
따라서 같은 데이터를 여러 번 처리하고자 한다면, 스트림을 새로 생성해야 한다.
스트림 생성
스트림을 이용하기 위해 먼저 스트림을 생성해야 함.
Stream<T> Collection.stream()
을 이용하여 해당하는 컬렉션을 기반으로하는 스트림을 생성할 수 있다.
중간 연산
중간 단계로써 데이터의 형변환 혹은 필터링, 정렬 등 스트림에 대한 가공을 해준다.
map(변환) / sorted(정렬) / skip(스트림 자르기) / limit(스트림 자르기) 등이 있다.
최종 연산
스트림의 요소를 소모해서 결과를 반환하는 단계다. 최종 연산 이후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다. (최종 연산의 결과값은 단일 값일 수도 있고 배열 혹은 컬렉션일 수도 있다.)
collect()를 이용해서 다른 콜렉션으로 바꾸는 것, reduce를 이용해서 incremental calculation하는 것도 가장 많이 쓰이는 패턴이다.
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("서울");
list.add("부산");
list.add("속초");
list.add("서울");
System.out.println(list);
List<String> result = list.stream() // 스트림 생성
.limit(2) // 중간 연산
.collect(Collectors.toList()); // 최종 연산
System.out.println(result);
System.out.println("list -> transformation -> set");
Set<String> set = list.stream().filter("서울"::equals).collect(Collectors.toSet());
set.forEach(System.out::println);
}
}
public class Main {
public static void main(String[] args) {
String[] arr = {"엑셀보다 쉬운 SQL", "웹개발 종합반", "알고보면 알기쉬운 알고리즘", "웹개발의 봄,Spring"};
Stream<String> stringStream = Arrays.stream(arr);
stringStream.forEach(className -> System.out.println("수업명 : " + className));
System.out.println();
}
}
[코드스니펫] 스트림 예제 3
예제 3을 위해 build.gradle의 dependencies 코드 블럭에 다음과 같이 library를 복붙해서 추가하자.
dependencies {
implementation 'org.apache.commons:commons-lang3:3.0'
// other codes..
}
복붙 후에 grale의 sync를 맞춰준 후
외부 라이브러리에 org.apache.commons:commons-lang3:3.0
가 잘 들어와있으면 준비 완료!
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import org.apache.commons.lang3.tuple.Pair;
class Sale {
String fruitName;
int price;
float discount;
public Sale(String fruitName, int price, float discount) {
this.fruitName = fruitName;
this.price = price;
this.discount = discount;
}
}
public class Main {
public static void main(String[] args) {
List<Sale> saleList =
Arrays.asList(new Sale("Apple", 5000, 0.05f), new Sale("Grape", 3000, 0.1f),
new Sale("Orange", 4000, 0.2f), new Sale("Tangerine", 2000, 0));
Stream<Sale> saleStream = saleList.stream();
saleStream.map(sale -> Pair.of(sale.fruitName, sale.price * (1 - sale.discount))).forEach(
pair -> System.out.println(pair.getLeft() + " 실 구매가: " + pair.getRight() + "원 "));
}
}
public class Main {
public static void main(String[] args) {
List<Integer> numArr = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Integer result = numArr.stream().reduce(0, Integer::sum);
// reduce와 sum을 활용하여 1부터 10까지 더하게 됩니다.
System.out.println(result);
}
}
문제 : '이'씨 성을 가진 사람들의 수를 세려고 한다.
이름 : ["김정우", "김호정", "이하늘", "이정희", "박정우", "박지현", "정우석", "이지수"]
다음 Javadoc (자바의 클래스 명세)를 확인하고 참고하자
https://docs.oracle.com/javase/8/docs/api/java/lang/String.html#startsWith-java.lang.String-
https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#count--
답안
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> names = Arrays.asList("김정우", "김호정", "이하늘", "이정희", "박정우", "박지현", "정우석", "이지수");
long count = names.stream()
.filter(str -> str.startsWith("이")).count();
System.out.println("count : " + count);
}
}
중간 연산은 스트림을 변환하거나 필터링하고, 최종 연산 전까지 지연 평가(lazy evaluation)된다.
메서드 | 설명 |
---|---|
filter(Predicate) | 조건에 맞는 요소만 필터링 |
map(Function) | 요소를 변환 |
flatMap(Function) | 다차원 → 1차원 평탄화 |
distinct() | 중복 제거 |
sorted() | 정렬 (Comparable 기반) |
sorted(Comparator) | 정렬 (사용자 정의 정렬 기준) |
limit(n) | 앞에서 n개만 유지 |
skip(n) | 앞에서 n개 건너뛰기 |
peek(Consumer) | 디버깅 용도로 중간 값 출력 |
List<String> names = List.of("kim", "lee", "lee", "park");
List<String> filtered = names.stream()
.filter(n -> n.length() == 3)
.distinct()
.sorted()
.collect(Collectors.toList()); // ["kim", "lee", "park"]
최종 연산은 스트림을 닫고 결과를 생성한다.
메서드 | 설명 |
---|---|
collect() | 결과를 리스트, 셋 등으로 수집 |
count() | 요소 개수 반환 (long) |
forEach() | 각 요소에 대해 작업 수행 |
reduce() | 누적 연산 수행 |
anyMatch() | 하나라도 조건 만족하면 true |
allMatch() | 모두 조건 만족하면 true |
noneMatch() | 모두 조건 불만족이면 true |
findFirst() | 첫 번째 요소 반환 (Optional) |
findAny() | 병렬일 경우 아무 요소나 반환 |
long count = names.stream().filter(n -> n.contains("k")).count();
boolean hasKim = names.stream().anyMatch(n -> n.equals("kim"));
Optional<String> first = names.stream().findFirst();
collect()
는 최종 연산이며, 결과를 다양한 형태로 반환할 수 있게 해주는 정적 메서드 집합인 Collectors
와 함께 자주 사용된다.
수집기 | 설명 |
---|---|
toList() | 리스트로 수집 |
toSet() | 집합으로 수집 |
toMap(keyMapper, valueMapper) | 맵으로 수집 |
joining() | 문자열 합치기 |
counting() | 요소 수 세기 |
summarizingInt() | 합계, 평균, 최댓값, 최솟값 등 요약 통계 |
groupingBy() | 그룹핑 (SQL의 GROUP BY) |
partitioningBy() | true/false 두 그룹 분리 |
Map<Integer, List<String>> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));
// {3=[kim, lee, lee], 4=[park]}
flatMap()
: 중첩 리스트를 평탄화
List<List<String>> data = List.of(
List.of("a", "b"),
List.of("c", "d")
);
List<String> flat = data.stream()
.flatMap(List::stream)
.collect(Collectors.toList()); // ["a", "b", "c", "d"]
reduce()
: 모든 값을 누적해서 하나로 만듦
int sum = List.of(1, 2, 3, 4).stream()
.reduce(0, Integer::sum); // 10
groupingBy()
: 조건 기준으로 그룹화
List<String> names = List.of("kim", "lee", "cho", "park");
Map<Integer, List<String>> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));