객체지향 #5부 - 의존성 관리하기

객체지향 #5부 - 의존성 관리하기

유연하고 재사용성 좋은 설계를 위해서는 낮은 결합도가 필수다. 낮은 결합도를 유지하기 위해 의존성을 올바르게 관리하는 방법을 알아보자.

단일 책임을 지키고 책임 수행에 꼭 필요한 상태만을 가지도록 객체를 설계하는게 이상적이다.
이런 상황에서 필수적으로 다른 객체의 도움이 필요하게 된다.
자연스래 협력관계는 이뤄지고 다른 객체에 대한 의존성이 발생하게 된다.

객체지향 설계의 핵심은 협력을 위해 필요한 의존성을 유지하면서 변경을 방해하는 의존성은 제거하는 데 있다.




의존성이란?

두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미한다.

객체의 협력에서 한 객체가 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 발생하게 된다.
의존성은 시점과 전파도에 따라 다음과 같은 분류를 가지게 된다.



전파도에 따른 의존성의 분류

의존성은 전파 되기 때문에 내가 의존하고 있는 객체가 가지고 있는 의존성도 함께 가지게 된다.

  • 직접 의존성 : 의존하는 객체가 의존 대상 객체를 직접 사용하는 경우
  • 간접 의존성 : 의존하는 객체가 의존 대상 객체를 사용하지 않고 다른 객체를 통해 의존 대상 객체를 사용하는 경우
A -> B (A는 B를 직접 의존한다)

A -> B -> C (A는 C를 간접 의존한다)




시점에 따른 의존성의 분류

  • 런타임 의존성 : 의존하는 객체가 정상적으로 동작하기 위해 의존 대상 객체가 존재해야 한다.
  • 컴파일 의존성 : 의존 대상 객체가 변경되면 의존하는 객체도 함께 변경된다.

김현우_도메인개념 컴파일 시점의 EventPlanner 의존성 그림


김현우_도메인개념 런타임 시점의 EventPlanner 의존성 그림


컴파일 시점과 런타임 시점의 의존성이 다르면 다를수록 설계가 유연해지고 재사용이 가능해진다.

소스코드 작성 시점에서 EventPlanner는 EventPlicy의 구체적인 정책들에 대해는 알지 못한다.
그러나 실행 시점에서는 구체적인 구현체에 의존하게 된다.
만일, 구체적인 구현체에 의존하고 있었다면, 다른 인스턴스와 협력할 가능성 자체가 없어진다.(딱딱한 설계)




클래스간에 의존성의 종류는 무엇이 있을까?


1.연관 관계(Association)

  • 연관 관계는 두 클래스가 서로 연결되어 있다는 것을 나타냅니다.
  • 이 관계는 한 클래스의 객체가 다른 클래스의 객체를 ‘알고 있음’을 의미합니다.
  • 예를 들어, 학생 클래스와 학교 클래스가 있을 때, 학생 객체가 학교 객체를 참조하고 있다면 이들 사이에는 연관 관계가 존재합니다.

      public class Student {
       private School school;
      }
        
      public class School {
      }
    
    



Student와 School의 연관관계 Student와 School의 연관관계



2.의존 관계(Dependency)

  • 의존 관계는 한 클래스가 다른 클래스의 메서드를 사용할 때 발생합니다.
  • 이 관계는 일시적이며, 한 클래스가 다른 클래스의 인스턴스를 메서드의 매개변수로 사용하거나, 메서드 내에서 생성할 때 나타납니다.
  • 예를들어 Student가 Course와 의존 관계를 맺고 있다. 메서드의 파라미터, 리턴타입 등

     public class Student {
        public Course study(Course course) {
          return new Course();
        }
      }
    
      public class Course {
      }
    
    



Student와 Course의 의존관계 Student와 Course의 의존관계



3.상속 관계(Inheritance)

  • 상속 관계는 한 클래스가 다른 클래스의 속성과 메서드를 상속받는 관계를 말합니다. 이는 ‘is-a’ 관계로도 알려져 있으며,
  • 일반적으로 부모 클래스와 자식 클래스 간의 관계를 나타냅니다.

     public class Vehicle {
      // ...
      }
    
      public class Car extends Vehicle {
      // ...
      }
    



Vehicle과 Car의 상속관계 Vehicle과 Car의 상속관계



  1. 실체화 관계(Realization)
    • 실체화 관계는 주로 인터페이스와 클래스 사이에서 발생합니다. 클래스가 인터페이스의 모든 추상 메서드를 구현할 때 이 관계가 형성됩니다.
    • 이는 ‘can-do’ 관계로 볼 수 있습니다.
     public interface Movable {
         void move();    
     }
    
     public class Car implements Movable {
         public void move() {
         // ...
         }
     }
    
    



Car와 Movable의 실체화 관계 Car와 Movable의 실체화 관계




종류는 알겠고, 클래스 설계시에 이런 의존성들을 어떻게 관리하는게 좋을까?

1.양방향 의존관계를 피해야 한다.

  • 양방향 의존관계는 두 클래스가 서로를 참조할 때 발생합니다. 이런 관계는 시스템의 복잡성을 증가시키고, 유지보수를 어렵게 만듭니다.
  • 예시:
    • 클래스 A가 클래스 B를 사용하고, 동시에 클래스 B도 클래스 A를 사용하는 경우, 이들은 양방향 의존관계에 있습니다.
    • 이를 해결하기 위해 중간에 인터페이스를 두거나, 서비스 레이어를 통해 의존성을 단방향으로 만들 수 있습니다.



2.다중성이 적은 방향을 선택해야 한다.

  • A와 B가 1대다 관계를 맺고 있는 경우, ManyToOne 관계를 맺는 방향으로 의존성을 설정해야 합니다.
  • 예시:
    • 클래스 A가 클래스 B를 사용하고, 클래스 B는 클래스 A를 사용하지 않는 경우, 이들은 ManyToOne 관계를 맺고 있습니다.
    • 이를 해결하기 위해 클래스 A가 클래스 B를 사용하는 방향으로 의존성을 설정합니다.



3.필요없으면 의존성을 없애는게 좋다.


더 나아가 패키지간의 의존성도 고려를 하자

패키지간의 의존성은 패키지가 다른 패키지의 클래스를 참조할 때 발생한다.
패키지 사이에 순환의존성이 존재한다면, 제거해야 한다.

  • 순환 의존성은 패키지 A가 패키지 B를 참조하고, 패키지 B가 다시 패키지 A를 참조하는 경우 발생합니다.
  • 이는 시스템의 복잡성을 증가시키고, 변경 사항이 전파되는 범위를 예측하기 어렵게 만듭니다.
  • 예시:
    • 패키지 A의 클래스가 패키지 B의 클래스를 사용하고, 패키지 B의 클래스가 패키지 A의 다른 클래스를 사용하는 경우 순환 의존성이 발생합니다.
    • 이를 해결하기 위해서는 중간에 인터페이스를 도입하거나, 공통 기능을 별도의 패키지로 분리하여 순환 의존성을 끊을 수 있습니다.




의존성 구현 방법

이론적인 화살표 긋기로 어떻게 의존성이 성립되어야 하는지 알았다. 이제 구현으로 내려가보자.
컴파일 시점런타임 시점의 의존성이 다르다면, 어떻게 런타임 시점에 구체화된 객체에 대한 의존성을 추가해줄 수 있을까?
이를 해결하기 위해 세 가지 방법이 제시된다.

  1. 객체를 생성할 때 생성자를 통해 의존성 해결

     public class EventPlanner {
     private EventPolicy policy;
    
     public EventPlanner(EventPolicy policy) {
         this.policy = policy;
     }
    
     // 이벤트 정책 적용
     public void applyPolicy(Order order) {
         policy.apply(order);
     }
    }
    
    
  2. 생성 후 Setter 메서드를 통해 의존성 해결

실행 시점 중간에도 실행되는 의존 객체를 변경할 수 있다는 장점이 있다.

   public class EventPlanner {
    private EventPolicy policy;

    public void setPolicy(EventPolicy policy) {
        this.policy = policy;
    }

    // 이벤트 정책 적용
    public void applyPolicy(Order order) {
        policy.apply(order);
    }
   }

  1. 메서드 실행 시 인자를 통해 의존성 해결

협력 관계에 대해 지속적인 의존 관계를 맺을 필요 없이 메서드 실행시에만 의존 관계를 맺는다.


    public class EventPlanner {
   
        // 이벤트 정책 적용
        public void applyPolicy(Order order, EventPolicy policy) {
            policy.apply(order);
        }
    }




바람직한 의존성의 기준이란

앞서 유연하고 재사용 가능한 설계를 위해 컴파일 의존성과 런타임 의존성을 분리해야 한다고 했다.

의존성은 객체간의 협력을 위해 필수적인 요소이다.
그러나 뭐든 과하면 문제가 된다.

바람직한 의존성의 기준은 재사용성 이다.
특정 구현체에 강하게 결합된 의존성은 재사용성을 떨어뜨린다.
다양한 환경에서 재사용할 수 있는 독립적인 의존성은 바람직하다.

두 요소 사이에 바람직한 의존성을 가지면 느슨한 결합도(loose coupling) 이라고 한다. 두 요소 사이에 바람직하지 못한 의존성을 가지면 단단한 결합도(tight coupling) 이라고 한다.

이제 의존성 관리에 대한 유용한 원칙과 기법을 알아보자.




의존성 관리에 대한 유용한 원칙과 기법들

1. 의존하는 객체에 대해 구체적으로 알 수록 결합도가 올라간다.

결합도의 정도는 한 요소가 다른 요소에 대해 더 많은 정보를 알 수록 높아진다.
다시 말해 EventPlanner크리스마스 이벤트 전략에 의존하고 있다면, 크리스마스 이벤트 전략에 대한 정보를 많이 알게 되어 결합도가 올라간다.

반면에, EventPlanner가 추상체인 EventPolicy에 의존하고 있다면, EventPolicy에 대한 정보를 알지 못하고, EventPolicy가 어떻게 어떤 이벤트를 진행할지 모르게 된다.

2. 추상체에 의존할 수록 결합도가 낮아진다.

그렇다면 결합도를 낮추기 위해 객체끼리 알 수 있는 지식의 양을 줄이기 위해서 어떻게 해야 할까?
정답은 추상화다.

구체적인 클래스
    ↓
 추상 클래스
    ↓
 인터페이스


아래로 내려갈 수록 의존하는 대상에 대한 지식이 적어진다.
추상 클래스는 협력하는 대상이 속한 클래스 상속 계층이 무엇인지 알고 있어야 한다.
인터페이스는 협력하는 객체에 대한 메세지만 알고 있으면 되기 때문에 가장 추상화됐다.

3. 의존성은 명시적으로 표현되어야 한다.

내부에 숨겨진 의존성은 추적이 어렵고, 변경에 대한 영향을 파악하기 어렵다.
EventPlanner가 EventPolicy를 의존하고 있다는 것을 생성자,Setter와 같은 방식으로 분명하게 하라.

4. 생성과 사용에 대한 책임을 관리하라.

객체 협력관계에서 내부에서 구현체에 대한 생성자를 사용하는 것은 risk가 굉장히 크다.
생성하기 위해 어떤 파라미터가 필요한지, 순서는 어떤지 협력하기 위한 객체의 모든 정보를 알아야 한다.
이는 곧 강력한 결합을 의미한다.

보통 이를 해결하기 위해 생성에 대한 책임은 객체의 외부에 사용자에게 위임하고, 사용에 대한 책임은 객체 내부에 위임한다.
이를 통해 특정 구현 Context에 한정되지 않고 다양한 구현체를 받아 재사용이 가능해진다.

//file: `배드케이스`

public class EventPlanner {
    private EventPolicy policy;
    
    //EventPlanner가 특정 구현체에 의존하고 해당 생성에 대한 너무 과한 지식이 요구된다.
    public EventPlanner() {
        this.policy = new ChristmasEventPolicy(파라미터1,파라미터2,파라미터3);
    }
}



//file: `굿케이스`

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



new 연산자를 사용해 객체를 생성하는 것은 의존성을 강하게 결합하는 행위이다. 금기에 가깝다.


그러나 경우에 따라서 생성에 대한 책임을 내부에 둘 때도 있다.
기본적인 이벤트 정책이 필요하고, 이벤트 정책이 없으면 정상적으로 동작하지 않는다면, 생성에 대한 책임을 내부에 두는 것이 좋다.


//file : `생성과 사용 책임을 함께 가지는 경우`

public class EventPlanner {
    private EventPolicy policy;
    
    //생성자 오버로딩을 통해 기본적인 디폴트 정책을 생성자 내부에 둔다.
    public EventPlanner() {
        this.policy = new DefaultEventPolicy();
    }
    
    public EventPlanner(EventPolicy policy) {
        this.policy = policy;
    }
}




5. 불변에 대한 의존은 해롭지 않다.

기본적으로 과한 의존성이 해가 되는 경우는 수정사항이 발생할 경우이다.
그래서 String,ArrayList와 같이 거의 불변하는 표준 클래스에 대한 의존은 해롭지 않다.

그러나 Collection에서는 최대한 추상적인 타입을 사용하는게 유리하다.
HashMap -> Map, ArrayList -> List 처럼.




6. 모든 개념은 객체로 문맥 확장이 가능하다.

예를 들어 EventPlanner 가 진행하는 EventPolicy가 없는 경우에는 어떻게 표현할 수 있을까?
이를 해결하기 위해 null이 아닌 EventPolicyNullEventPolicy를 넣어 확장할 수 있다.
중복된 조건에 대한 EventPolicy가 필요한 경우에도 CompositeEventPolicy를 통해 확장할 수 있다.

이런 유연함이 가능한 이유는 철저히 추상화에 의존하고 생성자를 통해 EventPolicy가 필요하다고 명시했기 때문이다.
그리고 new 와 같이 생성에 필요한 책임을 외부로 옮겼기 때문이다.
이게 컨텍스트 확장 이다. 결합도를 낮게 유지함으로써 확장된 개념이 필요할 때 간편하게 EventPolicy의 종류를 늘림으로써 해결 할 수 있었다.


낮은 결합도로 편하게 컨텍스트 확장이 가능하다는 점은 유연하고 재사용 가능한 설계를 만드는데 핵심이다.




결론

유연하고 재사용 가능한 설계는 최대한 작은 객체들을 조합하고 바꿔가며 만들어진 협력을 통해 구현된다.
유연하고 재사용 가능한 설계의 핵심은 결국 의존성 관리 이다.

더 자세한 내용은 조영호님 객체지향 강의를 참고해주세요.
패키지에 대한 의존성 관리와 묶는 기준에 대한 내용도 추가적으로 인사이트를 얻을 수 있을겁니다.