4. 설계 품질과 트레이드오프

  • 객체지향 설계는 상태(데이터), 책임 으로 분할의 중심축을 삶는 두 가지 방법이 존재

    • 훌륭한 설계는 데이터가 아니라 책임에 초점을 맞춰야 한다.
    • 상태는 구현에 속한다.
    • 구현은 불안정하기 때문에 변하기 쉽다.
    • 책임은 인터페이스에 속한다.
    • 상태를 캡슐화함으로써, 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다.
  • 이번 장(ch4) 는 차이를 느끼기 위해 일부러, 데이터의 중심으로 설계해보자

데이터를 준비하자

  • 가장 큰 차이는 discountConditions 이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125

class Screening(private val movie: Movie, private val sequence: Int, private val whenScreened: LocalDateTime) {
    fun getStartTime() = whenScreened
    fun isSequence(sequence: Int) = this.sequence == sequence
    fun getMovieFee() = movie.fee


    fun calculateFee(audienceCount: Int): Money {
        when (movie.movieType) {
            MovieType.AMOUNT_DISCOUNT -> if (movie.isDiscountable(whenScreened, sequence))
                return movie.calculateAmountDiscountedFee().times(audienceCount.toDouble())

            MovieType.PERCENT_DISCOUNT -> if (movie.isDiscountable(whenScreened, sequence))
                return movie.calculatePercentDiscountedFee().times(audienceCount.toDouble())

            MovieType.NONE_DISCOUNT -> return movie.calculateNoneDiscountedFee().times(audienceCount.toDouble())
        }
        return movie.calculateNoneDiscountedFee().times(audienceCount.toDouble())
    }
}

class Money(private val amount: BigDecimal) {
    companion object {
        val ZERO = wons(0)

        fun wons(amount: Long) = Money(BigDecimal.valueOf(amount))
        fun wons(amount: Double) = Money(BigDecimal.valueOf(amount))
    }

    fun plus(amount: Money) = Money(this.amount.add(amount.amount))
    fun minus(amount: Money) = Money(this.amount.subtract(amount.amount))
    fun times(percent: Double) = Money(this.amount.multiply(BigDecimal.valueOf(percent)))
    fun isLessThan(other: Money) = amount < other.amount
    fun isGreaterThan(other: Money) = amount >= other.amount
}


class DisCountCondition(
    val discountConditionType: DiscountConditionType,
    val sequence: Int,
    val dayOfWeek: DayOfWeek,
    val startTime: LocalTime,
    val endTime: LocalTime

) {
    fun isDiscountable(dayOfWeek: DayOfWeek, time: LocalTime): Boolean {
        if (discountConditionType != DiscountConditionType.PERIOD) {
            throw IllegalArgumentException()
        }
        return this.dayOfWeek == dayOfWeek && startTime <= time && endTime >= time
    }

    fun isDiscountable(sequence: Int): Boolean {
        if (discountConditionType != DiscountConditionType.SEQUENCE) {
            throw IllegalArgumentException()
        }
        return this.sequence == sequence
    }
}

enum class MovieType {
    AMOUNT_DISCOUNT, PERCENT_DISCOUNT, NONE_DISCOUNT
}

enum class DiscountConditionType {
    SEQUENCE, PERIOD
}


data class Movie(
    val title: String,
    val runningTime: Duration,
    val fee: Money,
    val discountConditions: List<DisCountCondition>,
    val movieType: MovieType,
    val discountAmount: Money,
    val discountPercent: Double,
) {
    fun calculateAmountDiscountedFee(): Money {
        if (movieType != MovieType.AMOUNT_DISCOUNT) {
            throw IllegalArgumentException()
        }
        return fee.minus(discountAmount)
    }

    fun calculatePercentDiscountedFee(): Money {
        if (movieType != MovieType.PERCENT_DISCOUNT) {
            throw IllegalArgumentException()
        }
        return fee.minus(fee.times(discountPercent))
    }

    fun calculateNoneDiscountedFee(): Money {
        if (movieType != MovieType.NONE_DISCOUNT) {
            throw IllegalArgumentException()
        }
        return fee
    }

    fun isDiscountable(whenScreened: LocalDateTime, sequence: Int): Boolean {
        for (condition in discountConditions) {
            if (condition.discountConditionType == DiscountConditionType.PERIOD) {
                if (condition.isDiscountable(whenScreened.dayOfWeek, whenScreened.toLocalTime())) {
                    return true
                }
            } else if (condition.isDiscountable(sequence)) {
                return true
            }
        }
        return false
    }
}

data class Reservation(
    val customer: Customer, val screening: Screening, val fee: Money, val audienceCount: Int
)

class ReservationAgency {
    fun reserve(screening: Screening, customer: Customer, audienceCount: Int): Reservation {
        val fee = screening.calculateFee(audienceCount)
        return Reservation(customer, screening, fee, audienceCount)
    }
}

class Customer()

설계 트레이드오프

캡슐화

  • 상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서이다.

    • 구현: 변경될 가능성이 높은 것
    • 인터페이스: 상대적으로 안정적인 부분, 변경 가능성이 적은 부분
  • 객체지향 설계의 가장 중요한 원리: 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화 하는 것

  • 복잡성 => 추상화 => 캡슐화

  • 캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법

    • 변경 될 수 있는 어떤 것이라도 캡슐화 해야 한다
  • 유지보수성 이란

    • 두려움, 주저함, 저항감 없이 코드를 변경할 수 있는 능력
    • 이때, 캡슐화가 가장 중요한 동료
  • 캡슐화를 위반하는 경우 과도한 접근자와 수정자를 가지게 된다.

    • 접근자, 수정자 => 자바의 public get~, public set~ 함수

    • 사용될 문맥을 추측 할 수밖에 없는 경우 개발자는 어떤 상황에서도 해당 객체가 사용될 수 있게 많은 접근자 메서드를 추가하게 됨

    • 접근자와 수정자에 과도하게 의존하는 설계 방식: 추측에 의한 설계(design-by-guessing strategy)

    • 내부 상태를 드러내는 메서드를 최대한 많이 추가할 수 밖에 없어짐, 내부 구현이 퍼블릭 인터페이스에 그대로 노출됨

응집도와 결합도

  • 높은 응집도와 낮은 결합도를 추구해야 하는 이유는 설계를 변경하기 쉽게 만들기 위함

  • 결합도가 높아도 상관 없는 경우 변경될 확률이 매우 적은 안정적인 모듈에 의존하는 것

    • 표준 라이브러리에 포함된 모듈, Ex) String, List
    • 성숙 단계에 포함된 프레임워크
  • 응집도는 모듈에 포함된 내부 요소들이 연관돼 잇는 정도

    • 객체지향 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임을 할당했는지를 나타낸다
    • 응집도는 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정 가능하다
      • 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 응집도가 높은것
      • 하나의 변경에 대해 다수의 모듈이 함께 변경돼야 한다면 응집도가 낮은것
  • 결합도는 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도

    • 의존성의 정도를 나타냄
    • 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정 가능하다
    • 내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우 두 모듈 사이의 결합도가 높다고 한다
  • 데이터 중심 설계에서는 시스템의 어떤 변경도 ReseravtionAgency의 변경을 유발함, 높은 결합도

  • 많은 요구사항 수정사항에 대해 ReseravtionAgency 을 변경해야 함

    • 할인 정책 추가
    • 할인 조건 추가
    • 할인 요금 계산 방법 변경
    • 예매 요금 계산 방법 변경
  • 로버트 마틴의, 단일 책임 원칙 (SRP)

    • 클래스는 단 한가지의 변경 이유만 가져야 한다는 것
    • 주의 할점
      • 단일 ‘책임’ 원칙 에서의 ‘책임’은 ‘변경의 이유’ 라는 의미로 사용된다
      • 역할, ‘책임’, 협력에서 이야기하는 ‘책임’과는 다르며 변경과 관련된 더 큰 개념을 가리킴

자율적인 객체를 향해

캡슐화를 지켜라

  • Rectangle 클래스는 Int 타입top, left, right, bootm 이라는 인스턴스 변수의 존재 사실을 인터페이스를 통해 외부에 노출시킴
  • 접근자와 수정자는 내부 구현을 인터페이스의 일부로 만들기 때문이다.
  • 결과적으로 right, bottom 대신, length, height를 이용해서 사각형을 표현하도록 수정한다면, 기존의 접근자 메서드(getter, setter) 를 사용하던 모든 코드에 영향을 미침
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Rectangle_Bad(var left: Int, var top: Int, var right: Int, var bottom: Int) {
    // 캡슐화 X
}

class Rectangle_Good(var left: Int, var top: Int, var right: Int, var bottom: Int) { // 캡슐화 O
    // 너비와 높이를 조절하는 로직을 캡슐화하여 Rectangle 스스로 증가시키도록, '책임을 이동'
    fun enlarge(multiple: Int) {
        right *= multiple
        bottom *= multiple
    }
}

class AnyClass() {
    fun anyMethodForBad(rectangle: Rectangle_Bad, multiple: Int) {
        rectangle.right *= multiple
        rectangle.bottom *= multiple
    }

    fun anyMethodForGood(rectangle: Rectangle_Good, multiple: Int) {
        rectangle.enlarge(multiple)
    }
}

스스로 자신의 데이터를 책임지는 객체

  • 객체를 설계할 때 데이터에 대해 두 가지 질문이 필요
    1. 이 객체가 어떤 데이터를 포함해야 하는가?
    2. 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?

캡슐화 위반

  • DiscountConditionisDiscountable 함수의 파라미터를 보면, 인스턴스 변수로 DayOfWeek 타입의 변수가 있는 것

    • 시간 정보가 인스턴스 변수로 포함돼 있다느 사실을 외부에 노출함
  • DiscountCondition 의 속성을 변경하면, isDiscountable 함수와 이를 사용하는 클라이어도 함께 수정되어야 함

    • 내부 구현의 변경이 외부로 퍼져나가는 파급 효과(ripple effect) 는 캡슐화가 부족하다는 증거
  • Movie의 내부 구현을 인터페이스에 노출시키고 있다.

    • calculateAmountDiscountedFee,calculatePercentDiscountedFee, calculateNoneDiscountedFee
    • 이 함수들은 할인 정책에는 금액, 비율, 미적용 세 가지가 존재한다는 사실을 만천하에 드러낸다.
  • 캡슐화는 단순히 객체 내부의 데이터를 외부로 부터 감추는 것 이상의 의미를 가진다

    • 캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것
    • 속성의 타입이건, 할인 정책의 종류건 상관 없이 내부 구현의 변경으로 인해 외부의 객체가 영향을 받는 다면 캡슐화를 위반한 것
    • 설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화해야 한다.

높은 결합도, 낮은 응집도

  • DiscountCondition의 구현을 변경이, 이를 의존하는 Movie 변경으로 이어진다.
  • Screening에서 MovieisDiscountable 메서드를 호출하는 부분도 함께 변경해야 한다.
  • 모두 캡슐화를 위반했기 때문이다.

데이터 중심 설계의 문제점

  • 변경에 취약

    1. 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요
    2. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.
  • 처음부터 데이터에 관해 결정하도록 강요하기에 너무 이른 시기에 내부 구현에 초점을 맞추게 됨

    • 데이터는 구현의 일부이다.

Reference