Java프로그래밍 12강 - 컬렉션과 스트림
Java 스트림은 컬렉션·배열·파일 등의 데이터를 원소의 연속으로 보고 선언적인 파이프라인으로 처리하게 해 준다. 이 글에서는 외부 반복과 내부 반복의 차이, 스트림 생성과 특성, 중간연산인 필터링·매핑·정렬·루핑, 종료연산인 집계·매칭·수집까지 12강의 핵심을 코드 중심으로 정리한다.
1. 외부 반복과 내부 반복
1.1 외부 반복
외부 반복은 컬렉션이나 배열의 원소를 다루는 반복 절차를 프로그램이 직접 제어하는 방식이다. 원소를 변수로 꺼내 작업하며 for, 향상된 for문, while, do-while, Iterator 등을 사용할 수 있다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");
for (String name : names) {
System.out.println(name);
}
Iterator<String> iterator = names.listIterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
외부 반복에서는 다음 원소가 있는지 확인하고, 원소를 얻고, 반복을 종료하는 흐름을 개발자가 명시한다. 처리 순서를 세밀하게 제어하기 쉽지만 반복 제어 코드가 실제 작업 코드와 함께 나타난다.
1.2 내부 반복과 forEach()
내부 반복은 컬렉션이나 스트림 같은 데이터 구조의 내부에 반복을 맡기고 프로그램은 각 원소에 수행할 작업만 전달하는 방식이다. 컬렉션의 forEach()는 함수형 인터페이스인 Consumer 객체를 인자로 받으며, 보통 람다식으로 처리 동작을 지정한다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");
names.forEach(item ->
System.out.println("내부 반복: " + item));
| 구분 | 반복 제어 | 특징 |
|---|---|---|
| 외부 반복 | 프로그램이 반복 구조를 직접 제어 | for, while, Iterator 등으로 원소를 하나씩 얻는다. |
| 내부 반복 | 컬렉션이나 스트림에 반복을 위임 | 처리 동작만 람다식으로 제공해 코드가 간결해지고 병렬 처리에 유리하다. |
2. 스트림의 정의와 생성
2.1 스트림의 의미와 특성
스트림은 컬렉션이나 배열 같은 데이터 소스로부터 만들어지는 원소의 시퀀스를 표현하고, 이를 간결하고 효율적으로 처리하도록 제공되는 인터페이스다. 내부 반복과 함수형 프로그래밍 방식을 지원하고 멀티코어 CPU를 활용한 병렬 처리도 지원한다. 관련 클래스와 인터페이스는 java.util.stream 패키지에 있다.
- 스트림은 원본 데이터로부터 생성되지만 원본 자체를 변경하지 않는다.
- 원본을 바꾸려면 새로운 컬렉션 등으로 결과를 수집해야 한다.
- 한 번 종료연산을 수행한 스트림은 다시 사용할 수 없는 일회용이다.
- 여러 연산을 파이프라인 형태로 연결할 수 있다.
- 지연 평가를 사용해 필요한 시점에 연산을 실행하고 처리 과정을 최적화한다.
주요 스트림 인터페이스는 객체 원소를 다루는 Stream<T>와 기본형 숫자를 위한 IntStream, LongStream, DoubleStream이다. 이들은 공통 기반인 BaseStream 계열에 속한다.
2.2 숫자로 스트림 만들기
기본형 전용 스트림은 불필요한 포장 객체 변환을 줄이고 숫자 집계 메소드를 직접 제공한다. IntStream.range()는 끝값을 제외하고, rangeClosed()는 끝값을 포함한다. of()는 나열한 값으로 스트림을 만든다.
System.out.println(IntStream.rangeClosed(1, 100).sum());
System.out.println(IntStream.rangeClosed(1, 100)
.average().getAsDouble());
System.out.println(IntStream.rangeClosed(1, 100)
.min().getAsInt());
System.out.println(IntStream.rangeClosed(1, 100)
.max().getAsInt());
2.3 배열·파일·컬렉션에서 생성
| 데이터 소스 | 생성 방법 | 결과 |
|---|---|---|
| 객체 배열 | Arrays.stream(T[]) | Stream<T> |
| int 배열 | Arrays.stream(int[]) | IntStream |
| double 배열 | Arrays.stream(double[]) | DoubleStream |
| 텍스트 파일 | Files.lines(Path) | 행 단위 Stream<String> |
| Collection | stream(), parallelStream() | 순차 또는 병렬 스트림 |
String[] strArray = { "홍길동", "이순신", "김유신" };
Stream<String> strStream = Arrays.stream(strArray);
strStream.forEach(item -> System.out.println(item));
int[] intArray = { 1, 2, 3 };
IntStream intStream = Arrays.stream(intArray);
intStream.forEach(item -> System.out.println(item));
Path path = Paths.get("c:\\data\\data.txt");
Stream<String> fileStream = Files.lines(path);
fileStream.forEach(line -> System.out.println(line));
fileStream.close();
Files.lines()가 만든 스트림은 외부 파일 리소스를 사용하므로 작업 후 닫아야 한다. 실제 코드에서는 try-with-resources로 자동 반환되게 구성할 수 있다.
HashSet, ArrayList, LinkedList 등 Collection 객체는 기본 메소드인 stream()과 parallelStream()을 제공한다. HashMap은 먼저 entrySet()으로 Set 형태를 얻은 뒤 스트림을 만든다.
3. 순차 스트림, 병렬 스트림과 파이프라인
3.1 일반 스트림과 병렬 스트림
stream()은 일반적인 순차 스트림을, parallelStream()은 병렬 처리가 가능한 스트림을 반환한다. 병렬 스트림의 forEach()를 실행하면 여러 ForkJoinPool 작업자 스레드와 main 스레드가 원소를 나누어 처리할 수 있으므로 출력 순서가 원래 원소 순서와 다를 수 있다.
Set<Integer> set = new HashSet<>();
for (int i = 0; i < 10; i++) set.add(i);
set.stream().forEach(System.out::println);
set.parallelStream().forEach(item ->
System.out.println(item + " (" +
Thread.currentThread().getName() + ")"));
3.2 스트림 파이프라인
스트림 파이프라인은 컬렉션·배열·파일에서 원본 스트림을 만들고, 중간연산으로 새로운 스트림을 연속 생성한 뒤, 종료연산으로 최종 결과나 동작을 만드는 처리 구조다. 메소드 호출을 연속으로 이어 쓰는 메소드 체이닝으로 간결하게 표현할 수 있다.
| 단계 | 역할 | 대표 메소드 |
|---|---|---|
| 스트림 생성 | 데이터 소스로부터 원본 스트림을 만든다. | stream(), Arrays.stream() |
| 중간연산 | 변환·필터링·정렬하여 새로운 스트림을 반환한다. | filter(), map(), sorted(), peek() |
| 종료연산 | 최종 결과를 만들거나 동작을 수행한다. | forEach(), count(), collect(), anyMatch() |
List<String> words =
Arrays.asList("apple", "banana", "cherry", "Avocado");
long count = words.stream()
.map(String::toUpperCase)
.filter(word -> word.startsWith("A"))
.count();
System.out.println(count); // 2
map()과 filter()는 중간연산이고 count()는 종료연산이다. 중간연산은 종료연산이 호출되기 전까지 실제 처리가 미뤄지는 지연 평가의 대상이다. 종료연산은 파이프라인 마지막에 한 번 수행되며 이후 그 스트림을 재사용할 수 없다.
double avg = books.stream()
.mapToInt(book -> book.getPrice())
.average()
.getAsDouble();
4. 중간연산 - 필터링과 매핑
4.1 distinct()와 filter()
필터링은 중복을 제거하거나 특정 조건을 만족하는 원소만 추출해 새로운 스트림을 만드는 작업이다. distinct()는 중복을 제거한다. 객체 스트림에서는 hashCode()의 반환값이 같고 equals()의 결과도 true인 경우를 같은 원소로 판단한다.
filter()는 true 또는 false를 반환하는 조건 람다식을 받아 true인 원소만 통과시킨다. 여러 filter를 체이닝하면 모든 조건을 차례로 적용할 수 있다.
String[] values =
{ "ABC", "BCD", "AFE", "CDE", "ABZ", "ACCZ" };
Arrays.stream(values)
.filter(item -> item.startsWith("A"))
.filter(item -> item.endsWith("Z"))
.filter(item -> item.length() > 3)
.forEach(System.out::println); // ACCZ
4.2 map(), mapToInt()와 기본형 변환
매핑은 각 원소를 다른 원소로 변환하여 새 스트림을 만드는 작업이다. Stream<T>의 map()은 객체 스트림을 만들고, mapToInt()는 변환 결과가 int인 IntStream을 만든다. 기본형 스트림은 asDoubleStream(), asLongStream()으로 다른 기본형 스트림으로 바꿀 수 있고, boxed()로 포장 객체의 스트림을 만들 수 있다.
String[] strNums = { "10", "20", "30" };
Arrays.stream(strNums)
.mapToInt(item -> Integer.parseInt(item))
.forEach(System.out::println);
int[] numbers = { 10, 20, 30 };
Arrays.stream(numbers)
.mapToObj(item -> String.valueOf(item))
.forEach(item ->
System.out.println(item + "(" + item.length() + ")"));
4.3 flatMap()
flatMap()은 각 원소를 하나의 스트림으로 변환한 뒤 여러 스트림을 다시 하나의 스트림으로 평탄화한다. 문장 목록을 단어 목록으로 바꿀 때처럼 한 원소가 여러 원소로 펼쳐지는 경우에 적합하다.
Arrays.asList(
"Hello world, this is java",
"Welcome to java world")
.stream()
.flatMap(sentence ->
Arrays.stream(sentence.split(" ")))
.forEach(System.out::println);
5. 중간연산 - 정렬과 루핑
5.1 sorted()
sorted()는 원소를 오름차순 또는 내림차순으로 정렬한 새 스트림을 반환한다. 인자 없이 객체 스트림을 정렬하려면 원소 타입이 Comparable이어야 하며 크기 비교에는 compareTo()가 사용된다. Java의 기본형 포장 클래스는 Comparable을 구현한다.
class Book implements Comparable<Book> {
String title;
String author;
int price;
public int compareTo(Book book) {
return Integer.compare(this.price, book.price);
}
}
books.stream()
.sorted()
.forEach(System.out::println);
위 compareTo는 가격을 비교하므로 책들이 가격의 오름차순으로 흐른다. 정렬은 원본 List의 순서를 직접 바꾸는 것이 아니라 정렬된 새 스트림을 반환한다.
5.2 peek()와 forEach()
루핑은 스트림 원소를 하나씩 순회하면서 동작을 수행한다. peek()는 각 원소에 람다식을 실행하면서 새 스트림을 반환하는 중간연산이며 디버깅이나 로깅에 자주 사용된다. 반면 forEach()는 각 원소를 처리하고 파이프라인을 끝내는 종료연산이다.
boolean found = books.stream()
.peek(book -> System.out.println(book))
.anyMatch(book -> book.price > 20000);
peek()만 호출하고 종료연산을 붙이지 않으면 지연 평가 때문에 동작이 실행되지 않는다. 위 예에서는 anyMatch()가 조건을 만족하는 첫 원소를 찾으면 즉시 종료할 수 있으므로 peek가 모든 원소를 출력하지 않을 수도 있다.
6. 종료연산 - 집계, 매칭과 수집
6.1 종료연산과 집계
종료연산은 중간처리를 거친 스트림에서 집계나 결과 출력 같은 최종 처리를 수행한다. 파이프라인의 마지막 단계에서 사용되며 실행 후 스트림은 다시 사용할 수 없다. 종료연산이 호출되기 전에는 파이프라인이 실제로 실행되지 않는 지연 평가가 적용된다.
집계 연산에는 count(), sum(), average(), max(), min() 등이 있다. IntStream의 average, max, min은 값이 없을 가능성을 표현하는 OptionalDouble 또는 OptionalInt를 반환하므로 강의 예에서는 getAsDouble(), getAsInt()로 값을 얻는다.
int[] values = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
long count = Arrays.stream(values)
.filter(n -> n % 2 == 0).count(); // 5
long sum = Arrays.stream(values)
.filter(n -> n % 2 == 0).sum(); // 30
int min = Arrays.stream(values).min().getAsInt();
double avg = Arrays.stream(values).average().getAsDouble();
6.2 매칭
| 메소드 | true를 반환하는 조건 |
|---|---|
anyMatch() | 적어도 하나의 원소가 조건을 만족한다. |
allMatch() | 모든 원소가 조건을 만족한다. |
noneMatch() | 어떤 원소도 조건을 만족하지 않는다. |
boolean anyEven = Arrays.stream(values)
.anyMatch(n -> n % 2 == 0); // true
boolean allEven = Arrays.stream(values)
.allMatch(n -> n % 2 == 0); // false
boolean noneEvenAfterOddFilter = Arrays.stream(values)
.filter(n -> n % 2 != 0)
.noneMatch(n -> n % 2 == 0); // true
6.3 collect()와 Collectors
수집은 스트림을 필터링하거나 매핑한 뒤 새 List, Set, Map 같은 컬렉션으로 만드는 종료연산이다. Collectors 클래스가 다양한 static 수집 도구를 제공하며 그 결과를 collect()의 인자로 전달한다.
List<Member> maleMembers = members.stream()
.filter(m -> m.getGender().equals("남"))
.collect(Collectors.toList());
Set<Member> femaleMembers = members.stream()
.filter(m -> m.getGender().equals("여"))
.collect(Collectors.toSet());
Map<String, Integer> ages = femaleMembers.stream()
.collect(Collectors.toMap(
member -> member.getName(),
member -> member.getAge()));
7. 핵심 개념 정리
- 외부 반복은 프로그램이 반복을 제어하고, 내부 반복은 데이터 구조에 반복을 맡긴 뒤 처리 동작만 전달한다.
- 스트림은 데이터 소스로부터 만든 원소의 시퀀스이며 원본을 변경하지 않고 한 번만 사용할 수 있다.
Stream<T>는 객체,IntStream·LongStream·DoubleStream은 기본형 숫자를 처리한다.- 배열은
Arrays.stream(), 파일은Files.lines(), 컬렉션은stream()또는parallelStream()으로 스트림을 만든다. - 중간연산은 새 스트림을 반환하고 종료연산 전까지 지연 평가된다. 종료연산은 최종 결과를 만들고 스트림을 소비한다.
distinct()와filter()는 필터링,map()과flatMap()은 매핑,sorted()는 정렬을 수행한다.peek()는 중간연산이고forEach()는 종료연산이다.- 집계, 매칭, 수집은 대표적인 종료연산이며 collect를 통해 List, Set, Map을 만들 수 있다.
스트림 프로그래밍의 핵심은 데이터 소스에서 스트림을 만든 뒤 필요한 중간연산을 체이닝하고, 마지막 종료연산으로 결과를 얻는 것이다. 각 단계의 반환형과 일회성·지연 평가 특성을 이해해야 정확하고 효율적인 파이프라인을 구성할 수 있다.
8. 예상문제 20선
1. 내부 반복에 대한 설명으로 옳은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
내부 반복은 반복 제어를 컬렉션이나 스트림에 맡기고 각 원소에 수행할 동작만 Consumer 등의 형태로 전달한다.
2. 컬렉션의 forEach()가 인자로 받는 함수형 인터페이스는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
forEach()는 각 원소를 소비하며 동작을 수행하는 Consumer 객체를 받고, 보통 람다식으로 전달한다.
3. Java 스트림의 특성으로 옳지 않은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
스트림은 일회용이므로 종료연산으로 소비한 뒤 다시 사용하려면 데이터 소스에서 새 스트림을 만들어야 한다.
4. 기본형 int 원소를 효율적으로 처리하는 스트림 인터페이스는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
IntStream은 int 기본형 원소를 위한 스트림이며 sum, average, min, max 같은 숫자 연산을 제공한다.
5. 1부터 100까지 양 끝값을 모두 포함한 IntStream을 만드는 메소드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
rangeClosed는 시작값과 끝값을 모두 포함하지만 range는 끝값을 포함하지 않는다.
6. Arrays.stream(new int[]{1, 2, 3})의 반환형은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
int 기본형 배열에서 Arrays.stream()을 호출하면 기본형 전용 IntStream이 만들어진다.
7. 텍스트 파일을 행 단위 문자열 스트림으로 만드는 메소드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
Files.lines(Path)는 파일의 각 행을 원소로 갖는 Stream<String>을 생성한다.
8. HashMap 객체로부터 스트림을 만드는 강의록의 방법은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
HashMap은 Collection 자체가 아니므로 entrySet()으로 Set 형태를 얻고 그 객체에서 stream 또는 parallelStream을 호출한다.
9. parallelStream()에 대한 설명으로 가장 알맞은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
병렬 스트림은 ForkJoinPool 작업자 등이 처리를 분담할 수 있어 forEach 결과 순서가 달라질 수 있다.
10. 다음 중 중간연산에 해당하는 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
filter는 조건을 만족하는 원소로 새 스트림을 만드는 중간연산이며 나머지는 파이프라인을 소비하는 종료연산이다.
11. 스트림의 지연 평가를 가장 정확히 설명한 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
filter, map 같은 중간연산은 파이프라인만 구성하고 count나 collect 같은 종료연산이 있어야 실제로 처리된다.
12. 객체 스트림에서 distinct()가 같은 원소인지 판단할 때 사용하는 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
객체의 hashCode 값이 같고 equals가 true이면 distinct가 중복 원소로 판단한다.
13. 문자열 스트림을 int 기본형 스트림으로 바꾸는 데 적합한 메소드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
mapToInt는 각 객체 원소를 int로 변환하는 함수를 적용하고 IntStream을 반환한다.
14. flatMap()이 특히 적합한 작업은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
flatMap은 각 원소를 스트림으로 매핑한 뒤 여러 결과 스트림을 하나의 평평한 스트림으로 합친다.
15. 인자 없는 sorted()로 Book 객체를 정렬하기 위한 조건은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
자연 순서로 객체를 정렬하려면 원소 타입이 Comparable을 구현하고 compareTo로 비교 기준을 제공해야 한다.
16. peek()와 forEach()의 차이로 옳은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
peek는 동작을 수행한 뒤 스트림을 반환하므로 체이닝할 수 있고, forEach는 원소를 소비하며 파이프라인을 끝낸다.
17. 스트림에 조건을 만족하는 원소가 적어도 하나 있는지 확인하는 메소드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
anyMatch는 하나라도 조건을 만족하면 true를 반환하며 만족 원소를 찾은 시점에 종료할 수 있다.
18. 강의 예의 1부터 10까지 정수 스트림에서 짝수의 개수와 합은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
짝수는 2, 4, 6, 8, 10의 다섯 개이며 합은 30이다.
19. 스트림 처리 결과를 Map으로 수집할 때 사용하는 Collectors 메소드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
toMap에 키와 값을 만드는 함수를 전달하고 그 Collector를 collect()에 넘기면 Map 결과를 얻는다.
20. 스트림 처리 결과를 새 컬렉션으로 보관해야 하는 이유로 가장 알맞은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
스트림은 원본을 변경하지 않으므로 필터링·매핑 결과를 저장하려면 collect와 Collector로 새 List, Set, Map 등을 만든다.
댓글
댓글 쓰기