자바와 함수형 프로그래밍

함수형 프로그래밍을 해야하는 이유(?)에 대해서 정리(참고, "왜 함수형 프로그래밍이 좋을까?") 했으니, 이번에는 자바를 활용하여 함수형 프로그래밍을 수행하는 방법에 대해 정리하려 한다.
함수형 프로그래밍이 꼭 함수형 언어로만 구현할 수 있는 것은 아니기에, 그리고, 현재 대한민국에서 가장 많이 사용되고 있는 언어가 자바이기에, 자바를 활용한 함수형 프로그래밍은 나름 의미가 있을 수 있다 생각한다.

자바는 버전 8이 릴리즈되면서 완벽하진 않지만, 함수형 프로그래밍에 필요한 주요한 기능들을 제공하게 되었다. (9 버전 이후의 자바를 아직 학습하지 못해 , 우선 8 버전을 기준으로 작성한다.)

  • 람다 표현식
  • 함수형 인터페이스
  • 스트림

자바 8 버전이 릴리즈되고 시간이 어느 정도 지났기때문에 위 항목들에 대해 모르고 있는 자바 개발자는 아마 거의 없을 것이라 생각하며, 자세한 설명은 생략하고, 이 글에서는 어떻게 자바를 활용하여 함수형 프로그래밍 패러다임을 따르는 코드를 만들 것인가에 대해 좀 더 포커스를 맞추어보자.

Functional style

functional style? 함수형 스타일? 통용되는 명칭인지는 모르겠지만, "Java 8 in Action" 의 챕터 13에서 이 명칭이 사용된다.(국내 번역된 책에서는 그냥 "함수형"으로 사용) 아마도 이 책의 저자는 OOP 언어인 Java 를 순수 함수형은 아니지만, 함수형 프로그래밍이 지향하는 형식대로 코드를 구현하는 것을 "functional style" 이라 정의하고 싶었던 것이지 않을까?

함수형 프로그래밍이 지향하는 형식의 가장 중심은 부수효과 없는 프로그래밍이다. 자바에서 부수효과 없는 프로그래밍이란 외부에서 어떤 클래스의 메소드를 사용할 때, 그 메소드의 결과가 오로지 입력값에 의존해야 한다는 것인데, 이를 만족하려면 클래스를 구현할 때 몇 가지 제약 사항을 지켜줘야한다.

객체의 필드는 final 로 정의하여 변경하지 못하도록 한다.

객체에 변경 가능 한 필드가 정의되어 있을 때, 이 객체를 두 개 이상의 스레드에서 공유한다면, 메소드 호출 시 실제 예상하는 결과가 아닌 다른 결과를 받을 수 있다. 아래 코드를 살펴보자.

public class Counter {  
    private int count;

    public Counter() {
        this.count = 0;
    }

    public void increment() {
        this.count++;
    }

    public int getCount() {
        return this.count;
    }
}

public class Main {  
    ...
    public static void main(String[] args) throws Exception {
        Counter counter = new Counter();

        Runnable countTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(countTask);
        Thread t2 = new Thread(countTask);

        t1.start();t2.start();
        t1.join();t2.join();

        System.out.println("counter.count is " + counter.getCount());
    }
}

Counter 클래스를 t1, t2 스레드에서 공유하고 있으며, 각 스레드에서 1씩 증가하는 메소드를 1000번씩 반복한다. 단순하게 생각하면 마지막 결과는 2000이 되어야 하지만, 실제 실행해보면, 실행할 때마다 아래와 같이 엉뚱한 결과가 나오는 것도 확인할 수 있다.

counter.count is 1983  

예상치 못한 결과가 나오는 것은 두 스레드에서 동시에 값을 증가시키기 위해 count 필드의 값을 읽으려 할 때 발생하는 경쟁조건 때문이다.
물론, increment 메소드의 바디를 synchronized 로 잠그면 위와 같은 문제가 발생하지 않겠지만, 프로그램의 실행 속도가 조금 느려지는 단점이 생긴다.

이러한 이유로, 객체의 필드는 변화하는 상태값 저장을 위한 용도로는 쓰지 않는 것이 "functional style" 인데, 필드를 사용해야 한다면 final 로 정의하여 변경시키지 못하도록 강제하는 것이 좋다.

위 코드의 Counter 클래스를 아래와 같이 변경하자.

public class Counter {  
    private final int count;

    public Counter() {
        this(0);
    }

    public Counter(int count) {
        this.count = count;
    }

    public Counter increment() {
        return new Counter(this.count+1);
    }

    public int getCount() {
        return this.count;
    }
}

우선 count 필드를 final 로 정의하여 인스턴스 내부에서 변경할 수 없도록 강제하였다. 이에 따라, increment 메소드 구현도 달라졌는데, count 값을 변경할 수 없기에 새로운 Counter 객체를 만들어 반환하도록 하였다.

이렇게 수정하고보면, 기존에 하나의 Counter 객체를 만들어 두 개의 스레드에서 공유하는 방식으로는 제대로 된 코드를 만들 수 없음을 바로 알아차릴 수 있다. 변경된 Counter 클래스를 사용하기 위해 아래와 같이 main 메소드를 변경하였다.

public static void main(String[] args) throws Exception {  
    Supplier<Integer> countTask = () -> {
        Counter counter = new Counter();
        for (int i = 1; i <= 1000; i++) {
            counter = counter.increment();
        }

        return counter.getCount();
    };

    CompletableFuture<Integer> task1 = 
                   CompletableFuture.supplyAsync(countTask);
    CompletableFuture<Integer> task2 = 
                   CompletableFuture.supplyAsync(countTask);

    task1.join();task2.join();
    int totalCount = task1.get() + task2.get();
    System.out.println("counter.count is " + totalCount);
}

CompletableFuture 를 사용하여 1~1000까지 카운트하는 작업을 동시에 수행하고, 마지막으로 두 작업이 모두 마쳤을 때 각 작업의 결과를 더하여 출력하도록 변경하였는데, CompletableFutureSupplier 를 사용하는 것이 이 코드의 핵심은 아니다.
그것보단 Counter 클래스의 count 필드를 final 로 변경했더니, 그에 따라 자연스럽게 increment 메소드의 구현 코드를 수정해야하고, 덩달아 Counter 클래스를 사용하는 방법도 변경되어야 했음을 주의깊게 봐주면 좋을 것 같다.

이제 좀 더 "Functional Style" 의 코드가 만들어졌다.

메소드는 지역 변수만 변경 가능해야 한다.

지역 변수는 그 변수가 선언된 메소드 내에서만 유효하다. 다른 말로 표현하자면, 외부에 전혀 드러나지 않는다. 그렇기 때문에, (..너무나 당연하지만..) 이를 마음껏 변경하여 사용한다 하더라도, 외부에는 전혀 영향을 주지 않는다. 그러니, 변화하는 상태를 저장해야 한다면, 지역 변수만 사용하자.

메소드는 예외를 발생시키지 않아야 한다.

메소드 내에서 예외를 발생시킨다며, 이 메소드는 부수효과 없는 메소드가 아니다. 아래와 같이 두 수를 나누는 메소드가 있다고 하자.

public static int divide(int a, int b) {  
    return a / b;
}

문제가 없는 코드인 것 같지만, 파라미터 b 의 값으로 0이 들어왔을 때, 문제가 발생한다. 아래와 같은 RuntimeException 이 발생할 것이다.

Exception in thread "main" java.lang.ArithmeticException: / by zero  

함수형 프로그래밍에서 함수는 항상 입력(input)에 따른 출력(output)이 있어야 한다. 하지만, 위와 같은 메소드는 경우에 따라 출력이 아닌 예외가 발생하기 때문에 "Functional Style" 이라고 말하기 어려울 것이다.

자바에서 예외를 쓰지 않으려면 어떻게 해야할까? 가장 쉬운 방법은 Optional 을 사용하는 것이다. 위 divide 메소드를 아래와 같이 변경하였다.

public static Optional<Integer> divide(int a, int b) {  
    Optional<Integer> result;
    try {
        result = Optional.of(a/b);
    } catch (ArithmeticException e) {
        result = Optional.empty();
    }
    return result;
}

이제 예외를 발생시키지 않고, 항상 입력에 따른 출력을 이쁘게(?) 내놓는 메소드가 되었다.
하지만, 한가지 아쉬운 점이라면, 이 메소드를 사용하는 입장에서 Optional.empty 를 출력으로 받았을 때, 어떤 이유로 제대로 된 값이 아닌 빈값이 출력되었는지에 대한 내용을 알 수 있는 방법이 없다. 별도의 메시지를 받기 위해선 Optional 을 대체 할 수 있는 클래스를 만들어서 사용하는 방법이 있을텐데, 이는 언젠가 별도의 포스팅으로 정리해도 좋을 것 같다.

결론

객체지향 언어인 자바를 활용하여 "Functional Style" 코드를 만드는 가장 기초적인 방법에 대해 정리해보았다. 이러한 스타일의 코드가 만능은 아닐 것이다. 다만, 명령형 프로그래밍(imperative programming)에 비해 좀 더 쉽게 동시성, 병렬성 프로그래밍이 가능하다는 장점이 있으니, "Functional Style"의 코드를 적절하게 사용하면, 더 좋은 코드를 생산할 수 있지 않을까 생각한다.