학습 자료

람다와 스트림 — 화살표로 쓰는 코드


Article

책 목록에서 25,000원 미만인 책의 제목만 뽑는다. 지금까지 배운 것으로 쓰면 이렇다.

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

for (Book b : books) {
    if (b.getPrice() < 25000) {
        result.add(b.getTitle());
    }
}

나쁘지 않다. 다만 무엇을 하려는지forifadd 사이에 흩어져 있다.

List<String> result = books.stream()
    .filter(b -> b.getPrice() < 25000)
    .map(Book::getTitle)
    .toList();

거르고, 바꾸고, 모은다. 읽으면 그대로다.

이번 강의는 저 화살표(->)가 무엇인지부터 시작한다.

람다 — 이름 없는 메서드

책을 가격순으로 정렬한다고 하자. sort 는 "무엇을 기준으로 정렬할지" 알아야 한다.

15강에서 배운 대로라면, 인터페이스를 하나 만들고 구현 클래스를 만들어 넘겨야 한다.

class PriceComparator implements Comparator<Book> {
    public int compare(Book a, Book b) {
        return a.getPrice() - b.getPrice();
    }
}

books.sort(new PriceComparator());

메서드 하나 넘기려고 클래스를 하나 만들었다. 몸통은 한 줄인데 껍데기가 다섯 줄이다.

람다는 그 껍데기를 지운다.

books.sort((a, b) -> a.getPrice() - b.getPrice());

(매개변수) -> 몸통 이 전부다. 타입도 안 적는다. booksList<Book> 이니 abBook 이라는 걸 컴파일러가 안다.

추상 메서드가 하나뿐이어야 한다는 조건 때문에, 15강의 default 메서드가 쓸모를 얻는다. default 는 몸통이 있으니 세지 않는다. 그래서 Comparator 에는 reversed() 같은 편의 메서드가 잔뜩 있어도 여전히 함수형 인터페이스다.

메서드 참조 — 더 줄인다

람다가 그저 메서드 하나를 부르기만 한다면 더 줄일 수 있다.

.map(b -> b.getTitle())     // 람다
.map(Book::getTitle)        // 메서드 참조

:: 는 "이 메서드를 쓰라"는 뜻이다. 넘어온 값에게 getTitle() 을 부른다.

books.sort(Comparator.comparing(Book::getTitle));   // 제목순 정렬

스트림 — 흘려보내며 처리한다

stream() 은 컬렉션을 흐름으로 바꾼다.

books.stream()
    .filter(b -> b.getPrice() < 25000)
    .map(Book::getTitle)
    .toList();

Collection 인터페이스에 stream()default 메서드로 들어 있다. 15강에서 "이미 세상에 퍼진 인터페이스에 메서드를 추가해야 할 때 쓴다"고 했던 그 일이 실제로 벌어진 것이다. 자바 8이 이 문법으로 모든 컬렉션에 스트림을 붙였다.

연산은 두 종류다.

종류돌려주는 것
중간 연산filter map sorted distinct limit또 다른 스트림 — 이어붙일 수 있다
터미널 연산toList count sum forEach findFirst결과 — 여기서 끝난다

중간 연산은 이어붙이기만 할 뿐 아무 일도 하지 않는다. 믿기 어려우면 찍어보자.

books.stream()
    .filter(b -> {
        System.out.println("filter: " + b.getTitle());
        return b.getTitle().length() > 2;
    });
콘솔
  • (아무것도 찍히지 않는다)

filter 안의 println 이 한 번도 실행되지 않았다. 터미널 연산이 없으면 스트림은 시작조차 하지 않는다.

실행 순서를 눈으로 본다

터미널 연산을 붙이고, filtermap 양쪽에 println 을 심어보자. 책은 네 권이다.

books.stream()
    .filter(b -> { System.out.println("  filter: " + b); return b.length() > 2; })
    .map(b -> { System.out.println("    map: " + b); return b.toUpperCase(); })
    .toList();

filter 가 네 번 다 돌고 나서 map 이 도는 게 자연스러워 보인다. 반복문 두 개를 쓴다면 그렇게 된다.

콘솔
  • filter: 자바
  • filter: 스프링
  • map: 스프링
  • filter: 코틀린
  • map: 코틀린
  • filter: 자바스크립트
  • map: 자바스크립트

섞여 있다. 원소 하나가 파이프를 끝까지 통과한 다음, 그다음 원소가 들어온다.

원소 하나씩 파이프를 통과한다
filter
자바 ✕스프링코틀린자바스크립트
map
스프링코틀린자바스크립트
자바스프링코틀린자바스크립트
자바 는 filter 에서 걸러져 map 으로 내려가지 않았다. 목록을 두 번 돌지 않는다.

이걸 지연 평가라고 부른다. 중간 연산은 계획만 세우고, 터미널 연산이 붙는 순간 원소를 하나씩 흘려보낸다.

덕분에 걸러진 원소는 뒷 단계를 아예 거치지 않는다. 그리고 일찍 멈출 수도 있다.

books.stream()
    .filter(b -> { System.out.println("  filter: " + b); return b.length() > 2; })
    .findFirst();
콘솔
  • filter: 자바
  • filter: 스프링
  • 결과: Optional[스프링]

네 권 중 두 권만 검사하고 멈췄다. 답을 찾았으니 나머지를 볼 이유가 없다.

Optional[스프링] 이라는 낯선 것이 나왔다. 다음 강의의 주제다. 찾는 게 없을 수도 있으니 null 대신 이것을 준다.

자주 쓰는 것들

List<String> titles = books.stream().map(Book::getTitle).toList();
List<Book> cheap    = books.stream().filter(b -> b.getPrice() < 26000).toList();
int total           = books.stream().mapToInt(Book::getPrice).sum();
long count          = books.stream().filter(b -> b.getPrice() > 21000).count();
콘솔
  • [자바, 스프링, 코틀린]
  • [자바, 코틀린]
  • 75000
  • 2

sum() 앞에 mapToInt 가 붙은 것에 주의하자. Stream<Integer> 에는 sum() 이 없다. 숫자 전용 스트림으로 바꿔야 한다. 17강에서 본 래퍼 클래스와 기본형의 경계다.

스트림은 한 번만 쓴다

Stream<Book> s = books.stream();
s.count();
s.count();
터미널
  • Exception in thread "main" java.lang.IllegalStateException:
  • stream has already been operated upon or closed

스트림은 그릇이 아니라 흐름이다. 한 번 흘려보내면 끝이다. 다시 쓰려면 books.stream() 을 다시 부른다.

정리하면

람다는 이름 없는 구현체이고, 스트림은 원소를 하나씩 파이프에 흘려보낸다.

다시 짚어보기
  1. 01

    람다는 함수형 인터페이스의 구현체다

    추상 메서드가 하나뿐인 인터페이스여야 한다. 새 문법이지 새 타입이 아니다.

    books.sort((a, b) -> a.getPrice() - b.getPrice());
  2. 02

    메서드 하나만 부른다면 :: 로 줄인다

    b -> b.getTitle() 은 Book::getTitle 이다.

  3. 03

    중간 연산은 터미널 연산이 붙어야 돈다

    filter 만 이어붙이면 아무 일도 일어나지 않는다.

  4. 04

    목록을 여러 번 돌지 않는다

    원소 하나가 파이프를 끝까지 통과한 뒤 다음 원소가 들어온다. findFirst 는 일찍 멈춘다.

    books.stream()
        .filter(b -> b.getPrice() < 25000)
        .map(Book::getTitle)
        .toList();
  5. 05

    스트림은 한 번 쓰면 닫힌다

    다시 쓰려면 stream() 을 다시 부른다.

방금 findFirst()Optional[스프링] 을 돌려줬다. 값을 그냥 주지 않고 무언가로 감싸서 줬다.

16강에서 map.get() 이 없는 키에 null 을 돌려주던 것과 비교해보자. null 은 "없다"는 뜻인지 "값이 진짜 null 이다"라는 뜻인지 구분되지 않고, 받는 쪽은 검사를 잊기 쉽다.

다음 강의에서 자바가 그 문제에 내놓은 답을 본다. 4강에서 처음 만난 null 이야기가 거기서 끝난다.

람다와 스트림 — 화살표로 쓰는 코드 — 디코드랩(DCODELAB)