티스토리 뷰

728x90
반응형

개방 폐쇄 원칙


  • 소프트웨어 개체(클래스, 함수, 모듈 등)는 확장에는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 합니다.
  • 확장에 대해 열려 있다의 의미 : 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 동작을 추가해서 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다의 의미 : 기존의 코드를 수정하지 않고도 새로운 동작을 추가하거나 변경할 수 있다.
  • 즉 개방 폐쇄 원칙은 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있다는 의미입니다.

 

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

  • 개방 폐쇄의 원칙은 런타임 의존성과 컴파일타임 의존성에 대한 이야기입니다. 런타임 의존성은 실행 시점에서 협력에 참여하는 객체들 사이의 관계이고, 컴파일타임 의존성은 코드 상에서 드러나는 클래스 사이의 관계입니다. 그리고 컴파일타임 의존성과 런타임 의존성이 다를수록 유연한 설계를 가질 수 있습니다.

 

💡 추상화가 핵심이다

  • 개방 폐쇄의 원칙은 추상화에 의존하는 것입니다.
  • 추상화란 핵심적인 부분은 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법입니다.
  • 변하는 것과 변하지 않는 것을 파악한 후 문맥에 따라 변하는 내용에 적합한 내용을 채워넣음으로써 각 문맥에 적합하게 기능을 구체화하고 확장할 수 있습니다.
  • 개방 폐쇄 원칙의 관점에서 변하지 않는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물이며 이 변하지 않는 부분은 문맥이 변경되더라도 변하지 않아야 합니다.
  • 추상화를 했다고 해서 개방 폐쇄 원칙이 적용되었다고 말할 수 없습니다. 이를 제대로 적용하기 위해서는 변하는 것과 변하지 않는 것이 무엇인지 이해하고 이를 추상화의 목적으로 삼아야 합니다. 
public abstract class DiscountPolicy {

    private List<DiscountCondition> conditions = new ArrayList<>();

    // 변하지 않는 부분
    public Money calculateDiscount(Screening screening) {
        for (DiscountCondition condition : conditions) {
            if (condition.isSatisfieBy(screening)) {
                return getDiscountAmount(screening);
            }
        }
        return Money.ZERO;
    }

    // 문맥에 따라 변하는 부분
    abstract protected Money getDiscountAmount(Screening screening);
}

 

생성 사용 분리


  • 아래 예제를 보면 Movie 클래스를 보면 추상화에 의존하고 있지만 생성자 부분에서 구체 클래스의 인스턴스를 생성하고 있습니다.
    이러한 코드에서는 Movie의 할인 정책이 변경되는 경우 코드를 직접 수정해야 하므로 개방 폐쇄 원칙을 위반합니다.
  • 하지만 Movie 클래스의 기본 정책이 Amount라면 문제 될 게 없습니다. 하지만 또 다른 문제는 Movie 클래스에서 생성과 사용을 함께 가지고 있으므로 문제가 될 수 있습니다.
  • 유연하고 재사용 가능한 설계를 원한다면 객체를 생성하는 책임과 객체를 사용하는 책임을 분리해야 합니다. 생성을 분리하는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트에게 넘기는 것입니다. 클라이언트에서 생성한 객체를 의존성 주입을 통해 전달하는 것입니다.
public class Movie {

    private String title;
    private DiscountPolicy discountPolicy;

    public Movie(String title) {
        this.title = title;
        this.discountPolicy = new AmountDiscountPolicy(...);
    }
    
    public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
        return fee.minus(discountPolicy.getDiscountAmount(screening));
    }
}

 

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

  • 책임 할당의 가장 기본이 되는 원칙은 정보 전문가에게 책임을 할당하는 것입니다. 도메인 모델은 정보 전문가를 찾기 위해 참조할 수 있는 일차적인 재료입니다. 어떤 책임을 할당하고 싶다면 1차적으로 도메인 모델 안의 개념 중 가장 적절한 후보가 있는지 찾아봐야 합니다.
  • 만약 적절한 도메인 모델이 존재하지 않는다면 순수한 가공물을 만들어 사용할 수 있습니다. 이는 오히려 전체적인 결합도를 낮추고
    재사용성을 높이기 위해 책임을 아무런 상관이 없는 가공물에게 넘기는 것입니다.

 

표현적 분해와 행위적 분해

  • 표현적 분해
    • 표현적 분해는 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것입니다. 표현적 분해는 도메인 모델에 담겨 있는 개념과 관계를 따르며 도메인과 소프트웨어 사이의 표현적 차이를 최소화 하는 것을 목적으로 합니다. 따라서 
      표현적 분해는 객체지향 설계를 위한 가장 기본적인 접근법입니다.
  • 행위적 분해
    • 어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 순수한 가공물을 추가하고 이 객체에 책임을 할당해야 합니다. 그 결과로 만들어진 순수한 가공물은 보통 특정한 행동을 표현하는 것이 일반적입니다.
  • 어떤 행동을 추가하려고 할 때 도메인 개념에 추가하는 것이 만족스럽지 못하다면 주저하지 말고 순수한 가공물을 만들어야 합니다. 
    객체지향이 실세계를 모방해야 한다는 헛된 주장에 현혹될 필요가 없습니다. 우리는 사용자들이 필요로 하는 애플리케이션을 만드는 것이지 실세계를 시뮬레이션하기 위한 것이 아닙니다.

 

의존성 주입


  • 의존성 주입이란 하나의 객체가 다른 객체를 필요로 할 때 필요한 객체를 내부에서 직접 만드는 것이 아닌 외부에서 필요한 객체를 만들고 이를 주입받는 형태입니다.

 

의존성을 주입 받는 방법

  • 생성자 주입 - 생성자를 통해 의존성을 주입 받는 방법
  • setter 주입 - 객체를 생성 후 setter 메서드를 통해 의존성을 주입 받는 방법, 이는 불변성을 가지지 못하므로 취약합니다.
  • 메서드 주입 - 메서드 실행 시 인자를 통해 주입 받는 방법

 

💡 숨겨진 의존성은 나쁘다

  • 앞에서 명시적 의존성과 숨겨진 의존성에 대한 이야기가 나왔었습니다. 숨겨진 의존성은 내부 구현을 파악해야 하므로 좋지 않다고 말했으며 이와 관련하여 SERVICE LOCATOR 패턴에 대해 알아보겠습니다.
  • SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소이며 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청합니다. 
  • 아래 예제에서 ServiceLocator는 싱글톤 패턴으로 만들어졌으며 Movie 클래스는 생성자를 통해 자신이 의존받고자 하는 객체를 요청하고 있습니다. 또한 Movie 객체를 생성하는 클라이언트에서는 ServiceLocator에 인스턴스를 등록하고 있습니다.
    이 패턴의 가장 큰 단점은 의존성을 감추고 있습니다. Movie 클래스가 필요로 하는 의존성을 명시적으로 선언해 놓지 않아 디버깅 과정도 힘들 뿐만 아니라 결국 런타임 시점에서 문제를 인지할 수 있습니다.
public class ServiceLocator {
    
    private static ServiceLocator instance = new ServiceLocator();
    private DiscountPolicy discountPolicy;
    
    private ServiceLocator() {}
    
    public static DiscountPolicy discountPolicy() {
        return instance.discountPolicy;
    }
    
    public static void provide(DiscountPolicy discountPolicy) {
        instance.discountPolicy = discountPolicy;
    }
}

public class Movie {

    private String title;
    private DiscountPolicy discountPolicy;

    public Movie(String title) {
        this.title = title;
        this.discountPolicy = ServiceLocator.discountPolicy();
    }
}

public class Main {

    public static void main(String[] args) {
        ServiceLocator.provide(new AmountDiscountPolicy(...));
        Movie movie = new Movie("공조");
    }
}

 

🤔 문제의 원인

  • 문제의 원인은 숨겨진 의존성이 캡슐화를 위반했기 때문입니다. 캡슐화는 코드를 읽고 이해하는 행위와 관련이 있습니다. 클래스의 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 훌륭한 코드입니다. 클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것입니다.
  • 숨겨진 의존성이 가지는 가장 큰 문제점을 의존성을 이해하기 위해 내부 구현을 이해할 것을 강요하고 있다는 것입니다. 따라서 숨겨진 의존성은 캡슐화를 위반합니다.
  • 의존성 주입은 숨겨진 의존성 문제를 해결할 수 있습니다. 필요한 의존성은 클래스의 퍼블릭 인터페이스에 명시적으로 드러냅니다.
    의존성을 이해하기 위해 내부 구현을 알 필요가 없기 때문에 의존성 주입은 객체의 캡슐화를 보호합니다. 또한 필요한 의존성을 컴파일 시점에 잡을 수 있습니다.
  • 의존성을 숨길수록 코드를 이해하기 어려우며 디버깅 또한 힘듭니다. 따라서 숨겨진 의존성보다 명시적인 의존성을 가급적 사용해야 합니다.

 

의존성 역전 원칙


💡 추상화와 의존성 역전

  • 아래 사진은 상위 수준 클래스인 Movie가 하위 수준 클래스인 구체 클래스에 의존하고 있는 관계입니다.
  • 객체 사이의 협력이 존재할 때 그 협력의 본질을 담고 있는 것은 상위 수준의 정책입니다. 즉 상위 수준의 클래스란 어떤 협력에서 중요한 정책이나 의사결정, 비지니스의 본질을 담고 있는 클래스를 의미합니다. 그러나 이 상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받을 수 있습니다.
  • 의존성은 변경의 전파와 관련된 것이기 때문에 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 합니다.

 

상위 수준의 클래스가 하위 수준의 클래스에게 의존하면 벌어지는 일

  • Movie가 의존하고 있는 구체 클래스가 변경될 때 Movie 클래스를 수정해야 합니다.(Amount -> Percent) 이는 하위 수준의 변경으로 인해 상위 수준이 변경되기 때문에 매우 곤란합니다. 또한 하위 수준의 문제로 인해 상위 수준에 위치하는 클래스들을 재사용하는 것이 어렵다면 이 또한 문제입니다.

 

🤔 해결책

  • 이 또한 해결책은 추상화입니다. 모두가 추상화에 의존하도록 수정하면 하위 수준 클래스의 변경으로 인해 상위 수준의 클래스가 영향을 받는 것을 방지할 수 있습니다. 또한 상위 수준을 재사용할 때 하위 수준의 클래스에 얽매이지 않고 다양한 컨텍스트에서 재사용할 수 있습니다.

정리

  • 상위 수준의 클래스는 하위 수준의 클래스에 의존하면 안 됩니다. 둘 다 추상화에 의존해야 합니다.
  • 추상화는 구체적인 사항에 의존해서는 안되며, 구체적인 사항이 추상화에 의존해야 합니다.

 

유연성에 대한 조언


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

  • 유연하고 재사용 가능한 설계란 런타임 의존성과 컴파일타임 의존성의 차이를 인지하고 컴파일타임 의존성으로부터 다양한 런타임 의존성을 만들 수 있는 코드 구조를 가지는 설계를 의미합니다. 하지만 이러한 유연하고 재사용 가능한 설계가 항상 옳은 것은 아닙니다.
  • 설계의 미덕은 단순함과 명확함으로부터 나오는데 단순하고 명확한 코드는 읽기 쉽고 이해하기 쉽습니다. 반면 변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 이를 버리게될 가능성이 높아집니다.
  • 유연한 설계라는 말의 이면에는 복잡한 설계라는 의미가 숨겨져 있습니다. 그 이유는 코드상의 클래스의 구조와 실행시의 동적인 객체 구조가 다르기 때문입니다. 이처럼 설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어집니다. 따라서 유연함은 단순성과 명확성의 희생 위에서 자라납니다.

 

💡 협력과 책임이 중요하다

  • 설계를 유연하게 만들기 위해서는 협력에 참여하는 객체가 다른 객체에게 어떤 메시지를 전송하는지가 중요합니다.
  • 설계를 유연하게 만들기 위해서는 역할, 책임, 협력에 초점을 맞춰야합니다. 다양한 문맥에서 협력을 재사용할 필요가 없다면 굳이 시간을 들여서 설계를 유연하게 만들 필요가 없어집니다. 또한 객체들이 메시지 전송자의 관점에서 동일한 책임을 수행할 수 있는지 여부를 판단할 수 없다면 공통의 추상화를 도출할 수 없습니다.

 

 

 

 

 

 

 

 

 

728x90
반응형