Java8 에서는 기존의 Java 에 비해 다양한 기능들이 접목되었다. 

Optional 과 Lambda, Stream API 와 같은 기능들이 그것인데, 이는 새로이 대세로 떠오르고 있는 함수형 프로그래밍의 메타를 적용한 Java 의 새로운 진화라 할 수 있겠다.


Java8 에 대한 전반적인 소개는 다음 링크를 참조하자.

(http://jins-dev.tistory.com/entry/Java8-%EC%97%90%EC%84%9C-%EC%83%88%EB%A1%9C-%EC%83%9D%EA%B2%A8%EB%82%9C-API-%EB%93%A4?category=760006)


본 포스팅에서 집중해볼 것은 실무에서도 이제 널리 사용되고 있는 Stream API 에 대한 내용과 간단한 사용방법들이다.


 대부분의 이전 Java는 데이터를 처리하기 위해 컬렉션을 사용하고 이는 전통적인 반복문과 조건문 사용에 기인한다. 

컬렉션을 사용하여 할 수 있는 기능은 무한하고 강력하지만, 정작 데이터를 처리하는 패턴은 SQL에서 데이터를 찾는것과 비슷하게 데이터를 찾거나, 묶는것 정도에 국한된다.


 Java8의 Stream API 는 컬렉션을 이용했던 기존의 코드를 좀 더 깔끔하게 구현하고 병렬처리에 있어서도 이점을 가질 수 있도록 하기 위하여 제공된다. 

Stream API의 기본이 되는 원형 stream() 메서드는 모든 컬렉션 타입에 대해 제공되며, 컬렉션 내의 Element 들에 대해 하나씩 구분한 Stream 결과를 나타낸다. 


먼저 Stream API 가 어떻게 구성되는지 알아보자.

Stream API 는 생성연산, 중간연산, 최종연산으로 Flow 가 구성되어 있다. 생성연산은 스트림의 생성을, 중간 연산은 스트림을 통한 데이터 가공 및 변환을 담당하고 최종 연산은 Stream 의 사용을 담당한다. 다음은 연산별로 Stream API 를 정리한 내용이다.



순서대로 스트림 생성연산, 중간연산, 최종연산을 분류하였다.

Stream API 를 사용하기 위해서는 반드시 위의 Pipeline 을 순서대로 따라야 하며, 여러개의 중간연산을 붙이는 것도 가능하다. 다만, 생성연산과 중간연산들은 Stream 을 반환해야만 추가 연산이 가능하다는 점을 잊지 말자.

이를 이용해서 다음 리스트에서 원하는 정보를 추출해보자.


Stream API 를 이용하면 위의 리스트에서 70 이상의 숫자들을 정렬해서 다음과 같이 추출해낼 수 있다.



결과도 다음과 같이 확인해볼 수 있다.



코드 역시 상당히 직관적으로 이해할 수 있다. 코드상에서 우리는 numList 의 Stream 을 생성하여, 70 이상인 요소들만 필터링하여 정렬 후 Array 형태로 반환하고 있다.


이처럼 Stream API 를 사용하면 만들어진 스트림을 별도의 저장이나 가공없이 filter(속성에 맞게 필터링), sorted(정렬), map(정보를 추출), collect(정보를 가공) 할 수 있으며 이 메서드들은 collect에 의해 연속되어 처리된 결과를 리턴하거나 형변환, 또는 Stream 연산의 결과를 취합하여 반환한다. 


다음은 몇가지 자주 사용되는 연산에 대한 설명이다.

(1) map
함수 형태 : Stream<R> map(Function<? super T, ? extends R> mapper)
설명 :  T 타입 객체를 입력받아 R 타입을 반환하는 스트림 생성. 가장 많이 쓰이는 함수 중 하나이며, Stream 을 다른 형태의 Stream 으로 매핑시키는 역할을 한다.

(2) filter
함수 형태 : Stream<T> filter(Predicate<? super T> predicate)
설명 : Predicate(T 를 입력으로 받아 Boolean 을 반환하는 조건 식) 람다식이 true를 반환하는 새로운 스트림 생성

(3) flatMap
함수 형태 : Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
설명 : T 타입을 입력으로 받아, R 타입의 객체를 1:N 으로 매핑하는 스트림을 생성. 말그대로 평평하게(Flat) 만드는 함수이다.

(4) peek
함수 형태 : Stream<T> peek(Consumer<? super T> action)
설명 : T 타입의 매개변수를 입력받아 void 를 반환하는 함수를 실행하고 Stream 을 그대로 반환.

(5) limit
함수 형태 : Stream<T> limit(long maxSize)
설명 : maxSize까지의 Element 들만 반환하는 스트림 생성

(6) sorted
함수 형태 : sorted(), sorted(Comparator<T> comparator)
설명 : 정렬된 스트림을 생성. Integer 라면 자동정렬하지만, T 자료형에 대해 직접 Comparator 구현 필요하다. 
입력으로 주어지는 전체 스트림의 요소를 한꺼번에 정렬하기 때문에, 연속적으로 적용이 불가능하다.

(7) distinct
함수 형태 : distinct()
설명 : 값은 값을 갖는 요소를 중복해서 발생하지 않는 스트림 생성

8번부터는 Terminal Operation 들이다.

(8) forEach
함수 형태 : void forEach(Consumer<? super T> consumer)
설명 : T 타입을 입력으로 받아 void 를 수행하고 void 를 반환한다. for문과 동일하지만, 동작은 동일하지 않다. 
(forEach 는 Stream API 의 일부라는 점을 명심하자. 일반 for 구문과 다르게 PipeLine 을 충실히 따른다.)

(9) count
함수 형태 : count()
설명 : Stream 의 요소의 갯수를 반환한다.

(10) anyMatch
함수 형태 : boolean anyMatch(Predicate<? super T> predicate)
설명 : T를 입력받아 boolean 을 반환하는 predicate 람다식을 만족하는 항목이 Stream 에 하나라도 존재하는지를 반환한다.

(11) noneMatch
함수 형태 : boolean noneMatch(Predicate<? super T> predicate)
설명 : anyMatch 와 동일하지만 람다식을 만족하는 항목이 없는지를 반환한다.

(12) collect
함수 형태 : <R,A> R collect(Collector<? super T,A,R> collector)
설명 : Stream 을 Collector 형태로 치환한다. List 나 Map 등의 Collection 으로 변환하는 함수가 들어간다.


Stream API 를 사용할 때 꼭 알아두어야 할 점은 Stream은 요소들을 보관하지 않으며 요소들은 하부의 컬렉션에 보관되거나 필요할 때 생성된다는 점이다. 또한 원본을 변경하지 않는 대신 결과를 담은 새로운 스트림을 반환한다. 

스트림 연산은 가능하면 지연(Lazy) 처리 되기 때문에 결과가 필요하기 전에는(최종 연산 이전) 실행되지 않는다. 

따라서 최종연산 이전의 연산의 순서는 보장할 수 없다. (가령, Stream 연산에 2개 이상의 sorted 가 있을 경우 어떤 sorted 가 먼저 수행될지는 불분명하다.)


이 말을 다시한번 정리해보자. 스트림은 최대한 연산을 지연하며, 그에 따라 최종연산에 오기 전까지는 연산이 실제로 수행되지 않는다는 것이다.


이를 통해 잘 알아두어야 할 점은, 각 연산별 Pipeline 은 
절차적이지 않다는 점이다. 가령, stream 에 peek 을 통해 연산을 수행한 뒤 collect 로 취합하더라도 루프를 중첩해서 돌지 않는다. 
Stream 은 최종 연산 수행시 내부 파이프라인을 통해 최적화된 연산을 수행한다.

이처럼 Stream 은 각 요소 단위로 연산이 수행되는데 지연(Lazy) 처리 특성에 따라 최종연산에서 한꺼번에 처리가 되므로 최종연산이 선언되지 않은 체인 스트림에서 동작을 수행할 경우 이는 반영되지 않는다. 즉, 다음과 같은 코드는 아무 동작도 하지 않는다.



위의 연산은 peek 이라는 중개 연산을 마지막으로 구문을 마치고 있기 때문에 결과적으로 Stream 연산이 수행되지 않는다. 따라서 출력도 되지 않는다.


이러한 Stream의 특성을 Lazy & ShortCircuit 이라 하며 이는 Stream 이 Collection 과 다르게 그 자체만으로 자료구조가 아닌 연산을 위한 자료구조인 특성을 반영한다 


Stream 을 이용하면 가독성이 뛰어나고, 잘 사용하면 성능적으로도 뛰어난 결과를 가져올 수 있으며 쉽게 병렬 처리 환경으로 이식도 가능하다.

Stream 의 응용은 무궁무진하며 사용하기 위해서는 익숙해지는 것이 중요하다. 많이 사용해보고 적절하게 응용해보도록 하자.





 Java8에서는 Lambda 식과 같은 함수형 프로그래밍을 지원하기 위한 API를 포함해서 이를 위한 특징적인 API 들이 많이 생겨났다.

그 중에서도 이 포스팅에서는 핵심적인 3가지, Stream, Optional, Lambda 에 대해 정리하고자 한다.


 먼저 컬렉션을 다루기위한 API 중에는 Stream API 가 존재한다.

Java 에서 컬렉션은 데이터를 관리하고 제어하기 위한 좋은 컨테이너이지만 구현의 인터페이스 적인 측면에 있어서는 깔끔하다고 보기는 힘들다.


특히 이것은 SQL 문과 비교해보면 이해가 쉽다. 학생 정보를 담고 있는 데이터 컬렉션에서 특정 성적 이상을 가진 학생들을 찾을 때 Collection을 이용하면 다음과 같이 구현할 수 있다.


List<Student> students = getStudentList();
List<Student> List = new ArrayList();
For(Student s : students)
{
    If(s.getGrade() >= 80)
        List.add(s);
}


반면, Sql 문을 사용한다면 이는 조건절이 포함된 SELECT 구문 하나로 처리가 가능하다.

Java8의 Stream API는 이러한 복잡한 구조의 데이터 처리를 간단하게 해주어 복잡도를 낮추는 설계가 가능하게 한다. 또한 이런 종류의 Lazy collection 은 멀티 스레드 및 병렬처리 환경에서도 개발자에게 상당히 유연한 선택지를 갖게 한다. 또한 한번만 소비 가능한 형태의 컬렉션이기 때문에 메모리 관리 차원에서의 이점도 있다.



List<Student> list = students.stream().filter(t->t.getGrade()>=80).collect(Collectors.toList());


Stream API 는 위와 같이 Collection 의 각 요소들을 따로따로 주어진 함수구문(위에서는 람다식)에 따라 처리하며 내부 버퍼를 통해 주어진 형태로 반환해준다.


Stream API 에는 위에 적용된 filter 나 collector 와 같은 메서드를 포함해서, map, flatMap, reduce, peek 등 다양한 메서드를 제공한다.

Stream API 의 자세한 사용에 있어서는 추가로 포스팅하고자 한다.


 새로 추가된 Optional API 는 Java8 에서 제공하는 Null이 포함되지 않는 Collection이다.

NullPointerException 을 걱정하지 않아도 되며, ifpresent(함수식) 기능을 통해 무결성 검증과 동시에 함수식을 수행할 수도 있다. 

가령 NULL 이 될 수 있는 객체에 대해 다음과 같이 핸들링 할 수 있다. 



public Student getStudentById(String studentId) {
// Stream API 의 findFirst() 는 그 자체로 Optional 을 return 하기 때문에 사실 이는 좀 어색한 함수 구현이다.
return getStudentList().stream()
.filter(student->student.getStudentId().equals(studentId)).findFirst().get();
}

public void foo() {

Student student = getStudentById("철수");

if(Optional.ofNullable(student).isPresent()) {
System.out.println("철수라는 학생이 존재합니다.");
} else {
System.out.println("철수라는 학생은 없습니다.");
}
}


위와 같이 Optional 을 사용하면 Null 을 핸들링할 수 있는 객체를 다룰 수 있다. if(student == null) 과 같은 형태 대신 좀 더 직관적으로 다양한 이용이 가능하다.


마지막으로 Java8의 람다식은 함수형 인터페이스를 제공함으로써 지원이 된다.

 함수형 인터페이스를 만드는 방법은 @FunctionalInterface 어노테이션을 이용해 명시적으로 생성도 가능하지만, method가 하나 존재하는 인터페이스를 선언함으로써 생성이 가능하다.

다음의 예시를 참조할 수 있다.



Public interface Foo
{
    Int calc(int a, int b);
}
{
    Foo add = (int a, int b) -> { return a+b; };
    Foo minus = (int a, int b) -> { return a-b; };
    Int addv = add.calc(3, 5);      //8
    Int minusv = minus.calc(5, 3);  //2
}


위의 예시는 직접 FunctionalInterface 를 구현하여 Lambda 식을 적용해본 것이고, 실제 많이 사용되는 것은 Java8 에서 제공하는 API 들이다. 다음은 그 몇가지 종류이다.


Function<T, R> : T 타입의 입력파라미터를 받아 R 타입을 리턴한다.


Supplier<T> : void 타입의 파라미터를 입력받아 T 타입을 리턴한다.


Consumer<T> : T 타입의 파라미터를 받아 void 를 리턴한다.


Predicate<T> : T 타입의 입력을 받아 boolean 을 리턴한다.


BiPredicate<T, U> : T와 U 타입을 입력받아 boolean 을 리턴한다.


UnaryOperator<T, T> : T 타입 2개를 입력받아 T 타입을 리턴한다.


이 외에도 많은 종류의 FunctionalInterface 를 제공하며, 잘 사용하면 함수형 프로그래밍의 장점을 누릴 수 있다.


+ Recent posts