Java (5) - Stream & Optional: 언제 쓰나
for문과 스트림의 시간을 재고 스트림이 게으르다는 것과 일회용이라는 것을 확인한 뒤 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는 하나를 찾으면 멈추고, 그 뒤의 원소는 peek도 filter도 거치지 않습니다.
그리고 두 번째 블록을 보면 원소 하나가 파이프라인 전체를 통과한 뒤 다음 원소로 넘어갑니다. 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 이면?
이 코드의 문제는 findByEmail이 null을 줄 수 있다는 사실이 시그니처에 안 적혀 있다는 것입니다. 문서를 읽거나 구현을 열어봐야 압니다. 대부분은 안 읽고 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“라고 적은 이유를 확인합니다.