본문 바로가기

개발/함수형 자바 프로그래밍

자바로 함수형 인터페이스 사용하기 (Functional Interface)

자바 8 함수형 프로그래밍

자바가 8로 접어 들면서 가장 크게 변화된 것은 함수형 개발 페러다임을 지원하기 시작 했다는 점 입니다. 하지만 기본적으로 자바라는 언어는 객체지향 언어 입니다. 그렇기 때문에 함수형으로 개발 하기 위해서는 어떤 점이 변경 되었고 또한 어떻게 적용할 수 있는지를 알아야 합니다.

생각해 보면 굳이 객체지향으로 설계된 자바라는 언어에 함수형 개발 방식을 접목 시켜야 될 이유가 뭐가 있을까요? 자바는 함수형 개발 방식을 사용할 수 있게 됨으로서 조금 더 재사용이 가능한 코드 조각을 만들 수 있게 되었고 이 때문에 조금 더 유연한 개발을 할 수 있는 가능성이 늘어 났습니다.


함수형 VS 객체지향

함수형 개발 방식과 객체지향 개발 방식을 비교할때 큰 차이는 값을 취급하는 단위가 어디까지 인지에 따라 나눌 수 있습니다. 예를 들어 자바는 예전 부터 값(상태) 과 행위를 다루기 위한 기본 단위를 객체로 정의 했었고 이 객체를 클래스라는 형태로 구현할 수 있었습니다. 어떻게 보면 자바 프로그램의 모든 단위가 객체(클래스) 로 이루어져 있다고 봐도 무방할 듯 합니다.

이에 반해 함수형 개발 방식은 행위에 해당하는 부분도 값으로 취급이 가능해 졌다는 것인데 자바에서 의미하는 기본형의 데이타 (Integer 나 String) 만 값이 아니라 행위(로직) 도 값으로 취급할 수 있게 되었다는 이야기 입니다. 이것은 자바가 코드의 재활용 단위가 클래스 였던 것이 함수 단위로 재사용이 가능해 지면서 조금더 개발을 유연하게 할 수 있게 된 점 이라고 할 수 있습니다.


함수형 인터페이스 종류

자바에서 제공하는 함수형 인터페이스 종류는 다음과 같습니다.

종류 인자 반환 설명
Runnable     기본적인 형태의 인터페이스, 인자와 반환값 모두 없음
Supplier<T>   <T> 인자가 없이 제너릭 타입의 반환값만 있는 인터페이스, 항상 같은 값을 반환
Consumer<T> <T>   제너릭 타입의 인자만 있고 반환값이 없는 인터페이스
Predicate<T> <T> Boolean 제너릭 타입의 인자와 Boolean 타입의 반환값을 가지는 인터페이스
Function<T, R> <T> <R> 제너릭 타입의 인자와 다른 제너릭 타입의 반환값이 같이 있는 인터페이스
UnaryOperator<T> <T> <T> 같은 제너릭 타입의 인자와 반환값을 가지고 있는 인터페이스
BinaryOperator<T> <T, T> <T> 같은 제너릭 타입의 인자 2개를 받고 같은 제너릭 타입의 반환값을 가지는 인터페이스
BiConsumer<T, U> <T, U>   다른 제너릭 타입의 인자 2개를 받고 반환값이 없는 인터페이스
BiPredicate<T, U> <T, U> Boolean 다른 제너릭 타입의 인자 2개를 받고 Boolean 타입의 반환값을 가지는 인터페이스
BiFunction<T, U, R>
<T, U> <R> 다른 제너릭 타입의 인자 2개를 받고 다른 제너릭 타입의 반환값을 가지는 인터페이스
Comparator<T>
<T, T> int 같은 제너릭 타입의 인자 2개를 받고 Integer 반환값을 가지는 인터페이스, 객체간의 값을 비교하기 위한 compare 기능을 위한 인터페이스

함수형 인터페이스는 어떤 인자를 받아서 어떤 값을 반환 할 것이라는 것을 제너릭 타입 을 통해 미리 주고 받을 객체형을 명시할 수 있는데 보면 제공하고 있는 함수형 인터페이스가 받을 수 있는 인자 값이 최대 2개를 넘지 않는 다는 것을 발견 할 수 있습니다.

이것은 함수를 어떻게 설계하는 것이 좋은지에 대한 가이드라고 볼 수 있는데 함수는 한가지의 일만 해야 되며 인자가 2개를 넘어가는 순간 하나 이상의 일을 하고 있을 가능성이 높으므로 다른 부수효과를 일으키지 않도록 어느정도 설계를 강제하는 것이라고 볼 수도 있습니다.

하지만 로직에 어쩔 수 없이 (그런 경우는 거의 없지만) 하나의 함수에서 처리하는게 더 효율적이라면 별도의 DTO 를 정의하고 여기에 값을 담아 인자로 전달하는 방법을 사용해야 합니다.


간단하게 사용해 보기

자 이제 실제 함수형 인터페이스를 이용해 간단한 연산을 담고 있는 함수 인터페이스를 정의해 보겠습니다.

BinaryOperator<Integer> add = Integer::sum; //더하기
BinaryOperator<Integer> subtract = (v1, v2) -> v1 - v2; //빼기
BinaryOperator<Integer> multiply = (v1, v2) -> v1 * v2; //곱하기
BinaryOperator<Integer> divide = (v1, v2) -> v1 / v2; //나누기
Function<Integer, String> amountFormat = (v1) -> new DecimalFormat("#,##0").format(BigDecimal.valueOf(v1)); //금액 형태로 포멧
Predicate<Integer> isPositiveNumber = (v1) -> v1 > 0; //양수인지 여부
Predicate<Integer> isNegativeNumber = (v1) -> v1 < 0; //음수인지 여부

이제 실제 정의한 인터페이스를 실행해 봅시다. 함수 인터페이스에 따라서 실행되는 함수 명칭이 조금씩 다르지만 거의 유사한 형태이니 주의하며 사용합니다.

log.debug("add : " + add.apply(1, 2));
log.debug("subtract : " + subtract.apply(1, 2));
log.debug("multiply : " + multiply.apply(1, 2));
log.debug("divide : " + divide.apply(1, 2));
log.debug("amountFormat : " + amountFormat.apply(1000000));
log.debug("isPositiveNumber : " + isPositiveNumber.test(1));
log.debug("isPositiveNumber : " + isNegativeNumber.test(2));

정의된 함수 인터페이스를 실행 했을때 실행 결과는 다음과 같이 나옵니다.

- add : 3
- subtract : -1
- multiply : 2
- divide : 0
- amountFormat : 1,000,000
- isPositiveNumber : true
- isPositiveNumber : false

코드를 함수형으로 조각내기

이것만 보면 함수형 인터페이스를 어떻게 사용해야 될지 감이 잘 오지 않을 수도 있을 것 같습니다. 하지만 함수형 개발 방식은 로직을 변수처럼 취급 할 수 있다는 점이 차이점 이라고 이야기 드렸습니다. 이것은 로직을 변수에 미리 만들어 두고 실행을 지연시켜 필요할때 사용이 가능함을 의미합니다.

테스트를 앞서 간단한 연산을 담고 있는 함수 인터페이스를 CalculatorUtils 클래스안에 담아 둡니다. 함수 인터페이스는 기존 함수와는 다르게 행위를 정의하는 것이 아니라 변수에 행위를 담는 것이기 때문에 이처럼 외부에서 사용을 해야 된다면 지역 변수가 아니라 전역 변수로 선언을 해줘야 메모리가 반환이 되지 않습니다.

public class CalculatorUtils {
    static public BinaryOperator<Integer> add = Integer::sum;
    static public BinaryOperator<Integer> subtract = (v1, v2) -> v1 - v2;
    static public BinaryOperator<Integer> multiply = (v1, v2) -> v1 * v2;
    static public BinaryOperator<Integer> divide = (v1, v2) -> v1 / v2;
    static public Function<Integer, String> amountFormat = (v1) -> new DecimalFormat("#,##0").format(BigDecimal.valueOf(v1));
    static public Predicate<Integer> isPositiveNumber = (v1) -> v1 > 0;
    static public Predicate<Integer> isNegativeNumber = (v1) -> v1 < 0;
}

별로 실용적이지는 않지만 인자의 값이 양수일때 값에 1을 더하고 인자의 값이 음수일때 값에 1을 빼는 로직을 함수형 인터페이스를 이용해 만들어 보겠습니다.

public void test(Integer number) {
    BinaryOperator<Integer> addNumber = CalculatorUtils.isPositiveNumber.test(number)
            ? CalculatorUtils.add
            : CalculatorUtils.subtract;
    log.debug(CalculatorUtils.amountFormat.apply(addNumber.apply(number, 1)));
}

이때 어떤 연산 함수가 실행 될지는 실제 양수인지 음수인지를 비교하는 비교문이 동작하고 나서 결정이 되고 실행은 그 이후에 이루어 지는 것을 볼 수 있습니다. 이처럼 함수형 인터페이스를 이용하면 실제 로직이 실행 되는 것을 좀더 유연하게 처리할 수 있게 됩니다.


함수형 인터페이스를 인자로 전달하기

앞서 함수형 인터페이스를 값으로 취급이 가능 하다고 이야기 드렸습니다. 그렇기 때문에 필요하다면 다른 함수의 인자로 함수형 인터페이스를 전달 할 수도 있는데요. 이것을 응용하면 필요한 핵심 로직을 미리 만들어 두고 만들어 둔 로직들을 단순히 조립만 하는 형태로 개발을 할 수도 있게 됩니다. 객체에서 중요한 요소인 행위상태관계행위에 대한 것들을 아예 분리할 수도 있다는 이야기 입니다. (혹시 이렇게 극단적으로 작성할일은 없겠지만 노파심에 이런게 가능 할 수 있다 정도만 알아두면 되겠습니다. )

public void test(Integer number, BinaryOperator<Integer> ... calcNumbers) {
    Stream.of(calcNumbers)
            .forEach(calc -> log.debug("calc : " + calc.apply(number, 2)));
}

실행은 아래와 같이 변경할 값과 실행할 함수 인터페이스를 담아 함께 넘깁니다.

this.test(1000, CalculatorUtils.add, CalculatorUtils.subtract);

그리고 만약 필요하다면 함수 인터페이스를 변수에 담지 않고 직접 정의해 전달 할 수도 있습니다.

this.test(1000, (v1, v2) -> v1 + v2);