Spring_부캠/두고두고 볼 개념

[Java] Lambda & Streams

FS29 2025. 7. 18. 14:06
Lambda & Streams

 

 

 

Lambda(람다식)이란?

자바에서 함수를 간결하게 표현하는 문법이다. 익명클래스의 축약버전.
쉽게 말해, 함수나 메서드를 변수처럼 넘길 수 있게 해주는 문법이다.
(매개변수) -> { 실행문 }

 

 

왜 람다를 쓰는가?

 

  • 코드가 짧고 직관적
  • 익명 객체(new Runnable() {...})보다 간결함
  • 함수형 인터페이스와 함께 사용하여 전달/실행 구조 가능
// 기존 방식
Runnable r = new Runnable() {
    public void run() {
        System.out.println("Hi");
    }
};

// 람다 방식
Runnable r = () -> System.out.println("Hi");

 

 

 

람다의 기초문법

 

1) 매개변수 없을 경우

() -> System.out.println("Hi")

 

2) 매개변수 1개 있을 경우

x -> x * x

 

3) 매개변수 2개 있을 경우

 

(a, b) -> a + b

 

 

4) 매개변수 다수 있을 경우

(x) -> { int y = x+1; return y; }

 


함수형 인터페이스

 

자바는 원래 함수만 따로 전달 될 수 없다. 모든 걸 객체로 다뤄야 해서 함수를 쓰고 싶다면 결국 객체의 메서드로 만들어야 한다.

그래서 인터페이스로 대신하는데, 예전에는 아래와 같이 사용했다.

public interface MyFunction {
    void run();
}
MyFunction f = new MyFunction() {
    public void run() {
        System.out.println("실행!");
    }
};

 

f.run()을 호출하면 마치 함수처럼 넘긴 것처럼 쓸 수 있는데 이 방식이 너무 길다는 단점이 있다.

이때 등장한 게 람다.

// 위 코드를 간단히 줄이면
MyFunction f = () -> System.out.println("실행!");

 

함수형 인터페이스란?
추상 메서드가 하나만 있는 인터페이스

람다식은 함수처럼 생긴 코드지만, 실제로는 자바에서 객체다. 이 객체는 반드시 함수형 인터페이스를 기반으로 만들어져야 한다.
즉, 람다는 클래스 없이도 메서드만 전달할 수 있게 도와주는 문법이며 실제로는 자바가 자동으로 해당 인터페이스의 익명 구현 객체로 변환해주는 것이다.

 

람다식은 무조건 추상 메서드 하나만 있는 인터페이스를 기반으로 동작한다. 이걸 함수형 인터페이스라고 부르는데 자바에서는 

람다=함수형 인터페이스의 익명 구현체를 간단히 줄인 문법이라고 이해하면 쉽다.

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}

 

잠깐! 인터페이스는 규칙(틀)인데 람다랑 뭔 상관임?

interface Animal {
    void sound();
}

 

위 코드는 sound라는 메서드를 꼭 만들어!라고 규칙만 정해놓은 건데, 실제 동작(내용)은 누가 구현해줘야 한다.

그런데 람다식도 결국 "어떤 동작(기능)"을 전달하는 방식이다.

x -> x + 1

이건 숫자 하나 받아서 1 더하는 동작을 말하는 건데, 

문제는 동작만 따로 전달하지 못한다. 자바는 함수만 덜렁 넘길 수 없고 무조건 객체로 전달해야 함을 명심하자.

그래서 람다식을 사용할 수 있는 방법으로 함수형 인터페이스를 규칙처럼 만든 거다.

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}
// 어떤 계산 로직 하나를 전달해줘.
// 근데 그 계산은 int a, int b 받아서 int 리턴.

위 코드에 의해 람다식이 이렇게 쏙~ 들어간다.

Calculator add = (a, b) -> a + b;

 

 

 

함수형 인터페이스 vs 일반 인터페이스 예시
// 함수형 인터페이스 - 람다식 사용 가능
@FunctionalInterface
interface Printable {
    void print();
}

// 일반 인터페이스 - 추상 메서드 2개 → 람다식 불가능
interface NotFunctional {
    void methodA();
    void methodB();
}

 

 


 

 

Stream이란?
자바 컬렉션(List, Set 등)을 함수형 스타일로 처리할 수 있도록 해주는 API. 필터링, 매핑, 집계 등 연산을 간결하게 수행.

 

 

스트림의 구조
list.stream()         // 스트림 생성
    .filter(...)       // 필터링
    .map(...)          // 값 변환
    .collect(...)      // 결과 수집

 

  • filter는 조건을 만족하는 요소만 통과시킴 true인 애만 살아남음
  • map은 요소의 값을 변환함 소문자->대문자
  • collect는 스트림 결과를 리스트, 집합 등으로 다시 모아주는 과정

 

이해를 돕기 위한 예제 코드

List<String> names = List.of("Tom", "Jerry", "Bob");

List<String> result = names.stream()
    .filter(name -> name.length() <= 3)
    .map(name -> name.toUpperCase())
    .collect(Collectors.toList());

// 출력: [TOM, BOB]

 

Stream은 중간 연산 최종 연산으로 나뉜다.

  • 중간연산: .filter(), .map() 등 연결만 하고 실제 실행은 안함.
  • 최종연산: .collect(), .forEach() 등 이때서야 실행됨

즉, 스트림은 *게으른 평가를 하므로 collect 같은 최종 연산이 와야 동작이 끝난다.

*게으른 평가란?
스트림은 .filter, .map같은 중간 연산만으로는 아무것도 실행되지 않고 collect()나 forEach() 같은 최종 연산이 와야 실행된다.
List<String> names = List.of("Tom", "Jerry", "Bob");

// 실행 안 됨 (아무 결과도 없음)
names.stream()
     .filter(name -> name.length() <= 3)
     .map(String::toUpperCase); // 결과 없음

// 실행됨
names.stream()
     .filter(name -> name.length() <= 3)
     .map(String::toUpperCase)
     .collect(Collectors.toList()); // 실행O

// 또는 실행됨
names.stream()
     .filter(name -> name.length() <= 3)
     .map(String::toUpperCase)
     .forEach(System.out::println); // 실행O, 출력됨

collect()와 forEach()는 둘 다 “스트림을 실행시키는 최종 연산”이다.
둘 다 있으면 실행되고, 둘 중 하나만 있어도 실행된다.
차이는 하나는 “데이터 수집”, 하나는 “즉시 실행”이다.
람다는 스트림 내부에서 이런 연산들을 표현하는 문법 역할을 한다.

 

 

람다 & 스트림 주의사항

 

 

  • 람다는 오직 함수형 인터페이스에만 쓸 수 있음
  • 스트림은 내부 반복(iteration)이며, 원본 컬렉션은 변경되지 않음
  • .collect(), .forEach(), .reduce() 등 중간/최종 연산 구분 필요

 

그래서 람다랑 스트림, 무슨 관계인데??

 

 

  • Stream 내부에서는 대부분 람다식을 써야 한다.
  • .filter(), .map(), .forEach()는 매개변수로 함수형 인터페이스를 받기 때문임.
  • 람다는 이 함수형 인터페이스를 간단하게 표현하는 방식이니까 → 자연스럽게 스트림=람다 활용의 대표 사례

 

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = List.of("Tom", "Jerry", "Bob");

        // 람다 + 스트림 + forEach (최종 연산)
        names.stream()
            .filter(name -> name.length() <= 3)         // 조건 필터링
            .map(name -> name.toUpperCase())            // 대문자로 변환
            .forEach(name -> System.out.println(name)); // 하나씩 출력
    }
}
// 출력: TOM
// 출력: BOB

 

 

 

forEach간단 예제 (collect 없이)
List<String> names = List.of("Tom", "Jerry", "Bob");

names.stream()
    .filter(name -> name.startsWith("J"))
    .forEach(name -> System.out.println(name)); // 출력: Jerry

 

 

 


질문사항

 

Q1. 람다식은 메서드랑 뭐가 다른가?

=> 메서드를 변수처럼 전달 가능. *익명 구현체의 축약형

*익명 구현체가 뭔데 (별로 안중요한데 궁금해서 알아봄)
이름이 없는 클래스. 인터페이스나 추상 클래스를 구현하면서 이름없이 바로 쓰는 클래스다.
// Runnable이라는 인터페이스를 구현하고 싶을 때
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hi");
    }
};
r.run();

위 코드에서 Runnable은 인터페이스다.(void run()이라는 메서드 하나만 있음)

그런데 클래스 만들지 않고 바로 new Runnable(){}해서 구현한 건데, 이게 바로 익명 구현 객체(익명 클래스의 인스턴스)

문제는 너무 길고 보기 힘듦=> 그래서 나온 게 람다식

// 위 코드를 람다식으로 바꾸면
Runnable r = () -> System.out.println("Hi");

 

Q2. 함수형 인터페이스는 왜 쓰는가?

=> 람다식이 동작하려면 반드시 1개의 추상메서드가 필요해서.

 

Q3. filter()와 map() 차이는?

=> filter는 조건 판별(걸러냄), map은 값 변환(바꿈).

 

Q4. 스트림 쓸 때 꼭 collect() 해야 해?

=> 최종연산 필요. 결과로 List 등 만들려면 collect() 필요.