728x90

클라이언트가 클래스의 인스턴스를 얻는 수단

  • public 생성자: 전통적인 수단
  • 정적 팩터리 메서드(static factory method)
    • 해당 클래스의 인스턴스를 반환하는 정적 메서드
    • 디자인 패턴에서의 팩터리 메서드와 다름
public static Boolean valueOf(boolean b) {
        return b ? Boolean.True : Boolean.False;
}

장점


이름을 가질 수 있다.

  • 생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하기 어렵다
  • BigTnteger(int, int, Random) vs Biginteger.probablePrime()
  • 하나의 시그니처로는 생성자를 하나만 만들 수 있다
    • 입력 매개변수들의 순서를 다르게 한 생성자를 새로 추가하는 방식으로 회피할 수 있지만 좋지 않은 방식
    • 정적 팩터리 메서드는 생성자를 정적 팩터리 메서드로 바꾸고 각각의 차이를 잘 드러내는 네이밍

호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다

  • 이로 인해 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 불필요한 객체 생성 피할 수 있다
  • 생성 비용이 큰 같은 객체가 자주 요청되는 상황이라면 성능 향상, 플라이웨이트 패턴과 비슷
  • 정적 팩터리 방식의 클래스 == 인스턴스 통제 클래스
    • 즉 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제 가능
  • 인스턴스 통제는 플라이웨이트 패턴의 근간이 되며, 열거 타입은 인스턴스가 하나만 만들어짐을 보장한다.

반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.

  • API를 만들 때 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 객체를 반환할 수 있어 api를 작게 유지 가능
  • 인터페이스를 정적 팩터리 메서드의 반환 타입으로 사용하는 인터페이스 기반 프레임워크를 만드는 핵심 기술
  • 자바8 이전에는 인터페이스에 정적 메서드를 선언할 수 없어서 정적 메서드가 필요하면 동반 클래스를 만들어서 정의하는 것이 관례였다.
    • ex) Collection <> Collections
  • 자바8 부터는 동반 클래스를 둘 이유가 별로 없고, 동반 클래스에 두었던 public 정적 멤버들 상당수를 인터페이스 자체에 두면 된다.
    • 하지만 자바9에서는 pirvate 정적 메서드까지 허용하지만 정적 필드와 정적 멤버 클래스는 여전히 public이어야 해서, 별도의 package-private 클래스에 두어야 할 수 도 있다.

입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.

  • 클라이언트는 팩터리가 건네주는 객체가 어느 클래스의 인스턴스인지 알 수도 없고 알 필요도 없다.
  • 반환 타입의 하위 타입이기만 하면 되기 때문이다.
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
     Enum<?>[] universe = getUniverse(elementType);
     if (universe == null)
         throw new ClassCastException(elementType + " not an enum");

     if (universe.length <= 64)
         return new RegularEnumSet<>(elementType, universe);
     else
         return new JumboEnumSet<>(elementType, universe);
 }

정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다

  • 이러한 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다
  • 대표적인 예시 JDBC

단점


  1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
    • 이 제약은 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들려면 이 제약을 지켜야 한다는 점에서 오히려 장점일 수 도 있다.
  2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다
    • 생성자처럼 API 설명에 명확히 드러나지 않으니 사용자는 정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야 한다.

정적 팩터리 메서드에 흔히 사용하는 명명 방식

from

  • 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
Date d = Date.from(instance);

of

  • 여러 매겨변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);

valueOf

  • from과 of의 더 자세한 버전

instance / getInstance

  • (매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다
StackWalker luke = StackWalker.getInstance(options);

create / newInstance

  • 위의 내용과 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다

get"Type"

  • getInstace와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.
FileStore fs = Files.getFilsStore(path);

new"Type"

  • newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.
BufferedReader br = Files.newBufferedReader(path);

type

  • getType과 newType의 간결한 버전
List<Complaint> litany = Collections.list(legacyLitany);

참고 출처

728x90
728x90

이번 주제 키워드

  • 디폴트 메서드란?
  • 진화되는 API가 호환성을 유지하는 방법
  • 디폴트 메서드 활용 패턴
  • 해결 규칙

인터페이스를 수정해야하는 경우

  • 바이너리 호환성은 유지되지만 소스 호환성은 유지되지 않아 해당 인터페이스를 구현한 모든 클래스를 수정해야한다.
    • 따라서 공개된 자바 API를 고치는 일은 굉장히 어려운 일이었다.
  • 자바 8에서는 이러한 문제를 해결하는 두 가지 방법을 제공한다.
    1. 인터페이스 내부에 정적 메서드를 사용하는 방법
    2. 인터페이스의 기본 구현을 제공할 수 있도록 디폴트 메서드 기능을 사용하는 방법
  • 즉, 자바8부터 메서드 구현을 인터페이스를 정의할 수 있어서 기존 인터페이스를 구현하는 클래스는 자동으로 인터페이스에 추가된 새로운 메서드의 디폴트 메서드를 상속받게 된다.
  • 따라서 기존의 코드 구현을 바꾸도록 강요하지 않으면서도 인터페이스를 바꿀 수 있다.

호환성(참고)

  • 바이너리 호환성
    • 뭔가를 바꾼 이후에도 에러 없이 기존 바이너리가 실행될 수 있는 상황.
    • ex) 인터페이스에 메서드를 추가했을 때 추가된 메서드를 호출하지만 않으면 문제가 일어나지 않는 경우
  • 소스 호환성
    • 코드를 고쳐도 기존 프로그램을 성공적으로 재컴파일할 수 있는 상황.
    • ex) 마찬가지로 인터페이스에 메서드를 추가하는 경우는 소스 호환성이 아니다.
  • 동작 호환성
    • 코드를 바꾼 다음에도 같은 입력값이 주어지면 프로그램이 같은 동작을 실행하는 상황.

디폴트 메서드

  • 자바 8에서는 호환성을 유지하면서 API를 바꿀 수 있도록 새로운 기능인 디폴트 메서드(default method)를 제공한다.
  • 이제 인터페이스는 자신을 구현하는 클래스에서 메서드를 구현하지 않을 수 있는 새로운 메서드 시그니처를 제공한다.
  • 인터페이스를 구현하는 클래스에서 구현하지 않은 메서드는 디폴트 메서드를 통해 인터페이스 자체에서 기본으로 제공한다.
  • 디폴트 메서드는 default라는 키워드로 시작하며 다른 클래스에 선언된 메서드처럼 메서드 바디를 포함한다.

이렇게 되면 이미 존재하는 추상 클래스와 자바 8의 인터페이스가 무엇이 다르냐고 물어볼 수 있다.

  1. 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스를 여러 개 구현할 수 있다.
  2. 추상 클래스는 인스턴스 변수(필드)로 공통 상태를 가질 수 있다. 하지만 인터페이스는 인스턴스 변수를 가질 수 없다.

디폴트 메서드 활용 패턴

  • 디폴트 메서드를 이용하는 두 가지 방식은 선택형 메서드(optional method)와 동작 다중 상속(multiple inheritance of behavoir)이다.

선택형 메서드

  • 이전의 인터페이스를 구현하는 클래스는 사용하지 않는 메서드에 대해 비어있는 메서드까지 필수적으로 구현해주어야 했다.
  • 하지만 디폴트 메서드를 이용하면 메서드의 기본 구현을 인터페이스로부터 제공받기 때문에 빈 구현을 제공할 필요가 없다.
  • 이를 통해 불필요한 코드의 양을 줄일 수 있다.
  • Iterator 인터페이스의 remove 메서드
default void remove() {
    throw new UnsupportedOperationException("remove");
}

동작 다중 상속

  • 인터페이스는 한 클래스에서 여러 개 구현할 수 있으므로 디폴트 메서드가 없더라도 다중 상속 을 활용 할 수 있다.
  • 거기에 추가로 구현을 포함하는 디폴트 메서드를 통해 동작다중 상속 을 활용할 수 있다.
  • ex) Rotatable, Moveable, Resizable 인터페이스의 조합을 통해 게임에 필요한 다양한 클래스들 구현 가능
    • 디폴트 메서드를 활용하는 구조가 마치 템플릿 메서드 패턴과 비슷해 보인다.
    • 디폴트 메서드 덕분에 인터페이스의 직접 수정도 가능하며 이를 구현한 클래스들을 오버라이딩하지 않은 이상 자동으로 상속받으니 문제 없다.
public interface Rotatable {
    void setRotationAngle(int angleInDegrees);
    int getRotationAngle();
    default void rotateBy(int angleInDegrees) { //디폴트 메서드
        setRotationAngle((getRotationAngle() + angleInDegrees) % 360);
    }
}

고민해볼 상황

  • 만약 같은 시그니처의 디폴트 메서드를 포함하는 여러 인터페이스를 구현하는 상황이라면?
  • 어떤 인터페이스의 디폴트 메서드를 사용하는 것일까!!!

해석 규칙

  • 드물지만 같은 시그니처를 갖는 디폴트 메서드를 상속받는 상황이 생길 수 있다.
  • 다중 상속을 허용하는 언어의 다이아몬드 상속 문제와 같다.
  • 어떤 메서드를 실행할까?
public interface A {
    default void hello() {
        System.out.println("Hello From A");
    }
}

public interface B extends A {
    default void hello() {
        System.out.println("Hello From B");
    }
}

public class C implements B, A {
    public static void main(String args[]){
        new C().hello();
    }
}

세가지 규칙

  1. 클래스가 항상 이긴다. 클래스나 슈퍼클래스에서 정의한 메서드가 디폴트 메서드보다 우선권을 갖는다.
  2. 1번 규칙 이외의 상황에서는 서브인터페이스가 이긴다. 상속관계를 갖는 인터페이스에서 같은 시그니처를 갖는 메서드를 정의할 때는 서브인터페이스가 이긴다.
    • 즉 B가 A를 상속받는다면 B가 이긴다.
  3. 여전히 디폴트 메서드의 우선순위가 결정되지 않았다면 여러 인터페이스를 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다.

충돌 그리고 명시적인 문제해결

  • 1,2 규칙으론 해결할 수 없는 경우
public interface A {
    default void hello() { ... }
}

public interface B {
    default void hello() { ... }
}

public class C implements B, A { }
  • A와 B 인터페이스 간의 상속관계도 없어 디폴트 메서드의 우선순위가 결정되지 않았다.
  • 따라서 자바 컴파일러는 어떤 메서드를 호출해야 할지 알수 없으므로 에러를 발생시킨다.
  • 충돌해결을 위해서는 아래처럼 개발자가 직접 클래스 C에서 사용하려는 메서드를 명시적으로 선택해야 한다.
public class C implements B, A {
    void hello() {
        B.super.hello();
    }
}

다이아몬드 문제

public interface A {
    default void hello() { ... }
}

public interface B extends A { }
public interface C extends A { }

public class D implements B, C { 
    public static void main(String... args) {
        new D().hello();
    }
}
  • 다이어그램의 모양이 다이아몬드를 닮아 다이아몬드 문제라 부른다.
  • D가 구현하는 B와 C 중 선택할 수 있는 메서드는 오직 A의 디폴트 메서드 뿐이다. D는 A의 hello를 호출한다.
  • 만약 B에 같은 디폴트 메서드 hello가 있었다면 가장 하위의 인터페이스인 B의 hello가 호출될 것이다.
  • B와 C가 모두 디폴트 메서드를 정의했다면 디폴트 메서드 우선순위로 인해 에러가 발생하고 명시적인 호출이 필요하게된다.

만약 C에서 디폴트 메서드가 아닌 추상메서드 hello를 추가하면 어떻게 될까?

public interface C extends A {
    void hello();
}
  • C는 A를 상속받으므로 C의 추상 메서드 hello가 A의 디폴트 메서드 hello보다 우선권을 갖는다.
  • 따라서 B와 C중 선택하지 못하며 컴파일에러가 발생하며 어떤 hello를 사용할지 명시적으로 선택해서 에러를 해결해야 한다.
728x90
728x90

자바 8 이전의 날짜와 시간 API의 문제들

  • Date 클래스는 직관적이지 못하며 자체적으로 시간대 정보를 알고있지 않다
  • Date클래스의 여러 메서드를 deprecated 시키고 등장한 Calendar 클래스 또한 쉽게 에러를 일으키는 설계 문제를 갖고 있다
  • Date와 Calendar 두 가지 클래스가 등장하면서 개발자들에게 혼란만 가중되었다.
  • 날짜와 시간을 파싱하는데 등장한 DateFormat은 Date에만 지원되었으며, 스레드에 안전하지 못했다.
  • Date와 Calendar는 모두 가변 클래스이므로 유지보수가 아주 어렵다.

LocalDate, LocalTime, Instant, Duration, Period 클래스

  • java.time 패키지는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등 새로운 날짜와 시간에 관련된 클래스를 제공한다.

LocalDate와 LocalTime

LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변 객체다.

  • LocalDate 객체는 어떤 시간대 정보도 포함하지 않는다.
  • 정적 팩토리 메서드 of으로 LocalDate 인스턴스를 만들 수 있다.
LocalDate date = LocalDate.of(2020, 12, 22); // of
int year = date.getYear();
Month month = date.getMonth();
int day = date.getDayOfMonth();
LocalDate now = LocalDate.now(); // 시스템 시계의 정보를 이용해서 현재 날짜 정보

LocalDate가 제공하는 get 메서드에 TemporalField를 전달해서 정보를 얻는 방법도 있다.

  • TemporalField는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다.
  • ChronoField는 TemporalField의 구현체이며 ChronoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.
// public int get(TemporalField field)
int year = date.get(ChronoField.YEAR);

시간에 대한 정보는 LocalTime 클래스로 표현할 수 있다.

  • LocalTime도 정적 메서드 of로 인스턴스를 만들 수 있다.
LocalTime time = LocalTime.of(13, 45, 20); // 13:45:20
int hour = time.getHour();
int minute = time.getMinute();
int second = time.getSecond();
  • parse 메서드를 통해 날짜와 시간 문자열로 LocalDate와 LocalTime의 인스턴스를 만들 수 있다.
LocalDate date = LocalDate.parse("2020-12-22");
LocalTime time = LocalTime.parse("13:45:20");`

날짜와 시간 조합

LocalDateTime은 LocalDate와 LocalTime을 쌍으로 갖는 복합 클래스다.

  • 즉, 날짜와 시간을 모두 표현할 수 있으며 정적 메서드 of로 인스턴스 또한 만들 수 있다.
  • atTime 메서드에 시간을 제공하거나 atDate메서드에 날짜를 제공해서 LocalDateTime을 만드는 방법도 있다.
  • toLocalDate, toLocalTime 메서드로 LocalDate, LocalTime 인스턴스 추출 가능
LocalDateTime dateTime = LocalDateTime.of(2020, Month.DECEMBER, 22, 13, 45, 20);
LocalDateTime dateTime2 = LocalDateTime.of(date, time);

LocalDateTime dateTime2 = date.atTime(13, 45, 20);
LocalDateTime dateTime2 = time.atDate(date);

LocalDate date = dateTime.toLocalDate();
LocalTime time = dateTime2.toLocalTime();

Instant 클래스 : 기계의 날짜와 시간

  • java.time.Instant 클래스에서는 기계적인 관점에서 시간을 표현한다.
  • Instant 클래스는 유닉스 에포크 시간(Unix epoch time) (1970년 1월 1일 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다.
  • 팩토리 메서드 ofEpochSecond에 초를 넘겨주어 인스턴스를 생성할 수 있다.
  • Instant 클래스는 나노초의 정밀도를 제공하며 오버로드된 ofEpochSecond 메서드 버전에서는 두 번째 인수를 이용해서 나노초 단위로 시간을 보정할 수 있다.
  • Instant 클래스도 사람이 확인할 수 있도록 시간을 표현해주는 정적 팩토리 메서드 now를 제공한다. 하지만 사람이 읽을 수 있는 시간정보는 제공하지 않는다.

Duration과 Period 정의

  • 지금까지 살펴본 모든 클래스는 Temporal 인터페이스를 구현하는데, Temporal 인터페이스는 특정 시간을 모델링하는 객체의 값을 어떻게 읽고 조작할지 정의한다.
  • Duration 클래스를 사용하면 두 시간 객체 사이의 지속시간을 만들 수 있다.
    • Duration.between(Temporal startInclusive, Temporal endExclusive : 정적 팩토리 메서드를 사용하면 두 시간 객체 사이의 지속시간을 만들 수 있다.
    • Duration 클래스는 초와 나노초로 시간 단위를 표현함으로 between 메서드에 LocalDate를 전달할 수 없다.
  • 년, 월, 일로 시간을 표현할 때는 Period 클래스를 사용하자.
    • Period 클래스의 팩토리 메서드 between(LocalDate startDateInclusive, LocalDate endDateExclusive 을 이용하면 두 LocalDate의 차이를 확인할 수 있다.

지금까지 살펴본 모든 클래스는 불변이다. 함수형 프로그래밍, 스레드 안정성과 도메인 모델의 일관성을 유지하는데 좋다.


날짜 조정, 파싱, 포매팅

  • 하지만 날짜나 시간 인스턴스에 시간을 더해야 하는 상황이나 시간 포맷터를 만드는 방법이 필요할 수 있다.
  • withAttribute 메서드를 사용하면 일부 속성이 수정된 상태의 새로운 객체를 반환받을 수 있다.
    • 기존 객체를 바꾸지 않는다.
LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.withYear(2011); // 2011-09-21
LocalDate date2 = date2.withDayOfMonth(25); // 2011-09-25
LocalDate date2 = date3.with(ChronoField.MONTH_OF_YEAR, 2); // 2011-02-25
  • 상재적인 방식으로 바꾸기도 가능하다.
LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.plusWeeks(1); // 2017-09-28
LocalDate date2 = date2.minusYear(6); // 2011-09-28
LocalDate date2 = date3.plus(6, ChronoUnit.MONTHS); // 2012-03-28

TemporalAdjusters 사용하기

  • 간단한 날짜 기능이 아닌 더 복잡한 날짜 조정기능이 필요할 때 with 메서드에 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다.
  • 날짜와 시간 API는 다양한 상황에서 사용할 수 있도록 다양한 TemporalAdjuste 팩토리 메서드를 제공한다.
  • 필요한 기능이 존재하지 않으면 커스텀 TemporalAdjuster 를 구현하여 사용할 수 있다.
LocalDate date1 = LocalDate.of(2021, 9, 6); // (월)
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2021-09-12

날짜와 시간 객체 출력과 파싱

  • 날짜와 시간 관련 작업에서 포매팅과 파싱은 필수적이다. java.time.format 패키지가 이를 지원한다.
  • 가장 중요하게 알아야 할 클래스는 DateTimeFormatter이다.
  • 정적 팩토리 메서드와 상수를 이용해서 손쉽게 포매터를 만들 수 있다.
LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18
LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date1 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);
  • 기존 java.util.DateFormat 클래스와 달리 모든 DateTileFormatter는 스레드에서 안전하게 사용할 수 있는 클래스이다.
  • 특정 패턴으로 포매터를 만들 수 있는 정적 팩토리 메서드도 제공한다.

DateTimeFormatter formatter = DateTimeFormatter.ofPatterm("dd/MM/yyyy");
LocalDate date = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);
  • LocalDate의 format 메서드는 요청 형식의 패턴에 해당하는 문자열을 생성한다.
  • 그리고 정적 메서드 parse는 같은 포매터를 적용해서 생성된 문자열을 파싱함으로써 다시 날짜를 생성한다.
  • 또한, DateTimeFormatterBuilder 클래스를 이용하면 원하는 포매터를 직접 만들 수 있다.

다양한 시간대와 캘린더 활용 방법

  • 새로운 날짜와 시간 API의 큰 편리함 중 하나는 시간대(timezone)를 간단하게 처리할 수 있다는 점이다.
  • 기존의 java.util.TimeZone을 대체할 수 있는 java.time.ZoneId 클래스가 새롭게 등장했다.
  • ZoneId를 이용하면 서머타임 같은 복잡한 사항이 자동으로 처리된다.
  • 또한 ZoneId는 불변 클래스다.

시간대 사용하기

  • 표준이 같은 지역을 묶어서 시간대(time zone) 규칙 집합을 정의한다.
  • ZoneRules 클래스에는 약 40개 정도의 시간대가 있다.
  • ZoneId의 getRules()를 이용해서 해당 시간대의 규정을 획득할 수 있다.
ZoneId romeZone = ZoneId.of("Europe/Rome");
  • 지역 ID는 '{지역}/{도시}' 형식으로 이루어 진다.
  • 지역집합 정보는 IANA Time Zone Database에서 제공하는 정보를 사용한다.
  • getDefault() 메서드를 이용하면 기존의 TimeZone 객체를 ZoneId 객체로 변환할 수 있다.
ZoneId zoneId = TimeZone.getDefault().toZoneId();
  • ZoneId는 LocalDate, LocalTime, LocalDateTime과 같이 ZonedDateTime 인스턴스로 변환할 수 있다.
  • ZonedDateTime은 지정한 시간대에 상대적인 시점을 표현한다.
    • 즉, ZonedDateTime = LocalDateTime + 타임존/시차
LocalDate date = LocalDate.of(2014, 13, 18);
ZonedDateTime zdt = date.atStartOfDay(romeZone);`


(추가)ZoneId vs ZoneOffset

  • ZoneId은 타임존, ZoneOffset은 시차를 나타냅니다.
  • ZoneOffset는 UTC 기준으로 고정된 시간 차이를 양수나 음수로 나타내는 반면에 ZoneId는 이 시간 차이를 타임존 코드로 나타냅니다
ZoneOffset seoulZoneOffset = ZoneOffset.of("+09:00");
System.out.println("+0900 Time = " + ZonedDateTime.now(seoulZoneOffset));
ZoneId seoulZoneId = ZoneId.of("Asia/Seoul");
System.out.println("Seoul Time = " + ZonedDateTime.now(seoulZoneId));

참고 출처

728x90
728x90

이번 주제 키워드

  • null 참조의 문제점과 null을 멀리해야 하는 이유
  • null 대신 Optional
  • Optional 활용

값이 없는 상황을 어떻게 처리할까?

  • Null 참조를 반환하는 방식으로 소유하고 있지 않음을 표현하는 경우가 있다.
  • 이러한 경우는 런타임 NPE가 발생하면서 프로그램 실행이 중단된다.
  • 이러한 NPE를 피하려면 null 확인 코드를 추가해서 예외 문제를 해결하려고 할 것이고 이것은 다양한 문제를 일으킨다!!

null 때문에 발생하는 문제

  • 에러의 근원이다

  • 코드를 어지럽힌다.

  • 아무 의미가 없다.

    • null은 아무 의미도 표현하지 않으므로 값이 없음을 표현하는 방법으로는 적절하지 않다.
  • 자바 철학에 위배된다.

    • 자바는 모든 포인터를 숨겼지만 null 포인터는 예외
  • 형식 시스템에 구멍을 만든다.

    • 모든 참조 형식에 null이 할당이 가능하므로 다른 부분으로 펴졌을 때 이 null의 의미조차 알 수 없다.


Optional 클래스

  • 자바8은 하스켈가 스칼라의 영향을 받아서 java.util.Optional라는 새로운 클래스 제공한다.
  • Optional은 선택형 값을 캡슐화하는 클래스이다.
  • 이는 값이 없을 수 있음을 명시적으로 보여주는 것이다.
  • Optional 클래스를 사용하면서 모델의 의미가 더 명확해졌다
  • Optional을 최대 1개의 원소를 가지고 있는 특별한 Stream이라고 생각하시면 좋다.
    • Optional 클래스와 Stream 클래스 간에 직접적인 구현이나 상속관계는 없지만 사용 방법이나 기본 사상이 매우 유사하기 때문

출처


Optional 객체 만들기


빈 Optional

  • 정적 팩토리 메서드 Optional.empty로 빈 Optional 객체를 얻을 수 있다.
Optional<Car> optCar = Optional.empty();

private static final Optional<?> EMPTY = new Optional<>();

null이 아닌 값으로 Optional 만들기

  • 정적 팩토리 메서드 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다.
  • 인수로 넣는 값이 null이라면 즉시 NPE가 발생한다.
Optional<Car> optCar = Optional.of(car);

null 값으로 Optional 만들기

  • 마찬가지로 정적 팩토리 메서드 Optional.ofNullable로 null 값을 저장할 수 있는 Optional을 만들 수 있다.
  • 인수로 넣는 값이 null이면 빈 Optional 객체가 반환된다.
Optional<Car> optCar = Optional.ofNullable(car);

map으로 Optional의 값을 추출하고 변환하기

  • 보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다.
String name = null;
if(insurance != null){
    name = insurance.getName();
}
  • 예를 들어 보험회사의 이름을 추출한다고 가정하자. 다음 코드처럼 이름 정보에 접근하기 전에 insurance가 null인지 확인해야 한다.
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
  • 이런 유형의 패턴에 사용할 수 있도록 Optional은 map 메서드를 지원한다.
  • Optional의 map은 스트림의 map 메서드와 개념적으로 비슷하다.
  • Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다.
  • 비어 있으면 아무 일도 일어나지 않는다.

flatMap으로 Optional 객체 연결

public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}
  • 이런 코드는 어떻게 활용할 수 있을까??

Optional<Person> optPerson = Optional.of(Person);
Optional<String> name = optPerson.map(Person::getCar).map(Car::getInsurance).map(Insurance::getName);
  • 위의 코드는 컴파일 되지 않는다.
  • optPerson.map(Person::getCar)
    • getCar은 Optional를 반환
    • 따라서 위의 코드의 반환값은Optional<Optional> 타입이라 getInsurance 메서드를 지원하지 않기 때문이다.
    • 스트림처럼 flatMap 활용, 이차원 Optional을 일차원 Optional로 평준화
public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar).flatMap(Car::getInsurance).map(Insurance::getName).orElse("Unknown");
}

도메인 모델에 Optional을 사용했을 때 데이터를 직렬화할 수 없는 이유

  • Optional 클래스는 필드 형식으로 사용할 것을 가정하지 않았으므로 Serializable 인터페이스를 구현하지 않았다.
  • 따라서 도메인 모델에 Optional을 사용한다면 직렬화 모델을 사용하는 도구나 프레임워크에서 문제가 생길 수 있다.
  • 만약 직렬화 모델이 필요하다면 변수는 일반 객체로 두되, Optional로 값을 반환받을 수 있는 메서드를 추가하는 방식이 권장된다.
public class Person {
    private Car car;
    public Optional<Car> getCarAsOptional() {
        return Optional.ofNullable(car);
    }
}

Optional 스트림 조작

  • 자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream()메서드를 추가했다.
  • Stream에서 가장 유용한 함수 체인의 형태는 아래와 같다.
Stream<Optional<String>> stream = ...;
Set<String> result = stream.filter(Optional::isPresent)
    .map(Optional::get)
    .collect(toSet());

디폴트 액션과 Optional 언랩

get

  • 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않은 메서드이다.
  • 값이 없으면 NoSuchElementException을 발생시키므로 값이 반드시 있다고 가정할 수 있는 상황이 아니면 get 메서드를 사용하지 말자

orElse

  • Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.

orElseGet(Supplier<? extends T> other)

  • orElse 메서드에 대응하는 게으른 버전의 메서드이다. Optional에 값이 없을 때만 Supplier가 실행된다.
  • 기본값이 필요한 상황에서 사용

orElseThrow(Supplier<? extends X> exceptionSupplier)

  • Optional이 비어있을 때 예외를 발생시킬 수 있으며, 발생시킬 예외의 종류를 정할 수 있다.

ifPresent(Consumer<? super T> consumer)

  • 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.
  • 값이 없으면 아무일도 일어나지 않는다.

ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

  • 자바 9
  • Optional이 비었을 때 실행할 Runnable을 인수로 받는다

두 Optional 합치기

  • Optional에서 mapflatMap은 Optional이 비어있다면 empty Optional을 반환한다.
  • 두 Optional에 대한 연산을 map과 flatMap을 적절히 활용하여 수행할 수 있다.

필터로 특정값 거르기

  • filter 메서드는 프레디케이트를 인수로 받는다.
  • Optional 객체가 값을 가지며 프레디케이트와 일치하면 filter 메서드는 그 값을 반환
  • 그렇지 않으면 빈 Optional 객체 반환

Optional을 사용한 실용 예제


잠재적으로 null이 될 수 있는 대상을 Optional로 감싸기

Object value = map.get("key");
Optional<Object> value = Optional.ofNullable(map.get("key"));
  • 참조하는 객체에 대하여 null이 될 수 있는 경우가 있다면 Optional 객체로 대체한다.

예외와 Optional 클래스

  • 자바 API에서 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킬 때가 있다.
  • 해당 메서드가 Optional을 반환하도록 모델링할 수 있다.
public static Optional<Integer> stringToInt(String s) {
  try {
    return Optional.of(Integer.parseInt(s));
  } catch(NumberFormatException e) {
    return Optional.empty();
  } 
}

기본형 Optional을 사용하지 말아야 하는 이유

  • Optional과 함께 기본형 특화 클래스인 OptionalInt, OptionalLong, OptionalDouble이 존재한다.
  • 하지만 Optional의 최대 요소 수는 한 개이므로 성능개선이 되지 않는다.
  • 또한 map, flatMap, filter 등을 지원하지 않으르로 다른 일반 Optional과 혼용할 수 없으므로 기본현 Optional을 사용하지 않는것을 권장한다.

추가로 생각해볼 부분

728x90
728x90

이번 주제 키워드

  • 람다 표현식으로 코드 리팩터링 하기
  • 람다 표현식이 객체지향 설계 패턴에 미치는 영향
  • 람다 표현식 테스팅
  • 람다 표현식과 스트림 API 사용 코드 디버깅

가독성과 유연성을 개선하는 리팩터링

  • 람다 표현식은 익명 클래스보다 코드를 좀 더 간결하게 만든다.
  • 그뿐만 아니라 동작 파라미터화의 형식을 지원하므로 람다 표현식을 이용한 코드는 더 큰 유연성을 갖출 수 있다.
  • 람도 표현식을 이용하여 코드를 리택터링 해보자!!

코드 가독성 개선

일반적으로 코드 가독성이 좋다는 것은 '어떤 코드를 다른 사람이 보았을 때 쉽게 이해할 수 있음'을 의미한다.

여러가지 방법들이 있지만 세 가지 리팩터링 예제를 알아보자

  1. 익명 클래스를 람다 표현식으로 리팩터링하기

    • 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.
    • 뒤에서 확인
  2. 람다 표현식을 메서드 참조로 리팩터링하기

    • 메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있다.
  3. 명령형 데이터 처리를 스트림으로 리팩터링하기

    • 스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다.


익명 클래스를 람다 표현식으로 리팩터링하기

Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
    }
};

Runnable r2 = () -> System.out.println("hello");
  • 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.
  • 익명 클래스에서 this는 익명 클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
  • 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다.
    • shadow variable
int a = 10;
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        int a = 2;
        System.out.println(a);
    }
};

Runnable r2 = () -> {
    int a = 2; // error
    System.out.println(a);
};
  • 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라진다.

interface Task {
    void execute();
}
public static void doSomething(Runnable r) { r.run(); }
public static void doSomething(Task t) { t.execute(); }
  • 익명 클래스와 달리 람다 표현식으로 인수를 전달하면 Runnable과 Task 모두 대상 형식이 될 수 있으므로 문제가 생긴다.
  • 명시적 형변환을 이용해서 제거할 수 있다.

코드 유연성 개선

  • 람다 표현식을 이용하면 동작 파라미터화(behavior parameterzation)을 쉽게 구현할 수있다.
  • 따라서 변화하는 요구사항에 대응할 수 있는 코드 구현 가능

함수형 인터페이스 적용

  • 람다 표현식을 사용하기 위해 함수형 인터페이스 적용해야 한다.

    • 조건부 연기 실행실행 어라운드 패턴을 살펴보자


조건부 연기 실행

  • 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 볼 수 있다.
  • 만약 클라이언트 코드에서 객체 상태를 자주 확인하거나,객체의 일부 메서드를 호출하는 상황이라면 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출(람다나 메서드 참조를 인수로 사용)하도록 새로운 메서드를 구현하는 것이 좋음
  • 코드 가독성이 좋아질 뿐아니라 캡슐화도 강화됨 (객체 상태가 클라이언트 코드로 노출되지 않음)

실행 어라운드

  • 매번 같은 준비, 종료 과정을 반복적으로 수행한다면 이를 람다로 변환할 수 있다.
  • 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 줄일 수 있다.

람다로 객체지향 디자인 패턴 리팩터링하기

디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있다.

전략 (Strategy)

  • 전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.
  • 전략을 구현하는 새로운 클래스를 람다 표현식을 통해 직접 전달할 수 있다.

기존 패턴

@FunctionalInterface
public interface ValidationStrategy {
    boolean execute(String s);
}

public class IsAllLowerCase implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

public class isNumeric implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("\d+");
    }
}

public class Validator {
    private final ValidationStrategy validationStrategy;

    public Validator(ValidationStrategy validationStrategy) {
        this.validationStrategy = validationStrategy;
    }

    public boolean validate(String s) {
        return validationStrategy.execute(s);
    }
}

Validator numericValidator = new Validator(new isNumeric());
numericValidator.validate("aaa");

Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
lowerCaseValidator.validate("bbbb");

람다 사용

Validator lowerCaseValidator2 = new Validator((String s) -> s.matches("[a-z]+"));
lowerCaseValidator2.validate("bbbb");

Validator numericValidator2 = new Validator((String s) -> s.matches("\d+"));
numericValidator2.validate("1234");

템플릿 메서드 (template method)

  • 알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다.
  • 추상 메서드로 원하는 동작을 구현하는 곳을 람다 표현식을 통해 전달할 수 있다.

기존

abstract class OnlineBanking {
  public void processCustomer(int id) {
    Customer c = Database.getCustomerWithId(id);
    makeCustomerHappy(c);
  }

  abstract void makeCustomerHappay(Customer c);
}

람다 사용

public void processCustomer(int id, Cusumer<Customer> makeCustomerHappy) {
  Customer c = Database.getCustomerWithId(id);
  makeCustomerHappy.accept(c);
}

new OnlineBankingLambda().processCustomer(1337, (Customer c) -> print("hello" + c.getName()));
  • 이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 Consumer 형식을 갖는 두 번째 인수를 메서드에 추가


옵저버 (observer)

  • 어떤 이벤트가 발생했을 때 한 객체(subject)가 다른 객체 리스트(observer)에 자동으로 알림을 보내야 하는 상황에서 사용하는 패턴이다.
  • 자세히 보고 싶다면? 여기


기존

interface Observer {
  void notify(String tweet);
}

public class NyTimes implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("monety")) {
            System.out.println("Breaking news in NY ! " + tweet);
        }
    }
}

public class Guardian implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("queen")) {
            System.out.println("Yet more new from London .. " + tweet);
        }
    }
}

public class LeMonde implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("wine")) {
            System.out.println("Today cheese, wine and news! " + tweet);
        }
    }
}

public interface NotiSubject {
    void registerObserver(NotiObserver o);
    void notifyObservers(String tweet);
}

public class Feed implements NotiSubject {
    private final List<NotiObserver> observers = new ArrayList<>();
    @Override
    public void registerObserver(NotiObserver o) {
        observers.add(o);
    }

    @Override
    public void notifyObservers(String tweet) {
        observers.forEach(o -> o.notify(tweet));
    }
}

Feed f = new Feed();
f.registerObserver(new NyTimes());
f.registerObserver(new LeMonde());
f.registerObserver(new Guardian());
f.notifyObservers("The Queen ...")
  • Observer 인터페이스는 새로운 트윗이 있을 때 subject가 호출할 수 있도록 notify라고 하는 하나의 메서드를 제공한다.
  • Observer 인테페이스를 구현하는 클래스를 만드는 대신 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있다.

람다

Feed feed = new Feed();
feed.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY ! " + tweet);
    }
});

feed.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("queen")) {
        System.out.println("Yet more new from London .." + tweet);
    }
});
  • 하지만 옵저버상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수 있다.

의무 체인 (chain-of-responsibility)

  • 작업 처리 객체의 체인(동작 체인 등)을 만들 때는 의무 체인 패턴을 사용한다.
  • 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 식이다.
  • 일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성한다.
    • 작업 처리 객체가 자신의 작업을 끝냈으면 다음 작업 처리 객체로 결과를 전달한다.

*작업 처리 객체 예제 코드
*

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;
    public void setSuccessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }

    public T handle(T input) {
        T r = handleWork(input);
        if (successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    abstract protected T handleWork(T input);
}
  • UML를 자세히 살펴보면 템플릿 메서드 패턴이 사용되었음을 알 수 있다.
  • handle 메서드는 일부 작업을 어떻게 처리할지 전체적으로 서술한다.
  • ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들수 있다.

기존

public class HandleTextProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return "From Raoul, Mario and Alan : " + input;
    }
}

public class SpellCheckProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return input.replaceAll("labda", "lambda");
    }
}

ProcessingObject<String> p1 = new HandleTextProcessing();
ProcessingObject<String> p2 = new SpellCheckProcessing();
p1.setSuccessor(p2);
p1.handle("Aren't ladbas really sexy?");

람다

UnaryOperator<String> headerProcessing = (String text) -> "From Raoul, Mario and Alan : " + text;
UnaryOperator<String> spellCheckProcessing = (String text) -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = headerProcessing.andThen(spellCheckProcessing);
pipeline.apply("Aren't ladbas really sexy?");
  • 이러한 패턴은 함수 체인과 비슷하다.
  • 람다 표현식을 조합하는 방식으로는 기본적으로 compose, andThen이 있다.
  • andThen메서드로 이들 함수를 조합해 체인을 만들어 보자.

팩토리 (factory)

  • 인스턴스화 로직을 클라이언트에게 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다.

기존

public class ProductFactory {
    public static Product createProduct(String name) {
        switch (name) {
            case "loan" : return new Loan();
            case "stock" : return new Stock();
            case "bond" : return new Bond();
            default: throw new RuntimeException("...");
        }
    }
}

Product p = ProductFactory.createProduct("loan");
  • createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 도 있지만
  • 주요 목적은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 create 할 수 있다는 점

람다

final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
  map.put("loan", Loan::new);
  map.put("stock", Stock::new);
  map.put("bond", Bond::new);
}

public static Product createProduct(String name) {
  Supplier<Product> p = map.get(name);
  if (p != null) {
    return p.get();
  }
  throw new IllegalArgumentException("No such product: " + name);
  ...
}
  • 하지만 팩토리 메서드 createProduct가 상품 생성자로 여러 인수로 전달하는 상황에서는 인수 개수에 맞게 특별한 함수형 인터페이스를 만들어야 한다.
  • 그러면 Map 시그니처가 복잡해진다.

람다 테스팅

  • 람다에 대해서도 단위 테스팅(unit testing)이 작성되어야만 한다.
  • 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다.

보이는 람다 표현식의 동작 테스팅

  • 람다의 동작을 테스트 하기 위해 람다를 필드에 저장해서 테스트할 수 있다.

람다를 사용하는 메서드의 동작에 집중

  • 람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각으로 캡슐화 하는것이다.
  • 람다 표현식을 사용하는 메서드의 동작을 테스트 함으로서 람다 표현식을 검증 할 수 있다.

복잡한 람다를 개별 메서드로 분할

  • 복잡한 로직이 포함된 람다를 구현하게 된다면 로직을 분리 하거나 메서드 레퍼런스를 활용하도록 하자.
  • 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

고차원 함수 테스팅

고차원 함수란 함수를 인수로 받거나 다른 함수를 반환하는 메서드 이다.

  • 메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트 할 수 있다.
  • 테스트해야하는 함수가 다른 함수를 반환한다면 함수형 인터페이스의 인스턴스로 간주하고 테스트 할 수 있다.

디버깅

람다 표현식과 스트림은 기존의 디버깅 기법을 무력화한다. 디버깅 방법을 살펴보자!!

스택 트레이스 확인

  • 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다.
  • 그렇기에 람다 표현식과 관련된 스택 트레이스는 이해하기 어려울 수 있다.
  • 이는 미래의 자바 컴파일러가 개선해야 할 부분이다.

정보 로깅

  • forEach를 통해 스트림 결과를 출력하거나 로깅할 수 있다. 하지만 forEach는 스트림을 소비하는 연산이다.
  • 스트림 파이프라인에 적용된 각각의 연산의 결과를 확인할 수 있다면 대신 peek라는 스트림 연산을 활용할 수 있다.
  • peek는 스트림의 각 요소를 소비한것 처럼 동작을 실행하지만, 실제로 스트림을 소비하지않고 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

참고출처

728x90
728x90

이번 주제 키워드

  • 컬렉션 팩토리 메서드 사용하기
  • 리스트 및 집합과 사용할 새로운 관용 패턴
  • 맵과 사용할 새로운 관용 패턴

컬렉션 팩토리

자바 9 에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공한다.

자바에서는 적은 요소를 포함하는 리스트를 어떻게 만들까?

List<String> friends = Arrays.asList("Raphael", "Olivia", "Thibaut");
  • 고정 크기의 리스트를 만들었으므로 요소를 갱신할 순 있지만 새 요소를 추가하거나 삭제할 수는 없다.
friends.set(0, "Richard"); // 문제 없음
friends.add("Tom");        //UnsupportedOperationException 발생
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
    implements RandomAccess, java.io.Serializable
{
    private final E[] a;

    ArrayList(E[] array) {
        a = Objects.requireNonNull(array);
    }
 ...
}
  • Arrays.asList()는 Arrays의 private 정적 클래스인 ArrayList를 리턴한다.
    • java.util.ArrayList 클래스와는 다른 클래스이다.
  • Arrays.asList는 내부적으로 고정된 크기의 배열로 구현되었기 때문에 이와 같은 일이 발생

그렇다면 set은??

Set<String> elems1 = new HashSet<>(Arrays.asList("e1","e2","e3"));
Set<String> elems2 = Stream.of("e1","e2","e3").collect(toSet());
  • 집합의 경우 리스트를 인수로 받는 HashSet 생성자를 사용하거나 스트림 API를 사용하는 방법이 존재했다.
  • 두 방법 모두 매끄럽지 못하며 내부적으로 불필요한 객체 할당을 필요로 한다.
  • 그리고 결과는 변환할 수 있는 집합이다.

자바 9에서 제공되는 팩토리 메서드

List.of

  • 변경할 수 없는 불변 리스트를 만든다.

Set.of

  • 변경할 수 없는 불변 집합을 만든다.
  • 중복된 요소를 제공해 집합 생성 시 IllegalArgumentException이 발생한다.

Map.of

  • 키와 값을 번갈아 제공하는 방법으로 맵을 만들 수 있다.

Map.ofEntries

  • Map.Entry<K, V> 객체를 인수로 받아 맵을 만들 수 있다.
  • 엔트리 생성은 Map.entry 팩터리 메서드를 이용해서 전달하자.

리스트 팩토리

List.of 팩토리 메소드를 이용해서 간단하게 리스트를 만들 수 있다.

List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
  • Arrays.asList 방법과 다르게 List.of 는 추가, 삭제뿐만 아니라 변경(set)도 할 수 없고 null 추가가 불가능한
    리스트로 만들어진다.

스트림 API vs 리스트 팩토리

데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 사용하기 간편한 팩토리 메서드를 사용하면 된다 !

  • 구현이 더 단순하고 목적을 달성하는데 충분하기 때문

집합 팩토리

// OK
Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");

// 요소가 중복되어 있다는 IllegalArgumentException 발생
Set<String> friends = Set.of("Raphael", "Olivia", "Olivia");
  • List.of 와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있다.

맵 팩토리

자바 9 에서는 두 가지 방법으로 바꿀 수 없는 맵을 만들 수 있다.

  1. Map.of 팩토리 메서드에 키와 값을 번갈아 제공하는 방법
Map<String, Integer> ageOfFriends = 
                Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);
  • 열개 이하의 키와 값 쌍을 가진 작은 맵을 만들 경우 (오버로딩으로 10개까지 지원해둔 것)
  1. Map.Entry<K,V> 객체를 인자로 받으며 가변 인수로 구현된 Map.ofEntries 이용
import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(
        entry("Raphael", 30), 
        entry("Olivia", 25),
        entry("Thibaut", 26));
  • 10개 이상의 경우 사용하면 좋다.
  • Map.entry는 Map.Entry 객체를 만드는 팩토리 메서드

리스트와 집합 처리

자바 8 에서는 List, Set 인터페이스에 다음와 같은 메서드를 추가했다.

  • removeIf
    • 프레디케이트를 만족하는 요소를 제거한다.
  • replaceAll
    • UnaryOperator 함수를 이용해 요소를 바꾼다.
    • UnaryOperator: Function(T, T), T → T
  • sort
    • List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

그런데 이들 메서드는 호출한 컬렉션 자체를 바꾼다.

  • 새로운 결과를 만드는 스트림 동작과 달리 이들 메서드는 기존 컬렉션을 바꾼다.
  • 왜 이런 메서드가 추가 되었을까?

컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더하기 때문이다!!

  • 삭제 시에는 IteratorCollection 의 상태를 동기화 시켜주어야 하기 때문이다.


removeIf 메서드

// ConcurrentModificationException 발생
for (Transaction transaction : transactions){
    if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
        transactions.remove(transaction);
    }
}

// for-each 내부적으로 Iterator 객체를 사용하므로 아래와 동일
for(Iterator<Transaction> iterator = transactions.iterator();
            iterator.hasNext(); ){
    Transaction transaction = iterator.next();
    if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
            // 반복하면서 별도의 두 객체를 통해 컬렉션을 바꾸고 있음
            transactions.remove(transaction);
    }
}
  • 다음은 숫자로 시작되는 참조 코드를 가진 트랜잭션을 삭제하는 코드
  • Iterator 객체 : next(), hastNext()를 이용해 소스를 질의한다.
  • Collection 객체 자체 : remove()를 호출해 요소를 삭제한다.
  • 반복자의 상태는 컬렉션의 상태와 서로 동기화 되지 않기 때문에 에러 발생
    • 즉 반복자에서도 요소를 조작하고 컬렉션에서도 요소를 조작하기 때문에 ConcurrentModificationException 발생
    • transactions.remove(transaction) 대신iterator.remove() 사용
    • 하지만 코드가 복잡해졌다.
  • 이유를 코드로 자세히 살펴보자

java.util.ArrayList의 remove()

    protected transient int modCount = 0;

    public boolean remove(Object o) {
        final Object[] es = elementData;
        final int size = this.size;
        int i = 0;
        found: {
            if (o == null) {
                for (; i < size; i++)
                    if (es[i] == null)
                        break found;
            } else {
                for (; i < size; i++)
                    if (o.equals(es[i]))
                        break found;
            }
            return false;
        }
        fastRemove(es, i);
        return true;
    }

    private void fastRemove(Object[] es, int i) {
        modCount++;
        final int newSize;
        if ((newSize = size - 1) > i)
            System.arraycopy(es, i + 1, es, i, newSize - i);
        es[size = newSize] = null;
    }
  • 살펴보면 remove시에 modCount를 증가를 시키고, System.arraycopy를 통해 remove할 데이터가 위치한 곳에 index+1부터 마지막까지 남은 데이터를 copy하고 해당 List의 맨 끝부분의 데이터를 null로 바꾸게 된다.
  • 결국 여기서 데이터의 조작은 이미 발생한 것이다. 그리고 생각해야 되는 부분이 클래스 변수인 modCount이다.
  • 이 변수는 처음에 iterator가 생성될 때 다른 클래스 변수인 expectedModCount 와 같은 값으로 동기를 하게 되어 있다

java.util.ArrayList의 이너 클래스 Itr


    public Iterator<E> iterator() {
            return new Itr();
        }

    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        // prevent creating a synthetic constructor
        Itr() {}

        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();
            ...
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }
  • 이 2개의 클래스 변수로 리스트의 데이터 변경 여부를 체크하게 되는 것이다
  • modCount 를 증가시키고 element가 제거되고 난 뒤 iterator에서 next() 메소드로 다음 element를 가져오려고 시도하는 순간 ConcurrentModificationException이 발생하는 것을 알 수 있다.

위의 단점을 removeIf 로 해결 가능하다.

transactions.removeIf(
    transaction -> Charater.isDigit(transaction.getReferenceCode().charAt(0))
);
  • 단순해 질 뿐 아니라 버그도 예방 가능!!
  • 삭제할 요소를 가리키는 프레디케이트를 인수로 받는다.

replaceAll 메서드

때로는 요소를 제거하는 것이 아닌 변경해야 할 상황이 있다.

스트림 API 를 사용하면 되지만 새 컬렉션을 만들기에 기존 컬렉션을를 바꾸고 싶은 경우 부적합

이때는 replaceAll 을 사용하여 데이터를 변경 가능 !

// 첫 단어만 대문자로 바꾸는 코드
referenceCodes.replaceAll(
    code -> Charater.toUpperCase(code.charAt(0)) + code.subString(1)
);

맵 처리

forEach 메서드

맵을 조회하기 위한 기존의 반복 코드

for(Map.Entry<String, Integer> entry : ageOfFriends.entrySet()){
    String friend = entry.getKey();
    Integer age = entry.getValue();
    System.out.println(friend + " is " + age + " years old");
}

forEach 를 사용한 코드

ageOfFriends.forEach(
    (friend, age) -> System.out.println(friend + " is " + age + " years old")
);
  • forEach 메서드는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는다.

정렬 메서드

다음 두 개의 새로운 메서드를 이용하면 맵을 키 또는 값을 기준으로 정렬 가능

  • Entry.comparingByValue
  • Entry.comparingByKey
Map<String, String> favoriteMovies = Map.ofEntries(
        Map.entry("ljo", "Star Wars"),
        Map.entry("hsy", "Matrix"),
        Map.entry("yhh", "James Bond")
);

favoriteMovies.entrySet().stream()
        .sorted(Entry.comparingByKey())
        .forEachOrdered(System.out::println); // 키 값 순서대로
hsy=Matrix
ljo=Star Wars
yhh=James Bond

getOrDefault 메서드

기존에 찾으려는 키가 존재하지 않을 경우 NPE을 방지하기 위해 널 체크를 해야 했지만getOrDefault 를 이용하면 이를 해결 할 수 있다.

  • 첫 번째 인수로 받은 가 맵에 없으면
  • 두 번째 인수로 받은 기본값 을 반환한다.
  • 키가 존재하더라도 값이 널인 상황에서는 널을 반환할 수 있으므로 주의

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다.

  • computeIfAbsent
    • 제공된 키에 해당하는 값이 없으면(null도 포함), 키를 이용해 새 값을 계산하고 맵에 추가한다.
  • computeIfPresent
    • 제공된 키가 존재하면 새 값을 계산하고 맵에 추가한다.
  • compute
    • 제공된 키로 새 값을 계산하고 맵에 저장한다.

Ex) 허승연님에게 줄 영화 목록을 만든다고 가정

  • 기존 코드
    String friend = "hsy";
    List<String> movies = friendsToMovies.get(friend);
    if (movies == null){     // 초기화 확인
        movies = new ArrayList<>();
        friendsToMovies.put(friend, movies);
    }
    movies.add("Iron man"); // 영화 추가
  • 컬렉션 API 사용
    friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>)).add("Star Wars");
</br>

삭제 패턴

  • 제공된 키에 해당하는 맵 요소를 제거하는 remove 메서드는 이미 알고 있다
    • 삭제할 경우 키가 존재하는지 확인하고 값을 삭제하지만
  • 자바 8 에서는 키가 특정한 값과 연관되어 있을 때만 항목을 제거하는 오버로드 버전 메서드를 제공한다.
map.remove(key, value); 

교체 패턴

맵의 항목을 바꾸는데 사용할 수 있는 메서드들

  • replaceAll
    • Bifunction 을 적용한 결과로 각 항목의 값을 교체한다.
    • 이 메서드는 ListreplaceAll 과 비슷한 동작을 수행
  • Replace
    • 키가 존재하면 맵의 값을 바꾼다.
    • 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전 도 있다.

합침

두 개의 맵에서 값을 합칠 때 조건을 걸고 합치려면 merge 메서드 이용

Map<String, String> family = Map.ofEntries(
    entry("Teo", "Star Wars"), entry("Cristina", "James Bond")
);
Map<String, String> friends = Map.ofEntries(
    entry("Raphael", "Star Wars"), entry("Cristina", "Matrix")
);

// merge 메서드 사용 - 조건에 따라 맵을 합치는 코드
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> 
    everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)
);

{Raphael=Star Wars, Cristina=James Bond & Matrix, Teo=Star Wars}
  • merge 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다.

개선된 ConcurrentHashMap

ConcurrentHashMap 는 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용


리듀스와 검색

ConcurrentHashMap 은 스트림에서 봤던 것과 비슷한 종류의 세 가지 새로운 연산을 지원한다.

  • forEach
    • 각 (키, 값) 상에 주어진 액션을 실행
  • reduce
    • 모든 (키, 값) 쌍을 제공된 리듀스 함수를 이용해 결과로 합침
  • search
    • null 이 아닌 값을 반환할 때까지 각 (키, 값) 쌍에 함수를 적용

또한, 다음 처럼 4가지 연산 형태를 지원

  1. 키, 값으로 연산 (forEach , reduce , search)
  2. 키로 연산 ( forEachKey, reduceKey, searchKey )
  3. 값으로 연산 ( forEachValue, reduceValue, searchValue )
  4. Map.Entry 객체로 연산 ( forEachEntry, reduceEntry, searchEntry )

위의 연산들은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행한다.

따라서, 이들 연산에 제공한 함수는 계산이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야한다.

그리고 이들 연산에 병렬성 기준값(threshold) 를 지정해야한다.

  • 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다.

계수

  • ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드 제공

집합뷰

  • ConcurrentHashMap 클래스는 집합 뷰로 반환하는 keySet 이라는 새 메서드 제공
  • 맵을 바꾸면 집합도 바뀌고 반대로 집합을 바꾸면 맵도 영향을 받는 구조
  • newKeySet 이라는 새 메서드를 이용해 ConcurrentHashMap 으로 유지되는 집합을 만들 수도 있다.

참고출처

728x90
728x90

이번 주제 키워드

  • 병렬 스트림으로 데이터 병렬 처리하기
  • 병렬 스트림의 성능 분석
  • 포크/조인 프레임워크
  • Spliterator로 스트림 데이터 쪼개기

병렬 스트림

  • 스트림을 이용하면 순차 스트림을 병렬 스트림으로 자연스럽게 바꿀 수 있다.
  • 컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.
  • 병렬 스트림이란, 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다.
  • 따라서 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있다.
  • 1부터 n부터까지의 합을 구하는 코드 : 일반 스트림
public static long sequentialSum(long n) {
    return Stream.iterate(1L, i -> i + 1)   //  무한 자연수 스트림 생성
                 .limit(n)                  //  n개 이하로 제한
                 .reduce(0L, Long::sum);    //  모든 숫자를 더하는 스트림 리듀싱 연산
}
  • 1부터 n부터까지의 합을 구하는 코드 : 전통적인 자바 코드
public static long iterativeSum(long n) {
    long result = 0;
    for (long i = 1L; i<= n; i++) {
        result += i;
    }
    return result;
}

n이 엄청나게 커진다면 병렬로 처리하는 것이 좋을 것이다. 어떻게 할까??

1. 순차 스트림을 병렬 스트림으로 변환하기

public static long parallelSum(long n) {
    return Stream.iterate(1L, i -> i + 1)
                 .limit(n)
                 .parallel()  //  스트림을 병렬 스트림으로 변환
                 .reduce(0L, Long::sum);
}

  • 순차스트림에 parallel 메서드를 호출하면 리듀싱 연산이 병렬로 처리된다.
    • 하지만 스트림 자체에는 아무 변화도 없다.
    • 내부적으로 불리언 플래그가 설정될 뿐
  • 이전 코드와 다른 점은 스트림이 여러 청크로 분할되어 있다는 것이다.
    • 따라서 리듀싱 연산을 여러 청크에 병렬로 수행할 수 있다.
  • 마지막 리듀싱 연산을 통해 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐 전체 스트림의 리듀싱 결과를 도출한다.
  • parallel과 sequential 메서드를 통해 어떤 연산을 병렬로 실행할지, 순차로 실행할지 제어할 수 있다.
    • parallel과 sequential 두 메서드 중 최종적으로 호출된 메서드가 전체 파이프라인에 영향을 미친다.
    • 아래 코드는 parallel이 마지막 호출되었으므로 위 파이프라인은 병렬로 실행된다.
stream.parallel()
      .filter(...)
      .sequential()
      .map(...)
      .parallel()
      .reduce();

스트림 성능 측정

  • 과연 어느것이 더 빠를까??
    • 순차스트림
    • 병렬스트림
    • 전통적인 for loop
  • JMH 라이브러릴 이용해 성능 측정!
    • https://github.com/openjdk/jmh
    • https://www.baeldung.com/java-microbenchmark-harness
    • JMH는 OpenJDK에서 개발한 성능 측정 툴이다.
    • 특정 메소드의 성능을 측정하는 식으로 사용할 수 있고 실제 테스트하기전 워밍업 과정과 실제 측정 과정을 수행하는데 각 과정의 실행 수를 제어할 수 있고, 측정 후 결과로 나오는 시간의 단위를 지정하는 기능도 제공한다.
  • 보통 for loop - 순차 스트림 - 병렬 스트림 순
    • 왜일까??
    • iterate가 박싱된 객체를 생성하므로 이를 다시 언박싱하는 과정이 필요했다.
    • 반복 작업은 병렬로 실행될 수 있도록 독립적인 청크로 분할하기 어렵다.
  • 두 번째 이유는 굉장히 큰 문제

- iterate 연산은 이전 연산의 결과에 따라 다음 함수의 입력이 달라지기 때문에 청크로 분할이 어렵다.
- 위와 같은 상황에서는 병렬 리듀싱 연산이 수행되지 않는다.
- 리듀싱 과정을 시작하는 시점에 전체 숫자 리스트가 준비되지 않았으므로 스트림을 병렬로 처리할 수 있도록 청크로 분할할 수가 없기 때문이다.
- iterate같은 경우는 스트림이 병렬로 처리되도록 지시했고 각각의 합계가 다른 thread에서 수행되었음에도 불구하고 순차처리 방식으로 처리되기 때문에 thread를 할당하는 오버헤드만 증가하게 될 뿐이다.
- 따라서 iterate와 같은 병렬과는 거리가 먼 방식을 사용하면 오히려 프로그램의 성능이 더 나빠질 수도 있다.

더 특화된 메서드 사용

  • LongStream.rangeClosed라는 메서드를 활용할 수 있다. 이는 iterate에 비해 아래와 같은 장점이 있다.
    • 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.
    • 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다. 예를 들어, 1 ~ 20의 숫자 범위를 각각 1 ~ 5, 6 ~ 10, 11 ~ 15, 16 ~ 20 범위의 숫자로 분할할 수 있다.
LongStream.rangeClosed(1, N)
          .parallel()
          .reduce(0L, Long:sum);
  • LongStream.rangeClosed를 활용하면 실질적으로 리듀싱 연산이 병렬로 수행된다.
  • 올바른 자료구조를 선택해야 병렬 실행도 최적의 성능을 발휘할 수 있다.

병렬 스트림의 올바른 사용법

  • 공유된 상태를 바꾸는 알고리즘을 사용할 때 병렬 스트림을 사용하면 문제가 발생한다.
public long sideEffectParalleSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
    return accumulator.total;
}

public class Accumulator {
    public long total = 0;
    public void add(long value) { total += value; }
}
  • 10번 수행한 결과
    • 올바른 결과값조차 나오지 않는다.
    • 레이스 컨디션이 일어나기 때문

병렬 스트림 효과적으로 사용하기

  • 확신이 서지 않을 때는 직접 측정해서 사용하라.
    • 병렬 스트림이 순차 스트림보다 항상 성능이 좋은 것이 아니기 때문에 모를 때는 직접 성능 체크해보는 것이 정확하다.
  • 박싱을 주의해서 사용하라.
    • 오토박싱/언박싱은 성능을 크게 저하시킬 수 있는 요소다. 기본형 특화 스트림(ex. IntStream, LongStream, DoubleStream)을 활용하여 박싱 동작을 피할 수 있다.
  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있음을 주의하라.
    • limit이나 findFirst같이 요소의 순서에 의존하는 연산을 병렬 스트림에 활용하게 되면 비싼 비용을 치뤄야 한다.
  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라.
    • 처리해야할 요소 수가 N이고 하나의 요소를 처리하는데 드는 비용이 Q라고 하면
    • 전체 스트림 파이프라인 처리 비용을 N*Q로 예상할 수 있다.
    • Q가 높아진다는 것은 병렬 스트림으로 성능 개선의 가능성이 있다는 것을 의미
  • 소량의 데이터에서는 병렬 스트림이 도움 되지 않는다.
    • 소량의 데이터를 처리하는 상황에서는 병렬화 과정에서 생기는 부가 비용을 상쇄할 수 있을 만큼의 이득을 얻지 못한다.
  • 스트림을 구성하는 자료구조가 올바른지 확인하라.
    • 예를 들면, ArrayList가 LinkedList보다 효율적으로 분할할 수 있다.
    • LinkedList는 분할하려면 모든 요소를 탐색해야 하지만 ArrayList는 요소를 탐색하지 않고도 리스트를 분할할 수 있다.
  • 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다.
    • 예를 들어, SIZED 스트림은 정확히 같은 크기의 두 스트림으로 분할되므로 효과적으로 스트림을 병렬처리 할 수 있다
    • 반면, 필터 연산은 스트림의 길이를 예측할 수 없으므로 효과적으로 병렬 처리 할 수 있을지 알 수 없게 된다.
  • 최종 연산의 병합 과정(ex. Collector의 combiner 메서드) 비용을 살펴봐라.
    • 병합 과정의 비용이 비싸다면, 병렬 스트림으로 얻은 성능의 이익이 서브스트림의 부분 결과를 합치는 과정에서 상쇄될 수 있다.

포크/조인 프레임워크

병렬 스트림이 수행되는 내부 인프라구조는 자바7에서 추가된 포크/조인 프레임워크로 병렬 스트림이 처리된다.

  • 포크/조인 프레임워크는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음에 서브태스크 각각의 결과를 합쳐서 전체 결과를 만들도록 설계되었다.
  • 포크/조인 프레임워크에서는 서브태스크를 스레드 풀(ForkJoinPool)의 작업자 스레드에 분산 할당하는 ExecutorService 인터페이스를 구현한다.
  • RecursiveAction 또는 RecursiveTask 추상 클래스를 상속받아서 구현
    • RecursiveAction: 반환값이 없을 때
    • RecursiveTask: 반환값이 있을 떄

RecursiveTask 활용

  • 스레드 풀을 이용하려면 RecursiveTask의 서브클래스를 만들어야 한다.
  • RecursiveTask를 정의하려면 추상 메서드 compute를 구현해야 한다.
  • protected abstract R compute();
  • compute 메서드는 태스크를 서브태스크로 분할하는 로직과 더 이상 분할할 수 없을 때 개별 서브태스크의 결과를 생산할 알고리즘을 정의한다.
  • compute 메서드 의사코드
if (태스크가 충분히 작거나 더 이상 분할할 수 없으면) {
    순차적으로 태스크 계산
} else {
    태스크를 두 서브태스크로 분할
    태스크가 다시 서브태스크로 분할되도록 이 메서드를 재귀적으로 호출함
    모든 서브태스크의 연산이 완료될 때까지 기다림
    각 서브태스크의 결과를 합침
}
  • 재귀적인 태스크 분할 과정
    • divide-and-conquer 알고리즘의 병렬화 버전이다.

fork & join으로 합 구하기

var pool = new ForkJoinPool(); // 쓰레드풀 생성
var task = new SumTask(1, 10); // task 생성
var resut = pool.invoke(task); // invoke() 호출로 작업 시작
System.out.println(resut);
// fork join을 이용하여 간단한 합 구하기
class SumTask extends RecursiveTask<Long> {
    private final long start;
    private final long end;
    private static final long THRESHOLD = 10;

    public SumTask(long start, long end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        long length = end - start + 1;
        if (length <= THRESHOLD) {
            return sum();
        }

        long mid = (start + end) / 2;
        SumTask leftSumTask = new SumTask(start, mid);
        SumTask rightSumTask = new SumTask(mid + 1, end);
        leftSumTask.fork(); // 스레드풀의 다른 스레드로 태스크를 비동기로 실행
                Long rightResult = rightSumTask.compute(); // 두번째 서브태스크 동기 실행
                Long leftResult = leftSumTask.join();
        return  rightResult + leftResult;
    }

    private Long sum() {
        long ret = 0L;
        for (long i = start; i < end; i++) {
            ret += i;
        }
        return ret;
    }
}
  • ForkJoinPool은 fork & join 프레임웤에서 제공하는 쓰레드 풀로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다.

fork()와 join()

  • compute()는 작업을 나누고, fork()는 작업을 큐에 넣는다. (반복)
  • join()으로 작업의 결과를 합친다. (반복)
  • fork와 join의 차이점

  • 비동기 메서드는 메서드를 호출만 할 뿐, 그 결과를 기다리지 않는다.
    • 내부적으로는 다른 쓰레드에게 작업을 수행하도록 지시만 하고 결과를 기다리지 않고 돌아오는 것

작업 훔치기

  • fork()가 호출되어 작업 큐에 추가된 작업 역시, compute()에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행한다.
  • 이것을 작업 훔쳐오기라고 하며, 이 과정은 모두 쓰레드풀에 의해 자동적으로 이루어진다.

포크/조인 프레임워크를 제대로 사용하는 방법

  • join 메서드를 태스크에 호출하면 태스크가 생산하는 결과가 준비될 때 까지 호출자를 블록시킨다. 따라서 두 서브태스크가 모두 시작된 다음에 join을 호출해야 한다. 그렇지 않으면 각각의 서브태스크가 다른 태스크가 끝나길 기다리게 되면서 순차 알고리즘보다 느리고 복잡한 프로그램이 될 수 있다.
  • RecursiveTask 내에서는 ForkJoinPool의 invoke 메서드 대신 compute나 fork 메서드를 호출한다. 순차 코드에서 병렬 계산을 시작할 때만 invoke를 사용한다.
  • 두 서브태스크에서 메서드를 호출할 때는 fork와 compute를 각각 호출하는 것이 효율적이다. 그러면 두 서브태스크의 한 태스크에는 같은 스레드를 재사용할 수 있으므로 풀에서 불필요한 태스크를 할당하는 오버헤드를 피할 수 있다.
    • 즉 compute()는 새 스레드를 사용하지 않기 때문
  • 포크/조인 프레임워크를 이용하는 병렬 계산은 디버깅이 어렵다.
  • 멀티코어에서 포크/조인 프레임워크를 사용하는 것이 순차처리보다 무조건 빠른 것은 아니다. 병렬 처리로 성능을 개선하려면 태스크를 여러 독립적인 서브태스크로 분할할 수 있어야 한다.

그렇다면 스트림은 어떻게 분할 로직을 개발하지 않고도 자동으로 스트림을 분할할까?? 바로 Spliterator 기법을 이용


Spliterator

  • Spliterator는 분할할 수 있는 반복자라는 의미다.
  • Iterator 처럼 소스의 요소 탐색 기능을 제공하지만 병렬 작업에 특화돼있다.
  • 자바 8은 컬렉션 프레임워크에 포함된 모든 자료구조에 사용할 수 있는 디폴트 Spliterator 구현을 제공한다.
  • 컬렉션은 spliterator라는 메서드를 제공하는 Spliterator 인터페이스를 구현한다.
public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action); //  Spliterator 의 요소를 하나씩 순차적으로 소비하면서 탐색해야 할 요소가 남아있으면 true를 반환(iterator 동작과 같다)
    Spliterator<T> trySplit();  //  Spliterator 의 일부 요소(자신이 반환한 요소)를 분할해서 두 번째 Spliterator를 생성하는 메서드
    long estimateSize();    //  탐색해야 할 요소 수 정보 제공 메서드
    int characteristics();
}

분할 과정

  • 스트림을 여러 스트림으로 분할하는 과정은 재귀적으로 일어난다.

  • 1단계: 첫 번째 Spliterator에 trySplit을 호출하면 두 번째 Spliterator가 생성된다.
  • 2단계: 두개의 Spliterator에 trySplit을 다시 호출하면 4개의 Spliterator가 생성된다.
    • 이처럼 trySplit의 결과가 null이 될 때 까지 이 과정을 반복한다.
  • 3단계: trySplit이 null을 반환했다는 것은 더 이상 자료구조를 분할할 수 없음을 의미
  • 4단계: Spliterator에 호출한 모든 trySplit의 결과가 null이면 재귀 분할 과정이 종료된다.

Spliterator의 특성

  • Spliterator의 characteristics 추상 메서드는 Spliterator 자체의 특성 집합을 int 타입으로 반환한다.
  • Spliterator 특성


정리

  • 내부 반복을 이용하여 다른 스레드를 이용하지 않고도 스트림을 병렬로 처리할 수 있다.
  • 병렬 처리 성능이 무조건 빠른 것이 아니기 때문에 성능 측정을 해보는 것이 좋다.
  • 병렬 스트림은 처리해야할 데이터가 아주 많거나 각 요소를 처리하는데 오랜 시간이 걸릴 때 성능을 높일 수 있다.
  • 기본형 특화 스트림을 이용하는 것이 병렬 처리보다 더욱 성능을 높일 수 있는 방법이다.
  • 스트림의 병렬처리는 포크/조인 프레임워크 이용하여 병렬화할 수 있는 태스크를 작은 태스크로 분할한 후, 분할된 태스크를 각각의 스레드로 실행하며 서브태스크 각각의 결과를 합쳐서 최종 결과를 생산한다.
  • Spliterator는 탐색하려는 데이터를 포함하는 스트림을 어떻게 병렬화 할 것인지를 정의한다.

참고 출처

728x90
728x90

예시로 시작해보자!!

  • 통화별로 트랜잭션을 그룹화한 다음에 모든 트랜잭션 합계를 계산하는 예제
  • Map<Currency, Integer> 을 반환해야 한다.
  • 명령형 코드
    • 코드가 길어서 무엇을 실행하는지 파악하기 어렵다.
    • Map<Currency, List<Transaction>> transactionByCurrencies = new HashMap<>(); // 그룹화한 트랜잭션을 저장할 맵을 생성 for (Transactin transaction : transactions) { // 트랜잭션 리스트를 반복 Currency currency = transaction.getCurrency(); List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency); if (transactionForCurrency = null) { // 현재 통화를 그룹화하는 맵에 항목이 없으면 항목을 만든다. transactionsForCurrency = new ArrayList<>(); transactionsByCurrencies.put(currency, transactionsForCurrency); } transactinsForCurrency.add(transaction); // 같은 통화를 가진 트랜잭션 리스트에 현재 탐색 중인 트랜잭션을 추가 }
  • 함수형 코드
    Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream().collect(groupingBy(Transaction::getCurrency));

이번 주제 키워드


reduce 연산(하나로 수렴)을 통해 스트림을 다양한 형태로 collect 하는 방법에 대한 내용

  • Collectors
  • reduce, reducing
  • 데이터의 그룹화(groupingBy)와 분할(Predicate)
  • Collector 인터페이스 살펴보기
    • toList() 살펴보기

Collector란??

  • 자바 8에서 Collector는 Stream 요소들을 어떻게 분류하고 모을지(reducing)를 담당하는 인터페이스이다.
    • 고급 리듀싱 기능을 수행
  • Collector 인터페이스를 implement하고 구현하여 스트림의 요소를 어떤 식으로 도출할지 지정한다.
  • 스트림에 collect()를 호출하면 스트림의 요소에 (컬렉터로 파라미터화된) 리듀싱 연산이 수행된다.
    • 즉 collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다.
    • 따라서 Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할지 결정

미리 정의된 컬렉터

Collectors 유틸티리 클래스에는 자주 사용하는 컬렉터 인스턴스를 쉽게 생설할 수 있는 정적 팩터리 메서드를 제공한다.

Collectors에서 제공하는 메서드의 기능은 크게 세가지

  1. 스트림 요소를 하나의 값으로 reduce하고 요약
  2. 스트림 요소들의 그룹화
  3. 스트림 요소들의 분할 (이것도 결국 bool 그룹화)

첫번째, Reducing과 요약

다양한 계산을 수행할 때 사용하는 유용한 컬렉터

  • 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산

counting()

  • 다른 컬렉터와 함께 사용할 때 유용하다.
menu.stream().collect(Collectors.counting())
menu.stream().count()

maxBy(), minBy()

  • 스트림값에서 최댓값과 최솟값 검색할 때 사용하는 컬렉터
  • 해당 컬렉터는 스트림의 요소를 비교하는데 사용할 Comparator인수를 받는다
Optional<Dish> mostCalorieDish = menu.stream().collect(Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)));

요약 연산

  • 필드의 합계나 평균 등을 반환하는 연산
  • 리듀싱 기능이 자주 사용된다.
  • 대표적으로 Collectors.summingInt
public static <T> Collector<T, ?, Integer> summingInt(ToIntFunction<? super T> mapper) {
  return new CollectorImpl<>(
          () -> new int[1],
          (a, t) -> { a[0] += mapper.applyAsInt(t); },
          (a, b) -> { a[0] += b[0]; return a; },
          a -> a[0], CH_NOID);
}
@FunctionalInterface
public interface ToIntFunction<T> {

    /**
     * Applies this function to the given argument.
     *
     * @param value the function argument
     * @return the function result
     */
    int applyAsInt(T value);
}
int totalColories = menu.stream().collect(summingInt(Dish:getCalories));
  • 위의 코드처럼 객체를 int로 매핑하는 함수를 인수로 받는다.
  • 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다.
  • summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.
  • 이외에도 averagingInt 메서드도 존재한다.

만약 두 개 이상의 연산을 한번에 수행해야 할 때는?

  • summarizingInt()
IntSummaryStatistics statistic = menu.stream().collect(Collectors.summarizingInt(Dish::getCalories));

문자열 연결

  • joining()
  • 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
menu.stream().map(Dish::getName).collect(Collectors.joining(", "));

범용 리듀싱 요약 연산

이러한 모든 Collector는 reducing 팩토리 메서드로도 정의할 수 있다. (가독성이나 편리성 측면에서 권장하지 않는다)

  • reducing()
public static <T, U> Collector<T, ?, U> reducing(
        U identity,
        Function<? super T, ? extends U> mapper,
        BinaryOperator<U> op) {
    ...
}
  • 첫 번째 인수 : reducing 연산의 초기값. 스트림에 인수가 없을 때는 반환값.
  • 두 번째 인수 : 요소를 변환하는 변환함수.
  • 세 번째 인수 : 같은 종류의 두 항목을 하나의 값으로 더하는 합계 함수(BinaryOperator.)
  • 예시 - 모든 메뉴 요소의 칼로리 합계
    menu.stream().collect(Collectors.reducing(0, Dish::getCalories, (i, j) -> i + j)
    menu.stream().collect(Collectors.reducing(0, Dish::getCalories, Integer::sum)

한개짜리 인수를 갖는 reducing()

public static <T> Collector<T, ?, Optional<T>> reducing(BinaryOperator<T> op) {
    ...
}
  • 초기값은 스트림의 첫 요소
  • 두번째 인수는 자신을 그대로 반환하는 항등 함수
  • 따라서 빈 스트림인 경우를 대비해 Optional를 반환한다.

잠깐 BinaryOperator을 살펴보자

public interface BinaryOperator<T> extends BiFunction<T,T,T> {
}

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
}
  • 즉 두 인수의 타입과 반환하는 타입은 같아야 한다.
    • 따라서 아래와 같은 사용은 컴파일 error
menu.stream().collect(Collectors.reducing((d1, d2) -> d1.getName() + d2.getName()))

collect vs reduce

  • 이 두가지 메서드로 같은 기능을 구현할 수 있지만 의미론적인 문제와 실용성 문제 등 몇가지 문제가 있다.
  • 의미론적인 문제
    • collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드
    • reduce 메서드는 두 값을 하나로 도출하는 불변형 연산
  • 실용성 문제
    • 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로 리듀싱 연산을 병렬로 수행할 수 없다.
    • 이를 피하기 위해 매번 리스트를 새로 할당하고 객체를 할당하느라 성능이 낮을 것 이다.

두번째, 그룹화

맨 처음 트랜잭션 currency 그룹화 예제를 생각해보면 명령형으로 그룹화를 구현하려면 까다롭고, 에러도 신경써야 하고, 코드가 길다.

  • Collectors.groupingBy 이용하면 쉽게 그룹화가 가능하다.
  • gropingBy 메서드의 인수로 전달되는 함수를 기준으로 그룹화되므로 이를 분류 함수라고 부른다.

1. 타입에 따른 메뉴 그룹 구하기

Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(Collectors.groupingBy(Dish::getType));

2. 복잡한 기준으로 그룹화하기

  • 메서드 참조 대신 람다 사용
public enum CaloricLevel { DIET, NORMAL, FAT }

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
    groupingBy(dish -> {
    if (dish.getCalories() <= 400) {
        return CaloricLevel.DIET;
    } else if (dish.getCalories() <= 700) {
        return CaloricLevel.NORMAL;
    } else {
        return CaloricLevel.FAT;
    }
    }));

3. 다수준 그룹화

Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = 
    menu.stream().collect(
        groupingBy(Dish::getType,
            groupingBy(dish -> {
                if (dish.getCalories() <= 400) {
                    return CaloricLevel.DIET;
                } else if (dish.getCalories() <= 700) {
                    return CaloricLevel.NORMAL;
                } else {
                    return CaloricLevel.FAT;
                }
            })
        )
    );

// 결과
{FISH={NORMAL=[salmon], DIET=[prawns]}, OTHER={NORMAL=[fries, pizza], DIET=[rice, fruit]}, MEAT={FAT=[pork], NORMAL=[beef], DIET=[chicken]}}
  • n수준 그룹화를 통해 n 수준 맵이 된다.

4. 서브그룹으로 데이터 수집

  • 분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다.
  • 따라서 요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 로직도 구현할 수 있다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType = 
    menu.stream().collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));

// 결과
{OTHER=Optional[pizza], MEAT=Optional[pork], FISH=Optional[salmon]}

5. 컬렉션 결과를 다른 형식에 적용하기

  • collectingAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다.
  • 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.
Map<Dish.Type, Dish> mostCaloricByType =
    menu.stream()
        .collect(groupingBy(Dish::getType,  //  분류함수
                 collectingAndThen(maxBy(comparingInt(Dish::getCalories)),  //  감싸인 컬렉터
                 Optional::get)));  //  변환함수. Optional에 포함된 값을 추출

세번째, 분할

분할은 분할 함수(프레디케이트)를 분류 함수로 사용하는 특수한 그룹화다

  • 프레디케이트를 사용하므로 맵의 키 형식은 bool
  • 따라서 두 개의 그룹으로 분류된다.

채식요리와 아닌 요리 분류

Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));

// 결과 : {false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}

분할의 장점

  • filter를 사용해서도 간단히 원하는 내용을 얻을 수 있는데 왜 사용해야하는가?
  • 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지한다는 것이 장점이다.
    • filter는 말그대로 필터링하여 보여주는 것이고, 분할은 데이터를 분할하는 용도로 사용하는 것이다.
  • 두 번째 인수로 컬렉터를 사용하는 오버로드 메서드도 있다.
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream().collect(
    partitioningBy(Dish::isVegetarian,  //  분할 함수
        groupingBy(Dish::getType)));    //  두 번째 컬렉터

// 결과 : 
{false={MEAT=[pork, beef, chicken], FISH=[prawns, salmon]}, 
 true={OTHER=[french fries, rice, season fruit, pizza]}}

지금까지 살펴본 모든 Collector는 Collector 인터페이스를 구현한다. Collector 인터페이스를 자세히 살펴보자.

Collector 인터페이스란?

  • reducing 연산(Collector)를 어떻게 구현할지 제공하는 메서드 집합으로 구성된 인터페이스.
  • Collector 인터페이스를 직접 구현하면 문제를 더 효율적으로 해결할 수 있다.
public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    BinaryOperator<A> combiner();
    Function<A, R> finisher();
    Set<Characteristics> characteristics();
  • T는 수집된 스트림 항목의 제네릭 타입
  • A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 타입
  • R은 수집 연산 결과 객체의 타입
    • 주로 컬렉션 타입

toList()를 확인해보면서 파악해보자!!

public static <T> Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
                               (left, right) -> { left.addAll(right); return left; },
                               CH_ID);
}

supplier 메서드

새로운 결과 컨테이너 만들기

  • supplier 메서드는 빈 결 이루어진 Supplier를 반환해야 한다.
@FunctionalInterface
public interface Supplier<T> {
    T get();
}
  • 즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.
  • toList()에서 supplier()은 빈 리스트를 반환한다.
public Supplier<List<T>> supplier() {
    return () -> new ArrayList<T>();
}

public Supplier<List<T>> supplier() {
    return ArrayList::new;
}

accumulator 메서드

결과 컨테이너에 요소 추가하기

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);
  • 스트림에서 n번째 요소를 탐색할 때 누적자와 n번째 요소를 함수에 적용한다.,
  • 함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.
  • toList()
public BiConsumer<List<T>, T> accumulator() {
    return (list, item) -> list.add(item);
}

public BiConsumer<List<T>, T> accumulator() {
    return List::add;
}

finisher 메서드

최종 변환 값을 결과 컨테이너로 적용하기

  • finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수(Function<T, R>)를 반환해야 한다.
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);

        static <T> Function<T, T> identity() {
                return t -> t;
        }
}
  • 때로 누적자 객체가 이미 최종 결과인 상황도 있는데 이럴 때는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.
    • toList()도 마찬가지
public Function<List<T>, List<T>> finisher() {
    return Function.identity();
}

위 3가지 메소드는 순차적 스트림 리듀싱 기능을 수행할 수 있다. 하지만 실제로 collect가 동작하기 전, 다른 중간 연산과 파이프라인을 구성할 수 있게 해주는 게으른 특성 그리고 병렬 실행 등도 고려해야 하므로 스트림 리듀싱 기능 구현은 생각보다 복잡하다.

combiner 메서드 : 두 결과 컨테이너 병합

  • combiner는 리듀싱 연산에서 사용할 함수를 반환하는 메서드이다.
  • combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할 지 정의한다.
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
}

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
}
  • toList()의 경우
    • 스트림의 두번째 서브 파트에서 수집한 항복 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가하면 된다.
public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    }
}
  • combiner 메서드를 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다.
  • 이 때 자바7의 포크/조인 프레임워크와 Spliterator를 사용한다.

  1. 스트림의 분할을 정의하는 조건이 성립할 경우 원래 스트림을 재귀적으로 분할한다.
  2. 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용하여 서브스트림을 병렬로 처리한다.
  3. combiner 메서드가 반환하는 함수를 통해 각 서브스트림의 결과를 합쳐 연산을 완료한다.

Characteristics 메서드

  • Characteristics 메서드는 컬렉터의 연산을 정의하는 Charactieristics 형식의 불변 집합을 반환한다.
  • Characteristics는 스트림을 병렬로 리듀스할건지, 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.
  • Characteristics는 다음 세 항목의 특성을 갖는 열거형이다.

UNORDERED

  • 리듀싱 결과는 스트림의 방문 순서나 누적 순서에 영향을 받지 않는다.

CONCURRENT

  • 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다.
  • 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은(집합처럼 요소의 순서가 무의미한) 상황에서만 병렬 리듀싱을 수행할 수 있다.

DENTITY_FINISH

  • finisher 메서드가 반환하는 함수는 identity를 적용할 뿐이므로 이를 생략할 수 있다.
  • 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다.
  • 또한 누적자 A를 결과 R로 안전하게 형변환 할 수 있다.
  • toList()
    • 마찬가지로 스트림의 요소를 누적하는데 사용한 리스트가 최종 결과 형식이므로 추가 변환이 필요 없다.
    static final Set<Collector.Characteristics> CH_ID
                = Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));

요약

  • collect는 스트림의 요소를 요약 결과로 누적하는 다양한 방법(컬렉터)을 인수로 갖는 최종 연산이다.
  • 다양한 컬렉터 등이 Collections 유틸 클래스에 미리 정의되어 있다.
  • 컬렉터는 다수준의 그룹화, 분할, 리듀싱 연산에 적합하게 설계되어 있다.
  • Collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터를 개발할 수 있다,

참고 출처

728x90
728x90

스트림 API가 지원하는 다양한 연산들을 살펴보자.

스트림의 요소 걸러내기


filter()

  • Predicate로 필터링
  • filter 메서드는 Predicate를 인수로 받아서 Predicate와 일치하는 모든 요소를 포함하는 스트림을 반환한다.
    Stream<T> filter(Predicate<? super T> predicate);
  • 다른 조건으로 여러 번 사용 가능하다.
    intStream.filter(i -> i%2 != 0 && i%3 != 0)...
    intStream.filter(i -> i%2 != 0).filter(i%3 != 0)...

distinct()

  • 고유 요소로 이루어진 스트림을 반환한다.
    • 즉 중복된 요소들을 제거
    • 고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다.
    Stream<T> distinct();

정렬


sorted()

  • 스트림을 정렬할 때 사용하는 메서드
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
  • Comparator 대신 int 값을 반환하는 람다식을 사용하는 것도 가능하다.
  • Comparator를 지정하지 않으면 스트림 요소의 Comparable으로 정렬한다.
    • 만약 Comparable을 구현한 클래스가 아니라면예외가 발생한다
  • jdk 1.8부터 Comparator 인터페이스에 정렬에 도움을 주는 static 메서드와 디폴트 메서드가 많이 추가되었다.
    • default
      • reversed
      • thenComparing
      • theComparingInt..
    • static
      • comparing
      • comparingInt..
  • 예를 들어 선수 스트림을 팀별, 성적순, 이름순으로 정렬하려면??
playerStream.sorted(Comparator.comparing(Player::getTeam)
        .thenComparing(Player::getScore)
        .thenComparing(Player::getName))
        .forEach(System.out::println);

스트림 슬라이싱


자바9에서 추가된 메서드인 takeWhile(), dropWhile()

takeWhile()

  • Predicate를 통한 슬라이싱이다.
  • 무한 스트림을 포함한 모든 스트림에 Predicate를 적용해 Predicate의 결과가 true인 동안 요소를 가져와 스트림을 슬라이스 할 수 있다.
  • 즉 Predicate와 일치하지 않는 요소를 발견하면 스트림의 나머지 부분이 버려진다.
    default Stream<T> takeWhile(Predicate<? super T> predicate)
  • 칼로리가 300보다 작은 요소들 구성된 스트림으로 슬라이스
    List<Food> slicedMenu = calorieSortedMenu.stream()
                             .takeWhile(dish -> dish.getCalories() < 300)
                                                            .collect(toList());

dropWhile()

  • Predicate를 통한 슬라이싱이다.
  • takeWhile()과 정반대의 작업을 수행한다.
  • Predicate이 처음으로 거짓이 되는 지점까지 발견된 요소를 버리고 작업을 중단하고 남은 모든 요소를 반환한다.
  • 마찬가지로 무한스트림에서도 동작
    default Stream<T> dropWhile(Predicate<? super T> predicate) 
  • 300 칼로리보다 높은 요소들로 구성된 스트림으로 슬라이스
    List<Food> slicedMenu = calorieSortedMenu.stream()
                         .dropWhile(dish -> dish.getCalories() < 300)
                         .collect(toList());

limit()

  • 스트림 축소
  • 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환한다.
  • 정렬되지 않은 스트림에도 사용가능하다.
    Stream<T> limit(long maxSize);

skip()

  • 요소 건너뛰기
  • 처음 n개 요소를 제외한 스트림을 반환한다.
  • 만약 n개 이하의 요소를 포함하는 스트림에 호출하면 빈 스트림이 반환된다.
    Stream<T> skip(long n);

매핑


특정 객체에서 특정 데이터를 선택하는 처리 과정에서 자주 수행되는 연산

Map()

  • 스트림의 각 요소에 함수 적용하기 위한 메서드
  • Function을 인수로 받아 각 요소에 적용한 결과가 새로운 요소로 매핑된 스트림을 반환한다.
  • 기본형 요소에 대한 mapToType 메서드도 지원한다 (mapToInt, mapToLong, mapToDouble).
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
  • 각 단어들의 글자 수의 리스트를 반환
    List<Integer> wordLengths = Arrays.asList("one", "two", "three").stream()
                                    .map(String::length)
                                    .collect(toList());

flatMap()

  • 스트림의 요소가 배열기나 map()의 연산결과가 배열인 경우, 즉 스트림의 타입이 Stream<T[]>인 경우 Stream<T>로 다루고 싶거나 Stream가 더 편리할 때 사용하는 메서드

  • 스트림의 각 값을 다른 스트결과적으로 하나의 평면화된 스트림을 반환한다.


한번 살펴보자!!

  • 해당 String 중에 길이가 5 이상인 String을 출력해보자
String[][] wordArr = new String[][]{
     new String[]{"team", "victory", "fighting"},
     new String[]{"flatMap", "is", "too", "difficult"}
};
  • map 사용
    Arrays.stream(wordArr) // Stream<String[]>
                    .map(innerArray -> Arrays.stream(innerArray)) // Stream<Stream<String>>
                    .forEach(innerStream -> innerStream.filter(word -> word.length() >  5) // Stream<String>
                            .forEach(System.out::println));

)))

  • flatMap 사용
    Arrays.stream(wordArr) //  Stream<String[]>
                    .flatMap(innerArray -> Arrays.stream(innerArray)) // // Stream<String>
                    .filter(word -> word.length() > 5)
                    .forEach(System.out::println);

))


stream으로 카드쌍 만들기

List<String> kinds = Arrays.asList("스페이드", "하트", "다이아몬드", "클로버");
List<String> nums = Arrays.asList("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K");
List<String[]> cards = kinds.stream()
                .flatMap(kind -> nums.stream()
                         .map(num -> new String[] {kind, num}))
                .peek(arr -> System.out.println(Arrays.toString(arr)))
                            .collect(Collectors.toList());
  • peek는 forEach와 달리 스트림 요소를 소모하지 않는다.


검색과 매칭 (쇼트 서킷)


특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리 유틸리티 메서드를 제공한다.

anyMatch

  • 적어도 한 요소와 일치하는지 확인하는 메서드
  • boolean 반환값이므로 최종 연산이다.
boolean anyMatch(Predicate<? super T> predicate);

allMatch

  • 모든 요소와 일치하는지 검사하는 메서드
  • 마찬가지로 최종 연산이다
boolean allMatch(Predicate<? super T> predicate);

noneMatch

  • allMatch와 반대 연산
  • 즉, 모든 요소가 Predicate 와 일치하지 않으면 true
boolean noneMatch(Predicate<? super T> predicate);

anyMatch, allMatch, noneMatch 세 메서드는 스트림 쇼트서킷 기법, 즉 자바의 &&, ||와 같은 연산을 활용한다.

findAny

  • 현재 스트림에서 임의의 요소를 반환한다.
  • 마찬가지로 최종연산이며 쇼트서킷을 이용해서 결과를 찾는 즉시 실행 종료
Optional<T> findAny();

findFirst

  • 첫 번째 요소를 찾아 반환한다. 순서가 정해져 있을 때 사용한다.
Optional<T> findFirst();

그렇다면 findAny와 findFirst는 언제 사용하는 메서드일까??

  • 바로 병렬성 때문이다.
  • 병렬 실행에서는 첫 번째 요소를 찾기 어렵기 때문에 findFirst 메서드를 사용하고
  • 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.

참고 - Optional이란??

  • Optional는 값의 존재나 부재 여부를 표헌하느 컨테이너 클래스이다.
  • 앞서 본 findAny 혹은 findFirst 메서드는 아무 요소도 반환하지 않을 수 있으므로 NPE가 발생할 수 있다.
  • 주요 메서드
    • isPresent() : Optional이 값을 포함하면 true, 아니라면 false
    • ifPresent(Consumer<T> block) : 값이 있으면 주어진 블록 실행
    • T get() : 값이 존재하면 값 반환, 없으면 NoSuchElementException
    • T orElse(T other) : 값이 있으면 값 반환, 없으면 기본값 반환

리듀싱


reduce

  • 모든 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환하는 메서드
  • reduce()는 두개의 인수를 갖는다.
T reduce(T identity, BinaryOperator<T> accumulator);
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
  • 초기값 T
  • 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator
  • combiner : 병렬처리된 결과를 합치는데 사용할 연산(병렬 스트림)
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
}

 @FunctionalInterface
 public interface BinaryOperator<T> extends BiFunction<T,T,T> { ... }
  • 초기값스트림의 첫 요소를 가지고 연산한 결과(누적 값, accumulated value)를 가지고 그 다음 요소와 연산한다.
  • 이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 모든 요소를 소모하게 되면 그 결과를 반환한다.
  • 최정연산 count(), sum() 등은 내부적으로 모두 reduce()를 이용해서 작성된 것이다.

reduce를 이용한 요소의 합

int sum = 0;
for(int x : numbers) {
        sum += x;
}
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
int sum = numbers.stream().reduce(0, Integer::sum);


이미지 출처

  • 0 + 4 의 결과인 4가 새로운 누적값

  • 누적값으로 람다를 다시 호출하며 다음 요소인 5를 소비

  • 반복하며 마지막 요소 9로 람다를 호출하면 최종적으로 21


초기값이 없는 경우

Optional<T> reduce(BinaryOperator<T> accumulator);
  • 처음 두 요소를 가지고 연산한 누적값을 사용

  • Optional 객체 반환한다.

    • 왜일까??
    • 스트림에 아무 요소도 없는 경우 초기값조차 없다면 합계가 없음을 나타낼 수 있도록 Optional 객체로 감싼 결과를 반환

최대값과 최소값

Option<Integer> max = numbers.stream().reduce(Integer::max);
Option<Integer> min = numbers.stream().reduce(Integer::min);

map - reduce

int count = menu.stream().map(d -> 1).reduce(0, (a, b) -> a + b);

map과 reduce를 연결하는 기법을 맵 리듀스 패턴이라 하며, 쉽게 병렬화하는 특징을 이용한 것을 구글이 발표하면서 유명해졌다.

기존 코드에 비해 reduce 메서드의 장점은??
바로 reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다. 기존 코드에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기 어렵고 동기화의 cost가 매우 크기 때문이다.

스트림 연산 : 상태 없음 vs 상태 있음

  • 스트림을 이용해서 연산을 쉽게 구현할 수 있으며 parrell 메서드를 통해 쉽게 병렬성을 얻을 수 있다.

  • 하지만 스트림 연산은 각각 다양한 연산을 수행하기 때문에 내부적인 상태를 고려해야 한다.

  • 내부 상태가 없는 연산

    • 사용자가 제공한 람다나 메서드 참조가 내부적인 가변 상태를 갖지 않는다는 가정하에
    • map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다.
    • 따라서 보통 상태가 없는, 즉 내부 상태를 갖지 않는 연산이다.
  • 내부 상태가 있는 연산

    • reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다.
    • 스트림에서 처리하는 요소 수와 관계없이 내부 상태의 크기는 한정되어 있다.
    • sorted, distinct는 값을 비교하기 위해 모든 요소가 버퍼에 추가되어 있어야 함.

숫자형 스트림


  • 스트림 API는 오토박싱 & 언박싱으로 인한 비용을 줄이기 위해 기본형 특화 스트림(primitive stream specialization)을 제공한다.

  • sum, max 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다.


기본형 특화 스트림

  • 기본형 특화 스트림으로 IntStream, DoubleStream, LongStream이 존재한다.
  • 각각의 인터페이스에는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max 같이 자주 사용하는 숫자 관련 리듀싱 연산 메서드를 제공한다.
int sum()
OptionalDouble average()
Optional*Int* max()
Optional*Int* min()
  • 해당 메서드들은 최종연산인 것을 잊지 말아야 한다

  • sum 메서드를 제외한 나머지 메서드들은 요소가 없을 때 0을 반환할 수 없으므로 이를 구분하기 위해 Optional 래퍼 클래스를 반환

  • Optional도 기본형에 대하여 지원한다. OptionalInt, OptionalDouble, optionalLong 세 가지 기본형 특화 스트림 버전의 Optional이 제공된다.


숫자 스트림으로 매핑

IntStream mapToInt(ToIntFunction<? super T> mapper);
LongStream mapToLong(ToLongFunction<? super T> mapper);
DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);

객체 스트림으로 복원하기

  • boxed메서드를 이용하면 특화 스트림을 일반 스트림으로 변환할 수 있다.
Stream<Integer> boxed(); // IntStream to Stream<Integer>
<U> Stream<U> mapToObj(IntFunction<? extends U> mapper); // IntStream to Stream<U>

스트림 만들기


이제 스트림으로 작업하기 위해 스트림을 생성하는 다양한 방법들을 알아보자

컬렉션

  • 컬렉션의 최고 조상인 Collection 인터페이스의 stream()
default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

값으로 스트림 만들기

  • 정적 메서드 Stream.of 을 이용하여 스트림을 만들 수 있다.
public static<T> Stream<T> of(T... values) {
        return Arrays.stream(values);
}
Stream<String> strStream = Stream.of("hello", "world";)

null이 될 수 있는 객체로 스트림 만들기

  • 자바 9부터 지원되며 Stream.ofNullable 메서드를 이용하여 null이 될 수 있는 객체를 지원하는 스트림을 만들 수 있다.
  • null이면 빈 스트림 반환
public static<T> Stream<T> ofNullable(T t) {
         return t == null ? Stream.empty()
                                            : StreamSupport.stream(new Streams.StreamBuilderImpl<>(t), false);
}

배열로 스트림 만들기

  • 배열을 인수로 받는 정적 메서드 Arrays.stream 을 이용하여 스트림을 만들 수 있다.
public static <T> Stream<T> stream(T[] array)
public static <T> Stream<T> stream(T[] array, int startInclusive, int endExclusive)
public static *Int*Stream stream(int[] array)
public static *Int*Stream stream(int[] array, int startInclusive, int endExclusive)

특정 범위의 정수 스트림 만들기

  • 특정 범위의 숫자를 이용해야 할 때 rangerangeClosed 메서드를 사용할 수 있다.
  • IntStream, LongStream 두 기본형 특화 스트림에서 지원된다.
  • range는 end가 범위에 포함되지 않으며, rangeClosed는 포함한다.
public static IntStream range(int startInclusive, int endExclusive)
public static IntStream rangeClosed(int startInclusive, int endInclusive)

파일로 스트림 만들기

  • 자바의 NIO API(논블록 I/O)도 스트림 api를 활용할 수 있다.
  • java.nio.file.Files의 많은 static 메서드가 스트림을 반환한다.
Stream<Path> Files.list(Path dir)
Stream<String> Files.lines(Path path)

함수로 무한(언바운드) 스트림 만들기

  • Stream.iterateStream.generate를 통해 함수를 이용하여 무한 스트림을 만들 수 있다.
  • iterate와 generate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다.
  • 따라서 무제한으로 값을 계산할 수 있지만, 보통 무한한 값을 출력하지 않도록 limit(n) 함수를 함께 연결해서 사용한다.
  • Stream.iterate
    • 초기값과 람다식 f에 의해 계산된 결과를 다시 seed 값으로 해서 계산을 반복한다.
    public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f)
    public static<T> Stream<T> iterate(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next)
    Stream<Integer> evenStream = Stream.iterate(0, n -> n + 2) // 0, 2, 4, 6, ...
    // 0 -> 0 + 2
    // 2 -> 2 + 2
    // 4 -> 4 + 2
    // ....
  • Stream.generate
    • Supplier를 인수로 받아서 새로운 값을 생산한다.
      public static<T> Stream<T> generate(Supplier<? extends T> s)
    Stream<Double> randomStream = Stream.generate(Math::random);
  • Random 클래스의 메서드들 이용
    public IntStream ints(long streamSize)
    public LongStream longs(long streamSize)
    public DoubleStream doubles(long streamSize)

    InStream intStream = new Random().ints(); // 무한 스트림 o
    InStream intStream = new Random().ints(5); // 무한 스트림 x

무한 스트림을 이용해서 피보나치 수열을 만들어보자

  • iterate()
    IntStream.iterate(new int[] {0, 1}, t -> new int[] {t[1], t[0] + t[1]})
          .limit(20)
                .map(t -> t[0])
          .forEach(System.out::println);
  • generate()
    IntSupplier fib = new IntSupplier() {
            private int prev = 0;
            private int cur = 1;
            @Override
            public int getAsInt() {
                int oldPrev = this.prev;
                int nextValue = this.prev + this.cur;
                this.prev = this.cur;
                this.cur = nextValue;
                return oldPrev;
            }
    };

    IntStream.generate(fib)
              .limit(20)
  • IntSupplier 인스턴스는 기존 피보나치 요소와 두 인스턴스 변수에 어떤 요소가 들어있는지 추적하므로 가변 상태 객체다.
  • iterate를 사용했을 때는 각 과정에서 새로운 값을 생성하면서 기존 상태를 바꾸지 않는 순수한 불변 상태를 유지했다.
  • 스트림을 병렬로 처리하면서 올바른 결과를 얻으려면 불변 상태 기법을 고수해야 한다.
728x90
728x90

스트림이 등장한 배경


  1. 거의 모든 자바 어플리케이션은 컬렉션을 이용하고 처리하기 위해서는 for문과 Iterator를 이용해서 코드를 작성해왔다.

    • 하지만 이러한 방식으로 작성된 코드는 길고 가독성이 좋지 않으며 재사용성 마저 떨어진다.
    • 데이터 소스마다 다른 방식으로 다뤄야한다. Collection이나 Iterator와 같은 인터페이스를 이용해서 표준화했지만, 각 컬렉션 클래스에는 같은 기능의 메서드들이 중복 정의 되어 있다.
      • ex) Collection.sort(), Arrays.sort()
  2. 많은 요소를 포함하는 컬렉션은 어떻게 처리해야 할까?

    • 멀티코어 아키텍처를 활용해서 병렬로 컬렉션의 요소를 처리해야 한다.
    • 하지만 병럴 처리 코드의 구현은 어렵고 복잡하며 디버깅도 어렵다.

스트림

컬렉션으로도 sql 질의처럼 고수준으로 추상화해서 처리할 수 있는 기능과 병렬 처리 기능을 만들 수 없을까??

List<Stirng> names = player.stream() 
        .filter(player -> player.getAge > 30) // 30살 이상 player 선택
        .map(Player::getName) // 요리명 추출
        .limit(3) // 선착순 3개만 추출
        .collect(toList()); // 스트림을 리스트로 변환

스트림이란 무엇인가?


스트림(Stream)은 자바 8 API에 새로 추가된 기능이다.

  • 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.
  • 여러 연산을 파이프라인으로 연결해 유연성이 좋다.
  • 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다.

스트림의 정의

스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소 로 정의할 수 있다

연속된 요소 : 컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다.

  • 컬렉션 : 자료구조, 요소의 저장 및 접근 연산 위주
  • 스트림 : 표현 계산식 위주

소스: 스트림은 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다.

  • ex) 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지된다.

데이터 처리 연산: 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원하며, 순차적으로 또는 병렬로 실행할 수 있다.

  • ex) filter, map, reduce, find, match, sort 등

스트림의 특징


스트림은 다음과 같은 주요 특성이 있다.

파이프라이닝

  • 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다.
  • 그 덕에 게으름(lazyness), 쇼트서킷(short-circuiting) 같은 최적화도 얻을 수 있다.

내부 반복

  • 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

스트림과 컬렉션


자바의 기존 컬렉션과 자바8에서 추가된 스트림 모두 연속된(순차적, sequenced) 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다.


그렇다면 컬렉션과 스트림의 차이는 무엇일까??

가장 큰 차이는 데이터를 계산하는 시점이다

  • 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조이다.
    • 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.
    • 예를 들어 컬렉션에 요소를 추가하거나 삭제하는 연산을 수행하기 전에 컬렉션의 모든 요소들은 메모리에 미리 저장되어 있어야 하며 계산되어야 한다는 뜻
  • 스트림은 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조이다.
    • 즉 사용자가 요청하는 값만 스트림에서 추출한다.
    • 스트림에 요소를 추가하거나 제거할 수 없다.
    • 이러한 특징으로 인해 생산자-소비자 관계를 형성하며 게으르게 만들이지는 컬렉션과 같다.
  • 책에서 예시를 든 것 처럼 다운로드 방식(컬렉션)과 스트리밍 방식(스트림)을 생각하면 차이를 이해하기 쉽다.

스트림은 일회용이다

  • 스트림은 Iterator와 마찬가지로 일회용이다.
  • 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
    • 마찬가지로 반복 사용할 수 있는 데이터 소스여야 한다는 뜻

외부 반복과 내부 반복

  • 컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다.
    • for-each나 Iterator를 이용해서 반복할 수 있으며 이를 외부 반복(external iteration)이라고 한다.
  • 스트림 라이브러리는 반복을 알아서 처리하고 결과 스트림 값을 저장해주는 내부 반복(internal iteration)을 사용한다.
    List<String> names = menu.stream()
                     .map(Dish::getName) // 요리명 추출
                     .collect(toList()); // 파이프라인 실행, 반복자 x


이미지 출처

  • 스트림의 내부 반복의 장점은 자동으로 병렬성 구현을 선택하기 때문에 병렬성을 쉽게 얻을 수 있다는 점이며, 내부적으로 더 최적화된 다양한 순서로 처리할 수 있는 점이다.

스트림 연산


스트림 인터페이스의 연산을 크게 두 가지로 구분할 수 있다.

중간 연산과 최종 연산

  • 중간 연산(intermediate operation): 연결할 수 있는 스트림 연산
  • 최종 연산(terminal operation): 스트림을 닫는 연산
    List<Stirng> names = menu.stream() // 요리 리스트에서 스트림 얻기
            .filter(dish -> dish.getCalories > 300) // 중간 연산 시작
            .map(Dish::getname)
            .limit(3) // 중간 연산 끝
            .collect(toList()); // 스트림을 리스트로 변환, 종단 연산

중간 연산

  • 중간 연산다른 스트림을 반환한다.
    • 따라서 여러 중간 연산을 연결해 질의를 만들 수 있다.
    • 연산 결과는 스트림이지만, 연산 전의 스트림과 같은 것은 아니다.
  • 중간 연산은 최종 연산을 스트림 파이프라인에 실행하기 전까지는 아무도 연산을 수행하지 않는다는 것이다.
    • 게으르다(lazy)는 것이다.
    • 중간 연산을 합친 다음에 합쳐진 중간 연산을 최종 연산으로 한 번에 처리하기 때문이다.
  • lazy 특성에 의한 최적화 효과
    List<Stirng> names = menu.stream() // 스트림 open
            .filter(dish -> {
                Syste.out.println("filtering:" + dish.getName());
                return dish.getCalories > 300;
            })
            .map(dish -> {
                Syste.out.println("mapping:" + dish.getName());
                return dish.getName();
            })
            .limit(3)
            .collect(toList());
    filtering:pork
    mapping:pork
    filtering:beef
    mapping:beef
    filtering:chicken
    mapping:chicken
    [pork, beef, chicken]
  • 첫 번째는 쇼트 서킷이다.
    • 300칼로리가 넘는 요리는 여러개였다고 가정해보자
    • 그럼에도 오직 처음 3개만 선택되었다.
    • limit 연산 쇼트서킷이라 불리는 기법 덕분이다.
  • 두 번째는 루프 퓨전이다.
    • filter와 map은 서로 다른 연산이지만 한 과정으로 병합되었다.

최종 연산

  • 최종 연산은 스트림 파이프라인에서 결과를 도출한다.
    • 보통 List, Integer, void 등 스트림 이외의 결과가 반환된다.
    • reduce(), collect(), count(), forEach()
  • 스트림의 요소를 소모하므로 단 한번만 가능하다.

마무리


스트림의 이용과정은 세 가지로 요약 가능하다

  1. 질의를 수행할 데이터 소스
  2. 스트림 파이프라인을 구성할 중간 연산 연결
  3. 스트림 파이프라인을 실행하고 결과를 만들 최종 연산

스트림의 파이프라인의 개념은 빌더 패턴과 비슷하다.

  • 호출을 연결해서 설정을 만든다(스트림의 중간 연산 연결)
  • 준비된 설정에 build 메서드 호출(스트림의 최종 연산)
728x90

+ Recent posts