pandaterry's 개발로그

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

카테고리 없음

[책 | 오브젝트] CH4. 설계품질과 트레이드오프

pandaterry 2025. 11. 16. 15:11

CH4. 설계 품질과 트레이드오프 - 객체지향 설계의 핵심

CH4. 설계 품질과 트레이드오프

도입부

공감이 되는 문장

설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다.

훌륭한 설계는?

  • 합리적인 비용 안에서 변경을 수용할 수 있는 구조
  • 결합도가 낮고 응집도가 높은 설계

훌륭하지 못한 설계는?

  • 객체를 단순히 데이터의 집합으로 보는 설계 → 변경에 취약 (구현이 인터페이스에 노출)

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

시스템을 객체로 분할하는 법

  1. 상태를 중심으로 분할 (상태 = 데이터)
  2. 책임 중심으로 분할

상태(데이터)를 중심으로 객체로 분할

  • 객체: 자신이 포함하고 있는 데이터를 조작하는데 필요한 오퍼레이션(CRUD 등)을 정의, 독립된 데이터 덩어리
  • 초점: 객체의 상태에 초점

책임을 중심으로 객체를 분할

  • 객체: 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관, 협력하는 공동체의 일원
  • 초점: 객체의 행동에 초점

왜 상태 중심 분할은 변경에 취약한가?

  1. 객체의 상태 = 상태 자체는 구현에 속한다. 구현에 사용되는 것이 상태이다.
  2. 구현은 변하기 쉽다.
  3. 그럼 구현이 변하면 상태도 당연히 변할 수 있다.
  4. 의존하는 모든 객체에 변경이 전파된다.
  5. 그래서 변경에 취약하다는 것이다.

반대로, 책임 중심 분할은 왜 변경에 튼튼한가?

  1. 객체의 책임은 인터페이스에 속한다.
  2. 필요한 상태를 캡슐화하여 절대 외부에 노출하지 않는다.
  3. 변경을 해도 파장이 외부로 나가는 것을 방지한다.

데이터 중심 설계

Movie 객체

프로젝트의 실제 Movie 클래스를 살펴보겠습니다:

src/main/java/.../chapter04/Movie.java
/**
 * 데이터 중심 설계 방식(잘못된 설계)
 */
@Getter
@Setter
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;

    // ... 할인 금액 계산 메서드들 ...
}

title, runningTime, fee 등은 기존 설계와 동일하다. 하지만, discountConditions가 변수로 Movie에 직접 들어가고, 할인 금액인 discountAmount, 할인 비율인 discountPercent가 직접 들어가 있다는 큰 차이가 있다.

영화에 사용된 할인 정책 종류를 아는 법

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

이 enum을 통해 discountAmount를 사용할지 discountPercent를 사용할지 정하는 방식이다.

데이터 집중하는 사고방식

  • 이 객체가 포함해야 하는 데이터, 그 자체에 집중한다.
  • 만약 책임을 결정하기 전에 이런 질문을 반복했다면 데이터 중심 설계에 매몰돼 있을 확률이 높다.
  • 가장 많이 보이는 유형이 movieTypediscountAmount/discountPercent처럼 종류와 사용될 변수를 같이 저장하는 것이 이런 설계에서 자주 보이는 패턴이다.

예매를 처리하는 ReservationAgency

데이터 중심 설계에서의 ReservationAgency는 다음과 같습니다. 실제 프로젝트에서는 리팩토링 전 코드가 주석으로 남아 있습니다:

src/main/java/.../chapter04/ReservationAgency.java
public class ReservationAgency {
    // 리팩토링 후
    public Reservation reserve(Screening screening, Customer customer, int audienceCount){
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);
    }

    // 리팩토링 전 (데이터 중심 설계)
    // 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(); 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 메서드 고찰

  • reserve는 2개의 영역으로 분할됩니다:
    • DiscountCondition에 대해 루프를 돌면서 할인 가능 여부를 확인하는 for 문
    • discountable 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산하는 if문
  • 이 클래스만 봐도 클래스를 다양하게 만든다고 해서 무조건 OOP가 아니라는 것을 알 수 있습니다.

2. 설계 트레이드오프

데이터 중심 VS 객체 중심

캡슐화, 응집도, 결합도라는 3가지 측면에서 평가할 생각이다.

캡슐화란?

  • 객체 안에 상태와 행동을 모으는 이유: 객체의 내부 구현(변하기 쉬운 것)을 외부로부터 감추기 위함이다.
  • 객체를 통해 감추게 되면 변경의 여파를 통제할 수 있다.
    • 구현: 변경이 쉬운 영역
    • 인터페이스: 상대적으로 안정적인 부분
  • 감추는 행위 = 캡슐화
  • 설계의 존재 목적: 요구사항이 변경되기 쉽기 때문에 이를 통제하기 위해 설계를 하는 것. 이때 불안정한 부분과 안정된 부분을 구분하자는 것이다.

응집도 & 결합도

응집도

모듈에 포함된 내부 요소들이 연관돼 있는 정도. 하나의 목적을 위해 긴밀하게 협력하는가에 대한 척도

결합도

의존성 정도를 나타내며, 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지 나타내는 척도.

좋은 설계란?

  • 좋은 설계 = 응집도가 높고, 결합도가 낮은 설계
  • 응집도: 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도
    • 모듈 전체가 함께 변경되면 응집도가 높고, 모듈의 일부만 변경되면 응집도가 낮은 것
    • 하나의 변경에 대해 하나의 모듈만 변경되면 응집도가 높은 것, 다수의 모듈이 함께 변경되면 낮은 것
  • 결합도: 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도
    • 한 모듈의 변경으로 변경해야 하는 모듈의 수가 많다면 결합도가 높은 것

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

캡슐화의 정도 = 응집도와 결합도를 결정한다.

캡슐화 위반

src/main/java/.../chapter04/Movie2.java
// 캡슐화 위반
@Getter
@Setter
public class Movie2 {
    private Money fee;
}
  • 객체가 수행할 책임이 아닌 저장할 데이터에 초점을 맞췄다. (set~, get~)
  • 이렇게 되면 과도하게 접근자와 수정자에 의존하게 되는데 이는 추측에 대한 설계 전략이라 부른다.
    • 협력을 고려하지 않고 객체가 다양한 상황에서 사용될 수 있을 것이라 막연한 추측을 기반으로 설계한다는 것이다.
    • 이렇게 되면 상태가 드러나는 메서드를 과도하게 많이 추가해야 한다는 압박에 시달리게 된다.
    • 그럼 결국 캡슐화를 위반할 수밖에 없다.

높은 결합도

public class ReservationAgency{
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Money fee;
        if (discountable) {
            ...
            fee = ...
        } else {
            fee = movie.getFee();  // 내부 구현에 직접 접근
        }
        ...
    }
}
  • fee의 타입을 변경한다고 가정할 때, getFee()의 메서드의 반환 타입도 수정이 되고, getFee 메서드를 호출하는 reserve 구현도 당연히 수정해야 한다.
  • 이는 정상적으로 캡슐화가 안된 것일뿐더러 이를 호출하는 클라이언트가 객체의 구현에 강하게 결합된다.
  • 데이터 중심 설계의 큰 단점이 이처럼 하나의 특정 객체에 제어 로직이 집중된다는 것이다.
    • 이렇게 되면 데이터가 변경되면 제어 객체를 함께 변경할 수밖에 없다.

낮은 응집도

ReservationAgency는 다음과 같은 수정사항이 있을 때, 코드를 수정해야만 한다:

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

낮은 응집도가 발생시키는 문제

  • 변경의 이유가 다른 코드를 하나의 모듈에 뭉치면 → 변경과 상관없는 코드들이 영향
  • 하나의 요구사항을 반영하기 위해 → 동시에 상관없는 여러 모듈을 수정

4. 자율적인 객체를 향해

캡슐화를 지켜라

다음과 같은 Rectangle 클래스가 있다고 가정해보자:

@Getter
@Setter
@AllArgsConstructor
class Rectangle{
    private int left;
    private int top;
    private int right;
    private int bottom;
}

이 사각형의 너비와 높이를 증가시키는 코드가 필요하다고 가정해보자.

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

문제점

  • 코드 중복이 발생할 확률이 높다. 다른 클래스도 필요하다면 계속 get~을 통해 가져와서 계산하는 코드가 필요해진다.
  • 변경에 취약하다. get~을 통해 내부 구현이 노출되었기 때문에 이 중에 하나라도 변경되면 이 메서드들은 모두 수정이 가해져야 한다.

개선된 방식은 다음과 같다:

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

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

상태와 행동을 객체라는 단위로 하나로 묶는 이유는 객체 스스로 상태를 처리할 수 있게 하기 위해서이다.

다음 두 질문으로 객체에 내부 상태를 저장하는 방식과 저장된 상태에 대해 호출할 수 있는 오퍼레이션의 집합을 얻을 수 있다:

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

ReservationAgency를 고쳐보자

1. 어떤 데이터를 포함해야 하는가?

src/main/java/.../chapter04/DiscountCondition.java
@Getter
@Setter
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    // ...
}

이건 이미 DiscountCondition이 관리해야 하는 데이터를 결정해놓았으니 넘어가자.

2. 이 데이터에 대해 수행할 수 있는 오퍼레이션은?

DiscountCondition은 순번 조건일 경우 sequence를 이용해서 할인 여부를 결정, 기간 조건일 경우엔 dayOfWeek, startTime, endTime을 통해 결정한다.

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를 고쳐보자

1. Movie는 이미 상태가 정의되어 있다

실제 프로젝트의 Movie 클래스는 다음과 같습니다:

src/main/java/.../chapter04/Movie.java
/**
 * 데이터 중심 설계 방식(잘못된 설계)
 */
@Getter
@Setter
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 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;
    }
}

2. 이 데이터를 처리하기 위해 어떤 오퍼레이션이 필요한지?

Movie는 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요할 것 같다.

위 코드에서 볼 수 있듯이, Movie 클래스에 여러 할인 계산 메서드들이 추가되었습니다. 하지만 여전히 문제점이 남아 있습니다.

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

이 정도 캡슐화도 사실 만족할 정도는 아니다. 이것마저도 데이터 중심의 설계 방식에 속한다고 볼 수 있다. 아직도 문제가 있기 때문이다.

캡슐화 위반

개선된 DiscountCondition 클래스도 여전히 문제가 있습니다:

src/main/java/.../chapter04/DiscountCondition.java
@Getter
@Setter
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

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

    public boolean isDiscountable(int sequence) {
        // ...
    }
}
  • isDiscountable은 객체 내부의 상태인 DayOfWeek, LocalTime이 인자로 받게 되면서 인스턴스 변수로 사용되고 있다는 사실을 외부에 노출하고 있는 것이다. sequence도 동일하다.

Movie 클래스도 비슷한 문제가 있습니다:

  • MovieisDiscountable과는 다르지만, 인자가 아닌 메서드명에 할인 정책의 종류를 노출시키고 있다. 만약 할인 정책이 추가되거나 제거되면 의존하는 클라이언트가 모두 영향을 받을 것이다.

높은 결합도

  • DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경된다면 Movie를 수정해야 한다.
  • DiscountCondition의 종류가 추가되거나 삭제된다면 Movie의 if-else문을 수정해야 한다.
  • DiscountCondition 만족 여부 판단 로직의 정보가 변경된다면 파라미터를 변경해야 한다. 이러면 Screening도 변경이 된다.

낮은 응집도

  • 할인 조건의 종류를 변경하려면 DiscountCondition, Movie, Screening을 모두 수정해야 한다. 이렇게 여러 곳을 변경하는 것은 응집도가 낮다는 것이다. 왜냐면 캡슐화를 위반했으니까 말이다.
  • 원래 DiscountCondition이나 Movie에 위치해야 하는 로직이 Screening까지 새어나왔기 때문이다.

데이터 중심 설계의 문제점

핵심 문제점 요약

  • 너무 이른 데이터 중심 사고: 데이터 중심 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에, 너무 이르게 내부 구현에 초점을 맞춘다.
  • 절차적 프로그래밍 방식: 데이터 중심 설계는 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 접근자(getter)와 수정자(setter)를 과도하게 추가하게 된다.
  • 캡슐화 실패: 결론적으로, 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패한다.

느낀점 및 인사이트

학습을 통해 얻은 인사이트

이번 장을 통해 객체지향 설계의 핵심을 다시 한 번 생각해볼 수 있었다. 단순히 클래스를 만들고 getter/setter를 추가하는 것이 객체지향 프로그래밍이 아니라, 객체가 자신의 데이터와 행동을 책임지고 다른 객체와 협력하는 것이 진정한 객체지향 설계임을 깨달았다.

특히 인상 깊었던 점은 "책임 중심으로 객체를 분할"하는 것의 중요성이었다. 데이터 중심 설계에서는 변경이 발생하면 여러 곳에 영향이 퍼지는 반면, 책임 중심 설계에서는 변경의 여파가 최소화된다. 이는 실제 개발에서도 매우 중요한 교훈이다.

또한, 완벽한 캡슐화를 달성하는 것이 얼마나 어려운지도 느꼈다. 처음에는 단순히 getter/setter를 제거하는 것으로 해결될 것 같았지만, 실제로는 메서드 시그니처나 내부 상태 노출 등 다양한 형태로 캡슐화 위반이 발생할 수 있다는 것을 알게 되었다.

앞으로 코드를 작성할 때는 "이 객체가 무엇을 해야 하는가?"라는 질문을 먼저 던지고, 데이터가 아닌 책임을 중심으로 설계를 시작해야겠다는 다짐을 하게 되었다.


결론

데이터 중심 설계는 직관적이고 이해하기 쉽지만, 변경에 취약하다는 치명적인 단점이 있다. 반면 책임 중심 설계는 초기 이해가 어려울 수 있지만, 변경에 강하고 유지보수가 용이한 설계를 만들 수 있다.

훌륭한 설계는 결합도가 낮고 응집도가 높은 설계이며, 이를 달성하기 위해서는 적절한 캡슐화가 필수적이다. 객체지향 프로그래밍에서 객체는 단순한 데이터 저장소가 아니라, 자신의 상태와 행동을 책임지는 자율적인 존재여야 한다.

설계할 때는 "무엇을 할 것인가(What)"를 먼저 결정하고, 그 다음에 "어떻게 할 것인가(How)"를 결정해야 한다. 데이터 중심 설계는 이 순서를 뒤집어서 발생하는 문제점들이 많다. 올바른 객체지향 설계를 위해서는 책임과 협력에 집중해야 한다.