OOP 모델링

OOP 모델링 (Domain Modeling with OOP)

도메인 모델링은 프로그램의 영역(도메인)을 코드로 표현하는 작업이에요. 이번 챕터에서는 스칼라 3에서 객체지향 프로그래밍(OOP)으로 도메인 모델링을 하는 방법을 소개할게요. 스칼라는 객체지향 설계에 필요한 도구를 모두 갖추고 있어서, 그 도구들이 어떻게 쓰이는지 하나씩 살펴볼게요.

출처: Scala 3 Book

본문

소개

스칼라는 객체지향 설계에 필요한 모든 도구를 제공해요.

  • 트레이트(Traits) — (추상) 인터페이스뿐 아니라 구체적인 구현까지 명시할 수 있게 해 줘요.
  • 믹스인 합성(Mixin Composition) — 더 작은 부품들로 구성 요소를 조립하는 도구를 제공해요.
  • 클래스(Classes) — 트레이트가 명시한 인터페이스를 구현할 수 있어요.
  • 클래스의 인스턴스는 각자 자신만의 비공개 상태(private state) 를 가질 수 있어요.
  • 서브타이핑(Subtyping) — 슈퍼클래스의 인스턴스가 필요한 자리에 한 클래스의 인스턴스를 쓸 수 있게 해 줘요.
  • 접근 수정자(Access modifiers) — 클래스의 어떤 멤버를 코드의 어느 부분이 접근할 수 있는지 제어하게 해 줘요.

트레이트

Java처럼 OOP를 지원하는 다른 언어들과 달리, 스칼라에서 분해(decomposition)의 기본 도구는 클래스가 아니라 트레이트예요. 트레이트는 이렇게 추상 인터페이스를 기술할 수 있어요.

Scala 2 버전

trait Showable {
  def show: String
}

Scala 3 버전

trait Showable:
  def show: String

그리고 구체적인 구현도 담을 수 있어요.

Scala 2 버전

trait Showable {
  def show: String
  def showHtml = "<p>" + show + "</p>"
}

Scala 3 버전

trait Showable:
  def show: String
  def showHtml = "<p>" + show + "</p>"

여기서 우리가 showHtml 메서드를 추상 메서드 show로부터 정의한다는 점을 볼 수 있어요.

Odersky와 Zenger는 서비스 지향 컴포넌트 모델에서 뷰(view)를 이렇게 제시해요.

  • 추상 멤버는 요구되는 서비스로 본다 — 이것들은 여전히 서브클래스가 구현해야 해요.
  • 구체 멤버는 제공되는 서비스로 본다 — 이것들은 서브클래스에 제공돼요.

이건 Showable 예제에서 이미 볼 수 있어요. Showable을 상속하는 Document 클래스를 정의하면, 우리는 show는 여전히 정의해야 하지만 showHtml은 제공받아요.

Scala 2 버전

class Document(text: String) extends Showable {
  def show = text
}

Scala 3 버전

class Document(text: String) extends Showable:
  def show = text

추상 멤버 (Abstract Members)

트레이트에서 추상으로 남길 수 있는 건 추상 메서드만이 아니에요. 트레이트는 이렇게 여러 가지를 담을 수 있어요.

  • 추상 메서드 (def m(): T)
  • 추상 값 정의 (val x: T)
  • 추상 타입 멤버 (type T), 그리고 경계를 가질 수도 있어요 (type T <: S)
  • 추상 given (given t: T) — Scala 3 전용

위의 각 기능들은 트레이트의 구현자에게 어떤 형태의 요구사항을 명시하는 데 사용될 수 있어요.

믹스인 합성 (Mixin Composition)

트레이트는 추상 정의와 구체 정의를 모두 담을 수 있을 뿐 아니라, 여러 트레이트를 합성하는 강력한 방법도 제공해요. 이 기능을 흔히 믹스인 합성이라고 불러요.

다음 두 개의 (각자 독립적으로 정의된) 트레이트가 있다고 가정해 볼게요.

Scala 2 버전

trait GreetingService {
  def translate(text: String): String
  def sayHello = translate("Hello")
}

trait TranslationService {
  def translate(text: String): String = "..."
}

Scala 3 버전

trait GreetingService:
  def translate(text: String): String
  def sayHello = translate("Hello")

trait TranslationService:
  def translate(text: String): String = "..."

두 서비스를 합성하려면, 그저 그것들을 상속하는 새 트레이트를 만들면 돼요.

Scala 2 버전

trait ComposedService extends GreetingService with TranslationService

Scala 3 버전

trait ComposedService extends GreetingService, TranslationService

한 트레이트의 추상 멤버(예: GreetingServicetranslate)는 다른 트레이트의 구체 멤버와 자동으로 매칭돼요. 이는 이 예제의 메서드뿐 아니라, 앞에서 언급한 다른 모든 추상 멤버(타입, 값 정의 등)에도 동일하게 적용돼요.

클래스

트레이트는 컴포넌트를 모듈화하고 인터페이스(요구되는 것과 제공되는 것)를 기술하는 데 훌륭해요. 하지만 어떤 시점에는 그것들의 인스턴스를 만들어야 해요. 스칼라에서 소프트웨어를 설계할 때는, 상속 모델의 **잎(leaf)**에만 클래스를 쓰는 편이 도움이 되는 경우가 많아요.

      Traits
      T1, T2, T3

      Composed traits
      S1 extends T1 with T2, S2 extends T2 with T3

      Classes
      C extends S1 with T3

      Instances
      new C()

이런 경향은 Scala 3에서 더 커져요. 이제 트레이트도 파라미터를 받을 수 있게 되면서 클래스의 필요성이 더 줄어들었거든요.

클래스 정의하기 (Defining Classes)

트레이트처럼 클래스도 여러 트레이트(단, 슈퍼클래스는 하나뿐)를 상속할 수 있어요.

Scala 2 버전

class MyService(name: String) extends ComposedService with Showable {
  def show = s"$name says $sayHello"
}

Scala 3 버전

class MyService(name: String) extends ComposedService, Showable:
  def show = s"$name says $sayHello"

서브타이핑 (Subtyping)

MyService의 인스턴스를 이렇게 만들 수 있어요.

Scala 2 버전

val s1: MyService = new MyService("Service 1")

Scala 3 버전

val s1: MyService = MyService("Service 1")

서브타이핑을 통해, 우리의 인스턴스 s1은 상속한 트레이트 중 어떤 것이든 요구되는 모든 자리에서 사용될 수 있어요.

val s2: GreetingService = s1
val s3: TranslationService = s1
val s4: Showable = s1
// ... and so on ...

확장 계획하기 (Planning for Extension)

앞서 언급했듯이, 다른 클래스를 상속하는 것도 가능해요.

class Person(name: String)
class SoftwareDeveloper(name: String, favoriteLang: String)
  extends Person(name)

하지만 트레이트가 분해의 기본 수단으로 설계되었기 때문에, 한 파일에서 정의된 클래스를 다른 파일에서 상속하는 것은 권장되지 않아요.

열린(Open) 클래스 — Scala 3 전용

Scala 3에서는 다른 파일의 비추상 클래스를 상속하는 것이 제한돼요. 이를 허용하려면 기본 클래스를 open으로 표시해야 해요.

Scala 3 전용

open class Person(name: String)

클래스를 open으로 표시하는 것은 Scala 3의 새 기능이에요. 클래스를 명시적으로 open으로 표시해야 하면 객체지향 설계에서 흔한 함정을 많이 피할 수 있게 돼요. 특히, 라이브러리 설계자들이 확장을 위해 명시적으로 계획해야 하고, 예를 들어 open으로 표시한 클래스에 추가적인 확장 계약(extension contract)을 문서화하게 돼요.

인스턴스와 비공개 가변 상태 (Instances and Private Mutable State)

OOP를 지원하는 다른 언어들과 마찬가지로, 스칼라의 트레이트와 클래스도 가변 필드를 정의할 수 있어요.

Scala 2 버전

class Counter {
  // can only be observed by the method `count`
  private var currentCount = 0

  def tick(): Unit = currentCount += 1
  def count: Int = currentCount
}

Scala 3 버전

class Counter:
  // can only be observed by the method `count`
  private var currentCount = 0

  def tick(): Unit = currentCount += 1
  def count: Int = currentCount

Counter 클래스의 각 인스턴스는 count 메서드를 통해서만 관찰할 수 있는 자신만의 비공개 상태를 가져요. 다음 상호작용이 그걸 보여줘요.

Scala 2 버전

val c1 = new Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2

Scala 3 버전

val c1 = Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2

접근 수정자 (Access Modifiers)

기본적으로 스칼라의 모든 멤버 정의는 공개(public)로 보여요. 구현 세부사항을 숨기기 위해, 멤버(메서드, 필드, 타입 등)를 privateprotected로 정의할 수 있어요. 이렇게 하면 그것들이 접근되거나 오버라이드되는 방식을 제어할 수 있어요.

  • private 멤버는 클래스/트레이트 자신과 그 컴패니언 object에서만 보여요.
  • protected 멤버는 클래스의 서브클래스에서도 보여요.

고급 예제: 서비스 지향 설계 (Service Oriented Design)

이제 스칼라의 고급 기능 몇 가지를 살펴보고, 그것들이 더 큰 소프트웨어 컴포넌트를 구성하는 데 어떻게 쓰이는지 보여드릴게요. 이 예제들은 Martin Odersky와 Matthias Zenger의 논문 "Scalable Component Abstractions"에서 가져온 거예요. 예제의 모든 세부사항을 이해하지 못해도 괜찮아요 — 주로 더 큰 컴포넌트를 구성하기 위해 여러 타입 기능을 어떻게 쓰는지 보여주기 위한 것이니까요.

우리의 목표는, 컴포넌트의 구현에서 나중에 정제(refine)될 수 있는 타입 패밀리를 가진 소프트웨어 컴포넌트를 정의하는 거예요. 구체적으로, 다음 코드는 SubjectObserver 컴포넌트를 두 개의 추상 타입 멤버 S(subject용)와 O(observer용)를 가진 트레이트로 정의해요.

Scala 2 버전

trait SubjectObserver {

  type S <: Subject
  type O <: Observer

  trait Subject { self: S =>
    private var observers: List[O] = List()
    def subscribe(obs: O): Unit = {
      observers = obs :: observers
    }
    def publish() = {
      for ( obs <- observers ) obs.notify(this)
    }
  }

  trait Observer {
    def notify(sub: S): Unit
  }
}

Scala 3 버전

trait SubjectObserver:

  type S <: Subject
  type O <: Observer

  trait Subject:
    self: S =>
      private var observers: List[O] = List()
      def subscribe(obs: O): Unit =
        observers = obs :: observers
      def publish() =
        for obs <- observers do obs.notify(this)

  trait Observer:
    def notify(sub: S): Unit

설명이 필요한 몇 가지가 있어요.

추상 타입 멤버 (Abstract Type Members)

type S <: Subject 선언은, SubjectObserver 트레이트 안에서 우리가 S라고 부르는 어떤 미지의(즉, 추상) 타입을 참조할 수 있다는 뜻이에요. 하지만 그 타입이 완전히 미지인 건 아니에요 — 적어도 Subject 트레이트의 어떤 서브타입이라는 건 알고 있죠. SubjectObserver를 상속하는 모든 트레이트와 클래스는, 선택한 타입이 Subject의 서브타입이기만 하면 S에 어떤 타입이든 골라 쓸 수 있어요. 선언의 <: Subject 부분은 S의 **상한(upper bound)**이라고도 불러요.

중첩 트레이트 (Nested Traits)

SubjectObserver 트레이트 안에 트레이트 두 개를 더 정의해요. 먼저 Observer 트레이트는 S 타입의 인자를 받는 추상 메서드 notify 하나만 정의해요. 잠시 후에 보게 되겠지만, 인자가 Subject 타입이 아니라 S 타입이라는 게 중요해요.

두 번째 트레이트 Subject는, 이 특정 subject에 구독한 모든 observer를 저장하기 위한 비공개 필드 observers 하나를 정의해요. subject에 구독하는 것은 단지 이 리스트에 object를 저장하는 것뿐이에요. 여기서도 obs 파라미터의 타입은 Observer가 아니라 O예요.

셀프 타입 어노테이션 (Self-type Annotations)

마지막으로, trait Subjectself: S =>가 뭘 뜻하는지 궁금했을 수도 있어요. 이를 셀프 타입 어노테이션(self-type annotation)이라고 불러요. 이것은 Subject의 서브타입이 S의 서브타입이기도 해야 한다는 요구사항이에요. obs.notifythis를 인자로 호출할 수 있으려면 S 타입의 값이 필요하기 때문에, 이게 필요해요. 만약 S가 구체적인 타입이라면, 셀프 타입 어노테이션은 trait Subject extends S로 대체할 수 있을 거예요.

컴포넌트 구현하기 (Implementing the Component)

이제 위 컴포넌트를 구현하고, 추상 타입 멤버를 구체적인 타입으로 정의할 수 있어요.

Scala 2 버전

object SensorReader extends SubjectObserver {
  type S = Sensor
  type O = Display

  class Sensor(val label: String) extends Subject {
    private var currentValue = 0.0
    def value = currentValue
    def changeValue(v: Double) = {
      currentValue = v
      publish()
    }
  }

  class Display extends Observer {
    def notify(sub: Sensor) =
      println(s"${sub.label} has value ${sub.value}")
  }
}

Scala 3 버전

object SensorReader extends SubjectObserver:
  type S = Sensor
  type O = Display

  class Sensor(val label: String) extends Subject:
    private var currentValue = 0.0
    def value = currentValue
    def changeValue(v: Double) =
      currentValue = v
      publish()

  class Display extends Observer:
    def notify(sub: Sensor) =
      println(s"${sub.label} has value ${sub.value}")

구체적으로, SubjectObserver를 상속하는 싱글턴 object SensorReader를 정의해요. SensorReader의 구현에서 우리는 타입 S를 이제 Sensor 타입으로 정의하고, 타입 ODisplay 타입과 동일하게 정의한다고 말해요. SensorDisplay 둘 다 SensorReader 안에 중첩 클래스로 정의되며, 각각 트레이트 SubjectObserver를 구현해요.

서비스 지향 설계의 예시일 뿐 아니라, 이 코드는 객체지향 프로그래밍의 여러 측면을 보여주기도 해요.

  • Sensor 클래스는 자신만의 비공개 상태(currentValue)를 도입하고, 상태의 수정을 changeValue 메서드 뒤에 캡슐화해요.
  • changeValue의 구현은 상속한 트레이트에서 정의된 publish 메서드를 사용해요.
  • Display 클래스는 트레이트 Observer를 상속하고, 빠진 메서드 notify를 구현해요.
  • 중요한 점은, 우리가 원래 파라미터를 S 타입으로 선언했기 때문에, notify의 구현이 sublabelvalue에만 안전하게 접근할 수 있다는 거예요.

컴포넌트 사용하기 (Using the Component)

마지막으로, 다음 코드는 SensorReader 컴포넌트를 어떻게 사용하는지 보여줘요.

Scala 2 버전

import SensorReader._

// setting up a network
val s1 = new Sensor("sensor1")
val s2 = new Sensor("sensor2")
val d1 = new Display()
val d2 = new Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)

// propagating updates through the network
s1.changeValue(2)
s2.changeValue(3)

// prints:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0

Scala 3 버전

import SensorReader.*

// setting up a network
val s1 = Sensor("sensor1")
val s2 = Sensor("sensor2")
val d1 = Display()
val d2 = Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)

// propagating updates through the network
s1.changeValue(2)
s2.changeValue(3)

// prints:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0

객체지향 프로그래밍의 도구들을 모두 살펴봤으니, 다음 절에서는 함수형 스타일로 프로그램을 설계하는 방법을 보여드릴게요.

더 알아보기 (Learn more)