본문 바로가기
Java/Java Basic

[Java Basic] 46 - Java Lambda식과 함수형 인터페이스

by Rosmary 2022. 9. 27.
728x90
반응형





Java는 대표적인 객체지향(Object Oriented) 언어다. 그렇기 때문에 초창기만 하더라도 C언어와 같이 함수만으로 프로그래밍을 작성하는 것이 불가능했다(Java는 함수 -매서드 -가 클래스 외부에 존재하면 에러가 발생한다).



그런데 JDK 1.8 버전에 이르러서, 함수형 인터페이스와 람다(Lambda)식 기능을 제공하면서 Java에서도 클래스를 벗어난 매서드, 즉 함수를 사용할 수 있게 되었다.

객체지향언어만을 고집하는 Java에서 왜 함수형 언어를 쓰는 것이 도움이 되는지 생각을 해보자. 코드 상에서 두 정수를 연산하는 절차를 단 한 번만 수행한다고 가정해보자. 연산은 덧셈이 될 수도, 뺄셈이 될 수도 있다. 그럼, 연산과 관련된 매서드만을 정의한 클래스를 아래와 같이 정의할 수 있을 것이다.



하지만, 단 한 번만 사용할 연산때문에 클래스 내부에 사용하지도 않을 매서드들을 잔뜩 정의하는 것은 아무래도 비효율적이다. 또한 클래스 자체도 한 번만 호출되고 나면 더 이상 사용할 일이 없음에도 불구하고 Operand의 인스턴스 변수인 Operation은 계속 메모리의 특정 위치를 가리키고 있기 때문에 자원도 쓸데없이 소모하는 꼴이 된다. 아래와 같이 인스턴스만 호출하여 사용한다면 이 문제가 해결되겠지만,



만약 매서드 내의 연산 절차를 잘못 정의한다면, 해당 클래스를 찾고, 내부의 매서드를 찾아 잘못된 부분을 고쳐야하기 때문에 추후 수정 작업 역시 시간이 많이 걸리게 된다(지금이야 아주 간단한 코드라 금방 찾지만 프로그램 크기가 커져 클래스가 수 십 개 된다고 생각해보자).


함수형 인터페이스와 람다식을 사용하면, 위에서 언급한 문제들을 한 번에 해결할 수 있다. 위의 코드를 함수형 인터페이스와 람다식을 활용하여 수정하면 아래와 같이 나타난다.

람다식 적용 코드 1
람다식 적용 코드 2




잘 보면 연산 과정에 대한 코드는 어떠한 매서드 블록 내에도 정의되어 있지 않다. 다만 operation() 함수 호출 시 넣은 람다식에 의해, 두 인자의 연산 결과가 반환된다.

본격적으로 람다식과 함수형 인터페이스에 대해 알아보자. 하지만 이 두 녀석을 소개하기 전에, 익명 클래스라는 개념을 먼저 알아야 할 필요가 있다.


1. 익명 클래스(Anonymous Class)

* 익명 클래스도 하나의 포스팅으로 설명을 해야 할 만큼 단순하지는 않다. 하지만 필자는 단순히 람다식과 함수형 인터페이스를 위한 기본적인 내용만 소개하는 것이니, 익명 클래스에 대한 내용은 다른 포스팅을 참고하시길 바란다.


스타크래프트1 게임을 보면, 영웅 유닛이 존재한다. Terran 종족의 대표적인 영웅은 Jim Raynor라는 아저씨다. 필자가 1999년에 처음 만났으니 벌써 알고 지낸 지 23년째다.

이렇게 생긴 아저씨다.



이 아저씨는 게임 미션에서 유닛 중 하나로 나타난다. 어떤 미션에서는 벌처(Vulture)라고 불리는 괴상한 차량을 타고 등장할 때도 있고, 기지 내부를 침투하는 미션에서는 해병(Marine) 유닛으로 나오기도 한다.



그럼, 스타크래프트를 개발하는 개발자들은 단 하나밖에 없는 영웅 유닛을 위해 클래스를 작성했을까? 라는 의문이 들게 된다. 일반 유닛들과 형태, 공격 방식, 이동 방식 등등은 동일하나 단지 차이가 있다면 공격력과 Health Point만 다른데, 그 차이때문에 별도의 클래스를 정의하는 것이 영 비효율적이다. 해병인 Jim Raynor만 한 번 만들어보자.



Raynor 아저씨를 클래스로 정의하면, 세상에 단 하나뿐이어야하는 영웅 유닛이 여러 기 생성 될 수 있다는 문제가 생긴다(도플갱어??). 다행히 Java는 인스턴스 뒤에 블록을 붙여 별개의 클래스를 생성할 수 있는 방법을 제공하는데, 이 기능을 통해 Raynor 아저씨가 세상에서 단 한 명만 존재할 수 있게 만들 수 있다.

attach가 아니라 attack이다. 늘상 그렇듯이 스크린샷의 오타는 수정이 귀찮으니 넘어가자.


이런 식으로 인스턴스 생성자 호출 코드 뒤에 블럭을 붙이게 되면 별도의 클래스 인스턴스가 생성된다. 지금은 Jim Raynor 아저씨를 필자가 마음대로 조종할 수 있는 이유가 참조 변수인 JimRaynor가 이 인스턴스를 가리키고 있어서인데, 만약 변수 없이 인스턴스를 생성하게 되면 Jim Raynor 아저씨는 존재하지만 필자가 어떻게 하더라도 조작할 수 없는 존재가 되어버리고 만다.

블록 뒤에 세미 콜론이 없으면 에러가 난다. 주의하자.



변수가 없는, 즉 이름이 없는 클래스라고 해서 이러한 형태로 만들어진 클래스를 익명 클래스(Anonymous Class)라고 한다. 익명 클래스는 진짜 이름이 없을까? 한 번 확인해보자.



인스턴스 내의 변수 자체가 클래스에 정의된 내용을 들고 온 것이 아니기 때문에 getClass().getName() 결과가 Marine으로 나타나지 않는다. Java 입장에서는 "내가 모르는 클래스니 이름은 호출한 Main Thread 이름 뒤에 순번을 붙여서 명명한다" 라는 알고리즘으로 클래스명을 저렇게 출력하는 것이다.

컴파일 후의 bin 파일도 확인해보면 익명 클래스 파일이 존재한다.



이렇게 객체가 프로그램 내에서 단 한 번만 사용하고자 하는 내용이 있다면, 인스턴스만 호출하여 익명 클래스를 만들면 된다.

이제 람다식으로 다시 돌아와보자. 람다식 역시 특정 절차를 단 한 번만 정의, 호출하여 사용한다. 위에서 본 익명 클래스와 연관이 있을 것이다. 어떻게 있을까?



2. 람다식(Lambda Expression)과 함수형 인터페이스(FunctioncalInterface)

먼저 람다식에 대해 알아보자. 람다식은 쉽게 말하면 매서드의 입력값과 출력값을 하나의 식으로 표현한 것이라 보면 된다. 위에서 예시로 보았던 Operand 클래스의 연산 매서드를 익명 클래스 형태로 다시 만들어보자.



합을 제외한 나머지 연산은 모두 Operand 클래스를 활용하여 익명 클래스로 생성했다. 하지만 합 역시 재사용이 되지 않는 상황이라면 굳이 Operand를 클래스로 만들 필요가 없다. 어차피 익명 클래스도 클래스를 상속받아 operation() 매서드를 재정의하는 것이기 때문이다. 상세 동작을 정의하지 않고 매서드 명세만 정의하려면 인터페이스를 사용하면 된다.


참고로 람다식을 위해 인터페이스를 정의하려면 @FunctionalInterface라는 Annotation을 적용하는 것이 좋다. @FunctionalInterface의 경우, 추상 매서드를 단 하나만 가질 수 있도록 강제하기 때문이다. 이 Annotation이 적용되면 프로그래머가 실수로 두 개 이상의 추상 매서드를 설계하는 경우 Java에서 에러를 발생시킨다(왜 단 하나의 추상 매서드만 설계하도록 하는지는 뒤에서 알아보자).



Operand 인터페이스를 통해 operation() 매서드를 정의하고 호출하는 것까지는 이해가 된다. 이제 operation() 매서드 내부의 정의된 내용을 람다식으로 바꿔야 한다. 우선, 인터페이스의 operation() 매서드를 Operand 객체 타입으로 변수를 하나 생성하고 익명 클래스 형태로 정의해보자.



함수형 인터페이스인 Operand 내부에 설계된 operation(int a, int b)를 오버라이딩으로 정의한 익명 클래스를 sum이라는 변수가 가리키도록 만들었다. 그런데, 함수형 인터페이스는 어차피 하나의 추상 매서드만을 허용하기 때문에 번거롭게 매서드 선언 부분을 다 기입할 필요가 없다. 대신, 이 부분은 람다식으로 변환이 가능하다.



람다식과 매서드 오버라이딩 방식으로 작성한 코드를 비교해보자. 매서드에서 입력 값으로 들어온 (int a, int b)가 람다식의 가장 앞 부분에 위치하며, 연산으로 반환되는 결과는 입력값과 -> 기호 뒤에 작성이 된다. 이제 합 연산 결과를 출력하는 System.out.printf() 부분을 람다식이 적용된 변수를 활용하여 바꿔보자.



연산 결과가 동일하게 잘 나타나는 것이 확인된다. 여기서 눈여겨봐야 할 부분은 변수 sumLambda의 클래스명인데, 의외로 sumLambda 클래스명이 아니라 익명의 Lambda 객체명으로 나타나는 것이 확인된다. 즉, 람다식은 클래스의 일종인 인터페이스를 참고하기는 하지만 익명 클래스처럼 생성된다. 람다식을 적용하지 않은 sum 변수가 익명 클래스 형태(Main$1)로 생성되는데, 여기에 람다가 적용되기 때문에 클래스명을 출력하면 위와 같이(Main$$Lambda$1) 나타나게 되는 것이다.



함수형 인터페이스의 경우 반드시 추상 매서드가 하나만 존재해야하는데, 사실 너무 당연하다. 두 개 이상의 매서드가 존재하면 람다식을 사용하면 어떤 매서드에 람다식을 적용할지에 대해 고민을 해야되는데, 이렇게되면 함수형으로 프로그램을 작성하는 것이 아니라 객체지향으로 언어를 작성하는 것과 별반 달라지지 않기 때문이다. 하지만 프로그래머가 코딩 중 실수로 함수형 인터페이스에 둘 이상의 매서드를 적용할 수 있는 가능성도 없지는 않기 때문에, 이러한 일이 발생하는 것을 방지하기 위해 @FunctionalInterface Annotation을 적용할 수 있도록 만든 것이다.

정리하자면, 람다식은 함수형 인터페이스 내부의 매서드의 연산이 자주 사용되지도 않고, 상황에 따라 다양하게 사용되어야 할 때, 연산 절차를 간단하게 함수형으로 정의할 수 있도록 만들어진 기능이라 보면 된다. 위의 Operand 객체 타입 변수인 sumLambda는 함수형 언어인 c 언어에서 다음과 같이 나타낼 수 있다.


int sumLambda(int a, int b) { return a + b ; } // 함수 정의
sumLambda(1, 2) => 3 // 함수 호출 및 결과 반환


Java 람다식이 적용된 Operand sumLamda와 비교해보자.


Operand sumLambda = (int a, int b) -> a + b;
sum(a, b) { sumLambda.operation(a, b) }; // 함수 정의
sum(1, 2) => 3 // 함수 호출 및 결과 반환


참고로 아래와 같은 코드는 Java에서 인식할 수 없는데, 람다식이 익명 객체이기 때문에 어떠한 클래스에서도 아래 코드에 대한 내용을 호출할 수 없기 때문이다.


( (int a, int b) -> a + b ).operation(1, 2); // operation은 Operand 클래스에 정의되어 있으나 람다식은 익명 클래스임.


따라서 람다식의 경우 최상위 객체인 Object로의 형변환이 불가능하다. 단지 함수형 인터페이스 객체 타입으로만 형 변환이 가능하다.

만약 람다식을 사용하여 함수 실행 결과까지 출력하고자 한다면 함수형 인터페이스 추상 매서드의 반환 타입 수정, 람다식의 결과 연산 부분을 화면 출력 코드로 변경하면 된다.






3. java.util.function 패키지 클래스

그런데, 하나 생각을 해보자. 필자가 Python에서도 한 번 소개한 내용이긴 한데, 프로그래밍의 함수, 매서드는 입력, 출력값을 통해 연산을 진행하며 이에 따라 4가지 형태가 존재한다고 Java에서도 설명을 진행한 적이 있다.

* 입력값, 출력값이 없는 함수/매서드
* 입력값만 존재하는 함수/매서드
* 출력값만 존재하는 함수/매서드
* 입력값, 출력값이 모두 존재하는 함수/매서드

람다식을 프로그래머가 일일이 만들기에는 뭔가 귀찮다. 다행히 Java는 java.util.function 이라는 패키지 안에, 입력 매개변수와 출력값에 따라 사용할 수 있도록 몇 가지 함수형 인터페이스를 정의해 두었다.

* java.lang.Runnable : 입력값, 출력값이 없는 람다식 생성 시 사용. 실행은 run() 매서드 사용.
* java.util.funciton.Consumer: 입력값만 존재하는 람다식 생성 시 사용. 실행은 accept() 매서드 사용
* java.util.function.Supplier: 출력값만 존재하는 람다식 생성 시 사용. 실행은 get() 매서드 사용
* java.util.function.Function: 입력값 / 출력값이 모두 존재하는 람다식 생성 시 사용. 실행은 apply() 매서드 사용
* java util.function.Predicate: 입력값 / 출력값이 모두 존재하나 출력값이 Boolean인 람다식 생성 시 사용.
실행은 test() 매서드 사용


Consumer와 Supplier가 기능이 조금 헷갈리는데, Supplier는 나에게 값을 제공하는 것, Consumer는 내가 값을 소모(입력)하는 것이라 생각하면 편하다. 이들 클래스는 프로그래머가 쓸데없이 함수형 인터페이스를 생성하는데 시간을 보내지 않도록 만들어준다. 사용 예시를 보자.


코드를 분석해보면 사용 방법은 크게 어렵지 않다는 것을 알 것이다.

Runnable을 제외한 나머지는 Generics가 적용되어 있는데, JavaDocumentation에서는 T와 R로 표시되어 있다. T는 Type의 약자로, 입력 매개변수의 타입을 지정하는 부분이고, R은 ReturnType의 약자로 결과값의 타입을 지정한다. 예를 들어 Function<Integer, Integer>로 선언한다면 해당 람다식은 반드시 정수형으로 입/출력값이 나타나도록 정의해야 한다는 말이다. 자세한 것은 Generics 관련 포스팅을 참고하자.

만약 매개 변수가 하나가 아니고 2개인 경우에는 다른 함수형 인터페이스를 사용해야한다. 위에서 등장한, 입력값이 사용가능한 함수형 인터페이스 이름 앞에 숫자 2를 뜻하는 Bi만 붙여주면 된다.



입력값으로 사용할 매개인자가 3개 이상인 경우에는 function 패키지 내에 정의된 클래스가 없기 때문에 직접 정의해서 사용해야 한다.

간간히 Java Documentation을 보다보면 UnaryOperator와 BinaryOperator로 끝나는 이름의 함수형 인터페이스가 객체 타입으로 지정되어 나타나는 경우가 있는데, 이 둘은 Function / BiFunction과 동일한 역할을 한다. 단지 입력, 출력값의 타입이 동일하다는 차이만 존재한다. 이 인터페이스로부터 파생된 몇몇 함수형 인터페이스가 존재하는데, IntUnaryOperator, DoubleUnaryOperator 등이 있다.

Arrays.setAll의 Documentation 자료 일부 내용이다.






4. 둘 이상의 Function 순서 지정 및 둘 이상의 Predicate의 결합

Function과 Predicate 인터페이스의 Documentation을 보면, 반드시 존재해야하는 단 하나의 추상 매서드 외에 default 제어자, 혹은 static 매서드가 정의되어 있는 것을 볼 수 있다.



Function 인터페이스에 정의된 매서드 중 andThen(), compose()라는 이름의 매서드가 보이는데, 이들이 매개인자로 받아들이는 값이 Function이다. Function 객체에서 Function을 받는 경우를 유추해보면, 함수형 인터페이스의 결과값을 받아 매개인자에 정의된 함수형 인터페이스에 적용하던가, 반대로 매개 변수의 결과를 받아 호출 객체인 함수형 인터페이스에 적용하는 것이 있을 것이다.

andThen() 매서드는, 매서드를 호출한 인스턴스 Function의 람다식을 먼저 실행한 뒤, 그 결과를 매개인자에 정의된 함수형 인터페이스의 람다식에 적용하고, compose는 그 반대 역할을 한다. 매서드와 달리 함수형 인터페이스는 익명 클래스 객체로부터 유래하기 때문에 위의 매서드를 사용하여 서로 다른 함수형 인터페이스를 합성하여 사용할 수 있도록 한다.

아래의 코드를 보자.



서로 다른 람다식을 적용하여 원하는 결과를 내기 위해 위와 같이 코드를 작성했다. 위 코드는 아래와 같이 하나로 합쳐 가독성을 높일 수 있다.



그런데 저렇게 Function 결과를 apply() 매서드만으로만 합성하는 것이 필자가 그토록 많이 이야기하는 수정의 용이성이 좋지 않다는 문제가 발생한다. 만약 저 두 함수형 인터페이스의 람다형을 하나로 합친 새로운 Function 객체를 만들면 조금 더 낫지 않을까?



andThen() 매서드를 사용하면 매서드를 호출한 객체의 람다식 결과가 매개변수로 넘어간다. 위의 예시는 multiply2가 먼저 실행되어 4라는 값이 결과로 나왔고, 이 값이 sqrt의 입력값으로 적용되어 최종 결과가 16으로 나온 것이다. compose()를 사용하면 반대의 효과가 나타나게 되어 결과가 8이 출력된다.



그럼, Function의 합성은 어떠한 경우에 유리할까? 연산이 여러 절차로 나눠져 있으며, 각 절차로 나타나는 결과로 반환되는 값의 타입이 각기 다른 경우 활용하기 편하다. 문자열로 저장된 16진법의 수를 문자열 2진법으로 변경하는 방법에 대해 코드로 작성해보자. 우선 16진법 문자열을 일반 정수형 10진법으로 변경해야한다. 그 다음 결과값을 가지고 2진법으로 변경한 뒤, 그 값을 문자열로 형변환해야한다.



andThen()과 compose() 매서드를 사용하면 함수형 인터페이스의 재활용이 가능하며, 심지어 이 함수형 인터페이스를 합성함으로써 새로운 함수형 인터페이스를 창조하는 것도 가능해진다. 또한 기능 자체가 세부 단위로 정의되어 있기 때문에 수정이 필요한 경우에도 코드를 뒤적거리며 시간을 보내는 일도 줄어들게 된다.


Predict 함수형 인터페이스 기능 역시 정의된 default, static 매서드가 Function과 크게 다르지 않다. and(), or() 는 이미 조건식에서 보았던 &&, || 연산자와 동일한 기능을 하기 때문에 설명이 필요없을 듯 하다. 추가로 봐야 할 내용이 negate() 매서드인데, 이 매서드를 호출한 결과의 역을 반환한다는 기능을 한다. 방화벽 관리자 분들이라면 많이 보셨을 단어일 것이다.




5. 함수형 인터페이스의 매서드 참조

문자열로 정의된 숫자를 10진법 정수형으로 변경하는 코드를 람다식으로 작성해보자. 이 Function 객체에 정의된 람다식은 Wrapper 클래스인 Integer의 valueOf() 매서드를 호출하고 있다.

박스안에 오타가 있는데, valueOf(a)다...



그런데, 생각을 해보자. 어차피 Function은 인자를 하나만 받고 있다는 것을 필자가 알고, JVM도 알고 모두가 알고 있다. 굳이 람다식에 입력값과 관련된 내용을 작성해 줄 필요가 없을 듯 하다. 물론, 연산 절차에 기본 연산자(+, -, *, /, %, <<, >> 등)를 사용한다면 이를 무시할 수 없겠지만, 특정 클래스의 매서드가 어차피 하나인 매개변수를 받는다면 (a)는 정말 불필요하다. 식을 조금 더 간단하게 변경해보자.



람다식 대신, 단지 Wrapper 클래스와 매서드 명만, 콜론 두 개로 구분하여 작성했는데, 아무런 에러 없이 잘 동작하는 것이 확인된다. 이렇게 함수형 인터페이스를 클래스명과 매서드로만 정의하는 방식을 "매서드 참조"라고 한다. 지금같이 static 매서드인 valueOf() 뿐만 아니라, 인스턴스 매서드, 또는 특정 객체의 인스턴스메서드를 호출하는 것도 가능하다.



심지어 클래스의 생성자를 호출하는 절차 역시 매서드 참조로 작성할 수 있다.



만약 매개변수가 없거나 매개변수가 2 개인 생성자라면 Function 대신 Supplier 또는 BiFunction 함수형 인터페이스를 사용하여 매서드 참조를 활용할 수 있다.



다음 포스팅에서는 Stream에 대해 알아보려 한다.



Fin.

반응형

댓글