본문 바로가기

java

Java8에 새로 추가된 것은 뭐가 있을까 ?_2 (funtional 패키지, Stream, 디폴트 메서드 등)

[배경]

 저번에는 버전이 java8로 넘어가면서 '함수형 프로그래밍 패러다임의 지원' 에 대해 기본적인 내용과 어떤 방식이 변화 되었고 어떤 것이 새로 추가가 되었는지 알아보았다. 이번에는 Lambda 표현식에 이어 funtional 패키지 Stream과 디폴트 메서드 등에 대해 알아보자.

 


[내용]

2. java.util.funtional 패키지

Java 8에서 제공하는 주요 Functional 인터페이스는 java.util.function 패키지에 있다.

  • Predicate
  • Supplier
  • Consumer
  • Function
  • UnaryOperator
  • BinaryOperator

Predicate

test()라는 메소드가 있으며, 두 개의 객체를 비교할 때 사용하고 boolean을 리턴한다. 추가로 and(), negative(), or()이라는 default 메소드가 구현되어 있으며, isEqual()이라는 static 메소드도 존재한다.

Supplier

get() 메소드가 있으며, 리턴값을 generic으로 선언된 타입을 리턴한다. 다른 인터페이스들과 다르게 추가적인 메소드는 선언되어 있지 않다.

Consumer

accept()라는 매개 변수를 하나 갖는 메소드가 있으며, 리턴 값이 없다. 그래서, 출력을 할 때처럼 작업을 수행하고 결과를 받을 일이 없을 때 사용한다. 추가로 andThen()이라는 default 메소드가 구현되어 있는데, 순차적인 작업을 할 때 유용하게 사용될 수 있다.

Function

apply()라는 하나의 매개 변수를 받는 메소드가 있으며, 리턴 값도 존재한다. 이 인터페이스는 Function<T, R>로 정의되어 있어, Generic 타입을 2개 갖고 있다. 앞에 있는 T는 입력 타입, 뒤에 있는 R은 리턴 타입을 의미한다. 즉, 변환을 할 필요가 있을 때 이 인터페이스를 사용한다.

UnaryOperator: A unary operator from T -> T

apply()라는 하나의 매개 변수를 갖는 메소드가 있으며, 리턴값도 존재한다. 단, 한 가지 타입에 대하여 결과도 같은 타입일 경우 사용한다.

BinaryOperator: A unary operator from (T, T) -> T

apply()라는 두개의 매개 변수를 갖는 메소드가 잇으며, 리턴값도 존재한다. 단, 한 가지 타입에 대하여 결과도 같은 타입일 경우 사용한다.

이 중에서 좀 복잡한 Predicate라는 인터페이스에 대해서 예제를 통해 살펴보자.

Predicate는 다음과 같은 방법으로 선언하면 된다.

Predicate<String> predicateLength5 = (a) -> a.length()>5;
Predicate<String> predicateContains = (a) -> a.contains("God");
Predicate<String> predicateStart = (a) -> a.startsWith("God");
  • predicateLength5는 길이가 5보다 큰지 여부를 확인한다.
  • predicateContains는 "God"이라는 문자열에 포함되었는지 여부를 리턴한다.
  • predicateStart는 "God"라는 문자열로 시작하는지 여부를 리턴한다.

이 Functional 인터페이스를 사용하기 위한 다음의 예제를 살펴보자.

package lambda.functinoal;

import java.util.function.Predicate;

public class PredicateExample {
    public static void main(String[] args){
        PredicateExample sample = new PredicateExample();

        Predicate<String> predicateLength5 = (a) -> a.length() > 5;
        Predicate<String> predicateContains = (a) -> a.contains("God");
        Predicate<String> predicateStart = (a) -> a.startsWith("God");

        String godOfJava = "GodOfJava";
        String goodJava = "GoodJava";

        sample.predicateTest(predicateLength5, godOfJava);
        sample.predicateTest(predicateLength5, goodJava);

        sample.predicateNegate(predicateLength5, godOfJava);
        sample.predicateNegate(predicateLength5, goodJava);

        sample.predicateAnd(predicateLength5, predicateContains, godOfJava);
        sample.predicateAnd(predicateLength5, predicateContains, goodJava);

        sample.predicateOr(predicateLength5, predicateStart, godOfJava);
        sample.predicateOr(predicateLength5, predicateStart, goodJava);
    }
    private void predicateTest(Predicate<String> p, String data){
        System.out.println(p.test(data));
    }
    private void predicateAnd(Predicate<String> p1, Predicate<String> p2, String data){
        System.out.println(p1.and(p2).test(data));
    }
    private void predicateOr(Predicate<String> p1, Predicate<String> p2, String data){
        System.out.println(p1.or(p2).test(data));
    }
    private void predicateNegate(Predicate<String> p, String data){
        System.out.println(p.negate().test(data));
    }
}

각 메소드들에 대해 살펴보면

  • predicateTest() : 데이터가 해당 조건에 맞는지를 확인한다.
  • predicateAnd() : 데이터가 두 개의 조건에 모두 맞는지 확인한다.
  • predicateOr() : 데이터가 두 개의 조건 중 하나라도 맞는지 확인한다.
  • predicateNegate() : 데이터가 조건과 다른지 확인한다.

3. Stream

 Java 8에 "stream(이하 스트림)"이 추가되었다. 자바의 스트림은 뭔가 연속된 정보를 처리하는 데 사용된다.

어떤 것을이 "연속된 정보"를 처리했을까?

가장 기본적인 것은 배열이고, 그 다음이 컬렉션이다. 컬렉션에는 스트림을 사용할 수 있지만, 배열에는 스트림을 사용할 수 없다. 그렇지만, 배열을 컬렉션의 List로 변환하는 방법에는 여러 가지가 존재한다.

다음과 같이 1, 3, 5라는 값이 정수 배열로 있다면, Arrays 클래스의 asList() 메소드로 변환 가능하다.

Integer[] values = {1, 3, 5};
List<Integer> list = new ArrayList<Integer>(Arrays.asList(values));

그러나 꼭 이렇게 할 필요는 없고, Arrays 클래스에 있는 stream()이라는 메소드를 사용하면 된다. 이 메소드의 매개 변수로 배열을 넘겨주면 stream 객체를 리턴해준다.

Integer[] values = {1, 3, 5};
List<Integer> list = Arrays.stream(values).collect(Collectors.toList());

그러면 본격적으로 스트림에 대해서 알아보자.
스트림은 다음과 같은 구조를 가진다.

list.stream().filter(x -> x > 10).count()
     스트림생성       중개 연산     종단 연산
  • 스트림 생성 : 컬렉션의 목록을 스트림 객체로 변환한다. 여기서 스트림 객체는 java.util.stream 패키지의 Stream 인터페이스를 말한다. 이 stream() 메소드는 당연히 Collection 인터페이스에 선언되어 있다.
  • 중개 연산 : 생성된 스트림 객체를 사용하여 중개 연산 부분에서 처리한다. 하지만, 이 부분에서는 아무런 결과를 리턴하지 못한다. 그래서 중개 연산(intermediate operation)이라고 한다.
  • 종단 연산 : 마지막으로 종단 연산에서 작업된 내용을 바탕으로 결과를 리턴해 준다. 그래서 이 부분을 종단 연산(terminal operation)이라고 한다.

 이 절차는 꼭 기억을 해 두는 것이 좋다. 그런데, 중개 연산은 반드시 있어야 하는 것은 아니다. 0개 이상의 중개 연산이 존재한다고 생각하면 이해가 쉬울 것이다.

그리고 여기서 사용한 stream()은 순차적으로 데이터를 처리한다. 다시 말해서, 10개의 데이터가 있다면, 0~9번째 인덱스를 하나씩 처음부터 처리한다. 만약 stream()을 보다 빠르게 처리하려면 parallelStream()을 사용하면 되는데, 이는 병렬로 처리하기 때문에 CPU도 많이 사용하고 몇 개의 쓰레드로 처리할지가 보장되지 않는다. 따라서, 일반적인 웹 프로그램에는 stream() 만을 사용할 것을 권장한다.

스트림에서 제공하는 연산의 종류는 다음과 같다.

  • filter(pred)
  • map(mapper)
  • forEach(block)
  • flatMap(flat-mapper)
  • sorted(comparator)
  • toArray(array-factory)
  • any / all / noneMatch(pred)
  • findFirst / Any(pred)
  • cumulate(binop)
  • reduce(binop) / reduce(base, binop)
  • collect(collector)

 이렇게 많은 종류의 연산자가 있지만, 일반적으로 많이 사용하는 것은 forEach(), filter(), map() 정도다. 먼저 각각의 연산자가 무슨 일을 하는지 간단히 살펴보고, 이 세개의 연산자에 대해서 알아보자.

연산자 설명
filter(pred) 데이터를 조건으로 거를 때 사용
map(mapper) 데이터를 특정 데이터로 변환
forEach(block) for 루프를 수행하는 것처럼 각각의 항목을 꺼냄
flatMap(flat-mapper) 스트림의 데이터를 잘개 쪼개서 새로운 스트림 제공
sorted(comparator) 데이터 정렬
toArray(array-factory) 배열로 변환
any / all / noneMatch(pred) 일치하는 것을 찾음
findFirst / Any(pred) 맨 처음이나 순서와 상관없는 것을 찾음
reduce(binop) / reduce(base, binop) 결과를 취합
collect(collector) 원하는 타입으로 결과를 리턴

 

Stream을 다시 한번 정리해보자

스트림은 Collections와 같이 목록을 처리할 때 유용하게 사용된다.

  • 스트림 생성 - 중간 연산 - 종단 연산으로 구분된다.
  • 스트림 생성시에는 stream() 메소드를 호출하면 Stream 타입을 리턴한다.
  • 중간 연산은 데이터를 가공할 때 사용되며 연산 결과로 Stream 타입을 리턴한다. 따라서 여러 개의 중간 연산을 연결할 수 있다.
  • 종단 연산은 스트림 처리를 마무리하기 위해서 사용되며, 숫자값을 리턴하거나 목록형 데이터를 리턴한다.

중간 연산의 종류에는 다음과 같은 것들이 있다.

  • filter()
  • map() / mapToInt() / mapToLong() / mapToDouble()
  • flatMap() / flatMapToInt() / flatMapToLong() / flatMapToDouble()
  • distinct()
  • sorted()
  • peek()
  • limit()
  • skip()

종단 연산의 종류에는 다음과 같은 것들이 있다.

  • forEach() / forEachOrdered()
  • toArray()
  • reduce()
  • collect()
  • min() / max() / count()
  • anyMatch() / allMatch() / noneMatch()
  • findFirst / findAny()

 

4. 디폴트 메서드 (Default Methods)

 Default method는 인터페이스에 있는 구현 메서드를 의미한다.

인터페이스에서 디폴트 메서드를 정의하여 인터페이스를 구현한 클래스에서 강제로 구현하지 않아도 된다.

기존에는 인터페이스 내에서 메소드를 추가할 경우, 해당 인터페이스를 상속받는 모든 클래스를 구현해야 했지만 디폴트 매소드를 정의하면서 다른 클래스들에는 영향을 주지 않고, 새로운 메소드를 인터페이스에 추가할 수 있게 되었다.

 

5. 새로운 날짜와 시간 API (New Date and Time API)

Java 8에서는 java.time 패키지로 새로운 날짜와 시간 API를 도입했다. 이는 Joda-Time 라이브러리에서 영감을 받아 더 직관적이고 사용하기 쉽게 설계되었다.

6. 메서드 레퍼런스 (Method References)

메서드 레퍼런스는 람다 표현식의 또 다른 형태로, 기존 메서드나 생성자를 참조할 수 있다.

7. 반복 애노테이션 (Repeatable Annotations)

하나의 대상에 동일한 애노테이션을 여러 번 적용할 수 있게 되었다.

8. 나시낙 패턴 (Nashorn JavaScript Engine)

Java 8은 Nashorn이라는 새로운 JavaScript 엔진을 도입하여 Java 어플리케이션 내에서 JavaScript를 실행할 수 있다.

 


[결론]

 이번에는 java 8 에 새로 등장하거나 변경되는것에 대해 알아보았다.  Stream API로 컬렉션 처리를 할때 간결한 방식으로 필터링, 매핑, 작업을 수행할 수 있다. 또 디폴트 매소드의 사용으로 모든 인터페이스 구현하지 않고 새로운 메소드를 인터페이스에 추가 할수 있었다. java 8 로 변경으로 개발자들의 유지보수가 더 쉽고 효율적인 코딩에 가까워 진거 같다. 다음 버전들도 어떤 차이가 있는지 공부해야 겠다.

 


[출처 및 참조]