『스칼라로 배우는 함수형 프로그래밍』 책을 읽어봅시다: 1편 - 순수 함수와 참조 투명성

이현수

안녕하세요? 저는 스튜디오킹덤에서 쿠키런: 킹덤 게임 서버 개발을 담당하고 있는 이현수입니다.

쿠키런: 킹덤 서버는 스칼라와 함수형 프로그래밍을 활용하고 있으며, 이는 국내에서 정말 보기 드문 개발 환경입니다. 스칼라를 좋아하는 저는 이러한 독특한 환경에 흥미를 느껴 팀에 합류하게 되었고, 지금은 스칼라와 함수형 프로그래밍이라는 좋은 연장을 활용해 만족스럽게 기능을 개발하고 있습니다.

사실 지난 10년 동안 개인적으로 스칼라를 공부해왔지만 그동안 스칼라를 사용할 업무 기회가 거의 없었는데, 이렇게 킹덤 서버팀에 합류하게 된 것은 저의 개발자 인생 중에서 특별하고 중요한 전환점이 되었습니다. 취미로 하던 스칼라 학습자에서 이제 프로페셔널 스칼라 개발자가 되었으니, 앞으로는 실무 경험을 바탕으로 스칼라와 함수형 프로그래밍의 저변을 넓히는 데 공헌하겠다는 마음을 먹었습니다. 그래서 그 일환으로 지금부터 기술 블로그에 스칼라와 함수형 프로그래밍에 관한 글을 연재하고자 합니다.

『스칼라로 배우는 함수형 프로그래밍』 책 소개

스칼라와 함수형 프로그래밍은 워낙 방대한 주제라 어디서부터 글을 시작해야 할지 고민이 되었습니다. 그러다 문득, 이미 출간된 좋은 책을 하나 참고하여 실무 경험을 더해 소개하면 좋겠다는 생각이 들었습니다.

출처: 『스칼라로 배우는 함수형 프로그래밍』 (제이펍, 2015)
출처: 『스칼라로 배우는 함수형 프로그래밍』 (제이펍, 2015)

현재 절판 상태이지만, 원서는 여기에서 구매할 수 있습니다.

이 책은 강렬한 표지 색깔 덕분에 빨간책(The Red Book)이라고도 불리며, 스칼라와 함수형 프로그래밍이라는 주제를 깊이 있게 다루는 유명한 책입니다. 저도 처음에는 충분한 배경 지식 없이 이 책을 읽으려고 시도했는데, 당시에 책의 내용을 이해하는 데에는 제법 어려움이 있었습니다. 다행히 그동안 스칼라와 함수형 프로그래밍을 다양한 경로로 익히고, 지금에서는 실무에서 매일 사용하다 보니 비로소 이 책의 내용을 제대로 이해하며 읽을 수 있게 되었습니다. 초반에 이 책을 읽으면서 겪었던 어려움이 기억나기에, 이 블로그 시리즈를 통해 처음 읽는 분들께 조금이나마 도움이 되었으면 합니다.

그래서 이번 블로그 시리즈의 제목을 『스칼라로 배우는 함수형 프로그래밍』 책을 읽어봅시다 로 정했습니다. 저와 함께 책을 읽으며 스칼라와 함수형 프로그래밍을 더 깊이 이해해보는 시간이 되길 바랍니다.

당부의 말씀

책에 이미 나온 내용을 굳이 옮겨쓰고 하나하나 설명하지는 않을 생각입니다. 글의 방향은 중요한 핵심 포인트만 간단히 요약하고, 제가 경험한 실무적인 팁을 조금 가미한 형태가 될 것 같습니다. 제목과 같이 책을 함께 읽어보시는 것을 추천드립니다.

함수형 프로그래밍이란 무엇인가?

주요 키워드

  • 계산 vs. 액션(computation vs. action)
  • 순수 함수(pure function)
  • 부수 효과(side effect)
  • 참조 투명성(referential transparency), 치환 모형(substitution model)

이 책의 1장을 읽어보셨다면, 함수형 프로그래밍의 기본 개념을 어느 정도 이해하실 수 있을 겁니다.

간단한 예제를 통해 함수형 프로그래밍이 어떤 느낌인지 간단히 살펴보겠습니다.

먼저 순수 함수라는 개념에 대해서 알아볼 건데, 다음과 같이 순수하지 않은 함수에서 순수 함수를 추출해내는 코드 리팩터링 과정의 예제를 살펴봅시다.

다음은 정수 n을 입력으로 받아 그 제곱 값을 계산하고, 문자열 템플릿을 적용하여 결과를 표준출력으로 출력하는 코드입니다. 여기서는 계산액션(콘솔 출력)이 혼합된 형태입니다.

def printSquare(n: Int): Unit = {
  val square = n * n
  println(s"The square of $n is $square")
}

이 코드에서 n * n 부분을 별도의 calculateSquare() 함수로 추출해, 다음과 같이 리팩터링할 수 있습니다.

def calculateSquare(n: Int): Int = n * n

def printSquare(n: Int): Unit = {
  val square = calculateSquare(n)
  println(s"The square of $n is $square")
}

이 과정을 조금 더 발전시켜, 문자열 템플릿 적용 부분도 calculateMessage() 함수로 추출하여 이렇게 리팩터링할 수 있습니다.

def calculateSquare(n: Int): Int = n * n

def calculateMessage(n: Int, square: Int): String =
  s"The square of $n is $square"

def printSquare(n: Int): Unit = {
  val square = calculateSquare(n)
  val message = calculateMessage(n, square)
  println(message)
}

이 리팩터링을 통해, 액션(콘솔 출력)과 계산 부분을 분리하여 순수 함수를 추출해낼 수 있었습니다.

이러한 과정이 자연스럽게 느껴지시나요?

관심사의 분리

순수 함수는 입력을 오직 함수의 인자로부터 받고, 출력은 반환 값으로만 이루어집니다. 즉, 인자가 아닌 외부 값을 읽거나, 외부 변수에 출력을 쓰는 등의 동작은 하지 않습니다. 순수 함수는 그 자체로 완결적이며, 함수 외부의 상태나 변화에 신경 쓸 필요가 없습니다. 이로 인해 함수가 독립적으로 동작하게 되어 코드의 이해와 유지보수가 훨씬 용이해집니다.

이렇게 입력에 대한 계산과 그 결과 출력만으로 이루어져있고, 함수 호출을 해도 아무 상태도 바꾸지 않는 위 calculateSquare(), calculateMessage() 같은 함수를 순수 함수라고 합니다.

순수 함수수학적 함수를 말합니다. 즉, 수학에서 말하는 함수라는 개념이 바로 순수 함수입니다.

함수(function), 프로시저(procedure), 서브루틴(subroutine), 메서드(method) 차이?

프로그래밍 언어에서 부 프로그램 코드를 호출할 수 있는 부분을 정의하는 여러가지 방법들이 있습니다. 엄밀히 말하면 서로 다른 성격의 것들이지만 대개 프로그래밍 언어의 문법상으로는 같은 취급을 하는 경우가 많습니다. 입력 값이나 반환 값이 있느냐, 부수 효과를 동반하느냐, 객체나 클래스에 종속되어 있느냐에 따라 조금씩 의미를 달리합니다. 하지만 함수형 프로그래밍 언어에서 함수를 언급할 때는 기본적으로 수학적 순수 함수라고 생각하면 됩니다.

함수를 수학에서 정의하는 함수처럼 쓰는 것은 함수형 프로그래밍의 실천 사항 중의 하나라고 할 수 있습니다.

앞으로 이 공식을 잘 기억해 두세요.

계산으로만 이루어짐 = 부수 효과 없음 = 순수성 = 참조 투명성

각각의 개념을 간단히 설명하자면 다음과 같습니다.

계산으로만 이루어짐 (Composed purely of computation)

함수의 동작이 오로지 입력에 기반한 계산 결과를 반환한다는 의미입니다. 함수는 외부 상태나 다른 요인에 의존하지 않으며, 주어진 입력에 대해 항상 동일한 출력을 반환합니다.

  • calculateSquare(): 입력 n만을 사용하여 계산합니다.
  • calculateMessage(): 입력 nsquare 값만으로 계산합니다.

부수 효과 없음 (No side effects)

부수 효과는 부작용(副作用) 혹은 영어로 사이드 이펙트(side effect)라고도 부릅니다.

함수가 반환하는 계산 결과를 작용(effect) 혹은 주작용(main effect)이라고 한다면, 함수의 실행으로 인해 초래되는 작용 이외의 부수적인 변화는 모두 부작용(부수 효과)에 해당합니다.

부수 효과가 없다는 것은 함수 실행 중에 외부 상태를 변경하거나 외부에서 관찰 가능한 다른 효과를 발생시키지 않는 상태를 의미합니다.

예를 들어, 파일 시스템, 데이터베이스, 네트워크 입출력이나 이와 유사한 동작이 없는 함수를 가리킵니다.

  • calculateSquare(), calculateMessage()계산 결과만 반환하며, 외부 상태를 변경하지 않습니다.
  • 반면에 printSquare()println()을 호출해 표준 출력에 값을 쓰므로 부수 효과가 발생합니다.

순수성 (Purity)

위의 두 가지 조건을 만족하는 함수를 순수한 함수라고 합니다. 즉, 동일한 입력에 대해 항상 동일한 출력을 반환하고, 외부에 영향을 미치지 않는 함수입니다.

  • 따라서 calculateSquare()calculateMessage()순수 함수입니다.
  • 반대로 printSquare()는 순수하지 않은 println()을 중간에 실행하므로 순수하지 않습니다.

참조 투명성 (Referential transparency)

이 용어는 분석 철학에서 어떤 표현이나 문장이 전체 글 내에서 항상 동일한 의미를 가지는 경우를 가리키는 의미로 사용되기도 합니다.

참조 투명성은 같은 입력 표현(함수 호출)에 대해 항상 같은 결과를 내는 성질을 의미하며, 프로그램에서 어떤 표현식을 그 평가 값으로 대체해도 그 프로그램의 결과가 동일하다는 것을 뜻합니다.

  • 예를 들어, calculateSquare(2)2 * 2 = 4이므로, calculateSquare(2)4로 대체해도 결과는 변하지 않습니다. 또한, calculateSquare()calculateMessage()는 몇 번을 호출해도 동일한 결과를 반환하므로 참조 투명합니다
  • printSquare()는 콘솔에 같은 메시지를 출력하지만, 호출할 때마다 콘솔에 출력되는 횟수가 더해지므로 참조 투명하지 않습니다.

결국, 이 모든 개념은 서로 긴밀하게 연결되어 있습니다. 이후에 더 깊이 다룰 예정이지만, 1장에서는 이 용어들과 개념을 간단히 요약해 두시면 충분합니다.

함수형 프로그래밍은 이렇게 부수 효과가 없는 순수 함수를 사용하고, 참조 투명성을 지키는 방향으로 코드를 작성합니다. 그렇다면 이렇게 하는 이유는 무엇이고, 그로 인해 얻을 수 있는 이점은 무엇일까요?

함수형 프로그래밍의 이점

지금까지 설명한 내용을 바탕으로, 함수형 프로그래밍의 주요 이점을 다음과 같이 정리할 수 있습니다.

  • 코드의 가독성 및 유지보수성: 순수성과 참조 투명성 덕분에 코드의 의도가 명확히 드러납니다. 순수 함수는 함수 시그니처(함수 이름 + 인자 + 반환 타입)대로 동작하는 것이 보장되고, 함수 시그니처로는 파악할 수 없는 숨겨진 동작(부수 효과)을 하지 않으므로 가독성이 높아지고, 코드 변경 시에도 영향을 쉽게 추적할 수 있어 유지보수가 수월해집니다.
  • 디버깅 및 테스트 용이성: 순수 함수는 외부 상태를 변경하지 않기 때문에, 함수가 예상과 다른 결과를 내더라도 해당 함수만 집중적으로 살펴보면 됩니다. 이는 프로그램의 특정 부분을 독립적으로 테스트할 수 있게 만들어주며, 부수 효과가 있는 함수보다 순수 함수가 테스트하기에 훨씬 적합합니다.

참조 투명성과 컴파일러

참조 투명성이 지켜지는 코드는 컴파일러가 분석하기 쉬워져 코드 블럭을 재구성하여 중복 계산을 제거하거나 실행 순서를 최적화할 수 있는 가능성을 열어주고, 더 나아가서 병렬 처리를 용이하게 하는 등의 도움을 줍니다.

이 외에도 여러 이점이 있겠지만, 이번에는 1장에서 언급된 내용만 다루고 넘어가겠습니다.

치환 모형(substitution model)과 등식적 추론(equational reasoning)

조금 거창하게 들리는 치환 모형등식적 추론(등식 추론이라고도 부릅니다)에 대해 알아보겠습니다.

참조 투명성이 유지된다면, 아래와 같이 치환 모형을 사용한 등식적 추론이 가능합니다.

// 함수 정의가 이렇고
def calculateSquare(n: Int): Int = n * n

calculateSquare(2) // 이 표현식이 주어졌을 때,

= (n => n * n)(2)  // 1. calculateSquare()를 본문(n * n)으로 치환
= 2 * 2            // 2. n=2 인자를 적용하여 계산 표현식으로 치환
= 4                // 3. 표현식을 계산한 결과로 치환

즉, f(x) = x * x일 때, f(2)2 * 2로 치환할 수 있고, 또 2 * 24로 치환되어 최종적으로 4라는 결과로 유도됩니다.

이게 별로 특별할 것이 없는 당연한 이야기처럼 느껴지시나요?

이렇게 할 수 있는 이유는 바로 calculateSquare() 함수가 순수 함수이며, 그래서 참조 투명성이 지켜지는 덕분에 가능한 것입니다.

그렇다면 반대로 등식적 추론이 불가능한 함수의 경우를 생각해봅시다. 그리고 왜 등식적 추론이 성립하지 않는지도 생각해봅시다.

예시1 (외부 상태에 의존하거나 상태를 변경하는 함수)

아래는 순수하지 않은 함수의 예시입니다.

var counter = 0

def incrementCounter(): Int = {
  counter = counter + 1
  counter
}

이와 같은 상황에서는 등식적 추론을 적용할 수 없습니다.

incrementCounter() // 처음 호출에서 1을 반환
incrementCounter() // 두 번째 호출에서 2를 반환

이 함수는 외부 상태인 counter 변수를 변경하기 때문에, 같은 함수를 호출하더라도 매번 결과가 달라집니다. 따라서 참조 투명성이 유지되지 않습니다. 등식적 추론을 하기 위해서는 함수 호출이 참조 투명성을 가져야 하며, 이는 동일한 입력에 대해 언제나 동일한 출력을 반환해야 함을 의미합니다.

예시2 (순수하지 않은 동작을 포함하는 함수)

또 다른 형태의 참조 투명하지 않은 함수부수 효과를 동반하는 함수입니다. 예를 들어, 함수가 콘솔에 값을 출력하거나 파일에 데이터를 기록하는 등의 입출력(I/O) 작업을 할 때 참조 투명성이 깨집니다.

예를 들어, 콘솔에 값을 출력하는 함수의 경우를 생각해봅시다:

def printHello(): Unit = {
  println("Hello, world!")
}

printHello()  // 이 표현식에 대해 등식적 추론을 적용해 봅시다

이 함수를 등식적 추론으로 치환해 본다면 다음과 같은 문제가 생깁니다:

printHello()  // () 리턴, 콘솔에 "Hello, world!" 출력
printHello()  // () 리턴, 또다시 "Hello, world!" 출력

이 함수는 Unit를 리턴하지만, 출력하는 행위 자체가 부수 효과이기 때문에 참조 투명하지 않습니다. 같은 값을 반환하는 함수라 하더라도 그 함수가 수행하는 액션(콘솔 출력) 때문에 등식적 추론이 불가능합니다.

  • printHello()는 단순히 값을 반환하는 것이 아니라 콘솔에 출력을 발생시키는 부수 효과가 포함되어 있습니다.
  • 즉, 같은 입력(Unit)에 대해 호출할 때마다 같은 결과(Unit)가 나오더라도, 그 과정에서 발생하는 부수 효과 때문에 등식적 추론을 할 수 없습니다. 함수 호출이 반복될 때마다 콘솔에 새로운 문자열이 찍히는 액션이 일어나기 때문입니다.

예시3 (예외를 던지는 함수)

예외를 던지는 함수는 특정 상황에서 정상적으로 값을 반환하지 않고, 프로그램 흐름을 중단시키며 예외를 발생시키므로 참조 투명성을 유지하지 않습니다.

def divide(a: Int, b: Int): Int = {
  if (b == 0) throw new ArithmeticException("Division by zero")
  else a / b
}

위 함수는 divide(4, 2)를 호출하면 2를 반환하지만, divide(4, 0)을 호출하면 예외를 던집니다. 따라서 동일한 입력에 대해 언제나 동일한 출력을 보장하지 않습니다. 즉, 특정 입력에서 예외가 발생하므로 등식적 추론이 성립하지 않습니다.

예시4 (종료되지 않는 함수)

이런 함수는 호출될 때 정상적으로 값을 반환하지 않고 영원히 실행을 지속하거나 실행이 멈추지 않습니다.

def infiniteLoop(): Int = {
  while (true) {}
  0
}

이 함수는 언제나 0을 반환하는 것으로 보일 수 있지만, 실제로는 무한 루프에 빠져 실행이 끝나지 않습니다. 따라서 등식적 추론을 통해 infiniteLoop()0으로 대체할 수 없습니다. 호출 결과가 무한 실행으로 이어지기 때문에 참조 투명성이 깨집니다.

예시5 (현재 시간 또는 임의의 값을 반환하는 함수)

현재 시간이나 임의의 값을 반환하는 함수는 매 호출마다 다른 결과를 반환하기 때문에 참조 투명성을 유지하지 않습니다.

def getCurrentTime(): Long = System.currentTimeMillis()
def getRandomNumber(): Int = scala.util.Random.nextInt()

getCurrentTime() 함수는 호출할 때마다 다른 값을 반환하며, getRandomNumber() 함수도 임의의 값을 생성하여 반환합니다. 이러한 함수들은 같은 입력에 대해 다른 결과를 반환하므로 등식적 추론을 적용할 수 없습니다.

예시6 (외부 자원에 의존하는 함수)

외부 자원에 접근하는 함수, 예를 들어 파일을 읽거나 데이터베이스에서 값을 조회하는 함수는 외부 상태나 환경에 따라 다른 결과를 반환할 수 있기 때문에 참조 투명성을 유지하지 않습니다.

def readFile(filename: String): String = {
  scala.io.Source.fromFile(filename).mkString
}

이 함수는 파일의 내용을 읽어 반환하지만, 파일 내용이 변경될 경우 같은 filename을 인자로 넘겨도 다른 결과를 반환할 수 있습니다. 외부 자원 상태에 따라 결과가 달라지기 때문에 등식적 추론이 불가능합니다.

예시7 (사용자 입력에 의존하는 함수)

사용자 입력을 요청하는 함수는 호출 시마다 사용자의 입력이 달라질 수 있어 같은 호출에서도 다른 결과를 반환할 수 있습니다.

def getUserInput(): String = {
  scala.io.StdIn.readLine()
}

getUserInput() 함수는 사용자가 입력하는 값에 따라 달라지므로 참조 투명성을 유지하지 않습니다. 동일한 입력에 대해 동일한 출력을 보장할 수 없기 때문에 등식적 추론을 적용할 수 없습니다.

예시8 (네트워크 호출을 수행하는 함수)

네트워크 통신을 통해 외부 API에 접근하거나 네트워크 요청을 수행하는 함수는 네트워크 상태나 외부 서버의 응답에 따라 다른 결과를 반환할 수 있습니다.

def fetchDataFromApi(url: String): String = {
  // 네트워크 요청을 보낸다고 가정
  // 실제 구현은 생략
}

네트워크 상태나 API 서버의 상태에 따라 결과가 달라질 수 있으며, 네트워크 장애나 지연이 발생할 수도 있기 때문에 이 함수는 등식적 추론이 불가능합니다.

이상으로 등식적 추론이 불가능한 함수의 유형을 알아보았고, 다음과 같이 정리할 수 있습니다.

등식적 추론이 안 되는 함수 = 참조 투명하지 않은 함수 = 순수하지 않은 함수 = 부수 효과가 있는 함수

순수 함수만 사용해서 일반적으로 유용한 프로그램을 작성할 수 있을까요?

답은 아니다 입니다. 함수형 프로그래밍 언어로 작성한 프로그램이라도 단순 계산 이상의 기능(데이터베이스 입출력, 네트워크 호출 등)이 필요한 경우 결국 어느 시점에는 순수하지 않는 동작을 실행하게 됩니다.

그러면 도대체 왜 함수형 프로그래밍에서는 이렇게 순수 함수를 강조할까요? 그것은 프로그램의 코드 대부분에 실제로 순수 함수를 사용하는 것이 일반적이기 때문입니다. 순수 함수를 합성해서 더 큰 단위의 순수 함수를 만들고, 그 함수들을 또 합성하여 기능을 수행하는 프로그램 코드가 됩니다. 그리고 마지막에 가서야 그 프로그램을 딱 한 번 순수하지 않게 실행합니다.

예를 들어, 한 번 실행하고 바로 종료하는 콘솔 프로그램이라면 주로 메인 함수에서, 반면에 반복적인 요청을 처리하는 서버라면 요청을 처리하는 컨트롤러 부분에서 그렇게 할 가능성이 높습니다. (그러나 이 경우 아마 함수형 웹 프레임워크나 라이브러리에 의해 순수하지 않는 동작을 실행하는 부분이 숨겨지게 되는 것이 일반적입니다)

즉, 가능하면 순수 함수를 합성해서 프로그램을 작성하고, 부수 효과를 마지막 순간까지 유예하는 방식으로 프로그래밍하는 것이 함수형 프로그래밍의 일반적인 전략입니다.

그리하여 오늘도 쿠키런: 킹덤 서버는 잘 돌아가고 있습니다.

이에 관한 자세한 내용은 나중에 책의 후반부, 제13장 외부 효과와 입출력 편에서 다루어집니다. 여러분들이 아마 어딘가에서 들어봤을 모나드(Monad) 같은 것들이 바로 함수형 프로그래밍에서 함수 합성을 가능하게 하는 도구 중의 하나입니다. 앞으로 이런 새롭고 흥미로운 개념들을 많이 만나보게 될 것입니다.

정리

이번에 『스칼라로 배우는 함수형 프로그래밍』 제1장 함수형 프로그래밍이란 무엇인가? 를 함께 읽어보았습니다.

이번 장에서 다룬 주요 키워드는 다음과 같습니다:

  • 계산 vs. 액션 (computation vs. action)
  • 순수 함수 (pure function)
  • 부수 효과 (side effect)
  • 참조 투명성 (referential transparency)
  • 치환 모형 (substitution model)
  • 등식적 추론 (equational reasoning)

지금까지 간단한 코드 예제들을 통해서 각각의 용어의 의미가 무엇인지 살펴보았습니다.

이 용어와 개념을 잘 이해해두면 앞으로 책을 읽어가는 데 도움이 될 것입니다. 함수형 프로그래밍의 기본을 차근차근 다져 나가는 과정에서, 이러한 개념들이 어떻게 서로 연결되는지 확인하는 것이 중요합니다.

이제 막 첫 장을 마쳤고, 앞으로 점점 더 흥미롭고 심화된 내용이 나올 것입니다. 저도 계속해서 책을 다시 읽어보고 내용을 정리하며 다음 글에서 더 유익한 설명을 드리도록 하겠습니다.

그럼 다음 편에서 뵙겠습니다. 감사합니다.

더보기

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.