객체지향 - 설계 품질과 트레이드 오프

dragonappear·2021년 6월 30일
0

OOP

목록 보기
2/3

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

책임 주도 설계라는 이름에서 알 수 있는 것처럼 역할,책임,협력 중에서 가장 중요한 것은 책임이다. 객체들이 수행할 책임이 적절하게 할당되지 못한 상황에서 원활한 협력도 기대할 수 없을 것이다. 역할은 책임의 집합이기 때문에 책임이 적절하지 못하면 역할 역시 협력과 조화를 이루지 못한다. 결국 책임이 객체지향 애플리케이션 전체의 품질을 결정하는 것이다.

객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동이다. 이 정의에는 객체지향 설계애 관한 두 가지 관점이 섞여있다. 첫 번째 고나점은 객체지향 설계의 핵심이 책임이라는 것이다. 두 번째 관점은 책임을 할당하는 작업이 응집도와 결합도 같은 설계 품질과 깊이 연관돼 있다는 것이다.

설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다. 훌륭한 설계란 합리적인 비용안에서 변경을 수용할 수 있는 구조를 만드는 것이다. 적절하 비용 안에서 쉽게 변경할 수 있는 설계는 응집도가 높고 서로 느슨하게 결합돼 있는 요소로 구성된다.

결합도와 응집도를 합리적인 수준으로 유지할 수 있는 중요한 원칙이 있다. 객체의 상태가 아니라 객체의 행동에 초점을 맞추는 것이다. 객체를 단순한 데이터의 집합으로 바라보는 시각은 객체의 내부 구현을 퍼블릭 인터페이스에 노출시키는 결과를 낳기 때문에 결과적으로 설계가 변경에 취약해진다. 이런 문제를 피할 수 있는 가장 좋은 방법은 객체의 책임에 초점을 맞추는 것이다. 책임은 객체의 상태에서 행동으로 나아가 객체와 객체 사이의 상호작용으로 설계 중심을 이동시키고, 결합도가 낮고 응집도가 높으며 구현을 효과적으로 캡슐화하는 객체들을 창조할 수 있는 기반을 제공한다.

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

데이터 중심의 Movie 클래스 역시 책임 중심의 Movie 클래스와 마찬가지로 영화를 표현하는 가장 기본적인 정보인 영화제목(title),사영시간(runningTime),기본요금(fee)을 인스턴스 변수로 포함한다. 하지만 기존의 설계와 동일한 부분은 여기까지다.

가장 두드러지는 차이점은 할인 조건의 목록이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것이다. 또한 할인 정책라는 별도의 클래스로 분리했던 이전예제와 달리 금액 할인 정책에 사용되는 할인 금액과 비율 할인 정책에 사용되는 할인 비율을 Movie 안에서 직접 정의하고 있다.

할인 정책은 영화별로 오직 하나만 지정할 수 있기 때문에 한 시점에 discountAmount와 discountPercent 중 하나의 값만 사용될 수 있다. 그렇다면 영화에 사용된 할인 정책의 종류를 어떻게 알 수 있을까? 할인 정책의 종류를 결정하는 것이 바로 movieType이다. movieType은 현재 영화에 설정된 할인 정책의 종류를 결정하는 열거형 타입인 MovieType의 인스턴스다.

public enum MovieType {
    AMOUNT_DISCOUNT, // 금액 할인 정책
    PERCENT_DISCOUNT, // 비율 할인 정책
    NONE_DISCOUNT // 미적용
}

movieType의 값이 AMOUNT_DISCOUNT라면 discountAmount에 저장된 값을 사용하고, PERCENT_DISCOUNT라면 discountPercent에 저장된 값을 사용한다. NONE_DISCOUNT인 경우에는 할인 정책을 적용하지 말아야 하기 때문에 discountAmount와 discountPercent 중 어떤 값도 사용하지 않는다.

이것은 말 그대로의 데이터 중심의 접근 방법이다. Movie가 할인 금액을 계산하는데 필요한 데이터는 무엇인가? 금액 할인 정책의 경우에는 할인 금액이 필요하고 비율 할인 정책의 경우에는 할인 비율이 필요하다. 이 데이터들은 discountAmount와 discountPercent라는 값으로 표현한다. 예매 가격을 계산하기 위해서는 Movie에 설정된 할인 정책이 무엇인지를 알아야 한다. 예매 가격을 계산하기 위해서는 Movie에 설정된 할인 정책이 무엇인지를 알아야한다. 어떤 데이터가 필요한가? MovieType을 정의하고 이 타입의 인스턴스를 속성으로 퐇마시켜 이 값에 따라 어떤 데이터를 사용하지를 결정한다.

데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다. 이 객체가 포함해야 하는 데이터는 무엇인가? 객체의 책임을 결정하기 전에 이런 질문의 반복에 휩쓸려 있다면 데이터 중심의 설계에 매몰돼 있을 확률이 높다. 특히 Movie 클래스의 경우처럼 객체의 종류를 저장하는 인스턴스 변수와 인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수를 하나의 클래스 안에 함께 포함시키는 방식은 데이터 중심의 설계 안에서 흔히 볼 수 있는 패턴이다.

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

 public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public Duration getRunningTime() {
        return runningTime;
    }

    public void setRunningTime(Duration runningTime) {
        this.runningTime = runningTime;
    }

    public Money getFee() {
        return fee;
    }

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

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

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

    public MovieType getMovieType() {
        return movieType;
    }

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

    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의 경우와 마찬가지로 순번 조건에서만 사용되는 데이터인 상영 순번(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 int getSequence() {
        return sequence;
    }

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

    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 클래스를 구현하자. 지금까지 했던 것과 동일하게 어떤 데이터를 포함해야 하는지를 결정하고 데이터를 캡슐화하기 위해 메서드를 추가하자.

import java.time.LocalDateTime;

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 int getSequence() {
       return sequence;
   }

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

   public LocalDateTime getWhenScreened() {
       return whenScreened;
   }

   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 fee, int audienceCount) {
        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(Money fee) {
        this.fee = fee;
    }

    public int getAudienceCount() {
        return audienceCount;
    }

    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 discountCondition : movie.getDiscountConditions()) {
            if(discountCondition.getType() == DiscountConditionType.PERIOD){
                discountable = screening.getWhenScreened().getDayOfWeek().equals(discountCondition.getDayOfWeek()) &&
                        discountCondition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <=0 &&
                        discountCondition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime())>=0;
            }else{
                discountable = discountCondition.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);
        }else{
            fee = movie.getFee();
        }
        
        return new Reservation(customer,screening,fee,audienceCount);
    }
}

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

설계 트레이드 오프:

객체지향 커뮤니티에서는 오랜 기간 동안 좋은 설계의 특징을 판단할수있는 기준에 관한 다양한 노의가 있어왔다. 여기서는 데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 캡슐화,응집도,결합도를 사용하겠다. 본격적으로 두 가지 방법을 비교하기 전에 세 가지 품질 척도의 의미를 살펴보자.

캡슐화:

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

변경될 가능성이 높은 부분을 구현이라고 부르고 상대적으로 안정적인 부분을 인터페이스라고 부른다. 객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하게 관계를 조절하는 것이다. 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는것이다.

설계가 필요한 이유는 요구사항이 변경되기 때문이고, 캡슐화가 중요한 이유는 불안정한 부분과 안정적인 부분을 분리해서 변경의 영향을 통헤할 수 있기 때문이다. 따라서 변겨으이 관점에서 설계의 품질을 판단하기 위해 캡슐화를 기준으로 삼을 수 있다.

정리하면 캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이다. 객체 내부에 무엇을 캡슐화해야하는가? 변경될 수 있는 어떤 것이라도 캡슐화해야한다. 이것이 바로 객체지향 설계의 핵심이다.

응집도와 결합도:

응집도는 모듈에 포함된 내부요소들이 연관돼있는 정도를 나타낸다. 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다. 모듈 내의 요소들이 서로 다른 목적을 추구한다면 그 모듈은 낮은 응집도를 가진다. 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.

결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도다. 어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 가진다. 어떤 모듈이 다른 모듈에 대해 꼭 필요한 지식만 알고있다면 두 모듈은 낮은 결합도를 가진다. 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.

좋은 설계란 오늘의 기능을 수행하면서 내일의 변경을 수용할수있는 설계다. 그리고 좋은 설계를 만들기 위해서는 높은 응집도와 낮은 결합도를 추구해야한다. 좋은 설계가 변경과 관련된것이고 응집도와 결합도의 정도가 설계의 품질을 결정한다면 다음과 같은 결론에 도달하게 된다. 응집도와 결합도는 변경과 관련된것이다.

높은 응집도와 낮은 결합도를 가진 설계를 추구해야하는 이유는 단 한가지다. 그것이 설계를 변경하기 쉽게 만들기 때문이다. 변경의 관점에서 응집도란 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다. 간단히 말해 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 응집도가 높은것이고 모듈의 일부만 변경된다면 응집도가 낮은것이다. 또한 하나의 변경데 애대 하나의 모듈만 변경된다면 응집도가 높지만 다수의 모듈이 함께 변경돼야 한다면 응집도가 낮은것이다.

변경과 응집도 사이의 관계를 그림으로 표현한 것이다. 왼쪽은 응집도가 높은 설계를 나타내며 오른쪽의 설계는 응집도가 낮은 설계를 나타낸다. 음영으로 칠해진 부분은 변경이 발생했을 때 수정되는 영역을 표현한것이다. 응집도가 높은 설계에서는 하나의 요구사항 변경을 반영하기 위해 오직 하나의 모듈만 수정하면 된다. 반면 응집도가 낮은 설계에서는 하나의 원인에 의해 변경해야 하는 부분이 다수의 모듈에 분산돼 있기 때문에 여러 모듈을 동시에 수정해야 한다.

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

기능적인 측면에서만 놓고 보면 이번 장에서 구현한 데이터 중심의 설계는 2장에서 구현한 책임 중심의 설계와 완전히 동일하다. 하지만 설계 관점에서는 완전히 다르다. 근번적인 차이점은 캡슐화를 다루는 방식이다.

데이터 중심의 설계는 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다. 반면 책임 중심의 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화한다.

캡슐화의 정도가 객체의 응집도와 결합도를 결정한다는 사실을 기억하라. 데이터 중심의 설계는 캡슐화를 위한하기 쉽기 때문에 책임 중심의 설계에 비해 응집도가 낮고 결합도가 높은 객체들을 양산하게 될 가능성이 높다.

요약하면 데이터 중심의 설계가 가진 대표적인 문제점을 다음과 같이 요약할 수 있다.
1. 캡슐화 위반
2. 높은 결합도
3. 낮은 응집도

각 문제점을 좀 더 자세히 살펴보자.

캡슐화 위반

데이터 중심으로 설계한 Movie 클래스를 보면 오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있다는 것을 알 수있다. 예를 들어, fee의 값을 읽거나 수정하기 위해서는 getFee 메서드 와 setFee 메서드를 사용해야만 한다.

    public Money getFee() {
        return fee;
    }

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

위 코드는 직접 객체의 내부에 접근할 수 없기 때문에 캡슐화의 원칙을 지키고 있는 것처럼 보인다. 정말 그럴까? 안타깝게도 접근자와 수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다. getFee메서드와 setFee 메서드는 Movie 내부에 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.

높은 결합도

지금까지 살펴본 것처럼 데이터 중심의 설계는 접근자와 수정자를 통해 내부 구현을 인터페이스의 일부로 만들기 때문에 캡슐화를 위반한다. 객체 내부의 구현이 객체의 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미한다. 그리고 더 나쁜 소식은 단지 객체의 내부 구현을 변경했음에도 이 인터페이스에 의존하는 모든 클라이언트들도 함께 변경해야한다는 것이다.

ReservationAgency의 코드를 다시보자

        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);
        }else{
            fee = movie.getFee();
        }

ReservationAgency는 한 명의 예매 요금을 계산하기 위해 Movie의 getFee 메서드를 호출하며 계산된 결과를 Money 타입의 fee에 저장한다. 이 떄 fee의 타입을 변경한다고 가정해보자. 이를 위해서는 getFee 메서더의 반환타입도 함께 수정해야 할 것이다. 그리고 getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 할 것이다.

결합도 측면에서 데이터 중심 설계가 가지는 또 다른 단점은 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것이다. 이 결합도로 인해 어떤 데이터 객체를 변경하더라도 제어 객체를 함께 변경할 수 밖에 없다.

0개의 댓글