Skip to content

Instantly share code, notes, and snippets.

@Hardtack
Last active August 1, 2019 07:54
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Hardtack/7f1c9d61cc1e5c07d7751880934a29d2 to your computer and use it in GitHub Desktop.
Save Hardtack/7f1c9d61cc1e5c07d7751880934a29d2 to your computer and use it in GitHub Desktop.
순수 함수형 상태 전이와 Functional Reactive 프로그래밍을 이용한 Reactive Model View Controller 설계 패턴 (1)

순수 함수형 상태 전이와 Functional Reactive 프로그래밍을 이용한 Reactive Model View Controller 설계 패턴 (1)

최근 많은 프로그래밍 언어에서는 함수형 프로그래밍을 지원하기 위한 다양한 도구들을 제공하고 있습니다. 그중 Swift는 최신 트렌드가 많이 반영된 언어로써, 스몰토크형 객체지향 프로그래밍과 함수형 프로그래밍을 함께 지원함으로써 개발자들에게 함수형 프로그래밍에 대한 접근성을 증가시켰습니다.

그리고 Reactive 프로그래밍이 조명을 받기 시작하며, Reactive Extension과 같은 훌륭한 리액티브 프로그래밍을 위한 도구를 제공하고 있습니다. Reactive Extension은 Reactive 프로그래밍을 지향하고 있으며, 이를 기반으로 Functional Reactive 프로그래밍을 실현할 수 있습니다. 또한, 다양한 언어에 포팅이 되어있어 서로 다른 언어에서 일괄적인 패턴을 적용할 수 있습니다.

이 글에선 순수 함수적 상태 전이에 대해 간단하게 알아보고, 이를 기반으로 Functional Reactive 프로그래밍이 지향하는바와, 이러한 기법들의 장점을 극대화 할 수 있는 설계에 대한 기본 원칙, 그리고 그에 따른 설계의 예를 다룰 것 입니다.

순수 함수형 상태 전이 (Pure Functional State Mutation)

순수 함수형 프로그래밍의 경우에는, 불변값(Immutable Value)를 다루고, 부수효과(Side effect)를 만들지 않는다는 원칙 때문에 상태를 전이하는 방법에 대해서 낯설게 느껴질 수 있습니다.

순수 함수로 상태를 다루기 위해서는 먼저 초계함수(High order function)에 대하여 알고 넘어가야합니다.

초계함수 (High Order Function)

초계함수의 정의는 간단합니다. 이 글에선

함수를 인자로 받는 함수

정도로 정의를 하면 충분할 것 같습니다.

함수형 프로그래밍에서 쓰이는 초계함수는 셀 수 없이 다양하게 있지만 가장 일반적으로 사용되는 함수들을 꼽아보았습니다.

아래 함수들은 함수형 프로그래밍에서 많이 쓰이는 초계함수들이며, 이해의 편의를 위해 리스트 타입에서의 정의로 설명하겠습니다.

  1. map

    map 함수는 리스트의 각 요소를 변환하여 새로운 리스트를 만드는 함수입니다. 일반적인 정의는 다음과 같습니다. (정의의 경우 수도-코드(Pseudo Code)로 작성하겠습니다.)

    func <T, R> map(origin: List<T>, mapper: T -> R) -> List<R>

    사실 Swift에서는 map 함수가 Array타입의 메소드로 정의가 되어있지만, 여기선 사소한 내용은 넘어가겠습니다.

    다음은 Swift에서 map을 이용해서 특정 숫자 배열의 값을 1 증가시키고, 문자열으로 변환하는 예제입니다.

    let foo = [1, 2, 3];
    // => [1, 2, 3]
    
    /**
     * 숫자를 1 증가시킨 후 문자열 타입으로 변환한다.
     */
    func increaseAndToString(x: Int) -> String {
        return String.init(x + 1);
    }
    // => Function
    
    foo.map(increaseAndToString)
    // => ["2", "3", "4"]
  2. flatMap

    flatMap 함수는 map 함수와 유사하지만, mapper의 반환 타입이 리스트라는 점에서 차이가 있습니다.

    map함수가 mapper가 반환한 값들을 요소로 하는 새로운 리스트를 만들었다면

    flatMap함수는 mapper가 반환한 리스트를 이어붙인 새로운 리스트를 만듭니다.

    flatMap은 일반적으로 다음과 같이 정의됩니다.

    func <T, R> flatMap(origin: List<R>, mapper: T -> List<R>) -> List<R>
    

    다음은 Swift에서 flatMap을 이용해서 문자열의 리스트를 문자의 리스트로 변환하는 예제입니다.

    let foo = ["Hello", "World!"];
    // => ["Hello", "World!"]
    
    /**
     * 문자열을 문자의 리스트로 변환한다.
     */
    func expandCharacters(string: String) -> Array<Character> {
        return Array.init(string.characters);
    }
    // => Function
    
    foo.flatMap(expandCharacters)
    // => ["H", "e", "l", "l", "o", "W", "o", "r", "l", "d", "!"]
  3. filter

    filter 함수는 이름에서 리스트에서 특정 조건을 만족하는 요소들로 새로운 리스트를 만드는 함수입니다. 여기서 "특정 조건은" Boolean 타입을 반환하는 함수로 정의됩니다.

    다음은 일반적인 filter함수의 정의입니다.

    func <T> filter(List<T> origin, predicate: T -> Boolean) -> List<T>
    

    다음은 filter를 이용해 숫자 리스트에서 짝수만 걸러내는 예제입니다.

    let foo = [1, 2, 3, 4, 5];
    // => [1, 2, 3, 4, 5]
    
    /**
     * 짝수인지 판별한다.
     */
    func isOdd(number: Int) -> Bool {
        return number % 2 == 0;
    }
    // => Function
    
    foo.filter(isOdd);
    // => [2, 4]
  4. fold

    fold 함수는 리스트의 값을 순차적으로 이행함수 (인자가 두개인 함수)에 적용해 하나의 값을 도출해내는 함수입니다.

    다음은 일반적인 fold함수의 정의입니다.

    func <T, R> fold(List<T> origin, initialValue: R, accumulator: (R, T) -> R) -> R
    

    다음은 fold 함수를 이용해서 문자열에 숫자들을 더하는 예제입니다. (Swift에선 reduce 라는 이름을 가집니다.)

    let foo = [1, 2, 3, 4, 5];
    // => [1, 2, 3, 4, 5]
    
    /**
     * 문자열 뒤에 숫자를 이어붙인다.
     */
    func appendNumber(left: String, right: Int) -> String {
        return left + String.init(right);
    }
    // => Function
    
    foo.reduce("Numbers: " , appendNumber);
    // => "Numbers: 12345"
  5. scan

    scan 함수는 fold함수와 유사합니다. 한가지 다른점은, 모든 값을 계산한 결과가 아닌, 중간 결과를 리스트로 그대로 반환하는 점 입니다.

    다음은 일반적인 scan함수의 정의입니다.

    func <T, R> scan(List<T> origin, initialValue: R, accumulator: (R, T) -> R) -> List<R>
    

    아쉽게도 Swift의 리스트에는 scan의 구현이 없습니다. 만약 있었다면 다음과 같은 형태였을 것입니다.

    let foo = [1, 1, 1, 1, 1];
    // => [1, 1, 1, 1, 1]
    
    foo.scan(1) { (x, y) in x + y };
    // => [2, 3, 4, 5, 6]

위 함수들이 함수형 프로그래밍에서 주로 사용되는 초계함수이며, 이 다섯가지로 상당히 많은 문제를 해결할 수 있을것입니다.

이렇게 대체할 수 있습니다.

모나드

위에서 언급한 초계함수들은 모두 리스트를 위한 도구들로써 언급되었지만, 사실 리스트에만 국한된 도구가 아닙니다.

fold, scan을 제외한 flatMap, map, filter는 "모나드" 라 불리는 모든 형태의 타입에 적용될 수 있습니다.

모나드의 엄밀한 정의는 조금 더 많은 내용을 포함하지만, 이 글에서는 어려운 내용을 포함하지 않기를 원하기에, 엄밀하지 않지만 단순한 정의를 하였습니다.

1. 값을 가질 수 있는 컨테이너 타입

2. flatMap 함수를 이용하여, 내부의 값을 다른 값으로 전이할 수 있는 타입

3. 이때 전이란, mapper에 의해 변환된 값을 가진 새로운 컨테이너를 만드는 것을 이야기합니다

map과 filter는 flatMap을 이용해 구현할 수 있는 함수이기에, 정의에 포함되지 않았습니다.

이 정의에 의하면 위에서 보았듯이 리스트는 모나드입니다.

Swift의 빌트인 타입인 Optional도 모나드입니다.

그리고 결정적으로 RxSwift의 Observable 타입 또한 모나드입니다.

그리고 RxSwift를 사용해 본 경험이 있다면 알 수 있듯이, fold(reduce)와 scan은 이미 Observable에 포함이 되어있습니다. 여기서 fold, scan등의 함수는 연속적인 값을 가질 수 있는 모나드에 적용될 수 있다는 것을 할 수 있을것입니다.

이를 명확하게 정의하면, "Foldable인 Monoid에 적용될 수 있다..." 이런식이지만 이해할 필요는 없습니다.

모나드에 대해서 언급을 한 것은, 위에서 언급한 다섯가지 초계함수들이 특정 종류에 속한 타입이라면 똑같이 적용될 수 있는 범용 개념이며, 리스트에 국한되지 않았다는것을 설명하기 위해서였습니다.

이제 모나드는 잊어버려도 좋습니다! (어차피 올바른 정의도 아닙니다)

순수 함수형 상태 전이 (Pure Functional State Mutation)

이제 순수 함수를 이용해 상태 전이를 이해하기 위한 기반은 다져졌습니다.

먼저 상태의 전이를 정의해보자면

기존 상태와, 전이 액션이 조합되어 새로운 상태를 만들어내는 현상

으로 정의 할 수 있습니다.

예를 들어, 1 이라는 숫자 상태에 1을 더하라 라는 액션이 조합된다면 2 라는 새로운 상태가 만들어집니다.

조금 더 현실 문제에 가까운 이야기를 해보겠습니다.

다음과 같은 타입의 상태가 있다고 합시다.

struct State {
    let loading: Bool
    let data: String?
}

위 타입은 데이터가 로딩중인지 나타내는 loading 필드와, 실제 데이터를 나타내는 data필드를 가집니다.

다음은 전이 액션에 대한 타입입니다.

enum Action {
    case setLoadingStart
    case setLoadingEnd
    case updateData(String?)
}

로딩의 시작, 끝, 그리고 데이터의 업데이트를 나타내는 액션입니다.

만약 초기상태가 다음과 같고

let initialState = State(loading: false, data: nil)

Action.setLoadingStart 액션을 수행하면, 전이된 상태는 다음과 같은 것 입니다.

State(loading: true, data: nil)

이는 다음과 같은 함수로 나타낼 수 있습니다.

func mutate(state: State, action: Action) -> State {
    switch action {
    case .setLoadingStart:
        return State.init(loading: true, data: state.data)
    case .setLoadingEnd:
        return State.init(loading: false, data: state.data)
    case .updateData(let data):
        return State.init(loading: state.loading, data: data)
    default:
        return state
    }
}

위 함수는 상태를 어떻게 전이할지에 대해 기술하고 있습니다. 이제 이 전이 방법을 실제로 적용하려면 어떻게 할까요?

어플리케이션, 또는 뷰 등의 생명주기동안에는 액션은 지속적으로 유입이 될 것 입니다. 이러한 액션의 지속적인 유입을 스트림으로 추상화하면 RxSwift를 이용해 Observable<Action>으로 나타낼 수 있습니다.

그리고, 액션의 지속적인 유입은 액션으로 인해 전이된 상태의 스트림을 생산하게 되고, 이는 Observable<State>로 나타낼 수 있습니다.

다음 예시에서는 액션의 스트림과 위에서 정의한 mutate 함수를 이용해 상태의 스트림을 만들어냅니다.

let actionStream: Observable<Action> = /* Create action stream */
let stateStream: Observable<State> = actionStream.scan(initialState, accumulator: mutate)

만약 loading 상태를 숨기고, data만 상태로 노출하고 싶다면 map 함수를 이용하면 됩니다.

let publicStateStream = stateStream.map { x in x.data }

정말 간단하지 않나요? 정말 이걸로 복잡한 문제들을 해결할 수 있을지 의심이 들 수 있지만, 이미 Facebook에서는 비슷한 방식([1], [2])으로 복잡한 문제들을 해결하고 있습니다.

이로써 순수함수만을 이용해 상태 전이를 만들어내는 방법을 알았습니다. 이렇게 순수 함수를 이용해 상태를 Reactive하게 다루는 방식을 Functional Reactive Programming이라고 부릅니다. (Reactive에 대한 정의는 Rx 사용자들에게는 이제는 진부할테니 생략하겠습니다.)

다음에는 Functional Reactive Programming을 이용해 어떻게 실제로 UI를 가진 어플리케이션을 구성하는지와, 단방향의 데이터 흐름(Unidirectional Data Flow)을 가진 모델-뷰-컨트롤러(Model-View-Controller) 패턴에 대한 이야기를 하겠습니다.

@Hardtack
Copy link
Author

@devxoul 해...야겠죠..?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment