PHpullh
심층 가이드/Java/람다/메서드

JAVA · 심층 가이드

Java 람다/메서드 완전 정리

람다가 invokedynamic으로 어떻게 연결되는지부터 PECS 와일드카드, 메서드 참조 네 유형, 커링까지 함수형 Java의 문법 기반을 13개 주제로 정리했습니다.

주제 13개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

람다는 익명 클래스의 짧은 표기가 아닙니다. 컴파일러는 람다 본문을 별도 메서드로 만들고 호출 지점에는 invokedynamic을 남겨서, 실제 구현 객체는 실행 시점에 LambdaMetafactory가 만들어 냅니다. 그래서 익명 클래스와 달리 클래스 파일이 매번 생성되지 않고, this도 다르게 동작합니다. 익명 클래스 안의 this는 그 익명 객체를 가리키지만, 람다 안의 this는 람다를 감싼 바깥 인스턴스를 가리킵니다.

람다 & 함수형 인터페이스로 SAM 개념을 잡은 뒤 Predicate / Consumer / Supplier / Function에서 표준 인터페이스의 이름 규칙을 외워 두면 나머지는 조합입니다. 인자가 둘이면 BiFunction과 BinaryOperator로 이어집니다. 그다음 함수형 인터페이스 합성에서 andThencompose의 방향 차이를 확인하고, 커링(Currying)과 부분 적용까지 가면 함수를 값처럼 조립하는 감각이 붙습니다.

성능 쪽에서 잘 놓치는 부분이 박싱입니다. 제네릭 타입 파라미터에는 원시 타입을 넣을 수 없어서 Function<Integer, Integer>는 호출마다 박싱과 언박싱을 겪습니다. 숫자를 대량으로 다루는 경로라면 IntUnaryOperatorIntPredicate 같은 원시 특화 인터페이스를 써야 합니다. 문법 쪽 함정으로는 체크 예외가 있습니다. 표준 함수형 인터페이스는 예외를 선언하지 않아서, 체크 예외를 던지는 코드는 람다 안에서 직접 잡아 언체크로 감싸거나 예외를 선언한 커스텀 인터페이스를 따로 만들어야 합니다.

01람다 &amp; 함수형 인터페이스

Java 8의 람다 표현식과 java.util.function 패키지의 함수형 인터페이스를 마스터합니다.

Java code

import java.util.function.*;

public class Lambdas {
    public static void main(String[] args) {
        // 기본 람다
        Runnable r = () -> System.out.println("실행!");
        r.run();

        // Function<T, R> — T 입력, R 출력
        Function<String, Integer> strLen = String::length;
        Function<Integer, Integer> double_ = n -> n * 2;
        Function<String, Integer> composed = strLen.andThen(double_);
        System.out.println(composed.apply("Hello")); // 10

        // Predicate<T> — boolean 반환
        Predicate<String> notEmpty = s -> !s.isEmpty();
        Predicate<String> longStr  = s -> s.length() > 5;
        Predicate<String> both     = notEmpty.and(longStr);
        System.out.println(both.test("Hello World")); // true

        // Consumer<T> — 반환 없음
        Consumer<String> print = System.out::println;
        Consumer<String> printUpper = s -> System.out.println(s.toUpperCase());
        print.andThen(printUpper).accept("java");

        // Supplier<T> — 인자 없음
        Supplier<String> greeting = () -> "Hello, Java!";
        System.out.println(greeting.get());

        // BiFunction<T, U, R>
        BiFunction<Integer, Integer, Integer> add = Integer::sum;
        System.out.println(add.apply(3, 4)); // 7

        // UnaryOperator, BinaryOperator
        UnaryOperator<Integer>   square = n -> n * n;
        BinaryOperator<Integer> multiply = (a, b) -> a * b;
        System.out.println(square.apply(5));    // 25
        System.out.println(multiply.apply(3,4)); // 12
    }
}
알아두면 좋은 점

Function.andThen()은 왼쪽에서 오른쪽으로, Function.compose()는 오른쪽에서 왼쪽으로 합성합니다. 수학의 함수 합성과 반대 방향인 andThen이 더 직관적입니다.

자주 하는 실수

람다에서 외부 변수를 캡처하려면 해당 변수가 effectively final이어야 합니다. 람다 외부에서 변수를 재할당하면 컴파일 에러가 납니다.

02메서드 참조 &amp; 커스텀 함수형 인터페이스

:: 연산자로 메서드를 람다처럼 사용하고 커스텀 함수형 인터페이스를 정의합니다.

Java code

import java.util.*;
import java.util.function.*;

public class MethodRefs {

    // @FunctionalInterface — 단 하나의 추상 메서드
    @FunctionalInterface
    interface Transformer<T> {
        T transform(T input);
        default Transformer<T> andThen(Transformer<T> next) {
            return input -> next.transform(this.transform(input));
        }
    }

    @FunctionalInterface
    interface ThrowingSupplier<T> {
        T get() throws Exception;

        static <T> Supplier<T> wrap(ThrowingSupplier<T> s) {
            return () -> {
                try { return s.get(); }
                catch (Exception e) { throw new RuntimeException(e); }
            };
        }
    }

    static int doubleIt(int n) { return n * 2; }

    public static void main(String[] args) {
        var nums = List.of(1, 2, 3, 4, 5);

        // 정적 메서드 참조
        Function<Integer, Integer> dbl = MethodRefs::doubleIt;

        // 인스턴스 메서드 참조 (특정 객체)
        String prefix = "Hello";
        Predicate<String> startsWith = prefix::startsWith;

        // 인스턴스 메서드 참조 (임의 객체)
        Function<String, String> toUpper = String::toUpperCase;
        Function<String, Integer> length = String::length;

        // 생성자 참조
        Function<String, StringBuilder> sbFactory = StringBuilder::new;
        BiFunction<Integer, Integer, int[]> arrFactory = int[]::new;

        // 커스텀 함수형 인터페이스 사용
        Transformer<String> trim  = String::trim;
        Transformer<String> upper = String::toUpperCase;
        Transformer<String> pipeline = trim.andThen(upper);
        System.out.println(pipeline.transform("  hello  ")); // HELLO

        // ThrowingSupplier — checked exception 래핑
        Supplier<Properties> props = ThrowingSupplier.wrap(() -> {
            var p = new Properties();
            p.setProperty("key", "value");
            return p;
        });
        System.out.println(props.get().getProperty("key"));

        // 실용 예시
        nums.stream()
            .map(MethodRefs::doubleIt)
            .map(String::valueOf)
            .forEach(System.out::println);
    }
}
알아두면 좋은 점

::로 참조하는 메서드의 시그니처가 함수형 인터페이스와 호환되어야 합니다. 컴파일러가 자동으로 매핑합니다.

자주 하는 실수

인스턴스 메서드 참조(String::length)와 특정 객체 메서드 참조(str::length)는 함수 타입이 다릅니다. 전자는 Function, 후자는 Supplier입니다.

03Unnamed Patterns (Java 21)

_ 와일드카드 패턴으로 패턴 매칭 간결화

Java code

<span class="cm">// Unnamed Patterns (Java 21) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Unnamed Patterns (Java 21)") }
알아두면 좋은 점

JAVA 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04제네릭 메서드

타입 파라미터를 메서드 레벨에서 선언하여 재사용 가능한 유틸리티를 만듭니다.

Java code

import java.util.*;

public class GenericMethods {
    // 제네릭 메서드 — 반환 타입 앞에 <T> 선언
    static <T> List<T> listOf(T... items) {
        return new ArrayList<>(Arrays.asList(items));
    }

    // Bounded 타입 파라미터
    static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    // 여러 타입 파라미터
    static <K, V> Map<K, V> mapOf(K key, V value) {
        Map<K, V> map = new HashMap<>();
        map.put(key, value);
        return map;
    }

    public static void main(String[] args) {
        List<String> names = listOf("Alice", "Bob");
        int bigger = max(10, 20);             // 타입 추론
        String later = max("apple", "banana");
        Map<String, Integer> m = mapOf("age", 30);
        System.out.printf("%s, %d, %s, %s%n", names, bigger, later, m);
    }
}
알아두면 좋은 점

대부분의 경우 타입 인자를 명시하지 않아도 컴파일러가 추론합니다. 추론 실패 시에만 ClassName.<Type>method()로 명시하세요.

자주 하는 실수

static void sort(T[] arr)에서 TComparable 바운드가 없으면 비교할 수 없습니다.

05와일드카드 심화 (PECS)

? extends T? super T의 차이, PECS 원칙을 마스터합니다.

Java code

import java.util.*;

public class WildcardPECS {
    // Producer Extends — 데이터를 꺼내기만
    static double sum(List<? extends Number> list) {
        double total = 0;
        for (Number n : list) {
            total += n.doubleValue();
        }
        return total;
    }

    // Consumer Super — 데이터를 넣기만
    static void fill(List<? super Integer> list, int count) {
        for (int i = 0; i < count; i++) {
            list.add(i);
        }
    }

    // 복합: 소스에서 읽고 대상에 쓰기
    static <T> void copy(List<? extends T> src,
                         List<? super T> dest) {
        for (T item : src) {
            dest.add(item);
        }
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(1.1, 2.2);
        System.out.println(sum(ints));    // 6.0
        System.out.println(sum(doubles)); // 3.3

        List<Number> nums = new ArrayList<>();
        fill(nums, 5);
        copy(ints, nums);
        System.out.println(nums);
    }
}
알아두면 좋은 점

PECS: Producer-Extends, Consumer-Super. 데이터를 읽으면 extends, 쓰면 super를 사용합니다.

자주 하는 실수

List에 원소를 추가할 수 없습니다. 컴파일러가 정확한 타입을 모르기 때문입니다.

06타입 이레이저(Type Erasure)

컴파일 후 제네릭 타입 정보가 소거되는 원리와 그 영향을 이해합니다.

Java code

import java.util.*;
import java.lang.reflect.*;

public class TypeErasure {
    // 컴파일 후: List list (타입 정보 소거)
    static <T> void printList(List<T> list) {
        for (T item : list) {
            System.out.println(item);
        }
    }

    // 타입 이레이저 때문에 오버로드 불가
    // void process(List<String> list) {}
    // void process(List<Integer> list) {} // 컴파일 에러!

    // 우회: 타입 토큰 패턴
    static <T> List<T> createList(Class<T> type, int size) {
        List<T> list = new ArrayList<>();
        System.out.println("타입: " + type.getName());
        return list;
    }

    // 리플렉션으로 제네릭 타입 정보 확인 (필드/파라미터)
    List<String> stringField;

    public static void main(String[] args) throws Exception {
        // 런타임에 제네릭 타입 확인 불가
        List<String> strs = new ArrayList<>();
        List<Integer> nums = new ArrayList<>();
        System.out.println(strs.getClass() == nums.getClass()); // true!

        // 필드의 제네릭 타입은 리플렉션으로 확인 가능
        Field f = TypeErasure.class.getDeclaredField("stringField");
        ParameterizedType pt = (ParameterizedType) f.getGenericType();
        System.out.println(pt.getActualTypeArguments()[0]); // String
    }
}
알아두면 좋은 점

타입 토큰(Class<T>)이나 Super Type Token 패턴으로 런타임에 타입 정보를 전달할 수 있습니다.

자주 하는 실수

new T(), new T[], instanceof T는 타입 이레이저 때문에 불가능합니다. Class<T>를 전달받아 사용하세요.

07함수형 인터페이스 합성

Function, Predicate, Consumer의 합성 메서드를 활용합니다.

Java code

import java.util.function.*;

public class FunctionComposition {
    public static void main(String[] args) {
        // Function 합성
        Function<String, String> trim = String::trim;
        Function<String, String> upper = String::toUpperCase;
        Function<String, Integer> length = String::length;

        // andThen: trim -> upper -> length
        Function<String, Integer> pipeline =
            trim.andThen(upper).andThen(length);
        System.out.println(pipeline.apply("  hello  ")); // 5

        // compose: length(upper(trim(x))) — 역순
        Function<String, String> step = upper.compose(trim);
        System.out.println(step.apply("  hello  ")); // HELLO

        // Predicate 합성
        Predicate<String> notEmpty = s -> !s.isEmpty();
        Predicate<String> isShort = s -> s.length() < 10;
        Predicate<String> valid = notEmpty.and(isShort);
        System.out.println(valid.test("hi"));   // true
        System.out.println(valid.test(""));     // false

        // Consumer 합성
        Consumer<String> print = System.out::println;
        Consumer<String> log = s -> System.out.println("[LOG] " + s);
        Consumer<String> both = print.andThen(log);
        both.accept("테스트");
    }
}
알아두면 좋은 점

andThen은 왼쪽→오른쪽 순서, compose는 오른쪽→왼쪽 순서입니다. 파이프라인에는 andThen이 직관적입니다.

자주 하는 실수

composeandThen의 실행 순서를 혼동하면 예상과 다른 결과가 나옵니다. 항상 순서를 확인하세요.

08Predicate / Consumer / Supplier / Function

Java 표준 함수형 인터페이스 4가지의 시그니처와 활용법입니다.

Java code

import java.util.*;
import java.util.function.*;

public class StdFunctional {
    public static void main(String[] args) {
        // Supplier<T> — 인자 없이 T 반환
        Supplier<List<String>> listFactory = ArrayList::new;
        List<String> list = listFactory.get();

        // Consumer<T> — T를 받고 반환 없음
        Consumer<String> printer = System.out::println;
        printer.accept("Hello Consumer");

        // Predicate<T> — T를 받고 boolean 반환
        Predicate<Integer> isPositive = n -> n > 0;
        System.out.println(isPositive.test(5));  // true

        // Function<T, R> — T를 받고 R 반환
        Function<String, Integer> toLength = String::length;
        System.out.println(toLength.apply("Java")); // 4

        // 실전 활용: 필터 + 변환 + 소비
        List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");
        Predicate<String> longName = s -> s.length() > 3;
        Function<String, String> toUpper = String::toUpperCase;

        names.stream()
            .filter(longName)
            .map(toUpper)
            .forEach(printer);
    }
}
알아두면 좋은 점

표준 함수형 인터페이스를 사용하면 람다 호환성이 보장됩니다. 커스텀 인터페이스보다 표준 인터페이스를 우선하세요.

자주 하는 실수

Function<Integer, Integer>보다 UnaryOperator<Integer>가 적합합니다. 입출력 타입이 같으면 특화 인터페이스를 사용하세요.

09BiFunction과 BinaryOperator

두 개의 인자를 받는 함수형 인터페이스를 활용합니다.

Java code

import java.util.*;
import java.util.function.*;

public class BiFunctionDemo {
    public static void main(String[] args) {
        // BiFunction<T, U, R> — 2개 인자, 1개 반환
        BiFunction<String, Integer, String> repeat =
            (s, n) -> s.repeat(n);
        System.out.println(repeat.apply("Ha", 3)); // HaHaHa

        // BinaryOperator<T> — 같은 타입 2개 -> 같은 타입
        BinaryOperator<Integer> max = Integer::max;
        System.out.println(max.apply(10, 20)); // 20

        // Map.merge에서 BinaryOperator 사용
        Map<String, Integer> scores = new HashMap<>();
        scores.put("Alice", 80);
        scores.merge("Alice", 90, Integer::max);
        System.out.println(scores.get("Alice")); // 90

        // Map.compute
        Map<String, List<String>> groups = new HashMap<>();
        BiFunction<String, List<String>, List<String>> append =
            (key, val) -> {
                if (val == null) val = new ArrayList<>();
                val.add("new-" + key);
                return val;
            };
        groups.compute("A", append);
        groups.compute("A", append);
        System.out.println(groups); // {A=[new-A, new-A]}
    }
}
알아두면 좋은 점

Map.merge(), Map.compute()에서 BiFunction을 활용하면 null 처리 로직을 깔끔하게 작성할 수 있습니다.

자주 하는 실수

BiFunction에는 compose()가 없고 andThen()만 있습니다. 합성 시 주의하세요.

10UnaryOperator와 특화 함수형 인터페이스

UnaryOperator, IntFunction 등 특화 인터페이스로 오토박싱을 피합니다.

Java code

import java.util.*;
import java.util.function.*;

public class SpecializedFunctions {
    public static void main(String[] args) {
        // UnaryOperator<T> — 같은 타입 변환
        UnaryOperator<String> exclaim = s -> s + "!";
        System.out.println(exclaim.apply("Hello")); // Hello!

        // List.replaceAll에서 활용
        List<String> words = new ArrayList<>(
            List.of("hello", "world"));
        words.replaceAll(String::toUpperCase);
        System.out.println(words); // [HELLO, WORLD]

        // 원시 타입 특화 — 오토박싱 방지
        IntUnaryOperator doubleIt = n -> n * 2;
        IntPredicate isEven = n -> n % 2 == 0;
        IntConsumer printInt = System.out::println;
        IntSupplier randomInt = () -> (int)(Math.random() * 100);

        // IntStream과 함께
        java.util.stream.IntStream.range(1, 6)
            .map(doubleIt)
            .filter(isEven)
            .forEach(printInt);

        // ToIntFunction — 객체를 int로 변환
        ToIntFunction<String> strLen = String::length;
        System.out.println(strLen.applyAsInt("Java")); // 4
    }
}
알아두면 좋은 점

원시 타입 스트림(IntStream 등)에서는 반드시 원시 타입 특화 함수형 인터페이스를 사용하세요. 오토박싱 비용을 제거합니다.

자주 하는 실수

Function<Integer, Integer> 대신 IntUnaryOperator를 써야 오토박싱이 발생하지 않습니다.

11메서드 참조 패턴 활용

실전에서 메서드 참조를 효과적으로 활용하는 패턴을 배웁니다.

Java code

import java.util.*;
import java.util.stream.*;

public class MethodRefPatterns {
    record Person(String name, int age) {}

    // 정적 팩토리 메서드
    static Person create(String name) {
        return new Person(name, 0);
    }

    // 인스턴스 메서드
    boolean isAdult(Person p) {
        return p.age() >= 18;
    }

    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie");

        // 생성자 참조로 객체 생성
        List<Person> people = names.stream()
            .map(MethodRefPatterns::create)
            .toList();

        // Getter를 키 추출기로
        Map<String, Person> byName = people.stream()
            .collect(Collectors.toMap(Person::name, p -> p));

        // 메서드 참조로 비교기 생성
        List<Person> sorted = List.of(
            new Person("Bob", 25),
            new Person("Alice", 30)
        ).stream()
            .sorted(Comparator.comparing(Person::name))
            .toList();

        // 체이닝: 이름순 -> 나이 역순
        Comparator<Person> comp = Comparator
            .comparing(Person::name)
            .thenComparing(Comparator.comparingInt(Person::age).reversed());

        System.out.println(sorted);
    }
}
알아두면 좋은 점

Comparator.comparing()과 메서드 참조를 조합하면 복잡한 정렬 로직을 선언적으로 표현할 수 있습니다.

자주 하는 실수

Comparator.comparing(Person::age)는 박싱이 발생합니다. Comparator.comparingInt(Person::age)를 사용하세요.

12SAM 변환과 함수형 프로그래밍

Single Abstract Method 인터페이스와 람다 변환 원리를 이해합니다.

Java code

import java.util.concurrent.*;

public class SAMConversion {
    // SAM 인터페이스 — 추상 메서드가 정확히 1개
    @FunctionalInterface
    interface Transformer<T> {
        T transform(T input);
    }

    // SAM 변환: 람다 -> 인터페이스 인스턴스
    static <T> T apply(T value, Transformer<T> t) {
        return t.transform(value);
    }

    public static void main(String[] args) {
        // 람다로 SAM 변환
        String result = apply("hello", String::toUpperCase);
        System.out.println(result); // HELLO

        // Runnable — SAM 인터페이스
        Runnable task = () -> System.out.println("실행");
        new Thread(task).start();

        // Callable — SAM 인터페이스
        Callable<Integer> calc = () -> 42;

        // Comparator — SAM 인터페이스 (equals 제외)
        java.util.List<String> list = new java.util.ArrayList<>(
            java.util.List.of("banana", "apple", "cherry"));
        list.sort(String::compareTo);
        System.out.println(list);

        // 기존 익명 클래스 -> 람다 변환
        // 기존: new Runnable() { public void run() { ... } }
        // 변환: () -> { ... }
    }
}
알아두면 좋은 점

SAM 변환은 @FunctionalInterface 어노테이션 없이도 동작합니다. 다만 어노테이션을 붙이면 컴파일러가 검증해줍니다.

자주 하는 실수

추상 메서드가 2개 이상인 인터페이스에는 람다를 사용할 수 없습니다. 익명 클래스를 사용해야 합니다.

13커링(Currying)과 부분 적용

다중 인자 함수를 단일 인자 함수 체인으로 변환하는 커링 패턴입니다.

Java code

import java.util.function.*;

public class Currying {
    // 커링: (a, b) -> r 을 a -> (b -> r)로 변환
    static <A, B, R> Function<A, Function<B, R>> curry(
            BiFunction<A, B, R> f) {
        return a -> b -> f.apply(a, b);
    }

    // 부분 적용: 첫 번째 인자 고정
    static <A, B, R> Function<B, R> partial(
            BiFunction<A, B, R> f, A a) {
        return b -> f.apply(a, b);
    }

    public static void main(String[] args) {
        // 일반 BiFunction
        BiFunction<String, String, String> greet =
            (greeting, name) -> greeting + ", " + name + "!";

        // 커링
        Function<String, Function<String, String>> curriedGreet =
            curry(greet);
        Function<String, String> sayHello = curriedGreet.apply("Hello");
        System.out.println(sayHello.apply("Alice")); // Hello, Alice!
        System.out.println(sayHello.apply("Bob"));   // Hello, Bob!

        // 부분 적용
        Function<String, String> hiGreet = partial(greet, "Hi");
        System.out.println(hiGreet.apply("Charlie")); // Hi, Charlie!

        // 수학 예시
        BiFunction<Double, Double, Double> power = Math::pow;
        Function<Double, Double> square = partial(power, 2.0);
        Function<Double, Double> cube = partial(power, 3.0);
        // 주의: Math.pow(base, exp)이므로 partial은 base를 고정
    }
}
알아두면 좋은 점

커링은 설정 패턴에 유용합니다. 데이터베이스 연결이나 로거 등 공통 인자를 먼저 바인딩하면 코드가 깔끔해집니다.

자주 하는 실수

Java의 타입 시스템이 복잡해서 3개 이상의 인자 커링은 가독성이 떨어집니다. 2개까지만 사용하고 그 이상은 객체로 묶으세요.

정리하며

  • 람다 안의 this는 익명 클래스와 달리 바깥 인스턴스를 가리킵니다
  • 숫자 처리 경로에서는 IntPredicate 같은 원시 특화 인터페이스로 박싱 비용을 없앱니다
  • 체크 예외를 던지는 코드는 람다 안에서 감싸거나 예외를 선언한 커스텀 인터페이스를 씁니다
  • andThen은 뒤에 이어 붙이고 compose는 앞에 끼워 넣는다는 방향만 기억하면 합성이 쉬워집니다

더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.