[Java] Java 8, 람다(lambda)와 스트림(stream)

clean·2024년 4월 24일
post-thumbnail

이 글은 책 <스프링 입문을 위한 자바 객체 지향의 원리와 이해>의 부록을 참고하여 작성하였습니다. 그리고 이 글은 스트림의 모든 것을 담았다기 보다는, 스트림의 등장 배경을 이해해보고 많이 쓰이는 스트림 메소드를 위주로 빠르게 공부하는 것을 목표로 하고 있습니다.

(오래전에 공부하다가 미루고 미루다 이제야 포스팅을 완성합니다.
갑자기 람다와 스트림에 대해 공부한 이유는 프로젝트를 진행하며 저의 가독성을 내다버린 코드들을 보며 더이상 미룰 수 없다는 생각이 들었기 때문입니다. 제가 짰는데 저도 읽기가 힘듭니다. 이런 코드를 코드리뷰 해야한다면 너무 힘들 것 같습니다...
빨리 스트림에 익숙해져서 다음 기능부터는 스트림으로 가독성 좋은 코드를 작성하는 것이 저의 목표입니다.)

람다가 도입된 이유

자바 8부터 도입된 람다는 왜 도입된걸까?
하나의 CPU안에 다수의 코어를 삽입하는 멀티 코어 프로세서들이 등장하며 일반 프로그래머에게도 병렬화 프로그래밍에 대한 필요성이 생기기 시작하였습니다.
이러한 추세에 따라 자바 JDK 8에서 병렬화를 위해 컬렉션(List, Set, Map 등)을 강화했고, 이러한 컬렉션을 더 효율적으로 사용하기 위해 스트림(Stream)을 강화했습니다. 또 스트림을 효율적으로 사용하기 위해 함수형 프로그래밍이, 다시 함수형 프로그래밍을 위해 람다가, 또 람다를 위해 인터페이스의 변화가 수반된 것입니다.

람다를 지원하기 위한 인터페이스를 함수형 인터페이스라고 한다.

람다란 무엇인가?

람다란 쉽게 말하여 코드 블록입니다.

기존에 코드 블록은 항상 메소드 안에 존재해야 했습니다.
그래서 코드 블록을 갖고 싶다면 메소드를 정의해야하고, 메소드를 정의하기 위해 익명 객체를 만들거나 하는 식으로 코드 블록을 사용하였습니다..

하지만 자바 8부터는 굳이 익명 객체를 만들지 않고도 람다식(코드블록)을 사용할 수 있게 되었습니다.
그리고 코드 블록인 람다를 메소드의 인자 또는 반환값으로도 사용할 수 있게 되었습니다.

그렇다면 (1) 익명 객체 없이 인터페이스 구현, (2) 익명 객체 사용, (3) 익명 객체 없이 람다식 사용 이렇게 차례대로 코드들을 살펴보며 람다와 함수형 인터페이스를 통해 어떻게 코드를 작성하기에 더 편리해졌는지 알아보겠습니다.

(1) 익명 객체 없이 인터페이스 구현(=생 클래스 만들기)

아래 코드는 익명 객체도 없이 Runnable 인터페이스를 구현한 MyTest 클래스를 만든 코드입니다. 쉽게 말하면 그냥 익명 객체도, 람다식도 사용하지 않고 인터페이스를 implements한 생 클래스를 하나 정의한 것입니다.

MyTest 클래스가 Runnable 인터페이스를 구현하였고, 이에 따라Runnable 안에 있는 추상 메소드인 run()을 구현하고 있는 것을 볼 수 있습니다.

public class B001 {
    public static void main(String[] args) {
        MyTest mt = new MyTest();

        Runnable r = mt;

        r.run();
    }

    static class MyTest implements Runnable {
        public void run() {
            System.out.println("Hello Lambda!");
        }
    }
}

(2) 익명 객체 사용

아래 코드는 Runnable 인터페이스를 익명 객체를 사용하여 구현한 코드입니다.

public class B002 {
    public static void main(String[] args) {
        Runnable r = new Runnable() {
            public void run() {
                System.out.println("Hello Lambda!!");
            }
        };

        r.run();
    }
}

만약 메소드를 일회용으로 쓰고 싶은거라면, (1)번에서처럼 생 클래스를 하나 만드는 것보다는 이렇게 익명 클래스를 만들어 쓰는 것이 더 편리할 것입니다.

하지만 함수형 인터페이스와 람다를 사용하면 익명 객체를 만들 필요도 없어집니다.

(3) 익명 객체 없이 람다식 사용

public class B003 {
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println("Hello Lambda!! :)");
        };

        r.run();
    }
}

(2)의 코드와 비교해보면 new Runnable() 부분이 생략되고, run() 메소드를 오버라이드하는 부분도 () -> { 로직 }으로 바뀐 것을 확인할 수 있습니다.

마치 Runnable 인터페이스 참조변수 r() -> { System.out.println("Hello Lambda!! :)"); };라는 람다식을 대입한 것처럼 보입니다.

우선 new Runnable()이 생략될 수 있었던 이유는 무엇일까요? 바로 Runnable 타입 참조 변수에 람다식을 대입하고 있기 때문에, new Runnable()은 써주지 않아도 컴파일러가 알아챌 수 있기 때문입니다.

그리고 public void run() {..}이 그냥 () -> {..}로 대체될 수 있었던 이유는 Runnable 인터페이스 안에 추상 메소드가 딱 하나 들어있었기 때문입니다.

이 예시에서는 public void run() 메소드의 매개변수가 없어서 () -> {로직}으로 썼지만, 인자가 있다면 (인자목록) -> {로직}으로 써주면 됩니다. 인자 목록 부분에서 매개변수들의 타입을 생략해주더라도 컴파일러가 인터페이스의 추상 메소드를 보고 추측할 수 있으므로 생략이 가능합니다.

코드 블록에서 코드가 한 줄이라면 {}가 생략이 가능하므로, 다음과 같은 코드도 가능합니다.

Runnable r = () -> System.out.println("Hello Lambda!!");

run()의 경우 리턴값이 void인 함수지만, 리턴값이 있는 경우, 중괄호를 생략했을 때, return도 생략해서 쓸 수 있습니다.

예를 들어 다음과 같은 인터페이스가 있다고 하겠습니다.

Add.java

public Add {
  int add(int a, int b);
}

이 인터페이스를 이용한 람다식은 아래 예시처럼 사용할 수 있습니다.

Add addInstance2 = (a, b) -> { return a + b; };
Add addInstance1 = (a, b) -> a + b; // 중괄호와 return 생략

람다식의 작성에 대해서는 아래 함수형 인터페이스 설명에서 더 자세히 알아보도록 하겠습니다.

함수형 인터페이스

함수형 인터페이스란, 앞에서 본 RunnableAdd와 같이 추상 메소드를 한개만 가지는 인터페이스를 의미합니다. 위의 예시처럼, 람다식을 인터페이스 참조변수에 대입하는 것처럼 사용을 할 수 있었는데, 이는 함수형 인터페이스만 가능합니다.

public class B005 {
    public static void main(String[] args) {
        MyFunctionalInterface mfi = (int a) -> { return a * a; };

        int b = mfi.runSomthing(5);

        System.out.println(b);
    }

    @FunctionalInterface // 함수형 인터페이스인지 검사하는 어노테이션
    interface MyFunctionalInterface {
        public abstract int runSomthing(int count);
    }
}

@FunctionalInterface 어노테이션은 해당 인터페이스가 함수형 인터페이스인지, 즉 하나의 추상 메소드를 가지고 있는지를 검사하는 어노테이션입니다. 이 어노테이션은 붙여도 되고 붙이지 않아도 됩니다.

람다식의 표현

위 함수형 인터페이스 설명 코드에서 이 부분은

MyFunctionalInteface mfi = (int a) -> { return a * a };

int를 생략하여 이렇게 바꿔 쓸 수도 있습니다.

MyFunctionalInteface mfi = (a) -> { return a * a };

여기서 소괄호를 또 생략하여 이렇게 바꿀 수도 있습니다.

MyFunctionalInteface mfi = a -> { return a * a };

메소드 부분이 딱 한줄이라면, 결과를 리턴하는 부분도 이렇게 간단하게 표현이 가능합니다.

MyFunctionalInteface mfi = a -> a * a; // 중괄호, 리턴생략

메소드 호출 인자, 반환값으로 사용되는 람다

위 예제에서 함수형 인터페이스 참조 변수에 람다를 대입하여 사용했습니다. 람다식을 참조 변수에 저장할 수 있으므로, 메소드의 호출인자로도 사용 가능하고 반환 값으로도 사용이 가능합니다.

람다를 메소드 호출 인자로 사용한 예시 코드

public class B007 {
    public static void main(String[] args) {
        MyFunctionalInterface mfi = a -> a * a; // 람다식을 저장
        doIt(mfi); // 람다식을 매개변수로 하여 메소드 호출
    }

    public static void doIt(MyFunctionalInterface mfi) {
        int b = mfi.runSomething(5);

        System.out.println(b);
    }
}

위 코드에서는 MyFunctionalInterface 타입의 참조 변수에 람다식을 저장하여 메소드를 호출했지만 아래와 같이 바로 람다식을 인자로 하여 메소드를 호출할 수도 있습니다.

doIt(a -> a*a); // 람다식을 매개변수로 하여 메소드 호출

또한 아래 코드 처럼 람다를 메소드의 반환 값으로 쓸 수도 있습니다.

public class B007 {
    public static void main(String[] args) {
        MyFunctionalInterface mfi = todo(); // 람다식을 매개변수로 하여 메소드 호출

        System.out.println(mfi.runSomething(3));
    }

    public static MyFunctionalInterface todo() {
        return num -> num * num;
    }
}

자바 8 API에서 제공하는 함수형 인터페이스

자바 8에서는 개발자들이 많이 쓸 것이라고 예상되는 함수형 인터페이스를 java.util.function 패키지와 여러 패키지에서 제공하고 있습니다.

함수형 인터페이스추상 메소드용도
Runnablevoid run()실행할 수 있는 인터페이스
Supplier<T>T get()제공할 수 있는 인터페이스
Consumer<T>void accept(T t)소비할 수 있는 인터페이스
Function<T, R>R apply(T t)입력을 받아서 출력할 수 있는 인터페이스
Predicate<T>Boolean test(T t)입력을 받아 참/거짓을 단정할 수 있는 인터페이스(조건식을 표현)
UnaryOperator<T>T apply(T t)단항 연산할 수 있는 인터페이스

위 표에 소개된 것 외에도 java.util.function에는 많은 표준 함수형 인터페이스가 정의되어 있습니다.

표준 함수형 인터페이스를 쓸 때의 장점?

표준 함수형 인터페이스는 디폴트 메소드를 제공하기 때문에 다른 코드와 상호 운용성이 좋아진다는 장점이 있습니다.

예) Predicate에서 and(), or(), negate로 조건을 조합해 하나의 새로운 Predicate로 결합가능

컬렉션 스트림에서 람다 사용

람다는 다양한 용도가 있지만 컬렉션 스트림을 위한 기능에 초점이 맞춰져 있습니다.

스트림이란?

스트림은 데이터 소스를 추상화하고, 데이터를 다루는 데 자주 사용되는 메소드를 정의해 놓은 것입니다. 데이터 소스를 추상화하였다는 것은 데이터 소스가 무엇이든 간에 같은 방식으로 다를 수 있게 되었다는 것입니다. 따라서 코드의 재사용성이 높아지게 됩니다.
스트림을 이용하면 배열이나 컬렉션 뿐만아니라 파일에 저장된 데이터도 모두 같은 방식으로 다를 수 있게 됩니다.

스트림은 데이터 소스를 변경하지 않습니다.

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐 데이터 소스를 변경하지 않습니다.

스트림은 일회용입니다.

스트림은 Iterator처럼 일회용으로 사용됩니다. 이터레이터가 컬렉션의 모든 요소를 끝까지 읽으면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용할 수가 없습니다.
스트림을 다시 사용하고 싶다면 다시 생성해야합니다.

스트림은 작업을 내부 반복으로 처리합니다.

스트림을 이용한 작업이 간결할 수 있는 비결 중 하나가 바로 "내부 반복"입니다.
내부 반복이라는 것은 반복문을 메소드의 내부에 숨길 수 있다는 것입니다.
forEach()라는 연산이 내부 반복을 지원하는 연산입니다. forEach()는 스트림에 정의된 메소드 중 하나로, 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용합니다.

    List<String> list = List.of(new String[]{"a", "b", "c"});
    list.forEach(System.out::println); // 모든 요소를 출력

실행 결과

스트림의 사용

스트림이 제공하는 다양한 연산을 이용해서, 컬렉션에 대해 여러 작업을 처리할 수 있습니다.
스트림을 용하는 과정은 스트림의 생성, 중간 연산최종 연산으로 분류할 수 있습니다.

스트림 생성

  • 데이터 소스로부터 스트림을 생성하는 단계입니다.

중간 연산

  • 연산 결과를 stream으로 반환합니다.
  • 중간 연산의 반환값에 계속 스트림 연산을 할 수 있습니다.

최종 연산

  • 연산 결과를 stream이 아닌 값으로 반환합니다.
  • 스트림의 요소를 소모하기 때문에, 최종 연산 이후에는 더이상 스트림 연산을 할 수 없으며, 최종 연산은 딱 한번만 할 수 있습니다.

아래는 스트림 사용의 예시 코드입니다.

List<String> names = students.stream()
    .filter(student -> student.getGrade() == 1)
    .map(Student::getName)
    .limit(10)
    .collect(toList()); // 1학년인 학생의 이름을 리스트로 뽑아내기

스트림 연산의 특징 - 지연된 연산

  • 스트림 연산에서 한가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것입니다.
  • 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야 하는지를 지정해주는 것일 뿐입니다. 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모됩니다.

스트림과 관련된 특별한 기능들

IntStream, DoubleStream, LongStream VS Stream<T>

  • 요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱과 언박싱으로 인한 비효율을 줄이기 위해서 데이터 소스의 요소를 primitive type으로 다루는 스트림인 IntStream, LongStream, DoubleStream이 제공됩니다.
  • 일반적으로 Stream<Integer> 대신 IntStream을 사용하는 것이 더 효율적이고 IntStream에는 int 타입의 값으로 작업하는 데 유용한 메소드들이 포함되어 있습니다.

병렬 스트림

자바는 병렬적으로 데이터를 다룰 수 있는 스트림 메소드를 제공합니다.

  • parallel(): 병렬로 연산 수행
  • sequential(): 병렬로 수행되지 않도록 함.

스트림 사용, 자세히 알아보기

생성

컬렉션에서 스트림을 생성해보기

컬렉션을 데이터 소스로 하여 스트림을 생성하려면 컬렉션에 .stream()을 붙여주면 됩니다.
stream()은 Collection.java에 정의되어 있습니다.

default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
}

사용 예시 - List

    List<String> list = List.of(new String[]{"a", "b", "c", "a", "b", "a", "c"});
    long aCount = list.stream().filter(str -> str.equals("a")).count();
    System.out.println(aCount); // 출력 결과: 3

사용 예시 - Set

    Set<Student> set = new HashSet<>();
    set.add(new Student("Kim", 1));
    set.add(new Student("Lee", 2));
    set.add(new Student("Park", 2));
    set.add(new Student("Choi", 3));

    List<String> list = set.stream()
        .filter(student -> student.getGrade() == 2)
        .map(Student::getName)
        .toList();

    for(String studentName : list) {
      System.out.println(studentName);
    }

배열에서 생성해보기

Arrays 클래스에 정의되어 있는 stream(T[]) 메소드, Stream 클래스에 정의되어 있는 of(T[]) 메소드로 생성을 할 수가 있습니다.

람다식으로 스트림 생성해보기

람다식을 데이터 소스로 하여 스트림을 생성할 때는 Stream 클래스에 정의되어 있는 iterate(), generate() 메소드를 이용합니다.

static <T> stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> stram<T> generate(Supplier<T> s)

사용 예시.

Stream.iterate(0, n -> n+2) // 0은 seed값. 0 -> 0+2 -> 2+2 -> 4+2
    .limit(4)
    .forEach(System.out::println);

결과

중간 연산

중간 연산 메소드는 stream을 리턴하며, 데이터를 적절하게 가공하는 단계입니다.

distinct()

스트림에서 중복 데이터를 제거합니다.

filter(Predicate<T> predicate)

조건에 맞는 데이터만을 남깁니다.

사용 예시

    menus.stream()
        .filter(element -> element.getName().equals(name))
        .findFirst()
        .orElseThrow(NoSuchElementException::new);

sorted(Comparator<T> comparator)

스트림을 comparator로 정렬합니다.
comparator를 넘기지 않으면 기본 정렬 기준으로 정렬합니다. (ex. 숫자면 오름차순, 문자열이면 사전 순)

map(Function<T, R> mapper)

원하는 필드만 뽑아내거나 특정 형태로 변환하고 싶을 때 사용합니다.

사용 예시

List<String> humanNames = humans.stream()
    .map(h -> h.getName())
    .collect(Collectors.toList());

flatMap(Function<T, R> mapper)

스트림의 원소를 각각 하나의 스트림으로 매핑한 후 그 스트림들을 다시 하나의 스트림으로 합칩니다.

예를 들어, map으로 변환한 결과가 리스트와 같은 컬렉션일 때, flatMap으로 그 각각의 리스트를 스트림으로 만들어 하나의 스트림으로 합칠 수가 있는 것입니다.

사용 예시

List<String> travelDestinations = humans.stream()
    .map(h -> h.getTravelDestinations())
    .flatMap(Collection::stream) // travelDestinations 리스트들을 하나의 스트림으로
    .distinct()
    .collect(Collectors.toList());

코드 출처: https://isntyet.github.io/java/java-stream-%EC%A0%95%EB%A6%AC(map)/

최종 연산

최종 연산을 쓰면 스트림이 소모됩니다. 따라서 마지막에 딱 한번 사용 가능합니다.

forEach(Consumer<? super T> action)

스트림의 모든 요소에 해당 연산을 적용하며, 보통은 출력하는 용도로 사용 됩니다.

사용 예시

list.stream()
    .filter(a -> a > 3)
    .forEach(System.out::println);

조건 검사 메소드

  • boolean allMatch(Predicate<? super T> predicate): 모든 요소가 일치하면 true
  • boolean anyMatch(Predicate<? super T> predicate): 하나의 요소라도 일치하면 true
  • boolean nonMatch(Predicate<? super T> predicate): 모든 요소가 불일치하면 true

예시코드. 가지고 있는 카드 중에 ACE 카드가 있는지를 확인

private boolean hasAceCard() {
    return cards.strean()
        .map(Card::getNumber)
        .andMatch(cardNumber -> cardNumber.equals(CardNumber.ACE));
}

코드 출처: https://www.youtube.com/watch?v=4ZtKiSvZNu4&t=339s

조건에 맞는 요소 리턴 메소드

  • findFirst(): 조건에 일치하는 첫번째 요소를 반환
  • findAny(): 조건에 일치하는 요소를 하나 반환

collect(Collector collector)

스트림의 요소를 수집해서 원하는 형태로 반환합니다.
collect 메소드를 이해할 때 Collector 인터페이스와 Collectors 클래스를 함께 알아두면 좋습니다.

  • Collector: 수집에 필요한 메소드를 정의해 놓은 인터페이스
  • Collectors: 다양한 기능의 Collector를 구현한 클래스. static 메소드로 미리 작성된 컬렉터를 제공합니다.

Collectors 클래스의 toList(), toSet(), toMap(), toCollection, toArray() 등의 메소드와 함께 사용할 수 있습니다.

예시 코드:

List<String> names = studentStream.map(Student::getName)
    .collect(Collectors.toList());

이중 Map의 경우, Key와 Value 쌍으로 지정해야 하므로, 객체의 어떤 필드를 Key로 Value로 사용할지 지정해야합니다.

예시 코드

Map<String, Person> map = personStream
    .collect(Collectors.toMap(p -> p.getId(), p->p));

Collectors 안에는 스트림의 통계 정보를 제공하는 메소드들도 존재합니다.

  • counting()
  • summingInt()
  • maxBy()
  • minBy()

합계 구하기 예시코드

long totalScore = stuStream.mapToInt(Student::getTotalScore).sum();
long totalScore = stuStream.collect(summingInt(Student::getTotalScore));

통계 연산

  • count()
  • sum()
  • average()
  • max()
  • min()
    등이 있습니다.

reduce()

스트림의 요소를 줄여나가면서 연산을 수행하고 최종 결과를 반환하니다.
그래서 매개변수 타입이 BinaryOperator<T>입니다.
처음 두 요소를 가지고 연산한 결고를 가지고 그 다음 요소와 연산합니다.
이 과정에서 스트림이 요소를 하나씩 소모하게되고, 스트림의 모든 요소를 소모하면 그 결과를 반환합니다.

참고. Stream<Integer>와 IntStream

Stream<Integer>는 int를 stream으로 만들기 위해 int -> Integer로 박싱하는 과정을 거친 후, sum()과 같은 최종 연산이 들어오면 합계를 구하기 위해 Integerint로 언박싱하는 상황을 거칩니다.

하지만 mapToInt() 메소드를 이용해서 IntStream을 만들면 박싱, 언박싱이 일어나지 않습니다.

예시 코드
Stream<Integer> 사용

int score = cards.stream()
    .map(card -> card.getNumber().getScore())
    .reduce(0, Integer::sum);
int score = cards.stream()
    .map(card -> card.getNumber().getScore())
    .sum();

글을 마치면서

스트림과 그 메소드들에 대해 빠르게 알아보았습니다.
스트림은 자주 사용해보고 연습하는 것이 중요하다고 합니다.

간단한 반복문 등을 사용하는 백준 문제를 스트림으로 풀어보면서 스트림을 연습하고 익숙해질 수 있을 것 같습니다.

Reference

profile
블로그 이전하려고 합니다! 👉 https://onfonf.tistory.com 🍀

0개의 댓글