BackEnd/OOP

[오브젝트] 설계 품질과 트레이드오프 (4장)

샤아이인 2022. 2. 4.

조영호님의 오브젝트 라는 책을 읽으며, 공부 내용을 정리하는 용도로 간략하게 정리해 봅니다.

 

오브젝트: 코드로 이해하는 객체지향 설계

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라! 객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두 번째 걸음은 객체를

wikibook.co.kr

 

객체를 단순한 데이터의 집합으로 바라보는 시각은 객체의 내부 구현을 퍼블릭 인터페이스에 노출시키는 결과를 낳기 때문에 결과적으로 설계가 변경에 취약해 진다.

이러한 문제를 해결하는 가장 좋은 방법은 책임에 초점을 맞추는 것 이다.

 

이번 4장에서는 기존의 영화 예매 시스템을 데이터 중심의 설계로 살펴보고, 객체지향과 어떤 차이점이 있는지 살펴보자.

 

1. 데이터 중심의 영화 시스템

객체지향 설계에서는 2가지 방식으로 시스템을 객체로 분할할 수 있다.

1) 상태(데이터)를 분할의 중심축으로

2) 책임을 분할의 중심축으로

 

데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는데 필요한 오퍼레이션을 정의한다. (상태에 초점)

책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다. (행동에 초점)

 

시스템을 분할하기 위해서는 책임에 초점을 맞춰야 한다.

 

객체의 상태는 구현에 속한다. 구현은 불안정 하기 때문에 변하기 쉽다.

상태를 분할의 중심축으로 보면, 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡술화의 원칙이 무너진다.

 

이에 반해 객체의 책임은 인터페이스에 속한다. 객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는데 필요한 상태를 캡슐화 함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다.

 

데이터를 준비하자

데이터 중심의 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방식이다.

다음 Movie에 저장될 데이터를 결정하는 것으로 시작해보자.

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

    private MovieType movieType;
    private Money discountAmount;
    private double discountPercent;
}

기존과 가장 큰 차이점은 할인 조건의 목록(discountConditions)이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것 이다.

또한 DiscountPolicy라는 별도의 클래스로 분리했던 이전 예제와 달리,

금액 할인 정책에 사용되는 할인금액(discountAmount), 할인비율(discountPercent)을 Movie안에서 직접 정의하고 있다.

 

영화별로 단 하나의 할인정책(DiscountPolicy)를 지정해야 하기 때문에 추가적인 MovieType(열거형)이 필요하다.

 

데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다.

 

이 객체가 포함해야 하는 데이터는 무엇인가?

 

객체의 책임을 결정하기 전에 이런 질문의 반복에 휩쓸려 있다면, 데이터 중심의 설계를 하고있을 확률이 높다.

 

추가적으로 캡슐화를 위해 getter, setter 를 추가해줘야 할 것 이다.

 

데이터 중심의 설계에서 할인 조건을 위해 해야하는 질문은 다음과 같다.

"할인 조건을 구현하는데 필요한 데이터는 무엇인가?" 현재 할인 조건의 종류를 저장할 데이터가 필요할것 이다.

이를 DiscountConditionType으로 만들어 보자.

public enum DiscountConditionType {
    SEQUENCE,
    PERIOD
}

 

할인 조건을 구현하는 DIscountCondition은 할인 조건의 타입을 저장할 인스턴스 변수인 type을 포함한다.

추가적으로 movie와 마찬가지로

1) 순번 조건에서만 사용되는 데이터인 sequence

2) 기간 조건에 사용하는 dayOfWeek

3) 시작 시간 startTime

4) 종료 시간 endTime

을 함께 포함하게 된다.

 

이와 같이 어떤 데이터를 포함해야 하는지를 결정하고 데이터를 캡슐화하기 위해 메서드를 추가하는 방식으로 Screening도 구현하자.

필요한 모든 데이터를 클래스로 구현한 다이어그램은 다음과 같다.

출처 - 오브젝트 106p

ReservationAgency는 데이터 클래스들을 조합헤서 영화 예매 절차를 구현하는 클래스 이다.

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Movie movie = screening.getMovie();

        boolean discountable = false;
        for(DiscountCondition condition : movie.getDiscountConditions()) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                        condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                        condition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
            } else {
                discountable = condition.getSequence() == screening.getSequence();
            }

            if (discountable) {
                break;
            }
        }

        Money fee;
        if (discountable) {
            Money discountAmount = Money.ZERO;
            switch(movie.getMovieType()) {
                case AMOUNT_DISCOUNT:
                    discountAmount = movie.getDiscountAmount();
                    break;
                case PERCENT_DISCOUNT:
                    discountAmount = movie.getFee().times(movie.getDiscountPercent());
                    break;
                case NONE_DISCOUNT:
                    discountAmount = Money.ZERO;
                    break;
            }

            fee = movie.getFee().minus(discountAmount).times(audienceCount);
        } else {
            fee = movie.getFee().times(audienceCount);
        }

        return new Reservation(customer, screening, fee, audienceCount);
    }
}

Discountcondition에 대해 루프를 돌면서 할인 가능 여부를 확인한다.

discountable 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산한다.

 

위 데이터 중심의 설계를 책임 중심의 설계 방법과 비교해 보면서 두 방법의 장단점을 파악해 보자.

 

 

2. 설계 트레이드오프

우선 캡슐화, 응집도, 결합도 라는 3가지 품질 척도의 의미를 살펴보자.

 

캡슐화

객체지향에서는 변경 가능성이 높은 부분은 내부에 숨기고, 외부에는 상대적으로 안정적인 부분을 공개함으로써 변경의 여파를 통제할 수 있다. 이처럼 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이 캡슐화 이다.

 

변경 가능성이 높은 부분을 구현, 상대적으로 안정적인 부분을 인터페이스라고 부른다.

 

객체지향에서 복잡성을 취급하는 주요한 추상화 방법은 캡슐화 이다.

이는 객체지향 언어를 사용한다고 해서 애플리케이션이 자동적으로 잘 캡슐화 되지 않는다.

설계 과정동안 지속적으로 캡슐화를 목표로 인식할때만 달성될 수 있다.

 

응집도와 결합도

응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다.

모듈 내의 요소들이 하나의 목적을 달성하기 위해 협력한다면 그 모듈은 높은 응집도를 갖는다고 할 수 있다.

객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임을 할당했는지를 나타낸다.

 

결합도는 의존성의 정도를 나타내며, 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도다.

어떤 모듈이 다른 모듈에 대해 너무 자세한 내용까지 알고 있다면, 두 모듈은 높은 결합도를 갖는다.

객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.

 

높은 응집도와 낮은 결합도를 가진 설계를 추구해야 하는 이유는 단 한가지다.

설계를 변경하기 쉽게 만들기 때문이다.

 

변경의 관점에서 응집도란 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다.

=> 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 높은 응집도를 갖는다.

 

결합도 역시 변경의 관점에서 보면, 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다.

=> 하나의 모듈을 수정할 때 얼마나 많은 모듈을 함께 수정해야 하는지.

 

3. 데이터 중심의 영화 예메 시스템의 문제점

데이터 중심의 설계가 가진 대표적인 문제점은 다음과 같다.

  • 캡슐화 위반
  • 높은 결합도
  • 낮은 응집도

 

데이터 중심으로 설계한 Movie 클래스를 보면 오직 메서드를 통해서만 객체 내부의 상태에 접근할 수 있다.

예를 들어 fee의 값을 읽거나 수정하기 위해서는 getFee, setFee 메서드를 사용해야 한다.

public class Movie {
    private Money fee;

    public Money getFee() {
        return fee;
    }

    public void setFee(Money fee) {
        this.fee = fee;
    }
}

따라서 위 코드는 캡슐화의 원칙을 지키는 것 처럼 보인다.

 

하지만 getter, setter 는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.

getFee 와 setFee는 Movie 내부에 Money 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.

 

Movie가 캡슐화의 원칙을 어기게 되는 근본적인 원인은 객체가 수행할 책임이 아니라 내부에 저장할 데이터에 초점을 맞췄기 때문이다.

 

설계를 할때 협력에 관해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 되는 경향이 있다.

이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략 이라고 부른다.

이 전략은 객체가 사용될 협력을 생각하지 않고, 다양항 상황에서 사용될 수 있을 것이라는 막연한 추측을 기반으로 한다.

 

높은 결합도

결합도 측면에서 데이터 중심의 설계가 가지는 단점은 여러 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문이다.

=> 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합한다. => 어떤 데이터 객체를 변경해도 제어 객체를 함께 변경해야 함.

 

출처 - 오브젝트 115p

데이터 중심의 영화 시스템을 보면 대부분의 제어 로직을 가지고 있는 ReservationAgency가 모든 데이터 객체에 의존한다는 것을 알 수 있습니다.

만약 DiscountCondition, Screening 중 어떤것을 수정해도 ReservationAgency도 함께 수정해야 한다.

ReservationAgency는 모든 의존성이 모이는 결합의 집결지 이다.

 

낮은 응집도

ReservationAgency를 예로 들어 변경과 응집도 사이의 관계를 살펴보자!

다음과 같은 수정이 발생할 경우 ReservationAgency의 코드를 수정해야 한다.

  • 할인 정책이 추가될 경우
  • 할인 정책별로 할인 요금을 계산하는 방법이 변경될 경우
  • 할인 조건이 추가되는 경우
  • 할인 조건별로 할인 여부를 판단하는 방법이 변결될 경우
  • 예매 요금을 계산하는 방법이 변경될 경우

 

낮은 응집도는 두가지 측면에서 설계에 문제를 일으킨다.

  1. 변경의 원인이 다른 코드들이 하나의 모듈 안에 뭉쳐있어 변경과 아무 상관 없는 코드들까지 영향을 받는다.
    1. 할인 정책을 추가하는 코드가 할인 조건을 판단하는 코드에 영향을 미칠 수 있다.
  2. 하나의 요구사항 변경을 위해 여러 모듈을 동시에 수정해야 한다.
    1. 할인 정책이 추가되면 3개의 모듈이 동시에 변경됨...
      1. MoneyType enum 열거형 값 추가
      2. ReservationAgency Switch에 case 추가
      3. Movie에 새로운 할인 정책 위해 필요한 데이터 추가

 

현재의 설계는 새로운 할인 정책이나 할인 조건을 추가하기 위해 하나 이상의 클래스를 동시에 수정해야 한다.

어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스가 흔들리면 설계의 응집도가 낮다는 증거이다.

출처 - 오브젝트 117p

 

4. 자율적인 객체를 향해

객체에게 의미있는 메서드는 객체가 책임져야 하는 무언가를 수행하는 메서드 이다.

속성의 가시성을 private로 설정했다고 해도 접근자(getter)와 수정자(setter)를 통해 속성을 외부로 제공하고 있다면 캡슐화를 위반하는 것 이다.

 

간단한 사각형 Rectangle Class를 통해 자기 스스로를 책임지는 객체에 대하여 알아보자.

class Rectangle{
    private int left;
    private int top;
    private int right;
    private int bottom;
    
    // 생성자, getter, setter
}

위와 같은 사각형이 있다고 해보자.

 

위 사각형의 너비, 높이를 증가시키는 코드는 다음과 같을것 이다. Rectangle 외부의 어떠한 클래스에 구현되어 있다.

class AnyClass{
    void anyMethod(Rectangle rectangle, int multiple){
    	rectangle.setRight(rectangle.getRight() * multiple)
        rectangle.setBottom(rectangle.getBottom() * multiple)
    }
}

위 코드는 2가지 큰 문제점이 있다.

1) 중복 코드가 발생할 확률이 높다. 다른곳에서도 사각형의 너비와 높이 변경을 위해 getter, setter를 사용하게 될 것 이다.

2) 변경에 취약하다.

 

예를들어 Rectangle의 right, bottom 을 length, height 로 변경한다고 해보자.

getter, setter는 내부 구현을 인터페이스의 일부로 만들기 때문에 현재의 Rectangle 클래스에는 int형 top, left, right, bottom 이라는 4가지 인스턴스 변수가 있음을 외부에 노출시키게 된다.

 

결과적으로 getRight, setRight, getBottom, setBottom을 getHeight, setHeight, getLength, setLength 로 변경해야 하고,

이 변경은 기존의 getter 메서드를 사용하던 anyMethod 와 같은 곳에 영향을 끼친다.

 

이는 캡슐화를 강화시킴으로써 극복 가능하다. 너비와 높이를 조절하는 로직을 Rectangle 내부에 캡슐화 시키자.

class Rectangle{
   public void enlarge(int multiple){
       right *= multiple;
       bottom *= multiple; 
   }
}

위 코드는 Rectangle의 상태를 변경하는 주체를 외부에서 Rectangle 내부로 이동시켰다.

자신의 크기를 스스로 조절하도록 '책임을 이동' 시킨 것 이다. 이것이 바로 객체가 자기 스스로 채임진다는 의미이다.

 

이런 식으로 객체가 자기스스로를 책임지도록 코드를 변경하면 우리의 영화 예매 시스템은 다음과 같아진다.

출처 - 오브젝트 125p

최소한 결합도의 측면에서 ReservationAgency에 의존성이 몰려있던 첫 설계보다는 개선되었다.

첫번째 설계보다는 내부 구현을 더 면밀하게 캡슐화 하고있기 때문이다.

두번째 설계에서는 데이터를 처리하는데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현하고 있다.

 

5. 하지만 여전히 부족하다

아직도 데이터 중심의 설계이다.

캡슐화 위반

DiscountCondition은 자신 상태를 스스로 관리하는 자율적인 객체인것 처럼 보인다.

하지만 DiscountCondition에 구현된 isDiscountable 메서드를 살펴보면 이상한점이 있다.

 

  • DiscountCondition의 isDiscountable(DayOfWeek, LocalTime)은 내부 인스턴스 변수의 타입을 인터페이스로 외부에 노출함
    • 이 메서드는 객체 내부에 DayOfWeek, LocalTime 정보가 인스턴스 변수로 포함돼 있음을 노출시킨다.
    • setType 메서드는 없지만, getType 메서드를 통해 내부에 DiscountConditionType 인스턴스 변수가 있음을 노출한다.

 

만약 DiscountCondition의 속성을 변경해야 한다면 어떻게 될까?

아마 두 isDiscountable 메서드의 파라미터를 수정하고, 해당 메서드를 사용하는 모든 Client를 수정해야 할 것 이다.

=> 내부 구현의 변경이 외부로 퍼져나가는 파급효과(ripple effect)는 캡슐화 위반의 증거이다.

 

  • Movie는 할인 정책의 종류를 인터페이스에 노출시키고 있음
    • 이번에는 메서드의 시그니처가 아닌, 메서드의 이름을 통해 3가지 할인 정책이 존재한다는 사실을 노출한다.
    • calculateAmountDiscountedFee, calculatePercentDIscountedFee, calculateNoneDiscountedFee
    • 3개의 메서드는 할인 정책에 금액할인, 비율할인, 미적용 총 3가지가 존재한다는 사실을 노출함

캡슐화란 변경될 수 있는 내부의 어떤것 이라도 감추는것을 의미한다.

 

높은 결합도

캡슐화 위반을 한 DiscountCondition의 내부 구현이 외부로 노출되었기 때문에 Movie와의 결합도가 높다.

 

DiscountCondition의 인터페이스 가 아닌, 내부 구현을 변경하는 경우에도 DiscountCondition에 의존하는 Movie를 변경해야 한다.

 

모든 문제의 근원을 캡슐화 원칙을 지키지 않았기 때문이다. 캡슐화를 설계의 첫 번째 목표로 삼아야 한다.

 

낮은 응집도

Screening을 살펴보자.

DiscountCondition이 할인 여부를 판단하는데 필요한 정보가 변경되면, Movie의 isDiscountable 메서드로 전달되는 인자의 종류를 변경해야 하고, 이로 인해 Screening에서 Movie.isDiscountable()을 호출할때 넘기는 인자도 바뀌게 된다.

 

DiscountCondition에 새롭게 나이할인 추가 => Movie.isDiscountable 메서드 인자로 나이를 추가 => Movie를 사용하는 Screening의 isDiscountable 메서드의 인자로 나이 추가

 

하나의 변경을 수용하기 위해 여러 곳을 동시에 변경해야 한다면 설계의 응집도가 낮다는 증거이다.

 

 

6. 데이터 중심 설계의 문제점

데이터 중심의 설계가 변경에 취약한 이유는 2가지 이다.

1) 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.

2) 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.

데이터 중심 설계는 행동보다 상태에 초점을 맞춘다

"이 객체가 포함하는 데이터가 무엇인가?" 는 데이터 중심 설계의 첫 질문이였다.

사실 데이터는 구현의 일부일 뿐이다.

 

데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체(구조체)일 뿐이다. 이로 인해 getter, setter가 과도하게 추가된다.

=> 이 데이터를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다.

 

데이터 중심의 설계는 내부구현이 객체의 인터페이스로 노출되고, 변경에 취약해 진다.

너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패하게 되는 것 이다.

 

데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다.

객체지향은 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 적절한 객체를 결정하는 것이 가장 중요하다.

올바른 객체지항 설계의 무게중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다.

 

하지만 데이터 중심 설계는 초점이 외부가 아니라 내부로 향한다.

객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스에 억지로 끼워 맞추게 된다.

 

객체가 내부에 어떤 상태이고, 그 상태를 어떻게 관리하는 지는 부가적인 문제이다.

핵심은 객체가 다른 객체와 협력하는 방법이다.

댓글