본문 바로가기
자바

Stream API

by 이상한나라의개발자 2024. 2. 2.

자바 8에서 처음 소개된 Stream은 컬렉션(Collection)의 처리를 매우 간결하고 호율적인 방식으로 할 수 있게 도와줍니다. Stream API를 사용하면 데이터를 선언적으로 처리할 수 있으며, 데이터 컬렉션을 함수형 스타일로 처리할 수 있게 합니다. 이는 루프와 조건문을 사용하지 않고도 데이터를 필터링, 정렬, 변환 등 다양한 연산을 수행할 수 있게 합니다.

 

Stream API의 주요 특징

불변성 

Stream API의 불변성(Immutability)은 스트림 연산이 원본 데이터를 변경하지 않는다는 것을 의미합니다. 즉, 스트림 연산은 원본 컬렉션에 어떤한 변화도 주지 않고, 필요한 연산의 결과를 새로운 스트림이나 값으로 반환합니다.

public class StreamExample {

    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "orange", "ok");

        List<String> result = words.stream()
                .filter(s -> s.startsWith("o"))
                .map(String::toUpperCase)
                .collect(toList());

        System.out.println("words = " + words);
        System.out.println("result = " + result);
    }
}

 

내부 반복

내부 반복이란 스트림이 데이터 컬렉션을 숨겨진 방식으로 반복 처리한다는 것을 의미합니다. 이는 개발자가 명시적으로 for-loop를 처리 하지 않아도 된다는 것을 의미합니다.이로인해, 코드는 더 간결하고 가독성이 높아질 뿐만 아니라 병렬 처리에도 용이합니다.

public class StreamExample {

    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "orange", "ok");

        words.stream()
                .filter(s -> s.startsWith("o"))
                .map(String::toUpperCase)
                .forEach(System.out::println);
    }
}

 

위 예시에서 words 리스트를 스트림으로 반환하고, "o"로 시작하는 단어만 필터링한 다음 각 단어를 출력합니다. 여기서 filter, forEach 연산은 스트림이 내부적으로 처리하는 반복로직을 포함하고 있습니다. 개발자는 반복을 어떻게 처리할지에 대해 신경쓸 필요가 없습니다. 무엇을 할지 즉, 어떤 조건으로 필터링하고 결과를 어떻게 처리할지에만 집중하면 됩니다.

 

외부 반복을 사용할 때는 개발자가 직접 각 요소를 어떻게 반복할지 제어 해야 합니다. 예를 들어 전통적인 for-loop 를 사용하는 경우는 아래와 같습니다.

for(String word : words) {
    if(word.startsWith("o")) {
        System.out.println(word);
    }
}

 

Stream API 에서 내부적은 반복은 어떤 함수형 프로그래밍 기법으로 이루어 지는지 알아보겠습니다.

public class StreamExample {

    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "orange", "ok");
        Predicate<String> isOk = s -> s.startsWith("o");
        List<String> result = predicate(words, isOk);
        for (String s : result) {
            System.out.println(s);
        }
    }

    private static <T> List<T> predicate(List<T> words, Predicate<T> predicate) {
        List<T> result = new ArrayList<>();
        for (T word : words) {
            if (predicate.test(word)) {
                result.add(word);
            }
        }
        return result;
    }
}

 

레이지 연산

필요할 때까지 연산을 수행하지 않고, 최종 연산이 호출될 때 까지 지연 시켰다가 모든 처리가 수행됩니다. 이러한 방식은 불필요한 계산을 최소화 하고, 필요한 데이터만 처리하여 효율성을 높일 수 있습니다. 레이지 연산 덕분에, 스트림은 무한한 요소를 가진 스트림을 다룰 수 있으며, 최종 연산이 요청될 때까지 실제 연산을 시작하지 않습니다

 

  • 효율성 : 데이터 처리를 최종 연산 시점까지 지연시킴으로서, 필요한 데이터만 처리하여 자원을 절약할 수 있습니다.
  • 무한 스타림 처리 : 무한한 요소를 생성할 수 있는 스트림을 다룰 수 있으며, 최종 연산에서 처리할 요소의 수를 제한함으로써 실제 사용하는 데이터만 처리합니다.
public class StreamExample {
    public static void main(String[] args) {
        Stream<Integer> numbers = Stream.iterate(0, n -> n + 1);

        numbers
                .filter(n -> n % 2 == 0) // 짝수만 필터링 ( 중간 연산 )
                .limit(10) // 처음 10개 요소만 출력 ( 중간 연산 )
                .forEach(System.out::println); // 각 요소를 출력 ( 최종 연산 )
    }
}

 

기본 사용법

Stream API의 사용은 크게 세 단계로 나눌 수 있습니다.

  1. 스트림 생성 : 컬렉션, 배열 등의 데이터 소스로 부터 스트림을 생성합니다.
  2. 중간 연산 : 하나 또는 여러 단계의 변환 연산에 스트림을 적용합니다. 예를 들어, filter, map, sorted 등이 있습니다.
  3. 종단 연산 : 모든 중간 연산의 결과를 도출하고, 스트림을 닫습니다. 예를 들어, forEach, collect, reduce 등이 있습니다.
public class StreamExample {

    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "orange", "ok");

        words.stream()
                .filter(s -> s.startsWith("o"))
                .map(String::toUpperCase)
                .forEach(System.out::println);

    }
}

 

중간 연산

자바 스트림 API에서 중간 연산은 스트림을 변환하는 연산으로, 하나의 스트림을 다른 스트림으로 변환합니다. 중간 연산은 레이지하게 실행되며, 스트림의 각 요소에 연산을 적용하지만, 최종 연산이 호출되기 전까지는 실제로 실행되지 않습니다. 중간 연산은 연쇄적으로 연결될 수 있으며, 각 연산은 특정 조건에 따라 요소를 필터링 하거나, 요소의 속성을 반환하고, 스트림을 정렬하는 등 다양한 작업을 수행할 수 있습니다.

 

중간 연산의 종류

  1. filter(Predicate<T>) : 조건에 맞는 요소만을 포함하는 스트림을 반환합니다.
  2. map(Function<T,R>) : 각 요소에 함수를 적용하고, 함수의 결과로 구성된 새 스트림을 반환합니다.
  3. peek(Consumer<T>) : 각 요소를 소비하는 연산을 수행하고 스트림을 반환합니다. 주로 디버깅 목적으로 사용됩니다.
  4. flatMap(Function<T, Stream<R>) : 각 요소를 하나의 스트림으로 변환하고 변환된 스트림을 하나의 스트림으로 조립 합니다. 
  5. distinct() : 스트림에서 중복 요소를 제게 합니다.
  6. sorted() : 스트림의 요소를 자연 순서대로 정렬합니다. 커스텀 Comparator를 인자로 받는 오버로드 버전도 있습니다.
  7. limit(long n) : 스트림의 처음 부터 지정된 개수만큼의 요소를 포함하는 새 스트림을 반환합니다.
  8. skip(long n) : 스트림의 처음부터 지정된 개수만큼 요소를 건너뛰고 나머지 요소를 포함하는 새 스트림을 반환합니다.
public class IntermediateOperationsStreamExam {

    @Data
    @Builder
    @ToString
    static class Employee {
        private String name;
        private String department;
        private double salary;

        public Employee(String name, String department, double salary) {
            this.name = name;
            this.department = department;
            this.salary = salary;
        }
    }

    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("John Doe", "Engineering", 75000),
                new Employee("Jane Smith", "Engineering", 80000),
                new Employee("Peter Brown", "HR", 45000),
                new Employee("Mary Johnson", "Engineering", 95000),
                new Employee("David Wilson", "HR", 55000),
                new Employee("Richard Wright", "Engineering", 60000),
                new Employee("Thomas Harris", "Marketing", 48000)
        );
        
        List<String> result = employees.stream()
                // Engineering 부서의 직원만 필터링
                .filter(e -> e.getDepartment().equals("Engineering"))
                // 연봉으로 정렬
                .sorted((o1, o2) -> Double.compare(o2.getSalary(), o1.getSalary()))
                // 상위 5명만
                .limit(5)
                // 이름과 연봉 정보로 변환 ( 이름 +": $" + 연봉 )
                .map(e -> e.getName() + ": $" + e.getSalary())
                // 결과를 리스트로 수집
                .collect(toList());

        System.out.println("result = " + result);

    }
}

 

flatMap 사용 예시

flatMap 메소드는 각 요소의 스트림을 변환하고, 변환된 스트림을 하나의 스트림으로 합치는 작업을 합니다. 이는 중첩된 구조를 평평하게 만들거나, 여러 스트림을 합치는데 유용합니다.

 

다음 예시에서는 여러 팀이 있고, 각 팀에는 여러 명의 멤버가 있다고 가정해 봅시다. 우리의 목표는 모든 팀의 모든 멤버를 포함하는 단일 스트림을 생성하는 것입니다. 결과 각 팀의 멤버를 평탄하게 합치는 작업 ( [member1, member2, member3, member4] )

 

아래 예시에서 peek는 주로 디버깅 용도로 사용됩니다. 중간 중간 연산시 값을 확인할 수 있습니다.

 

public class IntermediateOperationsStreamExam2 {

    @Data
    @Builder
    @ToString
    static class Member {
        String name;

        public Member(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }

    @Data
    @Builder
    @ToString
    static class Team {
        String teamName;
        List<Member> members;

        public Team(String teamName, List<Member> members) {
            this.teamName = teamName;
            this.members = members;
        }

        public List<Member> getMembers() {
            return members;
        }

        public String getTeamName() {
            return teamName;
        }
    }

    public static void main(String[] args) {

        Team teamA = Team.builder()
                .teamName("teamA")
                .members(List.of(Member.builder().name("member1").build(), Member.builder().name("member2").build()))
                .build();
        Team teamB = Team.builder()
                .teamName("teamB")
                .members(List.of(Member.builder().name("member3").build(), Member.builder().name("member4").build()))
                .build();

        List<Team> teams = List.of(teamA, teamB);

        List<String> result = teams.stream()
                .peek(team -> System.out.println("Before = " + team.getTeamName()))
                .flatMap(team -> team.getMembers().stream())
                .peek(member -> System.out.println("After = " + member.getName()))
                .map(Member::getName)
                .collect(toList());

        System.out.println("result = " + result);
        // result = [member1, member2, member3, member4]
    }
}

 

종단 연산

자바 스트림 API에서 종단 연산(Terminal operations)은 스트림 파이프라인의 실행을 트리거하고, 스트림을 소비하여 결과를 도출합니다. 중간 연산과는 달리, 종단 연산 후에는 스트림을 더 이상 사용할 수 없습니다. 종단 연산은 크게 결과를 반환하는 연산과 스트림을 소비하여 특정 작업을 수행하지만 결과를 반환하지 않는 연산으로 분류할 수 있습니다.

 

종단 연산의 종류

결과를 반환하는 연산

  1. collection(Collectors.toXXX()) : 스트림의 결과를 다양한 형태의 켈렉션으로 수집합니다.
  2. count() : 스트림의 데이터 개수를 반환합니다. ( 총 개수 )
  3. max(Compartor), min(Compartor) : 주어진 비교자를 사용하여 스트림의 최대값 또는 최소값을 찾습니다.
  4. findFirst() : 스트림의 첫 번째 요소를 Optional 객체로 반환합니다. 이 메서드는 순서가 중요한 데이터에서 사용됩니다. 예를 들어, 리스트나 배열과 같이 순서가 정의된 스트림에서 첫 번째 요소를 찾을 때 유용합니다.
  5. findAny() :  findFirst()와 마찬가지로 스트림에서 임의의 요소를 Optional 객체로 반환합니다. findAny()는 병렬 스트림에서 유용하며 순서에 구애 받지 않고 스트림에서 어떤 요소를 빠르게 반환하고 싶을 때 사용됩니다.
  6. allMatch(Predicate), anyMatch(Predicate), noneMatch(Predicate) : 스트림의 요소들이 주어진 조건과 일치하는지 여부를 검사합니다.
  7. reduce(BinaryOperator) : 스트림의 요소를 조합하여 축소(reduce) 하고, 이를 통해 스트림의 요소를 하나의 요약된 결과로 합칩니다.

결과를 반환하지 않는 연산

  1. forEach(Consumer) : 스트림의 각 요소에 대해 주어진 작업을 수행합니다. ( 출력 )
  2. forEachOrdered(Consumer) :  병렬 스트림에서 요소의 순서를 고려하여 각 요소에 대해 주어진 작업을 수행합니다.
public class TerminalOperationsStreamExam {
    public static void main(String[] args) {
        List<String> names = List.of("John", "Jane", "Jack", "Diane");

        // collect 예시
        List<String> filteredNames = names.stream()
                .filter(name -> name.startsWith("J"))
                .collect(toList());
        System.out.println("Filtered Names: " + filteredNames);

        // count 예시
        long count = names.stream()
                .filter(name -> name.startsWith("J"))
                .count();
        System.out.println("Names starting with J: " + count);

        // max 예시
        Optional<String> longestName = names.stream()
                .max((name1, name2) -> Integer.compare(name1.length(), name2.length()));
        longestName.ifPresent(name -> System.out.println("Longest Name: " + name));

        // anyMatch 예시
        boolean hasDiane = names.stream()
                .anyMatch(name -> name.equals("Diane"));
        System.out.println("Has Diane? " + hasDiane);

        // forEach 예시
        System.out.println("All Names:");
        names.forEach(System.out::println);
    }
}

 

allMatch(Predicate), anyMatch(Predicate), noneMatch(Predicate) 사용 예시

public class TerminalOperationsSteamExam {
    public static void main(String[] args) {
        List<Integer> integers = List.of(1, 2, 3, 4, 5, 6, 7);

        // 모든 값이 조건을 만족하면 true,아니면 false
        boolean result = integers.stream().allMatch(integer -> integer < 10);
        System.out.println("result = " + result);

        // 하나라도 조건을 만족하면 true, 아니면 false
        boolean result2 = integers.stream().anyMatch(integer -> integer > 5);
        System.out.println("result2 = " + result2);

        // 스트림의 모든 요소가 주어진 조건을 만족하는지 검사
        // 모든 요소가 조건을 만족하지 않으면 true, 하나라도 만족하면 fasle
        boolean result3 = integers.stream().noneMatch(integer -> integer > 10);
        System.out.println("result3 = " + result3);
    }
}

 

reduce 사용 예시

reduce 메소드는 스트림의 모든 요소를 하나의 결과로 합치는 작업을 수행하는데 사용됩니다. 이 작업은 누적 연산이라고도 하며 이는 스트림의 요소들을 반복적으로 처리하여 그 결과를 축척하는 방식으로 진행됩니다. reduce는 함수형 프로그래밍의 핵심 개념 중 하나로, 람다, 표현식과 함께 사용되어 강력한 데이터 처리 연산을 가능하게 합니다.

 

reduce 기본 형태

Optional<T> reduce(BinaryOperator<T> accumulator);
T reduce(T identity, BinaryOperator<T> accumulator);
<U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner);

 

 

reduce 사용 방법 

public class TerminalOperationsSteamExam2 {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        Integer reduce = numbers.stream()
                .reduce((a, b) -> a + b).get();
        System.out.println("reduce = " + reduce);

        int sum = numbers.stream()
                .reduce(0, (subtotal, element) -> subtotal + element);

        System.out.println("Sum of numbers: " + sum);

        // 메소드 참조를 사용한 더 간결한 방법
        int sumMethodReference = numbers.stream()
                .reduce(0, Integer::sum);

        System.out.println("Sum of numbers (method reference): " + sumMethodReference);
    }
}

 

 

Collectors 종단 연산에서의 다양한 함수

Collectors 클래스는 스트림의 요소들을 다양한 형태로 리듀스 하는 종단 연산을 위한 메서드들을 제공합니다. 여기에는 수집, 요약, 그룹화, 분할 등의 연산을 수행하는 메서드들이 포함되어 있습니다.

 

  1. toList() : 스트림의 모든 요소를 List에 수집합니다.
  2. toSet() : 스트림의 모든 요소를 중복 없이 Set에 수집합니다.
  3. toMap(Function keyMapper, Function valueMapper) : 다른 컬렉터의 결과에 추가적인 변호나 함수를 적용합니다.
  4. collectingAndThen(Collector downstream, Function finisher) : 다른 컬렉터의 결과에 추가적인 변환 함수를 적용합니다.
  5. joining(CharSequence Delimiter) : 스트림의 "String" 요소들을 하나의 "String" 으로 결합 합니다. 주로 구분자 지정에 사용됩니다.
  6. groupingBy(Function classifer) : 하나의 기준에 따라 요소들을 그룹화하고, 그 결과를 Map으로 수집합니다.
  7. partitioningBy(Predicate predicate) : 주어진 조건에 따르 스트림을 true or false 의 두 그룹으로 분할 합니다.
  8. counting() :  스트림의 요소 개수를 세어 반환합니다.
  9. maxBy(Comparator comparator) : 주어진 비교자를 사용하여 스트림에서 최대 요소를 찾습니다.
  10. minBy(Comparator comparator) : 주어진 비교자를 사용하여 스트림에서 최소 요소를 찾습니다.
  11. reducing(BinaryOperator reducer) : 스트림의 요소를 리듀싱 연산을 사용하여 축소합니다. 초기 값과 리듀서 함수를 제공할 수 있습니다.
// counting() == count() 두 함수는 같은 기능입니다.
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
System.out.println(" = " + numbers.stream().collect(Collectors.counting()));

// 중복 없이 Set에 수집 
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 4, 1);
Set<Integer> collect = numbers.stream().collect(Collectors.toSet());

// key, value 형태로 데이터를 변환합니다.
// collect1 = {1=1, 2=2, 3=3, 4=8, 5=5}
List<Integer> numbers = List.of(1, 2, 3, 4, 5,4);
Map<Integer, Integer> collect1 = 
        numbers.stream()
                .collect(Collectors.toMap(k -> k, v -> v, (v1, v2) -> v1 + v2));
System.out.println("collect1 = " + collect1);

// 다른 컬렉터의 결과에 추가적인 변환 함수를 적용합니다.
// collect2 = [[1, 2, 3, 4, 5, 4]]
List<Integer> numbers = List.of(1, 2, 3, 4, 5,4);
List<List<Integer>> collect2 = numbers.stream()
        .collect(Collectors.collectingAndThen(Collectors.toList(), List::of));
System.out.println("collect2 = " + collect2);

// String 요소들을 다른 String 요소와 결합 합니다.
String collect2 = List.of("john", "anthony", "park").stream()
        .collect(joining(","));
System.out.println("collect2 = " + collect2);

 

 

groupingBy

 

스트림의 요소들을 특정 기준에 따라 그룹화 하는 Collectors의 메소드중 하나 입니다. 이 연산은 컬렉션의 요소들을 분류하여 Map으로 수집하는데 사용되며, Collectors,groupingBy 함수를 이용하여 구현됩니다. groupingBy는 매우 유연하며, 단일 기준 뿐만 아니라 복합 기준으로도 그룹화 할 수 있습니다.

List<Person> people = List.of(
            new Person("John", 20),
            new Person("Henry", 22),
            new Person("Sarah", 30),
            new Person("John", 30),
            new Person("Jane", 20)
);

// 같은 나이 그룹핑
Map<Integer, Long> collect = people.stream()
        .collect(groupingBy(person -> person.getAge(), Collectors.counting()));
System.out.println("collect = " + collect);

// 같은 연령대 그룹핑
Map<String, Long> collect1 = people.stream()
        .collect(groupingBy(
                person -> {
                    if (person.getAge() < 20) return "10대 미만";
                    else if (person.getAge() < 30) return "20대";
                    else return "30대 이상";
                }, Collectors.counting()
        ));

// 좀더 복잡한 그룹핑
Map<String, Map<Integer, Long>> groupedByNamesAndAges = people.stream()
        .collect(groupingBy(Person::getName,
                groupingBy(Person::getAge, Collectors.counting())));
System.out.println("groupedByNamesAndAges = " + groupedByNamesAndAges);

 

 

partitioningBy

 

Stream API 종단 연산 Collectors의 메소드로 스트림의 요소들을 두 부분으로 분할하는데 사용됩니다. 이 메서드는 주어진 조건에 따라 스트림을 true, false 두 그룹으로 분할하여 결과는 Map<boolean, List<T>> 형태로 반환합니다. 이 때 "T"는 분할된 스트림의 데이터 타입을 나타냅니다.

 

아래 예시에서는 문자열 리스트의 길이가 5보다 큰 문자열과 그렇지 않은 문자열로 분할하는 방법을 보여줍니다.

public class TerminalOperationsCollectorsMethodPartitioningExam {
    public static void main(String[] args) {

        List<String> strings = List.of("apple", "cherry", "banana", "cat", "dog", "frog");

        Map<Boolean, List<String>> result = strings.stream()
                .collect(Collectors.partitioningBy(s -> {
                    if (s.length() > 5) {
                        return true;
                    } else {
                        return false;
                    }
                }));

        Map<Boolean, List<String>> collect = strings.stream()
                .collect(Collectors.partitioningBy(s -> s.length() > 5));

        System.out.println("result = " + result);
        System.out.println("collect = " + collect);

    }
}

'자바' 카테고리의 다른 글

Parallel Stream  (1) 2024.02.06
Optional<T>  (0) 2024.02.06
Lamda Method Reference  (0) 2024.01.31
Java Lamda 표현식과 Stream API  (1) 2024.01.31
함수형 프로그래밍과 일급객체  (0) 2024.01.31