015. (Objects) 8. 의존성 관리하기

8. 의존성 관리하기

잘 설계된 객체지향 애플리케이션은 작고 응집도 높은 객체들로 구성된다.

작고 응집도가 높은 객체란 객체가 가진 책임의 초점이 명확하고, 한 가지 일만 잘 수행하는 객체를 의미한다.

이 객체 하나로는 단독으로 수행할 수 있는 작업이 없기 때문에, 일반적인 애플리케이션을 구현하기 위해선 여러 객체들의 협력이 필요하다.

이처럼 협력은 필수적이지만, 과도한 협력은 설계 품질을 낮추는 원인이 될 수도 있으므로, 객체간의 의존성을 잘 관리해야 한다.

8.1. 의존성 이해하기

어떤 객체가 협력하기 위해 다른 객체를 필요로 하는 경우에 두 객체 사이에 의존성이 존재한다고 한다.

의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.

  • 실행 시점 : 의존하는 객체가 정상적으로 동작하기 위해서는, 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
  • 구현 시점 : 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경된다.

위의 설명에서 알 수 있듯이 의존성은 방향성을 가지며 의존하는 객체와 의존되는 객체의 단방향성을 나타낸다.

또한 의존성은 변경에 의한 전파 가능성을 암시하기도 하는데, 이로 인해 의존성 전이(transitive dependency) 라는 개념도 이해하고 있어야 한다.

flowchart LR
    A --> B
    B --> C

의존성 전이가 의미하는 것은 A가 B를 의존하고, B가 C를 의존하는 경우에 C의 변경이 A까지 전파됨을 말한다.

물론 의존성은 함께 변경될 수 있는 “가능성”을 나타내기 때문에 무조건 전이가 일어난다고 볼 수는 없다.

의존성의 실제 전이 여부는 변경의 방향과 캡슐화의 정도에 따라 달라지기 때문이다.

따라서 의존성의 종류를 직접 의존성(direct dependency)간접 의존성(indirect dependency) 로 분류하기도 한다.

직접 의존성은 한 객체가 다른 객체를 직접 의존하는 경우를 말하며, 간접 의존성은 직접적인 관계는 존재하지 않지만 의존성 전이에 영향을 받는 경우를 의미한다.

의존성은 클래스 간의 관계로 이해하면 편하지만, 클래스 뿐만 아니라 객체, 모듈 혹은 큰 규모의 시스템의 단위로도 표현될 수 있음을 유의해야 한다.

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

의존성과 관련하여 다뤄야하는 토픽이 또 있다.

바로 런타임 의존성(run-time dependency)컴파일타임 의존성(compile-time dependency) 이다.

런타임은 말 그대로 애플리케이션이 실행되는 시점을 가리킨다.

객체지향 애플리케이션에서 런타임은 객체들이 점유하는 시간이므로, 런타임 의존성은 결국 객체 사이의 의존성을 뜻하며

실제로 협력할 객체가 어떤 객체인 것인지는 런타임에 해결해야 한다.

컴파일타임 의존성은 일반적으로 작성된 코드를 컴파일하는 시점 혹은 문맥에 따라 코드 그 자체를 가리킨다.

컴파일타임 의존성은 실제로 우리가 작성할 코드의 구조에 영향을 받는다.

결론적으로 실제로 협력할 객체가 어떤 객체인지는 런타임에 해결해야 하며, 명시적으로 협력할 객체를 드러내는 경우 다른 클래스와의 협력이 불투명해진다.

따라서 런타임 구조와 컴파일타임 구조사이의 간극이 벌어질수록 유연하고 재사용이 가능한 설계로 나아갈 수 있다.

8.3. 컨텍스트 독립성

유연하고 확장 가능한 설계를 위해 런타임 의존성과 컴파일타임 의존성을 다르게 가져가야한다는 점을 이해했다면, 이제 결합도에 대해 고민할 차례다.

클래스는 자신과 협력할 객체의 구체적인 클래스에 대해 알아서는 안된다.

구체적인 클래스를 인지하는 경우, 해당 클래스가 사용하는 특정한 문맥에 강한 결합이 발생하기 때문이다.

어떤 클래스가 특정 문맥에 강결합하는 경우, 다른 문맥에서의 사용성은 저하게되기 마련이며

반대로 특정 문맥에 대해 최소한의 가정으로만 결합되어있다면 다른 문맥에서의 재사용이 용이해지는데, 이를 컨텍스트 독립성 이라고 부른다.

8.4. 의존성 해결하기

의존성 해결이란 컴파일타임 의존성을 실행 컨텍스트에 맞는 구체적인 런타임 의존성으로 교체하는 것을 말한다.

의존성 해결을 위해서는 일반적으로 아래와 같은 방법들이 존재한다.

구분 장점 단점
생성 시점에 생성자를 통해 의존성 해결 생성시점에 선택적인 인스턴스를 넘겨받을 수 있음 -
생성 후 setter 메서드를 통해 의존성 해결 실행 시점에 의존성을 변경하여 좀 더 유연한 설계가 가능 생성 후 의존 대상 설정 전까지 불완전 상태일 수 있음
메서드 호출시 인자를 이용해 의존성 해결 지속적인 의존 관계가 필요없거나 일시적인 의존 관계로 구현 가능한 경우 유용 대부분의 호출해서 동일한 객체를 인자로 전달하는 경우 비효율

권장되는 방법은 객체를 생성할때 의존성을 해결해서 완전 상태의 객체를 생성한 후, 필요에 따라 setter 메서드를 이용해 의존 대상을 변경시키는 것이다.

8.5. 유연한 설계

유연하고 재사용 가능한 설계를 지향한다면, 의존성을 관리하는 데 유용한 몇 가지 원칙과 기법을 익히는 것이 좋다.

먼저 의존성과 결합도의 관계를 살펴보자.

객체들이 협력하기 위해서는 서로의 존재와 수행 가능한 책임을 알아야 하며, 이를 통해 서로 간의 의존성을 낳는다.

협력은 객체지향의 근간이므로 이를 위해 발생하는 모든 의존성이 꼭 나쁘다고 볼 수는 없다.

의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서 바람직한 것이다.(물론 과한 의존성은 문제를 야기한다.)

그렇다면 바람직한 의존성이란 무엇일까?

어떠한 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 이는 바람직하지 못한 의존성이다.

반대로 다양한 환경에서 재사용할 수 있다면 바람직한 의존성이다.

즉, 바람직한 의존성은 재사용성과 관련이 있으며, 컨텍스트에 독립적인 의존성이다.

그렇다면 특정 컨텍스트에 강하게 의존하는 클래스를 다른 컨텍스트에서 재사용하려면 어떻게 해야할까?

결국 구현을 변경하는 방법밖에 없다. 하지만 이는 바람직하지 못한 의존성을 또 다른 바람직하지 못한 의존성을 교체하는 것이다.

8.6. 강한 결합도와 약한 결합도 그리고 추상화

어떤 두 요소 사이에 존재하는 의존성이 바람직한 경우 느슨하거나 약한 결합도를 가지고 있다고 표현하고,

바람직하지 못한 경우 단단하거나 강한 결합도를 가지고 있다고도 표현할 수 있다.

결합도는 결국 하나의 요소가 자신이 의존하고 있는 다른 요소에 대해 알고 있는 지식 혹은 정보의 양으로 결정된다.

반대로 다른 요소에 대한 지식이 적을 수록 약한 결합도를 나타내므로, 추상화를 이용하는 것이 좋다.

추상화란 어떤 양성, 세부사항, 구조 등을 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감추어 복잡도를 극복하는 방법이다.

추상화를 사용하면 현재 직면한 문제를 해결하는 데 불필요한 정보를 소거하여, 대상에 대해 알아야할 지식의 양을 줄이고 결과적으로 결합도를 낮추는 것이 가능하다.

일반적으로 추상화와 결합도의 관점에서 의존 대상을 아래와 같이 구분하는 것이 유용하다.

위에서 아래로 갈수록 결합도가 느슨해진다.

  • 구체 클래스 의존성(concreate class dependency)
  • 추상 클래스 의존성(abstract class dependency)
  • 인터페이스 의존성(interface dependency)

구체 클래스에 비해 추상 클래스는 메서드 내부 구현과 자식 클래스의 종류에 대한 정보를 클라이언트에게 감출 수 있다.

마지막으로 인터페이스에 의존하면 상속 계층을 모르더라도 협력이 가능해지며, 어떤 메시지를 수신할 것인가에 대해서만 인지하기에 결합도가 더욱 낮아진다.

8.7. 명시적 의존성

추상 클래스나 인터페이스로 결합도를 느슨하게 한 것으로는 부족하다.

클래스 내에서 구체 클래스에 대한 모든 의존성을 소거하고, 런타임시점에 어떤 인스턴스와 협력할지 고지하는 방식이 필요하다.

상술했듯 생성자나 setter, 메서드의 인자 등을 통해 인스턴스의 타입을 주입받을 수 있는데 이처럼 외부에 퍼블릭 인터페이스로 노출되는 것을 명시적 의존성(explicit dependency) 라고 하며, 인터페이스에 노출되지 않는 것을 숨겨진 의존성(hidden dependency) 라고 부른다.

의존성이 숨겨져 있는 경우 내부 구현을 직접 살펴보거나, 다른 컨텍스트의 재사용을 위해 내부 구현을 직접 변경하는 등의 리스크가 동반된다.

코드의 변경은 곧 잠재적인 버그 출현의 가능성을 시사하기 때문이다.