람다활용

람다 활용 - 필터

V1 - 람다를 사용하지 않는 방식

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, 10);

        List<Integer> evens = filterEvenNumber(numbers);
        List<Integer> odds = filterOddNumber(numbers);
    }

    //짝수만 거르기
    private static List<Integer> filterEvenNumber(List<Integer> numbers) {
        List<Integer> filtered = new ArrayList<>();
        for (int num : numbers) {
            if (num % 2 == 0) {
                filtered.add(num);
            }
        }
        return filtered;
    }

    //홀수만 거르기
    private static List<Integer> filterOddNumber(List<Integer> numbers) {
        List<Integer> filtered = new ArrayList<>();
        for (int num : numbers) {
            if (num % 2 == 1) {
                filtered.add(num);
            }
        }
        return filtered;
    }
}

V2 - 람다를 사용해서 중복을 제거한 방식

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, 10);

        List<Integer> even = filter(numbers, num -> num % 2 == 0);
        List<Integer> odd = filter(numbers, num -> num % 2 == 1);
    }
    
    private static List<Integer> filter(List<Integer> numbers, Predicate<Integer> predicate) {
        List<Integer> filtered = new ArrayList<>();
        for (int num : numbers) {
            if (predicate.test(num)) {
                filtered.add(num);
            }
        }
        return filtered;
    }
}

V3 - 별도의 유틸리티 클래스 작성

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

public class IntegerFilter {

    public static List<Integer> filter(List<Integer> numbers, Predicate<Integer> predicate) {
        List<Integer> filtered = new ArrayList<>();
        for (int num : numbers) {
            if (predicate.test(num)) {
                filtered.add(num);
            }
        }
        return filtered;
    }
}
import java.util.List;

public class FilterMainV3 {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 10);

        List<Integer> even = IntegerFilter.filter(numbers, num -> num % 2 == 0);
        List<Integer> odd = IntegerFilter.filter(numbers, num -> num % 2 == 1);
    }
}

V4 - 제네릭 사용

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

public class GenericFilter {
    
    public static <T> List<T> filter(List<T> values, Predicate<T> predicate) {
        List<T> filtered = new ArrayList<>();
        for (T val : values) {
            if (predicate.test(val)) {
                filtered.add(val);
            }
        }
        return filtered;
    }
}
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, 10);

        List<Integer> even = GenericFilter.filter(numbers, num -> num % 2 == 0);
        List<Integer> odd = GenericFilter.filter(numbers, num -> num % 2 == 1);

        List<String> strings = List.of("A", "BB", "CCC");
        List<String> result1 = GenericFilter.filter(strings, s -> s.length() >= 2);
        List<String> result2 = GenericFilter.filter(strings, s -> s.length() % 2 == 1);
    }
}

람다 활용 - 매핑

V1 - 람다를 사용하지 않는 방식

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);
        List<Integer> lengths = mapStringToLength(list);
    }

    //문자열의 길이
    private static List<Integer> mapStringToLength(List<String> list) {
        List<Integer> result = new ArrayList<>();
        for (String s : list) {
            result.add(s.length());
        }
        return result;
    }

    //문자열을 숫자로 반환
    private static List<Integer> mapStringToInteger(List<String> list) {
        List<Integer> result = new ArrayList<>();
        for (String s : list) {
            result.add(Integer.valueOf(s));
        }
        return result;
    }
}

V2 - 람다를 사용해서 중복을 제거한 방식

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> numbers = map(list, s -> Integer.valueOf(s));
        List<Integer> lengths = map(list, s -> s.length());
    }

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

V3 - 별도의 유틸리티 클래스 작성

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> result = new ArrayList<>();
        for (String s : list) {
            result.add(mapper.apply(s));
        }
        return result;
    }
}
import java.util.List;

public class MapMainV3 {
    public static void main(String[] args) {
        List<String> list = List.of("1", "12", "123", "1234");
        // 문자열을 숫자로 변환
        List<Integer> numbers = StringToIntegerMapper.map(list, s -> Integer.valueOf(s));
        System.out.println("numbers = " + numbers);
        
        // 문자열의 길이
        List<Integer> lengths = StringToIntegerMapper.map(list, s -> s.length());
        System.out.println("lengths = " + lengths);
    }
}

V4 - 제네릭 사용

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> values, Function<T, R> function) {
        List<R> result = new ArrayList<>();
        for (T value : values) {
            result.add(function.apply(value));
        }
        return result;
    }
}
import java.util.List;

public class MapMainV4 {
    public static void main(String[] args) {
        List<String> fruits = List.of("apple", "banana", "orange");

        // String -> String
        List<String> upperFruits = GenericMapper.map(fruits, s -> s.toUpperCase());
        System.out.println(upperFruits); // [APPLE, BANANA, ORANGE]

        // String -> Integer
        List<Integer> lengthFruits = GenericMapper.map(fruits, s -> s.length());
        System.out.println(lengthFruits); // [5, 6, 6]

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

람다 활용 - 필터와 매핑

필터와 매핑 예제 1

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

public class Ex1_Number {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        //짝수만 골라서 2배를 반환
        List<Integer> result1 = direct(numbers);
        List<Integer> result2 = lambda(numbers);
    }
    
    //전통적인 방식
    private static List<Integer> direct(List<Integer> numbers) {
        List<Integer> result = new ArrayList<>();
        for (int num : numbers) {
            if (num % 2 == 0) {
                result.add(num * 2);
            }
        }
        return result;
    }

    //람다 사용
    private static List<Integer> lambda(List<Integer> numbers) {
        List<Integer> filtered = GenericFilter.filter(numbers, n -> n % 2 == 0);
        return GenericMapper.map(filtered, n -> n * 2);
    }
}

direct()는 프로그램을 어떻게 수행해야 하는지 수행 절차를 명시한다.

  • 개발자가 로직 하나하나를 어떻게 실행해야 하는지 명시하며, 이러한 프로그래밍 방식을 명령형 프로그래밍이라고 한다.

  • 명령형 스타일은 익숙하고 직관적이지만, 로직이 복잡해질수록 반복 코드가 많아질 수 있다.

반면 lambda()무엇을 수행해야 하는지 원하는 결과에 초점을 맞춘다.

  • 특정 조건으로 필터하고 변환하라고 선언하면 구체적인 부분은 내부에서 수행된다.

  • 갭라자는 필터하고 변환하는 것과 같은 무엇을 해야 하는가에 초점을 맞춘다.

  • 이러한 프로그래밍 방식을 선언적 프로그래밍이라고 한다.

  • 선언형 스타일은 무엇을 하고자 하는지가 명확히 드러난다. 따라서 코드 가독성과 유지보수가 쉬워진다.

👆 명령형 vs 선언적 프로그래밍

  • 명령형 프로그래밍

    • 프로그램이 어떻게(How) 수행되어야 하는지, 즉 수행 절차를 명시하는 방식

    • 특징

      • 단계별 실행 : 프로그램의 각 단계를 명확하게 지정하고 순서대로 실행한다.

      • 상태 변화 : 프로그램의 상태(변수 값 등)가 각 단계별로 어떻게 변화하는지 명시한다.

      • 낮은 추상화 : 내부 구현을 직접 제어해야 하므로 추상화 수준이 낮다.

      • 예시 : 전통적인 for 루프, while 루프 등을 명시적으로 사용하는 방식

    • 장점 : 시스템의 상태와 흐름을 세밀하게 제어할 수 있다.

  • 선언적 프로그래밍

    • 프로그램이 무엇을(What) 수행해야 하는지, 즉 원하는 결과를 명시하는 방식

    • 특징

      • 문제 해결에 집중 : 어떻게 문제를 해결할지보다 무엇을 원하는지에 초점을 맞춘다.

      • 코드 간결성 : 간결하고 읽기 쉬운 코드를 작성할 수 있다.

      • 높은 추상화 : 내부 구현을 숨기고 원하는 결과에 집중할 수 있도록 추상화 수준을 높인다.

      • 예시 : filter, map 등 람다의 고차 함수를 활용, HTML, SQL 등

    • 장점 : 코드가 간결하고 의미가 명확하며 유지보수가 쉬운 경우가 많다.

필터와 매핑 예제 2

public class Student {
    private final String name;
    private final int score;

    //Constructor, getter, setter, toString...
}
import java.util.ArrayList;
import java.util.List;

public class Ex2_Student {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Apple", 100),
                new Student("Banana", 80),
                new Student("Berry", 50),
                new Student("Tomato", 40)
        );

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

    //전통적인 방식
    private 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;
    }

    //람다 사용
    private static List<String> lambda(List<Student> students) {
        List<Student> filtered = GenericFilter.filter(students, student -> student.getScore() >= 80);
        return GenericMapper.map(filtered, student -> student.getName());
    }
}

람다를 사용하면 구체적으로 어떻게 필터링하고 데이터를 추출하는지 보다는 요구사항에 맞춰서 무엇을 하고 싶은지에 초점을 맞춘다. 결과적으로 코드를 간결하게 작성하고 선언적 스타일로 해결할 수 있다.


람다 활용 - 스트림

필터와 매핑 기능을 별도의 유틸리티 클래스에서 각각 따로 제공하는 방식을 함께 편리하게 사용할 수 있도록 하나의 객체에 기능을 통합해본다.

V1 - 기본

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

public class MyStreamV1 {
    private final 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> function) {
        List<Integer> mapped = new ArrayList<>();
        for (Integer element : internalList) {
            mapped.add(function.apply(element));
        }
        return new MyStreamV1(mapped);
    }

    public List<Integer> toList() {
        return this.internalList;
    }
}
import java.util.List;

public class MyStreamV1Main {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // 짝수만 남기고, 남은 값의 2배를 반환
        returnValue(numbers);
        methodChain(numbers);
    }

    //메서드 체인 X
    public static void returnValue(List<Integer> numbers) {
        MyStreamV1 stream = new MyStreamV1(numbers);
        MyStreamV1 filteredStream = stream.filter(n -> n % 2 == 0);
        MyStreamV1 mappedStream = filteredStream.map(n -> n * 2);
        List<Integer> result = mappedStream.toList();
    }

    //메서드 체인 O
    public static void methodChain(List<Integer> numbers) {
        List<Integer> result = new MyStreamV1(numbers).filter(n -> n % 2 == 0)
                                                      .map(n -> n * 2)
                                                      .toList();
    }
}

MyStreamV1 클래스의 메서드는 자기 자신의 타입을 반환하기 때문에 메서드 체인 방식을 사용해 깔끔한 구조를 만들 수 있다.

V2 - 정적 팩토리 메서드 추가

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

//static factory 추가
public class MyStreamV2 {
    private final List<Integer> internalList;

    //private 설정
    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 MyStreamV2.of(filtered);
    }

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

    public List<Integer> toList() {
        return this.internalList;
    }
}
import java.util.List;

public class MyStreamV2Main {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // 짝수만 남기고, 남은 값의 2배를 반환
        List<Integer> result = MyStreamV2.of(numbers)
                                         .filter(n -> n % 2 == 0)
                                         .map(n -> n * 2)
                                         .toList();
    }
}

👆 정적 팩토리 메서드

정적 팩토리 메서드는 객체 생성을 담당하는 static 메서드로, 생성자 대신 인스턴스를 생성하고 반환하는 역할을 한다. 즉 일반적인 생성자 대신에 클래스의 인스턴스를 생성하고 초기화하는 로직을 캡슐화하여 제공하는 정적 메서드이다.

주요 특징

  • 정적 메서드 : 클래스 레벨에서 호출되며, 인스턴스 생성 없이 접근할 수 있다.

  • 객체 반환 : 내부에서 생성한 객체(또는 이미 존재하는 객체)를 반환한다.

    • 예) Integer.valueOf()는 미리 생성된 객체를 반환한다.

  • 생성자 대체 : 생성자와 달리 메서드 이름을 명시할 수 있어 생성 과정의 목적이나 특징을 명확하게 표현할 수 있다.

  • 유연한 구현 : 객체 생성 과정에서 캐싱, 객체 재활용, 하위 타입 객체 반환 등 다양한 로직을 적용할 수 있다.

생성자는 이름을 부여할 수 없지만, 정적 팩토리 메서드는 의미있는 이름을 부여할 수 있기 때문에 가독성이 더 좋아지는 장점이 있다. 인자들을 받아 간단하게 객체를 생성할 때는 주로 of(...) 라는 이름을 사용한다.

하지만 반대로 보면 이름도 부여해야 하고 준비해야 하는 코드도 더 많다. 객체의 생성이 단순한 경우에는 생성자를 직접 사용하는 것이 더 나은 선택일 수 있다.

V3 - 제네릭 사용 + 기능 추가

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

//Generic 추가
public class MyStreamV3<T> {
    private final 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> function) {
        List<R> mapped = new ArrayList<>();
        for (T element : internalList) {
            mapped.add(function.apply(element));
        }
        return MyStreamV3.of(mapped);
    }

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

    public List<T> toList() {
        return this.internalList;
    }
}
import java.util.List;

public class MyStreamV3Main {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Apple", 100),
                new Student("Banana", 80),
                new Student("Berry", 50),
                new Student("Tomato", 40)
        );

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

        //점수가 80점 이상이면서, 이름이 5글자인 학생의 이름을 대문자로 추출
        List<String> result2 = MyStreamV3.of(students)
                                         .filter(student -> student.getScore() >= 80)
                                         .filter(student -> student.getName().length() == 5)
                                         .map(student -> student.getName())
                                         .map(name -> name.toUpperCase())
                                         .toList();

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

        //내부 반복
        MyStreamV3.of(students)
                  .filter(student -> student.getScore() >= 80)
                  .map(student -> student.getName())
                  .forEach(name -> System.out.println("name = " + name));
    }
}

👆 외부 반복 vs 내부 반복

  • 외부 반복

    • for문, while문과 같은 반복문을 직접 사용해서 데이터를 순회하는 방식

    • 개발자가 직접 각 요소를 반복하며 처리한다.

  • 내부 반복

    • 직접 반복 제어문을 작성하지 않고, 반복 처리를 스트림 내부에 위임하는 방식

    • 스트림 내부에서 요소들을 순회하고, 개발자는 처리 로직(람다)만 정의해주면 된다.

    • 코드가 훨씬 간결해지며, 선언형 프로그래밍 스타일을 적용할 수 있다.

내부 반복 방식은 반복의 제어를 스트림에게 위임하기 때문에 코드가 간결해진다. 즉 개발자는 어떤 작업을 할지를 집중적으로 작성하고, 어떻게 순회할지는 스트림이 담당하도록 하여 생산성과 가독성을 높일 수 있다.

많은 경우 내부 반복이 선언형 프로그래밍 스타일로 더욱 직관적이기 때문에 더 나은 선택이다. 다만 때때로 외부 반복을 선택하는 것이 더 나은 경우도 있다.

  • 단순히 한두 줄 수행만 필요한 경우

  • 반복 제어에 대한 복잡하고 세밀한 조정이 필요한 경우 (breakcontinue 등을 사용하는 경우)

Last updated