Java Stream map()으로 데이터 변환하기

오늘 우리가 다뤄볼 내용은 Java Stream map()으로 데이터를 변환해보는 것입니다.

Java Stream과 Stream API를 배울 때 많은 분이 처음 부딪히는 작업은 “리스트의 값을 원하는 형태로 바꾼 뒤 다시 모으는 과정”입니다. 특히 map()으로 데이터를 변환하고 Collectors.toList()로 수집하는 흐름은 Java에서 매우 자주 등장합니다. 문법만 보면 짧아 보이지만, 실제로는 원본이 바뀌는지, 출력만 하는지, 새 리스트를 만드는지를 구분하지 못해 헷갈리는 경우가 많습니다. 그래서 여기서는 단순한 정의보다, 직접 실행하면서 확인할 수 있는 기준을 중심으로 Java Stream 통합 내용을 정리했습니다.

Java Stream map() 함수로 문자열을 대문자로 변환하는 과정
원본 문자열 리스트와 Stream을 거쳐 대문자로 변환된 새로운 리스트를 비교하여 보여줍니다.

문자열 예제가 자주 쓰이는 이유도 분명합니다. 데이터 구조가 단순하니 Stream API의 핵심 동작에 집중하기 좋기 때문입니다. 대문자 변환, 길이 계산, 조건 필터링, Collectors.toList() 수집까지 한 번에 묶어 보면 이후 객체 리스트를 다룰 때도 훨씬 덜 낯설어집니다.

반복문 대신 Java Stream을 쓸 때 달라지는 사고방식

for문으로도 같은 결과를 만들 수 있는데 왜 Stream을 배우는지부터 짚는 편이 좋습니다. 반복문은 “어떻게 하나씩 처리할지”를 자세히 적는 방식이고, Stream API는 “무엇을 변환하고 어떤 조건으로 모을지”를 더 선언적으로 드러내는 방식에 가깝습니다.

예를 들어 아래 두 작업은 겉보기엔 달라 보여도 구조는 같습니다.

  • 문자열을 모두 대문자로 바꾸기
  • 문자열을 각각 길이 숫자로 바꾸기

둘 다 공통 흐름은 같습니다.

1. 컬렉션에서 stream() 생성 2. map()으로 각 요소 변환 3. 필요하면 forEach()로 확인하거나 collect(Collectors.toList())로 저장

이 구조를 눈에 익혀 두면, 나중에 문자열이 아니라 사용자 객체나 주문 목록을 다룰 때도 같은 방식으로 읽을 수 있습니다. 그래서 초보 단계에서는 복잡한 예제보다 짧은 변환 예제를 여러 번 실행해 보는 편이 더 효과적입니다.

map()은 값의 개수보다 형태를 바꾸는 작업에 가깝다

map()을 처음 배울 때 가장 자주 생기는 오해는 filter()와의 혼동입니다. 둘은 역할이 분명히 다릅니다.

  • map() : 각 요소를 다른 값으로 변환
  • filter() : 조건에 맞는 요소만 남김

예를 들면:

  • "java" → "JAVA" : map()
  • "java" → 4 : map()
  • 길이 5 이상 문자열만 남김 : filter()

즉 map()은 보통 개수는 유지한 채 형태를 바꾼다고 이해하면 편합니다. 다만 실제 파이프라인 전체에서는 앞뒤에 filter()가 붙을 수 있으므로, 최종 결과 개수는 변할 수 있습니다. 실습할 때는 “지금 이 줄이 선택인지, 변환인지”를 구분하며 읽는 습관이 중요합니다.

대문자 변환으로 보는 Stream API의 기본 작업

가장 먼저 해 보기 좋은 예제는 문자열 리스트를 대문자로 바꾸는 작업입니다. 익숙한 데이터라서 결과를 바로 확인하기 쉽고, Collectors.toList()의 역할도 자연스럽게 드러납니다.

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

public class StreamUppercaseExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("java", "stream", "lambda", "filter");

        System.out.println("원본: " + words);

        List<String> uppercased = words.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println("대문자 변환: " + uppercased);
        System.out.println("원본 다시 확인: " + words);
    }
}
원본: [java, stream, lambda, filter]
대문자 변환: [JAVA, STREAM, LAMBDA, FILTER]
원본 다시 확인: [java, stream, lambda, filter]

여기서 확인해야 할 포인트는 문법보다 결과 해석입니다.

  • stream()은 리스트를 순회 가능한 흐름으로 만듭니다.
  • map(String::toUpperCase)는 각 문자열을 대문자로 변환합니다.
  • collect(Collectors.toList())는 변환된 값을 새 리스트로 모읍니다.
  • 원본 words는 그대로 유지됩니다.

실무나 학습 현장에서 자주 나오는 질문 중 하나가 “왜 원본이 바뀌지 않나요?”인데, 이 예제가 그 답이 됩니다. Stream은 기본적으로 원본 컬렉션 자체를 수정하는 도구가 아니라, 원본을 바탕으로 새 결과를 계산하는 방식으로 이해하는 편이 안전합니다.

길이 계산 예제가 Java Stream 학습에 유용한 이유

대문자 변환은 입력과 출력이 모두 문자열이라 부담이 적습니다. 반면 길이 계산은 String이 Integer로 바뀌므로 map()의 본질을 더 잘 보여 줍니다.

import java.util.Arrays;
import java.util.List;

public class StreamLengthPrintExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("Java", "Stream", "Lambda", "Filter", "Map");

        System.out.println("원본 문자열: " + words);
        System.out.println("각 문자열의 길이:");

        words.stream()
                .map(String::length)
                .forEach(System.out::println);
    }
}
원본 문자열: [Java, Stream, Lambda, Filter, Map]
각 문자열의 길이:
4
6
6
6
3

이 예제를 볼 때는 타입 흐름을 함께 보면 좋습니다.

  • 시작: List<String>
  • 중간: Stream<String>
  • 변환 후: Stream<Integer>
  • 최종 출력: forEach()

문자열을 숫자로 바꾸는 경험을 해 두면, 나중에 객체에서 특정 필드만 꺼내는 코드도 훨씬 자연스럽게 읽힙니다. 예를 들어 student -> student.getScore() 같은 코드도 결국 같은 패턴입니다.

Collectors.toList()와 forEach()는 목적이 다르다

초보 단계에서는 forEach()와 Collectors.toList()를 비슷하게 느끼는 경우가 많습니다. 하지만 목적은 다릅니다.

상황사용 메서드의미
결과를 화면에서 바로 확인forEach()요소를 하나씩 소비
결과를 저장하고 다음 작업에 사용collect(Collectors.toList())새 리스트 생성

예를 들어 길이만 바로 확인하려면:

words.stream()
    .map(String::length)
    .forEach(System.out::println);

길이 목록을 변수로 보관하려면:

List<Integer> lengths = words.stream()
    .map(String::length)
    .collect(Collectors.toList());

이 차이는 단순 문법 차이가 아니라 설계 차이입니다. 출력은 보통 “확인 단계”이고, 수집은 “재사용 단계”입니다. 실습에서는 둘을 모두 해 봐야 Stream API를 단순 출력 도구로 오해하지 않게 됩니다.

직접 실행하며 확인한 관찰: 학생들이 가장 많이 막히는 지점

수업 실습이나 개인 정리 과정에서 반복적으로 보이는 패턴이 있습니다. 문법을 못 외워서 막히는 경우보다, 중간 연산과 최종 연산의 역할을 섞어서 이해하는 경우가 더 많았습니다. 예를 들어 map()까지만 쓰고 왜 결과가 안 보이냐고 묻거나, forEach()로 출력한 뒤 그 결과를 다시 변수처럼 다루려는 실수가 자주 나옵니다.

직접 코드를 여러 번 실행해 보면 이 차이가 금방 보입니다. map()은 변환만 하고, forEach()는 소비하고, Collectors.toList()는 모읍니다. 글로 읽을 때는 비슷해 보여도 콘솔 출력으로 비교해 보면 훨씬 덜 헷갈립니다. 그래서 학습 초반에는 “결과를 바로 출력한 버전”과 “리스트로 저장한 버전”을 같은 데이터로 나란히 실행해 보는 방식이 특히 도움이 됐습니다.

null과 조건 처리를 붙이면 실전에 더 가까워진다

기본 예제는 이해용으로 좋지만, 실제 작업에서는 null이나 조건 필터링을 함께 다루는 경우가 많습니다. 이때 filter()와 map()의 순서를 구분해 보는 연습이 유용합니다.

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

public class StreamSafeExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("java", null, "stream", "ai", "lambda");

        List<String> result = words.stream()
                .filter(Objects::nonNull)
                .filter(word -> word.length() >= 5)
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println("결과: " + result);
    }
}
결과: [STREAM, LAMBDA]

이 코드를 읽을 때는 각 줄의 역할이 명확해야 합니다.

  • filter(Objects::nonNull) : null 제거
  • filter(word -> word.length() >= 5) : 조건 선택
  • map(String::toUpperCase) : 값 변환
  • collect(Collectors.toList()) : 최종 수집

여기서 주의할 점도 있습니다. null이 포함될 수 있는 데이터라면 String::length나 String::toUpperCase를 바로 적용하기 전에 먼저 걸러야 예외를 피할 수 있습니다. 모든 코드가 반드시 이 순서를 가져야 하는 것은 아니지만, 문자열 리스트 실습에서는 안전한 기본 습관으로 익혀 두기 좋습니다.

메서드 참조가 낯설다면 람다식으로 풀어 읽으면 된다

String::toUpperCase 같은 문법이 처음엔 어렵게 보일 수 있습니다. 하지만 대부분은 람다식을 줄여 쓴 형태입니다.

.map(String::toUpperCase)

위 표현은 아래와 같은 의미입니다.

.map(word -> word.toUpperCase())

마찬가지로:

.map(String::length)

는

.map(word -> word.length())

와 같습니다.

학습 초반에는 메서드 참조를 억지로 외우기보다, 먼저 람다식으로 의미를 이해한 뒤 짧게 바꿔 쓰는 편이 훨씬 덜 헷갈립니다. 직접 코드를 읽을 때도 “각 요소에 대해 어떤 메서드를 호출하는가”로 해석하면 부담이 줄어듭니다.

수업 실습 문제와 풀이 코드

실습은 한 번에 여러 개념을 묶어 보는 방식이 효과적입니다. 아래 문제는 filter(), map(), Collectors.toList()를 함께 연습할 수 있도록 구성했습니다.

문제 조건:

  • 문자열 리스트에서 null은 제외한다.
  • 길이가 5 이상인 문자열만 남긴다.
  • 남은 문자열을 모두 대문자로 바꾼다.
  • 결과를 새 리스트로 저장하고 출력한다.
  • 원본 리스트가 바뀌지 않았는지도 확인한다.
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class StreamPractice {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("java", "stream", null, "lambda", "map", "filter");

        List<String> result = words.stream()
                .filter(Objects::nonNull)
                .filter(word -> word.length() >= 5)
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        List<Integer> lengths = words.stream()
                .filter(Objects::nonNull)
                .map(String::length)
                .collect(Collectors.toList());

        System.out.println("원본 리스트: " + words);
        System.out.println("조건 적용 후 대문자 리스트: " + result);
        System.out.println("null 제외 후 길이 리스트: " + lengths);
        System.out.println("원본 유지 확인: " + words);
    }
}
원본 리스트: [java, stream, null, lambda, map, filter]
조건 적용 후 대문자 리스트: [STREAM, LAMBDA, FILTER]
null 제외 후 길이 리스트: [4, 6, 6, 3, 6]
원본 유지 확인: [java, stream, null, lambda, map, filter]

실행 후에는 아래 세 가지를 꼭 체크해 보세요.

  • filter()는 대상을 줄이고, map()은 값을 바꿉니다.
  • Collectors.toList()를 쓰면 결과를 다시 사용할 수 있습니다.
  • 원본 리스트는 수정되지 않습니다.

작은 과제로는 다음 정도가 좋습니다.

  • length() >= 5를 >= 4로 바꿔 결과 비교
  • toUpperCase()를 toLowerCase()로 변경
  • forEach() 버전과 Collectors.toList() 버전을 각각 작성해 차이 확인

이런 작은 수정은 검색으로 예제를 더 읽는 것보다 기억에 오래 남습니다.

문자열 예제가 객체 처리로 연결되는 이유

문자열 예제가 쉬워 보여도, 실제로는 객체 리스트 처리의 출발점 역할을 합니다. 문자열에서 하던 변환은 객체에서도 거의 같은 구조로 반복됩니다.

예를 들면 학생 목록이 있을 때:

  • student -> student.getName()
  • student -> student.getScore()
  • student -> student.getName().toUpperCase()

처럼 필요한 값만 뽑아 새 형태로 만들게 됩니다. 그러니 지금 하는 Java Stream 연습은 단순 문자열 장난이 아니라, 나중에 실무 코드에서 필요한 데이터를 정리하는 감각을 미리 익히는 과정으로 봐도 좋습니다.

Java Stream 통합 학습에서 먼저 점검할 기준

문법을 많이 아는 것보다 아래 기준이 잡혀 있으면 훨씬 안정적으로 읽을 수 있습니다.

  • 지금 하는 작업이 선택인지 변환인지 구분했는가
  • 화면 확인이 필요한지, 결과 저장이 필요한지 결정했는가
  • null 가능성이 있는 데이터를 먼저 점검했는가
  • 원본 컬렉션이 바뀌는지 새 결과가 만들어지는지 확인했는가

직접 정리해 보니 초보 학습자에게 정말 중요한 건 메서드 개수 자체가 아니었습니다. map(), filter(), forEach(), Collectors.toList() 정도만 정확히 구분해도 대부분의 입문 문제는 풀립니다. 이후에 sorted(), distinct(), groupingBy() 같은 기능을 배워도 중심이 흔들리지 않습니다.

참고 자료

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html

  • Oracle Stream API

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html

  • Oracle Collectors API

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html

  • Oracle Package java.util.stream

답글 남기기

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