개발일기/TIL(Since24.04.19)

람다와 스트림 이해하기

w.llama 2025. 6. 23. 22:24

자바 8에서 도입된 람다 표현식과 스트림 API는 자바 프로그래밍의 패러다임을 완전히 바꿔놓았습니다. 함수형 프로그래밍의 장점을 자바에 도입하여 더 간결하고 읽기 쉬운 코드를 작성할 수 있게 되었죠.

람다 표현식 기본 문법

람다 표현식은 (파라미터) -> 실행문 형태로 작성됩니다. 파라미터와 리턴값의 유무에 따라 다양한 형태로 사용할 수 있습니다.

파라미터와 리턴값 조합별 람다 표현식

파라미터 리턴값 예시

함수 인터페이스 파라미터 리턴값
Runnable X X
Supplier<T> X O
Consumer<T>  O O
Function<T, R> O X
Predicate<T> O O(boolean)

 

//e.g
// 파라미터 없음, 리턴값 없음
Runnable task = () -> System.out.println("작업 실행");

// 파라미터 없음, 리턴값 있음
Supplier<String> supplier = () -> "안녕하세요";

// 파라미터 있음, 리턴값 있음
Function<Integer, Integer> doubler = x -> x * 2;

// 파라미터 있음, 리턴값 없음
Consumer<String> printer = message -> System.out.println(message);

// 파라미터 있음, boolean 리턴
Predicate<Integer> isPositive = x -> x > 0;

스트림 API의 핵심 메서드들

스트림은 생성하기-> 가공하기 -> 결과 만들기 과정을 통해 결과를 낸다\

1. 생성(Source) 단계

스트림을 만드는 함수들:

  • stream() - 컬렉션에서 스트림 생성
  • parallelStream() - 병렬 스트림 생성
  • Stream.of() - 요소들로부터 스트림 생성
  • Stream.generate() - 무한 스트림 생성
  • Stream.iterate() - 반복을 통한 스트림 생성
  • Arrays.stream() - 배열에서 스트림 생성

2. 가공(Intermediate Operations) 단계

스트림을 변환하는 중간 연산들:

  • 필터링: filter(), distinct()
  • 변환: map(), flatMap()
  • 정렬: sorted()
  • 제한: limit(), skip()
  • 디버깅: peek()

3. 결과(Terminal Operations) 단계

최종 결과를 만드는 종료 연산들:

  • 조건 검사: allMatch(), anyMatch(), noneMatch()
  • 요소 찾기: findFirst(), findAny()
  • 결과 수집: collect(), reduce(), toArray()
  • 집계: count(), min(), max(), sum(), average()
  • 반복: forEach(), forEachOrdered()

등이 있고 자주쓰는 함수들의 예시를 살펴보면 다음과 같다.

자주 쓰는 함수들의 예시

가공 단계 함수들

1. filter() - 조건에 맞는 요소만 걸러내기

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수만 필터링
List<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());

// 결과: [2, 4, 6, 8, 10]

2. distinct() - 중복 제거

List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 4, 5, 5);

List<Integer> uniqueNumbers = numbers.stream()
    .distinct()
    .collect(Collectors.toList());

System.out.println(uniqueNumbers); // [1, 2, 3, 4, 5]

3. map() - 요소 변환

List<String> words = Arrays.asList("hello", "world", "java", "stream");

// 대문자로 변환
List<String> upperCaseWords = words.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// 문자열 길이로 변환
List<Integer> lengths = words.stream()
    .map(String::length)
    .collect(Collectors.toList());

4. sorted() - 정렬

List<String> names = Arrays.asList("홍길동", "김철수", "이영희", "박민수");

// 기본 정렬 (오름차순)
List<String> sortedNames = names.stream()
    .sorted()
    .collect(Collectors.toList());

// 내림차순 정렬
List<String> reverseSortedNames = names.stream()
    .sorted(Comparator.reverseOrder())
    .collect(Collectors.toList());

// 길이순 정렬
List<String> sortedByLength = names.stream()
    .sorted(Comparator.comparing(String::length))
    .collect(Collectors.toList());

5. limit() / skip() - 요소 제한 및 건너뛰기

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 처음 5개만 가져오기
List<Integer> first5 = numbers.stream()
    .limit(5)
    .collect(Collectors.toList()); // [1, 2, 3, 4, 5]

// 처음 3개 건너뛰고 나머지 가져오기
List<Integer> skipFirst3 = numbers.stream()
    .skip(3)
    .collect(Collectors.toList()); // [4, 5, 6, 7, 8, 9, 10]

// 조합: 3개 건너뛰고 4개만 가져오기
List<Integer> skipAndLimit = numbers.stream()
    .skip(3)
    .limit(4)
    .collect(Collectors.toList()); // [4, 5, 6, 7]

결과 단계 함수들

6. allMatch() - 모든 요소가 조건을 만족하는지 확인

List<Integer> scores = Arrays.asList(85, 92, 78, 95, 88);

// 모든 점수가 60점 이상인지 확인
boolean allPassed = scores.stream()
    .allMatch(score -> score >= 60);

System.out.println("전체 합격: " + allPassed); // true

7. anyMatch() - 하나라도 조건을 만족하는지 확인

List<String> words = Arrays.asList("apple", "banana", "cherry", "date");

// 'a'로 시작하는 단어가 있는지 확인
boolean hasWordStartingWithA = words.stream()
    .anyMatch(word -> word.startsWith("a"));

System.out.println("a로 시작하는 단어 존재: " + hasWordStartingWithA); // true

8. noneMatch() - 모든 요소가 조건을 만족하지 않는지 확인

List<Integer> positiveNumbers = Arrays.asList(1, 5, 10, 15, 20);

// 음수가 없는지 확인
boolean noNegatives = positiveNumbers.stream()
    .noneMatch(n -> n < 0);

System.out.println("음수 없음: " + noNegatives); // true

9. findFirst() - 첫 번째 요소 찾기

List<String> fruits = Arrays.asList("사과", "바나나", "체리", "대추");

// 첫 번째 과일 찾기
Optional<String> firstFruit = fruits.stream()
    .findFirst();

firstFruit.ifPresent(fruit -> 
    System.out.println("첫 번째 과일: " + fruit)); // 첫 번째 과일: 사과

10. forEach() - 각 요소에 대해 작업 수행

List<String> names = Arrays.asList("김철수", "이영희", "박민수");

// 각 이름 출력
names.stream()
    .forEach(name -> System.out.println("안녕하세요, " + name + "님!"));

11. reduce() - 요소들을 하나의 값으로 축약

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// 합계 구하기
int sum = numbers.stream()
    .reduce(0, (a, b) -> a + b);

// 최댓값 구하기
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);

// 문자열 연결
List<String> words = Arrays.asList("Java", "는", "재미있다");
String sentence = words.stream()
    .reduce("", (a, b) -> a + " " + b).trim();

12. collect() - 결과 수집

List<String> names = Arrays.asList("김철수", "이영희", "박민수", "최지영");

// List로 수집
List<String> nameList = names.stream()
    .filter(name -> name.startsWith("김"))
    .collect(Collectors.toList());

// Set으로 수집
Set<String> nameSet = names.stream()
    .collect(Collectors.toSet());

// 문자열로 조인
String joinedNames = names.stream()
    .collect(Collectors.joining(", "));

// 그룹핑
Map<Integer, List<String>> groupedByLength = names.stream()
    .collect(Collectors.groupingBy(String::length));

실전 예제: 학생 성적 관리 시스템

class Student {
    private String name;
    private int score;
    private String subject;
    private int age;
    
    // 생성자, getter, setter 생략
    
    public Student(String name, int score, String subject, int age) {
        this.name = name;
        this.score = score;
        this.subject = subject;
        this.age = age;
    }
}

List<Student> students = Arrays.asList(
    new Student("김철수", 85, "수학", 20),
    new Student("이영희", 92, "영어", 19),
    new Student("박민수", 78, "수학", 21),
    new Student("최지영", 95, "영어", 20),
    new Student("정호석", 88, "수학", 22),
    new Student("김영수", 76, "영어", 19),
    new Student("이민아", 91, "수학", 20)
);

// 수학 과목 학생들의 평균 점수
double mathAverage = students.stream()
    .filter(student -> "수학".equals(student.getSubject()))
    .mapToDouble(Student::getScore)
    .average()
    .orElse(0.0);

// 90점 이상 학생이 있는지 확인
boolean hasHighScorer = students.stream()
    .anyMatch(student -> student.getScore() >= 90);

// 모든 학생이 60점 이상인지 확인
boolean allPassed = students.stream()
    .allMatch(student -> student.getScore() >= 60);

// 첫 번째 영어 과목 학생 찾기
Optional<Student> firstEnglishStudent = students.stream()
    .filter(student -> "영어".equals(student.getSubject()))
    .findFirst();

// 과목별 학생 수 계산
Map<String, Long> studentCountBySubject = students.stream()
    .collect(Collectors.groupingBy(Student::getSubject, Collectors.counting()));

// 나이별 최고 점수
Map<Integer, Optional<Integer>> maxScoreByAge = students.stream()
    .collect(Collectors.groupingBy(Student::getAge, 
        Collectors.mapping(Student::getScore, 
            Collectors.maxBy(Integer::compareTo))));

// 상위 3명 학생 이름 (점수순)
List<String> top3Students = students.stream()
    .sorted(Comparator.comparing(Student::getScore).reversed())
    .limit(3)
    .map(Student::getName)
    .collect(Collectors.toList());

스트림 성능 최적화 팁

1. 병렬 스트림 활용

// 큰 데이터셋에서 병렬 처리
List<Integer> largeList = IntStream.range(1, 1000000)
    .boxed()
    .collect(Collectors.toList());

// 순차 처리
long sequentialSum = largeList.stream()
    .mapToLong(Integer::longValue)
    .sum();

// 병렬 처리
long parallelSum = largeList.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();

2. 조기 종료 활용

// findFirst(), anyMatch() 등은 조건을 만족하면 즉시 종료
boolean found = students.stream()
    .filter(student -> student.getScore() > 90)
    .findFirst()
    .isPresent();

메서드 체이닝의 힘

스트림 API의 진정한 힘은 여러 메서드를 체이닝하여 복잡한 데이터 처리를 간단하게 표현할 수 있다는 점입니다.

List<String> result = students.stream()
    .filter(student -> student.getScore() >= 80)  // 80점 이상
    .filter(student -> "수학".equals(student.getSubject()))  // 수학 과목
    .distinct()  // 중복 제거
    .sorted(Comparator.comparing(Student::getScore).reversed())  // 점수 내림차순
    .map(Student::getName)  // 이름만 추출
    .limit(5)  // 상위 5명만
    .collect(Collectors.toList());  // 리스트로 수집

// 복잡한 데이터 처리도 한 번에
Map<String, Double> subjectAverages = students.stream()
    .collect(Collectors.groupingBy(Student::getSubject,
        Collectors.averagingDouble(Student::getScore)));

자주 사용되는 스트림 패턴

1. 필터링 + 변환 + 수집

List<String> excellentStudentNames = students.stream()
    .filter(s -> s.getScore() >= 90)
    .map(Student::getName)
    .sorted()
    .collect(Collectors.toList());

2. 그룹핑 + 통계

Map<String, IntSummaryStatistics> statisticsBySubject = students.stream()
    .collect(Collectors.groupingBy(Student::getSubject,
        Collectors.summarizingInt(Student::getScore)));

3. 조건부 처리

students.stream()
    .filter(s -> s.getScore() < 60)
    .forEach(s -> System.out.println(s.getName() + " 학생은 재시험이 필요합니다."));

마무리

람다 표현식과 스트림 API는 자바 코드를 더욱 간결하고 읽기 쉽게 만들어주며, 특히 컬렉션 데이터를 처리할 때 그 진가를 발휘하죠. 처음에는 낯설 수 있지만, 계속 사용하다 보면 없어서는 안 될 필수 도구가 됩니다.

핵심 포인트 정리:

  • 함수형 인터페이스: Runnable, Consumer, Supplier, Function, Predicate 등을 활용한 람다 표현식
  • 필터링과 변환: filter(), map(), distinct(), sorted() 등으로 데이터 가공
  • 조건 검사: allMatch(), anyMatch(), noneMatch()로 조건 확인
  • 요소 찾기: findFirst(), findAny()로 원하는 요소 탐색
  • 데이터 제한: limit(), skip()으로 요소 개수 조절
  • 결과 수집: collect(), reduce()로 최종 결과 생성
  • 성능 최적화: 병렬 스트림과 조기 종료 활용

각 메서드의 특성을 이해하고 적절한 상황에서 활용하는 것이 중요합니다. 메서드 체이닝을 통해 복잡한 데이터 처리도 직관적으로 표현할 수 있으며, 함수형 프로그래밍의 장점을 충분히 활용할 수 있습니다.