[오브젝트] #9. 유연한 설계

bien·2024년 11월 11일
0

오브젝트

목록 보기
9/13

설계 원칙과 관련된 용어를 정리해보자!


01. 개방-폐쇄 원칙

  • 계방-폐쇄 원칙(Open-Closed Principle, OCP) (by. 로버트 마틴)
    • 소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려있어야 하고, 수정에 대해서는 닫혀 있어야 한다.
      • 확장에 대해 열려있다 :
        • 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 확장할 수 있다.
      • 수정에 대해 닫혀있다 :
        • 기존의 '코드'를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.

어떻게 코드를 수정하지 않고서 새로운 동작을 추가할 수 있을까?

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

  • 계방-폐쇄 원칙(OCP)은 런타임 의존성과 컴파일타임 의존성에 관한 이야기다.
    • 컴파일타임 의존성: 코드에서 드러나는 클래스들 사이의 관계
    • 런타임 의존성: 실행 시에 협력에 참여하는 객체들 사이의 관계
      • 유연하고 재사용 가능한 설계 = 두 의존성이 서로 다른 구조를 가진다.

  • 현재 Movie 설계는 계방-폐쇄 원칙(OCP)을 준수하고 있다.
    • 확장에 열려있다
      • 새로운 할인 정책을 추가하는 것만으로 기능 확장이 가능하다.
    • 수정에 닫혀있다
      • 기존의 코드를 수정하지 않고, 새로운 클래스를 추가하는 것만으로 확장 가능하다.

의존성 관점에서 개방-폐쇄 원칙을 따르는 설계란 컴파일타임 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조라고 할 수 있다.

추상화가 핵심이다.

  • 계방-폐쇄 원칙의 핵심: 추상화에 의존하는 것
    • 여기에는 2가지 개념이 모두 강조된다.
      1. 추상화
      2. 의존

🌐 추상화

  • 추상화: 핵심적인 부분(추상화)만 남기고 불필요한 부분은 생략(구현)함으로써 복잡성을 극복하는 기법
    • 추상화를 거치면, 문맥이 바뀌더라도 변하지 않는 부분만 남고, 문맥에 따라 변하는 부분은 생략된다.
    • 생략된 부분을 문맥에 적합한 부분으로 채워넣음으로써 각 문맥에 적합한 기능을 구체화하고 확장할 수 있다.
      • 핵심적인 부분 = 문맥에 영향받지 않는 핵심 (보존)
      • 불필요한 부분 = 문맥에따라 변화하는 부분 (제거)
        • 특정 문맥에서만 요구되는 부분을 채워넣어 구체화한다.
  • (추상화) 생략되지 않고 남겨지는 부분
    • 다양한 상황에서의 공통점을 반영추상화의 결과물
    • 수정되지 않아야 한다.
      • 즉, 추상화 부분은 수정에 닫혀있다.
  • (구현) 생략된 부분
    • 확장의 여지를 남긴다.
      • 즉, 생략된 부분은 변경에 열려있다.

💻 예시 코드: DiscountPolicy.java

public abstract class DiscountPolicy {
	private List<DiscountCodition> conditions = new ArrayList<>();
    
    public DiscountPolicy(DiscountCondition ... conditions) {
    	this.conditions = Arrasy.asList(conditions);
    }
    
    // 구현; 할인여부 판별 로직
    public Money calcualteDiscountAmount(Screening screening) {
    	for (DiscountCondition each : conditions) {
        	if (each.isSatisfiedBy(screening)) {
            	return getDiscountedFee(screening);
            }
        }
        
        return screening.getMovieFee();
    }
    
    // 추상화; 할인요금 계산방법
    abstract protected Money getDiscountAmount(Screening screening);
    
  • DiscountPolicy = 추상화
    • 구현: calcualteDiscountAmount = 할인여부 판단
      • 변하지 않는 부분.
    • 비구현: getDiscountAmount = 할인요금 계산
      • 변하는 부분.
      • 상속을 통해 생략된 부분을 구체화하여 할인정책을 확장할 수 있다.
  • 추상화(변하는 부분은 고정하고 변하지 않는 부분은 생략)가 개방-폐쇄 원칙의 기반이 된다.
    • 언제라도 추상화의 생략된 부분을 채워넣음으로써 새로운 문맥에 적절한 기능을 확장할 수 있다.
    • 추상화는 설계의 확장을 가능하게 한다.

🔗 의존성

  • 개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다.
    • 추상화하는 것만으로 수정에 대해 닫혀있는 설계가 가능한 것은 아니다.
    • 수정의 영향을 최소화하기 위해서는, 모든 요소가 추상화에 의존해야 한다.

💻 예시 코드: Movie.java

public class Movie {
	...
    private DiscountPolicy discountPolicy;
    
    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
    	...
        this.disocuntPolicy = discountPolicy;
    }
    
    public Money calcualteMovieFee(Screening screening) {
    	return fee.minus(discountPolicy.calculateDisocuntAmount(screening));
    }
}
  • Movie는 할인 정책을 추상화한 DiscountPolicy에만 의존한다.
    • 의존성 = 변경의 영향
    • DiscountPolicy = 변하지 않는 추상화
      • Movie가 더 안정된 추상화인 DiscountPolicy에 의존하므로, 할인 젗액 추가를 위해 새로운 자식 클래스가 추가되더라도 영향을 받지 않는다.
        • MovieDiscountPolicy는 변경에 닫혀있다.
  • 추상화확장을 가능하게 하고, 추상화에 대한 의존폐쇄를 가능하게 한다.

  • 단순히 추상화만으로 수정에 닫힌 설계가 완성되는 것은 아니다.
    • 올바른 추상화를 위해, 변경되지 않을 부분을 주의깊게 선정해야 한다.

02. 생성 사용 분리

  • 객체 생성에 대한 지식은 특정 문맥에 대해 과도한 결합을 유발한다.
    • 지식1. 객체의 타입
    • 지식2. 전달해야 하는 인자 목록
      • 컨텍스트를 바꾸기 위한 유일한 방법은, 코드의 컨텍스트 정보를 직접 수정하는 것 뿐이다.
  • 그러나 객체 생성은 피할 수 없다.
    • 문제는 객체 생성이 아니라, 부적절한 곳에서 객체를 생성하는 것이다.

🚫 잘못된 예시

예시에서 Movie는 생성자 안에서 DiscountPolicy인스턴스를 생성하고, calculateMovieFee 메서드 안에서 생성한 인스턴스를 사용하고 잇다.

즉, 동일한 클래스에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하고 있다.

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(screening.calculateDiscountAmount(screening));
    }

}

생성과 사용 분리

  • 생성과 사용을 분리(separation use from creation)
    • 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리하는 것.
      1. 객체를 생성하는 책임
      2. 객체를 사용하는 책임

소프트웨어 시스템은 (응용 프로그램 객체를 제작하고 의존성을 서로 "연결"하는) 시작 단계와 (시작 단계 이후에 이어지는) 실행 단계를 분리해야 한다.

  • 방법1. 클라이언트로 객체 생성 책임을 옮기는 것
    • Movie가 어떤 할인 정책을 적용할지는, Movie와 협력할 클라이언트가 지정하는 것이 가장 적절하다.

클라이언트 코드

public Money getAvatarFee() {
	Movie avatar = new Movie("아바타",
    						Duration.ofMinutes(120),
                            Money.wons(10000),
                            new AmountDisocuntPolicy(...));
                            
    return avatar.getFee();
}
  • 객체 생성의 책임이 클라이언트 코드로 옮겨감으로써, Movie는 DiscountPolicy 인스턴스 사용에만 주력하게 된다.

FACTORY 추가하기

  • 여전히 클라이언트 코드에서 객체 생성과 사용의 책임을 함께 가지고 있다는 문제가 남아있다.
    • 생성의 책임이 외부로 세어나가지 않기를 원하는 경우, FACTORY 패턴을 이용할 수 있다.
  • FACTORY
    • 객체 생성과 관련된 책임만 전담하는 별도의 객체
    • 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체

Factory.java

public class Factory {
	public Movie createAvatarMovie() {
    	return new Movie("아바타",
        				Duration.ofMinutes(230),
                        Money.wons(10000),
                        new AmountDisocuntPolicy(...));
    }
}
public class Client {
	private Factory factory;
    
    public client(Factory factory) {
    	this.factory =factory;
    }
    
    public Money getAvatarFee() {
    	Movie avatar = factory.createMadMaxMovie();
        return avatar.getFee();
    }
}
  • 이제 Client에는 사용과 관련된 책임만 남는다.
    1. Factory를 통해 생성된 Movie 객체를 얻기 위함
    2. Movie를 통해 가격을 계산하기 위한 것
  • 이제 Client는 오직 사용과 관련된 책임만 지고, 생성과 관련된 어떤 지식도 가지지 않을 수 있다.

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

  • FACTORY 사용의 의의
    • FACTORY 사용은 도메인 모델과 무관한 기술적인 결정이다.
    • 객체 생성 책임을 도메인과 관련없는 가공의 객체로 이동시킴으로써 결합도를 낮추고 재사용성을 높이기 위함이다.
  • 시스템을 객체로 분해하는 방법
    1. 표현적 분해(representational decomposition)
      • 도메인에 존재하는 사물, 개념을 그대로 표현한 객체를 통해 시스템을 분해하는 것.
      • 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것이 목적
        • 객체지향 설계를 위한 가장 기본적인 접근법
      • cf. 실제 애플리케이션에서는 도메인 개념을 초월하는 기계적인 개념이 필요할 수 잇다.
        • 예: 데이터베이스 접근을 위한 객체
    2. 행위적 분해(behavioral decomposition)
      • 도메인 개념을 표현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체를 이용
      • 결과물로 PURE FABRICATION(순수한 가공물)를 생성됨
        • 책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체
        • 행동 추가 시, 해당 행동을 책임질 마땅한 도메인 개념이 존재하지 않는 경우 사용

PURE FABRICATION 패턴

  • 문제: 도메인 객체에 책임을 할당할 경우, high cohesion, low coupling, 낮은 재사용성이 발생할 수 있다.
  • 해결책: 도메인 개념을 표현하지 않는, 인위적으로 편의상 만든 클래스에 매우 응집된 책임을 할당해라.
    • 순수한 가공물(pure fabrication)이라는 용어는, 적절한 대안이 없을 때 사람들이 창조적인 무언가를 만들어낸다는 것을 의미하는 관용적 표현이다.

03. 의존성 주입

  • 의존성 주입(Dependency Injection)
    • 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해 의존성을 해결하는 방법
    • 외부에서 의존성의 대상을 해결한 후, 이를 사용하는 객체로 주입한다.
    • 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 노출
  • 의존성 해결: 객체의 퍼블릭 인터페이스에 의존성을 명시적으로 노출해, 외부에서 전달하도록 하는 방법
    1. 생성자 주입(constructor injection): 객체 생성 시점에 생성자를 통한 의존성 해결
    2. setter 주입(setter injection): 객채 생성 후 setter 메서드를 통한 의존성 해결
    3. 메서드 주입(method injection): 메서드 실행 시 인자를 이용한 의존성 해결

숨겨진 의존성은 나쁘다.

SERVICE LOCATOR 패턴

  • SERVICE LOCATOR 패턴
    • 의존성을 해결할 객체들을 보관하는 일종의 저장소
    • 외부에서 객체에 의존성을 전달하는 방식이 아니라, 객체가 직접 SERVICE LOCATOR에게 의존성 해결을 요청한다.

💻 예시 코드

  • movie는 직접 ServiceLocator의 메서드를 호출하여 DiscountPolicy에 대한 의존성을 해결한다.
public class Movie {
	...
    private DiscountPolicy discountPolicy;
    
    public Moive(String title, Duration runningTime, Money fee) {
    	this.title = title;
        this.runningTime = runningTime'
        this.fee = fee;
        this.distcountPolicy = ServiceLocator.discountPolicy();
	}
]
  • ServiceLocatorDisocuntPolicy의 인스턴스를 등록하고 반환할 수 있는 메서드를 구현한 저장소다.
public class ServiceLocator() {
	private static SericeLocator soleInstance = new ServiceLocator();
    private DiscountPolicy discountPolicy;
    
    public static DiscountPolicy discountPolicy() {
    	return soleInstance.discountPolicy;
    }
    
    public static void provide(DiscountPolicy disocuntPolicy) {
    	soleInstance.disocuntPolicy = discountPolicy;
    }
    
    private ServiceLocator() {
    }
}
  • Movie의 인스턴스가 의존하기를 바라는 인스턴스를 등록한 후 Movie를 생성하는 방식으로 사용할 수 있다.
ServiceLocator.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타",
						Duration.ofMinutes(120),
                        Money.wons(1000));

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

🚫 단점

  • 의존성을 감춘다.
    • 숨겨진 의존성은 문제 발견시점을 코드 작성 시점이 아니라 실행시점으로 미룬다.
  • 단위 테스트 작성이 어렵다.
    • 각 단위 테스트는 서로 고립되어야 한다는 단위 테스트의 기본 원칙을 위반하게 된다.
  • 숨겨진 의존성은 캡슐화를 위반한다.
    • 캡슐화는 단순히 변수의 가시성을 private로 지정하고, 내용을 숨기는 개념이 아니다.
    • 캡슐화는 코드를 읽고 이해하는 행위와 관련있다.
      • 퍼블릭 인터페이스 만으로 사용방법을 이해할 수 있어야만 캡슐화 관점에서 훌륭한 코드다.
    • 숨겨진 의존성의 가장 큰 문제점은 코드의 내부 구현을 이해할 것을 강요한다.

명시적인 의존성이 숨겨진 의존성보다 좋다.
가급적 퍼블릭 인터페이스를 노출해라.
숨겨진 의존성은 코드 이해와 수정을 어렵게 한다.

사용이 권장되는 경우

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

04. 의존성 역전 원칙

추상화와 의존성 역전

  • 객체 사이 협력의 본질을 담고 있는 것은 상위 수준의 정책이다.
  • 의존성은 변경의 전파와 관련된 것이므로, 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 한다.
    • 이를 위해, 상위 수준의 클래스는 하위 수준의 클래스에 의존해서는 안 된다.
    • 상위 수준의 변경이 하위 수준에 영향을 미치는 것은 충분히 납득 가능하다.
    • 반대로, 하위수준의 변경이 상위 수준에 여파를 미쳐서는 안된다.
  • 추상화를 통해 이 의존성 문제를 해결할 수 있다.
    • 상위수준의 클래스와 하위수준의 클래스 모두 추상화에 의존한다.

의존성 역전 원칙

  • 의존성 역전 원칙(Dependency Inversion Principle, DIP)
    1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
    2. 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
  • 역전(inversion)이란?
    • 전통적인 소프트웨어 개발에서는, 상위 수준의 모듈이 하위 수준 모듈에 의존하고, 정책이 구체적인 것에 의존하는 경향이 있었다.
      • 상위 수준이 하위 수준을 호출하는 방법을 묘사하는 서브 프로그램의 계층 구조 정의가 주요 목표였다.
    • 잘 설계된 객체지향 프로그램은, 일반적으로 만들어지는 의존성 구조에 대해 '역전'된 것이다.

의존성 역전 원칙과 패키지

  • 역전은 의존성의 방향뿐만 아니라, 인터페이스의 소유권에도 적용된다.
    • 객체지향 프로그래밍 언어에서 소유권은 모듈이 결정한다.

  • MovieDiscountPolicy에 의존하고 있다.
    • 따라서 Movie를 정상적으로 컴파일하기 위해 DiscountPolicy 클래스가 필요한데, 이 클래스가 포함돼있는 패키지 안에 AmountDiscountPolicy, PercentDiscountPolicy가 함께 포함되어 있다.
      • 즉, Movie를 사용하기 위해 DiscountPolicy가 요구되고, 같은 패키지에 있는 불필요한 클래스까지 함께 존재해야 한다.
      • 이같은 컴파일 필요성은, 패키지 구조와 얽혀 코드 전체로 퍼져나가게 된다.
  • 코드의 컴파일이 성공하기 위해 함께 존재해야 하는 코드를 정의하는 것이 컴파일타임 의존성이다.

Separated Interface 패턴

  • SEPARATED INTERFACE 패턴 (by. 마틴 파울러)
    • 추상화를 별도의 독립저긴 패키지가 아니라 클라이어트가 속한 패키지에 포함시키는 것
    • 함께 사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모은다.

  • 상위수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다.
    • 이는 객체지향 프레임워크의 모듈 구조를 설계하는데 가장 중요한 핵심이 된다.

05. 유연성에 대한 조언

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

  • 유연한 설계에는 복잡성이 동반된다.
    • 객체지향에서 클래스 구조는 발생 가능한 모든 객체 구조를 담는 틀일 뿐이다.
    • 특정 시점의 객체 구조를 파악하는 유일한 방법은 클라이언트 코드 내 객체를 생성 혹은 변경하는 부분을 살펴보는 것 뿐이다.
  • 단순하고 명확한 해법이 만족스럽다면, 불필요한 유연성은 제거해라.
    • 유연성은 코드를 읽는 사람들이 복잡성을 수용할 수 있을 때에만 가치있다.

내 두 번째 주장은 우리의 지적 능력은 정적인 관계에 더 잘 들어맞고, 시간에 따른 진행 과정을 시각화하는 능력은 상대적으로 덜 발달했다는 점이다. 이러한 이유로 우리는 (자신의 한곌르 알고 있는 현명한 프로그래머로서) 정적인 프로그램과 동적인 프로세서 사이의 간극을 줄이기 위해 최선을 다해야 하며, 이를 통해 프로그램(텍스트 공간에 흩뿌려진)과 (시간에 흩뿌려진) 진행과정 사이를 가능한 한 일치시켜야 한다.[Dijkstra68].

협력과 책임이 중요하다

  • 협력에 참여하는 객체가 다른 객체에게 어떤 메시지를 전송하는지가 중요하다.
  • 먼저 역할, 책임, 협력에 초점을 맞춰야 한다.
    • 협력을 재사용할 필요가 없다면 유연한 설계도 필요없다.
  • 객체의 역할이 자리잡기 이전에 너무 성급하게 객체 생성에 집중해서는 안 된다.
    • 객체를 생성하는 방법에 대한 결정은 모든 책임이 자리를 잡은 후 가장 마지막 시점에 내리는 것이 적절하다.

그것은 바로 객체가 무엇이 되고 싶은지를 알게 될 때까지 객체들을 어떻게 인스턴스화 할 것인지에 대해 전혀 신꼉쓰지 않았다는 것이다. 이때 가장 중요한 관심거리는 마치 객체가 이미 존재하는 것처럼 이들 간의 관곌르 신경쓰는 일이다. 필자는 때가 되면 이러한 관계에 맞게 객체를 생성할 수 있을 것이라고 추측했다.

이렇게 추측했던 이유는 설계 동안 머리속에 기억해야 할 객체 수를 최소화해야하기 때문이다. 보통 요구사항을 충족시킬 수 있는 객체를 인스턴스화하는 방법에 대해 생각하는 것을 뒤로 미룰 때 위험을 최소화한 상태로 작업할 수 있다. 너무 일찍 결정하는 것은 비생산적이다.

객체를 생성하는 방법을 여러분 자신이 신경쓰기 전에 시스템에 필요한 것[책임]들을 생각하자[Shalloway01].


Reference

  • 오브젝트 | 조영호
profile
Good Luck!

0개의 댓글