8장 : 의존성 관리하기

7장 복습

잘 설계된 객체지향 애플리케이션은 작고 으집도 높은 객체들로 구성된다. 작고 응집도 높은 객체란 책임의 초점이 명확하고 한 가지 일만 잘 하는 객체를 의미한다. 애플리케이션 기능 구현을 위해 다른 객체에게 도움을 요청해야 하고 이런 요청이 객체 사이의 협력을 낳는다.

협력을 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다. 객체지향 설계의 핵심은 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는 데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이다.

1. 의존성 이해하기

✔️ 변경과 의존성

어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재하게 된다. 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.

  • 실행 시점 : 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.

  • 구현 시점 : 의존 대상 객체가 변경될 경우 의존하는 개체도 함께 변경된다.

어떤 객체가 예정된 작업을 정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이 존재한다고 말한다. 의존성은 방향성을 가지며 항상 단방향이다.

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

  • DayOfWeek과 LocalTime은 PeriodCondition의 인스턴스 변수로 사용된다.

  • Screening은 메서드 인자로 사용된다.

  • PeriodCondition이 DiscountCondition에 의존하는 이유는 인터페이스에 정의된 오퍼레이션들을 퍼블릭 인터페이스의 일부로 포함시키기 위해서다.

✔️ 의존성 전이

✔️ 런타임 의존성과 컴파일타입 의존성

  • 런타임 의존성(run-time dependency)

    • 런타임은 말 그대로 애플리케이션이 실행되는 시점을 가리킨다.

  • 컴파일타임 의존성(compile-time dependency)

    • 일반적으로 컴파일타임이란 작성된 코드를 컴파일하는 시점을 가리키지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다.

    • 동적 타입 언어의 경우에는 컴파일타임이 존재하지 않기 때문에 컴파일타임 의존성이라는 용어를 실제로 컴파일이 수행되는 시점으로 이해하면 의미가 모호해질 수 있다.

런타임 의존성과 컴파일타임 의존성이 다를 수 있다.

유연하고 재사용 가능한 설계를 창조하기 위해서는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다.

어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 안 된다. 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 한다.

✔️ 컨텍스트 독립성

클래스가 특정한 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다. 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다.

설게가 유연해지기 위해서는 가능한 한 자신이 실행될 컨텍스트에 대한 구체적인 정보를 최대한 적게 알아야 한다. 컨텍스트에 대한 정보가 적으면 적을수록 더 다양한 컨텍스트에서 재사용될 수 있기 때문이다.

✔️ 의존성 해결하기

컴파일타임 의존성은 구체적인 런타임 의존성으로 대체돼야 한다. Movie 클래스와 DiscountPolicy 클래스 사이에 존재하는 컴파일타임 의존성이 Movie 인스턴스와 PercentDiscountPolicy 인스턴스 사이의 런타임 의존성이나 Movie 인스턴스와 AmountDiscountPolicy 인스턴스 사이의 런타임 의존성으로 교체돼야 한다는 것을 의미한다.

이처럼 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다. 의존성을 해결하기 위해서는 일반적으로 다음과 같은 세 가지 방법을 사용한다.

  • 객체를 생성하는 시점에 생성자를 통해 의존성 해결

    Movie avatar = new Movie("아바타", 
    	Duration.ofMinutes(120),
    	Money.wons(10000),
    	new AmountDiscountPolicy(...)); // new PercentDiscountPolicy(...)

    이를 위해 Movie 클래스는 PercentDiscountPolicy 인스턴스와 AmountDiscountPolicy 인스턴스 모두를 선택적으로 전달받을 수 있도록 이 두 클래스의 부모 클래스인 DiscountPolicy 타입의 인자를 받는 생성자를 정의한다.

  • 객체 생성 후 setter 메서드를 통해 의존성 해결

    Movie avatar = new Movie(...);
    avatar.setDiscountPolicy(new AmountDiscountPolicy(...));

    이 경우 Movie 인스턴스가 생성된 후에도 DiscountPolicy를 설정할 수 있는 setter 메서드를 제공해야 한다.

    public class Movie {
    	public void setDiscountPolicy (DiscountPolicy discountPolicy) {
    		this.discountPolicy = discountPolicy;
    	}
    }

    setter 메서드를 이용하는 방식은 객체를 생성한 이후에도 의존하고 있는 대상을 변경할 수 있는 가능성을 열어 놓고 싶은 경우에 유용하다.

    단 의존 대상을 설정하기 전까지는 객체의 상태가 불완전할 수 있다.

    • 생성자 방식과 setter 방식 혼합

      Movie avatar = new Movie(..., new PercentDiscountPolicy(...));
      ...
      avatar.setDiscountPollicy(new AmountDiscountPolicy(...));
  • 메서드 실행 시 인자를 이용해 의존성 해결

    Movie가 항상 할인 정책을 알 필요까진 없고 가격을 계산할 때만 일시적으로 알아도 무방하다면 메서드의 인자를 이용해 의존성을 해결할 수 있다.

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

2. 유연한 설계

✔️ 의존성과 결합도

의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서는 바람직하지만, 과하면 문제가 될 수 있다.

바람직한 의존성은 재사용성과 관련이 있다. 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다. 바람직한 의존성이란 컨텍스트에 독립적인 의존성을 의미하며 다양한 환경에서 재사용될 수 있는 가능성을 열어놓는 의존성을 의미한다.

어떤 두 요소 사이에 존재하는 의존성이 바람직할 때 두 요소가 느슨한 결합도(loose coupling) 또는 약한 결합도(weak coupling)를 가진다고 말한다. 반대로 두 요소 사이의 의존성이 바람직하지 못할 때 단단한 결합도(tight coupling) 또는 강한 결합도(strong coupling)를 가진다고 말한다.

  • 의존성과 결합도

    • 일반적으로 의존성과 결합도를 동의어로 사용하지만 사실 두 용어는 서로 다른 관점에서 관계의 특성을 설명하는 용어다. 의존성은 두 요소 사이의 관계 유무를 설명한다. 그에 반해 결합도는 두 요소 사이에 존재하는 의존성의 정도를 상대적으로 표현한다.

어떤 의존성이 재사용을 방해한다면 결합도가 강하다고 표현하고, 어떤 의존성이 재사용을 쉽게 허용한다면 결합도가 느슨하다고 표현한다.

✔️ 지식이 결합을 낳는다

결합도의 정도는 한 요소가 자신이 의존하고 있는 다른 요소에 대해 알고 있는 정보의 양으로 결정된다.

서로에 대해 알고 있는 지식의 양이 결합도를 결정한다.

더 많이 알고 있다는 것은 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미한다. 기존 지식에 어울리지 않는 컨텍스트에서 클래스의 인스턴스를 사용하기 위해서 할 수 있는 유일한 방법은 클래스를 수정하는 것뿐이다. 결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에는 최대한 감추는 것이 중요하다.

다행스러운 점은 이 목적을 달성할 수 있는 가장 효과적인 방법에 대해 이미 알고 있다는 것이다. 추상화가 바로 그것이다.

✔️ 추상화에 의존하라

추상화란 어떤 양상, 세부사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다. 추상화를 사용하면 현재 다루고 있는 문제를 해결하는 데 불필요한 정보를 감출 수 있다. 따라서 대상에 대해 알아야 하는 지식의 양을 줄일 수 있기 때문에 결합도를 느슨하게 유지할 수 있다.

DiscountPolicy 클래스는 PercentDiscountPolicy 클래스가 비율 할인 정책에 따라 할인 요금을 계산한다는 사실을 숨겨주기 때문에 PercentDiscountPolicy의 추상화다. 따라서 Movie 클래스의 관점에서 협력을 위해 알아야 하는 지식의 양은 PercentDiscountPolicy보다 DiscountPolicy 클래스가 더 적다. Movie와 DiscountPolicy 사이의 결합도가 더 느슨한 이유는 Movie가 구체적인 대상이 아닌 추상화에 의존하기 떄문이다.

일반적으로 추상화와 결합도의 관점에서 의존 대상을 다음과 같이 구분하는 것이 유용하다. 목록에서 아래쪽으로 갈수록 클라이언트가 알아야 하는 지식의 양이 적어지기 떄문에 결합도가 느슨해진다.

  • 구체 클래스 의존성(concrete xlass dependency)

  • 추상 클래스 의존성(abstract class dependency)

  • 인터페이스 의존성(interface dependency)

실행 컨텍스트에 대해 알아야 하는 정보를 줄일수록 결합도가 낮아진다. 즉, 의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다.

✔️ 명시적인 의존성

public class Movie {
	...
	private DiscountPolicy discountPolicy;

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

결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상 클래스나 인터페이스로 선언하는 것만으로는 부족하다. 클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야만 한다. 하지만 런타임에 Movie는 구체 클래스의 인스턴스와 협력해야 하기 때문에 Movie의 인스턴스가 AmountDiscountPolicy의 인스턴스인지 PercentDiscountPolicy의 인스턴스인지를 알려줄 수 있는 방법이 필요하다. 다시 말해서 Movie의 의존성을 해결해 줄 수 있는 방법이 필요한 것이다.

의존성을 해결하는 방법에는 생성자, setter 메서드, 메서드 인자를 사용하는 세 가지 방식이 존재한다. 여기서의 트릭은 인스턴스 변수의 타입은 추상 클래스나 인터페이스로 정의하고 생성자, setter 메서드, 메서드 인자로 의존성을 해결할 때는 추상 클래스를 상속받거나 인터페이스를 실체화한 구체 클래스를 전달하는 것이다.

다음은 생성자를 사용해 의존성을 해결하는 경우를 나타낸 것이다. 생성자 안에서 인스턴스를 직접 생성하지 않고 생성자의 인자로 선언하고 있다.

public class Movie {
	...
	private DiscountPolicy discountPolicy;

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

의존성의 대상을 생성자의 인자로 전달받는 방법과 생성자 안에서 직접 생성하는 방법 사이의 가장 큰 차이점은 퍼블릭 인터페이스를 통해 할인 정책을 설정할 수 있는 방법을 제공하는지 여부다.

  • 명시적인 의존성(explicit dependency)

의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 직접 살펴볼 수밖에 없다.

더 큰 문제는 의존성이 명시적이지 않으면 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다는 것이다.

의존성은 명시적으로 표현돼야 한다.

✔️ new는 해롭다

대부분의 언어에서 클래스의 인스턴스를 생성할 수 있는 new 연산자를 제공한다. 하지만 new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.

  • new 연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야 한다. 따라서 new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수밖에 없기 때문에 결합도가 높아진다.

  • new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다. 따라서 new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.

결합도가 높으면 변경에 의해 영향을 받기 쉬워진다.

사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 
구체 클래스가 아닌 추상 클래스에 의존하게 함으로써 설계를 유연하게 만들 수 있다. 
그리고 그 출발은 객체를 생성하는 책임을 객체 내부가 아니라 클라이언트로 옮기는 것에서 
시작했다는 점을 기억하라. 

✔️ 가끔은 생성해도 무방하다

현력하는 기본 객체를 설정하고 싶은 경우에는 클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용하다.

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

의존성이 불편한 이유는 그것이 항상 변경에 대한 영향을 암시하기 때문이다. 따라서 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다. 자바라면 JDK에 포함된 표준 클래스가 이 부류에 속한다. 이런 클래스들에 대해서는 구체 클래스에 의존하거나 직접 인스턴스를 생성하더라도 문제가 없다.

  • ex. ArrayList

    public abstract class DiscountPolicy {
    	private List<DiscountCondition> conditions = new ArrayList<>();
    }

✔️ 컨텍스트 확장하기

public class Movie {
	public Movie(String title, Duration runningTime, Money fee) {
		this(title, runningTime, fee, null);
	}

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

	public Money calculateMovieDFee(Screening screening) {
		if (discountPolicy == null) {
			return fee;
		}
		
		return fee.minus(discoutPolicy.calculateDiscountAmount(screening));
	}
}

할인 정책이 존재하지 않는다는 사실을 예외 케이스로 처리하지 말고 기존에 Movie과 DiscountPolicy가 협력하던 방식을 따르도록 만드는 것이다.

public class NoneDiscountPolicy extends DiscountPolicy {
	@Override
	protected Money getDiscountAmount(Screening screening) {
		return Money.ZERO;
	}
}

할인 정책 중복 적용하기

public class OverlappedDiscountPolicy extends DiscountPolicy {
	private List<DiscountPolicy> discountPolicies = new ArrayList<>();

	public OverlappedDiscountPolicy(DiscountPolicy ...discountPolicies) {
		this.discountPolicies = Arrays.asList(discountPolicies);
	}

	@Override
	protected Money getDiscountAmount(Screening screening) {
		Money result = Money.ZERO;
		for (DiscountPolicy each : discountPolicies) {
			result = result.plus(eaxh.calculateDiscountAmount(screening));
		}
		return result;
	}
}
Movie avatar = new Movie("아바타", 
	Duration.ofMinutes(120), 
	Money.wons(10000),
	new OverlappedDiscountPlicy(
		new AmountDiscountPolicy(...),
		new PercentDiscountPolicy(...)
	)
);

✔️ 조합 가능한 행동

다양한 종류의 할인 정택이 필요한 컨텍스트에서 Movie를 재사용할 수 있었던 이유는 코드를 수정하지 않고도 협력 대상인 DiscountPolicy 인스턴스를 교체할 수 있었기 때문이다.

어떤 객체와 협력하느냐에 따라 객체의 행동이 달라지는 것은 유연하고 재사용 가능한 설계가 가진 특징이다.

훌륭한 객체지향 설계란 객체가 어떻게 하는지를 표현하는 것이 아니라 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지를 표현하는 설계다. 그리고 지금까지 설명한 것처럼 이런 설계를 창조하는 데 있어서의 핵심은 의존성을 관리하는 것이다.

Last updated