[Android] Functional Programming for Android Developers

안드로이드 개발자를 위한 함수형 프로그래밍 Part 1~2

Posted by Kim Heebeom on May 8, 2018

해당 글을 번역 및 일부 수정한 글 입니다.

Functional Programming for Android Developers

이 시리즈에서는 Functional Programming (FP)의 기본 원리와 자바, 코틀린에서 어떻게 FP를 사용할 수 있는지 배울 것입니다. FP는 아주 큰 주제입니다. 여기서는 특히 안드로이드 코드 작성에 유용한 개념과 기법만 배울 것 입니다.

What is Functional Programming and why should I use it?

Functional programming이라는 용어는 moniker(별명)가 제대로 정의하지 못하는 다양한 프로그래밍 개념의 산물입니다. (FP라는 용어 자체가 실제 추구하는 개념을 다 담고있지 못한다란 뜻 같습니다.) 핵심은, mutable stateside effect를 줄이는 프로그래밍 스타일이다.

FP는 아래 핵심들을 강조합니다.

  • Declarative code(선언적 코드) - 프로그래머는 무엇(what)에 대해 걱정해야 하며 컴파일러와 런타임이 어떻게(how)하는지 걱정하도록 해야 한다.
  • Explicitness(명확성) - 코드는 가능한 한 분명해야 합니다. 특히, 갑작스런 사고를 피하기 위해서 Side effect와 분리되어야 한다. 데이터 흐름 및 오류 처리는 명시적으로 정의되며, 응용 프로그램을 예기치 않은 상태로 전환할 수 있으므로 GOTO문 및 Exception과 같은 구조는 피할 수 있습니다.
  • Concurrency(동시성) - functional purity라고 알려진 개념 때문에 대부분의 함수형 코드는 기본적으로 concurrent하다. 일반적인 의견은 CPU코어가 예전처럼 매년 더 빨라지지 않고(see Moore’s law), 프로그램이 멀티 코어 아키텍쳐를 더 많이 활용해야 하기 때문에 이러한 특성으로 인해 FP의 인기가 높아지고 있습니다.
  • Higher Order Functions(고차 함수) - 함수는 다른 모든 언어 프리미티브와 마찬가지로 최상위 멤버이다. string이나 int처럼 함수를 전달할 수 있습니다.
  • Immutability(불변성) - 변수는 일단 초기화되면 수정할 수 없습니다. 일단 만들어지면 그것은 영원합니다. 만약 그것을 바꾸길 원하면 새로 만들어야 합니다. 이것은 Explicitness의 한 측면이면서 Side effect를 피하게 해줍니다.

더 쉽게 추론할 수 있고 갑작스런 오류를 피할 수 있도록 설계된 Declarative, Explicit and Concurrent code가 당신의 관심을 끌었길 바랍니다.

Pure functions

출력이 오직 입력에만 의존하는 함수를 pure functions(순수함수)이라 합니다. 그리고 순수함수엔 부작용이 없습니다.

두 개의 숫자를 더하는 함수입니다.

Java

int add(int x) {
    int y = readNumFromFile();
    return x + y;
}

Kotlin

func add(x: Int): Int {
    val y: Int = readNumFromFile()
    return x + y
}

이 함수의 출력은 입력에만 의존하지 않습니다. readNumFromFile() 이 반환하는 내용에 따라 x의 동일한 값에 대해 서로 다른 출력을 가질 수 있습니다. 이러한 함수를 impure하다고 합니다. 아래는 pure한 함수입니다.

Java

int add(int x, int y) {
    return x + y;
}

Kotlin

func add(x: Int, y: Int): Int {
    return x + y
}

순수함수는 수학에서의 함수에 비유할 수 있습니다. 수학 함수 출력은 입력에 따라서만 달라집니다. 따라서 함수형 프로그래밍은 우리가 익숙한 일반적인 프로그래밍 스타일보다 수학에 훨씬 가깝습니다. P.S. 주어진 입력에 대해 항상 동일한 출력을 반환하는 속성을 referential transparency라고 합니다.

Side effects

덧셈 함수에 결과를 파일로 쓰도록 수정합니다.

Java

int add(int x, int y) {
    int result = x + y;
    writeResultToFile(result);
    return result;
}

Kotlin

func add(x: Int, y: Int): Int {
    val result = x + y
    writeResultToFile(result)
    return result
}

이 함수는 계산 결과를 파일에 쓰고 있습니다. 이러한 경우에도 부작용은 발생할 수 있고 더 이상 순수함수가 아닙니다. 외부의 상태를 수정하는 코드는 변수를 변경하고, 파일에 쓰고, DB에 쓰고, 삭제하는 등의 부작용이 있다고 합니다.

부작용이 있는 함수는 더 이상 pure하지 않고 historical context에 의존하게 됩니다. 캐시에 의존하는 코드를 작성한다고 합시다. 이제 코드의 출력은 누군가가 캐시에 글을 썻는지, 무엇을 썻는지, 언제 썻는지, 데이터가 유효한지 등에 달려있습니다. 모든 가능한 상태를 이해하지 않으면 프로그램이 무엇을 하는지 이해할 수 없습니다. 만약 네트워크, DB, File, 사용자의 입력 등등 의존하는 다른 것들을 앱에 확장시킨다면 정확히 무슨 일이 일어나고 있는지 그리고 모든 것을 한 번에 머리속에서 맞추기가 어려워질 것입니다.

FP의 가장 훌륭한 아이디어는 부작용을 완전히 피하는 것이 아니라 그들을 포함시키고 격리시키는 것입니다. 우리의 앱이 부작용이 있는 기능이 깔려있는 대신 부작용이 시스템 가장자리로 밀려서 가능한 한 적은 영향을 미치기 때문에 앱을 더 쉽게 추론할 수 있습니다.

Ordering

side effect가 없는 많은 순수함수가 있다면, 실행 순서는 무의미합니다.

내부적으로 3개의 순수함수를 호출하는 함수를 보겠습니다.

Java

void doThings() {
    doThing1();
    doThing2();
    doThing3();
}

Kotlin

func doThings() {
    doThing1()
    doThing2()
    doThing3()
}

우리는 이러한 함수가 서로 의존하지 않는다는 것을 알고 있습니다. (하나의 출력이 다른 함수의 입력이 아니므로) 또한, 이 순수함수들은 시스템에 아무것도 변경하지 않습니다. 이러한 속성은 이 함수들의 순서에 독립적으로 실행될 수 있게 만듭니다. 그래서 얻는게 뭐냐고??? 이 함수들을 3개의 개별 CPU 코어에서 병렬로 실행할 수 있다. 아무런 걱정없이!

Immutability

Immutability는 일단 한번 생성된 값을 수정할 수 없다는 개념입니다.

아래 Car 클래스를 봅시다.

Java

public final class Car {
    private String name;

    public Car(final String name) {
        this.name = name;
    }

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

    public String getName() {
        return name;
    }
}

Kotlin

class Car(var name: String?)

Java 코드에서는 setter가 있고, kotlin 코드에서는 mutable 프로퍼티이므로 자동차를 만든 후에 자동차 이름을 수정할 수 있습니다.

Java

Car car = new Car("BMW");
car.setName("Audi");

Kotlin

var car = Car("BMW")
car.name = "Audi"

이 클래스는 not immutable합니다. 즉 생성된 후에 수정할 수 있다는 것을 말합니다. 그럼 이번엔 immutable하게 만들어 보겠습니다.

자바에서는 다음과 같은 작업이 필요합니다.

  • 변수를 final로 만든다.
  • setter를 제거한다.
  • 다른 클래스가 이 클래스를 상속하거나 내부를 수정할 수 없도록 final로 만든다.

Java

public final class Car {
    private final String name;

    public Car(final String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

코틀린에서는 이름을 immutable 값으로 만들어야합니다.

Kotlin

class Car(val name: String)

위의 코드들에서는 새로운 차를 만들 필요가 있다면 새로운 객체를 생성할 필요가 있습니다. 일단 만들어지면 아무도 수정할 수 없기 때문이죠.

하지만 Java에서 getter를 사용하거나 kotlin에서 이름 접근자를 사용하는 것은 어떨까요? 이름 변수를 외부로 반환하고 있습니다. 누군가가 getter에서 참조를 얻은 후 이름값을 수정하면 어떻게 될까요?

자바에서 String은 기본적으로 immutable이므로 문제가 되지않습니다. 하지만 리스트라면 어떤일이 벌어질까요?

Java

public final class Car {
    private final List<String> listOfDrivers;

    public Car(final List<String> listOfDrivers) {
        this.listOfDrivers = listOfDrivers;
    }

    public List<String> getListOfDrivers() {
        return listOfDrivers;
    }
}

이 경우 누군가가 getListOfDrivers() 메소드를 사용하여 내부 목록에 대한 참조를 가져와서 수정함으로써 클래스를 변경 가능하게 만들 수 있습니다.

immutable으로 만들려면 getter에서 실제 list를 그대로 전달하는 것이 아니라 deep copy를 전달해야 합니다. 그래야 caller로부터 안전하게 사용, 수정(?)될 수 있습니다. 또한, 생성자에게 전달된 목록도 deep copy로 만들어서 자동차를 만든 후에 아무도 외부에서 수정할 수 없도록 해야합니다.

Java

public final class Car {
    private final List<String> listOfDrivers;

    public Car(final List<String> listOfDrivers) {
        this.listOfDrivers = deepCopy(listOfDrivers);
    }

    public List<String> getListOfDrivers() {
        return deepCopy(listOfDrivers);
    }

    private List<String> deepCopy(List<String> oldList) {
        List<String> newList = new ArrayList<>();
        for (String driver : oldList) {
            newList.add(driver);
        }
        return newList;
    }
}

kotlin에서는 클래스 정의 자체에서 immutable 리스트를 간단하게 선언할 수 있습니다.

Kotlin

class Car(val listOfDrivers: List<String>)

java에서 호출하지 않는 한 안전하다.

Concurrency

순수함수는 우리에게 쉬운 동시성을 가능하게 하고, 만약 객체가 불변하다면, 수정할 수 없고 부작용을 일으킬 수 없기 때문에 순수함수에서 사용할 수 있습니다.

예를 들어, Car클래스에 getNoOfDrivers() 메서드를 추가하고 외부호출자가 드라이버 변수의 값을 수정할 수 있도록 변경해보겠습니다.

Java

public class Car {
    private int noOfDrivers;

    public Car(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }

    public int getNoOfDrivers() {
        return noOfDrivers;
    }

    public void setNoOfDrivers(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }
}

Kotlin

class Car(val listOfDrivers: List<String>)

Car 클래스의 인스턴스를 Thread1(T1) 과 Thread2(T2) 두 스레드에서 공유한다고 가정해봅시다.

Read-Modify-Write problem으로 알려진 고전적인 race condition입니다. 이 문제를 해결하는 기존의 방법은 Lock이나 Mutex를 사용해서 한번에 하나의 스레드만 공유 데이터에서 작동하고 작업이 완료되면 Lock을 푸는 것입니다.(위의 경우엔 T1은 계산이 완료될 때까지 Lock을 잡고 있는 것입니다.)

이러한 Lock 기반의 리소스 관리는 안전하게 수행하기도 어렵고 동시성 버그를 발생시키므로 분석하기가 매우 어렵습니다. Many programmers have lost their sanity to deadlocks and livelocks. 그럼 이 문제를 어떻게 해결해야할까요? 다시 Car클래스를 immutable하게 만들어봅시다.

Java

public final class Car {
    private final int noOfDrivers;

    public Car(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }

    public int getNoOfDrivers() {
        return noOfDrivers;
    }
}

Kotlin

class Car(val noOfDrivers: Int)

이제 T1은 T2가 Car 객체를 수정할 수 없음을 보장하므로 걱정없이 계산을 수행할 수 있습니다. T2에서 차량을 수정하려고 하면 자체 복사본이 생성되고 T1은 Car의 영향을 전혀 받지 않습니다. Lock이 필요없게 되는것이죠.

Immutability는 기본적으로 공유 데이터가 스레드로부터 안전함을 보장합니다. 수정하지 않아야 할 사항은 수정할 수 없습니다.

What If we need to have global modifiable state?

실제 애플리케이션을 쓰기 위해서, mutable 상태를 공유해야하는 경우가 많습니다. 실제로 noOfDrivers를 업데이트하고 시스템 전체에 반영해야 하는 요구가 있을 수 있습니다. 다음 장에선, functional architecture에서 어떻게 이러한 상황에 상태를 격리하고 side effect를 없애는지 다룰 것입니다.

Persistent data structures

Immutable한 객체가 많을 수는 있지만, 제한 없이 사용하면 GC에 과부하가 걸려 성능 문제가 발생합니다. FP는 객체 생성을 최소화하는 동시에 Immutability를 사용할 수 있는 특화된 데이터 구조를 제공한다. (aka. Persistent Data Structures)


[Reference]