오브젝트


데이터 중심 관점의 폐해

앞 절에서는 데이터 중심의 접근법 을 취할 경우 직면하게 되는 다양한 문제에 대해서 살펴보았다. 데이터 중심의 설계는 행동보다 데이터를 먼저 결정 하게 되고 협력이라는 문맥을 벗어나 고립된 객체의 상태에 초점 을 맞추기 때문에 캡슐화를 위반하기 쉽고 요소들 사이의 결합도가 높아지며, 코드를 변경하기 어려워진다.

데이터 중심 설계로 인해 발생하는 문제점을 해결할 수 있는 가장 기본적인 방법은 데이터가 아닌 책임에 초점을 맞추는 것이다.

책임에 초점을 맞춰서 설게할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기가 쉽지 않다는 것이다. 책임 할당 과정은 일종의 트레이드오프 활동이다. 동일한 문제를 해결할 수 있는 다양한 책임 할당 방법이 존재하며, 어떤 방법이 최선인지는 상황과 문맥에 따라 달라진다. 따라서 올바른 책임을 할당하기 위해서는 다양한 관점에서 설계를 평가 할 수 있어야한다.

캡슐화, 응집도, 결합도와 유연성, 가독성 관점에서 설계를 평가(트레이드오프)하여 최선의 설계를 구현

이번에 살펴볼 GRASP 패턴 은 책임 할당의 어려움을 해결하기 위한 답을 제시해 줄 것이다. GRASP 패턴을 이해하고 나면 응집도와 결합도, 캡슐화와 같은 다양한 기준에 따라 책임을 할당하고 결과를 트레이드오프 할 수 있는 기준을 배울 수 있다.


책임 주도 설계를 위해

  • 데이터 보다 행동을 먼저 결정하라.

  • 협력이라는 문맥 안에서 책임을 결정하라.

데이터보다 행동을 먼저 결정한다.

객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동이다. 클라이언트의 입장(메시지 송신자)에서 객체가 수행하는 행동이란 곧 객체의 책임 을 의미한다. 객체는 협력에 참여하기 위해서 존재하며 협력 안에서 수행하는 책임이 객체의 존재가치를 증명한다.

너무 이른시기에 데이터에 초점을 맞추게되면 객체의 캡슐화가 약화되기 때문에 낮은 응집도와 높은 결합도를 가진 객체들로 넘쳐나게된다. 그 결과로 얻게되는 것은 변경에 취약한 설계다.

따라서 우리에게 필요한 것은 객체의 데이터에서 행동으로 무게 중심을 옮기기 위한 기법이다. 책임 중심의 설계에서는 이 객체가 수행해야 하는 책임은 무엇인가 를 결정한 후에 이 책임을 수행하는 데 필요한 데이터는 무엇인가 를 결정한다. 다시말해 책임 중심의 설계에서는 객체의 행동, 즉 책임을 먼저 결정한 후에 객체의 상태를 결정한다는 것이다.

협력이라는 문맥 안에서 책임을 결정하라.

객체에게 할당된 책임의 품질은 협력에 적합한 정도 로 결정된다. 객체에게 할당된 책임이 협력에 어울리지 않는다면 그 책임은 나쁜 책임이다. 객체의 입장에서는 책임이 조금 어색해 보이더라도 협력에 적합하다면 그 책임은 좋은 것이다. 책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야한다.

이 사실은 객체의 책임을 어떻게 식별해야 하는가 에 대한 힌트를 제공한다. 협력을 시작하는 주체는 메시지 전송자 이기 때문에 협력에 적합한 책임이란 메시지 수진자가 아니라 메시지 전송자에게 적합한 책임을 의미 한다.

클라이언트의 의도에 적합한 책임을 할당해야 한다.

협력에 적합한 책임을 수확하기 위해서는 메시지를 결정한 후에 객체를 선택 해야 한다. 메시지가 존재하기 때문에 그 메시지를 처리할 객체가 필요한 것이다.

객체가 메시지를 선택하는 것이 아니라 메시지가 객체를 선택한다.

메시지가 클라이언트의 의도를 표현 한다는 사실에 주목해야 한다. 객체를 결정하기 전에 객체가 수신할 메시지를 먼저 결정한다는 점 역시 주목하자. 클라이언트는 어떤 객체가 메시지를 수신할지 알지 못한다. 클라이언트는 단지 임의의 객체가 메시지를 수신할 것이라는 사실을 믿고 자신의 의도를 표현한 메시지를 전송할 뿐이다. 그리고 메시지를 수신하기로 결저오딘 객체는 메시지를 처리할 책임 을 할당받게 된다.

메시지를 먼저 결정하기 때문에 클라이언트(메시지 송신자)는 메시지 수신자에 대한 어떠한 가정도 할 수 없다. 이는 메시지 전송자의 관점에서 메시지 수신자가 깔끔하게 캡슐화 되는 것이다.

이처럼 처음부터 데이터에 집중하는 데이터 중심의 설계는 캡슐화에 취약한 반면 협력이라는 문맥 안에서 메시지에 집중 하는 책임 중심의 설계는 캡슐화의 원리를 지키기가 훨씬 쉬워진다.

정리하자면 객체에게 적절한 책임을 할당하기 위해서는 협력이라는 문맥을 고려 해야 한다. 그리고 협력이라는 문맥에 적절한 책임이란 클라이언트의 관점에서 적절한 책임 을 의미한다.

올바른 객체지향 설계는 클라이언트가 전송할 메시지를 결정한 후에야 비로소 객체의 상태를 저장하는데 필요한 내부데이터에 관해 고민하기 시작


책임주도 설계

  • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.

  • 시스템 책임을 더 작은 책임으로 분할한다.

  • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.

  • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.

  • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.

책임 주도 설계의 핵심은 책임을 결정한 후에 책임을 수행할 객체를 결정하는 것이다.


책임 할당을 위한 GRASP 패턴

객체지향이 태어나고 성숙해가는 동안 많은 사람들이 다양한 책임 할당 기법을 고안했다. 그 중에서 대중적으로 가장 널리 알려진 것은 패턴 형식으로 제안된 GRASP 패턴 이다. 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한다.

설계 과정은 도메인 안에 존재하는 개념들을 정리하는 것으로 시작된다.

설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려보는 것 이 유용하다. 도메인 안에는 무수히 많은 개념들이 존재하며 이 도메인 개념들을 책임 할당의 대상으로 사용 하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다. 따라서 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 유력한 후보는 바로 도메인 개념 이다.

설계를 시작하는 단계에서는 도메인 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다. 단지 우리에게는 출발점이 필요할 뿐이다.

도메인 개념을 그려보는 단계에서는 책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보만 제공할 수 있다면 충분하다. 따라서 시작 시점에는 설계를 시작하기 위해 참고할 수 있는 개념들의 모음 정도로 간주한다. 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니다. 도메인 개념을 정리하는데 너무 많은 시간을 들이지 말고 빠르게 설계와 구현을 진행하자.

올바른 도메인 모델이란 존재하지 않는다. 필요한 것은 도메인을 그대로 투영한 모델이 아니라 구현에 도움이 되는 모델이다. 즉 실용적이면서도 유용한 모델이 정답이다.


정보 전문가에게 책임을 할당하자

책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임 으로 간주하는 것이다. 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.

메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.

따라서 첫 번쨰 질문은 다음과 같다. “메시지를 전송할 객체는 무엇을 원하는가” 지금껏 살펴봤던 ‘영화 예매 애플리케이션’을 예제로 살펴보자. 협력을 시작하는 객체는 미정이지만 클라이언트(메시지 전송자)가 원하는 것은 분명해 보인다. 바로 영화를 예매하는 것이다. 따라서 메시지의 이름으로는 예매하라 가 적절한 것 같다.

메시지를 결정했으므로 메시지에 적합한 객체를 선택해야 한다. 두 번째 질문은 다음과 같다. “메시지를 수신할 적합한 객체는 누구인가?” 이 질문에 답하기 위해서는 객체는 상태와 행동을 통합한 캡슐화의 단위 라는 사실에 집중해야 한다. 객체는 자신의 상태를 스스로 처리하는 자율적인 존재 여야 한다. 객체의 책임과 책임을 수행하는 데 필요한 상태는 동일한 객체 안에 존재해야 한다. 따라서 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행하는데 필요한 정보를 가장 잘 알고있는 객체에게 책임을 할당 하는 것이다. 이를 INFORMATION EXPERT(정보전문가) 패턴이라고 부른다.

INFORMATION EXPERT 패턴은 객체가 자율적인 존재여야 한다는 사실을 다시 한번 상기시킨다. 정보를 알고있는 객체만이 책임을 어떻게 수행할지 스스로 결정할 수 있기 때문이다. INFORMATION EXPERT 패턴을 따르면 정보와 행동을 최대한 가까운 곳에 위치시키기 때문에 캡슐화를 유지할 수 있다. 필요한 정보를 가진 객체들로 책임이 분산되기 때문에 더 응집력 있고, 이해하기 쉬워진다. 따라서 높은 응집도가 가능하다. 결과적으로 결합도가 낮아져서 간결하고 유지보수하기 쉬운 시스템을 구축할 수 있다.

INFORMATION EXPERT 패턴은 객체가 자신이 소유하고 있는 정보와 관련된 작업을 수행한다는 일반적인 직관을 표현

여기서 이야기하는 정보는 데이터와 다르다는 사실에 주의하자. 책임을 수행하는 객체가 정보를 ‘알고’ 있다고 해서 그 정보를 ‘저장’ 할 필요는 없다. 객체는 해당 정보를 제공할 수 있는 다른 객체를 알고있거나 필요한 정보를 계산해서 제공할 수도 있다. 어떤 방식이건 정보 전문가가 데이터를 반드시 저장하고 있을 필요는 없다는 사실을 이해하는 것이 중요 하다.

INFORMATION EXPERT 패턴에 따르면 ‘예매’ 하는데 필요한 정보를 가장 많이 알고 있는 객체에게 예매하라 메시지를 처리할 책임을 할당해야 한다. 어떤 객체가 좋을까? 아마 ‘상영’ 이라는 도메인 개님이 적합할 것이다. ‘상영’은 영화에 대한 정보와 상영시간, 상영 순번처럼 영화 예매에 필요한 다양한 정보를 알고 있다(이는 도메인 개념을 그려보면서 도메인들을 파악했다면 직관적으로 알 수 있다.) 따라서 영화 예매를 위한 정보 전문가이다.

예매하라 라는 메시지를 수신했을 때 ‘상영’ 이 수행해야 하는 작업의 흐름을 생각해보자. 이제부터는 외부의 인터페이스가 아닌 상영 객체의 내부로 들어가 메시지를 처리하기 위해 필요한 절차와 구현을 고민 해보는 것이다. 지금은 개략적인 수준에서 객체들의 책임을 결정 하는 단계이기 때문에 너무 세세한 부분까지 고민할 필요는 없다. 단지 상영 이 책임을 수행하는 데 필요한 작업을 구상해보고 스스로 처리할 수 없는 작업이 무엇인지를 가릴 정도의 수준 이면 된다.

만약 스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야 한다. 이 요청이 외부로 전송해야 하는 새로운 메시지가 되고, 최종적으로 이 메시지가 새로운 객체의 책임으로 할당된다. 이 같은 연쇄적인 메시지 전송과 수신을 통해 협력 공동체가 구성되는 것이다.

위에서 살펴본 것 처럼 INFORMATION EXPERT 패턴은 객체에게 책임을 할당할 때 가장 기본이 되는 책임 할당 원칙이다. 객체란 상태와 행동을 함께 가지는 단위라는 객체지향의 가장 기본적인 원리를 책임 할당의 관점에서 표현한다.


높은 응집도와 낮은 결합도

설계는 트레이드오프 활동이라는 것을 기억하자. 동일한 기능을 구현할 수 있는 수많은 설계가 존재한다. 따라서 실제로 설계를 진행하다 보면 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하게 발생한다. 이 경우에는 올바른 책임 할당을 위해 INFORMATION EXPERT 패턴 외에 다른 책임 할당 패턴들을 함꼐 고려할 필요가 있다.

높은 응집도와 낮은 결합도 는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리다. 책임을 할당할 수 있는 다양한 대안들이 존재한다면 응집도와 결합도의 측면에서 더 나은 대안을 선택하는 것이 좋다. 다시 말해 두 협력 패턴 중에서 높은 응집도와 낮은 결합도를 얻을 수 있는 설계가 있다면 그 설계를 선택한다.

DiscountConditionMovie 와 협력하는게 좋을까, 아니면 Screening 과 협력하는 것이 좋을까? 해답의 실마리는 바로 결합도 에 있다. 도메인 개념을 다시 살펴보면 도메인 상으로는 MovieDiscountCondition 목록을 속성으로 포함하고 있다(합성). MovieDiscountCondition 은 이미 결합되어 있기 때문에 MovieDiscountCondition 을 협력하게 하면 설계 전체적으로 결합도를 추가하지 않고도 협력을 완성할 수 있다. 하지만 ScreeningDiscountCondition 과 협력할 경우에는 둘 사이의 새로운 결합도가 추가된다. 따라서 낮은 결합도 패턴의 관점에서는 MovieDiscountCondition 이 협력하는 것이 더 나은 설계 대안인 것이다.

높은 응집도 패턴의 관점에서도 설계 대안들을 평가할 수 있다. Screening 객체의 가장 중요한 책임은 예매를 생성 하는 것이다. 만약 ScreeningDiscountCondition 과 협력해야 한다면 Screening 은 영화 요금 계산과 관련된 책임 일부를 떠안아야 할 것 이다. 이 경우 ScreeningDiscountCondition 이 할인 여부를 판단할 수 있고 Movie 가 이 할인 여부를 필요로 한다는 사실 역시 알고 있어야 한다.

다시 말해서 예매 요금을 계산하는 방식이 변경될 경우 Screening 도 함께 변경해야 하는 것이다. 결과적으로 ScreeningDiscountCondition 이 협력하게 되면 Screening 은 서로 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아질 수 밖에 없다.

반면 Movie 의 주된 책임은 영화 요금 계산 이다. 따라서 영화 요금을 계산하는 데 필요한 할인 조건을 판단하기 위해 MovieDiscountCondition 과 협력하는 것은 응집도에 아무런 해도 끼치지 않는다.


창조자에게 객체 생성 책임을 할당한다.

영화 예매 협력의 최종 결과물은 Reservation 인스턴스를 생성하는 것이다. 이것은 협력에 참여하는 어떤 객체에게는 Reservation 인스턴스를 생성할 책임을 할당해야 하는 것을 의미한다. GRASP의 CREATOR 패턴은 이 같은 경우에 사용할 수 있는 책임 할당 패턴으로서 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.

CREATOR 패턴은 객체 A 를 생성할 때 어떤 객체에게 객체 생성 책임을 할당해야 하는지 조언한다. 아래의 조건을 가장 많이 만족하는 B 에게 A 생성 책임을 할당하라.

  • B가 A 객체를 포함하거나 참조한다.

  • B가 A 객체를 기록한다.

  • B가 A 객체를 긴밀하게 사용한다.

  • B가 A 객체를 초기화 하는 데 필요한 데이터를 가지고 있다. (이 경우 B는 A에 대한 정보 전문가이다.)

CREATOR 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에게 해당 객체를 생성할 책임을 맡기는 것이다.

생성될 객체에 대해 잘 알고 있어야 하거나 그 객체를 사용해야 하는 객체는 어떤 방식으로든 생성될 객체와 연결될 것이다. 다시말해서 두 객체는 서로 결합된다.

Reservation 을 잘 알고 있거나, 긴밀하게 사용하거나, 초기화에 필요한 정보를 가지고 있는 객체는 무엇인가? 바로 Screening 이다.

대략적으로나마 영화 예매에 필요한 책임을 객체들에게 할당했다. 현재까지의 책임 분배는 설계를 시작하기 위한 대략적인 스케치에 불과하다. 실제 설계는 코드를 작성하는 동안에 이뤄진다. 그리고 협력과 책임이 제대로 동작할 수 있는 유일한 방법은 코드를 작성하고 실행해 보는 것 뿐이다.


구현을 통한 검증

Screening 을 구현하는 것으로 시작하자. Screening 은 영화를 예매할 책임을 맡으며 그 결과로 Reservation 인스턴스를 생성할 책임을 수행해야 한다. 다시말해 Screening 은 예매에 대한 정보 전문가인 동시에 Reservation 의 창조자다.

협력의 관점에서 Screening예매하라 메시지에 응답할 수 있어야 한다. 따라서 이 메시지를 처리할 수 있는 메서드를 구현하자

책임이 결정됐으므로 책임을 수행하는데 필요한 인스턴스 변수를 결정 해야 한다. Screening 은 상영시간(whenScreened)과 상영 순번(sequence)을 인스턴스 변수로 포함한다. 또한 Movie 에 가격을 계산하라는 메시지를 전송해야 하기 때문에 Movie 에 대한 참조도 포함해야 한다.

영화를 예매하기 위해서는 Movie 에게 가격을 계산하라 라는 메시지를 전송해서 계산돈 영화 요금을 반환받아야 한다. calculateFee() 메서드는 이렇게 반환된 요금에 예매 인원수를 곱하여 전체 예매 요금을 계싼한 후 Reservation 을 생성해서 반환한다.

Screening 을 구현하는 과정에서 Movie 에 전송하는 메시지의 시그니처를 calculateMovieFee(Screening screening) 으로 선언했다는 사실에 주목하자. 이 메시지는 수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현한다.

중요한 것은 Screening이 Movie의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이다.

Movie 의 구현은 고려하지 않고 필요한 메시지를 결정하면 Movie 의 내부 구현을 깔끔하게 캡슐화 할 수 있다.

이제 ScreeningMovie 를 연결하는 유일한 연결고리는 메시지 뿐이다. 따라서 메시지가 변경되지 않는 한 Movie 에 어떤 수정을 가하더라도 Screening 에는 영향을 미치지 않는다. 메시지를 기반으로 협력을 구성하면 ScreeningMovie 사이의 결합도를 느슨하게 유지할 수 있다.

메시지가 객체를 선택하도록 책임 주도 설계의 방식을 따르면 캡슐화와 낮은 결합도라는 목표를 비교적 손쉽게 달성할 수 있다.

ScreeningMovie 와 협력하기 위해 calculateMovieFee() 메시지를 전송한다. Movie 는 이 메시지에 응답하기 위해 calculateMovieFee 메서드를 구현해야 한다.

요금을 계산하기 위해 Movie 는 기본 금액(fee), 할인 조건(discountConditions), 할인 정책 등의 정보를 알아야 한다. 현재의 설계에서 할인 정책을 Movie 의 일부로 구현하고 있기 때문에 할인 정책을 구성하는 할인 금액(discountAmount)과 할인 비율(discountPercent)을 Movie 의 인스턴스 변수로 선언했다. 그리고 현재의 Movie 가 어떤 할인 정책이 적용된 영화인지를 나타내기 위한 영화 종류(movieType)를 인스턴스 변수로 포함한다.

MovieType 은 할인 정책의 종류를 나열하는 단순한 열거형 타입이다.

Movie 는 먼저 discountConditions의 원소를 차례대로 순회하면서 DiscountCondition 인스턴스에게 isSatisfiedBy() 메시지를 전송해서 할인 여부를 판단하도록 요청한다. 만약 할인 조건을 만족하는 DiscountCondition 인스턴스가 존재한다면 할인 요금을 계산하기 위해 calculateDiscountAmount() 메서드를 호출한다. 만약 만족하는 할인조건이 존재하지 않을 경우 기본금액인 fee를 반환한다.

실제로 할인 요금을 계산하는 calculateDiscountAmount() 메서드는 movieType 의 값에 따라 적절한 메서드를 호출한다.

Movie 는 각 DiscountCondition할인 여부를 판단하라 메시지를 전송한다. 그리고 DiscountCondition 은 이 메시지를 처리하기 위해 isSatisfiedBy() 메서드를 구현해야 한다.

DiscountCondition 은 기간조건을 위한 요일(dayOfWeek), 시작 시간(startTime), 종료 시간(endTime)과 순번 조건을 위한 상영 순번(sequence)을 인스턴스 변수로 포함한다. 추가적으로 할인 조건의 종류(type)을 인스턴스 변수로 포함한다. isSatisfiedBy() 메서드는 type의 값에 따라 적절한 메서드를 호출한다.

DiscountConditionType 은 할인 조건의 종류를 나열하는 단순한 열거형 타입이다.

이제 구현이 완료되었다. 코드가 만족스러울지 모르겠다. 안타깝게도 방금 작성한 코드는 마음을 불편하게 만드는 몇 가지 문제점 들이 숨어있다.


DiscountCondition 개선하기

가장 큰 문제점은 변경에 취약한 클래스를 포함 하고 있다는 것이다.

변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스

현재의 코드에서 변경의 이유가 다양한 클래스는 바로 DiscountCondition 이다. 다음과 같이 서로 다른 세 가지 이유로 변경될 수 있다.

  1. 새로운 할인조건 추가 isSatisfiedBy() 메서드 안에 if ~ else 구문을 수정해야 한다. 물론 새로운 할인 조건이 새로운 데이터를 요구한다면 DiscountCondition 에 새로운 속성을 추가하는 작업도 필요하다.

  2. 순번 조건을 판단하는 로직 변경 isSatisfiedBySequence() 메서드의 내부 구현을 수정해야 한다. 물론 순번 조건을 판단하는 데 필요한 데이터가 변경된다면 DiscountCondtion 의 sequence 속성 역시 변경해야 할 것이다.

  3. 기간 조건을 판단하는 로직이 변경되는 경우 isSatisfiedByPeriod() 메서드의 내부 구현을 수정해야 한다. 물론 기간 조건을 판단하는 데 필요한 데이터가 변경된다면 DiscountCondtion 의 dayOfWeek, startTime, endTime 속성 역시 변경해야 할 것이다.

위에서 나열한 것과 같이 DiscountCondtion 은 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다. 응집도가 낮다는 것은 서로 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다.

낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리한다.

앞에서 살펴본 것 처럼 DiscountCondtion 안에 구현된 isSatisfiedBySequence 메서드와 isSatisfiedByPeriod 메서드는 서로 다른 이유로 변경된다. isSatisfiedBySequence() 메서드는 순번 조건에 대한 요구사항이 달라질 경우 구현이 변경된다. 그에 반해 isSatisfiedByPeriod() 메서드는 기간 조건에 대한 요구사항이 달라질 경우에 구현이 변경된다.

두 가지 변경이 코드에 영향을 미치는 시점은 서로 다를 수 있다. 다시 말해 DiscountCondition은 서로 다른 이유로, 서로 다른 시점에 변경될 가능성이 높다. 서로 다른 이유로 변경되는 두 개의 메서드를 가지는 DiscountCondition 클래스의 응집도는 낮아질 수밖에 없는 것이다.

지금까지 살펴본 것 처럼 일반적으로 설계를 개선하는 작업은 변경의 이유가 하나 이상인 클래스를 찾는 것으로부터 시작 하는 것이 좋다. 변경의 이유가 하나 이상인 클래스는 위험 징후를 또렷하게 드러내는 몇 가지 패턴이 존재 한다. 일단 이 패턴을 이해하고 나면 클래스 안에 숨겨진 변경의 이유를 생각보다 쉽게 알아차릴 수 있을 것이다.

  • 인스턴스 변수가 초기화되는 시점 코드를 통해 변경의 이유를 파악할 수 있는 첫 번째 방법은 인스턴수 변수가 초기화 되는 시점 을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화 한다. 반면 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화 하고 일부는 초기화 되지 않은 상태로 남겨진다.

DiscountCondition 클래스를 다시 살펴보자. DiscountCondition 이 순번 조건을 표현하는 경우 sequence는 초기화 되지만 dayOfWeek, startTime, endTime은 초기화되지 않는다. 반대로 DiscountCondition 이 기간 조건을 표현하는 경우에는 dayOfWeek, startTime, endTime은 초기화 되지만 sequence는 초기화 되지 않는다.

클래스의 속성이 서로 다른 시점에 초기화되거나 일부만 초기화된다는 것은 응집도가 낮다는 증거이다. 따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.

함께 초기화 되는 속성을 기준으로 코드를 분리하자.

  • 메서드들이 인스턴스 변수를 사용하는 방식 코드를 통해 변경의 이유를 파악할 수 있는 두 번째 방법은 메서드들이 인스턴스 변수를 사용하는 방식 을 살펴보는 것이다. 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다. 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다.

DiscountConditionisSatisfiedBySequence() 메서드와 isSatisfiedByPeriod() 메서드가 이 경우에 해당한다. isSatisfiedBySequence() 메서드는 sequence를 사용하지만 dayOfWeek, startTime, endTime은 사용하지 않는다. 반대로 isSatisfiedByPeriod() 메서드는 dayOfWeek, startTime, endTime은 사용하지만 sequence는 사용하지 않는다. 이 경우 클래스의 응집도를 높이기 위해서 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.

속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리한다.


클래스 응집도 판단하기

지금까지 클래스의 응집도를 판단할 수 있는 세 가지 방법을 살펴봤다. 클래스가 다음과 같은 징후로 몸살을 앓고 있다면 클래스의 응집도는 낮은 것이다.

  • 클래스가 하나 이상의 이유로 변경되어야 한다면 응집도가 낮은 것이다. 변경의 이유를 기준으로 클래스를 분리하라.

  • 클래스의 인스턴스를 초기화 하는 시점에 경우에 따라 서로 다른 속성들을 초기화 하고 있다면 응집도가 낮은 것이다. 초기화 되는 속성의 그룹을 기준으로 클래스를 분리하라.

  • 메서드 그룹이 속성 그룹을 사용하는지 여부 로 나뉜다면 응집도가 낮은 것이다. 이들 그룹을 기준으로 클래스를 분리하라.

일반적으로 응집도가 낮은 클래스는 위 세 가지 문제를 동시에 가지는 경우가 대부분이다. 메서드의 크기가 너무 커서 긴 코드라인 속에 숨겨져 명확하게 보이지 않을 수도 있다. 이 경우 긴 메서드를 응집도 높은 작은 메서드로 잘게 분해해 나가면 숨겨져 있던 문제점이 명확하게 드러나는 경우가 많다.


타입 분리하기

DiscountCondition 의 가장 큰 문제는 순번 조건과 기간 조건이라는 두 개의 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점이다. 가장 먼저 떠오르는 방법은 두 타입을 SequenceCondtionPeriodCondtion 이라는 두 개의 클래스로 분리하는 것이다.

클래스를 분리하면 앞에서 언급했던 문제들이 모두 해결된다 SequenceCondtionPeriodCondtion자신의 모든 인스턴스 변수를 함께 초기화 할 수 있다. sequence 속성만 사용하는 메서드는 SequenceCondtion 으로, dayOfWeek, startTime, endTime을 사용하는 메서드는 PeriodCondtion 으로 이동했기 때문에 클래스에 있는 모든 메서드는 동일한 인스턴스 변수 그룹을 사용한다. 결과적으로 개별 클래스의 응집도가 향상되었다. 클래스를 분리함으로써 코드의 품질을 높이는데 성공한 것이다.

하지만 안타깝게도 클래스를 분리한 후에 새로운 문제가 나타났다. 수정 전에는 Movie 와 협력하는 클래스는 DiscountCondition 하나 뿐이었다. 그러나 수정 후에 Movie 인스턴스는 SequenceCondtionPeriodCondtion 이라는 두 개의 서로 다른 클래스의 인스턴스 모두와 협력할 수 있어야 한다.

이 문제를 해결하기 위해 생각할 수 있는 첫 번째 방법은 Movie 클래스 안에서 SequenceCondtion 의 목록과 PeriodCondtion 의 목록을 따로 유지하는 것이다.

하지만 위 방법은 새로운 문제를 야기한다. 첫 번쨰 문제는 Movie 클래스가 PeriodCondtionSequenceCondtion 클래스 양쪽 모두에게 결합된다는 것이다. 코드를 수정하기 전에는 MovieDiscountCondition 이라는 하나의 클래스에만 결합되어있었다. 클래스를 분리한 후에 설계의 관점에서 전체적인 결합도가 높아진 것이다.

두 번째 문제는 수정 후에 새로운 할인 조건을 추가하기가 더 어려워졌다는 것이다. 먼저 새로운 할인 조건 클래스를 담기 위한 List를 Movie 의 인스턴스 변수로 추가해야 한다. 그리고 이 List를 이용해 할인 조건을 만족하는지 여부를 판단하는 메서드도 추가해야 한다. 마지막으로 이 메서드를 호출하도록 isDiscountable() 메서드를 수정해야 한다.

클래스를 분리하기 전에는 DiscountCondition 의 내부 구현만 수정하면 Movie 에는 아무런 영향도 미치지 않았다. 하지만 수정 후에는 할인 조건을 추가하려면 Movie 도 함께 수정해야 한다. DiscountCondition 의 입장에서 보면 응집도가 높아졌지만 변경과 캡슐화라는 관점에서 보면 전체적으로 설계의 품질이 떨어졌다.


다형성을 통해 분리하기

사실 Movie 의 입장에서 보면 SequenceCondtion 이나 PeriodCondtion 은 아무 차이도 없다. 둘 모두 할인 여부를 판단하는 동일한 책임을 수행 하고 있을 뿐이다. 두 클래스가 할인 여부를 판단하기 위해 사용하는 방법이 서로 다르다는 사실은 Movie 입장에서는 그다지 중요하지 않다. 할인 가능 여부를 반환해 주기만 하면 Movie 는 객체가 SequenceCondtion 인스턴스인지, PeriodCondtion 인스턴스인지는 상관하지 않는다.

이 시점이 되면 자연스럽게 역할 의 개념이 무대 위로 등장한다. Movie 의 입장에서 SequenceCondtionPeriodCondtion 이 동일한 책임을 수행한다는 것은 동일한 역할을 수행한다는 것을 의미한다. 역할은 협력 안에서 대체 가능성을 의미하기 때문에 SequenceCondtionPeriodCondtion 에 역할의 개념을 이용하면 Movie 가 구체적인 클래스는 알지 못한채 오직 역할에 대해서만 결합되도록 의존성을 제한할 수 있다.

역할을 사용하면 객체의 구체적인 타입을 추상화 할 수 있다.

앞서 언급했던 것 처럼 자바에서는 일반적으로 역할을 구현하기 위해 추상 클래스, 인터페이스 를 사용한다. 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상클래스를 사용하면 된다. 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다면 인터페이스를 사용하면 된다.

할인 조건의 경우에는 SequenceCondtionPeriodCondtion 클래스가 구현을 공유할 필요는 없다. 따라서 DiscountCondition 이라는 이름을 가진 인터페이스를 이용해 역할을 구현하자.

이제 SequenceCondtionPeriodCondtion 의 인스턴스가 DiscountCondition 인터페이스를 실체화 하도록 수정한다. 이후에 Movie 는 협력하는 객체의 구체적인 타입을 몰라도 상관 없다. 협력하는 객체가 DiscountCondition 역할을 수행할 수 있고 isSatisfiedBy() 메시지를 이해할 수 있다는 사실만 알고 있어도 충분하다.

Movie 가 전송한 메시지를 수신한 객체의 구체적인 클래스가 무엇인가에 따라 적절한 메서드가 실행된다. 만약 메시지를 수신한 객체가 SequenceCondtion 의 인스턴스라면 SequenceCondtionisSatisfiedBy() 메서드가 실행될 것이다. 만약 메시지를 수신한 객체가 PeriodCondtion 클래스의 인스턴스라면 PeriodCondtionisSatisfiedBy() 메서드가 실행될 것이다. 즉, MovieDiscountCondition 사이의 협력은 다형적 이다.

DiscountCondition 의 경우에서 알 수 있듯이 객체의 암시적인 타입에 따라 행동을 분기해서 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결할 수 있다. 다시말해 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다. GRASP 에서는 이를 POLYMORPHISM(다형성) 패턴이라고 부른다.

조건에 따른 변화는 프로그램의 기본 논리다. 프로그램을 if ~ else 또는 switch ~ case 등의 조건논리를 사용하여 설계한다면 새로운 변화가 일어난 경우 조건 논리를 수정해야 한다. 이것은 프로그램을 수정하기 어렵고 변경에 취약하게 만든다.

POLYMORPHISM 패턴은 객체의 타입을 검사해서 타입에 따라 여러 대안들을 수행하는 조건적인 논리적인 사용하지 말라고 경고한다. 대신 다형성을 이용하여 새로운 변화를 다루기 쉽게 확장하라고 권고한다.


변경으로부터 보호하기.

DiscountCondition 의 두 서브클래스는 서로 다른 이유로 변경된다. SequenceCondtion 은 순번 조건의 구현 방법이 변경될 경우에만 수정된다. PeriodCondtion 은 기간 조건의 구현 방법이 변경될 경우에만 수정된다. 두 개의 서로 다른 변경이 두 개의 서로 다른 클래스 안으로 캡슐화된다.

새로운 할인 조건을 추가하는 경우에는 어떻게 될까? DiscountCondition 이라는 역할이 Movie 로 부터 PeriodCondtionSequenceCondtion 의 존재를 감춘다는 사실에 주목하자. DiscountCondition 이라는 추상화가 구체적인 타입을 캡슐화한다. Movie 의 관점에서 DiscountCondition 의 타입이 캡슐화된다는 것은 새로운 DiscountCondition 타입을 추가하더라도 Movie 가 영향을 받지 않는다는 것을 의미한다. Movie 에 대한 어떤 수정도 필요 없다. 오직 DiscountCondition 인터페이스를 실체화 하는 클래스를 추가하는 것 으로 할인 조건의 종류를 확장할 수 있다.

이처럼 변경을 캡슐화 하도록 책임을 할당 하는 것을 GRASP에서는 PROTECTED VARIATIONS(변경보호) 패턴이라고 한다.

변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라.

PROTECTED VARIATIONS 패턴은 책임 할당의 관점에서 캡슐화를 설명한 것이다. “설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화 한다.”라는 객체지향의 오랜 격언은 PROTECTED VARIATIONS 패턴의 본질을 잘 설명해준다. 우리가 캡슐화 해야하는 것은 변경 이다. 변경될 가능성이 높은 모든것을 캡슐화 한다. 그것이 타입일지라도

클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계의 결합도와 응집도를 향상시키는 매우 강력한 방법이다.

하나의 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분해하고 다형성 패턴에 따라 책임을 분산시켜라.

예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 PROTECTED VARIATIONS 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라.

적절한 상황에서 두 패턴을 조합하면 코드 수정의 파급 효과를 조절하고 변경과 확장에 유연하게 대처할 수 있는 설계를 얻을 수 있다.


Movie 클래스 개선하기

Movie 역시 DiscountCondition 과 동일한 문제를 갖고 있다. 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 가지 타입을 하나의 클래스 안에 구현하고 있기 때문에 하나 이상의 이유로 변경 될 수 있다. 한마디로 말해서 응집도가 낮은 것이다.

해결 방법 역시 DiscountCondition 과 동일하다. 역할 의 개념을 도입해서 협력을 다형적으로 만들면 된다. 다형성 패턴을 사용해서 서로 다른 행동을 타입별로 분리 하면 다형성의 혜택을 누릴 수 있다. 이렇게 하면 ScreeningMovie 가 메시지를 통해서만 다형적으로 협력하기 때문에 Movie 의 타입을 추가하더라도 Screening 에 영향을 미치지 않게 할 수 있다. 이것은 PROTECTED VARIATIONS 패턴을 이용해 타입의 종류를 안정적인 인터페이스 뒤로 캡슐화 할 수 있다는 것을 의미한다.

코드를 개선하자. 금액 할인 정책 과 관련된 인스턴스 변수, 메서드를 옮길 클래스의 이름은 AmountDiscountMovie 가 적합할 것 같다. 비율 할인 정책 과 관련된 인스턴스 변수, 메서드를 옮겨 담을 클래스는 PercentDiscountMovie 로 명명한다. 할인 정책을 적용하지 않은 경우 NoneDiscountMovie 클래스가 처리하게 될 것이다.

DiscountCondition 의 경우에는 역할을 수행할 클래스들 사이에 구현을 공유할 필요가 없었기 때문에 인터페이스 를 이용하여 구현했다. Movie 의 경우에는 구현을 공유할 필요가 있다. 따라서 추상 클래스 를 이용하여 역할을 구현하자.

변경 전의 Movie 클래스와 비교해서 discountAmount, discountPercent와 이 인스턴스 변수들을 사용하는 메서드들이 삭제됐다는 것을 알 수 있다. 이 인스턴스 변수들과 메서드들을 Movie 역할을 수행하는 적절한 자식 클래스로 옮길 것이다.

할인 정책의 종류에 따라 할인 금액을 계산하는 로직이 달라져야 한다. 이를 위해 calculateDiscountAmount() 메서드를 추상 메서드 로 선언함으로써 서브클래스들이 할인 금액을 계산하는 방식을 원하는대로 오버라이딩 할 수 있게 했다.

금액 할인 정책과 관련된 인스턴스 변수와 메서드를 AmountDiscountMovie 클래스로 옮기자. 그리고 Movie 를 상속받게 함으로써 구현을 재사용하자. 마지막으로 Movie dㅔ서 선언된 calculateDiscountAmount() 메서드를 오버라이딩 한 후 할인할 금액을 반환한다.

비율 할인 정책은 PercentDiscountMovie 클래스에서 구현한다. 이 클래스 역시 Movie 에서 선언된 calculateDiscountAmount() 메서드를 오버라이딩 한 후 정해진 비율에 따라 할인할 금액을 계산한 뒤 반환한다.

할인 정책을 적용하지 않기 위해서는 NoneDiscountMovie 클래스를 사용하면 된다. 이 경우 calculateDiscountAmount() 메서드는 0원을 반환한다.

이제 모든 구현이 끝났다. 지금까지 구현된 영화 예매 시스템은 모든 클래스의 내부 구현이 캡슐화 되어있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다. 각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다. 클래스는 작고 오직 한 가지 일만 수행한다. 책임은 적절하게 분배돼 있다. 이것이 책임을 중심으로 협력을 설계할 때 얻을 수 있는 혜택이다.

데이터 중심의 설계는 데이터와 관련된 클래스의 내부 구현이 인터페이스에 여과 없이 노출되기 때문에 캡슐화를 지키기 어렵다. 이로 인해 응집도가 낮고 결합도가 높으며 변경에 취약한 코드가 만들어질 가능성이 높다.

결론은 데이터가 아닌 책임을 중심으로 설계 하라는 것이다. 객체에게 중요한 것은 상태가 아니라 행동이다. 객체지향 설계의 기본은 책임과 협력에 초점을 맞추는 것이다.


도메인의 구조가 코드의 구조를 이끈다.

지금껏 우리는 도메인 모델을 중심으로 개념들의 관계를 파악하며 코드 설계를 진행하였다. 앞에서 설명한 것 처럼 도메인 모델은 단순히 설계에 필요한 용어를 제공하는 것을 넘어 코드의 구조에도 영향을 끼친다.

여기서 강조하고 싶은 것은 변경 역시 도메인 모델의 일부 라는 것이다. 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이의 관계가 투영 되어 있어야 한다.

구현을 가이드 할 수 잇는 도메인 모델을 선택하라. 객체 지향은 도메인 개념과 구조를 반영한 코드를 가능하게 만들기 때문에 도메인의 구조가 코드의 구조를 이끌어 내는 것은 자연스러울뿐만 아니라 바람직한 것이다.


변경과 유연성

설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다.

  1. 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계

  2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 구현

대부분의 경우에 1번이 더 좋은 방법이지만 유사한 변경이 반복적으로 발생 하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 더 좋다.

예를 들어 영화에 설정된 할인 정책을 실행중에 변경 할 수 있어야 한다는 요구사항이 추가됐다고 가정해 보자. 현재의 설계에서는 할인정책을 구현하기 위해 상속을 이용 하기 때문에 실행 중에 영화의 할인 정책을 변경하기 위해서는 새로운 인스턴스를 생성 한 후 필요한 정보를 복사해야 한다. 또한 변경 전후의 인스턴스가 개념적으로는 동일한 객체를 가리키지만 물리적으로 서로 다른 객체이기 때문에 식별자의 관점에서 혼란스러울 수 있다.

새로운 할인정책이 추가될 때 마다 인스턴스를 생성하고 상태를 복사하고 식별자를 관리하는 코드를 추가하는 일은 번거로울 뿐만 아니라 오류가 발생하기도 쉽다. 이 경우 코드의 복잡성이 높아지더라도 할인 정책의 변경을 쉽게 수용할 수 있게 코드를 유연하게 만드는 것이 더 좋은 방법이다.

해결 방법은 상속 대신 합성을 사용 하는 것이다. 기존에 구현된 할인 정책을 독립적인 DiscountPolicy로 분리한 후 Movie에 합성 시키면 유연한 설계가 완성된다.

합성을 사용한 예제의 경우 새로운 할인정책이 추가되더라도 할인 정책을 변경하는 데 필요한 추가적인 코드를 작성할 필요가 없다. 새로운 클래스를 추가하고 클래스의 인스턴스를 MoviechangeDiscountPolicy() 메서드에 전달하면 된다.

이 예시는 유연성에 대한 압박이 설계에 어떤 영향을 미치는지를 잘 보여준다. 실제로 유연성은 의존성 관리의 문제 이다.

요소들 사이의 의존성의 정도가 유연성의 정도를 결정한다.

유연성의 정도에 따라 결합도를 조절할수 있는 능력은 객체지향 개발자가 갖춰야 하는 중요한 기술 중 하나다.

책임을 할당하고 유연성을 기반으로 설계를 리팩터링해 나가면서 마침내 2장에서 소개했던 코드와 동일한 구조에 도달했다. 이제 어렴풋하게나마 협력이라는 문맥 안에서 적절한 책임을 적절한 객체에게 할당하는 방법에 대한 윤곽이 잡혔을 것이다. 하지만 객체지향어 어느 정도 익숙해지더라도 책임을 올바르게 할당하는 것은 여전히 어렵고 난해한 작업이다. 사실 객체지향 프로그래밍 언어를 이용해 절차형 프로그램을 작성하는 대부분의 이유가 바로 책임할당의 어려움 에서 기인한다.

만일 책임할당에 어려움을 겪고 있다면 일단 절차형 코드로 실행되는 프로그램을 빠르게 작성한 후 완성된 코드를 객체지향적인 코드로 변경하는 방법을 택해도 된다.


책임 주도 설계의 대안

책임 주도 설계에 익숙해지기 위해서는 부단한 노력과 시간이 필요하다. 설계를 진행하는 동안 데이터가 아닌 책임 관점에서 사고하기 위해서는 충분한 경험과 학습이 필요하다. 그러나 어느 정도 경험을 쌓은 숙련된 설계자조차도 적절한 책임과 객체를 선택하는 일에 어려움을 느끼곤 한다.

개인적으로 책임과 객체 사이에서 방황할 때 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성 하는 것이다. 아무것도 없는 상태에서 책임과 협력에 관해 고민하기 보다는 일단 실행되는 코드를 얻고난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다.

주의할 점은 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌어서는 안된다는 점이다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지 해야 한다.

메서드 응집도

데이터 중심으로 설계된 영화 예매 시스템에서 도메인 객체들은 단지 데이터의 집합일 뿐이며 영화 예매를 처리하는 모든 절차는 ReservationAgency 에 집중돼 있었다. 따라서 ReservationAgency에 포함된 로직들을 적절한 객체의 책임으로 분배 하면 책임 주도 설계와 거의 유사한 결과를 얻을 수 있다.

위의 reserve() 메서드는 길이가 너무 길고 이해하기도 어렵다. 이 메서드를 마우스 스크롤을 몇 번 해야만 전체 모습을 이해할 수 있는 매우 긴 메서드라고 상상해보자. 긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 끼친다.

  • 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는데 너무 많은 시간이 걸린다.

  • 하나의 메서드 안에서 너무 많은 작업을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.

  • 메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.

  • 로직의 일부만 재사용하는 것이 불가능하다.

  • 코드를 재사용 하는 유일한 방법은 코드를 복붙하는 것뿐이므로 코드 중복을 초래하기 쉽다.

긴 메서드는 응집도가 낮기 때문에 이해하기도 어렵고 재사용하기도 어려우며 변경하기도 어렵다.

위와 같은 메서드를 몬스터 메서드 라고 한다.

응집도가 낮은 메서드는 로직의 흐름을 이해하기 위해 주석이 필요한 경우가 대부분이다. 메서드가 명령문들의 그룹으로 구성되고 각 그룹에 주석을 달아야 할 필요가 있다면 그 메서드의 응집도는 낮은 것이다.

주석을 추가하는 대신 그룹화된 메서드 내 로직을 잘게 분해해서 각 메서드의 응집도를 높여라.

클래스의 응집도와 마찬가지로 메서드의 응집도를 높이는 이유도 변경과 관련이 깊다. 응집도 높은 메서드는 변경되는 이유가 단 하나 이어야 한다. 클래스가 작고, 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지를 쉽게 판단할 수 있다. 또한 메서드의 크기가 작고 목적이 분명하기 때문에 재사용하기도 쉽다. 작은 메서드들로 조합된 메서드는 마치 주석들을 나열한 것처럼 보이기 때문에 코드를 이해하기도 쉽다.

나는 다음과 같은 이유로 짧고, 이해하기 쉬운 이름으로 된 메서드를 좋아한다. 첫째 메서드가 잘게 나눠져 있을 때 다른 메서드에서 사용될 확률이 높아딘다. 둘째, 고수준의 메서드를 볼 때 일련의 주석을 읽는 것 같은 느낌이 들게 할 수 있다. 또한 메서드가 잘게 나눠져 있을 때 오버라이딩 하는 것도 훨씬 쉽다. 만약 큰 메서드에 익숙해져 있다면 메서드를 잘게 나누는 데는 약간의 시간이 걸릴 것이다. 작은 메서드는 실제로 이름을 잘 지었을때만 그 진가가 드러나므로 이름을 지을 때 주의해야 한다. 중요한 것은 메서드의 이름과 메서드 몸체의 의미적 차이다. 뽑아내는 것이 코드를 더욱 명확하게 하면 새로 만든 메서드의 이름이 원래 코드의 길이보다 길어져도 뽑아낸다.

객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해 하는 것이다. 긴 메서드를 작고 응집도 높은 메서드로 분리하면 각 메서드를 적절한 클래스로 이동하기가 더 수월해지기 때문이다.

다음은 위의 ReservationAgency 를 응집도 높은 메서드들로 잘게 분해한 것이다.

이제 ReservationAgency 클래스는 오직 하나의 작업만 수행하고, 하나의 변경 이유만 가지는 작고, 명확하고 응집도가 높은 메서드들로 구성돼 있다. 비록 클래스의 길이는 더 길어졌지만 일반적으로 명확성의 가치가 클래스의 길이보다 더 중요하다. 이렇게 조그마한 부분에서 개선된 명확성이 모여 변경하기 쉬운 코드가 만들어진다.

일단 메서드를 분리하고 나면 public 메서드는 사우이 수준의 명세를 읽는 것 같은 느낌이 든다.

코드를 작은 메서드들로 분해하면 전체적인 흐름을 이해하기도 쉬워진다. 동시에 너무 많은 세부사항을 기억하도록 강요하는 코드는 이해하기도 어렵다. 큰 메서드를 작은 메서드들로 나누면 한 번에 기억해야 하는 정보를 줄일 수 있다. 더 세부적인 정보가 필요하다면 그때 각 메서드의 세부적인 구현을 확인하면 된다.

수정 후의 코드는 변경하기도 더 쉽다. 각 메서드는 단 하나의 이유에 의해서만 변경된다. 할인 조건 중에서 기간 조건을 판단하는 규칙이 변경된다면 isSatisfiedByPeriod() 메서드를 수정하면 된다. 할인 규칙 중에서 금액 할인 규칙이 변겨오딘다면 calculateAmountDiscountedFee() 메서드를 수정하면 된다. 예매 요금을 계산하는 규칙이 변경되었다면 calculateFee() 메서드를 수정하면 된다.

작고, 명확하며, 한 가지 일에 집중하는 응집도 높은 메서드는 변경 가능한 설계를 이끌어 내는 기반이 된다. 이런 메서드들이 하나의 변경 이유를 가지도록 개선될 때 결과적으로 응집도 높은 클래스가 만들어진다.

안타깝게도 메서드들의 응집도 자체는 높아졌지만 이 메서드들을 담고 있는 ReservationAgency 클래스의 응집도는 여전히 낮다. ReservationAgency 클래스의 응집도를 높이기 위해서는 변경의 이유가 다른 메서드들을 적절한 위치로 분배해야 한다.

적절한 위치란 바로 각 메서드가 사용하는 데이터를 정의하고 있는 클래스를 의미한다.


객체를 자율적으로 만들자.

어떤 메서드를 어떤 클래스로 이동시켜야 할까? 객체가 자율적인 존재 라는 사실을 떠올리면 쉽게 답할 수 있다.

자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 하는 객체

자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다. 따라서 메서드가 사용하고 있는 데이터를 저장하고 있는 클래스로 메서드를 이동 시키면 된다.

어떤 데이터를 사용하는지를 가장 쉽게 알 수 있는 방법은 메서드 안에서 어떤 클래스의 getter(접근자 메서드)를 사용하는지 파악 하는 것이다. ReservationAgencyisDiscountable() 메서드를 보자.

ReservationAgency 클래스의 isDiscountable() 메서드는 DiscountConditiongetType() 메서드를 호출해서 할인 조건의 타입을 알아낸 후 타입에 따라 isSatisfiedBySequence() 메서드나 isSatisfiedByPeriod() 메서드를 호출한다. isSatisfiedBySequence() 메서드와 isSatisfiedByPeriod() 메서드 내부 구현 역시 할인 여부를 판단하기 위해 DiscountCondition 에 속한 데이터를 주로 이용한다는 것을 알 수 있다. 두 메서드를 데이터가 존재하는 DiscountCondition 으로 이동하고 ReservationAgency 에서 삭제하자

DiscountConditionisDiscountable() 메서드는 외부에서 호출 가능해야 하므로 가시성을 public으로 변경했다. isDiscountable() 메서드가 ReservationAgency 에 속할 때는 구현의 일부였지만 DiscountCondition 으로 이동한 후에는 퍼블릭 인터페이스의 일부가 된 것이다. 기존의 isDiscountable() 메서드는 DiscountCondition 의 인스턴스를 인자로 받아야 했지만 이제 DiscountCondition 의 일부가 됐기 때문에 인자로 전달받을 필요가 없어졌다.

메서드를 다른 클래스로 이동시킬 때는 인자에 정의된 클래스 중 하나로 이동하는 경우가 일반적이다.

이제 DiscountCondition 내부에서만 DiscountCondition 의 인스턴스 변수에 접근한다. 따라서 DiscountCondition 에서 모든 접근자 메서드(getter)를 제거할 수 있다. 이를 통해 DiscountCondition의 내부 구현을 캡슐화 할 수 있다. 또한 할인 조건을 계산하는 데 필요한 모든 로직이 DiscountCondition 에 모여있기 때문에 응집도 역시 높아졌다. ReservationAgency 는 내부 구현을 노출하는 접근자 메서드를 사용하지 않고 메시지를 통해서만 DiscountCondition과 협력한다. 따라서 낮은 결합도를 유지한다.

데이터를 사용하는 메서드를 데이터를 가진 클래스로 이동시키고 나면 캡슐화와 높은 응집도, 낮은 결합도를 가지는 설계를 얻게 된다.

변경 후의 코드는 책임 주도 설계 방법을 적용해서 구현했던 DiscountCondition 의 초기 모습과 유사해졌다. 여기에 POLYMORPHISM 패턴과 PROTECTED VARIATIONS 패턴을 차례대로 적용하면 최종 설계와 유사한 모습의 코드를 얻게 될 것이다.

여기서 하고 싶은 말은 책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링 하더라도 유사한 결과를 얻을 수 있다는 것이다. 처음부터 책임 주도 설계 방법을 따르는 것보다 동작하는 코드를 작성한 후에 리팩터링 하는 것이 더 훌륭한 결과물을 낳을 수도 있다. 캡슐화, 응집도, 결합도를 이해하고 훌륭한 객체지향 원칙을 적용하기 위해 노력한다면 책임 주도 설계 방법을 단계적으로 따르지 않아도 유연하고 깔끔한 코드를 얻을 수 있을 것이다.

참고 및 출처

  • 오브젝트