Java Backend 개발자 되기/Java

Java Stream API 한방에 이해하기

Kevin's IT Village 2025. 3. 13. 22:23
반응형

Java의 Stream은 위에서 아래로 흐르는 시냇물이다

프로그래밍에서 데이터를 처리할 때, 우리는 보통 for 문을 이용한 반복문을 먼저 떠올립니다.
하지만 Java 8부터 등장한 Stream API는 완전히 다른 방식으로 데이터를 다루게 해줍니다.

Stream은 마치 위에서 아래로 흐르는 시냇물처럼
컬렉션(List, Set 등)의 데이터를 흘려보내며
필터링하고, 가공하고, 집계하는 도구입니다.

예를 들어 볼까요?

👉 코드 스니핏 (Java):

List<String> names = List.of("kevin", "tom", "emma");

names.stream()
    .filter(name -> name.length() >= 5)
    .map(String::toUpperCase)
    .forEach(System.out::println);

이 코드는 names 리스트에 담긴 데이터를

  • 길이가 5 이상인지 판단해서 필터링해주고 (filter)
  • 대문자로 변환한 후에 (map)
  • 한 줄씩 출력 (forEach) 하는 구조입니다.

즉, Stream이 제공하는 선언형 스타일을 통해
하나의 흐름 속에서 데이터를 가공하고 처리하는 전체 과정을 명확하게 표현할 수 있습니다.


Stream은 각자 자기 할 일만 한다

Stream의 구성은 체이닝 구조로 이루어져 있으며, 각 연산은 자신이 맡은 역할만 수행합니다.

  • filter(): 조건을 걸어 원하는 요소만 통과시킵니다.
  • map(): 데이터를 다른 형식으로 변환합니다.
  • sorted(): 데이터를 정렬합니다.
  • collect(): 최종 결과를 컬렉션 형태로 반환합니다.
  • forEach(): 하나씩 꺼내서 소비(처리)합니다.

예를 들어 다음 코드처럼 각 연산은 파이프라인처럼 연결되어 실행됩니다.

👉 코드 스니핏 (Java):

List<String> cities = List.of("Seoul", "Busan", "Incheon");

cities.stream()
    .filter(city -> city.length() > 5)
    .map(String::toLowerCase)
    .forEach(System.out::println);

여기서 filter()는 길이가 5보다 큰 도시만 남기고,
map()은 해당 도시명을 소문자로 바꾸며,
forEach()는 최종 출력 처리를 담당합니다.

각 단계는 자기 할 일만 수행하고,
다른 연산과는 독립적으로 설계되어 있어 유지보수가 쉬워집니다.


Stream은 ‘대용량 데이터’를 흘려 보내기 위해 태어났다

많은 사람들이 처음엔 “for문 대신 쓰면 깔끔하네?”라고 Stream을 이해하지만,
사실 Java가 Stream을 만든 진짜 이유는 따로 있습니다.

그 이유는 바로 대용량 데이터를 효율적으로 다루기 위해서입니다.

기존 방식은 데이터를 전부 메모리에 올려놓고 처리했지만 Stream은 데이터를 하나씩 읽는 순간 바로 처리할 수 있습니다.
즉, 메모리를 덜 사용하고, 처리 효율도 훨씬 높다는 말이죠.

이 말이 이해되지 않는 다면 다음 예를 통해 Stream의 중요한 특징을 이해해 보세요.

공장에서 하루에 라면을 1만 개 생산한다고 가정해봅시다.

만약 면 1만 개를 모두 만든 다음에 포장 작업을 시작하려면,
완성된 면을 담아둘 엄청나게 큰 그릇이 필요하겠죠.

반면에 면이 하나 만들어질 때마다 바로 포장 단계로 넘어간다면,
그릇 같은 중간 저장 공간은 필요 없습니다.

이렇게 각 공정이 바로바로 이어지는 방식이 바로 Stream의 처리 방식과 유사합니다.

공정이 바로바로 이어진다는 의미는 1만 개의 면이 모두 만들어질 때까지 기다릴 필요가 없다는 뜻이기 때문에 공정 시간 또한 단축될 수 있다는 의미입니다.

 

👉 아래 그림은 방금 설명한 Stream의 처리 흐름을 시각적으로 표현한 것입니다.
Java Stream이 데이터를 처리하는 과정을 공장에서 제품을 생산할 때 사용하는 컨베이어 벨트에서의 흐름으로 그려봤습니다.

📌 그림: 데이터소스에서 데이터가 하나씩 흐르며 filter → map → collect 단계를 거치는 구조

데이터 소스에서 데이터가 하나씩 흐르며 filter → map → collect 단계를 거치는 구조

이 그림을 보면, Stream이 어떤 구조로 데이터를 처리하는지 한눈에 이해할 수 있습니다.

 

조금 더 여러분의 이해를 돕기 위해 코드 예시로 설명하겠습니다.

예를 들어 파일에서 한 줄씩 데이터를 읽고 싶다고 해봅시다.

기존 방식:

👉 코드 스니핏 (Java):

List<String> lines = Files.readAllLines(Path.of("data.txt"));
for (String line : lines) {
    if (line.contains("ERROR")) {
        System.out.println(line.toUpperCase());
    }
}

이 방식은 data.txt의 모든 라인을 List<String>에 한꺼번에 읽어들인 뒤,
해당 리스트를 반복문으로 순회하며 원하는 라인을 찾아 처리합니다.

처음엔 직관적이고 익숙하게 느껴질 수 있지만,
문제가 되는 건 파일 크기입니다.

  • 파일에 수천만 줄이 있다면, 그 모든 데이터를 한꺼번에 메모리에 올리게 되며
    메모리 사용량이 급증하고 OutOfMemoryError가 발생할 위험도 있습니다.
  • 모든 데이터를 메모리에 읽어놓고 그 후에야 처리하므로
    처리 지연이 발생하며, 반응성도 낮습니다.
  • 실시간으로 라인이 계속 추가되는 로그 파일이라면
    → 매번 파일을 다시 읽어야 하므로 비효율적입니다.

Stream 방식:

👉 코드 스니핏 (Java):

try (Stream<String> lines = Files.lines(Path.of("data.txt"))) {
    lines.filter(line -> line.contains("ERROR"))
         .map(String::toUpperCase)
         .forEach(System.out::println);
}

이 방식은 모든 데이터를 한꺼번에 메모리에 올릴때 까지 기다리는게 아니라 파일에서 한 줄씩 읽어오면서
조건에 맞는 줄만 필터링하고, 대문자로 변환 후 출력까지 이어집니다.

  • 한 줄씩 읽고 처리하므로 메모리 사용량이 매우 적습니다.
  • Stream의 lazy evaluation(지연 처리) 덕분에 필요한 작업만 최소로 수행합니다.
  • 처리 속도도 빨라지고, 대용량 로그 처리나 실시간 분석에도 적합합니다.

Java의 Stream과 Lambda는 베스트 프렌드이다. 그래서 게으르다

Java의 Stream은 선언형 API입니다.
즉, “어떻게 처리할지”를 구체적으로 지시하기보다는
“이렇게 처리하겠다”는 의도를 선언하는 방식으로 코드를 작성하게 해줍니다.

그런데 재미있는 점은, 이렇게 처리 흐름을 선언해도
최종 연산(forEach, collect 등)이 실행되기 전까지는 아무런 동작도 하지 않는다는 점입니다.

이런 동작 방식을 우리는 lazy evaluation(지연 처리)라고 부르죠.

그리고 이 Stream은 Lambda(람다)와 함께 사용할 때 그 진가가 드러납니다.
바로 반복적인 처리를 훨씬 더 간결하고 직관적으로 표현할 수 있기 때문이에요.

 

👉 코드 스니핏 (Java):

List<String> list = List.of("apple", "banana", "grape");

list.stream()
    .filter(s -> s.startsWith("b"))
    .map(String::toUpperCase)
    .forEach(System.out::println);
  • 이 코드는 먼저 "b"로 시작하는 문자열만 골라낸 다음,
    그 문자열을 대문자로 변환해서 출력합니다.여기서 중요한 건, 리스트에 있는 모든 데이터를 한꺼번에 처리하는 것이 아니라
    필요한 데이터만 골라서, 그때그때 필요한 작업만 수행한다는 점이에요.
    이런 방식이 바로 Stream의 지연 처리(lazy evaluation) 특성을 잘 보여줍니다.
  • 따라서 실행 결과는 "BANANA" 하나만 출력되죠.

Stream을 어떨 때 사용하면 좋을까?

Java Stream은 단순히 코드를 깔끔하게 만드는 것뿐 아니라, 데이터를 다루는 다양한 상황에서 효율적인 해결책이 되어줍니다.
다음과 같은 경우에 특히 효과적이에요:

  • 반복 작업이 많고, 데이터를 필터링하거나 가공하는 로직이 자주 등장할 때
    (예: 조건에 맞는 값만 골라내고, 형식을 바꾸는 작업 등)
  • 기존 코드가 너무 복잡해서 가독성이나 유지보수에 부담을 느낄 때
    → Stream은 처리 흐름을 명확하게 보여주기 때문에, 읽기 쉽고 수정도 편해집니다.
  • 파일, DB, API 등에서 연속적으로 들어오는 데이터를 실시간으로 처리해야 할 때
    → 한꺼번에 데이터를 모두 모으지 않고, 한 줄씩 흘려보내며 처리할 수 있어요.
  • 다루는 데이터의 양이 크고, 메모리 사용량을 최소화하면서 처리하고 싶을 때
    → 필요한 데이터만 읽고, 처리하고, 버리기 때문에 훨씬 효율적입니다.
  • 성능이 중요한 상황에서 병렬 처리(parallel stream)를 통해
    데이터를 동시에 나눠서 처리하고 싶을 때도 유용합니다.

실제로 어떻게 써보면 좋을까?

Stream의 개념을 이해했더라도, 실제로 써보지 않으면 감이 잘 안 올 수 있습니다.

그래서 저는 Stream의 개념만 이해하는 예제가 아니라 Stream을 애플리케이션 구현에 실제로 적용해 볼 수 있는 실사용 예를 강의에 담았습니다:

  • 구구단 애플리케이션 실습: Stream 기본 처리 흐름을 익히기 좋은 입문 실습
  • 사전지식 실습 문제: 조금 더 복잡한 데이터 가공 흐름을 직접 경험
  • 할일 관리 애플리케이션 프로젝트: 리스트 필터링, 상태 변경, 정렬 등
    실제 서비스에서 발생할 수 있는 상황을 Stream으로 구현해볼 수 있습니다.

단순히 문법만 배우는 게 아니라, Stream을 “실제로 사용할 수 있는 수준까지” 도달할 수 있게 도와주는 강의입니다.

👉 Java 백엔드 미니 프로젝트 실습 강의 보기
https://www.inflearn.com/course/java-백엔드-미니프로젝트-실습

 

[Java 실무 프로젝트 입문편] 객체지향 사고력 훈련 - 미니 프로젝트 3종 실습 강의 | Kevin - 인프런

Kevin | , [임베딩 영상]이런 분들께 추천해요Java 문법은 배웠지만, 실제로 어디서부터 어떻게 구현해야 할지 모르는 분Java 프로젝트 실습 경험이 부족한 분Spring을 배우긴 했지만, Java 기본기가 약

www.inflearn.com

 

반응형