포스트

Java (5) - Stream & Optional: 언제 쓰나

for문과 스트림의 시간을 재고 스트림이 게으르다는 것과 일회용이라는 것을 확인한 뒤 Optional을 쓸 자리와 안 쓸 자리를 다룹니다.

Java (5) - Stream & Optional: 언제 쓰나

자바 기초 시리즈의 5편입니다. 전체 목차는 0편에 있습니다.

3편에서 컬렉션을 만들었고 4편에서 그 안에 들어가는 타입을 봤습니다. 이번 편은 그 컬렉션을 다루는 방법입니다.

스트림은 자바 8에서 들어왔고, 그 뒤로 “for문 대신 스트림”이라는 분위기가 생겼습니다. 그런데 스트림이 항상 나은 건 아닙니다. 어디까지 나은지 재보는 게 이번 편입니다.

스트림이 하는 일

1
2
3
4
5
6
7
8
9
10
11
// for
List<String> result = new ArrayList<>();
for (Member m : members) {
    if (m.getAge() >= 20) result.add(m.getName());
}

// stream
List<String> result = members.stream()
        .filter(m -> m.getAge() >= 20)
        .map(Member::getName)
        .toList();

“어떻게 도는지”를 쓰던 것을 “무엇을 하는지”로 바꿔 씁니다. 리스트를 새로 만들고 add를 부르는 과정이 사라졌습니다.

스트림 연산은 두 갈래입니다.

  하는 일
중간 연산 스트림을 돌려준다. 실행되지 않는다 filter, map, sorted, peek, distinct
최종 연산 스트림이 아닌 걸 돌려준다. 여기서 실행된다 collect, toList, forEach, count, findFirst

이 구분이 이번 편의 절반입니다.

for보다 빠른가

확인 1: 크기를 바꿔가며 재본다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.*;
import java.util.stream.*;

public class Bench {
    static long sumFor(List<Integer> list) {
        long sum = 0;
        for (Integer n : list) if (n % 2 == 0) sum += n;
        return sum;
    }
    static long sumStream(List<Integer> list) {
        return list.stream().filter(n -> n % 2 == 0).mapToLong(Integer::longValue).sum();
    }
    static long sumParallel(List<Integer> list) {
        return list.parallelStream().filter(n -> n % 2 == 0).mapToLong(Integer::longValue).sum();
    }

    static void bench(int size, int rounds) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < size; i++) list.add(i);

        for (int i = 0; i < 2000; i++) { sumFor(list); sumStream(list); }   // 워밍업

        long t1 = System.nanoTime();
        for (int i = 0; i < rounds; i++) sumFor(list);
        long f = (System.nanoTime() - t1) / 1_000_000;

        long t2 = System.nanoTime();
        for (int i = 0; i < rounds; i++) sumStream(list);
        long s = (System.nanoTime() - t2) / 1_000_000;

        long t3 = System.nanoTime();
        for (int i = 0; i < rounds; i++) sumParallel(list);
        long p = (System.nanoTime() - t3) / 1_000_000;

        System.out.printf("원소 %-8d %6d회 반복 → for %5dms / stream %5dms / parallel %5dms%n",
                size, rounds, f, s, p);
    }

    public static void main(String[] args) {
        bench(10,        1_000_000);
        bench(1_000,       100_000);
        bench(1_000_000,       100);
    }
}
1
2
3
원소 10        1000000회 반복 → for    17ms / stream   127ms / parallel  6833ms
원소 1000       100000회 반복 → for    81ms / stream   505ms / parallel  3509ms
원소 1000000        100회 반복 → for   120ms / stream   510ms / parallel   186ms

세 줄에서 각각 다른 걸 봅니다.

첫 줄 — 원소 10개짜리를 100만 번 도는 경우입니다. 스트림이 7배 느립니다. 원소 10개를 처리하는 시간보다 스트림 파이프라인을 만드는 비용이 큽니다. 매번 Stream 객체와 람다 호출 구조를 세우기 때문입니다.

둘째 줄 — 원소가 1000개로 늘어도 여전히 for가 6배 빠릅니다.

셋째 줄 — 원소 100만 개에서는 차이가 4배로 줄었습니다. 파이프라인 만드는 비용이 전체에서 차지하는 비율이 작아졌기 때문입니다.

parallel은 더 극단적입니다. 작은 컬렉션에서는 400배까지 느려집니다. 일을 쪼개서 스레드에 나눠주고 결과를 합치는 비용이, 원소 10개 더하는 일보다 훨씬 큽니다. 100만 개에 가서야 겨우 stream보다 빨라집니다.

숫자는 제 환경의 예시입니다. 코어 수와 JIT 상태에 따라 크게 달라지고, 정확한 측정에는 JMH 같은 도구를 씁니다. 봐야 할 건 자릿수와 방향이지 값이 아닙니다.

그래서 결론은 이렇습니다.

대부분의 경우 이 차이는 무의미합니다. 위 첫 줄에서 100만 번을 돌려 110ms 차이가 났으니, 한 번당 0.0001ms입니다. 요청 하나가 DB를 한 번 갔다 오는 데 몇 ms가 걸리는 걸 생각하면 보이지도 않는 차이입니다.

그러니 성능이 아니라 읽기 쉬운 쪽으로 고릅니다. 다만 두 자리는 예외입니다.

  • 아주 짧은 루프를 아주 많이 도는 자리 — 여기서는 for가 낫습니다
  • parallelStream() — 이건 성능을 위해 쓰는 것이므로, 재보지 않고 쓰면 안 됩니다. 위 결과처럼 대부분 더 느립니다

스트림은 게으르다

중간 연산이 “실행되지 않는다”는 게 무슨 뜻인지 직접 봅니다.

확인 2: 최종 연산을 빼본다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.*;
import java.util.stream.*;

public class Stm {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);

        System.out.println("--- 최종 연산 없음 ---");
        nums.stream()
            .peek(n -> System.out.println("peek: " + n))
            .filter(n -> n % 2 == 0);
        System.out.println("(아무것도 안 찍힘)");

        System.out.println("--- 최종 연산 있음 ---");
        List<Integer> even = nums.stream()
            .peek(n -> System.out.println("peek: " + n))
            .filter(n -> n % 2 == 0)
            .collect(Collectors.toList());
        System.out.println("결과 = " + even);

        System.out.println("--- findFirst ---");
        Optional<Integer> first = nums.stream()
            .peek(n -> System.out.println("검사: " + n))
            .filter(n -> n > 2)
            .findFirst();
        System.out.println("찾은 값 = " + first.get());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- 최종 연산 없음 ---
(아무것도 안 찍힘)
--- 최종 연산 있음 ---
peek: 1
peek: 2
peek: 3
peek: 4
peek: 5
peek: 6
결과 = [2, 4, 6]
--- findFirst ---
검사: 1
검사: 2
검사: 3
찾은 값 = 3

첫 블록에서 peek이 한 번도 안 불렸습니다. filter까지 써놨는데 아무 일도 안 일어났습니다. 중간 연산은 “이렇게 처리할 것”이라는 계획만 쌓는 것이고, 최종 연산이 와야 실행됩니다.

세 번째 블록이 게으름의 이득입니다. 원소가 6개인데 3개만 검사했습니다. findFirst는 하나를 찾으면 멈추고, 그 뒤의 원소는 peekfilter도 거치지 않습니다.

그리고 두 번째 블록을 보면 원소 하나가 파이프라인 전체를 통과한 뒤 다음 원소로 넘어갑니다. peek을 6번 다 하고 나서 filter를 6번 하는 게 아닙니다. 그래서 findFirst가 중간에 멈출 수 있습니다.

1
2
3
4
5
6
7
[ 계획을 쌓는 단계 ]                 [ 최종 연산이 오면 ]

  stream()                            1 ─▶ peek ─▶ filter ─▶ (버림)
    .peek(...)     ← 실행 안 됨       2 ─▶ peek ─▶ filter ─▶ 결과에 담음
    .filter(...)   ← 실행 안 됨       3 ─▶ peek ─▶ filter ─▶ (버림)
                                      ...
  아무 일도 안 일어남                  원소 하나씩 세로로 통과한다

peek디버깅용입니다. 여기서처럼 “중간에 뭐가 지나가나”를 볼 때만 쓰고, 실제 로직을 넣으면 안 됩니다. 최종 연산이 무엇이냐에 따라 호출될 수도 안 될 수도 있기 때문입니다.

스트림은 일회용이다

확인 3: 같은 스트림을 두 번 쓴다

1
2
3
4
5
6
7
8
9
10
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);

Stream<Integer> s = nums.stream();
System.out.println("count = " + s.count());

try {
    System.out.println(s.filter(n -> n > 3).count());
} catch (IllegalStateException e) {
    System.out.println(e.getClass().getName() + ": " + e.getMessage());
}
1
2
count = 6
java.lang.IllegalStateException: stream has already been operated upon or closed

한 번 최종 연산을 부르면 그 스트림은 끝입니다. 다시 쓰려면 원본 컬렉션에서 stream()을 다시 부릅니다.

1
2
System.out.println(nums.stream().count());
System.out.println(nums.stream().filter(n -> n > 3).count());

컬렉션은 데이터를 담는 것이고 스트림은 그 데이터를 한 번 흘려보내는 통로라고 보면 됩니다. 통로를 필드에 담아두거나 메서드에서 돌려주면 이 예외를 만나게 됩니다.

Optional은 무엇을 푸는가

1
2
Member member = memberRepository.findByEmail(email);
String name = member.getName();     // member 가 null 이면?

이 코드의 문제는 findByEmailnull을 줄 수 있다는 사실이 시그니처에 안 적혀 있다는 것입니다. 문서를 읽거나 구현을 열어봐야 압니다. 대부분은 안 읽고 NullPointerException으로 알게 됩니다.

1
Optional<Member> findByEmail(String email);

반환 타입이 “없을 수도 있다”를 말해줍니다. 그리고 컴파일러가 그냥 꺼내 쓰지 못하게 막습니다.

반환 타입 전용이다

Optional메서드의 반환 타입으로 쓰라고 만든 것입니다. 나머지 자리에는 쓰지 않습니다.

자리 쓰나 이유
메서드 반환 타입 쓴다 원래 목적
필드 안 쓴다 직렬화가 안 되고, 객체마다 래퍼가 하나씩 더 생긴다
메서드 파라미터 안 쓴다 호출하는 쪽이 Optional.of(x)로 감싸야 한다. 오버로딩이 낫다
컬렉션의 원소 안 쓴다 비어 있음은 빈 컬렉션으로 표현한다

특히 컬렉션을 돌려줄 때 Optional을 쓰면 안 됩니다.

1
2
Optional<List<Order>> findOrders(Long memberId);   // 안 좋다
List<Order> findOrders(Long memberId);             // 빈 리스트를 준다

받는 쪽이 “비어 있음”을 두 가지(Optional.empty()와 빈 리스트)로 확인해야 합니다.

Optional을 잘못 쓰는 법

확인 4: get()과 orElse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.*;

public class Opt {
    static String expensive() {
        System.out.println("  >> expensive() 호출됨");
        return "기본값";
    }

    public static void main(String[] args) {
        Optional<String> found = Optional.of("찾은 값");
        Optional<String> empty = Optional.empty();

        System.out.println("[1] 값이 있을 때 orElse");
        System.out.println("결과 = " + found.orElse(expensive()));

        System.out.println("[2] 값이 있을 때 orElseGet");
        System.out.println("결과 = " + found.orElseGet(Opt::expensive));

        System.out.println("[3] 값이 없을 때 orElseGet");
        System.out.println("결과 = " + empty.orElseGet(Opt::expensive));

        System.out.println("[4] empty.get()");
        try {
            System.out.println(empty.get());
        } catch (NoSuchElementException e) {
            System.out.println(e.getClass().getName() + ": " + e.getMessage());
        }

        System.out.println("[5] orElseThrow()");
        try {
            System.out.println(empty.orElseThrow(() -> new IllegalStateException("회원 없음")));
        } catch (IllegalStateException e) {
            System.out.println(e.getClass().getName() + ": " + e.getMessage());
        }

        System.out.println("[6] Optional.of(null)");
        try {
            Optional.of(null);
        } catch (NullPointerException e) {
            System.out.println("NullPointerException");
        }
        System.out.println("Optional.ofNullable(null) = " + Optional.ofNullable(null));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[1] 값이 있을 때 orElse
  >> expensive() 호출됨
결과 = 찾은 값
[2] 값이 있을 때 orElseGet
결과 = 찾은 값
[3] 값이 없을 때 orElseGet
  >> expensive() 호출됨
결과 = 기본값
[4] empty.get()
java.util.NoSuchElementException: No value present
[5] orElseThrow()
java.lang.IllegalStateException: 회원 없음
[6] Optional.of(null)
NullPointerException
Optional.ofNullable(null) = Optional.empty

[1]이 이번 편에서 가장 놀라운 줄입니다. 값이 있는데도 expensive()가 호출됐습니다.

이유는 자바의 평가 순서입니다. found.orElse(expensive())orElse를 부르기 전에 인자를 먼저 계산합니다. orElse가 그 값을 쓸지 말지는 그 다음 일입니다.

1
2
3
4
5
6
7
8
9
10
11
   found.orElse(expensive())
                ▲
                └── 메서드를 부르려면 인자가 있어야 한다
                    → expensive() 가 먼저 실행된다
                    → 그 결과를 orElse 에 넘긴다
                    → orElse 는 값이 있으므로 그 결과를 버린다

   found.orElseGet(Opt::expensive)
                   ▲
                   └── 함수 자체를 넘긴다 (아직 실행 안 됨)
                       → orElse 안에서 값이 없을 때만 부른다

[2]에서 orElseGet은 아무것도 안 찍었고 [3]에서만 찍었습니다. 필요할 때만 실행됩니다.

expensive()가 진짜로 DB를 조회하거나 API를 호출한다면 차이가 큽니다.

1
2
3
4
5
// 회원을 찾았는데도 매번 기본 회원을 조회한다
memberRepository.findById(id).orElse(memberRepository.findDefault());

// 못 찾았을 때만 조회한다
memberRepository.findById(id).orElseGet(() -> memberRepository.findDefault());

기준은 단순합니다. 인자가 이미 있는 값이면 orElse, 만들어야 하는 값이면 orElseGet 입니다. orElse("")orElse(0)처럼 상수를 줄 때는 orElse가 낫습니다.

[4]get()을 그냥 부르면 NoSuchElementException: No value present입니다. 이러면 Optional을 쓴 의미가 없습니다. null 체크를 빼먹은 코드가 NullPointerException이 나는 것과 똑같은 상황이 됐습니다.

[5]orElseThrow를 쓰면 어떤 상황인지 아는 예외를 던질 수 있습니다. 스택 트레이스를 봤을 때 No value present보다 회원 없음이 훨씬 낫습니다.

[6]Optional.of(null)NullPointerException입니다. null일 수 있으면 ofNullable을 씁니다.

쓰는 모양

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 안 좋다 - null 체크가 Optional 체크로 이름만 바뀌었다
Optional<Member> opt = repository.findById(id);
if (opt.isPresent()) {
    Member m = opt.get();
    return m.getName();
}
return "이름 없음";

// 낫다
return repository.findById(id)
        .map(Member::getName)
        .orElse("이름 없음");

// 없으면 예외인 경우
Member m = repository.findById(id)
        .orElseThrow(() -> new MemberNotFoundException(id));

isPresent() + get() 조합이 나오면 대개 map, filter, orElse, orElseThrow로 바꿀 수 있습니다.

그래서 언제 쓰나

상황 고르는 것
컬렉션을 걸러서 변환한다 스트림 (읽기 쉬움)
원소가 몇 개 안 되고 아주 자주 도는 자리 for
중간에 break나 인덱스가 필요하다 for
중간에 체크 예외를 던져야 한다 for (6편에서 이유)
원소가 수십만 개고 계산이 무겁다 parallelStream 재보고 나서
없을 수도 있는 값을 돌려준다 Optional 반환
필드, 파라미터, 컬렉션 원소 Optional을 쓰지 않는다

스트림은 성능 도구가 아니라 표현 도구입니다. 반복문을 없애는 게 목적이 아니라, 하려는 일이 코드에 그대로 보이게 하는 게 목적입니다. 스트림으로 썼는데 더 안 읽히면 for가 맞습니다.

내가 헷갈렸던 지점

정리

  • 스트림은 성능 도구가 아니라 표현 도구다. 원소 10개짜리를 100만 번 도는 벤치마크에서 for가 7배 빨랐다. 대신 그 차이는 한 번당 0.0001ms라 대부분의 서비스에서는 무의미하다
  • parallelStream()은 재보지 않고 쓰면 안 된다. 작은 컬렉션에서 400배까지 느려졌다. 쪼개고 합치는 비용이 계산보다 크기 때문이다
  • 중간 연산(filter, map, peek)은 계획만 쌓고 실행되지 않는다. 최종 연산(collect, count, findFirst)이 와야 실행된다. 그래서 findFirst는 원소 6개 중 3개만 검사하고 멈춘다
  • 스트림은 일회용이다. 최종 연산을 두 번 부르면 IllegalStateException: stream has already been operated upon or closed가 난다. 필드에 담아두면 안 된다
  • Optional은 반환 타입 전용이다. 필드·파라미터·컬렉션 원소에는 쓰지 않고, 컬렉션은 빈 컬렉션을 돌려준다
  • orElse는 값이 있어도 인자가 평가된다. 인자가 메서드 호출이면 매번 실행된다. 만들어야 하는 값이면 orElseGet을 쓴다
  • get() 대신 orElseThrow 를 쓴다. No value present보다 도메인 예외가 스택 트레이스에서 훨씬 쓸모 있다

6편에서 예외를 봅니다. 그리고 위 표에서 “중간에 체크 예외를 던져야 하면 for“라고 적은 이유를 확인합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.