[Java] 함수형 인터페이스란? 활용 방법에 대해 알아보자

중복되는 코드 중에서 중복을 제거하기 어려운 로직을 제거하기 위해 알아보던 중

AOP 처럼 공통적인 기능을 한 곳에서 관리하고, 핵심 기능만을 분리할 수 없을까에 대해 고민해보다가

함수형 인터페이스에 대해 알게 되어 대표적인 함수형 인터페이스와 사용 방법에 대해 정리해보자 한다.


함수형 인터페이스란?

함수형 인터페이스는 FunctionalInterface 어노테이션이 붙어 있다.

자바 8부터 도입된 개념으로, 이름 그대로 "함수"를 위한 인터페이스다.

즉, 이 인터페이스를 구현한 클래스는 하나의 함수 처럼 동작하게 된다.

이 함수처럼 동작한다는 것은 입력을 받아서 출력을 내보는데

예를 들어, Function<T, R> 이라는 함수형 인터페이스는 T라는 타입을 입력 받아 R이라는 타입의 출력한다.

그렇다면 함수형 인터페이스를 구현한 클래스는 T라는 타입의 입력을 받아 R이라는 타입을 반환하게 되는 것이다.

public class StringToIntConverter implements Function<String, Integer> {
    @Override
    public int apply(String s) {
        return Integer.parseInt(s);
    }
}

 

이 뿐 아니라 함수형 인터페이스를 메서드의 매개변수로 사용하면 특정 동작을 인자로 전달할 수 있다.

대표적인 예로 List 컬렉션의 sort 메서드가 Comparator 함수형 인터페이스를 매개변수로 받아 정렬을 수행한다.

// 인터페이스 함수를 재정의해 사용
List<String> names = Arrays.asList("John", "Jane", "Tom", "Alice");
names.sort(new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return o1.compareTo(o2);
    }
});

// 람다 표현식
List<String> names = Arrays.asList("John", "Jane", "Tom", "Alice");
names.sort((o1, o2) -> o1.compareTo(o2));

 

 

 

함수형 인터페이스 종류

 

함수형 인터페이스는 크게 5가지로 Consumer, Supplier, Function, Operator, Predicate가 있다.

함수형 인터페이스의 특징으로는 각각 한개의 추상 메서드만을 갖고 있다는 특징이 있다.

또 이 인터페이스들은 여러 인터페이스들로 나뉘는데 이 큰 종류의 인터페이스 중 4가지에 대해서만 알아보고자 한다.

함수형 인터페이스 이름 설명 추상 메서드
Consumer<T> 객체 T를 받아 소비한다. void accept(T t)
Supplier<T> 객체T를 반환한다. T get()
Function<T, R> 객체T를 객체 R로 매핑한다. R apply(T t)
Predicate<T> 객체T를 받아 boolean을 반환한다. boolean test(T t)

 

이 포스팅에서는 내가 이번에 적용한 메서드 파라미터에 함수형 인터페이스를 넘겨 사용하는 방법에 대해 설명하려 한다.


Consumer<T>

한 개의 입력을 받아서 결과를 반환하지 않는 함수를 정의한다.

void accept(T t) 메서드를 가지며, 이 메서드는 매개변수 T를 받아서 아무런 결과도 반환하지 않는다.

주로 입력값을 이용한 연산이나 출력 등의 동작에 사용된다.

// Consumer를 파라미터로 받는 메서드 정의
public static void printList(Consumer<String> consumer, List<String> list) {
    for (String item : list) {
        consumer.accept(item);
    }
}

// 메서드 호출
printList(System.out::println, Arrays.asList("Apple", "Banana", "Cherry")); 
// 출력: 
// Apple
// Banana
// Cherry

 


Supplier<T>

입력 없이 결과를 반환하는 함수를 정의한다.

T get() 메서드를 가지며, 이 메서드는 아무런 매개변수를 받지 않고 결과 T를 반환한다.

주로 입력 받는 방식을 경정해 파라미터 없이 특정 결과를 생성하는데 사용된다.

 

아래 예제에서는 print() 메소드가  Supplier<String>를 매개변수로 받는다.

Supplier는 자신의 추상메서드 get()을 호출하고 있다.

그리고 이 메서드를 호출 시에 람다식을 이용해 문자열을 출력하고 있다. 

// Supplier를 파라미터로 받는 메서드 정의
public static void print(Supplier<String> supplier) {
    System.out.println(supplier.get());
}

// 메서드 호출
print(() -> "안녕하세요!");
// 출력: 안녕하세요!

 


Function<T, R> 

한 개의 입력을 받아서 결과를 매핑하여 반환하는 함수를 정의한다.

R apply(T t) 메서드를 가지며, 이 메서드는 매개변수 T를 받아서 R타입의 결과를 반환한다.

주로 T 타입의 객체를 받아 다른 형태R로 변환하는데 사용된다.

 

아래 예제에서는 print() 메소드가  Function<Integer, String>를 매개변수로 받는다.

Function은 자신의 추상 메서드 apply()에 정수 값을 받는다.

그리고 이 메서드를 호출할 때 생성자 참조 방식을 이용해서 정수 값을 문자열로 변환하고 변환된 문자열을 출력하고 있다.

// Function 파라미터로 받는 메서드 정의
public static void print(Function<Integer, String> function, int value) {
    System.out.println(function.apply(value));
}

// 메서드 호출
print(Object::toString, 5);  
// 출력: 5

Predicate<T>

한 개의 입력을 받아서 boolean 결과를 반환하는 함수를 정의한다.

boolean test(T t) 메서드를 가지며, 이 메서드는 매개변수 T를 받아서 boolean 결과를 반환한다.

주로 객체를 조건에 따른 필터링이 필요할 때 사용한다.

 

아래에서는  print() 메소드가 Predicate<Integer>를 매개변수로 받는다.

Predicate는 자신의 추상 메서드 test()에 정수 값을 받아서 이 값이 특정 조건을 만족하는지를 판단한다.

그리고 이 메서드를 호출할 때 람다식을 이용해 조건을 만족하면 value값이 출력된다.

// Predicate를 파라미터로 받는 메서드 정의
public static void print(Predicate<Integer> predicate, int value) {
    if (predicate.test(value)) {
        System.out.println(value);
    }
}

// 메서드 호출
print(x -> x > 5, 10);  
// 출력: 10

함수형 인터페이스 활용

지금까지 함수형 인터페이스들의 사용법에 대해 간단하게 알아보았다.

이번 목차에서는 이 함수형 인터페이스를 활용해 코드의 유연성과 재사용성을 높이는 방법에 대해 설명하려한다.

 

필자가 이 함수형 인터페이스에 대해 알아보게 된 계기는 다음과 같다.

public class InputHandler {
	...
    
    public static String receiveValidatedName() {
        while (true) {
            try {
                String input = InputView.inputName();
                Vaildator.validateName(input);
                return input;
            } catch (IllegalArgumentException exception) {
                OutputView.printErrorMessageFor(exception);
            }
        }
    }

    public static int receiveValidatedAge() {
        while (true) {
            try {
                String input = InputView.inputAge();
                Vaildator.validateAge(input);
                return Integer.parseInt(input);
            } catch (IllegalArgumentException exception) {
                OutputView.printErrorMessageFor(exception);
            }
        }
    }
}

위 코드를 보면 두 메서드는 거의 비슷한 구조를 갖고 있다.

while문과 try ~ catch문은 반복되어 작성되어 있고, try ~ catch 내부의 핵심 로직만 코드가 다르다.

이 핵심 로직이 try ~ catch 내부 블럭안에 감싸져 있을 뿐 아니라 검증 방식과 반환 값의 타입이 달라 메서드로 추출해 중복을 제거하기도 어렵다.

 

AOP 처럼 공통적인 기능을 한 곳에서 관리하고, 핵심 기능만을 분리할 수 없을까 ?

바로 여기서 함수형 인터페이스를 사용해 함수로 추상화하여  재사용가능한 코드를 만들 수 있었다.

public class InputHandler {

	...
	// function.identity()는 입력받은 값을 그대로 반환하는 함수를 반환
    public static String receiveValidatedName() {
        return receiveValidatedInput(InputView::inputName, Validator::validateName, Function.identity());
    }

    public static int receiveValidatedAge() {
        return receiveValidatedInput(InputView::inputAge, Validator::validateAge, Integer::parseInt);
    }

    private static <T> T receiveValidatedInput(Supplier<String> inputSupplier, Consumer<String> validator, Function<String, T> resultConverter) {
        while (true) {
            try {
                String input = inputSupplier.get();
                validator.accept(input);
                return resultConverter.apply(input);
            } catch (IllegalArgumentException exception) {
                OutputView.printErrorMessageFor(exception);
            }
        }
    }
}
receiveValidatedInput() 메서드 작동 방식
  1. Supplier<String> inputSupplier
    입력을 받는 방식을 결정한다.
    이 함수를 호출하면 사용자로부터 입력을 받아서 입력 결과를 문자열로 반환한다.
  2. Consumer<String> validator
    입력값을 이용해 검증하는 방식을 결정한다.
    이 함수를 호출하면 검증 클래스(Validator)를 통해 입력한 문자열이 유효한지 검사한다.
    만약 유효하지 않다면 검증 클래스의 메서드validateName(), validateAge()에 정의 된 예외를 발생시킨다.
  3. Function<String, T> resultConverter
    반환 값의 변환 방식을 결정한다.
    이 함수를 호출하면 입력된 문자열을 생성자 참조 방식으로 적절한 타입의 객체로 변환하여 반환한다.

 

receiveValidatedName() 메서드 작동 방식

receiveValidatedInput() 메서드를 호출하면서, 각각의 매개변수로 함수를 전달하고 있다.

  • 이름을 입력받는 방식(InputView::inputName)
  • 이름을 검증하는 방식(Validator::validateName)
  • 그리고 문자열을 그대로 반환하는 방식(Function.identity())을 전달한다.

 

receiveValidatedAge() 메서드 작동 방식

receiveValidatedInput() 메서드를 호출하면서, 각각의 매개변수로 함수를 전달하고 있다.

  • 나이를 입력받는 방식(InputView::inputAge)
  • 나이를 검증하는 방식(Validator::validateAge)
  • 그리고 문자열을 정수로 변환하는 방식(Integer::parseInt)을 전달한다.

 

이렇게 함수형 인터페이스를 활용하면 receiveValidatedInput 메서드는 전달된 함수에 따라서 다르게 동작한다.

따라서 receiveValidatedName()와 receiveValidatedAge()는 중복된 코드 없이 각 이름과 나이를 입력받는 동작을 수행할 수 있게 된다.