안녕하세요. 데이블에서 모바일 서비스를 개발하고 있는 김성철입니다. 이 글은 모나드와 함수형 프로그래밍을 이해하기 위해서 지난 1년여간을 생각하고 정리한 내용을 담은 것입니다. 총 5장으로 구성되어 있으며 6장에는 글의 흐름과 약간 관련 없는 내용을 추가로 담고 있습니다. 어떤 부분은 부족할 것이고 어떤 부분은 과장 되었을지도 모릅니다. 그러나 모나드에 관한 글을 꼭 한번 써보고 싶었습니다. 모나드를 생각하고 정리하면서 저 자신도 많이 성장한 것 같습니다. 주실 피드백이 있으시다면 sungcheol@dable.io 또는 skyfe79@gmail.com으로 보내주세요.
모나드를 얘기하기 전에 몇 가지를 정리하려고 합니다.
타입. 우리가 코드를 작성할 때 늘 사용하는 그 타입이 맞습니다. string, int, float, double 등. 원시타입(Primitive type) 이외에도 enum, struct, class 등을 사용하여 우리가 원하는 타입을 추상화를 통해 만들 수 있습니다. 이 글에서는 이렇게 만들어진 타입을 집합으로 생각합니다.
네. 타입은 고등학교 수학에서 제일 처음에 나오는 그 집합입니다.
$$ Boolean = { False, True } $$
$$ Int = { … -2, -1, 0, 1, 2, … } $$
$$ Double = { … 0.9, 0.99, 0.999 … 1.0 … } $$
$$ String = { “”, “a”, “aa”, “aaa”, … “b” … } $$
집합 원소 간에 연산할 때, 닫힘과 열림이라는 중요한 얘기가 나옵니다. 이에 대한 내용은 이 글 후반부인 닫힘과 열림
에서 다루겠습니다. 지금은 타입은 집합이다.
라고 생각합니다.
MS-DOS에서 터보 C를 사용하여 프로그래밍을 공부할 때, 함수는 명령어 집합 또는 작은 프로그램 단위라고 배웠습니다. 어떤 기능을 하는 코드들을 묶어 하나의 명령어처럼 사용할 수 있도록 함수를 정의했습니다. 함수를 이렇게 생각할 수 있지만, 이 내용은 수학에서 정의하는 함수와 거리가 멉니다. 하지만 함수형 프로그래밍에서 함수는 수학적 정의를 따르고 있습니다. 함수는 두 집합을 연결하여 관계를 만들어 주는 연산
으로 정의합니다.
$$ f: X \rightarrow Y $$
함수 $f$ 는 $X$와 $Y$를 연결하여 관계를 만들어 주는 함수입니다. 이러한 관계를 앞으로 $f: X \rightarrow Y $ 라고 표현하겠습니다. 이 관계에는 $ g: X \rightarrow X $ 도 있고 $ h: Y \rightarrow Y $ 도 있습니다. 우리가 매일 접하는 일상에서 예를 찾아보면, 웹사이트의 링크가 좋을 것 같습니다. 스마트폰에서 구글, 네이버 또는 다음과 같은 사이트에 접속하여 관심 있는 내용의 링크를 클릭하면 해당 내용을 다루는 사이트로 이동합니다. 링크를 사이트를 연결해주는 함수로 생각할 수 있습니다. 링크에는 같은 페이지 내에서 이동하는 Anchor 링크가 있고 다른 페이지로 이동하는 아웃 링크가 있습니다. 아웃 링크는 $f: X \rightarrow Y $ 라고 볼 수 있고 앵커 링크는 $ g: X \rightarrow X $ 또는 $ h: Y \rightarrow Y $ 라고 할 수 있습니다.
함수 $f$, $g$가 아래와 같을 때,
$$ f: X \rightarrow Y $$
$$ g: Y \rightarrow Z $$
두 함수를 합성한 합성 함수가 존재합니다.
$$ g \circ f: X \rightarrow Z $$
함수 합성은 정말 중요한 개념입니다. 함수를 합성할 수 있어야 확장
이라는 개념을 만들 수 있기 때문입니다. 개인적으로도 함수 합성을 이해했을 때 그 설렘을 잊을 수 없습니다. 마치 빅뱅처럼 느껴졌기 때문입니다. 그리고 함수를 합성할 수 있어야 큰 문제를 작은 단위로 쪼개어 해결한 후 그 결과들을 모아 큰 문제를 해결할 수 있습니다.
인터넷을 생각해 봅시다. 인터넷이 폭발적으로 성장할 수 있었던 이유는 링크
가 존재했기 때문입니다. 링크는 웹사이트 문서에서 다른 웹사이트 문서로 연결하는 방법입니다. 링크는 함수의 관계를 의미하며 링크를 계속 클릭하여 웹사이트를 계속 탐험하는 것은 함수 합성으로 이해할 수 있습니다.
$$ link: site \rightarrow site $$
$$ web: link \circ link \circ link \circ … link \circ link $$
우스갯소리로 수학자가 타임머신을 만드는 방법은 무엇일까요?
$$ T: Present \rightarrow Future $$
바로 현재에서 미래로 연결해 주는 함수를 찾으면 됩니다. 함수 $T$는 아래처럼 무수한 함수로 합성되어 있을 것입니다.
$$ T: z \circ x \circ y \circ … c \circ b \circ a $$
그러나 함수 $T$ 를 찾거나 개발하는 일은 불가능하거나 몹시 어려운 일이 될 것입니다. 왜 그럴까요? 그것은 Side Effect(이하 사이드이펙트) 때문입니다.
수학에서 다루는 함수는 모두 Pure Function(이하 순수함수)입니다. 함수 $T$를 구성하는 모든 함수가 순수함수이어야 함을 의미합니다. 어떻게 하여 현재에서 미래로 한번 이동했다고 해도 항상 성공할 수 없다면 그 방법은 순수함수가 될 수 없습니다.
함수 $ f: X \rightarrow Y $ 에 집합 $X$의 원소를 함수 $f$에 대입하면 집합 $Y$의 원소가 나옵니다. 그리고 함수 $f$가 항상 이러한 성질을 유지할 때, 함수 $f$를 순수함수라고 부릅니다.
순수함수는 동일한 인자가 주어졌을 때 항상 동일한 결과를 반환해야 하고 외부의 상태를 변경하지 않는 함수입니다. 외부 상태를 변경하지 않는다는 것은 외부에 있는 무엇을 변경하거나 의지하지 않는다는 것을 의미합니다. 순수함수는 외부상태에서 독립한 독립함수이어야 합니다. 함수 $f$가 외부 상태를 변경하지 않고 다른 함수 $g$가 외부상태를 변경했을 때, 함수 $f$에 같은 인자를 주었지만 다른 결과값을 반환하면 함수 $f$는 순수함수가 아닙니다. 외부환경 변화에 의해 변화를 받았기 때문입니다. 함수 $f$가 외부상태를 읽어 사용하여 자신이 만드는 결과에 영향을 준다면 외부 상태에 의존하고 있는 것입니다.
사이드이펙트는 어떤 함수가 존재할 때, 이 함수가 순수함수가 될 수 없게 만드는 모든 것을 의미합니다. 우리가 출근하거나 학교에 가는 길을 생각해 봅시다. 출근길이나 등굣길에 걸리는 시간을 교통수단의 합성으로 표현해 보겠습니다. 각 교통수단은 한 장소에서 다른 장소로 이동하는 함수입니다.
$$ 교통수단: 장소A \rightarrow 장소B $$
$$ 출근시간: 도보 \circ 지하철 \circ 버스 \circ 도보 $$
우리는 매일 같은 방법으로 출근하고 등교하지만 출근 시간이나 등교 시간을 항상 일정하게 맞출 수가 없습니다. 다만 예측할 수 있을 뿐입니다. 그 이유는 도보 속도가 매일 다를 것이고 버스가 오는 시간도 매일 다를 것이고 지하철이 오는 시간도 매일 다를 것이기 때문입니다. 즉, 위 함수들이 순수함수가 아니기 때문에 그 합성으로 이루어진 출근 시간도 순수함수가 아닌 것이 됩니다. 사이드이펙트 때문입니다.
프로그래밍에서 말하는 사이드이펙트는 무엇이 있을까요? 함수가 반환해야 하는 결과를 반환하지 못하게 하는 모든 것을 의미합니다. 파일 이름을 주면 해당 이름을 갖는 파일 포인터를 반환하는 함수를 생각해 봅시다.
$$ f: Name \rightarrow FILE $$
파일 이름에 해당하는 파일이 없을 때는 FILE을 반환할 수 없습니다. 이번에는 네트워크 API 요청을 생각해 봅시다.
$$ g: Request \rightarrow Response $$
우리는 요청 $Request$를 보냈을 때 항상 응답 $Response$를 받을 수 없음을 잘 알고 있습니다. 서버 코드에 버그가 있을 수도 있고 통신이 안되는 음영지역에서 요청을 보낼 수도 있고 때로는 서버 머신을 호스팅하는 곳에 정전이 발생할 수도 있습니다. 이러한 점도 클라이언트와 서버에게는 사이드이펙트가 됩니다.
간단한 예로 객체지향 언어에서 발생하는 사이드이펙트를 살펴봅시다. 객체지향 언어는 class 내부에 멤버 함수와 멤버 변수를 정의할 수 있습니다. 멤버 함수는 this 포인터를 통해 멤버 변수를 사용하는 경우가 많습니다. 이 경우 this 포인터를 사용하는 것 자체가 외부환경에 의존하는 것입니다.
class SomeClass {
var factor: Int = 1
fun calc(value: Int): Int {
return value * this.factor
}
}
calc(value:) 함수에 동일한 인자가 주어졌을 때 동일한 결과값을 반환하는 것은 factor 멤버 변수가 어떻게 관리되는냐에 달려 있습니다. 위의 코드는 좋지 않은 코드입니다. 위와 같은 상황에서는 아래처럼 변경하는 것이 좋습니다.
class SomeClass {
private const val factor: Int = 1
fun calc(value: Int): Int {
return value * this.factor
}
}
즉, factor를 외부에서 접근할 수 없게 하고 상수로 정의하여 값이 수정될 가능성을 제거했습니다. 상수는 클래스의 모든 인스턴스에서 동일하므로 Kotlin에서 static 성격을 갖는 companion object의 멤버로 만들어주어야 합니다.(사실 Kotlin에서는 이러한 이유로 각 클래스마다 동일한 const val 을 가질 수 없습니다. 위 코드는 컴파일되지 않습니다.)
class SomeClass {
companion object {
private const val FACTOR: Int = 1
}
fun calc(value: Int): Int {
return value * FACTOR
}
}
만약 calc(value:)함수에 다른 factor를 적용해야 하면 factor를 인자로 받는 것이 좋습니다.
class SomeClass {
fun calc(value: Int, factor: Int): Int {
return value * factor
}
}
이렇게 하면 calc 함수를 사용하는 쪽에서 값과 factor 값을 주어 결과값을 예상할 수 있고 테스트할 수 있습니다. 그렇다면 사이드이펙트가 나쁜 것일까요? 사실 사이드이펙트는 좋고 나쁜 것이 아닙니다. 시간이 흐르면 자연스럽게 증가하는 엔트로피처럼 시간이 흐를수록 사이드이펙트는 증가합니다.
자연에서는 사이드이펙트가 당연하지만 정확한 계산 결과를 만들어야 하는 컴퓨터 프로그램에서는 가능한 줄여야 합니다. 사이드이펙트는 시간이 지남에 따라 증가하기 때문에 소프트웨어를 구현한 후에도 지속해서 관리해 주어야 합니다.
지금까지 배운 내용을 정리하면 아래와 같습니다.
알림
이 글은 데이블 기술블로그에 올린 글을 제 블로그에 다시 올린 글임을 알려드립니다.