본문 바로가기

기술서적 정리/오브젝트 - 코드로 이해하는 객체지향 설계

09 CHAPTER 유연한 설계

https://wikibook.co.kr/object/

 

오브젝트: 코드로 이해하는 객체지향 설계

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라! 객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두 번째 걸음은 객체를

wikibook.co.kr

 

 

◈ 개방-폐쇄 원칙

확장 가능하고 변화에 유연하게 대응할 수 있는 설계를 만들 수 있는 원칙 중 하나 개방-폐쇄 원칙(OCP).

 

개방 폐쇄 원칙은 다음과 같이 요약할 수 있다.

 

1. 확장에 대해 열려있다.

애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 ‘동작’을 추가해서 애플리케이션의 기능을 확장할 수 있다.

 

2. 수정에 대해 닫혀 있다.

기존의 ‘코드’를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.

 

개방-폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다.



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

개방-폐쇄 원칙은 런타임 의존성과 컴파일타임 의존성에 관한 이야기다.

 

개방-폐쇄 원칙을 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있다.



추상화가 핵심이다.

개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다.

 

추상화 부분은 수정에 대해 닫혀 있다.

추상화를 통해 생략된 부분확장의 여지를 남긴다.

이것이 추상화가 개방-폐쇄 원칙을 가능하게 만드는 이유다.

 

단순히 어떤 개념을 추상화했다고 해서 수정에 대해 닫혀 있는 설계를 만들 수 있는 것은 아니다.

개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다.

수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 한다.

 

올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한함으로써 설계를 유연하게 확장할 수 있다.

 

여기서 주의할 점은 추상화를 했다고 해서 모든 수정에 대해 설계가 폐쇄되는 것은 아니라는 것이다.

추상화가 수정에 대해 닫혀 있을 수 있는 이유변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊게 선택했기 때문이라는 사실을 기억하라.




 

◈ 생성 사용 분리

객체 생성에 대한 지식과도한 결합도를 초래하는 경향이 있다.

 

물론 객체 생성을 피할 수는 없다.

어딘가에서는 반드시 객체를 생성해야 한다.

문제는 객체 생성이 아니다.

부적절한 곳에서 객체를 생성한다는 것이 문제다.

 

동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제이다.

유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다.

 

사용으로부터 생성을 분리하는 데 사용되는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것이다.

 

인스턴스를 생성하는 책임을 클라이언트에 맡김으로써 구체적인 컨텍스트와 관련된 정보는 클라이언트로 옮기고 사용을 담당할 클래스는 생성한 인스턴스를 사용하는 데만 주력한다.

 

사용을 담당한 클래스는 추상화에만 의존하도록 제한하기 때문에 확장에 대해서는 열려있으면서도 수정에 대해서는 닫혀 있는 코드를 만들 수 있다.



팩토리 추가하기.

객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가기를 원하지 않는다고 가정해보자.

 

이 경우 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다.

이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 팩토리라고 부른다.

 

팩토리를 사용하면 Client는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있다.



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

책임 할당의 가장 기본이 되는 원칙은 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 ‘정보 전문가’에게 책임을 할당하는 것이다.

어떤 책임을 할당하고 싶다면 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다.

 

팩토리는 도메인 모델에 속하지 않는다.

팩토리를 추가한 이유는 순수하게 기술적인 결정이다.

전체적으로 결합도를 낮추고 재사용성을 높이기 위해 도메인 개념에게 할당돼 있던 객체 생성 책임도메인 개념과 아무런 상관이 없는 가공의 객체로 이동시킨 것이다.

 

실제로 동작하는 애플리케이션은 데이터베이스 접근을 위한 객체와 같이 도메인 개념들을 초월하는 기계적인 개념들을 필요로 할 수 있다.

 

모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다.

이 경우 도메인 개념을 표현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다.

책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체‘퓨어 패브리케이션’이라고 부른다.

 

어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 ‘퓨어 패브리케이션’을 추가하고 이 객체에게 책임을 할당하라.

 

객체지향 애플리케이션은 도메인 개념뿐만 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다.

애플리케이션 내에서 인공적으로 창조한 객체들이 도메인 개념을 반영하는 객체들보다 오히려 더 많은 비중을 차지하는 것이 일반적이다.

 

설계자로서의 우리의 역할은 도메인 추상화를 기반으로 애플리케이션 로직을 설계하는 동시에 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조하는 것이다.

 

퓨어 패브리케이션 패턴.

퓨어 패브리케이션은 정보 전문가 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용된다.
어떤 객체가 책임을 수행하는 데 필요한 많은 정보를 가졌지만 해당 책임을 할당할 경우 응집도가 낮아지고 결합도가 높아진다면 가공의 객체를 추가해서 책임을 옮기는 것을 고민하라.

 

 

도메인 모델에서 출발해서 설계에 유연성을 추가하기 위해 책임을 이리저리 옮기다 보면 많은 퓨어 패브리케이션을 추가하게 된다는 사실을 알게 될 것이다.

팩토리객체의 생성 책임을 할당할만한 도메인 객체가 존재하지 않을 때 선택할 수 있는 퓨어 패브리케이션이다.




 

◈ 의존성 주입

사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다.

 

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

1. 생성자 주입 : 객체를 생성하는 시점에 생성자를 통한 의존성 해결.

2. 세터 주입 : 객체 생성 후 세터 메서드를 통한 의존성 해결.

3. 메서드 주입 : 메서드 실행 시 인자를 이용한 의존성 해결.

 

세터 주입의 장점은 의존성의 대상을 런타임에 변경할 수 있다는 것이다.

 

세터 주입의 단점은 객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지를 명시적으로 표현할 수 없다는 것이다.

세터 메서드는 객체가 생성된 후에 호출해야 하기 때문에 세터 메서드 호출을 누락한다면 객체는 비정상적인 상태로 생성될 것이다.

 

 

생성자 주입을 통해 의존성을 전달받으면 객체가 올바른 상태로 생성되는 데 필요한 의존성을 명확하게 표현할 수 있다는 장점이 있지만 주입된 의존성이 한 두 개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.




숨겨진 의존성은 나쁘다.

‘서비스 로케이터’는 의존성을 해결할 객체들을 보관하는 일종의 저장소다.

인스턴스를 등록하고 반환할 수 있는 메서드를 구현한 저장소다.

 

의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다.

숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제점을 발견할 수 있는 시점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문이다.

 

의존성을 숨기는 코드는 단위 테스트 작성도 어렵다.

서비스 로케이터는 내부적으로 정적 변수를 사용해 객체들을 관리하기 때문에 모든 단위 테스트 케이스에 걸쳐 서비스 로케이터의 상태를 공유하게 된다.

이것은 각 단위 테스트는 서로 고립돼야 한다는 단위 테스트의 기본 원칙을 위반한 것이다.

 

단위 테스트가 서로 간섭 없이 실행되기 위해서는 테스트 하려는 인스턴스를 사용하는 모든 단위 테스트 케이스에서 그 인스턴스를 생성하기 전에 서비스 로케이터에, 생성에 필요한 인스턴스를 추가하고 테스트가 끝날 때마다 추가된 인스턴스를 제거해야 한다.

 

캡슐화는 코드를 읽고 이해하는 행위와 관련이 있다.

클래스의 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 훌륭한 코드다.

클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것이다.

 

숨겨진 의존성은 의존성의 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어트려 놓는다.

 

의존성의 주입은 이 문제를 깔끔하게 해결한다.

필요한 의존성은 클래스의 퍼블릭 인터페이스에 명시적으로 드러난다.

의존성을 이해하기 위해 코드 내부를 읽을 필요가 없기 때문에 의존성 주입은 객체의 캡슐을 단단하게 보호한다.

 

이야기 핵심은 의존성 주입이 서비스 로케이터 패턴보다 좋다가 아니라 명시적인 의존성이 숨겨진 의존성보다 좋다는 것이다.

가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라.

 

어쩔 수 없이 서비스 로케이터 패턴을 사용해야 하는 경우도 있다.

깊은 호출 계층에 걸쳐 동일한 객체를 계속해서 전달해야 하는 고통을 견디기 어려운 경우에는 어쩔 수 없이 서비스 로케이터 패턴을 사용하는 것을 고려하라.





◈ 의존성 역전 원칙

추상화와 의존성 역전

객체 사이의 협력이 존재할 때 그 협력의 본질을 담고 있는 것은 상위 수준의 정책이다.

어떤 협력에서 중요한 정책이나 의사결정, 비즈니스의 본질을 담고 있는 것은 상위 수준의 클래스다.

 

상위 수준의 클래스는 어떤 식으로든 하위 수준의 클래스에 의존해서는 안된다.

 

대부분의 경우 우리가 재사용하려는 대상은 상위 수준의 클래스라는 점을 기억하라.

상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.

 

중요한 것은 상위수준의 클래스다.

상위 수준의 변경에 의해 하위 수준이 변경되는 것은 납득할 수 있지만 하위 수준의 변경으로 인해 상위 수준이 변경돼서는 곤란하다.

하위 수준의 이슈로 인해 상위 수준에 위치하는 클래스들을 재사용하는 것이 어렵다면 이것 역시 문제가 된다.

 

이 경우에도 해결사는 추상화다.

모두가 추상화에 의존하도록 수정하면 하위 수준의 클래스의 변경으로 인해 상위 수준의 클래스가 영향을 받는 것을 방지할 수 있다.

또한 상위 수준을 재사용할 때 하위 수준의 클래스에 얽매이지 않고도 다양한 컨텍스트에서 재사용이 가능하다.

 

지금까지 살펴본 내용을 정리해보면,

1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안되고 둘 모두 추상화에 의존해야 한다.

2. 추상화는 구체적인 사항에 의존해서는 안되고 구체적인 사항은 추상화에 의존해야 한다.

 

이를 의존성 역전 원칙이라고 부른다.



의존성 역전 원칙과 패키지

역전은 의존성의 방향뿐만 아니라 인터페이스 소유권에도 적용된다.

객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈이다.

 

C++ 같은 언어에서는 같은 패키지 안에 존재하는 불필요한 클래스들로 인해 빈번한 재컴파일과 재배포가 발생할 수 있다.

 

패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포돼야 한다.

이로 인해 이 패키지에 의존하는 클래스가 포함된 패키지 역시 재컴파일돼야 한다.

따라서 불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 가파르게 상승시킨다.

 

추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 한다.

그리고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다.

이 기법을 가리켜 세퍼레이티드 인터페이스 패턴 이라고 한다.

 

의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스 소유권 역시 역전시켜야 한다.

인터페이스 소유권을 서버가 아닌 클라이언트에 위치시킨다.

이는 객체지향 프레임워크의 모듈 구조를 설계하는 데 가장 중요한 핵심 원칙이다.

 

정리하자.

전통적인 패러다임에서는 상위 수준의 모듈이 하위 수준 모듈에 의존했다면 객체지향 패러다임에서는 상위 수준 모듈과 하위 수준 모듈이 모두 추상화에 의존한다.

전통적인 패러다임에서는 인터페이스가 하위 수준 모듈에 속했다면 객체지향 패러다임에서는 인터페이스가 상위 수준 모듈에 속한다.





◈ 유연성에 대한 조언

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

변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게 될 가능성이 높다.

 

객체지향에 입문한 개발자들이 가장 이해하기 어려워하는 부분이 바로 코드 상에 표현된 정적인 클래스의 구조와 실행 시점의 동적인 객체 구조가 다르다는 사실이다.

특정 시점의 객체 구조를 파악하는 유일한 방법은 클래스를 사용하는 클라이언트 구조 내에서 객체를 생성하거나 변경하는 부분을 직접 살펴보는 것뿐이다.

 

설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다.

따라서 유연함은 단순성과 명확성의 희생 위에서 자라난다.

 

불필요한 유연성은 불필요한 복잡성을 낳는다.

유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을 때만 가치가 있다.



협력과 책임이 중요하다.

다양한 컨텍스트에서 협력을 재사용할 필요가 없다면 설계를 유연하게 만들 당위성도 함께 사라진다.

객체들이 메시지 전송자의 관점에서 동일한 책임을 수행하는지 여부를 판단할 수 없다면 공통의 추상화를 도출할 수 없다.

동일한 역할을 통해 객체들을 대체 가능하게 만들지 않았다면 협력에 참여하는 객체들을 교체할 필요가 없다.

 

초보자가 자주 저지르는 실수 중 하나는 객체의 역할과 책임이 자리를 잡기 전에 너무 성급하게 객체 생성에 집중하는 것이다.

이것은 객체 생성과 관련된 불필요한 세부사항에 객체를 결합시킨다.

객체를 생성할 책임을 담당할 객체나 객체 생성 메커니즘을 결정하는 시점은 책임 할당의 마지막 단계로 미뤄야만 한다.

 

책임의 불균형이 심화되고 있는 상태에서 객체 생성 책임을 지우는 것은 설계를 하부의 특정한 메커니즘에 종속적으로 만들 확률이 높다.

불필요한 싱글톤 패턴은 객체 생성에 관해 너무 이른 시기에 고민하고 결정할 때 도입되는 경향이 있다.

핵심은 객체를 생성하는 방법에 대한 결정은 모든 책임이 자리를 잡은 후 가장 마지막 시점에 내리는 것이 적절하다는 것이다.