본문 바로가기
Study/오브젝트

[오브젝트] 9장. 유연한 설계

by Nahwasa 2022. 12. 22.

스터디 메인 페이지

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

    - 모든 이미지의 출처는 오브젝트(조용호 저) 책 입니다.

     


     

    CHAPTER 09. 유연한 설계

    ⚈ 8장에서 살펴본 다양한 의존성 관리 기법들을 원칙이라는 관점에서 정리하는게 9장이다.

    • ☆ 마침 스터디 순서상 이번주차에 8장과 9장이었는데, 같이보게 되서 매우 다행이었다. 8장 내용 중복 겸 +@로 설명해주는게 9장이다. 특히 평소 헷갈렸던 부분들을 해결해주는 부분이 많아서 개인적으로 많이 좋았던 장이다. SOLID 중 OCP도 다른 글들 봐도 명확히 이해가 안됬는데 이 책에 답이 있었다.

     


    01 개방-폐쇄 원칙

    OCP (Open-Closed Principle)

    • "소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다."

     

    '확장'과 '수정'

    • 확장 = 동작의 관점, 수정 = 코드의 관점
    • '확장'에 대해 열려 있다 : 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 '확장'할 수 있다.
    • '수정'에 대해 닫혀 있다 : 기존의 '코드'를 수정하지 않고도 애플리케이션의 '동작'을 추가하거나 변경할 수 있다.

     

    ⚈ OCP는 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다. 애플리케이션의 동작을 확장하려면 일반적으로 어찌됬든 코드를 수정해야 하는데 어떻게 코드를 수정하지 않고도 동작을 추가할 수 있을까?

     

    사실 OCP는 런타임 의존성과 컴파일타임 의존성에 관한 이야기다(8장에 나오는 얘기임).

    • 런타임 의존성 : 실행시에 협력에 참여하는 객체들 사이의 관계
    • 컴파일타임 의존성 : 코드에서 드러나는 클래스들 사이의 관계
    • 8장에서 얘기했듯이 유연하고 재사용 가능한 설계에서 런타임 의존성과 컴파일타임 의존성은 서로 다른 구조를 가진다.
    • OCP를 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있는 코드이다.
      (☆ OCP에 대해 그동안 명확하지 않았었는데, 이 내용을 보고 ㅗㅜㅑ 소리가 났다. 굳)

     

     예를들어 8장 마지막 부분에서 나왔던 Movie의 경우

    • 확장에 대해 열려 있다 : 새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다.
    • 수정에 대해서는 닫혀 있다 : 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것만으로 새로운 할인 정책을 확장할 수 있다.

     

    ⚈ 추상화가 핵심이다

    • 추상화 : 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법
    • OCP의 핵심은 추상화에 의존하는 것이다.
    • OCP의 관점에서 생략되지 않고 남겨진 부분은 다양한 상황에서 공통점을 반영한 추상화의 결과물이다. 공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 한다. 즉, 수정할 필요가 없어야 한다. 따라서 추상화 부분은 수정에 대해 닫혀 있다.
    • 추상화를 통해 생략된 부분은 확장의 여지를 남긴다. 언제라도 추상화의 생략된 부분을 채워넣음으로써 새로운 문맥에 맞게 기능을 확장할 수 있다. 즉, 확장에 대해 열려 있다. 

     

    ⚈ 추상화를 했다고 OCP를 만족하게 되는 것은 아니다.

    • OCP를 만족하는 설계는 공짜로 얻어지지 않는다.
    • 추상화가 수정에 대해 닫혀 있을 수 있는 이유는 변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊게 선택했기 때문이다.

     


    02 생성 사용 분리

    ⚈ 추상화에만 의존하기 위해서는 구체 클래스의 인스턴스를 직접 생성해서는 안 된다.

    • 동작을 추가하거나 변경하기 위해 기존의 코드를 수정하도록 만들기 때문에 OCP를 위반한다.
    • 물론 객체 생성을 피할 수는 없다. 어딘가에서는 반드시 객체를 생성해야 한다. 문제는 객체 생성 자체가 아니라 부적절한 곳에서 객체를 생성하는 것이다.
    • 즉, 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제이다.

     

    ⚈ 생성, 사용

    • 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다.
    • 하나는 객체를 '생성'하는 것이고
    • 다른 하나는 객체를 '사용'하는 것이다.
    • 객체에 대한 '생성과 사용을 분리(separating use from creation)' 해야 한다.

     

    ⚈ 생성의 책임을 클라이언트로 옮기자

    • 가장 보편적인 생성 사용 분리 방법이다.
    • 예를 들어 Movie에게 금액 할인 정책을 적용할지, 비율 할인 정책을 적용할지를 알고 있는 것은 그 시점에 Movie와 협력할 클라이언트이다. 현재의 컨텍스트에 관한 결정권을 가지고 있는 클라이언트로 컨텍스트에 대한 지식을 옮김으로써 Movie는 특정한 클라이언트에 결합되지 않고 독립적일 수 있다.

     

    ⚈ FACTORY 추가하기

    • 위처럼 클라이언트로 생성의 책임을 옮기는 것은, Movie는 특정 컨텍스트에 묶여서는 안 되지만 Client는 묶여도 상관없다는 전제가 깔려 있다.
    • 이 때 Client도 생성과 사용의 책임을 함께 지니고 있다고 해보자.
    • 그럼 Movie에서 생성의 책임을 Client로 옮긴 것과 마찬가지로, Movie를 생성하는 책임을 Client의 인스턴스를 사용할 문맥을 결정할 클라이언트로 옮길 수 있다.
    • 하지만 이 때 객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가기를 원하지 않는다고 가정해보자. 즉, Client도 특정한 컨텍스트에 묶이지 않기를 바란다고 가정해보자.
    • 이 경우 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 클라이언트는 이 객체를 사용하도록 만들 수 있다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다.

     


    객체지향이 실세계의 모방이라는 말은 옳지 않다.

    GRASP - INFORMATION EXPERT

    • ☆ GRASP에 대해서는 5장 정리 내용 참고. (링크)
    • 어떤 책임을 할당하고 싶다면 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다.
    • 그런데 위에 나온 FACTORY는 도메인 모델에 속하지 않는다.
    • FACTORY를 추가한 이유는 순수하게 기술적인 결정이다. 도메인 개념에게 할당돼 있던 객체 생성 책임을 도메인 개념과는 아무런 상관이 없는 가공의 객체로 이동시킨 것이다.

     

    순수한 가공물 (PURE FABRICATION)

    • 종종 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다. 
    • 실제로 애플리케이션은 DB 접근을 위한 객체와 같이 도메인 개념들을 초월하는 기계적인 개념들을 필요로 할 수 있다.
    • 모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다.
    • 이 경우 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다.
    • 이처럼 책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 순수한 가공물이라고 부른다.

     

    객체지향

    • 도메인 개념뿐만이 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다.
    • 이런 측면에서 객체지향이 실세계의 모방이라는 말은 옳지 않다.
    • 객체지향이 실세계를 모방해야 한다는 헛된 주장에 현혹될 필요가 없다. -> 우리가 애플리케이션을 구축하는 것은 사용자들이 원하는 기능을 제공하기 위해서지 실세계를 모방하거나 시뮬레이션하기 위한 것이 아니다.

     

     설계

    • 먼저 도메인의 본질적인 개념을 표현하는 추상화를 이용해 애플리케이션 구축 시작
    • 도메인 개념이 만족스럽지 못하다면 인공적인 객체를 창조하라
    • 예를 들어 FACTORY는 객체의 생성 책임을 할당할 만한 도메인 객체가 존재하지 않을 때 선택할 수 있는 PURE FABRICATION 이다.

     


    03 의존성 주입

    DI (Dependency Injection)

    • 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법
    • DI는 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있또록 만드는 방법을 포괄하는 명칭이다.

     

    의존성 주입에서 의존성을 해결하는 세 가지 방법을 가리키는 용어 정의

    • 생성자 주입(constructor injection) : 객체를 생성하는 시점에 생성자를 통한 의존성 해결
    • setter 주입(setter injection) : 객체 생성 후 setter 메서드를 통한 의존성 해결. 런타임에 의존성 대상을 변경 가능하다. 다만 setter 메서드 호출을 누락한다면 객체는 비정상적인 상태로 생성될 것이다.
    • 메서드 주입(method injection) : 메서드 실행 시 인자를 이용한 의존성 해결. 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있다. 주입된 의존성이 한 두 개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.
    • 번외 - 인터페이스 주입(interface injection) : 근본적으로 setter 주입이나 프로퍼티 주입과 동일하지만, 인터페이스에 명시적으로 주입이 필요하다는 점을 선언하는 방법. 이하 인터페이스 주입의 예
    public interface DiscountPolicyInjectable {
    	public void inject(DiscountPolicy discountPolicy);
    }

     

    숨겨진 의존성은 나쁘다

    • SERVICE LOCATOR 패턴은 나쁘다. -> 의존성을 감춘다. ☆ 책에서 제시된 코드만 보면 동시성 문제도 있어보인다.
    • 의존성을 감추는 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다.
    • 숨겨진 의존성은 문제점을 발견할 수 있는 시점을 실행 시점으로 미루기 때문에 이해하기 어렵고 디버깅하기 어렵다.

     

     숨겨진 의존성은 캡슐화를 위반한다.

    • 변수의 가시성을 private으로 선언하고 변경되는 내용을 숨겼다고 해서 캡슐화가 지켜지는 것은 아니다.
    • 클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것이다.
    • 명시적인 의존성이 숨겨진 의존성보다 좋다. 가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라. 의존성을 구현 내부에 숨기면 숨길수록 코드를 이해하기도, 수정하기도 어려워진다.

     

    요점은 명시적인 의존성

    • 유연성을 향상시키는 가장 효과적인 방법이다.

     


    04 의존성 역전 원칙

    DIP (Dependency Inversion Principle)

    • 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
    • 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
    • 로버트 마틴이 '역전'이라는 단어를 사용한 이유 : 의존성 역전 원칙을 따르는 설계는 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문
    • ☆ 자바에서 Map<HashSet<String>> 과 같은 형태로 사용하는 것도 추상화 내부에 구체클래스가 나오므로 동일하게 DIP를 위반할듯하다.

     

    해결사는 추상화

    • 유연하고 재사용 가능한 설계를 원한다면 모든 의존성의 방향이 추상 클래스나 인터페이스와 같은 추상화를 따라야 한다.
    • ☆ 결국 SOLID 원칙 중 OCP와 DIP의 해결책은 둘 다 추상화이다. 추상화를 바라보는 관점에 따라 의존성의 개념이라면 DIP, 확장의 개념이라면 OCP를 충족시킬 수 있다.

     

    대부분의 경우 우리가 재사용하려는 대상은 상위 수준의 클래스이다.

    • 상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.
    • 같은 의미로 추상화를 별도의 독립적인 패키지가 아니라 클리이언트가 속한 패키지에 포함시켜야 한다. 그리고 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. (SEPARATED INTERFACE 패턴)
    • DIP에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다.

     

    훌륭한 객체지향 설계를 위해서는 의존성을 역전시켜야 한다.

    • 의존성을 역전시켜야만 유연하고 재사용 가능한 설계를 얻을 수 있다.

     


    05 유연성에 대한 조언

    유연한 설계가 항상 좋은 것은 아니다.

    • 변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게될 가능성이 높다.
    • 유연한 설계라는 말의 이면에는 복잡한 설계라는 의미가 숨어 있다.
    • 설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다. 유연함은 단순성과 명확성의 희생 위에서 자라난다.

     

    정말 유연성이 필요한가?

    • 변경은 예상이 아니라 현실이어야 한다.
    • 미래에 변경이 일어날지도 모른다는 막연한 불안감은 불필요하게 복잡한 설계를 낳는다.
    • 아직 일어나지 않은 변경은 변경이 아니다. (☆ 익스트림 프로그래밍(XP)의 YAGNI 원칙을 뜻하는 것 같다.)

     

    트레이드 오프

    • 단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라.
    • 유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을 때만 가치가 있다.
    • 하지만 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어라.

     


    핵심은 협력과 책임

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

    • 핵심은 협력과 책임이 중요하다는 것이다.
    • 다양한 컨텍스트에서 협력을 재사용할 필요가 없다면 설계를 유연하게 만들 당위성도 함께 사라진다.
    • 동일한 책임을 수행하는지 여부를 판단할 수 없다면 공통의 추상화를 도출할 수 없다.
    • 역할을 통해 객체들을 대체 가능하게 만들지 않았다면 협력에 참여하는 객체들을 교체할 필요가 없다.
    • 책임을 할당하고 협력의 균형을 맞추는 것이 객체 생성에 관한 책임을 할당하는 것보다 우선이다.
    • 책에 나온 개발자가 평소 하던 일 : "객체가 무엇이 되고 싶을지를 알게 될 때까지 객체들을 어떻게 인스턴스화할 것인지에 대해 전혀 신경쓰지 않았다. 마치 객체가 이미 존재하는 것처럼 이들 간의 관계를 신경 쓰는 일이다."

     

    의존성을 관리해야 하는 이유는 역할, 책임, 협력의 관점에서 설계가 유연하고 재사용 가능해야 하기 때문이다.

    • 9장의 기법들을 적용하기 전에 역할, 책임, 협력의 모습이 선명하게 그려지지 않는다면 의존성을 관리하는 데 들이는 모든 노력이 물거품이 될 수도 있다.

    댓글