devseop08 님의 블로그

[API] 람다와 스트림 - Mordern Java - 1. 기초 본문

Language/Java

[API] 람다와 스트림 - Mordern Java - 1. 기초

devseop08 2025. 5. 31. 13:53

1.1 자바 역사의 흐름

  • 자바 8 등장 이전까진, 멀티 코어를 사용하는 병렬 실행 환경을 잘 활용하지 못했다.
  • 하나의 코어 외의 나머지 코어를 활용하려면 쓰레드를 사용해야 한다고 했지만, 쓰레드를 사용하면 관리가 어렵고 많은 문제를 야기했다.
  • 자바는 이러한 병렬 실행 환경을 쉽게 관리하고 에러가 덜 발생하는 방향으로 진화하려 노력했다.
  • 자바 8에서는 병렬 실행을 새롭고 단순한 방식으로 접근할 수 있는 방법을 제공한다.
  • 자바 9에서는 리액티브 프로그래밍이라는 병렬 실행 기법을 지원한다.
  • RxJava(리액티브 스트림 툴킷)를 표준적인 방식으로 지원한다.
  • 자바 8은 자바 역사의 흐름에 있어서, 멀티코어 프로세서의 쉽고 효과적인 활용과 간결한 코드의 작성이라는 요구 사항을 기반으로 한다.
  • 이러한 요구 사항을 충족하고자 자바 8부터 도입된 새로운 기술은 세 가지이다.
    • 스트림 API
    • 메서드에 코드를 전달하는 기법(동작 파라미터화)
    • 인터페이스의 디폴트 메소드
  • 스트림 API는 병렬 연산을 지원하는 새로운 API이고, 데이터베이스 질의 언어와 같이 고수준 언어로 원하는 동작을 표현하면, 구현에서 최적의 저수준 실행 방법을 선택하는 방식으로 동작한다.
  • 자바 8 이전에는 메소드에 코드를 전달하는 기법으로, 익명 클래스를 이용하여 동작 파라미터화를 구현할 수는 있었다.
  • 자바 8부터는 스트림 API를 이용한 메소드에 코드를 전달하는 기법으로 새롭고 간결한 방식의 동작 파라미터화 구현이 가능해졌다.

1.2 왜 아직도 자바는 변하는가?

1.2.1 프로그래밍 생태계에서의 자바의 위치

  • 과거 환경(자바는 잘 설계된 객체지향 언어로 출발, JVM의 유연성과 확장성으로 빠르게 프로그래밍 생태계 점유, 모든 브라우저가 자바 가상 머신 코드를 지원)
  • 새로운 환경(빅데이터 -> 멀티 코어, 컴퓨팅 클러스터 이용 필요, 즉 병렬 프로세싱 활용 필요)
    -> 자바 8 등장 이전의 자바로는 병렬 프로세싱에 충분히 대응할 수 없었다.)

1.2.2 자바 8 설계의 밑바탕을 이루는 프로그램 개념 1 : 스트림 처리

  • 스트림이란 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다.
  • 어떤 프로그램의 출력 스트림은 다른 프로그램의 입력 스트림이 될 수 있다.
  • 유닉스 명령 행에서는 파이프( | )를 이용해서 명령을 연결할 수 있다.
    cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
  • 유닉스에서는 여러 명령을 병렬로 실행한다. => cat 프로세스나 tr 프로세스가 완료되지 않은 시점에서 sort 프로세스가 행을 처리하기 시작할 수 있다.
  • 스트림 API의 핵심은 기존에는 한 번에 한 항목을 처리했지만 이제 자바 8에서는 우리가 하려는 작업을 고수준으로 추상화해서 일련의 스트림으로 만들어 처리할 수 있다는 것이다.
  • 스트림 파이프라인을 이용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수 있다.
  • 스레드라는 복잡한 작업을 사용하지 않으면서도 공짜로 병렬성을 얻을 수 있다.

1.2.3 자바 8 설계의 밑바탕을 이루는 프로그램 개념 2 : 동작 파라미터화로 메서드에 코드 전달하기

  • 코드 일부를 API로 전달하는 기능
  • 자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공한다.
  • 자바 8 이전의 자바에서는 메서드를 다른 메서드로 전달할 방법이 없었다.
  • 메서드를 다른 메소드의 인수로 넘겨주는 기능을 이론적으로 동작 파라미터화라 한다.
  • 동작 파라미터화가 중요한 이유는 스트림 API는 연산의 동작을 파라미터화할 수 있는 코드를 전달한다는 사상에 기초하기 때문이다.

1.2.4 자바 8 설계의 밑바탕을 이루는 프로그램 개념 3 : 병렬성과 공유 가변 데이터

  • 스트림 API을 이용하면 병렬성을 쉽게 얻을 수 있는 대신, 스트림 메서드로 전달하는 코드의 동작 방식을 조금 바꿔야 한다.
  • 스트림 메서드로 전달하는 코드는 다른 코드와 동시에 실행하더라도 안전하게 실행될 수 있어야 한다.
  • 다른 코드와 동시에 실행하더라도 안전하게 실행할 수 있는 코드를 만들려면 공유된 가변 데이터에 접근하지 않아야 한다.
  • 이러한 함수를 순수 함수, 부작용 없는 함수, 상태 없는(stateless) 함수라 부른다.
  • synchronized를 이용해서 공유된 가변 데이터를 보호하는 규칙을 만들 수도 있지만, 자바 8 스트림을 이용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다.
  • 다중 처리 코어에서 synchronized를 사용하면(다중 처리 코어에서는 순차적으로 실행되어야 하므로 병렬이라는 목적을 무력화시키면서) 생각보다 훨씬 더 비싼 대가를 치러야 할 수 있다.

1.2.5 자바가 진화해야하는 이유

  • 자바 8부터는 기존 값을 변화시키는 데 집중했던 고전적인 객체지향에서 벗어나 함수형 프로그래밍으로 다가섰다는 것이 자바 8의 가장 큰 변화이다.
  • 함수형 프로그래밍에서는 우리가 하려는 작업이 최우선시되며, 그 작업을 어떻게 수행하는지는 별개의 문제로 취급한다.
  • 언어는 하드웨어나 프로그래머 기대의 변화에 부응하는 방향으로 변화해야 한다.

1.3 자바 함수

  • 자바 8에서는 함수를 새로운 값의 형식으로 추가했다.
  • 멀티코어에서 병렬 프로그래밍을 활용할 수 있는 스트림과 연계될 수 있도록 함수를 만들었다.
  • 자바 프로그램에서 조작할 수 있는 값, 즉 42, 3.14와 같은 기본값과 클래스의 인스턴스를 가리키는 객체 참조값을 전통적으로 프로그래밍 언어에서는 일급값이라고 부른다.
  • 왜 함수를 새로운 값의 형식으로 추가했을까?
  • 자바 프로그래밍 언어의 다양한 구조체(클래스, 메서드)가 값의 구조를 표현하는데 도움이 될 수는 있지만 프로그램을 실행하는 동안 이러한 모든 구조체를 자유롭게 전달할 수는 없다.
  • 자유롭게 전달할 수 없는 구조체, 메서드와 클래스는 이급 시민이다.
  • 런타임에 메서드를 전달할 수 있다면, 즉 메서드를 일급 시민으로 만들면 프로그래밍에 유용하게 활용할 수 있다.

1.3.1 메서드와 람다를 일급 시민으로

  • 자바 8에서 메서드를 값으로 취급할 수 있는 기능은 스트림 같은 다른 자바 8 기능의 토대를 제공했다.
  • 자바 8이 나타나기 전의 익명 클래스의 인스턴스와 객체 참조
  • 이미 메서드가 인스턴스화 되어 메모리에 존재하는데 굳이 또 다른 클래스의 인스턴스에 감싸야할까? 불필요하지 않나?
    File[] hiddenFiles = new File(".").listFiles(new FileFilter{
        public boolean accept(File file){
            return file.isHidden();
        }
    });
  • 자바 8이 등장한 이후의 메서드 참조
  • 메서드 참조 ::('이 메서드를 값으로 사용하라'는 의미)를 이용해서 함수를 직접 전달한다.
    File[] hiddenFiles = new File(".").listFiles(File::isHidden);
  • 자바 8에서는 더 이상 메서드가 이급값이 아닌 일급값
  • 기존에 객체 참조를 이용해서 객체를 이리저리 주고 받았던 것처럼 자바 8에서는 메서드 참조를 전달할 수 있게 되었다.

람다 : 익명 함수

  • 자바 8에서는 기명 메서드를 일급값으로 취급할 뿐 아니라 람다(익명 함수)를 포함하여 함수도 값으로 취급할 수 있다.
    (int x) -> x + 1
  • 람다 문법 형식으로 구현된 프로그램을 함수형 프로그래밍, 즉 '함수를 일급값으로 넘겨주는 프로그램을 구현한다'라고 한다.

1.3.2 코드 넘겨주기 : 예제

    public static boolean isGreenApple(Apple apple){
        return GREEN.equals(apple.getColor());
    }
    public static boolean isHeavyApple(Apple apple){
        return apple.getWeight() > 150;
    }
    public interface Predicate<T>{
        boolean test(T t);
    }
    static List<Apple> filterApple(List<Apple> inventory, Predicate<Apple> p){

        List<Apple> result = new ArrayList<>();

        for(Apple apple: inventory){
            if(p.test(apple))
                result.add
        }

        return result;
    }
    filterApple(Apple::isGreenApple);

    filterApple(Apple::isHeavyApple);

1.3.3 메서드 전달에서 람다로

  • 메서드를 값으로 전달하는 것은 분명 유용하지만, isHeavyApple, isGreenApple처럼 한두 번만 사용할 메서드를 매번 정의하는 것은 번거롭다.
  • 익명 함수 또는 람다라는 새로운 개념을 이용하면 이러한 번거로움을 덜 수 있다.
  • 한 번만 사용할 메서드는 따로 정의를 구현할 필요가 없다.
    filterApples( inventory, (Apple apple) -> Green.equals(apple.getColor()) );

    filterApples( inventory, (Apple apple) -> apple.getWeight() > 150 );
  • 병렬성의 중요성을 고려하지 않았다면 새로운 스트림 API를 제공하는 대신에 다음과 같은 몇몇 라이브러리 메서드를 추가하는 방향으로 발전했을 수도 있다.
    static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);
  • 병렬성을 고려했기 때문에 이와 같은 설계를 포기하고 filter와 비슷한 동작을 수행하는 연산집합을 포함하는 새로운 스트림 API를 제공하게 된 것이다.

1.4 스트림

  • 스트림 API를 이용하면 컬렉션 API와는 상당히 다른 방식으로 데이터를 처리할 수 있다.
  • 컬렉션에선는 반복 과정을 직접 처리 : 외부 반복
  • 스트림 API에서는 라이브러리 내부에서 모든 데이터가 처리 : 내부 반복
  • 컬렉션으로는 멀티 코어를 활용할 수 없다.

1.4.1 멀티스레딩은 어렵다

  • 멀티스레딩 환경에서 각각의 스레드는 동시에 공유된 데이터에 접근하고, 데이터를 갱신할 수 있다.
  • 결과적으로 스레드를 잘 제어하지 못하면 원치 않는 방식으로 데이터가 바뀌기 때문에 멀티스레딩 모델은 순차적인 모델보다 다루기가 어렵다.
  • 자바 8은 스트림 API로 컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제 그리고 멀티 코어 활용 어려움이라는 두 가지 문제를 모두 해결했다.
    • 기존 컬렉션에서는 데이터를 처리할 때 반복되는 패턴이 너무 많았다. => 반복되는 패턴을 제공한다면 좋을 것이라는 아이디어가 변화의 동기
    • 데이터를 연산하는 동작들을 쉽게 병렬화할 수 있다는 점도 변화의 동기가 되었다.
    • 멀티 코어 환경에서 리스트를 필터링할 때 한 코어는 리스트의 앞 부분을 처리하고, 다른 코어는 리스트의 뒷부분을 처리하도록 요청할 수 있다. (포킹 단계)

  • 컬렉션은 어떻게 데이터를 저장하고 접근할지에 중점을 두는 반면 스트림은 데이터에 어떤 계산을 할 것인지 묘사하는 것에 중점을 둔다
  • 스트림은 스트림 내의 요소를 쉽게 병렬로 처리할 수 있는 환경을 제공한다는 것이 핵심이다.
  • 컬레션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트림으로 바꾸고, 병렬로 처리한 다음에 리스트로 다시 복원하는 것이다.
  • 스트림 API를 이용해 리스트에서 요소들을 순차적으로 또는 병렬로 필터링할 수 있다.
    // 순차 처리 방식
    List<Apple> heavyApples = 
        inventory.stream().filter((Apple a) -> a.getWeight() > 150)
                          .collect(toList());

    // 병렬 처리 방식
    List<Apple> heavyApples = 
        inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
                          .collect(toList());
  • 자바의 병렬성과 공유되지 않은 가변 상태
    • 자바 8은 라이브러리에서 분할을 처리, 즉 큰 스트림을 병렬로 처리할 수 있도록 작은 스트림으로 분할한다.
    • filter와 같은 라이브러리 메서드로 전달된 메서드가 상호 작용을 하지 않는 메서드라면 가변 공유 객체를 통해 공짜로 병렬성을 누릴 수 있다.
    • 메서드가 상호 작용하지 않는다 것의 의미
      • 메서드가 상호 작용하지 않는다는 것은 메서드 내에서 호출 관계가 없고, 공유되는 상태를 변경하지 않으며, 외부에 부수 효과를 발생 시키지 않는다는 것이다.
      • 즉, 메서드에 인자로 주어지는 입력에만 의존하고 메서드 외부의 상태는 건드리지 않는다는 것이다.

1.5 디폴트 메소드와 자바 모듈

  • 자바 8이 등장하기 전까지는 인터페이스를 업데이트하려면 해당 인터페이스를 구현하는 모든 클래스도 업데이트 해야했다.
  • 자바 8에서는 이러한 문제를 디폴트 메서드로 해결할 수 있다.
  • 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에 추가할 수 있는 기능을 제공한다.
  • 메서드 본문은 클래스 구현이 아니라 인터페이스의 일부로 포함된다. 이를 디폴트 메서드락고 부른다.
  • 디폴트 메서드를 이용하면 기존의 코드를 건드리지 않고도 원래의 인터페이스 설계를 자유롭게 확장할 수 있다.
  • 자바 8에서는 List에 직접 sort 메서드를 호출할 수 있는데 이는 자바 8의 List 인터페이스에 디폴트 메서드 정의가 추가되었기 때문이다.
    default void sort(Comparator<? super T> c){
        Collections.sort(this, c);
    }
  • 하나의 클래스에서 여러 인터페이스를 구현할 수 있는데, 여러 인터페이스에 동일한 시스니처의 디폴트 메서드가 존재하는 경우엔, 구현 클래스에서 디폴트 메서드를 재정의해야 한다.

1.6 함수형 프로그래밍에서 가져온 다른 유용한 아이디어

  • 자바에 포함된 함수형 프로그래밍의 핵심적인 두 가지 아이디어
      1. 메서드와 람다를 일급값으로 사용
      1. 가변 공유 상태가 없는 병렬 실행을 이용해서 효율적이고 안전하게 함수나 메서드를 호출
  • 함수형 언어는 명시적으로 서술형의 데이터 형식을 이용해 null을 회피하는 기법으로 프로그램을 돕는다.
  • 이러한 아이디어를 기반으로 자바 8에서는 NullPointer Exception을 피할 수 있도록 도와주는 Optional< T > 클래스를 제공한다.
  • Optional< T > 타입의 객체는 값을 갖거나 갖지 않을 수 있는 컨테이너 객체다.
  • Optional< T > 클래스는 값이 없는 상황을 어떻게 처리할 지 명시적으로 구현하는 메서드를 포함하고 있다 => NullPointer Exception을 피할 수 있다.