Swift functional programming

Swift와 함수형 프로그래밍의 역사

Rob Napier는 map, flatten, lense 등의 고전적인 함수형 프로그래밍 뿐 아니라 monad 와 functor 등의 현대적인 함수형 프로그래밍까지 두루 섭렵하였습니다. 그는 무한대의 세계에서 Maybe 의 세계로 이동하였으며 이러한 경험을 수없이 반복해 왔습니다. Haskell 에서 Church 까지 이어지는 수많은 경험 속에서 그가 발견한 사실은 Swift 가 함수형 프로그래밍 언어가 아니라는 사실입니다. Swift 를 함수형 언어의 극단으로 밀어부칠 경우, Cocoa 와의 호환성에 문제가 발생하게 됩니다.

하지만, Swift 는 함수형 세계로부터 많은 장점들을 흡수해 왔으며, 아직은 완벽한 value type 은 아니지만 가까운 시일 안에 그렇게 될 것으로 생각합니다. Rob 은 수년간 함수형 언어 세계에서 일어난 일들이 어떻게 Swift 에 영향을 주었는지 설명하고, 함수형 언어의 기능들을 Swift 언어, Cocoa 환경, 프로토콜 지향 프로그래밍 개념과 어떻게 접목시킬 수 있는지 보여줄 것입니다.

번역: 문상준 iOS 개발자. Reactive, Functional programming 과 Swift 관련 글쓰기에 관심이 많습니다. 현재는 Vandad Nahavandipoor 가 진행하는 crowdsource swift book 프로젝트 에서 열심히 활동하고 있습니다. 관심있는 분들의 참여는 언제나 환영입니다.


소 개 (0:00)

Swift 가 처음 소개된 후, 일주일 뒤에 저는 “Swift 는 함수형이 아니다” 라는 글을 기고하였습니다. 그로부터 2년이 지났지만, Swift 는 여전히 함수형이 아닙니다. Swift 언어의 함수형 프로그래밍에 대해 설명하는 것은 잘못된 접근법이라 생각합니다. 대신 수 년간 함수형 언어에서 축적된 경험과 연구들을 이야기해보고자 합니다. 우리는 이것을 Swift 방식으로 Swift 에서 활용할 수 있습니다.

함수형 프로그래밍이란?

함수형 프로그래밍은 monads, functors, Haskell, 멋진 코드라고 정의할 수 있습니다.

함수형 프로그래밍은 언어나 문법이 아니라 문제에 대한 접근 방식 중 하나입니다. 함수형 프로그래밍은 monads 개념이 소개되기 이전부터 수년간 우리 곁에 존재했습니다. 함수형 프로그래밍은 구조적인 방법으로 문제를 분해하고 그들을 다시 조합하는 문제 접근 방식 중의 하나입니다.

우선, Swift 언어로 작성된 간단한 예제를 살펴봅시다.

var persons: [Person] = []
for name in names {
    let person = Person(name: name)
    if person.isValid {
        persons.append(person)
    }
}

단순한 루프로 이루어진 예제입니다. 이것은 두 가지 동작을 수행합니다: names 를 persons 로 변환하고 만약 그들이 적절하면(valid) 배열에 추가합니다. 정말 간단한 구조입니다. 여기에서 좀 더 깊게 들어가 봅시다. 어떤 일이 일어나는지 이해하기 위해서 당신은 당신의 머리 속에서 이것을 실행시켜야 합니다. 당신은 다음과 같은 생각을 해야합니다. “이 persons 배열은 무슨 일을 하는거지?” 위의 코드는 이것이 names 와 함께 구성된 valid persons 의 list 라는 사실을 말해주지 않습니다.

이것은 간단한 예제이지만 더 간단해 질 수 있습니다. 이를 위해, 위의 예제를 각각의 수행업무(concerns)를 기준으로 분리할 수 있습니다.

이런 개발 뉴스를 더 만나보세요

var possiblePersons: [Person] = []
for name in names {
  let person = Person(name: name)
  possiblePersons.append(person)
}

var persons: [Person] = []
for person in possiblePersons {
  if person.isValid {
    persons.append(person)
  }
}

이제 우리는 두개의 루프를 갖게 되었습니다. 각각의 루프는 이전보다 적은 양의 업무를 담당하게 되었습니다. 각각의 루프는 간단하며, 간단해진 코드에서 우리는 패턴을 발견할 수 있습니다: 배열을 생성하고, 배열에 저장된 값들에 하나씩 접근하며, 각각의 값에 대해 어떠한 작업을 수행하고,마지막에는 다른 배열에 그 결과를 저장합니다.

어떤 작업을 여러번 수행해야 하는 경우, 우리는 함수를 작성하여 이러한 작업을 처리할 수 있음을 알고 있습니다. Swift 의 “map” 이 지금의 상황에 어울리는 함수입니다. map 은 특정한 리스트를 다른 리스트로 변환합니다. 중요한 것은 이 방식은 코드 자체로 이것이 무엇을 하는 것인지 알 수 있다는 것입니다: possiblePersons 은 namesperson 과 mapping 한 것입니다. 그것은 그들의 names 를 기준으로 한 persons list 입니다. 우리는 이제 우리 머리속에서 어떤 것을 실행할 필요가 없으며, 우리가 생각하는 것이 이미 코드에 존재하고 있습니다.

let possiblePersons = names.map(Person.init)
let persons = possiblePersons.filter { $0.isValid }

다른 루프 역시 매우 일반적인 패턴입니다: 여기서는 “filter” 라는 메소드가 사용되었습니다. filter 는 bool 을 리턴하는 predicate 함수를 인자로 사용합니다. filter 메소드는 predicate 를 적용하여 이를 통과한(valid) 결과만을 제공합니다. 우리는 map 과 filter 를 조합할 수 있습니다. 위의 예제처럼 map 으로 possbile persons 을 생성한 다음, filter 를 적용할 수 있습니다.

7 줄의 코드가 2 줄의 코드로 줄었습니다. 또한, 우리는 분리된 코드를 다시 합칠 수 있습니다. 분해한 코드를 다시 조합하는데 필요한 모든 값들을 chaining 방식을 이용해 합칠 수 있습니다.

매우 읽기 쉬운 코드가 되었습니다. 한 번에 한 라인씩 읽으면 됩니다.

let persons = names
    .map(Person.init)
    .filter { $0.isValid }

이러한 프로그래밍 습관을 배우고, 앞으로 이러한 프로그래밍 방식에 익숙해지길 바랍니다. 이와 유사한 사례를 구현해야 할 경우 더이상 for-loop 를 이용해 코드를 작성하지 않길 바랍니다. 왜냐하면 이 방식은 persons 를 어떻게 만들어야 하는지에 대해 초점을 맞추는 대신 persons 가 무엇인지에 대해 우리에게 알려주기 때문입니다.

함수형 도구들 (5:54)

1977 년, John Backus(FORTRAN, ALGOL 언어 탄생에 기여함) 는 Turing award 에서 수상한 뒤, “Can programming be liberated from the von Neumann style? A functional style and its algebra of programs” 라는 글을 발표하였습니다. 저는 이 제목을 좋아합니다. “Von Neumann style” 은 FORTRAN 과 ALGOL 을 의미합니다. 요즘으로 따지면 “C 언어” 라고 할 수 있겠습니다. 또한 이것은 절차적 프로그래밍을 의미하기도 합니다.

그 글은 FORTRAN, ALGOL 을 만든 것에 대한 그의 사과문이었습니다. 그는 이 글에서 절차적 프로그래밍이란 사용자가 원하는 최종 상태를 얻을 때까지의 단계적인 상태 변화라고 설명하고 있습니다. 또한, 그가 “함수형” 이라고 표현한 부분은 오늘날 우리가 알고 있는 의미와는 다르지만 그의 글은 많은 함수형 학자들에게 영감을 주었습니다.

글의 내용 중 “복잡한 것들을 간단한 것들로 분해하는 방법” 과 같은 것은 Swift 에서도 활용할 수 있는 것이기 때문에 저의 관심을 끌었습니다. 이는 간단한 것들을 제너릭(generic)으로 만들고 난 뒤, algebra 와 같은 어떤 규칙을 사용해 다시 조합하는 것입니다.

Algebra 는 어떤 것을 합치고, 분해하고, 변환하는 규칙들을 모아놓은 것이라고 할 수 있습니다. 우리는 프로그램을 조작하는데 사용할 수 있는 규칙들을 직접 만들 수도 있습니다. 우리는 이미 그렇게 했습니다: loop 를 사용하고, 좀 더 간단한 두개의 loop 로 분해하였으며, 각각의 loop 안에서 일반적인(generic) 형태를 발견했습니다. 그리고 그들을 다시 chaining 을 이용해 다시 조합했습니다. Haskell 프로그래머들이 Swift 를 처음 접하면 당황하는 경향이 있습니다. 왜냐하면 그들이 Haskell 에서 했던 것을 시도하기 때문입니다.

대부분의 함수형 프로그래밍 언어와 마찬가지로 Haskell 에서 composition 기본 유닛은 함수입니다. Haskell 은 함수를 결합하는(compose) 멋진 방법이 있습니다. foldr 함수와 + 함수를 결합하고 초기값으로 0 을 갖는 sum 이라는 새로운 함수를 만들 수 있습니다. 당신은 아래 코드가 보기 불편할지도 모르겠습니다. 하지만 당신은 이 코드가 멋지다는 사실을 금방 깨닫게 될 것입니다.

let sum = foldr (+) 0
sum [1..10]

이것을 Swift 에서도 구현할 수 있지만 Haskell 처럼 멋진 형태는 아닐 것입니다. 또한 잘 동작하지 않을 것입니다. 왜냐하면 Swift는 Haskell 과는 다른 유닛으로 composition 을 수행하기 때문입니다. Swift 는 함수형이 아닙니다.

Swift 의 composition 유닛은 type 입니다. Swift 에서 결합(compose)할 수 있는 것은 class, struct, enum, protocol 등이 있습니다. 아래 예제는 두 type 을 결합하는 일반적인 방법을 보여줍니다. 하나는 Sequence type(프로토콜) 이며, 다른 하나는 MyStruct type(struct) 입니다. 이러한 결합으로 MyStruct 는 Sequence 의 모든 method 를 활용할 수 있는 강력한 기능을 갖게 되었습니다. 우리는 단순한 조각들로부터 그들을 결합하고 조합할 수 있습니다.

extension MyStruct<T>: Sequence {
    func makeIterator() -> AnyIterator<T> {
        return ... 
    }
}

Swift 에서 주로 사용되는 또다른 composition 방법은 type 에 문맥(context)을 추가하는 것입니다. 가장 대표적인 것이 optional 입니다. Optional 은 type 에 문맥정보를 추가한 것이며, type 은 값이 있을 때도 있고 없을 때도 있습니다. type 에 관련해 문맥에 추가한 정보는 다음과 같습니다: 이것은 존재하는 것인가? 그것이 바로 문맥이 의미하는 것입니다. 문맥을 추가하는 것은 다른 추가적인 정보를 추적하는 다른 방법보다 훨씬 강력한 방법입니다. 아래 예제는 문맥 정보를 추가한 코드와 그렇지 않은 경우 코드를 비교한 것입니다.

// No value: magic value
let noValue = -1
let n = 0
if n != noValue { ... }

// No value: context
let n: Int? = 0
if let n = n { ... }

Integer 가 아니다 또는 값이 없다는 사실을 추적하는 한가지 방법은 integer 범위에 속하지 않는 -1 과 같은 값을 사용하는 것입니다. -1 은 integer 표현 범위 안에 속하지 않기 때문에 이를 이용해 값이 존재하지 않음을 표현할 수 있습니다. 하지만 모든 곳에서 값의 존재 유무를 확인해야 한다는 단점이 있습니다. 이것은 좋지 않은 방법이며 에러가 발생하기 쉽고 컴파일러도 우리에게 에러 발생 여부를 알려주지 못합니다.

만약 integer 를 integer optional 로 변경하면 컴파일러가 당신을 도와줄 것입니다. 문맥에는 값의 존재 유무에 대한 정보가 들어있고 컴파일러는 이 사실을 당신이 잊어버리지 않도록 도울 것입니다. 당신이 -1 을 사용해 본 경험이 있다면, 값을 확인하는 작업을 자주 빠뜨린다는 사실을 알고 있을 것입니다. 만약 이 값을 확인하는 것을 잊는다면, 프로그램은 예상치 못한 방향으로 진행될 것입니다.

예 제 (11:44)

좀 더 복잡한 예제를 살펴봅시다.

func login(username: String, password: String,
         completion: (String?, Error?) -> Void)

login(username: "rob", password: "s3cret") {
    (token, error) in
    if let token = token {
    // success
    } else if let error = error {
    // failure
    }
}

이것은 매우 일반적인 API 입니다. usernamepassword 를 인자로 받는 login 함수는 특정시점에 token 과 발생 가능한 error 를 반환합니다. 저는 우리가 문맥을 통해 더 나은 방법을 찾을 수 있다고 생각합니다.

첫 번째 문제는 completion 블럭입니다. 저는 그것이 String 이라고 말할 수 있습니다. 하지만 그 string 은 무엇인가요? token 입니다. 아래와 같이 token 이라는 label 을 추가할 수도 있지만 그것은 별로 도움이 되지 않습니다.

func login(username: String, password: String,
         completion: (_ token: String?, Error?) -> Void)

Swift 에서 label 은 type 이 아닙니다. 만약 label 을 추가한다고 해도, 이것은 여전히 string 입니다. 어떠한 것도 string 이 될 수 있습니다.

Token 은 규칙이 가지고 있습니다. 예를 들어 일정한 길이를 가지고 있어야 한다거나 절대 empty 가 될 수 없다거나 하는 규칙들이 존재할 수 있습니다. string 은 일정한 길이가 아니여도 상관없고, empty 가 될 수도 있습니다. 하지만 token 은 그러면 안됩니다. 우리는 token 이 좀 더 문맥적인 의미를 갖길 원합니다. token 은 규칙을 가지고 있으므로, 우리는 이 규칙들을 적용하고 싶습니다. 우리는 그렇게 할 수 있습니다. 우리는 이것을 좀 더 구조적(structure)으로 만들 수 있습니다. 그것이 바로 우리가 struct 라고 부르는 이유입니다.

struct Token {
    let string: String
}

string 을 구조체 안에 넣었습니다. 이렇게 바꾸는데는 비용도 들지 않고, 추가적인 메모리나 참조(indirection)가 요구되지도 않습니다. 또한, 이제는 규칙을 추가하는 것이 가능해졌습니다.

특정한 문자열만 허락하는 구조를 만들 수도 있습니다. extension 을 이용해 특정 문자열은 허락하지 않게 만들 수도 있습니다. 코드가 좀 더 나아졌습니다. 또한, 이 안에서 string 뿐만 아니라 dict, array, int 와 같은 모든 type 을 다룰 수 있습니다.

만약 구조체를 이용한 Token 과 같은 type 을 갖게 되면, 이것을 문맥에 추가할 수 있으며 이 type 에 당신이 추가한 것들에 대해 제어할 수 있게 됩니다. 당신은 그 type 이 무엇을 의미하는지 제어할 수 있습니다. label 이나 주석을 추가할 필요가 없습니다. 왜냐하면 첫번째 인자가 Token type 의 token 이라는 것이 명확하기 때문입니다.

두번째 문제는 우리가 usernamepassword 를 넘겨준다는 것입니다. 이들을 사용하는 대부분의 프로그램에서 당신은 항상 이들을 함께 전달했을 것입니다. 패스워드를 아이디 없이 개별적으로 넘기는 것은 무의미하지요. 저는 username 과 password 를 “함께(and)” 결합하는(compose) 규칙을 만들고자 합니다. 때문에 “and” type 이 필요합니다. 우리는 이미 그 type 을 가지고 있으며 그것은 바로 struct 입니다.

“AND” Type (Product) (14:50)

struct Credential {
    var username: String
    var password: String
}

구조체는 “and” type 입니다. Credentialusername 과(and) password 입니다. “and” type 은 일반적으로 “product type” 이라고도 합니다.

다음 문장을 크게 외쳐보세요. “credential 은 username 과(and) password 다.” 말이 되는 문장인가요? 만약 말이 되지 않는다면 이것은 잘못된 type 이거나 당신이 잘못 만든 것입니다.

func login(credential: Credential,
           completion: (Token?, Error?) -> Void){}

let credential = Credential(username: "rob",
                            password: "s3cret")
                    login(credential: credential) { (token, error) in
    if let token = token {
    // success
    } else if let error = error {
    // failure 
    }
}

이제 우리는 usernamepasswordCredential 로 교체했습니다. 이로 인해 우리의 signature 는 좀 더 간결해지고 명확해졌습니다. 또한 확장성도 향상되었습니다: extension 으로 Credential 을 확장하거나 다른 type 으로 Credential 을 구성하고 있는 규칙들을 교체할 수 있습니다. 이것은 원타임 패스워드가 될 수도 있고, 구글, Facebook 에 대한 액세스 토큰이 될 수 도 있습니다. 이제는 이런 변화가 발생하더라도 credential 을 넘겨주기만 하면 되기 때문에 API 코드를 변경할 필요가 없습니다.

여전히 우리의 코드는 문제가 남아 있습니다. 우리는 (Token?, Error?) 튜플을 인자로 전달하고 있습니다. 튜플은 anonymous struct 라고 할 수 있습니다. 따라서 튜플은 “and” type 입니다. (Token?, Error?) 는 4 개의 경우의 수가 존재합니다: 둘 다 값이 있거나, 둘 다 값이 없거나, 둘 중 하나만 있거나. 이 가운데 오직 두 가지만이 말이 됩니다. token 또는 error 가 있는 경우만 이치에 맞습니다.

let credential = Credential(username: "rob",
                            password: "s3cret")
                    login(credential: credential) { (token, error) in
    if let token = token {
    // success
    } else if let error = error {
    // failure 
    }
}

만약 token, error 둘 다 전달받았다면 어떻게 해야할까요? error 상태는 어떤 것일까요? fatal error 가 필요할까요? 이것을 무시해야 할까요? 어떻게 해야할까요? 당신은 이러한 점을 생각해야 하고 아마도 이것에 대해 테스트 코드를 작성해야 할 것입니다.

“OR” Type (Sum) (17:19)

문제는 당신이 의미하는 바가 “optional(역자 주: 저자는 optional 은 맘에 들지 않는 이름이라면서 maybe 라고 표현함.)” 이 아니라는 것입니다. 당신은 token 또는(or) error 를 표현하고자 합니다. “or” type 으로 사용할 수 있는 것은 무엇이 있을까요?

바로 enum 입니다. enum 은 “or” type 입니다. 반면에 struct 는 “and” type 입니다. “and” type 이 “product type” 이라고 불린 것과 마찬가지로, “or” type 은 일반적으로 “sum types” 이라고 말합니다.

enum Result<Value> {
    case success(Value)
    case failure(Error)
}

Result type 을 만들어 보았습니다. Swift 에 Result type 이 포함되지 않았다는 점이 저를 짜증나게 합니다. Result type 은 쉽게 만들 수 있습니다. Result type 을 통해 우리는 value(역자 주: 코드에서는 token, error 를 의미함)에 문맥을 추가했습니다. 단지 value 였던 것이 successful value 가 되었고, error 역시 failing error 가 되었습니다.

func login(credential: Credential,
           completion: (Result<Token>) -> Void)
     login(credential: credential) { result in
     switch result {
     case .success(let token): // success
     case .failure(let error): // failure
     }
}

만약 resulting token 을 추가한 형태로 API를 변경하면, 우리가 테스트해야만 했던 불가능한 경우의 수는 사라집니다. 우리는 그것이 불가능하기 때문에 그것에 대해 걱정할 필요가 없습니다. 저는 테스트 코드를 작성하는 것보다 처음부터 버그 발생이 불가능한 방식을 선호합니다.

저는 이 API 를 좋아합니다. credential 을 통해 로그인하고, 이후 resulting token 이 저에게 반환될 것입니다.

교 훈 (18:51)

“복잡한 것은 작고, 간단한 것으로 분리할 수 있다.” - 이것이야 말로 함수형 프로그래밍의 진정한 유산이며, 우리가 Swift 에서 활용해야만 하는 것들입니다.

우리는 이렇게 간단해진 것들로부터 일반적인(generic) 해결책을 찾을 수 있을 겁니다. 그 다음 우리의 프로그램에 대해 생각하고 판단하게 만드는 일관된 규칙을 적용하여 다시 이들을 결합시킬 수 있습니다. 이 방식은 컴파일러에서 버그가 발생하지 않도록 돕는 방법이며, 70년대의 John Backus 가 전적으로 동의하는 방법이라고 생각합니다. 분리한 다음, 다시 결합하세요.(Break it down, build it up.)

컨텐츠에 대하여

2016년 11월에 진행한 try! Swift NYC 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Rob Napier

Rob is co-author of iOS Programming Pushing the Limits. Before coming to Cocoa, he made his living sneaking into Chinese facilities in broad daylight. Later, he became a Mac developer for Dell. It’s not clear which was the stranger choice. He has a passion for the fiddly bits below the surface, like networking, performance, security, and text layout. He asks “but is it good Swift?” a lot.

4 design patterns for a RESTless mobile integration »

close