devseop08 님의 블로그

[오브젝트] 의존성 관리하기 본문

Architecture/객체지향설계

[오브젝트] 의존성 관리하기

devseop08 2025. 7. 10. 15:37

1. 의존성 이해하기

변경과 의존성

  • 어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재
  • 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 갖는다.
      1. 실행 시점: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
      1. 구현 시점: 의존 대상 객체에 변경이 발생할 경우 의존하는 객체도 함께 변경된다.
public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public boolean isSatisfiedBy(Screening screeing){
        return screeing.getStartTime().getDayOfWeek().equals(dayOfWeek) 
                &&
               startTime.compareTo(screening.getStartTime().toLocalTime())<=0
                &&
               endTime.compareTo(screening.getStartTime().toLocalTime())>=0;
    }
}
  • 실행 시점에 PeriodCondition의 인스턴스가 정상적으로 동작하기 위해서는 Screening의 인스턴스가 존재하고 getStartTime 메시지를 이해할 수 있어야 한다.
  • 어떤 객체가 예정된 작업을 정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이 존재한다고 말한다.
  • 의존성은 방향성을 가지며 항상 단방향이다.

  • 두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미한다.
  • 의존성은 변경에 의한 영향의 전파 가능성을 암시한다.
  • PeriodCondition은 DiscountCondition 인터페이스를 구현하고 있으며 DayOfWeek과 LocalTime의 인스턴스를 속성으로 포함하고 isSatisfiedBy 메서드의 인자로 Screening의 인스턴스를 받는다.
  • 또한 DayOfWeek의 인스턴스에게 compareTo 메시지를 전송하고 Screening의 인스턴스에게 getStartTime 메시지를 전송한다.
  • 따라서 PeriodCondition은 DiscountCondition, DayOfWeek, LocalTime, Screening에 대해 의존성을 갖는다.

  • 각각의 의존성을 다른 방식으로 표기했지만 의존성이 가지는 근본적인 특성은 동일하다.
  • PeriodCondition은 자신이 의존하는 대상이 변경될 때 함께 변경될 수 있다는 것이다.

의존성 전이

  • 의존성은 전이될 수 있다.
  • 의존성 전이가 의미하는 것은 PeriodConditon이 Screening에 의존할 경우 PeriodCondition은 Screening이 의존하는 대상에 대해서도 자동적으로 의존하게 된다는 것이다.

  • 의존성은 함께 변경될 수 있는 "가능성"을 의미하기 때문에 모든 경우에 의존성이 전이되는 것은 아니다.
  • 의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다.
  • Screening이 내부 구현을 효과적으로 캡슐화하고 있다면 Screening에 의존하고 있는 PeriodCondition까지는 변경이 전파되지 않을 것이다.
  • 의존성은 전이될 수 있기 때문에 의존성의 종류를 직접 의존성과 간접 의존성으로 나누기도 한다.
  • 런타임 의존성과 컴파일타임 의존성
  • 런타임은 애플리케이션이 실행되는 시점
  • 컴파일타임이란 일반적으로 작성된 코드를 컴파일하는 시점을 말하지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다.
  • 컴파일타임 의존성이 바로 이런 경우로, 컴파일타임 의존성이라는 용어가 중요하게 생각하는 것은 시간이 아니라 작성한 코드 구조이다.
  • 객체지향 어플리케이션에서 런타임의 주인공은 객체이므로 런타임 의존성이 다루는 주제는 객체 사이의 의존성이다.
  • 반면 컴파일타임, 즉 코드 관점에서의 주인공은 클래스이므로 컴파일타임 의존성이 다루는 주제는 클래스 사이의 의존성이다.
  • 런타임 의존성과 컴파일타임 의존성은 다를 수 있다.
  • 유연하고 재사용 가능한 코드를 설계하기 위해서는 두 종류의 의존성을 서로 다르게 만들어야 한다.
  • 컴파일타임 의존성을 실행 시에 런타임 의존성으로 대체해야 한다.
  • 유연하고 재사용 가능한 설계를 창조하기 위해서는 동일한 소스 코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다.
  • 어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다.
  • 실제로 협력할 객체가 어떤 것인지는 런타임에 결정해야 한다.
  • 컴파일 타임 구조와 런타임 구조 사이의 거리가 멀면 멀수록 설계가 유연해지고 재사용 가능해진다.
  • 컨텍스트 독립성
  • 구체 클래스에 대해 의존하는 것은 클래스의 인스턴스가 어떤 문맥에서 사용될 것인지를 구체적으로 명시하는 것과 같다.
  • 클래스가 특정한 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다.
  • 클래스가 사용될 특정한 문맥에 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다.
  • 이를 컨텍스트 독립성이라고 한다.
  • 설계가 유연해지기 위해서는 가능한 한 자신이 실행될 컨텍스트에 대한 구체적인 정보를 최대한 적게 알아야 한다.

의존성 해결하기

  • 의존성 해결: 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것
  • 의존성을 해결하기 위한 세 가지 방법
      1. 객체를 생성하는 시점에 생성자를 통해 의존성 해결
      1. 객체 생성 후 setter 메서드를 통해 의존성 해결
      1. 메서드 실행 시 인자를 이용해 의존성 해결
  • setter 메서드를 이용하는 방식은 객체를 생성한 이후에도 의존하고 있느 대상을 변경할 수 있는 가능성을 열어 놓고 싶은 경우에 유용
  • setter 메서드를 이용하는 방법은 실행 시점에 의존 대상을 변경할 수 있기 때문에 설계를 좀더 유연하게 만들 수 있지만 객체가 생성된 후에 협력에 필요한 의존 대상을 설정하기 때문에 객체 생성 후 의존 대상을 설정하기 전까지는 객체의 상태가 불완전할 수 있다.
  • 더 좋은 방법은 생성자 방식과 setter 방식을 혼합하는 것
  • 메서드 인자를 사용하는 방식은 지속적인 의존 관계가 필요 없이 일시적인 의존 관계가 존재해도 무방하거나 메서드가 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용
  • 클래스의 메서드를 호출하는 대부분의 경우에 매번 동일한 타입의 객체를 인자로 전달하고 있다면 생성자를 이용하는 방식이나 setter 메서드를 이용해 의존성을 지속적으로 유지하는 방식으로 변경하는 것이 좋다.

2. 유연할 설계

의존성과 결합도

  • 의존성은 협력을 위해 반드시 필요한 것이다.
  • 문제는 의존성의 존재가 아니라 의존성의 정도다.
  • 바람직하지 못한 의존성이 문제일 뿐이다.
  • 바람직한 의존성은 재사용성과 관련이 있다.
  • 특정 컨텍스트에 강하게 결합된 의존성은 바람직하지 않은 의존성이고 컨텍스트에 독립적인 의존성은 바람직한 의존성이다.
  • 특정 컨텍스트에 강하게 의존하는 클래스를 다른 컨텍스트에서 재사용할 수 있는 유일한 방법은 구현을 변경하는 것뿐이다.
  • 어떤 두 요소 사이에 존재하는 의존성이 바람직할 때 두 요소가 느슨한 결합도 또는 약한 결합도를 가진다고 말한다.
  • 두 요소 사이의 의존성이 바람직하지 못할 때 강한 결합도를 가진다고 말한다.

지식이 결합을 낳는다

  • 결합도의 정도는 한 요소가 자신이 의존하고 있는 다른 요소에 대해 알고 있는 정보의 양으로 결정된다.
  • 한 요소가 다른 요소에 대해 더 많은 정보를 알고 있을수록 두 요소는 강하게 결합된다.
  • 한 요소가 다른 요소에 대해 더 적은 정보를 알고 있을수록 두 요소는 약하게 결합된다.
  • 서로에 대해 알고 있는 지식의 양이 결합도를 결정한다. 더 많이 알수록 더 많이 결합된다.
  • 더 많이 알고 있다는 것은 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미한다.
  • 결합도를 느슨하게 유지하려면 협력하는 대상에 대해 더 적게 알아야 한다.
  • 협력하는 대상에 대해 필요한 정보 외에는 최대한 감추는 것이 중요, 그러기 위해선 추상화가 가장 효과적인 방법이 된다.

추상화에 의존하라

  • 추상화와 결합도의 관점에서 의존 대상 구분
    • 구체 클래스 의존성
    • 추상 클래스 의존성
    • 인터페이스 의존성
  • 클라이언트가 알아야 하는 지식의 양 : 구체 클래스 의존성 > 추상 클래스 의존성 > 인터페이스 의존성
  • 알아야 하는 지식의 양이 적을수록 결합도가 낮다.
  • 의존하는 대상이 더 추상적일수록 결합도는 낮아진다.

명시적인 의존성

public class Movie {
    ...
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee){
        ...
        this.discountPolicy = new AmountDiscountPolicy(...);
    }
} 

  • Movie는 추상 클래스인 DiscountPolicy 뿐만 아니라 구체 클래스인 AmountDiscountPolicy에도 의존하게 된다.
  • 결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상 클래스나 인터페이스로 선언하는 것만으로는 부족
  • 클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야만 한다.
  • 그런데 Movie는 런타임에 구체 클래스의 인스턴스와 협력해야 하기 때문에 의존성을 해결해 줄 수 있는 방법이 필요하다.
  • 의존성 해결: 컴파일 타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것
  • 의존성을 해결하는 방법에는 생성자, setter 메서드, 메서드 인자를 사용하는 세 가지 방식이 존재한다.
  • 인스턴스 변수의 타입은 추상 클래스나 인터페이스로 정의하고 생성자, setter 메서드, 메서드 인자로 의존성을 해결할 때는 추상 클래스를 상속하거나 인터페이스를 실체화한 구체 클래스 타입을 전달하는 것으로 해결
public class Movie {
    ...
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, 
                                                DiscountPolicy discountPolicy){
        ...
        this.discountPolicy = discountPolicy;
    }
}
  • 의존성의 대상을 생성자의 인자로 선언하는 방법은 Movie가 DiscountPolicy에 의존한다는 사실을 Movie의 퍼블릭 인터페이스에 드러내는 것이다.
  • 이는 setter 메서드를 사용하는 방식과 메서드 인자를 사용하는 방식의 경우에도 동일하다.
  • 모든 경우에 의존성은 명시적으로 퍼블릭 인터페이스에 노출된다. => 이를 명시적인 의존성이라고 부른다
  • Movie의 내부에서 AmountDiscountPolicy의 인스턴스를 직접 생성하는 방식은 Movie가 DiscountPolicy에 의존한다는 사실을 숨긴다.
  • 의존성이 퍼블릭 인터페이스에 표현되지 않는다;. => 이를 숨겨진 의존성이라고 부른다.
  • 의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 직접 살펴볼 수 밖에 없다.'
  • 의존성이 명시적이지 않으면 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다.
  • 의존성을 명시적으로 드러내면 코드를 직접 수정해야 하는 위험을 피할 수 있다.
  • 의존성은 명시적으로 표현돼야 한다. 유연하고 재사용 가능한 설계란 퍼블릭 인터페이스를 통해 의존성이 명시적으로 드러나는 설계다.
  • 경계해야 할 것은 의존성 자체가 아니라 의존성을 감추는 것이다.
  • new 는 해롭다
  • new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.
  • 결합도 측면에 new가 해로운 이유
    • new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수밖에 없기 때문에 결합도가 높아진다.
    • new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다. => 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.
  • new는 결합도를 높이기 때문에 해롭다.
  • new는 클래스를 구체 클래스에 결합시키는 것만으로 끝나지 않는다. 협력할 클래스의 인스턴스를 생성하기 위해 어떤 인자들이 필요하고 그 인자들을 어떤 순서로 사용해야 하는지에 대한 정보도 노출시킬 뿐만 아니라 인자로 사용되는 구체 클래스에 대한 의존성을 추가한다.
  • 해결 방법은 인스턴스를 생성하는 로직과 생성된 인스턴스를 사용하는 로직을 분리하는 것이다.
  • 인스턴스 사용과 인스턴스 생성의 책임을 다른 클래스로 분리해서 결합도를 낮출 수 있다.
  • 인스턴스를 생성하는 책임을 객체 내부가 아니라 객체를 사용하는 클라이언트가 지도록 한다.
  • 사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 구체 클래스가 아닌 추상 클래스에 의존하게 함으로써 설계를 유연하게 만들 수 있다.
  • 인스턴스를 생성하는 책임을 객체 내부가 아니라 클라이언트로 옮겨야 한다.

가끔은 생성해도 무방하다

  • 클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용한 경우도 있다.
  • 주로 협력하는 기본 객체를 설정하고 싶은 경우가 이런 경우에 속한다.
  • Movie가 대부분의 경우에는 AmountDiscountPolicy의 인스턴스와 협력하고 가끔씩만 PercentDiscountPolicy의 인스턴스와 협력하는 경우
  • 이런 상황에서 모든 경우에 인스턴스를 생성하는 책임을 클라이언트로 옮긴다면 클라이언트들 사이에 중복 코드가 늘어나고 Movie의 사용성도 나빠진다.
  • 해결 방법은 기본 객체를 생성하는 생성자를 추가하고 이 생성자에서 DiscountPolicy의 인스턴스를 인자로 받는 생성자를 체이닝하는 것이다.
public class Movie {
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee) {
        this(title, runningTime, fee, new AmountDiscountPolicy(...));
        // 생성자 체이닝
    }

    public Moive(String title, Duration runningTime, Money fee,
                                            DiscountPolicy discountPolicy){
        this.discountPolicy = discountPolicy;
    }
}
  • 이제 클라이언트는 대부분의 경우에 추가된 간략한 생성자를 통해 AmountDiscountPolicy의 인스턴스와 협력하게 하면서도 컨텍스트에 적절한 DiscountPolicy의 인스턴스로 의존성을 교체할 수 있다.
  • 이 방법은 메서드를 오버로딩하는 경우에도 사용할 수 있다.
public class Movie {
    public Money calculateMovieFee(Screening screening) {
        return calculateMovieFee(screening, new AmountDiscountPolicy(...));
    }

    public Money calculateMovieFee(Screening screening, 
                                        DiscountPolicy discountPolicy) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
} 

표준 클래스에 대한 의존은 해롭지 않다.

  • 의존성이 불편한 이유: 그것이 항상 변경에 대한 영향을 암시하기 때문
  • 자바의 JDK에 포함된 표준 클래스와 같이 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다.
  • 즉, 이런 클래스들에 대해서는 구체 클래스에 의존하거나 직접 인스턴스를 생성하더라도 문제가 없다.
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();
}
  • 비록 클래스를 직접 생성하더라도 가능한 한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리
  • 의존성의 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로 드러내는 것은 좋은 설계 습관이다.
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public void switchConditions(List<DiscountCondition> conditions) {
        this.conditions = conditions;
    }
}

조합 가능한 행동

  • 유연하고 재사용 가능한 설계는 객체가 어떻게(how) 하는지를 장황하게 나열하지 않고도 객체들의 조합을 통해 무엇(what)을 하는지를 표현하는 클래스들로 구성된다.
  • 따라서 클래스의 인스턴스를 생성하는 코드를 보는 것만으로 객체가 어떤 일을 하는지를 쉽게 파악할 수 있다.
  • 코드에 드러난 로직을 해석할 필요 없이 객체가 어떤 객체와 연결됐는지를 보는 것만으로도 객체의 행동을 쉽게 예상하고 이해할 수 있기 때문
  • 유연하고 재사용 가능한 설계는 작은 객체들의 행동을 조합함으로써 새로운 행동을 이끌어낼 수 있는 설계다.
  • 훌륭한 객체지향 설계란 객체가 어떻게 하는 지를 표현하는 것이 아니라 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지를 표현하는 설계
  • 이런 설계를 창조하는 데 있어서의 핵심은 의존성을 관리하는 것이다
  • 객체지향 시스템의 행위는 객체의 조합을 통해 나타나는 특성이다.
  • 객체의 구성을 변경해 시스템의 작동 방식을 바꿀 수 있다.