1. 오류: 스트림을 재사용하려 함
Java의 스트림(Stream)은 일회성 도구입니다. collect(), forEach(), count() 같은 터미널 연산을 한 번 수행하면 스트림은 닫히며 더 이상 사용할 수 없습니다.
오류 예시
Stream<String> stream = Stream.of("a", "b", "c");
stream.forEach(System.out::println); // 문제없음
stream.forEach(System.out::println); // IllegalStateException 발생!
출력:
a
b
c
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
피하는 방법
여러 작업을 해야 한다면 매번 새 스트림을 생성하세요:
List<String> list = List.of("a", "b", "c");
list.stream().forEach(System.out::println); // OK
list.stream().count(); // OK
꿀팁: 스트림은 구내식당의 일회용 컵과 같습니다. 한 번 쓰고 버리고, 다음 음료에는 새 컵을 쓰세요.
2. 오류: 스트림 순회 중 컬렉션 변경
스트림으로 순회하는 도중에 원본 컬렉션을 수정하면 ConcurrentModificationException이 발생합니다. 특히 필터링이나 요소 삭제 시 자주 겪는 문제입니다.
오류 예시
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // 펑!
}
});
결과:
Exception in thread "main" java.util.ConcurrentModificationException
올바른 방법
순회 중 컬렉션을 수정하지 않는 메서드를 사용하거나, 결과를 새 컬렉션으로 모으세요:
names.removeIf(name -> name.startsWith("A")); // OK, removeIf는 안전함
// 또는 Stream API 사용:
List<String> filtered = names.stream()
.filter(name -> !name.startsWith("A"))
.collect(Collectors.toList());
기억하세요: 스트림이 처리하는 동안에는 원본 컬렉션을 건드리지 마세요.
3. 오류: 무한 스트림에서 limit를 빼먹음
Stream.iterate와 Stream.generate는 무한 스트림을 만들 수 있습니다. limit으로 크기를 제한하지 않으면 프로그램이 영원히 멈출 수 있고(OutOfMemoryError까지도) 있습니다.
오류 예시
Stream<Integer> infinite = Stream.iterate(0, n -> n + 1);
infinite.forEach(System.out::println); // 프로그램이 끝나지 않습니다!
올바른 방법
limit으로 스트림을 제한하세요:
Stream<Integer> finite = Stream.iterate(0, n -> n + 1).limit(10);
finite.forEach(System.out::println); // 0부터 9까지 출력
팁: 무한 스트림은 결말 없는 드라마와 같습니다. 멈추는 주체는 당신이어야 합니다!
4. 오류: null을 잘못 다룸
Stream API는 null을 좋아하지 않습니다. 컬렉션에 null이 있으면 특히 요소에서 메서드를 호출할 때 NullPointerException이 발생할 수 있습니다.
오류 예시
List<String> names = Arrays.asList("Alice", null, "Bob");
names.stream()
.map(String::toUpperCase) // 예외! null에서
.forEach(System.out::println);
결과:
Exception in thread "main" java.lang.NullPointerException
올바른 방법
null 값을 미리 필터링하세요:
names.stream()
.filter(Objects::nonNull)
.map(String::toUpperCase)
.forEach(System.out::println);
Optional 다루기
Stream<Optional<T>>이 있다면, ‘펼치기’에는 (Java 9+) flatMap(Optional::stream)을 사용하세요:
List<Optional<String>> optionals = List.of(Optional.of("A"), Optional.empty(), Optional.of("B"));
optionals.stream()
.flatMap(Optional::stream)
.forEach(System.out::println); // "A"와 "B"만 출력
팁: 컬렉션에는 가능하면 null을 넣지 마세요. 어쩔 수 없다면 초기에 필터링하세요.
5. 오류: 요소 순서가 깨짐
일부 연산(예: flatMap, concat)은 특히 병렬 스트림을 사용하거나 여러 컬렉션을 합칠 때 요소 순서에 영향을 줄 수 있습니다.
오류 예시
List<Integer> list1 = List.of(1, 2, 3);
List<Integer> list2 = List.of(4, 5, 6);
Stream<Integer> combined = Stream.concat(list1.stream(), list2.stream());
combined.forEach(System.out::print); // 123456 — 순서가 유지됨
// 하지만 병렬 스트림을 사용하면:
Stream<Integer> par = Stream.concat(list1.parallelStream(), list2.parallelStream());
par.forEach(System.out::print); // 순서가 예기치 못할 수 있음!
올바른 방법
순서가 중요하다면 병렬 스트림을 피하거나 순서를 보장하는 메서드(forEachOrdered)를 사용하세요:
par.forEachOrdered(System.out::print);
사실: Stream API는 기본적으로 순서를 유지합니다. 다만 당신이 직접 순서를 깨뜨리면 예외입니다.
6. 오류: 비효율적인 컬렉션 병합
집합 연산에는 Set을 사용하는 것이 List보다 좋습니다. 중복을 피하고 성능을 높일 수 있습니다. 또한 flatMap으로 처리할 수 있는 곳에 중첩 루프를 쓰는 것은 비효율적입니다.
오류 예시
List<String> list1 = List.of("a", "b", "c");
List<String> list2 = List.of("b", "c", "d");
// 비효율적:
List<String> union = new ArrayList<>(list1);
for (String s : list2) {
if (!union.contains(s)) { // List의 contains — O(n)
union.add(s);
}
}
// Set과 Stream API를 사용하면 효율적:
Set<String> unionSet = Stream.concat(list1.stream(), list2.stream())
.collect(Collectors.toSet());
또는 차집합의 경우:
// 비효율적:
List<String> diff = new ArrayList<>(list1);
diff.removeAll(list2); // OK, 하지만 stream과 list.contains로 했다면 느렸을 것임
// 효율적:
Set<String> set2 = new HashSet<>(list2);
List<String> difference = list1.stream()
.filter(s -> !set2.contains(s))
.collect(Collectors.toList());
팁: 고유한 요소를 다룰 때는 항상 Set을 사용하세요. 큰 컬렉션에서는 중첩 루프를 피하고 stream + flatMap을 선호하세요.
7. 오류: 터미널 연산을 여러 번 사용하려 함
가끔 스트림을 두 갈래로 “나누어” 한쪽은 리스트로 모으고 다른 한쪽은 개수를 세고 싶을 때가 있습니다. 하지만 스트림은 일회성이므로 두 번째 터미널 연산에서 오류가 납니다.
오류 예시
Stream<String> stream = Stream.of("a", "b", "c");
List<String> list = stream.collect(Collectors.toList());
long count = stream.count(); // 예외! IllegalStateException
올바른 방법
스트림을 여러 번 사용해야 한다면 먼저 컬렉션으로 모으세요:
List<String> list = Stream.of("a", "b", "c").collect(Collectors.toList());
long count = list.size();
팁: 두 마리 토끼를 잡고 싶다면 먼저 컬렉션으로 수집하세요.
8. 오류: 리소스를 close()하지 않음
리소스와 연결된 스트림(예: Files.lines(Path))을 사용할 때는 사용 후 반드시 닫아 누수를 방지해야 합니다.
오류 예시
Stream<String> lines = Files.lines(Path.of("file.txt"));
lines.forEach(System.out::println); // OK, 하지만 리소스가 닫히지 않음!
올바른 방법
try-with-resources를 사용하세요:
try (Stream<String> lines = Files.lines(Path.of("file.txt"))) {
lines.forEach(System.out::println);
}
팁: 파일 스트림은 주전자와 같습니다. 끓였으면 끄세요!
9. 오류: Optional과 flatMap에서 null을 잘못 처리함
List<Optional<T>> 같은 컬렉션을 다룰 때 map(Optional::get)을 쓰면 내부 Optional이 비어 있을 경우 예외가 발생합니다.
오류 예시
List<Optional<String>> optionals = List.of(Optional.of("A"), Optional.empty());
optionals.stream()
.map(Optional::get) // 예외! NoSuchElementException
.forEach(System.out::println);
올바른 방법
flatMap(Optional::stream) (Java 9+)을 사용하세요:
optionals.stream()
.flatMap(Optional::stream)
.forEach(System.out::println); // "A"만
팁: Optional은 단순한 래퍼가 아니라, 값의 부재를 안전하게 다루기 위한 도구입니다.
10. 오류: equals/hashCode를 잘못 구현함
집합 연산(Set, distinct, 합집합, 교집합)은 equals와 hashCode가 올바르게 구현되어 있어야 제대로 동작합니다.
오류 예시
class Student {
String name;
int age;
// equals와 hashCode 없음!
}
Set<Student> students = new HashSet<>();
students.add(new Student("Alice", 20));
students.add(new Student("Alice", 20));
System.out.println(students.size()); // 2 — 같다고 간주되어야 함!
올바른 방법
Set, Map 컬렉션에서 사용하거나 distinct 연산을 할 계획이라면 클래스에 equals와 hashCode를 재정의하세요.
11. 오류: collect 이후 같은 스트림을 다시 사용함
터미널 연산(예: collect) 후에는 스트림이 닫힙니다. 같은 스트림으로 계속 작업하려 하면 오류가 발생합니다.
오류 예시
Stream<String> stream = Stream.of("a", "b", "c");
List<String> list = stream.collect(Collectors.toList());
stream.filter(s -> s.startsWith("a")).forEach(System.out::println); // Exception!
올바른 방법
컬렉션에서 새 스트림을 생성하세요:
list.stream().filter(s -> s.startsWith("a")).forEach(System.out::println);
12. 오류: parallelStream을 잘못 사용함
많은 사람들이 parallelStream()이 항상 빠르다고 생각합니다. 실제로는 작은 컬렉션이나 단순한 연산에서는 스레드 관리 오버헤드 때문에 더 느려질 수 있습니다.
오류 예시
List<Integer> numbers = IntStream.range(0, 10).boxed().collect(Collectors.toList());
numbers.parallelStream().forEach(System.out::println); // 일반 stream()보다 더 느릴 수 있음
올바른 방법
parallelStream은 정말 큰 컬렉션이나 무거운 계산에만 사용하세요. 특히 연산이 순수(부작용 없음)하고 결합법칙을 만족할 때 효과적입니다.
13. 오류: 터미널 연산을 호출하지 않음
중간 연산(map, filter, flatMap 등)은 터미널 연산(collect, forEach, count 등)을 호출하기 전까지는 실행되지 않습니다.
오류 예시
Stream<String> stream = Stream.of("a", "b", "c").map(String::toUpperCase);
// 아무 일도 일어나지 않음!
올바른 방법
반드시 터미널 연산을 호출하세요:
stream.forEach(System.out::println);
GO TO FULL VERSION