BottleH Blog

Object - 4장 설계 품질과 트레이드오프

    Tags

  • java
Object - 4장 설계 품질과 트레이드오프 thumbnail

객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동

  • 객체지향 설계의 핵심은 책임
  • 책임을 할당하는 작업이 응집도와 결합도 같은 설계 품질과 깊이 연관되어 있음

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

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

  • 상태(데이터)를 분할의 중심축으로 삼는 방법
    • 객체의 상태는 객체가 저장해야 하는 데이터의 집합
    • 변경에 취약하다.
  • 책임을 분할의 중심축으로 삼는 방법
    • 이 방법이 훌륭한 객체지향 설계 방법이다.
    • 변경에 안정적이다.

🔖 4.1.1 데이터를 준비하자

데이터 중심의 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방법

/** * 데이터 중심 설계 영화 Class */ 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 enum MovieType { /** * 금액 할인 정책 */ AMOUNT_DISCOUNT, /** * 비율 할인 정책 */ PERCENT_DISCOUNT, /** * 미적용 */ NONE_DISCOUNT }
  • 데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다.
  • 내부 데이터가 객체의 엷은 막을 빠져나가 외부의 다른 객체들을 오염시키는 것을 막아야 한다.
    • 내부의 데이터 반환하는 접근자(accessor)
    • 데이터를 변경하는 수정자(mutator)
/** * 데이터 중심 설계 영화 Class */ public class Movie { /** * 제목 */ private String title; /** * 상영 시간 */ private Duration runningTime; /** * 기본 요금 */ @Getter @Setter private Money fee; /** * 할인 조건 목록 */ @Getter @Setter private List<DiscountCondition> discountConditions; /** * 할인 정책의 종류를 결정하는 변수 */ @Getter @Setter private MovieType movieType; /** * 할인 금액 */ @Getter @Setter private Money discountAmount; /** * 할인 비율 */ @Getter @Setter private double discountPercent; } @Setter @Getter public enum DiscountConditionType { /** * 순번 조건 */ SEQUENCE, /** * 기간 조건 */ PERIOD } /** * 할인 조건 class */ public class DiscountCondition { /** * 할인 조건 타입 */ private DiscountConditionType type; /** * 상영 순번 */ private int sequence; /** * 요일 */ private DayOfWeek dayOfWeek; /** * 시작 시간 */ private LocalTime startTime; /** * 종료 시간 */ private LocalTime endTime; } /** * 상영 Class */ @Setter @Getter public class Screening { /** * 영화 */ private Movie movie; /** * 순번 */ private int sequence; /** * 상영 시작 시간 */ private LocalDateTime whenScreened; } /** * 예매 정보 Class */ @Setter @Getter @AllArgsConstructor public class Reservation { /** * 고객 */ private Customer customer; /** * 상영 */ private Screening screening; /** * 예매 요금 */ private Money fee; /** * 인원 수 */ private int audienceCount; } /** * 고객 class */ @AllArgsConstructor public class Customer { private String name; private String id; }

🔖 4.1.2 영화를 예매하자

/** * 영화 예매 절차 구현 Class */ 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().isAfter(screening.getWhenScreened().toLocalTime()) && !condition.getEndTime().isBefore(screening.getWhenScreened().toLocalTime()); } 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); } }

📖 4.2 설계 트레이드오프

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

🔖 4.2.1 캡슐화

객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절

  • 구현: 변경될 가능성이 높은 부분
  • 인터페이스: 상대적으로 안정적인 부분

설계가 필요한 이유는 요구사항이 변경되기 때문이고, 캡슐화가 중요한 이유는 불안정한 부분과 안정적인 부분을 분리해서 변경의 영향을 통제할 수 있기 때문이다.

  • 변경의 관점에서 설계의 품질을 판단하기 위해 캡슐화를 기준으로 삼을 수 있다.

🔖 4.2.2 응집도와 결합도

응집도

  • 모듈에 포함된 내부 요소들이 연관돼 있는 정도
  • 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타냄
  • 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도

결합도

  • 의존성의 정도를 나타냄
  • 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도
  • 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지
  • 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도

일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 추구

  • 설계를 변경하기 쉽게 만들기 때문
  • 결합도가 높아도 상관 없는 경우가 있음
    • 변경될 확률이 매우 적은 안정적은 모듈에 의존하는 것
    • ex) java의 String, ArrayList

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

  • 데이터 중심의 설계는 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다.
    • 캡슐화 위반
    • 높은 결합도
    • 낮은 응집도
  • 책임 중심의 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화

🔖 4.3.1 캡슐화 위반

데이터 중심으로 설계한 Movie 클래스를 보면 오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있다.

  • 이 메서드들은 어떤 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.
  • 캡슐화의 원칙을 어기게 된 근본적인 원인은 객체가 내부에 저장할 데이터에 초점을 맞췄기 때문
  • 이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략(design-by-guessing strategy) 라고 한다.

🔖 4.3.2 높은 결합도

ReservationAgency 의 fee 타입을 변경한다고 가정해보면 협력하는 클래스가 변경된다. 즉, getFee 메서드를 사용하는 것은 인스턴스 변수 public 제어자를 쓰는 것과 거의 동일하다.

  • ReservationAgency는 모든 의존성이 모이는 결합도의 집결지
  • 데이터 중심의 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버리기 때문에 어떤 변경이라도 일단 발생하고 나면 시스템 전체가 요동칠 수밖에 없다.

🔖 4.3.3 낮은 응집도

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

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

어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스를 수정해야 하는 것은 설계의 응집도가 낮다는 증거

단일 책임 원칙(Single Respojnsibility Principle, SRP)

  • 클래스는 단 한 가지의 변경 이유만 가져야 한다는 것

📖 4.4 자율적인 객체를 향해

🔖 4.4.1 캡슐화를 지켜라

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

  • 여기서 메서드는 객체가 책임져야 하는 무언가를 수행하는 메서드
@Setter @Getter @AllArgsConstructor public class Rectangle { private int left; private int top; private int right; private int bottom; }

이 사각형의 너비와 높이를 증가시키는 코드가 필요하다면?

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

위 코드는 문제가 많다.

  • 코드 중복이 발생할 확률 ⬆️
  • 변경에 취약
public class Rectangle { public void enlarge(int multiple) { right *= multiple; bottom *= multiple; } }
  • 변경 주체를 외부의 객체에서 Rectangle로 이동

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

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

따라서 객체를 설계할 때 아래와 같은 두 개의 질문을 던져야 한다.

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

ReservationAgency로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨보자.

/** * 할인 조건 class */ @Setter @Getter public class DiscountCondition { /** * 할인 조건 타입 */ private DiscountConditionType type; /** * 상영 순번 */ private int sequence; /** * 요일 */ private DayOfWeek dayOfWeek; /** * 시작 시간 */ private LocalTime startTime; /** * 종료 시간 */ private LocalTime endTime; }
  • 첫 번째 질문은 DiscountCondition이 관리해야 하는 데이터를 결정해 놓았다.
public class DiscountCondition { public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) { if (type != DiscountConditionType.PERIOD) { throw new IllegalArgumentException(); } return this.dayOfWeek.equals(dayOfWeek) && !this.startTime.isAfter(time) && !this.endTime.isBefore(time); } public boolean isDiscountable(int sequence) { if (type != DiscountConditionType.SEQUENCE) { throw new IllegalArgumentException(); } return this.sequence == sequence; } }
  • 두 번째 질문은 할인 조건을 판단할 수 있게 두 개의 isDiscountable 메서드가 필요할 것이다.
public class Movie { /** * 제목 */ private String title; /** * 상영 시간 */ private Duration runningTime; /** * 기본 요금 */ @Getter @Setter private Money fee; /** * 할인 조건 목록 */ @Getter @Setter private List<DiscountCondition> discountConditions; /** * 할인 정책의 종류를 결정하는 변수 */ @Getter @Setter private MovieType movieType; /** * 할인 금액 */ @Getter @Setter private Money discountAmount; /** * 할인 비율 */ @Getter @Setter 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 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; } }
  • 두 번째 질문에 대한 오퍼레이션들이다.
/** * 상영 Class */ @Setter @Getter @AllArgsConstructor public class Screening { /** * 영화 */ private Movie movie; /** * 순번 */ private int sequence; /** * 상영 시작 시간 */ private LocalDateTime 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.calculateNoneDiscountedFee().times(audienceCount); } } return movie.calculateNoneDiscountedFee().times(audienceCount); } } /** * 영화 예매 절차 구현 Class */ public class ReservationAgency { public Reservation reserve(Screening screening, Customer customer, int audienceCount) { Money fee = screening.calculateFee(audienceCount); return new Reservation(customer, screening, fee, audienceCount); } }
  • 최소한 결합도 측면에서 개선되었다.

📖 4.5 하지만 여전히 부족하다

사실 본질적으로 두 번째 설계 역시 데이터 중심의 설계 방식에 속한다.

🔖 4.5.1 캡슐화 위반

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

  • 시간 정보가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있음.
  • DiscountCondition의 속성을 변경해야 한다면 해당 메서드를 사용하는 모든 client도 함께 수정되어야 한다.
  • 내부 구현의 변경이 외부로 퍼져나가는 파급 효과(ripple effect) 는 캡슐화가 부족하다는 명백한 증거

Movie 또한 문제가 있다.

  • 할인 정책의 종류를 노출시키고 잇다.
  • 새로운 할인 정책이 ㅜㅊ가되거나 제거된다면 모든 client가 영향을 받는다.

캡슐화란 변하는 어떤 것이든 감추는 것이다. 내부 속성을 외부로부터 감추는 것은 '데이터 캡슐화'라고 불리는 캡슐화의 한 종류일 뿐이다.

🔖 4.5.2 높은 결합도

두 객체 사이에 결합도가 높을 경우 한 객체의 구현을 변경할 때 다른 객체에게 변경의 영향이 전파될 확률이 높아진다.

  • DiscountCondition의 기간 할인 조건의 명칭이 변경된다면 Movie를 수정해야 한다.
  • DiscountCondition의 종류가 추가되거나 삭제된다면 Movie를 수정해야 한다.
  • DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 Movie를 수정해야 한다.

더 심각한 것은 변경의 여파가 두 객체들로 한정되지 않는 것이다.

🔖 4.5.3 낮은 응집도

Screening 또한 할인 조건의 종류를 변경하기 위해서는 수정이 불가피하다.

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

두 번째 설계가 첫 번째 설계보다 개선된 것은 사실이지만 데이터 중심의 설계가 가지는 문제점으로 인해 몸살을 앓고 있다는 점에는 변함이 없다.

📖 4.6 데이터 중심 설계의 문제점

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

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

첫 번째 설계 실패 이유

  • 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다.
  • 접근자와 수정자를 과도하게 추가
  • 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현

두 번째 설계 실패 이유

  • 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.
  • 객체의 인터페이스는 구현을 캡슐화하는 데 실패하고 코드는 변경에 취약

결론적으로 데이터 중심의 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패하게 된다.

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

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

  • 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다.
  • 실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정
Written by@BottleH
Back-End Developer

GitHub