📃 절차적인 설계로 구현하기

일반적으로 프로그램은 데이터와 이 데이터를 사용하는 알고리즘 또는 프로세스의 조합으로 정의한다. 절차적인 방식은 프로그램을 구성하는 데이터와 프로세스를 개별적인 모듈로 분류해서 구현하는 방식을 말하는데, 보통 데이터를 먼저 정의하고 데이터를 사용하는 프로세스를 나중에 설계한다. 대부분의 시스템은 데이터를 관계형 데이터베이스에 저장하기 때문에 테이블과 컬럼, 테이블 사이의 관계를 이용해서 데이터의 구조를 설계한다.

보다시피 영화 데이터는 MOVIE 테이블에 저장하고, Primary KeyID 컬럼에, 영화의 제목은 TITLE 컬럼에 저장한다. 영화로부터 여러 개의 상영이 생성될 수 있기 때문에 SCREENING 테이블에서 MOVIE 테이블을 향하는 Foreign Key를 추가된 것을 볼 수 있다. 이렇게 테이블과 컬럼, 테이블 사이의 관계를 기반으로 데이터 모델을 설계했다면, 이제 데이터를 사용해서 상영을 예매하는 프로세스를 구현할 수 있을 것이다.

근데 한 가지 문제가 있는데, 데이터는 시스템 외부에 있는 별도의 연속성 저장소에 저장되어 있고, 구현해야 하는 프로세스는 메모리에 로드된 데이터만 처리할 수 있다는 점이다. 따라서 데이터를 처리하려면 먼저 연속성 저장소에 저장되어 있는 데이터를 메모리로 끌고 와야 한다. 이를 위해서 데이터베이스에서 긁어 온 데이터를 메모리에 보관할 방법이 필요하다.

객체지향 언어에서는 데이터를 객체 안에 보관하기 때문에 클래스를 이용해서 데이터를 저장할 코드를 구현하면 된다. 클래스는 일반적으로 테이블당 하나씩 추가하고 테이블의 컬럼별로 하나의 필드를 대응시키는 방식으로 구현한다.

 

코드로 예를 들어보자. Movie 클래스는 MOVIE 테이블의 데이터를 메모리에 로드하기 위한 용도로 만들어진 클래스다.

public class Movie {
		
	private Long id;
	private String title;
	private Integer runningTime;
	private Money fee;
		
	public Long getId() {
		return id;
	}
		
	public void setId(Long id) {
		this.id = id;
	}
		
	public String getTitle() {
		return title;
	}
		
	public void setTitle(String title) {
		this.title = title;
	}
		
	public Integer getRunningTime() {
		return runningTime;
	}
		
	public void setRunningTime(Integer runningTime) {
		this.runningTime = runningTime;
	}
		
	public Money getFee() {
		return fee;
	}
		
	public void setFee(Money fee) {
		this.fee = fee;
	}
}

Movie 클래스에는 id, title, runningTime, fee와 같이 4개의 필드가 선언되어 있고, 이 필드에는 MOVIE 테이블의 동일한 이름을 가진 컬럼의 데이터가 저장된다. 여기서 집중해서 봐야 할 부분은 필드에 private 접근 제한자가 붙어 있다는 점이다. 이렇게 필드에 private을 붙이면 다른 클래스가 Movie의 필드에 직접 접근할 수 없기 때문에 객체의 상태를 캡슐화할 수 있다.

이런 식으로 데이터에 직접 접근할 수 있는 길을 막았다면 간접적으로 데이터를 사용할 수 있는 방법을 제공해야 할 것이다. 객체지향 언어에서는 public 메서드를 제공해서 필드에 간접적으로 접근할 수 있는 길을 열어준다. 일반적으로 각 필드별로 하나의 Getter 메서드와 Setter 메서드를 추가하는 것이 관례이다. 이렇게 데이터를 저장할 클래스를 만들었다면, 이제 준비된 데이터를 사용할 프로세스를 구현할 차례다.

 

영화의 매핑 프로세스를 위한 알고리즘을 4단계로 구성해보면 아래와 같다. 절차적인 설계는 로직을 실행 순서대로 클래스 안에 배치한다.

  1. 데이터베이스 테이블에 저장된 데이터를 메모리로 읽어 들여서 프로세스가 이 데이터를 사용할 수 있도록 준비한다.

  2. 할인 조건을 이용해서 사용자가 예매하고 있는 상영이 할인 가능한지 판단한다.

  3. 할인 가능하다고 판단되면 할인 요금을 계산한다. 할인 대상이 아니라면 영화의 정가를 이용해서 요금을 계산한다.

  4. 계산된 요금을 이용해서 예매를 생성한 후에 데이터베이스에 저장한다.

 

위와 같이 영화를 예매하기 위한 알고리즘을 정의했다면 이 알고리즘을 코드로 옮겨야 한다.

보다시피 ReservationService 클래스에 reserveScreening() 메서드를 추가한 후에 알고리즘의 각 단계를 순서대로 코드로 옮기면 될 것이다.

프로세스를 구현하려면 데이터베이스에 저장된 데이터를 메모리 상의 객체로 로드해야 한다. 따라서 데이터베이스에 접근해서 데이터를 조회한 후에 데이터를 포함한 객체를 생성해줄 또 다른 객체가 필요하다.

이런 식으로 데이터에 접근해서 영속성 로직을 처리하는 객체를 데이터 접근 객체, 줄여서 DAO라고 부른다. 데이터 접근 객체는 일반적으로 테이블당 하나씩 추가한다.

 

DAO까지 준비했으니 ReservationServiceDAO를 사용할 수 있도록 필드로 추가했다.

이제 ReservationService는 필드로 선언된 DAO에 접근해서 데이터베이스에서 데이터를 조회하거나 객체를 저장할 수 있게 됐다.

 

이제 프로세스를 구현해보자.

public class ReservationService {
		
	public Reservation reserveScreening(Long customerId, Long screeningId, Integer audienceCount) {
		...
	}
}

제일 먼저 해야 할 일은 클래스 안에 reserveScreening() 메서드를 정의하는 것이다. 현재 reserveScreening() 메서드는 3개의 파라미터를 받는다. 첫 번째 파라미터에는 예매 중인 사용자의 아이디(customerId)를 전달받고, 두 번째 파라미터에는 사용자가 예매하고 있는 상영 정보(screeningId)를 전달받는다. 마지막 파라미터에는 예매 인원수(audienceCount)가 전달된다.

 

프로세스를 구현한 메서드를 정의했으면, 앞에서 정의한 알고리즘을 순서대로 구현하면 된다. 첫 번째 단계에서는 DAO를 이용해서 데이터베이스에 저장된 데이터를 메모리 객체로 로드하는 것이다.

데이터를 조회했으면 데이터베이스에서 조회한 DiscountCondition을 이용해서 사용자가 예매하고 있는 상영이 할인 가능한지 판단해야 한다.

할인 조건을 판단하는 로직을 reserveScreening() 메서드 안에 이어서 구현하면 메서드가 너무 길어지기 때문에 별도의 findDiscountCondition() 메서드로 분리하고, reserveScreening() 메서드에서 이 메서드를 호출하도록 구현했다.

private DiscountCondition findDiscountCondition(Screening screening, List<DiscountCondition> conditions) {
	for (DiscountCondition condition : conditions) {
		if (condition.isPeriodCondition()) {
			if (screening.isPlayedIn(condition.getDayOfWeek(), condition.getStartTime(), condition.getEndTime()) {
				return condition;
			}
		} else {
			if (condition.getSequence().equals(screening.getSequence()) {
				return condition;
			}
		}
	}
		
	return null;
}

findDiscountCondition() 메서드에서는 루프 안에서 파라미터로 전달된 DiscountCondition 리스트를 순회한다. 눈여겨 봐야 할 부분은 루프를 돌면서 DiscountCondition 타입을 확인해야 한다는 점이다. findDiscountCondition() 메서드는 먼저 DiscountConditionisPeriodCondition() 메서드를 호출해서 현재의 DiscountCondition이 기간 조건인지 여부를 체크하고 있다. 만약 기간 조건이라면 상영의 시작 시간을 이용해서 할인 여부를 판단한다. 반면에, 순서 조건이라면 상영의 회차를 이용해서 할인 여부를 판단한다.

 

여기서 핵심은 DiscountCondition의 타입이 무엇이고, 타입에 따라 어떤 일을 해야 하는지를 DiscountCondition이 아니라 외부의 ReservationService가 대신 판단하고 결정하고 있다는 것이다. 절차적인 방식으로 작성된 코드를 보면, 이런 형태의 코드와 자주 마주치게 될 것이다. 객체의 타입을 판단하고 타입에 따라 어떤 일을 할지 외부에서 결정하고 있다면 절차적인 방식으로 구현된 코드라고 생각해도 무방하다.

findDiscountCondition() 메서드는 반환값으로 DiscountCondition을 반환한다. 할인이 가능한 경우에는 NULL이 아닌 값을 반환하고, 할인이 불가능한 경우에는 NULL을 반환한다.

reserveScreening() 메서드에서는 findDiscountCondtiion() 메서드의 반환값이 NULL 값인지 확인해서 할인 금액을 계산할지 여부를 결정하고 있다. 반환된 DiscountConditionNULL이 아니라면 할인이 가능하다는 의미이기 때문에 calculateDiscount() 메서드를 호출해서 할인 금액을 계산하고, 영화의 정가에서 할인 금액만큼 차감한다.

아래는 할인 금액을 계산하는 calculateDiscount() 메서드의 내부 로직이다.

private Money calculateDiscount(DiscountPolicy policy, Movie movie) {
	if (policy.isAmountPolicy()) {
		return policy.getAmount();
	} else if (policy.isPercentPolicy()) {
		return movie.getFee().times(policy.getPercent());
	}
		
	return Money.ZERO;
}

findDiscountCondition() 메서드와 유사하게, DiscountPolicy 타입이 무엇이고, 그 타입에 따라 어떤 일을 해야 하는지를 DiscountPolicy 외부에서 결정하고 있다. DiscountPolicy가 금액 할인 정책이라면 DiscountPolicy 객체에 저장되어 있는 고정 할인 금액 메서드의 결과로 반환한다. 반대로, 비율 할인 정책 타입이라면 DiscountPolicy 객체에 저장된 비율을 이용해서 할인 금액을 계산한 후에 반환한다.

 

앞에서 설명한 것처럼 객체가 누구고, 무엇을 해야 하는지를 DiscountPolicy 자신이 아니라 외부의 ReservationService가 결정하고 있다. 이처럼 외부에서 객체의 타입을 판단하고 수행할 일을 결정하는 방식은 절차적으로 작성된 코드에서 반복적으로 나타나는 패턴이다.

요금을 계산했으면 예약 객체를 생성하고, 생성된 객체를 데이터베이스에 저장한 후에 저장된 Reservation 객체를 메서드의 결과로 반환한다. 이제 영화를 예매하는 모든 프로세스의 구현이 마무리된 것이다.

 

💥 절차적인 설계의 핵심

생각해보면, 절차적인 방식에서는 데이터를 설계할 때 데이터가 사용될 문맥을 고려하지 않았다. 프로세스는 데이터가 이미 완성된 뒤에 이 데이터를 조합해서 알고리즘을 구현한다. 데이터를 사용하는 방식은 프로세스가 결정하기 때문에 데이터를 설계할 때는 데이터가 사용될 프로세스에 대해서는 전혀 고려하지 않고 있는 것이다. 이처럼 데이터를 설계할 때, 사용될 문맥을 고려하지 않게 되면 유지 보수성 측면에서 다양한 문제가 발생한다.

이처럼 절차적인 방식의 경우, 프로세스를 구현한 클래스의 제어가 집중된다는 특징이 있다.

위의 그림을 보면, 프로세스를 구현한 ReservationServiceDAO나, 데이터를 저장하고 있는 객체 모두의 실행 흐름을 통제한다는 사실을 알 수 있다. 이런 식으로 프로세스를 구현한 객체 안으로 집중되는 방식을 중앙 집중식 제어 스타일이라고 부른다. 이렇게 제어가 한 곳에 집중되면 응집도와 결합도 측면에서 수정할 때 많은 문제가 발생한다.

 

🛠 변경과 의존성

이번에는 절차적인 방식으로 작성된 코드의 문제점을 살펴보고, 문제점을 해결하기 위해 코드를 개선하는 방법을 살펴보자.

절차적인 방식의 가장 큰 문제점은 데이터를 구현한 코드의 수정에 취약하다는 점이다. 절차적인 방식에서는 여러 프로세스가 데이터를 공유하기 때문에 데이터가 바뀌면 데이터에 의존하는 하나 이상의 프로세스가 동시에 수정되어야 하는 문제가 발생한다.

코드를 수정하는 이유는 다양하겠지만, 그 중에서 가장 중요한 이유는 요구사항의 변경 때문이다. 영화 예매 시스템의 요구 사항이 변경되면서 새로운 할인 조건을 추가해야 한다고 가정해보자. 새로 추가되는 할인 조건을 조합 조건이라고 부르자. 조합 조건은 상영의 회차와 시작 시간 모두를 만족해야만 예매 요금을 할인한다.

첫 번째 조합 조건은 사용자가 예매한 상영 회차가 조조이고, 월요일 오전 10시부터 오전 12시 사이에 시작하는 경우에 요금을 할인한다.

 

할인 조건에 새로운 타입을 추가하기 위해서는 데이터를 포함하고 있는 DiscountCondition 클래스를 수정해야 할 것이다.

이 코드에서 조합 조건을 추가하려면, 먼저 열거형 ConditionType에 조합 조건을 의미하는 새로운 상수인 COMBINED_CONDITION을 추가한다. 그리고 조합 조건 여부를 판단하기 위해 isCombinedCondition() 메서드도 따로 추가해줘야 한다.

 

이제 데이터를 구현한 DiscountCondtiion을 수정했으면 이제 이 데이터를 사용하는 프로세스를 수정해야 한다.

ReservationServicefindDiscountCondition() 메서드는 할인 여부를 판단하기 위해 DiscountCondition 클래스를 사용하고 있는데, 기존에는 기간 조건인지, 순서 조건인지 여부만 판단하는 로직만 있었지만 조합 조건을 처리하는 로직을 추가해줘야 한다.

조합 조건을 추가하기 위해 데이터인 DiscountCondition을 수정해야 하고, 그 영향으로 데이터를 사용하는 프로세스가 함께 수정됐다는 점을 기억해야 한다. 이렇게 데이터를 수정할 때, 프로세스도 함께 수정해야 한다는 점이 절차적인 설계가 가지는 한계점이다.

 

🔍 의미가 명확하도록

코드를 수정하는 두 번째 이유를 알아보자. 우리는 코드를 개선하기 위한 목적으로도 코드를 수정한다. 요구 사항 변경이 우리의 시스템을 사용할 사용자를 위해 코드를 수정하는 것이라면, 코드 개선은 코드를 함께 유지 보수할 동료 개발자를 위해 코드를 읽기 쉽고 유연해지도록 수정하는 경우다.

DiscountCondition 클래스를 계속 수정해보자. 이 클래스 안에는 "시간 범위" 라는 개념이 암시적으로 구현되어 있다.

근데 이 코드를 읽는 개발자는 시간 범위라는 개념을 사용하기 위해 startTimeendTime을 조합해야 한다는 생각을 못 할 수도 있다.

그래서 다른 개발자의 쉽고 빠른 이해를 위해 TimeInterval 클래스를 추가하고, startTimeendTime 필드를 클래스 안에 포함시켰다. 이렇게 하면, 기존 DiscountCondition 클래스 안에 있던 애매모호한 startTime, endTime 필드 대신, "시간 범위" 라는 명확한 TimeInterval 타입의 필드를 추가할 수 있게 됐다.

이제 데이터를 수정했으니 데이터를 사용하는 프로세스도 함께 수정해야 하는데, 기존의 ReservationService 클래스 안의 findDiscountCondition() 메서드는 기간 조건에 따라 할인 가능한지 여부를 판단하기 위해 DiscountCondition 클래스의 getStartTime() 메서드와 getEndTime() 메서드를 호출하고 있었다. 이 부분을 새로 설계한 TimeInterval을 이용하기 위해 DiscountConditiongetInterval() 메서드를 호출하도록 아래와 변경해야 한다.

지금까지 요구 사항 변경과 코드 개선을 위해 코드를 수정하는 2가지 경우를 살펴봤다. 2가지 경우의 공통점은 바로 데이터를 수정할 때마다 이 데이터를 사용하는 프로세스도 함께 수정해야 한다는 점이다. 절차적으로 작성된 코드에서 데이터를 수정하면 항상 코드를 수정해야 하는 문제가 발생한다. 문제를 해결하기 전에 "의존성" 이라는 개념을 살펴보자.

 

⛓ 의존성(Dependency)

A와 B라는 모듈이 있을 때, A가 B를 사용한다면 A가 B에 의존한다고 말한다.

의존성이라는 용어 안에는 변경이라는 개념이 포함되어 있다는 사실이 매우 중요하다. A가 B에 의존한다는 말은 B가 변경될 때 A도 함께 변경될 가능성이 있다는 의미가 포함되어 있다.

어떤 클래스의 코드 안에 다른 클래스의 요소가 import 되어 있으면 두 클래스 사이에 의존성이 존재하는 것이다.

당장 ReservationService만 보더라도 ScreeningDiscountConditionTimeInterval 등 다양한 외부 클래스를 포함하고 있는 것을 확인할 수 있다. 이렇게 다른 클래스에 정의된 요소들을 참조하는 구문이 의존성을 만든다.

코드에 포함된 외부 요소의 유형이 ReservationService가 함께 수정되는 이유를 결정한다는 사실도 알아두도록 하자. 예를 들어, DiscountConditionScreening 클래스의 이름을 변경하면 ReservationService도 함께 수정되게 될 것이다. 이처럼 변경의 방향과 의존성의 방향은 반대다.

 

그래서 절차적인 코드의 의존성에 어떤 문제가 있는 것일까?

절차적인 방식에서는 데이터와 프로세스가 별도의 모듈로 분리되고, 이렇게 분리된 프로세스 모듈이 데이터 모듈에 의존하게 된다. 프로세스는 데이터에 의존하고, 변경의 방향은 의존성과 반대이기 때문에 데이터가 수정될 때 프로세스로 변경의 영향이 전파될 수 밖에 없다.

따라서 데이터가 수정되더라도 프로세스 쪽으로 영향이 전파되지 않도록 private 접근 제어자를 이용해서 필드를 캡슐화 했던 것이다. 그리고 Getter 메서드와 Setter 메서드를 추가해서 다른 클래스가 직접 필드에 접근하지 않고도 클래스를 사용할 수 있게 만들었다.

 

하지만 불행하게도 DiscountCondition은 캡슐화 원칙을 위반하고 있다. GetterSetter 메서드를 살펴보면 이 메서드들이 DiscountCondition 내부에 포함된 interval 필드의 정의를 그대로 외부에 드러내고 있다는 사실을 알 수 있다.

이러한 문제가 발생하는 이유는 데이터를 설계할 때 데이터가 사용될 문맥을 고려하지 않았기 때문이다. 데이터가 어떤 문맥에서 어떻게 사용될지 모르는 상황에서 데이터를 사용 가능하게 만드는 유일한 방법은 모든 경우에 사용될 수 있도록 내부의 정보를 그대로 드러내는 것 뿐이다.

지금과 같이 문맥과 무관하게 어떤 상황에서도 데이터를 사용할 수 있도록 다 열어둬서 내부를 드러내는 설계 방식을 추측에 의한 설계 전략(Design-By-Guessing Strategy)이라고 부른다.

데이터를 설계할 때 언제 어떻게 사용될지 문맥을 전혀 고려하지 않았기 때문에 어떤 클래스가 해당 데이터를 사용하는지 파악하기 어렵다. 또한 추측에 의한 설계 전략에 따라 모든 경우에 접근 가능하도록 만들어야 하기 때문에 다른 클래스가 사용하는 방식을 통제하기도 어렵다. 따라서 DiscountCondition 내부를 수정하면 어디까지 영향이 전파될지 예측하기가 어려워진다.

 

지금까지 살펴본 것처럼 절차적인 방식으로 설계된 코드는 데이터를 수정할 때 프로세스가 함께 수정된다는 단점을 가지고 있다. 따라서 수정하기 쉬운 설계를 만들기 위해서는 데이터 변경으로 인한 파급 효과를 막는 것이 핵심일 것이다. 문제의 근본 원인이 프로세스와 데이터를 별도의 모듈로 분리했기 때문이라면, 이 문제를 해결할 수 있는 가장 좋은 방법은 "데이터와 프로세스를 하나의 모듈로 합치는 것" 이다. 즉, 데이터를 변경할 때 다른 모듈 안에 포함된 프로세스가 함께 변경되는 상황이 문제이기 때문에 데이터와 프로세스를 동일한 모듈 안에 위치시켜서 하나의 모듈 안에서만 변경이 발생하도록 개선하면 코드를 수정하기가 쉬워질 것이다.

이렇게 데이터와 프로세스의 통합하는 것이 객체지향의 바탕을 이루는 가장 기본적인 개념인 것이다.

 

🤝 데이터와 프로세스의 통합

이 문제를 해결하기 위해서는 프로세스를 구현한 로직을 데이터를 구현한 모듈로 이동시켜서 데이터를 수정할 때 하나의 모듈만 수정되도록 의존성을 통제해야 한다는 사실도 알아봤다. 그래서… 어떤 로직을 데이터로 이동시켜야 하는데?

ReservationService 클래스의 findDiscountCondition() 메서드를 다시 한번 살펴보자.

private DiscountCondition findDiscountCondition(Screening screening, List<DiscountCondition> conditions) {
	for (DiscountCondition condition : conditions) {
		if (condition.isPeriodCondition()) {
			if (screening.isPlayedIn(condition.getDayOfWeek(), condition.getStartTime(), condition.getEndTime()) {
				return condition;
			}
		} else {
			if (condition.getSequence().equals(screening.getSequence()) {
				return condition;
			}
		}
	}
		
	return null;
}

지금 DiscountCondition이라는 외부 클래스의 isPeriodCondition() 메서드를 통해 기간 조건인지 여부를 판단하고, getDayOfWeek(), getStartTime(), getEndTime() 메서드를 이용해서 시간 범위를 파악하는 등 다른 클래스의 데이터를 이용해서 의사 결정을 하거나 데이터를 제공한 클래스의 상태를 변경하는 로직이 있다면, 이 로직을 데이터를 보유한 클래스 쪽으로 옮겨야 한다.

데이터를 사용하는 로직을 데이터를 보유한 클래스 쪽으로 옮기면 데이터가 수정될 때, 변경의 영향 범위를 데이터 클래스 내부로 제한할 수 있게 된다. 따라서 클래스를 수정하기 쉬워질 것이다. 이렇게 데이터를 사용하는 로직을 데이터를 보유한 클래스로 이동시키는 작업을 객체지향에서는 책임의 이동(Shift of Responsibility)이라고 부른다. 절차적인 설계에서는 데이터를 사용해서 의사 결정하거나 데이터의 상태를 변경하는 로직을 프로세스라고 불렀지만, 객체지향 설계에서는 이런 로직을 책임이라고 부른다.

 

이제 findDiscountCondition 메서드에 포함된 DiscountCondition에 대한 책임을 DiscountCondition 클래스로 이동시켜보자. 제일 먼저 할 일은 이동시킬 로직을 담을 새로운 메서드를 DiscountCondition에 추가하는 일이다.

위와 같은 식으로 책임을 이동시켰다면, ReservationServicefindDiscountCondition() 메서드가 DiscountConditionisSatisfiedBy() 메서드를 호출하도록 수정하면 된다.

private DiscountCondition findDiscountCondition(Screening screening, List<DiscountCondition> conditions) {
	for (DiscountCondition condition : conditions) {
		if (condition.isSatisfiedBy(screening)) {
			return condition;
		}
	}
		
	return null;
}

이제 DiscountCondition 외부에서는 더 이상 DiscountConditionGetter 메서드를 호출하지 않는다. Getter 메서드은 DiscountCondition 안에서만 사용되기 때문에 외부에서 호출되지 않는 Getter들을 모두 삭제하고 직접 필드를 참조하도록 코드를 수정했다.

이제 내부의 데이터는 외부에 전혀 노출시키지 않고, 오직 할인 여부를 판단할 수 있는 사실만 공개하고 있다. 이를 객체지향 용어로 말하면 DiscountCondition"자기 자신을 책임지고 있다" 고 표현한다.

 

이제 할인 금액을 계산하는 calculateDiscount() 메서드 안에 구현된 책임을 이동시켜보자. calculateDiscount() 메서드는 DiscountPolicy를 이용해서 할인 금액을 계산하는 메서드다.

calcuateDiscount() 메서드 역시 findDiscountCondition()과 유사한 방식으로 DiscountPolicy의 타입을 확인하고, 할인 요금을 계산하는 방법을 대신 결정하고 있다. 책임을 DiscountPolicy로 이동시키자.

이제 외부에서는 더 이상 Getter 메서드를 사용하지 않기 때문에 Getter 메서드를 제거하고 필드에 직접 접근하도록 코드를 수정했다.

public class DiscountPolicy {
		
	private Long id;
	private PolicyType policyType;
	private Money amount;
	private Double percent;
		
	public Money calculateDiscount(Movie movie) {
		if (isAmountPolicy()) {
			return this.amount;
		} else if (isPercentPolicy()) {
			return movie.getFee().times(this.percent);
		}
				
		return Money.ZERO;
	}
		
	private boolean isAmountPolicy { ... }
	private boolean isPercentPolicy { ... }
		
	...
}

이렇게 코드를 수정한 후의 DiscountConditionDiscountPolicy는 데이터뿐만 아니라 자기 자신을 책임지는 로직도 함께 포함하고 있다.

두 객체는 자기 자신의 타입을 스스로 판단하고 책임을 수행하는 방법 역시 스스로 결정하고 있다. 따라서 내부의 데이터를 수정하거나 타입을 확장하더라도 ReservationService에는 영향을 미치지 않는다.

 

아쉽지만 아직 더 개선할 부분들이 존재한다. 현재 ReservationServiceDiscountPolicyDiscountCondition 두 클래스 모두에 의존하고 있다. DiscountPolicyDiscountCondition은 데이터와 프로세스를 함께 포함하도록 개선됐기 때문에 데이터를 수정하는 경우에는 ReservationService 클래스가 영향을 받지 않는다.

하지만 만약 두 클래스의 공개된 메서드가 변경된다면 ReservationService 클래스도 함께 수정될 수 밖에 없다. 따라서 ReservationServiceDiscountPolicyDiscountCondition 둘 중 하나에만 의존하도록 코드를 개선할 수 있다면 ReservationService가 수정되는 경우를 줄일 수 있을 것이다. 이렇게 의존하는 객체의 수를 줄이기 위해서는 도메인의 구조를 기반으로 객체의 구조를 변경해야 한다.

 

🤔 데이터와 객체의 차이점?

지금까지 클래스라는 동일한 빌딩 블록을 이용해서 데이터와 객체를 구현했지만, 사실 클래스 안에 구현된 로직의 특성에 따라 클래스의 인스턴스는 데이터가 될 수도 있고, 객체가 될 수도 있다. 로직이 클래스의 인스턴스를 수동적인 존재로 만든다면 이 클래스의 인스턴스는 "데이터" 가 되고, 로직이 클래스의 인스턴스를 능동적인 존재로 만든다면 이 클래스의 인스턴스는 "객체" 가 되는 것이다.

사실 클래스의 인스턴스가 능동적으로 자신의 상태를 처리하는 경우에만 객체라고 불러야 한다. 만약 인스턴스가 다른 객체의 판단에 의존하거나 세세한 의사결정을 외부에 의존한다면 클래스로 구현됐다고 하더라도 그 인스턴스는 데이터라고 불러야 한다.

데이터와 객체를 명확하게 구분하지 않는 프로그래밍 언어가 많기 때문에, 어떤 사람들은 수동적인 데이터를 진짜 객체와 명확하게 구분하기 위해 바보 데이터 객체(Dumb Data Object)라는 이름을 사용하기도 한다.

바보 데이터 객체는 본질적으로 객체가 아니라 데이터다. 단순히 데이터를 외부에 제공하고 외부의 판단과 결정에 전적으로 의존한다.

 

반면, 객체지향에서 얘기하는 객체는 스스로를 책임진다. 이 객체는 바보 데이터 객체와 구분하기 위해 똑똑한 객체(Smart Object)라고 부르기도 한다.

이처럼 데이터와 객체를 나누는 가장 큰 특성은 바로 자율성(Autonomous)이다. 자신의 상태와 행동에 관해 스스로 판단하고 결정한다면 객체라고 부른다. 비록 클래스를 이용해서 구현했다고 하더라도 자신의 상태와 행동에 대해 스스로 판단하거나 결정하지 못한다면 데이터라고 부른다.

자기 스스로의 원칙에 따라 일을 하거나 자기 자신을 통제할 수 있는 객체를 설계하는 것이 객체지향의 기본 철학이라는 사실을 꼭 명심해야 한다.

 

➡ 절차에서 객체로

이제 ReservationService가 의존하고 있는 클래스의 수를 줄여보자. 의존성을 조절하는 가장 좋은 방법은 도메인의 구조와 유사한 형태로 객체의 구조를 변경하는 것이다. 그럼 일단 영화 예매 도메인을 다시 살펴보자.

지금 하나의 할인 정책(DiscountPolicy)이 다수의 할인 조건(DiscountCondition)을 참조하고 있다. 지금 목표는 ReservationServiceDiscountPolicyDiscountCondition 중 하나에만 의존하도록 구조를 수정하는 것이다. 기존의 ReservationService는 할인 정책과 할인 조건 모두에 의존하고 있었지만, 앞으로는 할인 정책에만 의존하도록 하는 것이다.

그렇다면 이제 도메인의 구조를 반영해서 의존성을 개선해보자.

public class DiscountPolicy {
		
	private Long id;
	private PolicyType policyType;
	private Money amount;
	private Double percent;
	private List<DiscountCondition> conditions;  // 할인 조건 의존성 추가
		
	public Money calculateDiscount(Movie movie) {
		if (isAmountPolicy()) {
			return this.amount;
		} else if (isPercentPolicy()) {
			return movie.getFee().times(this.percent);
		}
				
		return Money.ZERO;
	}
		
	// ReservationService 내부에 있던 할인 여부를 판단하는 로직
	private boolean findDiscountCondition(Screening screening, List<DiscountCondition> conditions) {
		for (DiscountCondition condition : conditions) {
			if (condition.isSatisfiedBy(screening) != null) {
				return true;
			}
		}
				
		return false;
	}
}

 

그리고 DiscountPolicy에 있는 findDiscountCondition() 메서드를 호출하도록 reserveScreening() 메서드를 아래와 같이 수정해야 한다.

public class ReservationService {
		
	public Reservation reserveScreening(...) {
		...
				
		boolean found = findDiscountCondition(screening, conditions);
				
		Money discountAmount;
		if (found) {
			discountAmount = policy.calculateDiscount(movie);
		} else {
			discountAmount = Money.ZERO;
		}
				
		...
	}
}

이제 더 이상 ReservationServiceDiscountCondition에 의존하지 않도록 되었다. 아래 그림과 같이 DiscountPolicyDiscountCondition을 숨기는 캡슐화 경계를 형성한다. DiscountPolicy를 이용해서 DiscountCondition을 캡슐화 했기 때문에 ReservationService는 더 이상 DiscountCondition에 대해 알 수 없다.

이제 의도대로 코드가 좀 더 변경하기 쉬워졌는지 확인하기 위해 조합 조건을 추가해보도록 하자. 절차적인 방식으로 작성된 코드에서는 조합 조건을 추가하기 위해 DiscountCondition 객체를 수정했고, 그 영향으로 ReservationService도 함께 수정했어야 했다.

public class DiscountCondition {
		
	public boolean isSatisfiedBy(Screening screening) {
		if (isPeriodCondition()) {
			if (screening.isPlayedIn(this.dayOfWeek, this.startTime, this.endTime)) {
				return true;
			}
		} else {
			if (this.sequence.equals(screening.getSequence()) {
				return true;
			}
		}
				
		return false;
	}
		
	private boolean isPeriodCondition() {
		return ConditionType.PERIOD_CONDITION.equals(conditionType);
	}
		
	private boolean isSequenceCondition() {
		return ConditionType.SEQUENCE_CONDITION.equals(conditionType);
	}
}

도메인 구조를 반영해서 수정한 후의 DiscountCondition은 자신의 타입을 스스로 확인하고, 그 타입에 기반해서 어떤 일을 할지 스스로 결정하고 있다. 여기에 조합 조건을 추가하기 위해서는 isSatisfiedBy() 메서드의 조합 조건 여부를 판단하는 로직을 추가하고, 해당 로직에서 사용할 isCombinedCondition() 메서드를 아래와 같이 추가해야 한다.

public class DiscountCondition {

	private LocalTime startTime;
	private LocalTime endTime;
		
	public boolean isSatisfiedBy(Screening screening) {
		if (isPeriodCondition()) {
			if (screening.isPlayedIn(this.dayOfWeek, this.startTime, this.endTime)) {
				return true;
			}
		} else if (condition.isSequenceCondition()) {
			if (this.sequence.equals(screening.getSequence()) {
				return true;
			}
		} else if (condition.isCombinedCondition()) {  // 판단 로직 추가
			if (this.sequence.equals(screening.getSequence() &&
				screening.isPlayedIn(this.dayOfWeek, this.startTime, this.endTime)) {
				return true;		
			}
		}
				
		return false;
	}
		
	private boolean isPeriodCondition() {
		return ConditionType.PERIOD_CONDITION.equals(conditionType);
	}
		
	private boolean isSequenceCondition() {
		return ConditionType.SEQUENCE_CONDITION.equals(conditionType);
	}
		
	// 조합 조건 추가
	private boolean isCombinedCondition() {
		return ConditionType.COMBINED_CONDITION.equals(conditionType);
	}
}

보다시피 조합 조건과 같이 새로운 조건이 추가된다고 하더라도 모든 코드 수정은 DiscountCondition 클래스 안에서만 이루어지고 있다.

 

이제 이와 비슷하게 시간 범위를 명시적으로 표현하기 위한 TimeInterval을 이용해서 DiscountCondition을 더 개선 시키도록 하자. 기존에는 TimeInterval을 사용해서 DiscountCondition을 수정하려고 하면, DiscountCondition에 의존하는 ReservationService까지 같이 수정해야 했다.

public class DiscountCondition {

	private TimeInterval interval;  // 시간 범위를 TimeInterval로 대체
		
	public boolean isSatisfiedBy(Screening screening) {
		if (isPeriodCondition()) {
			if (screening.isPlayedIn(this.dayOfWeek, this.interval.getStartTime(), this.interval.getEndTime())) {
				return true;
			}
		} else if (condition.isSequenceCondition()) {
			if (this.sequence.equals(screening.getSequence()) {
				return true;
			}
		} else if (condition.isCombinedCondition()) {
			if (this.sequence.equals(screening.getSequence() &&
				screening.isPlayedIn(this.dayOfWeek, this.interval.getStartTime(), this.interval.getEndTime())) {
				return true;		
			}
		}
				
		return false;
	}
		
	private boolean isPeriodCondition() {
		return ConditionType.PERIOD_CONDITION.equals(conditionType);
	}
		
	private boolean isSequenceCondition() {
		return ConditionType.SEQUENCE_CONDITION.equals(conditionType);
	}
		
	private boolean isCombinedCondition() {
		return ConditionType.COMBINED_CONDITION.equals(conditionType);
	}
}

하지만 보다시피 이제 그럴 일이 없다. 추가로, DiscountPolicypublic 메서드를 수정하는 경우에도 파급 효과를 최소화할 수 있다. DiscountPolicy를 이용해서 DiscountCondition을 캡슐화 했기 때문에 ReservationService에는 아무런 영향을 미치지 않기 때문이다.

근데 처음부터 이런 식으로 설계를 할 수는 없는 걸까? 이 일련의 과정이 너무 번거롭고 비효율적이라는 생각이 든다. 이처럼 절차적인 방식에서는 변경과 의존성 관점에서 시도해 볼 수 있는 방법에 제한이 있다. 절차적인 방식에서는 데이터가 사용될 문맥을 고려하지 않고 데이터를 설계하기 때문에 데이터를 수정하면 데이터에 의존하는 프로세스도 함께 수정될 수 밖에 없다. 이 문제를 해결하는 최선의 방법은 객체를 설계하기 시작하는 초기부터 객체가 사용될 문맥을 함께 고려하는 것 뿐이다. 객체가 사용될 문맥을 고려해서 데이터와 책임을 동일한 클래스 안에 모아 놓는다면, 객체를 수정할 때 다른 코드에 미치는 파급 효과를 최소화할 수 있다.

이렇게 객체를 설계할 때, 객체가 사용될 문맥을 함께 고려하는 설계 방법을 책임 주도 설계(Responsibility-Driven Design)이라고 하는 것이다.

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글