함수형 프로그래밍으로 도메인 모델링

함수형 프로그래밍으로 도메인 모델링 (Domain Modeling with FP)

객체지향(OOP)에서는 데이터와 그 데이터를 다루는 동작(메서드)을 한 클래스 안에 묶는 게 익숙하죠. 그런데 함수형 프로그래밍(FP)에서의 도메인 모델링은 데이터데이터에 대한 연산을 분리해서 생각해요. '피자 가게' 예시를 통해 그 발상과 실제 구현 방식을 차근차근 볼게요.

출처: Scala 3 Book

본문

이 챕터는 Scala 3에서 함수형 프로그래밍(FP)을 사용한 도메인 모델링에 대한 소개를 다뤄요. FP로 주변 세계를 모델링할 때 보통 이런 Scala 구조를 사용해요:

  • 열거형(Enumerations)

  • 케이스 클래스(Case classes)

  • 트레이트(Traits)

대수적 데이터 타입(ADT)과 그 일반화된 버전(GADT)에 익숙하지 않다면, 이 섹션을 읽기 전에 Algebraic Data Types 섹션을 읽는 게 좋아요.

소개

FP에서 데이터그 데이터에 대한 연산은 서로 다른 두 가지예요. OOP처럼 둘을 강제로 함께 캡슐화할 필요가 없죠.

이 개념은 수치 대수(numerical algebra)와 비슷해요. 값이 0 이상인 정수들을 생각해 보면, 가능한 값의 집합이 이렇게 생겼어요:

0, 1, 2 ... Int.MaxValue

정수의 나눗셈을 무시하면, 그 값들에 대한 가능한 연산은 다음과 같아요:

+, -, *

FP에서 비즈니스 도메인도 비슷한 방식으로 모델링돼요:

  • 값의 집합(즉, 데이터)을 기술한다

  • 그 값들에 동작하는 연산(즉, 함수)을 기술한다

앞으로 보게 되겠지만, 이런 스타일로 프로그램을 사고하는 것은 객체지향 프로그래밍과 꽤 다릅니다. FP에서 데이터는 그냥 있을 뿐입니다: 데이터에서 기능을 분리하면, 동작(behavior)에 신경 쓰지 않고 데이터를 검사할 수 있습니다.

이 챕터에서는 피자 가게의 '피자'에 대한 데이터와 연산을 모델링할 거예요. Scala/FP 모델의 '데이터' 부분을 구현하는 방법을 보여주고, 그 데이터에 대한 연산을 조직화할 수 있는 여러 다른 방법을 소개할게요.

데이터 모델링하기

Scala에서 프로그래밍 문제의 데이터 모델을 기술하는 것은 간단해요:

  • 여러 대안(alternative)으로 이루어진 데이터를 모델링하려면 enum 구조를 사용해요 (Scala 2에서는 case object).

  • 단지 것들을 그룹화하고 싶거나(혹은 더 세밀한 통제가 필요하면) case 클래스를 사용해요.

대안 기술하기

크기, 종류, 토핑 같은 서로 다른 대안들로 이루어진 데이터는 Scala에서 열거형(enumeration)으로 깔끔하게 모델링돼요.

Scala 3 열거형은 enum 구조로 간결하게 표현돼요:

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

서로 다른 대안을 기술하는 데이터 타입(예: CrustSize)을 *합 타입(sum types)*이라고 부르기도 해요.

복합 데이터 기술하기

피자는 위의 여러 속성들의 복합(compound) 컨테이너로 생각할 수 있어요. PizzacrustSize, crustType, 그리고 잠재적으로 여러 개의 toppings로 구성된다는 것을 case 클래스로 기술할 수 있어요:

import CrustSize.*
import CrustType.*
import Topping.*

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

여러 구성 요소를 모으는 데이터 타입(예: Pizza)을 *곱 타입(product types)*이라고 부르기도 해요.

그리고 그게 다예요. 이것이 FP 스타일 피자 시스템의 데이터 모델이에요. 이 해법은 매우 간결한데, 피자에 대한 연산을 데이터 모델과 결합할 필요가 없기 때문이에요. 데이터 모델은 관계형 데이터베이스의 설계를 선언하는 것처럼 읽히기 쉬워요. 데이터 모델의 값을 만들고 검사하는 것도 아주 쉬워요:

val myFavPizza = Pizza(Small, Regular, Seq(Cheese, Pepperoni))
println(myFavPizza.crustType) // prints Regular
데이터 모델 더 보기

같은 방식으로 계속해서 전체 피자 주문 시스템을 모델링할 수도 있어요. 이런 시스템을 모델링하는 데 쓰이는 몇 가지 다른 case 클래스가 있어요:

case class Address(
  street1: String,
  street2: Option[String],
  city: String,
  state: String,
  zipCode: String
)

case class Customer(
  name: String,
  phone: String,
  address: Address
)

case class Order(
  pizzas: Seq[Pizza],
  customer: Customer
)
'살짝 뼈만 있는(skinny) 도메인 객체'

Debasish Ghosh의 책 Functional and Reactive Domain Modeling에서, OOP 실무자들은 데이터와 동작을 캡슐화한 클래스를 '풍부한 도메인 모델(rich domain models)'이라고 부르는 반면, FP 데이터 모델은 '살짝 뼈만 있는 도메인 객체(skinny domain objects)'로 생각할 수 있다고 말해요. 이는 이 예시에서 보여주듯 데이터 모델이 속성만 있고 동작은 없는 case 클래스로 정의되어, 짧고 간결한 데이터 구조가 되기 때문이에요.

연산 모델링하기

여기서 흥미로운 질문이 생겨요. FP가 데이터를 그 데이터에 대한 연산과 분리한다면, Scala에서는 그 연산을 어떻게 구현할까요?

답은 사실 아주 단순해요. 방금 모델링한 데이터의 값에 대해 동작하는 함수(또는 메서드)를 그냥 작성하면 돼요. 예를 들어 피자 가격을 계산하는 함수를 정의할 수 있어요:

def pizzaPrice(p: Pizza): Double = p match
  case Pizza(crustSize, crustType, toppings) =>
    val base  = 6.00
    val crust = crustPrice(crustSize, crustType)
    val tops  = toppings.map(toppingPrice).sum
    base + crust + tops

함수의 구현이 단순히 데이터의 모양을 따라간다는 걸 눈치챘을 거예요. Pizza가 case 클래스이므로 패턴 매칭으로 구성 요소를 추출하고, 헬퍼 함수를 호출해 개별 가격을 계산하죠.

def toppingPrice(t: Topping): Double = t match
  case Cheese | Onions => 0.5
  case Pepperoni | BlackOlives | GreenOlives => 0.75

마찬가지로 Topping은 열거형이므로 패턴 매칭으로 서로 다른 variant들을 구분해요. 치즈와 양파는 50센트, 나머지는 각각 75센트로 책정돼요.

def crustPrice(s: CrustSize, t: CrustType): Double =
  (s, t) match
    // if the crust size is small or medium,
    // the type is not important
    case (Small | Medium, _) => 0.25
    case (Large, Thin) => 0.50
    case (Large, Regular) => 0.75
    case (Large, Thick) => 1.00

크러스트 가격을 계산하려면 크기와 종류에 동시에 패턴 매칭을 하면 돼요.

위에 보인 모든 함수에 대한 중요한 점은, 그들이 *순수 함수(pure functions)*라는 거예요. 데이터를 변경하거나 (예외를 던지거나 파일에 쓰는 것 같은) 다른 부수 효과가 없어요. 그저 값을 받아 결과를 계산할 뿐이에요.

기능 조직화하기

위에서 pizzaPrice 함수를 구현할 때, 그것을 어디에 정의할지는 말하지 않았어요. Scala는 여러분의 로직을 다양한 이름 공간과 모듈로 조직화할 수 있는 훌륭한 도구를 많이 제공해요.

동작을 구현하고 조직화하는 방법에는 여러 가지가 있어요:

  • 동반 객체(companion objects)에 함수를 정의한다

  • 모듈식 프로그래밍 스타일을 쓴다

  • '함수형 객체(functional objects)' 접근 방식을 쓴다

  • 확장 메서드(extension methods)에 기능을 정의한다

이어지는 섹션에서 이 여러 해법을 보여줄게요.

동반 객체 (Companion Object)

첫 번째 접근 방식은 동작—즉 함수—을 동반 객체에 정의하는 거예요.

도메인 모델링의 Tools 섹션에서 논의했듯이, 동반 객체는 클래스와 같은 이름을 가지며 클래스와 같은 파일에 선언되는 object예요.

이 방식에서는 열거형이나 case 클래스 외에, 동작을 담은 같은 이름의 동반 객체도 정의해요.

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

// the companion object of case class Pizza
object Pizza:
  // the implementation of `pizzaPrice` from above
  def price(p: Pizza): Double = ...

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

// the companion object of enumeration Topping
object Topping:
  // the implementation of `toppingPrice` above
  def price(t: Topping): Double = ...

이 방식으로 Pizza를 만들고 가격을 이렇게 계산할 수 있어요:

val pizza1 = Pizza(Small, Thin, Seq(Cheese, Onions))
Pizza.price(pizza1)

이렇게 기능을 그룹화하면 몇 가지 장점이 있어요:

  • 기능을 데이터와 연관시키고, 프로그래머(그리고 컴파일러)가 찾기 쉽게 만들어요.

  • 이름 공간을 만들어서, 예를 들어 오버로딩에 의존하지 않고 price를 메서드 이름으로 쓸 수 있게 해줘요.

  • Topping.price의 구현은 Cheese 같은 열거형 값을 임포트하지 않고 접근할 수 있어요.

다만 고려해야 할 몇 가지 트레이드오프(장단점)도 있어요:

  • 기능을 데이터 모델에 밀접하게 결합해요. 특히 동반 객체는 case 클래스와 같은 파일에 정의해야 해요.

  • crustPrice처럼 CrustSize의 동반 객체나 CrustType의 동반 객체 어디에나 둘 수 있는 함수를 어디에 정의할지 명확하지 않을 수 있어요.

모듈 (Modules)

동작을 조직화하는 두 번째 방법은 '모듈식(modular)' 접근 방식을 쓰는 거예요. Programming in Scala 책은 모듈을 "잘 정의된 인터페이스와 숨겨진 구현을 가진 '더 작은 프로그램 조각'"이라고 정의해요. 이것이 뜻하는 바를 살펴볼게요.

PizzaService 인터페이스 만들기

가장 먼저 생각해야 할 것은 Pizza의 '동작들(behaviors)'이에요. 이때 PizzaServiceInterface 트레이트를 이렇게 스케치해요:

trait PizzaServiceInterface:

  def price(p: Pizza): Double

  def addTopping(p: Pizza, t: Topping): Pizza
  def removeAllToppings(p: Pizza): Pizza

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza
  def updateCrustType(p: Pizza, ct: CrustType): Pizza

보여주듯 각 메서드는 Pizza를 입력 매개변수로—다른 매개변수와 함께—받고, 결과로 Pizza 인스턴스를 반환해요.

이런 순수 인터페이스를 작성할 때는, '이 트레이트를 확장하는 모든 비추상(non-abstract) 클래스는 이 서비스들의 구현을 반드시 제공해야 한다'는 계약이라고 생각할 수 있어요.

이 시점에서 여러분이 이 API의 소비자라고 상상해 볼 수도 있어요. 그렇게 하면, API가 원하는 모습인지 확인하기 위해 샘플 '소비자' 코드를 스케치해 보는 게 도움이 돼요:

val p = Pizza(Small, Thin, Seq(Cheese))

// how you want to use the methods in PizzaServiceInterface
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

그 코드가 괜찮아 보이면, 보통 또 다른 API(예: 주문용 API)를 스케치하기 시작하겠지만, 지금은 피자만 보고 있으니 인터페이스 생각을 멈추고 이 인터페이스의 구체적인 구현을 만들게요.

이것은 보통 두 단계 과정이라는 점에 주목하세요. 첫 단계에서는 API의 계약을 인터페이스로 스케치합니다. 두 번째 단계에서는 그 인터페이스의 구체적인 구현을 만듭니다. 어떤 경우에는 기본 인터페이스의 여러 구체적인 구현을 만들게 될 수도 있습니다.

구체적인 구현 만들기

이제 PizzaServiceInterface의 모습을 알았으니, 인터페이스에 정의한 모든 메서드의 본문을 작성해 그 구체적인 구현을 만들 수 있어요:

object PizzaService extends PizzaServiceInterface:

  def price(p: Pizza): Double =
    ... // implementation from above

  def addTopping(p: Pizza, t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings(p: Pizza): Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(p: Pizza, ct: CrustType): Pizza =
    p.copy(crustType = ct)

end PizzaService

인터페이스를 만든 다음 구현을 만드는 이 두 단계 과정이 항상 필요한 건 아니지만, API와 그 사용을 명시적으로 생각하는 것은 좋은 접근 방식이에요.

모든 게 갖춰졌으니 Pizza 클래스와 PizzaService를 사용할 수 있어요:

import PizzaService.*

val p = Pizza(Small, Thin, Seq(Cheese))

// use the PizzaService methods
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

println(price(p4)) // prints 8.75

함수형 객체 (Functional Objects)

Programming in Scala 책에서 저자들은 '함수형 객체(Functional Objects)'를 "변경 가능한 상태(mutable state)가 없는 객체"로 정의해요. 이것은 scala.collection.immutable의 타입들에도 해당하는 경우예요. 예를 들어 List의 메서드들은 내부 상태를 변경하지 않고, 대신 결과로 List의 복사본을 만들어요.

이 접근 방식을 '하이브리드 FP/OOP 설계'로 생각할 수 있는데, 왜냐하면 여러분이:

  • 데이터를 불변(immutable) case 클래스로 모델링하고

  • 동작(메서드)을 데이터와 같은 타입 안에 정의하고

  • 그 동작을 순수 함수로 구현하기 때문이에요. 내부 상태를 변경하지 않고 복사본을 반환하죠.

이것은 정말 하이브리드 접근 방식이에요. OOP 설계처럼 메서드가 데이터와 함께 클래스 안에 캡슐화되지만, FP 설계의 특징처럼 메서드가 데이터를 변경하지 않는 순수 함수로 구현돼요.

예시

이 방식을 사용하면 case 클래스에서 피자에 대한 기능을 직접 구현할 수 있어요:

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
):

  // the operations on the data model
  def price: Double =
    pizzaPrice(this) // implementation from above

  def addTopping(t: Topping): Pizza =
    this.copy(toppings = this.toppings :+ t)

  def removeAllToppings: Pizza =
    this.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    this.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    this.copy(crustType = ct)

이전 접근 방식들과 달리, 이 메서드들은 Pizza 클래스의 메서드이므로 입력 매개변수로 Pizza 참조를 받지 않는다는 점에 주목하세요. 대신 현재 피자 인스턴스에 대한 자기 자신의 참조를 this로 가져요.

이제 이 새로운 설계를 이렇게 사용할 수 있어요:

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

확장 메서드 (Extension Methods)

마지막으로, 첫 번째 방식(동반 객체에 함수 정의)과 마지막 방식(타입 자체에 함수를 메서드로 정의) 사이에 있는 접근 방식을 보여줄게요.

확장 메서드는 함수를 타입 자체에 메서드로 정의하지 않고도 함수형 객체와 같은 API를 만들 수 있게 해줘요. 여기에는 여러 장점이 있을 수 있어요:

  • 데이터 모델이 다시 매우 간결해지고 어떤 동작도 언급하지 않아요.

  • 원래 정의를 바꾸지 않고도 타입에 메서드를 소급해서(retroactively) 추가할 수 있어요.

  • 동반 객체나 타입의 직접적인 메서드와 달리, 확장 메서드는 다른 파일에서 외부에 정의될 수 있어요.

예시를 다시 한 번 볼게요.

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

extension (p: Pizza)
  def price: Double =
    pizzaPrice(p) // implementation from above

  def addTopping(t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings: Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    p.copy(crustType = ct)

위 코드에서 피자에 대한 여러 메서드를 확장 메서드로 정의해요. extension (p: Pizza)를 통해 Pizza 인스턴스에 이 메서드들을 사용할 수 있게 하고 싶다고 말하는 거예요. 이 경우 수신자(receiver)는 p예요.

확장 메서드를 사용하면 이전과 같은 API를 얻을 수 있고요:

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

확장은 어떤 다른 모듈에서도 정의할 수 있어요. 보통 데이터 모델의 설계자라면 확장 메서드를 동반 객체에 정의할 거예요. 그러면 이미 모든 사용자에게 사용 가능하죠. 그렇지 않으면 확장 메서드는 사용하려면 명시적으로 임포트해야 해요.

이 접근 방식의 요약

Scala/FP에서 데이터 모델을 정의하는 것은 단순한 편이에요. 데이터의 variant들은 열거형으로, 복합 데이터는 case 클래스로 모델링하면 돼요. 그리고 동작을 모델링하려면 데이터 모델의 값에 대해 동작하는 함수를 정의하면 돼요. 함수를 조직화하는 여러 방법을 살펴봤어요:

  • 메서드를 동반 객체에 넣을 수 있다

  • 모듈식 프로그래밍 스타일을 사용해 인터페이스와 구현을 분리할 수 있다

  • '함수형 객체' 접근 방식을 사용해 정의된 데이터 타입에 메서드를 저장할 수 있다

  • 확장 메서드를 사용해 데이터 모델에 기능을 부여할 수 있다

더 알아보기