책임 주도 설계라는 이름에서 알 수 있듯이 역할, 협력, 책임 중에서 가장 중요한
것은 책임이다. 책임이 어플리케이션 전체의 품질을 결정한다.
객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은
응집도를 가진 구조를 창조하는 활동이다.
위 정의에는 1) 객체지향 설계의 핵심이 책임이라는 것, 2) 책임을 할당하는 작업이
응집도와 결합도 같은 설계 품질과 깊이 연관돼 있다는 것 이렇게 두 관점을 포괄한다.
객체를 단순히 데이터의 집합으로 바라보는 시각은 객체의 내부 구현을 퍼블릭
인터페이스에 노출시키는 결과를 낳기 때문에 결과적으로 설계가 변경에 취약해진다.
이어지는 내용에서는 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 통해
객체지향적인 구조와의 차이를 파악해볼 것이다.
데이터 중심 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는
방법이다.
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;
}
OOP적인 설계와 두드러지는 차이점은 할인 조건의 목록이 인스턴스 변수로 Movie
내에 직접 포함되있고, 금액 할인 정책과 비율 할인 정책에 사용되는 할인 금액,
할인 비율 역시 필드로 정의되어 있다는 것이다.
할인 정책은 영화별로 하나만 지정할 수 있기 때문에 discountAmount와
discountPercent 중 하나의 값만 사용될 수 있다. 정책을 결정하는 것이 바로
movieType이다.
public enum MovieType {
AMOUNT_DISCOUNT,
PERCENT_DISCOUNT,
NONE_DISCOUNT
}
데이터 중심의 설계에선 객체가 포함해야 하는 데이터에 집중한다. 특히 Movie의
경우처럼 객체의 종류를 저장하는 인스턴스 변수(movieType)와 인스턴스의
종류에 따라 배타적으로 사용될 인스턴스 변수(discountAmount,
discountPolicy)를 한 클래스 안에 함께 포함시키는 방식은 데이터 중심
설계에서 흔히 있는 패턴이다.
이제 Movie 클래스에 접근자와 수정자를 설정해준다.
public class Movie {
//...
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Duration getRunningTime() {
return runningTime;
}
//...
}
할인 조건을 구현하는 데 필요한 데이터는 무엇일까? 할인 조건의 타입을 지정할
DiscountConditionType을 정의하자.
public enum DiscountConditionType {
SEQUENCE,
PERIOD
}
할인 조건을 구현해보자.
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
}
getter와 setter도 마저 구현해준다.
public class DiscountCondition {
// ...
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;
}
// ...
}
이어서 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;
}
// ...
}
영화 예매 시스템의 목적인 영화 예매를 위해 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;
}
// ...
}
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).times(audienceCount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
reserve 메서드는 크게 DiscountCondition에 대해 루프를 돌며 할인 가능
여부를 확인하는 for 문과 discountable 변수의 값을 체크하고 적절한
할인 정책에 따라 예매 요금을 계산하는 if 문으로 나뉜다.
데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 캡슐화, 응집도,
결합도를 사용하겠다.
상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터
감추기 위해서다. 객체지향이 강력한 이유는 변경의 파급을 조절할 수 있기 때문이다.
변경될 가능성이 높은 부분을 구현이라고 부르고 상대적으로 안정적인 부분을
인터페이스라고 부른다. 객체 설계를 위한 가장 기본적인 아이디어는 변경의
정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존토록
관계를 조절하는 것이다.
결론적으로 객체지향에서 가장 중요한 원리는 캡슐화이며, 불안정한 구현
세부사항을 안정적인 인터페이스 뒤로 캡슐화하는 것이다. 객체 내부의 변경될
수 있는 그 어떤 것이라도 캡슐화해야 한다.
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다. 모듈 내의
요소들이 하나의 목적을 위해 긴밀히 협력한다면 그 모듈은 높은 응집도를 가진다.
OOP 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임을 할당했는지를
나타낸다.
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을
갖고 있는지를 나타내는 척도다. OOP 관점에서 결합도는 객체간 협력에 필요한
적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 가진 설계를 의미한다.
이 설계가 좋은 이유는 변경이 쉽기 때문이다. 변경의 관점에서 응집도란 변경이
발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다.

변경의 관점에서 결합도는 한 모듈이 변경되기 위해 다른 모듈의 변경을
요구하는 정도로 측정할 수 있다.

내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우에도
결합도가 높다고 표현한다. 구현이 아닌 인터페이스에 의존하도록 코드를
작성해야 낮은 결합도를 얻을 수 있다.
결론적으로, 캡슐화를 지키면 모듈 내의 응집도는 높아지고 모듈 사이의 결합도는
낮아진다.
데이터 중심의 설계는 가진 대표적인 문제점은 다음과 같이 요약할 수 있다.
public class Movie {
private Money fee;
public Money getFee(){
return fee;
}
public void setFee(Money fee){
this.fee = fee;
}
}
위 코드는 객체 내부에 직접 접근할 수 없기 때문에 캡슐화를 준수하는 듯 보이나
실상은 그렇지 않다. getFee,setFee 메서드는 Money타입의 fee라는
이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.
객체가 사용될 문맥을 추측할 수 밖에 없는 경우, 개발자는 어떤 상황에서도
해당 객체가 사용될 수 있게 과도한 접근자, 수정자 메서드를 추가하게 된다.
이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략
(design-by-guessing strategy)라고 부른다. 협력을 고려하지 않고
막연한 추측을 기반으로 설계를 진행하면 캡슐화 원칙을 위반하는 변경에
취약한 설계를 얻게된다.
객체 내부의 구현이 인터페이스에 드러나면 클라이언트가 구현에 강하게 결합된다.
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount){
...
Money fee;
if(discountable){
...
fee = movie.getFee().minus(dicountedAmount).times(audienceCount);
}else{
fee = movie.getFee();
}
...
}
}
위 코드에서 만약 fee의 타입을 변경하면 ReservationAgency의 구현도
변경된 타입에 맞게 수정되어야 한다. getFee를 사용하는 것은 사실상
fee의 가시성을 public으로 두는 것과 다름 없다.
한편, 데이터 중심 설계는 데이터 객체들을 사용하는 제어 로직이 특정 객체
안에 집중되기 때문에 제어 객체가 다수의 데이터 객체에 강하게 결합되어
어떤 데이터 객체를 변경하더라도 제어 객체를 함께 변경할 수 밖에 없다.
이와 같이 데이터 중심 설계는 전체 시스템을 하나의 커다란 의존성 덩어리로
만들어 버린다.
만약 다음과 같은 수정이 발생할 경우 ReservationAgency의 코드를 수정해야
할 것이다.
낮은 응집도는 두 가지 측면에서 문제를 야기한다.
변경의 이유가 서로 다른 코드들을 하나의 모듈 내에 뭉쳐놓아 변경과 아무 상관이
없는 코드들이 영향을 받는다. ReservationAgency 안에 할인 정책을 선택하는
코드와 할인 조건을 판단하는 코드가 함께 존재해 할인 정책 추가 작업이 할인
조건에도 영향을 미칠 수 있다.
하나의 요구사항을 변경하기 위해 동시에 여러 모듈을 수정해야 한다. 새로운
할인 정책 추가시 MovieType에 새로운 열거형 값을 추가하고,
ReservationAgency의 reserve 메서드의 switch 구문에 새로운
case절도 추가해야 한다. 더불어 새 할인 정책에 따라 요금을 계산하기 위해
필요한 데이터도 Movie에 추가해야 한다.
SRP를 한마디로 요약하면 클래스는 단 한 가지의 변경 이유만을 가져야 한다는
것이다. 이 원칙의 맥락에서 '책임'이라는 말이 '변경의 이유'라는 의미로
사용된다. 앞서 말한 책임과 달리 변경과 관련된 더 큰 개념을 가리킨다.
객체는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 한다.
여기서 말하는 메서드란 단순한 접근자, 수정자를 의미하는 것이 아니다.
객체가 책임져야 하는 무언가를 수행하는 메서드다.
Retangele 클래스를 예시로 생각해보자
package chap4;
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 void setLeft(int left) {
this.left = left;
}
public int getTop() {
return top;
}
public void setTop(int top) {
this.top = top;
}
}
이 사각형의 너비와 높이를 증가시키는 코드가 필요하다고 가정해보자.
public class AnyClass {
void anyMethod(Rectangle rectangle, int multiple) {
rectangle.setRight(rectangle.getRight() * multiple);
rectangle.setBottom(rectangle.getBottom() * multiple);
// ...
}
}
이 코드는 일단 '코드 중복'이 발생할 확률이 높다는 문제를 가진다. 다른 곳에서도
사각형의 높이와 너비를 변경한다면 위 코드가 필요하다.
두번째 문제점은 변경에 취약하다는 것이다. 만약 right와 bottom 대신
length와 height를 이용해서 사각형을 표현토록 수정한다면 접근자와
수정자가 사용된 모든 코드가 변경되어야 한다.
캡슐화를 강화하여 Rectangle 스스로 자신의 크기를 증가시키도록 '책임을
이동'시키면 문제를 해결할 수 있다.
class Rectangle{
public void enlarge(int multiple){
right *= multiple;
bottom *= multiple;
}
}
객체는 단순한 데이터 제공자가 아니며 객체가 협력에 참여하며 수행할 책임을
정의하는 오퍼레이션이 중요하다. 따라서 객체를 설계할 때 "이 객체가 어떤
데이터를 포함해야 하는가?"라는 질문은 다음 두 개의 질문으로 분리해야 한다.
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
두번째 질문에 따라 DiscountCondition을 수정해보면 다음과 같다.
public class DiscountCondition {
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;
}
}
위와 같이 두 가지 할인 조건을 판별할 수 있게 isDiscountable 메서드를
구현했다. 이제 Movie에 두번째 질문을 적용하여 정책별로 요금을 계산하는
세 가지 메서드를 구현하자.
public class Movie {
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;
}
}
Movie는 DiscountCondition의 목록을 포함하기 때문에 할인 여부를
판단하는 오퍼레이션을 포함해야 한다.
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;
}
}
이제 Screening과 ReservationAgency도 적절히 수정해보자.
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);
}
}
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
개선된 설계는 데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는
객체 스스로 구현하고 있다.
DiscountCondition의 isDiscountable 메서드 시그니처를 살펴보면
DayofWeek 타입의 요일 정보와 LocalTime타입의 시간 정보를 파라미터로
받는 것을 알 수 있다. 인터페이스를 통해 외부에 해당 정보가 인스턴스 변수로
포함되어 있다는 사실을 노출하고 있는 것이다.
만약 DiscountCondition의 속성을 변경하게 되면 두 isDiscountable
메서드의 파라미터를 수정하며 해당 메서드를 사용하는 모든 클라이언트도 함께
수정해야 한다. 내부 구현의 변경이 외부로 퍼지는 파급 효과(ripple effect)는
캡슐화가 부족하다는 명백한 증거다.
Movie 역시 calculate{DiscountPolicy}DiscountedFee 메서드들에서
할인 정책이 세 종류가 존재한다는 사실을 외부에 공개하고 있다. 새로운 할인
정책이 추가되거나 제거된다면 의존하는 모든 클라이언트가 영향을 받는다.
결론적으로 ,캡슐화는 변화가 어떤 것이든 감추는 것이다.
캡슐화 위반으로 인해 DiscountCondition의 내부 구현이 외부로 노출되어
Movie와 DiscountCondition 사이의 결합도는 높을 수밖에 없다.
DiscountCondition의 변경은 Movie에게 다음과 같은 영향을 미칠 수 있다.
DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로Movie를 수정해야 한다.DiscountCondition의 종류가 추가, 삭제된다면 Movie안의 if-elseDiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면Movie의 isDiscountable로 전달된 파라미터를 변경해야 한다. 이로 인해Movie의 isDiscountable 시그니처도 함께 변경되고 이에 의존하는Screening도 변경될 것이다.DiscountCondition의 구현을 변경하는 경우 Movie를 변경해야 한다는
것은 두 객체 사이 결합도가 높다는 것을 의미한다.
DiscountCondition이 할인 여부를 판단하는 데 필요한 정보가 변경되면
Movie의 isDiscountable 메서드로 전달해야 하는 파라미터의 종류를
변경해야 하고, Screening에서 Movie의 isDiscountable 메서드를
호출하는 부분도 함께 변경해야 한다.
결과적으로 하나의 변경을 수용하기 위해 여러 곳을 동시에 변경해야 한다.
이것은 설계의 응집도가 낮다는 증거다.
데이터 중심 설계가 변경에 취약한 이유는 두 가지다.
데이터 중심의 설계는 너무 이른 시기에 데이터에 관해 고민하기 때문에 캡슐화에
실패하게 된다. 객체의 내부 구현이 인터페이스를 어지럽히고 객체의 응집도와 결합도에
나쁜 영향을 미쳐 변경에 취약한 코드를 낳게 된다.
객체 지향 설계에서 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
하지만 데이터 중심 설계에서 초점은 객체의 내부로 향한다. 협력이 구현 세부사항에
종속되어 있고 그에 따라 객체의 내부 구현 변경시 협력하는 객체가 모두가 영향을
받을 수밖에 없었던 것이다.