Android 개발자를 위한 함수형 프로그래밍 Part 2

Android 개발자를 위한 함수형 프로그래밍 Part 2

원문 Functional Programming for Android Developers — Part 2을 번역한 글입니다.


이전 파트에서 Purity, Side effects, Ordering에 대해 배웠고, 이번 파트에서는 immutabilityConcurrency에 대해 알아보겠습니다. Part 1을 아직 읽지 않았다면, 여기에서 확인하세요.

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에서는 name이 수정 가능한 변수이기 때문에 생성 후 Car의 이름을 바꿀 수 있습니다.

Java

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

Kotlin

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

생성 후 수정이 가능하기 때문에 이 클래스는 immutable이 아닙니다.

immutable로 만들기 위해서는 (Java 코드에서):

  • name 변수를 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;
    }
}

Kotlin에서는 immutable 변수로 선언만 해주면 됩니다.

Kotlin

class Car(val name: String)

이제 새 인스턴스를 생성할 수는 있지만 한 번 생성되면 아무도 수정할 수 없습니다. 이 클래스는 이제 immutable 클래스입니다.

하지만 Java의 getName() getter나 Kotlin의 name 접근자는 어떨까요? 호출한 쪽에 name 변수의 값을 맞게 반환할까요? 만약 누군가 이 getter를 통해 참조를 얻은 후 name 값을 바꾸면 어떻게 될까요?

Java에서는 String은 기본적으로 immutable 입니다. 누군가 name의 참조를 얻어 수정하면, name 값을 복사해서 사용하고 원래의 string은 그대로 남아있습니다.

하지만 immutable이 아닌 List 같은 다른 타입들은 어떻게 될까요? Car 클래스가 Driver 리스트를 갖도록 수정해봅시다.

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()를 사용하여 참조를 얻어 내부 리스트를 수정할 수 있고, 따라서 이 클래스는 mutable이라고 할 수 있습니다.

immutable로 만들기 위해서는, getter에서 내부의 리스트와는 별도인 deep copy된 리스트를 넘겨서 새로운 리스트가 호출된 쪽에서 안전하게 수정될 수 있도록 해야 합니다. 생성자를 통해 받은 리스트 또한 deep copy를 통해 Car 생성 후에는 외부에서 아무도 수정하지 못하게 해야 합니다.

Deep copy는 가지는 데이터를 재귀적으로 모두 복사한다는 의미입니다. 예를 들어, 리스트가 일반 String이 아닌 Driver 객체의 리스트라면, Driver 객체도 각각 모두 복사해야 합니다. 그렇지 않으면 mutable한 원래의 Driver 객체의 참조를 갖는 리스트를 만들게 됩니다. 이 클래스에서는, 리스트가 이미 immutable인 String으로 구성되기 때문에 아래와 같이 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;
    }
}

이제 이 클래스는 정말로 immutable 합니다.

Kotlin에서는 간단하게 클래스에서 리스트를 immutable로 선언하기만 하면 됩니다. 그리고 사용할 때도 안전합니다(Java 코드에서 호출하는 등의 예외 상황만 없으면요).

Kotlin

class Car(val listOfDrivers: List<String>)

Concurrency

Part 1에서 얘기했던 것처럼, 순수 함수는 concurrency를 쉽게 가능하도록 해주고, 객체가 immutable이면 수정으로 인한 사이드 이펙트가 발생하는 일이 없기 때문에 순수 함수에서의 사용이 매우 쉽습니다.

예시를 봅시다. Car 클래스에 Java는 getNoOfDrivers() 메서드를 추가하고 Kotlin에서는 mutable 변수를 추가하여 외부에서 driver의 수를 수정할 수 있도록 만든다고 가정해보세요:

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(var noOfDrivers: Int)

Car 인스턴스를 Thread_1, Thread_2이라는 두 스레드 간에 공유한다고 가정해봅시다. Thread_1은 Driver 수에 기반한 어떤 계산을 하기 위해 Java의 noOfDrivers()를 호출하거나 Kotlin의 noOfDrivers 프로퍼티에 접근합니다. 이 때 Thread_2noOfDrivers 변수를 수정하려고 합니다. Thread_1은 이런 변화에 대해 모르기 때문에 그냥 값을 가져와서 계산합니다. 이런 계산들은 틀릴 수 밖에 없습니다.

아래 시퀀스 다이어그램은 이 이슈를 나타냅니다: image

이건 Read-Modify-Write 문제라고 하는 기본적인 race condition입니다. 이를 해결하기 위한 고전적인 방법은 locks와 mutexes를 사용하는 것입니다. 공유된 데이터에 한 번에 한 스레드만 접근하도록 하고, 한 연산이 끝나면 lock을 해제하는 방법입니다(우리 예제에서는, Thread_1이 계산을 끝낼 때까지 Car에 lock을 걸고 있는 겁니다).

이런 lock 기반의 자원 관리 방법은 안정적으로 만들이 아주 어렵고, 분석하기도 매우 힘든 동시성 버그가 생기기 쉽습니다. 많은 개발자들이 deadlock과 livelock 때문에 멘붕이 오곤 하죠.

immutability(불변성)가 이걸 어떻게 해결해줄 수 있을까요? 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)

Thread_2Car 인스턴스를 수정할 수 없다는 것이 보장되기 때문에, Thread_1은 이제 걱정없이 값을 가져와서 계산할 수 있습니다. Thread_2Car 인스턴스 수정을 원하면, 수정을 위해 복사하여 새로 만들 것이고, Thread_1은 전혀 영향받지 않을 것입니다. lock이 필요없습니다.

image

Immutability는 기본적으로 공유된 데이터가 thread-safe하다는 것을 보장합니다. 수정되지 말아야 할 것들은 수정될 수 없습니다.

수정 가능한 전역 변수가 필요할 땐 어떡하죠?

실제 어플리케이션을 만들 땐, 많은 인스턴스에 수정 가능한 상태들을 공유해야 합니다. noOfDrivers를 업데이트하고, 그 업데이트를 시스템 전반에 반영해야 하는 요구사항이 분명 있을 수 있습니다. 이 문제에 대해서는 다음 챕터의 functional architecture를 다룰 때, 상태 고립을 이용하고 사이드 이펙트를 시스템 구석으로 몰아놔서 처리할 것입니다.

Persistent data structures

Immutable 객체는 좋은 방법이지만, 제한없이 사용한다면, 가비지 콜렉터에 부하가 되고 성능 이슈가 생길 수도 있습니다. Functional programming은 객체 생성을 최소화하면서 immutability를 사용할 수 있는 적합한 데이터 구조도 제공합니다. 이런 구조가 Persistent Data Structures입니다.

Persistent Data Structure는 수정될 때 자신의 이전 버전을 항상 유지합니다. 이런 데이터 구조는 연산들이 (명시적으로) 같은 자리에 업데이트하지 않고, 항상 새 구조를 생성하기 때문에 효율적인 immutable이 가능합니다.

아래와 같은 단어들을 메모리에 저장해야 한다고 해봅시다: reborn, rebate, realize, realizes, relief, red, redder.

각각을 모두 따로 저장할 수도 있지만 필요 이상으로 메모리를 쓰게 됩니다. 자세히 보면, 단어에 겹치는 글자들이 많은 걸 알 수 있습니다. 그럼 이 단어들을 아래처럼 하나의 트리(Trie) 구조로 나타낼 수 있습니다(모든 트리 구조가 영구적이진 않지만 persistent data structure 구현을 위해 사용할 수 있는 방법 중 하나입니다):

image

Persistent Data Structure가 어떻게 동작하는지 알 수 있는 기본 구조입니다. 새로운 단어가 추가되면, 새 노드를 생성하고 적절한 곳에 연결합니다. 어떤 객체에서 노드 삭제가 필요한 경우, 해당 객체에서 참조만 제거할 뿐 실제 노드는 메모리에 남아있어서 사이드 이펙트를 방지할 수 있습니다. 이 구조를 참조하는 다른 객체들은 계속 쓸 수 있습니다.

아무 객체도 참조하지 않는다면 GC를 통해 전체 구조를 메모리에서 지울 수 있습니다.

Java에서 Persistent Data Structure는 기본적인 개념은 아닙니다. Clojure는 JVM에서 실행되는 함수형 언어이고, Persistent Data Structure를 위한 표준 라이브러리도 있습니다. Android 코드에서 Clojure의 statndard lib을 직접 쓸 수 있지만 용량과 메서드 수가 상당히 큽니다. 더 좋은 대안은 제가 전에 발견한 PCollections라는 라이브러리입니다. 427개의 메서드와 48kb dex size만으로, 충분히 쓸 수 있습니다.

예시로 PCollections를 이용해 persistent linked list를 생성하고 사용하는 법입니다:

Java

ConsPStack<String> list = ConsPStack.empty();
System.out.println(list);  // []

ConsPStack<String> list2 = list.plus("hello");
System.out.println(list);  // []
System.out.println(list2); // [hello]

ConsPStack<String> list3 = list2.plus("hi");
System.out.println(list);  // []
System.out.println(list2); // [hello]
System.out.println(list3); // [hi, hello]

ConsPStack<String> list4 = list3.minus("hello");
System.out.println(list);  // []
System.out.println(list2); // [hello]
System.out.println(list3); // [hi, hello]
System.out.println(list4); // [hi]

여기서 볼 수 있듯이 바로 수정되는 리스트는 없고, 수정이 필요한 경우 모두 새로운 복사본이 반환됩니다.

PCollections는 다양한 use case에 맞게 구현된 표준 Persistent data structure를 가지고 있습니다. 또한 Java의 표준 Collection 라이브러리와도 꽤 편하게 호환됩니다.

Kotlin은 이미 immutable collection이 표준 라이브러리로 지원되니, Kotlin을 사용하고 있다면 좋은 방향으로 가고 있는 겁니다.

Persistent data structures는 아주 광범위한 주제이고 이 섹션에서는 빙산의 일각만 다뤘을 뿐입니다. 좀 더 알고 싶으시다면, Chris Okasaki’s Purely Functional Data Structures를 완전 추천합니다.

Summary

ImmutabilityPurity는 안정성있고 동시성을 갖는 프로그램을 짜는 데 있어 강력한 조합입니다. 다음 파트에서는 higher order functions와 closures에 대해 알아보겠습니다.

Comments