객체 지향 5가지 원칙 등 우리는 이름을 가진 설계 원칙을 많이 들어봤다. 이들은 확장 가능하고 변화에 유연한 설계 기법이다.
대표적인 원칙들을 살펴보고, 이들은 궁극적으로 어떤 것을 목표로 하는지 알아보자.
소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 열려있어야 하고, 수정에는 닫혀있어야 한다.
확장에 열려있다.
어플리케이션 요구 사항이 변경될 때 이 변경에 맞게 새로운 "동작"을 추가해서 기능을 확장할 수 있다.
수정에 닫혀있다.
기존 "코드"를 수정하지 않고 어플리케이션 동작을 추가 및 변경이 가능하다.
OCP는 컴파일 시점 의존성은 고정하고 런타임 시점 의존성을 변경함으로 써 준수할 수 있다.
추상화에 의존하면 컴파일 시점 의존성을 고정하고 런타임 시점 의존성을 변경할 수 있다.
즉, OCP를 준수하기 위해선 추상화에 의존하면 된다.
DiscountPolicy
로 고정되있다. (변경에 닫혀있다.)단순히 어떤 개념을 추상화했다고 변경에 닫혀있는 설계가 되진않는다. OCP에서 폐쇄가 가능하게 만드는 것은 의존성의 방향이다. 변경의 파급효과를 최소화하기 위해선 모든 요소(필드)가 추상화에 의존해야 한다. 아래 코드를 살펴보자.
Movie.java
public class Movie {
...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
...
this.discountPolicy = discountPolicy;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie
는 DiscountPolicy
에만 의존하므로 변경이 닫혀있다 볼 수 있다.추상화를 했다고 모든 수정이 폐쇄되는 것을 아님을 명심하자. 변하는 것과 변하지 않는 것을 구분하고 이를 기반으로 변하는 부분을 추상화해야 한다.
결론적으로 추상화에 의존한다면 OCP를 준수할 수 있으므로 추상화를 잘해야 한다.
추상화에"만" 의존한다면 구현 클래스 인스턴스를 내부에서 결정하면 어떻게 될까?
Movie.java
public class Movie {
...
private DiscountPolicy discountPolicy; // 추상화 필드
public Movie(String title, Duration runningTime, Money fee) {
...
this.discountPolicy = new AmountDiscountPolicy(...);
// 내부에서 구현 클래스 인스턴스 생성
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이는 동적으로 추가 및 변경 발생 시 기존 코드를 수정해야 하므로 OCP를 위반한다 볼 수 있다. 또한, 특정 컨텍스트(가격 할인 정책)에 강하게 결합되있다.
동일한 클래스에서 객체 생성, 사용 같이 전혀 다른 목적을 가진 코드가 공존해선 안된다.
생성 사용 분리란 객체와 관련된 2가지 책임(생성, 사용)을 서로 다른 객체에 분리하는 것이다.
이처럼 외부(Client
)에서 생성을 책임진다면 Movie
는 특정 클라이언트에 결합되지 않고 독립적이게 된다. 덕분에 유연하고 재사용 가능한 설계가 된다.
생성 사용 분리 법칙을 적용하면 Movie
는 특정 컨텍스트에 독립적이게 된다. 만약, Client
도 특정 컨텍스트에 독립적이게 만들고 싶다면?
객체 생성 책임만 전담하는 별도의 객체를 추가하고 Client
가 이 객체를 사용하도록 만들면 된다. 이를 팩토리 패턴이라 하고 위에서 별도의 객체를 팩토리라고 한다.
Factory.java
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(
Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10)));
}
}
Client.java
public class Client {
private Factory factory;
public Client(Factory factory) {
this.factory = factory;
}
public Money getAvatarFee() {
Movie avatar = factory.createAvatarMovie();
return avatar.getFee();
}
}
Client
는 Factory
로 부터 생성된 객체를 통해 Movie
를 사용한다.
Factory
는 Movie
와 DiscountPolicy
를 생성하는 책임을 가진다. Client
는 오직 Movie
사용과 관련된 책임만 갖는다.
참고 GRASP
도메인 모델 : 정보 전문가를 찾기 위해 참고하는 자료책임을 할당하기 위해선 도메인 모델으르 통해 적절한 후보를 찾아야 한다.
앞서 Factory
객체는 모메인 모델에 속하지 않는다. 단순히 전체적인 결합도를 낮추고 재사용성을 높이기 위해 추가한 가공 객체에 불과하다.
이렇게 도메인 모델과는 관련없이 설계자의 편의를 위해 임의로 만든 객체를 순수한 가공물이라 한다.
표현적 분해
도메인에 존재하는 사물이나 개념을 표헌하는 객체를 통해 시스템을 분해하는 것을 말한다. 그러나, 도메인 개념으로 객체에 책임을 할당하기엔 한계가 있다. 또한 모든 책임을 도메인인 객체에 할당하면 응집도가 낮고 결합도가 높아지는 문제가 발생한다.
행위적 분해
순수한 가공물을 통해 객체에 책임을 할당하는 방법을 말한다.
우리는 객체에 책임을 할당할 때 이 2가지 방법을 모두 사용해야 한다.
이처럼 도메인을 반영하는 어플리케이션 구조에서 실용적인 창조를 할 수 있는 능력이 반드시 필요하다.
참고 객체지향을 실세계의 모방이라 하는 이유
어플리케이션은 도메인뿐만 아니라 설계자들이 임의로 만든 인공적인 추상화도 존재한다. 설세계에서도 인간의 편의를 위한 수많은 인공물들이 존재한다.
사용하는 객체(Movie
)가 아닌 외부에서 독립적인 객체가 인스턴스를 생성해 전달하여 의존성을 해결(Movie
의 DiscountPolicy
필드에 적절한 구현 클래스를 추가)하는 방법이다.이는 명시적인 의존성이라 볼 수 있다.
의존성 주입을 하는 방법은 아래 3가지가 있다.
생성자 주입
객체 생성과 동시에 의존성 주입이 강제되므로 객체의 상태가 안전함을 보장.
setter 주입
객체 생성 후 의존성이 주입된다. setter가 누락되는 경우 객체의 상태가 불안전해진다.
메서드 주입
public class Movie {
private DiscountPolicy discountPolicy;
...
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Money fee = movie.calculateMovieFee(new Screening(...));
특정 의존성이 하나의 메서드만 사용되는 경우(e.g. Movie
와 Screening
) 사용한다.
참고 Service Locator
의존성을 해결할 객체들을 보관하는 일종의 저장소이다.
의존성 주입과 달리 사용하는 객체가 직접 Service Locator에게 의존성을 해결해줄 것을 요청하는 방식이다. 숨겨진 의존성이라 볼 수 있다.
ServiceLocator.java
public class ServiceLocator {
private static ServiceLocator soleInstance = new ServiceLocator();
private DiscountPolicy discountPolicy;
public static DiscountPolicy discountPolicy() {
return soleInstance.discountPolicy;
}
public static void provide(DiscountPolicy discountPolicy) {
soleInstance.discountPolicy = discountPolicy;
}
private ServiceLocator() {
}
}
Movie.java
public class Movie {
...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee) {
...
this.discountPolicy = ServiceLocator.discountPolicy();
}
}
ServiceLocator.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10_000));
그러나, 이 방법은 ServiceLocator
객체 초기화 여부에 의존하게 되며 이를 직관적으로 알아차릴 수 없다.
이와같이 ServiceLocator
초기화 코드가 없다면 NPE가 발생하게 된다.
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10_000)); // NPE 발생
숨겨진 의존성의 단점은 아래와 같다.
가능하면 명시적인 의존성을 사용하자. 유연성을 향상 시킨다.
참고
의존성 주입을 지원하는 프레임워크가 없거나 깊은 계층간 호출을 거쳐 동일 객체를 전달하는 경우엔 Service Locator 패턴을 고려해보자.
- 상위 수준의 모듈은 하위 수준의 모듈에 의존해선 안된다. 둘 모두 추상화에 의존해야 한다.
- 추상화는 구체적인 사항에 의존해선 안된다. 구체적인 사항이 추상화에 의존해야 한다.
변경의 전파는 의존성과 관련되있다. 이를 최소화하기 위해선 의존성을 관리해야 한다.
이 의존성은 변경에 취약하다. 요금을 계산하는 상위 정책이 요금을 계산하는데 필요한 구체적인 방법에 의존하기 때문이다.
Movie
는 가격 계산이라는 추상적이고 더 높은 수준의 개념을 구현하는 반면, AmountDiscountPolicy
는 더 구체적인 수준을 구현하기에 하위 수준 개념을 구현한다 볼 수 있다.
이는 의존성의 방향이 잘못되었다고 말한다. 구현 클래스는 의존성의 시작점이여야지 못적지여선 안된다.
추상화를 의존하게 하자. 이를 통해 얻는 이점는 아래와 같다.
Movie
)가 추상화(DiscountPolicy
)를 의존하므로 하위 수준 클래스(AmountDiscountPolicy
)에 의존하지 않는다.이러한 방법을 의존성 역전 원칙(DIP)라고 부른다.
참고 역전(inversion)이라는 단어를 사용하는 이유
의존성이 절차지향 프로그래밍과는 반대 방향으로 나타나기 때문
참고
위에서 언급한 모듈은 Java의 패키지에 해당한다.
이는 OCP를 준수하지만 DIP는 준수하지 않는다. 그 이유는 무엇일까?
우리는 의존성을 고려할 때 코드 수정이라는 관점 외에 컴파일 측면도 고려해야 한다. 패키지내의 클래스 1개가 변경된다면 패키지 전체가 재배포된다. 이로 인해 DiscountPolicy
가 포함된 패키지의 구현 클래스들이 변경된다면 컴파일은 의존성을 타고 어플리케이션 코드 전체로 번져갈 것이다.
따라서, 이러한 구성(불필요한 클래스들을 같은 패키지에 두는 것)은 전체적인 빌드 시간을 가파르게 상승시킨다.
Movie
는 특정 컨텍스트들(가격 할인 정책, 비율 할인 정책)으로 부터 완전히 독립시키자. 덕분에 앞서 언급한 문제점들이 해결된다.
이러한 기법을 Seperated Interface 패턴이라 한다. 잘 설계된 객체 지향 어플리케이션은 인터페이스의 소유권은 서버가 아닌 클라이언트에 위치시킨다는 것을 명심하자.
미래에 변경이 일어날지도 모른다는 막연한 불안감은 오히려 오버엔지니어링을 유발한다. 아직 일어나지 않은 변경은 변경이 아니다.
설계가 유연할수록 클래스 구조와 객체 구조는 멀어진다. 유연함은 단순함과 명확성을 희생하면서 자라난다.
따라서, 불필요하게 유연성은 불필요한 복잡성을 낳는다. 단순하고 명확한 해법이 있다면 유연성을 제거하자.
의존성을 관리하는 이유는 역할 책임의 관점에서 설계가 유연하고 재사용이 가능해야 하기 때문이다. 아무리 의존성 관리를 잘해도 역할, 협력, 책임이 모호하다면 모두 물거품이 된다.
참고
객체의 역할과 책임을 확실히 정하고 객체를 생성해야 한다. 책임이 불균형한 상태에서 객체 생성 책임을 할당(e.g. 싱글톤 패턴)해버리면 설계를 해당 매커니즘에 종속적이게 한다.