Home [Java] 람다(Lambda) 표현식과 스트림(Stream)
Post
Cancel

[Java] 람다(Lambda) 표현식과 스트림(Stream)

Java 8부터 추가된 람다와 스트림에 대해서 알아보겠습니다.

람다 표현식(Lambda expression)

  • 익명 함수

  • 모든 메서드는 객체를 생성해야만 메서드를 호출할 수 있는데 이 람다식을 사용해서 메서드의 역할을 할 수 있다.

  • 람다식으로 인해 메서드를 변수처럼 다룰 수 있게 되었다.

    • 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수도 있다.
  • 일시적으로 한번만 사용되고 버려지는 익명클래스(익명객체)를 간단하게 표현할 수 있다.

메소드가 하나인 Java 인터페이스

  • java.lang.Runnable

  • java.util.Comparator

  • java.io.FileFilter

람다 표현식의 구성

이름과 반환타입을 제거하고 매개변수 선언부와 몸통{} 사이에 ->를 추가한다.

1
2
3
(int x, int y) -> {
    return x + y;
}
  • (int x, int y) : 매개변수 목록
  • -> : 화살표 토큰
  • x + y : 처리식 (한 줄인 경우 중괄호를 생략할 수 있다.)

람다 표현식 ↔ 익명 클래스

아래와 같이 메소드가 하나인 인터페이스를 익명 클래스로 구현했을 때보다 람다 표현식으로 구현하면 더욱 간결하게 구현할 수 있다.

1
2
3
interface AddClass {
    int operation(int a, int b);
}

익명 클래스로 처리했을 때

1
2
3
4
5
6
7
8
9
10
private void calculateAnony() {
    AddClass addObject = new AddClass() {
        @Override
        public int operation(int a, int b) {
            return a + b;
        }
    };
    
    System.out.println(addObject.operation(1, 2));	// 3
}

람다 표현식으로 처리했을 때

1
2
3
4
private void calculateLambda() {
    AddClass addObject = (a, b) -> a + b;
    System.out.println(addObject.operation(1, 2));
}
  • a와 b처럼 변수이름을 임의로 선언해도 문제 없이 수행된다.

함수형 인터페이스(Functional Interface)

함수형 인터페이스는 위에서 익명클래스, 람다함수가 구현한 것 인터페이스처럼 하나의 메소드만 선언되어 있는 것을 의미한다.

1
2
3
4
@FunctionalInterface
interface addClass {
    int operation(int a, int b);
}

@FunctionalInterface 어노테이션을 사용하여 명시적으로 표현할 수 있다.

  • 이 인터페이스에는 내용이 없는 하나의 메소드만 선언할 수 있다.
  • 만약 두 개의 인터페이스를 선언하면 컴파일 에러가 발생한다.

메소드 참조(더블 클론 ::)

Java 8에서 추가된 것으로, 람다 표현식이 단 하나의 메소드만을 호출하는 경우 해당 람다 표현식에서 불필요한 매개변수를 제거하고 사용할 수 있도록 해준다.

1
2
클래스이름::메소드이름
참조변수이름::메소드이름

메소드 참조의 종류

종류
static 메소드 참조ContainingClass::staticMethodName
특정 객체의 인스턴스 메소드 참조containingObject::instanceMethodName
특정 유형의 임의의 객체에 대한 인스턴스 메소드 참조ContainingType::methodName
생성자 참조ClassName::new

메소드 참조에 대한 자세한 내용은 ‘메소드 레퍼런스(Method Reference, 메소드 참조)’ 글에서 따로 정리하였습니다.

스트림(Stream)

연속된 정보를 처리할 때 사용한다.

  • 컬렉션에 사용할 수 있지만 배열에는 사용할 수 없다.

배열을 컬렉션으로 바꾸는 방법

방법1. Array 클래스의 asList()

1
2
Integer[] values = { 1, 2, 3 };
List<Integer> list = new ArrayList<Integer>(Arrays.asList(values));

방법2. Array 클래스의 stream()

1
2
Integer[] values = { 1, 2, 3 };
List<Integer> list = Arrays.stream(values).collect(Collectors.toList());

스트림의 구조

stream

1. 스트림 생성

컬렉션의 목록을 스트림 객체로 변환한다.

  • 스트림 객체 : java.util.stream 패키지의 Stream 인터페이스
  • Collection 인터페이스의 stream()를 사용한다.
    • 더 빠르게 처리할 때는 parallelStream() 를 사용하면 되는데, 병렬로 처리하기 때문에 CPU도 많이 사용하고 몇개의 쓰레드로 처리할 지 보장되지 않는다. (일반적으로는 사용 X)

2. 중개 연산(Intermediate operation)

생성된 스트림 객체를 사용하여 중개 연산 부분에서 처리한다.

  • 이 부분에서는 아무런 결과를 리턴하지 못한다. (그래서 중개 연산이라고 한다.)
  • 0개 이상의 중개 연산을 할 수 있다.

3. 종단 연산(Terminal operation)

중개 연산에서 작업된 내용을 바탕으로 결과를 리턴해준다.

스트림의 연산자

연산자설명
filter(pred)데이터를 조건으로 거를 때 사용
map(mapper)데이터를 특정 데이터로 변환
forEach(block)for 루프를 수행하는 것 처럼 각각의 항목을 꺼냄
flatMap(flat-mapper)스트림의 데이터를 잘게 쪼개서 새로운 스트림 제공
sorted(comparator)데이터의 정렬
toArray(array-factory)배열로 변환
any / all / nonMatch(pred)일치하는 것을 찾음
findFirst / Any(pred)맨 처음이나 순서와 상관없는 것을 찾음
reduce(binop) / reduce(base, binop)결과를 취합
collect(collector)원하는 타입으로 데이터를 리턴

forEach()

forEach는 종단 연산에 속하여 작업 내용을 보여주는 역할을 한다.

1
2
3
List<StudentDto> students = new ArrayList<>();
...
students.stream().forEach(student -> System.out.println(student.getName()));
  • stream() : 스트림 생성
  • forEach(student ... ) : student는 students List 객체에 담겨있는 StudentDto 객체를 의미한다.
1
students.stream().map(student -> student.getName()).forEach(name -> System.out.println(name));
  • map() : student 데이터를 변환한다.
  • forEach(name ... ) : name은 StudentDto 객체가 아니라 student.getName()의 결과인 String을 의미하게 된다.

map()

map은 중개 연산에 속해서 데이터를 변환하는 역할을 한다.

1
2
List<Integer> intList = Array.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
intList.stream().map(x -> x * 3).forEach(System.out.println(name));
  • map(x -> x * 3) : 스트림 내에 있는 값(Integer)을 3배로 변환한다.

filter()

filter는 중개 연산에 속해서 파라미터 값이 true인 데이터만 걸러내는 역할을 한다.

1
2
3
List<StudentDto> students = new ArrayList<>();
...
students.stream().filter(student -> student.getScoreMath() > scoreCutLine).forEach(student -> System.out.println(student.getName()));
  • filter(student -> student.getScoreMath() > scoreCutLine) : scoreCutLine 점수를 넘긴 학생들만 걸러낸다.

boxed()

boxed를 통해 기본 자료형을 래퍼 클래스로 Boxing할 수 있다.

1
2
int[] intArr = {1, 2, 3, 4, 5};
List<Integer> intList = Arrays.stream(intArr).boxed().collect(Collectors.toList());

mapTo기본자료형()

mapTo기본자료형를 통해 래퍼 클래스를 기본 자료형으로 Unboxing할 수 있다.

1
2
List<Integer> intList = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
int[] intArr = intList.stream().mapToInt(x -> x).toArray();

이 함수는 map과 같은 기능을 가지지만 반환형이 Stream이 아닌 IntStream이다.

기본 자료형 특화 스트림

Stream에서는 기본 자료형를 다룰 때 래퍼 클래스로 오토박싱이 이루어지는데, 이로 인해서 스트림이 병렬처리를 할 때 성능이 낮아진다.

Stream 객체가 public interface Stream<T> 로 제네릭 파라미터<T>를 받는 것으로 정의되어있기 때문이다. 그렇기 때문에 Stream에서는 아래와 같은 과정이 추가되어서 병렬 스트림에서 오버헤드가 발생할 수 있다.

  1. 기본 자료형을 래퍼 클래스(참조 자료형)으로 바꾸는 박싱이 일어나고
  2. 다시 래퍼 클래스를 기본 자료형으로 언박싱해서 내보낸다.

그래서 오토박싱이 일어나지 않도록 성능을 개선한 기본형 특화 스트림(IntStream, DoubleStream, LogStream)을 별도로 제공한다.

오토박싱 오버헤드가 발생하는 경우

1
2
long n = 10;
long result = Stream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum);
  • 숫자 n까지의 합을 병렬 스트림을 이용해서 구한다.

기본형 원시타입 long이 파라미터로 들어와서 오토박싱 오버헤드가 발생한다.

기본 자료형 특화 스트림을 사용한 경우

1
2
long n = 10;
long result = LongStream.rangeClosed(1, n).parallel().reduce(0L, Long::sum);

LongStream의 rangeClosed 메소드는 기본형 타입을 다루기 때문에 오토박싱이 일어나지 않는다.

Parallel

스트림의 parallel() , parallelStream() 메소드를 추가해주면 쉽게 병렬 처리를 할 수 있다.

1
2
3
4
5
IntStream.range(0, 10).parallel().forEach(index -> System.out.println(Thread.currentThread().getName() + ", index=" + index + ", " + new Date()));

try{
    Thread.sleep(5000);
} catch(InterruptedException e) { }

Parallel 사용 시 고려해야 할 점

하지만 스트림은 ForkJoinPool 방식을 이용하기 때문에 분할되는 작업이 균등하게 처리되어야 한다.

  • 작업을 분할하기 위해 Spliterator의 trySplit()을 사용하는데, 이 때 나누어지는 작업에 대한 비용이 높지 않아야 효율적으로 이루어질 수 있다.
  • 균등하게 처리되지 않을 경우 작업들이 이루어지고 난 후 Join할 때 나머지 작업들이 기다려야되게 되므로 효과가 적을 수 있다.
  • Array, ArrayList처럼 정확한 전체 사이즈를 알 수 있는 경우에는 분할 처리가 빠르고 비용이 적게들지만, LinkedList의 경우에는 효과를 찾기 어렵다.

또한 병렬로 처리되는 작업이 독립적이지 않다면, 수행 성능에 영향이 있을 수 있다.

  • stream의 중간 단계 연산 중 sorted(), distinct() 와 같은 작업을 수행할 경우 내부적으로 상태에 대한 변수를 각 작업들이 공유(synchronized)하게 되어 있다. 이러한 경우에는 순차적으로 실행하는 경우가 더 효과적일 수 있다.

자바 8의 가장 큰 변화

인터페이스의 스펙 변화 👉 람다 가능 👉 강화된 컬렉션 API 사용 👉 함수형 프로그래밍 가능

  • 인터페이스에 디폴트 메서드와 정적 메서드를 추가하는 결정을 내린 이유
    • Collection의 슈퍼 인터페이스인 Iterable 인터페이스에 많은 변화가 필요했다. (forEach 도입 등)
    • 이전 JDK를 기반으로 작성된 프로그램도 자바8JVM에서 구동될 수 있도록 새로운 추상 인스턴스 메서드를 추가하는 것이 아닌 디폴트 메서드라고 하는 새로운 개념을 추가한 것이다.

결론

이처럼 람다와 스트림을 함께 사용해서 데이터 프로세싱을 효율적으로 할 수 있고, 코드가 간결해진다.

이처럼 스트림은 데이터를 병렬로 처리할 수 있어서 for문보다 빠를 수 있다. 하지만 상황에 따라 for문은 컴파일러가 최적화하는 많은 방법이 있고 데이터 개수가 많지 않거나, 데이터가 많이 변경될 가능성이 있다면 for문이 더 빠를 수 있다. 이러한 경우를 많이 분석하여 스트림을 사용하는 것이 중요할 것 같다.

출처

  • 자바의 신
  • https://thalals.tistory.com/361
  • https://m.blog.naver.com/tmondev/220945933678
  • TCP school - 메소드 참조
  • https://gom20.tistory.com/217
  • 스프링 입문을 위한 자바 객체 지향의 원리와 이해
This post is licensed under CC BY 4.0 by the author.