⚗️ 필터 만들기

먼저 숫자들이 들어 있는 리스트에서 짝수만 골라내는 행동과 홀수만 골라내는 행동을 구현해보자.

package lambda.lambda5.filter;

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

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

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

        // 짝수만 거르기
        List<Integer> evenNumbers = filterEvenNumber(numbers);
        System.out.println("evenNumbers = " + evenNumbers);

        // 홀수만 거르기
        List<Integer> oddNumbers = filterOddNumber(numbers);
        System.out.println("oddNumbers = " + oddNumbers);

        /*
        evenNumbers = [2, 4, 6, 8, 10]
        oddNumbers = [1, 3, 5, 7, 9]
        */
    }

    private static List<Integer> filterEvenNumber(List<Integer> numbers) {
        ArrayList<Integer> filtered = new ArrayList<>();
        for (Integer number : numbers) {
            if (number % 2 == 0) {
                filtered.add(number);
            }
        }

        return filtered;
    }

    private static List<Integer> filterOddNumber(List<Integer> numbers) {
        ArrayList<Integer> filtered = new ArrayList<>();
        for (Integer number : numbers) {
            if (number % 2 != 0) {
                filtered.add(number);
            }
        }

        return filtered;
    }
}

람다를 사용하지 않았을 때의 구현 방식이다. 그냥 리스트를 순회해서 조건에 맞는 값을 넣고 결과를 반환하도록 한 것이다. 이제 filterEvenNumber()filterOddNumber() 메서드를 하나의 filter() 메서드로 통합해보도록 하자.

package lambda.lambda5.filter;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

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

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

        List<Integer> evenList = filter(numbers, number -> number % 2 == 0);
        List<Integer> oddList = filter(numbers, number -> number % 2 != 0);

        System.out.println("evenList = " + evenList);  // evenList = [2, 4, 6, 8, 10]
        System.out.println("oddList = " + oddList);  // oddList = [1, 3, 5, 7, 9]
    }

    static List<Integer> filter(List<Integer> numbers, Predicate<Integer> predicate) {
        List<Integer> result = new ArrayList<>();
        for (Integer number : numbers) {
            if (predicate.test(number)) {
                result.add(number);
            }
        }

        return result;
    }
}

필터는 짝수인지, 홀수인지 조건을 판단하는 역할을 하므로 Predicate를 사용했다. 보다시피 필터 안에서 조건에 맞는지 predicate.test()로 검증하고 있다. 순회하면서 각각의 항목에 람다식이 적용되고, 최종적으로 각 조건에 맞는 데이터가 새로운 리스트에 담기고 있는 것이다.

 

현재 filter()가 받는 인자의 타입이 List<Integer>이고, Predicate<Integer>이기 때문에 숫자 리스트에 있는 값을 필터링 하는 모든 곳에서 사용 가능하다. 이 필터를 다른 곳에서도 편리하게 사용하기 위해 별도의 클래스로 빼자.

package lambda.lambda5.filter;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class IntegerFilter {

    public static List<Integer> filter(List<Integer> list, Predicate<Integer> predicate) {
        List<Integer> result = new ArrayList<>();
        for (Integer integer : list) {
            if (predicate.test(integer)) {
                result.add(integer);
            }
        }

        return result;
    }
}
package lambda.lambda5.filter;

import java.util.List;

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

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

        System.out.println("even numbers: " + IntegerFilter.filter(numbers, n -> n % 2 == 0));
        System.out.println("odd numbers: " + IntegerFilter.filter(numbers, n -> n % 2 != 0));

        /*
        even numbers: [2, 4, 6, 8, 10]
        odd numbers: [1, 3, 5, 7, 9]
        */
    }
}

보다시피 원하는 곳에서 깔끔하게 필터를 갖다 사용할 수 있다. 하지만, 지금 문제는 만일 다른 타입의 데이터를 처리하고 싶은 경우에는 위 IntegerFilter를 사용할 수 없다. 그렇다면 각 데이터 타입에 맞는 별도의 클래스들을 전부 만들어 줘야 하는걸까?

 

🎭 제네릭 도입

package lambda.lambda5.filter;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class GenericFilter {

    public static <T> List<T> filter(List<T> list, Predicate<T> predicate) {
        List<T> result = new ArrayList<>();
        for (T integer : list) {
            if (predicate.test(integer)) {
                result.add(integer);
            }
        }

        return result;
    }
}

제네릭 <T>를 선언하고, Integer로 되어 있는 부분들을 전부 T로 바꿔줬다.

package lambda.lambda5.filter;

import java.util.List;

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

        // 숫자 필터
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        System.out.println("짝수: " + GenericFilter.filter(numbers, n -> n % 2 == 0));

        // 문자 사용 필터
        List<String> strings = List.of("A", "BB", "CCC");
        System.out.println("길이가 2 이상인 문자열: " + GenericFilter.filter(strings, s -> s.length() >= 2));
    }
}

이제 보다시피 다양한 타입의 리스트에 필터를 사용할 수 있게 되었다.


📇 맵 만들기

맵은 대응, 변환을 의미하는 매핑(Mapping)의 줄임말이다. 프로그래밍에서는 어떤 데이터를 다른 데이터로 변환하는 작업이라고 이해하면 된다.

package lambda.lambda5.map;

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

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

        List<String> list = List.of("1", "12", "123", "1234");

        // 문자열을 숫자로 변환
        List<Integer> numbers = mapStringToInteger(list);
        System.out.println("numbers = " + numbers);

        // 문자열의 길이를 반환
        List<Integer> length = mapStringToLength(list);
        System.out.println("length = " + length);
    }

    static List<Integer> mapStringToInteger(List<String> list) {
        List<Integer> result = new ArrayList<>();
        for (String s : list) {
            result.add(Integer.parseInt(s));
        }

        return result;
    }

    static List<Integer> mapStringToLength(List<String> list) {
        List<Integer> result = new ArrayList<>();
        for (String s : list) {
            result.add(s.length());
        }

        return result;
    }
}

단순히, 문자열 타입의 숫자들을 숫자형으로 변환하거나, 문자열의 길이로 데이터를 뽑아내도록 매핑한 것이다. 필터에서와 마찬가지로 2가지의 메서드를 하나의 메서드(map())로 통합해보자.

package lambda.lambda5.map;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

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

        List<String> list = List.of("1", "12", "123", "1234");

        // 문자열을 숫자로 변환
        List<Integer> mapStringToInteger = map(list, s -> Integer.valueOf(s));
        System.out.println("mapStringToInteger = " + mapStringToInteger);

        // 문자열의 길이를 반환
        List<Integer> mapStringToLength = map(list, s -> s.length());
        System.out.println("length = " + mapStringToLength);

        /*
        mapStringToInteger = [1, 12, 123, 1234]
        length = [1, 2, 3, 4]
        */
    }

    static List<Integer> map(List<String> list, Function<String, Integer> mapper) {
        List<Integer> result = new ArrayList<>();
        for (String s : list) {
            result.add(mapper.apply(s));
        }

        return result;
    }
}

Function<String, Integer>map()의 인자로 넘기게 되면, s -> Integer.valueOf()s -> s.length()와 같은 람다식으로 데이터를 변환할 수 있다. 순회하면서 리스트에 담긴 모든 항목에 대해 람다식이 적용되는 것이다.

 

이제 매핑 기능을 자유롭게 사용할 수 있도록 별도의 클래스로 분리하도록 하자.

package lambda.lambda5.map;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

public class StringToIntegerMapper {

    public static List<Integer> map(List<String> list, Function<String, Integer> mapper) {
        List<Integer> numbers = new ArrayList<>();
        for (String s : list) {
            numbers.add(mapper.apply(s));
        }

        return numbers;
    }
}
package lambda.lambda5.map;

import java.util.List;

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

        List<String> list = List.of("1", "12", "123", "1234");

        // 문자열을 숫자로 변환
        List<Integer> mapStringToInteger = StringToIntegerMapper.map(list, s -> Integer.parseInt(s));
        System.out.println("mapStringToInteger = " + mapStringToInteger);

        // 문자열의 길이를 반환
        List<Integer> mapStringToLength = StringToIntegerMapper.map(list, s -> s.length());
        System.out.println("length = " + mapStringToLength);

        /*
        mapStringToInteger = [1, 12, 123, 1234]
        length = [1, 2, 3, 4]
        */
    }
}

 

곧바로 제네릭을 추가로 도입해서 다양한 타입의 데이터를 받아 변환할 수 있도록 처리까지 해주자.

package lambda.lambda5.map;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

public class GenericMapper {

    public static <T, R> List<R> map(List<T> list, Function<T, R> mapper) {
        List<R> numbers = new ArrayList<>();
        for (T s : list) {
            numbers.add(mapper.apply(s));
        }

        return numbers;
    }
}

제네릭을 <T, R> 선언하고, StringT(입력)로, IntegerR(출력)로 변경했다.

package lambda.lambda5.map;

import java.util.List;

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

        List<String> fruits = List.of("apple", "banana", "grape", "orange");

        // 대문자로 변환
        List<String> mapToUpperCase = GenericMapper.map(fruits, s -> s.toUpperCase());
        System.out.println("mapToUpperCase = " + mapToUpperCase);

        // 문자열의 길이로 변환
        List<Integer> mapToStringLength = GenericMapper.map(fruits, s -> s.length());
        System.out.println("mapToStringLength = " + mapToStringLength);

        // * 반복
        List<Integer> integers = List.of(1, 2, 3);
        List<String> star = GenericMapper.map(integers, n -> "*".repeat(n));
        System.out.println("star = " + star);
    }
}

⚔️ 필터와 맵 활용

이제 필터와 맵을 함께 사용해서 앞선 예제를 해결해보자.

package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import lambda.lambda5.filter.GenericFilter;
import lambda.lambda5.map.GenericMapper;

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

        // 짝수만 남기고, 남은 값의 2배를 반환
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> directResult = direct(numbers);
        System.out.println("directResult = " + directResult);

        List<Integer> lambdaResult = lambda(numbers);
        System.out.println("lambdaResult = " + lambdaResult);
    }

    static List<Integer> direct(List<Integer> numbers) {
        List<Integer> result = new ArrayList<>();
        for (Integer number : numbers) {
            if (number % 2 == 0) {
                result.add(number * 2);
            }
        }

        return result;
    }

    static List<Integer> lambda(List<Integer> numbers) {
        return GenericMapper.map(GenericFilter.filter(numbers, n -> n % 2 == 0), n -> n * 2);
    }
}

여기서 direct()lambda()는 서로 전혀 다른 프로그래밍 스타일을 보여준다.

 

🤔 명령형 vs 선언적?

direct() 처럼 프로그램을 어떻게 수행해야 하는지 수행 절차를 명시하는 방식을 명령형 프로그래밍이라고 한다. 익숙하고 직관적이지만, 로직이 복잡할수록 반복 코드가 많아질 수 있다. 반면, lambda()는 무엇을 수행해야 하는지에 초점을 맞춘다. 특정 조건으로 필터링하고, 변환하는 구체적인 부분은 내부에서 수행되는 것이다. 이를 선언적 프로그래밍 방식이라고 한다.

  • 명령형 프로그래밍(Imperative Programming): 프로그램이 어떻게(How) 수행되어야 하는지, 수행 절차를 명시하는 프로그래밍 방식
    • 단계별 실행: 프로그램의 각 단계를 명확히 지정하고 순서대로 실행한다.
    • 상태 변화: 프로그램의 상태가 어떻게 변화하는지 단계별로 명시한다.
    • 낮은 추상화: 내부 구현을 개발자가 직접 제어해야 하기 때문에 추상화 수준이 낮다.
  • 선언적 프로그래밍(Declarative Programming): 프로그램이 무엇(What)을 수행해야 하는지, 즉 원하는 결과를 명시하는 방식
    • 코드 간결성: 간결하고 읽기 쉬운 코드를 작성할 수 있다.
    • 높은 추상화: 내부 구현을 숨기고 원하는 결과에 집중할 수 있도록 추상화 수준을 높인다.

정리하자면, 명령형 프로그래밍은 수행해야 할 단계와 처리 과정을 자세히 서술해서 어떻게 결과에 도달할지를 명시하고, 선언적 프로그래밍은 원하는 결과나 상태를 기술하며, 그 결과를 얻기 위한 내부 처리 방식은 추상화되어 있어 무엇을 원하는지에 집중할 수 있다. 이때 람다를 사용하면 코드를 간결하게 선언적 프로그래밍 방식으로 해결할 수 있다.

 

그럼 이제 점수가 80점 이상인 학생의 이름을 뽑아내는 프로그램을 작성해보도록 하자. 그 전에 먼저 학생 클래스를 먼저 만들어줬다.

package lambda.lambda5.mystream;

public 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;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", score=" + score +
                '}';
    }
}
package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import lambda.lambda5.filter.GenericFilter;
import lambda.lambda5.map.GenericMapper;

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

        // 점수가 80점 이상인 학생의 이름을 추출
        List<Student> students = List.of(
                new Student("학생1", 100),
                new Student("학생2", 70),
                new Student("학생3", 55),
                new Student("학생4", 90),
                new Student("학생5", 85)
        );

        List<String> directResult = direct(students);
        System.out.println("directResult = " + directResult);

        List<String> lambdaResult = lambda(students);
        System.out.println("lambdaResult = " + lambdaResult);
    }

    static List<String> direct(List<Student> students) {
        List<String> result = new ArrayList<>();
        for (Student student : students) {
            if (student.getScore() >= 80) {
                result.add(student.getName());
            }
        }

        return result;
    }

    static List<String> lambda(List<Student> students) {
        return GenericMapper.map(GenericFilter.filter(students, s -> s.getScore() >= 80), n -> n.getName());
    }
}

보다시피 direct()는 프로그램이 어떻게 수행되어야 하는지에 초점이 맞춰져 있고, lambda()“점수가 80점 이상”“학생의 이름을 출력” 하는 것처럼 요구사항에 맞춰 무엇(What)을 하고 싶은지에 초점이 맞춰져 있다.


🌊 스트림 만들기

지금까지는 필터와 매핑을 구현하려면 각기 다른 유틸리티에서 끌어와 사용해야 했다. 그리고 필터된 결과를 다시 매핑 작업에 전달해야 했다. 이 2개의 기능을 하나의 객체로 만들어버릴 순 없을까?

필터와 맵을 사용할 때를 생각해보면, 마치 요구사항에 맞게 물 흐르듯 코드를 작성했었다. 좁은 시냇물... 이게 바로 스트림이다. 이렇듯 데이터가 흘러가면서 필터도 되고, 매핑도 되는 클래스를 만들어 보자.

package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public class MyStreamV1 {

    private List<Integer> internalList;

    public MyStreamV1(List<Integer> internalList) {
        this.internalList = internalList;
    }

    public MyStreamV1 filter(Predicate<Integer> predicate) {
        List<Integer> filtered = new ArrayList<>();
        for (Integer element : internalList) {
            if (predicate.test(element)) {
                filtered.add(element);
            }
        }

        return new MyStreamV1(filtered);
    }

    public MyStreamV1 map(Function<Integer, Integer> mapper) {
        List<Integer> mapped = new ArrayList<>();
        for (Integer element : internalList) {
            mapped.add(mapper.apply(element));
        }

        return new MyStreamV1(mapped);
    }

    public List<Integer> toList() {
        return internalList;
    }
}

위의 코드를 보면 MyStreamV1은 자신만의 리스트(internalList)를 가지고 있다. 스트림은 자신의 데이터를 필터링하거나 매핑해서 새로운 스트림을 만들 수 있다. 그리고 내부의 데이터 리스트를 반환할 수 있도록 toList() 메서드도 구현해 놓았다.

 

이제 위 MyStreamV1을 사용해보자. 숫자들이 담긴 리스트에서 짝수만 필터링하고, 그 짝수의 2배인 값들을 반환하는 것이다.

package lambda.lambda5.mystream;

import java.util.List;

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

        // 짝수만 남기고, 그 값의 2배를 반환
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        returnValue(numbers);
    }

    static void returnValue(List<Integer> numbers) {
        List<Integer> result = new MyStreamV1(numbers)
                .filter(n -> n % 2 == 0)
                .map(n -> n * 2)
                .toList();

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

returnValue() 메서드가 어떤 과정을 거쳐서 진행되는 건지 순서대로 확인해보자.

// 1. 일단 스트림을 생성한다.
List<Integer> result = new MyStreamV1(numbers)

// 2. 짝수만 걸러낼 수 있도록 필터링한다.
.filter(n -> n % 2 == 0)

// 3. 걸러낸 짝수들에 2를 곱한다.
.map(n -> n * 2)

// 4. 최종 결과를 리스트 형식으로 반환한다.
.toList();

MyStreamV1filter()map()를 호출하게 되면, 자기 자신 타입을 반환하기 때문에 위와 같이 메서드 체이닝 방식으로 연결해서 호출할 수 있다.

 

🏭 정적 팩토리 메서드

이제 정적 팩토리 메서드를 추가해서 스트림을 리팩토링 해보자.

package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

// 정적 팩토리 추가
public class MyStreamV2 {

    private List<Integer> internalList;

    private MyStreamV2(List<Integer> internalList) {
        this.internalList = internalList;
    }

    // static factory
    public static MyStreamV2 of(List<Integer> internalList) {
        return new MyStreamV2(internalList);
    }

    public MyStreamV2 filter(Predicate<Integer> predicate) {
        List<Integer> filtered = new ArrayList<>();
        for (Integer element : internalList) {
            if (predicate.test(element)) {
                filtered.add(element);
            }
        }

        return new MyStreamV2(filtered);
    }

    public MyStreamV2 map(Function<Integer, Integer> mapper) {
        List<Integer> mapped = new ArrayList<>();
        for (Integer element : internalList) {
            mapped.add(mapper.apply(element));
        }

        return new MyStreamV2(mapped);
    }

    public List<Integer> toList() {
        return internalList;
    }
}

위의 코드를 보면, 기존 생성자를 통해 인스턴스를 생성할 수 없도록 private으로 막아두었다. 이제 외부에서 MyStreamV2를 생성하고 싶으면, 오직 of() 메서드를 사용해서 생성해야 한다.

package lambda.lambda5.mystream;

import java.util.List;

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

        // 짝수만 남기고, 그 값의 2배를 반환
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> result = MyStreamV2.of(numbers)
                .filter(number -> number % 2 == 0)
                .map(number -> number * 2)
                .toList();

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

정적 팩토리 메서드란, 객체 생성을 담당하는 static 메서드로, 생성자 대신 인스턴스를 생성하고 반환하는 역할을 한다. 생성자와 비슷하지만, 인스턴스를 생성하고 초기화하는 로직을 외부에서 알 수 없도록 캡슐화하고 있다는 점에만 차이가 있다.

주요 특징은 아래와 같다.

  • 정적 메서드: 클래스 레벨에서 호출되며, 인스턴스 생성 없이 접근 가능하다.
  • 객체 반환: 내부에서 생성한 객체나 이미 존재하는 객체를 반환한다.
  • 생성자 대체: 생성자와 달리 메서드 이름을 명시할 수 있어, 생성 과정의 목적이나 특징을 명확하게 표현할 수 있다.
  • 유연한 구현: 객체 생성 과정에서 캐싱, 객체 재활용, 하위 타입 객체 반환 등 다양한 로직을 적용할 수 있다.

알다시피 생성자에는 이름을 부여할 수 없다. 따라서 정적 팩토리 메서드를 사용하게 되면 의미 있는 이름을 부여할 수 있기 때문에 가독성이 향상된다. 참고로 인자들을 받아 간단하게 객체를 생성할 때는 주로 of(...)라는 이름을 사용한다.

 

이제 기존의 스트림에 다양한 데이터 타입을 처리할 수 있도록 제네릭을 도입하도록 하자.

package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

// 제네릭 추가
public class MyStreamV3<T> {

    private List<T> internalList;

    private MyStreamV3(List<T> internalList) {
        this.internalList = internalList;
    }

    // static factory
    public static <T> MyStreamV3<T> of(List<T> internalList) {
        return new MyStreamV3<>(internalList);
    }

    public MyStreamV3<T> filter(Predicate<T> predicate) {
        List<T> filtered = new ArrayList<>();
        for (T element : internalList) {
            if (predicate.test(element)) {
                filtered.add(element);
            }
        }

        return MyStreamV3.of(filtered);
    }

    public <R> MyStreamV3<R> map(Function<T, R> mapper) {
        List<R> mapped = new ArrayList<>();
        for (T element : internalList) {
            mapped.add(mapper.apply(element));
        }

        return MyStreamV3.of(mapped);
    }

    public List<T> toList() {
        return internalList;
    }
}

MyStreamV3는 내부에 List<T> internalList를 포함하고 있기 때문에 MyStreamV3<T>로 선언해줬고, map()T를 받아 다른 타입인 R로 반환한다. R을 사용하는 곳은 현재 map() 메서드밖에 없기 때문에 map() 메서드 앞에 제네릭 <R>을 붙여줬다.

package lambda.lambda5.mystream;

import java.util.List;

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

        List<Student> students = List.of(
                new Student("학생1", 100),
                new Student("학생2", 70),
                new Student("학생3", 55),
                new Student("학생4", 90),
                new Student("학생26", 85),
                new Student("학생27", 89),
                new Student("학생28", 70),
                new Student("학생150", 90),
                new Student("학생157", 55)
        );

        // 점수가 80점 이상인 학생 이름을 추출
        List<String> result1 = ex1(students);
        System.out.println("result1 = " + result1);

        // 점수가 80점 이상이면서 번호가 2자릿수인 학생의 이름을 추출
        List<String> result2 = ex2(students);
        System.out.println("result2 = " + result2);

    }

    private static List<String> ex1(List<Student> students) {
        return MyStreamV3.of(students)
                .filter(s -> s.getScore() >= 80)
                .map(s -> s.getName())
                .toList();
    }

    private static List<String> ex2(List<Student> students) {
        return MyStreamV3.of(students)
                .filter(s -> s.getScore() >= 80)
                .filter(s -> s.getName().length() == 4)
                .map(s -> s.getName())
                .toList();
    }
}

/*
result1 = [학생1, 학생4, 학생26, 학생27, 학생150]
result2 = [학생26, 학생27]
*/

 

근데 filter()map()도 스트림 안에서 데이터 리스트를 하나씩 처리하는 기능인데, 최종 결과를 출력하는 기능도 포함시킬 수 있지 않을까? forEach() 메서드를 추가해보자.

package lambda.lambda5.mystream;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;

// 제네릭 추가
public class MyStreamV3<T> {

    private List<T> internalList;

    private MyStreamV3(List<T> internalList) {
        this.internalList = internalList;
    }

    // static factory
    public static <T> MyStreamV3<T> of(List<T> internalList) {
        return new MyStreamV3<>(internalList);
    }

    public MyStreamV3<T> filter(Predicate<T> predicate) {
        List<T> filtered = new ArrayList<>();
        for (T element : internalList) {
            if (predicate.test(element)) {
                filtered.add(element);
            }
        }

        return MyStreamV3.of(filtered);
    }

    public <R> MyStreamV3<R> map(Function<T, R> mapper) {
        List<R> mapped = new ArrayList<>();
        for (T element : internalList) {
            mapped.add(mapper.apply(element));
        }

        return MyStreamV3.of(mapped);
    }

    public List<T> toList() {
        return internalList;
    }

    // 추가
    public void forEach(Consumer<T> consumer) {
        for (T element : internalList) {
            consumer.accept(element);
        }
    }
}

위 코드처럼 forEach() 메서드로 최종 데이터를 출력할 수 있다. 최종 데이터는 요소를 먹기만 하기 때문에 Consumer를 사용했다.

package lambda.lambda5.mystream;

import java.util.List;

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

        List<Student> students = List.of(
                new Student("학생1", 100),
                new Student("학생2", 70),
                new Student("학생3", 55),
                new Student("학생4", 90),
                new Student("학생26", 85),
                new Student("학생27", 89),
                new Student("학생28", 70),
                new Student("학생150", 90),
                new Student("학생157", 55)
        );

        // 점수가 80점 이상인 학생 이름을 추출
        List<String> result = MyStreamV3.of(students)
                .filter(s -> s.getScore() >= 80)
                .map(s -> s.getName())
                .toList();

        // 외부 반복
        for (String s : result) {
            System.out.println("name: " + s);
        }

        // 추가
        MyStreamV3.of(students)
                .filter(s -> s.getScore() >= 80)
                .map(s -> s.getName())
                .forEach(name -> System.out.println("name: " + name));
    }
}

/*
name: 학생1
name: 학생4
name: 학생26
name: 학생27
name: 학생150
*/

 

🤔 내부 반복 vs 외부 반복?

스트림을 도입하기 전에는 for 문이나 While 문처럼 반복문을 직접 사용해서 데이터를 순회하는 외부 반복(External Iteration) 방식이었다. 반면, 스트림에서 제공하는 forEach() 메서드처럼 데이터를 처리하는 방식을 내부 반복(Internal Iteration)이라고 부른다. 반복 처리를 스트림 내부에 위임하는 것이다. 스트림 내부에서 요소들을 순회하고, 람다와 같은 처리 로직만 정의해주면 된다.

대부분의 경우, 내부 반복을 사용할 수 있는 상황이라면 내부 반복을 사용하는 편이 좋다. 다만 단순히 몇 줄의 실행만 필요한 경우, 반복 제어에 대한 복잡하고 세밀한 조정이 필요한 경우에는 외부 반복을 선택하는 것이 더 나을 수도 있다.

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글