본문 바로가기
자바

[모던 자바 인 액션] 람다 표현식

by __Minnie_ 2025. 4. 17.
람다란 무엇인가?

 

람다 표현식은 메서드로 전달할 수 있는 익명함수를 단순화한 것을 말한다. 람다의 특징으로는 익명, 함수, 전달, 간결성이 있다. 보통의 메서드와는 달리 익명이고, 메서드와 달리 클래스에 소속되지 않으며, 람다 자체를 메서드의 인수로 전달하거나 변수로 관리할 있고, 익명 클래스보다 더 간결하다.

 

# 람다를 사용하지 않은 코드
Comparator<Apple> byWeight = new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
    	return a1.getWeight().compareTo(a2.getWeight());
    }
};

# 람다를 사용한 코드
Comparator<Apple> byWeight = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

 

위처럼 람다를 사용한 경우 코드가 훨씬 간결해지는 것을 볼 수 있다. 람다는 위 코드에서 볼 수 있듯 (Apple a1, Apple a2)의 파라미터 리스트와 화살표 바디 부분으로 나눌 수 있다. 

 

(paramters) -> expression의 표현식 스타일이 있고, (parameters) -> {statements;} 블록스타일이 있다. 표현식의 경우 return이 함축되어 있기 때문에 명시적으로 사용하지 않아도 된다.

 

# 람다 사용 예시

(String s) -> s.length()

() -> {} 

() -> 42

(int x, int y) -> {
    System.out.printIn(x);
    System.out.printIn(y);
}

# 틀린 표현. 블록 스타일에서는 return을 명시해주어야 한다.
() -> {42}

 

 

함수형 인터페이스

 

함수형 인터페이스란 predicate처럼 정확히 하나의 추상 메서드를 지정하는 인터페이스를 의미한다. (디폴트 메서드가 아무리 많이 있더라도, 추상 메서드가 오직 하나라면 함수형 인터페이스)

public interface Predicate<T> {
	boolean test(T t);
}

 

람다를 사용하면 아래 코드처럼 람다 표현식 자체가 함수형 인터페이스의 인스턴스로 취급된다. (정확하게는 함수형 인터페이스를 구현한 클래스의 인스턴스)

Predicate<Apple> predicate = (Apple apple) -> apple.getColor.equals(GREEN);

 

 

함수 디스크립터(function descriptor)

 

 

함수 디스크립터는 람다 표현식의 시그니처를 서술하는 메서드를 말한다. (함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 의미)

 

여기서 중요한 것은 람다 표현식을 함수형 인터페이스를 인수로 하는 메서드에 인수로 전달할 수 있고, 람다 표현식의 시그니처와 함수형 인터페이스의 추상 메서드 시그니처는 동일하다는 것이다. 

 

함수형 인터페이스  함수 디스크립터
Predicate<T> T -> boolean
Consumer<T> T -> void
Function<T, R> T -> R
Supplier<T> () -> T

 

참고! 함수형 인터페이스에는 @FuntionalInterface어노테이션이 붙어있다. 이 어노테이션이 붙어 있는데, 추상 메소드가 1개아 아닌 경우 에러가 발생한다.

 

실행 어라운드 패턴

 

우리가 지금까지 배운 람다와 동작 파라미터화를 사용하기 아주 좋은 예제가 있는데, 그것이 바로 실행 어라운드 패턴이다. 실행 어라운드 패턴은 실제 작업 코드 전에 동일한 전처리, 코드 후에 동일한 후처리가 있는 패턴을 말한다. 

 

public interface BufferedReaderProcessor {
	String process(BufferedReader b) throws IOException);
}

public String processFile(BufferedReaderProcessor p) throws IOException {
	try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    	return p.process(br);
    }
}

String oneline = processFile((BufferedReader br) -> br.readline());
String twoline = processFile((BufferedReader br) -> br.readline() + br.readline());

 

위 bufferedReader를 인자로 받아서 String을 반환하는 함수형 인터페이스와 람다를 활용하면 한줄을 읽을 것인지, 두줄을 읽을 것인지 사용자의 요구가 바뀌어도 유연하게 대응할 수 있는 코드를 완성할 수 있다. 

 

 

자바에서 기본으로 제공되는 함수형 인터페이스

 

1. Pedicate

@FuncationalInterface
public interface Predicate<T> {
	boolean test(T t);
}

public <T> List<T> filter(List<T> list, Predicate<T> p) {
	List<T> results = new ArrayList<>();
    for (T t: list) {
    	if (p.test(t)) {
        	results.add(t);
        }
    }
    
    return results;
}

filter(listOfStrings, (String s) -> !s.isEmpty();

 

 

2. Consumer

@FuncationalInterface
public interface Consumer<T> {
	void accept(T t);
}

public <T> void forEach(List<T> list, Consumer<T> c) {
	for (T t: list) {
    	c.accept(t);
    }
}

foreach(Arrays.asList(1, 2, 3, 4), (Integer i) -> System.out.printIn(i));

 

 

3. Funtion

@FuncationalInterface
public interface Function<T, R> {
	R apply(T t);
}

public <T, R> List<R> map(List<T> list, Function<T, R> f) {
	List<R> result = new ArrayList<>();
	for (T t: list) {
    	result.add(f.apply(t));
    }
    return result;
}

map(ArrayLists.asList("a", "b", "c"), (String s) -> s.length());

 

 

위 예제들을 보면 모두 제네릭을 사용하고 있는 것을 볼 수 있다. 제네릭의 경우, 원시 타입은 사용하지 못하고 참조타입만을 사용할 수 있다. 그래서 자바에서는 박싱과 언박싱을 자동으로 제공한다. 예를 들어 int -> Integer로 변환하는 박싱, Integer -> int로 변환하는 언박싱이 있다. 그래서 자바는 아래 예제처럼 기본형 특화 인터페이스를 따로 제공한다.

 

public interface IntPredicate {
	boolean test(int i);
}

 

IntPredicate외에도 다양한 기본형에 대한 특화 인터페이스가 존재한다. (IntConsumer, DoublePredicate ...) 

 

 

람다의 형식 검사

 

람다 표현식을 사용하게 되면 내부적으로 형식 검사라른 것을 진행하게 된다. 람다가 사용된는 콘텍스트를 보면 람다의 형식을 추론할 수 있는데, 콘텍스트를 통해서 유추되는 람다 표현식의 형식을 대상형식이라고 한다. 람다 표현식은 해당 대상형식을 지켜야 하고, 이를 형식 검사라고 한다. 

 

@FuncationalInterface
public interface Predicate<T> {
	boolean test(T t);
}

public <T> List<T> filter(List<T> list, Predicate<T> p) {
	List<T> results = new ArrayList<>();
    for (T t: list) {
    	if (p.test(t)) {
        	results.add(t);
        }
    }
    
    return results;
}

filter(listOfStrings, (String s) -> !s.isEmpty();

 

만약 위 같은 코드가 있다고 하자, filter(listOfStrings, (String s) -> !s.isEmpty();를 보면 람다가 사용되었으니, 먼저 콘텍스를 통해서 대상 형식을 확인하게 된다. filter메소드를 보면 Predicate<T>를 인자로 받는 것을 확인할 수 있고, 우리는 이를 통해 Predicate<T>가 대상형식임을 알 수 있다. 대상 형식의 함수 디스크립터를 보면 T -> Boolean인 것을 확인할 수 있고, 람다 역시 함수 디스크립터가 동일한 것을 확인할 수 있다. 따라서 위 코드는 유효한 람다 표현식이라고 볼 수 있다.

 

우리는 이러한 특징 덕분에 아래 예제처럼 동일한 람다 표현식을 다른 함수형 인터페이스에 사용할 수 있다. 

Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

Function<Apple, Apple> c2 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

 

 

 

 

형식추론

 

자바는 람다 표현식이 사용된 대상 형식을 통해서 함수형 인터페이스를 추론한다. 이를 통해서 자바는 람다 표현식의 파라미터 형식을 추론할 수 있기 때문에 우리는 이를 생략할 수 있다. 즉, 아래처럼 람다 표현식의 파라미터에서 Apple이라는 타입을 생략할 수 있다는 것이다. 

Predicate<Apple> predicate = (Apple apple) -> apple.getColor.equals(GREEN);
Predicate<Apple> predicate = (apple) -> apple.getColor.equals(GREEN);

 

 

지역변수

 

람다 표현식에서는 자유변수(람다 외부에서 선언된 변수)를 사용할 수 있다. (이를 람다 캡쳐링이라고 부름) 단, 모든 자유변수에서 가능한 것은 아니고 인스턴스 변수, 정적 변수에서 가능하며, 지역변수는 final선언이 되었거나, final은 아니지만 사실상 final처럼 사용되는 변수에서만 가능하다.

 

이는 람다가 자유변수를 사용하는 방식 때문인데, 람다에서는 값을 복사하는 방식으로 자유변수를 사용하기 때문에 지역변수에 final이어야 한다는 조건이 붙는 것이다. 

 

 

 

메소드 참조

 

메소드 참조란 기존의 메서드를 활용해서 람다처럼 활용하는 것을 의미하며, 메서드 명 앞에 ::를 붙여서 사용한다. 예를 들어서 Apple::getWeight() 처럼 사용한다. 

 

메소드 참조를 하는 방법에는 1. 정적 메소드 참조 2. 인스턴스 메서드 참조 3. 기존 객체의 인스턴스 메서드 참조가 있다.

 

아래코드에서 case1은 1번 방식을 case2는 3번 방식을 사용한 것이다. 아래 코드처럼 메소드 참조를 사용하면 코드도 간결해지고 코드를 직관적으로 이해할 수 있게 된다. 

# case1
Function<String> stringToInt = (String s) -> Integer.parseInt(s);

Function<String> stringToInt = Integer::parseInt(s);


# case 2
Predicate<String> startsWithNumber = (String string) -> this.startsWithNumber(string);

Predicate<String> startsWithNumber = this::startsWithNumber;

 

 

 

생성자 참조

 

 

우리는 ClassName::new처럼 클래스명과 new라는 키워드를 통해서 생성자 참조를 만들 수 있다. 단, 생성자의 함수 디스크립터와 함수형 인스턴스의 함수 디스크립터는 동일해야 한다.

 

# case 1 (이 경우 Apple에 인수가 없는 생성자가 있어야 한다.)
Supploer<Apple> c1 = Apple::new;
Apple a1 = c1.get();

# case 2 (이 경우 Apple에 Integer를 인수로 갖는 생성자가 있어야 한다.)
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(100);

 

 

만약에 자바에서 제공되는 함수형 인스턴스 중에서 내가 원하는 함수 디스크립터가 없는 경우에는 아래처럼 우리가 직접 함수형 인터페이스를 만들어서 사용할 수 있다. 

public interface TriFunction<T, U, V, R> {
	R apply(T t, U u, V v);
}

 

 

우리가 지금까지 배운 동작 파라미터화와 람다를 활용해서 사과의 무게를 통해서 정렬하려면 어떻게 구현할 수 있을까?

void sort(Comparator<? super E> c)

자바에서 기본으로 제공하는 위 sort 메소드를 사용한다고 해보자.

 

우리는 기본적인 동작 파라미터화와 람다를 사용하면 아래처럼 작성할 수 있을 것이다.

inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight)));

 

 

여기서 우리가 Comparator에서 제공하는 Comparable 키를 추출해서 Comparator를 반환하는 정적 메서드 comparing을 사용하면 아래처럼 작성할 수 있다. 

inventory.sort(Comparator.comparing((a) -> a1.getWeight());

 

 

여기에 메서드 참조를 사용하면 아래처럼 간단한 코드로 바꿀 수 있다. 

inventory.sort(Comparator.comparing(Apple::getWeight));

 

 

 

람다 표현식을 조합할 수 있는 유용한 메서드

 

 

자바에서는 일부 함수형 인터페이스에 람다 표현식을 조합할 수 있는 유틸리티 메서드를 제공한다. (유틸리티 메서드는 디폴트 메서드 형태로 제공된다. 앞에서 언급한 것처럼 함수형 인터페이스 조건에 디폴트 메서드는 영향을 주지 않기 때문이다.)

 

1. Comparator 조합 메서드

우리가 만약 사과의 무게를 역순으로 정렬하고 싶다면 어떻게 해야 할까? 조건을 바꿔서 다시  Comparator를 작성하는 방법도 있겠지만, reverse라는 디폴트 메서드를 사용하면 편하게 변경할 수 있다.

inventory.sort(Comparator.comparing(Apple::getWeight));

inventory.sort(Comparator.comparing(Apple::getWeight).reversed());

 

 

두번째 유용한 메서드는 연결 메소드이다. 무게로 첫번째 비교를 한 후 두번째 비교자를 전달할 수 있다. 

inventory.sort(Comparator.comparing(Apple::getWeight));

inventory.sort(Comparator.comparing(Apple::getWeight).thenComparing(Apple::getCountry));

 

 

 

2. Predicate 조합 메서드

우리가 만약 빨간색이 아닌 사과를 추려야 한다면 어떻게 해야 할까?

 

아래코드처럼 negate메소드를 활용해서 쉽게 생성할 수 있다.

Predicate<Apple> redApple = (a) -> a.getColor().equals(RED);

Predicate<Apple> notRedApple = redApple.negate();

 

이외에도 and, or로 필터링 조건을 추가할 수 있다. 

# and 조건 (빨간색이면서 150이상인 사과)
Predicate<Apple> redAndHeavyApple = redApple.and(a -> a.getWeight > 150);

# or 조건(빨간색이거나 150이상인 사과)
Predicate<Apple> redOrHeavyApple = redApple.or(a -> a.getWeight > 150);

 

and, or을 여러번 연결해서 사용할 수 있는데, 만약 a.or(b).and(c)처럼 사용한다면  (a | |b) && c와 동일하다.

 

 

3. Function

 

function은 andThen, compose두가지 메소드를 제공한다. andThen은 이름에서부터 알 수 있듯이 f.andThen(g)라고 작성하면 f를 실행한 결과가 g의 인수로 제공되는 것이다. 반대로 f.compose(g)는 g를 실행한 결과가 f의 인수로 제공된다. 즉 f.andThen(g)는 g(f(x)) 가 되고 f.compose(g)는 f(g(x))가 된다.

 

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;

Function<Integer, Integer> h1 = f.andThen(g);
Function<Integer, Integer> h2 = f.compose(g);

int result = h1.apply(1); # 4
int result = h2.apply(1); # 3