잘 설계된 객체지향 App. 은 작고 응집도 높은 객체들로 구성됩니다. 책임이 명확하고 한 가지 일을 잘하는 객체이지요. 이 객체들이 협력을 하는 것입니다. 협력은 하면 필연적으로 의존성이 발생합니다. 너무 과한 협력은 과한 의존성을 발생시키므로 결국 '적당히' 가 중요합니다.
충분히 협력적이면서도 유연한 객체르 만들기 위한 의존성 관리에 대한 글입니다.
의존성
변경과 의존성
의존성은 의존하고 있는 대상의 변경에 영향을 받을 수 있는 가능성입니다.
- 실행 시점 의존성: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 함.
- 구현 시점 의존성: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경됨.
위 글만 읽으면 무슨 소리인지 잘 이해가 안되는데, 코드를 통해서 구체적으로 알아봅시다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
...
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0&&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
실행 시점에 PeriodCondition 의 인스턴스가 정상 동작하려면 Screening 의 인스턴스가 존재해야 합니다. 이렇게 어떤 객체가 예정된 작업을 하기 위해 다른 객체를 필요로 할 때 의존성이 있다고 합니다.
의존성은 방향성을 가지며 항상 단방향입니다. 이는 점선 화살표로 표현합니다.
PeriodCondition 은 DayOfWeek, LocalTime, Screening 에 대해 의존성을 가집니다. DayofWeek, LocalTime, Screening, DiscountCondition 이 변경되면 PeriodConditon 도 함께 변경될 수 있습니다.
PeriodCondition 이 DiscountCondition 에 의존하는 이유는 인터페이스에 정의된 오퍼레이션들을 퍼블릭 인터페이스의 일부로 포함시키기 위함입니다.
(여기서 서술하는 '의존관계' 는 UML 의 의존 관계와는 다름. 두 요소 사이에 변경에 의해 영향을 주고 받는 것을 말한다.)]]
의존성 전이
의존성 전이(transtive dependency) 에 의미는 PeriodCondition 이 Screening 에 의존하면 PeriodCondition 은 Screening 이 의존하는 대상에도 자동적으로 의존하게 된다는 것입니다. 전파된다는 것이지요.
의존성은 함께 변경될 수 있는 가능성을 의미하는 것입니다. 즉, 모든 경우 의존성이 전이되는 것은 아닙니다. 이는 변경 방향, 캡슐화 정도에 따라 달라집니다. 의존성은 아래 두 종류로 분류되기도 합니다.
- 직접 의존성(direct dependency): 한 요소가 다른 요소에 직접 의존하는 경우.
- 간접 의존성(indirect dependency): 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우
의존성은 객체일 수도, 모듈일 수도, 더 큰 규모의 시스템일 수도 있습니다. 하지만 의존성의 본질, 의존하고 있는 대상의 변경에 영향을 받을 수 있는 가능성이라는 것은 변하지 않습니다.
런타임 의존성과 컴파일 타임 의존성
- 런타임: App.이 실행되는 시점.
- 컴파일 타임: 보통 작성된 코드를 컴파일하는 시점. 문맥에 따라서는 코드 그 자체이기도 함.(동적 언어는 컴파일 타임이 없음.) 중요한 것은 코드의 구조임.
런타임 의존성은 객체 사이의 의존성이 주제이고, 컴파일 타임 의존성은 클래스 사이의 의존성이 주제입니다.
런타임 의존성과 컴파일 타임 의존성은 다를 수 있습니다! 조금 더 정확히 말하면 유연한 코드를 위해 달라야 합니다.
위와 같은 클래스 관계가 있을 때 코드 레벨에서 Movie 클래스는 추상 클래스(혹은 인터페이스)의 구현체인 AmountDiscountPolicy 나 PercentDiscountPolicy 를 모릅니다. 단지, 추상 클래스(혹은 인터페이스)인 DiscountPolicy 을 알고 의존하고 있을 뿐입니다.
하지만 런타임 의존성을 살펴보면 Movie 는 금액 할인 정책을 적용하기 위해서 AmountDiscountPolicy 의 인스턴스와 협력해야 합니다. 비율 할인 정책을 위해서는 PercentDiscountPolicy 의 인스턴스와 협력해야 하지요. Movie 클래스는 두 클래스를 모르지만 Movie 인스턴스는 두 인스턴스와 협력할 수 있어야 합니다.
코드 작성 시점의 Movie 클래스는 할인 정책을 구현한 두 클래스의 존재를 모르지만 실행 시점의 Movie 객체는 두 클래스의 인스턴스와 협력할 수 있게 됩니다. 유연한 설계를 위해 같은 소스 코드로 다양한 실행 구조를 만들 수 있어야 합니다.
어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안되고 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 합니다. 컴파일 타임 구조와 런타임 구조 사이가 멀수록 설계가 유연해집니다.
컨텍스트 독립성
클래스가 특정한 문맥에 강하게 결합될 수록 다른 문맥에서 사용하기 어렵습니다. 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해집니다. 이를 컨텍스트 독립성이라고 합니다.
가능한 자신이 실행될 컨텍스트에 대한 구체적인 정보를 최대한 추상적으로만 알아야 합니다. 정보가 적어야 다양한 컨텍스트에서 재사용될 수 있습니다.
의존성 해결
컴파일 타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 합니다.
의존성 해결을 위해 보통 아래 세 방법을 사용합니다.
- 객체 생성 시점에 생성자로 의존성 해결
- 객체 생성 후 setter 메서드를 통해 의존성 해결
- 메서드 실행 시 인자를 이용해서 의존성 해결
Movie 생성자에 전달하는 인스턴스에 따라 의존성 해결 방법
Movie avatar = new Movie("아바타",
Duration.ofMinutes((120),
Money.wons(10000),
new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타",
Duration.ofMinutes((120),
Money.wons(10000),
new PercentDiscountPolicy(...));
이를 위해서는 Movie 클래스의 생성자에 아래처럼 되어 있어야 하겠조.
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
Movie 인스턴스를 생성한 후에 Setter 로 의존성 해결 방법
Movie avatar = new Movie(...);
avatar.setDiscountPolicy(new AmountDiscountPolicy(...));
이를 위해서는 Movie 클래스에 해당 setter 메서드가 있어야 겠죠.
public class Movie {
...
public void setDiscountPolicy (DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
setter 메서드 이용 방식은 객체 생성 이후에도 의존하고 있는 대상을 변경할 수 있는 가능성이 필요할 때 유용합니다. 이 덕분에 설계가 더 유연해집니다.
하지만 객체 생성 후에 협력에 필요한 의존 대상을 설정하기 때문에 설정 전까지는 객체의 상태가 불완전할 수 있습니다. 만약 setter 로 인스턴스 변수 설정을 하지 전에 그것을 사용하는 함수(혹은 코드)를 실행하면 NullPointerException 예외가 발생할 것입니다.
그렇다면 더 좋은 방법은 '생성자 이용 방식 + setter 이용 방식' 이겠죠. 이것이 시스템 상태를 안정적으로 유지하면서 유연성도 늘어나 가장 선호되는 방법입니다.
매소드 실행 시 인자를 이용하는 의존성 해결 방법
만약 Movie 가 항상 할인 정책을 알 필요가 없고 가격 계산 시에만 일시적으로 알아도 된다면 메서드의 인자를 이용해서 의존성을 해결할 수 도 있습니다.
public class Movie {
....
public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
메서드 실행시에만 일시적으로 의존관계가 있어도 될때, 메서드 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용합니다. 하지만 대부분의 경우에는 의존성을 지속적으로 유지하는 방식으로 변경하는 것이 더 좋습니다.
유연한 설계
의존성과 결합도
바람직한 의존성은 재사용성이 핵심입니다. 컨텍스트에 독립적인 의존성은 바람직한 의존성입니다.
특정 컨텍스트에 강하게 의존하는 클래스를 다른 컨텍스트에 재사용하려면 내부 구현을 변경하는 수 밖에 없습니다.이렇게 재사용을 위해 내부 구현을 변경하게 만드는 모든 의존성은 좋지 않습니다. 바람직하지 않은 의존성을 단단한(강한) 결합도라고 하고 바람직한 의존성을 느슨한(약한) 결합도라고 합니다.
더 많이 더 구체적으로 알수록 더 많이 결합됩니다. 이는 더 작은(구체적인) 컨텍스트에서만 재사용이 가능하다는 것입니다. 우리는 결합도를 느슨하게 하기 위해 협력 대상에 대해 필요한 정보 외에는 최대한 감추어야 합니다.
추상화에 의존하자
추상화는 어떤 세부사항, 구조를 더 명확히 이해하기 위해서 특정 절차나 물체를 의도적으로 감추거나 생략해서 복잡도를 줄이는 방법입니다. 이것으로 불필요한 정보를 감출 수 있지요. 결국 결합도를 느슨하게 할 수 있습니다.
일반적으로 추상화와 결합도의 관점에서 의존 대상을 아래처럼 구분하는 게 좋습니다. 아래쪽이 클라이언트가 알아야 하는 지식 양이 적어서 결합도가 더 낮아집니다.
- 구체 클래스 의존성(concrete class dependency)
- 추상 클래스 의존성(abstract class dependency)
- 인터페이스 의존성(interface dependency)
즉, 의존하는 대상이 더 추상적일수록 결합도는 더 낮아집니다.
명시적인 의존성
명시적인 의존성(explicit dependency): 모든 경우 의존성은 명시적으로 퍼블릭 인터페이스에 노출됩니다.
위에 '의존성 해결' 에서 본 생성자로 의존성을 해결하는 방법과 setter 메소드로 의존성을 해결하는 방법의 경우 Movie 가 DiscountPolicy 에 의존한다는 사실을 Movie 의 퍼블릭 인터페이스에 드러내고 있습니다.
반면에 만약 아래 코드처럼 Movie 의 생성자 내부에서 인스턴스를 직접 생성한다고 해봅시다.
public class Movie {
....
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee) {
....
this.discountPolicy = new AmountDiscountPolicy(...);
}
}
이 경우 의존성이 퍼블릭 인터페이스에 표현되지 않습니다. Movie 가 DiscountPolicy 에 의존한다는 사실을 감추고 있죠. 이를 숨겨진 의존성(hidden dependency) 라고 합니다.
의존성이 숨겨져 있으면 의존성 파악을 위해 내부 구현을 직접 살펴보아야 하며, 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 합니다.
명시적인 의존성을 사용해야 퍼블릭 인터페이스를 통해서 컴파일 타임 의존성을 적적한 런타임 의존성으로 교체할 수 있습니다.
new 는 해롭다
new 는 해롭다 라는 말이 꽤 뜬금없이 들리죠? 클래스의 인스턴스를 생성하는 new 를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아집니다.
- new 를 사용하려면 구체 클래스의 이름을 직접 기술해야 함. --> 클라이언트는 추상화가 아닌 구현체에 직접 의존하게 됨.
- new 를 사용하려면 어떤 인자를 이용해서 생성자를 호출해야 하는지도 알아야 해서 클라이언트가 알아야 하는 지식의 양이 늘어남.
AmountDiscountPolicy 인스턴스를 직접 생성할 때의 Movie 클래스의 코드입니다.
public class Movie {
...
private DiscountPolicy discountPolicy;
...
public Movie(String title, Duration runningTime, Money fee) {
...
this.discountPolicy = new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY,
LocalTime.of(10, 0), LocalTime.of(11, 59)),
new PeriodCondition(DayOfWeek.THURSDAY,
LocalTime.of(10, 0), LocalTime.of(20, 59))));
}
}
생성자에 전달되는 인자를 알고 있어야 해서 Movie 클래스가 알아야 하는 지식의 양을 늘리기 때문에 Movie 가 AmountDiscountPolicy 에게 더 강하게 결합되게 합니다. 또 Movie 가 AmountDiscountPolicy 의 생성자에서 참조하는 두 구현체 SequenceCondition, PeriodCondition 에도 의존하게 됩니다. 또 그 인자 DayOfWeek, LocalTime 에 대해서도 Movie 을 결합시키죠. 결국 Movie 의 결합도가 아래 그림처럼 높아집니다.
사실 Movie 는 DiscountPolicy 에게 calculateDiscountAmount 메시지를 전송하기 위해서 의존하는 것입니다. 즉, 다른 의존성은 불필요합니다.
해결책은 인스턴스 생성 로직과 생성된 인스턴스를 사용하는 로직을 분리하는 것입니다. Movie 는 AmountDiscountPolicy 인스턴스를 생성하면 안되고 단지 이미 생성된 AmountDiscountPolicy 인스턴스를 외부로부터 전달받아야 합니다. 이는 위 '의존성 해결' 에서 서술한 방법을 이용하면 됩니다.
생성자를 통해 외부 인스턴스를 전달받는다면 아래 코드처럼 됩니다.
public class Movie {
....
private DiscountPolicy discountPolicy8;
...
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
...
this.discountPolicy = discountPolicy;
}
}
AmountDiscountPolicy 의 인스턴스는 Movie 의 클라이언트가 처리할 것입니다. 이제 Movie 는 AmountDiscountPolicy 의 인스턴스를 사용하는 책임만 가집니다.
Movie avater = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
...
));
사용과 생성의 책임을 분리하고 의존성을 생성자에게 명시적으로 드러내고, 구현체가 아닌 추상 클래스에 의존하게 되어 설계가 유연해졌습니다. 객체 생성 책임이 객체 내부가 아닌 클라이언트로 옮김으로써 말이죠!
물론 항상 new 를 쓰지 말아야 하는 것은 아님
클래스 안에서 객체의 인스턴스를 직접 생성하는 것이 좋을 때도 있습니다. 바로 협력하는 기본 객체 설정 시 그렇죠!
만약 Movie 가 대체로 :AmountDiscountPolicy 의 협력하고 가끔 :PercentDiscountPolicy 와 협력한다고 합시다. 이 때 클라이언트가 항상 인스턴스를 생성하도록 하면 코드 중복이 늘어나 좋지 않을 것입니다.
기본 객체를 생성하는 생성자를 추가ㅏ고 이 생성자에 :DiscountPolicy 를 인자로 받는 생성자를 체이닝 하는 것입니다.
public class Movie {
private DiscountPolicy discountPolicy;
...
public Movie(String title, Duration runningTime, Money fee) {
this(title, runningTime, fee, new AmountDiscountPolicy(...));
}
public Movie(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 의 표준 클래스는 당연히 구체 클래스에 의존하거나 직접 인스턴스를 생성해도 괜찮습니다. ArrayList 같은 경우이죠. 표준 클래스 구현 코드는 사실상 수정되지 않기 때문에 문제되지 않습니다.
물론 비록 클래스를 직접 생성하더라도 가능한 추상적인 타입을 사용하는 것이 확장성 측면에서 더 유리합니다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public void switchConditions(List<DiscountCondition> conditions) {
this.conditions = conditions;
}
}
위 코드에서도 conditions 의 타입을 ArrayList 가 아닌 인터페이스인 List 을 사용했습니다. 다양한 List 타입의 구현체로 conditions 을 대체할 수 있어 유연합니다. 표준 클래스를 사용하는 의존성으로 생기는 영향이 적어도 추상화에 의존하고 의존성을 명시적으로 드러내는 것이 좋습니다.
컨텍스트 확장
다른 컨텍스트에서 Movie 를 확장해서 재사용하는 예를 살펴봅시다.
할인 혜택을 제공하지 않는 영화의 경우, 중복 적용이 가능한 할인 정책을 구현하는 경우,
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 calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이런 식으로 하면 Movie 와 DiscountPolicy 사이의 협력 방식에 어긋나게 됩니다. 이를 해결하기 위해서 단순히 '할인 혜택이 없는 할인 정책'인 클래스 NoneDiscountPolicy 을 만들고 DiscountPolicy 을 확장하면 됩니다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
중복할인의 경우도 '중복으로 할인되는 할인 정책' 인 클래스 OverlappedDiscountPolicy 을 만들고 DiscountPolicy 을 확장하면 됩니다.
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(each.calculateDiscountAmount(screening));
}
return result;
}
}
이렇게 Movie 을 수정하지 않고도 할인 정책을 적용하지 않는, 중복 할인을 적용하는 새로운 기능을 추가하는 것이 굉장히 간단히 되었습니다!!!
결합도를 낮춰서 얻는 컨텍스트의 확장 이라는 개념이 유연하고 재사용 가능한 설계를 만드는 핵심입니다.
조합 가능한 행동
유연, 재사용 가능한 설계는 객체가 어떻게(how) 하는지를 장황하게 나열하지 않고 객체들의 조합으로 무엇(what)을 하는지를 표현하는 클래스로 구성됩니다. 클래스의 인스턴스 생성 코드로만으로 객체의 일을 쉽게 알 수 있습니다.코드의 로직 없이 객체가 어떤 객체와 연결되는지를 보는 것만으로도 행동을 쉽게 예상, 이해할 수 있습니다. 즉, 선언적으로 객체의 행동을 정의할 수 있습니다.
작은 객체들의 행동을 조합해서 새로운 행동을 이끌어 낼 수 있는 설계가 유연, 재사용 가능한 설계입니다. 객체가 어떻게 하는지를 표현하는 것이 아닌, 객체들의 조합을 선언적으로 표현해서 객체들이 무엇을 하는지를 표현하는 설계이지요. 이 설계의 핵심은 결국 의존성 관리 입니다.
'Computer Science > 객체지향' 카테고리의 다른 글
상속과 코드 재사용. 상속을 사용할 때 조심해야 할 점을 중심으로 - 코드로 이해하는 객체지향 (0) | 2023.08.28 |
---|---|
유연한 설계 - 코드로 이해하는 객체지향 (0) | 2023.08.26 |
객체 분해 - 코드로 이해하는 객체지향 (0) | 2023.08.21 |
메시지와 인터페이스 - 코드로 이해하는 객체지향 (0) | 2023.08.17 |
책임 할당 - 코드로 이해하는 객체지향 프로그래밍 (0) | 2023.07.28 |