DEV&OPS/Java

함수형 패러다임 자바 stream

ALEPH.GEM 2022. 5. 17. 14:29
728x90

함수형 프로그래밍

함수형 프로그래밍(Functional Programming, FP)은 자료 처리를 수학적 함수의 계산으로 취급하고, 프로그램의 상태(State)와 가변 데이터를 멀리하는 프로그래밍 패러다임 중 하나입니다. 

객체 지향 프로그래밍(OOP)이나 명령형 프로그래밍(Imperative Programming)이 어떻게(How) 상태를 바꾸는 일련의 명령어에 집중하는 반면, 함수형 프로그래밍은 무엇(What)을 할지 함수의 응용(Application)에 집중합니다.

 

함수형 프로그래밍은 다음과 같은 이점을 제공합니다.

  • 예측 가능성 향상: 순수 함수와 불변성 덕분에 코드가 더 명확하고 예상치 못한 버그(Side Effect) 발생 가능성이 줄어듭니다.
  • 테스트 용이성: 외부 환경에 의존하지 않는 순수 함수 덕분에 단위 테스트(Unit Test)를 작성하기 매우 쉽습니다.
  • 병렬 처리 용이성: 상태를 변경하지 않는 불변 데이터 덕분에, 멀티 코어 환경에서 스레드 간의 동기화 문제 없이 안전하게 병렬 처리를 할 수 있습니다.
  • 코드 간결성: 함수 조합 및 람다 표현식을 통해 코드가 간결해지고 가독성이 향상됩니다.

 

1. 순수 함수 (Pure Function)

  • 동일한 입력에는 항상 동일한 출력을 반환합니다. 
  • 함수 외부의 상태를 변경하지 않습니다 (부수 효과/Side Effect 없음)부수 효과의 예: 전역 변수 수정, 콘솔 출력, 파일/네트워크 I/O, 데이터베이스 변경 등.

장점: 순수 함수는 독립적이어서 예측 가능하고, 병렬 처리에 안전합니다.

 

2. 불변성 (Immutability)

한번 생성된 데이터의 값을 변경하지 않는 것을 의미합니다.

데이터를 수정해야 할 경우, 기존 데이터를 변경하는 대신 새로운 데이터를 생성하여 반환합니다.

 

장점: 프로그램의 상태 변화 추적이 쉬워져 오류 발생 가능성이 낮아지고 동시성(Concurrency) 처리가 용이해집니다.

 

3. 일급 함수 (First-Class Function)

함수가 다른 변수와 동일하게 취급되는 것을 의미합니다.

  • 변수에 할당될 수 있습니다.
  • 다른 함수의 인자로 전달될 수 있습니다. (콜백 함수)
  • 다른 함수의 반환 값으로 사용될 수 있습니다.

자바의 람다 표현식은 함수를 일급 객체처럼 다룰 수 있게 해주는 핵심 기능입니다.

 

4. 고차 함수 (Higher-Order Function)

고차 함수는 다음 중 하나 이상을 수행하는 함수입니다.

  • 함수를 인자로 받습니다.
  • 함수를 반환합니다.

예를 들어, map, filter, reduce와 같은 함수들이 고차 함수에 해당합니다.

자바의 Stream API를 사용할 때 이 개념을 자주 접하게 됩니다.

 

 

스트림 stream

자바8부터 추가된 컬렉션(배열)을 람다식으로 처리할 수 있도록 해줍니다.

스트림처럼 일괄 처리가 필요할 때 보통 병렬로 처리하게 됩니다.

함수형 프로그래밍처럼 람다식으로 처리하면 병렬 처리에 안전하므로 도입되었습니다.

자바7이전에는 컬렉션(리스트, 배열 등) 순차처리를 위해  for나 while 루프나 Iterator를 사용해서 컬렉션의 요소를 하나씩 꺼내어 "어떻게(How)" 처리할지(예: 인덱스 관리, 조건 비교)를 직접 코드로 작성했습니다.

하지만 Stream API를 사용하면, "무엇을(What)" 할지만 선언하면 됩니다.

(예: "이 리스트에서 1000원 넘는 것만 골라내")

이런 방식을 선언형 프로그래밍이라고 하며, 코드가 훨씬 간결하고 가독성이 높아집니다.

 

 

 

스트림의 예시:

import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;

public class IteratorStreamEx {

	public static void main(String[] args) {
		List<String> list = Arrays.asList("홍길동", "고길동","김길동");
		
		//Java7 이전
		Iterator<String> iterator = list.iterator();
		while(iterator.hasNext()) {
			String name = iterator.next();
			System.out.println(name);
		}
		
		//Java8 이후
		Stream<String> stream = list.stream();
		stream.forEach(name -> System.out.println(name));
	}

}

 

 

 

Stream API 처리 3단계

Stream API는 크게 3가지 단계로 이루어진 파이프라인(Pipeline) 구조를 가집니다.

  1. 스트림 생성 (Creation)
    • 데이터 소스(컬렉션, 배열 등)로부터 스트림을 생성합니다.
    • 예: list.stream(), Arrays.stream(array)
  2. 중간 연산 (Intermediate Operations)
    • 스트림을 변환하거나 필터링하는 연산을 수행합니다.
    • 이 연산들은 지연 평가(Lazy Evaluation)됩니다. 즉, 최종 연산이 호출되기 전까지는 실제로 실행되지 않습니다.
    • 중간 연산은 항상 새로운 스트림을 반환합니다. (불변성 유지)
    • 주요 연산:
      • filter(Predicate<T>): 조건에 맞는 요소만 남깁니다. (예: filter(p -> p.getPrice() > 1000))
      • map(Function<T, R>): 요소를 다른 형태로 변환합니다. (예: map(p -> p.getName()))
      • sorted(): 요소를 정렬합니다.
  3. 최종 연산 (Terminal Operations)
    • 스트림의 요소를 소모하여 최종 결과를 도출합니다.
    • 이 연산이 호출되어야만 모든 중간 연산이 실행됩니다.
    • 스트림은 최종 연산 후 닫히므로 재사용할 수 없습니다.
    • 주요 연산:
      • collect(Collector): 결과를 새로운 컬렉션(List, Set, Map 등)으로 만듭니다. (예: collect(Collectors.toList()))
      • forEach(Consumer<T>): 각 요소를 순회하며 작업을 수행합니다.
      • count(): 요소의 개수를 반환합니다.
      • findFirst(), findAny(): 조건에 맞는 첫 번째 요소를 찾습니다. (Optional 반환)
      • reduce(): 모든 요소를 하나로 집계합니다.

 

요구사항 예시: "가격이 1000원이 넘는 제품만 골라서, 그 제품들의 이름을 대문자로 바꾼 뒤, 별도의 리스트로 만들어라."

아래의 Product 클래스가 있다고 가정합니다.

class Product {
    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }

    @Override
    public String toString() {
        return "Product{" + "name='" + name + '\'' + ", price=" + price + '}';
    }
}

 

기존이라면 for 루프 등을 이용했겠지만 Stream API(선언형 프로그래밍) 으로 구현할 수 있습니다.

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

public class StreamApiExample {
    public static void main(String[] args) {
        List<Product> products = Arrays.asList(
            new Product("Laptop", 1500),
            new Product("Mouse", 50),
            new Product("Keyboard", 100),
            new Product("Monitor", 1200)
        );

        // Stream 파이프라인을 통해 "무엇을(WHAT)" 할지 선언
        List<String> expensiveProductNames = products.stream() // 1. 스트림 생성
            .filter(p -> p.getPrice() > 1000)     // 2. 중간 연산: 1000원 초과 필터링
            .map(p -> p.getName().toUpperCase())  // 2. 중간 연산: 이름을 대문자로 변환
            .collect(Collectors.toList());        // 3. 최종 연산: List로 수집

        System.out.println(expensiveProductNames);
    }
}

 

 

람다식으로 객체 컬렉션 처리

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

class Student{
	private String name;
	private int score;
	public Student(String name, int score) {
		this.name = name;
		this.score = score;
	}
	public String getName() {return name;}
	public int getScore() {return score;}
}

public class LambdaEx {

	public static void main(String[] args) {
		List<Student> list = Arrays.asList(
				new Student("홍길동", 80),
				new Student("고길동", 92)
				);
		Stream<Student> stream = list.stream();
		stream.forEach( s -> {
			String name = s.getName();
			int score = s.getScore();
			System.out.println(name+":"+score);
		});
	}

}
import java.util.Arrays;
import java.util.List;

public class MapAndReduceEx {

	public static void main(String[] args) {
		List<Student> studentList = Arrays.asList(
				new Student("홍길동", 10),
				new Student("고길동", 20),
				new Student("김길동", 35)
				);
		
		double avg = studentList.stream().mapToInt(Student :: getScore).average().getAsDouble();
		System.out.println("평균: "+avg);
	}

}

 

병렬 처리

병렬처리시에도 반복 작업을 스트림 내부에서 처리하기 때문에 코드가 간결하며 안전합니다.

.stream()을 .parallelStream()으로 바꾸면 멀티 코어 CPU를 활용하여 병렬로 처리할 수 있습니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class ParallelEx {
	public static void print(String str) {
		System.out.println(str+":"+Thread.currentThread().getName());
	}

	public static void main(String[] args) {
		List<String> list = Arrays.asList("홍길동","고길동","김길동","이길동","박길동");
		
		//순차처리
		Stream<String> stream = list.stream();
		stream.forEach(ParallelEx :: print);	//람다식 메소드 참조
		System.out.println();
		
		//병렬처리
		Stream<String> parallelStream = list.parallelStream();
		parallelStream.forEach(ParallelEx :: print);
	}

}

 

 

스트림을 얻는 기본 방법

import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class FromCollectonEx {

	public static int sum;
	
	public static void main(String[] args) {
		//컬렉션으로부터 스트림 얻기
		List<Student> studentList = Arrays.asList(new Student("홍길동", 20), new Student("고길동", 30), new Student("김길동", 35));
		Stream<Student> stream = studentList.stream();
		stream.forEach(s->System.out.println(s.getName()));
		
		//배열로부터 스트림 얻기
		String[] strArray = {"홍길동", "고길동", "김길동"};
		Stream<String> strStream = Arrays.stream(strArray);
		strStream.forEach(a->System.out.print(a+","));
		System.out.println();
		
		//숫자 범위로부터 스트림 얻기
		IntStream intStream = IntStream.rangeClosed(1,100);
		intStream.forEach(a -> sum+=a);
		System.out.println("합계:"+sum);
	}

}

 

파일(폴더)로부터 스트림 얻기

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class FromFileEx {

	public static void main(String[] args) throws IOException {
		Path path = Paths.get("src/패키지경로/test.txt");	//텍스트파일의 경로 프로젝트의 소스폴더의 패키지 경로 예
		Stream<String> stream;
		
		//Files.lines()
		stream = Files.lines(path, Charset.defaultCharset());
		stream.forEach(System.out :: println);	//메소드 참조
		System.out.println();
		
		//BufferedReader
		File file = path.toFile();
		FileReader fileReader = new FileReader(file);
		BufferedReader br = new BufferedReader(fileReader);
		stream = br.lines();
		stream.forEach(System.out :: println);
		
		//디렉토리를 소스로 하는 스트림
		Path path2 = Paths.get("D:/절대경로/");	//경로 구분자가 역슬래시가 아니라 슬레시
		Stream<Path> stream2 = Files.list(path2);
		stream2.forEach(p->System.out.println(p.getFileName()));
	}

}

 

스트림 파이프라인

스트림 인터페이스에는 필터링, 매핑, 정렬 등 중간 처리 메소드가 있는데 이 메소드들은 중간 처리된 스트림을 리턴합니다.

그리고 이 스트림은 다시 다른 중간 처리 메소드를 호출해서 순차적으로 처리하는 파이프라인을 형성하게됩니다.

메소드의 리턴타입이 stream이라면 중간처리 메소드인 것입니다.

import java.util.Arrays;
import java.util.List;

class Member{
	public static int MALE = 0;
	public static int FEMALE = 1;
	private String name;
	private int gender;
	private int age;
	
	public Member(String name, int gender, int age) {
		this.name = name;
		this.gender = gender;
		this.age = age;
	}
	
	public int getGender() {return gender;}
	public int getAge() {return age;}
}

public class StreamPipelinesEx {

	public static void main(String[] args) {
		List<Member> list = Arrays.asList(
				new Member("김철수", Member.MALE, 30),
				new Member("김영희", Member.FEMALE, 20),
				new Member("안철수", Member.MALE, 45),
				new Member("고영희", Member.FEMALE, 37)
				);
		//스트림 파이프 라인
		double ageAvg = list.stream().filter(m->m.getGender()==Member.MALE).mapToInt(Member :: getAge).average().getAsDouble();
		
		System.out.println("남자 평균 나이: "+ageAvg);
	}

}

 

스트림 필터링

import java.util.Arrays;
import java.util.List;

public class FilteringEx {

	public static void main(String[] args) {
		List<String> names = Arrays.asList("김철수","나철수","안철수","이철수","박철수","안철수");
		
		//중복제거
		names.stream().distinct().forEach(n->System.out.println(n));
		System.out.println();
		
		//필터링
		names.stream().filter(n->n.startsWith("안")).forEach(n->System.out.println(n));
		System.out.println();
		
		//중복 제거 후 필터링
		names.stream().distinct().filter(n->n.startsWith("안")).forEach(n->System.out.println(n));
	}

}

 

스트림 매핑

원래의 스트림 요소가 복수개의 요소로 대체하는 예제

import java.util.Arrays;
import java.util.List;

public class FlatMapEx {

	public static void main(String[] args) {
		List<String> inputList1 = Arrays.asList("java8 lambda", "stream mapping");
		inputList1.stream().flatMap(data->Arrays.stream(data.split(" "))).forEach(word->System.out.println(word));
		System.out.println();
		List<String> inputList2 = Arrays.asList("10, 20, 30, 40, 50, 60, 70");
		inputList2.stream().flatMapToInt(data->{
			String[] strArr = data.split(",");
			int[] intArr = new int[strArr.length];
			for(int i=0; i<strArr.length;i++) {
				intArr[i] = Integer.parseInt(strArr[i].trim());
			}
			return Arrays.stream(intArr);
		}).forEach(number->System.out.println(number));
	}

}

 

아래 예제의 Student는 위(람다식으로 객체 컬렉션 처리)에서 정의했던 Student class 입니다.

같은 패키지에 정의되어 있다면 다시 정의할 필요가 없습니다.

public class MapEx {

	public static void main(String[] args) {
		List<Student> studentList = Arrays.asList(
				new Student("김철수",10),
				new Student("이철수",20),
				new Student("박철수",30),
				new Student("안철수",40)
				);
		studentList.stream().mapToInt(Student :: getScore).forEach(score->System.out.println(score));
	}
}

 

아래는 정수 데이터를 실수로 대체하고 박싱도 해보는 예제입니다. 

import java.util.Arrays;
import java.util.stream.IntStream;

public class AsDoubleStreamEx {

	public static void main(String[] args) {
		int[] intArray = {5,4,3,2,1};
		IntStream intStream = Arrays.stream(intArray);
		intStream.asDoubleStream().forEach(d->System.out.println(d));
		System.out.println();
		intStream = Arrays.stream(intArray);
		intStream.boxed().forEach(obj->System.out.println(obj.intValue()));
	}

}

 

스트림 정렬

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;

class SortableStudent extends Student implements Comparable<SortableStudent>{

	public SortableStudent(String name, int score) {
		super(name, score);
	}

	@Override
	public int compareTo(SortableStudent o) {
		return Integer.compare(getScore(), o.getScore());
	}
	
}

public class SortingEx {

	public static void main(String[] args) {
		IntStream intStream = Arrays.stream(new int[] {5,2,1,4,3});
		intStream.sorted().forEach(n->System.out.print(n + ","));
		System.out.println();
		
		List<SortableStudent> studentList = Arrays.asList(
				new SortableStudent("김철수", 30),
				new SortableStudent("이철수", 20),
				new SortableStudent("박철수", 10),
				new SortableStudent("안철수", 40)
				);
		studentList.stream().sorted().forEach(s->System.out.print(s.getScore() + ","));
		System.out.println();
		
		studentList.stream().sorted(Comparator.reverseOrder()).forEach(s->System.out.print(s.getScore()+","));
	}

}

 

스트림 루핑 peek(), forEach()

스트림 루핑은 요소 전체를 반복처라하는 것을 말하는데, peek()은 중간처리 메소드이지만 forEach()는 최종 처리 메소드의 차이가 있습니다.

peek()은 최종 처리 메소드가 호출될때까지 지연되기 때문에 반드시 최종처리 메소드를 호출해야만 합니다.

import java.util.Arrays;

public class LoopingEx {

	public static void main(String[] args) {
		int[] intArr = {1,2,3,4,5,6,7};
		System.out.println("peek()을 마지막에 호출한 경우. 동작하지 않음.(중간처리메소드)");
		Arrays.stream(intArr).filter(a->a%2==0).peek(n->System.out.println(n));	//동작하지 않음
		System.out.println("최종처리 메소드를 마지막에 호출한 경우.");
		int total = Arrays.stream(intArr).filter(a->a%2==0).peek(n->System.out.println(n)).sum();
		System.out.println("합계:"+total);
		System.out.println("forEach()를 마지막에 호출한경우");
		Arrays.stream(intArr).filter(a->a%2==0).forEach(n->System.out.println(n));
	}

}

 

스트림 매칭 allMatch(), anyMatch(), noneMatch()

매칭 여부를 판단해서 boolean을 리턴해주는 메소드입니다.

import java.util.Arrays;

public class MathchEx {

	public static void main(String[] args) {
		int[] intArr = {2,4,6,8};
		boolean result = Arrays.stream(intArr).allMatch(a->a%2==0);
		System.out.println("모두 2의 배수? "+result);
		result = Arrays.stream(intArr).anyMatch(a->a%3==0);
		System.out.println("하나라도 3의 배수? "+result);
		result = Arrays.stream(intArr).noneMatch(a->a%3==0);
		System.out.println("3의 배수가 없음? "+result);
		
	}

}

 

기본 집계 메소드 sum(), count(), average(), max(), min()

import java.util.Arrays;

public class AggregateEx {

	public static void main(String[] args) {
		long count = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%2==0).count();
		System.out.println("2의 배수의 개수:"+count);
		long sum = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%2==0).sum();
		System.out.println("2의 배수의 합:"+sum);
		double avg = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%2==0).average().getAsDouble();
		System.out.println("2의 배수의 평균:"+avg);
		int max = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%2==0).max().getAsInt();
		System.out.println("2의 배수 최대값:"+max);
		int min = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%2==0).min().getAsInt();
		System.out.println("2의 배수 최소값:"+min);
		int first = Arrays.stream(new int[] {1,2,3,4,5,6}).filter(n->n%3==0).findFirst().getAsInt();
		System.out.println("첫번째 3의 배수 값:"+first);
	}

}

 

Optional 클래스

컬렉션의 요소는 동적으로 추가되는 경우가 대부분입니다.

만약 동적으로 요소가 없는 상황에서 평균 값을 구하는 메소드를 실행할 경우 예외가 발생합니다.

이런경우를 대비해 디폴트 값을 지정해줄 수 있습니다.

import java.util.ArrayList;
import java.util.List;
import java.util.OptionalDouble;

public class OptionalEx {

	public static void main(String[] args) {
		
		List<Integer> list = new ArrayList<>();
		//list에 값이 없는 경우 평균 구하기
		OptionalDouble optional = list.stream().mapToInt(Integer::intValue).average();
		if(optional.isPresent()) {
			System.out.println("평균1: "+optional.getAsDouble());
		}else {
			System.out.println("평균1(기본값): "+0.0);
		}
		
		double avg = list.stream().mapToInt(Integer::intValue).average().orElse(0.0);
		System.out.println("평균2: "+avg);
		
		list.stream().mapToInt(Integer::intValue).average().ifPresent(a->System.out.println("방법3: "+a));
	}

}

 

 

커스텀 집계 reduce()

reduce() 연산은 스트림(Stream)의 여러 요소들을 하나의 단일 값으로 "줄여나간다" 또는 "응축시킨다" 라고 생각하면 됩니다.

reduce()는 스트림의 모든 요소를 순회하면서, 우리가 제공하는 로직(예: 덧셈, 최댓값 찾기)을 반복적으로 적용하여 최종 결과 1개를 만들어냅니다.

 

reduce()의 작동 원리: "누적기(Accumulator)"라는 누적 계산 개념을 사용합니다.

  1. 시작 값을 하나 정합니다. (예: 덧셈을 시작할 거니까 0부터 시작)
  2. 스트림의 첫 번째 요소를 가져와 시작 값과 계산합니다. (예: 0 + 5)
  3. 결과를 임시로 가집니다. (결과: 5)
  4. 스트림의 두 번째 요소를 가져와 방금 전의 임시 결과와 계산합니다. (예: 5 + 3)
  5. 결과를 다시 임시로 가집니다. (결과: 8)
  6. ... 스트림의 마지막 요소까지 이 과정을 반복합니다.
  7. 최종 결과를 반환합니다.

 

import java.util.Arrays;
import java.util.List;

public class ReductionEx {

	public static void main(String[] args) {
		List<Student> studentList = Arrays.asList(
				new Student("홍길동", 92),
				new Student("고길동", 95),
				new Student("김길동", 88));
		
		int sum1 = studentList.stream().mapToInt(Student::getScore).sum();
		int sum2 = studentList.stream().map(Student::getScore).reduce((a,b)->a+b).get();
		int sum3 = studentList.stream().map(Student::getScore).reduce(0,(a,b)->a+b);
		
		System.out.println(sum1);
		System.out.println(sum2);
		System.out.println(sum3);
	}

}

 

reduce()의 두 가지 주요 형태

1. reduce(T identity, BinaryOperator<T> accumulator)

  • identity (초기값): 계산을 시작할 기본값입니다. 스트림이 비어있을 경우 이 값이 그대로 반환됩니다.
  • accumulator (누적기): 두 개의 값을 받아서 하나로 합치는 로직입니다. 람다 표현식 (a, b) -> a + b가 여기 들어갑니다.
    • a: 현재까지 누적된 중간 결과
    • b: 스트림에서 새로 꺼내온 요소

예시: 숫자의 총합 구하기

import java.util.Arrays;
import java.util.List;

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

        // 1. 초기값(identity)을 0으로 설정
        // 2. 누적기(accumulator)로 (a, b) -> a + b (덧셈)을 사용
        int sum = numbers.stream()
                         .reduce(0, (partialSum, nextElement) -> partialSum + nextElement);

        System.out.println("총합: " + sum); // 출력: 총합: 15
        
        // (0 + 1) -> 1
        // (1 + 2) -> 3
        // (3 + 3) -> 6
        // (6 + 4) -> 10
        // (10 + 5) -> 15 (최종 반환)
    }
}

 

메서드 참조를 이용하면 더 간결해집니다.

int sum = numbers.stream().reduce(0, Integer::sum);

 

2. reduce(BinaryOperator<T> accumulator)

이 형태는 초기값(identity)을 생략합니다.

  • 초기값이 없기 때문에, 스트림의 첫 번째 요소를 초기값으로 사용합니다.
  • 문제점: 만약 스트림이 비어 있다면 (요소가 하나도 없다면) 반환할 값이 없습니다.
  • 해결책: 자바는 이 경우 null 대신, 값이 "있을 수도 있고 없을 수도 있다"는 의미의 Optional<T> 객체를 반환합니다.

예제: 최대값 찾기

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class ReduceMaxExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(5, 8, 3, 10, 2);

        // 초기값 없이, (a, b) -> a > b ? a : b (더 큰 값 반환) 로직만 제공
        Optional<Integer> maxOptional = numbers.stream()
                                               .reduce((a, b) -> a > b ? a : b);

        // Optional에서 값을 안전하게 꺼내기
        if (maxOptional.isPresent()) {
            System.out.println("최댓값: " + maxOptional.get()); // 출력: 최댓값: 10
        }

        // (5, 8) -> 8
        // (8, 3) -> 8
        // (8, 10) -> 10
        // (10, 2) -> 10 (최종 반환)

        // 메서드 참조 사용:
        // Optional<Integer> maxOptional = numbers.stream().reduce(Integer::max);
    }
}

 

 

 

수집 collect()

필터링 또는 매핑한 요소들을 수집하는 기능입니다.

필요한 요소만 컬렉션에 담을 수 있고 요소들을 그룹핑한 후 집계(reduction) 할 수 있습니다.

 

reduce() 와 collect() 이 둘은 헷갈릴 수 있지만 목적이 다릅니다.

  • reduce(): 스트림의 요소들을 조합해서 하나의 값 (숫자, 문자열, POJO 등)을 만들 때 사용합니다. (예: 총합, 평균, 최댓값, 문자열 합치기)
  • collect(): 스트림의 요소들을 모아서 새로운 컬렉션 (List, Set, Map)을 만들 때 사용합니다.

 

collect() 예제:

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

class Student2{
	public enum Sex{MALE, FEMALE}
	public enum City{Seoul, Pusan}
	private String name;
	private int score;
	private Sex sex;
	private City city;
	
	public Student2(String name, int score, Sex sex) {
		this.name = name;
		this.score = score;
		this.sex = sex;
	}
	
	public Student2(String name, int score, Sex sex, City city) {
		this.name = name;
		this.score = score;
		this.sex = sex;
		this.city = city;
	}
	
	public String getName() {return name;}
	public int getScore() {return score;}
	public Sex getSex() {return sex;}
	public City getCity() {return city;}
}

public class ToListEx {

	public static void main(String[] args) {
		List<Student2> totalList = Arrays.asList(
				new Student2("김철수", 10, Student2.Sex.MALE),
				new Student2("김영희", 6, Student2.Sex.FEMALE),
				new Student2("안철수", 8, Student2.Sex.MALE),
				new Student2("이영희", 10, Student2.Sex.FEMALE)
				);
		
		//남자만 수집하여 새로운 컬렉션(list) 생성
		List<Student2> maleList = totalList.stream().filter(s->s.getSex()==Student2.Sex.MALE).collect(Collectors.toList());
		maleList.stream().forEach(s->System.out.println(s.getName()));
		System.out.println();
		
		//여자만 수집하여 새로운 컬렉션(HashSet) 생성
		Set<Student2> femaleSet = totalList.stream().filter(s->s.getSex() == Student2.Sex.FEMALE).collect(Collectors.toCollection(HashSet::new));
		femaleSet.stream().forEach(s->System.out.println(s.getName()));
	}

}

 

사용자 정의 컨테이너에 수집

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class MaleStudent{
	public List<Student2> list;
	public MaleStudent() {
		list = new ArrayList<Student2>();
	}
	//요소를 수집하는 메소드
	public void accumulate(Student2 student) {
		list.add(student);
	}
	//두 MaleStudent를 결합하는 메소드
	public void combine(MaleStudent other) {
		list.addAll(other.getList());
	}
	public List<Student2> getList(){
		return list;
	}
}

public class MaleStudentEx {

	public static void main(String[] args) {
		List<Student2> totalList = Arrays.asList(
				new Student2("김철수", 10, Student2.Sex.MALE),
				new Student2("김영희", 6, Student2.Sex.FEMALE),
				new Student2("안철수", 8, Student2.Sex.MALE),
				new Student2("이영희", 10, Student2.Sex.FEMALE)
				);
		
		MaleStudent maleStudent = totalList.stream().filter(s->s.getSex()==Student2.Sex.MALE)
				.collect(MaleStudent::new, MaleStudent::accumulate, MaleStudent::combine );
		
		maleStudent.getList().stream().forEach(s->System.out.println(s.getName()));
	}

}

 

요소를 그룹핑해서 수집

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

public class GroupingEx {

	public static void main(String[] args) {
		List<Student2> totalList = Arrays.asList(
				new Student2("김철수", 10, Student2.Sex.MALE, Student2.City.Seoul),
				new Student2("김영희", 6, Student2.Sex.FEMALE, Student2.City.Pusan),
				new Student2("안철수", 8, Student2.Sex.MALE, Student2.City.Pusan),
				new Student2("이영희", 10, Student2.Sex.FEMALE, Student2.City.Seoul)
				);
		
		Map<Student2.Sex, List<Student2>> mapBySex = totalList.stream().collect(Collectors.groupingBy(Student2::getSex));
		
		System.out.print("[남자]");
		mapBySex.get(Student2.Sex.MALE).stream().forEach(s->System.out.print(s.getName() + " "));
		System.out.println();
		System.out.print("[여자]");
		mapBySex.get(Student2.Sex.FEMALE).stream().forEach(s->System.out.print(s.getName() + " "));
		System.out.println();
		
		Map<Student2.City, List<String>> mapByCity = totalList.stream()
				.collect(Collectors.groupingBy(Student2::getCity,Collectors.mapping(Student2::getName, Collectors.toList())));
		System.out.print("[서울]");
		mapByCity.get(Student2.City.Seoul).stream().forEach(s->System.out.print(s + " "));
		System.out.println();
		System.out.print("[부산]");
		mapByCity.get(Student2.City.Pusan).stream().forEach(s->System.out.print(s + " "));
	}

}

 

그룹핑 후 집계

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

public class GroupingAndReductionEx {

	public static void main(String[] args) {
		List<Student2> totalList = Arrays.asList(
				new Student2("김철수", 10, Student2.Sex.MALE, Student2.City.Seoul),
				new Student2("김영희", 6, Student2.Sex.FEMALE, Student2.City.Pusan),
				new Student2("안철수", 8, Student2.Sex.MALE, Student2.City.Pusan),
				new Student2("이영희", 20, Student2.Sex.FEMALE, Student2.City.Seoul)
				);
		
		//성별로 평균 점수를 저장하는 Map
		Map<Student2.Sex, Double> mapBySex = totalList.stream().collect(Collectors.groupingBy(Student2::getSex, Collectors.averagingDouble(Student2::getScore)));		
		System.out.println("남자 평균:"+mapBySex.get(Student2.Sex.MALE));
		System.out.println("여자 평균:"+mapBySex.get(Student2.Sex.FEMALE));
		
		//성별로 이름을 쉼표로 구분하여 저장하는 Map
		Map<Student2.Sex, String> mapByName = totalList.stream()
				.collect(Collectors.groupingBy(Student2::getSex, Collectors.mapping(Student2::getName, Collectors.joining(","))));
		System.out.println("남자 전체 이름: "+mapByName.get(Student2.Sex.MALE));
		System.out.println("여자 전체 이름: "+mapByName.get(Student2.Sex.FEMALE));
	}

}

 

스트림 병렬처리

병렬 스트림(Parallel Stream)은 간단히 말해, 하나의 스트림 작업을 여러 개의 스레드(CPU 코어)에 자동으로 분배하여 동시에 처리하는 방식입니다.

일반적인 stream()은 순차 스트림(Sequential Stream)이라고 부르며, 모든 작업을 단일 스레드에서 순서대로 처리합니다.

 

방법 1: parallelStream() 호출

애초에 컬렉션에서 스트림을 생성할 때부터 병렬로 시작합니다.

import java.util.List;
import java.util.Arrays;

public class ParallelStreamExample1 {
    public static void main(String[] args) {
        List<Integer> massiveList = Arrays.asList(/* ... 아주 많은 데이터 ... */);

        // stream() 대신 parallelStream()을 호출
        massiveList.parallelStream()
                   .map(num -> num * 2) // 이 작업이 여러 스레드에서 동시에 실행됨
                   .forEach(System.out::println); 
                   // (주의: 병렬이라 출력 순서는 보장되지 않음)
    }
}

 

방법 2: parallel() 중간 연산 호출

이미 순차 스트림이 있는 경우, 중간에 parallel()을 호출하여 병렬 스트림으로 전환할 수 있습니다.

List<Integer> massiveList = Arrays.asList(/* ... 아주 많은 데이터 ... */);

massiveList.stream() // 일단 순차 스트림으로 시작
           .parallel() // 이 시점부터 병렬로 전환!
           .map(num -> num * 2)
           .forEach(System.out::println);

 

병렬 스트림은 내부적으로 자바 7부터 도입된 Fork/Join 프레임워크를 사용합니다.

  1. Fork (분할): 하나의 큰 작업을 여러 개의 작은 하위 작업(subtask)으로 쪼갭니다.
  2. Process (처리): 쪼개진 작업들을 여러 스레드에 분배하여 동시에 처리합니다. (이때 자바의 공용 ForkJoinPool을 사용합니다.)
  3. Join (결합): 각 스레드에서 처리된 결과들을 다시 하나로 합칩니다.

reduce()는 병렬 스트림에서는 세 번째 인자인 combiner가 핵심적인 역할을 합니다.

reduce(identity, accumulator, combiner)
  • identity: 초기값 (예: 0)
  • accumulator: 각 스레드 내에서 하위 작업을 누적하는 로직 (예: (partialSum, num) -> partialSum + num)
  • combiner: 서로 다른 스레드의 누적 결과들을 최종적으로 합치는 로직 (예: (sum1, sum2) -> sum1 + sum2)

 

import java.util.Arrays;
import java.util.List;

public class TimeTest {

	//요소 처리
	public static void work(int value) {
		try {
			Thread.sleep(100);
		}catch(Exception e) {
			
		}
	}
	
	//순차 처리
	public static long testSequencial(List<Integer> list) {
		long start = System.nanoTime();
		list.stream().forEach((a)->work(a));
		long end = System.nanoTime();
		long runTime = end - start;
		return runTime;
	}
	
	//병렬 처리
	public static long testParallel(List<Integer> list) {
		long start = System.nanoTime();
		list.stream().parallel().forEach((a)->work(a));
		long end = System.nanoTime();
		long runTime = end - start;
		return runTime;
	}
	
	public static void main(String[] args) {
		//컬렉션
		List<Integer> list = Arrays.asList(0,1,2,3,4,5,6,7,8,9);
		
		long timeSequencial = testSequencial(list);
		long timeParallel = testParallel(list);
		
		System.out.println(timeSequencial);
		System.out.println(timeParallel);
	}

}

 

주의사항: 

.parallelStream()은  잘못 사용하면 오히려 성능이 더 느려지거나 데이터가 꼬일 수 있습니다.

1. 데이터가 적으면 오히려 느리다

데이터를 쪼개고(Fork), 스레드를 관리하고, 결과를 합치는(Join) 데는 오버헤드(추가 비용)가 발생합니다.

데이터가 수천~수만 건 정도로 적다면, 이 오버헤드가 실제 작업 시간보다 커서 순차 스트림보다 훨씬 느립니다.

  • 결론: 수백만 건 이상의 대용량 데이터에 사용하세요.

2. CPU 집약적인 작업에 사용하라

병렬 스트림은 CPU 코어를 전부 활용하는 데 목적이 있습니다.

만약 작업이 I/O 작업 (파일 읽기/쓰기, 네트워크/DB 조회)이라면, 스레드들은 CPU를 쓰는 게 아니라 대부분 대기 상태에 빠집니다.

  • 결론: 복잡한 수학 계산, 암호화 등 CPU를 많이 쓰는 작업에 효과적입니다. I/O 작업에는 사용하지 마세요.

3. 상태를 공유하지 마라 (Stateless)

여러 스레드가 하나의 공유된 변수나 리스트에 동시에 접근하여 수정하려고 하면(예: total += num 또는 list.add(...)), 데이터가 유실되거나(Race Condition) 프로그램이 깨집니다.

  • 결론: 스트림 내의 람다식은 반드시 순수 함수여야 하며, 외부의 상태를 변경해서는 안 됩니다.

4. 순서가 중요하다면 조심하라

병렬 처리는 순서를 보장하지 않습니다.

findFirst()처럼 순서가 중요한 연산은 병렬 처리에 적합하지 않거나(순차 처리보다 느릴 수 있음), findAny()를 사용하는 것이 좋습니다.

병렬 스트림은 특정 조건(대용량, CPU 집약적, 상태 없음)에서 효과적입니다.

 

 

 

 

 

 

728x90