[Java] Stream API 톺아보기
서론
오늘은 함수형 프로그래밍을 가능하게 해주는 util 클래스인 Stream에 대해서 알아보겠습니다.
이 글에서는 간단한 원리와 활용 방법에 관해 다루고 있음을 미리 말씀드립니다.
본론
정의
스트림(Stream)은 개울이란 뜻인데, 이는 데이터의 흐름을 만들도록 돕는 역할을 합니다. 주로 컬렉션 형태로 구성된 데이터를 람다(참조)를 이용해 간결하고 직관적으로 프로세스 할 수 있게 돕습니다. 뿐만 아니라, 배열이나 파일에 저장된 데이터도 스트림으로 제어할 수 있습니다.
장점
1) 코드가 간결하다.
스트림이 나오기 이전에는 아래와 같이, for문과 Iterator를 이용하여 코드를 작성해야 했습니다.
String[] strArr = {"a", "b", "c"};
List<String> strList = Arrays.asList(strArr);
for (int i = 0; i < strList.size(); i++) {
System.out.println(strList.get(i));
}
그러나 스트림을 사용하면 같은 동작을 더 간결하게 나타낼 수 있습니다. 단, 스트림으로 변환할 수 있는 형식은 컬렉션 형태로 제한됩니다. 따라서 asList 메서드를 통해, String 배열을 List 형태로 변환하는 과정이 요구됩니다.
Stream<String> strStream1 = strList.stream();
strStream1.forEach(System.out::println);
단점
1) 배열, 컬렉션 등을 순회하는 속도가 느리다.
for문과 while 문에 비해, 순회하는 속도가 느린 편입니다. 관련된 내용은 저 역시 아직 완벽히 알지 못하므로, 해당 링크(참조링크)로 설명을 갈음하겠습니다.
생성
먼저, 스트림은 of 메서드를 활용하여, 다음과 같이 생성할 수 있습니다. 스트림된 데이터를 간단하게 출력해보겠습니다. 스트림을 바로 출력할 수 없기 때문에 Collectors를 사용합니다. Collectors는 ~~를 말합니다.
Stream<String> nameStream = Stream.of("Kim", "Park", "Choi");
List<String> names = nameStream.collect(Collectors.toList());
System.out.println("names = ", names);
변환
Stream API에서 가장 많이 사용되는 변환 연산은 map
과 flatMap
입니다. 이 두 연산의 차이점을 이해하는 것이 Stream API를 효과적으로 사용하는 데 중요합니다.
map() 연산
map
은 스트림의 각 요소를 다른 형태로 변환할 때 사용합니다. 1:1 매핑을 수행합니다.
// 예시 1: 문자열 길이 변환
List<String> names = List.of("Eunsoo", "Mingjae", "Jungwan");
List<Integer> lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println(lengths); // [6, 7, 7]
// 예시 2: 숫자 제곱 변환
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
List<Integer> squares = numbers.stream()
.map(n -> n * n)
.collect(Collectors.toList());
System.out.println(squares); // [1, 4, 9, 16, 25]
flatMap() 연산
flatMap
은 스트림의 각 요소를 여러 개의 요소로 변환하고, 이를 하나의 스트림으로 평탄화할 때 사용합니다. 1:N 매핑을 수행합니다.
// 예시 1: 문자열을 단어로 분리
List<String> lines = List.of("a b c", "d e", "f");
List<String> words = lines.stream()
.flatMap(line -> Arrays.stream(line.split(" ")))
.collect(Collectors.toList());
System.out.println(words); // [a, b, c, d, e, f]
// 예시 2: 중첩 리스트 평탄화
List<List<Integer>> nestedNumbers = List.of(
List.of(1, 2, 3),
List.of(4, 5, 6),
List.of(7, 8, 9)
);
List<Integer> flattenedNumbers = nestedNumbers.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(flattenedNumbers); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
map vs flatMap 비교
특징 | map() | flatMap() |
---|---|---|
변환 방식 | 1:1 매핑 | 1:N 매핑 |
리턴 타입 | Stream |
Stream |
주 사용처 | 단순 변환 | 중첩 구조 해체 |
예시 | 문자열 → 길이 | 문자열 → 단어들 |
실무 적용 팁
map 사용 시점
- 단순한 데이터 변환이 필요할 때
- 각 요소를 다른 타입으로 변환할 때
- 1:1 매핑이 가능한 경우
flatMap 사용 시점
- 중첩된 컬렉션을 평탄화할 때
- JSON 배열이나 DB 결과를 처리할 때
- 1:N 매핑이 필요한 경우
성능 고려사항
- 단순 변환은 map이 더 효율적
- 중첩 구조 해체는 flatMap이 더 적합
- 불필요한 중첩 스트림 생성 주의
실전 예제
// JSON 응답 처리 예시
class User {
private String name;
private List<String> roles;
// getters, setters
}
List<User> users = // ... DB에서 가져온 사용자 목록
// 모든 사용자의 역할을 하나의 리스트로 수집
List<String> allRoles = users.stream()
.flatMap(user -> user.getRoles().stream())
.distinct()
.collect(Collectors.toList());
// 사용자 이름과 역할 수 매핑
Map<String, Integer> userRoleCounts = users.stream()
.collect(Collectors.toMap(
User::getName,
user -> user.getRoles().size()
));
활용 :: 중간연산
본격적으로 Stream을 활용했을 때, 어떤 연산을 쉽게 처리할 수 있는지 직접 활용하며 살펴보도록 하겠습니다. 먼저, 중간연산입니다. 중간 연산은 반환 값이 스트림입니다. 따라서 원하는 값이 도출될 때까지 계속해서 Stream 연산을 활용할 수 있습니다. 활용할 수 있는 메서드들은 다음과 같습니다.
중복제거
Stream<T> distinct()
추출 : Stream<T>
filter (Predicate<T>
predicate)
조건을 만족하는 요소만을 반환합니다.
Stream<Integer> numberStream = Stream.of(1, 2, 3, -1, -2, -3);
Stream<Integer> filteredNumberStream = numberStream.filter(x -> x > 0);
List<Integer> filteredNumbers = filteredNumberStream.collect(Collectors.toList());
System.out.println(filteredNumbers); // 1, 2, 3
// 메서드 체이닝
List<Integer> newFilteredNumbers = Stream.of(1, 2, 3, -1, -2).filter(x -> x > 0).collect(Collectors.toList());
System.out.println(newFilteredNumbers); // 1, 2, 3
후자와 같은 방식을 연속해서 함수를 호출해 연산하는 방식을 메서드 체이닝
이라고 합니다. 함수형 프로그래밍 시, 유용하게 활용할 수 있습니다.
Stream<T> limit (long maxSize)
Stream<T> sorted() // sorted(Comparator<T> comparator)
Stream<R> map(Function<T,R> mapper)
활용 :: 최종연산
반환 값이 스트림이 아니며, 스트림의 요소를 소모하므로 단 한번만 가능합니다.
void forEach
void forEachOrdered
long count
Optional<T> max()
Optional<T> min()
Optional<T> findAny() : 아무거나 하나
Optional<T> findFirst() :
boolean allMatch (Predicate<T> p) : 모두 만족
boolean anyMatch (Predicate<T> p) : 하나로도 만족.
boolean noneMatch (Predicate<> p) : 만족시키지 않음.
toArray() // toArray(IntFuction<A[]> generator) : 스트림의 모든 요소를 배열로 반환한다.
Optional<T> reduce (BinaryOperator<T> accumulator) : 스트림의 요소를 하나씩 줄여가면서(리듀싱) 계산한다.
...
R collect : 스트림의 요소를 수집한다.
https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/stream/package-summary.html