• 이번 장에서는 유연하고 재사용 가능할 설계를 만들기 위해 적용할 수 있는 다양한 의존성 관리 기법들을 원칙이라는 관점에서 정리 합니다.

01. 개방-폐쇄 원칙

  • 개방-폐쇄 원칙(Open-CLosed Principle, OCP) : 소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 열려 있어야 하고, 수정에 대해서는 닫허 있어야 합니다.
    • 확장에 대해 열려 있다는 것은 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 동작을 추가해서 애플리케이션의 기능을 확장할 수 있습니다.
    • 수정에 대해 닫혀 있다는 것은 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있습니다.
  • 개방-폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계입니다.
  • 처음에는 동작을 확장하는 것과 코드를 수정하지 않는 것이 서로 대립되는 개념으로 보일 수도 있습니다.

컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라

  • 의존성 관점에서 개방-폐쇄 원칙을 따르는 설계란 컴파일타임 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조입니다.
    • 런타임 의존성은 실행시에 협력에 참여하는 객체들 사이의 관계입니다.
    • 컴파일타임 의존성은 코드에서 드러나는 클래스들 사이의 관계입니다.

추상화가 핵심이다

  • 개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것입니다.
  • 개방-폐쇄 원칙의 관점에서 생략되지 않고 남겨지는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물 입니다.
    • 추상화 부분은 수정에 대해 닫혀 있습니다.
    • 추상화를 통해 생략된 부분은 확장의 여지를 남깁니다.
  • 개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향입니다. 수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 합니다.
  • 핵심은 추상화라는 것을 기억해야 합니다. 올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한함으로써 설계를 유연하게 확장할 수 있습니다.
  • 추상화가 수정에 대해 닫혀 있을 수 있는 이유는 변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊에 선택했기 때문입니다.

02. 생성 사용 분리

  • 문제는 객체 생성이 아니라 부적절한 곳에서 객체를 생성하는 것이 문제입니다.
  • 메시지를 전송하지 않고 객체를 생성하기만 한다면 아무런 문제가 없습니다. 또는 객체를 생성하지 않고 메시지를 전송하기만 한다면 괜찮습니다. 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제입니다.
  • 유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 합니다. 하나는 객체를 생성하는 것이고, 다른 하나는 객체를 사용하는 것입니다. 한 마디로 말해서 객체에 대한 생성과 분리(separation use from creation) 해야 합니다.
  • 소프트웨어 시스템은 (응용 프로그램 객체를 제작하고 의존성을 서로 ‘연결’하는) 시작 단계와 (시간 단계와 이후에 이어지는) 실행 단계를 분리해야 합니다.
  • 사용으로부터 생성을 분리하는 데 사용되는 가장 보편적은 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것입니다.

FACTORY 추가하기

  • 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있습니다. 이처럼 생성과 사용과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부릅니다.
  • Client는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있습니다.

순수한 가공물에게 책임 할당하기

  • 책임 할당의 가장 기본이 되는 원칙은 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 INFORMATION EXPERT에게 책임을 할당하는 것입니다.
  • 도메인 모델은 INFORMATION EXPERT를 찾기 위해 참조할 수 있는 일차적인 재료입니다. 어떤 책임을 할당하고 싶다면 제일 먼저 도메인 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 합니다.
  • FACTORY는 도메인 모델에 속하지 않습니다. FACTORY를 추가한 이유는 순수하게 기술적인 결정입니다. 전체적인 결합도를 낮추고 재사용성을 높이기 위해 도메인 개념에게 할당돼 있던 객체 생성 책임을 도메인 개념과는 아무런 상관이 없는 가공의 객체로 이동 시킨 것입니다.

객체를 분해하는 방식

  • 표현적 분해(representational decomposition)
    • 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것입니다.
    • 도메인 모델에 담겨 있는 개념과 관계에 따르며 도메인과 소프트웨어 사이의 표현적 차이를 최소화 하는 것을 목적으로 합니다.
    • 객체지향 설계를 위한 가장 기본적인 접근법 입니다.
  • 행위적 분해(behavioral decomposition)
    • 어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 PURE FABRICATION을 추가하고 이 객체에게 책임을 할당합니다. 그 결과로 추가된 PURE FABRICATION은 보통 특정한 행동을 표현하는 것이 일반적입니다. 따라서 PURE FABRICATION은 표현적 분해보다는 행위적 분해에 의해 생성되는 것이 일반적 입니다.

      책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 PURE FABRICATION(순수한 가공물)이라고 부릅니다.

  • 이런 측면에서 객체지향이 실세계의 모방이라는 말이 옳지 않습니다.
    • 애플리케이션 내에서 인공적으로 창조한 객체들이 도메인 개념을 반영하는 객체들보다 오히려 더 많은 비중을 차지하는 것이 일반적입니다.
  • 만약 도메인 개념이 만족스럽지 못하다면 주저하지 말고 인공적인 객체를 창조합니다.
  • 도메인 모델에서 출발해서 설계에 유연성을 추가하기 위해 책임을 이리저리 옮기다 보면 많은 PURE FABRICATION을 추가하게 된다는 사실을 알게 될 것입니다.
    • FACTORY는 객체의 생성 책임을 할당할만한 도메인 객체가 존재하지 않을 때 선택할 수 있는 PURE FABRICATION입니다.

PURE FABRICATION 패턴

  • 객체지향 설계는 문제 도메인 상의 개념을 소프트웨어 객체로 구현하고 책임을 할당 합니다. 하지만 만약 도메인 객체에 책임을 할당할 경우 HIGH COHESION, LOW COUPLING, 재사용성 등의 목적을 위반 한다면 문제 도메인 개념을 표현하지 않는, 인위적인 또는 편의상 만든 클래스에 매우 응집된 책임을 할당합니다. 이들 클래스는 문제 도메인 상에는 존재하지 않지만 순수하게 전체 설계의 품질을 높이기 위해 설계자의 임의에 따라 추가한 상상 속의 가공물 입니다.
  • PURE FABRICATION은 INFORMATION EXPERT 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용됩니다. 어떤 객체가 책임을 수행하는 데 필요한 많은 정보를 가졌지만 해당 책임을 할당할 경우 응집도가 낮아지고 결합도가 높아진다면 가공의 객체를 추가해서 책임을 옮기는 것을 고민합니다. 순수한 가공물(PURE FABRICATION)이라는 표현은 적절한 대안이 없을 때 사람들이 창조적인 무언가를 만들어 낸다는 것을 의미하는 관용적인 표현입니다.

03. 의존성 주입

  • 생성과 사용을 분리하면 오로지 인스턴스를 사용하는 책임만 남게 됩니다. 이것은 외부의 다른 객체가 생성된 인스턴스를 전달해야 한다는 것을 의미합니다. 이처럼 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입(Dependency Injection)이라고 부릅니다. 이 기법을 의존성 주입이라고 부르는 이유는 외부에서 의존성의 대싱을 해결한 후 이를 사용하는 객체 쪽으로 주입하기 때문입니다.
  • 의존성 주입은 근본적으로 의존성 해결 방법과 관련이 깊습니다. 의존성 해결은 컴파일타임 의존성과 런타임 의존성의 차이점을 해소하기 위한 다양한 메커니즘을 포괄합니다.
  • 의존성 주입은 의존성을 해결하기 위해 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법을 포괄하는 명칭입니다.

의존성 주입에서는 의존성을 해결하는 세 가지 방법을 가리키는 별도의 용어를 정의합니다.

생성자 주입(constructor injection)
  • 객체를 생성하는 시점에 생성자를 통한 의존성을 해결하는 방법입니다.
setter 주입(setter injection)
  • 객체 생성 후 setter 메서드를 통한 의존성을 해결하는 방법입니다.
  • setter 주입의 장점은 의존성의 대상을 런타임에 변경할 수 있다는 것입니다.
    • 생성자 주입을 통해 설정된 인스턴스는 객체의 생명주기 전체에 걸쳐 관계를 유지하는 반면, setter 주입은 언제라도 의존 대상을 교체할 수 있습니다.
  • setter 주입의 단점은 객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지를 명시적으로 표현할 수 없다는 것입니다.
    • setter 메서드는 객체가 생성된 후에 호출돼야 하기 때문에 setter 메서드 호출을 누락한다면 객체는 비정상적인 상태로 생성될 것입니다.
메서드 주입(method injection)
  • 메서드 실행 시 인자를 이용한 의존성을 해결하는 방법입니다.
  • 메서드 주입은 메서드 호출 주입(method call injection)이라고도 부르며 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있습니다.
  • 생성자 주입을 통해 의존성을 전달받으면 객체가 올바른 상태로 생성되는 데 필요한 의존성을 명확하게 표현할 수 있다는 장점이 있지만 주입된 의존성이 한 두개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있습니다.
  • 메서드 주입을 의존성 주입의 한 종류로 볼 것인가에 대해서는 논란의 여지가 있습니다.

프로퍼티 주입과 인터페이스 주입

  • setter 주입과 프로퍼티 주입은 언어 사이의 차이점을 제외하고 나면 개념적으로 동일하기 때문에 같은 기법으로 간주해도 무방합니다.
    • setter 주입 : 자바
    • 프로퍼티 주입 : C#
  • 인터페이스 주입(interface injection)이라는 의존성 주입 기법도 있습니다. 인터페이스 주입의 기본 개념은 주입할 의존성을 명시하기 위해 인터페이스를 사용하는 것입니다.
    • 인터페이스 주입은 근본적으로 setter 주입이나 프로퍼티 주입과 동일합니다. 단지 어떤 대상을 어떻게 주입할 것인지를 인터페이스를 통해 명시적으로 선언한다는 차이만 있을 뿐입니다. 인터페이스 주입은 의존성 주입이 도입되던 초창기에 자바 진영에서 만들어진 몇몇 프레임워크에서 의존성 대상을 좀 더 명시적으로 정의하고 편하게 관리하기 위해 도입한 방법입니다. 따라서 약간의 구현적인 관점을 덜어내고 의존성 주입이 가지는 목적과 용도라는 본질적인 측면에서 바라보면 인퍼테이스 주입은 setter 주입과 프로퍼티 주입의 변형으로 볼 수 있습니다.

숨겨진 의존성은 나쁘다

  • 의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 존재합니다. 가장 널리 사용되는 대표적인 방법은 SERVICE LOCATOR 패턴입니다.
  • SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소입니다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청합니다. SERVICE LOCATOR 패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스의 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해줍니다. SERVICE LOCATOR 패턴의 가장 큰 단점은 의존성을 감춥니다.
  • 의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다는 사실을 알 수 있습니다.
  • 숨겨진 의존성은 의존성의 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어뜨려 놓습니다. 이것은 코드를 이해하고 디버깅 하기 어렵게 만듭니다.
  • 핵심은 명시적인 의존성이 숨겨진 의존성보다 좋다는 것입니다. 가급적 의존성을 객체의 퍼블릭 인터페이스에 노출합니다. 의존성을 구현 내부에 숨기면 숨길수록 코드를 이해하기도, 수정하기도 어려워집니다.
  • 어쩔수 없이 SERVICE LOCATOR 패턴을 사용해야 하는 경우도 있습니다. 의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우나 깊은 호출 계층에 걸쳐 동일한 객체를 계속해서 전달해야 하는 고통을 견디기 어려운 경우에는 어쩔수 없이 SERVICE LOCATOR 패턴을 사용하는 것을 고려합니다.
  • 가능하다면 의존성을 명시적으로 표현할 수 있는 기법을 사용합니다.
    • 의존성 주입은 의존성을 명시적으로 명시할 수 있는 방법 중 하나일 뿐입니다.
    • 요점은 명시적인 의존성에 초점을 맞추는 것입니다.
    • 이 방법이 유연성을 향상시키는 가장 효과적인 방법입니다.

04. 의존성 역전 원칙

추상화와 의존성 역전

  • 의존성은 변경의 전파와 관련된 것이기 때문에 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 합니다.
  • 상위 수준 클래스는 어떤 식으로든 하위 수준 클래스에 의존해서는 안 됩니다.
    • 상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워집니다.
  • 이 경우에도 해결사는 추상화입니다. 모두가 추상화에 의존하도록 수정하면 하위 수준 클래스의 변경으로 인해 상위 수준의 클래스가 영향을 받는 것을 방지할 수 있습니다. 또한 상위 수준을 재사용할 때 하위 수준의 클래스에 얽매이지 않고도 다양한 컨텍스트에서 재사용이 가능합니다.

의존성 역전 원칙

  1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 됩니다. 둘 모두 추상화에 의존해야 합니다.
  2. 추상화는 구체적인 사항에 의존해서는 안 됩니다. 구체적인 사항은 추상화에 의존해야 합니다.

의존성 역전 원칙과 패키지

  • 역전은 의존성의 방향뿐만 아니라 인터페이스의 소유권에도 적용됩니다. 객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈입니다.
  • SEPARATED INTERFACE 패턴
    • 추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 합니다.
    • 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 합니다.
  • 의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 합니다.
  • 훌륭한 객체지향 설계를 위해서는 의존성을 역전시켜야 합니다. 그리고 의존성을 역전시켜야만 유연하고 재사용 가능한 설계를 얻을 수 있습니다.

05. 유연성에 대한 조언

유연한 설계는 유연성이 필요할 때만 옳다

  • 설계의 미덕은 단순함과 명확함으로부터 나옵니다. 명확한 설계를 가진 코드는 읽기 쉽고 이해하기도 편합니다.
  • 유연한 설계는 복잡하고 암시적입니다.
  • 객체지향에 입문한 개발자들이 가장 이해하기 어려워하는 부분이 바로 코드 상에 표현된 정적인 클래스의 구조와 실행 시점의 동적인 객체 구조가 다르다는 사실입니다.
  • 객체지향 코드에서 클래스의 구조는 발생 가능한 모든 객체 구조를 담는 틀일 뿐입니다. 특정 시점의 객체 구조를 파악하는 유일한 방법은 클래스를 사용하는 클라이언트 코드 내에서 객체를 생성하거나 변경하는 부분을 직접 살펴보는 것뿐입니다.
  • 설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어집니다. 따라서 유연함은 단순성과 명확성의 희생 위에서 자라납니다.
  • 유연한 설계를 단순하고 명확하게 만드는 유일한 방법은 사람들 간의 긴밀한 커뮤니케이션뿐입니다.
  • 불필요한 유연성은 불필요한 복잡성을 낳습니다. 단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거합니다. 유연성은 코드를 읽는 사람들이 복잡함을 수용할 수있을 때만 가치가 있습니다. 하지만 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만듭니다.

협력과 책임이 중요하다

  • 마지막으로 하고 싶은 말은 객체의 협력과 책임이 중요하다는 것입니다.
  • 지금까지 클래스를 중심으로 구현 메커니즘 관점에서 의존성을 설명했지만 설계를 유연하게 만들기 위해서는 협력에 참여하는 객체가 다른 객체에게 어떤 메시지를 전송하는지가 중요합니다.
  • 설계를 유연하게 만들기 위해서는 먼저 역할, 책임, 협력에 초점을 맞춰야 합니다. 다양한 컨텍스트에서 협력을 재사용할 필요가 없다면 설계를 유연하게 만들 당위성도 함께 사라집니다. 객체들이 머세지 전송자의 관점에서 동일한 책임을 수행하는지 여부를 판단할 수 없다면 공통의 추상화를 도출할 수 없습니다. 동일한 역할을 통해 객체들을 대체 가능하게 만들지 않았다면 협력에 참여하는 객체들을 교체할 필요가 없습니다.
  • 초보자가 자주 저지르는 실수 중 하나는 객체의 역할과 책임이 자리를 잡기 전에 너무 성급하게 객체 생성에 집중하는 것입니다. 이것은 객체 생성과 관련된 불필요한 세부사항에 객체를 결합시킵니다. 객체를 생성할 책임을 담당할 객체나 객체 생성 메커니즘을 결정하는 시점은 책임 할당의 마지막 단계로 미뤄야합니다. 중요한 비즈니스 로직을 처리하기 위해 책임을 할당하고 협력의 균형을 맞추는 것이 객체 생성에 관하 책임을 할당하는 것보다 우선입니다. 책임 관점에서 객체들 간에 균형이 잡힌 상태라면 생생과 관련 된 책임을 지게 될 객체를 선택하는 것은 간단한 작업이 됩니다.
  • 책임의 불균형이 심화되고 있는 상태에서 객체의 생성 책임을 지우는 것은 설계를 하부의 특정한 메커니즘에 종속적으로 만들 확률이 높습니다. 불필요한 SINGLETON 패턴을 객체 생성에 관해 너무 이른 시기에 고민하고 결정할 때 도입되는 경향이 있습니다. 핵심은객체를 생성하는 방법에 대한 결정은 모든 책임이 자리를 잡은 후 가장 마지막 시점에 내리는 것이 적절하다는 것입니다.
  • 의존성을 관리해야 하는 이유는 역할, 책임, 협력에 관점에서 설계가 유연하고 재사용 가능해야 하기 때문입니다. 따라서 역할, 책임, 협력에 먼저 집중합니다. 이번 장에서 설명한 다양한 기법들을 적용하기 전에 역할, 책임, 협력의 모습이 선명하게 그려지지 않는다면 의존성을 관리하는 데 들이는 모든 노력이 물거품이 될 수도 있다는 사실을 명심합니다.