반응형

Chapter07 병렬 데이터 처리와 성능

자바의 병렬

자바 7 이전의 버전에서는 병렬의 처리가 어려웠다.
분할을 위한 스레드 할당 → 동기화 추가 → 결과 합치기의 과정을 통해 병렬 처리가 실행되는데, 각 병렬처리를 한 후에 스레드를 합칠 때 동기화를 적절히 이뤄줘야 교착 상태를 피할 수 있었다.
7버전 부터 지원하기 시작한 Fork/Join 프레임워크를 활용하면 해당 문제를 쉽고 효율적으로 해결할 수 있다. 이 Fork/Join 프레임워크를 활용한 스트림 API와 병렬 처리에 대해서 배워보자.


병렬 스트림

  • 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림
  • 멀티코어 프로세서가 각각의 청크를 처리하도록 한다
  • Collections.parallelStream() or Arrays.stream().parallel()
    • Pipeline에있는 parallel 상태에 true가 저장된다.
    • 이후 파이프라인 처리할 때 처리할 때 parallel 체크를 통해 해결한다.
  • 병렬 처리를 고려할 때는 성능 벤치마킹을 하는 것을 권장
  • 성능 최적화
    • 기본형(primitive) 타입의 경우 기본형 특화 스트림 권장 - 오토박싱의 이유
    • 일반적으로 순차적인 처리가 필요한 스트림의 경우 병렬 비용이 더욱 비싸 느릴 가능성이 높음
    • 순차 처리가 필요없는 경우 findAny같은 순서에 상관 없는 쇼트 서킷, unordered 같은 메서드 사용
    • 하나의 요소를 처리하는 데 드는 비용이 비싸다면 고려
    • 스트림 자료구조의 특성을 확인 ex) LinkedList vs ArrayList 일반적으로 좋음
    • 병합 과정의 비용을 생각해야 한다
    | 소스 | 분해성 |
    | --- | --- |
    | ArrayList | Excellent |
    | LinkedList | Bad |
    | IntStream.range | Excellent |
    | Stream.iterate | Bad |
    | HashSet | Good |
    | TreeSet | Good |
    | Stream.generate | iterate보단 낫다 |

parallel처리 관련 이미지

Untitled

Untitled

Untitled

포크/조인 프레임워크

  • 병렬 작업할 때 사용

  • 재귀적으로 큰 작업을 작은 작업으로 분할한 후 서브태스크의 결과를 합쳐서 전체 결과를 만듦

  • 스레드 풀(ForkJoinPool)의 작업자 스레드에 분산 할당하는 ExecutorService

    • ExecutorService를 왜 언급하는가?

      RecursiveTask를 실제로 활용했을 때 해당 추상 클래스의 부모인 ForkJoinTask에서 ForkJoinPool을 활용해서 실제 활용하고있기 때문

      RecursiveTask → ForkJoinTask에서 사용하는 ForkJoinPoolExcutorService

      Untitled

      Untitled

  • 분할 정복(divide-and-conquer) 알고리즘의 병렬화

  • compute() 메서드 오버라이딩 해서 구현

  • 병렬처리 시 주의점

    • join 호출은 두 서브태스크가 모두 시작된 다음에 해야한다.
    • ForkJoinPool의 invoke메서드는 병렬 계산 시작하는 시점에서 한번만 사용
    • 한쪽은 fork() 한쪽은 compute() ⇒ 같은 스레드 재사용 피하기 위함
    • 디버깅이 어렵다 ⇒ 스택이 아닌 다른 스레드 이기 때문
    • 병렬 처리가 무조건 빠르지 않다.
  • 작업 훔치기(work stealing) 특성을 갖고있음

  • ForkJoin을 활용한 RecursiveTask 예시

import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;

public class ForkJoinSumCalculator extends RecursiveTask<Long> {

    //THRESHOLD 이상의 값 까지만 분해
  public static final long THRESHOLD = 10_000;

  private final long[] numbers;
  private final int start;
  private final int end;

  public ForkJoinSumCalculator(long[] numbers) {
    this(numbers, 0, numbers.length);
  }

  private ForkJoinSumCalculator(long[] numbers, int start, int end) {
    this.numbers = numbers;
    this.start = start;
    this.end = end;
  }

  @Override
  protected Long compute() {
    int length = end - start; //해당 태스크에서 더할 배열의 길이
    if (length <= THRESHOLD) {
      return computeSequentially();
    }
        //각 태스크 분리 left, right
    ForkJoinSumCalculator leftTask =
        new ForkJoinSumCalculator(numbers, start, start + length / 2);
        //생성한 태스크 비동기 실행
    leftTask.fork();
    ForkJoinSumCalculator rightTask =
        new ForkJoinSumCalculator(numbers, start + length / 2, end);
        //두번째 태스크 동기 실행
    Long rightResult = rightTask.compute();
        //비동기 실행했던 left의 결과를 읽거나 처리완료 후 읽기까지 대기
    Long leftResult = leftTask.join();
    return leftResult + rightResult;
  }

    //가장 작은 단위일 때 작은 단위로 쪼갠 태스크의 결과를 계산
  private long computeSequentially() {
    long sum = 0;
    for (int i = start; i < end; i++) {
      sum += numbers[i];
    }
    return sum;
  }

    public static long forkJoinSum(long n) {
    long[] numbers = LongStream.rangeClosed(1, n).toArray();
    ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
    return FORK_JOIN_POOL.invoke(task);
  }
}

//호출 방법
ForkJoinSumCalculator.forkJoinSum(long n));

Spliterator 인터페이스

  • 구성 요소
    • booleaen tryAdvance(Consumer<? super T> action)
      • 요소를 하나씩 순차적으로 돌며 요소가 남아있는지 확인
    • Spliterator trySplit()
      • 일부 요소를 분할해서 새로운 Spliterator 생성
    • long estimateSize()
      • 탐색해야 할 요소 수 정보 제공(RecursiveTask의 THRESHOLD 역할로 보임)
    • int characteristics()
      • 다양한 특성 집합을 포함(ORDERED, DISTINCT 그 외 다수)

7장을 마치며

  • 내부 반복을 통해 명시적으로 스트림 병렬처리
  • 병렬처리가 무조건 빠르지 않음 특성과 병렬처리 후의 결과 등을 잘 확인하여 고려할 것
  • 병렬 처리에는 포크/조인 프레임워크를 활용한다
  • Spliterator를 통해 병렬 처리를 직접 정의할 수 있다.
반응형
반응형

Chapter03 람다 표현식

목차

  • 람다란 무엇인가?
  • 어디에, 어떻게 람다를 사용하는가?
  • 실행 어라운드 패턴
  • 함수형 인터페이스, 형식 추론
  • 메서드 참조
  • 람다 만들기

람다란 무엇인가?

람다의 특징

  • 익명
    • 보통의 메서드와 달리 이름이 없으므로 익명 이라 표현한다.
  • 함수
    • 특정 클래스에 종속되지 않기 때문에 함수라고 부른다. But 메서드의 특성처럼 파라미터 리스트, 바디, 반환 형식, 예외 리스트 등을 포함한다.
  • 전달
    • 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성
    • 익명 클래스처럼 불필요한 코드를 생성할 필요가 없다.
<익명 클래스 활용>
Comparator<Apple> byWeight = new Comparator<Apple>(){
        public int compare(Apple a1, Apple a2) {
                return a1.getWeight().compareTo(a2.getWeight());
        }
}

->

<람다 활용>
Comparator<Apple> byWeight =
        (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

  • 파라미터 리스트
    • ()안의 파라미터 개수
  • 화살표
    • 람다의 파라미터와 바디의 구분자
  • 람다 바디
    • 두 사과의 무게를 비교

예제를 통한 람다 표현식

1. (String s) → s.length() 

2. (Apple a) → a.getWeight() > 150

3. (int x, int y) → {
        System.out.println("Result:");
        System.out.println(x + y);
}

4. () -> 42

5. (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

어디에, 어떻게 람다를 사용할까?

함수형 인터페이스

메서드를 파라미터화 할 수 있는 인터페이스

함수형 인터페이스의 특징으로는 추상 메서드를 하나만 선언하는 것이다.

함수형 인터페이스 활용

2장에서 구현했던 필터 메서드

public static List<Apple> filter(List<Apple> inventory, ApplePredicate p) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (p.test(apple)) {
            result.add(apple);
        }
    }
    return result;
}

람다를 통한 filter사용

List<Apple> greenApples
        = filter(inventory, (Apple a) -> GREEN.equals(a.getColor()));

Predicate의 자리에 람다가 들어간다.

그래서 함수형 인터페이스 이야기가 왜 나오는데?

람다가 인터페이스의 추상 메서드를 구현할 수 있는 추상 메서드의 구현체(디스크립터)가 될 수 있다.

함수 디스크립터

뜻 그대로 함수를 설명하는 것이라 생각하면 된다.

함수 Runnable의 run이 있을 때

파라미터 : null 반환 : void 으로 했을 때 해당 형태만 일치한다면, 해당 함수의 내부를 어떻게 사용할지 람다로 설명을 하는 것이다.

이 때 파라미터와 반환이 같은 것을 같은 시그니처를 갖는 함수라 볼 수 있는데 시그니처만 같게 한다면 람다를 생성할 수 있다는 뜻이다.

일치하는 시그니처

public Callable<String> fetch() {
        return () -> "Tricky example ;-)";
}

파라미터 : x

반환 : String

일치하지 않는 시그니처

Predicate<Apple> p = (Apple a) -> a.getWeight();

파라미터 : Apple

반환 : boolean

@FunctionalInterface

함수형 인터페이스를 구현할 때 해당 어노테이션을 인터페이스 위에 붙였을 때 함수형 인터페이스가 불가하면 컴파일 에러를 발생시킨다.

ex) 추상메서드 2개

람다 활용 : 실행 어라운드 패턴

public static String processFile(BufferedReaderProcessor p) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(FILE))) {
        return p.process(br);
    }
}

함수형 인터페이스 사용

함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다.

람다 표현식을 사용하려면 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다.

이를 위해 자바에서는 java.util.function 패키지로 여러 함수형 인터페이스를 제공한다.

  • Predicate T → boolean
  • Consumer → void
  • Function<T, R> T → R
  • Suplier → T
  • UnaryOperator → T

기본형 특화

기본형의 경우 함수형 인터페이스 특성 상 제네릭을 받아서 디스크립터가 구성되는데, 이를 행하기 위해 박싱, 언박싱 과정이 불필요하게 들어가게된다.

때문에 이러한 불필요한 연산을 피할 수 있도록 돕는 IntPredicate, IntFunction 등 각 인터페이스에 맞게 지원한다.

형식 검사, 형식 추론, 제약

형식 검사

List<Apple> heavierThan150g =
filter(inventory, (Apple, apple) -> apple.getWeight() > 150);

filter(List<Apple> list, Predicate<Apple> p){
        ...        
        p.test();
        ...
}

interface Predicate<T>{
        boolean test(T t);
}

람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다.

  1. 람다가 사용된 콘텍스트가 무엇인가?
  2. 대상 형식은 Predicate
  3. Predicate 인터페이스의 추상 메서드는 무엇인가?
  4. Apple을 파라미터로 받아 boolean을 반환한다.
  5. 함수 디스크립터는 Apple → boolean이기 때문에 형식 검사가 성공적으로 완료된다.

특별한 void 호환 규칙

람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다

void를 반환하는 함수 디스크립터는 다른 값이 반환되어도 사용이 가능함

형식 추론

Comparator<Apple> c = 
        (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

->

Comparator<Apple> c = 
        (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

컴파일러가 람다의 표현식과 관련된 함수형 인터페이스를 추론한다.

지역 변수 사용

람다는 자기 바깥에 있는 접근 가능한 변수에 대해서도 사용이 가능하다

이와 같은 동작을 람다 캡처링 이라고 부른다.

쓰면 안되는 예시

<컴파일 에러>
int n = 0;
Runnable r = () -> System.out.println(n);
n++;

->

int n = 0;
do {
    int finalN = n;
    n++;
    Runnable r = () -> System.out.println(finalN);
    r.run();
} while (n != 5);

메서드 참조

기존에 있던 메서드 정의를 재활용해서 람다처럼 코드뭉치를 전달할 수 있다.

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));

->

inventory.sort(comparing(Apple::getWeight));

그래서 왜 사용하는데?

기존 메서드 구현을 명시적으로 참조함으로써 가독성을 높일 수 있고, 재사용도 가능하기에 효율이 좋다.

사용가능한 메서드 참조 형식

  • 정적 메서드 참조
    • static 유틸메서드 등
    • List::of
    • 정적 팩토리 메서드
  • 다양한 형식의 인스턴스 메서드 참조
    • String::length
  • 기존 객체의 인스턴스 메서드 참조
    • this::getWeight

기존의 표현식을 메서드 참조로 바꾸는 예시

람다 : (args) -> ClassName.staticMethod(args)

메서드 참조 : ClassName::staticMethod

생성자 참조

Apple apple1 = new Apple();

Apple apple1 = Apple::new;

위의 메서드 참조와 형식은 같다.

람다 표현식을 조합할 수 있는 유용한 메서드

몇몇 함수형 인터페이스에서 지원하는 유용한 유틸리티 메서드 소개

Comparator

  • comparing() → Comparator.comparing()
  • comparing().reversed() → 역정렬
  • thanComparing() → 두번째 비교자를 통한 추가비교

Predicate

  • negate() → 결과반전
  • and() → 비교연산 추가
  • or() → 비교연산 추가

Function

  • andThen() → 함수를 입력 후 다른 함수의 입력으로 전달
  • compose() → andThen()의 반대순서로 함수의 입력전달
반응형
반응형

2장 동작 파라미터화 코드 전달하기

동작 파라미터는

  • 리스트의 모든 요소에 대해서 ‘어떤 동작’을 수행할 수 있다.
  • 리스트 관련 작업을 끝낸 다음에 ‘어떤 다른 동작’을 수행할 수 있다.
  • 에러가 발생하면 ‘정해진 어떤 다른 동작’을 수행할 수 있다.

간단하게 표현하면 메서드의 인수로 원하는 동작을 줄 수 있는 것이다.

변화하는 요구사항에 대응하기

파라미터를 추가해가며 요구사항에 대응하기

요구 시나리오#1 - 사과목록에서 녹색 사과만 필터

enum Color { RED, GREEN }

public static List<Apple> filterGreenApples(List<Apple> inventory){
		List<Apple> result = new ArrayList<>();
		for(Apple apple : inventory){
				if(GREEN.equals(apple.getColor()){
						result.add(apple);
				}
		}
		return result;
}

요구 시나리오#2 - 사과 목록에서 빨간사과도 필터링

기존에 있던 filterGreenApple()로직과 동일한 filterRedApple() 메서드 추가 후 RED.equals만 변경

filterRedApple(){
		...
		if(RED.equals(apple.getColor())
		...
}

요구 시나리오#3 - 색을 파라미터화

public static List<Apple> filterGreenApples(List<Apple> inventory, Color color){
		...
		if(apple.getColor().equals(color))
		...

요구 시나리오#4 - 과일의 무게 기준으로 구분

public static List<Apple> filterApplesByWeight(List<Apple> inventory, Color weight){
{
		...
		if(apple.getWeight() > weight) {
		...

이렇게 4개의 요구사항 시나리오를 통해 만들어진 각 메서드들은 코드가 계속 중복되고있다.

<aside> 💡 소프트웨어 공학의DRY(Don’t Repeat Yourself)원칙을 어기는 것이다.

</aside>

원칙을 지키기 위해 이 코드를 성능을 개선하기 위해서는 코드를 구현한 메서드 전체 구현을 고쳐야 한다. 이는 엔지니어링적으로 생각했을 때 상당히 비효율적인 방법이다.

반복을 제거하기 위한 방법

public static List<Apple> filterApples(List<Apple> inventory, Color color, int weight, boolean flag){
		List<Apple> result = new ArrayList<>();
		for(Apple apple: inventory){
				if((flag && apple.getColor().equals(color)) ||
				(!flag && apple.getWeigth() > weight)){
						result.add(apple);
				}
		}
		return result;
}

List<Apple> greenApples = filterApples(inventory, GREEN, 0, true);

List<Apple> heavyApples = filterApples(inventory, null, 150, flase);

로 두 경우 모두 체크가 가능하다.

이 코드는 flag의 이미가 무엇인지 명확히 이해하기도 힘들고, 요구사항이 세부적으로 더 변경되거나 한다면 코드를 수정하기가 곤란해질 것이다.

2.2 동작 파라미터화

이번엔 사과의 필터를 참 또는 거짓을 반환하는 함수인 프레디케이트를 넣어보자

interface ApplePredicate {

    boolean test(Apple a);

 }

public class AppleHeavyWeightPredcate implements ApplePredicate{
		public boolean test(Apple apple) {
				return apple.getWeight() > 150;
		}
}

public class AppleGreenColorPredicate implements ApplePredicate{
		public boolean test(Apple apple) {
				return GREEN.equals(apple.getColor());
		}
}

이렇게 전략적으로 filter를 동작 파라미터로 넣는형식을 전략 디자인 패턴(strategy design pattern)이라고 하고, 주요 체크로직을 캡슐화하여 숨겨둘 수 있고, 내부적으로만 주요로직을 수행하기 때문에 로직 변경이 필요할 때의 범위 설정이 용이하다.

이제 해당 전략을 받는 메서드를 재구현하면

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p){

		List<Apple> result = new ArrayList<>();
		for(Apple apple : inventory){
				if(p.test(apple)){
						result.add(apple);
				}
		}
		return result;
}

의 코드가 되고 유연성이 늘어난다.

동작 코드를 메서드에 넣음으로써

for문에 의한 탐색 로직이 여러개 있을 필요가 없어졌고, 필요하다면 주요 로직만 ApplePredicate의 구현체로 구현하고, 활용하면 될 것이다.

public class AppleRedAndHeavyPredicate implements ApplePredicate{
		public boolean test(Apple apple){
				return "red".equals(apple.getColor())
						&& apple.getWeight() > 150;
		}
}

현재 코드로 봤을 때 매 객체는 p.test(apple)로 test안에 Apple객체를 전달 받아서 구현체를 전달받은 AppleRedAndHeavyPredicate의 구현체인 곳에서 Apple객체를 활용하고 있다.

return "red".equals(apple.getColor())
						&& apple.getWeight() > 150;
<주요 비교로직>
return apple.getWeight() > 150;
return "green".equals(apple.getColor());
<리스트를 도는 로직>
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p){
		List<Apple> result = new ArrayList<>();
		for(Apple apple: inventory){
				if(p.test(apple)){
						result.add(apple);
				}
		}
		return result;
}

주요 비교로직이 동작 파라미터화(코드뭉치) 되어

동작 파라미터를 filterApples(ApplePredicate)에 넘겨줘서 해당 판단 비교로직을 if문에 넣는다.

로 정리가 된다.

퀴즈

static class AppleToStringPredicate implements  ApplePredicate{
    
    @Override
    public String toString(Apple apple) {
      return apple.getWeight();
    }
    
  }

  public static void prettyPrintApple(List<Apple> inventory, ApplePredicate p){
    for(Apple apple : inventory){
      String output = p.toString(apple);
      System.out.println(output);
    }
  }

2.3 복잡한 과정 간소화

이전의 과정에서 코드뭉치를 담는 일을 수행하려면 interface를 선언하고 해당 인터페이스를 구현하는 구현체, 구현체의 내부 로직 구현 등 해야 할 일이 많다.

익명클래스

익명클래스를 사용하면 블록 내부에 일회용 클래스를 선언할 수가 있는데, 선언과 동시에 인스턴스화가 된다. 즉 즉석에서 필요할 때 해당 클래스로 구현체를 만들어서 사용할 수도 있다.

List<Apple> redApples = filterApples(inventory, new ApplePredicate(){
		public boolean test(Apple apple)(
				return RED.equals(apple.getColor());
		}
});

하지만 결국 이렇게 클래스를 구현한다면 코드가 많이 복잡해지게 되고, 다른 개발자들에게는 좋지 않은 읽고싶지않은 코드가 될 것이다.

익명 클래스도 나쁜 방법은 아니지만 이를 더 간결하게 활용할 수 있는 람다가 나오고 람다를 주로 활용하게 된 것이다.

람다

람다 표현식을 사용하게 되면 아까 만났던 코드가 아래와 같이 변한다

List<Apple> result = filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));

상당히 간결해지면서 가독성 또한 좋아졌다.

리스트 형식으로 추상화

public interface Predicate<T> {
		boolean test(T t);
}

public static <T> List<T> filter(List<T> list, Predicate<T> p) {
		List<T> result = new ArrayList<>();
		for(T e: list){
				if(p.test(e)){
						result.add(e);
				}
			}
		return result;
}
List<Apple> redApples = filter(inventory, (Apple apple) → RED.equals(apple.getColor()));
List<Integer> evenNumbers = filter(numbers, (Integer i) → i % 2 == 0);

형식으로 불러오는게 가능하다.

이렇게 직접 함수형 인터페이스를 선언 후 동작파라미터를 T타입으로 넘겨준다면 깔끔한 추상화가 될 것이다.

2장을 마치며

<aside> 💡 - 동작 파라미터화에서는 메서드 내부적으로 다양한 동작을 수행할 수 있도록 코드를 메서드 인수로 전달한다.

  • 동작 파라미터화를 이용하면 변화하는 요구사항에 더 잘 대응할 수 있는 코드를 구현할 수 있으며 나중에 엔지니어링 비용을 줄일 수 있다.
  • 코드 전달 기법을 이용하면 동작을 메서드의 인수로 전달할 수 있다.

</aside>


  • 가장 대표적인 함수형 인터페이스 4가지

<aside> 💡 자바에서 지원하는 함수형 인터페이스로는 대표적으로 크게 네가지가 있다.

  • Supplier<T> - 공급자 : 매개변수는 없고 반환 값만 있다
    • get()
  • Consumer<T> - 사용자 : 매개변수는 있고, 반환 값이 없다
    • accept()
  • Function<T, R> - 함수 : 일반적인 함수형태, 하나의 매개변수를 받아 결과를 반환
    • apply()
  • Predicate<T> - 매개변수 하나를 받아서 boolean타입으로 반환한다
    • test()

T는 제네릭 타입을 뜻하고, R은 리턴 타입을 뜻한다.

추가로 Bi가 붙은 형태가 있는데 다른 방법은 똑같고 매개변수가 두개 들어간다는 의미이다.

</aside>

반응형

+ Recent posts