자바 백엔드 입문 9편 — 자바 Stream API 람다

2026-05-17자바 백엔드 입문

자바 백엔드 입문 9편. 컬렉션을 함수형으로 다루는 자바 Stream API와 람다 표현식의 표준 패턴을 컨베이어 벨트 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 9편 — 자바 Stream API 람다

이 글은 자바 백엔드 입문 시리즈 59편 중 9편이에요. 5편 자바 컬렉션 의 List·Map을 "한 줄로 변환·필터링" 하는 모던 자바의 진수 — Stream API 와 람다를 풀어 가요.

Stream이 헷갈리는 이유

자바 코드 보면 stream().filter(...).map(...).collect(...) 같은 줄줄이 메서드 호출이 등장해요. 처음 보면 "이게 뭐지? 마법 같은 한 줄?" 가 안 잡혀요.

이 글에서는 컨베이어 벨트 비유로 풀어요. Stream = "컨베이어 벨트", filter = "불량품 거르기", map = "가공", collect = "상자에 담기". 끝까지 따라오시면 한국 회사 자바 코드의 70% 풍경이 한눈에 들어와요.

람다 표현식 — 함수를 값처럼

Stream 이해의 전제. 람다 = "이름 없는 함수". 자바 8(2014) 도입.

// 익명 클래스 (옛 스타일)
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello");
    }
};

// 람다 표현식 (자바 8+)
Runnable r = () -> System.out.println("Hello");

10줄이 1줄로. 함수를 "변수에 담아 전달하는" 함수형 프로그래밍 패러다임.

람다 문법

// (매개변수) -> 본문
() -> System.out.println("Hi")                    // 매개변수 없음
x -> x * 2                                        // 한 개 (괄호 생략 가능)
(x, y) -> x + y                                   // 두 개
(int x, int y) -> x + y                           // 타입 명시
(x, y) -> {                                       // 여러 줄 본문
    int sum = x + y;
    return sum;
}

메서드 레퍼런스 — 더 짧게

// 람다
list.forEach(s -> System.out.println(s));

// 메서드 레퍼런스 (더 깔끔)
list.forEach(System.out::println);

Class::method 또는 instance::method 표기. 람다가 "기존 메서드 한 줄 호출" 일 때 매우 자주.

Stream — 컨베이어 벨트

Stream = "컬렉션 위에 흐르는 데이터 파이프라인". 자바 8(2014) 도입.

List<String> names = List.of("Alice", "Bob", "Charlie", "David");

// 길이 5 이상인 이름만 대문자로
List<String> result = names.stream()
        .filter(name -> name.length() >= 5)         // 거르기
        .map(String::toUpperCase)                   // 가공
        .collect(Collectors.toList());              // 수집

System.out.println(result);   // [ALICE, CHARLIE, DAVID]

for 루프 + if 조합 5~6줄이 한 표현식으로. 선언적 스타일"무엇을 하라" 만 적고 "어떻게" 는 라이브러리에 맡김.

파이프라인 3단계

[소스] → [중간 연산 0+개] → [종료 연산 1개]

1. 소스 — 컬렉션의 시작점

list.stream()                                       // List·Set
map.entrySet().stream()                             // Map
Stream.of("a", "b", "c")                            // 직접 생성
Arrays.stream(arr)                                  // 배열

2. 중간 연산 — 변환·필터링 (Stream 반환, 지연 평가)

메서드 의미
filter(pred) 조건 통과한 요소만
map(fn) 변환 (T → R)
flatMap(fn) 중첩 평탄화
distinct() 중복 제거
sorted() 정렬
limit(n) 처음 n개
skip(n) 처음 n개 건너뜀
peek(c) 중간 디버깅 (값 안 바꿈)

3. 종료 연산 — 결과 도출 (Stream 종료)

메서드 의미
collect(Collectors.toList()) List로 수집
collect(Collectors.toSet()) Set으로
collect(Collectors.toMap(k, v)) Map으로
count() 개수
forEach(c) 각 요소 실행 (반환 X)
findFirst() 첫 요소 (8편 Optional 반환)
anyMatch(p)·allMatch(p)·noneMatch(p) boolean
reduce(init, fn) 축약 (합·곱 등)
min(cmp)·max(cmp) 최소·최대
toList() (자바 16+) 짧은 표기

지연 평가 — 중요한 특성

중간 연산은 호출 즉시 실행 안 됨 — 종료 연산이 호출돼야 비로소 파이프라인 가동.

Stream<String> stream = names.stream()
        .filter(n -> { System.out.println("filter " + n); return n.length() > 3; })
        .map(n -> { System.out.println("map " + n); return n.toUpperCase(); });
// 출력 X — 아직 실행 안 됨

stream.collect(Collectors.toList());   // 이때 비로소 filter·map 호출

"지연 평가" 덕분에 — 1억 개 데이터에서 처음 3개만 필요할 때 1억 번 다 돌지 않고 "3개 찾으면 즉시 종료".

자주 쓰는 패턴 6가지

(1) filter + map + collect

List<String> activeUserNames = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .collect(Collectors.toList());

(2) groupingBy — 그룹화

Map<String, List<User>> usersByCountry = users.stream()
        .collect(Collectors.groupingBy(User::getCountry));

// 카운트만
Map<String, Long> countByCountry = users.stream()
        .collect(Collectors.groupingBy(User::getCountry, Collectors.counting()));

SQL의 GROUP BY와 같은 의미. 한국 회사 백엔드 매우 자주.

(3) toMap — 키-값 변환

Map<Long, User> userMap = users.stream()
        .collect(Collectors.toMap(User::getId, Function.identity()));

Function.identity() = "받은 그대로 반환" (즉, x -> x 의 명시적 표기).

(4) reduce — 축약

int totalAmount = orders.stream()
        .mapToInt(Order::getAmount)
        .sum();

// 또는
int total = orders.stream()
        .map(Order::getAmount)
        .reduce(0, Integer::sum);

(5) joining — 문자열 연결

String csv = users.stream()
        .map(User::getName)
        .collect(Collectors.joining(", "));     // "Alice, Bob, Charlie"

(6) findFirst — Optional 반환

Optional<User> firstActive = users.stream()
        .filter(User::isActive)
        .findFirst();

병렬 Stream — parallelStream()

long count = users.parallelStream()
        .filter(User::isActive)
        .count();

stream() 대신 parallelStream() 박으면 — 멀티 스레드로 병렬 처리. 다만 함정:

  • 데이터 적으면(< 수만) 오히려 느림 (스레드 오버헤드)
  • 순서 보장 X
  • ForkJoinPool 공유 — 다른 작업과 충돌 가능

한국 회사 실무 — 일반 Stream이 99%. parallelStream은 거의 안 씀. 진짜 빠르게 만들고 싶으면 비동기·아키텍처 차원으로 풀어야지 parallelStream 한 줄로 해결되는 경우 드뭄.

Stream API 안티패턴

(1) for 루프 단순 대체

// ❌ 그냥 for 루프가 더 명확
users.stream().forEach(user -> log.info(user.getName()));

// ✅ for-each 가 더 깔끔
for (User user : users) {
    log.info(user.getName());
}

Stream 의 진가는 "변환·필터·집계" 가 동반될 때. 단순 순회는 for-each 가 명확.

(2) Stream 재사용

Stream<String> stream = list.stream();
stream.filter(...).collect(...);
stream.map(...).collect(...);     // ❌ IllegalStateException — Stream은 일회용

Stream 은 "한 번 종료 연산 실행하면 끝". 다시 쓰려면 list.stream() 으로 새로 생성.

(3) forEach 안에서 외부 변수 변경

// ❌ 부작용 — 순서 보장 X, 병렬 시 에러
List<String> result = new ArrayList<>();
users.stream().forEach(u -> result.add(u.getName()));

// ✅ collect 사용
List<String> result = users.stream()
        .map(User::getName)
        .collect(Collectors.toList());

Spring·JPA에서 자주

이 시리즈 거의 모든 글에 등장.

// 컨트롤러
@GetMapping
public List<OrderResponse> list() {
    return orderService.findAll().stream()
            .map(OrderResponse::from)
            .collect(Collectors.toList());
}

// 통계
Map<String, Long> statsByStatus = orderRepository.findAll().stream()
        .collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));

자바 백엔드 = 매일 Stream 다루는 일.

💡 입문자 빠른 룰

컬렉션에서 변환·필터·집계가 동반되면 Stream. 단순 순회는 for-each. .stream().filter().map().collect() 4단 콤보가 80% 시나리오 커버. 메서드 레퍼런스(::)로 더 깔끔하게.

한 줄 정리 — 자바 Stream API + 람다 = 컬렉션을 함수형으로 다루는 표준. filter·map·collect 파이프라인이 70% 패턴. parallelStream은 함정 많아 거의 안 씀. for 루프 단순 대체는 안티패턴.

시험 직전 한 번 더 — Stream + 람다 입문자가 매번 헷갈리는 것

  • 람다 = 익명 함수 (자바 8+)
  • 문법 = (매개변수) -> 본문
  • 메서드 레퍼런스 = Class::method·instance::method
  • Stream = 컬렉션 위 파이프라인 (자바 8+)
  • 파이프라인 = 소스 → 중간 연산 → 종료 연산
  • 중간 연산 = filter·map·flatMap·distinct·sorted·limit·skip·peek
  • 종료 연산 = collect·count·forEach·findFirst·anyMatch·reduce
  • 지연 평가 = 종료 연산 호출 전엔 실행 X
  • collect(toList) = List로 수집 (자바 16+ .toList() 단축)
  • collect(groupingBy) = SQL GROUP BY와 같은 의미
  • collect(toMap) = 키-값 변환
  • collect(joining) = 문자열 연결
  • reduce = 축약 (합·곱·최소·최대)
  • mapToInt(...).sum() = 합계 표준 패턴
  • Stream은 일회용 — 종료 연산 후 재사용 X
  • parallelStream = 멀티 스레드 (한국 실무 거의 안 씀)
  • 안티패턴 1 = for 루프 단순 대체 (.forEach 만)
  • 안티패턴 2 = Stream 재사용 (IllegalStateException)
  • 안티패턴 3 = forEach 안에서 외부 변수 변경
  • findFirst()·findAny() = Optional 반환 (8편)
  • 자바 백엔드 = 매일 Stream 다루는 일

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!