동작 파라미터화 / 스트림

김주형·2022년 11월 14일
0

자라기

목록 보기
8/22

🙇🏻‍♂️Reference


동작 파라미터화

변화하는 요구사항에 쉽게 적응하는 유용한 패턴

  • 코드 일부를 API로 전달하는 기능
  • 자바 8 이전엔 메서드를 다른 메서드로 전달할 방법이 없었다고 한다.
  • 자바 8에선 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공하며 이러한 기능을 이론적으로 '동작 파라미터화'라고 부른다고 한다.
  • 연산의 동작을 파라미터화 할 수 있는 코드를 전달한다는 사상에 기초한다는 것이 핵심

참 또는 거짓을 반환하는 함수를 predicate라고 한다고 한다.
선택 조건을 결정하는 인터페이스를 정의한다.

public interface ApplePredicate {
	booelan test (Apple apple);
}

다양한 선택 조건을 대표하는 여러 버전의 ApplePredicate 정의

public class AppleHeavyWeightPredicate 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());
    }
}
  • 사과 선택 전략을 캡슐화 한 것을 전략 디자인 패턴이라고 한다.
    • 각 알고리즘(전략)을 캡슐화하는 팩토리를 정의해둔 다음, 런타임에 선택하는 기법
  • ApplePredicate(역할) : 팩토리
  • AppleHeavyWeightPredicate(구현체), AppleGreenColorPredicate(구현체) : 전략

ApplePredicate는 어떻게 다양한 동작을 수행할 수 있을까?
-> 객체를 받아 조건을 검사하도록 메서드를 고처야 한다.

  • 이렇게 메서드가 다양한 동작(전략)을 받아서 내부적으로 다양한 동작을 수행하는 것이 가능하다.
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate predicate) {
	List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory) {
    	if(predicate.test(apple)) { // predicate 객체로 사과 검사 조건을 캡슐화
        	result.add(apple);
        }
    }
    return result;
}

Apple의 속성과 관련한 모든 변화에 대응할 수 있는 유연한 코드를 준비하게 되었다.

public class AppleRedAndHeavyPredicate implements ApplePredicate {
	public boolean test(Apple apple) {
    	return RED.equals(apple.getColor())
        	&& apple.getWeight() > 150;
        }
 }
 List <Apple> redAndHeavyApple = 
 	filterApples(inventory, new AppleRedAndHeavyPredicate());
  • 전달한 ApplePredicate 객체에 의해 filterApples 메서드의 동작이 결정된다.
  • 즉, filterApples 메서드의 동작을 파라미터화 한 것이다.

한 개의 파라미터, 다양한 동작

  • 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이 '동작 파라미터화'의 강점이다.
  • 메서드가 다른 동작을 수행하도록 재활용 할 수 있다.
  • 유연한 API를 만들 때 동작 파라미터화가 중요한 역할을 한다.

  • 동작을 추상화해서 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있다.
  • 여러 클래스를 구현해서 인스턴스화하는 과정은 조금 거추장스러울 수 있다.
  • 이 부분을 어떻게 개선할 수 있을까?

복잡한 과정 간소화

구현하는 여러 클래스를 정의한 다음 인스턴스화하는 과정은 상당히 번거로운 작업이다.

public class AppleHeavyWeightPredicate 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());
    }
  } 
  
public class FilterlingApples {
	....

... 쓰다가 지쳤다. 로직과 관련없는 코드가 많이 추가되었다.
익명 클래스를 이용하면 코드의 양을 줄일 수 있다고 한다.
모든 것을 해결해주는 건 아니지만 간단하게 람다 표현식으로 더 가독성 있는 코드를 구현 하는 방법을 알아보자.

익명 클래스

  • 지역 클래스(블록 내부에 선언된 클래스)와 비슷한 개념
  • 말 그대로 이름이 없는 클래스
  • 클래스 선언과 인스턴스화를 동시에 할 수 있다.
// filterApples 메서드의 동작을 직접! 파라미터화
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.euqlas(apple.getColor()));
  • 훨씬 간결해지면서 문제를 더 잘 설명하는 코드가 되었다.
  • 복잡성 문제를 해결할 수 있다.

동작 파라미터화 3가지 방법

  • 클래스
  • 익명 클래스
  • 람다

  1. 동작을 (한 조각의 코드로) 캡슐화
  2. 메서드로 전달 (메서드의 동작(전략)을 다양하게 파라미터화)

Comparator로 정렬

변화하는 요구사항에 쉽게 대응할 수 있는 다양한 정렬 동작 수행 코드

public interafce Comparator<T> {
	int compare(T o1, T o2);
}
  • Comparator를 구현해서 sort 메서드의 동작을 다양화 가능
// 익명 클래스 이용
inventory.sort(new Comparator<Apple>() }
	public int compare(Apple a1, Apple a2) {
    	return a1.getWeight().compareTo(a2.getWeight();
    }
});

// 람다 표현식 이용
inventory.sort(
Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWegit()));
  • Runnable로 코드 블록 실행
// 익명 클래스 이용
public interafce Runnable {
	void run();
}
Thread t = new Thread(new Runnalbe() {
	public void run() {
    	System.out.println("helloWorld");
    }
});

// 람다식 이용
Thread t = new Thread(() -> System.out.println("helloWorld"); ));

스트림이란 무엇인가?

  • 선언형으로 컬렉션 데이터를 처리할 수 있다.
    • 데이터를 처리하는 임시 구현 코드 대신 질의로 표현할 수 있다.
    • 데이터 컬렉션 반복 처리에 유용
  • 멀티 스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다.

예시

  • 저칼로리 요리명 반환
  • 칼로리 기준으로 요리를 정렬하는 코드
  • 자바7 -> 자바8로 개선
List<Dish> lowCaloricDishes = new ArrayList<>();
  for (Dish dish: menu) { // 누적자로 요소 필터링
  	if(dish.getCalories() < 400) {
    	lowCaloricDishes.add(dish);
        }
   }
// 익명 클래스로 정렬
Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
	public int compare(Dish dish1, Dish dish2) {
    	return Integer.compare(dish1.getCalories(), dish2.getCalories());
 }
 
List<Dish> lowCaloricDishesName = new ArrayList<>();
  for (Dish dish: lowCaloricDishes) { 
  // 정렬된 리스트를 처리하면서 요리 이름 선택
  	lowCaloricDishesName.add(dish.getName());
   }
  • lowCaloricDishes라는 '가비지 변수'를 사용했다.
  • 컨테이너 역할만 하는 중간 변수라는 의미이다.
  • 자바 8에서 이러한 세부 구현을 라이브러리 내에서 모두 처리한다.
List<Dish> lowCaloricDishesName =
	menu.stream()
    	.filter(d -> d.getCalories() < 400) // 400칼로리 이하 요리 선택
        .sorted(comparing(Dish::getCalories)) // 칼로리로 요리 정렬
        .map(Dish::getName) // 요리명 추출
        .collect(toList()); // 모든 요리명 리스트에 저장

stream()을 parallelStream()으로 바꾸면 이 코드를 멀티코어 아키텍처에서 병렬로 실행할 수 있다.

List<Dish> lowCaloricDishesName =
	menu.parallelStream()
    .filter(d -> d.getCalories() < 400)
    .sorted(comparing(Dishes::getCalories))
    .map(Dish::getName)
    .collect(toList());
  • 선언형으로 코드를 구현할 수 있다.
    • 루프와 if 조건문 등의 제어 블록을 사용해서 어떻게 동작을 구현할지 지정할 필요 없이 '저칼로리의 요리만 선택하라'같은 동작의 수행을 지정할 수 있다.
  • 선언형 코드와 동작 파라미터화를 활용하면 변하는 요구사항에 쉽게 대응할 수 있다.
    • 기존 코드를 복사하여 붙여 넣는 방식을 사용하지 않고 람다 표현식을 이용해서 필터링하는 코드도 쉽게 구현할 수 있다.
  • filter, sorted, map, collect 같은 연산은 고수준 빌딩 블록으로 이루어져 있으므로 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있다.
    • 내부적으로 단일 스레드 모델에 사용할 수 있지만 멀티코어 아키텍처를 최대한 투명하게 활용할 수 있게 구현되어 있다.

결과적으로 데이터 처리 과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다.
이 모든 것이 스트림 API 덕분이다.

자바 8 스트림 API의 특징을 요약하면 다음과 같다고 한다.

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.

스트림 시작하기

그래서 스트림이 정확히 뭘까?
'데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'로 정의할 수 있다고 한다.. 무슨 뜻일까

  • 연속된 요소
    • 컬렉션과 마찬가지로 특정 요소 형식으로 이뤄진 연속된 값 집합의 인터페이스를 제공한다.
    • 컬렉션은 자료구조이므로 시간과 공간의 복잡성과 관련된 요소 저장 및 접근 연산이 주를 이루는 반면,
    • 스트림은 filter, sorted, map처럼 표현 계산식이 주를 이룬다.
    • 컬렉션의 주제 = 데이터, 스트림의 주제 = 계산
  • 소스
    • 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다.
    • 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지된다.
    • 리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지한다.
  • 데이터 처리 연산
    • 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다.
    • filter, map, reduce, find, match, sort 등으로 데이터를 조작할 수 있다.
    • 스트림 연산은 순차적으로 또는 병렬로 실행할 수 있다.

중요한 특징

  • 파이프라이닝 : 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다.
    • 게으름(laziness), 쇼트서킷 같은 최적화도 얻을 수 있다.
    • 연산 파이프라인은 데이터 소스에 적용하는 데이터베이스 질의와 비슷하다.
  • 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.
List<String> threeHighCaloricDishNames = 
	menu.stream() // 요리 리스트에서 스트림을 얻는다.
    	.filter(dish -> dish.getCalories() > 300) // 파이프라인 연산 만들기, 고칼로리 요리 필터링
        .map(Dish::getName) // 요리명 추출
        .limit(3) // 선착순 세 개만 선택
        .collect(toList()); // 결과를 다른 리스트로 저장

assertExpectedEqualsActual(threeHighCaloricDishNames, [port,beef,chicken]);
  • 요리 리스트를 포함하는 menu에 stream 메서드 호출로 스트림 얻기
  • 데이터 소스 : 요리 리스트 (메뉴)
  • 데이터 소스는 연속된 요소를 스트림에 제공
  • 스트림에 데이터 처리 연산 적용 (filter, map, limit, collect)
  • collect를 제외한 모든 연산은 서로 파이프라인(소스에 적용하는 질의)을 형성할 수 있도록 스트림 반환
  • collect 연산으로 파이프라인 처리한 결과 반환(스트림이 아니라 List 반환)
  • collect 호출 전까지는 menu에서 무엇도 선택되지 않으며 출력 결과 없음. 메서드 호출 저장 효과
  • filter : 람다를 인수로 받아 스트림에서 특정 요소 제외
  • map : 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출
  • limit : 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 축소 truncate 한다고 한다.
  • collect : 스트림을 다른 형식으로 변환. 다양한 변환 방법을 인수로 받아 스트림에 누적된 요소를 특정 결과로 변환시키는 기능을 수행

자바 8 이전과 달리 좀 더 선언형으로 데이터를 처리할 수 있게 되었다고 한다.
스트림 API가 결과적으로 파이프라인을 더 최적화할 수 있는 유연성을 제공하고 어떤 연산을 수행할 수 있는지 자세히 살펴보기 전에 컬렉션 API와 개념적인 차이를 확인하자.


스트림과 컬렉션

연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스 제공
연속된 : 순서와 상관없이 아무 값에나 접속하는 것이 아니라, 순차적으로 값에 접근한다는 것을 의미

데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이라고 한다.

적극적 생성 vs 게으른 생성

컬렉션 : 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조

  • 모든 요소는 컬렉션에 추가하기 전에 미리 계산되어야 한다.
  • 생산자 중심, 적극적으로 생성
    스트림 : 요청할 때만 요소를 계산하는 고정된 자료구조
  • 스트림에 요소를 추가하거나 스트림에서 요소를 제거할 수 없다.
  • 사용자가 요청하는 값만 스트림에서 추출한다는 것을 의미한다.(사용자 입장에서는 변화를 알 수 없다.)
  • 생산자와 소비자 관계 형성
  • 게으르게 만들어지는 컬렉션과 같다. (사용자가 데이터를 요청할 때만 값을 계산)

딱! 한 번만 탐색할 수 있다.

List<String title = Arrays.asList("Java8", "In", "Action");
Stream<String> string = title.stream();
string.forEach(System.out::println); // title의 각 단어를 출력
string.forEach(System.out::println); // Stream이 이미 소비되었거나 닫힘, IllegalStateException

스트림은 단 한 번만 소비할 수 있다.
-> 탐색된 스트림의 요소는 소비된다.
반복자와 마찬가지로 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
디버깅 시 참고하자 ..

  • 철학적 접근
    스트림 : 시간적으로 흩어진 값의 집합 // 런타임
    컬렉션 : 특정 시간에 모든 거이 존재하는 공간(메모리)에 흩어진 값

외부 반복과 내부 반복

외부 반복 : 사용자가 직접 요소를 반복해야 하는 방법 (컬렉션)

List<String> names = new ArrayList<>();
for (Dish dish: menu) { // 메뉴 리스트를 명시적으로 순차 반복
	names.add(dish.getName());  // 이름 추출해서 리스트에 추가
}
List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) { // 명시적 반복
	Dish dish = iterator.next();
    names.add(dish.getName());
}

내부 반복 : 반복을 알아서 처리하고 결과 스트림값을 어딘가에 저장해줌. (스트림) 함수에 어떤 작업을 수행할지만 지정하면 모든 것 알아서 처리

List<String names = menu.stream()
					.map(Dish::getName) // map 메서드를 getName 메서드로 파라미터화해서 요리명 추출
                    .collect(toList()); // 파이프라인 실행. 반복자는 필요없다.

내부 반복과 외부 반복이 어떤 점이 다르고 어떤 이득을 주는 걸까?

유명한 예시
마리오 : “소피아 . 장난감좀 정리하렴. 방바닥에 장난감있지?”
소피아 : “네, 공이 있어요”
마리아 : “좋아, 그럼공을 상자에 담자. 또 어떤장난감이 있지?”
소피아 : “인형이 있어요”
마리아 : “그럼 인형을 상자에 담자, 또 어떤장난감이 있지?”
소피아 : “책이 있어요”
마리아 : "그럼 책을 상자에 담자. 또 어떤 장난감이 있지?"
소피아 : "아무것도 없어요."
마리아 : "참 잘했어."

컬렉션은 외부적으로 반복, 명시적으로 컬렉션 항목을 하나씩 가져와서 처리한다.

내부 반복
'소피아, 바닥에 있는 모든 장난감을 상자에 담자.'

내부 반복을 이용하면 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다.
하지만 내부 반복 뿐 아니라 스트림 라이브러리의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택한다.
반면 for-each를 이용하는 외부 반복에서는 병렬성을 스스로 관리해야 한다.
컬렉션 인터페이스와 비슷하면서도 반복자가 없는 무엇이 절실했으며, 결국 스트림이 탄생했다고 한다.

주의점

  • 스트림은 내부 반복을 사용하므로 반복 과정을 우리가 신경쓰지 않아도 된다.
  • 하지만 이와 같은 이점을 누리려면 반복을 숨겨주는 연산 리스트(filter, map..)가 미리 정의되어 있어야 한다.
  • 반복을 숨겨주는 대부분의 연산은 람다 표현식을 인수로 받으므로 동작 파라미터화를 활용할 수 있다.
  • 자바 언어는 복잡한 데이터 처리 질의를 표현할 수 있도록 다양한 추가 연산을 제공한다.
List<String highCaloricDishes = menu.stream()
	.filter(dish -> dish.getCalories() > 300)
    .map(Dish::getName)
    .collect(toList())
profile
왔을때보다좋은곳으로

0개의 댓글