자바 스트림(Stream)
자바의 스트림(Stream)은 'Java 8'부터 지원되기 시작한 기능이다. 컬렉션에 저장되어 있는 엘리먼트들을 하나씩 순회하면서 처리할 수 있는 코드패턴이다. 람다식과 함께 사용되어 컬렉션에 들어있는 데이터에 대한 처리를 매우 간결한 표현으로 작성할 수 있다. 또 한, 내부 반복자를 사용하기 때문에 병렬처리가 쉽다는 점이 있다.
'Java 6' 이전까지는 컬렉션의 엘리먼트들을 순회하기 위해서 Iterator 객체를 이용했다.
ArrayList<String> list = new ArrayList<String>(Arrays.asList("a", "b", "c"));
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()) {
String value = iterator.next();
if (StringUtils.equals(value, "b") {
System.out.println("값 : " + value);
}
}
"컬렉션을 순회하면서 값들을 출력하라"라는 단순한 동작을 위해서 보기에 지저분한 코드들이 많이 생성된다. for each 구문을 이용하면 좀 더 깔끔해지긴한다.
ArrayList<String> list = new ArrayList<String>(Arrays.asList("a", "b", "c"));
for (String value : list) {
if (StringUtils.equals(value, "b") {
System.out.println("값 : " + value);
}
}
'Java 8'부터 추가된 스트림을 사용하면 조금 더 단순하게 코드를 작성할 수 있다.
ArrayList<String> list = new ArrayList<String>(Arrays.asList("a", "b", "c"));
list.stream()
.filter("b"::equals)
.forEach(System.out::println);
스트림(Stream) 사용법
스트림 생성
스트림 생성 - 컬렉션
자바의 스트림을 사용하려면 우선 스트림 객체를 생성해야한다.
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
자바 코드에서 자주 사용하는 컬렉션 객체들은 stream()
메소드를 지원한다. 컬렉션 객체에서 stream() 메소드를 호출하면 스트림 객체를 만들 수 있다.
스트림 생성 - 배열
배열의 경우 정적 메소드를 이용하면 된다.
String[] array = new String[]{"a", "b", "c"};
Stream<String> stream1 = Arrays.stream(array);
Stream<String> stream2 = Arrays.stream(array, 1, 3); // 인덱스 1포함, 3제외 ("b", "c")
정적 메소드 'Arrays.stream()'에 인자로 배열을 입력하면 배열을 순회하는 스트림 객체를 만들 수 있다. Arrays.stream() 메소드에 배열과 시작, 종료 인덱스를 인자로 주면 배열의 일부를 순회하는 스트림 객체를 만들 수도 있다. (이 때, 종료 인덱스는 포함되지 않음을 주의해야한다)
스트림 생성 - 빌더
배열이나 컬렉션을 통해서 생성하는게 아닌 직접 값을 입력해서 스트림 객체를 생성하는 방법도 있다.
String<String> stream = Stream<String>builder()
.add("Apple")
.add("Banana")
.add("Melon")
.build();
이렇게 만들어진 스트림 객체는 "Apple", "Banana", "Melon" 순서로 문자열 데이터를 처리하게 된다.
스트림 생성 - Generator
데이터를 생성하는 람다식을 이용해서 스트림을 생성할 수도 있다.
public static<T> Stream<T> generate(Supplier<T> s) { ... }
Supplier 에 해당하는 람다식이 데이터를 생성하는 람다식이다.
Stream<String> stream = Stream.generate(() -> "Hello").limit(5);
generate() 메소드의 인자로 "Hello"를 찍어주는 람다식을 주었다. 이렇게 되면 "Hello"라는 데이터를 무한대로 생성하는 스트림이 만들어진다. 여기에 limit() 메소드를 이용해서 스트림이 "Hello" 문자열을 5개만 찍어내도록 제한을 걸어줬다.
스트림 생성 - Iterator
혹은 iterate() 메소드를 이용해서 수열 형태의 데이터를 생성할 수도 있다.
// (100, 110, 120, 130, 140)
Stream<String> stream = Stream.iterate(100, n -> n + 10).limit(5);
'n → n + 10' 이라는 람다를 인자로 넘겨서 초기 값 100부터 10씩 증가하는 숫자를 생성하는 스트림을 만들 수 있다.
스트림 생성 - Empty 스트림
특수한 스트림으로 '빈 스트림(Empty Stream)'을 사용할 수 있다. stream 객체를 참조하는 변수가 null이라면 NullPointException이 발생할 수도 있다.
Stream<String> stream = Stream.empty();
이럴 때에는 'Stream.empty()'를 사용하면 된다.
스트림 생성 - 기본 타입
자바에서는 기본타입(Primitive Type)에 대해서 오토박싱과 언박싱이 발생한다. int 변수를 다룰 때, Integer 클래스로 오토박생해서 처리하는 경우가 있는데, 이 경우 오버헤드가 발생해서 성능저하가 있을 수 있다. 스트림 객체의 생성에서도 마찬가지인데 오토박싱을 하지 않으려면 다음과 같이 스트림을 사용하면 된다.
IntStream intStream = IntStream.range(1, 10); // 1 ~ 9
LongStream longStream = LngStream.range(1, 10000); // 1 ~ 9999
이러면 오토박싱이 수행되지 않는다.
Stream<Integer> stream = IntStream.range(1, 10).boxed();
제네릭을 이용한 클래스로 사용하려면 박싱을 해서 사용해야한다.
정해진 값이 아니라 랜덤 값을 스트림으로 뽑아내려면 Random() 클래스를 사용하면 된다.
DoubleStream stream = new Random().double(3); // double 형 랜덤 숫자 3개 생성
스트림 생성 - 문자열 스트림
문자열에 대해서 스트림을 생성할 수도 있다.
IntStream stream = "Hello,World".chars(); //(72, 101, 108, 108, 111, 44, 87, 111, 114, 108, 100)
문자열을 구성하고 있는 문자들의 ASCII 코드 값을 스트림형태로 뽑아주는 예제코드다.
Stream<String> stream = Pattern.compile(",").splitAsStream("Apple,Banana,Melon");
특정 구분자(Delimiter)를 이용해서 문자열을 스플릿 한 다음 각각을 스트림으로 뽑아낼 수도 있다.
스트림 생성 - 파일
텍스트 파일을 읽어서 라인단위로 처리하는 코드는 매우 흔하다. 이런 코드 역시 스트림으로 작성할 수 있다.
Stream<String> stream =
Files.lines(Paths.get("test.txt"), Charset.forName("UTF-8"));
'test.txt' 파일의 데이터를 라인단위로 읽어서 뽑아주는 스트림 객체다. 이 때, 데이터는 'UTF-8'로 디코딩해서 읽어들인다.
스트림 생성 - 스트림연결
두 개의 스트림을 연결해서 하나의 새로운 스트림으로 만들어 낼 수도 있다.
Stream<String> stream1 = Stream.of("Apple", "Banana", "Melon");
Stream<String> stream2 = Stream.of("Kim", "Lee", "Park");
Stream<String> stream3 = Stream.concat(stream1, stream2);
// "Apple", "Banana", "Melon", "Kim", "Lee", "Park"
Stream.concat() 메소드를 이용해서 두 개의 스트림을 붙여서 새로운 스트림을 만들 수 있다.
스트림 데이터 가공
스트림 객체가 뽑아내는 데이터들에 대해 뭔가 작업을 해야한다. 특정 데이터들만 걸러내거나 데이터에 대해서 가공을 할 수 있다. 데이터를 가공해주는 메소드들은 가공된 결과를 생성해주는 스트림 객체를 리턴한다.
Filter
필터(filter)는 스트림에서 뽑아져 나오는 데이터에서 특정 데이터들만 골라내는 역할을 한다.
Stream<T> filter(Predicate<? super T> predicate);
filter() 메소드에는 boolean 값을 리턴하는 람다식을 넘겨주게된다. 그러면 뽑아져 나오는 데이터에 대해 람다식을 적용해서 true가 리턴되는 데이터만 선별한다.
filter의 간단한 예를 살펴보자.
Stream<Integer> stream = IntStream.range(1, 10).boxed();
stream.filter(v -> ((v % 2) == 0))
.forEach(System.out::println);
// 2, 4, 6, 8
1부터 9까지 데이터를 뽑아내는 스트림을 만들고, filter 메소드에 짝수를 선별해주는 람다식을 넣어줬다. 이러면 1부터 9까지의 데이터 중 짝수 데이터만 뽑아내주는 스트림 객체가 리턴된다.
Map
map()은 스트림에서 뽑아져 나오는 데이터에 변경을 가해준다.
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
map() 메소드는 값을 변환해주는 람다식을 인자로 받는다. 스트림에서 생성된 데이터에 map() 메소드의 인자로 받은 람다식을 적용해 새로운 데이터를 만들어낸다.
Stream<Integer> stream = IntStream.range(1, 10).boxed();
stream.filter(v -> ((v % 2) == 0))
.map(v -> v * 10)
.forEach(System.out::println);
// 20, 40, 60, 80
위 예제를 보면, 1부터 9까지의 숫자 중에 filter()를 이용해서 짝수만 뽑아낸 다음 곱하기 10을 해서 10배에 해당하는 숫자를 생성하는 스트림 예제다.
flatMap
map() 메소드와 비슷한 역할을 하는 flatMap() 메소드도 있다.
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
flatMap() 메소드의 인자로 받는 람다는 리턴 타입이 Stream이다. 즉, 새로운 스트림을 생성해서 리턴하는 람다를 인자로 받는다. flatMap()은 중첩된 스트림 구조를 한단계 적은 단일 컬렉션에 대한 스트림으로 만들어주는 역할을 한다. 프로그래밍에서는 이런 작업을 '플랫트닝(Flattening)'이라고 한다.
예를 들어보자.
List<List<String>> list = Arrays.asLists(Arrays.asList("A", "B", "C"),
Arrays.asList("a", "b", "c"));
// [["A", "B", "C"], ["a", "b", "c"]]
이런 형태의 이중 구조 리스트를 스트림으로 순회한다고 생각해보자.
List<List<String>> list = Arrays.asLists(Arrays.asList("A", "B", "C"),
Arrays.asList("a", "b", "c"));
List<String> flatList = list.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
// ["A", "B", "C", "a", "b", "c"]
한 껍데기를 벗겨내서 조금 플랫한 리스트로 만들어주는 예제다. 여기서 람다는 '(e) → Collection.stream(e)'이며, 이를 축약해서 'Collection::stream'으로 사용했다.
Sorted
스트림 데이터들을 정렬하고자할 때, sorted() 메소드를 이용한다.
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
인자 없이 sorted() 메소드를 호출할 때에는 오름차순으로 정렬한다. 만약 정렬할 때 두 값을 비교하는 별도의 로직이 있다면, comparator를 sorted() 메소드의 인자로 넘겨줄 수도 있다.
Peek
peek() 메소드는 스트림 내 엘리먼트들을 대상으로 map() 메소드처럼 연산을 수행한다. 하지만 새로운 스트림을 생성하지는 않고 그냥 인자로 받은 람다를 적용하기만 한다.
Stream<T> peek(Consumer<? super T> action);
peek() 메소드는 그냥 한번 해본다는 의미로 생성되는 데이터들에 변형을 가하지 않고 그냥 인자로 받은 람다식만 수행해준다.
int sum = IntStream.range(1, 10)
.peek(System.out::println)
.sum();
이런식으로 중간에 로깅 같은 것을 하고자 할 때, peek() 메소드를 사용하면 좋다.
스트림 결과 생성
지금까지 본 데이터 수정 연산들은 또 데이터에 수정을 가한 결과 데이터들을 만들어내는 또 다른 스트림 객체를 리턴했다. 즉, 중간 작업(Intermediate Operations)들이며 이들만으로는 의미있는 스트림을 만들 수 없다. 데이터를 가공하고, 필터링한 다음 그 값을 출력하거나 또 다른 컬렉션으로 모아두는 등의 마무리 작업이 필요하다.
통계 값
정수 값을 받는 스트림의 마무리는 '총합'을 구하거나 '최대값', '최소값', '숫자의 개수', '평균 값' 등에 대한 계산이다.
int sum = IntStream.range(1, 10).sum();
int count = IntStream.range(1, 10).count();
int max = IntStream.range(1, 10).max();
int min = IntStream.range(1, 10).min();
int avg = IntStream.range(1, 10).average();
// 짝수 숫자의 총합
int evenSum = IntStream.range(1, 10)
.filter(v -> ((v % 2) == 0))
.sum();
만약 비어있는 스트림이라면 count(), sum() 메소드는 0을 리턴한다. 최대, 최소 값의 경우 Optional을 이용해 리턴한다.
Reduce
중간 연산을 거친 값들은 reduce
라는 메소드를 이용해 결과값을 만들어낸다. reduce() 메소드는 파라미터에 따라 3가지 종류가 있다.
// 스트림에서 나오는 값들을 accumulator 함수로 누적
Optional<T> reduce(BinaryOperator<T> accumulator);
// 동일하게 accumulator 함수로 누적하지만 초기값(identity)이 있음
T reduce(T identity, BinaryOperator<T> accumulator);
우선 스트림에서 뽑아져 나오는 값들을 누적시키는 accumulator 함수는 2개의 파라미터를 인자로 받아 하나의 값을 리턴하는 함수형 인터페이스다.
예를 들어
(a, b) -> Integer.sum(a, b)
이런 함수를 accumulator로 사용할 수 있다.
스트림은 때에 따라서 뽑아져 나오는 값이 없을 수 있다. 중간에 filter() 메소드를 거치면서 값이 하나도 나오지 않을 수 있기 때문이다. 때문에 reduce() 메소드의 리턴값은 Optional이다. 만약 초기값을 주는 reduce() 메소드를 사용한다면, 초기값이 있기 때문에 Optional이 아닌 것을 확인할 수 있다. 뽑아져 나오는 값은 초기값에 accumulator 함수가 적용된다.
Collect
자바 스트림을 이용하는 가장 많은 패턴 중 하나는 컬렉션의 엘리먼트 중 일부를 필터링하고, 값을 변형해서 또 다른 컬렉션으로 만드는 것이다.
Set<Integer> evenNumber = IntStream.range(1, 1000).boxed()
.filter(n -> (n%2 == 0))
.collect(Collectors.toSet());
자바 스트림은 collect() 메소드를 이용해 뽑아져 나오는 데이터들을 컬렉션으로 모아 둘 수 있다. 위 예제는 1부터 999까지의 숫자 중 짝수만 모아서 Set 컬렉션에 모아두는 예제다.
collect() 메소드에는 Collector 메소드를 사용할 수 있다. Collector 클래스에 있는 정적 메소드를 이용해서 뽑아져나오는 객체들을 원하는 컬렉션으로 만들 수 있다. Collector.toList()를 호출하면 리스트로 만들고, Collector.toSet()을 호출하면 Set으로 만들어준다.
Collector.joining()을 사용하면 작업한 결과를 하나의 문자열로 이어 붙이게 된다.
List<String> fruit = Arrays.asList("Banana", "Apple", "Melon");
String returnValue = fruit.stream()
.collect(Collectors.joining());
System.out.println(returnValue);
// BananaAppleMelon
Collector.joining() 메소드에 추가로 인자를 주면 문자열을 좀 더 멋지게 붙일 수 있다.
List<String> fruit = Arrays.asList("Banana", "Apple", "Melon");
String returnValue = fruit.stream()
.collect(Collectors.joining(",", "<", ">"));
System.out.println(returnValue);
// <Banana,Apple,Melon>
첫 번째 인자는 구분자이고, 두번째는 문자열의 맨 처음(prefix), 세번째는 문자열의 마지막(suffix)에 올 문자열이다.
foreach
스트림에서 뽑아져 나오는 값에 대해서 어떤 작업을 하고 싶을 때 foreach 메소드를 사용한다. 이 메소드는 앞에서 본 메소드들과 다르게 어떤 값을 리턴하지는 않는다.
Set<Integer> evenNumber = IntStream.range(1, 1000).boxed()
.filter(n -> (n%2 == 0))
.forEach(System.out::println);
1부터 999까지의 숫자 중 짝수만 뽑아내서 출력하는 코드다.
여기까지가 자바 스트림 기초다. 시간이 되면 조금 더 고급 내용을 정리하겠다.
댓글