도메인 모델링

도메인 모델링

Scala는 함수형 프로그래밍(FP)과 객체지향 프로그래밍(OOP)을 모두 지원하고, 두 패러다임의 융합도 가능해요. 이번 장에서는 OOP와 FP에서 데이터를 모델링하는 방법을 빠르게 훑어볼게요.

출처: Scala 3 Book

본문

Scala는 함수형 프로그래밍(FP)과 객체지향 프로그래밍(OOP)을 모두 지원하며, 두 패러다임의 융합도 지원해요. 이 절은 OOP와 FP의 데이터 모델링을 빠르게 개괄해 줍니다.

OOP 도메인 모델링 (OOP Domain Modeling)

OOP 스타일로 코드를 작성할 때, 데이터 캡슐화를 위한 주된 두 도구는 트레이트(trait)클래스(class) 예요.

트레이트 (Traits)

Scala 트레이트는 단순한 인터페이스로 쓸 수 있지만, 추상·구체 메서드와 필드를 담을 수도 있고, 클래스처럼 매개변수를 가질 수도 있어요. 동작을 작고 모듈화된 단위로 조직하는 좋은 방법을 제공하죠. 나중에 속성과 동작의 구체적인 구현을 만들고 싶을 때, 클래스와 객체가 트레이트를 확장해서 원하는 동작을 얻는 데 필요한 만큼 많은 트레이트를 섞어(mix in) 넣을 수 있어요.

트레이트를 인터페이스로 쓰는 예시로, 개와 고양이 같은 동물을 위한 잘 조직되고 모듈화된 동작을 정의하는 트레이트 세 개를 볼게요.

trait Speaker {
  def speak(): String  // has no body, so it’s abstract
}

trait TailWagger {
  def startTail(): Unit = println("tail is wagging")
  def stopTail(): Unit = println("tail is stopped")
}

trait Runner {
  def startRunning(): Unit = println("I’m running")
  def stopRunning(): Unit = println("Stopped running")
}
trait Speaker:
  def speak(): String  // has no body, so it’s abstract

trait TailWagger:
  def startTail(): Unit = println("tail is wagging")
  def stopTail(): Unit = println("tail is stopped")

trait Runner:
  def startRunning(): Unit = println("I’m running")
  def stopRunning(): Unit = println("Stopped running")

이 트레이트들이 주어졌을 때, 그 트레이트를 모두 확장하면서 추상 메서드인 speak의 동작을 제공하는 Dog 클래스를 볼게요.

class Dog(name: String) extends Speaker with TailWagger with Runner {
  def speak(): String = "Woof!"
}
class Dog(name: String) extends Speaker, TailWagger, Runner:
  def speak(): String = "Woof!"

클래스가 extends 키워드로 트레이트를 확장하는 게 보이죠.

비슷하게, 같은 트레이트들을 구현하면서 상속받은 구체 메서드 두 개도 오버라이드하는 Cat 클래스도 볼게요.

class Cat(name: String) extends Speaker with TailWagger with Runner {
  def speak(): String = "Meow"
  override def startRunning(): Unit = println("Yeah ... I don’t run")
  override def stopRunning(): Unit = println("No need to stop")
}
class Cat(name: String) extends Speaker, TailWagger, Runner:
  def speak(): String = "Meow"
  override def startRunning(): Unit = println("Yeah ... I don’t run")
  override def stopRunning(): Unit = println("No need to stop")

이 예시들은 그 클래스들을 이렇게 사용한다는 걸 보여줘요.

val d = new Dog("Rover")
println(d.speak())      // prints "Woof!"

val c = new Cat("Morris")
println(c.speak())      // "Meow"
c.startRunning()        // "Yeah ... I don’t run"
c.stopRunning()         // "No need to stop"
val d = Dog("Rover")
println(d.speak())      // prints "Woof!"

val c = Cat("Morris")
println(c.speak())      // "Meow"
c.startRunning()        // "Yeah ... I don’t run"
c.stopRunning()         // "No need to stop"

그 코드가 말이 된다면, 트레이트를 인터페이스로 쓰는 데 편안해졌다는 뜻이에요. 아니라면 걱정하지 마세요. 도메인 모델링 장에서 더 자세히 설명할게요.

클래스 (Classes)

Scala 클래스 는 OOP 스타일 프로그래밍에 사용돼요. "사람(person)"을 모델링하는 클래스 예시를 볼게요. OOP에서는 필드가 전형적으로 가변(mutable)이라서, firstNamelastName이 모두 var 매개변수로 선언되어 있어요.

class Person(var firstName: String, var lastName: String) {
  def printFullName() = println(s"$firstName $lastName")
}

val p = new Person("John", "Stephens")
println(p.firstName)   // "John"
p.lastName = "Legend"
p.printFullName()      // "John Legend"
class Person(var firstName: String, var lastName: String):
  def printFullName() = println(s"$firstName $lastName")

val p = Person("John", "Stephens")
println(p.firstName)   // "John"
p.lastName = "Legend"
p.printFullName()      // "John Legend"

클래스 선언이 생성자(constructor)를 만든다는 점을 눈여겨보세요.

// this code uses that constructor
val p = new Person("John", "Stephens")
// this code uses that constructor
val p = Person("John", "Stephens")

생성자와 그 외 클래스 관련 주제는 도메인 모델링 장에서 다룰게요.

FP 도메인 모델링 (FP Domain Modeling)

FP 스타일로 코드를 작성할 때는 이런 개념들을 쓰게 돼요.

  • 데이터를 정의하는 대수적 데이터 타입(Algebraic Data Types)
  • 데이터에 대한 기능을 제공하는 트레이트

열거형과 합 타입 (Enumerations and Sum Types)

합 타입(sum type)은 Scala에서 대수적 데이터 타입(ADT)을 모델링하는 한 가지 방법이에요.

데이터가 서로 다른 선택지로 표현될 수 있을 때 합 타입을 써요.

예를 들어 피자(pizza)는 세 가지 주요 속성이 있어요.

  • 크러스트 크기 (Crust size)
  • 크러스트 종류 (Crust type)
  • 토핑 (Toppings)

이들은 단일 값만 포함하는 합 타입인 열거형(enumeration)으로 간결하게 모델링돼요.

Scala 2에서는 sealed 클래스와 case object를 결합해 열거형을 정의해요.

sealed abstract class CrustSize
object CrustSize {
  case object Small extends CrustSize
  case object Medium extends CrustSize
  case object Large extends CrustSize
}

sealed abstract class CrustType
object CrustType {
  case object Thin extends CrustType
  case object Thick extends CrustType
  case object Regular extends CrustType
}

sealed abstract class Topping
object Topping {
  case object Cheese extends Topping
  case object Pepperoni extends Topping
  case object BlackOlives extends Topping
  case object GreenOlives extends Topping
  case object Onions extends Topping
}

Scala 3은 열거형을 정의하는 enum 구문을 제공해요.

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

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

열거형을 만들면 그 멤버들을 일반 값처럼 import할 수 있어요.

import CrustSize._
val currentCrustSize = Small

// enums in a `match` expression
currentCrustSize match {
  case Small => println("Small crust size")
  case Medium => println("Medium crust size")
  case Large => println("Large crust size")
}

// enums in an `if` statement
if (currentCrustSize == Small) println("Small crust size")
import CrustSize.*
val currentCrustSize = Small

// enums in a `match` expression
currentCrustSize match
  case Small => println("Small crust size")
  case Medium => println("Medium crust size")
  case Large => println("Large crust size")

// enums in an `if` statement
if currentCrustSize == Small then println("Small crust size")

여기 Scala로 합 타입을 만드는 또 다른 예시가 있어요. Succ 케이스가 매개변수를 가지므로 이건 열거형이라고 부르진 않아요.

sealed abstract class Nat
object Nat {
  case object Zero extends Nat
  case class Succ(pred: Nat) extends Nat
}

합 타입은 이 책의 도메인 모델링 절에서 자세히 다뤄요.

enum Nat:
  case Zero
  case Succ(pred: Nat)

열거형은 이 책의 도메인 모델링 절과 레퍼런스 문서에서 자세히 다뤄요.

곱 타입 (Product Types)

곱 타입(product type)은 모양(shape)이 하나뿐인 대수적 데이터 타입(ADT)이에요. 예를 들어 Scala에서 case 객체로 표현되는 싱글턴 객체, 또는 접근 가능한 필드를 가진 불변 구조체로 case 클래스로 표현되는 타입이죠.

case 클래스는 class의 모든 기능을 가지면서, 함수형 프로그래밍에 유용하게 만드는 추가 기능도 내장돼 있어요. 컴파일러가 클래스 앞의 case 키워드를 보면 이런 효과와 이점이 생겨요.

  • 케이스 클래스 생성자 매개변수는 기본적으로 공개(public) val 필드가 되어, 필드가 불변(immutable)이고 각 매개변수에 대한 접근자 메서드가 생성돼요.
  • unapply 메서드가 생성돼서, 케이스 클래스를 match 표현식에서 더 다양한 방식으로 쓸 수 있게 해줘요.
  • 클래스에 copy 메서드가 생성돼요. 이건 원본 객체를 바꾸지 않고 객체의 수정된 복사본을 만드는 방법을 제공해요.
  • 구조적 동등성(structural equality)을 구현하기 위해 equalshashCode 메서드가 생성돼요.
  • 기본 toString 메서드가 생성돼서 디버깅에 도움이 돼요.

이 모든 메서드를 직접 클래스에 하나씩 추가할 수도 있어요. 하지만 이런 기능들은 함수형 프로그래밍에서 너무 흔히 쓰여서, case 클래스를 쓰는 게 훨씬 편리해요.

이 코드는 case 클래스 기능 몇 가지를 보여줘요.

// define a case class
case class Person(
  name: String,
  vocation: String
)

// create an instance of the case class
val p = Person("Reginald Kenneth Dwight", "Singer")

// a good default toString method
p                // : Person = Person(Reginald Kenneth Dwight,Singer)

// can access its fields, which are immutable
p.name           // "Reginald Kenneth Dwight"
p.name = "Joe"   // error: can’t reassign a val field

// when you need to make a change, use the `copy` method
// to “update as you copy”
val p2 = p.copy(name = "Elton John")
p2               // : Person = Person(Elton John,Singer)

case 클래스에 대한 훨씬 더 자세한 내용은 도메인 모델링 절을 참고하세요.

더 알아보기