[오브젝트] chap08을 읽고

잘 설계된 객체지향 애플리케이션은 작고 응집도 높은 객체들로 구성된다. 응집도가 높은 객체란 책임의 초점이 명확하고 한 가지 일만 잘 하는 객체를 의미한다. 이런 작은 객체들이 혼자서 할 수 있는 작업은 거의 없기 때문에 다른 객체에게 도움을 요청해야 한다. 이런 요청이 객체의 협력을 낳는다.

협력은 필수적이지만 과도한 협력은 설계를 곤경에 빠뜨린다. 협력은 객체가 다른 객체에 대해 알 것을 강요한다. 다른 객체의 존재한다는 것을 알아야 하고, 그 객체가 수신할 수 있는 메시지에 대해서도 알고 있어야 한다. 이런 지식이 객체 사이의 의존성을 낳는다.

하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다. 핵심은 필요한 의존성은 유지하면서도 변경을 방해하는 의존성을 제거하는 것이다. 이런 관점에서 객체지향 설계는 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.

그렇다면 의존성은 구체적으로 무엇일까?

의존성 이해하기

변경과 의존성

의존성은 이렇게 설명할 수 있다. A 객체가 어떤 다른 객체 B가 존재하지 않을 때 정상적으로 작동하지 않는다면 A는 B에 의존하고 있다고 할 수 있다.

객체가 협력하기 위해서는 두 객체 사이에 의존성이 존재하게 된다. 의존성은 방향성을 가지며 단방향이다. B에 의존하는 A객체가 아무리 변경되어도 B에게 영향을 미치지 않는다. B가 변경되면 A에 영향을 끼킨다.

두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미한다. 따라서 의존성은 변경에 의한 영향의 전파 가능성을 암시한다.

의존되는 클래스의 이름 변경, 인터페이스의 이름 변경, 오퍼레이션의 시그니처 변경이 의존하는 클래스의 변경을 야기한다.

의존성 전이

A 객체가 B에 의존하고 있고, B객체가 C에 의존하고 있다. 여기서 C가 변경될 때, A에 영향을 미치는 경우에 의존성이 전이되었다고 한다.

하지만 의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 모든 경우에 의존성이 전이되는 것은 아니다. 의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다. 의존성 전이는 변경에 의해 영향이 널리 전파될 수도 있다는 경고일 뿐이다.

의존성은 전이될 수 있기 때문에 직접 의존성간접 의존성으로 나누기도 한다. 직접 의존성은 직접 의존하고 있는 경우를 나타내며 코드 상에 명시적으로 드러난다. 하지만 간접 의존성은 직접 의존하지 않지만 변경의 여파가 전달되는 경우를 말한다. 중요한 것은 의존성은 코드 상에 명시적으로 드러나지 않을 수도 있다는 것이다.

런타임 의존성과 컴파일타임 의존성

컴파일타임 의존성이란 코드 작성 시점의 의존성을 나타낸다. 컴파일타임 의존성이 다루는 주제는 클래스 사이의 의존성이다. 하지만 런타임의 주인공은 객체이다. 런타임 의존성은 코드 실행 시점에 어떤 객체에 의존하고 있는지를 나타낸다. 만약 컴파일타임에 인터페이스에 의존하고 있다면, 런타임 의존성은 실제 인터페이스를 구현한 객체이다.

유연하고 재사용 가능한 설계의 특징은 런타임 의존성과 컴파일타임 의존성 사이의 거리가 멀다는 것이다. 즉, 유연하고 재사용 가능한 설계는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만든다. 클래스는 자신과 협력할 객체의 구체적인 클래스에 대해 알아서는 안된다.

컨텍스트 독립성

유연한 설계를 위해 클래스는 자신과 협력할 객체의 구체적인 클래스를 알아서는 안 된다. 구체적인 클래스를 알면 알수록 그 클래스가 사용되는 특정한 문맥에 강하게 결합되기 때문이다.

클래스가 A 문맥과 B 문맥에 재사용할 수 있으려면 A와 B의 최소한의 공통적인 가정을 뽑은 C라는 문맥을 기반으로 클래스를 설계하면 된다. 이는 A와 B 문맥에 대해 독립적이기 때문에 컨텍스트 독립성이라고 부른다. 즉, 컨텍스트에 대한 정보가 적으면 적을수록 더 다양한 컨텍스트에서 재사용될 수 있다.

의존성 해결하기

그렇다면 어떻게 런타임에 적절한 객체들과 협력할 수 있을까? 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다. 일반적으로 다음과 같은 세 가지 방법을 사용한다.

  • 객체를 생성하는 시점에 생성자를 통해 의존성 해결 (인스턴스 변수)
  • 객체 생성 후 setter 메서드를 통해 의존성 해결 (인스턴스 변수)
  • 메서드 실행 시 인자를 이용해 의존성 해결 (메서드 파라미터)

메서드 인자를 사용하는 방식은 협력 대상에 대해 지속적으로 의존 관계를 맺을 필요 없이 메서드가 실행되는 동안만 일시적으로 의존 관계가 존재해도 무방하거나, 메서드가 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용하다. 하지만 클래스의 메서드를 호출하는 대부분의 경우에 매번 동일한 객체를 인자로 전달하고 있다면 생성자 방식이나 setter 방식으로 변경하는 것이 좋다.

유연한 설계

설계를 유연하고 재사용 가능하게 만들기로 결정했다면 의존성 관리에 유용한 원칙과 기법들을 익혀야 한다. 먼저 의존성과 결합도의 관계를 살펴보자.

의존성과 결합도

모든 의존성이 나쁜 것은 아니다. 의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서 바람직한 것이다. 하지만 의존성이 과하면 문제가 될 수 있다.

그렇다면 바람직한 의존성이란 무엇인가? 바람직한 의존성은 재사용성과 관련이 있다. 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다. 즉, 특정 컨텍스트에 강하게 결합된 의존성은 바람직하지 않은 의존성이다.

예를 들어, C 인터페이스를 A 클래스와 B 클래스가 구현하고 있을 때, 어떤 클래스가 A 클래스에 의존한다면 B의 컨텍스트에서 클래스를 재사용할 수 없다.

바람직한 의존성이란 다양한 환경에서 클래스를 재사용할 수 있는 의존성이다. 즉, 컨텍스트 독립적인 의존성이 좋은 의존성이다.

좋은 의존성과 나쁜 의존성을 세련되게 가리키는 용어가 바로 결합도이다. 의존성이 바람직할 때 두 요소가 느슨한 결합도 또는 약한 결합도를 가진다고 말한다. 반대의 경우 단단한 결합도 또는 강한 결합도라고 한다.

관계의 특성을 설명할 때, 의존성은 두 요소의 관계 유무를 설명해 “의존성이 존재한다”, “의존성이 존재하지 않는다”라고 표현한다. 그에 반해 결합도는 두 요소 사이의 존재하는 의존성의 정도를 상대적으로 표현한다.

의존성이 바람직하냐 바람직하지 못하냐를 결정하는 것은 재사용 가능성이고 그에 따라 결합도가 약한지 강한지 이야기 할 수 있다.

의존성 관리의 유용한 원칙과 기법

이제 의존성과 결합도의 관계를 알아보았으니 의존성을 관리하기 위한 유용한 원칙과 기법들을 알아보자.

지식이 결합을 낳는다

결합도의 정도는 한 요소가 자신이 의존하고있는 다른 요소에 대해 알고 있는 정보의 양으로 결정된다. 아는 정보의 양이 많을 수록 더욱 강하게 결합된다. 구체 클래스보다 인터페이스나 추상 클래스에 의존하는 경우가 더욱 느슨한 결합도를 유지할 수 있다.

즉, 결합도를 느슨하게 유지하려면 협력하는 대상에 대해 더 적게 알아야 한다. 결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외는 최대한 감추는 것이 중요하다.

추상화에 의존하라

추상화를 사용하면 현재 다루고 있는 문제를 해결하는 데 불필요한 정보를 감출 수 있다. 따라서 대상에 대해 알아야 하는 지식의 양을 줄일 수 있기 때문에 결합도를 느슨하게 유지할 수 있다.

아래쪽으로 갈수록 클라이언트가 알아야 하는 지식의 양이 적어지기 때문에 결합도가 느슨해진다.

  • 구체 클래스 의존성
  • 추상 클래스 의존성
  • 인터페이스 의존성

여기서 중요한 것은 실행 컨텍스트에 대해 알아야 하는 정보를 줄일수록 결합도가 낮아진다는 것이다. 다시 말해 의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다는 것이 핵심이다.

명시적인 의존성

결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상 클래스나 인터페이스로 선언하는 것만으로는 부족하다. 클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야만 한다.

특히 놓칠 수 있는 부분은 인스턴스 변수를 인터페이스나 추상 클래스로 선언하고 클래스 안에서 직접 구체 클래스를 대입하는 경우이다.

앞의 설명처럼 위의 경우에는 생성자, setter 메서드, 메서드 인자로 구체 클래스를 전달하는 방법으로 구체 클래스에 대한 의존성을 없앨 수 있다.

의존성의 대상을 인자로 전달하는 방법과 클래스 안에서 직접 생성하는 방법 사이의 가장 큰 차이점은 퍼블릭 인터페이스에 나타난다. 외부에서 전달 받는 모든 경우에 퍼블릭 인터페이스에 명시적으로 의존성이 노출된다. 이를 명시적인 의존성이라고 부른다.

클래스 내에서 의존성의 대상을 직접 생성하는 경우에는 클래스가 어떤 클래스에 대한 의존성을 가지고 있는지 외부에서 알 방법이 없다. 즉, 의존성이 퍼블릭 인터페이스에 표현되지않는다. 이를 숨겨진 의존성이라고 한다.

숨겨진 의존성의 문제점은 해당 클래스를 다른 컨텍스트에 대해 재사용하기 위해 내부 구현을 변경해야 한다는 것이다. 하지만 명시적인 의존성은 컴파일타임 의존성을 적절한 런타임 의존성으로 교체할 수 있어 재사용이 용이하다.

숨겨져 있는 의존성을 밝은 곳으로 널리 알려라. 그렇게 하면 설계가 유연하고 재사용 가능해 질 것이다.

new는 해롭다

new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다. 결합도의 측면에서 new가 해로운 이유는 크게 두가지이다.

  • new연산자를 사용하기 위해 구체 클래스의 이름을 직접 기술해야하기 때문에 결합도가 높아진다.
  • new연산자는 생성하려는 구체 클래스 뿐만 아니라 생성자의 인자 까지도 알아야 한다. new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.

new가 구체 클래스를 생성하는데 어떤 정보가 필요한지에 대한 지식도 요구한다. 만약 생성자의 인자 목록이나 인자 순서를 바꾸는 경우에도 클라이언트 클래스의 구현을 변경해야한다. 더 많은 것에 의존할수록 점점 더 변경에 취약해진다.

따라서 우리는 new를 사용하기 보다 외부의 인스턴스를 전달받아야 한다.

가끔은 생성해도 무방하다

클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용한 경우도있다. 클래스가 B 컨텍스트보다 A 컨텍스트에서 주로 사용된다면 외부의 주입을 받는 생성자 하나, 내부에서 A를 생성하는 생성자 하나를 체이닝 하는 것이다.

모든 상황에서 인스턴스 생성의 책임을 클라이언트로 옮긴다면 중복 코드가 많아지고, 클래스의 사용성도 나빠지기 때문이다. 위와 같은 방식을 채택한다면 클래스의 사용성을 향상시키면서 다양한 컨텍스트에서 유연하게 사용될 수 있다.

구체 클래스에 의존하더라도 사용성이 더 중요하다면 결합도를 높이는 방향으로 코드를 작성할 수 있다.

표준 클래스에 대한 의존은 해롭지 않다

의존성이 불편한 이유는 의존성이 변경에 대한 영향을 암시하기 때문이다. 따라서 변경할 가능성이 없는 클래스라면 의존성이 문제가 되지 않는다.

JDK에 포함된 표준 클래스가 이에 속한다. JDK의 코드가 수정될 확률은 0에 가깝기 때문에 클래스 내에서 직접 인스턴스를 생성해도 문제가 되지 않는다.

하지만 비록 클래스를 생성하더라도 가능한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다. 예를 들면 ArrayList를 사용하는 대신 List를 사용하는 것이다. 이렇게 하면 추후 다양한 List 타입의 객체로 설계의 유연성을 높일 수 있다.

의존성에 의한 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로 드러내는 것은 좋은 설계 습관이다.

컨텍스트 확장하기

에를 들어보자. Movie 클래스는 할인 정책을 알아야 하는 책임을 가진다. 따라서 Movie 클래스는 내부에 DiscountPolicy 타입의 인스턴스 변수를 가지고 있다. 지금까지의 할인 정책은 비율 할인 정책, 정액 할인 정책만을 가지고 있었다.

만약 할인 정책이 없는 경우, 또는 여러 할인 정책을 가지는 경우 컨텍스트 확장을 통해 기존 코드를 재사용 할 수 있을까?

할인 정책이 없는 경우 어떻게 해야할까? 하나의 할인 정책을 가지고 있던 지금까지의 협력 방식에 어긋난다. 기존 인스턴스를 외부에서 주입받을 때 null값을 집어 넣어야 할까? 기존의 코드는 null에 대응하고 있지 않기 때문에 기존의 코드를 수정해야 할 것이다.

해결책은 할인 정책이 존재하지 않는다는 사실을 예외 케이스로 처리하지 말고 기존의 컨텍스트를 따르도록 하는 것이다. 즉, 할인 정책이 존재하지 않는다는 사실을 할인 정책의 한 종류조 간주하자. 할인할 금액을 0원으로 반환하는 DiscountPolicy의 자식 클래스로 만들면 된다.

여러 할인 정책을 가지는 경우도 같은 방법을 사용해서 해결할 수 있다. 중복 할인 정책을 할인 정책의 한가지로 간주하는 것이다. 이제 외부에서 인스턴스를 생성해 Movie의 생성자를 통해 Movie에 조립하면 된다.

설계를 유연하게 만들 수 있었던 이유는 Movie 클래스가 DiscountPolicy라는 추상화에 의존하고, 생성자를 통해 명시적으로 DiscountPolicy에 대한 의존성을 명시적으로 드러냈으며, new와 같이 구체 클래스를 다뤄야 하는 책임을 Movie 외부로 옮겼기 때문이다.

우리는 DiscountPolicy의 자식 클래스를 추가함으로써 간단하게 Movie가 사용될 컨텍스트를 확장할 수 있었다. 결합도를 낮춤으로써 얻게 되는 컨텍스트의 확장이라는 개념이 유연하고 재사용 가능한 설계를 만드는 핵심이다.

조합 가능한 행동

다양한 종류의 할인 정책이 필요한 컨텍스트에서 Movie를 재사용할 수 있었던 이유는 코드를 직접 수정하지 않고도 협력 대상인 DiscountPolicy 인스턴스를 교체할 수 있었기 때문이다.

유연하고 재사용 가능한 설계는 응집도 높은 책임들을 가진 작은 객체들을 다양한 방식으로 연결함으로써 애플리케이션의 기능을 쉽게 확장할 수 있다.

유연하고 재사용 가능한 설계는 객체가 어떻게(how) 하는지를 나열하지 않고 객체들의 조합을 통해 무엇(what)을 하는지 표한하는 클래스들로 구성된다. 코드 로직을 해석할 필요 없이 객체의 연결만 보고도 객체의 행동을 예상하고 이해할 수 있게 된다. 즉, 선언적으로 행동을 정의할 수 있는 것이다.

훌륭한 객체지향 설계란 객체가 어떻게 하는지를 표현하는 것이 아니라 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지를 표현하는 설계다.

태그:

카테고리:

업데이트:

댓글남기기