[아이템45] 스트림은 주의해서 사용하라

2024. 11. 14. 18:52·1️⃣ 백앤드/이펙티브 자바

1. 스트림 API

다량의 데이터 처리 작업을 돕고자 자바 8에 스트림 API 가 추가되었다. 스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처 등 어디로부터든 올 수 있고, 타입은 객체 참조나 int, long, double 세 기본 타입을 지원한다.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()   // 스트림 생성
     .filter(name -> name.startsWith("A"))   // 'A'로 시작하는 요소 필터링
     .forEach(System.out::println);   // 출력
스트림 파이프라인은 소스 스트림 → 중간 연산 (변환) → 종단 연산으로 끝난다.

소스 스트림, 중간 연산, 종단 연산

중간 연산인 변환 과정에서, 예를 들어 각 원소의 특정 조건을 만족 못하는 원소를 걸러낼 수 있다. 그리고, A 스트림에서 B 스트림으로 변환할 때에 A, B 스트림의 원소 타입이 같을 수도 있고, 다를 수도 있다. 종단 연산은 중간 연산이 내놓은 스트림에 최후의 연산을 가한다. 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력할 수 있다.

스트림 파이프라인은 종단 연산이 호출 될 때 평가된다. (지연평가)

즉, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않게 된다. 이렇게 지연된 평가가 무한 스트림을 다룰 수 있게 해준다. 무한으로 늘어나는 모든 요소를 즉시 계산하지 않고, 필요한 때 계산을 할 수 있기 때문이다. 참고로 종단 연산이 없으면 아무 일도 하지 않으니, 꼭 빼먹지 말자.

스트림 API 는 메서드 연쇄를 지원하는 플루언트 API 다.

파이프라인 하나를 구성하는 모든 호출을 연결해 단 하나의 표현식으로 완성되고, 파이프라인 여러 개를 연결해 표현식을 하나로 만들 수도 있다. 그리고, 기본적으로 스트림 파이프라인은 parallel 메서드를 호출해주지 않는 이상 순차적으로 수행된다.

 

2. 스트림 API 는 언제 써야하는가? (예시)

아래 예시를 살펴보자. 사전 파일에서 단어를 읽고, 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력할건데, 아나그램은 철자를 구성하는 알파벳은 같은데 순서만 다른 단어를 말한다. 키(알파벳 정렬)가 "aelpst" 일 때, "staple", "petals" 과 같은 단어들이 값이 된다.

// 스트림을 사용하지 않고 구현
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);
    }
}

groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word); 이 구문에 주목하자. 자바 8에 추가된 computeIfAbsent 메서드를 사용하여, 키가 있으면 값에 매핑한 결과를 반환하고, 없으면 새롭게 키와 값을 매핑한다. 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다. 반면, 아래 스트림을 과하게 구현한 예시를 살펴보자. 따라하진 말자.

// 스트림을 과용하여 구현
public static void main(String[] args) throws IOException {
    File dectionary = new File(args[0]);
    int minGroupSize = Integer.parseInt(args[1]);

    try(Stream<String> words = Files.lines(dectionary.toPath())) {
        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 class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> alphabetize(word))) // 터미널 작업, 모든 단어를 Map으로 수집
                    .values().stream() // 맵에서 value 모음을 가져오고 새 스트림을 만듬
                    .filter(group -> group.size() >= minGroupSize) // 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);
    }
}

파일을 열어 모든 라인으로 구성된 스트림을 얻고, 로직 (알파벳정렬) 을 수행 후에 종단 연산에서 Map 으로 수집한다. 사실 상 끝난거다. 그 다음, 맵의 values() 가 반환한 값으로부터 사용자가 지정한 문턱값과 비교해 살아남은 리스트만 출력한다. 위와 차이점은 alphabetize 메서드를 따로 분리했다는 사실인데, 스트림으로 구현할 수 있음에도 하지 않았다. 스트림을 사용한다면 명확성이 떨어지고 잘못 구현할 가능성이 커지고, 심지어 char용 스트림을 지원하지 않기 때문에 느려질 수도 있다.

스트림과 반복문을 적절히 조합하고, 기존 코드는 스트림을 사용하도록 리팩터링하되 새 코드가 나아보일 때만 반영하자.

 

3. 스트림 API 를 사용해야 할 때, 하지말아야 할 때

되풀이 되는 계산을 할 때, 함수 객체 (람다) 또는 반복 코드 블록을 통해 사용한다. 코드 블록에서는 지역 변수를 읽고 수정할 수 있지만, 람다는 final 만 된다. 그리고, 코드 블록에서는 return, break, continue, 메서드 선언에 명시된 예외를 던질 수 있지만, 람다는 그럴 수 없다. 코드 블록에 특화된 일을 해야할 때는, 스트림과는 맞지 않는다. 그럼 언제 스트림을 사용해야할까?

원소들의 시퀀스를 일관되게 변환하거나, 필터링하거나, 연산을 사용해 결합하거나, 컬렉션에 모을 때.

그렇다면 언제 스트림으로 처리하기 어려울까? 바로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 각 단계의 값에 동시 접근이 어려울 때이다. 아래 처음 20개의 메르센 소수를 출력하는 프로그램 예시를 통해 살펴보자.

static Stream<BigInteger> primes() { // 2부터 다음 확률적 소수를 순차적으로 생성하는 무한 스트림을 반환
    return Stream.iterator(TWO, BigInteger::nextProbablePrime);
}

먼저 메르센 수와 메르센 소수가 무엇인가? (2의 p제곱 - 1) 형태의 수가 메르센 수인데, p 가 소수일 때 메르센 수도 소수면 이 수를 메르센 소수라고 한다. 이를 구하기 위해 첫 스트림으로 사용할 모든 소수를 반환하는 primes 메서드가 있다. Stream.iterate 정적팩터리는 매개변수를 두 개 받는데, 스트림의 첫 번째 원소와 스트림에서 다음 원소를 생성해주는 함수 두 매개개변수가 필요하다.이제, 처음 20개의 메르센 소수를 출력하는 프로그램을  살펴보자.

public static void main(String[] args) { // 메르센 소수를 20개를 출력하는 프로그램
    primes().map(p -> TWO.pow(p.intValueExact().subtract(ONE))) // 메르센 소수로 변환
            .filter(mersenne -> mersenne.isProbablePrime(50)) // 메르센 수가 소수인지 필터링
            .limit(20) // 20개만 선택, 무한 스트림이므로 프로그램은 끝나지 않음
            .forEach(System.out::println); // forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
}

소수들을 사용해 메르센 수를 계산하고, 결과값이 소수인 경우만 남긴 다음에 원소 수를 20개로 제한하고, 결과를 출력한다.

이제, 메르센 소수의 지수(p) 를 출력하길 원한다면?

메르센 소수를 생성한 후에 이전 단계에서 생성된 소수에 접근할 수 없기 때문에, 첫 번째 중간연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 계산해내야 한다. 지수는 숫자를 이진수로 표현한 다음, 몇 비트인지 세면 나온다.

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

 

'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글

[아이템47] 반환 타입으로는 스트림보다 컬렉션이 낫다  (0) 2024.11.19
[아이템46] 스트림에서는 부작용 없는 함수를 사용하라  (0) 2024.11.18
[아이템44] 표준 함수형 인터페이스를 사용하라  (1) 2024.11.13
[아이템43] 람다보다는 메서드 참조를 사용하라  (0) 2024.11.12
[아이템42] 익명 클래스보다는 람다를 사용하라  (2) 2024.11.11
'1️⃣ 백앤드/이펙티브 자바' 카테고리의 다른 글
  • [아이템47] 반환 타입으로는 스트림보다 컬렉션이 낫다
  • [아이템46] 스트림에서는 부작용 없는 함수를 사용하라
  • [아이템44] 표준 함수형 인터페이스를 사용하라
  • [아이템43] 람다보다는 메서드 참조를 사용하라
HOZINU
HOZINU
주니어 백앤드 개발자의 세상만사 이모저모. 주로 개발 이야기를 다룸.
  • HOZINU
    백엔드 탐험 일지
    HOZINU
  • 전체
    오늘
    어제
  • 블로그 메뉴

    • ⛪ HOME
    • 🌍 GITHUB
    • 카테고리 (73)
      • 1️⃣ 백앤드 (72)
        • 이펙티브 자바 (72)
      • 2️⃣ CS (0)
        • 운영체제 (0)
        • 네트워크 기초 (0)
        • 네트워크 응용 (0)
        • SSL & PKI (0)
        • 기타 (0)
      • 3️⃣ 코딩테스트 (0)
      • 4️⃣ 개인공부 (0)
        • MSA (0)
        • REDIS (0)
      • 5️⃣ 일상이야기 (1)
  • 인기 글

  • 태그

    계층구조
    싱글턴
    의존객체
    캡슐화
    정적 팩터리 메서드
    맥북
    컴포지션
    Cleaner
    Comparable
    멤버클래스
    equals
    정보은닉
    finalizer
    CLONE
    try-with-resources
    optional
    빌더
    표준예외
    로타입
    hashcode
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
HOZINU
[아이템45] 스트림은 주의해서 사용하라
상단으로

티스토리툴바