Page tree
Skip to end of metadata
Go to start of metadata

Introduction

이 글에서는 Java Standard Development Kit Specification 8(JSDK 8)에서 제안된 Functional Interface(함수형 인터페이스)와 그에 따라 제안된 @FunctionalInterface 어노테이션, 그리고 Lambda Expresstion(람다식)에 대해 다룹니다.

Functional Interface는 함수를 일급객체로서 사용할 수 없는 자바 언어의 단점을 보완하기 위해 도입됐으며, 이 덕분에 자바 언어는 전보다 간결한 표현이 가능해졌고 가독성(readability)이 높아지게 되었습니다. 이 글에서는 Functional Interface의 정의, JSDK8에 도입되어 있는 대표적인 Functional Interface들과 그 사용법, 그리고 마지막으로 그와 관련된 Lambda Expression에 대한 상세한 내용을 다룰 것입니다.

Prerequisite

이 글을 이해하기 위해 다음 지식이 필요합니다.

  • 객체 지향 프로그래밍과 객체, 클래스, 인터페이스의 관계
  • Java Generic Type
  • 메소드와 함수의 차이
  • 일급 객체

이 글에 포함된 예제를 실행하기 위해 다음 프로그램이 설치되어 있어야 합니다.

  • JSDK 8 이상
  • IDE (Eclipse / IntelliJ / NetBeans 등)

 

1. Functional Interface

Functional Interface는 일반적으로 '구현해야 할 추상 메소드가 하나만 정의된 인터페이스'를 가리킵니다. Java Language Specification의 설명을 인용하면 다음과 같습니다.

functional interface is an interface that has just one abstract method (aside from the methods of Object), and thus represents a single function contract.

Functional Interface는 (Object 클래스의 메소드를 제외하고) 단 하나의 추상 메소드만을 가진 인터페이스를 의미하며, 그런 이유로 단 하나의 기능적 계약을 표상하게 된다.

 

예를 들어 다음과 같습니다.

Functional Interface 예시
//Functional Interface인 경우: 메소드가 하나만 있음
public interface Functional {
	public boolean test(Condition condition);
}
 
//java.lang.Runnable도 결과적으로 Functional Interface임
public interface Runnable {
	public void run();
}
 
//구현해야 할 메소드가 하나 이상 있는 경우는 Functional Interface가 아님
public interface NonFunctional {
	public void actionA();
	public void actionB();
}

매우 간단한 듯 하면서도 'Object 클래스의 메소드를 제외하고'라는 이상한 단서가 붙어있다는 것이 눈에 거슬립니다.

이는 자바 언어에서 사용되는 모든 객체들이 Object 객체를 상속하고 있다는 것을 기억하면 쉽게 이해할 수 있습니다. 결과적으로 인터페이스 구현체들이 Object 객체의 메소드를 굳이 재정의하지 않아도 그 메소드들을 소유할 수 있으므로, 이 메소드들은 Functional Interface의 대상이 되지 않는 것입니다.

인터페이스에 Object의 메소드들이 포함되어 있어도 무방함
//Object 객체의 메소드만 인터페이스에 선언되어 있는 경우는 Functional Interface가 아님
public interface NotFunctional {
	public boolean equals(Object obj);
}
 
//Object 객체의 메소드를 제외하고 하나의 추상 메소드만 선언되어 있는 경우는 Functional Interface임
public interface Functional {
	public boolean equals(Object obj);
	public void execute();
}
 
//Object객체의 clone 메소드는 public 메소드가 아니기 때문에 Functional Interface의 대상이 됨
public interface Functional {
	public Object clone();
}
public interface NotFunctional {
	public Object clone();
	public void execute();
}

 

이 외에도 method 재정의 규격, 인터페이스 상속 등등에 영향을 받을 수 있습니다. 이에 대한 상세한 내용은 https://docs.oracle.com/javase/specs/jls/se8/html/jls-9.html#jls-9.8에서 참고할 수 있습니다.


 

2. @FunctionalInterface Annotation

지금까지 최대한 단순하게 설명하였지만, 작성한 인터페이스가 Functional Interface인지 확인하는 것은 Java Specification을 확실히 이해하고 있지 않으면 어려운 일입니다. 그런 이유로, Java SDK 8에서는 @FunctionalInterface라고 하는 어노테이션을 제공하여 작성한 인터페이스가 Functional Interface인지 확인할 수 있도록 하고 있습니다.

Java Specification에는 다음과 같이 @FunctionalInterface가 설명되어 있습니다.

The annotation type FunctionalInterface is used to indicate that an interface is meant to be a functional interface (§9.8). It facilitates early detection of inappropriate method declarations appearing in or inherited by an interface that is meant to be functional.

It is a compile-time error if an interface declaration is annotated with @FunctionalInterface but is not, in fact, a functional interface.

Because some interfaces are functional incidentally, it is not necessary or desirable that all declarations of functional interfaces be annotated with @FunctionalInterface.

어노테이션 타입 FunctionalInterface는 어떤 인터페이스가 Functional Interface라는 것을 나타내기 위해 사용된다. 이것을 이용하면 부적절한 메소드 선언이 포함되어 있거나 함수형이어야 하는 인터페이스가 다른 인터페이스를 상속한 경우 미리 확인할 수 있다.

@FunctionalInterface로 지정되어 있으면서 실제로는 Functional Interface가 아닌 인터페이스를 선언한 경우 컴파일 타임 에러가 발생한다.

어떤 인터페이스들은 우연히 함수형으로 정의될 수도 있기 때문에, Functional Interface들이 모두 @FunctionalInterface 어노테이션으로 선언될 필요도 없고 그렇게 하는 것이 바람직하지도 않다.

즉, @FunctionalInterface는 작성한 인터페이스가 Functional Interface임을 확실히 하기 원할 때에만 사용하면 됩니다.


 

3. Lambda Expression

드디어 Lambda Expression(람다식)에 대해 다룰 차례입니다. 람다식은 Java SDK 8에 처음 도입되어 자바언어의 표현력을 한 단계 끌어올렸다는 평가를 받는 표현식입니다. Java Specification에서는 람다식에 대해 다음과 같이 설명하고 있습니다.(https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.27)

A lambda expression is like a method: it provides a list of formal parameters and a body - an expression or block - expressed in terms of those parameters.

(중간 생략)

Evaluation of a lambda expression produces an instance of a functional interface (§9.8). Lambda expression evaluation does not cause the execution of the expression's body; instead, this may occur at a later time when an appropriate method of the functional interface is invoked.

람다식은 메소드와 비슷하다: 규격을 갖춘 파라미터의 목록과 그 파라미터를 이용해서 표현된 몸통(표현식이든 블록이든)을 제공한다.

(중간 생략)

람다식이 평가(evaluation)되면 그 결과 Functional Interface의 인스턴스를 생성한다. 람다식의 처리 결과는 표현식 몸통을 실행하는 것이 아니다; 대신 나중에 이 Functional Interface의 적절한 메소드가 실제 호출(invoke)될 때 이것(표현식 몸통의 실행)이 일어난다.

 

지금까지 Functional Interface에 대해 길게 설명했는데, 이제서야 그 이유가 설명되었습니다. 람다식의 평가 결과가 Functional Interface의 인스턴스라니 말입니다. 그렇다면 람다식은 어떻게 사용하는 것일까요? 다시 한 번 Java Specification을 살펴보기로 합니다.

Lambda Expression 표기법
() -> {}                // No parameters; result is void
() -> 42                // No parameters, expression body
() -> null              // No parameters, expression body
() -> { return 42; }    // No parameters, block body with return
() -> { System.gc(); }  // No parameters, void block body

() -> {                 // Complex block body with returns
  if (true) return 12;
  else {
    int result = 15;
    for (int i = 1; i < 10; i++)
      result *= i;
    return result;
  }
}                          

(int x) -> x+1              // Single declared-type parameter
(int x) -> { return x+1; }  // Single declared-type parameter
(x) -> x+1                  // Single inferred-type parameter
x -> x+1                    // Parentheses optional for single inferred-type parameter

(String s) -> s.length()      // Single declared-type parameter
(Thread t) -> { t.start(); }  // Single declared-type parameter
s -> s.length()               // Single inferred-type parameter
t -> { t.start(); }           // Single inferred-type parameter

(int x, int y) -> x+y  // Multiple declared-type parameters
(x, y) -> x+y          // Multiple inferred-type parameters
(x, int y) -> x+y    // Illegal: can't mix inferred and declared types
(x, final y) -> x+y  // Illegal: no modifiers with inferred types 

실제로 람다식에 대한 Java Specification 내용을 보면 생각보다 많이 복잡합니다. 위의 예를 봐도 생각보다 만만치 않구요. 딱 눈에 들어오는 간단한 예는 없을까요?

아래 코드는 사칙연산에 대한 연산자를 표상하는 Functional Interface인 ArithmeticOperator와 람다식을 같이 사용하는 예제를 보여줍니다.

@FunctionalInterface ArithmeticOperator
@FunctionalInterface
public interface ArithmeticOperator {
	public int operate(int a, int b);
}
ArithmeticCalculator
@FunctionalInterface
public class ArithmeticCalculator {
    /**
     * 실제 계산은 ArithmeticCalculator에 위임한다.
     */
	public static int calculate(int a, int b, ArithmeticOperator operator){
		return operator.operate(a, b);
	}
} 
ArithmeticCalculatorTest
public class ArithmeticCalculatorTest {
	@Test
	public void testPlus{
		int firstNumber = 5;
        int secondNumber = 94;
 
        int result = ArithmeticCalculator.calculate(firstNumber, secondNumber, (a, b) -> a + b);
        Assert.assertEquals(firstNumber + secondNumber, result);
	}
} 

위와 같이 ArithmeticOperator 구현체가 예상되는 곳(7 라인, 세 번째 파라미터)에 람다식이 입력되어 있습니다.  이미 설명했던 대로, 이 람다식은 ArithmeticOperator의 인스턴스를 생성하게 되는데, 다음과 같은 의미를 합니다.

  • (a, b) : ArithmeticOperator.operate 메소드의 두 개의 파라미터를 순서대로 표상함. 둘 다 int type을 가질 것으로 추론될(inferred) 수 있다.
  • a + b : 람다식 몸통. 중괄호로 묶여 있지 않으므로, 이 처리 값이 리턴될 것이다.

이 람다식은 정확히 아래 클래스 코드와 동일하게 동작합니다.

ArithmeticOperator 구현체 - PlusOperator
// 이 클래스를 이용하면 람다식 대신 아래와 같이 실행된다.
// ArithmeticCalculator.calculate(firstNumber, secondNumber, (a, b) -> a + b); 대신 ArithmeticCalculator.calculate(firstNumber, secondNumber, new PlusOperator());
public class PlusOperator implements ArithmeticOperator {
	@Override
	public int operate(int a, int b) {
		return a + b;
	}
}

이와 같이 람다식은 Functional Interface의 계약에 근거한 "추론"을 통해 인스턴스를 생성합니다. 이 추론이 가능한 이유는 Functional Interface가 단 하나의 메소드만을 갖기로 전제했기 때문입니다. 어떤 모양이든 간에 람다식은 새로운 클래스를 만들어야만 인스턴스 생성이 가능하던 자바 언어의 한계를 넘게 해 주었고 그만큼 자바 언어의 표현력을 강화했다는 것에서 중요하다 할 수 있습니다.

다음은 람다식을 이용한 사칙연산 프로그램의 예제 소스입니다. 아래 예제에서는 굳이 새로운 인터페이스를 생성하지 않고 java.util.function.Function 인터페이스를 사용하여 람다식을 처리하는 방법에 대해서도 다루고 있습니다. (java.util.function.Function 인터페이스에 대한 내용은 다음 단락에서 상세히 다룹니다.)

자체 생성 ArithmeticOperator 인터페이스를 이용하는 경우
// 자체 생성한 인터페이스
@FunctionalInterface
public interface ArithmeticOperator {
	public int operate(int a, int b);
}
 
// 자체 생성한 ArithmeticOperator 인터페이스를 사용하는 경우
public void testArithmeticOperator() {
	ArithmeticOperator plusOperator = (a, b) -> a + b;
	ArithmeticOperator minusOperator = (a, b) -> a - b;
	ArithmeticOperator multiplyOperator = (a, b) -> a * b;
	ArithmeticOperator divideOperator = (a, b) -> {
		if (b == 0) {
			b = 1;
		}
		return a / b;
	};
	ArithmeticOperator spareOperator = (a, b) -> {
		if (b == 0) {
			b = 1;
		}
		return a % b;
	};
	int a = new Random().nextInt(10000);
	int b = new Random().nextInt(10000);
	int plus = plusOperator.operate(a, b);
	int minus = minusOperator.operate(a, b);
	int multiply = multiplyOperator.operate(a, b);
	int divide = divideOperator.operate(a, b);
	int spare = spareOperator.operate(a, b);

	Assert.assertEquals(a + b, plus);
	Assert.assertEquals(a - b, minus);
	Assert.assertEquals(a * b, multiply);
	Assert.assertEquals(a / b, divide);
	Assert.assertEquals(a % b, spare);
}
java.util.function.Function 인터페이스를 이용하는 경우
// java.util.function.Function 인터페이스의 apply 메소드가 하나의 파라미터만 받을 수 있기 때문에 정의한 클래스
public class TwoNumbers {
	private int first;
	private int second;
	public TwoNumbers(int first, int second) {
		this.first = first;
		this.second = second;
	}
	public int getFirst() {
		return first;
	}
	public void setFirst(int first) {
		this.first = first;
	}
	public int getSecond() {
		return second;
	}
	public void setSecond(int second) {
		this.second = second;
	}
}
 
// 별도의 인터페이스나 메소드 없이 java.util.function.Function 인터페이스를 직접사용
@Test
private void testFunction() {
	Function<TwoNumbers, Integer> plusOperator = n -> n.getFirst() + n.getSecond();
	Function<TwoNumbers, Integer> minusOperator = n -> n.getFirst() - n.getSecond();
	Function<TwoNumbers, Integer> multiplyOperator = n -> n.getFirst() * n.getSecond();
	Function<TwoNumbers, Integer> divideOperator = n -> {
		if (n.getSecond() == 0) {
			return 0;
		}
		return n.getFirst() / n.getSecond();
	};
	Function<TwoNumbers, Integer> spareOperator = n -> {
		if (n.getSecond() == 0) {
			return 0;
		}
		return n.getFirst() % n.getSecond();
	};
	TwoNumbers numbers = new TwoNumbers(new Random().nextInt(10000), new Random().nextInt(10000));
	int plus = plusOperator.apply(numbers);
	int minus = minusOperator.apply(numbers);
	int multiply = multiplyOperator.apply(numbers);
	int divide = divideOperator.apply(numbers);
	int spare = spareOperator.apply(numbers);

	Assert.assertEquals(numbers.getFirst() + numbers.getSecond(), plus);
	Assert.assertEquals(numbers.getFirst() - numbers.getSecond(), minus);
	Assert.assertEquals(numbers.getFirst() * numbers.getSecond(), multiply);
	Assert.assertEquals(numbers.getFirst() / numbers.getSecond(), divide);
	Assert.assertEquals(numbers.getFirst() % numbers.getSecond(), spare);
}

위의 예에서는 파라미터를 하나만 받는 java.util.function.Function 인터페이스를 사용했기 때문에 TwoNumbers라는 객체를 이용하였습니다만, java.util.function.BiFunction 인터페이스를 이용하면 TwoNumbers 객체 없이 바로 람다식만으로 두 개 파라미터를 처리할 수 있습니다.

자세한 내용은 java.util.function.BiFunction API를 확인하시기 바랍니다.

 

그 외에 람다식의 다양한 표현 방식이나 Scope와 같은 상세한 내용은 Java Specification을 참고하시기 바랍니다.


 

4. java.util.function 패키지 인터페이스와 java.util.stream.Stream 인터페이스

Java SDK 8의 java.util.function 패키지에는 수많은 Functional Interface들이 등록되어 있습니다. 이 패키지에 등록되어 있는 모든 인터페이스들은 @FunctionalInterface로 지정되어 있으며 API 문서에는 다음과 같은 설명이 추가되어 있습니다.

This is a functional interface and can therefore be used as the assignment target for a lambda expression or method reference.

이것은 Functional Interface이며 그러므로 람다식이나 메소드 레퍼런스를 위한 할당 대상으로 사용될 수 있습니다.

이 장에서는 java.util.function 패키지의 대표적인 인터페이스에 대해 설명한 뒤, 이 인터페이스들을 이용하는 java.util.stream.Stream 인터페이스에 람다식을 적용하는 방법에 대해 설명합니다.

 

java.util.function 패키지의 대표적인 세 개의 인터페이스는 다음과 같습니다. (그 외의 다른 인터페이스들은 몇 개를 제외하면 모두 아래 인터페이스의 변형입니다.)

Interface 명설명
Consumer<T>

void accept(T) 메소드가 선언되어 있는 인터페이스. 입력된 T type 데이터에 대해 어떤 작업을 수행하고자 할 때 사용한다.

리턴타입이 void이므로 처리 결과를 리턴해야 하는 경우에는 Function 인터페이스를 사용해야 한다. 혹은 call by reference를 이용하여 입력된 데이터의 내부 프러퍼티를 변경할 수도 있다.

Function<T, R>

R apply(T) 메소드가 선언되어 있는 인터페이스. 입력된 T type 데이터에 대해 일련의 작업을 수행하고 R type 데이터를 리턴할 때 사용한다.

입력된 데이터를 변환할 때 사용할 수 있다.

Predicate<T>boolean test(T) 메소드가 선언되어 있는 인터페이스. 입력된 T type 데이터가 특정 조건에 부합되는지 확인하여 boolean 결과를 리턴한다.

뭔가 복잡해보이지만 사실은 매우 단순합니다. 인터페이스 명 그대로, 어떤 데이터를 소비(Consume)하고 어떤 기능(Function)을 수행하며 어떤 근거(Predicate)을 확인하기 위해 사용하게 됩니다. 그런데 이것들은 어디에 사용되는 것일까요?

이 인터페이스들은 바로 Java SDK 8에 새롭게 도입된 java.util.stream.Stream 인터페이스와 결합되어 매우 강력한 기능을 제공하게 됩니다. 예제를 통해 이 강력함을 확인해보도록 하겠습니다. (이 예제는 Oracle에서 제공하는 Lambda Expression Tutorial에서 부분 발췌하였습니다.)

 

Person이라는 데이터 목록(List)로부터 나이가 일정 범위에 들어있는 데이터에 대해 출력하는 메소드를 작성하려 합니다. 람다식을 이용하지 않는다면 아래와 같은 코드가 만들어질 것입니다.

without Lambda Expression
public static void printPersonsWithinAgeRange(
    List<Person> roster, int low, int high) {
    for (Person p : roster) {
        if (low <= p.getAge() && p.getAge() < high) {
            p.printPerson();
        }
    }
} 
 
// 사용하기
printPersonsWithinAgeRange(roster, 10, 20);

하지만 이 코드는 Person 목록에 대해 "나이가 일정 범위에 있는 Person에 대해 printPerson한다" 라는 기능밖에 수행하지 못합니다. 이름이 A로 시작한다거나, 남자이거나... 등등 다른 조건이 필요한 경우엔 그 때마다 새로운 메소드를 만들어줘야 합니다.

이 문제를 해결하기 위해 Java Collection API는 Stream이라는 인터페이스를 제안하였고 이 Stream과 람다식을 이용하여 매우 간단하게 이런 기능을 처리할 수 있습니다. 새로운 클래스나 메소드를 만드는 대신 말이죠.

Stream과 람다식
// 메소드나 클래스 생성 없이 바로 사용. roster 객체는 List 타입의 인스턴스
roster
    .stream()
    .filter( p -> p.getAge() >= 10 && p.getAge() <= 20 )
    .forEach( p -> p.printPerson() ); 

이 코드는 위에서 다뤘던 printPersonsWithinAgeRange 메소드와 완벽히 동일한 방식으로 동작합니다.  List 인스턴스인 roster의 stream() 메소드를 호출하여 Stream 타입의 인스턴스를 얻은 후, 그에 대해 Stream.filter(Predicate) 메소드와 Stream.forEach(Consumer) 메소드를 체인(chain) 형식으로 호출하고 있습니다. 즉, Stream 인터페이스의 메소드들이 위에서 다뤘던 java.util.function 패키지의 인터페이스들을 사용하도록 되어 있는 것입니다.

Stream 인터페이스의 기능은 매우 많으므로 대표적으로 많이 사용되는 메소드에 대해 설명하면 다음과 같습니다.

Method Spec설명
Stream<T> filter(Predicate<? super T> predicate)

입력된 predicate에 부합되는 목록만을 필터링한다.

filter 메소드를 연쇄적으로 사용할 수도 있으나 그것보다는 하나의 filter 메소드 내의 람다식에서 조건을 조합하는 것이 바람직하다.

즉, filter(p -> p.getAge() > 10).filter(p -> p.getAge() < 20) 보다는 filter(p -> p.getAge() > 10 && p.getAge() < 20) 이 낫다.

<R> Stream<R> map(Function<? super T, ? extends R> mapper)

현재 Stream으로부터 전달된 T 타입 데이터들을 R 타입 데이터 스트림으로 변환한다. 예를 들어 다음과 같이 사용할 수 있다.

roster
    .stream()
    .filter(
        p -> p.getGender() == Person.Sex.MALE
            && p.getAge() >= 18
            && p.getAge() <= 25)
    .map(p -> p.getEmailAddress())
    .forEach(email -> System.out.println(email));

한 번 변환한 뒤에는 이 메소드 체인(chain) 내에서는 원본을 얻을 수 없다는 점에 주의해야 한다. (원본에 변형을 가하는 것은 아니다.)

void forEach(Consumer<? super T> action)  현재 Stream의 하나 하나의 Element들에 대해 특정 작업을 수행할 때 사용한다.

위 세 가지가 Stream 인터페이스의 대표적인 메소드이지만 이보다도 훨씬 많은 메소드가 정의되어 있습니다. 이에 대해서는 다른 글에서 다루도록 하겠습니다.


 

Conclusions

이 장에서 다룬 내용은 다음으로 요약될 수 이 있습니다.

  1. Functional Interface는 Object 클래스의 메소드를 제외하고 단 하나의 메소드만 가지고 있는 인터페이스를 의미합니다.
  2. 신규 작성한 인터페이스를 Functional Interface로 제한하고 싶은 경우 @FunctionalInterface 어노테이션을 지정합니다.
  3. 람다식은 기본적으로 "파라미터부 -> {몸통부}" 의 형태를 띄며 평가 결과로 Functional Interface의 인스턴스를 생성한다.
  4. Stream 인터페이스는 람다식과 결합하여 List를 일괄적으로 처리할 수 있도록 도와준다. 대표적인 메소드로 filter, map, forEach 등이 있다. 
    이 API를 이용하면 클래스나 메소드를 만들지 않고도 효과적이고 가독성 높은 코드를 작성할 수 있다.