개발자는 기록이 답이다

오브젝트 4장 - 설계 품질과 트레이드오프(2) 본문

기술 서적/OOP

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

slow-walker 2023. 12. 12. 00:23

 

4. 자율적인 객체를 향해

 

캡슐화를 지켜라

 

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

데이터 중심의 설계가 낮은 응집도와 높은 결합도라는 문제를 가진 것은 캡슐화의 원칙을 위반했기 때문이다.

 

객체는 자신이 어떤 데이터를 가지고 있는지를 내부에 캡슐화하고 외부에 공개하면 안된다.

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

 

여기서 메서드란 단순히 속성 하나의 값을 반환하거나 변경하는 접근자나 수정자를 의미하는게 아니다!

객체가 책임져야 하는 무언가를 수행하는 메서드다!

 

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

 

사각형을 표현하는 간단한 클래스를 살펴보자.

아래 클래스는 사각형의 좌표를 포함하고, 각 속성에 대한 접근자와 수정자 메서드를 제공한다.

package chapter4.movie;

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 int getTop() {
        return top;
    }

    public int getRight() {
        return right;
    }

    public int getBottom() {
        return bottom;
    }

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

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

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

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

 

이 사각형의 너비와 높이를 증가시키는 코드가 아래와 같이 구현되어있다고 가정하자.

package chapter4.movie;

public class AnyClass {

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

 

  • 문제점 1. 코드 중복이 많다 
    • 접근자를 통해 값을 가져온 후 수정자를 이용해 값을 설정하는 유사한 코드가 존재한다
  • 문제점 2. 변경에 취약하다
    • right와 bottom대신 length와 height를 이용해서 사각형을 표현하도록 수정한다고 하면, 접근자와 수정자는 내부 구현을 인터페이스의 일부로 만들기 때문에 현재 Rectancle클래스는 int타입의 4가지 인스턴스 변수의 존재 사실을 인터페이스를 통해 외부에 노출시킨다.
    • 그러면 getRight, setRight를 모두 getLength, setLenght로 바꿔야해서 기존 접근자 메서드를 사용하던 모든 코드에 영향을 끼친다.

캡슐화를 강화시켜서 Rectangle내부에서 너비와 높이를 조절하는 로직을 캡슐화하면 2가지 문제점을 해결할 수 있다.

자신의 크기는 스스로 증가시키도록 책임을 이동시켰다 = 객체가 자기 스스로 책임진다

package chapter4.movie;

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 void enlarge(int multiple) {
        right *= multiple;
        bottom *= multiple;
    }
}

 

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

 

객체는 단순히 데이터 제공자가 아니다.

객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.

 

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

import java.time.DayOfWeek;
import java.time.LocalTime;

public class DiscountCondition {
    private DiscountConditionType type;
    /**
     * 객체가 관리해야 할 데이터
     */
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime 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;
    }
}
package chapter4.movie;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
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 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;
    }
    
    // 할인 여부 판단 - 객체가 할인조건 목록 포함 중
    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;
    }
}
package chapter4.movie;

import java.time.LocalDateTime;

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);
                }
                break;
            case NONE_DISCOUNT:
                return movie.calculateNoneDiscountedFee().times(audienceCount);
        }
        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}
package chapter4.movie;

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에 의존성이 몰려있던 첫 번째 설계보다 개선되었다.

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

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

 

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

개선되긴 했지만 두 번째 설계 역시 데이터 중심의 설계 방식에 속한다.

첫 번째 설계에서 발생했던 대부분의 문제가 두 번째 설계에서도 여전히 발생하기 때문이다.

 

캡슐화 위반
package chapter4.movie;

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메서드의 시그니처를 살펴보면, DiscountCondition에 속성으로 포함돼 있는 요일정보와, 시간정보, 순번정보를 파라미터로 받는것을 알 수 있다. 이 메서드는 객체 내부에 해당 데이터가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있는 것이다.

 

비록 setType 메서드는없지만, getType메서드를 통해 내부에 DiscountConditionType을 포함하고 있다는 정보 역시 노출시키고 있다.

 

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

 

 

Movie역시 캡슐화가 부족하다, 영화 요금 계산을 위해 금액 할인 정책, 비율 할인 정책, 할인 미적용의 경우에 호출할 수 있는 3가지 메서드를 구현하고 있다.

package chapter4.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 calculateNoneDiscountedFee() {...}
}

 

DiscountCondition의 isDiscountable메서드는 시그니처를 통ㅇ해 객체 내부의 상태를 그대로 드러냈지만, Movie의 요금 계싼 메서드는 객체의 파라미터나 반환 값으로 내부에 포함된 속성에 대한 어떤 정보도 노출하지 않아서 캡슐화의 원칙을 지키고 있다고 생각할지도 모른다.

 

하지만 Movie 역시 내부 구현을 인터페이스에 노출 시키고 있다. 메서드 명에 할인 정책 종류를 들어내고 있기 때문이다.

만약 할인 정책이 추가되거나 제거된다면 이 메서드들에 의존하는 모든 클라이언트가 영향을 받을 것이다. 

 

🚩  캡슐화의 진정한 의미

단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다.
내부 속성을 외부로부터 감추는 것은 '데이터 캡슐화'라고 불리는 캡슐화의 한 종류일 뿐이다.
캡슐화란 변할 수 잇는 어떤 것이라도 감추는 것이다. 내부 구현의 변경으로 인해 외부의 객체가 영향을 받는다면 캡슐화를 위반한 것이다.  설계에서는 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화해야 한다[GOF94]

 

높은 결합도

 

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

package chapter4.movie;


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

 

낮은 응집도

 

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

하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거이다 ← 캡슐화 위반

package chapter4.movie;

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);
                }
                break;
            case NONE_DISCOUNT:
                return movie.calculateNoneDiscountedFee().times(audienceCount);
        }
        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}

 

 

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

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

 

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

 

데이터 중심 설계 방식에 익숙한 개발자들은 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다.

이것은 상태와 행동을 하나의 단위로 캡슐화하는 객체 지향 패러다임에 반하는 것이다.

 

데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다.

이로 인해 접근자와 수정자를 과도하게 추가하게 되고, 이 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에서 구현하게 된다.

접근자와 수정자는 public속성과 큰 차이가 없기 때문에 객체의 캡슐화는 완전히 무너질 수 밖에 없다.

 

비록 데이터를 처리하는 작업과 데이터를 같은 객체안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기가 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.

 

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

 

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

객체가 내부에 어떤 상태를 가지고 그 상태를 어떻게 관리하는가는 부가적인 문제다. 중요한 것은 객체가 다른 객체와 협력하는 방법이다.

 

데이터중심설계에서 초점은 객체 외부가 아니라 내부로 향한다.

실행 문맥에 대한 깊이있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스를 억지로 끼워 맞출 수 밖에 없다.

두 번째 설계가 바로 이러한 이유때문에 문제였는데, 객체의 인터페이스에 구현이 노출돼 있었기 때문에 협력이 구현 세부사항에 종속돼 있고, 객체의 내부 구현이 변경됐을 때 협력하는 객체 모두가 영향을 받을 수 밖에 없게 된 것이다.

 

Github 코드 링크

 

GitHub - codesejin/OOP-Object: [오브젝트] - 코드로 이해하는 객체지향 설계

[오브젝트] - 코드로 이해하는 객체지향 설계. Contribute to codesejin/OOP-Object development by creating an account on GitHub.

github.com