람다식

smj_716·2025년 4월 2일

Java-study / live-study

목록 보기
16/16

1. 람다식이란❓

익명 함수를 간결하게 표현하는 방법이다. 즉, 이름 없는 함수를 표현하는 문법이다.
자바에서는 객체 지향 언어의 특성상 메서드는 항상 클래스 내부에 정의되어야 했다. 하지만 Java 8부터 도입된 람다식 덕분에 메서드를 "값처럼" 전달할 수 있게 되었다.


2. 람다식 사용법

✅ 매개변수 X, 반환 X
실행만 하고 입력도 결과도 없는 경우이다.

() -> System.out.println("Hello World")

✅ 매개변수 1개, 반환 X
하나의 입력값을 받아서 처리한다.

x -> System.out.println(x)

✅ 매개변수 2개, 반환 O
두 값을 받아 연산 후 반환

(a, b) -> a + b

✅ 중괄호와 return 사용
람다식이 복잡하거나 여러 줄일 경우, 중괄호 {}return을 명시해야한다.

(a, b) -> {
    int result = a * b;
    return result;
}

✅ 타입 생략 or 명시
컴파일러가 타입을 추론할 수 있기 때문에 보통 타입을 생략하지만 명시도 가능하다.
단 매개변수가 여러 개일 때 하나에만 타입 명시하는 건 안된다!

// 타입 생략
(x, y) -> x + y

// 타입 명시
(int x, int y) -> x + y

✅ 매개변수가 하나면 괄호 생략 가능
매개변수가 2개 이상이면 무조건 괄호가 필요하다.

x -> x * 2      // 가능
(x) -> x * 2    // 이것도 가능

2. 함수형 인터페이스

👉 함수형 인터페이스란❓

오직 하나의 추상 메서드만 가지는 인터페이스를 말한다.

이러한 인터페이스는 람다식의 대상이 될 수 있다. 람다식은 결국 하나의 메서드를 구현하는 문법적 축약형이라서 하나의 메서드만 가진 인터페이스에만 적용될 수 있다.

👉 왜 필요할까❓

자바는 객체지향 언어이기 때문에 값(데이터)은 쉽게 전달하지만 동작(함수)은 전달이 어려웠다.
"데이터를 이렇게 처리해줘" 라고 함수를 전달하면서 데이터를 흘려보내는 방식을 사용하기 위해 하나의 메서드를 가진 인터페이스에 람다를 전달해서 사용하는 구조를 만든 것이다.
즉, 람다식은 어떤 "동작"을 추상화한 인터페이스의 구현체가 되는 거고 그 인터페이스가 바로 함수형 인터페이스이다.

@FunctionalInterface
public interface MyFunction {
    void doWork();
}
  • @FunctionalInterface는 선택사항이며 하나의 추상 메서드만 존재하는지 컴파일러가 체크해주는 용도이다.
public class Main {
    public static void main(String[] args) {
        MyFunction func = () -> System.out.println("일하는 중입니다!");
        func.doWork();  // 출력: 일하는 중입니다!
    }
}
  • 람다식 () -> System.out.println(...)은 사실 MyFunction 인터페이스의 doWork()를 구현한 익명 클래스이다. 이걸 람다로 축약해서 표현한 것이다.

👉 그럼 람다식을 사용할 때 함수형 인터페이스를 매번 만들어야할까❓

함수의 형태가 너무 다양하기 때문에 일일이 정의해서 사용하기엔 양이 많으니 미리 함수형 인터페이스를 만들어 제공하는 함수형 인터페이스 표준 API가 존재한다.

  • java.util.function 패키지로 제공된다.
  • 함수의 형태와 목적에 따라 다른 종류가 존재한다.

✅ 자주 쓰는 함수형 인터페이스 종류

함수형 인터페이스메서드 형태설명
Runnablevoid run()매개변수 없음, 반환 없음 (대표적으로 쓰레드에 사용)
Consumervoid accept(T t)매개변수 1개, 반환 없음 (값을 소비)
SupplierT get()매개변수 없음, 반환 1개 (값을 공급)
Function<T, R>R apply(T t)T를 R로 변환 (매핑)
Predicateboolean test(T t)T를 평가해 true/false 반환
UnaryOperatorT apply(T t)입력과 출력이 같은 연산자
BinaryOperatorT apply(T t1, T t2)두 값을 받아 연산한 결과 반환

✅ 익숙한 예시

우리가 자주 사용하는 Stream API도 람다식을 이용한다.

List<String> names = Arrays.asList("Amy", "John", "Zoe");

names.stream()
     .filter(name -> name.startsWith("A"))
     .map(String::toUpperCase)
     .forEach(System.out::println);

결국 names.stream() 데이터를 filter(), map(), forEach() 안의 람다식처럼 처리해달라는 뜻이다.
람다식은 내부적으로 아래와 같은 함수형 인터페이스를 인자로 받는다.

  • filter()Predicate<T> (T -> boolean)
  • map()Function<T, R> (T -> R)
  • forEach()Consumer<T> (T -> void)

3. Variable Capture

람다식 안에서는 람다 바깥에 있는 지역 변수를 사용할 수 있다. 이렇게 바깥에 있는 변수를 람다식 내부에서 참조하는 것을 Variable Capture(변수 캡쳐)라고 한다.

➡️ 캡처 가능한 변수 조건

람다식에서 사용되는 지역 변수는 final이거나 사실상 final(=effectively final)이여야한다.
즉, 한 번 값이 할당된 이후 변경되지 않아야 한다는 것!!

public class CaptureExample {
  public static void main(String[] args) {
      int number = 10;

      Runnable r = () -> System.out.println("number: " + number);
      r.run();

      // number = 20; // ❌ 컴파일 에러! 변경 불가능
  }
}

여기서 number는 람다 밖에서 정의됐지만 값이 변경되지 않았기 때문에 "사실상 final"로 간주되고 람다에서 사용할 수 있는 것이다.

➡️ 왜 final이여야 하지?

람다식은 바깥에 있는 변수의 "값 자체"가 아니라 값의 복사본을 캡처해서 사용한다.
만약 값을 변경해버리면 복사된 값과 실제 변수의 값이 달라질 수 있어서 일관성을 해지게 된다. 자바는 이런 혼란을 방지하기 위해 final 또는 사실상 final만 허용하는 것이다.

➡️ 지역 변수만 제한?

람다에서 제한이 있는 건 오직 메서드 내 지역 변수이고 클래스 필드나 static 변수는 자유롭게 사용 가능하다. 왜 그럴까❓

  • 자바에서 지역변수스택 프레임(stack frame)에 저장된다.
  • 메서드가 끝나면 지역 변수도 사라진다.
  • 근데 람다식은 메서드가 끝나도 람다 객체는 여전히 살아있을 수 있다. 이때 지역 변수의 값이 사라져버리면 참조 문제가 생기니까 아예 복사본으로 캡쳐하도록 설계되어있고 그 복사본이 안전하려면 값이 불변이여야 하는 것이다!!
  • 대신 필드나 static 변수는 객체가 살아 있는 한 유지되기에 제한이 없다.
public class CaptureExample {
    static int staticVar = 10;     // static 변수 (사용 가능)
    int instanceVar = 20;         // 인스턴스 변수 (사용 가능)

    public void doSomething() {
        int localVar = 30;        // 지역 변수 (final처럼 사용해야 함)

        Runnable r = () -> {
            System.out.println(staticVar);    // 가능
            System.out.println(instanceVar);  // 가능
            System.out.println(localVar);     // 가능 (단, 값 변경 X)
        };

        // localVar = 40; // ❌ 오류: 변경하면 effectively final 아님
        r.run();
    }
}

4. 메소드, 생성자 레퍼런스

(1) 메소드 레퍼런스

람다식을 쓰다 보면 어떤 메서드 하나만 호출하는 람다식을 자주 볼 수 있다.
예를 들어 list.forEach(name -> System.out.println(name)); 에서 System.out.println()은 호출하는 람다식이다. 이럴 때 메서드 레퍼런스를 사용하면 더 짧고 깔끔하게 바꿀 수 있다.
list.forEach(System.out::println); 👉 이게 바로 메서드 레퍼런스!

🧩 형식

클래스이름 또는 객체이름 :: 메서드이름

🧩 사용 조건

  • 람다식 안에서 딱 하나의 메서드 호출만 하고 있을 때
  • 메서드 이름만 남겨도 의미가 분명할 때

(2) 생성자 레퍼런스

람다식에서 객체를 생성할 때도 생성자만 호출하고 있다면 메서드 참조처럼 ::new를 사용할 수 있다.

🧩 형식

클래스이름::new

🧩 예시

public class Dog {
    String name;

    public Dog(String name) {
        this.name = name;
    }

    public void bark() {
        System.out.println(name + " 멍멍!");
    }
}
  
Function<String, Dog> dogMaker = name -> new Dog(name);
Dog dog = dogMaker.apply("초코");
dog.bark();  // 출력: 초코 멍멍!

Function<String, Dog>는 이름(String)을 받아 Dog 객체를 만들어주는 함수형 인터페이스이다. Dog::new는 "name"을 받아서 new Dog(name)을 대신하는 문법이다.

0개의 댓글