개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › Java

스트림

스트림은 무엇이고, for 문과 무엇이 다른가

스트림은 컬렉션(여러 값을 담은 리스트 같은 것)에 든 값을 하나씩 흘려보내면서 거르고 바꾸는 방법입니다. 코드에서는 nums.stream()으로 시작해 점(.)으로 단계를 이어 붙입니다. .filter(n -> n % 2 == 0)은 거르는 단계로, n -> ... 는 값 n을 받아 계산한 답을 돌려주는 짧은 함수(람다)이고, 여기서는 n을 2로 나눈 나머지가 0인지(짝수인지) 보아 true인 값만 통과시킵니다. .map(n -> n * 10)은 바꾸는 단계로, 통과한 값을 10배로 바꿉니다. .toList()는 흘러나온 값을 모아 리스트로 만드는 마지막 단계입니다. 같은 일을 for 문으로 쓰려면 결과를 담을 새 리스트를 만들고, 값을 하나씩 꺼내고, if로 조건을 보고, add로 담는 일을 모두 직접 적어야 합니다. 이 실습은 스트림과 for 문을 나란히 놓고 filter 줄과 map 줄을 하나씩 지워 가며 같은 코드를 컴파일하고 실행해, result에 무엇이 담기는지와 원래 리스트 nums가 바뀌는지를 봅니다. (import 줄과 main 메서드는 줄여서 보입니다.)

실행으로 대조 · JDK 21에서 여덟 가지 조합을 javax.tools로 컴파일하고 실행해 대조

이 실습에 나오는 개념

Stream API
컬렉션 등의 데이터에 대해 필터링, 변환, 집계를 선언적으로 표현하는 API다. filter, map 같은 중간 연산을 이어 붙이고 collect, forEach 같은 최종 연산으로 마무리한다. 중간 연산은 최종 연산이 호출될 때까지 실행되지 않으며(지연 평가), 스트림은 원본 데이터를 변경하지 않고 한 번만 사용할 수 있다.
람다 표현식
이름 없는 함수를 간결하게 표현하는 문법으로 Java 8에서 도입되었다. (매개변수) -> 실행문 형태로 쓰며, 추상 메서드가 하나뿐인 함수형 인터페이스의 구현으로 사용된다. 동작(코드)을 값처럼 다른 메서드에 전달할 수 있게 해 준다.
filter와 map
filter와 map은 스트림에 이어 붙이는 두 가지 중간 연산이다. filter(조건)는 조건이 true인 값만 남긴 스트림을 돌려주고, 조건을 담은 n -> n % 2 == 0 같은 람다는 값 하나를 받아 true나 false를 돌려준다. map(함수)은 스트림의 값마다 함수를 적용한 결과로 이루어진 스트림을 돌려주므로, 값을 바꾸지만 값의 개수는 그대로이다. 둘 다 원본 컬렉션에서 값을 지우거나 고치지 않고 새 스트림을 돌려주며, 결과를 얻으려면 끝에 toList 같은 최종 연산이 필요하다.
toList()와 스트림의 결과
toList()는 스트림이 흘려보낸 값을 스트림에서 만난 순서대로 List에 모으는 최종 연산이다. 중간 연산인 filter와 map은 새 스트림을 돌려줄 뿐이어서, 값을 담은 리스트는 toList() 같은 최종 연산을 거쳐야 얻는다. toList()가 돌려주는 List는 수정할 수 없어서 add 같은 메서드를 부르면 UnsupportedOperationException이 난다. 스트림은 값을 저장하는 자료구조가 아니라 원본에서 값을 전달하는 통로이고, 연산은 원본을 바꾸지 않는다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. List<Integer> nums = List.of(1, 2, 3, 4, 5, 6); 줄이 값 6개를 이 순서대로 담은 리스트 nums를 만듭니다. 이 리스트가 앞으로 거르고 바꿀 값의 출처입니다.
  2. nums.stream() 줄이 nums에 든 값을 하나씩 흘려보내는 흐름, 곧 스트림을 만듭니다. 스트림은 값을 따로 저장하는 곳이 아니라, nums에서 값을 하나씩 꺼내 다음 단계로 전해 주는 통로입니다. 아래에 이어 붙인 .filter, .map, .toList 줄은 그 통로에 단계를 더한 것입니다. 끝의 .toList() 줄이 실행되면 값이 하나씩 흘러갑니다.
  3. 값 1: filter가 n % 2 == 0 식을 계산합니다. 1 % 2의 나머지는 1이어서 false이고, 이 값은 버려집니다. 버려진 값은 map에 닿지 않고 result에 담기지 않습니다.
  4. 값 2: filter가 n % 2 == 0 식을 계산합니다. 2 % 2의 나머지는 0이어서 true이고, 이 값은 다음 단계로 통과합니다.
  5. 값 2: map이 n * 10 식을 계산해 값을 바꿉니다. n * 10은 20이고, 바뀐 값은 toList 쪽으로 흘러가 모입니다.
  6. 값 3: filter가 n % 2 == 0 식을 계산합니다. 3 % 2의 나머지는 1이어서 false이고, 이 값은 버려집니다. 버려진 값은 map에 닿지 않고 result에 담기지 않습니다.
  7. 값 4: filter가 n % 2 == 0 식을 계산합니다. 4 % 2의 나머지는 0이어서 true이고, 이 값은 다음 단계로 통과합니다.
  8. 값 4: map이 n * 10 식을 계산해 값을 바꿉니다. n * 10은 40이고, 바뀐 값은 toList 쪽으로 흘러가 모입니다.
  9. 값 5: filter가 n % 2 == 0 식을 계산합니다. 5 % 2의 나머지는 1이어서 false이고, 이 값은 버려집니다. 버려진 값은 map에 닿지 않고 result에 담기지 않습니다.
  10. 값 6: filter가 n % 2 == 0 식을 계산합니다. 6 % 2의 나머지는 0이어서 true이고, 이 값은 다음 단계로 통과합니다.
  11. 값 6: map이 n * 10 식을 계산해 값을 바꿉니다. n * 10은 60이고, 바뀐 값은 toList 쪽으로 흘러가 모입니다.
  12. nums의 값 6개가 모두 흘러갔습니다. .toList() 줄이 모인 값으로 리스트를 만들어 result에 담습니다. result는 [20, 40, 60]입니다.
  13. System.out.println(result) 줄의 출력은 [20, 40, 60]입니다.
  14. System.out.println(nums) 줄의 출력은 [1, 2, 3, 4, 5, 6]입니다. 스트림은 nums에서 값을 흘려보내기만 했고 nums를 고치지 않았습니다.

실행 결과

코드

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
// 스트림: nums의 값을 하나씩 흘려보내는 흐름을 만든다
List<Integer> result = nums.stream()
    // 거르기: 짝수인 값만 통과시킨다
    .filter(n -> n % 2 == 0)
    // 바꾸기: 통과한 값을 10배로 바꾼다
    .map(n -> n * 10)
    // 모으기: 흘러나온 값을 새 리스트에 담는다
    .toList();
System.out.println(result);
System.out.println(nums);

이 모형의 전제

컴파일러와 실행 환경은 JDK 21.0.12.1입니다. 여덟 가지 조합(방식 2 x filter 2 x map 2)을 모두, 화면의 코드에 import java.util.*;과 class Main, main 메서드를 둘러 javax.tools로 컴파일하고 새 클래스 로더로 읽어 실행한 표준 출력과 대조했습니다. 화면은 이 둘러싼 줄(import, class, main)을 줄여서 보여 줍니다. 대조한 것은 result 출력, nums 출력, 코드에 적은 단계(소스의 줄에서 읽은 것), 그리고 화면의 코드가 Probe가 컴파일한 소스와 같은 글인지입니다. 단계 문장이 기대는 사실은 별도로 쟀습니다. 조합마다 같은 소스에 filter, map, if, add가 불릴 때 기록을 남기는 줄만 더해 실행해, 값 하나가 filter와 map을 모두 지난 뒤 다음 값으로 넘어가고(스트림도 for 문과 같은 순서), 버려진 값에는 map이 불리지 않는 것을 확인했습니다. 거르기도 바꾸기도 없는 스트림의 result가 nums와 같은 값이지만 같은 객체는 아닌 것(result == nums는 false, equals는 true)도 이 JDK에서 실행해 확인했습니다. 문서는 Java SE 21 API를 직접 열어 확인했습니다. java.util.stream 패키지 설명에서는 스트림이 요소를 저장하는 자료구조가 아니라 원본에서 요소를 연산의 파이프라인으로 전달하는 것이라는 것, 연산이 원본을 바꾸지 않고 filter가 원본에서 요소를 지우지 않고 새 스트림을 만든다는 것, 중간 연산은 지연되며 최종 연산이 실행되어야 원본 순회가 시작된다는 것을 따랐습니다. Stream 문서에서는 filter가 조건에 맞는 요소로 이루어진 스트림을, map이 함수를 적용한 결과로 이루어진 스트림을 돌려주는 중간 연산이라는 것, toList()가 요소를 List에 모으고 그 List는 수정할 수 없다는 것을 따랐습니다. List.of 문서에서는 List.of가 수정할 수 없는 리스트를 만든다는 것을 따랐습니다. Stream 문서는 마지막 약 2천 자를 읽지 못했습니다. toList()가 돌려주는 리스트가 새로 만든 것이라는 문장은 문서에 명시되어 있지 않고, 문서의 구현 요구 사항(요소를 새 ArrayList에 복사)과 이 JDK에서의 관찰을 따랐습니다. 주의할 점은 nums가 List.of로 만든 수정할 수 없는 리스트라서, 이 실습에서 nums가 그대로인 것이 스트림 덕분만은 아니라는 것입니다. 스트림이 원본을 바꾸지 않는다는 것은 위 패키지 설명의 문장을 따른 것이고, 이 실습의 실행이 따로 증명한 것이 아닙니다. 이 밖에 정렬(sorted), 개수 제한(limit), 합계, 병렬 스트림, 스트림은 한 번만 쓸 수 있다는 것은 다루지 않았고, 모으는 줄(toList)이 없으면 아무 일도 일어나지 않는 것과 처리 순서는 "세부 동작 실습: 스트림 지연 평가"에서, toList 말고 Map 등으로 모으는 방법은 "스트림으로 모으기"에서 다룹니다.

앞 실습: 람다 · 다음 실습: 애너테이션

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요