본문 바로가기

Programming Project/스칼라로 배우는 함수형 프로그래밍 정리

[01]함수형 프로그래밍이란 무엇인가? - 스칼라로 배우는 함수형 프로그래밍


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

스칼라로 배우는 함수형 프로그래밍을 읽고 정리한 글입니다.

-

 

함수형 프로그래밍은

오직 순수 함수(pure function)들로만, 다시 말해서 부수 효과(side effect)가 없는 함수들로만 구축한다는 것이다.

side effect가 없는 함수란, 어떤 값을 return하는 것 외에 다른 행위들(변수를 수정하거나, 오류를 내면서 실행을 중단하거나, 파일에서 무엇인가를 읽거나 수정하거나, 화면에 print를 한다거나..)을 발생시키지 않는 함수를 말한다. 이렇게 순수 함수들로만 프로그래밍을 하게 되면 모듈성(modularity)이 증가하여 훨씬 이로운 점들이 많이 생긴다. 모듈성 덕분에 순수 함수는 test, 재사용, 병렬화, 일반화, 분석이 쉬워진다. 또한, 버그가 생길 여지가 적다.

 

이 글에서는 여러 side effect가 있는 프로그램에서 side effect를 제거해 볼 것이다. 또한 참조 투명성(referential transparency)과 치환 모형(substitution model)에 대해서 알아본다.

  

1.1 FP의 이점 

1.1.1 side effect가 있는 프로그램

커피숍에서 커피를 구매하는 프로그램을 작성한다고 하자. 우선 구현시에 impure한 함수(순수하지 않은 함수)를 구현해본다.

class Cafe {
    def buyCoffie(cc: CreditCard): Coffee = {
    	val cup = new Coffee()
        cc.charge(cup.price)
        cup
    }
}

cc.charge(cup.price)가 side effect의 예시이다. charge에는 외부 세계와의 어떠한 상호작용이 관여하게 된다. 이를테면, 어떤 웹 서비스를 통해서 신용카드 회사와의 거래(트랜젝션)을 승인하고, 대금을 청구하고, 이후 참조를 위해 거래를 영구적으로 기록하는 등의 행위를 하게 된다. 그러나 이 함수 자체는 단지 하나의 Coffee 객체를 돌려줄 뿐이고 그 외의 모든 동작은 모두 부수적으로(on the side) 일어난다. 여기에서 side effect(부수효과)라는 단어가 유래된다.

 

side effect가 있기 때문에 이 코드는 검사하기가 어렵다. 코드를 검사하기 위해서 실제로 신용카드 회사와 연결해서 카드 이용 대금을 청구하고 싶지는 않기 때문이다. testability(검사성)을 위해 다음과 같이 설계를 변경해볼 수 있다.

실제 대금 결제를 위해 신용카드 회사와 연동하는 방법을 CreditCard에 넣는 것은 좋지 않다. (cc.charge를 사용하는 부분)또한 이 결제에 관한 정보를 우리의 내부 시스템에 persist(영속)하게 기록하는 방법에 대한 것도 넣지 않는 것이 좋을 것이다. CreditCard에서는 그런 부분을 신경쓰지 않게 만들고, 대신 지급을 위한 Payments 객체를 buyCoffee에 전달한다면 코드의 모듈성과 검사성을 좀 더 높일 수 있다.

class Cafe{
    def buyCoffee(cc: CreditCard, p: Payments): Coffee = {
    	val cup = new Coffee()
        p.charge(cc, cup.price)
        cup
    }
}

 

이렇게 코드를 바꾸면, p.charge에서 여전히 side effect가 발생하지만 적어도 testability는 높아졌다. Payments를 하나의 인터페이스로 만들고 그 인터페이스의 모의(mock)구현을 작성하면 검사를 수월하게 진행할 수 있다(가라로 만든 Payments를 넣으면 된다는 뜻). 그러나 이 또한 이상적인 방식은 아니다. 이렇게 구현하기 위해서는 반드시 Payments를 인터페이스로 만들어야 하며, concrete class(모든 연산에 대한 구현이 되어있는, 즉 추상화되어 있지 않은 class)를 잘 만든다고 해도 모의 구현은 사용이 어색할 수 있다.

 

검사 문제 외에도 이 구현은 buyCoffee를 재사용하기 어렵다는 문제도 있다. 열 두개의 커피를 주문하기 위해서는 열두번의 loop을 통해 열 두번 계산을 해야 한다. 이는 바람직하지 않다.

 

1.1.2 Functional한 해법 : side effect의 제거.

이에 대한 functional한 해법은 side effect를 제거하고 buyCoffee가 Coffee뿐만 아니라 청구하는 결과(Payments)를 하나의 값으로 돌려주게 하는 것이다. 청구 금액을 신용카드 회사에 보내는 등의 buyCoffee에서는 관심을 가지지 않는 관심사는 buyCoffee 외의 다른 어딘가에서 해결하도록 한다. 다음의 코드는 스칼라로 포현된 functional 한 해법이 어떤 모습인지 보여주는 예시이다.

class Cafe{
    def buyCoffee(cc: CreditCard): (Coffee, Charge) = {
        val cup = new Coffee()
        (cup, Charge(cc, cup.price))
    }
}

buyCoffee는 이제 Coffee와 Charge쌍(pair)을 돌려준다. 그 쌍의 형식은 (Coffee, Charge)로 지정되어 있다.

이제 buyCoffee는 어떠한 side effect도 발생시키지 않게 된다.

이제 청구(Charge)건의 생성이 청구의 처리연동문제와 분리되었다. buyCoffee함수는 이제 Coffee뿐만 아니라 Charge도 돌려준다. 이런 변경 덕분에 여러 잔의 커피를 한번의 거래로 구매하기 위해 이 함수를 재사용하기 쉬워졌음을 잠시 후에 보게 될 것이다. 그렇다면 이 Charge는 구체적으로 어떻게 생겨야 할까?

case class Charge(cc: CreditCard, amount: Double) {
    def combine(other: Charge): Charge = 
        if (cc == other.cc)
            Charge(cc, amount + other.amount)
        else
            throw new Exception("Can't combine charges to different cards")
}

CreditCard와 amount를 담으며, 청구건들을 취합하는 combine 함수 또한 가지고 있다.

 

커피 n잔의 구매를 구현한 buyCoffees함수를 보자. 이제 이 함수는 buyCoffee함수를 통해 구현할 수 있게 된다.

class Cafe {
    def buyCoffee(cc: CreditCard): (Coffee, Charge) = ...
    def buyCoffees(cc: CreditCard, n: Int): (List[Coffee], Charge) = {
    	// List.fill(n)(x)은 x의 복사본 n개로 이루어진 List를 생성한다.  
        val purchases: List[(Coffee, Charge)] = List.fill(n)(buyCoffee(cc))
        
        
        // unzip은 쌍들의 목록을 목록들의 쌍으로 분리한다. 
        // 지금 예에서는 이를 이용하여 하나의 쌍을 두개의 값으로 해체한다.
        
        // charges.reduce는 한번에 청구건 2개를 combine을 이용하여 하나로 결합하는 과정을 반복함으로써
        // 청구건들의 목록 전체를 하나의 청구 건으로 환원(reduction)한다. reduce는 고차 함수의 예시이다. (추후 설명)
        val (coffees, charges) = purchases.unzip(
            coffees, charges.reduce((c1,c2) => c1.combine(c2))
        )
    }
}

이 함수는 이제 buyCoffee를 재사용하여 buyCoffees를 정의할 수 있으며, 두 함수 모두 Payments 인터페이스의 복잡한 모의 구현을 신경쓰지 않고도 손쉽게 검사할 수 있다. 실제로 Cafe는 이제 Charge의 대금이 어떻게 처리되는지 알지 못한다. 물론 실제 청구 처리를 위해서는 여전히 payments 클래스가 필요하겠지만, Cafe에서 관심이 있는 부분이 아니다.

Charge를 일급(first-class) 값(value)로 만들면 청구건들을 다루는 비즈니스 로직을 좀 더 쉽게 조립할 수 있게 된다는 이득이 생긴다. (business logic이 업무 논리라고 번역되어 있어 그냥 비즈니스 로직으로 쓴다)

더보기

(다시 정리하는) first-class citizen이란?

1. 변수나 데이터에 할당이 가능해야 한다.

2. 함수의 파라미터가 될 수 있어야 한다.

3. 리턴값으로 리턴할 수 있어야 한다.

4. 동일한지 비교가 가능해야 한다.

Charge가 일급 값이기 때문에 같은 카드의 여러 청구건들에 대한 값을 하나의 List[Charge] 로 취합하는 다음과 같은 함수를 작성하는 것이 가능하다.

def coalesce(charges: List[Charge]): List[Charge] = 
    charges.groupBy(_.cc).values.map(_.reduce(_ combine _)).toList

// TMI : coalesce는 합체하다라는 뜻을 가지고 있는 단어이고, 
//       SQL에서는 몇개의 칼럼중 처음으로 NULL이 아닌 값을 리턴하는 구문으로도 쓰인다.

이 코드는 다수의 메서드(groupBy, map, reduce...)에게 값을 전달한다. 이 함수는 청구건들의 목록을 받아서 신용카드별로 묶은 후 카드당 하나의 청구건으로 취합한다.

 

지금까지, FP의 이점들을 맛만 봤다. 앞으로 조금 더 알아보자.

 

1.2 (순수)함수란 구체적으로 무엇인가?

입력 형식이 A이고 출력 형식이 B인 함수 f는 형식이 A인 모든 값 a를 각각 형식이 B인 하나의 값 b에 연관시키되, b가 오직 a값에 의해서만 결정된다는 조건을 만족하는 계산이다. 내부 또는 외부 공정의 상태 변경은 f(a)의 결과를 계산하는데 어떠한 경향도 주지 않는다. 예를 들어 Int => String 형식의 intToString함수는 모든 정수를 그에 대응되는 문자열에 대응시킨다. 그 외의 일은 전혀 하지 않는다.

부수 효과가 없는 함수를 순수(pure)함수라고 부르지만, 앞으로 언급하는 (거의)모든 함수는 순수 함수를 의미한다.

순수 함수의 예시인 + -, String의 length 등은 항상 같은 값을 돌려주고, 그 외의 일은 전혀 일어나지 않는다. 순수 함수의 이런 개념을 참조 투명성(referal transparency)이라는 개념을 통해 공식화 할 수 있다. (참조 투명성은 함수가 아니라 표현식(expression)의 한 속성이다.) 2+3은 하나의 표현식이다. 프로그램 코드에서 2+3을 5로 대체해도 달라지는 것이 없다. 따라서 이 표현식에는 부수 효과가 없으며 이것이 표현식의 참조 투명성의 전부이다. 즉 임의의 프로그램에서 어떤 표현식을 그 결과로 바꾸어도 프로그램의 의미가 달라지지 않는다면 그 표현식은 참조에 투명한 것이다.

더보기

참조 투명성과 순수성

만일 모든 프로그램 p에 대해 표현식 e의 모든 출현(occurrence)을 e의 평가 결과로 치환해도 p의 의미에 아무 영향이 미치지 않는다면, 그 표현식 e는 참조에 투명하다. 만일 표현식 f(x)가 참조에 투명한 모든 x에 대해 참조에 투명하면, 함수 f는 순수(pure)하다.

1.3 참조 투명성, 순수성, 그리고 치환 모형

참조 투명성의 원리가 원래의 buyCoffee를 살펴보며 어떻게 적용할 수 있는지 알아보자.

def buyCoffee(cc: CreditCard): Coffee = {
    val cup = new Coffee()
    cc.charge(cup.price)
    cup
}

cc.charge(cup.price)의 반환 형식이 무엇이든 buyCoffee는 그 반환값을 폐기한다. 따라서 buyCoffee(aliceCreditCard)의 평가 결과는 cup이며 이는 new Coffee()와 동등하다. 앞의 참조 투명성의 정의 하에서, buyCoffee가 투명하려면 임의의 p에 대해 p(buyCoffee(aliceCreditCard))가 p(new Coffee())와 동일하게 작동해야 한다. 그렇지만 그렇지 않음이 명백하다. new Coffee()라는 프로그램은 아무 일도 하지 않지만 buyCoffee(aliceCreditCard)는 신용카드 회사에 연결해서 대금을 청구하기 때문이다.

참조 투명성은 함수가 수행하는 모든 것이 함수의 리턴 값으로 대표된다는 invariant(불변) 조건을 강제한다. 이러한 제약을지키면 치환 모형(substitution model)이라고 부르는, 프로그램 평가에 대한 간단하고도 자연스러운 추론 모형(reasoning model)이 가능해진다. 참조 투명성은 프로그램에 대한 등식적 추론(equational reasoning)이 가능하게 만든다.

순수성의 개념을 이런 식으로 공식화 해 보면, 함수적 프로그램의 모듈성이 다른 경우에 비해 더 좋은 경우가 많은 이유를 짐작할 수 있다. module형 프로그램은 전체와는 독립적으로 이해하고 재사용할 수 있는 구성 요소(component)들로 구성된다. 그런 프로그램에서 프로그램 전체의 의미는 오직 컴포넌트들의 의미와 컴포넌트들의 합성에 관한 규칙 들에만 의존한다. 즉, 컴포넌트는 합성가능(composable)하다. 

순수 함수는 모듈적이고 합성 가능한데, 이는 순수 함수에서의 계산이 "결과로 무엇을 할 것인지"나 "입력을 어떻게 얻을 것인지"와 분리되어 있기 때문이다. 즉, 순수 함수는 하나의 블랙박스이다. 입력이 주어지는 방식은 단 하나이다. 입력은 함수에 대한 인수로써 주어진다. 그리고 그 결과를 계산해서 돌려줄 뿐, 그것이 어떻게 쓰이는지는 신경쓰지 않는다. 이러한 관심사의 분리 때문에 side effect를 생각하지 않아도 되며, 프로그램 로직의 재사용성이 높아진다. 

1.4 요약

이번 장에서는 FP가 무엇인지 대략적으로 소개하고 사용 이유에 대해 알아보았다.

또 왜 프로그램에 대한 추론을 쉽게 해 주는지, 그리고 모듈성을 높여 주는지에 대해 알아보았다.

2 3 4장에서는 FP의 low-level 에서의 traditional한 프로그래밍 방법에 대해 알아본다.

 

---

 

단어가 헷갈릴 수 있으니 적습니다.

더보기

용어 사전

Functional programming : 함수형 프로그래밍

strictness : 엄격성 / non-strictness , laziness : 비엄격성

referential transparency : 참조 투명성

substitution model : 치환 모형

persistence: 영속성

concrete class : 구체, 구현, 구상 클래스 (여러 이름이 있다)

business logic : 업무 논리;;라고 번역하심