리액티브 프로그래밍을 위한 사전 지식

...·2024년 9월 15일

Reactive Programming

목록 보기
4/5

함수형 인터페이스

함수형 인터페이스는 기존 인터페이스에 비해 단 하나의 추상 메서드만 정의되어 있다.

함수형 프로그래밍 세계에서는 함수를 일급 시민으로 취급한다. 여기서 말하는 일급 시민이란 함수를 값으로 취급한다는 의미를 조금 더 고급스럽게 표현한 것인데, 함수를 값으로 취급하기 때문에 어떤 함수를 호출할 때 함수 자체를 파라미터로 전달할 수 있다.

대표적인 예로 Comparator 인터페이스를 들 수 있다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    
    ...
    default Comparator<T> reversed() {
        return Collections.reverseOrder(this);
    }
    
    default Comparator<T> thenComparing(Comparator<? super T> other) {
        Objects.requireNonNull(other);
        return (Comparator<T> & Serializable) (c1, c2) -> {
            int res = compare(c1, c2);
            return (res != 0) ? res : other.compare(c1, c2);
        };
    }
    ...
    ...
}
class Person {
    String name;
    int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}

public class ComparatorExample {
    public static void main(String[] args) {
        // Person 객체 리스트 생성
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 30));
        people.add(new Person("Bob", 25));
        people.add(new Person("Charlie", 35));

        // Collections.sort() 함수에 익명 Comparator 객체 전달
        Collections.sort(people, new Comparator<Person>() {
            @Override
            public int compare(Person p1, Person p2) {
                // 나이로 오름차순 정렬
                return Integer.compare(p1.age, p2.age);
            }
        });

        // 정렬된 리스트 출력
        for (Person person : people) {
            System.out.println(person);
        }
    }
}

인터페이스 자체를 익명 구현 객체로 전달하는 방식은 코드 자체가 길어져서 지저분하게 보이기도 한다. 그래서 Java 8 버전부터 기존에 이미 사용하던 인터페이스의 익명 구현 객체를 전달하던 방식을 조금 더 함수형 프로그래밍 방식에 맞게 표현할 수 있는 방법을 추가했는데 그것이 바로 람다 표현식이다.

람다 표현식

Java에서 인터페이스의 익명 구현 클래스를 화살표 간결하게 표현하는 방법을 Java 8부터 지원하는데, 그것이 바로 람다 표현식이다.

람다 표현식은 Javascript의 화살표 함수와 비슷한 형태로 Java에서 함수를 값으로 취급하기 위해 생겨난 간결한 형태의 표현식이다.

람다 파라미터는 함수형 인터페이스에 정의된 추상 메서드의 파라미터를 의미한다. 그리고 람다 바디는 이 추상 메서드에서 구현되는 메서드 바디를 의미한다. 우리가 일반적으로 알고 있는 메서드의 문법처럼 람다 바디에서 람다 파라미터로 전달받은 값들을 사용하게 된다.

class Person {
    String name;
    int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}

public class ComparatorExample {
    public static void main(String[] args) {
        // Person 객체 리스트 생성
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 30));
        people.add(new Person("Bob", 25));
        people.add(new Person("Charlie", 35));

        // 람다 표현식으로 Comparator 전달
        Collections.sort(people, (p1, p2) -> Integer.compare(p1.age, p2.age));

        // 정렬된 리스트 출력
        for (Person person : people) {
            System.out.println(person);
        }
    }
}

위에서 작성한 익명 구현 객체로 넘기는 형식을 람다 표현식으로 바꾼 코드이다. 람다 표현식에서는 메서드 파라미터의 타입이 내부적으로 추론되어 생략이 가능해진다.

함수형 인터페이스의 추상 메서드를 람다 표현식으로 작성해서 메서드의 파라미터로 전달한다는 의미는 메서드 자체를 파라미터로 전달하는 것이 아니라 함수형 인터페이스를 구현한 클래스의 인스턴스를 람다 표현식으로 작성해서 전달한다는 것이다.

람다 표현식에서는 파라미터로 전달받은 변수뿐만 아니라 람다 표현식 외부에서 정의된 변수(자유 변수, Free Variable) 역시 사용할 수 있다.

람다 표현식 외부에서 정의된 자유 변수를 람다 표현식에 사용하는 것을 람다 캡처링(Lambda Capturing)이라고 한다.

public class LambdaCapturingExample {
    public static void main(String[] args) {
        // effectively final 변수 선언
        String greeting = "Hello";

        // 람다 표현식에서 greeting 변수를 캡처
        Runnable runnable = () -> {
            System.out.println(greeting + ", world!");
        };

        // Runnable 실행
        runnable.run();
    }
}

람다 표현식에서 사용되는 자유 변수를 final 또는 final 같은 효력을 지녀야 한다.

메서드 레퍼런스

ClassName::static method 유형

메서드 레퍼런스로 표현할 수 있는 가장 흔한 유형은 클래스의 static 메서드이다.

public class StaticMethodReferenceExample {
    // static 메서드 정의
    public static void printUpperCase(String str) {
        System.out.println(str.toUpperCase());
    }

    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

        // static 메서드 레퍼런스 사용
        names.forEach(StaticMethodReferenceExample::printUpperCase);
    }
}

ClassName::instance method 유형

메서드 레퍼런스로 표현할 수 있는 두 번째 유형은 클래스에 정의된 인스턴스 메서드이다.

class Person {
    String name;
    
    Person(String name) {
        this.name = name;
    }
    
    // 인스턴스 메서드
    public String getName() {
        return name;
    }
}

public class InstanceMethodReferenceExample {
    public static void main(String[] args) {
        List<Person> people = Arrays.asList(new Person("Alice"), new Person("Bob"), new Person("Charlie"));

        // ClassName::instanceMethod 형태의 메서드 레퍼런스
        people.forEach(Person::getName); // getName() 메서드가 각 Person 인스턴스에 대해 호출됨
        
        // 출력: 메서드 호출을 활용하여 출력
        people.forEach(person -> System.out.println(person.getName()));  // 람다 표현식
    }
}

object::instance method 유형

메서드 레퍼런스로 표현할 수 있는 세 번째 유형은 ClassName::instance method 형태가 아니라 클래스의 객체인 object::instance method 형태로 축약된다.

이와 같은 메서드 레퍼런스 유형은 람다 표현식 외부에서 정의된 객체의 메서드를 호출할 때 사용된다.

class Printer {
    public void printMessage() {
        System.out.println("Hello from the instance method!");
    }
}

public class ObjectInstanceMethodReferenceExample {
    public static void main(String[] args) {
        // Printer 객체 생성
        Printer printer = new Printer();
        
        // object::instanceMethod 메서드 레퍼런스 사용
        Supplier<Void> printAction = printer::printMessage; // printer 객체의 인스턴스 메서드를 참조
        
        // 메서드 호출
        printAction.get(); // 실제로 printer 객체의 printMessage() 메서드 호출
    }
}

ClassName::new 유형

메서드 레퍼런스로 표현할 수 있는 네 번째 유형은 바로 생성자이다. 람다 표현식 내부에서 어떤 클래스의 생성자를 사용해야 될 경우, 람다 표현식에서 형식 추론이 가능해지면 ClassName::new와 같이 축약할 수 있다.

// Person 클래스
class Person {
    String name;

    // 기본 생성자
    public Person() {
        this.name = "Unknown";
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "'}";
    }
}

public class ConstructorReferenceExample {
    public static void main(String[] args) {
        // Supplier 인터페이스를 사용하여 Person 생성자 참조
        Supplier<Person> personSupplier = Person::new;

        // 생성자 참조를 사용하여 Person 객체 생성
        Person person = personSupplier.get();

        // 생성된 Person 객체 출력
        System.out.println(person);
    }
}

함수 디스크립터

리액티브 프로그래밍 학습을 본격적으로 진행하다 보면 람다 표현식의 파라미터 개수나 파라미터 타입, 리턴 타입 등을 보면서 이 람다 표현식이 Java에서 지원하는 어떤 함수형 인터페이스에 해당되는지 알아야 할 때가 있다.

함수 디스크립터(Function Descriptor)는 함수 서술자, 함수 설명자 정도로 이해할 수 있는데, 실제로 일반화된 람다 표현식을 통해서 이 함수형 인터페이스가 어떤 파라미터를 가지고, 어떤 값을 리턴하는지 설명해 주는 역할을 한다.

Predicate

Java에서는 함수형 인터페이스 중에서 구현해야 되는 추상 메서드가 하나의 파라미터를 가지고, 리턴 값으로 boolean 타입의 값을 반환하는 함수형 인터페이스를 Predicate이라고 정의해 두었다.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    ...
    ...
}

Predicate을 가장 흔하게 볼 수 있는 코드는 스트림의 filter 메서드이다. filter 메서드는 파라미터로 Predicate을 가지며 filter 메서드 내부에서 이 Predicate을 사용하여 test 메서드의 리턴 값이 true인 데이터만 필터링하게 된다.

public class PredicateExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Predicate 정의: 짝수인지 판단
        Predicate<Integer> isEven = num -> num % 2 == 0;

        // Predicate를 사용하여 짝수만 필터링
        List<Integer> evenNumbers = numbers.stream()
                                           .filter(isEven)
                                           .collect(Collectors.toList());

        System.out.println("짝수 리스트: " + evenNumbers);
    }
}

Consumer

Consumer 함수형 인터페이스는 단어의 의미처럼 데이터를 소비하는 역할을 한다. 데이터를 소비한다는 의미는 리턴 값이 없다는 의미와 같다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

Consumer를 사용하기 좋은 대표적인 예로, 일정 주기별로 특정 작업을 수행한 후, 결과 값을 리턴할 필요가 없는 경우가 대부분인 배치 처리를 들 수 있다.

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Diana");

        // Consumer 정의: 이름을 출력하는 작업
        Consumer<String> printName = name -> System.out.println(name);

        // Consumer를 사용하여 각 이름 출력
        names.forEach(printName);
    }
}

Function

Function 함수형 인터페이스는 구현해야 되는 추상 메서드가 하나의 파라미터(T 타입)를 가지며, 리턴 값으로 R 타입의 값을 반환합니다.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
    ...
    ...
}

Function 함수형 인터페이스는 함수 내에서 어떤 처리 과정을 거친 후에 그 결과로 특정 타입의 값을 반환하는 전형적인 함수 역할을 하기 때문에 Function이라는 이름을 가진다고 보면 된다.

Function 함수형 인터페이스를 쉽게 접할 수 있는 코드는 바로 Java 스트림의 map 메서드이다.

import java.util.function.Function;

public class FunctionExample {
    public static void main(String[] args) {
        // Function 정의: 문자열을 대문자로 변환
        Function<String, String> toUpperCase = str -> str.toUpperCase();

        // Function을 사용하여 문자열 변환
        String result = toUpperCase.apply("hello world");

        System.out.println("대문자로 변환된 문자열: " + result);
    }
}

Supplier

Supplier 함수형 인터페이스는 추상 메서드가 파라미터를 갖지 않으며 리턴 값으로 T 타입의 값만 반환한다.

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

Supplier는 이름 그대로 어떤 값이 필요할 때 데이터를 제공하는 용도로 사용할 수 있다.

import java.util.Random;
import java.util.function.Supplier;

public class SupplierExample {
    public static void main(String[] args) {
        // Supplier 정의: 랜덤 숫자 생성
        Supplier<Integer> randomNumberSupplier = () -> new Random().nextInt(100); // 0~99 사이의 랜덤 숫자

        // Supplier를 사용하여 랜덤 숫자 생성
        Integer randomNumber = randomNumberSupplier.get();

        System.out.println("랜덤 숫자: " + randomNumber);
    }
}

Bixxxxx

BiPredicate, BiConsumer, BiFunction과 같이 Bi로 시작하는 함수형 인터페이스는 함수형 인터페이스에서 구현해야 하는 추상 메서드에 전달하는 파라미터가 하나 더 추가되어 두 개의 파라미터를 가지는 함수형 인터페이스인데, Java에서 지원하는 기본 함수형 인터페이스의 확장형이라고 보면 된다.

참고 서적: 스프링으로 시작하는 리액티브 프로그래밍

profile
주니어 백엔드 개발자

0개의 댓글