-
오브젝트 3 - 데이터 중심 설계 vs. 책임 주도 설계책책책 책을 읽읍시다/프로그래밍 2023. 2. 13. 14:31
데이터 중심 설계 예제로 보는 설계 품질과 트레이드 오프
객체지향 설계에서는 두 가지 방법을 이용해 시스템을 객체로 분할할 수 있다. 첫 번째 방법은 상태(데이터)를 분할의 중심축으로 삼는 방법이고, 두 번째 방법은 책임을 분할의 중심축으로 삼는 방법이다.
데이터 중심의 관점에서는 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의한다. 책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다. 데이터 중심의 관점은 객체의 상태에 초점을 맞추고 책임 중심의 관점은 객체의 행동에 초점을 맞춘다. 전자는 객체를 독립된 데이터 덩어리로 바라보고 후자는 객체를 협력하는 공동체의 일원으로 바라본다. 훌륭한 객체지향 설계는 책임에 초점을 맞춰야 하는데, 이유는 변경과 관련이 있다.
객체의 상태는 구현에 속한다. 구현은 불안정하기 때문에 변하기 쉽다. 상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다. 결과적으로 상태 변경은 인터페이스의 변경을 초래하며 이 인터페이스에 의존하는 모든 객체에게 변경의 영향이 퍼지게 된다. 따라서 데이터에 초점을 맞추는 설계는 변경에 취약할 수밖에 없다.
그에 비해 객체의 책임은 인터페이스에 속한다. 객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화함으로써 구현 변경에 대한파장이 외부로 퍼져나가는 것을 방지한다. 따라서 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있게 된다.
영화 예매 시스템을 데이터 중심 설계로 재구성해보자.
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() { return movieType; } public void setMovieType(MovieType movieType) { this.movieType = movieType; } 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 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; } }
MovieType
public enum MovieType { AMOUNT_DISCOUNT, // 금액 할인 정책 PERCENT_DISCOUNT, // 비율 할인 정책 NONE_DISCOUNT // 미적용 }
DiscountCondition
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; } }
DiscountConditionType
public enum DiscountConditionType { SEQUENCE, // 순번 조건 PERIOD // 기간 조건 }
Screening
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 LocalDateTime getWhenScreened() { return whenScreened; } public void setWhenScreened(LocalDateTime whenScreened) { this.whenScreened = whenScreened; } public int getSequence() { return sequence; } public void setSequence(int sequence) { this.sequence = sequence; } }
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 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); } }
객체들의 관계도는 아래와 같다. 이전에 비해 단촐하고 간단해보인다.
가장 두드러지는 차이점은 할인 조건의 목록(discountConditions)이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것이다. 또한 할인 정책을 DiscountPolicy라는 별도의 클래스로 분리했던 이전 예제와 달리 금액 할인 정책에 사용되는 할인 금액(discountAmount)과 비율 할인 정책에 사용되는 할인 비율(discountPercent)을 Movie 안에서 직접 정의하고 있다. 할인 정책은 영화별로 하나만 지정할 수 있는데 이를 결정하는 것이 movieType이다.
데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다. 이 객체가 포함해야 하는 데이터는 무엇인가? 객체의 책임을 결정하기 전에 이런 질문의 반복에 휩쓸려 있다면 데이터 중심의 설계에 매몰돼 있을 확률이 높다. 특히 Movie 클래스의 경우처럼 객체의 종류를 저장하는 인스턴스 변수(movieType)와 인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수(discountAmount, discountPercent)를 하나의 클래스 안에 함께 포함시키는 방식은 데이터 중심의 설계 안에서 흔히 볼 수 있는 패턴이다.
이제 이 설계를 책임 중심의 설계 방법과 비교해 보면서 두 방법의 장단점을 파악해보자. 그 전에 캡슐화, 응집도, 결합도에 대해 짚어보자.
캡슐화
객체지향에서 가장 중요한 원리는 캡슐화이다. 캡슐화는 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 한 종류다. 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는 것이다. 설계가 필요한 이유는 요구사항이 변경되기 때문이고, 캡슐화가 중요한 이유는 불안정한 부분과 안정적인 부분을 분리해서 변경의 영향을 통제할 수 있기 때문이다. 따라서 변경의 관점에서 설계의 품질을 판단하기 위해 캡슐화를 기준으로 삼을 수 있다.
정리하면 캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이다. 객체 내부에 무엇을 캡슐화해야 하는가? 변경될 수 있는 어떤 것이라도 캡슐화해야 한다. 이것이 바로 객체지향 설계의 핵심이다.
응집도와 결합도
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다. 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다. 모듈 내의 요소들이 서로 다른 목적을 추구한다면 그 모듈은 낮은 응집도를 가진다. 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다. 변경의 관점에서 응집도란 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다. 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 응집도가 높은 것이고 모듈의 일부만 변경된다면 응집도가 낮은 것이다. 또한 하나의 변경에 대해 하나의 모듈만 변경된다면 응집도가 높지만 다수의 모듈이 함께 변경돼야 한다면 응집도가 낮은 것이다.
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도다. 어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 가진다. 어떤 모듈이 다른 모듈에 대해 꼭 필요한 지식만 알고 있다면 두 모듈은 낮은 결합도를 가진다. 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다. 변경의 관점에서 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다. 하나의 모듈을 수정할 때 얼마나 많은 모듈을 함께 수정해야 하는지를 나타낸다. 따라서 결합도가 높으면 높을수록 함께 변경해야 하는 모듈의 수가 늘어나기 때문에 변경하기가 어려워진다.
문제점 1. 캡슐화 위반
인스턴수 변수들의 가시성을 모두 private으로 제한했지만, getter/setter가 문제다. Movie의 예를 들면 getFee 메서드와 setFee 메서드는 Movie 내부에 Money 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다. 캡슐화 원칙을 어기게 된 근본적인 원인은 객체가 수행할 책임이 아니라 내부에 저장할 데이터에 초점을 맞췄기 때문이다. 설계할 때 협력에 관해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 되는 경향이 있다. 객체가 사용될 문맥을 추측할 수밖에 없는 경우 개발자는 어떤 상황에서도 해당 객체가 사용될 수 있게 최대한 많은 접근자 메서드를 추가하게 되는 것이다.
앨런 홀럽(Allen Holub)은 이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략(design-by-guessing strategy)[Holub04]이라고 부른다. 객체가 사용될 협력을 고려하지 않고 객체가 다양한 상황에서 사용될 수 있을 것이라는 막연한 추측을 기반으로 설계한다. 따라서 프로그래머는 내부 상태를 드러내는 메서드를 최대한 많이 추가해야 한다는 압박에 시달릴 수밖에 없으며 결과적으로 대부분의 내부 구현이 퍼블릭 인터페이스에 그대로 노출될 수밖에 없어 캡슐화를 위반하고 변경에 취약해지게 된다.
문제점 2. 높은 결합도
ReservationAgency는 한 명의 예매 요금을 계산하기 위해 Movie의 getFee 메서드를 호출하며 계산된 결과를 Money 타입의 fee에 저장한다. 이때 fee의 타입을 변경한다고 가정해보자. 이를 위해서는 getFee 메서드의 반환 타입도 함께 수정해야 할 것이다. 그리고 getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 할 것이다.
fee의 타입 변경으로 인해 협력하는 클래스가 변경되기 때문에 getFee 메서드는 fee를 정상적으로 캡슐화하지 못한다. 사실 getFee 메서드를 사용하는 것은 인스턴스 변수 fee의 가시성을 private에서 public으로 변경하는 것과 거의 동일하다. 이처럼 데이터 중심 설계는 객체의 캡슐화를 약화시키기 때문에 클라이언트가 객체의 구현에 강하게 결합된다.
결합도 츠견에서 데이터 중심 설계가 가지는 또 다른 단점은 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것이다. 이 결합도로 인해 어떤 데이터 객체를 변경하더라도 제어 객체를 함께 변경할 수 밖에 없다. ReservationAgency가 모든 데이터 객체에 의존하듯이 말이다. DiscountCondition의 데이터가 변경되면 ReservationAgency도 함께 수정해주어야 하고, 다른 객체도 마찬가지이다. ReservationAgency는 모든 의존성이 모이는 결합도의 집결지이다. 시스템 안의 어떤 변경도 ReservationAgency의 변경을 유발한다. 이와 같이 데이터 중심의 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버리기 때문에 어떤 변경이라도 일단 발생하고 나면 시스템 전체가 요동칠 수밖에 없다.
문제점 3. 낮은 응집도
ReservationAgency의 예로 들어 변경과 응집도 사이의 관계를 살펴보자. 다음과 같은 수정사항이 발생하는 경우에 ReservationAgency의 코드를 수정해야 할 것이다.
- 할인 정책이 추가될 경우
- 할인 정책별로 할인 요금을 계산하는 방법이 변경될 경우
- 할인 조건이 추가되는 경우
- 할인 조건별로 할인 여부를 판단하는 방법이 변경될 경우
- 예매 요금을 계산하는 방법이 변경될 경우
낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다.
- 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다. 예를 들어 ReservationAgency 안에 할인 정책을 선택하는 코드와 할인 조건을 판단하는 코드가 함께 존재하기 때문에 새로운 할인 정책을 추가하는 작업이 할인 조건에도 영향을 미칠 수 있다. 어떤 코드를 수정한 후에 아무런 상관도 없던 코드에 문제가 발생하는 것은 모듈의 응집도가 낮을 때 발생하는 대표적인 증상이다.
- 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다. 응집도가 낮을 경우 다른 모듈에 위치해야 할 책임의 일부가 엉뚱한 곳에 위치하게 되기 때문이다. 새로운 할인 정책을 추가해야 한다고 가정해 보자. 이를 위해서는 MovieType에 새로운 할인 정책을 표현하는 열거형 값을 추가하고 ReservationAgency의 reserve 메서드의 switch 구문에 새로운 case 절을 추가해야 한다. 또한 새로운 할인 정책에 따라 할인 요금을 계산하기 위해 필요한 데이터도 Movie에 추가해야 한다. 하나의 요구사항 변화를 수용하기 위해 MovieType, ReservationAgency, Movie라는 세 개의 클래스를 함께 수정해야 하는 것이다.
현재의 설계는 새로운 할인 정책을 추가하거나 새로운 할인 조건을 추가하기 위해 하나 이상의 클래스를 동시에 수정해야 한다. 어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스를 수정해야 하는 것은 설계의 응집도가 낮다는 증거이다.
고쳐보자
객체 스스로 자신의 상태를 처리할 수 있도록 상태와 행동을 객체라는 하나의 단위로 묶는 것이다. 객체는 단순한 데이터 제공자가 아니다. 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 중요하다. 따라서 객체를 설계할 때 "이 객체가 어떤 데이터를 포함해야 하는가?"라는 질문은 다음과 같은 두 개의 개별적인 질문으로 분리해야 한다.
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
두 질문을 조합하면 객체 내부의 상태를 저장하는 방힉과 저장된 상태에 대해 호출할 수 있는 오퍼레이션의 집합을 얻을 수 있다. 다시 말해 새로운 데이터 타입을 만들 수 있는 것이다.
ReservationAgency에서 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨야 한다.
DiscountCondition
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; } }
DiscountCondition이 관리해야 할 데이터는 이미 있고, 이 데이터에 대해 수행할 수 있는 오퍼레이션인 isDiscountable을 추가하였다.
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() { 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; } }
마찬가지로 데이터는 이미 정의되어 있고, 타입별로 영화 요금을 계산하는 메서드와 할인 여부를 판단하는 메서드를 추가했다.
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); } 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); } }
Screening은 Movie가 금액 할인 정책이나 비율 할인 정책을 지원할 경우 Movie의 isDiscountable 메서드를 호출해 할인이 가능한지 여부를 판단한 후 적절한 Movie의 메서드를 호출해서 요금을 계산한다.
ReservationAgency
public class ReservationAgency { public Reservation reserve(Screening screening, Customer customer, int audienceCount) { Money fee = screening.calculateFee(audienceCount); return new Reservation(customer, screening, fee, audienceCount); } }
Screening의 calculateFee 메서드를 호출해 예매 요금을 계산한 후 계산된 요금을 이용해 Reservation으로 생성하는 것으로 간단해졌다.
여전히 부족한 데이터 중심 설계
캡슐화 관점에서 향상되었지만 아직 만족스럽지 못하다. 자신의 데이터를 스스로 처리하여 잘 된 것 같지만 DiscountCondition의 isDiscountable 메서드는 시그니처를 통해 객체 내부의 상태를 그대로 드러낸다. 객체가 어떤 인스턴스 변수를 가지고 있는지 파라미터에 나와있고, 만약 이 파라미터를 수정하면 해당 메서드를 사용하는 모든 클라이언트도 함꼐 수정되어야 한다. Movie 역시 할인 금액 계산과 관련된 3개 메서드명을 통해 할인 정책의 종류를 까발린다. 새로운 할인 정책이 추가되거나 제거되면 이 메서드들에 의존하는 모든 클라이언트가 영향을 받을 것이다. 따라서 Movie는 세 가지 할인 정책을 포함하고 있다는 내부 구현을 성공적으로 캡슐화하지 못한다.
이 예제는 캡슐화가 단순히 객체 내부의 데이터를 외부러부터 감추는 것 이상의 의미를 가진다는 것을 잘 보여준다. 사실 캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것을 의미한다. 내부 속성을 외부로부터 감추는 것은 '데이터 캡슐화'라고 불리는 캡슐화의 한 종류일 뿐이다.
캡슐화란 변할 수 있는 어떤 것이라도 감추는 것이다. 그것이 속성의 타입이건, 할인 정책의 종류건 상관 없이 내부 구현의 변경으로 인해 외부의 객체가 영향을 받는다면 캡슐화를 위반한 것이다. 설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화해야 한다[GOF94]. 이것이 캡슐화라는 용어를 통해 말하고자 하는 징정한 의미다.
정리하면 캡슐화란 변하는 어떤 것이든 감추는 것이다[Bain08, Shalloway01]. 그것이 무엇이든 구현과 관련된 것이라면 말이다.캡슐화 위반으로 인해 DiscountCondition의 내부 구현이 외부로 노출됐기 때문에 Movie와 DIscountCondition 사이의 결합도는 높을 수밖에 없어 아래와 같은 문제가 발생할 가능성이 있다.
- DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경된다면 Movie를 수정해야 한다.
- DiscountCondition의 종류가 추가되거나 삭제된다면 Movie 안의 if ~ else 구문을 수정해야 한다.
- 각 DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드로 전달된 파라미터를 변경해야 한다. 이로 인해 Movie의 isDiscountable 메서드 시그니처도 함께 변경될 것이고 결과적으로 이 메서드에 의존하는 Screening에 대한 변경을 초래할 것이다.
이 요소들은 DiscountCondition의 구현에 속하는데 인터페이스가 아닌 '구현'을 변경하는 경우에도 DiscountCondition에 의존하는 Movie를 변경해야 한다는 것은 두 객체 사이의 결합도가 높다는 것을 의미한다.
DiscountCondition 할인 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드를 호출하는 부분도 함께 변경해야 하고, 이로 인해 Screening에서 Movie의 isDiscountable 메서드를 호출하는 부분도 함께 변경해야 한다. 결과적으로 할인 조건의 종류를 변경하기 위해서는 DiscountCondition, Movie, Screening을 수정해야 한다. 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 것이다.
이게 다 모두 캡슐화를 위반해서 발생하는 문제이다. 캡슐화를 위반한 설계는 변경에 취약할 수밖에 없고, 데이터 중심 설계가 변경에 취약한 이유는 아래 두 가지이다.
- 데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
- 데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.
데이터 중심 설계 방식에 익숙한 개발자들은 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 이것은 상태와 행동을 하나의 단위로 캡슐화하는 객체지향 패러다임에 반하는 것이다. 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다. 이로 인해 접근자와 수정자를 과도하게 추가하게 되고 이 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다. 앞에서 설명한 것처럼 접근자와 수정자는 public 속성과 큰 차이가 없기 때문에 객체의 캡슐화는 완전히 무너질 수밖에 없다. 이것이 첫 번째 설계가 실패한 이유다.
비록 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다. 결과적으로 객체의 인터페이스는 구현을 캡슐화하는 데 실패하고 코드는 변경에 취약해진다. 이것이 두 번째 설계가 실패한 이유다.
결론적으로 데이터 중심의 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패하게 된다. 객체의 내부 구현이 객체의 인터페이스를 어지럽히고 객체의 응집도와 결합도에 나쁜 영향을 미치기 때문에 변경에 취약한 코드를 낳게 된다.
데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다. 객체지향 애플리케이션을 구현한다는 것은 협력하는 객체들의 공동체를 구축한다는 것을 의미한다. 따라서 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 적절한 객체를 결정하는 것이 가장 중요하다. 올바른 객체지향 설계의 무게 중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다. 객체가 내부에 어떤 상태를 가지고 그 상태를 어떻게 관리하는가는 부가적인 문제다. 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
안타깝게도 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. 실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때무에 이미 구현된 객체의 인터페이스를 억지로 끼워맞출 수밖에 없다.
책임 할당하기 : 책임 주도 설계
객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다. 객체에게 할당된 책임이 협력에 어울리지 않는다면 그 책임은 나쁜 것이다. 객체의 입장에서는 책임이 조금 어색해 보이더라도 협력에 적합하다면 그 책임은 좋은 것이다. 책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다. 협력에 적합한 책임을 수확하기 위해서는 객체를 결정한 후에 메세지를 선택하는 것이 아니라 메세지를 결정한 후에 객체를 선택해야 한다. 메세지가 존재하기 때문에 그 메세지를 처리할 객체가 필요한 것이다. 객체가 메세지를 선택하는 것이 아니라 메세지가 객체를 선택하게 해야 한다[Metz12].
메세지가 클라이언트의 의도를 표현한다는 사실에 주목하라. 객체를 결정하기 전에 객체가 수신할 메세지를 먼저 결정한다는 점 역시 주목하라. 클라이언트는 어떤 객체가 메세지를 수신할지 알지 못한다. 클라이언트는 단지 임의의 객체가 메세지를 수신할 것이라는 사실을 믿고 자신의 의도를 표현한 메세지를 전송할 뿐이다. 그리고 메세지를 수신하기로 결정된 객체는 메세지를 처리할 '책임'을 할당 받게 된다.
책임 할당을 위한 GRASP 패턴
여러 책임 할당 기법 중 가장 널리 알려진 것은 크레이그 라만(Craig Larman)이 패턴 형식으로 제안한 GRASP(General Responsibility Assignment Software Pattern)이다. 영화 예매 시스템(https://baby-care-dev.tistory.com/17)으로 돌아가 보자.
도메인 개념에서 출발하기
설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려 보는 것이 유용하다. 도메인 안에는 무수히 많은 개념들이 존재하며 이 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다. 따라서 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 유력한 후보는 바로 도메인 개념이다.
설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다. 단지 우리에게는 출발점만이 필요할 뿐이다. 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니다. 도메인 개념을 정리하는 데 너무 많은 시간을 들이지 말고 빠르게 설계와 구현을 진행하라.
정보 전문가에게 책임을 할당하라
메세지는 메세지를 수신할 객체가 아니라 메세지를 전송할 객체의 의도를 반영해서 결정해야 한다. 따라서 첫 번째 질문으로 다음과 같이 해볼 수 있다. "메세지를 전송할 객체는 무엇을 원하는가?" 영화 예매 시스템을 예로 들면 협력을 시작하는 객체는 미정이지만 이 객체가 원하는 것은 분명하다. 바로 영화를 예매하는 것이다. 따라서 메세지의 이름으로는 "예매하라"가 적절하다. 메세지를 결정했다면 이에 적합한 객체를 선택해야 한다. 두 번째 질문으로 "메세지를 수신할 적합한 객체는 누구인가?"라고 물어볼 수 있다. 이 질문에 답하기 위해서는 객체가 상태와 행동을 통일한 캡슐화의 단위라는 사실에 집중해야 한다. 객체는 자신의 상태를 스스로 처리하는 자율적인 존재여야 한다. 객체의 책임과 책임을 수행하는 데 필요한 상태는 동일한 객체 안에 존재해야 한다. 따라서 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. GRASP에서는 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다.
이 패턴에 따르면 예매하는 데 필요한 정보를 가장 많이 알고 있는 객체에게 "예매하라" 메세지를 처리할 책임을 할당해야 한다. '상영'이라는 도메인은 영화에 대한 정보와 상영 시간, 사영 순번처럼 영화 예매에 필요한 다양한 정보를 알고 있어 예매를 위한 정보 전문가이고 여기에 책임을 할당하는게 적합할 것이다. 만약 여기서 가격 계산이나 할인과 같은 스스로 처리할 수 없는 작업이 있다면 외부(영화, 할인 조건)에 도움을 요청해야 한다.
높은 응집도와 낮은 결합도
높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리인데, GRASP에서는 이를 LOW COUPLING(낮은 결합도) 패턴과 HIGH COHESION(높은 응집도) 패턴이라고 부른다. LOW COUPLING에 대해 먼저 살펴보자.
DiscountCondition은 Screening과도 협력해도 기능적으로 동일한 결과를 만들 수 있지만, Movie와 결합하면 더 유연한 설계를 얻을 수 있다. 위 도메인 모델을 살펴보면 도메인 상으로 Movie는 DiscountCondition의 목록을 속성으로 포함하고 있다. Movie와 DiscountCondition은 이미 결합돼 있기 때문에 Movie를 DiscountCondition과 협력하게 하면 설계 전체적으로 결합도를 추가하지 않고 협력을 완성할 수 있다. 하지만 Screening이 DiscountCondition과 협력할 경우에는 둘 사이에 새로운 결합도가 추가되기 때문에 결합도가 올라간다.
HIGH COHESION 관점에서도 살펴보자. Screening의 가장 중요한 책임은 예매를 생성하는 것인데, DiscountCOndition과 협력하면요금 계산과 관련된 책임 일부를 떠안아야 한다. 다시 말해서 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경해야 하는 것이다. Screening과 DiscountCondition이 서로 다른 이유로 변경되는 책이을 짊어지게 되므로 응집도가 낮아진다. 반면 Movie의 주된 책임은 영화 요금을 계산하는 것이다. 따라서 영화 요금을 계산하는 데 필요한 할인 조건을 판단하기 위해 Movie가 DiscountCondition과 협력하는 것은 응집도에 아무런 해도 끼치지 않게 된다.
창조자에게 객체 생성 책임을 할당하라
영화 예매 협력의 최종 결과물은 Reservation 인스턴스를 생성하는 것이다. 이것은 협력에 참여하는 어떤 객체에게는 Reservation 인스턴스를 생성할 책임을 할당해야 한다는 것이다. GRASP의 CREATOR(창조자) 패턴은 이 같은 경우에 사용할 수 있는 책임 할당 패턴으로서 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.
객체 A를 생성해야 할 때 아래 조건을 최대한 많이 만족하는 B에게 객체 생성의 책임을 할당해야 한다.
- B가 A 객체를 포함하거나 참조한다.
- B가 A 객체를 기록한다.
- B가 A 객체를 긴밀하게 사용한다.
- B가 A 객체를 초기화하는 데 필요한 데이터를 가지고 있다(이 경우 B는 A에 대한 정보 전문가이다)
CRETOR 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성할 책임을 맡기는 것이다. 생성될 객체에 대해 잘 알고 있어야 하거나 그 객체를 사용해야 하는 객체는 어떤 방식으로든 생성될 객체와 연결된 것이다. 다시 말해서 두 객체는 서로 결합된다. 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체적인 결합도에 영향을 미치지 않으므로 낮은 결합도를 유지할 수 있다.
Reservation을 잘 알고 있거나, 긴밀하게 사용하거나, 초기화에 필요한 데이터를 가지고 있는 객체는 바로 Screening이다. 예매 정보를 생성하는 데 필요한 영화, 상영 시간, 상영 순번 등에 대한 정보 전문가이며, 예매 요금을 계산하는 데 필수적인 Movie도 알고 있기 때문이다.
'책책책 책을 읽읍시다 > 프로그래밍' 카테고리의 다른 글
오브젝트 5 - 상속 vs. 합성 (0) 2023.02.14 오브젝트 4 - 메세지, 인터페이스, 의존성 (0) 2023.02.13 오브젝트 2 - 유연한 객체 협력 모델 만들기 (0) 2023.02.08 오브젝트 1 - 맛보기 (0) 2023.02.07 혼자 공부하는 컴퓨터구조 + 운영체제 - 운영체제 (0) 2023.01.17