devseop08 님의 블로그

[오브젝트] 설계 품질과 트레이드 오프 본문

Architecture/객체지향설계

[오브젝트] 설계 품질과 트레이드 오프

devseop08 2025. 6. 26. 16:29
  • 객체지향 설계란 올바른 객체에서 올바른 책임을 할당하면서 낮은 결합도높은 응집도를 가진 구조를 창조하는 활동이다.
  • 책임을 할당하는 작업은 응집도와 결합도 같은 설계 품질과 깊이 연관돼 있다.
  • 훌륭한 설계란 합리적인 비용 안에서 변경을 수용할 수 있는 구조를 만드는 것
  • 적절한 비용 안에서 쉽게 변경할 수 있는 설계는 응집도가 높고 서로 느슨하게 결합돼 있는 요소로 구성된다.
  • 합리적인 수준의 응집도와 결합도를 유지하기 위해서는 객체의 상태가 아니라 행동에 초점을 맞춰야 한다.
  • 좋은 설계와 나쁜 설계를 살펴보면서 통찰을 얻을 수 있다.
  • 영화 예매 시스템을 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 살펴보고 객체지향적으로 설계한 구조와 어떤 차이점이 있는지 살펴본다.
  • 1. 데이터 중심의 영화 예매 시스템
  • 데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의한다.
  • 책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다.
  • 훌륭한 객체지향 설계는 데이터가 아니라 책임에 초점을 맞춰야 한다.
  • 그 이유는 변경과 관련된다.
  • 객체의 상태는 구현에 속하는데 구현은 불안정하기 때문에 변하기 쉽다.
  • 상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부 사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다.
  • 객체의 책임은 인터페이스에 속한다.
  • 객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화 => 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지
  • 데이터를 기준으로 분할한 영화 예매 시스템의 설계를 살펴본다.
  • 데이터를 준비하자
  • 데이터 중심의 설계는 객체가 내부에 저장해야 하는 '데이터가 무엇인가'를 묻는 것으로 시작한다.
  • 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 클래스와 책임 중심의 Movie 클래스의 두드러지는 차이점은 할인 조건의 목록이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것
  • 또한 할인 정책을 DiscountPolicy라는 별도의 클래스로 분리했던 책임 중심의 분할과 달리 금액 할인 정책에 사용되는 할인 금액(discountAmount)와 비율 할인 정책에 사용되는 할인 비율(discountPercent)을 Movie 안에 직접 정의하고 있다.
  • 할인 정책은 영화별로 오직 하나만 지정할 수 있기 때문에 한 시점에 discountAmount와 discountPercent 중 하나의 값만 사용 가능
  • 할인 정책의 종류를 결정하는 것이 movieType, movieType은 현재 영화에 설정된 할인 정책의 종류를 결정하는 열거형 타입인 MovieType의 인스턴스
public enum MoneyType {
    AMOUNT_DISCOUNT,   // 금액 할인 정책
    PERCENT_DISCOUNT,  // 비율 할인 정책
    NONE_DISCOUNT      // 미적용
}
  • 데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다.
  • Movie 클래스의 경우처럼 객체의 종류를 저장하는 인스턴스 변수와 인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수(discountAmount, discountPercent)를 하나의 클래스 안에 함께 포함시키는 방식은 데이터 중심의 설계 안에서 흔히 볼 수 있는 패턴이다.
  • 캡슐화를 위해 접근자와 수정자를 추가
public class Movie {
    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 Money getDiscountPercent() {
        return discountPercent;
    }

    public void setDiscountPercent(double discountPercent) {
        this.discountPercent = discountPercent;
    }
}
  • 할인 조건(DiscountCondition)을 구현해보자
  • 할인 조건을 구현하는데 필요한 데이터는 무엇인가?
  • 현재의 할인 조건의 종류를 저장할 데이터가 필요하다.
  • 할인 조건의 타입을 저장할 DiscountConditionType 선언
public enum DiscountConditionType {
    SEQUENCE,    // 순번 조건
    PERIOD       // 기간 조건
}
public class DiscountCondition {
    private DiscountConditionType type;

    private int sequence;

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

    public getSequence() {
        return sequence;
    }

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

    public DiscountConditionType getType(){
        return type;
    }

    punblic 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;
    }
}
  • Screening 클래스 구현
  • 상영을 구현하기 위해 필요한 데이터는 무엇인가?
  • 영화(Movie), 상영 순번(sequence), 상영 시간(whenScreened)이 필요하다.
public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Movie getMovie() {
        return movie;
    }

    public void setMovie(Movie movie) {
        this.movie = movie;
    }

    public LocalDateTime getWhenScreened(){
        return whenScreened;
    }

    public void setWhenScreened(LocalDateTime whenScreened){
        this.whenScreened = whenScreened;
    }

    public int getSequence() {
        return sequence;
    }

    public void setSequence(int sequence) {
        this.sequence = sequence;
    }
}
  • 영화 예매 시스템의 목적은 영화를 예매하는 것
  • Reservation 클래스를 추가
  • 예약을 위해 필요한 데이터는 무엇인가?
  • 예약을 위해선 고객(customer), 상영(screening), 요금(fee), 관람 인원(audienceCount) 데이터가 필요하다.
public class Reservation {
    private Customer customer;
    private Screening screening;
    private Money fee;
    private int audienceCount;

    public Reservation(Customer customer, Screening screening,
                        Money fee, int sequence) {
        this.customer = customer;
        this.screening = screening;
        this.fee = fee;
        this.audienceCount = audienceCount;                    
    }

    public Customer getCustomer(){
        return customer;
    }

    public void setCustomer(Customer customer){
        this.customer = customer;
    }

    public Screening getScreening(){
        return screening;
    }

    public void setScreening(Screening screening){
        this.screening = screening;
    }

    public Money getFee(){
        return fee;
    }

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

    public int getAudienceCount() {
        return audienceCount;
    }

    public void setAudienceCount(int audienceCount) {
        this.audienceCount = audienceCount;
    }
}
public class Customer {
    private String name;
    private String id;

    public Customer(String name, String id){
        this.name = name;
        this.id = id;
    }
}
  • 영화 예매 시스템 구현을 위한 데이터 클래스

영화를 예매하자

  • 데이터 클래스들을 조합해서 영화 예매 절차를 구현하자
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(codition.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();
        }

        return new Reservation(customer, screening, fee, audienceCount);
    }
}
  • reserve 메서드는 DiscountCondition에 대해 루프를 돌면서 할인 가능 여부를 확인하는 for문과 discountable 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산하는 if문으로 구성된다.

2. 설계 트레이드오프

  • 데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 설계의 세 가지 품질 척도인 캡슐화, 응집도, 결합도를 사용

캡슐화

  • 객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있다.
  • 변경될 가능성이 높은 부분은 구현, 상대적으로 안정적인 부분은 인터페이스
  • 객체 설계를 위한 가장 기본적인 아이디어: 변경의 정도에 따라 구현과 인터페이스를 분리, 외부에서는 인터페이스에만 의존하도록 관계 조절
  • 객체지향에서 가장 중요한 원리는 캡슐화, 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는것
  • 캡슐화는 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 종류
  • 객체지향 언어를 사용한다고 해서 애플리케이션의 복잡성이 잘 캡슐화될 것이라고 보장할 수는없다.
  • 객체지향 프로그래밍을 통해 전반적으로 얻을 수 있는 장점은 오직 설계 과정 동안 캡슐화를 목표로 인식할 때만 달성될 수 있다.
  • 변경될 수 있는 어떤 것이라도 캡슐화해야 한다.
  • 캡슐화는 유지 보수성이 목표다.

응집도와 결합도

  • 응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다.
  • 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.
  • 결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도
  • 어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 갖는다.
  • 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
  • 좋은 설계 = 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계
  • 좋은 설계의 애플리케이션 = 애플리케이션을 구성하는 각 요소의 응집도가 높고 서로 느슨하게 결합
  • 좋은 설계는 변경과 관련되기 때문에 설계의 품질을 결정하는 응집도와 결합도도 자연스레 변경과 관련된 것이다.
  • 높은 응집도와 낮은 결합도는 설계를 변경하기 쉽게 만든다.
  • 응집도가 높은 설계에서는 하나의 요구사항 변경을 반영하기 위해 오직 하나의 모듈만 수정하면된다.

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

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

  • 영향을 받는 모듈의 수 외에도 변경의 원인을 이용해 결합도의 개념을 설명할 수도 있다.
  • 내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우에는 두 모듈 사이의 높다고 표현할 수 있다.
  • 퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다고 표현
  • 클래스의 구현이 아닌 인터페이스에 의존하도록 코드를 작성해야 낮은 결합도를 얻을 수 있다.
  • "인터페이스에 대해 프로그래밍하라"
  • 캡슐화의 정도가 응집도와 결합도에 영향을 미친다.
  • 캡슐화를 시키면 모듈 안의 응집도는 높아지고 모듈 사이의 결합도는 낮아진다.

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

  • 데이터 중심의 설계는 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다.
  • 책임 중심의 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화한다.
  • 데이터 중심의 설계가 가진 대표적인 문제점: 캡슐화 위반, 높은 결합도, 낮은 응집도
  • 캡슐화 위반
  • 데이터 중심으로 설계한 Movie 클래스
  • 오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있다.
public class Movie {
    private Money fee;

    public Money getFee(){
        return fee;
    }

    public void setFee(Money fee){
        this.fee = fee;
    }
}
  • Movie 클래스 타입의 객체의 내부에 직접 접근할 수 없기 때문에 캡슐화의 원칙을 지키고 있는 것처럼 보이지만 접근자와 수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.
  • getFee 메서드와 setFee 메서드는 Movie 내부에 Money 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.
  • 설계할 때 협력에 관해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 되는 경향이 있다.
  • 접근자와 수정자에 과도하게 의존하는 설계 방식 = 추측에 의한 설계 전략

높은 결합도

  • 객체 내부의 구현이 객체의 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미한다. => 객체의 내부 구현 변경은 이 인터페이스에 의존하는 모든 클라이언트들도 함께 변경해야 한다.
public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, 
                                    int audienceCount) {
        Money fee;
        if(discountable){
            ...
            fee = movie.getFee().minus(discountAmount).times(audienceCount);
        }else {
            fee = movie.getFee();
        }

        ...
    }
}
  • Movie의 인스턴스 변수 fee의 타입을 변경한다고 해보자
  • Movie의 getFee 메서드의 반환 타입도 함께 수정해야 하고, getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 한다.
  • Movie의 fee 타입 변경으로 인해 협력하는 클래스가 변경되기 때문에 getFee 메서드는 fee를 정상적으로 캡슐화하지 못한다.
  • 결합도 측면에서 데이터 중심 설계가 가지는 또 다른 단점은 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것
  • 너무 많은 대상에 의존하여 변경에 취약한 ReservationAgency

낮은 응집도

  • 서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 공존할 때 모듈의 응집도가 낮다고 말한다.
  • ReservationAgency의 변경 이유가 될 수 있는 수정사항
    • 할인 정책이 추가되는 경우
    • 할인 정책 별로 할인 요금을 계산하는 방법이 변경될 경우
    • 할인 조건이 추가되는 경우
    • 할인 조건별로 할인 여부를 판단하는 방법이 변경될 경우
    • 예매 요금을 계산하는 방법이 변경될 경우
  • 낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다.
    • 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다.
    • 응집도가 낮을 경우 다른 모듈에 위치해야 할 책임의 일부가 엉뚱한 곳에 위치하게 되기 때문에 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.
  • 현재의 설계는 새로운 할인 정책을 추가하거나 새로운 할인 조건을 추가하기 위해 하나 이상의 클래스를 동시에 수정해야 한다.

단일 책임 원칙

  • 단일 책임 원칙은 클래스는 단 한 가지의 변경 이유만 가져야 한다는 원칙이다.
  • 단일 책임 원칙이 클래스의 응집도를 높일 수 있다.
  • 단일 책임 원칙이라는 맥락에서 '책임'이라는 말은 '변경의 이유'라는 의미로 사용된다.
  • 단일 책임 원칙에서의 책임은 지금까지 살펴본 역할, 책임, 협력에서의 책임과는 다른 개념이다.

4. 자율적인 객체를 향해

캡슐화를 지켜라

  • 객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 하는데 메서드는 단순히 속성 하나의 값을 반환하거나 변경하는 접근자나 수정자를 의미하는 것이 아니다.
  • 객체에게 의미 있는 메서드는 객체가 책임져야 하는 무언가를 수행하는 메서드다.
  • 속성의 가시성을 private으로 설정했다고 해도 접근자와 수정자를 통해 속성을 외부로 제공하고 있다면 캡슐화를 위반하는 것이다.
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() { 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 클래스를 이용하는 외부 클래스 AnyClass
class AnyClass {
    void anyMethod(Rectangle rectangle, int multiple){
        rectangle.setRight(rectangle.getRight * multiple);
        rectangle.setBottom(rectangle.getBottom * multiple);
        ...
    }
}
  • 첫 번째 문제점 : 코드 중복이 발생할 확률이 높다.
  • 다른 곳에서도 사각형의 너비와 높이를 증가시키는 코드가 필요하면 그곳에서도 getRight와 getBottom 메서드를 호출하고 수정자 메서드를 이용해 값을 설정하는 유사한 코드가 존재할 것
  • 두 번째문제점 : 변경에 취약하다.
  • Rectangle이 right와 bottom 대신 length와 height를 이용해서 사각형을 표현하도록 수정한다고 가정
  • getRight, setRight, getBottom, setBottom 메서드를 getLength, setLength, getHeight, setHeight로 변경해야 하고, 이 변경은 기존의 접근자 메서드를 사용하던 모든 코드에 영향을 미친다.
  • 해결 방법 : 캡슐화 강화
  • Rectangle을 변경하는 주체를 외부의 객체에서 Rectangle로 이동시킨다.
  • 자신의 크기를 Rectangle 스스로 증가시키도록 책임을 이동시키는 것이다. => 객체가 자기 스스로를 책임진다
class Rectangle {
    public void enlarge(int multiple) {
        right *= multiple;
        bottom *= multiple;
    }
}

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

  • 상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서다.
  • 객체는 단순한 데이터 제공자가 아니다.
  • 객체 내부의 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.
  • 객체 설계 시 "이 객체가 어떤 데이터를 포함해야 하는가?"라는 질문은 두 개의 개별적인 질문으로 분리해야 한다.
    • 이 객체가 어떤 데이터를 포함해야 하는가?
    • 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
  • ReservationAgency로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨보자
  • 할인 조건을 표현하는 DiscountCondition에서 시작해보자
  • 첫 번째 질문 : DiscountCondition은 어떤 데이터를 관리해야 하는가?
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
}
  • 두 번째 질문 : 이 데이터에 대해 수행할 수 있는 오퍼레이션이 무엇인가?
  • DiscountCondition은 DiscountConditionType이 순번 조건일 경우에는 sequence를 이용해서 할인 여부를 결정하고, 기간 조건일 경우에는 dayOfWeek, startTime, endTime을 이용해 할인 여부를 결정한다.
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;
}
  • 두번째 질문 : 포함한 데이터를 처리하기 위해 어떤 오퍼레이션이 필요한가?
  • 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요하다.
  • 할인 정책의 타입을 반환하는 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;
    }
}
  • 할인 여부를 판단하는 isDiscountable 메서드
  • 기간 조건을 판단하기 위해 필요한 whenScreened와 순번 조건의 만족 여부를 판단하는 데 필요한 sequence를 isDiscountable 메서드의 파라미터로 전달
public class Movie {
    public boolean isDiscountable(LocalDateTime whenScreened, int sequence){
        for(DiscountConditoin 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;
    }
}
  • 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:
                return movie.calculateNoneDiscountedFee().times(audienceCount);
        }

        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}
  • ReservationAgency 구현
public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, 
                                                        int audienceCount) {
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);    
    }
}
  • 개선된 설계에서는 데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현한다.

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

  • 두 번째 설계 역시 본질적으로 데이터 중심의 설계 방식에 속한다
  • 아직 해결되지 못한 부분이 있다.

캡슐화 위반

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public DiscountConditionType getType() { ... }

    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) { ... }

    public boolean isDiscountable(int sequence) { ... }
}
  • isDiscountable(DayOfWeek dayOfWeek, LocalTime time) 메서드와 isDiscountable(int sequence) 메서드는 DayOfWeek, LocalTime, int 타입의 데이터가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출한다.
  • getType 메서드를 통해 내부에 DiscountConditionType을 포함하고 있다는 정보 역시 인터페이스에 노출
  • DiscountCondition의 속성을 변경해야한다면 두 isDiscountable 메서드의 파라미터를 수정하고 해당 메서드를 사용하는 모든 클라이언트도 함께 수정해야 한다.
  • Movie 역시 캡슐화가 부족, 내부 구현을 인터페이스에 노출시키고 있다.
  • Movie 요금 계산 메서드들은 객체의 파라미터나 반환 값으로 내부에 포함된 속성에 대한 어떤 정보도 노출하지 않는다
  • 하지만 calculateAmountDiscountedFee, calculatePercentDiscountedFee, calculateNoneDiscountedFee라는 세 개의 메서드는 이름에서 할인 정책의 종류를 노출시키고 있다.
  • 새로운 할인 정책이 추가되거나 기존 할인 정책이 제거된다면 이 메서드들에 의존하는 모든 클라이언트가 영향을 받을 것이다.

캡슐화의 진정한 의미

  • 캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 갖는다.
  • 캡슐화의 진정한 의미는 변할 수 있는 어떤 것이라도 감추는 것을 말한다.

높은 결합도

  • 캡슐화 위반으로 인해 DiscountCondition의 내부 구현이 외부로 노출됐기 때문에 Movie와 DiscountCondition 사이의 결합도는 높을 수 밖에 없다.
  • Movie에게까지 영향을 미치는 DiscountCondition에 대한 변경
    • DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경된다면 Movie를 수정해야 한다.
    • DiscountCondition의 종류가 추가되거나 삭제된다면 Movie안의 if ~ else 구문을 수정해야 한다.
    • 각 DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드로 전달된 파라미터를 변경해야 한다. 이는 Movie의 이 메서드에 의존하는 Screening에 대한 변경을 초래한다.
  • 캡슐화의 원칙을 지키지 않았기 때문에 결합도가 높아지는 문제가 발생하는 것이다.

낮은 응집도

  • DiscountCondition이 할인 여부를 판단하는데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드의 파라미터 종류 뿐만 아니라 Screening에서 Movie의 isDiscountable 메서드를 호출하는 부분도 함께 변경
  • 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거다.
  • DiscountCondition이나 Movie에 위치해야하는 로직이 Screening으로 새어나왔기 때문에 DiscountCondition과 Movie의 내부 구현이 인터페이스에 그대로 노출되고 Screening은 그런 노출된 구현에 직접적으로 의존하고 있다.

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

  • 캡슐화를 위반하는 데이터 중심의 설계가 변경에 취약한 두 가지 이유
    • 데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요
    • 데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정

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

  • 데이터는 구현의 일부이고 데이터 주도 설계는 너무 이른 시기에 이런 내부 구현에 초점을 맞추게 한다.
  • 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체 => 접근자와 수정자를 과도하게 추가, 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다. => 첫 번째 설계가 실패한 이유
  • 데이터를 먼저 결정하고, 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.
  • 인터페이스는 구현을 캡슐화하는 데 실패 => 변경에 취약한 코드 => 두 번째 설계가 실패한 이유
  • 데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다
  • 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다.
  • 실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다.
  • 구현이 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스를 협력에 억지로 끼워맞출 수밖에 없는 것이다.
  • 두 번째 설계에서 객체의 인터페이스에 구현이 노출돼 있었기 때문에 협력이 구현 세부 사항에 종속되고 그에 따라 객체 내부 구현의 변경이 발생할 때 협력하는 객체 모두가 영향을 받았던 것