책임에 초점을 맞춰서 설계할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기가 쉽지 않은 것
책임 할당 과정은 일종의 트레이드오프 활동으로, 동일한 문제를 해결할 수 있는 다양한 책임 할당 방법이 존재하며, 어떤 방법이 최선인지는 상황과 문맥에 따라 달라짐
따라서 올바른 책임을 할당하기 위해서는 다양한 관점에서 설계를 평가할 수 있어야 함
데이터보다 행동을 먼저 결정
객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동(협력 안에서 수행하는 책임)
너무 이른 시간에 데이터에 초점을 맞추면 캡슐화가 약화되기 때문에 낮은 응집도와 높은 결합도를 가진 객체가 되므로 변경에 취약한 설계가 됨
따라서 행동(책임)을 먼저 결정한 후에 상태(데이터)를 결정해야 함
협력이라는 문맥에서 책임을 결정
책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 함
협력을 시작하는 주체는 메시지 전송자이기 때문에 적합한 책임은 수신자가 아니라 전송자에게 적합한 책임을 의미
따라서 협력에 적합한 책임을 결정하기 위해서는 메시지를 결정한 후에 객체를 선택해야 함
메시지를 먼저 결정하기 때문에 송신자는 수신자에 대한 어떠한 가정도 할 수 없기 때문에 수신자가 캡슐화되어 책임 중심의 설계가 응집도가 높고 결합도가 낮으며 변경하기 쉬움
책임 할당을 위한 GRASP 패턴
GRASP(General Responsibility Assignment Software Pattern)은 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것
INFORMATION EXPERT(정보 전문가) 패턴
책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것
정보를 알고 있다고 해서 정보를 저장하고 있을 필요는 없음
정보를 알고 있는 객체만이 스스로 책임을 어떻게 수행할 지 결정할 수 있기 때문에 필요한 정보를 가진 객체들로 책임이 분산되기 때문에 더 응집력 있고 이해하기 쉬워짐
결과적으로 높은 응집도와 낮은 결합도로 간결하고 유지보수하기 쉬운 시스템을 구축할 수 있음
설계는 트레이드오프 활동이므로 동일한 기능을 구현할 수 있는 무수히 많은 설계가 존재하기 때문에 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하게 발생
올바른 책임 할당을 위해 INFORMATION EXPERT 패턴 이외의 다른 책임 할당 패턴들을 고려할 필요가 있음
LOW COUPLING(낮은 결합도) 패턴
의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가
낮은 결합도는 모든 설계 결정에서 염두에 둬야 하는 원리
HIGH COHESION(높은 응집도) 패턴
복잡성을 낮춤
높은 응집도는 모든 설계 결정에서 염두에 둬야 하는 원리
영화 예매 시스템에서 Screening의 중요한 책임은 예매를 생성하는 것이고, Movie는 영화 요금을 계산하는 것인데, 만약 DiscountCondition이 Movie가 아닌 Screening과 협력한다면 Screening이 영화 요금 계산과 관련된 책임 일부를 떠안으므로 응집도가 떨어짐
CREATOR(창조자) 패턴
객체 A를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공
B가 A 객체를 포함하거나 참조
B가 A 객체를 기록
B가 A 객체를 긴밀하게 사용
B가 A 객체를 초기화하는 데 필요한 데이터를 가짐
어떤 방식으로든 생성되는 객체와 연결되거나 관련될(결합) 필요가 있는 객체에 해당 객체를 생성할 책임을 맡기는 것
이미 결합된 객체에게 생성 책임을 할당하는 것은 설계의 결합도에 영향을 미치지 않기 때문에 낮은 결합도를 유지할 수 있음
실제 설계는 코드를 작성하는 동안 이뤄지므로 협력과 책임이 제대로 동작하는지 확인할 수 있는 방법은 코드를 작성하고 실행해 보는 것 뿐
위의 DiscountCondition의 가장 큰 문제점은 변경에 취약하여 코드를 수정해야 하는 이유를 두 개 이상 가짐
새로운 할인 조건 추가가 되면 isSatisfiedBy 메서드 수정, 필요한 데이터를 프로퍼티에 추가해야 함
순번 조건을 판단하는 로직이 변경되면 isSatisfiedBySequence 메서드를 수정해야 하고, 관련된 프로퍼티도 수정할 수도 있음
기간 조건을 판단하는 로직이 변경되면 isSatisfiedByPeriod 메서드를 수정해야 하고, 관련된 프로퍼티도 수정할 수도 있음
DiscountCondition은 두 개 이상의 변경 이유를 가지기 때문에 응집도가 낮다는 것이고, 연관성이 없는 기능이나 데이터가 있다는 것을 의미하므로 변경의 이유에 따라 클래스를 분리해야 함
변경의 이유를 파악할 수 있는 첫 번째 방법은 인스턴스 변수가 초기화되는 시점을 살펴보는 것
응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화하고 응집도 낮은 클래스는 일부만 초기화하고 일부는 초기화되지 않은 상태로 남겨짐
따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 함
변경의 이유를 파악할 수 있는 두 번째 방법은 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것
모든 메서드가 객체의 모든 프로퍼티를 사용한다면 응집도가 높고, 메서드들이 사용하는 프로퍼티에 따라 그룹이 나뉜다면 응집도가 낮음
따라서 프로퍼티 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 함
DiscountCondition을 두 개의 독립적인 조건 타입에 따라 SequenceCondition, PeriodCondition으로 분리하면 개별 클래스들의 응집도가 향상되고 코드의 품질을 높임
문제는 Movie와 협력하는 클래스가 1개에서 2개로 증가
이 문제를 해결하기 위해 Movie에서 SequenceCondition과 PeriodCondition 목록을 따로 유지한다면 Movie가 두 개의 클래스 모두에 결합되어 결합도가 높아지는 문제가 발생하고, 새로운 할인 조건을 추가하기가 더 어려워짐(리스트 변수, 관련 메서드를 추가해야 함)
POLYMORPHISM(다형성) 패턴
객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하여 변화하는 행동을 각 타입의 책임으로 할당하는 것
Movie의 입장에서는 두 클래스가 할인 여부를 판단하는 동일한 책임을 수행하기 때문에 동일한 역할을 수행한다
역할은 협력 안에서 대체 가능성을 의미하기 때문에 Movie는 구체적인 클래스는 알지 못한 채 역할에 대해서만 결합되도록 의존성을 제한할 수 있음
따라서 역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있음
역할을 구현하기 위해 구현을 공유해야 할 필요가 있다면 추상 클래스로, 구현을 공유할 필요가 없다면 인터페이스를 사용
class DiscountCondition { ... };
class PeriodCondition : public DiscountCondition { ... };
class SequenceCondition : public DiscountCondition { ... };
class Movie
{
private:
bool isDiscountable(Screening screening)
{
for(const DiscountCondition& discountCondition : discountConditions)
{
if(discountCondition.isSatisfiedBy(screening))
{
return true;
}
}
return false;
}
private:
vector<DiscountCondition> discountConditions;
};
if-else, switch-case 등의 조건 논리를 사용해서 설계한다면 새로운 변화가 일어날 때 조건 논리를 수정해야 하므로 프로그램을 수정하기 어렵고 변경에 취약하게 만듦
따라서 객체의 타입을 검사해서 타입에 따라 여러 대안들을 수행하는 조건적인 논리를 사용하지 말라고 경고하고 다형성ㅇ르 이용해 새로운 변화를 다루기 쉽게 확장하라고 권고
PROTECTED VARIATIONS(변경 보호) 패턴
변경을 보호하도록 책임을 할당하는 것
클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하여 설계의 결합도와 응집도를 향상
클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분해하고 POLYMORPHISM 패턴에 따라 책임을 분산시키고, 변경으로 인해 여러 클래스들이 불안정해진다면 PROTECTED VARIATION 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라
적절한 상황에서 두 패턴을 조합하면 코드 수정의 파급 효과를 조절할 수 있고 변경과 확장에 유연하게 대처할 수 있는 설계를 얻을 수 있음
변경에 대비할 수 있는 첫 번째 방법은 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것
변경에 대비할 수 있는 두 번째 방법은 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것
대부분의 경우에는 첫 번째 방법이 좋지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 더 좋음
책임 주도 설계의 대안
최대한 빠르게 목적한 기능을 수행하는 코드를 작성하여 아무것도 없는 상태에서 책임과 협력에 관해 고민하기 보다는 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것
객체지향 설계에 대한 경험이 부족하거나, 설계의 실마리가 풀리지 않을 때 이런 방법을 사용하면 좋을 수 있음
주의할점은 코드를 수정한 후에 기능이 바뀌어서는 안됨
이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 기능은 유지한 채 나부 구조를 변경하는 것을 리팩토링(Refactoring)이라고 부름
긴 메서드(몬스터 메서드)는 응집도가 낮기 때문에 이해하기도 어렵고 재사용하기도 어려우며 변경하기도 어려움
어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는데 너무 많은 시간이 걸림
하나의 메서드 안에서 너무 많은 작업을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어려움
메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높음
로직의 일부만 재사용하는 것이 불가능
코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이므로 코드 중복을 초래하기 쉬움
응집도가 낮은 메서드는 로직의 흐름을 이해하기 위해 주석이 필요한 경우가 대부분
메서드가 명령문의 그룹으로 구성되고 각 그룹에 주석을 달아야할 필요가 있다면 응집도가 낮은 것이므로 메서드를 분해하여 각 메서드의 응집도를 높여야 함
응집도가 높은 메서드는 변경되는 이유가 단 하나여야 하고, 클래스가 작고 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지 쉽게 판단할 수 있고, 재사용하기도 쉽고, 오버라이딩이 쉬움
min, max, avg가 자주 호출되거나 계산 비용이 높은 환경에서는 최대, 최소, 평균값을 미리 계산하고 해당 함수에서 값들을 반환하는 것이 계산 횟수를 줄여 비용을 절감할 수 있음
캐싱
미리가져오기(prefetching) : operator new는 운영체제의 시스템 함수를 호출하는데, 일반적으로 프로세스의 함수보다 느리기 때문에 나중에 어차피 필요할 메모리를 지금 미리 할당해 놓는 것 std::vector처럼 메모리를 할당할 때 필요한 양의 두 배의 메모리를 할당하는 예에 해당
캐싱과 미리가져오기 방법은 메모리를 사용하여 속도를 향상시키는 방법으로 트레이드오프를 고려해야 함