본문으로 건너뛰기

자바의 Lambda와 함수형 인터페이스 이해하기

· 약 11분

이 글을 읽은 후엔 람다 표현식과 함수형 인터페이스에 대해 이해하고 더 간결하고 가독성 좋게 코드를 작성할 수 있을 것입니다.

들어가며

우리가 개발하면서 자주 고민하는 상황은 코드가 거의 비슷한데 사소한 부분만 다른 경우이다. 최근 우테코 8기 로또 미션에서 입력을 다시 받는 요구사항을 만족하기 위해 이런 고민했었다.

잘못된 입력 시 예외를 발생시키고 다시 입력을 받는 요구사항이 주어졌다. 예를 들어 1,000원 단위로 구매할 수 있는 로또를 1,500원을 입력했을 때 예외를 발생시키고 다시 입력을 받도록 구현해야 한다.

LottoApplication.java
public static void main(String[] args) {
Money money;
List<Lotto> lottos;
while (true) {
try {
String input = input("구입금액을 입력해 주세요.");
long amount = Long.parseLong(input);
money = new Money(amount);
lottos = LottoFactory.createLottos(money);
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}


List<Integer> winningNumbers;
Integer bonus;
WinningLotto winningLotto;

while (true) {
try {
String input = input("당첨 번호를 입력해 주세요.");
winningNumbers = parseNumbers(input);
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}

while (true) {
try {
String input = input("보너스 번호를 입력해 주세요.");
bonus = Integer.parseInt(input);
winningLotto = LottoFactory.createWinningLotto(winningNumbers, bonus);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}

// TODO: 출력: 통계 출력
}

private static List<Integer> parseNumbers(String input) {
return Arrays.stream(input.split(","))
.map(String::trim)
.map(Integer::parseInt)
.toList();
}

private static String input(String message) {
System.out.println(message);
return Console.readLine();
}

예외가 발생하면 입력을 해당 시점부터 다시 받기 위해 whiletry-catch를 사용해야 하고, 입력을 세 번 받아야하니 while(true)try-catch 블록을 세 번 반복해야 한다.

이를 개선하는 방법은 다양하다. 메서드로 분리하여 flag를 통해 로직을 변경하거나 전략 패턴을 사용하여 다양한 전략 구현체를 집어넣어 주면 된다. 하지만 자바 8버전 이후여야만 사용할 수 있는 방법이 있다. 이는 꽤 간단하며 가독성 또한 높다.

자바8 이전의 세계와 이후의 세계는 어떻게 다른지, 람다 표현식과 함수형 인터페이스를 통해 어떻게 개선할 수 있는지 알아보자.

자바 7에서 개선하기

먼저 중복되는 부분을 최대한 줄여보자. 일단 whiletry-catch 블록이 중복되므로 하나의 메서드 안에서 처리하도록 바꿔보자.

Worst Case
public static void main(String[] args) {
...

MoneyAndLottos moneyAndLottos = readAgain(0);
WinningLotto winningLotto = readAgain(1);

...
}

public static <T> T readAgain(int flag) {
while (true) {
try {
if (flag == 0) {
return (T) getMoneyAndLottos();
} else if (flag == 1) {
return (T) getWinningLotto();
} else {
return (T) getWinningLottoBonus();
}
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}

private static MoneyAndLottos getMoneyAndLottos() {
Money money;
List<Lotto> lottos;

String input = input("구입금액을 입력해 주세요.");
long amount = Long.parseLong(input);

money = new Money(amount);
lottos = LottoFactory.createLottos(money);

return new MoneyAndLottos(money, lottos);
}

private static WinningLotto getWinningLotto() {
List<Integer> winningNumbers;
Integer bonus;

String input = input("당첨 번호를 입력해 주세요.");
winningNumbers = parseNumbers(input);

return LottoFactory.createWinningLotto(winningNumbers, bonus);
}

flag를 통해 어떤 값을 읽을지 결정하도록 바꿨다. 하지만 이 방법은... 정말 좋지 않다. flag가 true일 때와 false일 때의 로직이 완전히 다르므로 오히려 가독성이 떨어지고 유지보수가 어려워진다. 요구사항이 바뀌었을 때 유연하게 대응할 수도 없다.

예를 들어 구매 금액을 입력하기 전에 사용자의 이름을 입력받아야 한다면? 주어진 입력에 따라 동적으로 처리해야 한다면? 결국 모든 것을 처리하는 하나의 거대한 필터 메서드를 구현하게 된다. 이 방법은 너무 최악이기도 하고 아마 이렇게 하는 사람은 없을 것이다.

코드를 다시 살펴보면 whiletry-catch 블록은 그대로인데 내가 원하는 동작만 달라지고 있다. 그래서 동작을 파라미터로 넘겨서 처리할 수 있는 방법이 필요하다. 이를 위해 인터페이스를 하나 만들어보자.

Strategy Pattern
public interface LottoStrategy {
void execute();
}

행동을 결정하는 인터페이스를 정의하자.

MoneyAndLottosStrategy.java
public class MoneyAndLottosStrategy implements LottoStrategy {
private Money money;
private List<Lotto> lottos;

@Override
public void execute() {
String input = input("구입금액을 입력해 주세요.");
long amount = Long.parseLong(input);
money = new Money(amount);
lottos = LottoFactory.createLottos(money);
}
}

그리고 그 인터페이스를 구현하는 클래스를 만든다. 각 구현체는 내가 원하는 동작을 수행한다. 이렇게 되면 main 메서드를 다음과 같이 바꿀 수 있다.

LottoApplication.java
public static void main(String[] args) {
...

readAgain(new MoneyAndLottosStrategy());
readAgain(new WinningLottoStrategy());

...
}

public static void readAgain(LottoStrategy strategy) {
while (true) {
try {
strategy.execute();
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}

원하는 동작마다 구현체를 다르게 하여 readAgain 메서드에 넘겨주면 된다. 이를 전략 패턴(Strategy Pattern) 이라고 부른다. 전략 패턴은 각 알고리즘을 캡슐화하는 구현체들을 정의한 후 런타임에 알고리즘을 선택하는 기법이다.

이 방법의 핵심은 동작(또는 전략)을 파라미터화한다는 것이다. 메서드가 다양한 동작을 파라미터로 받아서 내부적으로 다양한 동작을 수행할 수 있다.

전략 패턴을 사용한 코드는 bool 플래그를 사용한 코드보다 훨씬 낫다. 하지만 여전히 불편한 점이 있다. 구현체 클래스를 매번 만들어야 한다는 점이다. 만약 동작이 하나 더 추가된다면 또 하나의 클래스를 만들어야 한다.

클래스 파일를 매번 생성하는 것은 번거로운 작업이니 좀 더 간단하게 만들어보자.

Anonymous Class
public static void main(String[] args) {
...

readAgain(new LottoStrategy() {
@Override
public void execute() {
String input = input("구입금액을 입력해 주세요.");
long amount = Long.parseLong(input);
money = new Money(amount);
lottos = LottoFactory.createLottos(money);
}
});

readAgain(new LottoStrategy() {
@Override
public void execute() {
String input = input("당첨 번호를 입력해 주세요.");
winningNumbers = parseNumbers(input);
String input = input("보너스 번호를 입력해 주세요.");
bonus = Integer.parseInt(input);
winningLotto = LottoFactory.createWinningLotto(winningNumbers, bonus);
}
});

...
}

자바는 1.1버전에서부터 익명 클래스를 제공한다. 익명 클래스를 사용하면 구현체를 조금 더 간단하게 생성할 수 있다. 매번 클래스 파일을 생성(cmd + N)하지 않아도 된다. 그러나 코드가 여전히 장황하다는 점은 변함없다. while문과 try-catch 블록의 중복은 제거되었지만, 익명 클래스의 긴 문법 때문에 오히려 작성 과정이 번거로워졌다.

그래서 람다 표현식이 등장한다. 람다 표현식을 사용하면 익명 클래스보다 훨씬 간결하게 동작을 파라미터화할 수 있다. 자바 8에서 도입된 람다 표현식을 사용하여 코드를 더 간결하게 만들어보자.

자바 8에서 개선하기

람다 표현식을 사용하면 다음과 같이 코드를 작성할 수 있다.

아니 이게 뭐지? 이게 가능한가?
public static void main(String[] args) {
...

readAgain(() -> {
String input = input("구입금액을 입력해 주세요.");
long amount = Long.parseLong(input);
money = new Money(amount);
lottos = LottoFactory.createLottos(money);
});

readAgain(() -> {
String input = input("당첨 번호를 입력해 주세요.");
winningNumbers = parseNumbers(input);
String input = input("보너스 번호를 입력해 주세요.");
bonus = Integer.parseInt(input);
winningLotto = LottoFactory.createWinningLotto(winningNumbers, bonus);
});

...
}

이전 코드보다 훨씬 간단해졌다! 익명 클래스를 일일이 작성할 필요 없이 동작 자체를 값처럼 전달할 수 있게 되었다.

이제 앞서 프리코스 문제는 어느 정도 해결했으니 어떻게 해결하게 됐는지 알아보자.

람다란?

람다는 익명 함수로 함수형 인터페이스의 인스턴스를 만드는 간결한 방법이다. 람다에는 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

어디에, 어떻게 람다를 사용할까?

람다는 아래 사진처럼 파라미터와 화살표, 바디로 구성되고 두 가지 스타일로 작성할 수 있다.

람다 표현식의 구성 요소
  • 파라미터가 하나일 때: (parameter) -> expression 또는 parameter -> expression
  • 파라미터가 여러 개일 때: (param1, param2) -> expression
  • 바디가 여러 문장일 때: (param1, param2) -> { statement1; statement2; }

앞서 말했듯 람다는 함수형 인터페이스(Functional Interface)를 구현하는 데 사용된다. 함수형 인터페이스는 단 하나의 추상 메서드를 가지는 인터페이스이다. 대표적인 함수형 인터페이스는 Comparator, Runnable, Callable, Supplier, Consumer, Function 등이 있다.

일반적으로 @FunctionalInterface 어노테이션을 사용하여 함수형 인터페이스임을 명시한다. 이 어노테이션은 선택 사항이지만, 컴파일러가 해당 인터페이스가 함수형 인터페이스인지 검사하도록 도와준다. 그래서 컴파일 타임에 실수를 방지할 수 있다.

함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다. 아래처럼 MyFunctionalInterfaceComparator라는 함수형 인터페이스가 있다면, 다음과 같이 람다 표현식을 사용하여 해당 인터페이스의 인스턴스를 생성할 수 있다.

Functional Interface Example
// custom functional interface
@FunctionalInterface // 생략 가능
public interface MyFunctionalInterface {
void execute();
}

// java.util.Comparator 인터페이스
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}

// 람다 표현식 사용 예시
MyFunctionalInterface myFunc = () -> System.out.println("Hello, Lambda!");
myFunc.execute(); // 출력: Hello, Lambda!

Comparator<Integer> comparator = (a, b) -> Integer.compare(a, b);
int result = comparator.compare(5, 10); // result는 음수

람다는 추상 메서드를 구현하는 구현체(인스턴스)이다. 익명 클래스와 거의 동일하다. 그래서 변수 myFunc로 받았으면 execute 메서드를 호출해야 실행된다.

🍄‍🟫

람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 했다. 근데 람다 표현식에는 어떤 함수형 인터페이스를 구현하는지의 정보가 없다..! 그렇다면 컴파일러는 어떻게 이 과정을 처리할까?

람다 동작 원리

타입 확인

자바 컴파일러는 람다가 사용되는 콘텍스트(context)를 이용해서 람다의 형식(type)을 추론한다. 콘텍스트란 람다가 할당되는 변수, 메서드 파라미터 등을 의미한다. 앞서 봤던 MyFunctionalInterface 예제를 다시 살펴보자.

Type Checking Example
@FunctionalInterface
public interface MyFunctionalInterface {
void execute(String message);
}

// 사용 예시
MyFunctionalInterface myFunc = (msg) -> System.out.println(msg);

여기서 컴파일러가 (msg) -> System.out.println(msg)의 타입을 확인하는 순서는 다음과 같다.

  1. 람다가 할당되는 변수(콘텍스트) myFunc의 타입을 확인한다. myFuncMyFunctionalInterface 타입이다.
  2. MyFunctionalInterface의 추상 메서드 execute의 시그니처를 확인한다.
    함수 디스크립터는 String -> void 이다.
  3. 람다의 파라미터 리스트와 바디를 함수 디스크립터(execute 메서드의 시그니처)와 비교한다.
    람다의 파라미터 msgString 타입으로 추론되고, 바디가 void를 반환하는지 확인한다.

람다의 타입은 람다에 의해 결정되는게 아니라 콘텍스트에 의해 결정된다. 그래서 하나의 람다로 다양한 함수형 인터페이스를 구현할 수 있다.

Different Functional Interfaces
@FunctionalInterface
public interface Printer {
void print(String message);
}

// 아래는 모두 유효한 코드이다.
Printer printer = (msg) -> System.out.println(msg);
MyFunctionalInterface myFunc = (msg) -> System.out.println(msg);

형식 추론

자바 컴파일러는 할당할 변수, 메서드 파라미터(즉, 콘텍스트)를 통해서 람다와 관련된 함수형 인터페이스를 추론한다. 그래서 컴파일러는 람다 표현식의 파라미터 타입을 알 수 있으므로 파라미터의 타입을 생략할 수 있다.

Type Inference Example
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}

Comparator<Integer> comparator = (Integer a, Integer b) -> Integer.compare(a, b);
Comparator<Integer> comparator = (a, b) -> Integer.compare(a, b);

이쯤되니 살짝 아쉬운 점이 있다. 코드 자체는 정말 간결해지지만 람다 자체가 무엇을 하는지 명확하지 않다. 즉 코드를 이해하기 어려워진다.

이를 해결하기 위해 람다 표현식 대신 메서드 참조(Method Reference)를 사용할 수 있다. 메서드 참조는 기존에 정의된 메서드를 람다 표현식처럼 사용할 수 있도록 해준다.

메서드 참조

메서드 참조는 이미 존재하는 메서드를 참조하여 람다식처럼 사용하는 문법이다.

// 람다를 사용한 정렬
inventory.sort((item1, item2) -> item1.getName().compareTo(item2.getName()));

람다를 사용한 정렬을 먼저 보자. sort 메서드는 Comparator를 파라미터로 받고, Comparator는 두 개의 Item을 비교하는 compare 메서드를 가진 함수형 인터페이스이다.

// java.util.List 인터페이스 내 sort 메서드를 간략화한 버전
public void sort(Comparator c) {
// ...
}

// java.util.Comparator 인터페이스 내 compare 메서드를 간략화 한 버전
public interface Comparator<T> {
int compare(T o1, T o2);
}

이제 메서드 참조를 이용해보자. sort는 T, T -> int 를 기대하기 때문에 Comparator를 주면된다. Comparator 내의 comparing 메서드는 Comparator를 반환하기 때문에 이 메서드를 전달하면 된다. 근데 comparing 메서드는 Function 타입의 파라미터를 받기 때문에 Item::getName 메서드 참조를 전달하면 된다.

Item.java
public class Item {
private String name;

public String getName() { // 인스턴스 메서드임
return name;
}
}

getItem 메서드는 Item 인스턴스를 받아서 String(이름)을 반환하는 메서드이다. 이 메서드를 참조하여 Function<Item, String> 타입의 함수형 인터페이스로 사용할 수 있다.

// java.util.List 인터페이스 내 sort 메서드를 간략화한 버전
public void sort(Comparator c) {
// ...
}

// java.util.Comparator 인터페이스 내 comparing 메서드를 간략화 한 버전
public static Comparator comparing(Function keyExtractor) {
// ...
}

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

// 메서드 참조를 사용한 정렬
inventory.sort(Comparator.comparing(Item::getName));

람다를 사용하는 곳이라면 전부 사용할 수 있으며 메서드명 앞에 구분자(::)를 붙이는 방식으로 사용한다. 명시적으로 메서드명을 참조함으로써 가독성을 더욱 높일 수 있다.

메서드 참조는 다음과 같은 세 가지 형태가 있다.

  1. 정적 메서드 참조: ClassName::staticMethodName
  2. 인스턴스 메서드 참조: instance::instanceMethodName
  3. 생성자 참조: ClassName::new

개발하면서 아마 자주 사용했을테니 추가적인 설명은 생략하겠다.

람다, 메서드 참조 활용하기 (최최종)

열심히 학습했으니 마지막으로 리스트를 정렬하는 문제를 풀며 람다와 메서드 참조의 활용법을 정리해보자.

Sort Example
void sort(Comparator<T> comparator) {
// ...
}

sort 메서드는 Comparator를 파라미터로 받아서 리스트를 정렬한다. 정렬 동작은 파라미터화 되어 외부에서 결정하고 있다. 즉 sort에 전달된 정렬 전략에 따라 sort의 동작이 달라질 것이다.

Implements Comparator
// ItemComparator.java
public class ItemComparator implements Comparator<Item> {
@Override
public int compare(Item o1, Item o2) {
return o1.getName().compareTo(o2.getName());
}
}

// Application.java 에서 정렬 시
inventory.sort(new ItemComparator());

가장 먼저 살펴본 것은 Comparator 인터페이스를 구현한 클래스를 만들어서 사용하는 방법이다. 이 방법은 전략 패턴을 사용한 전통적인 방법이다. 하지만 클래스 파일을 매번 생성해야 한다는 단점이 있다.

Anonymous Class Sort
inventory.sort(new Comparator<Item>() {
@Override
public int compare(Item o1, Item o2) {
return o1.getName().compareTo(o2.getName());
}
});

익명 클래스를 사용하면 클래스 파일을 매번 생성하지 않아도 된다. 하지만 여전히 장황한 문법 때문에 코드가 길어진다는 단점이 있다.

Lambda Sort
inventory.sort((o1, o2) -> o1.getName().compareTo(o2.getName()));

람다 표현식을 사용하면 코드가 훨씬 간결해진다. 익명 클래스보다 훨씬 짧고 가독성도 좋다.

Method Reference Sort
import static java.util.Comparator.comparing;

inventory.sort(comparing(Item::getName));

마지막으로 메서드 참조를 사용하여 코드가 짧아졌을 뿐만 아니라 코드의 의미도 명확해졌다. 이 구문에서 Item을 이름 별로 비교해서 정렬하라는 의미가 전달된다.

마치며

람다가 무엇이고 어떻게 동작하는지, 자바 8부터 코드를 어떻게 더 깔끔하게 구현할 수 있는지 알아보았다. 람다가 기술적으로 자바 8 이전의 자바로 할 수 없었던 일을 제공하는 것은 아니다. 다만 동작을 파라미터화할 때 익명 클래스와 같이 판에 박힌 코드를 구현할 필요가 없다는 점에서 장점이 있다. 람다와 메서드 참조를 적절히 활용하여 가독성 좋고 유지보수하기 쉬운 코드를 작성해보자!

참고자료

모던 자바 인 액션