지금까지 많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 Iterator를 이용해서 코드를 작성했다. 그러나 이런 방식으로 작성된 코드는 너무 길고 알아보기 힘들며, 데이터 소스마다 다른 방식으로 다뤄야 한다는 단점이 있다. 비록 Collection이나 Iterator와 같은 인터페이스를 이용해서 컬렉션을 다루는 방식으로 표준화했지만, 그것보다 더 깔끔하게 사용 가능한 것이 바로 스트림(Stream)이다.
스트림(Stream)이란?
스트림은 데이터 소스를 추상화하고 데이터를 다루는데 자주 사용이 되는 메서드들을 정의해 두었다. 데이터 소스를 추상화했다는 것은 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다. 스트림을 이용하면, 배열이나 컬렉션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.
스트림을 이용하면 선언형으로 데이터 소스를 처리할 수 있다고 말하기도 한다. 선언형 프로그래밍이란 어떻게 수행하는 지보단 무엇을 수행하는지에 관심을 두는 프로그램 패러다임을 말한다. 명령형 방식은 절차에 따라 하나하나 따라가야 코드를 이해할 수 있는 반면, 선언형 방식으로 코드를 작성하면 내부 동작 원리를 모르더라도 코드가 무슨 일을 하는지 이해할 수 있다. 즉, 어떻게 동작하는지에 대한 부분은 추상화가 되어 있다.
스트림의 특징
- 스트림은 데이터 소스를 변경하지 않는다.
스트림은 read-only로 데이터 소스로부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다. 필요하다면 정렬된 결과를 컬렉션이나 배열에 담아서 반환하는 방식으로 사용한다.
- 스트림은 일회용이다.
스트림은 생성, 중간 연산, 최종 연산으로 나뉘는데 중간 연산은 여러 번 수행 가능하지만 최종 연산은 한 번 수행하고 나면 스트림을 소모한다.
즉, 스트림은 Iterator처럼 일회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용 할 수 없다. 필요하다면 다시 생성하여 사용해야 한다.
- 스트림은 작업을 내부 반복으로 처리한다.
스트림을 이용한 작업이 간결할 수 있는 비결 중의 하나가 바로 내부 반복이다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식이나 메서드 참조를 데이터 소스의 모든 요소에 적용한다.
- 스트림이 제공하는 연산을 이용하면 편리하다.
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다. 스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있다.
중간 연산은 연산 결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다.
최종 연산은 스트림의 요소를 소모하면서 연산을 수행하기 때문에 단 한번만 연산이 가능하다.
스트림을 생성하고 중간 연산을 거쳐 최종 연산을 하는 것을 스트림의 파이프 라인이라고 한다.
- 지연된 연산을 수행한다.
지연된 연산이란 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것이다. 스트림에 대해 중간 연산을 호출해도 즉각적인 연산이 수행되지 않는다는 것이다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야 하는지를 지정해 주는 것일 뿐이다. 최종 연산이 수행되어야지만 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.
- 병렬 스트림
스트림으로 데이터를 다룰 때의 장점 중 하나가 바로 병렬 처리가 쉽다는 것이다.
병렬 스트림은 내부적으로 fork&join 프레임웍을 이용해서 자동적으로 연산을 병렬로 수행한다. 스트림에 parallel()이라는 메서드를 호출해서 스트림의 속성을 변경해 병렬로 연산을 수행하도록 지시하기만 하면 된다. 병렬로 처리되지 않게 하려면 sequential()을 호출하면 된다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 sequential()을 호출할 필요가 없고 parallel()을 취소할 때만 사용한다. 하지만 모든 병렬 처리가 다 빠른 것은 아니다.
그럼 이제 본격적으로 스트림 사용법을 알아본다!
스트림 생성
- 컬렉션인 경우 스트림 생성 방법
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> intStream = list.stream()
위와 같이 컬렉션의 최고 조상인 Collection에 저장된 stream()을 이용하여 해당 컬렉션을 소스로 하는 스트림을 반환하도록 할 수 있다.
- 배열인 경우 스트림 생성 방법
Stream<String> strStream = Stream.of(new String[]{"a","b","c"});
Stream<String> strStream2 = Arrays.stream(new String[]{"a","b","c"});
//기본형 스트림 생성 방법
IntStream intStream = IntStream.of(1, 2, 3);
IntStream intStream2 = Arrays.stream(new int[]{1, 2, 3});
- 빈 스트림 생성 - empty()
Stream emptyStream = Stream.empty(); //빈 스트림을 생성해서 반환
- 두 스트림의 연결 - concat()
//strs1, strs2가 스트림이라 가정
Stream<String> strs3 = Stream.concat(strs1, strs2);
- 임의의 수 생성
난수를 생성하는데 사용하는 Random 클래스에는 해당 타입의 난수들로 이루어진 스트림을 반환하는 메서드들이 있다.
IntStream ints(long streamSize, int begin, int end)
LongStream longs(long streamSize, long begin, long end)
DoubleStream doubles(long streamSize, double begin, double end)
IntStream intStream = new Random().ints(5) //5개의 난수 스트림 반환
해당 메서드들은 크기가 정해지지 않은 무한 스트림(infinite stream)이므로 인수로 원하는 개수를 넘겨주거나 limit()을 함께 사용하여 스트림의 크기를 정해주어야 한다. 생성해주는 수의 범위는 자료형의 최저값부터 최고값이다. 생성 범위를 지정하고 싶다면 두 번째, 세 번째 인자에 시작 범위와 끝 범위를 넘겨주면 된다.
- 람다식 - iterate(), generate()
Stream 클래스의 iterate()와 generate()는 람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.
static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> Stream<T> generate(Supplier<T> s)
iterate()는 seed 값으로 지정된 값부터 시작해서 람다식 f에 의해 계산되는 결과를 다시 seed 값으로 해서 람다식 적용을 반복한다.
generate()도 iterate()처럼 람다식에 의해 계산되는 값을 요소로 하는 무한 스트림을 생성해서 반환하지만 iterate()와 달리 이전의 결과를 이용해서 다음 요소를 계산하지 않는다. 그리고 매개변수가 없는 람다식만 허용된다.
위 두 메서드에 의해 생성된 스트림을 기본형 스트림 타입의 참조변수로는 다룰 수 없다. 필요하다면 mapToInt()를 이용하여 변환해야 한다.
반대로 기본형 타입의 스트림을 Stream<Integer>타입으로 변환하려면, boxed()를 사용하면 된다.
스트림의 중간 연산
- 스트림 자르기 skip(), limit()
Stream<T> skip(long n)
Stream<T> limit(long maxSize)
intStream.skip(3).limit(5); //4번째 요소부터 5개 요소를 가진 스트림 반환
기본형 스트림에도 정의되어 있다.
- 스트림의 요소 걸러내기 - filter(), distinct()
Stream<T> filter(Predicate<? super T> predicate) //람다식(연산결과가 boolean)으로 필터 정의
Stream<T> distinct() // 중복 제거
- 정렬 - sorted(), comparing()
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator)
sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator 대신 int 값을 반환하는 람다식을 사용하는 것도 가능하다. Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다는 것을 주의해야 한다.
문자열 스트림을 정렬할 때 사용하는 람다식
- Comparator.natualOrder() //기본 정렬(사전 순)
- Comparator.reverseOrder() //기본 정렬의 역순
- Comparator.<String>natualOrder().reversed()
- String.CASE_INSENSITIVE_ORDER //대소문자 구분 안 함
- Comparator.comparing(String::length) //길이 순 정렬
- Comparator.comparingInt(String::length) //no오토박싱 길이순 정렬
studentStream.sorted(Comparator.comparing(Student::getBan))
.thenComparing(Studnt::getTotalScore)
.thenComparing(Student::getName)
.forEach(System.out::println)
학생 스트림을 반별, 성적순, 그리고 이름순으로 정렬하여 출력하는 코드이다. thenComparing()은 정렬 조건을 추가할 때 사용한다.
- 변환 - map()
fileStream.map(File::getName) //Stream<File> -> Stream<String>
.filter(s -> s.indexOf('.') != -1) //확장자가 없는 파일 제외
.map(s -> s.substring(s.indexOf('.')+1)) //Stream<String> -> Stream<String>
.map(String::toUpperCase); //모두 대문자 변환
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 하는 경우에 사용한다.
- 조회 - peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하는 데 사용한다. forEach()와 달리 스트림의 요소를 소모하지 않기 때문에 여러 번 사용 가능하다.
- 스트림을 기본형 스트림으로 변환 - mapToInt(), mapToLong(), mapToDouble()
IntStream studentStream = studentStrea.mapToInt(Student::getTotalScore);
Stream<T> 타입의 스트림을 기본형 스트림으로 변환할 때 사용한다. 언박싱이 일어나는 것을 막아 더 좋은 성능을 낼 수 있다.
- 스트림 여러 번 사용 가능 - summaryStatistic()
IntSummaryStatistics stat = scoreStream.summaryStatistics();//stat 여러번 최종 연산 가능
- flatMap() - Stream <T []>을 Stream <T>으로 변환
스트림의 요소가 배열이거나 map()의 연산 결과가 배열인 경우이면 스트림 타입이 Stream<T[]>인데 Stream <T>으로 변환해준다.
스트림의 최종 연산
최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산 후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다.
- 일반 스트림 / 기본형 스트림에서의 통계
int sum() //스트림의 모둔 요소의 총 합
OptionalDouble average() //sum() / (double)count()
OptionalInt max() //요소 중 최댓값
OptionalInt min() //요소 중 최소값
Optional 자세히 알아보기 참고 - [Java] Optional와 Optional 클래스 메서드
일반 스트림에서는 count(), max(), min()만 지원한다.
- 요소 순환 - forEach(Consumer<? super T> action)
- 조건 검사 - allMatch(), anyMatch(), noneMatch()
스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는 지, 아니면 어떤 요소도 일치하지 않는지 확인하는 용도로 사용한다. 이 메서드들은 모두 매개변수로 Predicate를 요구하며, 연산 결과로 boolean을 반환한다.
- 조건 검사2 - findAny(), findFirst()
filter()와 함께 쓰여서 조건에 맞는 스트림의 요소가 있는지 확인하는 데 사용된다. 둘 다 같은 일을 하지만, 병렬 스트림인 경우에는 findAny()를 사용해야 한다. 반환 타입은 Optional<T>이며, 스트림의 요소가 없을 때는 null이 저장되어 있는 비어있는 Optional 객체를 반환한다.
- 리듀싱 - reduce()
스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과(Optional<T>)를 반환한다. 그래서 매개변수의 타입이 BinaryOperator<T>이다. 처음 두 요소를 가지고 연산한 결과를 가지고 그다음 요소와 연산한다. 이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트림의 모든 요소를 소모하게 되면 그 결과를 반환한다.
매개변수로 연산 결과의 identity(초기값)을 받는 경우에는 초기값과 스트림의 첫 번째 요소로 연산을 시작한다. 스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로, 반환 타입이 Optional<T>가 아니라 T이다.
collect()에 대한 내용은 다음 글에서 다룬다.
[Java] 스트림의 collect()와 세부 메서드
collect()는 스트림의 요소를 수집하는 최종 연산으로 리듀싱(reducing)과 유사하다. collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어 있어야 하는데, 이 방법을
ta-mi.tistory.com
자바의 정석(남궁성)을 정리한 내용입니다.
'Java' 카테고리의 다른 글
[Java] Collector 구현하기 (0) | 2022.09.18 |
---|---|
[Java] 스트림의 collect()와 세부 메서드 (0) | 2022.09.18 |
[Java] Optional<T>와 Optional 클래스 메서드 (2) | 2022.09.16 |
[Java] 함수형 인터페이스(Functional Interface) - 매개변수, 형 변환, 변수 참조, function 패키지 (0) | 2022.09.15 |
[Java] 람다식(Lambda expression) - 생성 규칙 / 메서드 참조 (0) | 2022.09.15 |