객체지향 #6부 - 의존성 원칙에 대한 구체적인 이야기

객체지향 #6부 - 의존성 원칙에 대한 구체적인 이야기

기존에 배웠던 의존성에 대한 원칙은 다소 추상적이다. 이번 장에서는 의존성 원칙을 구체적으로 살펴보고, 실질적인 예시를 통해 의존성을 어떻게 관리해야 하는지 알아보자.

앞서 5장에 나온 기법들을 범용적인 언어로 묶어낸 원칙에 대해 알아보고 구체적인 의미를 파헤쳐보자.

개방-폐쇄 원칙 (Open-Close-Principle)

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

로버트 마틴

많은 이들이 들어는 봤을 유명한 원칙이다.
그러나 열려있고 닫혀있고, 개방-폐쇄가 공존한다는 이 원칙을 보고 바로 온전히 이해하기는 쉽지 않다.

  • 확장에 대해 열려 있다 : 요구사항 변경에 맞게 새로운 동작을 추가해 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다 : 기존의 코드를 수정하지 않고도 기능을 추가하거나 변경할 수 있다.

    OCP의 시작은 추상화에 있다.
    추상화를 통해 최대한 공통화 된 인터페이스를 만들어야 한다.
    불변성에 기대어야 한다. 다형성을 통해 최대한 런타임시에 추상화된 객체에 대한 의존으로 동작을 구현한다.
    이후 추상체에 대한 구현의 추가로 원하는 기능을 확장할 수 있다.

//file: `굿케이스`

public class EventPlanner {
    private EventPolicy policy;
    
    //EventPlanner가 특정 구현체에 의존하지 않고, 생성에 대한 책임을 외부에 위임한다.
    public EventPlanner(EventPolicy policy) {
        this.policy = policy;
    }
}

앞장에서 예시로 든 EventPlanner->EventPolicy


여러 이벤트 전략들을 EventPolicy 타입으로 추상화 하고, 동작을 캡슐화해 간결하게 인터페이스를 정의했다.
다양한 EventPolicy에 대한 확장은 EventPolicy를 구현하는 구현체를 추가함으로써 가능하다.
추가시에 EventPolicy를 사용하고 있는 기존의 코드들에 대한 수정은 필요하지 않다.

핵심은 추상체에 의존하여 컴파일 시점에 의존성을 고정하고 런타임 시점에 의존성을 변경하는 것이다.



생성-사용 분리

객체지향 설계에서 추상화에 의해 협력관계를 구성했다면, 내부에서 특정 구현체에 대한 생성을 하고 사용을 하지 말아야 한다.
어떤 구현체에 대해 사용을 해야 할 지는 부탁하는 사람이 알아서 수행하는 객체는 사용을 하는 책임만 가지는 것이 좋다.
위의 코드 예시에서 EventPlanner가 EventPolicy를 생성하지 말고, 외부에 EventPlanner에게 지시하는 객체가 생성해서 주입된 이벤트 정책을 실행시켜야 한다.
그럼 EventPolicy는 추상체에만 의존하여 컴파일 시점에 의존성을 고정하고, 런타임 시점에 의존성을 변경할 수 있다.불변안정성이 생긴다.

동일한 클래스 안에서 객체 생성과 사용을 하지 마라.
특정한 구현체에 대한 생성은 외부의 지시자가 수행하도록 하라.

Q : 수행자에서 생성을 분리해서 지시자로 빼면, 지시자는 생성과 사용을 함께 가지게 되는데요?

그런 상황을 방지하기 위해서 지시자에도 생성과 사용을 분리해야 한다.
지시자의 생성에 대한 책임을 분리하기 위해 도메인과 상관 없는 생성의 책임을 담당해주는 인공 클래스를 생성할 수 있다.

적당한 책임을 가진 클래스를 찾지 못하거나 특정 도메인에 대한 책임 할당이 높은 응집도, 낮은 결합, 재사용성 등의 목적을 위반한다면 편의를 위해 인공적인 클래스를 생성해서 부여한다.

[GRASP] Pure Fabrication Pattern(순수한 가공물 패턴)



우리는 반드시 도메인에 관련된 내용만을 협력 클래스로 만들어야 하는 것은 아니다.
시스템 분해에는 도메인에 존재하는 개념을 협력 클래스로 만드는 표현적 분해 방법이 있고,
실제 동작을 위해서 도메인 개념을 초월하는 행위적 분해 를 위한 기계적인 인공 개념의 클래스가 필요할 수 있다.




행위적 분해를 통해 나온 순수 인공 클래스란?

EventPlanner에게 지시하는 Client 객체에서는 특정 EventPolicy를 결합시켜 EventPlanner를 생성하게 된다.
이럴 경우 Client객체에서 생성 책임과 사용 책임을 동시에 가지게 되는데, 생성 책임을 분리하기 위해 가공물인 FACTORY 객체를 생성해서 생성에 대한 책임을 넘긴다.
Client의 생성 책임을 따로 넘길 도메인 개념과 객체가 없기 때문이다.

//file: `Client-FACTORY`

public class EventPlannerFactory {

    public static EventPlanner createHolidayEventPlanner() {
        // 여기에서 필요한 EventPolicy 구현체들을 생성합니다.
        EventPolicy policy= new HolidayEventPolicy(...);
        
        // EventPlanner 객체를 생성하고, 정책 목록을 전달합니다.
        return new EventPlanner(policy);
    }
}

public class Client{
    private EventPlannerFactory factory;
    
    public Client(EventPlannerFactory factory){
        this.factory = factory;
    }
    
    public void calculateHolidaySomething(){
        EventPlanner planner = factory.createHolidayEventPlanner();
        // ...
    }
    
    
}



순수 인공물 사용 사례는 아래와 같다.

  • 데이터 접근 객체(Data Access Object, DAO): 데이터베이스와의 상호작용을 처리하는 클래스.
  • 서비스 레이어(Service Layer): 비즈니스 로직을 처리하는 클래스.
  • 유틸리티 클래스(Utility Class): 공통적으로 사용되는 기능을 제공하는 클래스.
  • 팩토리 클래스(Factory Class): 객체 생성을 처리하는 클래스.
    • 객체 생성-사용 책임을 분리하기 위해 생성만을 책임지는 클래스를 생성한다.
  • 검증 클래스(Validator Class): 검증을 처리하는 클래스.
    • 도메인 내부에 검증 로직이 함께 있으면 응집도가 낮아져 검증로직만을 위한 클래스를 생성한다.




명시적인 외부 의존성 주입

생성과 사용의 책임을 분리했다면, 객체 내부에는 오로지 인스턴스 사용에 대한 책임만 남는다.
사용하려는 외부에서 인스턴스를 생성해서 전달하는 것을 의존성 주입 이라고 한다.

앞서 우리는 런타임과 컴파일의 의존성이 다를수록 유연하고 좋다고 했다.
그리고 그에 따른 해결방안으로 추상적인 타입에 의존하고, 구체적인 타입에는 의존하지 않는 것을 추천했다.

스프링의 핵심은 의존성 주입(Dependency Injection)에 있습니다.
이는 객체 간의 결합도를 낮추어 유연하고 확장 가능한 코드를 작성할 수 있게 해주며,
이를 통해 애플리케이션의 테스트 용이성과 유지보수성을 크게 향상시킵니다. 또한, 스프링 프레임워크는 이러한 의존성 주입을 쉽고 효율적으로 관리할 수 있는 다양한 도구와 기능을 제공합니다.


의존성 주입에는 다음 네 가지 방법이 있다.

  • 생성자 주입 : 객체 생성시에 의존성 주입
  • 수정자 주입 : setter 메서드를 통한 의존성 주입
  • 인터페이스 주입 : 인터페이스를 통한 의존성 주입
  • 메서드 주입 : 일반 메서드를 통한 의존성 주입


수정자 주입은 런타임 시에 의존성 대상 객체를 수정할 수 있다는 장점이 있는 반면,
초기 생성시에 불완전한 상태의 생성이 가능하다는 단점이 공존한다.

인터페이스 주입은 주입하는 의존성을 명시하기 위해 인터페이스를 사용하는 것이다.

//file: `인터페이스 주입 예시`

public interface EventPolicyInjectable {
    public void inject(EventPolicy policy);
}

public class EventPlanner implements EventPolicyInjectable {
    private EventPolicy policy;
    
    @Override
    public void inject(EventPolicy policy) {
        this.policy = policy;
    }
}


위에 네 방식의 공통점은 외부에서 의존성에 대한 명세를 보고 주입을 할 수 있다는 점이다.
가급적 의존하는 대상에 대한 추상체를 퍼블릭 인터페이스에 명시해야 한다.

Q : 의존성이 숨겨지면 무슨 문제가 생기나요?

숨겨진 의존성은 문제점을 발견하는 시점이 컴파일이 아닌 런타임시점으로 미뤄진다.
테스트 코드 작성도 어려워진다. 구현에 의존성 주입이 들어있기 때문에 외부에서는 내부 구현에 대한 지식이 요구된다.
내부 구현이 드러나야 완전한 동작 이해가 가능하다는 것은 캡슐화가 깨졌다는 의미와 같다.


의존성 역전 원칙

아래는 의존성 역전 원칙의 두 가지 정의다.

  • 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안된다.
  • 추상화는 구체적인 사항에 의존해서는 안된다. 구체적인 사항은 추상화에 의존해야 한다.


Q : 흔히 말하는 의존성 역전 에 대해서는 왜 역전이라고 부르는가?

우리가 절차지향적으로 코드를 짜던 예전 시대에서는 상위 수준에서 하위 수준에 의존하는, 정책이 구체적인 것에 의존하는 경향이 있었다.
상위 수준이 하위 수준의 모듈을 호출하는 방법으로 묘사하는 계층에 대한 정의가 목표였었다.
잘 설계된 객체지향은 이런 전통적인 계층 구조를 뒤집어서 역전이라고 명명됐다.



의존성 역전된 객체는 어떤 패키지에 속해야 할까?

의존성을 역전하기 위해 사용된 추상화 객체는 고수준 모듈(사용자, 클라이언트)과 함께 존재해야 좋다.
그래야 특정한 컨텍스트로부터 완전히 분리된 상태가 되어, 재사용성이 높아진다.

//file: `의존성 역전된 객체는 어떤 패키지에 속해야 할까?`

EventPlanner - EventPolicy는  패키지

EventPolicy의 구현체들은 다른 패키지

패키지의존성역전 객체에 대한 패키지 위치. 의존되는 추상화 객체는 사용되어지는 객체와 한 곳에 있는게 좋다.




결론

너무 유연함에 집착하게 되면, 실행 시점과 클래스의 구조가 달라 복잡해진다.
그렇기 때문에 유연성과 단순성 사이에서 적절한 균형을 잡아야 한다.