이번 프리코스 과제에서는 다음과 같은 요구사항이 있었다.
사용자가 잘못된 값을 입력할 경우
IllegalArgumentException
을 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.
각 입력마다 반복문과 try-catch를 적용하면 해결할 수 있었지만 코드가 불필요하게 복잡해지는 느낌이 들었다. 평소 코드 중복이 발생하면 이를 함수로 묶어 분리해 재사용하도록 리팩토링했는데, 이번에는 조금 상황이 달랐다.
보통 함수 분리를 한다면 여러 구문을 통째로 모아 분리했지만, 이번 상황에서는 반복문과 try-catch라는 껍데기만 분리하고 싶었다. 즉, 해당 제어문 내부에서 실행되는 로직(함수)을 매개변수로 받아야 하는 상황이었다.
public void run() {
process(inputMoney);
process(inputWinningNumbers);
process(inputBonusNumber);
process(result);
}
private void process(??? func) {
while (true) {
try {
func();
} catch (IllegalArgumentException e) {
continue;
}
break;
}
}
private void inputMoney() {...}
private void inputWinningNumbers() {...}
private void inputBonusNumber() {...}
private void result() {...}
위와 같이 매개변수로 함수를 받을 수만 있다면 코드가 훨씬 간결해질 것 같다. 과연 가능할까? 궁금하다면 이 글을 끝까지 읽어보자!
함수형 인터페이스라는 말을 종종 들어봤을 것이다. 이는 추상 메서드가 1개만 정의된 인터페이스를 의미하는 말로, 자바에서 람다 표현식을 통해 함수형 프로그래밍을 구현하기 위해 만들어졌다.
람다 표현식에 대해 자세히 궁금하다면 아래 게시글을 읽어보자.
람다 표현식(Lambda Expression) 완벽 정리 - Inpa
@FunctionalInterface
public interface LottoRandom {
List<Integer> getLottoNumbers();
}
위 코드는 함수형 인터페이스가 적용된 인터페이스이다. 인터페이스 내에 추상 메서드가 하나밖에 없다. 어노테이션으로 @FunctionalInterface
가 명시되어 있기는 하지만 2개 이상의 메서드 선언 시 컴파일 오류를 발생시켜 개발자의 실수를 방지하는 목적이기 때문에 없어도 무방하다.
이렇게 정의된 함수형 인터페이스는 클래스 구현 없이도 아래처럼 람다 표현식을 통해 간단하게 정의하여 사용할 수 있다.
LottoRandom lottoRandom = () -> List.of(1, 2, 3, 4, 5, 6);
위 예시는 인터페이스를 직접 만들었을 때의 예시이다. 이 경우, 저 람다 표현식을 인자로 받는 메서드의 시그니처는 아래와 같을 것이다.
public List<Integer> getRandomNumbers(LottoRandom lottoRandom) {
// ...
}
이런 매개변수 형식이 가능한 이유는 람다 표현식을 특정 함수형 인터페이스로 감싸주었기 때문이다.
만약 인터페이스 없이 람다식만을 가지고 매개변수로 전달하고 싶다면 어떻게 할 수 있을까? 람다식이 타입을 가져야 대상 메서드에서 매개변수로 람다식을 유추해서 전달받을 수 있을 것 같은데, 람다 표현식은 매번 그 시그니처(매개변수, 리턴값)가 달라질 수 있기에 더욱 애매해 보인다.
getRandomNumbers(() -> List.of(1, 2, 3, 4, 5, 6));
public List<Integer> getRandomNumbers(????) {
// ...
}
이런 상황(람다식의 타입 유추)을 위해 자바에서는 미리 함수형 인터페이스를 정의해두고 제공하고 있는데, 이를 함수형 인터페이스 표준 API라고 한다.
// 람다식에서의 매개변수와 반환값 형태
(매개변수) -> {
// ...
return 반환값;
}
가장 기본적인 표준 API는 Runnable
로, 이 API는 매개변수와 반환값을 가지지 않는다. 실제 구현부를 확인해보자.
우리가 알던 함수형 인터페이스와 같이 정말 단순하게 생겼다. 유일한 추상 메서드인 run()
은 매개변수와 반환값이 존재하지 않는다. 즉 매개변수와 반환값이 존재하지 않는 간단한 람다식을 특정 함수에 전달하고 싶을 경우, 람다식의 타입을 Runnable로 유추할 수 있다는 뜻이다.
Runnable을 활용하는 예시 코드를 보자.
public class Application {
public static void main(String[] args) {
func(() -> {
System.out.println("람다식 동작!!");
System.out.println("함수형 인터페이스 별 거 없네");
});
}
public static void func(Runnable lambda) {
lambda.run();
}
}
main()
함수에서 func()
에 전달할 람다식은 매개변수와 반환값 없이 단순히 문자열을 출력하고 있다. 그래서 func()
의 시그니처를 보면 인자로 주어질 람다식의 타입을 Runnable
로 유추하고 있다.
실제로 실행해보면 정상적으로 동작하는 것을 확인할 수 있다.
자바에서 제공하는 함수형 인터페이스 표준 API는 Runnable
을 포함하여 크게 6가지가 있다.
출처) 함수형 인터페이스 표준 API 총정리 - Inpa
위 표를 보면 알 수 있듯이, 각 표준 API는 매개변수와 반환값의 여부 및 타입을 기준으로 구분된다. 표준 API들은 그 이름을 기반으로 매개변수와 반환값에 대해 어떻게 동작하는지 직관적으로 이해할 수 있기에 어렵게 생각하지 않아도 된다. (ex. Consumer
(소비자)는 주어진 매개변수를 사용해버려서 아무것도 반환하지 않는다)
다만 깊게 들어가면 표준 API의 종류가 굉장히 많아지는데, 전부 외울 필요는 없다. 평소 자바 컬렉션에서 이런 기능 없나?
라는 생각이 들면 관련 함수를 찾아보듯이, 필요할 때마다 찾아서 사용하면 그만이다.
출처) Java 8 Functional Interface Naming Guide
위 그림은 수많은 표준 API들을 보여준다. 만약 특정 상황에서 표준 API 사용이 필요해진다면 왼편의 매개변수 목록과 윗편의 반환 타입을 확인해서 대응하는 표준 API를 찾아 사용하면 된다.
우리가 평소 람다 표현식을 가장 많이 사용하는 곳은 어디일까? 바로 stream이다.
public List<Integer> getNumbers() {
return numbers.stream()
.map((number) -> number.get())
.toList();
}
stream을 사용할 때 대부분의 경우 인자로 들어가는 것은 람다 표현식이다. 위에서 우리는 람다 표현식의 타입을 유추하기 위해 함수형 인터페이스를 사용한다고 배웠다.
그럼 람다 표현식을 인자로 받는 stream의 메서드들(filter
, map
, peek
등)은 전부 인자로 함수형 인터페이스 표준 API를 받을까?
public interface Stream<T> extends BaseStream<T, Stream<T>> {
Stream<T> filter(Predicate<? super T> predicate);
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
Stream<T> peek(Consumer<? super T> action);
// ...
}
그렇다! 실제 Stream 인터페이스를 열어보면 위와 같이 함수형 인터페이스를 인자로 전달받고 있는 것을 확인할 수 있다.
우리가 함수형 인터페이스를 모르고 있었다고 해도 생각보다 우리 코드와 밀접한 연관이 있었던 것이다.
public List<Integer> getNumbers() {
return numbers.stream()
.map((number) -> number.get())
.toList();
}
위 코드를 보면서 불편한 점이 느껴지지 않았는가?
public List<Integer> getNumbers() {
return numbers.stream()
.map(Number::get)
.toList();
}
아마 대부분은 이와 같이 수정하고 싶었을 것이다. 실제로 둘은 동일하게 동작한다. 하지만 두 구문의 차이가 무엇인지 정확하게 인지하고 있는가?
이는 메서드 참조라는 문법을 활용한 것인데 관심이 있다면 다음 게시글을 읽어보자.
지금까지 함수형 인터페이스에 대해 알아보았다. 이제 우리는 처음에 마주했던 문제를 이미 해결할 수 있을 것이다.
코드를 다시 확인해보자.
public void run() {
process(inputMoney);
process(inputWinningNumbers);
process(inputBonusNumber);
process(result);
}
private void process(??? func) {
while (true) {
try {
func();
} catch (IllegalArgumentException e) {
continue;
}
break;
}
}
private void inputMoney() {...}
private void inputWinningNumbers() {...}
private void inputBonusNumber() {...}
private void result() {...}
이제 방법이 보이지 않는가? 각 메서드는 매개변수와 반환값이 없으니 process()
의 시그니처 타입을 Runnable
로 설정할 수 있을 것 같다.
한 번 바꿔보자.
public void run() {
process(this::inputMoney);
process(this::inputWinningNumbers);
process(this::inputBonusNumber);
process(this::result);
}
private void process(Runnable action) {
try {
action.run();
} catch (IllegalArgumentException e) {
process(action);
}
}
private void inputMoney() {...}
private void inputWinningNumbers() {...}
private void inputBonusNumber() {...}
private void result() {...}
매개변수로 메서드 참조를 전달하고, process
함수에서는 인자를 Runnable
타입으로 받아 동작한다. 그리고 예외가 발생하면 process
함수를 재귀호출하여 올바르게 종료할 때까지 반복할 수 있도록 작성했다.
함수형 인터페이스 표준 API 총정리 - Inpa
람다 표현식(Lambda Expression) 완벽 정리 - Inpa
Java 8 Functional Interface Naming Guide
람다식을 더 짧게 - 메소드::참조 문법 - Inpa
코드 리뷰를 통해 함수형 인터페이스에 대해 공유해주셨는데 정말 유익하네요!🥹 다음 주차에 적용해보도록 하겠습니다. 좋은 정보 정말 감사드립니다!