모나드란 무엇인가?

이정훈·2023년 3월 11일
1

Swift 파헤치기

목록 보기
10/10
post-thumbnail

함수형 프로그래밍을 공부하다보면 모나드(Monad)라는 단어를 자주 접할 수 있었다.

사실 이 모나드라는 개념은 수학의 범주론에서 사용한 개념을 프로그래밍 상에 도입한 것이다.

수학이라는 말을 들으니 벌써부터 머리가 아프다;;

하지만 이번 포스트에서는 프로그래밍 입장에서 과연 모나드라는 것이 무엇이냐에 대하여 다루어 보려고 한다.

프로그래밍에서 모나드는 예를 들면 싱글톤 패턴과 같이 프로그래밍 디자인 패턴 혹은 모나드의 특징을 만족하는 어떤 것이라 할 수 있다.

그렇다면 프로그래밍에서 모나드는 무엇을 말하는 것일까?

Functional Programming

모나드를 알아보기 전에 함수형 프로그래밍 패러다임에 대해 간단히 짚고 넘어가려고 한다.

먼저 함수형 프로그래밍 패러다임은 순수 함수 형태를 이용 하는데, 순수 함수란 매개변수로 들어온 전달인자와 함수의 지역 변수 만을 사용하여 외부의 다른 값에 영향을 미치지 않고 함수를 수행한 결과를 반환하는 방식이다.

함수형 프로그래밍은 순수 함수를 베이스로 다음과 같은 특징을 가진다.

함수는 하나의 일급 객체로서..

변수상수에 함수를 할당 가능하고 다른 함수의 전달인자로서 매개변수에 전달 가능하다.

Context

다시 모나드로 돌아와서..

모나드를 이해하기 위한 첫 출발점은 바로 Context이다. Context는 무언가를 담을 수 있는 것이라고 할 수 있고, Context 안에 담겨 있는 것을 Contents라고 할 수 있다.

아래 그림을 보면 이해가 될 것이다.

위의 그림에서 상자가 있고 상자 안에 축구공이 담겨있다. 여기서 상자는 무언가를 담고 있는 Context라고 할 수 있고 Context인 상자에 담겨 있는 축구공은 Contents라고 할 수 있다.

그럼 프로그래밍을 할때 ContextContents의 개념을 어디서 확인할 수 있을까?

간단한 예시로 Optional을 예로 들수 있다.

@frozen enum Optional<Wrapped>

Swift에서 Optional의 정의는 열거형 제네릭 타입으로 값이 존재하면 case .some(value) case로 값이 nil이라면 case .none case가 된다.

let number: Int? = 42
let nothing: Int? = nil

그렇다면 위의 Optional<Int> 타입의 number 변수와 nothing 변수는 다음과 같이 표현할 수 있다.

number 변수는 Optional이라는 Context 안에 42라는 값이 Contents로 담겨 있고 nothing 변수는 Optional이라는 Context 안에 아무 것도 없는 형태로 볼 수 있다.

정리하자면 Optional이라는 형태처럼 어떤 값을 담는 형태가 일종의 모나드라는 것이다.

Container

Container는 무언가를 담을 수 있는 것이라고 할 수 있고 프로그래밍에서는 Array, Dictionary, Set 등의 타입들은 특정 타입의 값을 담을 수 있으므로 Container라고 한다.

Container는 무언가를 담는 다는 측면에서 Context의 역할을 할 수 있기 때문에 Context로서 사용할 수 있다.

Functor

프로그래밍에서 Functormap 함수를 적용할 수 있는 컨테이너 타입이라고 할 수 있다.

map 함수를 적용할 수 있으며 어떤 값을 담을 수 있는 Optional, Array, Dictionary, Set과 같은 타입들이 모두 Functor에 해당한다. 따라서 앞서 언급한 타입 내부에는 모두 map 함수가 정의 되어 있다.

map

아래는 SwiftOptional 타입에 정의 되어 있는 map의 형태이다.

func map<U>(_ transform: (Wrapped) throws -> U) rethrows -> U?

map 함수는 U 타입을 반환하는 콜백 함수를 전달 받아 각 요소에 적용하고 결과적으로 Optional<U> 타입으로 맵핑한 반환 값을 반환한다.

let number: Int? = 42
let addedThree: Int? = number.map { (number: Int) -> Int in
    return number + 3
}

print(addedThree)    //Optional(45)

위의 예시는 Optional Context에 담겨있는 숫자 42map method를 통해 3을 더해주는 함수를 적용한 반환값을 다시 Optional Context에 담아 반환하는 코드이다.

이것을 그림으로 이해하면 아래와 같다.

사실 동일한 Context에 담아서 반환하기만 하면 되기 때문에 내부에 담겨있는 값의 타입은 크게 중요하지 않다.

따라서 내부의 Int 타입의 값을 다른 타입으로 바꾸는 것도 가능하다.

let number: Int? = 42
let StringNumber: String? = number.map { (number: Int) -> String in
    return String(number)
}

print(StringNumber)    //Optional("42")

반대로 Context 내부에 값이 존재하지 않을 수도 있다.

그럼 map은 콜백 함수를 실행하지 않고 빈 값을 Context로 포장하여 반환 한다. 다시 말하면 따로 nil check를 구현하지 않아도 된다는 말과도 같다.

let nothing: Int? = nil
let addedThree = nothing.map { (number: Int) -> Int in
    return number + 3
}

print(addedThree)    //nil

만약 위의 코드를 기존의 옵셔널 바인딩을 사용하여 구현하면 다음과 같이 구현할 수 있다.

let nothing: Int? = nil
var addedThree: Int? = nil

if let nothing = nothing {
	addedThree = nothing + 3
}

print(addedThree)    //nil

비록 간단한 코드라 코드가 얼마 되진 않지만 코드의 양의 차이를 떠나서 map 함수를 사용한다면 nothing 변수에 값이 있든 없든 상관하지 않고도 동일한 로직으로 데이터 처리가 가능해진다.

map의 문제점

map 함수를 단순히 내부 값에 접근하거나, 내부 요소를 순회하는 방법이라고 이해한다면 아래와 같은 문제에 직면하게 된다. 우리는 아래와 같이 StringInt 타입으로 캐스팅하는 상황에 대해 생각해볼 필요가 있다.

let stringNumber: String? = "45"
let result = stringNumber.map{ Int($0) }

Swift에서 Int의 initializer는 다음과 같이 정의 되어 있다.

init?(_ description: String)

정의를 확인해 보면 초기화 실패 가능성에 대비해 반환 값이 Optional 형태인것을 확인할 수 있다. 그렇다면 위의 result의 타입은 무엇이 될까?

다시 한번 map 함수의 정의를 살펴보면..

func map<U>(_ transform: (Wrapped) throws -> U) rethrows -> U?

❗️ map 함수는 U타입을 가지는 함수를 전달받아 U?를 반환하는 함수이다.

이미 Int 함수(생성자)의 결과가 Optional이기 때문에 U 타입 자체가 이미 Optional 타입이며, map 함수의 반환 타입은 U? 타입이기 때문에 다음과 같은 결과가 나타난다.

print(type(of: result))    //Optional<Optional<Int>>

그저 String 타입을 Int타입으로 바꾸면서 Optional<Int> 타입의 결과를 의도했을 수 있지만, 전혀 다른 결과를 도출해 낸다. 이러한 중첩적인 Context의 형태는 함수의 연속적인 합성을 저해하는 요인이 된다.

모나드

map과 비슷하지만 위에서 언급한 map의 단점을 보완하기 위한 method인 flatMap method가 존재한다.

flatMap

Swift에서 Optional 타입에 정의 되어 있는 flatMap 함수의 형태는 다음과 같다.

func flatMap<U>(_ transform: (Wrapped) throws -> U?) rethrows -> U?

map 함수와의 차이라면, flatMapU? 타입을 가지는 함수를 전달받아 최종적으로 U? 타입을 반환하는 함수이다. 따라서 flatMap을 사용한다면 위에서 언급한 map 함수의 문제를 해결할 수 있다.

let stringNumber: String? = "45"
let result = stringNumber.flatMap{ Int($0) }

print(result)    //Optional(45)
print(type(of: result))    //Optional<Int>

위의 예시에서 flatMapInt?을 반환하는 함수를 전달받아 결과로 Int? 타입의 값을 반환하고 있다.

compactMap

추가적으로 SwiftSequence 타입에서 Optional을 반환하는 함수를 flatMap에 전달할때는 Swift 4.1버전부터 flatMap 대신 compactMap이라는 함수를 사용한다.

Array 타입에 정의 되어 있는 compactMap의 정의는 다음과 같다.

@inlinable public func compactMap<ElementOfResult>(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult]

compactMapElementOfResult? 타입의 값을 하나씩 가져와 [ElementOfResult]을 반환하는 함수이다.

결국 Array 내부의 Optional 타입을 모두 같은 위상으로 풀어주기 때문에 아래의 예시와 같이 Array 내부에 nil 값이 존재하면 compactMap의 적용 결과 nil은 제외된다.

let optionalNumbers = [1, 2, Optional.none, 4, Optional.none, 6]
let numbers = optionalNumbers.compactMap { $0 }

print(numbers)    //[1, 2, 4, 6]

정리하자면 모나드는 Functor의 한 종류이며 flatMap method을 사용할 수 있는 것을 모나드라고 설명할 수 있다는 것이다.

왜 모나드를 사용하는 것일까?

Swift에서 값이 nil인지 확인 하는 대표적인 방법 중 하나로 Optional Binding 방법이 있다.

아래의 코드는 Optional 타입의 상수에 값이 존재하는지 확인하고 값이 존재 한다면 다시 그 값을 Int 타입으로 타입 캐스팅 가능한지 확인하는 코드이다.

let StringNumber: String? = "45"
var result: Int?

if let string: String = StringNumber {
    if let number: Int = Int(string) {
        result = number + 3
    } else {
        result = nil
    }
} else {
    result = nil
}

print(result)    //Optional(48)

모나드는 연속적인 체이닝 형태의 함수의 합성을 가능하게 하는 방향을 제시한다.

flatMap을 사용할 수 있는 것을 모나드라고 할 수 있기 때문에 위의 코드를 flatMapmap의 연속적인 체이닝 형태로 다시 구현한 형태는 아래와 같다.

let stringNumber: String? = "45"
var result: Int?

result = stringNumber.flatMap { (number: String) -> Int? in
    return Int(number)
}.map { (number: Int) -> Int in
    return number + 3
}


print(result)    //Optional(48)

flatMap의 경우도 마찬가지로 nil을 만날 경우 콜백 함수를 실행하지 않고 아무것도 반환하지 않기 때문에 굳이 nil check를 구현 하지 않아도 연속적인 함수의 합성을 가능하게 한다.

또한 flatMap을 사용할 경우 더 적은 라인 수로 마무리가 되며, Swift에서 mapflatMap은 타입 추론 등으로 생략이 가능하기 때문에 극단적으로 다음과 같이 한줄로도 마무리 할 수 있다.

let stringNumber: String? = "45"
var result: Int?

result = stringNumber.flatMap { Int($0) }.map { $0 + 3 }

print(result)    //Optional(48)

Optional Binding 방법을 사용한 경우와 flatMap을 사용한 방법을 비교해 보았을때 위의 예시는 간단한 경우 이지만 더 복잡하게 이중 혹은 삼중 Container 형태의 경우 코드 생산성 측면에서 더 극단적인 차이가 발생할 것이라고 생각한다.

정리

모나드가 무엇인지 정리를 해서 작성해 보았지만 결국

모나드가 무엇이냐?

라는 질문을 받았을때 이것이 무엇이다라고 단 한 문장으로 정리하기 참 어려운 개념인거 같다.

다시 정리하자면 필자가 이해한 모나드의 정의는

  • 모나드는 Container의 일종이다.
  • 모나드는 Functor의 한 종류이다.
  • 모나드는 flatMap method를 사용할 수 있다.

정도로 정리할 수 있을거 같다.


Reference

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글