stream 을 사용할 때 주의할 점

수박참외메론·2023년 3월 12일
0

1. stream 의 특징

스트림 API는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.

  • 스트림 API 의 추상개념
    - 스트림은 데이터 원소의 유한, 무한 시퀀스를 뜻한다.
    - 스트림 파이프라인은 이 원소들로 수행하는 연산단계를 표현하는 개념이다.
  • 스트림 파이프라인은 소스 스트림에서 시작해 종단연산으로 끝나며, 그 사이에 하나 이상의 중간연산이 있을 수 있다.
  • 스트림 파이프라인은 지연 평가(lazy evaluation)된다.
    평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
  • 스트림 API 는 메서드 연쇄를 지원하는 fluent API이다.

2. stream 을 과하게 사용하지 말자.

스트림 API 는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있지만, 제대로 사용하지 않는다면 읽기 어렵고 유지보수도 힘든 코드를 생산할 수 있다.

public class Anagrams {
	public static void main(String[] args) throws IOException {
        File dictionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        Map<String, Set<String>> groups = new HashMap<>();
        try(Scanner s = new Scanner(dictionary)) {
            while(s.hasNext()) {
                String word = s.next();
                groups.computeIfAbsent(alphabetize(word),
                        (unused) -> new TreeSet<>()).add(word);
            }
        }

        for(Set<String> group : groups.values())
            if(group.size() >= minGroupSize)
                System.out.println(group.size() + ": "+group);

    }

    private static String alphabetize(String s){
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}
  • 맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent 메서드를 사용했다.
    - 이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다.
    - 키가 없으면 건네진 함수 객체를 키에 적용시켜 값을 계산해낸 다음 그 키와 값을 매핑하고 계산된 값을 반환한다.
  • 이처럼 computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다.
	public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[0]);

        Map<String, Set<String>> groups = new HashMap<>();
        try(Stream<String> words = Files.lines(dictionary)) {
            words.collect(
                    groupingBy(word -> word.chars().sorted()
                            .collect(StringBuilder::new,
                                    (sb, c) -> sb.append((char) c),
                                    StringBuilder::append).toString())
                            .values().stream()
                            .filter(group -> group.size() >= minGroupSize)
                            .map(group -> group.size() +": "+group)
                            .forEach(System.out::println);
            
        }
    }

이런식으로 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

다음 코드는 스트림을 적당히 사용한 예시이다.

	public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[0]);

        Map<String, Set<String>> groups = new HashMap<>();
        try(Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> alphabetize(word)))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .forEach(g -> System.out.println(g.size() +": "+g));
        }
    }

    private static String alphabetize(String s){
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }

스트림을 본 적이 없더라도 이 코드는 이해하기 쉬울 것이다.

  • try-with-resources 블록에서 사전 파일을 열고 파일의 모든 라인으로 구성된 스트림을 얻는다.
  • 스트림 변수의 이름을 words 로 지어 스트림 안의 각 우너소가 단어임을 명확히 하고
  • 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.

2-1. char 값들은 stream 사용을 삼가자.

자바가 기본 타입인 char 용 스트림을 지원하지 않는다.

"Hello world!".chars.forEach(System.out::print);

이 스트림의 결과로 hello world! 가 출력될 것 같지만 이상한 뭉치의 숫자들을 출력한다.
이는 "hello world!".chars()가 반환하는 스트림의 원소는 char 가 아닌 int 값이기 때문이다.

"Hello world!".chars.forEach(System.out.print((char) x));

이렇게 하면 되지만 그냥 char 값들을 처리할 때는 되도록이면 스트림을 삼가자.

2-2. 함수 객체로는 할 수 없는 일들이 있다.

  • 코드 블록에서는 범위 안의 지역 변수를 읽고 수정할 수 있지만, 람다는 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능
  • 코드 블록에서는 return 문을 이용해 메서드에서 빠져나가거나, break이나 continue문으로 블록 바깥의 반복문을 종료하거나 반복을 한번 건너띌 수 있다.
  • 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다.

그래서 이런 경우들에는 스트림이 맞지 않아서 쓰면 안좋다는 말이다.

2-3. 스트림을 적용하기 힘든 예시

  • 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우
    - 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문.
    • 그래서 필요하다면 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법이 나을 것.

2-3-1. 메르센 소수

메르센 소수를 출력하는 프로그램을 작성해보자. 메르센 수는 2^p-1 형태의 수이고, p 가 소수이면 해당 메르센 수도 소수일 때 메르센 소수라고 한다.

static Stream<BigInteger> primes(){
	return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
public static void main(String[] args) {
	primes().map(p->TWO.pow(p.intValueExact()).subtract(ONE))
    	.filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
}

이 때 우리가 메르센 소수 앞에 지수(p)를 출력하고 싶다고 해보자.
이 값은 초기 스트림에서 이미 사용된 값으로 종단연산에서는 접근 할 수 없으므로, 앞서 말했드싱 매핑을 거꾸로 수행해 메르센 수의 지수를 구해낼 수 있다.

.forEach(mp -> System.out.println(mp.bitLength() + ": " +mp);

2-3-2. 데카르트 곱

카드 덱을 초기화하는 작업을 생각해보자.

카드는 숫자(rank)와 무늬(suit)를 묶은 불변 값 클래스이고, 숫자와 무늬는 모두 열거타입이라고 하자.
이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제이다.

	private static List<Card> new Deck(){
        List<Card> result = new ArrayList<>();
        for(Suit suit : suit.values())
            for(Rank rank : Rank.values())
                result.add(new Card(suit, rank));
        return result;
    }
	private static List<Card> new Deck(){
        return Stream.of(Suit.values())
        	.flatMap(suit ->
            			Stream.of(Rank.values())
                        	.map(rank -> new Card(suit, rank)))
            .collect(toList());
    }

아마 보통의 프로그래머라면 첫번째 방식이 익숙하겠지만, 스트림이 익숙한 개발자는 두번째 방식을 선호할 수도 있다. 더 나아보이는 방식을 동료들과 협의하여 사용하자.

2-4. 그에반해 스트림이 잘 맞는 일들은?

  • 원소들의 시퀀스를 일관되게 변환한다.
  • 원소들의 시퀀스를 필터링한다.
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
  • 원소들의 시퀀스를 컬렉션에 모은다.
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

3. 스트림에서는 부작용 없는 함수를 사용하라

3-1. 스트림을 스트림 답게 사용하라

  • 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
  • 각 변환단계는 이전 단계의 결과를 받아 처리하는 입력만이 결과에 영향을 주는 함수여야 한다.
  • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않아야 한다.
  • 위와 같이 동작하려면, 함수 객체는 모두 부작용이 없어야 한다.

다음은 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 작업이다.

Map<String, Long> freq = new HashMap<>();
try(Stream<String> words = new Scanner(file).tokens()){
	words.forEach(word -> {
    	freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}
  • 스트림을 사용했지만, 스트림 코드를 가장한 반복적 코드이다.
  • 이 코드의 모든 작업이 종단 연산인 foreach에서 일어난다.
Map<String, Long> freq;
try(Stream<String> words = new Scanner(file).tokens()) {
	freq = words
    		.collect(groupingBy(String::toLowerCase, counting()));
}
  • 자바 개발자라면 첫번째 방식이 매우 익숙하여 저렇게 작성할 수도 있다.(foreach가 익숙하기 때문)
  • 하지만 foreach 연산은 종단 연산 중 기능이 가장 적고 가장 덜 스트림스럽다.
  • foreach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는데는 쓰지 말자.

3-2. Collector(수집기)

  • java.util.stream.Collectors 클래스는 메서드를 무려 39개나 가지고 있지만, 익숙해지면 패턴이 다 비슷비슷해서 유용하게 사용할 수 있다.
  • 익숙해지기 전까지는 그저 축소 전략을 캡슐화한 블랙박스 객체라고 생각하고 사용하자.
    - 여기서 축소는 스트미 원소들을 객체 하나에 취합한다는 뜻.

3-2-1. 리스트 수집기

  • 가장 흔한단어 10개를 뽑아내는 스트림 파이프라인
List<String> topTen = freq.keySet().stream()
	.sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

3-2-2. 맵 수집기

  • 가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper) 이다.
  • 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
private static final Map<String, Operation> stringToEnum = 
	Stream.of(values()).collect(
    	toMap(Object::toString, e->e));
  • 인수 3개를 받는 toMap 은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관짓는 맵을 만들 때 유용하다.
Map<Artist, Album> topHits = albums.collect(
	toMap(Album::artist, a->a, maxBy(Comparing(Album::sales))));
  • 다양한 음악가의 앨범을 담은 스트림을 가지고
  • 음악가와 그 음악가의 베스트 앨범을 연관짓는 코드
  • 비교자로는 BinaryOperator 에서 정적 임포트한 maxBy 라는 정적팩터리 메서드를 사용했다.
  • "앨범 스트림을 맵으로 바꾸는데, 이 맵은 각 음악가와 그 음악가의 베스트 앨범의 맵" 이라는 내용을 그대로 코드로 옮긴 결과물.

3-3-3. groupingBy

  • 입력으로는 분류 함수를 받고 출력으로는 원소들을 카테고리별로 모아놓은 맵을 담은 수집기를 반환.
words.collect(groupingBy(word -> alphabetize(word)))
  • groupingBy 가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생서앟게 하려면, 분류 함수와 함께 다운스트림 수집기도 명시해야 한다.
  • 여기서 list 말고 set을 반환하고 싶으면 toSet()을 groupingBy의 인수로 넘기면 된다.
  • toCollection(collectionFactory)를 건네면 커스텀 컬렉션을 출력하도록 할 수도 있다.
Map<String, Long> freq = words
	.collect(groupingBy(String::toLowerCase, counting()));
  • 위와 같이 코드를 작성하면 각 key 를 담은 컬렉션이 아닌 해당 카테고리에 속하는 원소의 개수와 매핑한 맵을 얻을 수 있다.

앞으로 살펴볼 Collectors 메서드는 특이하게도 '수집'과는 관련이 없다.

minBy, maxBy

  • 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은, 혹은 가장 큰 원소를 찾아 반환한다.

joining

  • 이 메서드는 문자열 등의 CharSequence 인스턴스 스트림에만 적용할 수 있다.
  • 매개변수가 없으면 단순히 원소들을 연결(concatenate) 하는 수집기를 반환한다.
  • 하나짜리는 delimiter 를 매개변수로 받아 연결부위에 delimiter 을 삽입한다.
  • 3개짜리 joining 은 구분문자 + prefix + subfix이다.
    예를 들어 joining(",","[","]")이런식으로 사용한 수집기는 [came, saw, conqured] 처럼 컬렉션을 출력한 듯한 문자열을 생성할 수 있다.
profile
하루하루는 성실하게 인생전체는 되는대로

0개의 댓글