[Java] Stream이란? 사용하는 이유와 활용 방법
Java에서는 배열, 컬렉션, 리스트 등을 사용해 많은 데이터를 처리합니다.
기존에는 반복문이나 반복자(iterator)를 사용해 데이터를 하나씩 꺼내 처리했지만, 이러한 방식은 코드가 길어지고 가독성이 떨어진다는 단점이 있습니다. 또한, 코드의 재사용성이 낮아 유지보수에도 어려움이 따릅니다.
이러한 불편함을 줄이고 더욱 직관적이고 효율적으로 데이터를 처리할 수 있도록 도와주는 것이 Stream API입니다. Stream을 활용하면 더욱 간결하고 가독성 높은 코드로 데이터를 다룰 수 있습니다.
이번 포스팅에서는 Java의 Stream이 무엇인지, 사용하는 이유와 활용 방법에 대해 알아보도록 하겠습니다.
Java Stream이란?
Stream API는 JDK 8 부터 추가된 기능
중 하나입니다. 컬렉션 데이터를 다양한 함수와 조합하여 필터링하고 가공하여 손쉽게 처리
할 수 있습니다.
기존의 반복문(for문)과 반복자(Iterator)를 사용하면 코드가 길어지고 가독성과 재사용성이 떨어지며 데이터 타입마다 다른 방식으로 다뤄야 하는 불편함이 있습니다. Stream은 데이터 소스를 추상화하여 일관된 방식으로 다룰 수 있도록 합니다. 데이터를 처리하는 다양한 메서드를 제공하여 코드의 재사용성을 높여줍니다.
Java Stream을 사용하는 이유
Java 8 이전에는 컬렉션(Collection)을 활용해 데이터를 처리하였습니다. 이는 코드가 길고 가독성이 떨어져 작업이 번거로웠습니다.
이러한 문제를 Java Stream API를 통해 해결할 수 있습니다. Stream을 사용하면 데이터를 처리하는 작업을 선언적이고 함수형 스타일로 표현할 수 있어 코드가 간결하고 가독성이 향상됩니다. 또한, filter, map, reduce 등의 메서드를 제공하여 데이터를 처리하는 과정이 직관적이고 재사용 가능한 코드로 변환되어 유지보수성과 효율성을 향상시킬 수 있습니다.
Java Stream을 사용할 때의 장점에 대해 더욱 자세히 알아보도록 하겠습니다.
불변성을 보장한다.
List<String> strList = Arrays.asList("나", "가", "다"); List<String> sortedList = strList.stream().sorted().collect(Collectors.toList());
Stream API는 원본의 데이터를 조회하여 원본의 데이터가 아닌 별도의 요소들로 Stream을 생성
합니다. 그래서 원본의 데이터로부터 읽기만 할 뿐이며, 정렬이나 필터링 등의 작업은 별도의 Stream 요소들에서 처리됩니다.
내부 반복으로 작업을 처리한다.
strStream.sorted().forEach(System.out::print); // 반복문이 forEach 함수 내부에 숨겨져 있다.
Stream을 사용하면 코드가 간결해지는 이유 중 하나는 내부 반복 때문입니다. 기존에는 반복문을 사용하기 위해 for, while 등의 명령어를 사용해야 했지만, Stream에서는 반복 명령어를 메서드 내부에 숨기고 있어, 보다 간결한 코드의 작성이 가능합니다.
일회용이다.
List<String> strList = Arrays.asList("나", "가", "다"); Stream<String> strStream = strList.stream(); strStream.sorted().forEach(System.out::print); strStream.filter(str -> str.equals("가")).forEach(System.out::print); // 사용 불가
Stream API는 일회용이기 때문에 한번 사용이 끝나면 재사용이 불가능합니다. Stream이 또 필요한 경우 별도의 Stream을 생성해주어야 하며, 닫힌 Stream을 다시 사용할 경우 IllegalStateException
이 발생합니다.
가독성 향상
// 반복문(for문)을 사용한 방식 public static void main(String[] args) { List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); int sum = 0; for (Integer number : numbers) { if (number % 2 == 0) { sum += number * 2; } } System.out.println(sum); }
// Java Stream API를 사용한 방식 public static void main(String[] args) { List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); int sum = numbers.stream() .filter(number -> number % 2 == 0) // 짝수 필터링 .map(number -> number * 2) // 2배로 변환 .reduce(0, Integer::sum); // 값 모두 더하기 System.out.println(sum); }
1 부터 10 까지의 숫자 리스트에서 짝수를 2배하여 모두 더하는 예제를 기존의 반복문 방식과 Stream 방식을 통해 비교해 보았습니다.
Stream API는 기존의 명령형 방식 대신 선언형 방식으로 코드를 작성
할 수 있습니다. 그리고 여러 작업을 연속적으로 처리하는 체이닝
을 사용하여, 코드의 가독성이 증가합니다.
유지보수성 향상
Java Stream은 명령형 접근 방식 대신 선언형 접근 방식으로 코드를 작성합니다. 이전 반복문에는 코드가 각 단계를 명시적으로 처리해야 했고, 그로 인해 코드가 길어지고 복잡해졌지만, Stream을 사용하면 필요한 작업을 한 줄의 코드로 선언적으로 표현할 수 있어 코드가 직관적이고 간결해져 가독성과 유지보수성이 향상됩니다.
그리고 Stream API는 불변성을 유지하면서 데이터를 처리
합니다. 즉, 데이터를 변경하지 않고 새로운 값을 생성하는 방식으로, 코드에서 데이터를 수정하거나 변경할 때 발생할 수 있는 사이드 이펙트가 줄어듭니다.
이는 유지보수에 있어서 중요한 요소이며, 데이터를 변경하는 코드가 많으면, 나중에 의도하지 않은 버그가 발생할 확률이 커지기 때문입니다.
병렬처리 지원
public static void main(String[] args) { List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); int sum = numbers.parallelStream() // 병렬처리 .filter(number -> number % 2 == 0) // 짝수 필터링 .map(number -> number * 2) // 2배로 변환 .reduce(0, Integer::sum); // 값 모두 더하기 System.out.println(sum); }
Stream에서 지원하는 병렬처리는 데이터의 흐름을 나누어 멀티 스레드로 병렬처리하고 이후 합치는 과정을 통해 대량의 데이터를 빠르고 쉽게 처리할 수 있습니다.
데이터 병렬처리는 멀티 스레드를 사용하기 때문에 데이터를 효율적으로 처리할 수 있습니다. Java Stream API는 parallel() 또는 parallelStream() 연산을 추가하여 병렬처리가 가능합니다.
Stream API의 연산 구조

생성 → 가공 → 결과
Stream은 데이터를 처리하기 위해 데이터를 생성하고, 생성된 데이터를 가공하여 필요한 형태로 변환 후 최종적으로 결과를 소비합니다.
Stream 생성
// 1. 배열 Stream String[] arr = new String[]{"가", "나", "다"}; Stream<String> strStream = Arrays.stream(arr); // 생성 // 2. 컬렉션 Stream List<String> list = Arrays.asList("가", "나", "다"); Stream<String> strStream = list.stream(); // 생성 // 3. Stream.builder() Stream<String> builderStream = Stream.<String>builder() .add("가") .add("나") .add("다") .build(); // 4. 람다식 Stream.generate(), iterate() Stream<String> generatedStream = Stream.generate(() -> "가").limit(3); // 생성 시 Stream의 크기가 정해져있지 않기 때문에 최대 크기를 제한 Stream<Integer> iteratedStream = Stream.iterate(0, n -> n + 2).limit(5); // 0, 2, 3, 6, 8 // 5. 기본 타입형 스트림 IntStream intStream = IntStream.range(1, 5); // 1, 2, 3, 4 // 6. 병렬 Stream : parallelStream() Stream<String> parallelStream = list.parallelStream();
생성은 데이터 컬렉션을 Stream으로 변환하는 과정으로 Stream을 만들어 내는 것을 의미합니다. Stream API를 사용하여 가공하기 위해서 최초 1번 수행되어야 합니다.
생성 단계에서는 모든 데이터가 한꺼번에 메모리에 로드되지 않고 필요할 때만 로드
됩니다. 이는 대량의 데이터셋에서 메모리 사용량을 최적화하고, 불필요한 데이터를 로드하지 않아 효율적입니다.
중간 연산 (가공하기)
데이터에 대한 중간 연산으로 filter, map 등을 통해 가공하는 역할을 수행합니다.
filter : 필터
// Filtering List<String> strList = Arrays.asList("가", "나", "다"); Stream<String> strStream = strList.stream().filter(str -> str.contains("가"));
Stream 내 요소들을 Filtering하는 작업, if문 역할 (List내의 "가" 가 표함된 요소만 선택)
map : 변환
List<String> strList = Arrays.asList("a", "b", "c"); Stream<String> strStream = strList.stream().map(String::toUpperCase); // A, B, C 대문자 변환 List<String> strList = Arrays.asList("1", "2", "3"); Stream<Integer> integerStream = strList.stream().map(Integer::parseInt); // 문자열 → 정수 변환 IntStream intStream = IntStream.rangeClosed(1, 100).map(n -> n * n); // 1 부터 100까지의 숫자를 제곱하여 변환
Sorted : 정렬
// strList 오름차순 정렬 List<String> strList = Arrays.asList("a", "b", "c"); Stream<String> sorted = strList.stream().sorted(); // IntStream 오름차순 정렬 Stream<Integer> sorted = IntStream.rangeClosed(1, 100) .boxed() // IntStream을 Stream<Integer>로 변환 .sorted(); // 문자열 길이 기준 오름차순 정렬 List<String> strList = Arrays.asList("aa", "bbb", "c"); Stream<String> sorted = strList.stream().sorted(Comparator.comparingInt(String::length)); // strList 내림차순 정렬 List<String> strList = Arrays.asList("a", "b", "c"); Stream<String> sorted = strList.stream().sorted(Comparator.reverseOrder()); // IntStream 내림차순 정렬 Stream<Integer> sorted = IntStream.rangeClosed(1, 100) .boxed() // IntStream을 Stream<Integer>로 변환 .sorted(Comparator.reverseOrder()); // 문자열 길이 기준 내림차순 정렬 List<String> strList = Arrays.asList("aa", "bbb", "c"); Stream<String> sorted = strList.stream().sorted(Comparator.comparingInt(String::length).reversed());
peek : 연산 중간 처리 내용 출력
Stream<Integer> sorted = IntStream.rangeClosed(1, 100) .map(n -> n * n) .peek(System.out::println) .boxed() .sorted(Comparator.reverseOrder());
연산 중간에 처리 된 내용을 살펴봅니다.
distinct : 중복 제거
// 중복되는 문자열 제거 List<String> strList = Arrays.asList("aa", "bbb", "c", "a", "aa"); Stream<String> distinct = strList.stream() .map(String::toUpperCase) .sorted() .distinct(); // 1 부터 100 까지의 숫자를 10으로 나눈 나머지 값 중복 제거 IntStream distinct = IntStream.rangeClosed(1, 100) .map(n -> n % 10) .distinct();
limit : 개수 제한
IntStream limit = IntStream.rangeClosed(1, 100).limit(10);
1 부터 100 까지의 숫자 중 처음 10개의 숫자만 사용합니다
skip : 지정한 개수만큼 skip
IntStream skip = IntStream.rangeClosed(1, 100).skip(10);
1 부터 100 까지의 숫자 중 10개를 skip하고 사용합니다.
이러한 중간 연산을 거치면 새로운 데이터의 Stream이 만들어집니다.
최종 연산 (결과 만들기)
Stream에 대한 최종 연산을 수행하는 것을 의미하며, 최종적인 목적물을 얻는 처리 과정입니다. 최종 연산을 수행하면 데이터 컬렉션이나 하나의 값으로 변환되어 결과물을 얻을 수 있습니다.
최종 연산은 결과물을 얻기 위해 1번만 수행할 수 있습니다. 최종 연산이 수행되면, Stream은 닫혀서 더 이상 Stream API로 중간 연산이나 최종 연산을 다시 처리할 수 없습니다. 데이터에 대한 추가적인 가공이 필요할 경우 Stream을 새로 생성해서 작업해야 합니다.
Stream의 데이터 연산 처리 시 지연평가(Lasy Evaluation)
의 특징이 존재합니다.
지연 평가(Lasy Evaluation)
Stream의 중간 연산은 데이터를 변환하거나 필터링하는 역할을 하지만 즉시 실행되지 않고 최종 연산이 호출될 때 실행되는데, 이를 지연 평가(Lasy Evaluation)
라고 합니다.
즉, 중간 연산이 여러 개 있어도 최종 연산이 호출될 때 한 번에 처리
되는 것을 의미합니다.
연산
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 1. count: 짝수 개수 구하기 long count = numbers.stream().filter(n -> n % 2 == 0).count(); // 2. sum: 합계 구하기 int sum = numbers.stream().mapToInt(Integer::intValue).sum(); // 3. min: 최소값 찾기 Optional<Integer> min = numbers.stream().min(Integer::compareTo); // 4. max: 최대값 찾기 Optional<Integer> max = numbers.stream().max(Integer::compareTo); // 5. average: 평균값 구하기 OptionalDouble average = numbers.stream().mapToInt(Integer::intValue).average();
기본형 타입을 사용하는 경우 스트림 내 요소들로 count(), sum(), min(), max(), average() 등
을 사용하여 연산을 수행할 수 있습니다.
reduce : 요소의 소모
// 1. 1 ~ 5 까지 곱셈 수행 (결과 = 120) int reduce = IntStream.rangeClosed(1, 5).reduce(1, (x, y) -> x * y); // 2. 1 ~ 5 까지 덧셈 수행 (결과 = 16) int reduce = IntStream.rangeClosed(1, 5).reduce(1, Integer::sum);
Stream의 요소를 하나씩 줄여가며 누적 연산을 수행합니다.
collect : 요소의 수집
List<Student> students = Arrays .asList(new Student("tao", 20), new Student("tistory", 22), new Student("blog", 22)); // 1. toList 리스트 변환 (결과 = [tao, tistory, blog]) List<String> toList = students.stream() .map(Student::getName) .collect(Collectors.toList()); // 2. joining 작업 결과를 하나의 문자열로 이어붙이기 (결과 = Hi! tao tistory blog!) String joining = students.stream() .map(Student::getName) .collect(Collectors.joining(" ", "Hi! ", "!")); // 3. groupingBy 나이별로 그룹화하여 Map으로 변환 (결과 = {20=[tao], 22=[tistory, blog]}) Map<Integer, List<Student>> groupingBy = students.stream() .collect(Collectors.groupingBy(Student::getAge));
Stream의 요소를 수집하여 원하는 형태로 변환하기 위해 사용됩니다. Java Collector 인터페이스를 매개변수로 호출하는데, Java에서 제공하는 Collectors 클래스에서 이미 만들어둔 메서드를 통해 요소를 변환해서 사용합니다.
Java 16 이상 부터 toList(), toArray()는 .collect()없이 사용할 수 있습니다.
match : 요소의 검사
List<String> strList = Arrays.asList("tao", "tistory", "blog"); // 1. anyMatch 하나라도 만족하면 true boolean match = strList.stream().anyMatch(str -> str.contains("t")); // true // 2. allMatch 모두 만족해야 true boolean match = strList.stream().allMatch(str -> str.contains("t")); // false boolean match = strList.stream().allMatch(str -> str.length() > 2); // true // 3. noneMatch 하나라도 만족하면 false, 만족하지 않아야 true boolean match = strList.stream().noneMatch(str -> str.contains("t")); // false boolean match = strList.stream().noneMatch(str -> str.length() > 2); // false boolean match = strList.stream().noneMatch(str -> str.contains("z")); // true
find : 요소 찾기
List<String> strList = Arrays.asList("tao", "tistory", "blog"); // 1. findFirst 첫번째 요소 반환 Student findFirst = students.stream().findFirst().orElseThrow(); // 2. findAny 먼저 찾은 요소 하나 반환 Student findAny = students.stream().findAny().orElseThrow();
findFirst()
는 병렬 스트림에서도 첫 번째 요소를 반환하여, 병렬 처리 중에도 순차적인 순서를 보장합니다. 그러나 병렬 처리 환경에서 성능이 떨어질 수 있습니다.
findAny()
는 병렬 스트림에서 요소를 처리하는 순서에 관계없이 원하는 요소를 빠르게 찾을 수 있어 더 효율적입니다. 그러나 병렬 처리 환경에서 순서를 보장하지 않습니다.
forEach : 스트림 각 요소에 대한 작업을 수행
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); // 1. 요소 출력 numbers.stream().forEach(System.out::println); // 2. 각 요소에 2를 곱해서 출력 numbers.stream().forEach(n -> System.out.println(n * 2)); // 3. 1 ~ 100 숫자를 각각 출력 IntStream.rangeClosed(1, 100).forEach(System.out::println); // 중간연산이 없다면 stream을 호출하지 않아도 됨 numbers.forEach(System.out::println);
마무리
Java의 Stream API는 단순히 데이터 처리만을 위한 도구가 아니며, 기존의 반복문과 명령형 접근 방식에서 벗어나 함수형 프로그래밍 스타일의 코드를 작성하여 가독성과 유지보수성을 크게 향상시킵니다. Stream API를 사용하면 불필요한 반복문과 복잡한 로직을 줄이고, 데이터를 처리하는 과정을 선언적으로 작성하여 직관적으로 표현할 수 있습니다.
이처럼 Stream API는 단순히 코드를 간결하게 만들어주는 것 이상으로 더 깨끗하고 효율적이게 유지보수하기 좋은 코드를 작성하는데 도움을 줍니다.
'Java' 카테고리의 다른 글
Entity에 Setter사용을 지양하는 이유 feat.DTO, Entity 간 변환 (0) | 2024.09.26 |
---|---|
Java - Scanner 클래스 next(), nextLine()의 차이 (0) | 2024.05.27 |
댓글을 사용할 수 없습니다.