오브젝트 - 09. 유연한 설계

청포도봉봉이·2025년 6월 25일

오브젝트

목록 보기
9/15
post-thumbnail

01. 개방-폐쇄 원칙

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

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

여기서 키워드는 '확장'과 '수정'이다. 이 둘은 순서대로 애플리케이션의 '동작'과 '코드'의 관점을 반영한다.

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

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

현재 설계는 새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다. 따라서 '확장에 대해서는 열려 있다'. 현재의 설계는 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것 만으로도 새로운 할인 정책을 확장할 수 있다. 따라서 '수정에 대해서는 닫혀 있다'. 이것이 개방-폐쇄 원칙이 의미하는 것이다.

추상화가 핵심이다.

개방-폐쇄 원칙의 관점에서 생략되지 않고 남겨지는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물이다. 공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 한다. 다시 말해서 수정할 필요가 없어야 한다. 따라서 추상화 부분은 수정에 닫혀 있다. 추상화를 통해 생략된 부분은 확장의 여지를 남긴다. 이것이 추상화가 개방-폐쇄 원칙을 가능하게 만드는 이유다.

주의할 점은 추상화를 했다고 모든 수정에 대해 설계가 폐쇄되는 것은 아니라는 것이다. 수정에 대해 닫혀 있고 확장에 대해 열려 있는 설계는 공짜로 얻어지지 않는다. 변경에 의한 파급효과를 최대한 피하기 위해서는 변하는 것과 변하지 않는 것이 무엇인지를 이해하고 이를 추상화의 목적으로 삼아야 한다.

02. 생성 사용 분리

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

결합도가 높아질수록 개방-폐쇄 원칙을 따르는 구조를 설계하기가 어려워진다.

유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다. 하나는 객체를 생성하는 것이고, 다른 하나는 객체를 사용하는 것이다. 한 마디로 말해서 객체에 대해 생성과 사용을 분리해야 한다.

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

public class Client {
	public Money getAvatarFee() {
		Movie avatar = new Movie(..., new AmountDiscountPolicy(...));

		return avatar.getFee();
	}
}

FACTORY 추가하기

객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다.

public class Factory {
	public Movie createAvatarMovie() {
		return new Movie(..., new AmountDiscountPolicy(...));
	}
}
public class Client {
    private Factory factory;

    public Client(Factory factory) {
        this.factory = factory;
    }
    
    public Money getAvatarFee() {
        Movie avatar = factory.createAvatarMovie();
        return avatar.getFee();
    }
}

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

03. 의존성 주입

생성과 사용을 분리하면 Movie는 오로지 인스턴스를 사용하는 책임만 남게 된다. 이것은 외부의 다른 객체가 Movie에게 생성된 인스턴스를 전달해야 한다는 것을 의미한다. 이처럼 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다. 이 기법을 의존성 주입이라고 부르는 이유는 외부에서 의존성의 대상을 해결한 후 이를 사용하는 객체 쪽으로 주입하기 때문이다.

의존성을 해결하는 세 가지 방법

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

숨겨진 의존성은 나쁘다

의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 존재한다. 그 중 대표적인 방법은 SERVICE LOCATOR 패턴이다. SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청한다.

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

public class Movie {
	// ...

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

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

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의 인스턴스가 AmountDiscountPolicy의 인스턴스에 의존하기를 원한다면 다음과 같이 ServiceLocator에 인스턴스를 등록한 후 Movie를 생성하면 된다.

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

SERVICE LOCATOR 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다. Movie는 DiscountPolicy에 의존하고 있지만 Movie의 퍼블릭 인터페이스 어디에도 이 의존성에 대한 정보가 표시돼 있지 않다. 의존성은 암시적이며 코드 깊숙한 곳에 숨겨져 있다.

위 코드를 읽는 개발자는 인스턴스 생성에 필요한 모든 인자를 생성자에 전달하고 있기 때문에 Movie는 온전한 상태로 생성될 것이라고 예상할 것이다. 하지만 아래 코드를 실행해보면 NullPointerException 예외가 던져진다.

avatar.calculateMovieFee(screnning);

위 예제로부터 의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다는 사실을 알 수 있다.

의존성을 숨기는 코드는 단위 테스트 작성도 어렵다.

04. 의존성 역전 원칙

추상화와 의존성 역전

다음 Moive는 구체 클래스에 대한 의존성으로 인해 결합도가 높아지고 재사용성과 유연성이 저해된다.

public class Movie {
	private AmountDiscountPolicy discountPolicy;
}

의존성은 변경의 전파와 관련된 것이기 때문에 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 한다. 그림 9.7의 문제점은 의존성의 방향이 잘못됐다는 것이다. 의존성은 Movie에서 AmountDiscountPolicy로 흘러서는 안 된다. AmountDiscountPolicy에서 Movie로 흘러야 한다. 상위 수준의 클래스는 어떤 식으로든 하위 수준의 클래스에 의존해서는 안 되는 것이다.

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

이를 의존성 역전 원칙(Dependency Inversion Principle, DIP)이라고 부른다.

의존성 역전 원칙과 패키지

이 그림에서 구체 클래스인 Movie, AmountDiscountPollicy, PercentDiscountPollicy는 모두 추상 클래스인 DiscountPolicy에 의존한다. 따라서 개방-폐쇄 원칙을 준수할뿐만 아니라 의존성 역전 원칙도 따르고 있기 때문에 이 설계가 유연하고 재사용 가능하다고 생각할 것이다. 하지만 Movie를 다양한 컨텍스트에서 재사용하기 위해서는 불필요한 클래스들이 Movie와 함께 배포돼야만 한다.

C++ 같은 언어에서는 같은 패키지 안에 존재하는 불필요한 클래스들로 인해 빈번한 컴파일 재배포가 발생할 수 있다. DiscountPolicy가 포함된 패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포돼야 한다.

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

그림 9.10과 같이 추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 한다. 그리고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. 마틴 파울러는 이 기법을 SEPARATED INTERFACE 패턴이라고 부른다.

유연하고 재사용 가능하며 컨텍스트에 독립적인 설계는 전통적인 패러다임이 고수하는 의존성의 방향을 역전시킨다. 전통적인 패러다임에서는 상위 수준 모듈이 하위 수준 모듈에 의존했다면 객체지향 패러다임에서는 상위 수준 모듈과 하위 수준 모듈이 모두 추상화에 의존한다. 전통적인 패러다임에서는 인터페이스가 하위 수준 모듈에 속했다면 객체지향 패러다임에서는 인터페이스가 상위 수준 모듈에 속한다.

05. 유연성에 대한 조언

협력과 책임이 중요하다

설계를 유연하게 만들기 위해서는 먼저 역할, 책임, 협력에 초점을 맞춰야 한다. 다양한 컨텍스트에서 협력을 재사용할 필요가 없다면 설계를 유연하게 만들 이유가 없다.

profile
서버 백엔드 개발자

0개의 댓글