[JAVA] 18-4. 스트림을 이용한 데이터 처리 방법

Re_Go·2024년 6월 13일
0

JAVA

목록 보기
31/37
post-thumbnail

1. 컬렉션의 반복 처리 방법

앞서 소개해드린 컬렉션에서 List, Set, Map 클래스의 배열 순회는 for문 혹은 enhanced-for문, iterator를 주로 사용
한다고 했는데요. 여기서는 그 방법을 간단하게 보겠습니다.

// 1. List 
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("orange");

// List를 for 루프로 반복
for (String item : list) {
     System.out.println(item);
}

// 2. Set
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(3);

// Enhanced for 문을 사용하여 Set을 순회
for (Integer item : set) {
     System.out.println(item);
}

// 3. Map
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("orange", 3);
for (String key : map.keySet()) {
    System.out.println("Key: " + key + ", Value: " + map.get(key));
}

// Map의 엔트리셋을 for 루프로 반복
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}

위의 코드에서 지금은 각각의 컬렉션이 enhanced-for문을 사용하였지만, 추가 작업을 하는 코드를 작성하단다면 for문의 가독성은 떨어질 수 있는데요.

그래서 자바에서는 컬렉션 마다 순회 하면서 다양한 작업을 가능하게 해주는 API 또한 제공 합니다. 바로 스트림 이라고 하죠.

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class StreamExample {
    public static void main(String[] args) {
        // List
        List<String> list = new ArrayList<>();
        list.add("apple");
        list.add("banana");
        list.add("orange");

        // List에 스트림 적용하여 출력
        list.stream().forEach(item -> System.out.println(item));

        // Set
        Set<Integer> set = new HashSet<>();
        set.add(1);
        set.add(2);
        set.add(3);

        // Set에 스트림 적용하여 출력
        set.stream().forEach(item -> System.out.println(item));

        // Map
        Map<String, Integer> map = new HashMap<>();
        map.put("apple", 1);
        map.put("banana", 2);
        map.put("orange", 3);

        // Map의 Key에 스트림 적용하여 출력
        map.keySet().stream().forEach(key -> System.out.println("Key: " + key));

        // Map의 Value에 스트림 적용하여 출력
        map.values().stream().forEach(value -> System.out.println("Value: " + value));

        // Map의 Entry에 스트림 적용하여 출력
        map.entrySet().stream().forEach(entry -> System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue()));
    }
}

위의 코드 예제는 각 컬렉션 중 ArrayList, HashSet, HashMap 객체를 할당 받은 변수들이 Collection에서 제공하고 있는 stream 메서드를 이용하여 forEach 스트림 메서드로 배열을 순회하며 코드문을 실행한 예제인데요.

이처럼 Collection에서 제공하는 stream 메서드를 사용한다면 각 컬렉션은 복잡한 for문을 사용할 필요 없이 배열 순회 뿐만 아니라 여러 작업을 stream 메서드의 부속 메서드에 의해 처리 할 수 있습니다.

바꿔 말하면 컬렉션마다 사용할 수 있는 각각의 메서드로 복잡한 반복 처리를 하는게 아니라, stream이라는 단일 파이프 라인을 구성하고, 그 안에서 공통적으로 사용할 수 있는 메서드를 사용하여 보다 간결하고 효율적인 반복 처리가 가능 하다는 것이죠.

2. 외부 반복자 VS 내부 반복자

이처럼 앞서 살펴본 컬렉션의 반복 작업 처리를 위한 반복자로는 for문(enhanced-for문 포함), iterator, stream이 존재하는데요. 여기서 for문과 iterator는 외부 반복자
, stream은 내부 반복자
에 속합니다.

그리고 방금 언급했던 외부, 내부 반복자를 살펴본다면 다음과 같은데요.

  1. 외부 반복자 (external iterator) : 개발자가 컬렉션의 반복 과정을 직접 설계하고 제어하는 방식으로, 주로 명령형 프로그램에서 사용되며 코드가 복잡해질 수 있다는 단점이 존재하나 그만큼 반복 제어 과정에서 개발자의 의도대로 유연한 코드 제어 작업이 가능합니다.
// 컬렉션 리스트 생성
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("orange");

// for문을 이용한 컬렉션 반복 작업
for (String item : list) {
	System.out.println(item);
}

// iterator를 이용한 컬렉션 반복 작업
Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
      String item = iterator.next();
      System.out.println(item);
	}
  1. 내부 반복자(internal iterator) : 컬렉션 내부에서 반복 작업을 캡슐화 한 형태의 반복자로서 함수형 프로그래밍 (람다식이 적용 가능한)에 주로 사용되며, 코드가 간결해지고 가독성이 향상되며, 병렬 처리에 용이하다는 특징이 존재합니다.) 그러나 컬렉션 내부에서 자동 처리되는 만큼 개발자가 직접 제어 과정에 참여하는대에 제한적입니다.
// 컬렉션 리스트 생성
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("orange");

list.stream().forEach(item -> System.out.println(item));

외부 반복자와 내부 반복자의 차이에 대해 요약한 사진은 다음과 같습니다.

(자료 출처 : https://cornswrold.tistory.com/293)

3. 스트림의 뜻

내부 반복자를 사용하기 위해 필요한 stream API는 다음과 같은 특징을 가지고 있습니다.

  1. 연속된 요소 처리: 스트림은 연속된 요소들의 집합을 나타냅니다. 이것은 컬렉션의 요소뿐만 아니라 파일의 행, 배열 등 다양한 소스에서 가져온 요소들을 처리할 수 있습니다.
// 배열에서 스트림 생성
int[] numbers = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(numbers);

// 스트림을 이용한 요소 처리
stream.forEach(System.out::println);
  1. 데이터 변환: 다양한 데이터 소스를 스트림으로 변환하여 데이터를 처리할 수 있습니다. 예를 들어, 배열, 파일, 데이터베이스 등의 데이터 소스를 스트림으로 변환하여 데이터를 처리할 수 있습니다.
// 파일에서 스트림 생성
Stream<String> lines = Files.lines(Paths.get("file.txt"));

// 스트림을 이용한 데이터 변환
lines.map(String::toUpperCase)
     .forEach(System.out::println);
  1. 컬렉션 처리: 컬렉션에 저장된 요소들을 효율적으로 처리할 때 스트림을 사용할 수 있습니다. 예를 들어, 요소를 필터링하거나 변환하는 등의 작업을 수행할 수 있습니다.
// 리스트 생성
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// 스트림을 이용한 컬렉션 처리
names.stream()
     .filter(name -> name.startsWith("A"))
     .forEach(System.out::println);
  1. 병렬 처리: 스트림은 요소를 병렬로 처리할 수 있는 기능을 제공합니다. 이를 통해 멀티코어 CPU를 활용하여 성능을 향상시킬 수 있습니다.
// 배열에서 병렬 스트림 생성
int[] numbers = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(numbers).parallel();

// 병렬 스트림을 이용한 연산 수행
stream.forEach(System.out::println);
  1. 대용량 데이터 처리: 대용량 데이터를 처리할 때 스트림을 사용하면 메모리를 효율적으로 관리할 수 있습니다. 스트림은 필요한 요소만을 처리하므로 대용량 데이터를 효율적으로 처리할 수 있습니다.
// 대용량 데이터를 포함한 파일에서 스트림 생성
Stream<String> lines = Files.lines(Paths.get("large_data.txt"));

// 스트림을 이용한 대용량 데이터 처리
lines.filter(line -> line.contains("keyword"))
     .forEach(System.out::println);
  1. 함수형 프로그래밍: 스트림은 함수형 프로그래밍을 지원합니다. 함수형 인터페이스와 람다 표현식을 사용하여 간결하고 가독성 있는 코드를 작성할 수 있습니다.
// 리스트 생성
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// 스트림을 이용하여 요소들의 합을 구함
int sum = numbers.stream()
                 // 초기값 0과 람다식을 사용하여 모든 요소를 더함
                 .reduce(0, (a, b) -> a + b);

// 합계 출력
System.out.println("Sum: " + sum);

이러한 스트링의 특징이라고 한다면, 원본 데이터를 변경하지 않고 원하는 값을 필터링 하여 획득 할 수 있고, 최종 연산을 마친 스트림은 이후에 사용할 수 없다는 특징(Iterator와 마찬가지로 일회용)을 가지는데요.

코드를 보다보면 stream을 생성한 뒤 map, foreach, filter 등의 추가 스트림들이 부여되는데요. 이처럼 순회의 한 과정에서 stream과 그 외 여러 스트림의 결합스트림 파이프라인 이라고 부릅니다.

앞서 소개해드린 스트림 파이프라인은 간단히 말해서 데이터의 일련의 정제 과정을 의미하는데요. 크게 생성 - 중간 연산 - 최종 연산 으로 나누어지며, 중간 연산에서는 filter, map, distinct 등이 존재하고, 최종 연산에서는 거의 forEach를 사용합니다. (최종 연산 메서드에 대해 궁금한 사항은 다음 블로그를 참조해 주세요)

(자료 출처 : https://yooniron.tistory.com/36)

4. stream 생성 과정

우선 스트림은 다음과 같은 여러 데이터들에 의해 생성 되는데요.

Integer[] integers = {1, 2, 3, 4, 5};
Double[] doubles = {1.1, 2.2, 3.3, 4.4, 5.5};
String[] strings = {"apple", "banana", "cherry", "date", "elderberry"};

// 배열을 리스트로 변환
List<Integer> integerList = Arrays.asList(integers);
List<Double> doubleList = Arrays.asList(doubles);
List<String> stringList = Arr	ays.asList(strings);

// 리스트를 스트림으로
Stream<Integer> intStream = integerList.stream();
Stream<Double> doubleStream = doubleList.stream();
Stream<String> stringStream1 = stringList.stream();
Stream<String> stringStream2 = Arrays.stream(new String[] {"Re_Go", "James", "Tomas"}, 0,3);
Stream<Charactor> charStream = stream.of(new char[] {'A', 'B', 'C'})

위의 코드를 보다보면 대강 눈치 채셨겠지만 스트림은 리스트의 타입과 일치하는 타입으로 생성되며, 이미 생성되어 있는 리스트를 넘겨줄 수도 있고, 리스트가 아닌 다른 배열과 같은 데이터 집합으로도 전달이 가능합니다.

또한 두 쌍 이상으로 이루어진 객체 형식의 데이터 집합으로도 아래와 같이 스트림 생성이 가능합니다.

// 학생 클래스 정의
static class Student {
        private String name;
        private boolean passed;

        // 생성자
        public Student(String name, boolean passed) {
            this.name = name;
            this.passed = passed;
        }

        // Getter 메서드
        public String getName() {
            return name;
        }

        public boolean isPassed() {
            return passed;
        }
    }

List<Student> students = new ArrayList<>();
students.add(new Student("Alice", true));
students.add(new Student("Bob", false));
students.add(new Student("Charlie", true));
students.add(new Student("David", false));

Stream<Student> studentStream1 = students.stream();
Stream<Student> studentStream2 = stream.of(new Student ("James", true), new Student("Nick", false);

특히 위에서처럼 기본적인 방법으로 스트림을 생성할 때 기존의 데이터 타입의 값들이 해당 타입의 객체로 변환되는데요. (예를 들어 배열에 문자 "Re_Go"가 들어있다 치면 new String("Re_Go") 작업이 추가되는 듯)

이때 발생되는 오토박싱이나, 역변환(언박싱) 작업을 줄일수 있도록 기본 데이터타입에 따른 전용 스트림 생성 방식 또한 제공됩니다.

이를 기본형 스트림 이라고 부르죠. 물론 모든 타입에 대한 기본형 클래스가 있다는건 아니고, 어디까지나 Int, Long, Double 같은 기본형 타입의 데이터 집합을 위한 스트림 타입이라는 점에 주의해야 합니다.

 // 기본형 배열
        int[] intArray = {1, 2, 3, 4, 5};
        double[] doubleArray = {1.1, 2.2, 3.3, 4.4, 5.5};
        String[] stringArray = {"A", "B", "C"};
        char[] charArray = {'A', 'B', 'C'};

        // 기본형 배열을 스트림으로 변환
        IntStream intStream = IntStream.of(intArray);
        DoubleStream doubleStream = DoubleStream.of(doubleArray);
        Stream<String> stringStream = Stream.of(stringArray);
        // chars() 메서드는 문자 값을 UTF-16 유니코드인 int로 변환하여 스트림으로 반환하기 때문에 IntStream 사용이 가능합니다.
        IntStream charStream = new String(charArray).chars();

이러한 기본형 스트림을 이용한 생성 방법은 일반적인 스트림 생성 방법(Stream)과는 다르게 해당 타입에 맞는 메서드를 사전에 더욱 많이 제공해 줄 수 있다는 장점이 존재합니다.

정리하자면 원본이 되는 List의 타입이 기본형일 때 해당 타입의 기본형 스트림을 사용
할 수 있으며, 이를 제외한 클래스나 인터페이스 타입의 경우는 기본 스트림 방식으로 생성해야 합니다.

// 기본형 스트림 생성법 (int에 대한)
int[] intArr = {1, 2, 3, 4, 5};
IntStream intStream1 = Arrays.stream(intArr);

// 기본적인 스트림 생성법 (기본형을 제외한)
Integer[] integerArr = {1, 2, 3, 4, 5};
Stream<Integer> intStream2 = Arrays.stream(integerArr);

System.out.println(intStream1.sum()) // 1부터 5까지를 다 더한 15 출력
System.out.println(intStream2.sum()) // 숫자형 메서드를 사용하지 못하므로 에러 발생

// 일반적인 스트림에서 타입 메서드를 사용하려면 언박싱 과정을 거쳐 기본형 데이터로 반환 후 해당 타입의 메서드를 사용해야 합니다.
int convertedIntStream = intStream2.mapToInt(Integer::intValue);
System.out.println(convertedIntStream.sum());

5. stream의 기본 구성 메서드를 활용한 연산 과정

이렇게 생성된 stream은 기본적으로 중간 연산 후 최종 연산을 거치게 되는데요. 중간 연산에 사용되는 메서드들은 다음과 같습니다.

  1. map(람다식 또는 추상연산) : 스트림의 각 요소를 특정 함수에 매핑하여 새로운 요소로 변환합니다.
stream.map(Function.identity())
  1. filter(람다식 또는 추상연산) : 주어진 조건을 만족하는 요소만을 필터링합니다.
stream.filter(element -> element > 5)
  1. sorted(람다식 또는 추상연산) : 요소들을 정렬합니다. 기본적으로는 Comparable 인터페이스를 구현한 클래스의 요소에 대해 자연 순서로 정렬됩니다.
stream.sorted()
  1. collect(Collectors.groupingBy()) : 요소들을 특정 기준에 따라 그룹화합니다. 예를 들어, 같은 특성을 가진 요소들을 그룹화할 수 있습니다.
stream.collect(Collectors.groupingBy(Person::getCity))
  1. distinct() : 스트림에서 중복을 제거하는 데 사용됩니다.
stream.distinct();
  1. limit() : 스트림에서 처음 n개의 요소만을 반환합니다.
stream.limit(3);
  1. skip() : 스트림에서 처음 n개의 요소를 건너뛴 후의 나머지 요소들을 반환합니다.
stream.skip(2);
  1. flatMap() : 각 요소를 하나 이상의 새로운 스트림으로 매핑하고, 모든 결과 스트림을 하나의 스트림으로 연결합니다.
stream.flatMap(list -> list.chars().mapToObj(c -> (char) c));
// 결과값은 : [H, e, l, l, o, W, o, r, l, d]

그럼 기본적인 리스트 배열의 데이터를 처리해 보도록 하겠습니다.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        Object[] array = {1, "Re_Go", 30, false, 60, "re_go"};

        // 배열을 ArrayList로 변환
        List<Object> list = new ArrayList<>(Arrays.asList(array));

        // 맵핑 (Mapping): 문자열인 요소만 추출
        List<String> mappedList = list.stream()
                                     .filter(element -> element instanceof String)
                                     .map(String.class::cast)
                                     .toList();
        System.out.println("Mapping 결과: " + mappedList);

        // 필터링 (Filtering): 숫자인 요소만 추출
        List<Integer> filteredList = list.stream()
                                         .filter(element -> element instanceof Integer)
                                         .map(Integer.class::cast)
                                         .toList();
        System.out.println("Filtering 결과: " + filteredList);

        // 정렬 (Sorting): 숫자를 오름차순으로 정렬
        List<Integer> sortedList = list.stream()
                                       .filter(element -> element instanceof Integer)
                                       .map(Integer.class::cast)
                                       .sorted()
                                       .toList();
        System.out.println("Sorting 결과: " + sortedList);

        // 그룹화 (Grouping): 문자열 요소를 길이에 따라 그룹화
        Map<Integer, List<String>> groupedMap = list.stream()
                                                    .filter(element -> element instanceof String)
                                                    .map(String.class::cast)
                                                    .collect(Collectors.groupingBy(String::length));
        System.out.println("Grouping 결과: " + groupedMap);
    }
}

6. 무한 스트림

앞서 살펴본 방법들은 데이터의 개수가 제한이 되어있는 스트림, 즉 유한 스트림인데요. 데이터의 개수가 제한이 되어있지 않은 스트림무한 스트림 또한 생성이 가능하며, 생성 방법은 다음과 같습니다.

  1. iterate를 이용한 생성 방법 : iterator와 람다식을 이용해 조건에 맞는 값을 생성하는 방식입니다. 초기값과 람다식으로 구성되며, 각 요소들의 결과값은 이전 결과값과 연속적으로 이어집니다. (n -> n + 1에서 n은 이전의 결과값을 의미함.)
// 1. 1부터 시작하여 2씩 증가하는 무한 스트림 (일반 스트림)
Stream<Integer> infiniteStream = Stream.iterate(1, n -> n + 2);

// 0부터 시작하여 1씩 증가하는 무한 스트림 (기본형 스트림)
IntStream infiniteIntStream = IntStream.iterate(0, n -> n + 1);
  1. generate를 이용한 생성 방법 : generator와 람다식을 이용해 조건에 맞는 값을 생성하는 방식입니다. 특히 generator는 각 요소가 서로 독립적이며, Iterator에 비해 좀 더 구체적인 랜덤 생성 방식을 제공합니다.
// 무작위 정수 생성 무한 스트림
Stream<Integer> infiniteRandomStream = Stream.generate(Math::random);
// 일정 범위에서 무작위 정수 생성 기본형 타입의 무한 스트림
IntStream infiniteRandomStream = Stream.generate(() -> (int) (Math.random() * 100));
profile
인생은 본인의 삶을 곱씹어보는 R과 타인의 삶을 배워 나아가는 L의 연속이다.

0개의 댓글