본문 바로가기
그룹 스터디 공부(IT 서적)/오브젝트

04 설계 품질과 트레이드 오프

by hanyugyeong 2023. 7. 31.
반응형
SMALL

객체지향 설계의 핵심은 역할, 책임, 협력이다.

협력: 애플리케이션의 기능을 구현하기 위해 메시지를 주고받는 객체들 사이의 상호작용이다. 

책임: 객체가 다른 객체와 협력하기 위해 수행하는 행동

역할: 대체 가능한 책임의 집합이다.

이 중에서도 가장 중요한 것이 '책임'이다. 책임은 객체지향 애플리케이션의 품질을 결정한다.

 

객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 것이다. 즉, 객체지향 설계의 핵심이 책임이고 책임을 할당하는 과정이 응집도, 결합도와 연관되어있다.

 

설계라는 것은 변경을 위해 존재하고 변경 시에는 반드시 비용이 발생한다. 훌륭한 설계란 적절한 비용 안에서 쉽게 변경할 수 있는 응집도가 높고 서로 느슨하게 결합된 요소로 구성된 설계이다.

 

그렇다면 응집도를 높이기 위해선 어떡하면 되느냐?

 

객체의 상태가 아니라 객체의 행동에 초점을 두면 된다. 다시 말해 객체의 책임에 초점을 두는 것이다. (이는 데이터 중심 설계와 반대된다.)

 

01 데이터 중심의 영화 예매 시스템

객체지향 설계에서는 두 가지 방법을 이용해 시스템을 객체로 분할할 수 있다. 

1) 상태를 분할의 중심축으로 삼는 방법

2) 책임을 분할의 중심축으로 삼는 방법 

 

휼륭한 객체지향 설계는 데이터가 아니라 책임에 초점을 맞춰야 한다. 이유는 변경과 관련이 있다. 

 

설계가 필요한 이유는 요구사항이 변경되기 때문이다. 그런데 데이터 중심의 설계는 변경에 취약하다. 객체의 상태(=데이터)는 구현에 속한다. 그런데 구현은 불안정하기 때문에 변하기 쉽다. 이러한 상태의 변경은 인터페이스의 변경을 초래하고 해당 인터페이스에 의존하는 모든 객체에 영향이 퍼지게 된다. 따라서 데이터 중심의 설계는 변경에 취약하다.

 

반면에, 책임 주도 설계는 상대적으로 변경에 안정적인 설계를 얻을 수 있다. 책임은 인터페이스에 속한다. 안정적인 인터페이스 뒤에 상태를 캡슐화함으로써 구현 변경에 대한 파급효과를 방지한다. 따라서 책임 주도 설계는 변경에 안정적이다.

 

데이터를 준비하자 

데이터 중심의 설계는 객체가 내부에 저장해야 하는 '데이터가 무엇인가'부터 시작된다. 그러다 보니 Movie 안에 직접 정의되는 차이들이 발생했다. 데이터 중심 설계에서는 이처럼 객체의 종류를 저장하는 인스턴스 변수와 인스턴스 종류에 따라 배타적으로 사용될 인스턴스 변수를 하나의 클래스에 포함시키는 방식이 사용된다.

import java.util.List;

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; // 할인 조건 직접 정의
}

 movieType의 값이 AMOUNT_DISCOUNT라면 discountAmount에 저장된 값을 사용하고 

PERCENT_DISCOUNT라면 discountPercent에 저장된 값을 사용한다. 

NONE_DISCOUNT인 경우에는 할인 정책을 적용하지 말아야 하기 때문에 discountAmount와 discountPercent 중 어떤 값도 사용하지 않는다. 

// 할인 정책의 종류를 결정한다
public enum MovieType {
    AMOUNT_DISCOUNT, // 금액 할인 정책
    PERCENT_DISCOUNT, // 비율 할인 정책
    NONE_DISCOUNT // 미적용
}

그런데, 객체지향에서 가장 중요한 원칙은 캡슐화이다. 따라서 캡슐화를 지키기 위해 접근자(내부의 데이터를 반환) 수정자(데이터를 변경)를 추가한다.

import java.util.Collections;
import java.util.List;

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; // 할인 조건 직접 정의

    public MovieType getMovieType() {
        return movieType;
    }

    public void setMovieType(MovieType movieType) {
        this.movieType = movieType;
    }

    public Money getFee() {
        return fee;
    }

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

    public List<DiscountCondition> getDiscountConditions() {
        return Collections.unmodifiableList(discountConditions);
    }

    public void setDiscountConditions(List<DiscountCondition> discountConditions) {
        this.discountConditions = discountConditions;
    }

    public Money getDiscountAmount() {
        return discountAmount;
    }

    public void setDiscountAmount(Money discountAmount) {
        this.discountAmount = discountAmount;
    }

    public double getDiscountPercent() {
        return discountPercent;
    }

    public void setDiscountPercent(double discountPercent) {
        this.discountPercent = discountPercent;
    }
}

순번 조건은 상영 순번을 이용해 할인 여부를 판단하고 기간 조건은 상영 시간을 이용해 할인 여부를 판단한다.

public enum DiscountConditionType {
    SEQUENCE,       // 순번조건
    PERIOD          // 기간 조건
}

할인 조건을 구현하는 DiscountCondition은 할인 조건의 타입을 저장할 인스턴스 변수인 type을 포함한다. 또한 movieType의 경우와 마찬가지로 순번 조건에서만 사용되는 데이터인 상영 순번(sequence)과 기간 조건에서만 사용되는 데이터가 포함된다. 물론 캡슐화의 원칙에 따라 이 속성들을 클래스 외부로 노출해서는 안 된다. 매서드를 추가하자. 

public class DiscountCondition {
    private DiscountConditionType type;

    private int sequence;

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public DiscountConditionType getType() {
        return type;
    }

    public void setType(DiscountConditionType type) {
        this.type = type;
    }

    public DayOfWeek getDayOfWeek() {
        return dayOfWeek;
    }

    public void setDayOfWeek(DayOfWeek dayOfWeek) {
        this.dayOfWeek = dayOfWeek;
    }

    public LocalTime getStartTime() {
        return startTime;
    }

    public void setStartTime(LocalTime startTime) {
        this.startTime = startTime;
    }

    public LocalTime getEndTime() {
        return endTime;
    }

    public void setEndTime(LocalTime endTime) {
        this.endTime = endTime;
    }

    public int getSequence() {
        return sequence;
    }

    public void setSequence(int sequence) {
        this.sequence = sequence;
    }
}

Screen, Reservation, Customer 클래스도 구현해보자. 

영화를 예매하자 

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);
    }
}

reserve 매서드는 크게 두 부분으로 나눌 수 있다. 첫 번째는 DiscountCondition에 대해 루프를 돌면서 할인 가능 여부를 확인하는 for 문이고, 두 번째는 discountable 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산하는 if문이다.

02 설계 트레이드오프

데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 캡슐화, 응집도, 결합도를 사용하겠다. 

캡슐화

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

여기서 구현이란 나중에 변경될 가능성이 높은 어떤 것을 가리킨다. 

객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있다. 

 

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

 

객체지향에서 가장 중요한 원리는 캡슐화이다. 

객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는 것이다. 

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

응집도와 결합도 

구조적 설계 방법이 주도하던 시대의 기준이나 객체지향의 시대에도 여전히 유효하다.

응집도

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

  • 모듈에 포함된 내부 요소들이 연관돼 있는 정도
  • 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다.
  • 모듈 내의 요소들이 서로 다른 목적을 추구한다면 그 모듈은 낮은 응집도를 가진다.

결합도

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

어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 가진다. 

어떤 모듈이 다른 모듈에 대해 꼭 필요한 지식만 알고 있다면 두 모듈은 낮은 결합도를 가진다. 

 

좋은 설계를 만들기 위해서는 높은 응집도와 낮은 결합도를 추구해야 한다. 

 

응집도가 높은 설계에서는 하나의 요구사항 변경을 반영하기 위해 오직 하나의 모듈만 수정하면 된다.

응집도가 낮은 설계에서는 하나의 원인에 의해 변경해야 하는 부분이 다수의 모듈에 분산돼 있기 때문에 여러 모듈을 동시에 수정해야 한다. 

결합도는 한 모듈이 변경하기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다. 결합도가 높으면 함께 변경해야 하는 모듈의 수가 늘기 때문에 변경이 어려워진다.

결합도 변경의 원인으로도 설명이 가능하다. 1) 내부 구현을 변경했을 때, 결합도가 높다. 2) 퍼블릭 인터페이스를 변경했을 때 결합도가 낮다.반면에 자바의 String과 같은 변경될 확률이 매우 낮은 경우 결합도를 신경 쓰지 않는다.그러나 직접 작성한 코드의 경우 항상 불안정하고 언제라도 변경될 수 있다. 따라서 결합도에 신경 써야 한다.

 

응집도와 결합도는 변경과 관련이 깊다. 변경이 쉬우면 높은 응집도 낮은 결합도를 가졌다고 할 수 있다. 

 

캡슐화의 정도가 응집도 결합도에 영향을 미친다. 그렇기 때문에 응집도 결합도를 고민하기 전에 캡슐화부터 향상하자.

03 데이터 중심의 영화 예매 시스템의 문제점 

데이터 중심의 설계가 가진 대표적인 문제점 

1) 캡슐화 위반

2) 높은 결합도 

3) 낮은 응집도 

캡슐화 위반 

게터, 세터를 통해 인스턴스 변수를 노골적으로 드러냄

public class Movie {
    private Money fee;
    
    public Money getFee() {
        return fee;
    }

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

높은 결합도 

객체 내부의 구현이 객체의 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미한다. 그리고 더 나쁜 소식은 단지 객체의 내부 구현을 변경했음에도 이 인터페이스에 의존하는 모든 클라이언트들도 함께 변경해야 한다는 것이다. 

 

낮은 응집도 

서로 다른 이유로 변경되는 코드가 모듈 안에 공존하면 응집도가 낮다.

코드를 수정하는 이유를 찾아보자

ReservationAgency가 변경되는 이유

  • 할인정책 추가
  • 할인 정책별 계산 로직 변경
  • 할인 조건 추가
  • 예매 요금 계산 방식 변경
  • 할인 조건별 할인 여부 판단 방법 변경

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

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

04 자율적인 객체를 향해 

데이터 중심 설계가 낮은 응집도, 높은 결합도를 갖게 되는 근본적인 이유가 바로 "캡슐화를 위반했기 때문"이다.

객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 한다.

 

Rectangle은 사각형의 좌표들을 포함하고 각 속성에 대한 접근자와 수정자 메서드를 제공한다.

 

public class Rectangle {
	private int left;
	private int top;
	private int right;
	private int bottom;

	public Rectangle(int left, int top, int right, int bottom) {
		this.left = left;
		this.top = top;
		this.right = right;
		this.bottom = bottom;
	}

	public int getLeft() {
		return left;
	}

	public void setLeft(int left) {
		this.left = left;
	}

	public int getTop() {
		return top;
	}

	public void setTop(int top) {
		this.top = top;
	}

	public int getRight() {
		return right;
	}

	public void setRight(int right) {
		this.right = right;
	}

	public int getBottom() {
		return bottom;
	}

	public void setBottom(int bottom) {
		this.bottom = bottom;
	}
}

이 사각형의 너비와 높이를 증가시키는 코드가 필요하다고 가정 아마 이 코드는 Rectangle 외부의 어떤 클래스 안에 다음과 같이 구현돼 있을 것이다. 

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

위 코드에는 많은 문제점이 도사리고 있다.

1) '코드 중복'이 발생할 확률이 높다는 것이다. 

-> 이런 경우 만약 사각형의 크기를 늘리거나 줄리는 여러 상황에서 접근자와 수정자를 이용해 값을 수정하는 코드가 중복될 가능성이 크다.

2) '변경에 취약' 하다 

-> 사각형의 좌표를 표현하는 용어를 바꿀 때 변수뿐 아니라 여러 메서드까지 수정해야 하는 '변경에 취약'한 특징을 갖고 있다. 

 

해결 방법은 캡슐화를 강화시키는 것이다. Rectangle 내부에 너비와 높이를 조절하는 로직을 캡슐화하면 두 가지 문제를 해결할 수 있다. 

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

Rectangle을 변경하는 주체를 외부의 객체에서 Rectangle로 이동시켰다. 즉, 자신의 크기를 Rectangle 스스로 증가시키도록 '책임을 이동'시킨 것이다. 이것이 바로 객체가 자기 스스로를 책임진다는 말의 의미다. 

 

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

우리가 상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서다.

 

객체를 설계할 때 "이 객체가 어떤 데이터를 포함해야 하는가?"라는 질문은 다음과 같은 두 개의 개별적인 질문으로 분리해야 한다. 

 

1) 이 객체가 어떤 데이터를 포함해야 하는가?

2) 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?

 

두 질문을 조합하면 객체의 내부 상태를 저장하는 방식과 저장된 상태에 대해 호출할 수 있는 오퍼레이션의 집합을 얻을 수 있다. 다시 말해 새로운 데이터 타입을 만들 수 있는 것이다. 

 

할인 조건을 표현하는 DiscountCondition에서 시작하자. 첫 번째 질문은 어떤 데이터를 관리해야 하는 지를 묻는 것이다. 

DiscountCondition이 관리해야 하는 데이터를 결정해 놓았다. 

public class DiscountCondition {
    private DiscountConditionType type;

    private int sequence;

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
  }

두 번째 질문은 이 데이터에 대해 수행할 수 있는 오퍼레이션이 무엇인가를 묻는 것이다. 할인 조건에는 순번 조건과 기간 조건의 두 가지 종류가 존재한다. 

DiscountCondition은 순번 조건일 경우에는 sequence를 이용해서 할인 여부를 결정하고, 

기간 조건일 경우에는 dayOfWeek, startTime, endTime을 이용해 할인 여부를 결정한다. 

 

따라서 다음과 같이 두 가지 할인 조건을 판단할 수 있게 두 개의 isDiscountable 메서드가 필요할 것이다. 

isDiscountable 메서드 안에서 type의 값을 이용해 현재의 할인 조건 타입에 맞는 적절한 메서드가 호출됐는지 판단한다. 

public class DiscountCondition {
    public DiscountConditionType getType() {
        return type;
    }

    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
        if (type != DiscountConditionType.PERIOD) {
            throw new IllegalArgumentException();
        }

        return this.dayOfWeek.equals(dayOfWeek) &&
                this.startTime.compareTo(time) <= 0 &&
                this.endTime.compareTo(time) >= 0;
    }

    public boolean isDiscountable(int sequence) {
        if (type != DiscountConditionType.SEQUENCE) {
            throw new IllegalArgumentException();
        }

        return this.sequence == sequence;
    }
}

이제 Movie를 구현하자. 첫 번째 질문은 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;
    }

두 번째 질문은 이 데이터를 처리하기 위해 어떤 오퍼레이션이 필요한지를 묻는 것이다. Movie가 포함하는 데이터를 살펴보면 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요할 것 같다. 

 

할인 정책에는 금액 할인, 비율 할인, 할인 미적용의 세 가지 타입이 있다는 사실을 기억하라. 따라서 DiscountCondition과 마찬가지로 할인 정책의 타입을 반환하는 getMovieType 매서드와 정책별로 요금을 계산하는 세 가지 메서드를 구현해야 한다. 

public class Movie {
    public MovieType getMovieType() {
        return movieType;
    }

    public Money calculateAmountDiscountedFee() {
        if (movieType != MovieType.AMOUNT_DISCOUNT) {
            throw new IllegalArgumentException();
        }

        return fee.minus(discountAmount);
    }

    public Money calculatePercentDiscountedFee() {
        if (movieType != MovieType.PERCENT_DISCOUNT) {
            throw new IllegalArgumentException();
        }

        return fee.minus(fee.times(discountPercent));
    }

    public Money calculateNoneDiscountedFee() {
        if (movieType != MovieType.NONE_DISCOUNT) {
            throw new IllegalArgumentException();
        }

        return fee;
    }
}

Movie는 DiscountCondition의 목록을 포함하기 때문에 할인 여부를 판단하는 오퍼레이션 역시 포함해야한다. 

isDiscountable 메서드를 추가하자. 기간 조건을 판단하기 위해 필요한  dayOfWeek, whenScreened 순번 조건의 만족 여부를 판단하는 데 필요한 sequence를 isDiscountable 메서드의 파라키터로 전달하자.

public class Movie {
    public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
        for(DiscountCondition condition : discountConditions) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                    return true;
                }
            } else {
                if (condition.isDiscountable(sequence)) {
                    return true;
                }
            }
        }

        return false;
    }
}

만약 할인 조건이 기간 조건이라면 DiscountCondition의  isDiscountable(DayOfWeek dayOfWeek, LocalTime whenScreened) 매서드를 호출하고, 순번 조건이라면 DiscountCondition의 isDiscountable(int sequence) 메서드를 호출한다. 

 

이제 Screening을 살펴보자. Screening이 관리하는 데이터와 메서드를 함께 표시하겠다. 

public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
        this.movie = movie;
        this.sequence = sequence;
        this.whenScreened = whenScreened;
    }

    public Money calculateFee(int audienceCount) {
        switch (movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculateAmountDiscountedFee().times(audienceCount);
                }
                break;
            case PERCENT_DISCOUNT:
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculatePercentDiscountedFee().times(audienceCount);
                }
            case NONE_DISCOUNT:
                movie.calculateNoneDiscountedFee().times(audienceCount);
        }

        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}

Screening은 Movie가 금액 할인 정책이나 비율 할인 정책을 지원할 경우 Movie의 isDiscountable메서드를 호출해 할인이 가능한지 여부를 판단한 후 적절한 Movie의 메서드를 호출해서 요금을 계산한다. 할인이 불가능하거나 할인 정책이 적용되지 않은 영화의 경우 Movie의 calculateNoneDiscountedFee 메서드로 영화 요금을 계산한다. 

 

ReservationAgency는 Screening의 calculateFee 메서드를 호출해 예매 요금을 계산한 후 계산된 요금을 이용해 Reservation을 생성한다. 

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

1) 데이터 주도 설계 초기 모습

2) 캡슐화 진행 후 

 

데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현하고 있다. 따라서 이 객체들은 스스로를 책임진다고 말할 수 있다.

05 하지만 여전히 부족하다 

캡슐화 관점에서 두 번째 설계가 첫 번째 설계보다 향상된 것은 사실이지만 그렇다고 해서 만족스러울 정도는 아니다. 

사실 두 번째 설계 역시 데이터 중심의 설계 방식에 속한다고 할 수 있다. 

캡슐화 위반

수정된 객체들은 자기 자신의 데이터를 스스로 처리한다. 예를 들어 DiscountCondition은 자기 자신의 데이터를 이용해 할인 가능 여부를 스스로 판단한다. 

public class DiscountCondition {
    private DiscountConditionType type;

    private int sequence;

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public DiscountCondition(int sequence){
        this.type = DiscountConditionType.SEQUENCE;
        this.sequence = sequence;
    }

    public DiscountCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime){
        this.type = DiscountConditionType.PERIOD;
        this.dayOfWeek= dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public DiscountConditionType getType() {
        return type;
    }

    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
        if (type != DiscountConditionType.PERIOD) {
            throw new IllegalArgumentException();
        }

        return this.dayOfWeek.equals(dayOfWeek) &&
                this.startTime.compareTo(time) <= 0 &&
                this.endTime.compareTo(time) >= 0;
    }

    public boolean isDiscountable(int sequence) {
        if (type != DiscountConditionType.SEQUENCE) {
            throw new IllegalArgumentException();
        }

        return this.sequence == sequence;
    }
}

객체지향이 자신의 상태를 스스로 관리하는 자율적인 객체를 지향하는 것이라고 한다면 분명 지금의 설계는 객체지향의 취지에 맞는 것처럼 보일 것이다. 하지만 DiscountCondition에 구현된 두 개의 isDiscountable 메서드를 자세히 살펴보면 이상한 점이 몇 군데 눈에 띈다. 

 

기간 조건을 판단하는 isDiscountable(DayOfWeek dayOfWeek, LocalTime time) 메서드의 시그니처를 자세히 살펴보면 DiscountCondition에 속성으로 포함돼 있는 DayOfWeek 타입의 요일 정보와 LocalTime 타입의 시간 정보를 파라미터로 받는 것을 알 수 있다. 

 

두 번째 isDiscountable(int sequence) 메서드 역시 객체가 int 타입의 순번 정보를 포함하고 있음을 외부에 노출한다. 

 

만약 DiscountCondition의 속성을 변경해야 한다면 어떻게 될까? 아마도 두 isDiscountable 메서드의 파라미터를 수정하고 매당 메서드를 사용하는 모든 클라이언트도 함께 수정해야 할 것이다. 내부 구현의 변경이 외부로 퍼져나가는 파급효과는 캡슐화가 부족하다는 명백한 증거이다. 

 

Movie 역시 캡슐화가 부족하기는 마찬가지다. 

할인 정책의 종류를 노출하고 있는데  calculateAmountDiscountedFee, calculatePercentDiscountedFee, calculateNoneDiscountedFee 라는 세 개의 메서드는 할인 정책에는 금액 할인 정책, 비율 할인 정책, 미적용의 세 가지가 존재한다는 사실을 만천하에 드러내고 있다. 

 

캡슐화의 진정한 의미 

캡슐화가 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다는 것을 잘 보여준다.

캡슐화란 변하는 어떤 것이든 감추는 것이다. 그것이 무엇이든 구현과 관련된 것이라면 말이다. 

높은 결합도 

캡슐화의 위반으로 DiscountCondition의 내부 구현이 외부로 노출됐기 때문에 Movie와 DiscountCondition 사이의 결합도는 높을 수 밖에 없다. 

public class Movie {
    public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
        for(DiscountCondition condition : discountConditions) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                    return true;
                }
            } else {
                if (condition.isDiscountable(sequence)) {
                    return true;
                }
            }
        }

        return false;
    }
}

Movie의 isDiscountable 메서드는 DiscountCondition의 목록을 순회하면서 할인 조건의 종류에 따라 DiscountCondition에 구현된 두 개의 isDiscountable 메서드 중에서 적절한 것을 호출한다. 중요한 것은 Movie와 DiscountCondition 사이의 결합도이므로 DiscountCondition에 대한 어떤 변경이 Movie에게까지 영향을 미치는지를 살펴봐야 한다. 

1) DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경된다면 Movie를 수정해야 한다.

2) DiscountCondition의 종류가 추가되거나 삭제된다면 Movie안의 if~else 구문을 수정해야한다. 

3) 각 DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 매서드로 전달된 파라미터를 변경해야 한다. 이로 인해 Movie의 isDiscountable 메서드 시그니처도 함께 변경될 것이고 결과적으로 이 메서드에 의존하는 Screening에 대한 변경도 초래할 것이다.

낮은 응집도 

Screening을 살펴보자. 설명한 것처럼 DiscountCondition이 할인 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드로 전달해야 하는 파라미터의 종류를 변경해야 하고, 이로 인해 Screening에서 Movie의 isDiscountable 메서드를 호출하는 부분도 함께 변경해야 한다. 

public class Screening {
    public Money calculateFee(int audienceCount) {
        switch (movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculateAmountDiscountedFee().times(audienceCount);
                }
                break;
            case PERCENT_DISCOUNT:
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculatePercentDiscountedFee().times(audienceCount);
                }
            case NONE_DISCOUNT:
                movie.calculateNoneDiscountedFee().times(audienceCount);
        }

        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}

 

결과적으로 할인 조건의 종류를 변경하기 위해서는 DiscountCondition, Movie, 그리고 Movie를 사용하는 Screening을 함께 수정해야 한다. 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거다. 

 

06 데이터 중심 설계의 문제점 

객체의 행동보다 상태에 초점을 둔다

데이터는 고작 구현의 일부라는 것을 명심하자.

데이터 주도 설계는 데이터에 대한 결정을 너무 이른 시간에 한다.
첫 번째 설계는 public 속성과 큰 차이가 없는 접근자 수정자를 과도하게 많이 추가했기 때문에 실패했다.
두 번째 설계는 데이터에 관한 지식이 객체의 인터페이스에 모두 드러나 실패했다.

 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다

데이터 중심의 설계는 초점을 내부에 둔다. 그러다 보면 이미 구현된 객체의 인터페이스를 억지로 끼워 맞출 수밖에 없다. 결과적으로 올바른 협력을 하지 못하게 된다.

반응형
LIST

'그룹 스터디 공부(IT 서적) > 오브젝트' 카테고리의 다른 글

05 책임 할당하기_02  (0) 2023.08.09
05 책임 할당하기_01  (0) 2023.08.07
03 역할,책임,협력  (0) 2023.07.24
02 객체지향 프로그래밍  (0) 2023.07.23
01.객체, 설계  (0) 2023.07.23