오브젝트-코드로 이해하는 객체지향 설계 : Part 4

24060 단어 OOPOOP

이전글


머릿말

3장 맺음말에서 우린 시스템의 기능을 제공할 때 객체지향 세계를 어떻게 구축하는지를 알아봤습니다. 객체지향의 핵심은 책임, 역할, 협력이었고, 올바른 객체에게 올바른 책임을 할당하며 낮은 결합도와 높은 응집도를 가진 구조를 만드는 것이 훌륭한 설계를 만드는 길이었습니다. 이번 장에서는 우리가 지금까지 해왔던 책임 중심의 설계가 아닌 상태 중심의 설계를 살펴보며 훌륭한 객체지향 설계는 어떤 특징이 있는지, 그리고 설계의 품질을 향상시키는 것에는 어떤 것들이 필요하며 동시에 어떤 것을 포기해야하는지를 알아보겠습니다.

예제 소스 코드 Link


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

데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는데 필요한 오퍼레이션정의하지만, 책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다. 각 관점에서 초점을 맞추는 것은 객체의 상태와 행동으로 서로 다르다. 그럼 우린 어떤 것을 선택해야 할까. 정답은 당연히 행동이다. 그렇다면 왜 그래야만 하는지를 확인해보자.

데이터 중심의 설계는 객체가 내부에 저장해야 하는 ‘데이터가 무엇인가’를 묻는 것으로 시작한다. 기존 책임 중심의 설계와 다른 점을 아래에 나열해보겠다. 우선 책임 중심으로 설계했던 영화 예매 애플리케이션을 상태 중심으로 설계해보자.

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;
    
    
}

기본적으로 영화를 표현 가능한 속성들을 인스턴스 변수로 포함한다. 혹시 이전과의 차이점이 보이는가. 우린 이전에 합성을 통해 DiscountPolicy 클래스로 분리했던 할인 정책과 할인 금액, 할인 비율이 Movie의 인스턴스(discountAmount, discountPercent)로 직접 정의되어 있다. 할인 정책의 종류는 java enum을 사용해 결정한다.

아까 초점이 상태(데이터)에 있다고 한 것을 기억하는가? Movie가 할인 금액을 계산하기 위해 필요한 데이터들, 즉 동작을 위한 상태들을 인스턴스 변수로 선언해놓았다. DiscountCondition은 속성들을 추출하기 위한 getter 메서드만 가진다.

뒤이어 이어지는 Screening, Reservaion, Customer 모두 각자의 기능을 수행하기 위해 필요한 데이터를 인스턴스 변수로 갖는다. 코드는 상단 예제 링크를 참조 바란다. 자 그러면 이제 이 둘을 비교해보자.


설계의 품질을 판단하기 위한 기준

데이터 중심 설계와 책임 중심 설계, 이 둘을 비교하기 전에 어떤 기준에서 장단점을 판단할 것인지를 미리 확인하자. 기준은 총 3개이다. 캡슐화, 응집도, 결합도이다. 각 기준이 어떤 것을 의미하는지 확인해보자.

  1. 캡슐화
    객체는 다른 객체에게 노출되는 퍼블릭 인터페이스와 객체 내부에서만 접근할 수 있는 내부 구현으로 나눠진다고 말했다. 퍼블릭 인터페이스는 내부 구현에 비해 상대적으로 변경될 가능성이 낮고 안정적이었고, 내부 구현은 그 반대였다. 따라서 우린 객체의 변경이 전체 설계에 많은 영향을 끼치지 않도록 하기 위해 변경될 가능성이 높은 내부 구현을 숨기고 퍼블릭 인터페이스를 통해 객체가 서로 커뮤니케이션하도록 했다. 결국 변경의 정도에 따라 둘을 분리해 다른 객체가 협력을 요청할때는 안정적인 퍼블릭 인터페이스에만 의존하도록 설계하는 것이 변경의 파급효과를 통제하는 방법이었다.

그리고 이를 위해 필요한 가장 중요한 원리가 캡슐화다. 외부에서 자주 변경될 수 있는 부분은 아예 흔적조차 보여주지 않음으로써 안정적으로 갖춰진 퍼블릭 인터페이스만 바라보고 협력할 수 있도록 만들어준다.

  1. 응집도
    모듈에 포함된 내부 요소가 얼마나 연관되어 있는지에 대한 척도다. 하나의 모듈을 구성하는 요소들이 하나의 목적을 위해 단결된 힘으로 협력한다면 높은 응집도를 갖는다. 소방차는 화재를 진압하기 위한 소방관들과 물 호스, 사다리차 등 '화재를 진압'하기 위한 다양한 요소들이 응집된 물체다. 객체도 하나의 목적을 이루기 위한 요소들이 잘 응집되어야 한다. 이는 모듈들이 담당하는 책임이 모두 유사하다는 점을 시사한다.

  2. 결합도
    앞선 Movie와 DiscountPolicy의 예시를 기억하는가. 우린 합성을 사용해 Movie가 요금을 계산하는 책임을 다하기 위해 할인 정책 객체와 협력하려고 할 때 세부적인 클래스와 협력하지 않도록 DiscountPolicy 추상 클래스를 생성해 의존했다. 결합도는 의존성의 정도다. 만약 할인 정책을 자세하게 구현한AmountDiscountPolicy, PercentDiscountPolicy와 협력했다면 Movie는 이 둘에게 높은 의존성을 갖게 되고 이는 강한 결합도로 이어진다. 저 둘이 없으면 할인 정책을 적용시키지 못하기 때문이다. 즉 결합도는 객체 또는 클래스가 협력에 필요한 수준의 의존성을 유지하고 있는가를 나타낸다.

객체지향의 강점으로 우린 캡슐화를 통해 어떤 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 적절하게 조절하는 것을 꼽았다. 궁극적으로는 캡슐화의 정도가 응집도와 결합도에 영향을 미친다. 캡슐화를 지키면 자연스럽게 모듈 안의 응집도는 높아지고 모듈 사이의 결합도는 낮아진다. 캡슐화만 잘해도 응집도와 결합도는 자연스럽게 적당히 조율된다. 자 이제 데이터 중심 설계와 책임 중심 설계를 비교해보자.

변경의 관점에서 응집도는 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도이다.
변경의 관점에서 결합도는 한 모듈이 변경되기 위해 다른 모듈의 변경을 요구하는 정도이다.


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

우리는 앞서 설계의 품질을 측정하기 위해 응집도, 결합도, 캡슐화를 알아봤다. 그렇다면 데이터 중심 설계는 왜 안좋을까. 바로 캡슐화를 위반하며 높은 결합도와 낮은 응집도를 갖기 때문이다. 좀더 자세히 알아보자.

  1. 캡슐화 위반

    public class Moive {
    	private Money fee;
    
        public Money getFee(){
        	return fee;
        }
    
        public void setFee(Money fee){
        	this.fee = fee;
        }
    
    }

    우리가 흔히 객체의 내부에 접근하기 위해 사용되는 getter, setter 메서드이다. 언뜻 보기에는 private 로 인스턴스 변수를 선언해 캡슐화 원칙을 지키는 것처럼 보이지 않지만 전혀 그렇지 않다. 그 이유는 이미 메서드의 이름에서부터 나타난다. 이미 퍼블릭 인터페이스를 통해 우리는 Movie 객체에 fee라는 인스턴스 변수가 존재한다는 것을 알 수 있다.

    그 원인은 객체가 수행할 책임을 확인하지 않고 냅다 getter,setter 메서드부터 정의했기 때문이다. 객체지향의 핵심은 책임이다. 이를 고민하지 않고 객체를 설계하면 위처럼 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 된다. 그 이유는 객체가 어떤 책임을 수행해야하는지가 정해지지 않은 채로 데이터만 인스턴스 변수로 선언되다보니, 이후 어떤 책임이 할당되어도 객체를 사용할 수 있도록 최대한 많은 접근자와 수정자를 선언하게 되는 것이다. 결국 우리에게 중요한 책임은 우리가 기능을 수행하기 위해 파악한 문맥이 아닌 일어날수도 있는 가능성이 되버린 것이다.

    이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략(design-by-guessing strategy) 라고 한다. 압도적으로 위험한 설계다.

  1. 높은 결합도

    public class ReservationAgency {
        public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        	...
            Money fee;
        	if (discountable) {
            	...
                fee = movie.getFee().minus(discountAmount).times(audienceCount);
            } else {
            	fee = movie.getFee().times(audienceCount);
            }
            ...
        }

    영화 예매를 진행하는 메서드이다. 인자로 들어온 Movie에서 영화를 가져온 뒤 할인 정책을 확인하며 할인 여부를 결정한 뒤 알맞는 할인 정책을 적용시킨 뒤 새로운 예매 객체를 반환한다.

    여기서 우리가 확인해야 하는건 Movie의 인스턴스 변수로 선언되어 있는 fee다. Movie 객체에서 fee를 꺼내 연산을 진행하고 결과를 Money타입의 fee에 넣어주는 부분이 보이는가? 만약 우리가 이때 fee의 타입을 변경해야한다면 (1) 이 부분의 타입도 변경되어야 한다. 또한 (2) Movie의 getFee() 메서드의 반환 타입도 변경되어야 하며 (3) getFee()를 호출하는 ReservationAgency의 구현도 변경된다.

    자그마치 변경될 부분이 세 곳이다. 변경에 대한 파급효과를 제어하지 못하는 것과 동시에 우리는 getter 메서드를 통해 타 클래스에서 필드를 꺼내오는데 사실상 인스턴스 변수의 가시성을 private이 아닌 public으로 두는 것과 같다. Moive.fee와 Moive.getFee()가 다를 것이 무엇이란 말인가. 남들에게 fee라는 인스턴스 변수를 갖는다고 광고하는 것과 다를 바 없다. 캡슐화는 꿈도 꾸지 못한다. 이처럼 데이터 중심의 설계는 객체의 캡슐화를 악화시키며 객체의 구현에 강하게 결합된다.

    또한 위 예제에서는 ReservationAgency에 여러 데이터 객체들을 사용하는 제어 로직이 존재하기 때문에 객체 자체가 대부분의 클래스에 의존한다. 즉 대부분의 클래스를 변경할 때마다 ReservationAgency는 변경된다. 데이터 중심의 설계는 하나의 클래스가 모든 의존성이 모이는 결합도의 집결지가 될 확률이 높다. 즉 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어버리는 것이다. 적절한 비용으로 변경이 유연한 설계? 어림도 없다.

  2. 낮은 응집도

    소방차를 예시로 들었던 것이 기억나는가? 만약 소방차에 화재 진압과 전혀 관계없는 물품들이 계속해서 존재한다면 어떨까. 헤어 드라이기, 스피커, 바리깡 등이 들어가있다면 어떨까. 소방차 내부를 정리해줘야 하는 이유가 화재 진압을 잘하기 위한 목적에서 한참 벗어나게 된다. 객체도 동일하다. 서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 존재한다면 모듈의 응집도는 낮아진다. 즉 응집도를 결정하는 것은 코드를 수정하는 이유다.

    앞선 ReservationAgency는 변경하기 위한 이유가 너무 많았다. 할인 정책이 추가되거나, 요금 계산 방법이 변경되는 경우, 할인 조건이 추가되는 경우 등 변경 이유가 수도 없다. 이는 변경과 관련없는 코드들도 변경에 의한 파급효과를 정면으로 맞이한다는 문제점이 있으며, 이때문에 여러 모듈을 수정해야 한다는 문제도 있다. 그리고 이 각기 다른 코드들은 파편화된 책임이다. 결국 어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스를 수정해야 한다는 것은 설계의 응집도가 낮다는 명백한 증거다.

    단일 책임 원칙(Single Responsibility Principle, SRP - SOLID's S)

    엉클밥은 단일 책임 원칙이라는 설계 원칙을 제시했다. 클래스는 단 한 가지의 변경 이유만 가져야 한다는 의미이다. 이 원칙을 통해 우린 자연스럽게 클래스의 응집도를 높일 수 있다. 이 때 책임은 변경의 이유라는 의미로 사용한다. 이 때의 책임은 객체의 책임과는 다르다.


캡슐화를 지켜라

객체는 자신이 어떤 데이터를 가지고 있는지 내부에 캡슐화하고 외부에 공개해서는 안된다. 객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드만을 통해 상태에 접근할 수 있어야 한다. 한 이백번쯤 얘기한 듯 하다. 인스턴스 변수의 가시성이 아무리 private이어도 접근자와 수정자를 통해 외부로 제공하고 있다면 캡슐화를 위반하는 것과 같다.

또한 항상 객체 내부를 변경하는 주체가 객체 자신이 되도록 하자. 자율적인 객체는 내부 구현을 다른 객체에게 감춤과 동시에 자신의 상태를 변경하는 책임이 자신에게 있다. 앨리스가 음료수를 마시고 키가 커질때 음료수가 앨리스의 키를 크게 했는가? 앨리스는 '음료수를 마셔라'는 요청을 처리했을뿐이다. 키를 커지게 하는 것은 음료수가 아니라 앨리스 자신이다.


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

우리가 아까 상태와 행동을 하나의 단위로 묶어 객체로 표현한 이유를 기억하는가. 바로 객체 스스로가 자신의 상태를 처리할 수 있게 하기 위해서였다. 데이터 중심의 설계와는 다르게 책임 중심의 설계에서는 객체는 단순한 데이터 제공자가 아니다. 객체 내부의 데이터보단 협력에 참여하면서 수행하는 책임을 정의는 오퍼레이션이 훨씬 중요하다. 따라서 이는 두가지 질문으로 연결된다.

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

우리는 설계 과정에서 데이터를 스스로 처리하는 메서드들을 객체 스스로가 어떻게 구현하는지 확인해야 한다. 이를 만족할 때에만 객체들이 스스로를 책임진다고 말할 수 있다. 위 링크로 가서 첫번째 설계에서 어떻게 바뀌었는지를 살펴보자.

두번째 설계는 결합도 측면에서 전보다 훨씬 나아졌다. 이전에는 ReservationAgency에 프로세스들이 몰려있었던 것을 각 객체가 스스로의 상태를 가지고 오퍼레이션할 수 있도록 변경했다. 이제서야 객체들은 스스로를 책임질 수 있게 되었다.


캡슐화 위반

앞서 우리는 객체가 스스로 상태를 관리하도록 변경했다. 하지만 문제점이 몇 개 있다.

public class DiscountCondition {
	private DayofWeek dayofWeek;
	private LocalTime time;

	public boolean isDiscountable(**DayOfWeek dayofWeek, LocalTime time**) { ... }
}

위 메서드는 객체 내부에 DayOfWeek, LocalTime 타입의 시간 정보가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있다. 만약 DiscountCondition 의 속성을 변경해야 한다면?

당연스럽게도 위 메서드를 사용하는 클라이언트 객체 모두 변경이 일어난다. 자연스럽게 내부 구현의 변경이 외부에 퍼져나가는 파급 효과가 발생한다. 이 역시 캡슐화가 부족하다는 증거다.


높은 결합도

Movie의 isDiscountable() 메서드다. 작성된 메서드 내부에서 둘이 갖는 결합도 때문에 DiscountCondition 객체의 변경에 따른 파급효과가 Movie에게까지 영향을 미친다.

  • DiscountCondition의 PERIOD(기간 할인 조건의 명칭)이 변경되면 Movie는 변경된다.
  • DiscountCondition이 추가된다면 조건을 판별해줄 if~else문이 추가된다.

이 요소들은 결국 DiscountCondition의 구현에 속한다. 우리는 이전에 내부 구현은 변경될 가능성이 높기 때문에 숨긴다고 했었다. 그리고 그 목적은 변경에 대한 파급효과를 퍼뜨리지 않기 위해서였다. 하지만 위의 이유들로 인해 DiscountCondition의 구현의 변화는 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;
    }

낮은 응집도

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

        return movie.calculateNoneDiscountedFee().times(audienceCount);
    }
}

이전 내용을 조금만 떠올려보자. DiscountCondition의 isDiscountable()은 할인 여부를 판단했었다. 이 때 판단하기 위한 정보가 변경되면 이를 호출하는 Movie의 isDiscountable의 인자도 변경되어야 한다. 그리고 결국은 Screening에서 Movie의 isDiscountable()을 호출하는 곳까지 변경해야한다.

결국 모두 다 변경해야한다. 하나의 변경을 위해 무려 세군데에서 변경이 이루어진다. 이는 낮은 응집도의 명백한 증거다. 그리고 이것의 원인 역시 캡슐화를 위반했기 때문이다.

DiscountCondtion, Movie의 내부 구현이 노출되었고, Screening은 그 노출된 구현에 의존한다. 따라서 구현의 변경에 따른 파급효과를 맞을래야 맞지 않을수가 없다는 의미다. 그렇다면 생각해보자. 우린 그저 데이터 중심으로 필요한 상태만 먼저 정의했을 뿐인데 왜이렇게 많은 문제들이 발생하는 것일까.


데이터 중심 설계의 문제점

본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.

데이터는 구현의 일부다. 다른 객체에게 어떤 책임을 위임할 것인지에 대한 메시지도 정해지지 않았는데 너무 일찍 내부 구현에 초점을 맞추게 된다.

이렇게 설계하게 되면 객체를 단순한 데이터의 집합체로 바라보게 된다. 이로 인해 접근자, 수정자는 과도하게 추가되고 이 데이터 객체를 사용하는 절차를 별도의 객체에 구현하게 된다. 또한 접근자, 수정자는 필드만 private 으로 선언해놓을 뿐이지 public 속성과 차이가 없다. 따라서 캡슐화는 무너진다.

수정을 통해 작업과 데이터를 같은 객체 안에 두더라도 초점이 데이터라면 캡슐화는 완벽하지 못하다. 데이터가 오퍼레이션보다 먼저 결정된 순간 데이터가 인터페이스에 드러날 수 밖에 없다.


협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다

객제지향은 협력하는 객체들의 공동체를 구축하는 것이라고 말했다. 따라서 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 수 있는 적절한 객체를 결정하는 것이 중요하다. 즉 핵심은 객체의 내부 구현이 아니라 다른 객체와 어떻게 협력하는지이다.

하지만 데이터 중심 설계에서는 내부로 초점이 향한다. 구현이 이미 결정된 상태기 때문에 다른 객체의 협력 방법은 억지로 끼워맞춘 인터페이스일 수 밖에 없었던 것이다.


맺음말

이번 장에서는 데이터 중심 설계와 책임 중심 설계의 차이점을 알아보며 왜 데이터 중심의 설계가 단점을 갖는지를 중점적으로 살펴봤다. 그리고 그 핵심은 캡슐화를 위반하는 것이었다. 항상 캡슐화는 자율적인 객체를 위해 필수적이다. 마구잡이로 날뛰는 파급효과는 애플리케이션의 훌륭한 설계를 갉아먹는다. 항상 캡슐화를 지키고 SOLID를 기억하자.

좋은 웹페이지 즐겨찾기