개발자는 기록이 답이다

오브젝트 9장 - 유연한 설계 본문

기술 서적/OOP

오브젝트 9장 - 유연한 설계

slow-walker 2024. 1. 20. 08:31

 

8장에서는 유연하고 재사용 가능한 설계를 만들기 위해 적용할 수 있는 다양한 의존성 관리기법을 학습했다.

이번 장에서는 해당 기법들을 원칙이라는 관점에서 정리하고자 한다.

 

앞 장의 내용이 반복된다는 느낌을 받을 수 있겠지만, 이름을 가진 설계 원칙을 통해 기법들을 정리하는 것은 장황하게 설명된 개념과 매커니즘을 또렷하게 정리할 수 있게 도와줄 뿐만 아니라 설계를 논의할때 사용할 수 있는 공통의 어휘를 익힌다는 점에서도 가치가 있다.


 

1. 개방-폐쇄 원칙

 

로버트 마틴은 확장 가능하고 변화에 유연하게 대응할 수 있는 설계를 만들 수 있는 원칙 중 하나로 개방-폐쇄 원칙(Open-Closed Principle, OCP)을 고안했다.

 

소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려있어야하고, 수정에 대해서는 닫혀 있어야 한다.

 

  • 확장에 대해 열려 있다 :  애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다 : 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.

즉, 기존 코드를 수정하지 않고도 애플리케이션의 동작을 확장 할 수 있어야 한다.

 

컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라

 

OCP는 런타임 의존성과 컴파일타임 의존성에 관한 이야기이다.

 

  • 컴파일 타임 의존성 : 코드에서 드러나는 클래스들 사이의 관계
  • 런타임 의존성 : 실행 시 협력에 참여하는 객체들 사이의 관계

 

 

영화 예매 시스템 할인 정책을 의존성 관점에서 다시 살펴보면,

  • 컴파일 타임 의존성 : Movie클래스는 추상 클래스인 DiscountPolicy에 의존한다
  • 런타임 의존성 : Movie인스턴스 AmountDiscountPolicy와 PercentDiscountPolicy 인스턴스에 의존한다.

 

 

여기서 중복 할인 정책을 추가하고 싶다면 DiscountPolicy의 자식 클래스로 OverlappedDiscountPolicy클래스를 추가하면 끝이다.

기존 클래스의 어떤 코드도 수정하지 않아도 애플리케이션의 동작이 확장된다.

 

OCP를 수용하는 코드는 컴파일 타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있다.

즉, 의존성 관점에서 OCP를 따르는 설계란 컴파일타임 의존성을 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조라고 할 수 있다.

 

추상화가 핵심이다

 

OCP의 핵심은 추상화에 의존하는 것이다.

 

추상화란 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법이다.

추상화 과정을 거치면 문맥이 바뀌더라도 "변하지 않는 부분"만 남게 되고, 문맥에 따라 "변하는 부분"은 생략된다.

 

  • OCP 관점에서 생략되지 않고 남겨지는 부분 : 다양한 상황에서 공통점을 반영한 추상화의 결과물

공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 하고, 수정할 필요가 없어야 한다.

따라서, 추상화 부분은 수정에 대해 닫혀있어야 하고, 추상화를 통해 생략된 부분은 확장의 여지를 남긴다.

→ 추상화가 OCP원칙을 가능하게 만드는 이유

 

package chapter10.movie;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

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

    public DiscountPolicy(DiscountCondition... conditions) {
        this.conditions = Arrays.asList(conditions);
    }
    
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition each : conditions) {
            if (each.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }
        return screening.getMovieFee();
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

 

DiscountPolicy는 할인 여부를 판단해서 요금을 계산하는 calculateDiscountAmount메서드조건을 만족할 때 할인된 요금을 계산하는 추상메서드인 getDiscountAmount메서드로 구성되어 있다.

 

  • 변하지 않는 부분 : 할인 여부를 판단하는 로직
  • 변하는 부분 : 할인된 요금을 계산하는 방법

따라서 DiscountPolicy는 추상화다. 추상화 과정을 통해 생략된 부분은 할인 요금을 계산하는 방법이다.

우리는 상속을 통해 생략된 부분을 구체화함으로써 할인 정책을 확장할 수 있는 것이다.

 

변하지 않는 부분을 고정하고, 변하는 부분을 생략하는 추상화 메커니즘이 개방-폐쇄 원칙의 기반이 된다는 사실에 주목하라

언제라도 생략된 부분은 채워넣음으로써 새로운 문맥에 맞게 기능을 확장할 수 있기 때문에 추상화는 설계의 확장을 가능하게 한다.

 

단순히 특정 개념을 추상화했다고 해서 수정에 대해 닫혀있는 설계를 만들 수 있는건 아니다.

OCP에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다. 수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 한다.

 

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에 대해서만 의존한다.

의존성이란 변경의 영향을 의미하고, DiscountPolicy는 변하지 않는 추상화라는 사실에 주목하라.

Movie는 안정된 추상화인 DiscountPolicy에 의존하기 때문에 할인 정책을 추가하기 위해 DiscountPolicy의 자식 클래스를 추가하더라도 영향을 받지 않는다.

 

 

8장에서 설명한 것처럼 명시적 의존성해결 방법을 통해 컴파일타임 의존성을 런타임 의존성으로 대체함으로써 실행시에 객체의 행동을 확장할 수 있다.

 

비록 이런 기법들이 OCP를 따르는 코드를 작성하는데 중요하지만, 핵심은 추상화라는 것을 기억하라.

올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한함으로써 설계를 유연하게 확장할 수 있다.

 

 

※ 주의할 점

  1. 변경에 의한 파급효과를 최대한 피하기 위해 변하는 것과 변하지 않는 것이 무엇인지 이해하기
  2. 변경되지 않을 부분을 신중하게 결정하기

2. 생성 사용 분리

 

추상화(DiscountPolicy)에만 의존하기 위해서는 의존하는 클래스(Movie) 내부에서 구체 클래스(AmountDiscountPolicy)의 인스턴스를 생성해서는 안된다.

이러한 코드는 동작을 추가하거나 변경하기 위해 기존의 코드를 수정하도록 만들기 때문에 OCP를 위반한다.

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구조를 설계하기 어려워진다. 특히 객체 생성에 대한 지식은 과도한 결합도를 초래하는 경향이 있다.

객체의 타입과 생성자에 전달해야하는 인자에 대한 과도한 지식은 코드를 특정한 컨텍스트에 강하게 결합시킨다.

 

물론 객체 생성을 어딘가에서는 반드시 해야한다.

문제는 객체 생성이 아니다. 부적절한 곳에서 객체를 생성한다는게 문제이다.

 

위의 코드에서

  1. 메세지를 전송하지 않고 객체를 생성하기만 했거나
  2. 객체를 생성하지 않고 메세지를 전송하기만 했다면 괜찮았을 것이다.

동일한 클래스 안에서 객체 생성과 사용이라는 2가지 이질적인 목적을 가진 코드가 공존하는게 문제인것이다.

객체와 관련된 2가지 책임을 서로 다른 객체로 분리해야 한다

  • 객체 생성
  • 객체 사용

생성과 사용을 분리해야 한다(sepereating use from creation)

 

사용으로부터 생성을 분리하는데 사용되는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것이다.

즉, "Movie의 클라이언트"가 적절한 DiscountPolicy인스턴스를 생성한 후 Movie에게 전달하는 것이다.

 

현재의 컨텍스트에 관한 결정권을 갖고있는 클라이언트로 "컨텍스트에 대한 지식"을 옮김으로써 Movie는 특정한 클라이언트에 결합되지 않고 독립적일 수 있다.

public class Client {
    public Money getAvatarFee() {
        Movie avatar = new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10000),
                new AmountDiscountPolicy(...));
        return avatar.getFee();
    }
}

 

정리하자면, Movie의 의존성을 추상화인 DiscountPolicy로만 제한하고, 구체 클래스의 인스턴스 생성의 역할은 Movie의 클라이언트에게 위임함으로써 확장에는 열려 있고 수정애는 닫혀있는 코드를 만들 수 있다.

 

FACTORY추가하기

 

생성 책임을 Client로 옮긴 배경에는 Movie는 특정 컨텍스트에 묶여서는 안되지만, Client는 묶여도 상관이 없다는 전제가 깔려 있다.

하지만 Movie를 사용하는 Client도 특정한 컨텍스트에 묶이지 않기를 바라면 어떨까?

 

Client코드를 다시보면 Movie인스턴스를 생성하는 동시에 getFee()메시지도 함께 전송한다는 것을 알 수 있다.

즉, Client역시 생성과 사용의 책임을 함께 지니고 있다.

 

"Movie를 생성하는 책임"을 "Client의 인스턴스를 사용할 문맥을 결정할 클라이언트"에게 옮기는 것이다.

하지만 객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가길를 원하지 않는다고 가정해보자.

 

이 경우 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다.

이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체Factory라고 부른다.

package chapter10.movie;

public class Client {
    
    private Factory factory;

    public Client(Factory factory) {
        this.factory = factory;
    }

    public Money getAvatarFee() {
        Movie avatar = factory.createAvatarMovie();
        return avatar.getFee();
    }
}
package chapter10.movie;

import java.time.Duration;

public class Factory {
    public Movie createAvatarMovie() {
        return new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10000),
                new AmountDiscountPolicy(...));
    }
}

 

Foctory를 사용하면 Movie와 AmountDiscountPolicy객체를 생성하는 책임 모두 FACTORY로 이동할 수 있다.

그러면 Client에는 사용과 관련된 책임만 남게 되는데, 하나는 FACTORY를 통해 생성된 Movie객체를 얻기 위한 것이고, 다른 하나는 Movie를 통해 가격을 계산하기 위한 것이다. Client는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지고 있지 않다.

 

 

순수한 가공물에게 책임 할당하기

 

5장에서 책임 할당 원칙을 패턴의 형태로 기술한 GRASP패턴에 대해 살펴봤다.

책임 할당의 가장 기본이 되는 원칙은 책임을 수항하는데 필요한 정보를 가장 많이 알고 있는 INFORMATION EXPERT(정보 전문가)에게 책임을 할당하는 것이다. 도메인 모델은 INFORMATION EXPERT를 착기 위해 참조할 수 있는 일차적인 재료다.

어떤 책임을 할당하고 싶다면 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다.

 

위에서 추가한 Factory는 도메인 모델에 속하지 않는다. Factory를 추가한건 순수하게 기술적인 결정이다.

전체적인 결합도를 낮추고 재사용성을 높이기 위해 "도메인 개념에게 할당돼 있던 객체 생성 책임"을 "도메인 개념과는 아무런 상관이 없는 가공의 객체"로 이동시킨 것이다.

 

크레이그 라만은 시스템을 객체로 분해하는 데 크게 2가지 방식이 존재한다고 설명한다

  • 표현적 분해(representational decomposition)
    • 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템 분해
    • 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것을 목적으로 한다
    • 객체지향 설계를 위한 가장 기본적인 접근법
  • 행위적 분해(behavioral decomposition)
    • 순수한 가공물에게 책임을 할당

하지만 종종 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다.

도메인 모델은 설계를 위한 중요한 출발점이지만, 단지 "출발점"일 뿐이다.

 

모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다.

이 경우 도메인 개념을 표현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다. 이를 PURE FABRICATION(순수한 가공물)이라고한다.

 

어떤 행동을 추가하려고 하는데, 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 PURE FABRICATION 을 추가하고 이 객체에게 책임을 할당하라

 

객체지향 애플리케이션은 오히려 도메인 개념을 반영하는 객체들보다 인공적으로 창조한 객체들이 더 많은 비중을 차지한다.

 

Factory는 객체의 생성 책임을 할당할만한 도메인 객체가 존재하지 않을때 선택할 수 있는 PURE FABRICATION이다.

(14장에서 나올 대부분의 디자인 패턴을 PURE FABRICATION을 포함한다.)

 

3. 의존성 주입

생성과 사용을 분리하면 Movie에는 오로지 "인스턴스를 사용하는 책임"만 남게 된다.

이것은 외부의 다른 객체가 Movie에게 생성된 인스턴스를 전달해야 한다는 것을 의미한다.

이처럼 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입(Dependency Injection)이라고 부른다.

 

8장에 나온 의존성 해결 "컴파일타임 의존성과 런타임 의존성 차이점을 해소"하기 위한 다양한 매커니즘을 포괄한다.

의존성 주입은 의존성을 해결하기 위해 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법을 포괄하는 명칭이다.

 

  • 생성자 주입(constructor Injection) : 객체를 생성하는 시점에 생성자를 통한 의존성 해결
  • setter 주입(setter Injection) : 객체를 생성 후 setter 메서드를 통한 의존성 해결
  • 메서드 주입(method Injection) : 메서드 실행 시 인자를 이용한 의존성 해결

 

▶ 생성자 주입은 Movie생성자의 인자로 AmountDiscountPolicy의 인스턴스를 전달해서 DiscountPolicy클래스에 대한 컴파일타임 의존성을 런타임 의존성으로 대체하는 아래와 같은 코드이다.

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

 

 setter주입은 이미 생성된 Movie에 대해 setter메서드를 이용해 의존성을 해결한다.

생성자 주입은 객체의 생명주기 전체에 걸쳐 관계를 유지하는 반면, setter주입은 언제라도 런타임 시점에 의존 대상을 교체할 수 있다.

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

 

하지만 객체가 올바로 생성되기 위해 어떤 의존성이 필수인지 명시적으로 표현할 수 없다.

객체가 생성된 후에 호출돼야 하기 때문에 setter메서드 호출이 누락된다면 객체는 비정상적인 상태로 생성될 것이다.

 

 메서드 주입은 메서드 호출 주입(method call injection)이라고 부르며, 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있다. 생성자 주입을 통해 의존성을 전달받으면 객체가 올바른 상태로 생성되는데 필요한 의존성을 명확하게 표현할 수 있다는 장점이 있지만, 주입되는 의존성이 한 두개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.

avatar.calculateDiscountAmount(screening, new AmountDiscountPolicy(...));

 

(메서드 주입을 의존성 주입의 종류로 볼 것인가에 대한 논란의 여지가 있지만, 외부에서 객체가 필요로 하는 의존성을 해결한다는 측면에서 같은 종류로 간주한다.)

주입 방식 생성자 주입 setter 주입 메서드 주입
특징 - 객체의 생성 시점에 외부에서 필요한 의존성 전달 받음
- 객체의 생명 주기 동안 의존성 유지
- 이미 생성된 객체에 대해 런타임 시점에 의존성 변경 가능 - 메서드 호출 시점에 필요한 의존성 주입
- 해당 의존성이 적게 사용되는 경우 활용
장점 - 의존성이 명시적으로 표현되어 객체 생성 시 필요한 의존성을 강제할 수 있음
- 불변성 유지하기 용기
- 런타임 시 동적인 의존성 변경 가능
- 선택적으로 의존성 주입 가능
- 의존성이 메서드 내에서만 필요한 경우 활용
- 객체의 생성과 무관하게 의존성을 주입할 수 있음
단점 - 생성자 파라미터가 많을 경우 코드 가독성 저하
- 런타임 시동적인 의존성 변경 어려움
- 객체 생성 이후 setter메서드 호출이 누락되면 비정상적인 상태로 남음
- NPE 에러 발생 가능성 높음
- 생성자 주입과 비교하면 명시적 표현이 어려워 질 수 있음

 

※ setter주입과 메서드 주입이 헷갈려서 정리해봤다.

  • setter주입의 경우, 클래스 내부에 관련 의존성을 조합으로 필드를 갖고 있는 경우에 사용한다.
  • 메서드주입의 경우, 클래스 내부에 관련 의존성 필드없이, 특정 메서드 내부에서 일시적으로 해당 의존성이 필요한 경우에 사용한다.
프로퍼티 주입과 인터페이스 주입

setter주입이라는 용어는 속성을 설정하는 메서드를 구현하는 자바 언어에서 유래했다
JavaBeans 명세는 속성을 설정하는 메서드는 set이라는 접두사로 시작해야 한다고 규정하고 잇으며 자바 진영에서는 이런 메서드를 가리켜 setter메서드라고 한다.

C#에서는 자바의 setter메서드를 대체할 수 있는 프로퍼티(property)라는 기능을 제공한다.
따라서 C#진영에서는 setter주입 대신 프로퍼티 주입(property injection)이라는 용어를 사용한다. 둘 다 동일하다.
-
인터페이스 주입(Interface Injection)이라는 의존성 주입 기법도 있다. 주입할 의존성을 명시하기 위해 인터페이스를 사용하는 것이다
예를 들어, Movie에 DiscountPolicy의 인스턴스를 주입하고 싶다면 아래와 같이 DiscountPolicy를 주입하기 위한 인터페이스를 정의해야한다.
public interface DiscountPolicyInjectable {
	public void inject(DiscountPolicy discountPolicy)
}​

 

DiscountPolicy를 주입받기 위해 Movie는 이 인터페이스를 구현해야 한다.
public class Movie implements DiscountPolicyInjectable {
   private DiscountPolicy discountPolicy;
	
    @Override
    public void inject(DiscountPolicy discountPolicy) {
    this.discountPolicy = discountPolicy;
    }
}​

 

인터페이스 주입은 근본적으로 setter주입이나 프로퍼티 주입과 동일하다.
단지 어떤 대상을 어떻게 주입할 것인지를 인터페이스르 통해 명시적으로 선언한다는 차이만 있을 뿐이다.
인터페이스주입은 의존성이 도입되던 초창기에 자바 진영에서 만들어진 몇몇 프레임워크에서 의존성 대상을 좀 더 명시적으로 정의하고 편하게 관리하기 위해 도입한 방법이다.
따라서 약간의 구현적인 관점을 덜어내고 의존성 주입이 가지는 목적와 용도라는 보닞ㄹ적인 측면에서 바라보면 인터페이스 주입은 setter주입과 프로퍼티 주입의 변형으로 볼 수 있다.

 

숨겨진 의존성은 나쁘다

 

의존성 주입외에 의존성을 해결할 수 있는 방법은 다양하다. 그중에서 대표적인 방법은 SERVICE LOCATOR패턴이다.

 

SERVICE LOCATOR 는 의존성을 해결할 객체들을 보관하는 일종의 저장소다. "외부에서 객체에게 의존성을 전달하는 의존성 주입"과 달리 "SERVICE LOCATOR"의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청한다.

 

SERVICE LOCATOR패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스의 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지) 몰라도 되게 해준다.

 

예를 들어 SERVICE LOCATOR버전의 Movie는 직접 ServiceLocator의 메서드를 호출해서 DiscountPolicy에 대한 의존성을 해결한다.

 

public class Movie {
	...
	private DiscountPolicy discountPolicy;

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

 

ServiceLocator는 DiscountPolicy의 인스턴스를 등록하고 반환할 수 있는 메서드를 구현한 저장소이다.

package chapter10.movie;

public class ServiceLocator {
    private static ServiceLocator soleInstance = new ServiceLocator();
    private DiscountPolicy discountPolicy;
    // DiscountPolicy 인스턴스를 반환하는 메서드
    public static DiscountPolicy discountPolicy() {
        return soleInstance.discountPolicy;
    }
    // DiscountPolicy 인스턴스를 등록하기 위한 메서드
    public static void provide(DiscountPolicy discountPolicy) {
        soleInstance.discountPolicy = discountPolicy;
    }
    private ServiceLocator() { // 싱글턴을 보장을 위한 인스턴스 생성 방지
        
    }
}

 

Movie인스턴스가 AmountDiscountPolicy인스턴스에 의존하길 원한다면 아래처럼 ServiceLocator에 인스턴스를 등록한 후 Movie를 생성하면 된다. 등록한 이후에 생성되는 모든 Movie는 금액 할인 정책을 기반으로 요금을 계산한다.

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

 

 

SERVICE LOCATOR는 간단해보이지만, 의존성을 감춘다는 단점이 있다.

Movie는 DiscountPolicy에 의존하고 있지만, Movie의 퍼블릭 인터페이스 어디에서도 이 의존성에 대한 정보가 표시돼 있지 않다.

 

왜 숨겨진 의존성이 나쁘다는걸까?

  • 문제가 발견되는 시점을 코드 작성 시점(컴파일 타임)이 아닌 실행 시점(런타임 시점)에서 알 수 있다.
  • 단위 테스트 작성이 어렵다.
  • 캡슐화를 위반한다.

▶ 문제 발견 시점 = 런타임 

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

 

위의 코드만 보면 개발자는 인스턴스 생성에 필요한 모든 인자를 Movie의 생성자에 전달하고 있기 때문에 온전한 상태로 객체가 생성될 것이라고 예상하지만, 실행해보면 NPE예외가 발생한다.

avatar.calculateMovieFee(screening);

 

디버깅을 하고 나서야 discountPolicy의 값이 null이라는 것을 알게 되고, Movie의 생성자가 ServiceLocator를 이용해 의존성을 해결한다는 사실을 알게 된다. Movie의 인스턴스 생성 바로 전에 ServiceLocator.provide()메소드를 추가하고 해결할 것이다.

 

 

 단위 테스트 작성의 어려움

 

일반적인 단위 테스트 프레임워크는 테스트 케이스 단위로 테스트에 사용될 객체들을 새로 생성하는 기능을 제공한다. 하지만 위에서 구현한 ServiceLocator는 내부적으로 "정적 변수"를 사용해 객체들을 관리하기 때문에 모든 단위 테스트 케이스에 걸쳐 ServiceLocater의 상태를 공유하게 된다. 각 단위 테스트는 서로 고립돼야 한다는 단위 테스트의 기본 원칙을 위반한 것이다.

 

먼저 실행된 테스트 케이스에서 AmountDiscountPolicy를 ServiceLocater에 추가한 뒤, 추가적으로 PercentDiscountPolicy를 테스트할때 ServiceLocater에 해당 인스턴스를 추가하지 않는다면 이 테스트 케이스는 원하는 값을 내놓지 못한다.

 

따라서 단위 테스트가 서로 간섭없이 실행되기 위해서는 Movie를 테스트하는 모든 단위 테스트 케이스에서 Movie 생성하기 전에 ServiceLocater에 필요한 DiscountPolicy의 모든 인스턴스를 추가하고 끝날때마다 추가된 인스턴스를 제거해야한다.

 

▶  캡슐화 위반

 

단순히 인스턴스의 변수의 가시성을 private으로 선언했다고 캡슐화가 지켜지는게 아니다.

 

캡슐화라는건 코드를 읽고 이해하는 행위과 관련이 있다. 클래스의 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 훌륭한 코드다. 클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것이다.

 

숨겨진 의존성은 "의존성을 이해하기 위해 코드의 내부 구현을 이해할 것을 강요"하기 때문에 캡슐화를 위반했다.

의존 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어트려 놓는다.

 

 

반면에 의존성 주입은 이러한 문제를 해결한다.

  • 필요한 의존성을 클래스의 퍼블릭 인터페이스에 명시적으로 드러내기 때문에, 의존성을 이해하기 위해 코드 내부를 읽을 필요가 없다.
  • 의존성 관련된 문제를 컴파일 시점에 잡을 수 잇다
  • 단위 테스트 작성 시 ServiceLocater 객체를 추가하거나 제거할 필요없이, 그저 필요한 인자만 전달하면 객체를 생성할 수 있다.

 

※ 핵심 : 의존성 주입이 SERVICE LOCATOR보다 좋다는게 아니라, 명시적인 의존성이 숨겨진 의존성 보다 좋다는 것이다.

 

SERVICE LOCATOR를 어쩔 수 없이 사용해야 하는 경우는 아래이다

  • 의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우
  • 깊은 호출 계층에 걸쳐 동일한 객체를 계속 전달해야 하는 고통을 견디기 어려운 경우

 

4. 의존성 역전 원칙

 

추상화와 의존성 역전

 

의존성 역전 원칙(Dependency Inversion Principle, DIP)

  1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안된다. 둘다 추상화에 의존해야 한다.
  2. 추상화는 구체적인 사항에 의존해서는 안된다. 구체적인 사항은 추상화에 의존해야 한다.

예를 들어, 하위 수준의 AmountDiscountPolicy가 PercentDiscountPolicy로 변경된다고 해서 상위 수준의 Movie가 영향을 받으면 안된다. 상위 수준의 Movie변경으로 인해 하위 수준의 AmountDiscountPolicy가 영향을 받아야 한다.

 

이는 추상화로 해결할 수 있다. Movie와 AmountDiscountPolicy 모두가 추상화(DiscountPolicy)에 의존하도록 수정하면 하위 수준의 클래스의 변경으로 인해 상위 수준의 클래스가 영향받는 것을 방지할 수 있다.

 

 

"역전"이라고 표현한 이유가 뭘까?

 

 상위 수준이 하위 수준에 의존하기만 한 것이 아니라, 추상화에 의존하기 때문에 방향이 항상 아래로만 가는것은 아니다.

 

의존성 역전 원칙과 패키지

 

역전은 의존성의 방향뿐만 아니라 "인터페이스의 소유권"에도 적용된다.

 

객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈이다.

자바는 패키지를 이용해 모듈을 구현하고, C#이나 C++는 네임스페이스를 이용해 모듈을 구현한다.

 

 

위 그림을 보면 구체 클래스인 Movie, AmountDiscountPolicy, PercentDiscountPolicy 모두 추상 클래스인 DiscountPolicy에 의존해서 OCP를 지키고, 의존성 역전 원칙도 따라서 유연하고 재사용 가능한 설계라고 생각할 것이다.

 

하지만 Movie를 다양한 컨텍스트에서 재사용하기 위해서 불필요한 클래스들이 Moive와 함께 배포돼야만 한다.

Moive를 정상적으로 컴파일 하려면 DiscountPolicy가 필요하다. 

(코드의 컴파일이 성공적으로 되기 위해 함께 존재해야 하는 것이 컴파일 타임의 의존성이다.)

 

문제는 DiscountPolicy가 포함돼 있는 패키지안에  AmountDiscountPolicy, PercentDiscountPolicy 가 함께 존재한다는 것이다.

C++같은 언어에서는 같은 패키지 안에 존재하는 불필요한 클래스들로 인해 빈번한 재컴파일과 재배포가 발생할 수 있다.

DiscountPolicy가 포함된 패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포되어야 하고, 이로 인해 패키지에 의존하는 Movie클래스가 포함된 패키지 역시 재컴파일 돼야한다.

 

따라서 불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 가파르케 상승시킨다.

 

Movie의 재사용을 위해 필요한것이 DiscountPolicy뿐이라면 Movie같은 패키지로 모으고 AmountDiscountPolicy, PercentDiscountPolicy를 별도의 패키지로 위치시켜 의존성을 해결할 수 있다.

추상화를 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시키고, 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. 이를 SEPARATED INTERFACE패턴이라고 부른다.

 

  • 그림 9.9 : 전통적인 설계 패러다임으로 인터페이스의 소유권을 서버 모듈에 위치시킨다.
  • 그림 9.10 : 객체지향 패러다임으로 인터페이스의 소유권을 클라이언트 모듈에 위치시킨다.

 

🤔 궁금한점 : 이전 회사에서 spring boot의 gradle로 빌드할때는 엄청 많은 코드를 담은 패키지가 다 같이 빌드되서 엄청 오래걸렸다. 아마 40초에서 1분정도까지 걸린것 같다. 이런 문제를 해결하기 위해 멀티 모듈로 만드는걸 의미하는걸까? 멀티 모듈을 각 모듈별로 서로 독립적으로 개발되고 빌드될 수 있기 때문에, 변경사항에 따른 전체 컴파일로 속도가 느리지 않을 것 같다. 아니면 gradle에서 의존성 관리를 따로 패키지별로로 설정해주는게 있는 것일까? 

 

패키지라는건 소스코드를 구조화하고 클래스를 논리적으로 그룹화하는 방식인데, 패키지 구조가 어떻게 되는지 자세히는 모르겠지만 일단 그냥 느낌만 한번 봐보도록 하자.

 

▶ MVC패턴 구조

MVC 패턴에서는 주로 model, view, controller 패키지를 사용하여 각각의 역할을 구분하고,

여러 패키지로 나누어 관리하면 코드의 유지보수성이 높아지고, 의존성을 더 명확하게 정의한다.

com
  ├── example
  │     ├── controller
  │     │     └── MyController.java
  │     ├── model
  │     │     └── MyModel.java
  │     └── view
  │           └── MyView.java
  └── other
        └── unrelated
                └── UnrelatedClass.java

 

▶ SEPARATED INTERFACE패턴 구조

 

인터페이스를 사용해서 추상화를 정의하고, 클라이언트와 구현체로 분리함으로써 시스템의 결합도를 낮추는 것을 목표로 한다.

com
└── example
    └── movieapp
    │   ├── Movie.java
    │   └── DiscountPolicy.java
    │       
    └── discount
    	    ├── AmountDiscountPolicy.java
            └── PercentDiscountPolicy.java

 

 

5. 유연성에 대한 조언

유연한 설계는 유연성이 필요할때만 옳다

 

 

항상 유연하고 재사용 가능한 설계가 좋은 것은 아니다. 설계의 미덕은 단순함과 명확함이다. 

유연한 설계 = 복잡한 설계

 

유연한 설계를 단순하고 명학하게 만드는 유일한 방법은 사람들 간의 긴밀한 커뮤니케이션 뿐이다.

복잡성이 필요한 이유와 합리적인 근거를 제시하지 않는다면 어느 누구도 설계를 만족스러운 해법으로 받아들이지 않을 것이다.

 

불필요한 유연성은 불필요한 복잡성을 낳는다.

 

 

유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을때만 가치가 있다.

하지만, 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어라.

 

협력과 책임이 중요하다

 

지금까지 클래스를 중심으로 구현 메커니즘 관점에서 의존성을 설명했지만, 유연하게 만들기 위해서 "협력에 참여하는 객체가 다른 객체에게 어떤 메세지를 전송하는지가 중요하다"

 

Movie가 다양한 할인 정책과 협력할 수 있는 이유?

 

모든 할인 정책이 Movie가 전송하는 calculateDiscountAmount 메시지를 이해할 수 있기 때문이다.

 

중요한 비즈니스 로직을 처리하기 위해 "책임을 할당하고 협력의 균형을 맞추는 것"이 "생성에 관한 책임을 할당하는 것"보다 우선이다.

 

불필요한 SIGLETON패턴[GOF94]은 객체 생성에 관해 너무 이른 시점에 고민하고 결정할때 도입되기 때문에, 핵심은 객체를 생성하는 방법에 대한 결정은 모든 책임이 자리 잡은 후 가장 마지막 시점에 내리는게 적절하다.