Programming/Java

자바 기초부터 모던 자바까지 - 람다편

KOOCCI 2022. 7. 6. 01:09

목표 : 람다가 필요한 이유와 자바에서 적용된 내용을 알아본다.


이전 함수형 프로그래밍 포스트에 이어서, 람다에 대해 하나씩 알아가볼 예정이다.

 

자바에 람다와 함수형 프로그래밍이 왜 필요하지?

아래와 같이 정리해보고자 한다.

  • 이름 없는 함수를 선언할 수 있다. 메서드는 반드시 특정 클래스나 인터페이스 안에 포함되어야 하고 메서드의 이름이 있어야 하지만, 람다 표현식은 이러한 제약에서 벗어난다. 즉, 유연성이 생긴다.
  • 소스 코드의 분량이 획기적으로 줄어들 수 있다. 반복적인 작업이 필요한 기존소스의 비효율성을 낮출 수 있다.
  • 코드를 파라미터로 전달할 수 있다. 외부에서 동작을 정의해서 메서드에 전달할 때 편리하게 사용할 수 있다.

기존의 자바에서 탈피하여 지금 우리가 람다를 공부하는 이유다.

람다 표현식을 조금더 구체적으로 알아보자.

람다는 축약이 매우 많다. 따라서 한번에 이해하기가 어려울 수 있으니, 하나씩 차근히 바꾸어 나가보려고 한다.

public static void main(String[] args) {
    //쓰레드를 생성한다.
    Thread thread = new Thread(new Runnable() {
        // run 메서드를 구현한다.
        @Override
        public void run() {
            System.out.println("Hello World");
        }
    });
}

쓰레드 프로그래밍의 Runnable 인터페이스의 run 메서드다.

익명 클래스를 이용해서 내용을 구현하고 있는 것을 볼 수 있다. (Runnable)

인터페이스를 생성자의 파라미터로 받거나 메서드의 파라미터로 받아서 처리할 때 유용하게 사용할 수 있다.

이때, 주요하게 봐야할 점은 패턴이 반복된다는 것이다.

  1. 메서드의 이름
  2. 메서드에 전달되는 파라미터 목록
  3. 메서드를 구현한 본문
  4. 메서드의 리턴 타입

위 4가지는 메서드의 필수 규격이다.

중요도로 따졌을 때, 파라미터 목록, 그리고 본문이 제일 중요하다.

그리고 리턴 타입이 중요하며 마지막으로 이름은 제일 중요하지 않다.

람다에서는 리턴 타입과 이름을 과감히 생략해버린다.

 

람다 표현식으로 전환해보자.

1단계 : 익명 클래스 선언 부분 제거

//쓰레드를 생성한다.
Thread thread = new Thread(
    // run 메서드를 구현한다.
    @Override
    public void run() {
        System.out.println("Hello World");
    }
);

실제로 동작하는 소스는 아니지만, new Runnable 부분을 삭제 했다.

Thread 의 생성자 인수로 들어갈 수 있는 것 유일하게 Runnable 인터페이스 혹은 이를 구현한 클래스밖에 없으니 생략이 가능하다.

 

2단계 : 메서드 선언 부분 제거

//쓰레드를 생성한다.
Thread thread = new Thread(
    () {
        System.out.println("Hello World");
    }
);

이 역시 실제로 동작하진 않지만, Override한 run 메소드 명과 메소드 리턴 타입을 없애버렸다.

리턴 타입이 없다면, void고, 있다면 이미 정해져 있기 때문에 생략이 가능하다.

 

3단계 : 람다 문법으로 정리

Thread thread = new Thread(() -> System.out.println("Hello World"));

단, 한줄로 정리되었다.

감싸고 있던 중괄호를 없애고, 세미콜론을 정리해주었다.

람다에서는 파라미터 목록을 메서드의 본문으로 전달한다는 의미로 '->'기호를 사용한다.

추가적으로 알아야 할 것은 Runnable 인터페이스의 run 메서드의 경우 입력 파라미터가 없지만, 이경우에도 ()를 남겨놔야 한다는 것이다.

 

순서를 다시한번 정리하자.

  1. 익명 클래스를 이용해서 메서드를 정의한다.
  2. 익명 클래스를 생성하기 위해서 선언한 인터페이스 이름 부분을 삭제한다. 삭제 후에는 메서드 선언 부분만 남는다.
  3. 메서드의 파라미터 목록과 구현한 바디 영역을 제외하고 리턴 타입, 메서드 명을 삭제한다. 삭제 후에는 파라미터 목록과 바디 영역만 남는다.
  4. 람다 문법에 맞게 '->'를 이용해서 문장을 완성한다.

 

형식 추론

한가지를 더 줄어보고자 한다.

인터페이스에 정의되어 있는 메서드의 파라미터 타입은 이미 정해져있거나, 제네릭을 통해 선언되어 있다.

즉, 굳이 데이터 타입을 전달할 필요가 없다.

(String a) -> System.out.println(a);
(a) -> System.out.println(a); // String 생략

위 두가지는 동일한 람다 표현식이다.

 

람다 표현식과 변수

지금까지는 람다 표현식에서 내부에서 선언한 변수들만 사용해 왔다.

그러나, 외부에서 생성한 변수도 참조해 사용할 수 있다.

예를 들면, 클래스에서 정의한 멤버 변수나 메서드 내부에서 생성한 로컬 변수를 참조할 수 있다.

int threadNumber = 100;
list.stream().forEach((String s) -> System.out.println(s + ", " + threadNumber));

단, 주의할 점은 외부 변수의 참조는 final 혹은 final과 유사한 조건이어야 한다.

final 키워드를 붙이지 않더라도 값이 할당된 이후에 변경될 가능성이 없다면 컴파일러는 final 변수와 동일하게 취급하며, 람다 표현식에서 활용하더라도 컴파일 오류가 발생하지 않는다.

 

함수형 인터페이스

함수형 프로그래밍 포스트 에서 함수형 인터페이스에 대해 간단히 설명했었지만, 조금 더 구체적으로 배워보자.

 

익명 클래스를 구현해야할 인터페이스는 통상적으로 여러개의 메서드를 포함하고 있다.

그에 비해 람다 표현식은 이름이 없고 단지 파라미터와 리턴 타입만으로 식별하는데 어떻게 자바 컴파일러가 이를 인식하고 인터페이스의 구현체로 컴파일 할 수 있을까?

결론은 람다 표현식을 쓸 수 있는 인터페이스는 오직 public 메서드를 하나만 가지고 있는 인터페이스여야 한다.

이를 함수형 인터페이스라 부르며, 함수형 인터페이스에서 제공하는 단 하나의 추상 메서드함수형 메서드라고 한다.

(default, static 메서드는 있어도 상관없다)

 

그리고 개발자 입장에서 람다 표현식을 쓰기 위해 매번 메서드가 하나뿐인 인터페이스를 제공해야 하는 번거로움을 없애기 위해, 가장 자주 사용할법한 패턴으로 java.util.function 패키지로 제공하고 있다.

참고로, @FunctionalInterface 어노테이션은 없어도 문제 없지만, 명시적으로 넣어주는 것이 좋다.

 

함수형 인터페이스의 주요 패턴은 무엇이 있을까?

인터페이스 명 메서드 명 내용
Consumer<T> void accept(T t) - 파라미터를 전달해서 처리한 후 결과를 리턴 받을 필요가 없을 때 사용한다.

- 받기만 하고 리턴하지 않아서 Consumer(소비자)라는 이름을 사용한다.
Function<T, R> R apply(T t) - 전달할 파라미터를 다른 값으로 변환해서 리턴할 때 사용한다.

- 주로 값을 변경하거나 매핑할 때 사용한다.
Predicate<T> boolean test(T t) - 전달받은 값에 대해 true/false 값을 리턴할 때 사용한다.

- 주로 데이터를 필터링하거나, 조건에 맞는지 여부를 확인하는 용도로 사용한다.
Supplier<T> T get() - 파라미터 없이 리턴 값만 있는 경우 사용한다.

- 받지는 않고 리턴만 하기 때문에 Supplier(공급자)라는 이름을 사용한다.

위 4가지에 대해서는 반드시 기억해 두도록 하자

 

Consumer 인터페이스

앞서 설명한 대로 소비에만 집중하는 인터페이스다. 리턴 타입도 void다.

제네릭 타입은 파라미터에 사용된다.

public class ConsumerExample {
    public static void excuteCunsumer(List<String> nameList, Consumer<String> consumer) {
        for(String name : nameList) {
            // 메서드의 두 번째 인수로 전달된 람다 표현식을 실행
            consumer.accept(name);
        }
    }
}

public static void main(String[] args) {
    List<String> nameList = new ArrayList<>();
    nameList.add("A");
    nameList.add("B");
    nameList.add("C");
    nameList.add("D");

    ConsumerExample.excuteCunsumer(nameList, new Consumer<String>() { // 람다 표현식 미사용
        @Override
        public void accept(String s) {
            System.out.println(s);
        }
    });
    ConsumerExample.excuteCunsumer(nameList, (String s) -> System.out.println(s));
    ConsumerExample.excuteCunsumer(nameList, s -> System.out.println(s)); // 형식 추론
    ConsumerExample.excuteCunsumer(nameList, System.out::println); // 참조 메소드
}

 

조금 헤깔릴 수 있는 사항을 정리하고 가자.

위 식에서 excuteCunsumer는 두번 째 인수로 람다 표현식을 사용중이다.

람다 표현식의 실행 결과를 메서드의 두번째 인수로 전달할 것이라고 생각해버릴 수 있다.

람다 표현식은 그 자체로 실행되는 것이 아니다.

함수형 인터페이스에 포함되어 있는 함수형 메서드의 내부 코드를 정의하는 것이다.

아래 변환 소스를 보도록 하자.

public Consumer<String> getExpression() {
    return (String name) -> System.out.println(name);
}

ConsumerExample.excuteCunsumer(nameList, getExpression());

 getExpression의 리턴 타입은 Consumer 인터페이스이고, 람다 표현식을 리턴값으로 넣어주었다.

그래서 getExpression을 excuteConsumer의 두번째 인수로 넣었고 람다 표현식의 실행 결과가 리턴될 것이라 볼 경우가 있는데 그렇지 않다.

 

즉, 람다 표현식은 Consumer 인터페이스의 accept 메서드의 동작을 정의했을 뿐이다.

어떤 데이터가 넘어올지 모르지만, 이렇게 처리하겠다는 것이다.

 

Function 인터페이스

Function 인터페이스는 두 개의 제네릭 타입을 정의해야 하고, T와 R이라는 이름을 갖는다.

그리고 함수형 메서드에서는 T를 인수로 받아서 R로 리턴하는 apply 메서드를 가지고 있다.

역할은 특정한 클래스를 파라미터로 받아서 처리하고 리턴하는 형태다.

public class FunctionExample {
    public static int executeFunction(String context, Function<String, Integer> function) {
        return function.apply(context);
    }
}

public static void main(String[] args) {
    FunctionExample.executeFunction("HELLO! WELCOME TO JAVA WORLD", (String context) -> context.length());
}

apply 메서드가 어떻게 정의될지 모르지만, 전달받은 첫 번째 context문장을 처리하고, 그 결과를 리턴하는 코드가 만들어졌다.

그리고, 그 로직은 main에서 설정되어 들어가게 된다.

주로 데이터를 가공하거나 매핑하는 용도로 많이 사용하고, 비즈니스 로직에 대한 리턴 값이 필요할 때 자주 사용한다.

 

Predicate 인터페이스

리턴 타입 중 특별히 참/거짓 중 하나를 선택하는 bool 타입을 필요로 할 때 사용한다.

함수형 메서드는 test다. 제네릭 타입으로 선언된 객체를 파라미터로 받아서 처리한 후 참/거짓 중 하나를 리턴한다.

제네릭 타입은 파라미터에 사용된다.

public class PredicateExample {
    public static boolean isValid(String name, Predicate<String> predicate) {
        return predicate.test(name);
    }
}

public static void main(String[] args) {
    PredicateExample.isValid("", (String name) -> !name.isEmpty());
}

Supplier 인터페이스

마지막으로 Supplier 인터페이스다.

아무것도 받지 않고, return만 존재하며, 함수형 메서드는 get이다.

제네릭 타입은 리턴값에 사용된다.

public class SupplierExample {
    public static String executeSupplier(Supplier<String> supplier) {
        return supplier.get();
    }
}

public static void main(String[] args) {
    SupplierExample.executeSupplier(() -> {return "HELLO WORLD"});
}

java.util.function

이외에도 많은 인터페이스가 있지만, 정말 대부분이 위 4가지를 응용했거나, 상속받아서 구현되어 있다.

박싱/언박싱의 비용을 이름으로서 구별하는 IntConsumer, DoubleToLongFunction 등도 있고, Operator 인터페이스는 Function 인터페이스를 상속받아 구현되어 있다.

 

메서드 참조

함수를 메서드의 파라미터로 전달하는 것메서드 참조라고 한다.

 

매서드 참조는 람다 표현식과는 달리 코드를 여러 곳에서 재사용할 수 있고, 직접 개발한 메서드도 사용할 수 있다.

또한, 람다 표현식을 한번 더 축약할 수 있고, 코드의 가독성도 높일 수 있다.

메서드 참조는 함수형 인터페이스와 연관되어 있지만 람다 표현식을 대체하기 위한 것은 아니며 상호 보완적이다.

메서드 참조 역시 람다 표현식 처럼 결과가 아닌, 코드 자체를 전달하는 것이니 헤깔리지 말자.

 

(String name) -> System.out.println(name) // 람다 표현식 구문
System.out::println // 메서드 참조 구문

 

System 클래스의 out 객체에 있는 println 메서드를 호출한다는 뜻이다.

당연히 출력해야 할 값이 파라미터로 전달되어야 하는데, 이 때 사용하는 값은 왼편에 생략된 String name이다.

 

public static void main(String[] args) {
    List<String> list = new ArrayList<>();

    ...

    for(String entity : list) {
        System.out.println(entity);
    }
}

자바7까지는 위 코드가 정석적이였다.

자바 8부터는 컬렉션 프레임워크를 개선하기 위해 스트림이라는 개념을 도입했고, 스트림에는 람다 표현식과 메서드 참조를 사용할 수 있게 되었다.

list.stream().forEach((String entity) -> System.out.println(entity));
list.stream().forEach(System.out::println);

조금 더 심화 예시를 보자.

 

public class MethodReferenceExample {
    public static MethodReferenceExample of() {
        return new MethodReferenceExample();
    }
    
    // 데이터 처리 로직 정의
    public static void executeMethod(String entity) {
        if(entity != null && !"".equals(entity)) {
            System.out.println("Contents : " + entity);
            System.out.println("Length : " + entity.length());
        }
    }
    
    // 대문자로 변경하는 코드
    public void toUpperCase(String entity) {
        System.out.println(entity.toUpperCase());
    }
    
    // 실행하는 예
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
        
        // 정적 메서드 참조
        list.stream().forEach(MethodReferenceExample::executeMethod);
        // 한정적 메서드 참조
        list.stream().forEach(MethodReferenceExample.of()::toUpperCase);
        // 비한정적 메서드 참조
        list.stream().map(String::toUpperCase).forEach(System.out::println);
    }
}

위 예시에서 메서드 참조를 정의하는 문법은 두 가지다.

  • 클래스명::메서드명
  • 객체 변수명::메서드명

그리고 메서드 참조는 3가지로 구분된다.

  • 정적 메서드 참조
  • 비한정적 메서드 참조
  • 한정적 메서드 참조 

정적 메서드 참조

static으로 정의한 메서드를 참조할 때 사용하며 가장 일반적이다.

static 메서드는 호출할 때 객체를 생성하지 않기 때문에 명확히 확인해볼 수 있다.

Integer::parseInt
(String s) -> Integer.parseInt(s)

 

비한정적 메서드 참조

비한정적(unbound) 메서드 참조는 static 메서드를 참조하는 것과 유사하게 작성한다.

비한정적이라는 표현은 작성하는 구문 자체가 특정한 객체를 참조하기 위한 변수를 지정하지 않는다는 의미다.

String::toUpperCase

String 클래스의 toUpperCase 메서드는 public 메서드이며 static이 아니기 때문에 반드시 String 클래스가 객체화되어야만 호출 할 수 있다.

그러나, 마치 static 메서드를 참조하는 것처럼 정의하였다. 위 코드를 람다 표현식으로 보자.

(String str) -> str.toUpperCase()

풀어서 보면, 객체의 생성을 파라미터로 받았다. 즉, 람다 표현식 내부에서 객체 생성이 일어났기 때문에 객체를 참조할만한 변수가 외부에 존재하지 않는다.

만약, 처리해야 하는 데이터가 여러개라면 조금 복잡해질 수 있다.

List에서 연속된 2개의 데이터를 뽑아서 크기를 구하는 방식을 작성하면 다음과 같다.

List<String> list = new ArrayList<>();

...
list.stream().sorted((String a, String b) -> a.compareTo(b));
list.stream().sorted(String::compareTo); // 위와 동일

매우 함축적이라, 이해하고 해석하는데 어려울 수 있다.

 

한정적 메서드 참조

한정적(bound)이라는 단어를 사용한 이유는 참조하는 메서드가 특정 객체의 변수로 제한되기 때문이다.

Calendar.getInstance()::getTime

Calendar cal = Calendar.getInstance();
() -> cal.getTime()

Calendar cal = Calendar.getInstance(); // 객체 생성
cal::getTime // 메서드 참조 구문. cal 변수를 참조

자바8 이후 많은 클래스가 생성자를 이용한 객체 인스턴스화 보다는, of 와 같은 메서드로 생성하는 경향이 많다.

이러한 경우, 한 줄 소스로 메서드 참조를 정의할 수 있다.

그러나 매번 instance를 만들 수 있으므로, 외부에 한번 생성하고, 재활용하는 경우가 많다.

 

한정적 메서드 참조와 비한정적 메서드 참조의 차이는 다음과 같다.

한정적 메서드 참조외부에서 정의한 객체의 메서드를 참조할 때 사용하며, 비한정적 메서드 참조람다 표현식 내부에서 생성한 객체의 메서드를 참조할 때 사용한다.

 

생성자 참조

생성자는 메서드와 엄연히 다르다.

return이 없으며, 객체 생성 시에만 사용되고 객체 생성 시, 초기화의 개념을 가진다.

생성자 참조는 아래 문법을 가진다.

클래스명::new

 

람다 표현식 조합

마지막으로 한가지만 더 알아볼 것이다.

람다 표현식은 예전 재귀 호출과 같은 작업으로 진행했던, 마치 파이프라인 명령어처럼 기능을 조합하고 연결할 수 있다.

 

Consumer 조합

Consumer<String> consumer = System.out::println;
Consumer<String> andThen = text -> System.out.println("text length : " + text.length());
consumer.andThen(andThen).accept("JAVA");
더보기

JAVA
text length : 4

Consumer 인터페이스에 들어가보면, andThen이라는 default 메서드가 있다. (함수형 메서드는 accept다)

그리고 실행 결과를 보면, 두가지 Consumer가 순차적으로 호출됨을 볼 수 있다.

호출되고 처리되는 순서를 보도록 하자.

  • accept 메서드에 "JAVA" 문자열을 파라미터로 전달하여 함수형 인터페이스를 실행
  • 첫번째 람다 구문이 실행
  • 두번째 람다 구문이 실행

이건 Consumer 인터페이스의 함수형 메서드의 리턴타입이 void라서 그렇다.

즉, 리턴값을 그대로 받아서 처리하지 않고, 마치 두개의 Consumer를 연속해서 실행한 것과 동일한 결과를 준다.

Predicate 조합

Predicate 인터페이스에도 Consumer의 andThen처럼 어떤 메서드들이 있는지 보면, and/or와 같이 참/거짓 판별에 사용하는 메서드들이 있다. 즉, 여러 개의 참과 거짓에 대한 조건식을 합친다는 것이다.

 

public class Person {
    private String name;
    private String sex;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSex() {
        return sex;
    }

    public void setSex(String sex) {
        this.sex = sex;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

public class PredicateAndExample {
    public static Predicate<Person> isMale() {
        return (Person p) -> "male".equals(p.getSex());
    }

    public static Predicate<Person> isAdult() {
        return (Person p) -> p.getAge() > 20;
    }
}

public static void main(String[] args) {
    Predicate<Person> isMan = PredicateAndExample.isMale();
    Predicate<Person> isAdult = PredicateAndExample.isAdult();
    Predicate<Person> isManNAdult = isMan.and(isAdult);

    Person p = new Person();
    p.setAge(21);
    p.setName("ASD");
    p.setSex("male");

    System.out.println(isManNAdult.test(p));

}

 

첫번째로, 람다 표현식을 별도의 static 메서드로 정의해둔 것을 집중하자.

이런 형태의 개발 방향이 가독성을 높여준다.

두번째로, 람다 표현식의 조합은 반드시 지켜야 하는 규정이 있는데, 두 함수형 인터페이스의 제네릭 타입이 동일해야 한다는 것이다.

따라서, 위 예제는 객체로 별도로 만들어 비교해둔 것이다.

Function 조합

Function 인터페이스에도 andThen 이 있지만, Consumer 인터페이스와는 많이 다르다.

public static void main(String[] args) {
    Function<String, Integer> parseIntFunction = (String str) -> Integer.parseInt(str) + 1;
    Function<Integer, String> intToStrFunction = (Integer i) -> "String : " + i;

    System.out.println(parseIntFunction.apply("1000"));
    System.out.println(intToStrFunction.apply(1000));
    System.out.println(parseIntFunction.andThen(intToStrFunction).apply("1000"));
}
더보기

1001
String : 1000
String : 1001

눈치 챘겠지만, andThen으로 연결되는 제네릭 타입 두가지에 대해, 파라미터, 리턴타입이 잘 연결될 수 있는 형태가 되어야 한다. (String -> Integer -> String)

 

또, Function이 지원하는 compose라는 메서드가 있다.

 

public static void main(String[] args) {
    Function<String, Integer> parseIntFunction = (String str) -> Integer.parseInt(str) + 1;
    Function<Integer, String> intToStrFunction = (Integer i) -> "String : " + i;

    System.out.println(parseIntFunction.apply("1000"));
    System.out.println(intToStrFunction.apply(1000));
    System.out.println(intToStrFunction.compose(parseIntFunction).apply("1000"));
}

compose는 뒤에서 앞으로 호출된다고 보면 된다.

 

다음 포스팅에서는 본격적으로 스트림에 대해 알아볼 예정이다.

지금까지 공부한 함수형 인터페이스가 자바에서 어떻게 쓰였는지 알아볼 수 있을 것이다.