
결과는 같습니다. 달라지는 것은 코드를 읽는 순서입니다.
2편에서 Predicate와 Function을 만들었지만, 솔직히 그 자체로는 쓸 일이 잘 떠오르지 않았을 겁니다. 조건 하나를 변수에 담아 두는 게 뭐 그리 대단한가 싶었을 테니까요.
오늘 그 물음에 답합니다. 스트림은 그 조각들이 실제로 일하는 현장입니다.
짝수만 골라내는 두 가지 방법
리스트에서 짝수만 뽑는 코드를 써 보겠습니다. 지금까지 배운 대로라면 이렇게 씁니다.
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
List<Integer> evens = new ArrayList<>();
for (int n : nums) {
if (n % 2 == 0) {
evens.add(n);
}
}
스트림으로 쓰면 이렇게 됩니다.
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
줄 수가 준 것보다 눈여겨볼 것이 있습니다. 위 코드는 "빈 리스트를 만들고, 하나씩 꺼내서, 검사하고, 담아라" 라고 절차를 지시합니다. 아래 코드는 "짝수만 걸러서 리스트로 모아라" 라고 목적을 말합니다. 담을 그릇을 미리 만드는 일도, 반복을 도는 일도 코드에서 사라졌습니다.
흐름으로 다룬다는 것
스트림(Stream)은 컬렉션의 원소를 하나씩 흘려보내면서 처리하는 통로입니다.
쉽게 — 공장 컨베이어 벨트를 떠올리면 됩니다. 벨트 위로 물건이 하나씩 지나가고, 중간중간에 검사기와 가공기가 놓여 있습니다. 불량품을 빼내는 검사기가
filter, 모양을 바꾸는 가공기가map입니다. 벨트 끝에는 상자가 놓여 있고, 통과한 물건이 거기 담깁니다. 그게collect입니다.

그림 1. 세 토막의 순서는 바뀌지 않습니다. 특히 마지막이 없으면 앞의 둘도 돌지 않습니다.
그래서 스트림 코드는 늘 세 토막입니다.
- 열기 —
stream()으로 컬렉션을 흐름으로 바꿉니다. - 손보기 —
filter,map같은 것을 필요한 만큼 이어 붙입니다. 중간 연산이라고 부릅니다. - 닫기 —
collect나forEach로 마무리합니다. 최종 연산입니다.
중간 연산은 몇 개든 이어 붙일 수 있고, 최종 연산은 딱 하나입니다. 이어 붙이는 이 모양을 체인이라고 부르는데, 점을 찍어 계속 연결되는 형태라 익숙해지면 위에서 아래로 읽어 내려가는 것만으로 처리 과정이 그려집니다.
filter — 남길 것을 정합니다
filter는 조건에 맞는 것만 통과시킵니다. 여기서 2편이 되살아납니다. filter가 받는 것이 바로 Predicate입니다.
Predicate<Integer> isEven = n -> n % 2 == 0; // 2편에서 만든 것
nums.stream().filter(isEven) // 그대로 넘어갑니다
물론 실제로는 변수에 담지 않고 그 자리에 바로 씁니다.
nums.stream().filter(n -> n % 2 == 0)
2편에서 "조건을 값으로 넘긴다"고 했던 말이 여기서 실물이 됩니다. 조건이 값이니까 메서드에 인자로 넘길 수 있고, 그래서 filter라는 하나의 메서드가 세상 모든 조건을 처리할 수 있는 것입니다.
map — 하나씩 다른 것으로 바꿉니다
map은 통과하는 원소를 하나씩 다른 값으로 변환합니다. 받는 것은 짐작하신 대로 Function입니다.
List<String> words = List.of("apple", "hi", "banana", "cat", "elephant");
List<String> result = words.stream()
.filter(s -> s.length() >= 5) // apple, banana, elephant
.map(s -> s.toUpperCase()) // APPLE, BANANA, ELEPHANT
.collect(Collectors.toList());
여기서 중요한 성질이 하나 있습니다. map은 개수를 바꾸지 않습니다. 세 개가 들어오면 세 개가 나갑니다. 개수를 줄이는 것은 filter의 일이고, map은 각각의 내용물만 갈아 끼웁니다. 이 구분이 흐릿하면 나중에 원하는 결과가 안 나올 때 어디를 고쳐야 할지 헤매게 됩니다.
타입도 바뀔 수 있습니다. 사람 목록에서 나이만 뽑으면 Person이 Integer로 바뀌죠.
List<Integer> ages = persons.stream()
.map(p -> p.getAge())
.collect(Collectors.toList());
콜론 두 개는 뭔가요
스트림 코드를 검색하다 보면 이런 것을 만납니다.
persons.forEach(System.out::println);
p -> System.out.println(p)라고 쓸 것을 System.out::println으로 줄인 것입니다. 람다가 하는 일이 이미 있는 메서드를 그대로 호출하는 것뿐이라면, 그 메서드 이름만 가리켜도 된다는 문법이고 메서드 참조라고 부릅니다.
.map(s -> s.toUpperCase()) // 이것과
.map(String::toUpperCase) // 이것은 같습니다
지금은 "람다를 더 줄인 표기"라고만 알아 두시면 충분합니다. 규칙이 몇 가지 더 있어서 뒤에 따로 한 편을 잡아 다루겠습니다. 다만 남의 코드를 읽을 때 자주 나오니, 눈에 익혀 두면 좋습니다.
단골 함정 — 최종 연산이 없으면 아무 일도 없습니다
가장 많이 겪는 일입니다. 아래 코드는 오류도 없고 경고도 없지만, 아무것도 출력하지 않습니다.
nums.stream()
.filter(n -> {
System.out.println("검사 중: " + n); // 찍히지 않습니다
return n % 2 == 0;
});
filter를 분명히 불렀는데 왜 검사조차 안 할까요. 중간 연산은 주문서를 적어 두기만 할 뿐, 실제로 일을 시작하지 않기 때문입니다. 자바는 최종 연산이 나타나는 순간에야 흐름을 돌립니다. 이런 성질을 지연 연산이라고 합니다.
주문만 받고 요리를 시작하지 않는 주방과 같습니다. "나가요"라는 신호, 그러니까 collect나 forEach가 있어야 그제서야 불을 켭니다. 위 코드 끝에 .collect(Collectors.toList()) 한 줄을 붙이면 그때 비로소 검사 로그가 찍힙니다.
이게 왜 이렇게 설계됐냐면, 그래야 필요한 만큼만 일할 수 있기 때문입니다. 100만 개짜리 리스트에서 조건에 맞는 첫 번째만 찾을 때, 지연 연산 덕분에 100만 개를 다 훑지 않고 찾는 즉시 멈출 수 있습니다.
직접 실행해 보기
앞의 내용을 한 번에 확인해 봅니다. 특히 마지막 블록에서 최종 연산이 없으면 정말 아무 일도 안 일어나는지 직접 보시기 바랍니다.
import java.util.List;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 1) filter — 짝수만
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println("짝수만: " + evens);
// 2) filter + map — 홀수를 제곱
List<Integer> odd2 = nums.stream()
.filter(n -> n % 2 == 1)
.map(n -> n * n)
.collect(Collectors.toList());
System.out.println("홀수의 제곱: " + odd2);
// 3) 타입이 바뀌는 map — 숫자를 문자열로
List<String> labels = nums.stream()
.filter(n -> n > 7)
.map(n -> n + "번")
.collect(Collectors.toList());
System.out.println("이름표: " + labels);
// 4) 최종 연산이 있는 경우 — 검사 로그가 찍힙니다
System.out.println("--- collect 있음 ---");
nums.stream()
.filter(n -> {
System.out.println(" 검사: " + n);
return n > 8;
})
.collect(Collectors.toList());
// 5) 최종 연산이 없는 경우 — 한 줄도 찍히지 않습니다
System.out.println("--- collect 없음 ---");
nums.stream()
.filter(n -> {
System.out.println(" 검사: " + n);
return n > 8;
});
System.out.println("(위에 아무것도 없으면 정상입니다)");
}
}📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.
원본은 그대로 남습니다
또 하나 자주 오해하는 지점입니다. 스트림은 원래 컬렉션을 건드리지 않습니다.
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
nums.stream().filter(n -> n % 2 == 0).collect(Collectors.toList());
System.out.println(nums); // [1, 2, 3, 4, 5, 6] — 그대로입니다
걸러진 결과는 새로 만들어진 리스트이고, nums는 손대지 않은 채 남습니다. 그래서 결과를 쓰려면 반드시 변수에 받아야 합니다. 2편에서 본 removeIf가 원본을 직접 고쳤던 것과 정확히 반대라, 둘을 나란히 기억해 두면 헷갈리지 않습니다.
한 가지 덧붙이면, 스트림은 한 번 쓰면 끝입니다. 최종 연산을 두 번 부르면 예외가 납니다. 다시 처리하려면 nums.stream()으로 흐름을 새로 열어야 합니다.
한 장 정리
| 항목 | 내용 |
|---|---|
| 스트림 | 컬렉션 원소를 하나씩 흘려보내며 처리하는 통로 |
| 구조 | stream() → 중간 연산 → 최종 연산 |
filter | 조건에 맞는 것만 통과. Predicate를 받음 |
map | 원소를 다른 값으로 변환. Function을 받음. 개수는 그대로 |
collect | 결과를 리스트 등으로 모으는 최종 연산 |
| 지연 연산 | 최종 연산이 없으면 중간 연산도 실행되지 않음 |
| 원본 | 바뀌지 않음. 결과는 새 컬렉션 |
이것만 기억하면 됩니다. 스트림은 거르고(filter) 바꾸고(map) 담는(collect) 세 동작이 전부입니다. 그리고 담지 않으면 거르지도 바꾸지도 않습니다. 2편에서 만든 Predicate와 Function이 각각 filter와 map의 자리에 들어간다는 것까지 이으면, 지난 두 편이 왜 필요했는지가 분명해집니다.
미니 퀴즈
지연 연산과 map의 개수 규칙에서 많이 틀립니다. 정답은 눌러야 나옵니다.
미니 퀴즈
포켓코딩(JAVA4) 3편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 스트림 중개 연산 — 정렬하고 중복 없애고 잘라 내기.