
Spring을 공부하다 보면 다음과 같은 코드를 자주 만나게 된다.
assertThrows(NoUniqueBeanDefinitionException.class,
() -> ac.getBean(DiscountPolicy.class));
코드의 전체적인 의미는 어느 정도 이해가 되는데, 중간에 등장하는 () ->가 눈에 들어왔다.
() -> ac.getBean(DiscountPolicy.class)
도대체 () ->는 무엇이고, 왜 이렇게 사용하는 걸까?
이 문법이 바로 자바의 람다식(Lambda Expression)이다.
Spring을 공부하다 보면 테스트 코드뿐만 아니라 다양한 곳에서 람다식을 마주치게 된다. 단순히 문법을 외우고 넘어가기보다는, 람다식이 무엇인지, 왜 등장했는지, 기존 코드와 비교했을 때 어떤 장점이 있는지 이해해 두면 앞으로 코드를 읽는 데 큰 도움이 될 것 같다.
이번 글에서는 자바의 람다식이 무엇인지부터 실제 코드에서 어떻게 사용되는지까지 하나씩 공부해보고자 한다.
람다식(Lambda Expression)은 간단하게 말하면 메서드의 동작을 하나의 식으로 간결하게 표현하는 문법이다.
예를 들어 다음과 같은 메서드가 있다고 해보자.
public int add(int a, int b) {
return a + b;
}
이 메서드의 핵심 동작은 결국 두 숫자를 더해서 반환하는 것이다.
람다식을 사용하면 이러한 동작을 다음과 같이 간결하게 표현할 수 있다.
(a, b) -> a + b
구조를 나누어 보면 다음과 같다.
(a, b) -> a + b
↑ ↑
매개변수 실행할 코드
->를 기준으로
가 위치한다.
즉,
(a, b) -> a + b
는
a와b를 받아서a + b를 수행한다.
라는 동작 자체를 표현한 것이라고 이해할 수 있다.
여기서 한 가지 의문이 생긴다.
그냥 메서드를 만들어서 사용하면 되는 것 아닌가?
맞다. 람다식 없이도 프로그램을 작성할 수 있다.
하지만 Java에서는 특정 상황에서 "어떤 동작을 전달해야 하는 경우"가 자주 발생한다.
예를 들어 다음과 같은 상황이다.
Java에서는 과거에 이러한 동작을 전달하기 위해 주로 익명 클래스(Anonymous Class)를 사용했다.
예를 들어 Runnable을 사용한다고 해보자.
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
우리가 정말 전달하고 싶은 동작은 사실 이것뿐이다.
System.out.println("Hello");
하지만 이 한 줄을 실행하기 위해 상당히 많은 코드를 작성해야 한다.
Java 8부터 도입된 람다식을 사용하면 이를 다음과 같이 줄일 수 있다.
Runnable runnable = () -> System.out.println("Hello");
훨씬 간결해졌다.
결국 람다식의 중요한 목적 중 하나는 하나의 동작을 간결하게 표현하고 전달하는 것이다.
람다식의 기본적인 형태는 다음과 같다.
(매개변수) -> 실행문
예를 들어 매개변수가 없는 경우에는 다음과 같이 작성할 수 있다.
() -> System.out.println("Hello");
매개변수가 하나라면 다음과 같이 작성할 수 있다.
(x) -> x * 2
매개변수가 여러 개라면 다음과 같다.
(a, b) -> a + b
실행해야 하는 코드가 여러 줄이라면 { }를 사용할 수도 있다.
(a, b) -> {
int result = a + b;
return result;
}
한 줄로 표현할 수 있는 경우에는 훨씬 간단하게 작성할 수 있다.
(a, b) -> a + b
여기서 중요한 점이 하나 있다.
람다식은 단독으로 아무 곳에서나 사용할 수 있는 것이 아니다.
Java의 람다식은 기본적으로 함수형 인터페이스(Functional Interface)를 구현하는 방식으로 사용된다.
함수형 인터페이스란 추상 메서드를 하나만 가지고 있는 인터페이스를 말한다.
예를 들어 다음과 같은 인터페이스가 있다고 해보자.
interface Calculator {
int calculate(int a, int b);
}
Calculator에는 추상 메서드가 하나만 존재한다.
int calculate(int a, int b);
따라서 람다식으로 구현할 수 있다.
Calculator calculator = (a, b) -> a + b;
그리고 다음과 같이 사용할 수 있다.
int result = calculator.calculate(10, 20);
System.out.println(result);
결과는 다음과 같다.
30
사실 람다식은 완전히 새로운 종류의 함수가 생긴 것이 아니라, 함수형 인터페이스의 추상 메서드를 간결하게 구현하는 문법이라고 이해하는 것이 더 정확하다.
앞에서 만든 Calculator를 기존 방식으로 구현하면 다음과 같다.
Calculator calculator = new Calculator() {
@Override
public int calculate(int a, int b) {
return a + b;
}
};
익명 클래스를 사용했기 때문에 코드가 상당히 길다.
하지만 람다식을 사용하면 다음과 같이 작성할 수 있다.
Calculator calculator = (a, b) -> a + b;
두 코드는 Calculator가 요구하는 calculate() 동작을 제공한다는 점에서는 같은 목적을 가진다.
// 익명 클래스
Calculator calculator = new Calculator() {
@Override
public int calculate(int a, int b) {
return a + b;
}
};
// 람다식
Calculator calculator = (a, b) -> a + b;
람다식을 사용하면 불필요한 코드가 사라지고 실제로 어떤 동작을 수행하는지가 더욱 잘 보인다.
앞에서 람다식은 함수형 인터페이스에서 사용할 수 있다고 했다.
함수형 인터페이스는 추상 메서드가 하나만 존재하는 인터페이스다.
Java에서는 함수형 인터페이스임을 명확하게 표현하기 위해 @FunctionalInterface를 사용할 수도 있다.
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
}
@FunctionalInterface는 반드시 작성해야 하는 것은 아니다.
추상 메서드가 하나라면 람다식의 대상이 될 수 있다.
하지만 @FunctionalInterface를 붙여두면 컴파일러가 해당 인터페이스가 함수형 인터페이스의 조건을 만족하는지 검사해준다.
예를 들어 추상 메서드를 하나 더 추가하면 다음과 같다.
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
int minus(int a, int b);
}
이제 추상 메서드가 2개이기 때문에 함수형 인터페이스가 될 수 없고 컴파일 오류가 발생한다.
이제 처음에 봤던 코드로 돌아가보자.
assertThrows(NoUniqueBeanDefinitionException.class,
() -> ac.getBean(DiscountPolicy.class));
처음에는 다음 코드가 상당히 낯설었다.
() -> ac.getBean(DiscountPolicy.class)
하지만 람다식의 구조를 알고 보면 생각보다 간단하다.
() -> ac.getBean(DiscountPolicy.class)
↑ ↑
매개변수 없음 실행할 코드
즉,
매개변수는 없고, 실행하면
ac.getBean(DiscountPolicy.class)를 수행하는 동작
을 의미한다.
그렇다면 assertThrows()는 왜 이 동작을 전달받는 것일까?
JUnit의 assertThrows()는 다음과 같은 목적으로 사용한다.
특정 코드를 실행했을 때 내가 예상한 예외가 발생하는지 확인한다.
예를 들어 다음 코드가 있다고 해보자.
ac.getBean(DiscountPolicy.class);
현재 Spring Container에 DiscountPolicy 타입의 Bean이 2개 존재한다고 가정해보자.
@Bean
public DiscountPolicy rateDiscountPolicy() {
return new RateDiscountPolicy();
}
@Bean
public DiscountPolicy fixedDiscountPolicy() {
return new FixDiscountPolicy();
}
DiscountPolicy 타입으로 조회하면 Spring 입장에서는 어떤 Bean을 반환해야 하는지 결정할 수 없다.
따라서 다음 예외가 발생한다.
NoUniqueBeanDefinitionException
우리는 테스트에서 이 예외가 정말 발생하는지 확인하고 싶다.
그래서 다음과 같이 작성한다.
assertThrows(
NoUniqueBeanDefinitionException.class,
() -> ac.getBean(DiscountPolicy.class)
);
이를 자연어로 읽으면 다음과 같다.
ac.getBean(DiscountPolicy.class)를 실행했을 때
NoUniqueBeanDefinitionException이 발생하는지 확인해라.
여기서 중요한 점은 ac.getBean()을 바로 실행하는 것이 아니라 "나중에 실행할 코드" 자체를 assertThrows()에게 전달한다는 것이다.
다음과 같이 작성한다고 생각해보자.
ac.getBean(DiscountPolicy.class);
assertThrows(NoUniqueBeanDefinitionException.class, ...);
첫 번째 줄이 실행되는 순간 이미 예외가 발생한다.
ac.getBean()
↓
예외 발생
↓
테스트 중단
따라서 assertThrows()가 예외를 검사할 기회조차 없다.
그래서 JUnit에게
"이 코드를 지금 내가 실행하는 것이 아니라, 네가 직접 실행해보고 예외가 발생하는지 확인해줘."
라고 실행할 동작 자체를 전달해야 한다.
그 역할을 람다식이 하는 것이다.
() -> ac.getBean(DiscountPolicy.class)
실행 흐름을 단순화하면 다음과 같다.
assertThrows()
↓
람다식 전달
↓
() -> ac.getBean(DiscountPolicy.class)
↓
JUnit이 람다식 실행
↓
NoUniqueBeanDefinitionException 발생
↓
예상한 예외와 같은지 검사
↓
테스트 성공
이렇게 생각하면 assertThrows()에서 왜 람다식을 사용하는지도 이해할 수 있다.
Java에서는 람다식을 편리하게 사용할 수 있도록 여러 함수형 인터페이스를 기본으로 제공한다.
대표적으로 다음과 같은 것들이 있다.
매개변수와 반환값이 모두 없는 동작을 표현한다.
Runnable runnable = () -> System.out.println("Hello");
값을 하나 받아서 처리하고 반환값은 없다.
Consumer<String> consumer =
name -> System.out.println(name);
매개변수 없이 값을 반환한다.
Supplier<String> supplier =
() -> "Hello";
값을 받아서 다른 값으로 변환한다.
Function<Integer, Integer> function =
number -> number * 2;
값을 받아서 true 또는 false를 반환한다.
Predicate<Integer> predicate =
number -> number > 10;
처음부터 이 인터페이스들을 모두 외울 필요는 없다고 생각한다.
중요한 것은 람다식을 봤을 때
"아, 어떤 함수형 인터페이스가 요구하는 하나의 동작을 구현해서 전달하고 있구나."
라고 이해할 수 있는 것이다.
처음 Spring 테스트 코드를 공부하면서 다음 코드를 봤을 때는 () ->가 단순히 처음 보는 Java 문법처럼 느껴졌다.
assertThrows(NoUniqueBeanDefinitionException.class,
() -> ac.getBean(DiscountPolicy.class));
하지만 람다식을 공부하고 다시 보면 이 코드가 훨씬 명확하게 보인다.
() -> ac.getBean(DiscountPolicy.class)
이 코드는 단순히 문법을 짧게 작성한 것이 아니라,
"ac.getBean()을 실행하는 동작 자체를 assertThrows()에게 전달한다."
라는 의미를 가지고 있다.
람다식은 처음 보면 ->라는 문법 때문에 어렵게 느껴질 수 있지만 핵심은 생각보다 단순했다.
(매개변수) -> 실행할 동작
그리고 Java에서는 이 동작을 함수형 인터페이스를 통해 전달할 수 있다.
Spring이나 Java 코드를 공부하다 보면 앞으로도 람다식을 계속 만나게 될 것이다.
그때마다 문법 자체만 바라보기보다는
"지금 어떤 동작을 어디에 전달하고 있는가?"
를 생각하면서 코드를 읽어보면 람다식을 이해하는 데 훨씬 도움이 될 것 같다.