[번역] Point-Free #10 두 flatMap 이야기

본문 링크

Swift 4.1부터 flatMap의 기능 중 일부가 deprecate 됐습니다. 왜 그런 일이 일어났을까요? 오늘은 기본적인 flatMap에 대해 알아보고, 없어진 기능에 대해 이해한 후 관련해서 유용한 코드를 작성해보도록 하겠습니다!

시작하며

Swift 4.1에 제안된 내용은 flatMap에 치중돼있는 기능을 작게 나눠서 새로운 이름을 부여하자는 것이었습니다. 이 제안 자체는 받아들여졌지만 커뮤니티 피드백에 의해 이름은 다르게 바뀌었습니다.

먼저, 왜 이런 변화가 제안됐는지와 처음 제안된 이름이 바뀌어야 했는지 알아야 합니다. 앞으로 이름 짓는 것이 얼마나 중요한지와 예상하지 못한 방법으로 이전에 익혔던 직관을 활용할 수 있다는 것을 보여드리고자 합니다.

두 flatMap 이야기

Swift 초기에는 map, filter, reduce 그리고 flatMap과 같은 함수를 표준 라이브러리에서 추가하는 등 함수형 프로그래밍 패턴에 대한 지원을 해줬습니다.

Swift 1.0의 flatMap은 우리에게 친근한 방식인 Array에 정의되어 있었습니다.

// extension Array {
//   func flatMap<B>(_ f: @escaping (Element) -> [B]) -> [B] {
//   }
// }

이런 함수는 배열을 반환하기 때문에 연산자를 연결할 수 있다는 점에서 뛰어납니다.

예를 들어, 다음과 같이 쉼표와 줄 바꿈으로 이루어진 값의 목록이 있다고 해보겠습니다.

let csv = """
1,2,3,4
3,5,2
8,9,4
"""

이 문자열의 쉼표 사이에 있는 값을 모두 추출하고 싶다면 어떻게 해야 할까요?

먼저, 줄 바꿈을 기준으로 자릅니다.

csv
  .split(separator: "\n")
// ["1,2,3,4", "3,5,2", "8,9,4"]

이제 배열의 각 원소로 가서 쉼표를 기준으로 나눠보겠습니다.

배열을 수정할 때는 우리에게 익숙한 map을 사용합니다.

csv
  .split(separator: "\n")
  .map { $0.split(separator: ",") }
// [["1", "2", "3", "4"], ["3", "5", "2"], ["8", "9", "4"]]

위 코드는 중첩된 배열을 반환하지만, 우리가 원하는 결과물은 평평한(flat) 배열입니다.

이것이 정확히 flatMap이 제공하는 기능입니다. flatMap은 주어진 함수를 각 요소에 적용한 결과 배열을 평평하게(flatten) 만들어줍니다.

csv
  .split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
// ["1", "2", "3", "4", "3", "5", "2", "8", "9", "4"]

배열에서 flatMap이 소개된지 얼마 지나지 않아 Optional에도 flatMap이 생기게 되었습니다. 명세는 다음과 같습니다.

// extension Optional {
//   func flatMap<B>(_ f: @escaping (Element) -> B?) -> B? {
//   }
// }

OptionalflatMap이 추가돼서 좋은 점은 옵셔널을 그냥 반환하는 것이 아니라 연산자를 연결할 수 있게 되었다는 점입니다.

예를 들어, String은 다음과 같이 데이터를 받아서 옵셔널 문자열을 반환하는 실패가 가능한 이니셜라이저를 가지고 있습니다.

String(data: Data(), encoding: .utf8)
// Optional("")

배열의 경우, 옵셔널로 감싸인 값을 변환하기 위해 map을 사용할 수 있습니다. 옵셔널 문자열을 정수로 변환하고 싶은 경우, 코드는 다음과 같을 것입니다.

String(data: Data(), encoding: .utf8)
  .map(Int.init)
// Optional(nil)

빈 문자열로 Int 이니셜라이저를 호출했기 때문에 nil을 반환받을 것입니다. 그런데 약간 이상한 점이 있습니다. 반환된 결과의 타입이 Optional<Int>일까요?

_: Int? =  String(data: Data(), encoding: .utf8)
  .map(Int.init)
// 옵셔널 타입인 `Int??`의 값이 unwrap되지 않았습니다.

아닙니다! map을 사용한 결과는 이중 옵셔널인 Int??입니다. 우리가 한 일은 옵셔널이 결과로 나올 수 있는 곳에 옵셔널을 반환하는 이니셜라이저를 입력으로 전달한 것입니다. 이 행동은 결국 중첩 옵셔널로 이어졌습니다. 여기서 flatMap을 사용하면 모든 것이 해결됩니다.

_: Int? =  String(data: Data(), encoding: .utf8)
  .flatMap(Int.init)
// nil

드디어 반환값이 잘 처리됐네요. 계속 nil을 반환하고 있으니 다른 데이터를 넣어봅시다.

String(data: Data([55]), encoding: .utf8)
  .flatMap(Int.init)
// Optional(7)

flatMap과 함께라면 옵셔널 문자열을 받아서 껍데기를 벗긴 후 함수를 적용하고 거기서 반환된 옵셔널 결과를 flatten 한 후 단일 옵셔널 정수로 반환할 수 있습니다.

Array에는 기존 flatMap의 의도를 모호하게 만드는 세 번째 flatMap이 있습니다. 이 flatMap은 배열 요소를 받아서 변환 작업을 통해 옵셔널을 반환합니다. 문자열로 이루어진 배열을 예시로 들어보겠습니다.

["1", "2", "buckle", "my", "shoe"]

각 문자열을 정수로 변환하고 싶다면 어떻게 해야 할까요? map을 사용하면 다음과 같은 결과를 얻게 될 것입니다.

["1", "2", "buckle", "my", "shoe"]
  .map(Int.init)
// [{some 1}, {some 2}, nil, nil, nil]

변환이 성공한 경우의 옵셔널로 감싸인 값과 실패했을 경우의 nil로 이루어진 배열을 결과로 받을 것입니다.

map 대신 flatMap을 사용하면 nil을 무시하기 때문에 안전하게 정수의 옵셔널 껍데기를 벗길 수 있게 됩니다.

["1", "2", "buckle", "my", "shoe"]
  .flatMap(Int.init)
// [1, 2]

정말 유용하지 않나요? 하지만 이러한 종류의 flatMap은 옵셔널의 품질에 따라 배열의 품질을 결정하기 때문에 기존과는 다른 느낌을 선사합니다.

flatMap을 동시에 쓰면 느낌이 더 강해집니다. CSV 예제를 받아서 각 값을 정수로 만든 후 모두 더해보곘습니다.

csv.split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
  .flatMap { Int($0) }
  .reduce(0, +)
// 41

첫 번째 flatMap은 쉼표로 각 값을 분리하고 flatten 해서 하나의 배열로 반환합니다. 두 번째 flatMap은 문자열에서 Int를 생성하려고 시도하고 실패하면 해당 경우를 무시합니다. 이 두 연산은 아주 다르다고 할 수 있는데 이름은 같습니다. 게다가 코드를 한 눈에 봤을 때 바로 알아보기는 힘들 것입니다.

우리는 타입에 대해 생각하는 것에 시간을 많이 쏟습니다. 특히 함수의 모양에 관해서요. 때로는 이 과정이 메소드의 정의에서 길을 잃게 만듭니다. 컨테이너 타입이 정의에서 없어지기도 하고, 함수와 변수 이름이 흐릿해지기도 합니다. 그럴 땐 좀 더 쉽게 이해할 수 있게 함수 서명을 타입으로만 고정시켜봅시다. 프리 펑션 문법인 (Configuration) -> (Data) -> ReturnValue를 사용해보겠습니다.

// flatMap : ((A) -> [B]) -> ([A]) -> [B]
// flatMap : ((A) ->  B?) -> ( A?) ->  B?

// flatMap : ((A) ->  B?) -> ([A]) -> [B]

튀는 형태가 하나 있지 않나요? 위쪽의 두 flatMapArrayOptional 중 하나의 컨테이너 타입을 가지고 연산합니다. 하지만 세 번째 flatMap 연산은 두 컨테이너를 모두 사용합니다.

편의를 위해 생략했던 내용을 다시 채워보면 다음과 같을 것입니다.

// flatMap : ((A) ->    Array<B>) -> (   Array<A>) ->    Array<B>
// flatMap : ((A) -> Optional<B>) -> (Optional<A>) -> Optional<B>

// flatMap : ((A) -> Optional<B>) -> (   Array<A>) ->    Array<B>

각 컨테이너 타입을 제네릭 타입으로 표현하면 어떨까요? 각 타입의 이름을 다음과 같이 바꿔보겠습니다.

// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>
// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>

// flatMap : ((A) -> N<B>) -> (M<A>) -> M<B>

앞의 두 서명은 정확히 동일한 모양을 가지네요! 하지만 세 번째 서명은 두 개의 서로 다른 제네릭 컨테이너 타입을 가지고 의미적으로 더 혼란스럽기도 합니다. 예를 들면 M을 생성하기 위해 N이 정확히 하는 일은 무엇인가? 와 같이요. 입력받는 함수에 대해 변환을 거쳐서 뒤쪽의 결과가 나오는 것처럼 생각할 수도 있지만 그런 경우에도 N에 대해 잘 설명되진 않습니다.

이해하기 쉽게 만드려면 다음과 같이 구체 타입을 사용하는 방법이 있습니다.

// flatMap : ((A) -> B?) -> (M<A>) -> M<B>

이는 여전히 혼란스럽습니다. 모든 MOptional과 같이 사용할 수 있을까요? 이 세 번째 flatMap은 다른 것에 비해 상대적으로 덜 제네릭합니다.

Optional Promotion

Optional을 반환하는 연산을 위해 ArrayflatMap을 덮어씌우는 것에는 또 다른 이슈가 있는데요, 바로 Optional Promotion 입니다. 옵셔널 파라미터를 필수로 하는 경우, Swift는 값을 자동으로 Optional을 감쌉니다. 이는 코드를 더욱 간결하게 만들어주고 엔지니어에게 주어진 보일러플레이트 코드에 대한 부담을 줄여줍니다. 보통 이 결과는 .some이 명시적으로 감싸져서 나옵니다. 하지만 타입 추론은 양날의 검이라서 옵셔널을 반환하는 클로저의 경우엔 어떻게 해도 같은 결과가 나오는 공평한 상황이 생깁니다.

다음과 같은 코드가 있다고 해보겠습니다.

[1, 2, 3]
  .flatMap { $0 + 1 }
// [2, 3, 4]

이 코드는 컴파일, 실행 그리고 결과 생성까지 잘 됩니다. 하지만 의미적으로는 이상합니다. 우리는 클로저에서 옵셔널을 반환하지 않습니다. 그 이유는 다음과 같이 컴파일러가 자동으로 값을 감싸주기 때문입니다.

[1, 2, 3]
  .flatMap { .some($0 + 1) }
// [2, 3, 4]

값을 수동으로 감싸는 로직은 약간 불편해 보입니다. 옵셔널은 항상 .some을 반환해야 하고 실패할 경우 nil을 반환해야 합니다. 하지만 이 연산은 절대 실패할 수 없기 때문에 그냥 map을 사용하겠습니다.

[1, 2, 3]
  .map { $0 + 1 }
// [2, 3, 4]

옵셔널 프로모션이 존재하기 때문에 map과 작업하는 어떠한 연산자든 flatMap과도 작동할 수 있습니다. 보통 map을 사용하는 것을 피하려고 하고 flatMap을 사용하려고 하는 것이 바로 flatMap은 대부분 결과를 반환하는데 성공하지만 map은 그렇지 않을 수 있기 때문입니다. 그런데 이렇게 flatMap을 모든 곳에 쓰게 된다면 flatMap의 의미적인 내용이 많이 모호해집니다. 그래서 flatMapmap을 사용할 때 실패할 수 있는지 여부를 명확하게 문서화해야 합니다.

더 최악인 경우는 컴파일 타임 오류가 날거라고 예상하는 타입에 변화를 만들었지만 중첩된 flatMap과 옵셔널 프로모션으로 인해 컴파일은 성공하고 런타임에 원하지 않는 행동을 하는 경우입니다. 예를 들어, 옵셔널 값인 name을 가지는 User 구조체가 주어졌고 이를 배열로 관리한다고 해보겠습니다.

struct User {
  let name: String?
}

let users = [User(name: "Blob"), User(name: "Math")]

유저의 배열이 주어졌을 경우, 그들의 이름을 뽑아내기 위해 map을 사용할 것입니다.

users
  .map { $0.name }
// [{some "Blob"}, {some "Math"}]

하지만 이 배열은 옵셔널 값을 가지는 배열입니다. 우리가 실제로 해야 하는 것은 flatMap을 사용하는 것이죠.

users
  .flatMap { $0.name }
// ["Blob", "Math"]

이러한 코드는 실제 상황에도 많이 만나는 경우입니다. 그런데 만약 User 타입이 name 값을 필수로 바꾼다면 어떨까요? 우리의 강력한 타입 시스템 덕분에 우리는 옵셔널 껍데기를 벗길 수 있게 될테니 다음과 같을 것입니다.

struct User {
  let name: String
}

컴파일은 당연히 될 것입니다. 그러면 결과는 어떨까요?

users
  .flatMap { $0.name }
// ["B", "l", "o", "b", "M", "a", "t", "h"]

예상치 못한 결과네요! 이러한 이상한 친구의 이름이 새로 바뀐다니 너무 좋습니다.

compactMap과 filterMap

앞에서 보셨듯이 몇몇 flatMap은 다른 flatMap과는 다른 행동을 하는 것을 보셨을 것입니다. 이게 바로 이름을 바꾸게 된 계기입니다. 배열과 옵셔널에 변환을 적용해서 같은 타입을 반환하게 하는 flatMap이 사라진 것이 아닙니다! 이름이 바뀐건 오직 튀는 친구들 뿐입니다.

처음에 제안된 이름은 배열을 돌면서 nil 값을 무시하고 map한다는 의미의 filterMap입니다. 정의는 다음과 같았습니다.

extension Array {
  func filterMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

약간의 바이크쉐딩(덜 중요한 논의)을 통해 이름은 compactMap으로 결정됐습니다. 이미 Ruby에서도 compact라는 메소드가 배열의 nil 값을 걸러주는 의도로 사용돼고 있었기 때문에 선조의 지혜를 따르기로 했습니다. 그럼 compactMap을 정의해볼까요?

extension Array {
  func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

함수 이름 말곤 아무것도 바뀌지 않았으니 비교하지 않으셔도 됩니다.

filterMap의 일반성

compactMap이라는 이름이 가지는 불리한 점 중 하나는 이름이 배열에서 하는 행동과 강하게 엮여 있다는 것입니다. compactMap은 배열의 nil을 제거해서 더 작은 배열로 “압축(compacting)”합니다. 이는 compactMap이 가진 가능성을 오히려 막는 역할을 합니다.

하지만 filterMap이라는 이름의 좋은 점 중 하나는 꽤 괜찮은 일반성을 가진다는 것입니다.

(A) -> Bool를 입력으로 받아서 (A) -> A?라는 함수로 유도해보겠습니다.

func filterSome<A>(_ p: @escaping (A) -> Bool) -> (A) -> A? {
  return { p($0) ? .some($0) : .none }
}

(A) -> Bool의 값이 true라면 .some을 씌운 값을 반환할 것이고 그게 아니면 .none일 것입니다.

filterSome이 있으니 배열에 대한 filter를 다시 작성할 수 있겠네요.

func filter<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> [A] {
  return { $0.filterMap(filterSome(p)) }
}

정수에 통하는 필터도 만들 수 있습니다.

filter { $0 % 2 == 0 }
// ([Int]) -> [Int]

그리고 이 필터를 배열에 연결하면 다음과 같은 결과가 나올 것입니다.

Array(0..<10)
  |> filter { $0 % 2 == 0 }
// [2, 4, 6, 8, 10]

이 필터는 좋아보이지만, 실제로 쓸만한 곳은 딱히 찾을 수가 없네요. Swift 표준 라이브러리가 만들어둔 기존의 필터를 사용할 수 있습니다. 그런데도 이런 filter를 이런 방식으로 바라보는 것은 filter가 더 나은 일반화를 이끌어낼 수 있다고 생각하기 때문입니다.

이런 사고 방식은 이전에도 있었습니다. 바로 Optional<A>를 일반화해서 Either<A, B>로 표현한 것이죠.

enum Either<A, B> {
  case left(A)
  case right(B)
}

A의 부재를 nil로 모델링하는 것 대신, 그 자리에 B 타입의 다른 값을 넣을 수 있습니다.

Either에 대한 filterSome 같은 함수를 만든다면 다음과 같을 것입니다.

func partitionEither<A>(_ p: @escaping (A) -> Bool) -> (A) -> Either<A, A> {
  return { p($0) ? .right($0) : .left($0) }
}

이 함수는 주어진 (A) -> Bool에 대해서, 타입 (A)의 값을 Either<A, A>의 두 경우 중 하나로 나눈(partition) 결과를 반환합니다.

이제 filterMap을 partition과 Either에 대한 지식을 바탕으로 일반화해보겠습니다.

extension Array {
  func partitionMap<A, B>(_ transform: (Element) -> Either<A, B>) -> (lefts: [A], rights: [B]) {
    var result = (lefts: [A](), rights: [B]())
    for x in self {
      switch transform(x) {
      case let .left(a):
        result.lefts.append(a)
      case let .right(b):
        result.rights.append(b)
      }
    }
    return result
  }
}

filterMap에서 filter를 유도한 것처럼 partitionMap에서 partition을 유도해볼까요?

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return { $0.partitionMap(partitionEither(p)) } // error
}

위 코드는 튜플 이름에 대해 타입 시스템에 문제가 있지만, 약간의 작업이 들어가면 충분히 해결 가능합니다.

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return {
    let (lefts, rights) = $0.partitionMap(partitionEither(p))
    return (lefts, rights)
  }
}

partition은 표준 라이브러리에 존재하지 않지만 filterfilterMap의 관계와 OptionalEither로 일반화하는 과정을 알아보다보니 저절로 밝혀졌습니다.

여기서 두 개의 평행된 이야기가 나올 수 있겠네요.

  • (A) -> Bool라는 명세를 optional을 반환하는 함수인 (A) -> A?로 만드는 것은 filterMap 함수로 이어지고, 여기서 유도할 수 있는 filter는 우리에게 이미 친숙한 존재입니다.

  • (A) -> Bool라는 명세를 either를 반환하는 함수인 (A) -> Either<A, A>로 만드는 것은 partitionMap 함수로 이어지고, 여기서 partition 함수를 유도할 수 있습니다.

여기에 functional setters와 같은 이전 에피소드에서 만든 다른 장치들을 조합할 수도 있습니다. 이 에피소드에서는 아주 작지만 매우 복잡한 방식으로 합쳐져있는 제네릭 세터(generic setters)를 정의했습니다. 게다가 프리 펑션과 잘 맞는 것을 확인했으니 partitionMap의 프리 펑션 버전을 정의해보겠습니다.

func partitionMap<A, B, C>(_ p: @escaping (A) -> Either<B, C>) -> ([A]) -> (lefts: [B], rights: [C]) {
  return { $0.partitionMap(p) }
}

같이 쓸만한 함수도 하나 정의하겠습니다.

let evenOdds = { $0 % 2 == 0 ? Either.left($0) : .right($0) }
// (Int) -> Either<Int, Int>

이제 이 함수를 partitionMap과 함께 사용해봅시다.

partitionMap(evenOdds)
// ([Int]) -> (lefts: [Int], rights: [Int])

새로운 함수가 만들어졌네요. 이 함수는 정수로 이루어진 배열을 받아서 짝수는 왼쪽, 홀수는 오른쪽으로 나뉜 튜플을 반환합니다.

Array(1...10)
  |> partitionMap(evenOdds)
// ([2, 4, 6, 8, 10], [1, 3, 5, 7, 9])

예상한대로 작동하는군요. 게다가 이전 에피소드에서 살펴본 tuple composable setters도 사용할 수 있습니다. 짝수만 골라내서 제곱해봅시다.

Array(1...10)
  |> partitionMap(evenOdds)
  |> (first <<< map)(square)
// ([4, 16, 36, 64, 100], [1, 3, 5, 7, 9])

이제 단 몇 줄만으로 우리는 배열을 짝수와 홀수 둘로 나누고 한 쪽을 제곱할 수 있게 되었습니다.

그래서 요점이 무엇인가요?

이번 에피소드에선 이름 짓기란 무엇인지와 flatMap의 이름 변경에서 볼 수 있듯이 얼마나 이름 짓기가 빠르게 의미 없는 논쟁이 될 수 있는지에 대해서도 얘기해봤습니다. 그렇다면 이름을 바꾸는 것은 의미가 있었다고 할 수 있을까요? 코드를 deprecate하는 것은 많은 코드 베이스에 흙탕물을 뿌리는 것입니다. 대부분의 사람들은 자신의 코드 베이스가 기존의 flatMap과 잘 작동하도록 만들어뒀을테니 Swift 4.1로 올린 후 이런 의문이 들 것입니다. “왜 이름을 바꾼거지?” 이름을 바꾸는 것이 그만큼의 작업을 감수해야할 정도로 의미있는 일일까요?

무언가를 볼 때 한 발짝 뒤에서 조금 더 추상적인 방식으로 보고 흥미로운 방식으로 엮을 수 있다는 것은 정말 놀랍습니다. 우리는 flatMap의 모양만 보고 어떤 것이 튀는지와 다른 두 flatMap이 직관적으로 같은지에 대해 알 수 있었습니다.

그리고 튀는 flatMapfilterMap으로 이름을 바꾸면서 이전에 보지 못했던 방식으로 일반화해볼 수 있는 기회도 있었습니다.

이름 짓는 것은 정말 논쟁 거리가 되기 쉽습니다! 이름을 통해 관련된 개념 사이의 강력한 유대를 가질 수 있습니다. 이 유대는 우리로 하여금 흥미로운 발견을 할 수 있게 해줍니다. filterfilterMap의 관계에서 partitionpartitionMap의 관계를 이끌어 낸 것 처럼요!

이번에 flatMap의 튀는 기능의 이름이 compactMap으로 바뀌었으니, 나중엔 더 많은 타입에 대해 알아볼 기회도 생길 것 같습니다.


본문에 연습문제와 참고 자료가 있습니다. 여기에서 확인해주세요!