오브젝트 - 04. 설계 품질과 트레이드오프

청포도봉봉이·2025년 5월 6일
1

오브젝트

목록 보기
4/7
post-thumbnail

객체지향 설계의 핵심은 역할, 책임, 협력이다. 협력은 애플리케이션의 기능을 구현하기 위해 메시지를 주고받는 객체들 사이의 상호작용이다. 책임은 객체가 다른 객체와 협력하기 위해 수행하는 행동이고, 역할은 대체 가능한 책임의 집합이다.

이번 장에서는 영화 예매 시스템을 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 살펴보고 객체지향적으로 설계한 구조와 어떤 차이점이 있는지 살펴보겠다.

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

데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의한다. 책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다. 데이터 중심의 관점은 객체의 상태에 초점을 맞추고 책임 중심 관점은 객체의 행동에 초점을 맞춘다.

데이터를 준비하자

데이터 중심의 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방법이다. 그리고 객체가 내부에 저장해야 하는 '데이터가 무엇인가'를 묻는 것으로 시작한다.

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와의 차이점은 할인 조건의 목록(discountConditions)이 인스턴스 변수 Movie 안에 직접 포함돼 있다는 것이다. 또한 할인 정책을 DiscountPolicy라는 별도의 클래스로 분리했던 이전 예제와 달리 금액 할인 정책에 사용되는 할인 금액(discountAmount)과 비율 할인 정책에 사용되는 할인 비율(discountPercent)을 Movie 안에서 직접 정의하고 있다.

할인 정책은 영화별로 오직 하나만 지정할 수 있기 때문에 한 시점에 discountAmountdiscountPercent 중 하나의 값만 사용될 수 있다. 그렇다면 영화에 사용된 할인 정책의 종류를 알 수 있을까? 할인 정책의 종류를 결정하는 것이 바로 movieType이다.

public enum MovieType {
    AMOUNT_DISCOUNT,
    PERCENT_DISCOUNT,
    NONE_DISCOUNT
}

이제 필요한 데이터를 준비했다. 객체지향의 가장 중요한 원칙은 캡슐화이므로 내부 데이터가 객체의 엷은 막을 빠져나가 외부의 다른 객체들을 오염시키는 것을 막아야 한다. 가장 간단한 방법은 내부의 데이터를 반환하는 접근자(accessor)와 데이터를 변경하는 수정자(mutator)를 추가하는 것이다.

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 Money getFee() {
        return fee;
    }

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

    public MovieType getMovieType() {
        return movieType;
    }

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

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

Movie를 구현하는 데 필요한 데이터를 결정했고 메서드를 이용해 내부 데이터를 캡슐화하는 데도 성공했다. 이제 할인 조건을 구현해보자. 할인 조건의 타입을 저장한 DiscountConditionType을 정의하자.

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

할인 조건을 구현하는 DiscountCondition은 할인 조건의 타입을 저장할 인스턴스 변수인 type을 포함한다. 또한 movieType의 경우와 마찬가지로 순번 조건에서만 사용되는 데이터인 상영 순번, 기간 조건을 포함한다.

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

캡슐화를 위해 메서드를 추가하자.

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

이어서 Screening 클래스를 구현하자.

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

    public Movie getMovie() {
        return movie;
    }

    public int getSequence() {
        return sequence;
    }

    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }

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

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

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

영화 예매 시스템의 목적은 영화를 예매하는 것이다. Reservation 클래스를 추가하자.

public class Reservation {
    private Customer customer;
    private Screening screening;
    private Money fee;
    private int audienceCount;

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

    public Customer getCustomer() {
        return customer;
    }

    public Screening getScreening() {
        return screening;
    }

    public Money getFee() {
        return fee;
    }

    public int getAudienceCount() {
        return audienceCount;
    }

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

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

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

    public void setAudienceCount(int audienceCount) {
        this.audienceCount = audienceCount;
    }
}

Customer는 고객의 정보를 보관하는 간단한 클래스다.

public class Customer {
    private String name;
    private String id;

    public Customer(String name, String id) {
        this.name = name;
        this.id = id;
    }
}

영화를 예매하자

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();
                case PERCENT_DISCOUNT -> discountAmount = movie.getFee().times(movie.getDiscountPercent());
                case NONE_DISCOUNT -> discountAmount = Money.ZERO;
            }
            
            fee = movie.getFee().minus(discountAmount);
        } else {
            fee = movie.getFee();
        }
        
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

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

02. 설계 트레이오프

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

캡슐화

상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서다. 여기서 구현이란 나중에 변경될 가능성이 높은 어떤 것을 가리킨다. 객체지향이 강력한 이유는 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 조절할 수 있는 장치를 제공하기 때문이다.

변경될 가능성이 높은 부분을 구현이라고 부르고 상대적으로 안정적인 부분을 인터페이스라고 부른다는 사실을 기억하라. 객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것이다.

지금까지 설명한 내용에서 알 수 있는 것처럼 객체지향에서 가장 중요한 원리는 캡슐화다.

응집도와 결합도

응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다.
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도다.

응집도가 높을수록 변경의 대상과 범위과 명확해지기 때문에 코드를 변경하기 쉬워진다. 변경으로 인해 수정되는 부분을 파악하기 위해 코드 구석구석을 헤매고 다니거나 여러 모듈을 동시에 수정할 필요가 없으며 변경을 반영하기 위해 오직 하나의 모듈만 수정하면 된다.

낮은 결합도를 가진 왼쪽 설계에서는 모듈 A를 변경했을 때 오직 하나의 모듈만 영향 받는다는걸 알 수 있다. 반면 높은 결합도를 가진 오른쪽의 설계에서는 모듈 A를 변경했을 때 4개의 모듈을 동시에 변경해야 한다.

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

데이터 중심의 설계가 가진 문제점을 다음과 같이 요약할 수 있다.

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

캡슐화

public class Movie {
    private Money fee;

    public Money getFee() {
        return fee;
    }

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

위 코드는 직접 객체의 내부에 접근할 수 없기 때문에 캡슐화 원칙을 지키는 것처럼 보인다. 하지만 접근자와 수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.

높은 결합도

데이터 중심 설계는 접근자와 수정자를 통해 내부 구현을 인터페이스의 일부로 만들기 때문에 캡슐화를 위반한다.

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        // ...
        Money fee;
        if (discountable) {
			// ...
            fee = movie.getFee().minus(discountAmount);
        } else {
            fee = movie.getFee();
        }

		// ...
    }
}

이 코드에서 알 수 있는 것처럼 ReservationAgency는 한 명의 예매 요금을 계산하기 위해 MoviegetFee 메서드를 호출하며 계산된 결과를 Money 타입의 fee에 저장한다. 이때 fee 의 타입을 변경한다고 가정해보자.

이를 위해서는 getFee 메서드의 반환 타입도 함께 수정해야 할 것이다. 그리고 getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 할 것이다.

영화 예매 시스템을 살펴보면 대부분의 제어 로직을 ReservationAgency가 모든 데이터 객체에 의존한다는 것을 알 수 있다. DiscontCondition의 데이터가 변경되면 DiscontCondition뿐만 아니라 ReservationAgency도 함께 수정해야 한다. Screening의 데이터가 변경되면 Screening뿐만 아니라 ReservationAgency 도 함께 수정해야 한다. ReservationAgency는 모든 의존성이 모이는 결합도의 집결지다.

낮은 응집도

서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 공존할 때 모듈의 응집도가 낮다고 말한다.

아마 다음과 같은 수정사항이 발생하는 경우에 ReservationAgency의 코드를 수정해야 할 것이다.

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

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

  • 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다.
  • 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.

04. 자율적인 객체를 향해

캡슐화를 지켜라

캡슐화는 설계의 제 1원리다.

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

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

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

    public int getLeft() {
        return left;
    }

    public int getRight() {
        return right;
    }

    public int getGetRight() {
        return getRight;
    }

    public int getBottom() {
        return bottom;
    }

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

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

    public void setGetRight(int getRight) {
        this.getRight = getRight;
    }

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

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

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

이 코드에는 많은 문제점이 있다. 첫 번째는 '코드 중복'이 발생할 확률이 높다. 다른 곳에서도 사각형의 너비와 높이를 증가시키는 코드가 필요하다면 아마 그곳에서도 getRightgetBottom 메서드를 호출해서 right와 bottom을 가져온 후 수정자 메서드를 이용해 값을 설정하는 유사한 코드가 존재할 것이다.

두 번째 문제점은 '변경에 취약'하다는 점이다. Rectangle이 right와 bottom 대신 length와 height를 이용해서 사각형을 표현한다고 수정한다고 가정해보자. 접근자와 수정자는 내부 구현을 인터페이스의 일부로 만들기 때문에 현재의 Rectangle 클래스는 int 타입의 top, left, right, bottom이라는 4가지 인스턴스 변수의 존재 사실을 인터페이스를 통해 외부에 노출시키게 된다.

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

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

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

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

우리가 상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서다. 객체는 단순한 데이터 제공자가 아니다. 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.

따라서 객체를 설계할 때 다음과 같은 두 개의 개별적인 질문으로 분리해야 한다.

  • 이 객체가 어떤 데이터를 포함해야 하는가?
  • 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?

다시 영화 예매 시스템 예제로 돌아가 ReservationAgency로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨보자.

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime 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;
}

두 번째 질문은 이 데이터에 대해 수행할 수 있는 오퍼레이션은 무엇인가를 묻는 것이다. Movie가 포함하는 데이터를 보면 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요할 것 같다.

public class Movie {
	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 calculateNoneDiscountFee() {
    	if (movieType != MovieType.NONE_DISCOUNT) {
        	throw new IllegalArgumentException();
	    }

    	return fee;
	}
}

MovieDiscountCondition의 목록을 포함하기 때문에 할인 여부를 판단하는 오퍼레이션 역시 포함해야 한다. isDiscountable 메서드를 추가하자.

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

        return false;
    }
}

Movie의 isDiscountable 메서드는 discountConditions에 포함된 DiscountCondition을 하나씩 훑어 가면서 할인 조건의 타입을 체크한다. 만약 할인 조건이 기간 조건이라면 DiscountConditionisDiscountable(DayOfWeek, dayOfWeek, LocalTime whenScreend)를 호출하고 순번 조건이라면 DiscountConditionisDiscountable(int sequence) 메서드를 호출한다.

이제 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);
                }
            }
            case PERCENT_DISCOUNT -> {
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculatePercentDiscountedFee().times(audienceCount);
                }
            }
            case NONE_DISCOUNT -> {
                return movie.calculateNoneDiscountFee();
            }
        }
        
        return movie.calculateNoneDiscountFee();
    }
}

ReservationAgencyScreeningcalculateFee 메서드를 호출해 예매 요금을 계산한 후 계산된 요금을 이용해 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);
    }
}

두 번째 설계가 결합도 측면에서 ReservationAgency에 의존성이 몰려있던 첫 번째 설계보다는 개선된 것으로 보인다.

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

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

아직도 두 번째 설계에서도 문제가 발생한다. 그 이유를 살펴보자.

캡슐화 위반

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

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) {
		// ...
    }
}

DiscountCondition에 구현된 두 개의 isDiscountable 메서드를 자세히 살펴보면 이상한 점이 몇 군데 눈에 띈다.

isDiscountable(DayOfWeek dayOfWeek, LocalTime time) 메서드의 시그니처를 살펴보면 DayOfWeek 타입의 요일 정보와 LocalTime 타입의 시간 정보를 파라미터로 받는 것을 알 수 있다. 요일과 이 메서드는 객체 내부에 요일과 시간 정보가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있는 것이다. 두 번째 isDiscountable(int sequence) 메서드 역시 객체가 int 타입의 순번 정보를 포함하고 있음을 외부에 노출한다. 비록 setType 메서드는 없지만 getType 메서드를 통해 내부에 DiscountConditionType을 포함하고 있다는 정보 역시 노출시키고 있다.

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

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;

	public MovieType getMovieType() {
		// ...
	}

	public Money calculateAmountDiscountedFee() {
		// ...
	}

	public Money calculatePercentDiscountedFee() {
		// ...
	}

	public Money calculateNoneDiscountFee() {
		// ...
	}
}

Movie 역시 내부 구현을 인터페이스에 노출시키고 있다. 여기서 노출시키는 것은 할인 정책의 종류다. 할인 정책에는 금액 할인, 비율 할인, 미적용 세 가지가 존재한다는 사실을 드러내고 있다.

높은 결합도

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

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

    return false;
}
  • DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경되면 Movie를 수정해야 한다.
  • DiscountCondition의 종류가 추가되거나 삭제된다면 Movie 안의 if ~ else 구문을 수정해야 한다.
  • DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 MovieisDiscountable 메서드로 전달된 파라미터를 변경해야 한다. 이로 인해 MovieisDiscountable 메서드 시그니처도 변경될 것이고 결과적으로 메서드에 의존하는 Screening에 대한 변경을 초래할 것이다.

낮은 응집도

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

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

	    return movie.calculateNoneDiscountFee();
	}
}

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

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

  • 데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
  • 데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.

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

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

profile
서버 백엔드 개발자

0개의 댓글