Java Stream과 Collectors.toList() 활용법

학생들과 함께 자바 스트림을 다루다 보면 많이 보게되는 경우를 다뤄보려고 합니다. Java Stream을 처음 정리할 때 가장 자주 막히는 부분은 “조건에 맞는 값만 남긴 뒤, 다시 리스트로 어떻게 모으는가”입니다. 특히 Stream API에서 filter(), map(), Collectors.toList()가 한 줄에 이어지면, 문법은 보이는데 흐름은 잘 안 잡히는 경우가 많습니다. 그래서 이 글은 짝수의 제곱값과 3의 배수 리스트를 하나의 흐름으로 묶어, 어떤 상황에서 filter()만 쓰고 언제 map()을 붙여야 하는지 직접 판단할 수 있게 정리합니다. 실습과 실행 결과를 같이 보면서 읽으면, 단순 암기보다 훨씬 빠르게 이해가 이어집니다.

필터링만 할 때와 변환까지 할 때가 왜 다르게 느껴질까

숫자 리스트를 다루는 예제는 작아 보이지만, 실제로는 Java에서 데이터를 가공하는 기본 패턴을 그대로 담고 있습니다. 예를 들어 3의 배수만 뽑는 작업은 “조건에 맞는 값만 남기기”에 해당하고, 짝수의 제곱값을 구하는 작업은 “조건에 맞는 값만 남긴 뒤, 남은 값을 다시 변환하기”에 해당합니다.

둘은 결과가 비슷해 보여도 내부 단계는 다릅니다.

  • 3의 배수만 뽑기: filter() 후 바로 수집
  • 짝수의 제곱값 구하기: filter() 후 map()으로 변환, سپس 수집

여기서 실수하기 쉬운 지점은 map()을 조건 검사처럼 쓰는 것입니다. map()은 값을 바꾸는 연산이고, 조건으로 거르는 역할은 filter()가 맡습니다. 이 차이를 분명히 해 두면 나중에 문자열, 객체 리스트, DTO 변환 같은 작업으로 확장할 때도 구조를 놓치지 않습니다.

조건을 먼저 읽으면 Stream 코드가 덜 흔들린다

실무나 수업에서 코드를 바로 쓸 때는 종종 구현부터 시작하지만, 사실 더 안전한 순서는 “조건 읽기 → 변환 여부 판단 → 결과 수집 방식 선택”입니다. 예를 들어 다음 질문을 먼저 던져 보면 좋습니다.

  • 이 작업은 특정 값만 남기는가?
  • 남긴 값의 모양을 바꿔야 하는가?
  • 결과를 새 리스트로 보관해야 하는가?

이 순서로 정리하면 Stream API의 흐름이 자연스럽게 연결됩니다. 반대로 “일단 stream()부터 붙여 보자” 식으로 가면, filter()와 map()의 위치를 바꾸거나 collect()를 빠뜨리는 일이 잦아집니다. 입문 단계에서 자주 보이는 오해도 이 흐름에서 생깁니다.

수업에서 자주 보인 실수와 직접 실행하며 확인한 점

수업에서 학생들이 많이 헷갈린 부분은 “3의 배수만 남기는 문제”를 map(n -> n % 3 == 0)처럼 적는 경우였습니다. 이렇게 쓰면 숫자 목록이 아니라 참/거짓 값으로 바뀌기 때문에, 원하는 결과와 다른 방향으로 흘러갑니다. 직접 코드를 실행해 보면 이 차이가 더 분명합니다. filter()는 원소를 통과시키거나 제외하고, map()은 통과한 원소를 다른 값으로 바꿉니다.

직접 정리해 보니, 초보자에게는 “조건을 읽는 연산”과 “값을 바꾸는 연산”을 분리해서 설명하는 편이 훨씬 덜 헷갈렸습니다. 특히 Collectors.toList()로 끝나는 예제는 결과가 한눈에 보여서, 원본과 결과의 분리 개념을 익히기에 좋았습니다. 이런 유형의 작업은 화면 출력용 데이터, 가공 결과 목록, 분류 결과처럼 실제 활용이 많은 편이라 입문 주제로도 적절합니다.

원본 데이터는 유지하고 결과만 새로 만드는 흐름

Stream을 쓰는 이유 중 하나는 원본 데이터를 그대로 두면서도, 필요한 형태의 결과를 별도로 만들 수 있다는 점입니다. 예를 들어 아래 원본 리스트가 있다고 해 보겠습니다.

[1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 15]

이 값에서 짝수만 골라 제곱하거나, 3의 배수만 추리면 결과는 새 리스트로 만들어집니다. 원본을 직접 수정하는 방식이 아니라, 파이프라인을 통해 계산된 결과를 따로 받는 구조입니다. 실무에서는 이런 방식이 꽤 자주 쓰입니다. 원본 데이터를 보존해야 하거나, 한 번의 입력으로 여러 형태의 결과를 만들어야 할 때 특히 유용합니다.

다만 모든 경우에 Stream이 더 좋은 것은 아닙니다. 조건이 아주 단순하고 중간 과정이 거의 없다면 일반 반복문이 더 읽기 쉬울 수 있습니다. 그래서 “Stream이 무조건 좋다”보다 “이 작업이 필터링, 변환, 수집의 조합인지”를 먼저 보는 태도가 더 안전합니다.

짝수의 제곱값과 3의 배수 리스트를 함께 보면 구조가 보인다

두 예제는 결과는 다르지만 뼈대는 같습니다. 둘 다 List를 stream()으로 바꾸고, 조건을 걸고, 마지막에 Collectors.toList()로 다시 모읍니다. 차이는 중간 단계 하나입니다. 짝수 제곱값은 map()이 들어가고, 3의 배수 리스트는 그대로 수집합니다.

구분3의 배수 리스트짝수의 제곱값
조건n % 3 == 0n % 2 == 0
변환없음n * n
결과원래 타입의 리스트계산된 값의 리스트
학습 포인트필터링만 수행필터링 + 변환 수행

이 비교는 단순해 보이지만, Java Stream을 처음 이해할 때는 매우 중요합니다. 왜냐하면 많은 예제가 비슷한 문법을 사용하기 때문에, 구조를 놓치면 코드가 그냥 “긴 한 줄”처럼 보이기 때문입니다. 반대로 흐름을 한 번 잡아두면, 이후에는 문자열 처리나 객체 변환도 같은 방식으로 읽을 수 있습니다.

수업 실습 문제와 풀이 코드

아래 실습은 Java Stream의 기본 흐름을 한 번에 확인하기 좋습니다. 입력 리스트에서 3의 배수는 그대로 뽑고, 짝수는 제곱한 뒤 각각 새 리스트로 저장해 보겠습니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamPracticeGuide {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 15);

        System.out.println("원본 숫자: " + numbers);

        List<Integer> multiplesOf3 = numbers.stream()
                .filter(n -> n % 3 == 0)
                .collect(Collectors.toList());

        List<Integer> evenSquares = numbers.stream()
                .filter(n -> n % 2 == 0)
                .map(n -> n * n)
                .collect(Collectors.toList());

        System.out.println("3의 배수: " + multiplesOf3);
        System.out.println("짝수의 제곱값: " + evenSquares);
    }
}
원본 숫자: [1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 15]
3의 배수: [3, 6, 9, 12, 15]
짝수의 제곱값: [4, 16, 36, 64]

실행 후에는 다음을 확인해 보면 좋습니다. 원본 리스트가 그대로 남아 있는지, filter() 조건이 의도한 대로 동작하는지, map()이 들어간 경우 결과 의미가 달라지는지입니다. 이 세 가지가 맞아야 Stream 파이프라인을 제대로 이해한 것입니다. 특히 forEach()는 출력에는 유용해도 리스트로 다시 모으는 역할은 하지 않으므로, 결과를 저장하려면 collect(Collectors.toList())가 필요합니다.

Collectors.toList()를 사용할 때 확인해야 할 부분

입문자에게 Collectors.toList()는 가장 익숙한 수집 방법입니다. collect()는 결과를 모으는 동작이고, Collectors.toList()는 그 결과를 List로 받는다는 뜻입니다. 문법이 단순해서 학습용 예제로 적합하지만, 몇 가지는 주의해야 합니다.

  • Java 버전에 따라 Stream.toList()를 볼 수도 있습니다.
  • 반환 리스트의 수정 가능 여부는 기대와 다를 수 있어 확인이 필요합니다.
  • 예제에서는 개념 이해를 위해 collect(Collectors.toList())가 설명하기 쉽습니다.

즉, 이 코드를 “무조건 정답”으로 외우기보다, “결과를 리스트로 모으는 기본형”으로 이해하는 편이 좋습니다. 버전별 차이는 공식 문서를 확인하는 습관이 안전합니다.

반복문보다 Stream이 더 어울리는 작업은 따로 있다

모든 코드를 Stream으로 바꾸는 것이 목적은 아닙니다. 오히려 짧은 반복이나 상태 변경이 많은 작업은 일반 for문이 더 명확할 수 있습니다. 반면 아래처럼 조건과 변환이 연속되는 작업은 Stream과 잘 맞습니다.

  • 조건에 맞는 값만 추리기
  • 추린 값을 다른 형태로 바꾸기
  • 결과를 새 리스트로 저장하기
  • 원본은 건드리지 않고 파생 결과만 얻기

이 기준을 가지고 보면 Java Stream은 단순 문법이 아니라 작업을 분해하는 도구에 가깝습니다. 그래서 코드를 읽을 때도 “지금은 무엇을 거르고, 무엇을 바꾸고, 어디에 모으는가”로 바라보면 이해가 쉬워집니다.

작업 전 마지막으로 점검할 조건식

숫자 예제는 작아 보여도 조건식 실수가 꽤 자주 발생합니다. n % 3 == 0은 3의 배수이고, n % 2 == 0은 짝수입니다. 이런 기본 조건이 틀리면 뒤에 map()이나 collect()가 아무리 맞아도 결과는 의도와 달라집니다.

확인할 기준은 간단합니다.

  • 남기려는 기준은 filter()에 넣기
  • 바꾸려는 계산은 map()에 넣기
  • 결과를 리스트로 받으려면 collect(Collectors.toList()) 사용하기
  • 원본과 결과는 변수로 분리하기

이 기준만 정확히 잡아도 입문 수준의 Stream 작업은 훨씬 안정적으로 풀 수 있습니다. 이후에 객체 리스트나 복합 조건으로 넘어가도 구조가 무너지지 않습니다.

정리하며 다시 확인한 실전 판단 기준

Java Stream을 배우는 이유는 문법을 외우기 위해서가 아니라, 데이터를 처리하는 방식을 더 분명하게 보기 위해서입니다. 3의 배수 리스트는 조건 필터링의 기본을 보여주고, 짝수 제곱값은 필터링 뒤 변환이 붙는 구조를 보여줍니다. 둘 다 Collectors.toList()로 마무리된다는 점까지 익히면, 결과를 새 리스트로 만드는 흐름이 한 번에 정리됩니다.

실제로 코드를 쓸 때는 다음 질문만 기억해도 충분합니다.

  • 이 작업은 걸러내는가, 바꾸는가
  • 원본을 유지해야 하는가
  • 결과를 다시 리스트로 보관해야 하는가
  • forEach()로 끝내면 안 되는 문제인가

이 질문에 답할 수 있으면 Stream API를 읽는 속도가 눈에 띄게 빨라집니다. 다음 단계에서는 문자열, 객체, 중첩 컬렉션으로 같은 패턴을 확장해 보면 좋습니다.

참고 자료

  • Oracle Java Stream API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html
  • Oracle Collectors API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html
  • Oracle Java List API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다