본문 바로가기

프로그래밍 언어/JAVA

JAVA 입문 - 스트림

지난 글: [프로그래밍 언어/JAVA] - JAVA 입문 - 람다식

 

스트림이란?

배열 요소를 특정 기준에 따라 정렬(sorting)하거나, 요소 중 특정 값은 제외하고 출력하는(filter) 기능 등 이렇게 여러 자료의 처리에 대한 기능을 구현해 놓은 클래스가 스트림(stream)이다. 스트림을 활용하면 배열, 컬렉션 등의 자료를 일관성 있게 처리할 수 있다. 자료에 따라 기능을 각각 새로 구현하는 것이 아니라 처리해야 하는 자료가 무엇인지와 상관없이 같은 방식으로 메서드를 호출할 수 있기 때문이다. 다른 말로는 자료를 추상화했다고 한다.

 

배열을 예로 들면 아래 코드는 정수 5개를 요소로 가진 배열이고, 이를 모두 출력하는 출력문이다.

int[] arr = {1, 2, 3, 4, 5};
for(int i = 0; i < arr.length; i++) {
	System.out.println(arr[i]);
}

이 배열에 대한 스트림을 생성하여 출력하면 아래와 같다.

int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr).forEach(n -> System.out.println(n);

위 코드를 분석하면 아래와 같다.

스트림을 생성하고 미리 구현되어 있는 forEach( ) 메서드(최종 연산)를 사용해 배열의 요소를 하나씩 꺼내 출력할 수 있다. 그럼 스트림에 미리 구현되어 있는 연산 기능을 살펴보자.

 

스트림 연산

스트림 연산의 종류는 크게 중간 연산과 최종 연산 두 가지다. 중간 연산은 자료를 거르거나 변경하여 또 다른 자료를 내부적으로 생성한다. 최종 연산은 생성된 내부 자료를 소모해 가며 연산을 수행한다. 따라서 최종 연산은 마지막에 한 번만 호출된다. 그리고 최종 연산이 호출되어야 중간 연산의 결과가 만들어진다. 오늘 배울 연산은 중간 연산과 최종 연산의 여러 종류 중 가장 많이 사용하는 연산 위주로 배운다.

 

중간 연산 - filter( ), map( )

filter( )는 조건을 넣고 그 조건에 맞는 참인 경우만 추출하는 경우에 사용한다. 문자열 배열이 있을 때 문자열의 길이가 5 이상인 경우만 출력하는 코드는 아래와 같다.

map( )은 클래스가 가진 자료 중 이름만 출력하는 경우에 사용한다. 예로 고객 클래스가 있다면 고객 이름만 가져와 출력할 수 있다. map( )은 요소들을 순회하여 다른 형식으로 변환하기도 한다. map( )을 사용하는 예는 아래와 같다.

filter( )와 map( ) 둘 다 함수를 수행하며 해당 조건이나 함수에 맞는 결과를 추출해 내는 중간 역할을 한다. 그리고 최종 연산으로 중간 연산 결과를 출력한다.

 

최종 연산 - forEach( ), count( ), sum( ), reduce( )

최종 연산은 스트림의 자료를 소모하면서 연산을 수행하기에 최종 연산이 수행되고 나면 해당 스트림은 더 이상 사용할 수 없다. 최종 연산은 결과를 만드는 데 주로 사용한다. forEach( )는 위에서도 보았듯 요소를 하나씩 꺼내는 기능을 한다. 통계용으로 사용되는 count( ), sum( )은 배열 요소의 합계를 구한다든가 개수를 출력하는 등의 연산을 수행한다. 오늘은 스트림의 기본 내용과 사용하는 방법 위주로 배운다.

 

스트림 생성하고 사용하기

정수 배열에 스트림 생성하고 사용하기

스트림을 활용해 정수 배열에 대한 개수와 합을 출력하는 코드를 작성해보자.

출력 결과

출력 결과를 보면 배열의 합과 개수가 계산되는 것을 알 수 있다. count( ), sum( ) 외에도 max( ), min( ), average( ) 등 통계 연산을 위한 메서드도 제공한다.

 

Collection에서 스트림 생성하고 사용하기

Collection 인터페이스를 구현한 클래스 중 가장 많이 사용하는 ArrayList에 스트림을 생성하고 활용해보자. 아래처럼 문자열을 요소로 갖는 ArrayList가 있다.

이 ArrayList의 스트림을 생성하여 출력하고, 정렬하는 예를 살펴보자. Collection 인터페이스의 메서드를 살펴보면 아래와 같은 메서드가 있다.

Collection에서 stream( ) 메서드를 사용하면 이 클래스는 제네릭형을 사용해 아래처럼 자료형을 명시할 수 있다.

이렇게 생성된 스트림은 내부적으로 ArrayList의 모든 요소를 갖고 있다. 각 요소를 하나씩 출력하는 기능을 구현해보자. 모든 요소를 하나씩 가져와 처리할 때 스트림의 forEach( ) 메서드를 활용한다.

forEach( ) 메서드는 내부적으로 반복문이 수행된다. 그럼 forEach( ) 괄호 안에 구현되는 람다식의 의미는 무엇일까? forEach( ) 메서드가 수행되면 요소가 하나씩 차례로 변수 s에 대입되고 이를 매개변수로 받아 출력문이 호출된다.

 

이번에는 ArrayList에 저장된 이름을 정렬하여 그 결과를 출력해보자. 위에서 stream 변수에 스트림을 생성했지만 forEach( ) 메서드가 수행되면서 자료가 소모되었다. 따라서 스트림을 새로 생성해야 한다.

Stream<String> stream2 = sList.stream();
stream2.sorted().forEach(s -> System.out.println(s));

여기서는 중간 연산으로 정렬을 위한 sorted( ) 메서드를 호출하고, 최종 연산으로 출력을 위해 forEach( ) 메서드를 사용한다. sorted( ) 메서드를 사용하려면 정렬 방식에 대한 정의가 필요하다. 따라서 사용하는 자료 클래스가 Comparable 인터페이스를 구현해야 한다. 만약 구현되어 있지 않다면 sorted( ) 메서드의 매개변수로 Comparator 인터페이스를 구현한 클래스를 지정할 수 있다. ArrayList 외에 다른 Collection의 자료도 같은 방식으로 정렬하고 출력할 수 있다. 이것이 스트림을 사용하는 장점이다.

 

지금까지의 코드를 정리해 작성하면 아래와 같다.

출력 결과

 

스트림의 특징

지금까지 살펴본 스트림의 특징을 정리하면 아래와 같다.

 

자료의 대상과 관계없이 동일한 연산을 수행한다

배열이나 컬렉션에 저장된 자료를 갖고 수행할 수 있는 연산은 여러 가지가 있다. 배열에 저장된 요소 값을 출력한다든가 조건에 따라 자료를 추출하거나, 자료가 숫자일 때 합계, 평균 등을 구할 수도 있다. 스트림은 컬렉션의 여러 자료 구조에 대해 이러한 작업을 일관성 있게 처리할 수 있는 메서드를 제공한다.

 

한 번 생성하고 사용한 스트림은 재사용할 수 없다

어떤 자료에 대한 스트림을 생성하고 이 스트림에 메서드를 호출하여 연산을 수행했다면 해당 스트림을 다시 다른 연산에 사용할 수 없다. 예로 스트림을 생성하여 배열에 있는 요소를 출력하기 위해 각 요소들을 하나씩 순회하며 출력에 사용하는데, 이때 요소들이 '소모된다'라고 얘기한다. 소모된 요소는 재사용할 수 없으므로 만약 다른 기능을 호출하려면 스트림을 새로 생성해야 한다.

 

스트림의 연산은 기존 자료를 변경하지 않는다

스트림을 생성하여 정렬한다거나 합을 구하는 등의 여러 연산을 수행한다 해서 기존 배열이나 컬렉션이 변경되지는 않는다. 스트림 연산을 위해 사용하는 메모리 공간이 별도로 존재하므로, 스트림의 여러 메서드를 호출하더라도 기존 자료에는 영향을 미치지 않는다.

 

스트림의 연산은 중간 연산과 최종 연산이 있다

스트림에서 사용하는 메서드는 크게 중간 연산과 최종 연산 두 가지로 나뉜다. 스트림에 중간 연산은 여러 개가 적용될 수 있고, 최종 연산은 맨 마지막에 한 번 적용된다. 만약 중간 연산이 여러 개 호출되었더라도 최종 연산이 호출되어야 스트림의 중간 연산이 모두 적용된다. 예로 자료를 정렬하거나 검색하는 중간 연산이 호출되어도 최종 연산이 호출되지 않으면 정렬이나 검색한 결과를 가져올 수 없다. 이를 '지연 연산(lazy evaluation)'이라 한다.

 

프로그래머가 기능을 지정하는 reduce( ) 연산

이제까지 배운 연산은 기능이 미리 정해져 있었다. reduce( ) 연산은 내부적으로 스트림의 요소를 하나씩 소모하면서 프로그래머가 직접 지정한 기능을 수행한다.

 

JDK에서 제공하는 reduce( ) 메서드의 정의는 아래와 같다.

T reduce(T identify, BinaryOperator<T> accumulator)

첫 번째 매개변수 T identify는 초깃값을 의미하고 두 번째 매개변수 BinaryOperator<T> accumulator는 수행해야 할 기능이다. BinaryOperator 인터페이스는 두 매개변수로 람다식을 구현하며 이 람다식이 각 요소가 수행해야 할 기능이 된다. 이때 BinaryOperator 인터페이스를 구현한 람다식을 직접 써도 되고, 람다식이 길면 인터페이스를 구현한 클래스를 생성해 대입해도 된다. 또한 BinaryOperaotr는 함수형 인터페이스로 apply( ) 메서드를 반드시 구현해야 한다. apply( ) 메서드는 두 개의 매개변수와 한 개의 반환값을 가지는데, 세 개 모두 같은 자료형이다. reduce( ) 메서드가 호출될 때 BinaryOperator의 apply( ) 메서드가 호출된다.

 

reduce( ) 메서드를 사용해 모든 요소의 합을 구할 때, 두 번째 매개변수에 람다식을 직접 쓰는 경우는 아래와 같다.

초깃값은 0이고 스트림 요소가 매개변수로 전달되면서 합을 구한다. 내부적으로는 반복문이 호출되며 람다식에 해당하는 부분이 리스트 요소만큼 호출되는 것이다. 따라서 reduce( ) 메서드에 어떤 람다식이 전달되느냐에 따라 다양한 연산을 수행할 수 있다. reduce( )는 처음부터 마지막까지 모든 요소를 소모하며 람다식을 반복해 수행하므로 최종 연산이다.

 

배열에 여러 문자열이 있을 때 그 중 길이가 가장 긴 문자열을 찾는 코드를 작성하며 reduce( ) 메서드 사용법을 알아보자. 두 번째 매개변수에 람다식을 직접 쓰는 경우와 BinaryOperator 인터페이스를 구현한 클래스를 사용하는 경우 두 가지를 살펴보자.

출력 결과

구현하는 람다식이 너무 긴 경우에는 6~16행처럼 직접 BinaryOperator 인터페이스를 구현한 클래스를 만들고 reduce( ) 메서드에 해당 클래스로 생성한 인스턴스를 매개변수로 전달하면 여기에 구현된 apply( ) 메서드가 자동으로 호출된다. 람다식으로 구현된 부분도 익명 클래스의 인스턴스가 생성되는 것이므로 내부적으로는 동일한 구조라 할 수 있다. 

 

스트림을 활용해 여행객의 여행 비용 계산하기

패키지 여행을 떠나는 고객들이 있다. 여행 비용은 15세 이상은 100만 원, 그 미만은 50만 원이다. 고객 세 명이 패키지 여행을 간다고 했을 때 비용 계산과 고객 명단 검색등을 스트림을 활용하여 구현해보자.

 

우선 고객 클래스를 정의한다. 고객 클래스는 이름, 나이, 비용을 멤버 변수로 가지며, 멤버 변수에 대한 get( ) 메서드만 제공한다.

세 명의 고객을 ArrayList에 추가하고 이에 대한 스트림을 생성하여 다음 연산을 수행해보자.

  • 고객의 명단을 출력한다.
  • 여행의 총 비용을 계산한다.
  • 고객 중 20세 이상인 사람을 이름을 정렬하여 출력한다.

스트림을 사용하지 않고 위 내용을 구현한다면 코드를 여러 번 반복해서 사용해야 할 것이다. 하지만 미리 구현되어 있는 스트림의 메서드로 코드를 간결하게 작성할 수 있다.

출력 결과

고객 명단을 출력하는 코드에서 19행에서는 map( ) 메서드를 사용해 고객 이름을 가져오고 forEach( ) 메서드로 이름을 출력하고 있다. 21행에서는 각 고객이 지불한 비용을 가져와 mapToInt( ) 메서드로 그 값을 정수로 변환한 후 sum( )으로 합을 구한다. 최종 연산 sum( )의 반환 값이 int형이므로 int형 total 변수에 결과를 대입했다. 25행의 20세 이상 고객을 가져와 이름을 정렳는 부분은 3개의 중간 연산을 사용했다. filter( )로 20세 이상만 추출한 후 map( )으로 이들의 이름을 가져오고, sorted( )를 사용해 이름을 정렬했다. 그리고 최종 연산 forEach( )를 사용해 출력했다.

 

참고 서적 : 자바 프로그래밍 입문 - 박은종

'프로그래밍 언어 > JAVA' 카테고리의 다른 글

JAVA 입문 - 예외 처리하기  (0) 2022.06.11
JAVA 입문 - 예외 클래스  (0) 2022.06.11
JAVA 입문 - 람다식  (0) 2022.06.09
JAVA 입문 - 내부 클래스  (0) 2022.06.08
JAVA 입문 - Map 인터페이스  (0) 2022.06.07