[도서] 자바 8 인 액션 (1~7장)

dbswlekq·2023년 3월 20일

프레디케이트

수학에서는 인수로 값을 받아 true나 false를 반환하는 함수를 predicate라고 한다. boolean으로 반환.

2. 동적 파라미터화 코드 전달하기

동적 파라미터화 : 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록.

동작 파라미터화를 이용하면 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다. 코드 블록은 나중에 프로그램에서 호출된다. (실행이 나중으로 미뤄짐) 예를 들어 나중에 실행될 메서드의 인수로 코드 블록을 전달할 수 있다. 결과적으로는 메서드의 동작이 파라미터화 되는 것이다.

동작파라미터화를 추가하려면 쓸데없는 코드가 늘어난다. 자바 8은 람다 표현식으로 이 문제를 해결한다.

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

2.1.1 녹색 사과 필터링

// 초기 데이터
List<Apple> inventory = Arrays.asList(new Apple(80,"green"), new Apple(155, "green"), new Apple(120, "red"));

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;
	}
// [Apple{color='green', weight=80}, Apple{color='green', weight=155}]

여기서 만약 다른 색으로 필터링 하고 싶다면?

2.1.2 색을 파라미터화

public static List<Apple> filterApplesByColor(List<Apple> inventory, String color){
		List<Apple> result = new ArrayList<>();
		for(Apple apple: inventory){
			if(apple.getColor().equals(color)){
				result.add(apple);
			}
		}
		return result;
	}
// red로 파라미터 전달
// [Apple{color='red', weight=120}]

색 이외에도 무게로 구분할 수 있으면 좋겠다.

public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight){
		List<Apple> result = new ArrayList<>();
		for(Apple apple: inventory){
			if(apple.getWeight() > weight){
				result.add(apple);
			}
		}
		return result;
	}
// 100으로 파라미터 전달
// [Apple{color='green', weight=155}, Apple{color='red', weight=120}]

2.2 동적 파라미터화

// 선택 조건을 결정하는 인터페이스
interface ApplePredicate {
        public boolean test(Apple a);
    }

// 무거운 사과만 선택
static class AppleWeightPredicate implements ApplePredicate {
        public boolean test(Apple apple) {
            return apple.getWeight() > 150;
        }
    }

// 녹색 사과만 선택
static class AppleColorPredicate implements ApplePredicate {
        public boolean test(Apple apple) {
            return "green".equals(apple.getColor());
        }
    }

이렇게 메서드가 다양한 동작을 받아서 내부적으로 다양한 동작을 수행할 수 있다.

2.2.1 추상적 조건으로 필터링

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;
    }
static class AppleRedAndHeavyPredicate implements ApplePredicate {
        public boolean test(Apple apple) {
            return "red".equals(apple.getColor())
                    && apple.getWeight() > 150;
        }
    }

List<Apple> list = filterAapples(inventory, new AppleRedHeavyPredicate());

filterApples 메서드 동작을 파라미터화 한 것. = ‘코드를 전달'할 수 있는 것이나 다름없다.

3. 람다 표현식

3.1 람다란 무엇인가

람다 표현식 : 메서드로 전달할 수 있는 익명 함수를 단순화한 것. 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

  • 익명 : 보통의 메서드와 달리 이름이 없으니 익명이라 표현한다.
  • 함수 : 특정 클래스에 종속되지 않으므로 함수라고 부른다.
  • 전달 : 메서드 인수로 전달하거나 변수로 저장 가능하다.
  • 간결성 : 익명 클래스처럼 자질구레한 코드를 구현할 필요가 없다.

람다의 세 부분

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 파라미터 리스트 : 두 개의 사과
  • 화살표 : 람다의 파라미터 리스트와 바디를 구분
  • 람다의 바디 : 두 사과의 무게를 비교. 반환값에 해당하는 표현식.

유효한 람다 표현식

(String s) -> s.length() 
// String 형식의 파라미터를 가지며, int를 반환 

(Apple a) -> a.getLength() > 150 
// Apple 형식의 파라미터를 가지며, boolean을 반환 

(int x, int y) -> { 
System.out.println("Result :"); 
System.out.println(x + y); } 
// int 형식의 파라미터 2개를 가지며, 리턴값이 없음 

() -> 42 
// 파라미터가 없으며, int 42를 반환 

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())
// Apple 형식의 파라미터 2개를 가지며, int(두 사과의 무게 비교 결과)를 반환

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

List<Apple> list = filter(inventory, (Apple a) -> "green".equals(a.getColor()));

람다는 정확히 말하자면 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다. 위 예제에서는 함수형 인터페이스 Predicate<T>를 기대하는 filter 메서드의 두 번째 인수로 람다 표현식을 전달했다.

3.2.1 함수형 인터페이스

정확히 하나의 추상 메서드를 지정하는 인터페이스.

많은 디폴트 메서드가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스다.

추상 메서드가 하나라는 뜻은 default method 또는 static method 는 여러 개 존재해도 상관 없다는 뜻.

그리고 @FunctionalInterface 어노테이션을 사용하는데, 이 어노테이션은 해당 인터페이스가 함수형 인터페이스 조건에 맞는지 검사해준다. 이 어노테이션이 없어도 함수형 인터페이스로 동작하고 사용하는 데 문제는 없지만, 인터페이스 검증과 유지보수를 위해 붙여주는 게 좋다.

3.2.2 함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킨다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터 라고 부른다.

예를 들어 Runnable 인터페이스의 유일한 추상 메서드 run은 인수와 반환값이 없으므로(void) Runnable 인터페이스는 인수와 반환 값이 없는 시그니처로 생각할 수 있다.() -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다.

3.4 함수형 인터페이스 사용

Predicate

Predicate : test라는 추상 메서드를 정의하며 test는 제네릭 형식 T의 객체를 인수로 받아 불린을 반환함.

Consumer

Consumer : 제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의함.

Function

Function<T, R> : 제네릭 형식 T를 인수로 받아서 제네릭 형식 R 객체를 반환하는 apply라는 추상 메서드를 정의함. 입력을 출력으로 매핑하는 람다를 정의할 때 활용할 수 있음.

Runnable

인자를 받지 않고 리턴값도 없는 인터페이스.

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

3.5.3 형식 추론

코드를 조금 더 단순화 시킬 수 있다. 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용하여 람다 표현식과 관련된 함수형 인터페이스를 추론하다. 즉, 대상 형식을 이용하여 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론 가능하다. 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있기 때문에 람다 문법에서 이를 생략할 수 있다.

// 형식을 추론하지 않음
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

// 형식을 추론함
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

그런데, 붙이는게 더 가독성을 위해 좋은 것이 아닌가?

상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고, 형식을 배제하는 것이 가독성을 향상시킬 때도 있다고 한다.

3.6 메서드 레퍼런스

3.6.1 요약

메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드 레퍼런스를 활용 가능.

ex) Apple::getWeight = Apple 클래스에 정의된 getWeight의 메서드 레퍼런스

결과적으로 메서드 레퍼런스는 람다 표현식 (Apple a) → a.getWeigth()를 축약한 것.

str.sort((s1, s2) -> s1.compareToIgnoreCase(s2)); // 둘이 동일
str.sort(String::conpareToIgnoreCase);

메서드 레퍼런스

(arg0, rest) -> arg0.instanceMethod(rest) // 람다식. arg0은 ClassName 형식
ClassName::instanceMethod // 메서드 참조

(String s) -> s.length() // 람다식
String::length // 메서드 참조

(String s1, String s2) -> s1.split(s2) // 람다식
String::split // 메서드 참조

(String s1, String s2) -> s2.split(s1) // s1과 s2의 위치를 바꾼 경우 메서드 참조 불가능

(args) -> expr.instanceMethod(args) // 람다식
expr::instanceMethod // 메서드 참조

Transaction expensiveTransaction = new Transaction(1);

() -> expensiveTransaction.getValue() // 람다식
expensiveTransaction::getValue // 메서드 참조

(int value) -> expensiveTransaction.setValue(value) // 람다식
expensiveTransaction::setValue // 메서드 참조

3.6.2 생성자 레퍼런스

() -> Apple

Supplier<Apple> c1 = Apple::new; // 둘이 동일
Supplier<Apple> c1 = () -> new Apple;

3.7.4 메서드 레퍼런스 사용

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

‘Apple을 weight별로 비교해서 inventory를 sort하라’

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

Comparator 조합

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);

inventory.sort(comparing(Apple::getWeight)
  				.reversed() // 역정렬
			  	.thenComparing(Apple::getCountry)); // 같다면 메서드로 두 번째 비교자(나라)

Predicate 조합

// 빨간 색이 아닌 사과 (negate)
Predicate<Apple> notRedApple = redApple.negate();

// 빨간색이면서 무거운 사과 (and)
Predicate<Apple> RedHeavyApple = redApple.and(apple -> apple.getWeight > 150);

// 빨간색이면서 무거운 사과 또는 초록색 사과 (or)
Predicate<Apple> RedHeavyOrGreenApple = 
								redApple.and(apple -> apple.getWeight > 150)
          						.or(apple -> GREEN.equals(a.getColor()));

4. 스트림

4.1 스트림이란 무엇인가

자바8 이전

List <Dish> lowCalDishes = new ArrayList<>(); // 가비지 변수 사용

for(Dish dish : menu)[
    if(dish.getCAlories() < 400){
        lowCalDishes.add(dish);
    }
}

Collection.sort(lowCalDishes, new Comparator<Dish>() {
    public int compare(Dish d1, Dish d2){
        return Integer.compare(d1.getCalories(), d2.getCalories());
    }
});

List <String> lowCalDishesNm = new ArrayList<>();

for(Dish dish : lowCalDishes){
    lowCalDishesNm.add(dish.getName());
}

스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.

List<String> lowCaloricDishesName = 
	menu.stream()
			.filter(d -> d.getCalories() < 400) // 400칼로리 이하 요리 선택
			.sorted(comparing(Dish::getCalories)) // 칼로리로 요리 정렬
			.map(Dish::getName) // 요리명 추출
			.collect(toList()); // 모든 요리명을 리스트에 저장
  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.

4.2 스트림 시작하기

스트림 : 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조다. 즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다. 반면 스트림은 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조다. 이러한 스트림의 특성은 게으른 생성을 가능하게 한다.

특징

  • 파이프라이닝 : 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 그 덕에 게으름(lazyness), 쇼트서킷(short-circuiting) 같은 최적화도 얻을 수 있다.
  • 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.
List<Stirng> names = menu.stream() // 메뉴에서 스트림을 얻는다
		.filter(dish -> dish.getCalories > 300) // 파이프 라인 연산 만들기. 필터링
		.map(Dish::getname) // 요리명 추출
		.limit(3) // 선착순 세 개만 선택
		.collect(toList()); // 결과를 다른 리스트로 저장
// [pork, beef, chicken]

4.4 스트림 연산

스트림은 연결할 수 있는 스트림 연산인 중간 연산과 스트림을 닫는 연산인 최종 연산으로 구성된다.

List<Stirng> names = menu.stream() // 요리 리스트에서 스트림 얻기
		.filter(dish -> dish.getCalories > 300) // 중간 연산
		.map(Dish::getname) // 중간 연산
		.limit(3) // 중간 연산
		.collect(toList()); // 스트림을 리스트로 변환

4.4.3 스트림 이용하기

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

4.5 요약

  • 스트림은 소스에서 추출된 연속 요소
  • 스트림은 내부 반복을 지원. ex) filter, map, sorted
  • 스트림을 반환하면서 다른 연산과 연결될 수 있는 연산을 중간 연산. ex) filter, map
  • 중간 연산을 이용해서 파이프라인을 구성할 수 있지만 중간 연산으로는 어떤 결과도 생성할 수 X
  • 스트림 파이프라인을 처리해서 스트림이 아닌 결과를 반환하는 연산을 최종 연산. ex) forEach, count

5. 스트림 활용

5.1 필터링과 슬라이싱

5.1.1 프레디케이트로 필터링

스트림 인터페이스는 filter 메서드를 지원한다. filter메서드는 프레디케이트(불리언 반환하는 함수)를 인수로 받아 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

List<Dish> vegetarianMenu = menu.stream()
								.filter(Dish::isVegetarian)
                .collect(toList());

5.1.2 고유 요소 필터링

스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다. (중복 제거)

List<Dish> vegetarianMenu = menu.stream()
								.filter(i -> i % 2 ==0)
               	.disticnt()                         
                .forEach(System.out::printtln);

5.1.3 스트림 축소

주어진 사이즈 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다.

5.1.4 요소 건너뛰기

skip(n)은 처음 n개의 요소를 제외, 이후 요소 만을 포함하는 스트림을 반환한다.

5.2 매핑

특정 객체에서 특정데이터를 선택하는 작업.

Dish객체를 요소로 가지고 있는 스트림 → 매핑 → Dish객체의 calories값을 요소로 가지는 스트림

List<String> dishNames = 
	menu.stream()
			.map(Dish::getCalorie)
			.collect(toList());

Predicate를 이용한 요소 검사

자바의 &&, ||와 같은 스트림의 쇼트서킷 기법.

(쇼트서킷은 &&, || 같이 앞 조건식의 결과에 따라 뒤 조건식의 실행여부를 결정하는 논리연산자.)

  • allMatch : 스트림의 모든 요소가 주어진 Predicate과 일치하는지 여부 반환
  • anyMatch : 스트림의 요소 중에서 Predicate과 일치하는 경우가 적어도 하나라도 있는지 여부 반환
  • noneMatch : allMatch와 반대되는 연산
public boolean isLikeUser(String userId) {
        if (null == userId) return false;
        return likeUsers.stream()
                .anyMatch(u -> u.getUserId().equals(userId));
    }

5.3.3 Optional이란?

값의 존재나 부재 여부를 표현하는 컨테이너 클래스.

  • isPresent() : 값이 있으면 true, 없으면 false
  • isPresent(Consumer block) : 값이 있으면 주어진 블록 실행
  • get() : 값이 존재하면 값을 반환, 없으면 NoSuchElementException
  • orElse(T other) : 값이 있으면 값 반환, 없으면 기본값 반환


6. 스트림으로 데이터 수집

collect는 스트림의 요소를 요약 결과로 누적하는 최종 연산.

.toList // 각 요소를 리스트로 만들어라
.groupingBy // 각 키, 그리고 키에 대응하는 요소 리스트를 값으로 포함하는 Map을 만들어라

6.2 리듀싱과 요약

리듀싱 연산 : 모든 스트림 요소를 처리해서 값으로 도출.

counting

long howManyDishes = menu.stream.collect(Collectors.counting());

min / max

//Comparator 구현
Comparator caloriesComparator = Comparator.comparingInt(Dish::getCalories);

//스트림 연산
Optional mostCalorieDish = menu.steam()
  .collect(maxBy(caloriesComparator));

요약 연산(합계 summingInt, 평균 averageingInt)

int totalCalories = menu.steam().collect(summingInt(Dish::getCalories));

문자열 연결

String shortMenu = menu.stream().map(Dish::getName).collect(joining( 구분값 ));

6.3 그룹화

// groupingBy 사용
Map<Dish.Type, List> dishesByType = 
 menu.stream().collect(groupingby(Dish::getType));

// {FISH=[prawns, salmon], MEAT=[pork,beef,chicken] }

6.4 분할

분할은 분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다.

분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다.

결과적으로 그룹화 맵은 최대 두 개의 구룹으로 분류된다. 분류 함수로 사용했을 때 결과 Map의 키는 true, false로 나온다.

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

// {false = [pork, beef, chicken, salmon],
// true = [french fries, rice, fruit]}

7. 병렬 데이터 처리와 성능

7.1 병렬 스트림

각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다. 따라서 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있다.

(Chunk : 아이템이 트랜잭션에 commit되는 수)

ex) 1부터 n까지 합을 더하는 연산

public long iterativeSum(long n){
	long result = 0;
    for (long i = 1L; i <= n; i++){
    	result += i;
    }
    return result;
}

스트림을 활용하면 스레드별로 task를 나누어 병렬적으로 처리하는 것이 가능하다. 또한 이 방법이 더 효과적이다.

Stream.itereate(1L, i -> i + 1)
	  .limit(n)
    .parallel() // parallel() 메소드는 실제 이 메소드가 호출 되는 시점에 어떤 작업을 처리하는 것이 아니라 이 pipeline을 병렬로 처리하라고 하는 정보를 제공해주는 메소드
    .reduce(0L, Long::sum);

몇 개의 스레드를 사용해야 하는지, 어떻게 결과 변수를 동기화 할 건지, 몇 개의 스레드를 사용해야 할까? 숫자는 어떻게 생성할까? 생성된 숫자는 누가 더할까?

= 리듀싱 연산으로 스트림의 모든 숫자를 더한다. 이전 코드와 다른 점은 스트림이 여러 청크로 분할되어 있다는 것이다. 따라서 리듀싱 연산을 여러 청크에 병렬로 수행할 수 있다. 마지막엔 리듀싱 연산으로 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐서 전체 스트림의 리듀싱 결과를 도출한다.

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

1000개 이상일 때 병렬 스트림 쓰자와 같이 양이 기준이 되면 안된다. 하지만 힌트는 된다.

  • 확신이 없다면 직접 측정. 순차를 병렬로 바꾸는 것은 쉽다. 하지만 무조건 바꾸는 것이 능사는 아니고 적절한 벤치마크로 직접 성능을 측정하는 것이 바람직하다.
  • 박싱 주의. 자동 방식, 언박싱은 성능을 크게 저하시킬 수 있는 요소이다. 따라서 되도록이면 기본형 특화 스트림을 사용하자.
  • limit, findFirst 처럼 요소의 순서에 의존적인 연산은 병렬 스트림을 느리게 한다.
  • 스트림에서 수행하는 전체 파이프라인 연산비용을 고려하라. 하나의 요소를 처리하는 수가 Q이고 수행횟수가 N일 때 Q가 높아질 수록 성능 개선 가능성이 높다.
  • 소량의 데이터에선 사용하지 말라.
  • 자료구조가 적절한지 확인하라. LinkedList는 분할하기 위해서는 무조건 모든 요소를 탐색해야 하기 때문에 비효율 적이다.
  • 최정 연산의 병합과정의 오버헤드가 크면 병렬 처리의 이점이 줄어든다. 병렬 스트림으로 얻은 성능의 이익이 서브스트림의 부분 결과를 합치는 과정에서 상쇄될 수 있기 때문이다.

0개의 댓글