[Java] 함수형 인터페이스 (@FunctionalInterface) 알아보기 | 백과 블로그
default
negate
()
return
static
alwaysTrue
()
return
true
@Override
boolean
equals
(Object obj)
이 규칙 덕분에, 함수형 인터페이스에 default 메서드로 여러 편의 기능을 추가해도 여전히 람다로 구현할 수 있는 상태가 유지됩니다.
1부터 100까지의 숫자 중, 2의 배수만 뽑아라. 3의 배수만 뽑아라. 5의 배수만 뽑아라...
가장 단순하게 접근하면 이런 코드가 나옵니다.
package org.example;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main (String[] args) {
List<Integer> numbers = new ArrayList <>();
for (int i = 1 ; i <= 100 ; i++) {
numbers.add(i);
}
List<Integer> num2 = get2(numbers);
print(num2);
List<Integer> num3 = get3(numbers);
print(num3);
}
public static List<Integer> get2 (List<Integer> origin) {
List<Integer> filtered = new ArrayList <>();
for (Integer num : origin) {
if (num % 2 == 0 ) filtered.add(num);
}
return filtered;
}
public static List<Integer> get3 (List<Integer> origin) {
List<Integer> filtered = new ArrayList <>();
for (Integer num : origin) {
if (num % 3 == 0 ) filtered.add(num);
}
return filtered;
}
}
====출력===
[ 2 , 4 , 6 , 8 , 10 , 12 , ..., 100 ]
==출력종료==
====출력===
[ 3 , 6 , 9 , 12 , 15 , ..., 99 ]
==출력종료==
의도한 대로 동작은 하지만, 코드를 보면 문제가 보입니다.
get2와 get3는 조건문 한 줄(num % 2 == 0, num % 3 == 0)만 다를 뿐, 나머지 코드는 완전히 동일합니다.
요구사항에 "5 이상만 골라라", "짝수이면서 3의 배수인 것만 골라라" 같은 조건이 추가될 때마다 메서드가 하나씩 늘어나는 구조입니다
물론 "배수 판별"만 놓고 보면 getMultiple(List<Integer> origin, int n)처럼 배수 값을 파라미터로 받는 메서드로 리팩토링할 수 있습니다.
하지만 "5 이상만", "10개만 추출" 같이 조건의 형태 자체 가 달라지는 순간, 이 방식으로는 대응이 불가능합니다.
진짜 필요한 것은 배수 값이 아니라, 조건 판단 로직 자체를 바꿔 끼우는 것 입니다.
두 메서드를 다시 보면, 변하지 않는 부분과 변하는 부분이 명확히 나뉩니다.
변하지 않는 것: 리스트를 순회하면서, 조건에 맞으면 결과 리스트에 담는 로직
변하는 것: "조건이 무엇인가"
자바에서는 값(숫자, 문자열, 객체)은 변수에 담아 전달할 수 있지만, "로직 자체" 는 전달하기 까다로운 대상이었습니다.
Java 8부터는 이 문제를 함수형 인터페이스(Functional Interface) 로 깔끔하게 해결이 가능합니다.
추상 메서드가 하나뿐이기 때문에, 람다 표현식으로 그 자리에 바로 구현체를 만들어 전달할 수 있습니다.
package org.example;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class Main2 {
public static void main (String[] args) {
List<Integer> numbers = new ArrayList <>();
for (int i = 1 ; i <= 100 ; i++) {
numbers.add(i);
}
List<Integer> num2 = filter(numbers, (n) -> n % 2 == 0 );
print(num2);
List<Integer> num3 = filter(numbers, (n) -> n % 3 == 0 );
print(num3);
List<Integer> lower5 = filter(numbers, (n) -> n < 5 );
print(lower5);
}
@FunctionalInterface
interface Condition {
boolean check (int n) ;
}
public static List<Integer> filter (List<Integer> item, Condition condition) {
List<Integer> filtered = new ArrayList <>();
for (Integer num : item) {
if (condition.check(num))
filtered.add(num);
}
return filtered;
}
private static void print (List<Integer> item) {
System.out.println("====출력===" );
System.out.println(item.toString());
System.out.println("==출력종료==" );
}
}
filter 메서드는 이제 리스트를 순회하는 로직만 담당하고, "무엇을 기준으로 거를지"는 Condition이라는 함수형 인터페이스를 통해 외부에서 주입 받습니다.
====출력===
[2 , 4 , 6 , 8 , 10 , 12 , ..., 100 ]
==출력종료==
====출력===
[3 , 6 , 9 , 12 , 15 , ..., 99 ]
==출력종료==
====출력===
[1 , 2 , 3 , 4 ]
==출력종료==
메서드는 filter 하나뿐인데, 조건은 (n) -> n % 2 == 0, (n) -> n % 3 == 0, (n) -> n < 5처럼 얼마든지 갈아 끼울 수 있습니다.
처음 코드에서 조건이 하나 늘 때마다 메서드가 하나씩 늘어나던 구조가, 이제는 호출부에서 조건식 한 줄만 바꾸는 구조 로 바뀌었습니다.
여기서 한 걸음 더 나아가, "2의 배수이면서 3의 배수인 것" 또는 "5의 배수이거나 7의 배수인 것"처럼 조건을 조합 해야 하는 경우도 있습니다.
이때는 함수형 인터페이스에 default 메서드를 추가해서 해결할 수 있습니다.
@FunctionalInterface
interface Condition {
boolean check (int n) ;
default Condition and (Condition other) {
Objects.requireNonNull(other);
return (t) -> check(t) && other.check(t);
}
default Condition or (Condition other) {
Objects.requireNonNull(other);
return (t) -> check(t) || other.check(t);
}
}
and와 or는 새로운 Condition을 반환합니다.
이 새로운 Condition의 check 로직은, 원래 자신의 check 결과와 인자로 받은 other의 check 결과를 && 또는 ||로 묶은 것입니다.
default 메서드이기 때문에 추상 메서드 개수(1개)에는 영향을 주지 않고, 여전히 함수형 인터페이스 조건을 만족합니다.
이제 다음과 같이 조건을 조합해서 사용할 수 있습니다.
Condition filter2 = (n) -> n % 2 == 0 ;
Condition filter3 = (n) -> n % 3 == 0 ;
List<Integer> num2and3 = filter(numbers, filter2.and(filter3));
print(num2and3);
Condition filter5 = (n) -> n % 5 == 0 ;
Condition filter7 = (n) -> n % 7 == 0 ;
List<Integer> num5or7 = filter(numbers, filter5.or(filter7));
print(num5or7);
====출력===
[6 , 12 , 18 , 24 , 30 , 36 , ..., 96 ]
==출력종료==
====출력===
[5 , 7 , 10 , 14 , 15 , 20 , ..., 100 ]
==출력종료==
filter2.and(filter3)는 새로운 Condition 객체를 반환하고, filter 메서드는 이 조합된 Condition을 그대로 받아서 사용합니다.
조건을 조합하는 로직이 Condition 인터페이스 내부에 캡슐화되어 있기 때문에, 사용하는 쪽 코드는 매우 단순합니다.
헷갈리기 쉬운 포인트: 람다 자체는 타입이 없다 체이닝을 시도하다 보면 다음과 같은 코드를 작성하고 싶어질 수 있습니다.
filter(numbers, ((n) -> n % 2 == 0 ).and(n -> n % 3 == 0 ));
이유는 람다 표현식 자체는 고유한 타입을 가지지 않기 때문 입니다.
람다가 어떤 함수형 인터페이스로 취급될지는 컴파일러가 문맥(대입되는 변수의 타입, 메서드 파라미터의 타입 등)을 보고 결정합니다.
그런데 (n) -> n % 2 == 0 뒤에 바로 .and(...)를 호출하면, 컴파일러 입장에서는 이 람다가 아직 어떤 타입인지 확정되지 않은 상태이므로 .and()라는 메서드가 어디에 정의된 것인지 찾을 수 없습니다.
filter(numbers, ((Condition) (n -> n % 2 == 0 )).and(n -> n % 3 == 0 ));
Condition filter2 = (n) -> n % 2 == 0 ;
Condition filter3 = (n) -> n % 3 == 0 ;
filter(numbers, filter2.and(filter3));
변수에 대입하는 순간 그 변수의 타입(Condition)이 람다의 타입으로 확정되므로, 이후에는 .and(), .or() 같은 default 메서드를 자유롭게 호출할 수 있습니다.
코드 가독성 측면에서도 조건에 이름을 붙여두는 두 번째 방식이 더 낫습니다.
실제로 자바 Predicate 인터페이스또한 거의 동일한 방식으로 구현되어있습니다.
제네릭을 도입한것과 메서드명을 제외하면 거의 동일합니다.
함수형 인터페이스는 추상 메서드가 하나뿐인 인터페이스이며, 이 덕분에 람다 표현식으로 즉석에서 구현체를 만들어 전달할 수 있습니다.
반복되는 로직과 변하는 로직(조건)을 분리하고 싶을 때, 조건을 함수형 인터페이스로 추출하면 조건이 늘어나도 메서드를 추가할 필요가 없어집니다.
default 메서드를 활용하면 and, or처럼 조건을 조합하는 기능을 인터페이스 자체에 캡슐화할 수 있습니다.
람다 표현식은 그 자체로는 타입이 없으며, 대입이나 파라미터 전달 같은 문맥을 통해 타입이 확정됩니다.
람다에 바로 메서드를 체이닝하려면 캐스팅이 필요하고, 실무에서는 변수에 먼저 담아 사용하는 편이 더 명확합니다.
잘 사용하면 GoF 의 전략패턴을 구현할 시, 굉장히 깔끔한 코드를 작성할 수 있게 됩니다.
예를 들어, 회원마다 회원 등급별 할인정책을 두고 구현해야 하는 케이스입니다. (혹은 쿠폰등)
단순하게 등급에 따라 N원 할인 이라는 정책만 존재한다면 상관없지만, SILVER 등급은, 1천원 할인 , GOLD 등급 부터는, N% 할인(단 1만원 한도) 등 정책이 나눠지게 된다면 discount 로직에 case 가 추가되게 됩니다.
이경우에, 다음과 같이 할인 정책을 함수형 인터페이스로 정의해두면 매우 효율적으로 처리가 가능하게 됩니다.
enum MemberGrade { BRONZE, SILVER, GOLD, VIP }
@FunctionalInterface
interface DiscountPolicy {
int apply (int price) ;
}
@Component
public class DiscountPolicyRegistry {
private final Map<MemberGrade, DiscountPolicy> policies = new EnumMap <>(MemberGrade.class);
public DiscountPolicyRegistry () {
register(MemberGrade.BRONZE, price -> price);
register(MemberGrade.SILVER, price -> price - 1000 );
register(MemberGrade.GOLD, price -> price * 9 / 10 );
register(MemberGrade.VIP, price -> price * 8 / 10 );
}
private void register (MemberGrade grade, DiscountPolicy policy) {
policies.put(grade, policy);
}
public int calculate (MemberGrade grade, int price) {
DiscountPolicy policy = policies.get(grade);
if (policy == null ) throw new IllegalArgumentException ("등록되지 않은 등급: " + grade);
return policy.apply(price);
}
}
실제 사용하는 부분에서는, registry 에 등록된걸 가져와서 다음과 같이 계산만 하면 됩니다.
DiscountPolicyRegistry registry = new DiscountPolicyRegistry ();
registry.calculate(MemberGrade.GOLD, 10000 );
registry.calculate(MemberGrade.VIP, 10000 );
전통 방식이었다면 BronzeDiscount, SilverDiscount, GoldDiscount, VipDiscount 등급 하나 늘 때마다 클래스 파일이 하나씩 늘고, 어딘가에서 if (grade == GOLD) ... else if (grade == VIP) ... 형태의 분기문도 같이 늘어났을 것 입니다.
하지만, 만약 동일 정책(일괄적으로 N원 할인)을 가지고 있다면 오히려 복잡성이 높아질 수 있어 비추천합니다.
즉, 알아서 잘 상황에 맞게 잘 써라... 입니다.